mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 06:30:45 +08:00
1299 lines
45 KiB
JavaScript
1299 lines
45 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
|
||
import { DiscordHarnessBridge } from '../../../src/channels/discord/discord-bridge.mjs';
|
||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||
import { TextHarnessBridge } from '../../../src/channels/shared/text-harness-bridge.mjs';
|
||
import { SlackHarnessBridge } from '../../../src/channels/slack/slack-bridge.mjs';
|
||
import { TelegramHarnessBridge } from '../../../src/channels/telegram/telegram-bridge.mjs';
|
||
import { WhatsappHarnessBridge } from '../../../src/channels/whatsapp/whatsapp-bridge.mjs';
|
||
|
||
function deferred() {
|
||
let resolve;
|
||
let reject;
|
||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||
resolve = resolvePromise;
|
||
reject = rejectPromise;
|
||
});
|
||
return { promise, resolve, reject };
|
||
}
|
||
|
||
async function eventually(predicate, timeoutMs = 1_000) {
|
||
const deadline = Date.now() + timeoutMs;
|
||
while (Date.now() < deadline) {
|
||
if (predicate()) return;
|
||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||
}
|
||
assert.fail('condition was not met before timeout');
|
||
}
|
||
|
||
async function within(promise, timeoutMs, messageText) {
|
||
let timer;
|
||
try {
|
||
return await Promise.race([
|
||
promise,
|
||
new Promise((_, reject) => {
|
||
timer = setTimeout(() => reject(new Error(messageText)), timeoutMs);
|
||
}),
|
||
]);
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
function stateFixture(initialSessions = {}) {
|
||
const sessions = new Map(Object.entries(initialSessions));
|
||
const seen = new Set();
|
||
return {
|
||
sessions,
|
||
seen,
|
||
state: {
|
||
sessionFor(key) { return sessions.get(key) ?? null; },
|
||
async setSession(key, sessionId) {
|
||
sessions.set(key, sessionId);
|
||
return true;
|
||
},
|
||
async clearSession(key) { sessions.delete(key); },
|
||
hasSeen(messageId) { return seen.has(messageId); },
|
||
async markSeen(messageId) { seen.add(messageId); },
|
||
},
|
||
};
|
||
}
|
||
|
||
function message(messageId, content, overrides = {}) {
|
||
return {
|
||
messageId,
|
||
senderId: 'actor-a',
|
||
senderIsBot: false,
|
||
kind: 'direct',
|
||
conversationId: 'chat-a',
|
||
content,
|
||
addressed: true,
|
||
replyTarget: { id: `target-${messageId}` },
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function questionInteraction({
|
||
id = 'question-one',
|
||
sessionId = 'session-one',
|
||
questions = [{ id: 'answer', question: '请回答' }],
|
||
respond = async () => ({ accepted: true }),
|
||
...rest
|
||
} = {}) {
|
||
return {
|
||
kind: 'question',
|
||
interactionId: id,
|
||
rpcId: id,
|
||
sessionId,
|
||
payload: { type: 'question/requested', sessionId, questions },
|
||
respond,
|
||
...rest,
|
||
};
|
||
}
|
||
|
||
function approvalInteraction({
|
||
id = 'approval-one',
|
||
sessionId = 'session-one',
|
||
toolName = 'bash',
|
||
callId = 'call-one',
|
||
reason = '测试审批链路',
|
||
argumentsText = JSON.stringify({ command: "printf 'approval-test\\n'" }),
|
||
respond = async () => ({ accepted: true }),
|
||
...rest
|
||
} = {}) {
|
||
return {
|
||
kind: 'approval',
|
||
interactionId: id,
|
||
rpcId: `rpc-${id}`,
|
||
sessionId,
|
||
payload: {
|
||
type: 'approval/requested',
|
||
sessionId,
|
||
approvalId: id,
|
||
toolName,
|
||
callId,
|
||
reason,
|
||
},
|
||
toolCall: { callId, name: toolName, arguments: argumentsText },
|
||
respond,
|
||
...rest,
|
||
};
|
||
}
|
||
|
||
function createBridge({ harness, state, bot, signal, logger } = {}) {
|
||
return new TextHarnessBridge({
|
||
descriptor: { key: 'test', label: 'Test' },
|
||
bot,
|
||
harness,
|
||
state,
|
||
signal,
|
||
logger: logger ?? { warn() {}, error() {} },
|
||
});
|
||
}
|
||
|
||
test('all four shared text channels execute /compact outside the model prompt path', async () => {
|
||
for (const [name, Bridge] of [
|
||
['slack', SlackHarnessBridge],
|
||
['telegram', TelegramHarnessBridge],
|
||
['discord', DiscordHarnessBridge],
|
||
['whatsapp', WhatsappHarnessBridge],
|
||
]) {
|
||
const fixture = stateFixture({ 'direct:chat-a': `session-${name}` });
|
||
const sent = [];
|
||
const executed = [];
|
||
const bridge = new Bridge({
|
||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||
state: fixture.state,
|
||
harness: {
|
||
executeCommand: async (sessionId, line) => {
|
||
executed.push({ sessionId, line });
|
||
return {
|
||
commandId: `command-${name}`,
|
||
result: { kind: 'success', text: 'Compacted 12 history items (~3456 tokens).' },
|
||
};
|
||
},
|
||
ask: async () => assert.fail('/compact must not be submitted to the model'),
|
||
},
|
||
});
|
||
|
||
await bridge.accept(message(`compact-${name}`, '/compact'));
|
||
|
||
assert.deepEqual(executed, [{ sessionId: `session-${name}`, line: '/compact' }]);
|
||
assert.deepEqual(sent, ['已压缩 12 条历史记录(约 3456 个 token)。']);
|
||
}
|
||
});
|
||
|
||
test('all four shared text channels list models and presets locally and advertise fast commands', async () => {
|
||
for (const [name, Bridge] of [
|
||
['slack', SlackHarnessBridge],
|
||
['telegram', TelegramHarnessBridge],
|
||
['discord', DiscordHarnessBridge],
|
||
['whatsapp', WhatsappHarnessBridge],
|
||
]) {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const presetUpdates = [];
|
||
let agentPreset = null;
|
||
let asks = 0;
|
||
let creates = 0;
|
||
const agentPresetCatalog = {
|
||
defaultId: 'preset-001',
|
||
items: Array.from({ length: 70 }, (_, index) => ({
|
||
id: `preset-${String(index + 1).padStart(3, '0')}`,
|
||
label: `${name} Preset ${index + 1} ${'x'.repeat(64)}`,
|
||
})),
|
||
};
|
||
const bridge = new Bridge({
|
||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||
state: fixture.state,
|
||
harness: {
|
||
listModels: async () => ({
|
||
groups: [{
|
||
id: `${name}-provider`,
|
||
name: `${name} Provider`,
|
||
models: [{ id: 'model-one', name: 'Model One' }],
|
||
}],
|
||
failures: [],
|
||
}),
|
||
agentPresetSettings: async () => ({ agentPreset, agentPresetCatalog }),
|
||
updateAgentPreset: async (value) => {
|
||
presetUpdates.push(value);
|
||
agentPreset = value;
|
||
return { agentPreset, agentPresetCatalog };
|
||
},
|
||
createSession: async () => { creates += 1; return `${name}-session`; },
|
||
ask: async () => { asks += 1; return 'unexpected model reply'; },
|
||
},
|
||
});
|
||
|
||
await bridge.accept(message(`models-${name}`, '/models'));
|
||
assert.match(sent.at(-1), new RegExp(`1\\. ${name}-provider/model-one`), name);
|
||
assert.equal(asks, 0, `${name} ask`);
|
||
assert.equal(creates, 0, `${name} create`);
|
||
assert.equal(fixture.sessions.size, 0, `${name} session binding`);
|
||
|
||
const presetReplyStart = sent.length;
|
||
await bridge.accept(message(`presets-${name}`, '/presetlist'));
|
||
const presetReplies = sent.slice(presetReplyStart);
|
||
assert.ok(presetReplies.length > 1, `${name} sends every preset-list chunk`);
|
||
assert.match(presetReplies.join('\n'), /preset-070/, name);
|
||
assert.equal(asks, 0, `${name} preset ask`);
|
||
assert.equal(creates, 0, `${name} preset create`);
|
||
assert.equal(fixture.sessions.size, 0, `${name} preset session binding`);
|
||
|
||
await bridge.accept(message(`preset-current-${name}`, '/preset'));
|
||
assert.match(sent.at(-1), /跟随 Host 默认/, name);
|
||
assert.equal(asks, 0, `${name} preset current ask`);
|
||
assert.equal(creates, 0, `${name} preset current create`);
|
||
|
||
const selectReplyStart = sent.length;
|
||
await bridge.accept(message(`preset-select-${name}`, '/preset 2'));
|
||
assert.deepEqual(presetUpdates, ['preset-002'], `${name} scoped preset update`);
|
||
assert.equal(sent.length, selectReplyStart + 1, `${name} sends the complete select reply`);
|
||
assert.match(sent.at(-1), /preset-002/, name);
|
||
|
||
const defaultReplyStart = sent.length;
|
||
await bridge.accept(message(`preset-default-${name}`, '/preset --default'));
|
||
assert.deepEqual(presetUpdates, ['preset-002', null], `${name} scoped preset reset`);
|
||
assert.equal(sent.length, defaultReplyStart + 1, `${name} sends the complete default reply`);
|
||
assert.match(sent.at(-1), /跟随 Host 默认/, name);
|
||
assert.equal(asks, 0, `${name} mutation ask`);
|
||
assert.equal(creates, 0, `${name} mutation create`);
|
||
assert.equal(fixture.sessions.size, 0, `${name} mutation session binding`);
|
||
|
||
await bridge.accept(message(`help-${name}`, '/help'));
|
||
const help = sent.at(-1);
|
||
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
|
||
assert.match(help, new RegExp(`\\${command}`), `${name} ${command}`);
|
||
}
|
||
assert.match(help, /\/model 2/, `${name} numbered model selection`);
|
||
assert.match(help, /\/preset id:<ID>/, `${name} numeric preset ID selection`);
|
||
}
|
||
});
|
||
|
||
test('/stop uses the shared command fast lane without waiting for the running prompt', async () => {
|
||
const fixture = stateFixture({ 'direct:chat-a': 'session-running' });
|
||
const askStarted = deferred();
|
||
const releaseAsk = deferred();
|
||
const sent = [];
|
||
const controls = {};
|
||
let promptSettled = false;
|
||
const session = {
|
||
sessionExists: async () => true,
|
||
ask: async (_text, options) => {
|
||
controls.prompt = options.control;
|
||
askStarted.resolve();
|
||
await releaseAsk.promise;
|
||
return '原任务完成';
|
||
},
|
||
stopActiveTurn: async (control) => {
|
||
controls.stop = control;
|
||
return true;
|
||
},
|
||
};
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||
harness: { workspaceSession: () => session },
|
||
});
|
||
|
||
const prompt = bridge.accept(message('running-prompt', '执行一个长任务'))
|
||
.finally(() => { promptSettled = true; });
|
||
await askStarted.promise;
|
||
|
||
await within(
|
||
bridge.accept(message('running-stop', '/stop')),
|
||
250,
|
||
'/stop waited for the ordinary conversation queue',
|
||
);
|
||
assert.equal(promptSettled, false);
|
||
assert.equal(sent.includes('已请求停止当前任务。'), true);
|
||
assert.equal(controls.stop.owner, controls.prompt.owner);
|
||
assert.equal(controls.stop.key, controls.prompt.key);
|
||
|
||
releaseAsk.resolve();
|
||
await prompt;
|
||
assert.equal(sent.at(-1), '原任务完成');
|
||
});
|
||
|
||
test('a stopped shared-channel turn closes an opened stream instead of leaving a processing placeholder', async () => {
|
||
const fixture = stateFixture({ 'direct:chat-a': 'session-running' });
|
||
const finished = [];
|
||
let cancelled = 0;
|
||
const sent = [];
|
||
const stopped = new Error('stopped');
|
||
stopped.code = 'turn-stopped';
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: {
|
||
sendText: async (_target, text) => sent.push(text),
|
||
openStream: async () => ({
|
||
update() {},
|
||
async finish(text) { finished.push(text); },
|
||
cancel() { cancelled += 1; },
|
||
}),
|
||
},
|
||
harness: {
|
||
workspaceSession: () => ({
|
||
sessionExists: async () => true,
|
||
ask: async () => { throw stopped; },
|
||
}),
|
||
},
|
||
});
|
||
|
||
await bridge.accept(message('stopped-stream', '执行长任务'));
|
||
|
||
assert.deepEqual(finished, ['已停止。']);
|
||
assert.equal(cancelled, 0);
|
||
assert.deepEqual(sent, []);
|
||
});
|
||
|
||
test('Slack, Telegram, and Discord remember any valid direct message per bot', async () => {
|
||
for (const [name, Bridge] of [
|
||
['slack', SlackHarnessBridge],
|
||
['telegram', TelegramHarnessBridge],
|
||
['discord', DiscordHarnessBridge],
|
||
]) {
|
||
let sessionSequence = 0;
|
||
const harness = {
|
||
createSession: async () => `${name}-session-${++sessionSequence}`,
|
||
ask: async () => `${name} reply`,
|
||
};
|
||
const first = stateFixture();
|
||
const second = stateFixture();
|
||
const firstSent = [];
|
||
const secondSent = [];
|
||
const firstBot = {
|
||
sendText: async (target, text) => firstSent.push({ target, text }),
|
||
};
|
||
const secondBot = {
|
||
sendText: async (target, text) => secondSent.push({ target, text }),
|
||
};
|
||
const firstBridge = new Bridge({ bot: firstBot, harness, state: first.state });
|
||
const secondBridge = new Bridge({ bot: secondBot, harness, state: second.state });
|
||
const firstTarget = { channelId: `${name}-private-a` };
|
||
const secondTarget = { channelId: `${name}-private-b` };
|
||
|
||
await firstBridge.accept(message(`${name}-direct-a`, 'hello', {
|
||
conversationId: `${name}-private-a`,
|
||
replyTarget: { ...firstTarget, replyToMessageId: 'reply-a' },
|
||
connectionTestTarget: firstTarget,
|
||
}));
|
||
assert.deepEqual(connectionTestTarget(first.state), firstTarget, name);
|
||
|
||
await firstBridge.accept(message(`${name}-group`, 'hello group', {
|
||
kind: 'group',
|
||
conversationId: `${name}-group`,
|
||
addressed: true,
|
||
replyTarget: { channelId: `${name}-group` },
|
||
connectionTestTarget: { channelId: `${name}-group` },
|
||
}));
|
||
assert.deepEqual(connectionTestTarget(first.state), firstTarget, `${name} group`);
|
||
|
||
await firstBridge.accept(message(`${name}-direct-a`, 'duplicate', {
|
||
conversationId: `${name}-private-replay`,
|
||
replyTarget: { channelId: `${name}-private-replay` },
|
||
connectionTestTarget: { channelId: `${name}-private-replay` },
|
||
}));
|
||
assert.deepEqual(connectionTestTarget(first.state), firstTarget, `${name} duplicate`);
|
||
|
||
await secondBridge.accept(message(`${name}-direct-b`, 'hello', {
|
||
conversationId: `${name}-private-b`,
|
||
replyTarget: { ...secondTarget, replyToMessageId: 'reply-b' },
|
||
connectionTestTarget: secondTarget,
|
||
}));
|
||
assert.deepEqual(connectionTestTarget(second.state), secondTarget, `${name} second bot`);
|
||
|
||
const reconnectedFirstBridge = new Bridge({ bot: firstBot, harness, state: first.state });
|
||
await reconnectedFirstBridge.sendConnectionTest(`${name} first test`);
|
||
await secondBridge.sendConnectionTest(`${name} second test`);
|
||
assert.deepEqual(
|
||
firstSent.find(({ text }) => text === `${name} first test`)?.target,
|
||
firstTarget,
|
||
`${name} reconnect`,
|
||
);
|
||
assert.deepEqual(
|
||
secondSent.find(({ text }) => text === `${name} second test`)?.target,
|
||
secondTarget,
|
||
`${name} bot isolation`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('answers a multi-question interaction on the fast lane with canonical values', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const asked = [];
|
||
const submitted = deferred();
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push({ sessionId, text });
|
||
await options.onInteraction(questionInteraction({
|
||
sessionId,
|
||
questions: [
|
||
{
|
||
id: 'language',
|
||
question: '选择回答语言',
|
||
options: [{ label: '中文' }, { label: 'English' }],
|
||
},
|
||
{
|
||
id: 'deliverables',
|
||
question: '选择交付内容',
|
||
multiSelect: true,
|
||
options: [{ label: '测试' }, { label: '文档' }],
|
||
},
|
||
],
|
||
respond: async (result) => {
|
||
submitted.resolve(result);
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await submitted.promise;
|
||
return '交互已完成';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('prompt', '请分步提问'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('选择回答语言')));
|
||
await bridge.accept(message('language', '2'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('选择交付内容')));
|
||
await bridge.accept(message('deliverables', '1,文档,发布说明'));
|
||
|
||
assert.deepEqual(await submitted.promise, {
|
||
ok: true,
|
||
value: {
|
||
sessionId: 'session-one',
|
||
answer: {
|
||
answers: [
|
||
{ id: 'language', selected: ['English'] },
|
||
{ id: 'deliverables', selected: ['测试', '文档'], custom: '发布说明' },
|
||
],
|
||
},
|
||
},
|
||
});
|
||
await processing;
|
||
assert.deepEqual(asked, [{ sessionId: 'session-one', text: '请分步提问' }]);
|
||
assert.equal(sent.at(-1).text, '交互已完成');
|
||
assert.deepEqual(sent[1].target, { id: 'target-language' });
|
||
});
|
||
|
||
test('answers approvals on the interaction fast lane with precise text decisions', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const asked = [];
|
||
const submitted = [];
|
||
const cases = [
|
||
{ reply: '批准', outcome: 'allowed-once' },
|
||
{ reply: '同意', outcome: 'allowed-once' },
|
||
{ reply: ' YeS ', outcome: 'allowed-once' },
|
||
{ reply: '拒绝', outcome: 'rejected' },
|
||
{ reply: '不同意', outcome: 'rejected' },
|
||
{ reply: ' NO ', outcome: 'rejected' },
|
||
];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push({ sessionId, text });
|
||
for (const [index, approvalCase] of cases.entries()) {
|
||
const answered = deferred();
|
||
await options.onInteraction(approvalInteraction({
|
||
id: `approval-${index + 1}`,
|
||
sessionId,
|
||
toolName: `tool-${index + 1}`,
|
||
reason: `精准回复测试 ${index + 1}`,
|
||
respond: async (result) => {
|
||
submitted.push(result);
|
||
answered.resolve();
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await answered.promise;
|
||
assert.equal(submitted.at(-1).value.outcome, approvalCase.outcome);
|
||
}
|
||
return '所有审批已完成';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('approval-start', '启动审批测试'));
|
||
for (const [index, approvalCase] of cases.entries()) {
|
||
await eventually(() => sent.some(({ text }) => text.includes(`tool-${index + 1}`)));
|
||
await bridge.accept(message(`approval-reply-${index + 1}`, approvalCase.reply));
|
||
}
|
||
await processing;
|
||
|
||
assert.deepEqual(submitted, cases.map(({ outcome }, index) => ({
|
||
ok: true,
|
||
value: {
|
||
sessionId: 'session-one',
|
||
approvalId: `approval-${index + 1}`,
|
||
outcome,
|
||
},
|
||
})));
|
||
assert.deepEqual(asked, [{ sessionId: 'session-one', text: '启动审批测试' }]);
|
||
assert.equal(sent.at(-1).text, '所有审批已完成');
|
||
});
|
||
|
||
test('all four shared text channel bridges inherit the approval fast lane', async () => {
|
||
const channels = [
|
||
['Slack', SlackHarnessBridge],
|
||
['Discord', DiscordHarnessBridge],
|
||
['Telegram', TelegramHarnessBridge],
|
||
['WhatsApp', WhatsappHarnessBridge],
|
||
];
|
||
|
||
for (const [label, Bridge] of channels) {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const submitted = deferred();
|
||
const asked = [];
|
||
const bridge = new Bridge({
|
||
state: fixture.state,
|
||
logger: { warn() {}, error() {} },
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
await options.onInteraction(approvalInteraction({
|
||
id: `${label.toLowerCase()}-approval`,
|
||
sessionId,
|
||
toolName: `${label.toLowerCase()}-tool`,
|
||
respond: async (result) => {
|
||
submitted.resolve(result);
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await submitted.promise;
|
||
return `${label} 审批完成`;
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message(`${label}-prompt`, `${label} 启动审批`));
|
||
await eventually(() => sent.some(({ text }) => text.includes(`${label.toLowerCase()}-tool`)));
|
||
await bridge.accept(message(`${label}-decision`, '批准'));
|
||
await processing;
|
||
|
||
assert.equal((await submitted.promise).value.outcome, 'allowed-once', label);
|
||
assert.deepEqual(asked, [`${label} 启动审批`], label);
|
||
assert.equal(sent.at(-1).text, `${label} 审批完成`, label);
|
||
}
|
||
});
|
||
|
||
test('keeps an imprecise approval reply out of the Harness prompt queue', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const asked = [];
|
||
const submitted = deferred();
|
||
let responseCalls = 0;
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
await options.onInteraction(approvalInteraction({
|
||
sessionId,
|
||
respond: async (result) => {
|
||
responseCalls += 1;
|
||
submitted.resolve(result);
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await submitted.promise;
|
||
return '审批已继续';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('imprecise-start', '启动精准匹配测试'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('测试审批链路')));
|
||
const imprecise = bridge.accept(message('imprecise-reply', '好的'));
|
||
await imprecise;
|
||
assert.equal(responseCalls, 0);
|
||
assert.deepEqual(asked, ['启动精准匹配测试']);
|
||
|
||
const approval = bridge.accept(message('precise-reply', '批准'));
|
||
await Promise.all([processing, approval]);
|
||
|
||
assert.deepEqual(await submitted.promise, {
|
||
ok: true,
|
||
value: {
|
||
sessionId: 'session-one',
|
||
approvalId: 'approval-one',
|
||
outcome: 'allowed-once',
|
||
},
|
||
});
|
||
assert.deepEqual(asked, ['启动精准匹配测试']);
|
||
});
|
||
|
||
test('presents parallel approvals from one conversation in fifo order without codes', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const submitted = [];
|
||
const firstAnswered = deferred();
|
||
const secondAnswered = deferred();
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, _text, options) => {
|
||
await Promise.all([
|
||
options.onInteraction(approvalInteraction({
|
||
id: 'fifo-first-id',
|
||
sessionId,
|
||
toolName: 'first-tool',
|
||
reason: '第一条审批',
|
||
respond: async (result) => {
|
||
submitted.push(result);
|
||
firstAnswered.resolve();
|
||
return { accepted: true };
|
||
},
|
||
})),
|
||
options.onInteraction(approvalInteraction({
|
||
id: 'fifo-second-id',
|
||
sessionId,
|
||
toolName: 'second-tool',
|
||
reason: '第二条审批',
|
||
respond: async (result) => {
|
||
submitted.push(result);
|
||
secondAnswered.resolve();
|
||
return { accepted: true };
|
||
},
|
||
})),
|
||
]);
|
||
await Promise.all([firstAnswered.promise, secondAnswered.promise]);
|
||
return '并行审批已完成';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('fifo-start', '启动并行审批'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('first-tool')));
|
||
assert.equal(sent.some(({ text }) => text.includes('second-tool')), false);
|
||
|
||
await bridge.accept(message('fifo-first-reply', '批准'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('second-tool')));
|
||
await bridge.accept(message('fifo-second-reply', '拒绝'));
|
||
await processing;
|
||
|
||
const approvalMessages = sent.filter(({ text }) => (
|
||
text.includes('first-tool') || text.includes('second-tool')
|
||
));
|
||
assert.equal(approvalMessages.length, 2);
|
||
assert.match(approvalMessages[0].text, /first-tool/);
|
||
assert.match(approvalMessages[1].text, /second-tool/);
|
||
assert.equal(approvalMessages.some(({ text }) => (
|
||
text.includes('fifo-first-id') || text.includes('fifo-second-id')
|
||
)), false);
|
||
assert.deepEqual(submitted.map(({ value }) => ({
|
||
approvalId: value.approvalId,
|
||
outcome: value.outcome,
|
||
})), [
|
||
{ approvalId: 'fifo-first-id', outcome: 'allowed-once' },
|
||
{ approvalId: 'fifo-second-id', outcome: 'rejected' },
|
||
]);
|
||
});
|
||
|
||
test('a completed approval tombstone never steals yes or no from a live question', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const approvalDone = deferred();
|
||
const questionDone = deferred();
|
||
let questionResponse;
|
||
const asked = [];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
await options.onInteraction(approvalInteraction({
|
||
sessionId,
|
||
respond: async () => {
|
||
approvalDone.resolve();
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await approvalDone.promise;
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'question-after-approval',
|
||
sessionId,
|
||
questions: [{ id: 'continue', question: '是否继续?' }],
|
||
respond: async (result) => {
|
||
questionResponse = result;
|
||
questionDone.resolve();
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await questionDone.promise;
|
||
return '审批和提问均已完成';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('approval-then-question', '开始组合交互'));
|
||
await eventually(() => sent.some(({ text }) => text.includes("printf 'approval-test")));
|
||
await bridge.accept(message('approval-before-question', '批准'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('是否继续?')));
|
||
await bridge.accept(message('question-yes', 'yes'));
|
||
await processing;
|
||
|
||
assert.deepEqual(questionResponse.value.answer.answers, [{
|
||
id: 'continue',
|
||
selected: [],
|
||
custom: 'yes',
|
||
}]);
|
||
assert.deepEqual(asked, ['开始组合交互']);
|
||
assert.equal(sent.at(-1).text, '审批和提问均已完成');
|
||
});
|
||
|
||
test('a sibling approval waits for an in-flight question answer without deadlocking', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const questionResponseStarted = deferred();
|
||
const releaseQuestionResponse = deferred();
|
||
const questionDone = deferred();
|
||
const approvalDone = deferred();
|
||
const asked = [];
|
||
const questionResponses = [];
|
||
const approvalResponses = [];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
await options.onInteraction(approvalInteraction({
|
||
id: 'sibling-approval',
|
||
sessionId,
|
||
respond: async (result) => {
|
||
approvalResponses.push(result);
|
||
approvalDone.resolve();
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'sibling-question',
|
||
sessionId,
|
||
questions: [{ id: 'continue', question: '是否继续执行?' }],
|
||
respond: async (result) => {
|
||
questionResponses.push(result);
|
||
questionResponseStarted.resolve();
|
||
await releaseQuestionResponse.promise;
|
||
questionDone.resolve();
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await Promise.all([questionDone.promise, approvalDone.promise]);
|
||
return '组合交互已完成';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('sibling-start', '启动并行交互'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('是否继续执行?')));
|
||
const answeringQuestion = bridge.accept(message('sibling-question-answer', 'yes'));
|
||
await questionResponseStarted.promise;
|
||
const approving = bridge.accept(message('sibling-approval-answer', '批准'));
|
||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||
assert.equal(approvalResponses.length, 0);
|
||
|
||
releaseQuestionResponse.resolve();
|
||
await Promise.all([answeringQuestion, approving, processing]);
|
||
assert.deepEqual(questionResponses[0].value.answer.answers, [{
|
||
id: 'continue',
|
||
selected: [],
|
||
custom: 'yes',
|
||
}]);
|
||
assert.equal(approvalResponses[0].value.outcome, 'allowed-once');
|
||
assert.deepEqual(asked, ['启动并行交互']);
|
||
assert.equal(sent.at(-1).text, '组合交互已完成');
|
||
});
|
||
|
||
test('isolates pending questions by normalized conversation key', async () => {
|
||
const fixture = stateFixture({
|
||
'direct:chat-a': 'session-a',
|
||
'direct:chat-b': 'session-b',
|
||
});
|
||
const sent = [];
|
||
const answeredA = deferred();
|
||
const asked = [];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push({ sessionId, text });
|
||
if (sessionId === 'session-b') return '乙会话完成';
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'question-a',
|
||
sessionId,
|
||
questions: [{ id: 'a', question: '甲会话的问题' }],
|
||
respond: async (result) => {
|
||
answeredA.resolve(result);
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await answeredA.promise;
|
||
return '甲会话完成';
|
||
},
|
||
},
|
||
});
|
||
|
||
const first = bridge.accept(message('a-start', '启动甲'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('甲会话的问题')));
|
||
await bridge.accept(message('b-normal', '乙的普通问题', {
|
||
senderId: 'actor-b',
|
||
conversationId: 'chat-b',
|
||
}));
|
||
assert.deepEqual(asked, [
|
||
{ sessionId: 'session-a', text: '启动甲' },
|
||
{ sessionId: 'session-b', text: '乙的普通问题' },
|
||
]);
|
||
|
||
await bridge.accept(message('a-answer', '甲的答案'));
|
||
assert.deepEqual((await answeredA.promise).value.answer.answers, [
|
||
{ id: 'a', selected: [], custom: '甲的答案' },
|
||
]);
|
||
await first;
|
||
});
|
||
|
||
test('a group question only accepts an addressed reply from the initiating actor', async () => {
|
||
const fixture = stateFixture({ 'group:room': 'session-group' });
|
||
const sent = [];
|
||
const submitted = deferred();
|
||
const asked = [];
|
||
let responseCalls = 0;
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
if (text !== '甲发起交互') return '普通群消息已处理';
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'group-question',
|
||
sessionId,
|
||
questions: [{ id: 'actor', question: '只能由甲回答' }],
|
||
respond: async (result) => {
|
||
responseCalls += 1;
|
||
submitted.resolve(result);
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await submitted.promise;
|
||
return '甲的交互完成';
|
||
},
|
||
},
|
||
});
|
||
const group = { kind: 'group', conversationId: 'room' };
|
||
|
||
const first = bridge.accept(message('group-start', '甲发起交互', { ...group }));
|
||
await eventually(() => sent.some(({ text }) => text.includes('只能由甲回答')));
|
||
assert.match(sent[0].text, /群聊中请 @机器人/);
|
||
|
||
await bridge.accept(message('group-unaddressed', '没有 @ 的回答', {
|
||
...group,
|
||
addressed: false,
|
||
}));
|
||
const intruder = bridge.accept(message('group-intruder', '乙试图代答', {
|
||
...group,
|
||
senderId: 'actor-b',
|
||
}));
|
||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||
assert.equal(responseCalls, 0);
|
||
assert.deepEqual(asked, ['甲发起交互']);
|
||
|
||
await bridge.accept(message('group-answer', '甲的真正答案', { ...group }));
|
||
assert.deepEqual((await submitted.promise).value.answer.answers, [{
|
||
id: 'actor',
|
||
selected: [],
|
||
custom: '甲的真正答案',
|
||
}]);
|
||
await Promise.all([first, intruder]);
|
||
assert.deepEqual(asked, ['甲发起交互', '乙试图代答']);
|
||
assert.equal(bridge.status.messagesRejected, 1);
|
||
});
|
||
|
||
test('deduplicates replays and safely closes recovered questions and approvals', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
let parallelResponse;
|
||
let recoveredResponse;
|
||
let approvalResponse;
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, _text, options) => {
|
||
const current = questionInteraction({
|
||
id: 'replayed-question',
|
||
sessionId,
|
||
questions: [{ id: 'current', question: '只应显示一次' }],
|
||
});
|
||
await options.onInteraction(current);
|
||
await options.onInteraction(current);
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'parallel-question',
|
||
sessionId,
|
||
questions: [{ id: 'parallel', question: '不应显示的并行问题' }],
|
||
respond: async (result) => { parallelResponse = result; },
|
||
}));
|
||
await options.onInteraction(approvalInteraction({
|
||
id: 'orphan-approval',
|
||
sessionId,
|
||
recovered: true,
|
||
respond: async (result) => { approvalResponse = result; },
|
||
}));
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'orphan-question',
|
||
sessionId,
|
||
recovered: true,
|
||
questions: [{ id: 'secret', question: '旧会话中的敏感问题内容' }],
|
||
respond: async (result) => { recoveredResponse = result; },
|
||
}));
|
||
await options.onInteractionResolved({
|
||
kind: 'question',
|
||
interactionId: 'replayed-question',
|
||
sessionId,
|
||
outcome: 'cancelled',
|
||
});
|
||
return '已继续处理';
|
||
},
|
||
},
|
||
});
|
||
|
||
await bridge.accept(message('replay', '测试交互重放'));
|
||
assert.equal(sent.filter(({ text }) => text.includes('只应显示一次')).length, 1);
|
||
assert.equal(sent.some(({ text }) => text.includes('旧会话中的敏感问题内容')), false);
|
||
assert.equal(sent.some(({ text }) => text.includes('遗留的待回答问题')), true);
|
||
assert.deepEqual(parallelResponse?.error, {
|
||
code: 'cancelled',
|
||
message: 'Test is already handling another user interaction.',
|
||
details: {},
|
||
});
|
||
assert.deepEqual(recoveredResponse?.error, {
|
||
code: 'cancelled',
|
||
message: 'Test safely cancelled an interaction left by an earlier client.',
|
||
details: {},
|
||
});
|
||
assert.deepEqual(approvalResponse, {
|
||
ok: true,
|
||
value: {
|
||
sessionId: 'session-one',
|
||
approvalId: 'orphan-approval',
|
||
outcome: 'rejected',
|
||
},
|
||
});
|
||
assert.equal(sent.some(({ text }) => text.includes("printf 'approval-test")), false);
|
||
});
|
||
|
||
test('keeps a failed interaction response pending so the actor can retry', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const completed = deferred();
|
||
const submittedAnswers = [];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, _text, options) => {
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'retry-question',
|
||
sessionId,
|
||
respond: async (result) => {
|
||
submittedAnswers.push(result.value.answer.answers[0].custom);
|
||
if (submittedAnswers.length === 1) throw new Error('temporary failure');
|
||
completed.resolve();
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await completed.promise;
|
||
return '重试成功';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('retry-start', '启动可重试交互'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('请回答')));
|
||
await bridge.accept(message('retry-first', '第一次答案'));
|
||
assert.equal(sent.some(({ text }) => text.includes('回答提交失败')), true);
|
||
await bridge.accept(message('retry-second', '重试后的答案'));
|
||
await processing;
|
||
|
||
assert.deepEqual(submittedAnswers, ['第一次答案', '重试后的答案']);
|
||
assert.equal(sent.at(-1).text, '重试成功');
|
||
});
|
||
|
||
test('notifies the actor when an in-flight response resolves elsewhere before rejection', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const responseStarted = deferred();
|
||
const asked = [];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'response-resolved-race',
|
||
sessionId,
|
||
respond: async () => {
|
||
options.onInteractionResolved({
|
||
kind: 'question',
|
||
interactionId: 'response-resolved-race',
|
||
sessionId,
|
||
outcome: 'answered',
|
||
});
|
||
responseStarted.resolve();
|
||
const error = new Error('interaction no longer pending');
|
||
error.code = 'interaction-not-pending';
|
||
throw error;
|
||
},
|
||
}));
|
||
await responseStarted.promise;
|
||
return '原会话已结束';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('response-race-start', '启动提交竞态'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('请回答')));
|
||
await bridge.accept(message('response-race-answer', '已经收到的答案'));
|
||
await processing;
|
||
|
||
assert.deepEqual(asked, ['启动提交竞态']);
|
||
assert.equal(sent.some(({ text }) => text.includes('已在其他客户端处理')), true);
|
||
});
|
||
|
||
test('discards a claimed answer when the interaction resolves during message recording', async () => {
|
||
const fixture = stateFixture({ 'direct:chat-a': 'session-one' });
|
||
const originalMarkSeen = fixture.state.markSeen;
|
||
const answerMarkStarted = deferred();
|
||
const releaseAnswerMark = deferred();
|
||
fixture.state.markSeen = async (messageId) => {
|
||
if (messageId === 'racing-answer') {
|
||
answerMarkStarted.resolve();
|
||
await releaseAnswerMark.promise;
|
||
}
|
||
await originalMarkSeen(messageId);
|
||
};
|
||
const sent = [];
|
||
const asked = [];
|
||
const externallyResolved = deferred();
|
||
let resolveInteraction;
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
if (text === '后来的普通问题') return '后来问题的回答';
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'resolved-race-question',
|
||
sessionId,
|
||
}));
|
||
resolveInteraction = () => {
|
||
options.onInteractionResolved({
|
||
kind: 'question',
|
||
interactionId: 'resolved-race-question',
|
||
sessionId,
|
||
outcome: 'answered',
|
||
});
|
||
externallyResolved.resolve();
|
||
};
|
||
await externallyResolved.promise;
|
||
return '第一轮已由其他客户端完成';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('race-start', '启动外部解决竞态'));
|
||
await eventually(() => typeof resolveInteraction === 'function');
|
||
const answer = bridge.accept(message('racing-answer', '原本的问题答案'));
|
||
await answerMarkStarted.promise;
|
||
resolveInteraction();
|
||
releaseAnswerMark.resolve();
|
||
await Promise.all([processing, answer]);
|
||
await bridge.accept(message('later-prompt', '后来的普通问题'));
|
||
|
||
assert.deepEqual(asked, ['启动外部解决竞态', '后来的普通问题']);
|
||
assert.equal(asked.includes('原本的问题答案'), false);
|
||
assert.equal(sent.some(({ text }) => text.includes('已在其他客户端处理')), true);
|
||
});
|
||
|
||
test('accepts a first answer received while its question presentation is still in flight', async () => {
|
||
const fixture = stateFixture();
|
||
const presentationStarted = deferred();
|
||
const releasePresentation = deferred();
|
||
const submitted = deferred();
|
||
const sent = [];
|
||
const asked = [];
|
||
let questionPresentations = 0;
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: {
|
||
sendText: async (target, text) => {
|
||
sent.push({ target, text });
|
||
if (text.includes('发送仍在进行的问题')) {
|
||
questionPresentations += 1;
|
||
presentationStarted.resolve();
|
||
await releasePresentation.promise;
|
||
}
|
||
},
|
||
},
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'first-presentation-race',
|
||
sessionId,
|
||
questions: [{ id: 'first', question: '发送仍在进行的问题' }],
|
||
respond: async (result) => {
|
||
submitted.resolve(result);
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await submitted.promise;
|
||
return '首问已回答';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('presentation-start', '启动首问竞态'));
|
||
await presentationStarted.promise;
|
||
const answer = bridge.accept(message('presentation-answer', '首问答案'));
|
||
releasePresentation.resolve();
|
||
await Promise.all([processing, answer]);
|
||
|
||
assert.deepEqual((await submitted.promise).value.answer.answers, [{
|
||
id: 'first',
|
||
selected: [],
|
||
custom: '首问答案',
|
||
}]);
|
||
assert.deepEqual(asked, ['启动首问竞态']);
|
||
assert.equal(questionPresentations, 1);
|
||
assert.equal(sent.at(-1).text, '首问已回答');
|
||
});
|
||
|
||
test('discards an answer already received when a later question resolves during presentation', async () => {
|
||
const fixture = stateFixture();
|
||
const secondPresentationStarted = deferred();
|
||
const releaseSecondPresentation = deferred();
|
||
const externallyResolved = deferred();
|
||
const sent = [];
|
||
const asked = [];
|
||
let resolveInteraction;
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: {
|
||
sendText: async (target, text) => {
|
||
sent.push({ target, text });
|
||
if (text.includes('仍在发送的第二问')) {
|
||
secondPresentationStarted.resolve();
|
||
await releaseSecondPresentation.promise;
|
||
}
|
||
},
|
||
},
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
if (text !== '启动第二问竞态') return '不应把第二问答案作为新 prompt';
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'second-presentation-race',
|
||
sessionId,
|
||
questions: [
|
||
{ id: 'first', question: '第一问' },
|
||
{ id: 'second', question: '仍在发送的第二问' },
|
||
],
|
||
}));
|
||
resolveInteraction = () => {
|
||
options.onInteractionResolved({
|
||
kind: 'question',
|
||
interactionId: 'second-presentation-race',
|
||
sessionId,
|
||
outcome: 'answered',
|
||
});
|
||
externallyResolved.resolve();
|
||
};
|
||
await externallyResolved.promise;
|
||
return '已由其他客户端完成';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('second-race-start', '启动第二问竞态'));
|
||
await eventually(() => typeof resolveInteraction === 'function');
|
||
const firstAnswer = bridge.accept(message('second-race-first', '第一问答案'));
|
||
await secondPresentationStarted.promise;
|
||
const secondAnswer = bridge.accept(message('second-race-second', '第二问已收到的答案'));
|
||
resolveInteraction();
|
||
releaseSecondPresentation.resolve();
|
||
await Promise.all([processing, firstAnswer, secondAnswer]);
|
||
|
||
assert.deepEqual(asked, ['启动第二问竞态']);
|
||
assert.equal(asked.includes('第二问已收到的答案'), false);
|
||
assert.equal(sent.some(({ text }) => text.includes('已在其他客户端处理')), true);
|
||
});
|
||
|
||
test('passes the runtime signal to Harness and safely cancels a pending question on abort', async () => {
|
||
const controller = new AbortController();
|
||
const fixture = stateFixture({ 'direct:chat-a': 'stale-session' });
|
||
const sent = [];
|
||
const cancelled = deferred();
|
||
let existsSignal;
|
||
let createSignal;
|
||
let askSignal;
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
signal: controller.signal,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
sessionExists: async (_sessionId, options) => {
|
||
existsSignal = options?.signal;
|
||
return false;
|
||
},
|
||
createSession: async (options) => {
|
||
createSignal = options?.signal;
|
||
return 'session-one';
|
||
},
|
||
ask: async (sessionId, _text, options) => {
|
||
askSignal = options.signal;
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'abort-question',
|
||
sessionId,
|
||
respond: async (result) => {
|
||
cancelled.resolve(result);
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await new Promise((resolve, reject) => {
|
||
options.signal.addEventListener('abort', () => reject(options.signal.reason), {
|
||
once: true,
|
||
});
|
||
});
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('abort-start', '启动后停止'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('请回答')));
|
||
controller.abort(new DOMException('runtime stopped', 'AbortError'));
|
||
await processing;
|
||
|
||
assert.equal(existsSignal, controller.signal);
|
||
assert.equal(createSignal, controller.signal);
|
||
assert.equal(askSignal, controller.signal);
|
||
assert.deepEqual(await cancelled.promise, {
|
||
ok: false,
|
||
error: {
|
||
code: 'cancelled',
|
||
message: 'The Test interaction ended before the user answered.',
|
||
details: {},
|
||
},
|
||
});
|
||
});
|