fix: support Harness approvals across IM channels

This commit is contained in:
xmanrui 2026-08-18 16:45:25 +08:00
parent ce8b9e80e7
commit 66bdad6299
17 changed files with 2970 additions and 164 deletions

View file

@ -427,10 +427,113 @@ test('pending questions are isolated by DingTalk conversation', async () => {
await firstA;
});
test('question replays are deduplicated and approval interactions are never auto-approved', async () => {
test('DingTalk handles approval replies on the fast lane and presents approvals in FIFO order', async () => {
const fixture = stateFixture();
const sent = [];
let approvalResponses = 0;
const asked = [];
const decisions = [];
const decided = 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-approval',
ask: async (sessionId, text, options) => {
asked.push({ sessionId, text });
const approval = (approvalId, toolName, reason) => ({
kind: 'approval',
interactionId: approvalId,
rpcId: `rpc-${approvalId}`,
sessionId,
payload: {
type: 'approval/requested',
sessionId,
approvalId,
toolName,
callId: `call-${approvalId}`,
reason,
},
toolCall: {
callId: `call-${approvalId}`,
name: toolName,
arguments: JSON.stringify({ operation: reason }),
},
respond: async (result) => {
decisions.push(result);
if (decisions.length === 2) decided.resolve();
return { accepted: true };
},
});
await options.onInteraction(approval(
'approval-build',
'bash',
'运行第一项构建操作',
));
await options.onInteraction(approval(
'approval-write',
'write_file',
'运行第二项写入操作',
));
await decided.promise;
return '两个审批均已处理';
},
},
state: fixture.state,
});
const turn = bridge.accept(message('approval-start', '发起两个审批'));
await eventually(() => sent.some(({ text }) => text.includes('运行第一项构建操作')));
assert.equal(sent.some(({ text }) => text.includes('运行第二项写入操作')), false);
assert.equal(sent.some(({ text }) => text.includes('approval-build')), false);
await bridge.accept(message('approval-invalid', '好的'));
assert.deepEqual(decisions, []);
assert.deepEqual(asked, [{ sessionId: 'session-approval', text: '发起两个审批' }]);
assert.match(sent.at(-1).text, /批准/);
assert.match(sent.at(-1).text, /拒绝/);
await bridge.accept(message('approval-allow', '批准'));
assert.deepEqual(decisions, [{
ok: true,
value: {
sessionId: 'session-approval',
approvalId: 'approval-build',
outcome: 'allowed-once',
},
}]);
assert.equal(sent.filter(({ text }) => text.includes('运行第二项写入操作')).length, 1);
assert.equal(sent.some(({ text }) => text.includes('approval-write')), false);
await bridge.accept(message('approval-reject', '拒绝'));
await turn;
assert.deepEqual(decisions, [
{
ok: true,
value: {
sessionId: 'session-approval',
approvalId: 'approval-build',
outcome: 'allowed-once',
},
},
{
ok: true,
value: {
sessionId: 'session-approval',
approvalId: 'approval-write',
outcome: 'rejected',
},
},
]);
assert.equal(sent.at(-1).text, '两个审批均已处理');
});
test('question replays are deduplicated and an unrenderable approval is safely rejected', async () => {
const fixture = stateFixture();
const sent = [];
let approvalResponse;
let secondQuestionResponse;
const bridge = new DingtalkHarnessBridge({
api: { sendText: async (request) => sent.push(request) },
@ -478,7 +581,10 @@ test('question replays are deduplicated and approval interactions are never auto
approvalId: 'approval-one',
toolName: 'bash',
},
respond: async () => { approvalResponses += 1; },
respond: async (result) => {
approvalResponse = result;
return { accepted: true };
},
});
await options.onInteractionResolved({
kind: 'question',
@ -504,8 +610,15 @@ test('question replays are deduplicated and approval interactions are never auto
details: {},
},
});
assert.equal(sent.some(({ text }) => text.includes('approval')), false);
assert.equal(approvalResponses, 0);
assert.deepEqual(approvalResponse, {
ok: true,
value: {
sessionId: 'session-replay',
approvalId: 'approval-one',
outcome: 'rejected',
},
});
assert.equal(sent.some(({ text }) => text.includes('无法完整展示')), true);
assert.equal(sent.at(-1).text, '交互已取消');
});

View file

