mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 20:55:59 +08:00
fix: support Harness interactions in DingTalk and Feishu
This commit is contained in:
parent
c9ec84cc67
commit
3e7e1dcdd7
13 changed files with 5061 additions and 881 deletions
|
|
@ -6,6 +6,25 @@ import {
|
|||
DingtalkHarnessBridge,
|
||||
} from '../../../src/channels/dingtalk/dingtalk-bridge.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function eventually(predicate, message = 'condition was not met') {
|
||||
const deadline = Date.now() + 1_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.fail(message);
|
||||
}
|
||||
|
||||
function message(id, text, overrides = {}) {
|
||||
return {
|
||||
msgId: id,
|
||||
|
|
@ -269,3 +288,826 @@ test('commands stay local and unsafe session webhooks are rejected before Harnes
|
|||
assert.equal(sent.length, 1);
|
||||
assert.equal(bridge.status.lastError, '钉钉消息没有安全的回复地址。');
|
||||
});
|
||||
|
||||
test('a DingTalk reply answers a pending Harness question before the original turn queue', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const asked = [];
|
||||
const response = deferred();
|
||||
const releaseTurn = deferred();
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: { sendText: async (request) => sent.push(request) },
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => false,
|
||||
createSession: async () => 'session-question',
|
||||
ask: async (sessionId, text, options) => {
|
||||
asked.push({ sessionId, text });
|
||||
await options.onInteraction({
|
||||
kind: 'question',
|
||||
rpcId: 'question-rpc',
|
||||
sessionId,
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId,
|
||||
questions: [{
|
||||
id: 'environment',
|
||||
header: '运行环境',
|
||||
question: '请问要使用哪个环境?',
|
||||
options: [
|
||||
{ label: '生产环境', description: '处理真实数据' },
|
||||
{ label: '预发环境' },
|
||||
],
|
||||
}],
|
||||
},
|
||||
respond: async (result) => {
|
||||
response.resolve(result);
|
||||
return { accepted: true };
|
||||
},
|
||||
});
|
||||
await response.promise;
|
||||
await releaseTurn.promise;
|
||||
return '已按测试环境继续。';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
let firstSettled = false;
|
||||
const first = bridge.accept(message('prompt', '先问我一个问题'))
|
||||
.finally(() => { firstSettled = true; });
|
||||
await eventually(() => sent.some(({ text }) => text.includes('请问要使用哪个环境')));
|
||||
|
||||
await Promise.race([
|
||||
bridge.accept(message('answer', '测试环境')),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('question reply deadlocked')), 500)),
|
||||
]);
|
||||
|
||||
assert.equal(firstSettled, false);
|
||||
assert.deepEqual(await response.promise, {
|
||||
ok: true,
|
||||
value: {
|
||||
sessionId: 'session-question',
|
||||
answer: {
|
||||
answers: [{ id: 'environment', selected: [], custom: '测试环境' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(asked, [{ sessionId: 'session-question', text: '先问我一个问题' }]);
|
||||
|
||||
releaseTurn.resolve();
|
||||
await first;
|
||||
assert.equal(sent.length, 2);
|
||||
assert.match(sent[0].text, /DeepSeek Harness 需要你补充信息/);
|
||||
assert.equal(sent[1].text, '已按测试环境继续。');
|
||||
assert.equal(bridge.status.messagesReceived, 2);
|
||||
assert.equal(bridge.status.messagesReplied, 1);
|
||||
});
|
||||
|
||||
test('pending questions are isolated by DingTalk conversation', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-a', 'session-a');
|
||||
fixture.sessions.set('p2p:staff-b', 'session-b');
|
||||
const sent = [];
|
||||
const asked = [];
|
||||
const answeredA = deferred();
|
||||
const releaseA = deferred();
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: { sendText: async (request) => sent.push(request) },
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
createSession: async () => assert.fail('existing sessions should be reused'),
|
||||
ask: async (sessionId, text, options) => {
|
||||
asked.push({ sessionId, text });
|
||||
if (sessionId === 'session-b') return '乙会话的普通回答';
|
||||
await options.onInteraction({
|
||||
kind: 'question',
|
||||
rpcId: 'rpc-a',
|
||||
sessionId,
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId,
|
||||
questions: [{ id: 'a', question: '甲会话的问题' }],
|
||||
},
|
||||
respond: async (result) => {
|
||||
answeredA.resolve(result);
|
||||
return { accepted: true };
|
||||
},
|
||||
});
|
||||
await answeredA.promise;
|
||||
await releaseA.promise;
|
||||
return '甲会话完成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
const firstA = bridge.accept(message('a-prompt', '启动甲会话', {
|
||||
senderStaffId: 'staff-a',
|
||||
}));
|
||||
await eventually(() => sent.some(({ text }) => text.includes('甲会话的问题')));
|
||||
|
||||
await bridge.accept(message('b-message', '乙会话的消息', {
|
||||
senderStaffId: 'staff-b',
|
||||
}));
|
||||
assert.deepEqual(asked, [
|
||||
{ sessionId: 'session-a', text: '启动甲会话' },
|
||||
{ sessionId: 'session-b', text: '乙会话的消息' },
|
||||
]);
|
||||
assert.equal(sent.some(({ text }) => text === '乙会话的普通回答'), true);
|
||||
|
||||
await bridge.accept(message('a-answer', '甲的答案', { senderStaffId: 'staff-a' }));
|
||||
assert.deepEqual((await answeredA.promise).value.answer.answers, [
|
||||
{ id: 'a', selected: [], custom: '甲的答案' },
|
||||
]);
|
||||
releaseA.resolve();
|
||||
await firstA;
|
||||
});
|
||||
|
||||
test('question replays are deduplicated and approval interactions are never auto-approved', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
let approvalResponses = 0;
|
||||
let secondQuestionResponse;
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: { sendText: async (request) => sent.push(request) },
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => false,
|
||||
createSession: async () => 'session-replay',
|
||||
ask: async (sessionId, _text, options) => {
|
||||
const question = {
|
||||
kind: 'question',
|
||||
rpcId: 'replayed-question',
|
||||
sessionId,
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId,
|
||||
questions: [{ id: 'choice', question: '只应显示一次' }],
|
||||
},
|
||||
respond: async () => ({ accepted: true }),
|
||||
};
|
||||
await options.onInteraction(question);
|
||||
await options.onInteraction(question);
|
||||
await options.onInteraction({
|
||||
kind: 'question',
|
||||
interactionId: 'parallel-question',
|
||||
rpcId: 'parallel-question',
|
||||
sessionId,
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId,
|
||||
questions: [{ id: 'parallel', question: '不应无声丢弃' }],
|
||||
},
|
||||
respond: async (result) => {
|
||||
secondQuestionResponse = result;
|
||||
return { accepted: true };
|
||||
},
|
||||
});
|
||||
await options.onInteraction({
|
||||
kind: 'approval',
|
||||
rpcId: 'approval-rpc',
|
||||
sessionId,
|
||||
payload: {
|
||||
type: 'approval/requested',
|
||||
sessionId,
|
||||
approvalId: 'approval-one',
|
||||
toolName: 'bash',
|
||||
},
|
||||
respond: async () => { approvalResponses += 1; },
|
||||
});
|
||||
await options.onInteractionResolved({
|
||||
kind: 'question',
|
||||
sessionId,
|
||||
interactionId: 'replayed-question',
|
||||
outcome: 'cancelled',
|
||||
});
|
||||
return '交互已取消';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message('replay', '测试重放'));
|
||||
|
||||
assert.equal(sent.filter(({ text }) => text.includes('只应显示一次')).length, 1);
|
||||
assert.deepEqual(secondQuestionResponse, {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'cancelled',
|
||||
message: 'DingTalk is already handling another user interaction.',
|
||||
details: {},
|
||||
},
|
||||
});
|
||||
assert.equal(sent.some(({ text }) => text.includes('approval')), false);
|
||||
assert.equal(approvalResponses, 0);
|
||||
assert.equal(sent.at(-1).text, '交互已取消');
|
||||
});
|
||||
|
||||
test('a message arriving during question submission is re-queued as the next normal prompt', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const asked = [];
|
||||
const submitStarted = deferred();
|
||||
const releaseSubmit = deferred();
|
||||
const answered = deferred();
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: { sendText: async (request) => sent.push(request) },
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
createSession: async () => 'session-submit-race',
|
||||
ask: async (sessionId, text, options) => {
|
||||
asked.push(text);
|
||||
if (text === '下一个真实问题') return '第二轮回答';
|
||||
const interaction = {
|
||||
kind: 'question',
|
||||
interactionId: 'submit-race-question',
|
||||
rpcId: 'submit-race-question',
|
||||
sessionId,
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId,
|
||||
questions: [{ id: 'answer', question: '请先回答当前问题' }],
|
||||
},
|
||||
respond: async (result) => {
|
||||
submitStarted.resolve(result);
|
||||
await releaseSubmit.promise;
|
||||
answered.resolve();
|
||||
return { accepted: true };
|
||||
},
|
||||
};
|
||||
await options.onInteraction(interaction);
|
||||
await answered.promise;
|
||||
return '第一轮回答';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
const first = bridge.accept(message('submit-start', '启动交互'));
|
||||
await eventually(() => sent.some(({ text }) => text.includes('请先回答当前问题')));
|
||||
const answer = bridge.accept(message('submit-answer', '当前问题的答案'));
|
||||
await submitStarted.promise;
|
||||
|
||||
let nextSettled = false;
|
||||
const next = bridge.accept(message('submit-next', '下一个真实问题'))
|
||||
.finally(() => { nextSettled = true; });
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.equal(nextSettled, false);
|
||||
|
||||
releaseSubmit.resolve();
|
||||
await Promise.all([answer, first, next]);
|
||||
assert.deepEqual(asked, ['启动交互', '下一个真实问题']);
|
||||
assert.equal(asked.includes('当前问题的答案'), false);
|
||||
assert.deepEqual(sent.slice(-2).map(({ text }) => text), ['第一轮回答', '第二轮回答']);
|
||||
});
|
||||
|
||||
test('a queued next prompt stays separate when question submission must be retried', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const asked = [];
|
||||
const firstSubmitStarted = deferred();
|
||||
const releaseFirstSubmit = deferred();
|
||||
const answered = deferred();
|
||||
const submittedAnswers = [];
|
||||
let submitAttempts = 0;
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: { sendText: async (request) => sent.push(request) },
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
createSession: async () => 'session-submit-retry-race',
|
||||
ask: async (sessionId, text, options) => {
|
||||
asked.push(text);
|
||||
if (text === '排队的下一个问题') return '第二轮完成';
|
||||
await options.onInteraction({
|
||||
kind: 'question',
|
||||
interactionId: 'submit-retry-question',
|
||||
rpcId: 'submit-retry-question',
|
||||
sessionId,
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId,
|
||||
questions: [{ id: 'answer', question: '请回答后再继续' }],
|
||||
},
|
||||
respond: async (result) => {
|
||||
submittedAnswers.push(result.value.answer.answers[0].custom);
|
||||
submitAttempts += 1;
|
||||
if (submitAttempts === 1) {
|
||||
firstSubmitStarted.resolve();
|
||||
await releaseFirstSubmit.promise;
|
||||
throw new Error('temporary response failure');
|
||||
}
|
||||
answered.resolve();
|
||||
return { accepted: true };
|
||||
},
|
||||
});
|
||||
await answered.promise;
|
||||
return '第一轮完成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { error() {}, warn() {} },
|
||||
});
|
||||
|
||||
const first = bridge.accept(message('submit-retry-start', '启动可重试交互'));
|
||||
await eventually(() => sent.some(({ text }) => text.includes('请回答后再继续')));
|
||||
const firstAnswer = bridge.accept(message('submit-retry-answer', '第一次答案'));
|
||||
await firstSubmitStarted.promise;
|
||||
|
||||
let nextSettled = false;
|
||||
const next = bridge.accept(message('submit-retry-next', '排队的下一个问题'))
|
||||
.finally(() => { nextSettled = true; });
|
||||
releaseFirstSubmit.resolve();
|
||||
await firstAnswer;
|
||||
await eventually(() => sent.some(({ text }) => text.includes('回答提交失败')));
|
||||
assert.equal(nextSettled, false);
|
||||
assert.deepEqual(asked, ['启动可重试交互']);
|
||||
|
||||
const retry = bridge.accept(message('submit-retry-again', '重试后的答案'));
|
||||
await Promise.all([retry, first, next]);
|
||||
|
||||
assert.deepEqual(submittedAnswers, ['第一次答案', '重试后的答案']);
|
||||
assert.deepEqual(asked, ['启动可重试交互', '排队的下一个问题']);
|
||||
assert.deepEqual(sent.slice(-2).map(({ text }) => text), ['第一轮完成', '第二轮完成']);
|
||||
});
|
||||
|
||||
test('an invalid pending reply does not block the valid answer behind it', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const invalidNoticeStarted = deferred();
|
||||
const releaseInvalidNotice = deferred();
|
||||
const answered = deferred();
|
||||
let submitted;
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
sendText: async (request) => {
|
||||
if (request.text === '请用文字回答当前问题。') {
|
||||
invalidNoticeStarted.resolve();
|
||||
await releaseInvalidNotice.promise;
|
||||
}
|
||||
sent.push(request);
|
||||
},
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
createSession: async () => 'session-invalid-reply',
|
||||
ask: async (sessionId, _text, options) => {
|
||||
await options.onInteraction({
|
||||
kind: 'question',
|
||||
interactionId: 'invalid-reply-question',
|
||||
rpcId: 'invalid-reply-question',
|
||||
sessionId,
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId,
|
||||
questions: [{ id: 'answer', question: '请给出有效文字答案' }],
|
||||
},
|
||||
respond: async (result) => {
|
||||
submitted = result;
|
||||
answered.resolve();
|
||||
return { accepted: true };
|
||||
},
|
||||
});
|
||||
await answered.promise;
|
||||
return '有效答案已收到';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
const first = bridge.accept(message('invalid-reply-start', '启动交互'));
|
||||
await eventually(() => sent.some(({ text }) => text.includes('请给出有效文字答案')));
|
||||
const invalid = bridge.accept(message('invalid-reply-image', '', {
|
||||
msgtype: 'picture',
|
||||
text: undefined,
|
||||
}));
|
||||
await invalidNoticeStarted.promise;
|
||||
const valid = bridge.accept(message('invalid-reply-valid', '真正的答案'));
|
||||
releaseInvalidNotice.resolve();
|
||||
|
||||
await Promise.all([invalid, valid, first]);
|
||||
assert.deepEqual(submitted.value.answer.answers, [{
|
||||
id: 'answer',
|
||||
selected: [],
|
||||
custom: '真正的答案',
|
||||
}]);
|
||||
assert.equal(sent.at(-1).text, '有效答案已收到');
|
||||
});
|
||||
|
||||
test('an answer resolved elsewhere is not reinterpreted as a later prompt', async () => {
|
||||
const fixture = stateFixture();
|
||||
const originalMarkSeen = fixture.state.markSeen;
|
||||
const answerMarkStarted = deferred();
|
||||
const releaseAnswerMark = deferred();
|
||||
fixture.state.markSeen = async (id) => {
|
||||
if (id === 'resolved-answer-first') {
|
||||
answerMarkStarted.resolve();
|
||||
await releaseAnswerMark.promise;
|
||||
}
|
||||
await originalMarkSeen(id);
|
||||
};
|
||||
const sent = [];
|
||||
const asked = [];
|
||||
const resolved = deferred();
|
||||
let resolveInteraction;
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: { sendText: async (request) => sent.push(request) },
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
createSession: async () => 'session-resolved-race',
|
||||
ask: async (sessionId, text, options) => {
|
||||
asked.push(text);
|
||||
if (text === '后来的普通问题') return '后来问题的回答';
|
||||
await options.onInteraction({
|
||||
kind: 'question',
|
||||
interactionId: 'resolved-race-question',
|
||||
rpcId: 'resolved-race-question',
|
||||
sessionId,
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId,
|
||||
questions: [{ id: 'answer', question: '可能在其他客户端回答' }],
|
||||
},
|
||||
respond: async () => ({ accepted: true }),
|
||||
});
|
||||
resolveInteraction = async () => {
|
||||
await options.onInteractionResolved({
|
||||
kind: 'question',
|
||||
interactionId: 'resolved-race-question',
|
||||
sessionId,
|
||||
outcome: 'answered',
|
||||
});
|
||||
resolved.resolve();
|
||||
};
|
||||
await resolved.promise;
|
||||
return '第一轮已由其他客户端完成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
const first = bridge.accept(message('resolved-race-start', '启动外部解决竞态'));
|
||||
await eventually(() => typeof resolveInteraction === 'function');
|
||||
const answer = bridge.accept(message('resolved-answer-first', '原本的问题答案'));
|
||||
await answerMarkStarted.promise;
|
||||
const later = bridge.accept(message('resolved-later-second', '后来的普通问题'));
|
||||
await resolveInteraction();
|
||||
releaseAnswerMark.resolve();
|
||||
|
||||
await Promise.all([answer, first, later]);
|
||||
assert.deepEqual(asked, ['启动外部解决竞态', '后来的普通问题']);
|
||||
assert.equal(asked.includes('原本的问题答案'), false);
|
||||
assert.equal(sent.some(({ text }) => text.includes('已在其他客户端处理')), true);
|
||||
});
|
||||
|
||||
test('an orphan question is cancelled without exposing it to a new conversation', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
let recoveredResponse;
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: { sendText: async (request) => sent.push(request) },
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
createSession: async () => 'session-orphan-recovery',
|
||||
ask: async (sessionId, _text, options) => {
|
||||
await options.onInteraction({
|
||||
kind: 'question',
|
||||
interactionId: 'orphan-secret-question',
|
||||
rpcId: 'orphan-secret-question',
|
||||
sessionId,
|
||||
recovered: true,
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId,
|
||||
questions: [{ id: 'secret', question: '旧会话中的敏感问题内容' }],
|
||||
},
|
||||
respond: async (result) => {
|
||||
recoveredResponse = result;
|
||||
return { accepted: true };
|
||||
},
|
||||
});
|
||||
return '新的消息已继续';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('orphan-recovery', '新的会话消息'));
|
||||
assert.deepEqual(recoveredResponse, {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'cancelled',
|
||||
message: 'DingTalk safely cancelled an interaction left by an earlier client.',
|
||||
details: {},
|
||||
},
|
||||
});
|
||||
assert.equal(sent.some(({ text }) => text.includes('旧会话中的敏感问题内容')), false);
|
||||
assert.equal(sent.some(({ text }) => text.includes('遗留的待回答问题')), true);
|
||||
assert.equal(sent.at(-1).text, '新的消息已继续');
|
||||
});
|
||||
|
||||
test('question delivery replays safely and a multi-question batch keeps canonical answers', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const response = deferred();
|
||||
let firstQuestionAttempts = 0;
|
||||
let secondQuestionAttempts = 0;
|
||||
let replayTask = Promise.resolve();
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
sendText: async (request) => {
|
||||
if (request.text.includes('选择回答语言')) {
|
||||
firstQuestionAttempts += 1;
|
||||
if (firstQuestionAttempts === 1) throw new Error('temporary first question failure');
|
||||
}
|
||||
if (request.text.includes('选择交付内容')) {
|
||||
secondQuestionAttempts += 1;
|
||||
if (secondQuestionAttempts === 1) throw new Error('temporary second question failure');
|
||||
}
|
||||
sent.push(request);
|
||||
},
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => false,
|
||||
createSession: async () => 'session-question-batch',
|
||||
ask: async (sessionId, _text, options) => {
|
||||
// An already-started turn remains the owner even if a workspace switch
|
||||
// clears the conversation-to-session mapping while it is running.
|
||||
fixture.sessions.delete('p2p:staff-approved');
|
||||
const interaction = {
|
||||
kind: 'question',
|
||||
interactionId: 'batch-question',
|
||||
rpcId: 'batch-question',
|
||||
sessionId,
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId,
|
||||
questions: [
|
||||
{
|
||||
id: 'language',
|
||||
question: '选择回答语言',
|
||||
options: [{ label: '中文' }, { label: 'English' }],
|
||||
},
|
||||
{
|
||||
id: 'deliverables',
|
||||
question: '选择交付内容',
|
||||
multiSelect: true,
|
||||
options: [{ label: '测试' }, { label: '文档' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
reconnect: () => {
|
||||
replayTask = new Promise((resolve, reject) => {
|
||||
queueMicrotask(() => options.onInteraction(interaction).then(resolve, reject));
|
||||
});
|
||||
},
|
||||
respond: async (result) => {
|
||||
response.resolve(result);
|
||||
return { accepted: true };
|
||||
},
|
||||
};
|
||||
await assert.rejects(
|
||||
options.onInteraction(interaction),
|
||||
/temporary first question failure/,
|
||||
);
|
||||
await options.onInteraction(interaction);
|
||||
await response.promise;
|
||||
return '批量问题已完成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { error() {}, warn() {} },
|
||||
});
|
||||
|
||||
const first = bridge.accept(message('batch-start', '请分步提问'));
|
||||
await eventually(() => sent.some(({ text }) => text.includes('选择回答语言')));
|
||||
await bridge.accept(message('batch-language', '2'));
|
||||
await eventually(() => sent.some(({ text }) => text.includes('选择交付内容')));
|
||||
await replayTask;
|
||||
await bridge.accept(message('batch-deliverables', '1,文档,发布说明'));
|
||||
|
||||
assert.deepEqual(await response.promise, {
|
||||
ok: true,
|
||||
value: {
|
||||
sessionId: 'session-question-batch',
|
||||
answer: {
|
||||
answers: [
|
||||
{ id: 'language', selected: ['English'] },
|
||||
{ id: 'deliverables', selected: ['测试', '文档'], custom: '发布说明' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(firstQuestionAttempts, 2);
|
||||
assert.equal(secondQuestionAttempts, 2);
|
||||
await first;
|
||||
assert.equal(sent.at(-1).text, '批量问题已完成');
|
||||
});
|
||||
|
||||
test('aborting a DingTalk turn cancels its pending Harness question', async () => {
|
||||
const fixture = stateFixture();
|
||||
const controller = new AbortController();
|
||||
const sent = [];
|
||||
const cancelled = deferred();
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: { sendText: async (request) => sent.push(request) },
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => false,
|
||||
createSession: async () => 'session-cancel-question',
|
||||
ask: async (sessionId, _text, options) => {
|
||||
await options.onInteraction({
|
||||
kind: 'question',
|
||||
interactionId: 'question-to-cancel',
|
||||
rpcId: 'question-to-cancel',
|
||||
sessionId,
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId,
|
||||
questions: [{ id: 'cancel', question: '等待重连前取消' }],
|
||||
},
|
||||
respond: async (result) => {
|
||||
cancelled.resolve(result);
|
||||
return { accepted: true };
|
||||
},
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
if (options.signal.aborted) {
|
||||
reject(options.signal.reason);
|
||||
return;
|
||||
}
|
||||
options.signal.addEventListener('abort', () => reject(options.signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const processing = bridge.accept(message('cancel-start', '启动后停止'));
|
||||
await eventually(() => sent.some(({ text }) => text.includes('等待重连前取消')));
|
||||
controller.abort(new DOMException('runtime stopped', 'AbortError'));
|
||||
await processing;
|
||||
|
||||
assert.deepEqual(await cancelled.promise, {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'cancelled',
|
||||
message: 'The DingTalk interaction ended before the user answered.',
|
||||
details: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('group questions tell users to mention the bot and ignore unmentioned answers', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const answered = deferred();
|
||||
let responseCalls = 0;
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: { sendText: async (request) => sent.push(request) },
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => false,
|
||||
createSession: async () => 'session-group-question',
|
||||
ask: async (sessionId, _text, options) => {
|
||||
await options.onInteraction({
|
||||
kind: 'question',
|
||||
interactionId: 'group-question',
|
||||
rpcId: 'group-question',
|
||||
sessionId,
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId,
|
||||
questions: [{ id: 'group', question: '群聊问题' }],
|
||||
},
|
||||
respond: async () => {
|
||||
responseCalls += 1;
|
||||
answered.resolve();
|
||||
return { accepted: true };
|
||||
},
|
||||
});
|
||||
await answered.promise;
|
||||
return '群聊交互完成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
const group = {
|
||||
conversationType: '2',
|
||||
conversationId: 'group-question-room',
|
||||
};
|
||||
|
||||
const first = bridge.accept(message('group-start', '开始群聊问答', {
|
||||
...group,
|
||||
isInAtList: true,
|
||||
}));
|
||||
await eventually(() => sent.some(({ text }) => text.includes('群聊问题')));
|
||||
assert.match(sent[0].text, /群聊中请 @机器人/);
|
||||
|
||||
await bridge.accept(message('group-unmentioned', '这条不算答案', {
|
||||
...group,
|
||||
isInAtList: false,
|
||||
}));
|
||||
assert.equal(responseCalls, 0);
|
||||
await bridge.accept(message('group-mentioned', '这条才是答案', {
|
||||
...group,
|
||||
isInAtList: true,
|
||||
}));
|
||||
await first;
|
||||
|
||||
assert.equal(responseCalls, 1);
|
||||
assert.equal(bridge.status.messagesIgnored, 1);
|
||||
assert.equal(sent.at(-1).text, '群聊交互完成');
|
||||
});
|
||||
|
||||
test('only the actor who started a DingTalk group interaction can answer it', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('group:actor-room', 'session-group-actor');
|
||||
const asked = [];
|
||||
const submitted = deferred();
|
||||
let interactionSubmitted = false;
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: { sendText: async () => undefined },
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
createSession: async () => assert.fail('the group session should already exist'),
|
||||
ask: async (sessionId, text, options) => {
|
||||
asked.push(text);
|
||||
if (text !== '甲发起交互') return '普通群消息已处理';
|
||||
await options.onInteraction({
|
||||
kind: 'question',
|
||||
interactionId: 'dingtalk-group-actor',
|
||||
rpcId: 'dingtalk-group-actor',
|
||||
sessionId,
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId,
|
||||
questions: [{ id: 'actor', question: '只能由甲回答' }],
|
||||
},
|
||||
respond: async (result) => {
|
||||
interactionSubmitted = true;
|
||||
submitted.resolve(result);
|
||||
return { accepted: true };
|
||||
},
|
||||
});
|
||||
await submitted.promise;
|
||||
return '甲的交互已完成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
const group = {
|
||||
conversationType: '2',
|
||||
conversationId: 'actor-room',
|
||||
isInAtList: true,
|
||||
};
|
||||
|
||||
const first = bridge.accept(message('actor-start', '甲发起交互', {
|
||||
...group,
|
||||
senderStaffId: 'staff-a',
|
||||
}));
|
||||
await eventually(() => asked.length === 1);
|
||||
const intruder = bridge.accept(message('actor-b', '乙试图代答', {
|
||||
...group,
|
||||
senderStaffId: 'staff-b',
|
||||
}));
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(interactionSubmitted, false);
|
||||
assert.deepEqual(asked, ['甲发起交互']);
|
||||
|
||||
await bridge.accept(message('actor-a', '甲的答案', {
|
||||
...group,
|
||||
senderStaffId: 'staff-a',
|
||||
}));
|
||||
assert.deepEqual((await submitted.promise).value.answer.answers, [{
|
||||
id: 'actor',
|
||||
selected: [],
|
||||
custom: '甲的答案',
|
||||
}]);
|
||||
await Promise.all([first, intruder]);
|
||||
assert.deepEqual(asked, ['甲发起交互', '乙试图代答']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,69 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { HarnessClient, HarnessReplyTracker, HarnessRpcError } from '../../../src/channels/dingtalk/harness-client.mjs';
|
||||
import {
|
||||
HarnessClient,
|
||||
HarnessInteractionError,
|
||||
HarnessReplyTracker,
|
||||
HarnessRpcError,
|
||||
} from '../../../src/channels/dingtalk/harness-client.mjs';
|
||||
import {
|
||||
HarnessClient as FeishuHarnessClient,
|
||||
} from '../../../src/channels/feishu/harness-client.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function eventually(predicate, message = 'condition was not met') {
|
||||
const deadline = Date.now() + 1_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.fail(message);
|
||||
}
|
||||
|
||||
class FakeSocket {
|
||||
#listeners = new Map();
|
||||
readyState = 0;
|
||||
|
||||
addEventListener(name, listener) {
|
||||
const listeners = this.#listeners.get(name) ?? new Set();
|
||||
listeners.add(listener);
|
||||
this.#listeners.set(name, listeners);
|
||||
}
|
||||
|
||||
removeEventListener(name, listener) {
|
||||
this.#listeners.get(name)?.delete(listener);
|
||||
}
|
||||
|
||||
open() {
|
||||
if (this.readyState !== 0) return;
|
||||
this.readyState = 1;
|
||||
this.emit('open', {});
|
||||
}
|
||||
|
||||
frame(value) {
|
||||
this.emit('message', { data: JSON.stringify(value) });
|
||||
}
|
||||
|
||||
close(code = 1000) {
|
||||
if (this.readyState >= 2) return;
|
||||
this.readyState = 3;
|
||||
this.emit('close', { code });
|
||||
}
|
||||
|
||||
emit(name, event) {
|
||||
for (const listener of [...(this.#listeners.get(name) ?? [])]) listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
test('HarnessClient lists only absolute workspace paths and forwards request options', async () => {
|
||||
const client = new HarnessClient({
|
||||
|
|
@ -391,3 +453,818 @@ test('Harness client validates the RPC envelope and preserves server error codes
|
|||
assert.equal(request.url, 'http://127.0.0.1:3080/api/session.history');
|
||||
assert.match(request.body.rpcId, /^dingtalk-/);
|
||||
});
|
||||
|
||||
test('interaction watcher decodes mux requests, filters sessions, and posts exact responses', async () => {
|
||||
const requests = [];
|
||||
const opened = deferred();
|
||||
let socket;
|
||||
let socketUrl;
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080/base',
|
||||
workspace: '/tmp/workspace',
|
||||
fetchImpl: async (url, options) => {
|
||||
requests.push({
|
||||
url: url.toString(),
|
||||
method: options.method,
|
||||
body: JSON.parse(options.body),
|
||||
});
|
||||
return { ok: true, json: async () => ({ accepted: true }) };
|
||||
},
|
||||
createWebSocket: (url) => {
|
||||
socketUrl = url;
|
||||
socket = new FakeSocket();
|
||||
queueMicrotask(() => socket.open());
|
||||
return socket;
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const interactions = [];
|
||||
const resolutions = [];
|
||||
const watching = client.watchInteractions('session-one', {
|
||||
signal: controller.signal,
|
||||
onOpen: opened.resolve,
|
||||
onInteraction: (interaction) => interactions.push(interaction),
|
||||
onResolved: (resolution) => resolutions.push(resolution),
|
||||
});
|
||||
await opened.promise;
|
||||
|
||||
socket.frame({
|
||||
type: 'server-request',
|
||||
rpcId: 'other-rpc',
|
||||
method: 'question/requested',
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId: 'session-other',
|
||||
questions: [{ id: 'ignored', question: 'ignore me' }],
|
||||
},
|
||||
});
|
||||
socket.frame({
|
||||
type: 'server-request',
|
||||
rpcId: 'approval-rpc',
|
||||
method: 'approval/requested',
|
||||
payload: {
|
||||
type: 'approval/requested',
|
||||
sessionId: 'session-one',
|
||||
approvalId: 'approval-one',
|
||||
toolName: 'bash',
|
||||
},
|
||||
});
|
||||
socket.frame({
|
||||
type: 'server-request',
|
||||
rpcId: 'question-rpc',
|
||||
method: 'question/requested',
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId: 'session-one',
|
||||
questions: [{ id: 'language', question: 'Which language?' }],
|
||||
},
|
||||
});
|
||||
socket.frame({
|
||||
type: 'server-request',
|
||||
rpcId: 'resolution-envelope',
|
||||
method: 'question/resolved',
|
||||
payload: {
|
||||
type: 'question/resolved',
|
||||
sessionId: 'session-one',
|
||||
questionRpcId: 'question-rpc',
|
||||
outcome: 'answered',
|
||||
},
|
||||
});
|
||||
socket.frame({
|
||||
type: 'server-request',
|
||||
rpcId: 'approval-resolution-envelope',
|
||||
method: 'approval/resolved',
|
||||
payload: {
|
||||
type: 'approval/resolved',
|
||||
sessionId: 'session-one',
|
||||
approvalId: 'approval-one',
|
||||
outcome: 'rejected',
|
||||
},
|
||||
});
|
||||
|
||||
await eventually(() => interactions.length === 2 && resolutions.length === 2);
|
||||
|
||||
assert.equal(socketUrl, 'ws://127.0.0.1:3080/api/events.mux');
|
||||
assert.equal(interactions.length, 2);
|
||||
assert.deepEqual({
|
||||
kind: interactions[0].kind,
|
||||
interactionId: interactions[0].interactionId,
|
||||
rpcId: interactions[0].rpcId,
|
||||
sessionId: interactions[0].sessionId,
|
||||
}, {
|
||||
kind: 'approval',
|
||||
interactionId: 'approval-one',
|
||||
rpcId: 'approval-rpc',
|
||||
sessionId: 'session-one',
|
||||
});
|
||||
assert.deepEqual({
|
||||
kind: interactions[1].kind,
|
||||
interactionId: interactions[1].interactionId,
|
||||
rpcId: interactions[1].rpcId,
|
||||
sessionId: interactions[1].sessionId,
|
||||
}, {
|
||||
kind: 'question',
|
||||
interactionId: 'question-rpc',
|
||||
rpcId: 'question-rpc',
|
||||
sessionId: 'session-one',
|
||||
});
|
||||
assert.deepEqual(resolutions.map(({ kind, interactionId, outcome }) => ({
|
||||
kind,
|
||||
interactionId,
|
||||
outcome,
|
||||
})), [
|
||||
{
|
||||
kind: 'question',
|
||||
interactionId: 'question-rpc',
|
||||
outcome: 'answered',
|
||||
},
|
||||
{
|
||||
kind: 'approval',
|
||||
interactionId: 'approval-one',
|
||||
outcome: 'rejected',
|
||||
},
|
||||
]);
|
||||
assert.equal(requests.length, 0, 'receiving an approval must not approve it automatically');
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
value: {
|
||||
sessionId: 'session-one',
|
||||
answer: { answers: [{ id: 'language', selected: [], custom: 'Chinese' }] },
|
||||
},
|
||||
};
|
||||
assert.deepEqual(await interactions[1].respond(result), { accepted: true });
|
||||
assert.deepEqual(requests, [{
|
||||
url: 'http://127.0.0.1:3080/api/respond',
|
||||
method: 'POST',
|
||||
body: { type: 'client-response', rpcId: 'question-rpc', result },
|
||||
}]);
|
||||
|
||||
controller.abort();
|
||||
await watching;
|
||||
assert.equal(socket.readyState, 3);
|
||||
});
|
||||
|
||||
test('interaction watcher reconnects over wss and exposes replayed stable request ids', async () => {
|
||||
const sockets = [];
|
||||
const seen = [];
|
||||
const controller = new AbortController();
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'https://harness.example/nested',
|
||||
workspace: '/tmp/workspace',
|
||||
interactionReconnectDelayMs: 0,
|
||||
createWebSocket: (url) => {
|
||||
const socket = new FakeSocket();
|
||||
sockets.push({ socket, url });
|
||||
queueMicrotask(() => socket.open());
|
||||
return socket;
|
||||
},
|
||||
});
|
||||
const watching = client.watchInteractions('session-replay', {
|
||||
signal: controller.signal,
|
||||
onInteraction: ({ rpcId }) => seen.push(rpcId),
|
||||
});
|
||||
await eventually(() => sockets[0]?.socket.readyState === 1);
|
||||
const frame = {
|
||||
type: 'server-request',
|
||||
rpcId: 'stable-question-rpc',
|
||||
method: 'question/requested',
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId: 'session-replay',
|
||||
questions: [{ id: 'replay', question: 'Still pending?' }],
|
||||
},
|
||||
};
|
||||
sockets[0].socket.frame(frame);
|
||||
sockets[0].socket.close(1006);
|
||||
await eventually(() => sockets[1]?.socket.readyState === 1, 'watcher did not reconnect');
|
||||
sockets[1].socket.frame(frame);
|
||||
|
||||
await eventually(() => seen.length === 2);
|
||||
|
||||
assert.deepEqual(seen, ['stable-question-rpc', 'stable-question-rpc']);
|
||||
assert.deepEqual(sockets.map(({ url }) => url), [
|
||||
'wss://harness.example/api/events.mux',
|
||||
'wss://harness.example/api/events.mux',
|
||||
]);
|
||||
controller.abort();
|
||||
await watching;
|
||||
});
|
||||
|
||||
test('interaction response receipts distinguish protocol rejection from invalid transport data', async () => {
|
||||
let receipt = { accepted: false, reason: 'not-pending' };
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/workspace',
|
||||
fetchImpl: async () => ({ ok: true, json: async () => receipt }),
|
||||
});
|
||||
const result = { ok: true, value: { sessionId: 'session-one' } };
|
||||
|
||||
await assert.rejects(
|
||||
client.respondInteraction('question-rpc', result),
|
||||
(error) => error instanceof HarnessInteractionError
|
||||
&& error.code === 'interaction-not-pending',
|
||||
);
|
||||
receipt = { accepted: false, reason: 'bad-response' };
|
||||
await assert.rejects(
|
||||
client.respondInteraction('question-rpc', result),
|
||||
(error) => error instanceof HarnessInteractionError
|
||||
&& error.code === 'interaction-bad-response',
|
||||
);
|
||||
receipt = { type: 'server-response', accepted: false };
|
||||
await assert.rejects(
|
||||
client.respondInteraction('question-rpc', result),
|
||||
/invalid interaction response receipt/,
|
||||
);
|
||||
});
|
||||
|
||||
test('ask opens the interaction watcher before prompting and closes it with the turn', async () => {
|
||||
let socket;
|
||||
let promptRpcId;
|
||||
let historyCalls = 0;
|
||||
const responses = [];
|
||||
const interactions = [];
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/workspace',
|
||||
fetchImpl: async (_url, options) => {
|
||||
responses.push(JSON.parse(options.body));
|
||||
return { ok: true, json: async () => ({ accepted: true }) };
|
||||
},
|
||||
createWebSocket: () => {
|
||||
const createdSocket = new FakeSocket();
|
||||
socket = createdSocket;
|
||||
queueMicrotask(() => createdSocket.open());
|
||||
return createdSocket;
|
||||
},
|
||||
});
|
||||
client.ensureRunning = async () => true;
|
||||
client.rpc = async (method, payload, _timeoutMs, options) => {
|
||||
if (method === 'session.history') {
|
||||
historyCalls += 1;
|
||||
if (historyCalls === 1) return { events: [] };
|
||||
return {
|
||||
events: [
|
||||
{ event: { seq: 1, type: 'turn/start', data: { turn: 1 } } },
|
||||
{ event: {
|
||||
seq: 2,
|
||||
type: 'user/message',
|
||||
data: { turn: 1, source: { rpcId: promptRpcId } },
|
||||
} },
|
||||
{ event: {
|
||||
seq: 3,
|
||||
type: 'assistant/message',
|
||||
data: {
|
||||
turn: 1,
|
||||
message: { content: [{ type: 'text', text: '已收到回答' }] },
|
||||
},
|
||||
} },
|
||||
{ event: { seq: 4, type: 'turn/end', data: { turn: 1, reason: 'completed' } } },
|
||||
],
|
||||
};
|
||||
}
|
||||
assert.equal(method, 'session.prompt');
|
||||
assert.equal(socket.readyState, 1, 'interaction watcher must open before session.prompt');
|
||||
assert.equal(payload.sessionId, 'session-one');
|
||||
promptRpcId = options.rpcId;
|
||||
socket.frame({
|
||||
type: 'server-request',
|
||||
rpcId: 'turn-start-frame',
|
||||
method: 'session/event',
|
||||
payload: {
|
||||
type: 'session/event',
|
||||
sessionId: 'session-one',
|
||||
event: { seq: 1, type: 'turn/start', data: { turn: 1 } },
|
||||
},
|
||||
});
|
||||
socket.frame({
|
||||
type: 'server-request',
|
||||
rpcId: 'user-message-frame',
|
||||
method: 'session/event',
|
||||
payload: {
|
||||
type: 'session/event',
|
||||
sessionId: 'session-one',
|
||||
event: {
|
||||
seq: 2,
|
||||
type: 'user/message',
|
||||
data: { turn: 1, source: { rpcId: promptRpcId } },
|
||||
},
|
||||
},
|
||||
});
|
||||
socket.frame({
|
||||
type: 'server-request',
|
||||
rpcId: 'question-during-ask',
|
||||
method: 'question/requested',
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId: 'session-one',
|
||||
questions: [{
|
||||
id: 'ready',
|
||||
question: 'Ready?',
|
||||
options: [{ label: 'Yes' }, { label: 'No' }],
|
||||
}],
|
||||
},
|
||||
});
|
||||
return {};
|
||||
};
|
||||
|
||||
const answer = await client.ask('session-one', '请先提问', {
|
||||
onInteraction: async (interaction) => {
|
||||
interactions.push(interaction.rpcId);
|
||||
await interaction.respond({
|
||||
ok: true,
|
||||
value: {
|
||||
sessionId: interaction.sessionId,
|
||||
answer: { answers: [{ id: 'ready', selected: ['Yes'] }] },
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(answer, '已收到回答');
|
||||
assert.deepEqual(interactions, ['question-during-ask']);
|
||||
assert.deepEqual(responses, [{
|
||||
type: 'client-response',
|
||||
rpcId: 'question-during-ask',
|
||||
result: {
|
||||
ok: true,
|
||||
value: {
|
||||
sessionId: 'session-one',
|
||||
answer: { answers: [{ id: 'ready', selected: ['Yes'] }] },
|
||||
},
|
||||
},
|
||||
}]);
|
||||
assert.equal(socket.readyState, 3);
|
||||
});
|
||||
|
||||
test('interaction callbacks preserve frame order and watcher shutdown drains them', async () => {
|
||||
const opened = deferred();
|
||||
const releaseRequested = deferred();
|
||||
const order = [];
|
||||
let socket;
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/workspace',
|
||||
createWebSocket: () => {
|
||||
socket = new FakeSocket();
|
||||
queueMicrotask(() => socket.open());
|
||||
return socket;
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const watching = client.watchInteractions('session-ordered', {
|
||||
signal: controller.signal,
|
||||
onOpen: opened.resolve,
|
||||
onInteraction: async () => {
|
||||
order.push('requested:start');
|
||||
await releaseRequested.promise;
|
||||
order.push('requested:end');
|
||||
},
|
||||
onResolved: () => order.push('resolved'),
|
||||
});
|
||||
await opened.promise;
|
||||
|
||||
socket.frame({
|
||||
type: 'server-request',
|
||||
rpcId: 'ordered-question',
|
||||
method: 'question/requested',
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId: 'session-ordered',
|
||||
questions: [{ id: 'ordered', question: 'Wait for delivery' }],
|
||||
},
|
||||
});
|
||||
socket.frame({
|
||||
type: 'server-request',
|
||||
rpcId: 'ordered-resolution',
|
||||
method: 'question/resolved',
|
||||
payload: {
|
||||
type: 'question/resolved',
|
||||
sessionId: 'session-ordered',
|
||||
questionRpcId: 'ordered-question',
|
||||
outcome: 'cancelled',
|
||||
},
|
||||
});
|
||||
controller.abort();
|
||||
|
||||
await eventually(() => order.length > 0);
|
||||
assert.deepEqual(order, ['requested:start']);
|
||||
let settled = false;
|
||||
void watching.finally(() => { settled = true; });
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.equal(settled, false);
|
||||
|
||||
releaseRequested.resolve();
|
||||
await watching;
|
||||
assert.deepEqual(order, ['requested:start', 'requested:end', 'resolved']);
|
||||
});
|
||||
|
||||
test('concurrent asks on one Harness session receive interactions only for their own active turn', async () => {
|
||||
const sockets = [];
|
||||
const prompts = [];
|
||||
const received = { first: [], second: [] };
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/workspace',
|
||||
createWebSocket: () => {
|
||||
const socket = new FakeSocket();
|
||||
sockets.push(socket);
|
||||
queueMicrotask(() => socket.open());
|
||||
return socket;
|
||||
},
|
||||
});
|
||||
client.ensureRunning = async () => true;
|
||||
client.rpc = async (method, payload, _timeoutMs, options) => {
|
||||
if (method === 'session.history') return { events: [] };
|
||||
assert.equal(method, 'session.prompt');
|
||||
prompts.push({ text: payload.content[0].text, rpcId: options.rpcId });
|
||||
return {};
|
||||
};
|
||||
const firstController = new AbortController();
|
||||
const secondController = new AbortController();
|
||||
const first = client.ask('shared-session', 'first', {
|
||||
signal: firstController.signal,
|
||||
onInteraction: ({ rpcId }) => received.first.push(rpcId),
|
||||
});
|
||||
const second = client.ask('shared-session', 'second', {
|
||||
signal: secondController.signal,
|
||||
onInteraction: ({ rpcId }) => received.second.push(rpcId),
|
||||
});
|
||||
await eventually(() => prompts.length === 2 && sockets.length === 2);
|
||||
|
||||
const active = prompts[0];
|
||||
const frames = [
|
||||
{
|
||||
type: 'server-request',
|
||||
rpcId: 'shared-turn-start',
|
||||
method: 'session/event',
|
||||
payload: {
|
||||
type: 'session/event',
|
||||
sessionId: 'shared-session',
|
||||
event: { seq: 1, type: 'turn/start', data: { turn: 1 } },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'server-request',
|
||||
rpcId: 'shared-user-message',
|
||||
method: 'session/event',
|
||||
payload: {
|
||||
type: 'session/event',
|
||||
sessionId: 'shared-session',
|
||||
event: {
|
||||
seq: 2,
|
||||
type: 'user/message',
|
||||
data: { source: { rpcId: active.rpcId } },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'server-request',
|
||||
rpcId: 'shared-plugin-context',
|
||||
method: 'session/event',
|
||||
payload: {
|
||||
type: 'session/event',
|
||||
sessionId: 'shared-session',
|
||||
event: {
|
||||
seq: 3,
|
||||
type: 'user/message',
|
||||
data: { source: { kind: 'plugin', pluginId: 'time-context' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'server-request',
|
||||
rpcId: 'shared-question',
|
||||
method: 'question/requested',
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId: 'shared-session',
|
||||
questions: [{ id: 'owner', question: 'Who owns this turn?' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const frame of frames) {
|
||||
for (const socket of sockets) socket.frame(frame);
|
||||
}
|
||||
await eventually(() => received.first.length + received.second.length === 1);
|
||||
|
||||
assert.deepEqual(received[active.text], ['shared-question']);
|
||||
assert.deepEqual(received[active.text === 'first' ? 'second' : 'first'], []);
|
||||
firstController.abort();
|
||||
secondController.abort();
|
||||
await Promise.allSettled([first, second]);
|
||||
});
|
||||
|
||||
test('channel-specific clients share interaction ownership for the same Harness origin', async () => {
|
||||
const sockets = [];
|
||||
const prompts = [];
|
||||
const received = { dingtalk: [], feishu: [] };
|
||||
const makeClient = (Client, channel) => {
|
||||
const client = new Client({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/workspace',
|
||||
createWebSocket: () => {
|
||||
const socket = new FakeSocket();
|
||||
sockets.push({ channel, socket });
|
||||
queueMicrotask(() => socket.open());
|
||||
return socket;
|
||||
},
|
||||
});
|
||||
client.ensureRunning = async () => true;
|
||||
client.rpc = async (method, payload, _timeoutMs, options) => {
|
||||
if (method === 'session.history') return { events: [] };
|
||||
assert.equal(method, 'session.prompt');
|
||||
prompts.push({ channel, rpcId: options.rpcId, text: payload.content[0].text });
|
||||
return {};
|
||||
};
|
||||
return client;
|
||||
};
|
||||
const dingtalk = makeClient(HarnessClient, 'dingtalk');
|
||||
const feishu = makeClient(FeishuHarnessClient, 'feishu');
|
||||
const dingtalkController = new AbortController();
|
||||
const feishuController = new AbortController();
|
||||
const first = dingtalk.ask('cross-channel-session', 'dingtalk prompt', {
|
||||
signal: dingtalkController.signal,
|
||||
onInteraction: ({ rpcId }) => received.dingtalk.push(rpcId),
|
||||
});
|
||||
await eventually(() => prompts.length === 1);
|
||||
const second = feishu.ask('cross-channel-session', 'feishu prompt', {
|
||||
signal: feishuController.signal,
|
||||
onInteraction: ({ rpcId }) => received.feishu.push(rpcId),
|
||||
});
|
||||
await eventually(() => prompts.length === 2 && sockets.length === 2);
|
||||
|
||||
const activePrompt = prompts[0];
|
||||
const frames = [
|
||||
{
|
||||
type: 'server-request', rpcId: 'cross-start', method: 'session/event',
|
||||
payload: {
|
||||
type: 'session/event', sessionId: 'cross-channel-session',
|
||||
event: { seq: 1, type: 'turn/start', data: { turn: 1 } },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'server-request', rpcId: 'cross-user', method: 'session/event',
|
||||
payload: {
|
||||
type: 'session/event', sessionId: 'cross-channel-session',
|
||||
event: {
|
||||
seq: 2,
|
||||
type: 'user/message',
|
||||
data: { source: { rpcId: activePrompt.rpcId } },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'server-request', rpcId: 'cross-question', method: 'question/requested',
|
||||
payload: {
|
||||
type: 'question/requested', sessionId: 'cross-channel-session',
|
||||
questions: [{ id: 'owner', question: 'Which channel owns this turn?' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const frame of frames) {
|
||||
for (const { socket } of sockets) socket.frame(frame);
|
||||
}
|
||||
await eventually(() => received.dingtalk.length + received.feishu.length === 1);
|
||||
|
||||
assert.deepEqual(received, { dingtalk: ['cross-question'], feishu: [] });
|
||||
dingtalkController.abort();
|
||||
feishuController.abort();
|
||||
await Promise.allSettled([first, second]);
|
||||
});
|
||||
|
||||
test('a reconnect revalidates same-session ownership before accepting replayed questions', async () => {
|
||||
const sockets = [];
|
||||
const prompts = [];
|
||||
const received = { first: [], second: [] };
|
||||
let historyEvents = [];
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/workspace',
|
||||
interactionReconnectDelayMs: 0,
|
||||
createWebSocket: () => {
|
||||
const socket = new FakeSocket();
|
||||
sockets.push(socket);
|
||||
queueMicrotask(() => socket.open());
|
||||
return socket;
|
||||
},
|
||||
});
|
||||
client.ensureRunning = async () => true;
|
||||
client.rpc = async (method, payload, _timeoutMs, options) => {
|
||||
if (method === 'session.history') return { events: historyEvents };
|
||||
assert.equal(method, 'session.prompt');
|
||||
prompts.push({ text: payload.content[0].text, rpcId: options.rpcId });
|
||||
return {};
|
||||
};
|
||||
const firstController = new AbortController();
|
||||
const secondController = new AbortController();
|
||||
const first = client.ask('handoff-session', 'first', {
|
||||
signal: firstController.signal,
|
||||
onInteraction: ({ rpcId }) => received.first.push(rpcId),
|
||||
});
|
||||
await eventually(() => prompts.length === 1 && sockets.length === 1);
|
||||
const second = client.ask('handoff-session', 'second', {
|
||||
signal: secondController.signal,
|
||||
onInteraction: ({ rpcId }) => received.second.push(rpcId),
|
||||
});
|
||||
await eventually(() => prompts.length === 2 && sockets.length === 2);
|
||||
|
||||
const firstPrompt = prompts.find(({ text }) => text === 'first');
|
||||
const secondPrompt = prompts.find(({ text }) => text === 'second');
|
||||
const firstTurnFrames = [
|
||||
{
|
||||
type: 'server-request', rpcId: 'handoff-start-one', method: 'session/event',
|
||||
payload: {
|
||||
type: 'session/event', sessionId: 'handoff-session',
|
||||
event: { seq: 1, type: 'turn/start', data: { turn: 1 } },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'server-request', rpcId: 'handoff-user-one', method: 'session/event',
|
||||
payload: {
|
||||
type: 'session/event', sessionId: 'handoff-session',
|
||||
event: {
|
||||
seq: 2,
|
||||
type: 'user/message',
|
||||
data: { source: { rpcId: firstPrompt.rpcId } },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'server-request', rpcId: 'handoff-plugin-one', method: 'session/event',
|
||||
payload: {
|
||||
type: 'session/event', sessionId: 'handoff-session',
|
||||
event: {
|
||||
seq: 3,
|
||||
type: 'user/message',
|
||||
data: { source: { kind: 'plugin', pluginId: 'time-context' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const frame of firstTurnFrames) {
|
||||
historyEvents.push({ event: frame.payload.event });
|
||||
for (const socket of sockets.slice(0, 2)) socket.frame(frame);
|
||||
}
|
||||
const firstQuestion = {
|
||||
type: 'server-request', rpcId: 'handoff-question-one', method: 'question/requested',
|
||||
payload: {
|
||||
type: 'question/requested', sessionId: 'handoff-session',
|
||||
questions: [{ id: 'one', question: 'First turn question' }],
|
||||
},
|
||||
};
|
||||
for (const socket of sockets.slice(0, 2)) socket.frame(firstQuestion);
|
||||
await eventually(() => received.first.length === 1);
|
||||
|
||||
sockets[0].close(1006);
|
||||
const secondTurnEvents = [
|
||||
{ seq: 4, type: 'turn/end', data: { turn: 1, reason: 'completed' } },
|
||||
{ seq: 5, type: 'turn/start', data: { turn: 2 } },
|
||||
{
|
||||
seq: 6,
|
||||
type: 'user/message',
|
||||
data: { source: { rpcId: secondPrompt.rpcId } },
|
||||
},
|
||||
];
|
||||
historyEvents.push(...secondTurnEvents.map((event) => ({ event })));
|
||||
for (const event of secondTurnEvents) {
|
||||
sockets[1].frame({
|
||||
type: 'server-request',
|
||||
rpcId: `handoff-event-${event.seq}`,
|
||||
method: 'session/event',
|
||||
payload: { type: 'session/event', sessionId: 'handoff-session', event },
|
||||
});
|
||||
}
|
||||
const secondQuestion = {
|
||||
type: 'server-request', rpcId: 'handoff-question-two', method: 'question/requested',
|
||||
payload: {
|
||||
type: 'question/requested', sessionId: 'handoff-session',
|
||||
questions: [{ id: 'two', question: 'Second turn question' }],
|
||||
},
|
||||
};
|
||||
sockets[1].frame(secondQuestion);
|
||||
await eventually(() => received.second.length === 1);
|
||||
await eventually(() => sockets.length >= 3 && sockets[2].readyState === 1);
|
||||
sockets[2].frame(secondQuestion);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
assert.deepEqual(received, {
|
||||
first: ['handoff-question-one'],
|
||||
second: ['handoff-question-two'],
|
||||
});
|
||||
firstController.abort();
|
||||
secondController.abort();
|
||||
await Promise.allSettled([first, second]);
|
||||
});
|
||||
|
||||
test('a new ask adopts a replayed orphan question before its queued prompt can run', async () => {
|
||||
const oldHistory = [
|
||||
{ event: { seq: 1, type: 'turn/start', data: { turn: 1 } } },
|
||||
{
|
||||
event: {
|
||||
seq: 2,
|
||||
type: 'user/message',
|
||||
data: { turn: 1, source: { rpcId: 'prompt-from-old-runtime' } },
|
||||
},
|
||||
},
|
||||
];
|
||||
const received = [];
|
||||
const prompted = deferred();
|
||||
let socket;
|
||||
const controller = new AbortController();
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/workspace',
|
||||
createWebSocket: () => {
|
||||
socket = new FakeSocket();
|
||||
queueMicrotask(() => {
|
||||
socket.open();
|
||||
socket.frame({
|
||||
type: 'server-request',
|
||||
rpcId: 'orphan-question',
|
||||
method: 'question/requested',
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId: 'orphan-session',
|
||||
questions: [{ id: 'orphan', question: 'Recovered question' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
return socket;
|
||||
},
|
||||
});
|
||||
client.ensureRunning = async () => true;
|
||||
client.rpc = async (method) => {
|
||||
if (method === 'session.history') return { events: oldHistory };
|
||||
assert.equal(method, 'session.prompt');
|
||||
prompted.resolve();
|
||||
return {};
|
||||
};
|
||||
|
||||
const asking = client.ask('orphan-session', 'new queued prompt', {
|
||||
signal: controller.signal,
|
||||
onInteraction: ({ rpcId, recovered }) => received.push({ rpcId, recovered }),
|
||||
});
|
||||
await prompted.promise;
|
||||
await eventually(() => received.length === 1);
|
||||
assert.deepEqual(received, [{ rpcId: 'orphan-question', recovered: true }]);
|
||||
|
||||
controller.abort();
|
||||
await Promise.allSettled([asking]);
|
||||
});
|
||||
|
||||
test('an orphan approval remains fail-closed for the future approval handler', async () => {
|
||||
const oldHistory = [
|
||||
{ event: { seq: 1, type: 'turn/start', data: { turn: 1 } } },
|
||||
{
|
||||
event: {
|
||||
seq: 2,
|
||||
type: 'user/message',
|
||||
data: { source: { rpcId: 'approval-prompt-from-old-runtime' } },
|
||||
},
|
||||
},
|
||||
];
|
||||
const received = [];
|
||||
const prompted = deferred();
|
||||
const controller = new AbortController();
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/workspace',
|
||||
createWebSocket: () => {
|
||||
const socket = new FakeSocket();
|
||||
queueMicrotask(() => {
|
||||
socket.open();
|
||||
socket.frame({
|
||||
type: 'server-request',
|
||||
rpcId: 'orphan-approval-rpc',
|
||||
method: 'approval/requested',
|
||||
payload: {
|
||||
type: 'approval/requested',
|
||||
sessionId: 'orphan-approval-session',
|
||||
approvalId: 'orphan-approval-id',
|
||||
toolName: 'bash',
|
||||
},
|
||||
});
|
||||
});
|
||||
return socket;
|
||||
},
|
||||
});
|
||||
client.ensureRunning = async () => true;
|
||||
client.rpc = async (method) => {
|
||||
if (method === 'session.history') return { events: oldHistory };
|
||||
assert.equal(method, 'session.prompt');
|
||||
prompted.resolve();
|
||||
return {};
|
||||
};
|
||||
|
||||
const asking = client.ask('orphan-approval-session', 'new queued prompt', {
|
||||
signal: controller.signal,
|
||||
onInteraction: (interaction) => received.push(interaction),
|
||||
});
|
||||
await prompted.promise;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.deepEqual(received, []);
|
||||
|
||||
controller.abort();
|
||||
await Promise.allSettled([asking]);
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,14 @@ import assert from 'node:assert/strict';
|
|||
import test from 'node:test';
|
||||
import { FeishuRuntime } from '../../../src/channels/feishu/feishu-runtime.mjs';
|
||||
|
||||
class FakeClient {}
|
||||
class FakeClient {
|
||||
static instances = [];
|
||||
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
FakeClient.instances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDispatcher {
|
||||
register(handlers) {
|
||||
|
|
@ -40,24 +47,39 @@ class FakeWSClient {
|
|||
|
||||
function fakeLark() {
|
||||
FakeWSClient.instances.length = 0;
|
||||
FakeClient.instances.length = 0;
|
||||
return {
|
||||
Domain: { Feishu: 'feishu-domain', Lark: 'lark-domain' },
|
||||
LoggerLevel: { info: 'info' },
|
||||
Client: FakeClient,
|
||||
EventDispatcher: FakeDispatcher,
|
||||
WSClient: FakeWSClient,
|
||||
defaultHttpInstance: {
|
||||
request: async (options) => options,
|
||||
get: async (_url, options) => options,
|
||||
delete: async (_url, options) => options,
|
||||
head: async (_url, options) => options,
|
||||
options: async (_url, options) => options,
|
||||
post: async (_url, _data, options) => options,
|
||||
put: async (_url, _data, options) => options,
|
||||
patch: async (_url, _data, options) => options,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connected', async () => {
|
||||
let harnessChecks = 0;
|
||||
let harnessSignal;
|
||||
const runtime = new FeishuRuntime({
|
||||
lark: fakeLark(),
|
||||
appId: 'cli_test',
|
||||
appSecret: 'secret',
|
||||
ownerOpenId: 'ou_owner',
|
||||
harness: {
|
||||
async ensureRunning() { harnessChecks += 1; },
|
||||
async ensureRunning(options) {
|
||||
harnessChecks += 1;
|
||||
harnessSignal = options.signal;
|
||||
},
|
||||
},
|
||||
state: { hasSeen: () => false },
|
||||
});
|
||||
|
|
@ -77,11 +99,16 @@ test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connect
|
|||
assert.equal(status.ready, true);
|
||||
assert.equal(status.feishuLongConnectionState, 'connected');
|
||||
assert.equal(status.harnessReachable, true);
|
||||
assert.equal(harnessSignal.aborted, false);
|
||||
assert.equal((await FakeClient.instances[0].options.httpInstance.request({
|
||||
url: 'https://open.feishu.cn/test',
|
||||
})).timeout, 15_000);
|
||||
|
||||
const stopped = await runtime.stop();
|
||||
assert.equal(stopped.ready, false);
|
||||
assert.equal(stopped.feishuLongConnectionState, 'idle');
|
||||
assert.equal(FakeWSClient.instances[0].state, 'closed');
|
||||
assert.equal(harnessSignal.aborted, true);
|
||||
});
|
||||
|
||||
test('FeishuRuntime fails closed when the initial WebSocket handshake times out', async () => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,160 @@ import {
|
|||
HarnessReplyTracker,
|
||||
} from '../../../src/channels/feishu/harness-client.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function eventually(predicate, message = 'condition was not met') {
|
||||
const deadline = Date.now() + 1_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.fail(message);
|
||||
}
|
||||
|
||||
class FakeSocket {
|
||||
#listeners = new Map();
|
||||
readyState = 0;
|
||||
|
||||
addEventListener(name, listener) {
|
||||
const listeners = this.#listeners.get(name) ?? new Set();
|
||||
listeners.add(listener);
|
||||
this.#listeners.set(name, listeners);
|
||||
}
|
||||
|
||||
removeEventListener(name, listener) {
|
||||
this.#listeners.get(name)?.delete(listener);
|
||||
}
|
||||
|
||||
open() {
|
||||
if (this.readyState !== 0) return;
|
||||
this.readyState = 1;
|
||||
this.#emit('open', {});
|
||||
}
|
||||
|
||||
frame(value) {
|
||||
this.#emit('message', { data: JSON.stringify(value) });
|
||||
}
|
||||
|
||||
close(code = 1000) {
|
||||
if (this.readyState >= 2) return;
|
||||
this.readyState = 3;
|
||||
this.#emit('close', { code });
|
||||
}
|
||||
|
||||
#emit(name, event) {
|
||||
for (const listener of [...(this.#listeners.get(name) ?? [])]) listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
test('interaction watcher uses the real Harness wire protocol and leaves approvals fail-closed', async () => {
|
||||
const requests = [];
|
||||
const opened = deferred();
|
||||
let socket;
|
||||
let socketUrl;
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080/base',
|
||||
workspace: '/tmp/dsh-feishu-workspace',
|
||||
fetchImpl: async (url, options) => {
|
||||
requests.push({
|
||||
url: url.toString(),
|
||||
method: options.method,
|
||||
body: JSON.parse(options.body),
|
||||
});
|
||||
return { ok: true, json: async () => ({ accepted: true }) };
|
||||
},
|
||||
createWebSocket: (url) => {
|
||||
socketUrl = url;
|
||||
socket = new FakeSocket();
|
||||
queueMicrotask(() => socket.open());
|
||||
return socket;
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const interactions = [];
|
||||
const watching = client.watchInteractions('session-feishu', {
|
||||
signal: controller.signal,
|
||||
onOpen: opened.resolve,
|
||||
onInteraction: (interaction) => interactions.push(interaction),
|
||||
});
|
||||
await opened.promise;
|
||||
|
||||
socket.frame({
|
||||
type: 'server-request',
|
||||
rpcId: 'approval-rpc',
|
||||
method: 'approval/requested',
|
||||
payload: {
|
||||
type: 'approval/requested',
|
||||
sessionId: 'session-feishu',
|
||||
approvalId: 'approval-one',
|
||||
toolName: 'bash',
|
||||
},
|
||||
});
|
||||
socket.frame({
|
||||
type: 'server-request',
|
||||
rpcId: 'question-rpc',
|
||||
method: 'question/requested',
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId: 'session-feishu',
|
||||
questions: [{
|
||||
id: 'environment',
|
||||
question: '请选择测试环境',
|
||||
options: [{ label: '测试环境' }, { label: '生产环境' }],
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
await eventually(() => interactions.length === 2);
|
||||
assert.equal(socketUrl, 'ws://127.0.0.1:3080/api/events.mux');
|
||||
assert.deepEqual(interactions.map((interaction) => ({
|
||||
kind: interaction.kind,
|
||||
interactionId: interaction.interactionId,
|
||||
rpcId: interaction.rpcId,
|
||||
sessionId: interaction.sessionId,
|
||||
})), [
|
||||
{
|
||||
kind: 'approval',
|
||||
interactionId: 'approval-one',
|
||||
rpcId: 'approval-rpc',
|
||||
sessionId: 'session-feishu',
|
||||
},
|
||||
{
|
||||
kind: 'question',
|
||||
interactionId: 'question-rpc',
|
||||
rpcId: 'question-rpc',
|
||||
sessionId: 'session-feishu',
|
||||
},
|
||||
]);
|
||||
assert.equal(requests.length, 0, 'receiving an approval must never approve it automatically');
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
value: {
|
||||
sessionId: 'session-feishu',
|
||||
answer: { answers: [{ id: 'environment', selected: ['测试环境'] }] },
|
||||
},
|
||||
};
|
||||
assert.deepEqual(await interactions[1].respond(result), { accepted: true });
|
||||
assert.deepEqual(requests, [{
|
||||
url: 'http://127.0.0.1:3080/api/respond',
|
||||
method: 'POST',
|
||||
body: { type: 'client-response', rpcId: 'question-rpc', result },
|
||||
}]);
|
||||
|
||||
controller.abort();
|
||||
await watching;
|
||||
assert.equal(socket.readyState, 3);
|
||||
});
|
||||
|
||||
test('HarnessClient lists only absolute workspace paths', async () => {
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue