mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 10:33:21 +08:00
634 lines
22 KiB
JavaScript
634 lines
22 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
|
||
import { TextHarnessBridge } from '../../../src/channels/shared/text-harness-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');
|
||
}
|
||
|
||
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 createBridge({ harness, state, bot, signal, logger } = {}) {
|
||
return new TextHarnessBridge({
|
||
descriptor: { key: 'test', label: 'Test' },
|
||
bot,
|
||
harness,
|
||
state,
|
||
signal,
|
||
logger: logger ?? { warn() {}, error() {} },
|
||
});
|
||
}
|
||
|
||
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('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, cancels parallel and recovered questions, and leaves approval pending', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
let parallelResponse;
|
||
let recoveredResponse;
|
||
let approvalResponses = 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) => {
|
||
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({
|
||
kind: 'approval',
|
||
interactionId: 'approval-one',
|
||
rpcId: 'approval-rpc',
|
||
sessionId,
|
||
payload: { type: 'approval/requested', approvalId: 'approval-one' },
|
||
respond: async () => { approvalResponses += 1; },
|
||
});
|
||
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.equal(approvalResponses, 0);
|
||
});
|
||
|
||
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: {},
|
||
},
|
||
});
|
||
});
|