fix: support Harness interactions in DingTalk and Feishu

This commit is contained in:
xmanrui 2026-08-18 13:03:15 +08:00
parent c9ec84cc67
commit 3e7e1dcdd7
13 changed files with 5061 additions and 881 deletions

View file

@ -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, ['甲发起交互', '乙试图代答']);
});

View file

@ -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

View file

@ -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 () => {

View file

@ -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',