@ -797,6 +797,256 @@ test('ask opens the interaction watcher before prompting and closes it with the
assert.equal(socket.readyState, 3);
});
test('approval interactions expose only matching tool calls from the active turn', async () => {
let socket;
let promptRpcId;
let historyCalls = 0;
const interactions = [];
const currentToolCall = {
callId: 'call-current-0',
name: 'bash',
arguments: JSON.stringify({ cmd: 'pwd-0' }),
};
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/workspace',
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;
// Baseline and mux-open refresh both precede this new prompt. The mux
// session/event frames below establish ownership before approvals arrive.
if (historyCalls <= 2) return { events: [] };
return {
events: [
{ event: { seq: 1, type: 'turn/start', data: { turn: 7 } } },
{ event: {
seq: 2,
type: 'user/message',
data: { turn: 7, source: { rpcId: promptRpcId } },
} },
{ event: {
seq: 3,
type: 'assistant/message',
data: {
turn: 7,
message: { content: [{ type: 'text', text: '审批上下文已捕获' }] },
},
} },
{ event: { seq: 4, type: 'turn/end', data: { turn: 7, reason: 'completed' } } },
],
};
}
assert.equal(method, 'session.prompt');
assert.equal(socket.readyState, 1);
assert.equal(payload.sessionId, 'session-tool-call');
promptRpcId = options.rpcId;
const emitEvent = (rpcId, event) => socket.frame({
type: 'server-request',
rpcId,
method: 'session/event',
payload: { type: 'session/event', sessionId: 'session-tool-call', event },
});
const emitApproval = (rpcId, approvalId, callId) => socket.frame({
type: 'server-request',
rpcId,
method: 'approval/requested',
payload: {
type: 'approval/requested',
sessionId: 'session-tool-call',
approvalId,
toolName: 'bash',
callId,
reason: '测试工具调用展示',
},
});
emitEvent('turn-start-frame', {
seq: 1,
type: 'turn/start',
data: { turn: 7 },
});
emitEvent('user-message-frame', {
seq: 2,
type: 'user/message',
data: { turn: 7, source: { rpcId: promptRpcId } },
});
emitEvent('other-turn-tool-frame', {
seq: 3,
type: 'tool/call',
data: {
turn: 6,
step: 1,
callId: 'call-other-turn',
name: 'bash',
arguments: JSON.stringify({ cmd: 'whoami' }),
},
});
for (let index = 0; index < 40; index += 1) {
emitEvent(`current-tool-frame-${index}`, {
seq: 4 + index,
type: 'tool/call',
data: {
turn: 7,
step: 1,
callId: `call-current-${index}`,
name: 'bash',
arguments: JSON.stringify({ cmd: `pwd-${index}` }),
},
});
}
emitEvent('code-dispatch-tool-frame', {
seq: 44,
type: 'tool/code-dispatch-start',
data: {
rootCallId: 'run-code-root',
parentCallId: 'run-code-root',
subCallId: 'call-code-1',
name: 'bash',
arguments: { cmd: 'echo code-mode' },
},
});
emitApproval('matching-approval-rpc', 'matching-approval', 'call-current-0');
emitApproval('code-mode-approval-rpc', 'code-mode-approval', 'call-code-1');
emitApproval('missing-approval-rpc', 'missing-approval', 'call-missing');
emitApproval('other-turn-approval-rpc', 'other-turn-approval', 'call-other-turn');
return {};
};
const answer = await client.ask('session-tool-call', '请测试审批上下文', {
onInteraction: (interaction) => interactions.push(interaction),
});
assert.equal(answer, '审批上下文已捕获');
assert.deepEqual(interactions.map((interaction) => ({
approvalId: interaction.interactionId,
hasToolCall: Object.hasOwn(interaction, 'toolCall'),
toolCall: interaction.toolCall,
})), [
{
approvalId: 'matching-approval',
hasToolCall: true,
toolCall: currentToolCall,
},
{
approvalId: 'code-mode-approval',
hasToolCall: true,
toolCall: {
callId: 'call-code-1',
name: 'bash',
arguments: JSON.stringify({ cmd: 'echo code-mode' }),
},
},
{
approvalId: 'missing-approval',
hasToolCall: false,
toolCall: undefined,
},
{
approvalId: 'other-turn-approval',
hasToolCall: false,
toolCall: undefined,
},
]);
assert.equal(socket.readyState, 3);
});
test('reconnect history restores a Code Mode sub-call before replaying its approval', async () => {
const sockets = [];
const controller = new AbortController();
let historyEvents = [];
let received;
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/workspace',
interactionReconnectDelayMs: 0,
createWebSocket: () => {
const socket = new FakeSocket();
const index = sockets.push(socket) - 1;
queueMicrotask(() => {
socket.open();
if (index === 1) {
socket.frame({
type: 'server-request',
rpcId: 'replayed-code-approval-rpc',
method: 'approval/requested',
payload: {
type: 'approval/requested',
sessionId: 'code-reconnect-session',
approvalId: 'replayed-code-approval',
toolName: 'bash',
callId: 'root-call:code:1',
},
});
}
});
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');
const events = [
{ seq: 1, type: 'turn/start', data: { turn: 1 } },
{
seq: 2,
type: 'user/message',
data: { turn: 1, source: { rpcId: options.rpcId } },
},
{
seq: 3,
type: 'tool/code-dispatch-start',
data: {
rootCallId: 'root-call',
parentCallId: 'root-call',
subCallId: 'root-call:code:1',
name: 'bash',
arguments: { command: 'echo restored' },
},
},
];
historyEvents = events.map((event) => ({ event }));
sockets[0].frame({
type: 'server-request',
rpcId: 'code-reconnect-start',
method: 'session/event',
payload: { type: 'session/event', sessionId: 'code-reconnect-session', event: events[0] },
});
sockets[0].frame({
type: 'server-request',
rpcId: 'code-reconnect-user',
method: 'session/event',
payload: { type: 'session/event', sessionId: 'code-reconnect-session', event: events[1] },
});
sockets[0].close(1006);
return {};
};
const asking = client.ask('code-reconnect-session', '测试 Code Mode 重连', {
signal: controller.signal,
onInteraction: (interaction) => { received = interaction; },
});
await eventually(() => received !== undefined);
assert.equal(received.recovered, false);
assert.deepEqual(received.toolCall, {
callId: 'root-call:code:1',
name: 'bash',
arguments: JSON.stringify({ command: 'echo restored' }),
});
controller.abort();
await Promise.allSettled([asking]);
});
test('interaction callbacks preserve frame order and watcher shutdown drains them', async () => {
const opened = deferred();
const releaseRequested = deferred();
@ -1213,7 +1463,7 @@ test('a new ask adopts a replayed orphan question before its queued prompt can r
await Promise.allSettled([asking]);
});
test('an orphan approval remains fail-closed for the future approval handler', async () => {
test('an orphan approval is delivered only as a recovered interaction for safe rejection', async () => {
const oldHistory = [
{ event: { seq: 1, type: 'turn/start', data: { turn: 1 } } },
{
@ -1262,8 +1512,11 @@ test('an orphan approval remains fail-closed for the future approval handler', a
onInteraction: (interaction) => received.push(interaction),
});
await prompted.promise;
await new Promise((resolve) => setTimeout(resolve, 20));
assert.deepEqual(received, []);
await eventually(() => received.length === 1);
assert.equal(received[0].kind, 'approval');
assert.equal(received[0].interactionId, 'orphan-approval-id');
assert.equal(received[0].recovered, true);
assert.equal(Object.hasOwn(received[0], 'toolCall'), false);
controller.abort();
await Promise.allSettled([asking]);

View file

@ -382,10 +382,113 @@ test('pending Harness questions are isolated by Feishu conversation', async () =
await firstA;
});
test('question replays are deduplicated and approvals remain fail-closed in the bridge', async () => {
test('Feishu handles approval replies on the fast lane and presents approvals in FIFO order', async () => {
const fixture = stateFixture([['p2p:ou_user', 'session-approval']]);
const sent = [];
const asked = [];
const decisions = [];
const decided = deferred();
const bridge = new FeishuHarnessBridge({
client: textClient(async (message) => sent.push(message)),
harness: {
sessionExists: async () => true,
createSession: async () => assert.fail('the existing session should be reused'),
ask: async (sessionId, text, options) => {
asked.push({ sessionId, text });
const approval = (approvalId, toolName, reason) => ({
kind: 'approval',
interactionId: approvalId,
rpcId: `rpc-${approvalId}`,
sessionId,
payload: {
type: 'approval/requested',
sessionId,
approvalId,
toolName,
callId: `call-${approvalId}`,
reason,
},
toolCall: {
callId: `call-${approvalId}`,
name: toolName,
arguments: JSON.stringify({ operation: reason }),
},
respond: async (result) => {
decisions.push(result);
if (decisions.length === 2) decided.resolve();
return { accepted: true };
},
});
await options.onInteraction(approval(
'approval-build',
'bash',
'运行第一项构建操作',
));
await options.onInteraction(approval(
'approval-write',
'write_file',
'运行第二项写入操作',
));
await decided.promise;
return '两个审批均已处理';
},
},
state: fixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_user']),
});
const turn = bridge.accept(event('approval-start', '发起两个审批'));
await eventually(() => sent.some(({ text }) => text.includes('运行第一项构建操作')));
assert.equal(sent.some(({ text }) => text.includes('运行第二项写入操作')), false);
assert.equal(sent.some(({ text }) => text.includes('approval-build')), false);
await bridge.accept(event('approval-invalid', '好的'));
assert.deepEqual(decisions, []);
assert.deepEqual(asked, [{ sessionId: 'session-approval', text: '发起两个审批' }]);
assert.match(sent.at(-1).text, /批准/);
assert.match(sent.at(-1).text, /拒绝/);
await bridge.accept(event('approval-allow', '批准'));
assert.deepEqual(decisions, [{
ok: true,
value: {
sessionId: 'session-approval',
approvalId: 'approval-build',
outcome: 'allowed-once',
},
}]);
assert.equal(sent.filter(({ text }) => text.includes('运行第二项写入操作')).length, 1);
assert.equal(sent.some(({ text }) => text.includes('approval-write')), false);
await bridge.accept(event('approval-reject', '拒绝'));
await turn;
assert.deepEqual(decisions, [
{
ok: true,
value: {
sessionId: 'session-approval',
approvalId: 'approval-build',
outcome: 'allowed-once',
},
},
{
ok: true,
value: {
sessionId: 'session-approval',
approvalId: 'approval-write',
outcome: 'rejected',
},
},
]);
assert.equal(sent.at(-1).text, '两个审批均已处理');
});
test('question replays are deduplicated and an unrenderable approval is safely rejected', async () => {
const fixture = stateFixture();
const sent = [];
let approvalResponses = 0;
let approvalResponse;
let parallelQuestionResponse;
const bridge = new FeishuHarnessBridge({
client: textClient(async (message) => sent.push(message)),
@ -433,7 +536,10 @@ test('question replays are deduplicated and approvals remain fail-closed in the
approvalId: 'approval-one',
toolName: 'bash',
},
respond: async () => { approvalResponses += 1; },
respond: async (result) => {
approvalResponse = result;
return { accepted: true };
},
});
await options.onInteractionResolved({
kind: 'question',
@ -461,8 +567,15 @@ test('question replays are deduplicated and approvals remain fail-closed in the
details: {},
},
});
assert.equal(sent.some(({ text }) => text.includes('approval')), false);
assert.equal(approvalResponses, 0);
assert.deepEqual(approvalResponse, {
ok: true,
value: {
sessionId: 'session-replay',
approvalId: 'approval-one',
outcome: 'rejected',
},
});
assert.equal(sent.some(({ text }) => text.includes('无法完整展示')), true);
assert.equal(sent.at(-1).text, '交互已取消');
});

View file

@ -351,10 +351,109 @@ test('QQ pending questions stay isolated between private conversations', async (
]);
});
test('QQ presents concurrent approvals in FIFO order without a code and consumes exact decisions', async () => {
const fixture = stateFixture([['c2c:owner-openid', 'session-approval']]);
const sent = [];
const asked = [];
const completed = deferred();
const responses = [];
const bridge = new QqHarnessBridge({
bot: { sendText: async (target, text) => sent.push({ target, text }) },
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
createSession: async () => assert.fail('the existing session should be reused'),
ask: async (sessionId, text, options) => {
asked.push(text);
for (const [approvalId, reason, command] of [
['qq-approval-one', '允许执行第一步', "printf 'first-step\\n'"],
['qq-approval-two', '允许执行第二步', "printf 'second-step\\n'"],
]) {
await options.onInteraction({
kind: 'approval',
interactionId: approvalId,
rpcId: `${approvalId}-rpc`,
sessionId,
payload: {
type: 'approval/requested',
sessionId,
approvalId,
toolName: 'bash',
callId: `${approvalId}-call`,
reason,
},
toolCall: {
callId: `${approvalId}-call`,
name: 'bash',
arguments: JSON.stringify({ command }),
},
respond: async (result) => {
responses.push(result);
if (responses.length === 2) completed.resolve();
return { accepted: true };
},
});
}
await completed.promise;
return '审批完成';
},
},
state: fixture.state,
});
const prompt = bridge.accept(message({
messageId: 'approval-start',
content: '启动两个审批',
}));
await eventually(() => sent.some(({ text }) => text.includes('允许执行第一步')));
assert.equal(sent.some(({ text }) => text.includes('允许执行第二步')), false);
assert.match(sent.find(({ text }) => text.includes('允许执行第一步')).text, /bash/);
assert.match(sent.find(({ text }) => text.includes('允许执行第一步')).text, /批准.*拒绝/s);
assert.doesNotMatch(sent.find(({ text }) => text.includes('允许执行第一步')).text, /qq-approval-one/);
await bridge.accept(message({
messageId: 'approval-allow',
content: '批准',
replyTarget: { scope: 'c2c', targetId: 'owner-openid', msgId: 'approval-allow' },
}));
await eventually(() => sent.some(({ text }) => text.includes('允许执行第二步')));
assert.doesNotMatch(sent.find(({ text }) => text.includes('允许执行第二步')).text, /qq-approval-two/);
await Promise.all([
bridge.accept(message({
messageId: 'approval-reject',
content: '拒绝',
replyTarget: { scope: 'c2c', targetId: 'owner-openid', msgId: 'approval-reject' },
})),
prompt,
]);
assert.deepEqual(responses, [
{
ok: true,
value: {
sessionId: 'session-approval',
approvalId: 'qq-approval-one',
outcome: 'allowed-once',
},
},
{
ok: true,
value: {
sessionId: 'session-approval',
approvalId: 'qq-approval-two',
outcome: 'rejected',
},
},
]);
assert.deepEqual(asked, ['启动两个审批']);
assert.equal(sent.at(-1).text, '审批完成');
});
test('QQ deduplicates question replays, cancels orphan questions, and keeps approvals fail-closed', async () => {
const fixture = stateFixture();
const sent = [];
let approvalResponses = 0;
let approvalResponse;
let parallelResponse;
let orphanResponse;
const bridge = new QqHarnessBridge({
@ -404,7 +503,7 @@ test('QQ deduplicates question replays, cancels orphan questions, and keeps appr
approvalId: 'qq-approval',
toolName: 'bash',
},
respond: async () => { approvalResponses += 1; },
respond: async (result) => { approvalResponse = result; },
});
await options.onInteractionResolved({
kind: 'question',
@ -446,7 +545,14 @@ test('QQ deduplicates question replays, cancels orphan questions, and keeps appr
details: {},
},
});
assert.equal(approvalResponses, 0);
assert.deepEqual(approvalResponse, {
ok: true,
value: {
sessionId: 'session-replay',
approvalId: 'qq-approval',
outcome: 'rejected',
},
});
assert.equal(sent.some(({ text }) => text.includes('approval')), false);
assert.deepEqual(orphanResponse, {
ok: false,

View file

@ -0,0 +1,866 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
HarnessApprovalQueue,
harnessApprovalDecision,
harnessApprovalText,
validHarnessApproval,
} from '../../../src/channels/shared/harness-approval.mjs';
function deferred() {
let resolve;
const promise = new Promise((resolvePromise) => { resolve = resolvePromise; });
return { promise, resolve };
}
function interaction({
id,
toolName,
respond,
...rest
} = {}) {
const callId = `call-${id}`;
return {
kind: 'approval',
interactionId: id,
rpcId: `rpc-${id}`,
sessionId: 'session-one',
payload: {
type: 'approval/requested',
sessionId: 'session-one',
approvalId: id,
toolName,
callId,
},
toolCall: {
callId,
name: toolName,
arguments: JSON.stringify({ command: `${toolName} --run` }),
},
respond,
...rest,
};
}
const approval = {
type: 'approval/requested',
sessionId: 'session-one',
approvalId: 'approval-secret-id',
toolName: 'bash',
callId: 'call-secret-id',
reason: '测试 IM 审批链路',
};
const toolCall = {
callId: 'call-secret-id',
name: 'bash',
arguments: JSON.stringify({ command: "printf 'approval-test\\n'" }),
};
test('maps only precise whole-message approval replies', () => {
const allowed = ['批准', '同意', 'yes', 'YES', ' YeS\n'];
const rejected = ['拒绝', '不同意', 'no', 'NO', '\tNo '];
const unmatched = [
'',
'1',
'2',
'好的',
'可以',
'行',
'没问题',
'批准吧',
'请批准',
'yes please',
'nope',
'同 意',
];
for (const text of allowed) {
assert.equal(harnessApprovalDecision(text), 'allowed-once', text);
}
for (const text of rejected) {
assert.equal(harnessApprovalDecision(text), 'rejected', text);
}
for (const text of unmatched) {
assert.equal(harnessApprovalDecision(text), null, text);
}
assert.equal(harnessApprovalDecision(undefined), null);
assert.equal(harnessApprovalDecision(1), null);
});
test('validates the Harness approval/requested payload without inventing options', () => {
assert.equal(validHarnessApproval(approval), true);
assert.equal(validHarnessApproval({ ...approval, callId: undefined, reason: undefined }), true);
for (const key of ['sessionId', 'approvalId', 'toolName']) {
const invalid = { ...approval, [key]: '' };
assert.equal(validHarnessApproval(invalid), false, key);
}
assert.equal(validHarnessApproval({ ...approval, type: 'question/requested' }), false);
assert.equal(validHarnessApproval({ ...approval, callId: 42 }), false);
assert.equal(validHarnessApproval({ ...approval, reason: 42 }), false);
assert.equal(validHarnessApproval(null), false);
});
test('formats a simple approval prompt without exposing an id or requiring a code', () => {
assert.equal(harnessApprovalText(approval), null);
const text = harnessApprovalText(approval, { toolCall });
assert.match(text, /bash/);
assert.match(text, /测试 IM 审批链路/);
assert.match(text, /批准/);
assert.match(text, /拒绝/);
assert.match(text, /同意/);
assert.match(text, /不同意/);
assert.match(text, /yes/i);
assert.match(text, /no/i);
assert.match(text, /printf 'approval-test/);
assert.doesNotMatch(text, /approval-secret-id|call-secret-id/);
assert.doesNotMatch(text, /\/approve|\/reject|审批码/);
assert.doesNotMatch(text, /1\s*[.\u3001]仅本次|2\s*[.\u3001]拒绝/);
assert.equal(harnessApprovalText(approval, {
toolCall: { ...toolCall, callId: 'another-call' },
}), null);
assert.equal(harnessApprovalText(approval, {
toolCall: { ...toolCall, name: 'another-tool' },
}), null);
assert.equal(harnessApprovalText(approval, {
toolCall: { ...toolCall, arguments: 'x'.repeat(6_001) },
}), null);
assert.match(harnessApprovalText(approval, {
toolCall: { ...toolCall, arguments: '' },
}), /\{\}/);
assert.equal(harnessApprovalText(approval, {
toolCall: { ...toolCall, arguments: ' ' },
}), null);
});
test('tells a group user to address the bot when replying to an approval', () => {
const text = harnessApprovalText(approval, { toolCall, requiresMention: true });
assert.match(text, /@.*机器人/);
assert.match(text, /批准/);
assert.match(text, /拒绝/);
});
test('never submits the next FIFO approval before its operation is presented', async () => {
const queue = new HarnessApprovalQueue({ logger: { warn() {}, error() {} } });
const sent = [];
const firstConfirmation = deferred();
const confirmationStarted = deferred();
const responses = [];
const context = (toolName) => ({
key: 'direct:actor-a',
actor: 'actor-a',
send: async (text) => sent.push({ toolName, text }),
});
await queue.handleRequested(interaction({
id: 'first',
toolName: 'first-tool',
respond: async (result) => responses.push(result),
}), context('first-tool'));
await queue.handleRequested(interaction({
id: 'second',
toolName: 'second-tool',
respond: async (result) => responses.push(result),
}), context('second-tool'));
const first = queue.claimReply({
key: 'direct:actor-a',
actor: 'actor-a',
text: '批准',
send: async (text) => {
sent.push({ toolName: 'reply-one', text });
if (text.startsWith('已批准')) {
confirmationStarted.resolve();
await firstConfirmation.promise;
}
},
}).process();
await confirmationStarted.promise;
let earlySecondSettled = false;
const earlySecond = queue.claimReply({
key: 'direct:actor-a',
actor: 'actor-a',
text: '批准',
send: async (text) => sent.push({ toolName: 'early-reply-two', text }),
}).process().finally(() => { earlySecondSettled = true; });
await Promise.resolve();
assert.equal(responses.length, 1);
assert.equal(earlySecondSettled, false);
firstConfirmation.resolve();
await Promise.all([first, earlySecond]);
const earlyTexts = sent
.filter(({ toolName }) => toolName === 'early-reply-two')
.map(({ text }) => text);
assert.equal(sent.some(({ toolName, text }) => (
toolName === 'second-tool' && text.includes('second-tool --run')
)), true);
assert.equal(earlyTexts[0].includes('请精准回复'), true);
await queue.claimReply({
key: 'direct:actor-a',
actor: 'actor-a',
text: '批准',
send: async (text) => sent.push({ toolName: 'reply-two', text }),
}).process();
assert.deepEqual(responses.map(({ value }) => value.approvalId), ['first', 'second']);
});
test('a failed presentation cannot be followed by a blind approval', async () => {
const queue = new HarnessApprovalQueue({ logger: { warn() {}, error() {} } });
let presentationAttempts = 0;
const responses = [];
const sent = [];
const requested = interaction({
id: 'presentation-retry',
toolName: 'bash',
respond: async (result) => responses.push(result),
});
await assert.rejects(queue.handleRequested(requested, {
key: 'direct:actor-a',
actor: 'actor-a',
send: async (text) => {
presentationAttempts += 1;
if (presentationAttempts === 1) throw new Error('temporary send failure');
sent.push(text);
},
}), /temporary send failure/);
await queue.claimReply({
key: 'direct:actor-a',
actor: 'actor-a',
text: '批准',
send: async (text) => sent.push(text),
}).process();
assert.equal(responses.length, 0);
assert.equal(sent.some((text) => text.includes('bash --run')), true);
assert.equal(sent.some((text) => text.includes('请精准回复')), true);
await queue.claimReply({
key: 'direct:actor-a',
actor: 'actor-a',
text: '批准',
send: async (text) => sent.push(text),
}).process();
assert.equal(responses.length, 1);
assert.equal(responses[0].value.outcome, 'allowed-once');
});
test('an unrenderable approval never claims it was rejected after not-pending', async () => {
const queue = new HarnessApprovalQueue({ logger: { warn() {}, error() {} } });
const sent = [];
await queue.handleRequested(interaction({
id: 'unrenderable-resolved',
toolName: 'bash',
toolCall: undefined,
respond: async () => {
const error = new Error('already handled elsewhere');
error.code = 'interaction-not-pending';
throw error;
},
}), {
key: 'direct:actor-a',
actor: 'actor-a',
send: async (text) => sent.push(text),
});
assert.deepEqual(sent, ['该审批已处理,无需再次回复。']);
assert.equal(sent.some((text) => text.includes('已安全拒绝')), false);
});
test('resolved waits for an in-flight presentation before showing the next approval', async () => {
const queue = new HarnessApprovalQueue({ logger: { warn() {}, error() {} } });
const firstSendStarted = deferred();
const releaseFirstSend = deferred();
const sent = [];
const secondResponses = [];
const firstRequest = queue.handleRequested(interaction({
id: 'first-resolved',
toolName: 'first-tool',
respond: async () => ({ accepted: true }),
}), {
key: 'direct:actor-a',
actor: 'actor-a',
send: async (text) => {
firstSendStarted.resolve();
await releaseFirstSend.promise;
sent.push(text);
},
});
await firstSendStarted.promise;
await queue.handleRequested(interaction({
id: 'second-after-resolved',
toolName: 'second-tool',
respond: async (result) => secondResponses.push(result),
}), {
key: 'direct:actor-a',
actor: 'actor-a',
send: async (text) => sent.push(text),
});
const resolved = queue.handleResolved({
kind: 'approval',
interactionId: 'first-resolved',
outcome: 'rejected',
});
const earlyNextDecision = queue.claimReply({
key: 'direct:actor-a',
actor: 'actor-a',
text: '批准',
send: async (text) => sent.push(text),
}).process();
await Promise.resolve();
assert.deepEqual(sent, []);
assert.equal(secondResponses.length, 0);
releaseFirstSend.resolve();
await Promise.all([firstRequest, resolved, earlyNextDecision]);
assert.match(sent[0], /first-tool --run/);
assert.equal(sent[1], '已拒绝此次操作。');
assert.match(sent[2], /second-tool --run/);
assert.match(sent[3], /请精准回复/);
assert.equal(secondResponses.length, 0);
await queue.claimReply({
key: 'direct:actor-a',
actor: 'actor-a',
text: '批准',
send: async (text) => sent.push(text),
}).process();
assert.equal(secondResponses.length, 1);
});
test('a next-presentation failure never rewrites an accepted decision as submit failure', async () => {
const errors = [];
const queue = new HarnessApprovalQueue({
logger: { warn() {}, error(...args) { errors.push(args); } },
});
const responses = [];
let reconnects = 0;
const sent = [];
await queue.handleRequested(interaction({
id: 'accepted-first',
toolName: 'first-tool',
respond: async (result) => responses.push(result),
}), {
key: 'direct:actor-a',
actor: 'actor-a',
send: async (text) => sent.push(text),
});
await queue.handleRequested(interaction({
id: 'failed-second-presentation',
toolName: 'second-tool',
reconnect: () => { reconnects += 1; },
respond: async (result) => responses.push(result),
}), {
key: 'direct:actor-a',
actor: 'actor-a',
send: async () => { throw new Error('second presentation unavailable'); },
});
await queue.claimReply({
key: 'direct:actor-a',
actor: 'actor-a',
text: '批准',
send: async (text) => sent.push(text),
}).process();
assert.equal(responses.length, 1);
assert.equal(responses[0].value.approvalId, 'accepted-first');
assert.equal(sent.includes('已批准,仅对本次操作有效。'), true);
assert.equal(sent.some((text) => text.includes('审批提交失败')), false);
assert.equal(reconnects, 1);
assert.equal(errors.length, 1);
});
test('resolved during submit gives one final outcome even when the HTTP response fails', async () => {
const queue = new HarnessApprovalQueue({ logger: { warn() {}, error() {} } });
const responseStarted = deferred();
const releaseResponse = deferred();
const sent = [];
await queue.handleRequested(interaction({
id: 'resolved-submit',
toolName: 'bash',
respond: async () => {
responseStarted.resolve();
await releaseResponse.promise;
throw new Error('response receipt lost');
},
}), {
key: 'direct:actor-a',
actor: 'actor-a',
send: async (text) => sent.push(text),
});
const deciding = queue.claimReply({
key: 'direct:actor-a',
actor: 'actor-a',
text: '批准',
send: async (text) => sent.push(text),
}).process();
await responseStarted.promise;
await queue.handleResolved({
kind: 'approval',
interactionId: 'resolved-submit',
outcome: 'allowed-once',
});
releaseResponse.resolve();
await deciding;
assert.equal(sent.filter((text) => text === '已批准,仅对本次操作有效。').length, 1);
assert.equal(sent.some((text) => text.includes('审批提交失败')), false);
});
test('resolving a blocked next approval preserves the route barrier for later items', async () => {
const queue = new HarnessApprovalQueue({ logger: { warn() {}, error() {} } });
const confirmationStarted = deferred();
const releaseConfirmation = deferred();
const sent = [];
const context = {
key: 'direct:actor-a',
actor: 'actor-a',
};
await queue.handleRequested(interaction({
id: 'barrier-a',
toolName: 'tool-a',
respond: async () => ({ accepted: true }),
}), { ...context, send: async (text) => sent.push(text) });
await queue.handleRequested(interaction({
id: 'barrier-b',
toolName: 'tool-b',
respond: async () => ({ accepted: true }),
}), { ...context, send: async (text) => sent.push(text) });
await queue.handleRequested(interaction({
id: 'barrier-c',
toolName: 'tool-c',
respond: async () => ({ accepted: true }),
}), { ...context, send: async (text) => sent.push(text) });
const decidingA = queue.claimReply({
...context,
text: '批准',
send: async (text) => {
sent.push(text);
if (text.startsWith('已批准')) {
confirmationStarted.resolve();
await releaseConfirmation.promise;
}
},
}).process();
await confirmationStarted.promise;
const resolvingB = queue.handleResolved({
kind: 'approval',
interactionId: 'barrier-b',
outcome: 'cancelled',
});
await Promise.resolve();
assert.equal(sent.some((text) => text.includes('tool-b --run')), false);
assert.equal(sent.some((text) => text.includes('tool-c --run')), false);
releaseConfirmation.resolve();
await Promise.all([decidingA, resolvingB]);
assert.equal(sent.some((text) => text.includes('tool-b --run')), false);
assert.equal(sent.some((text) => text.includes('tool-c --run')), true);
});
test('approval decisions stay bound to the initiating actor, route, and group mention', async () => {
const queue = new HarnessApprovalQueue({ logger: { warn() {}, error() {} } });
const sent = [];
const responses = [];
await queue.handleRequested(interaction({
id: 'guarded',
toolName: 'bash',
respond: async (result) => responses.push(result),
}), {
key: 'group:room-a',
actor: 'actor-a',
requiresMention: true,
send: async (text) => sent.push(text),
});
assert.equal(queue.claimReply({
key: 'group:room-a',
actor: 'actor-a',
text: 'yes',
addressed: true,
hasPendingQuestion: true,
send: async (text) => sent.push(text),
}), null);
assert.equal(queue.claimReply({
key: 'group:room-b',
actor: 'actor-a',
text: '批准',
addressed: true,
send: async (text) => sent.push(text),
}), null);
const skippedUnauthorized = queue.claimReply({
key: 'group:room-a',
actor: 'actor-b',
text: '批准',
addressed: true,
send: async (text) => sent.push(text),
});
const beforeSkippedCount = sent.length;
await skippedUnauthorized.process(async () => false);
assert.equal(sent.length, beforeSkippedCount);
await queue.claimReply({
key: 'group:room-a',
actor: 'actor-b',
text: '批准',
addressed: true,
send: async (text) => sent.push(text),
}).process();
await queue.claimReply({
key: 'group:room-a',
actor: 'actor-a',
text: '批准',
addressed: false,
send: async (text) => sent.push(text),
}).process();
await queue.claimReply({
key: 'group:room-a',
actor: 'actor-a',
text: '可以',
addressed: true,
send: async (text) => sent.push(text),
}).process();
assert.equal(responses.length, 0);
await queue.claimReply({
key: 'group:room-a',
actor: 'actor-a',
text: '批准',
addressed: true,
send: async (text) => sent.push(text),
}).process();
assert.equal(responses.length, 1);
assert.equal(responses[0].value.outcome, 'allowed-once');
assert.equal(sent.filter((text) => text.includes('只有发起当前任务')).length, 2);
assert.equal(sent.some((text) => text.includes('请精准回复')), true);
});
test('a deferred approval reply stays silent when the approval resolves with its question unfinished', async () => {
const queue = new HarnessApprovalQueue({ logger: { warn() {}, error() {} } });
const questionCompletion = deferred();
const sent = [];
const context = {
key: 'direct:actor-a',
actor: 'actor-a',
send: async (text) => sent.push(text),
};
await queue.handleRequested(interaction({
id: 'resolved-during-question',
toolName: 'bash',
respond: async () => ({ accepted: true }),
}), context);
const reply = queue.claimReply({
...context,
text: '批准',
hasPendingQuestion: true,
questionCompletion: questionCompletion.promise,
isQuestionPending: () => true,
}).process();
await Promise.resolve();
await queue.handleResolved({
kind: 'approval',
interactionId: 'resolved-during-question',
outcome: 'rejected',
});
questionCompletion.resolve();
await reply;
assert.equal(sent.some((text) => text.includes('请先完成当前问题')), false);
assert.equal(sent.at(-1), '已拒绝此次操作。');
});
test('requested replay updates the responder without duplicating the approval prompt', async () => {
const queue = new HarnessApprovalQueue({ logger: { warn() {}, error() {} } });
const sent = [];
let oldResponses = 0;
let newResponse;
const context = {
key: 'direct:actor-a',
actor: 'actor-a',
send: async (text) => sent.push(text),
};
await queue.handleRequested(interaction({
id: 'replayed',
toolName: 'bash',
respond: async () => { oldResponses += 1; },
}), context);
await queue.handleRequested(interaction({
id: 'replayed',
toolName: 'bash',
respond: async (result) => { newResponse = result; },
}), context);
assert.equal(sent.filter((text) => text.includes('bash --run')).length, 1);
await queue.claimReply({
...context,
text: '同意',
}).process();
assert.equal(oldResponses, 0);
assert.equal(newResponse.value.approvalId, 'replayed');
assert.equal(newResponse.value.outcome, 'allowed-once');
});
test('replaying a queued approval never presents it ahead of the current item', async () => {
const queue = new HarnessApprovalQueue({ logger: { warn() {}, error() {} } });
const sent = [];
const context = {
key: 'direct:actor-a',
actor: 'actor-a',
send: async (text) => sent.push(text),
};
await queue.handleRequested(interaction({
id: 'replay-current',
toolName: 'current-tool',
respond: async () => ({ accepted: true }),
}), context);
const queued = interaction({
id: 'replay-queued',
toolName: 'queued-tool',
respond: async () => ({ accepted: true }),
});
await queue.handleRequested(queued, context);
await queue.handleRequested({ ...queued, reconnect: () => undefined }, context);
assert.equal(sent.filter((text) => text.includes('current-tool --run')).length, 1);
assert.equal(sent.some((text) => text.includes('queued-tool --run')), false);
});
test('two decisions claimed for the current item cannot approve the next FIFO item', async () => {
const queue = new HarnessApprovalQueue({ logger: { warn() {}, error() {} } });
const releaseFirstResponse = deferred();
const firstResponseStarted = deferred();
const responses = [];
const sent = [];
const context = {
key: 'direct:actor-a',
actor: 'actor-a',
send: async (text) => sent.push(text),
};
await queue.handleRequested(interaction({
id: 'double-first',
toolName: 'first-tool',
respond: async (result) => {
responses.push(result);
firstResponseStarted.resolve();
await releaseFirstResponse.promise;
},
}), context);
await queue.handleRequested(interaction({
id: 'double-second',
toolName: 'second-tool',
respond: async (result) => responses.push(result),
}), context);
const firstDecision = queue.claimReply({ ...context, text: '批准' }).process();
await firstResponseStarted.promise;
const duplicateDecision = queue.claimReply({ ...context, text: '批准' }).process();
releaseFirstResponse.resolve();
await Promise.all([firstDecision, duplicateDecision]);
assert.deepEqual(responses.map(({ value }) => value.approvalId), ['double-first']);
assert.equal(sent.some((text) => text.includes('second-tool --run')), true);
assert.equal(sent.at(-1), '该审批已处理,无需再次回复。');
await queue.claimReply({ ...context, text: '批准' }).process();
assert.deepEqual(responses.map(({ value }) => value.approvalId), [
'double-first',
'double-second',
]);
});
test('a failed approval response can retry and not-pending becomes a tombstone', async () => {
const queue = new HarnessApprovalQueue({ logger: { warn() {}, error() {} } });
const sent = [];
let firstAttempts = 0;
const accepted = [];
const context = {
key: 'direct:actor-a',
actor: 'actor-a',
send: async (text) => sent.push(text),
};
await queue.handleRequested(interaction({
id: 'retry-response',
toolName: 'retry-tool',
respond: async (result) => {
firstAttempts += 1;
if (firstAttempts === 1) throw new Error('temporary response failure');
accepted.push(result);
},
}), context);
await queue.handleRequested(interaction({
id: 'not-pending-response',
toolName: 'expired-tool',
respond: async () => {
const error = new Error('already resolved');
error.code = 'interaction-not-pending';
throw error;
},
}), context);
await queue.claimReply({ ...context, text: '批准' }).process();
assert.equal(firstAttempts, 1);
assert.equal(sent.some((text) => text.includes('审批提交失败')), true);
await queue.claimReply({ ...context, text: '批准' }).process();
assert.equal(firstAttempts, 2);
assert.equal(accepted.length, 1);
assert.equal(sent.some((text) => text.includes('expired-tool --run')), true);
await queue.claimReply({ ...context, text: '拒绝' }).process();
assert.equal(sent.at(-1), '该审批已处理,无需再次回复。');
await queue.claimReply({ ...context, text: 'no' }).process();
assert.equal(sent.at(-1), '该审批已处理,无需再次回复。');
});
test('a failed message-recording preflight cannot block later approval replies', async () => {
const queue = new HarnessApprovalQueue({ logger: { warn() {}, error() {} } });
const sent = [];
const responses = [];
const context = {
key: 'direct:actor-a',
actor: 'actor-a',
send: async (text) => sent.push(text),
};
await queue.handleRequested(interaction({
id: 'preflight-retry',
toolName: 'bash',
respond: async (result) => responses.push(result),
}), context);
await assert.rejects(queue.claimReply({
...context,
text: '批准',
}).process(async () => {
throw new Error('state persistence failed');
}), /state persistence failed/);
await queue.claimReply({ ...context, text: '拒绝' }).process(async () => true);
assert.equal(responses.length, 1);
assert.equal(responses[0].value.outcome, 'rejected');
});
test('closing a route rejects every queued approval without presenting hidden items', async () => {
const queue = new HarnessApprovalQueue({ logger: { warn() {}, error() {} } });
const sent = [];
const responses = [];
const context = {
key: 'direct:actor-a',
actor: 'actor-a',
send: async (text) => sent.push(text),
};
await queue.handleRequested(interaction({
id: 'close-first',
toolName: 'first-tool',
respond: async (result) => responses.push(result),
}), context);
await queue.handleRequested(interaction({
id: 'close-second',
toolName: 'second-tool',
respond: async (result) => responses.push(result),
}), context);
await queue.closeRoute(context.key);
assert.deepEqual(responses.map(({ value }) => ({
approvalId: value.approvalId,
outcome: value.outcome,
})), [
{ approvalId: 'close-first', outcome: 'rejected' },
{ approvalId: 'close-second', outcome: 'rejected' },
]);
assert.equal(sent.some((text) => text.includes('first-tool --run')), true);
assert.equal(sent.some((text) => text.includes('second-tool --run')), false);
assert.equal(sent.includes('已拒绝此次操作。'), true);
});
test('closing while a decision is submitting races it with a fail-closed rejection', async () => {
const queue = new HarnessApprovalQueue({ logger: { warn() {}, error() {} } });
const allowedStarted = deferred();
const releaseAllowed = deferred();
const outcomes = [];
const sent = [];
const context = {
key: 'direct:actor-a',
actor: 'actor-a',
send: async (text) => sent.push(text),
};
await queue.handleRequested(interaction({
id: 'closing-submit',
toolName: 'bash',
respond: async (result) => {
outcomes.push(result.value.outcome);
if (result.value.outcome === 'allowed-once') {
allowedStarted.resolve();
await releaseAllowed.promise;
throw new DOMException('runtime stopped', 'AbortError');
}
},
}), context);
const deciding = queue.claimReply({ ...context, text: '批准' }).process();
await allowedStarted.promise;
await queue.closeRoute(context.key);
releaseAllowed.resolve();
await deciding;
assert.deepEqual(outcomes, ['allowed-once', 'rejected']);
assert.equal(sent.filter((text) => text === '已拒绝此次操作。').length, 1);
assert.equal(sent.some((text) => text.includes('审批提交失败')), false);
});
test('closing during presentation follows a stale prompt with a rejected outcome', async () => {
const queue = new HarnessApprovalQueue({ logger: { warn() {}, error() {} } });
const presentationStarted = deferred();
const releasePresentation = deferred();
const sent = [];
const requested = queue.handleRequested(interaction({
id: 'closing-presentation',
toolName: 'bash',
respond: async () => ({ accepted: true }),
}), {
key: 'direct:actor-a',
actor: 'actor-a',
send: async (text) => {
if (sent.length === 0) {
presentationStarted.resolve();
await releasePresentation.promise;
}
sent.push(text);
},
});
await presentationStarted.promise;
await queue.closeRoute('direct:actor-a');
releasePresentation.resolve();
await requested;
assert.match(sent[0], /bash --run/);
assert.equal(sent[1], '已拒绝此次操作。');
});
test('closing a displayed not-pending approval leaves a resolved notice', async () => {
const queue = new HarnessApprovalQueue({ logger: { warn() {}, error() {} } });
const sent = [];
await queue.handleRequested(interaction({
id: 'closing-not-pending',
toolName: 'bash',
respond: async () => {
const error = new Error('already handled');
error.code = 'interaction-not-pending';
throw error;
},
}), {
key: 'direct:actor-a',
actor: 'actor-a',
send: async (text) => sent.push(text),
});
await queue.closeRoute('direct:actor-a');
assert.match(sent[0], /bash --run/);
assert.equal(sent[1], '该审批已处理,无需再次回复。');
assert.equal(sent.some((text) => text.includes('已拒绝')), false);
});

View file

@ -1,7 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DiscordHarnessBridge } from '../../../src/channels/discord/discord-bridge.mjs';
import { TextHarnessBridge } from '../../../src/channels/shared/text-harness-bridge.mjs';
import { SlackHarnessBridge } from '../../../src/channels/slack/slack-bridge.mjs';
import { TelegramHarnessBridge } from '../../../src/channels/telegram/telegram-bridge.mjs';
import { WhatsappHarnessBridge } from '../../../src/channels/whatsapp/whatsapp-bridge.mjs';
function deferred() {
let resolve;
@ -73,6 +77,35 @@ function questionInteraction({
};
}
function approvalInteraction({
id = 'approval-one',
sessionId = 'session-one',
toolName = 'bash',
callId = 'call-one',
reason = '测试审批链路',
argumentsText = JSON.stringify({ command: "printf 'approval-test\\n'" }),
respond = async () => ({ accepted: true }),
...rest
} = {}) {
return {
kind: 'approval',
interactionId: id,
rpcId: `rpc-${id}`,
sessionId,
payload: {
type: 'approval/requested',
sessionId,
approvalId: id,
toolName,
callId,
reason,
},
toolCall: { callId, name: toolName, arguments: argumentsText },
respond,
...rest,
};
}
function createBridge({ harness, state, bot, signal, logger } = {}) {
return new TextHarnessBridge({
descriptor: { key: 'test', label: 'Test' },
@ -146,6 +179,348 @@ test('answers a multi-question interaction on the fast lane with canonical value
assert.deepEqual(sent[1].target, { id: 'target-language' });
});
test('answers approvals on the interaction fast lane with precise text decisions', async () => {
const fixture = stateFixture();
const sent = [];
const asked = [];
const submitted = [];
const cases = [
{ reply: '批准', outcome: 'allowed-once' },
{ reply: '同意', outcome: 'allowed-once' },
{ reply: ' YeS ', outcome: 'allowed-once' },
{ reply: '拒绝', outcome: 'rejected' },
{ reply: '不同意', outcome: 'rejected' },
{ reply: ' NO ', outcome: 'rejected' },
];
const bridge = createBridge({
state: fixture.state,
bot: { sendText: async (target, text) => sent.push({ target, text }) },
harness: {
createSession: async () => 'session-one',
ask: async (sessionId, text, options) => {
asked.push({ sessionId, text });
for (const [index, approvalCase] of cases.entries()) {
const answered = deferred();
await options.onInteraction(approvalInteraction({
id: `approval-${index + 1}`,
sessionId,
toolName: `tool-${index + 1}`,
reason: `精准回复测试 ${index + 1}`,
respond: async (result) => {
submitted.push(result);
answered.resolve();
return { accepted: true };
},
}));
await answered.promise;
assert.equal(submitted.at(-1).value.outcome, approvalCase.outcome);
}
return '所有审批已完成';
},
},
});
const processing = bridge.accept(message('approval-start', '启动审批测试'));
for (const [index, approvalCase] of cases.entries()) {
await eventually(() => sent.some(({ text }) => text.includes(`tool-${index + 1}`)));
await bridge.accept(message(`approval-reply-${index + 1}`, approvalCase.reply));
}
await processing;
assert.deepEqual(submitted, cases.map(({ outcome }, index) => ({
ok: true,
value: {
sessionId: 'session-one',
approvalId: `approval-${index + 1}`,
outcome,
},
})));
assert.deepEqual(asked, [{ sessionId: 'session-one', text: '启动审批测试' }]);
assert.equal(sent.at(-1).text, '所有审批已完成');
});
test('all four shared text channel bridges inherit the approval fast lane', async () => {
const channels = [
['Slack', SlackHarnessBridge],
['Discord', DiscordHarnessBridge],
['Telegram', TelegramHarnessBridge],
['WhatsApp', WhatsappHarnessBridge],
];
for (const [label, Bridge] of channels) {
const fixture = stateFixture();
const sent = [];
const submitted = deferred();
const asked = [];
const bridge = new Bridge({
state: fixture.state,
logger: { warn() {}, error() {} },
bot: { sendText: async (target, text) => sent.push({ target, text }) },
harness: {
createSession: async () => 'session-one',
ask: async (sessionId, text, options) => {
asked.push(text);
await options.onInteraction(approvalInteraction({
id: `${label.toLowerCase()}-approval`,
sessionId,
toolName: `${label.toLowerCase()}-tool`,
respond: async (result) => {
submitted.resolve(result);
return { accepted: true };
},
}));
await submitted.promise;
return `${label} 审批完成`;
},
},
});
const processing = bridge.accept(message(`${label}-prompt`, `${label} 启动审批`));
await eventually(() => sent.some(({ text }) => text.includes(`${label.toLowerCase()}-tool`)));
await bridge.accept(message(`${label}-decision`, '批准'));
await processing;
assert.equal((await submitted.promise).value.outcome, 'allowed-once', label);
assert.deepEqual(asked, [`${label} 启动审批`], label);
assert.equal(sent.at(-1).text, `${label} 审批完成`, label);
}
});
test('keeps an imprecise approval reply out of the Harness prompt queue', async () => {
const fixture = stateFixture();
const sent = [];
const asked = [];
const submitted = deferred();
let responseCalls = 0;
const bridge = createBridge({
state: fixture.state,
bot: { sendText: async (target, text) => sent.push({ target, text }) },
harness: {
createSession: async () => 'session-one',
ask: async (sessionId, text, options) => {
asked.push(text);
await options.onInteraction(approvalInteraction({
sessionId,
respond: async (result) => {
responseCalls += 1;
submitted.resolve(result);
return { accepted: true };
},
}));
await submitted.promise;
return '审批已继续';
},
},
});
const processing = bridge.accept(message('imprecise-start', '启动精准匹配测试'));
await eventually(() => sent.some(({ text }) => text.includes('测试审批链路')));
const imprecise = bridge.accept(message('imprecise-reply', '好的'));
await imprecise;
assert.equal(responseCalls, 0);
assert.deepEqual(asked, ['启动精准匹配测试']);
const approval = bridge.accept(message('precise-reply', '批准'));
await Promise.all([processing, approval]);
assert.deepEqual(await submitted.promise, {
ok: true,
value: {
sessionId: 'session-one',
approvalId: 'approval-one',
outcome: 'allowed-once',
},
});
assert.deepEqual(asked, ['启动精准匹配测试']);
});
test('presents parallel approvals from one conversation in fifo order without codes', async () => {
const fixture = stateFixture();
const sent = [];
const submitted = [];
const firstAnswered = deferred();
const secondAnswered = deferred();
const bridge = createBridge({
state: fixture.state,
bot: { sendText: async (target, text) => sent.push({ target, text }) },
harness: {
createSession: async () => 'session-one',
ask: async (sessionId, _text, options) => {
await Promise.all([
options.onInteraction(approvalInteraction({
id: 'fifo-first-id',
sessionId,
toolName: 'first-tool',
reason: '第一条审批',
respond: async (result) => {
submitted.push(result);
firstAnswered.resolve();
return { accepted: true };
},
})),
options.onInteraction(approvalInteraction({
id: 'fifo-second-id',
sessionId,
toolName: 'second-tool',
reason: '第二条审批',
respond: async (result) => {
submitted.push(result);
secondAnswered.resolve();
return { accepted: true };
},
})),
]);
await Promise.all([firstAnswered.promise, secondAnswered.promise]);
return '并行审批已完成';
},
},
});
const processing = bridge.accept(message('fifo-start', '启动并行审批'));
await eventually(() => sent.some(({ text }) => text.includes('first-tool')));
assert.equal(sent.some(({ text }) => text.includes('second-tool')), false);
await bridge.accept(message('fifo-first-reply', '批准'));
await eventually(() => sent.some(({ text }) => text.includes('second-tool')));
await bridge.accept(message('fifo-second-reply', '拒绝'));
await processing;
const approvalMessages = sent.filter(({ text }) => (
text.includes('first-tool') || text.includes('second-tool')
));
assert.equal(approvalMessages.length, 2);
assert.match(approvalMessages[0].text, /first-tool/);
assert.match(approvalMessages[1].text, /second-tool/);
assert.equal(approvalMessages.some(({ text }) => (
text.includes('fifo-first-id') || text.includes('fifo-second-id')
)), false);
assert.deepEqual(submitted.map(({ value }) => ({
approvalId: value.approvalId,
outcome: value.outcome,
})), [
{ approvalId: 'fifo-first-id', outcome: 'allowed-once' },
{ approvalId: 'fifo-second-id', outcome: 'rejected' },
]);
});
test('a completed approval tombstone never steals yes or no from a live question', async () => {
const fixture = stateFixture();
const sent = [];
const approvalDone = deferred();
const questionDone = deferred();
let questionResponse;
const asked = [];
const bridge = createBridge({
state: fixture.state,
bot: { sendText: async (target, text) => sent.push({ target, text }) },
harness: {
createSession: async () => 'session-one',
ask: async (sessionId, text, options) => {
asked.push(text);
await options.onInteraction(approvalInteraction({
sessionId,
respond: async () => {
approvalDone.resolve();
return { accepted: true };
},
}));
await approvalDone.promise;
await options.onInteraction(questionInteraction({
id: 'question-after-approval',
sessionId,
questions: [{ id: 'continue', question: '是否继续?' }],
respond: async (result) => {
questionResponse = result;
questionDone.resolve();
return { accepted: true };
},
}));
await questionDone.promise;
return '审批和提问均已完成';
},
},
});
const processing = bridge.accept(message('approval-then-question', '开始组合交互'));
await eventually(() => sent.some(({ text }) => text.includes("printf 'approval-test")));
await bridge.accept(message('approval-before-question', '批准'));
await eventually(() => sent.some(({ text }) => text.includes('是否继续?')));
await bridge.accept(message('question-yes', 'yes'));
await processing;
assert.deepEqual(questionResponse.value.answer.answers, [{
id: 'continue',
selected: [],
custom: 'yes',
}]);
assert.deepEqual(asked, ['开始组合交互']);
assert.equal(sent.at(-1).text, '审批和提问均已完成');
});
test('a sibling approval waits for an in-flight question answer without deadlocking', async () => {
const fixture = stateFixture();
const sent = [];
const questionResponseStarted = deferred();
const releaseQuestionResponse = deferred();
const questionDone = deferred();
const approvalDone = deferred();
const asked = [];
const questionResponses = [];
const approvalResponses = [];
const bridge = createBridge({
state: fixture.state,
bot: { sendText: async (target, text) => sent.push({ target, text }) },
harness: {
createSession: async () => 'session-one',
ask: async (sessionId, text, options) => {
asked.push(text);
await options.onInteraction(approvalInteraction({
id: 'sibling-approval',
sessionId,
respond: async (result) => {
approvalResponses.push(result);
approvalDone.resolve();
return { accepted: true };
},
}));
await options.onInteraction(questionInteraction({
id: 'sibling-question',
sessionId,
questions: [{ id: 'continue', question: '是否继续执行?' }],
respond: async (result) => {
questionResponses.push(result);
questionResponseStarted.resolve();
await releaseQuestionResponse.promise;
questionDone.resolve();
return { accepted: true };
},
}));
await Promise.all([questionDone.promise, approvalDone.promise]);
return '组合交互已完成';
},
},
});
const processing = bridge.accept(message('sibling-start', '启动并行交互'));
await eventually(() => sent.some(({ text }) => text.includes('是否继续执行?')));
const answeringQuestion = bridge.accept(message('sibling-question-answer', 'yes'));
await questionResponseStarted.promise;
const approving = bridge.accept(message('sibling-approval-answer', '批准'));
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(approvalResponses.length, 0);
releaseQuestionResponse.resolve();
await Promise.all([answeringQuestion, approving, processing]);
assert.deepEqual(questionResponses[0].value.answer.answers, [{
id: 'continue',
selected: [],
custom: 'yes',
}]);
assert.equal(approvalResponses[0].value.outcome, 'allowed-once');
assert.deepEqual(asked, ['启动并行交互']);
assert.equal(sent.at(-1).text, '组合交互已完成');
});
test('isolates pending questions by normalized conversation key', async () => {
const fixture = stateFixture({
'direct:chat-a': 'session-a',
@ -253,12 +628,12 @@ test('a group question only accepts an addressed reply from the initiating actor
assert.equal(bridge.status.messagesRejected, 1);
});
test('deduplicates replays, cancels parallel and recovered questions, and leaves approval pending', async () => {
test('deduplicates replays and safely closes recovered questions and approvals', async () => {
const fixture = stateFixture();
const sent = [];
let parallelResponse;
let recoveredResponse;
let approvalResponses = 0;
let approvalResponse;
const bridge = createBridge({
state: fixture.state,
bot: { sendText: async (target, text) => sent.push({ target, text }) },
@ -278,14 +653,12 @@ test('deduplicates replays, cancels parallel and recovered questions, and leaves
questions: [{ id: 'parallel', question: '不应显示的并行问题' }],
respond: async (result) => { parallelResponse = result; },
}));
await options.onInteraction({
kind: 'approval',
interactionId: 'approval-one',
rpcId: 'approval-rpc',
await options.onInteraction(approvalInteraction({
id: 'orphan-approval',
sessionId,
payload: { type: 'approval/requested', approvalId: 'approval-one' },
respond: async () => { approvalResponses += 1; },
});
recovered: true,
respond: async (result) => { approvalResponse = result; },
}));
await options.onInteraction(questionInteraction({
id: 'orphan-question',
sessionId,
@ -318,7 +691,15 @@ test('deduplicates replays, cancels parallel and recovered questions, and leaves
message: 'Test safely cancelled an interaction left by an earlier client.',
details: {},
});
assert.equal(approvalResponses, 0);
assert.deepEqual(approvalResponse, {
ok: true,
value: {
sessionId: 'session-one',
approvalId: 'orphan-approval',
outcome: 'rejected',
},
});
assert.equal(sent.some(({ text }) => text.includes("printf 'approval-test")), false);
});
test('keeps a failed interaction response pending so the actor can retry', async () => {

View file

@ -345,6 +345,89 @@ test('pending Enterprise WeChat questions stay isolated by conversation', async
);
});
test('Enterprise WeChat accepts only an exact approval decision and never forwards a fuzzy reply', async () => {
const transport = testClient();
const completed = deferred();
const prompts = [];
const responses = [];
const bridge = new WecomHarnessBridge({
client: transport.client,
generateStreamId: (() => { let index = 0; return () => `approval-${++index}`; })(),
harness: {
sessionExists: async () => true,
ask: async (sessionId, text, options) => {
prompts.push(text);
await options.onInteraction({
kind: 'approval',
interactionId: 'wecom-approval',
rpcId: 'wecom-approval-rpc',
sessionId,
payload: {
type: 'approval/requested',
sessionId,
approvalId: 'wecom-approval',
toolName: 'bash',
callId: 'wecom-approval-call',
reason: '允许执行企业微信审批测试',
},
toolCall: {
callId: 'wecom-approval-call',
name: 'bash',
arguments: JSON.stringify({ command: "printf 'wecom-approval\\n'" }),
},
respond: async (result) => {
responses.push(result);
completed.resolve();
return { accepted: true };
},
});
await completed.promise;
return '审批已继续';
},
},
state: state(),
});
const outputTexts = () => [
...transport.active.map(({ body }) => body.markdown.content),
...transport.streamed.map(({ content }) => content),
];
const prompt = bridge.accept(frame({
msgid: 'approval-start',
text: { content: '启动审批' },
}));
await eventually(() => outputTexts().some((text) => text.includes('允许执行企业微信审批测试')));
const outputCountBeforeFuzzyReply = outputTexts().length;
const fuzzy = bridge.accept(frame({
msgid: 'approval-fuzzy',
text: { content: '可以' },
}));
await eventually(() => outputTexts().slice(outputCountBeforeFuzzyReply).some((text) => text.includes('回复')
&& text.includes('批准') && text.includes('拒绝')));
assert.deepEqual(responses, []);
assert.deepEqual(prompts, ['启动审批']);
await Promise.all([
fuzzy,
bridge.accept(frame({
msgid: 'approval-exact',
text: { content: ' YES ' },
})),
prompt,
]);
assert.deepEqual(responses, [{
ok: true,
value: {
sessionId: 'session-existing',
approvalId: 'wecom-approval',
outcome: 'allowed-once',
},
}]);
assert.deepEqual(prompts, ['启动审批']);
});
test('question replays are deduplicated and approvals remain fail-closed', async () => {
const transport = testClient();
const answered = deferred();

View file

@ -191,10 +191,81 @@ test('Weixin answers a multi-question interaction before the original turn queue
assert.equal(sent.find(({ text }) => text.includes('选择交付物')).contextToken, 'context-multi-language');
});
test('Weixin consumes an exact rejection as the pending approval response', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', 'session-approval');
const sent = [];
const asked = [];
const completed = deferred();
const responses = [];
const bridge = new WeixinHarnessBridge({
api: { sendText: async (request) => sent.push(request) },
baseUrl: 'https://ilinkai.weixin.qq.com/',
token: 'host-token',
ownerUserId: 'owner-user',
harness: {
sessionExists: async () => true,
createSession: async () => assert.fail('the existing session should be reused'),
ask: async (sessionId, text, options) => {
asked.push(text);
await options.onInteraction({
kind: 'approval',
interactionId: 'weixin-approval-exact',
rpcId: 'weixin-approval-exact-rpc',
sessionId,
payload: {
type: 'approval/requested',
sessionId,
approvalId: 'weixin-approval-exact',
toolName: 'bash',
callId: 'weixin-approval-exact-call',
reason: '允许执行微信审批测试',
},
toolCall: {
callId: 'weixin-approval-exact-call',
name: 'bash',
arguments: JSON.stringify({ command: "printf 'weixin-approval\\n'" }),
},
respond: async (result) => {
responses.push(result);
completed.resolve();
return { accepted: true };
},
});
await completed.promise;
return '审批已拒绝';
},
},
state: fixture.state,
});
const prompt = bridge.accept(message('approval-start', '启动审批'));
await eventually(() => sent.some(({ text }) => text.includes('允许执行微信审批测试')));
const presentation = sent.find(({ text }) => text.includes('允许执行微信审批测试')).text;
assert.match(presentation, /bash/);
assert.match(presentation, /批准.*拒绝/s);
await Promise.all([
bridge.accept(message('approval-reject', ' 不同意 ')),
prompt,
]);
assert.deepEqual(responses, [{
ok: true,
value: {
sessionId: 'session-approval',
approvalId: 'weixin-approval-exact',
outcome: 'rejected',
},
}]);
assert.deepEqual(asked, ['启动审批']);
assert.equal(sent.at(-1).text, '审批已拒绝');
});
test('Weixin deduplicates question replays, rejects parallel questions, and keeps approvals fail-closed', async () => {
const fixture = stateFixture();
const sent = [];
let approvalResponses = 0;
let approvalResponse;
let parallelResponse;
let orphanResponse;
const bridge = new WeixinHarnessBridge({
@ -246,7 +317,7 @@ test('Weixin deduplicates question replays, rejects parallel questions, and keep
approvalId: 'weixin-approval',
toolName: 'bash',
},
respond: async () => { approvalResponses += 1; },
respond: async (result) => { approvalResponse = result; },
});
await options.onInteractionResolved({
kind: 'question',
@ -288,7 +359,14 @@ test('Weixin deduplicates question replays, rejects parallel questions, and keep
details: {},
},
});
assert.equal(approvalResponses, 0);
assert.deepEqual(approvalResponse, {
ok: true,
value: {
sessionId: 'session-replay',
approvalId: 'weixin-approval',
outcome: 'rejected',
},
});
assert.equal(sent.some(({ text }) => text.includes('approval')), false);
assert.deepEqual(orphanResponse, {
ok: false,