mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 23:10:46 +08:00
Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
55ed947d9a
22 changed files with 2976 additions and 170 deletions
225
lib/index.js
225
lib/index.js
File diff suppressed because one or more lines are too long
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "@xmanrui/dsh-im",
|
"name": "@xmanrui/dsh-im",
|
||||||
"version": "0.7.1",
|
"version": "0.7.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@xmanrui/dsh-im",
|
"name": "@xmanrui/dsh-im",
|
||||||
"version": "0.7.1",
|
"version": "0.7.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tencent-connect/qqbot-connector": "1.2.0",
|
"@tencent-connect/qqbot-connector": "1.2.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@xmanrui/dsh-im",
|
"name": "@xmanrui/dsh-im",
|
||||||
"version": "0.7.1",
|
"version": "0.7.2",
|
||||||
"description": "通过扫码、App Manifest或机器人凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信、QQ、Slack、Telegram、Discord和WhatsApp)。",
|
"description": "通过扫码、App Manifest或机器人凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信、QQ、Slack、Telegram、Discord和WhatsApp)。",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
harnessQuestionText,
|
harnessQuestionText,
|
||||||
validHarnessQuestion,
|
validHarnessQuestion,
|
||||||
} from '../shared/harness-question.mjs';
|
} from '../shared/harness-question.mjs';
|
||||||
|
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
||||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||||
|
|
||||||
|
|
@ -123,7 +124,9 @@ export class DingtalkHarnessBridge {
|
||||||
#queues = new Map();
|
#queues = new Map();
|
||||||
#pendingInteractions = new Map();
|
#pendingInteractions = new Map();
|
||||||
#interactionKeys = new Map();
|
#interactionKeys = new Map();
|
||||||
|
#interactionTasks = new Set();
|
||||||
#acceptedMessageIds = new Set();
|
#acceptedMessageIds = new Set();
|
||||||
|
#approvals;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
api,
|
api,
|
||||||
|
|
@ -149,6 +152,7 @@ export class DingtalkHarnessBridge {
|
||||||
this.#state = state;
|
this.#state = state;
|
||||||
this.#status = status;
|
this.#status = status;
|
||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
|
this.#approvals = new HarnessApprovalQueue({ label: 'DingTalk', logger });
|
||||||
this.#replyTimeoutMs = replyTimeoutMs;
|
this.#replyTimeoutMs = replyTimeoutMs;
|
||||||
this.#maxMessageChars = maxMessageChars;
|
this.#maxMessageChars = maxMessageChars;
|
||||||
this.#signal = signal;
|
this.#signal = signal;
|
||||||
|
|
@ -179,7 +183,57 @@ export class DingtalkHarnessBridge {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let sessionWebhook = null;
|
||||||
|
try {
|
||||||
|
sessionWebhook = normalizeDingtalkSessionWebhook(message.sessionWebhook);
|
||||||
|
} catch {
|
||||||
|
// An unsafe reply route must never be able to submit an approval.
|
||||||
|
}
|
||||||
const pending = this.#pendingInteractions.get(key);
|
const pending = this.#pendingInteractions.get(key);
|
||||||
|
const approvalReply = this.#approvals.claimReply({
|
||||||
|
key,
|
||||||
|
actor: sender,
|
||||||
|
messageId,
|
||||||
|
text: sessionWebhook && message?.msgtype === 'text'
|
||||||
|
? nonEmptyString(message?.text?.content) ?? ''
|
||||||
|
: '',
|
||||||
|
addressed: String(message?.conversationType) !== '2' || message?.isInAtList === true,
|
||||||
|
hasPendingQuestion: Boolean(pending),
|
||||||
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
||||||
|
? pending.queue
|
||||||
|
: null,
|
||||||
|
isQuestionPending: () => this.#pendingInteractions.has(key),
|
||||||
|
send: sessionWebhook
|
||||||
|
? (reply) => this.#send(sessionWebhook, reply)
|
||||||
|
: async () => undefined,
|
||||||
|
});
|
||||||
|
if (approvalReply) {
|
||||||
|
let current;
|
||||||
|
current = approvalReply.process(async () => {
|
||||||
|
if (this.#state.hasSeen(messageId)) return false;
|
||||||
|
await this.#state.markSeen(messageId);
|
||||||
|
increment(this.#status, 'messagesReceived');
|
||||||
|
this.#status.lastMessageAt = new Date().toISOString();
|
||||||
|
if (!sessionWebhook) {
|
||||||
|
increment(this.#status, 'messagesRejected');
|
||||||
|
this.#status.lastRejectedAt = new Date().toISOString();
|
||||||
|
this.#status.lastError = '钉钉消息没有安全的回复地址。';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (this.#signal?.aborted) return;
|
||||||
|
this.#status.lastError = '钉钉审批处理失败。';
|
||||||
|
this.#logger.error?.('[dsh-dingtalk] failed to process an approval reply', error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.#acceptedMessageIds.delete(messageId);
|
||||||
|
this.#interactionTasks.delete(current);
|
||||||
|
});
|
||||||
|
this.#interactionTasks.add(current);
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
if (pending && pending.actor !== sender) {
|
if (pending && pending.actor !== sender) {
|
||||||
return this.#enqueueMessage(message, messageId, sender, key);
|
return this.#enqueueMessage(message, messageId, sender, key);
|
||||||
}
|
}
|
||||||
|
|
@ -233,6 +287,7 @@ export class DingtalkHarnessBridge {
|
||||||
...[...this.#pendingInteractions.values()].flatMap((pending) => (
|
...[...this.#pendingInteractions.values()].flatMap((pending) => (
|
||||||
pending.queue ? [pending.queue] : []
|
pending.queue ? [pending.queue] : []
|
||||||
)),
|
)),
|
||||||
|
...this.#interactionTasks,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -344,6 +399,7 @@ export class DingtalkHarnessBridge {
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await this.#cancelPendingInteraction(key);
|
await this.#cancelPendingInteraction(key);
|
||||||
|
await this.#approvals.closeRoute(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -475,8 +531,14 @@ export class DingtalkHarnessBridge {
|
||||||
sessionWebhook,
|
sessionWebhook,
|
||||||
requiresMention,
|
requiresMention,
|
||||||
}) {
|
}) {
|
||||||
// The transport deliberately exposes every interaction kind. Approval is
|
if (await this.#approvals.handleRequested(interaction, {
|
||||||
// left unanswered until #5 adds its own policy and renderer.
|
key,
|
||||||
|
actor,
|
||||||
|
requiresMention,
|
||||||
|
send: (text) => this.#send(sessionWebhook, text),
|
||||||
|
})) return;
|
||||||
|
|
||||||
|
// Approval requests return above; the existing question state machine stays unchanged.
|
||||||
if (interaction?.kind !== 'question') return;
|
if (interaction?.kind !== 'question') return;
|
||||||
const questions = interaction?.payload?.questions;
|
const questions = interaction?.payload?.questions;
|
||||||
const interactionId = typeof interaction?.interactionId === 'string'
|
const interactionId = typeof interaction?.interactionId === 'string'
|
||||||
|
|
@ -550,7 +612,8 @@ export class DingtalkHarnessBridge {
|
||||||
await this.#presentInteraction(pending);
|
await this.#presentInteraction(pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
#handleInteractionResolved(resolution) {
|
async #handleInteractionResolved(resolution) {
|
||||||
|
if (await this.#approvals.handleResolved(resolution)) return;
|
||||||
const interactionId = resolution?.interactionId;
|
const interactionId = resolution?.interactionId;
|
||||||
if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
|
if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
|
||||||
const key = this.#interactionKeys.get(interactionId);
|
const key = this.#interactionKeys.get(interactionId);
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ export class DiscordApi {
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `Bot ${this.#token}`,
|
authorization: `Bot ${this.#token}`,
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.7.1)',
|
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.7.2)',
|
||||||
},
|
},
|
||||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||||
signal: requestSignal(signal, timeoutMs),
|
signal: requestSignal(signal, timeoutMs),
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
harnessQuestionText,
|
harnessQuestionText,
|
||||||
validHarnessQuestion,
|
validHarnessQuestion,
|
||||||
} from '../shared/harness-question.mjs';
|
} from '../shared/harness-question.mjs';
|
||||||
|
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
||||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||||
|
|
||||||
|
|
@ -67,6 +68,7 @@ export class FeishuHarnessBridge {
|
||||||
#resolvedQuestionReplies = new Map();
|
#resolvedQuestionReplies = new Map();
|
||||||
#acceptedMessageIds = new Set();
|
#acceptedMessageIds = new Set();
|
||||||
#interactionTasks = new Set();
|
#interactionTasks = new Set();
|
||||||
|
#approvals;
|
||||||
#status;
|
#status;
|
||||||
#allowedSenderOpenIds;
|
#allowedSenderOpenIds;
|
||||||
#replyTimeoutMs;
|
#replyTimeoutMs;
|
||||||
|
|
@ -95,6 +97,7 @@ export class FeishuHarnessBridge {
|
||||||
this.#allowedSenderOpenIds = allowedSenderOpenIds;
|
this.#allowedSenderOpenIds = allowedSenderOpenIds;
|
||||||
this.#replyTimeoutMs = replyTimeoutMs;
|
this.#replyTimeoutMs = replyTimeoutMs;
|
||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
|
this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
|
||||||
this.#signal = signal;
|
this.#signal = signal;
|
||||||
ensureStatus(this.#status);
|
ensureStatus(this.#status);
|
||||||
}
|
}
|
||||||
|
|
@ -138,6 +141,44 @@ export class FeishuHarnessBridge {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
const pending = this.#pendingInteractions.get(key);
|
const pending = this.#pendingInteractions.get(key);
|
||||||
|
const approvalReply = this.#approvals.claimReply({
|
||||||
|
key,
|
||||||
|
actor: senderOpenId(event),
|
||||||
|
messageId,
|
||||||
|
text: extractText(event) ?? '',
|
||||||
|
addressed: event?.message?.chat_type === 'p2p'
|
||||||
|
|| (Array.isArray(event?.message?.mentions) && event.message.mentions.length > 0),
|
||||||
|
hasPendingQuestion: Boolean(pending),
|
||||||
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
||||||
|
? pending.queue
|
||||||
|
: null,
|
||||||
|
isQuestionPending: () => this.#pendingInteractions.has(key),
|
||||||
|
send: (text) => this.#send(event.message.chat_id, text),
|
||||||
|
});
|
||||||
|
if (approvalReply) {
|
||||||
|
const processing = approvalReply.process(async () => {
|
||||||
|
if (this.#state.hasSeen(messageId)) return false;
|
||||||
|
await this.#state.markSeen(messageId);
|
||||||
|
this.#status.lastMessageAt = new Date().toISOString();
|
||||||
|
this.#status.messagesReceived += 1;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
let current;
|
||||||
|
current = processing
|
||||||
|
.then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
|
||||||
|
.catch((error) => this.#handleMessageFailure(
|
||||||
|
event,
|
||||||
|
messageId,
|
||||||
|
processingReaction,
|
||||||
|
error,
|
||||||
|
))
|
||||||
|
.finally(() => {
|
||||||
|
this.#acceptedMessageIds.delete(messageId);
|
||||||
|
this.#interactionTasks.delete(current);
|
||||||
|
});
|
||||||
|
this.#interactionTasks.add(current);
|
||||||
|
return current;
|
||||||
|
}
|
||||||
if (pending && senderOpenId(event) !== pending.actor) {
|
if (pending && senderOpenId(event) !== pending.actor) {
|
||||||
return this.#enqueueMessage(event, messageId, key, processingReaction);
|
return this.#enqueueMessage(event, messageId, key, processingReaction);
|
||||||
}
|
}
|
||||||
|
|
@ -290,6 +331,7 @@ export class FeishuHarnessBridge {
|
||||||
this.#status.lastError = null;
|
this.#status.lastError = null;
|
||||||
} finally {
|
} finally {
|
||||||
await this.#cancelPendingInteraction(key);
|
await this.#cancelPendingInteraction(key);
|
||||||
|
await this.#approvals.closeRoute(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -490,8 +532,14 @@ export class FeishuHarnessBridge {
|
||||||
chatId,
|
chatId,
|
||||||
requiresMention,
|
requiresMention,
|
||||||
}) {
|
}) {
|
||||||
// Approval is deliberately exposed by the transport but remains
|
if (await this.#approvals.handleRequested(interaction, {
|
||||||
// unanswered until #5 adds an authenticated policy and renderer.
|
key,
|
||||||
|
actor,
|
||||||
|
requiresMention,
|
||||||
|
send: (text) => this.#send(chatId, text),
|
||||||
|
})) return;
|
||||||
|
|
||||||
|
// Approval requests return above; the existing question state machine stays unchanged.
|
||||||
if (interaction?.kind !== 'question') return;
|
if (interaction?.kind !== 'question') return;
|
||||||
const questions = interaction?.payload?.questions;
|
const questions = interaction?.payload?.questions;
|
||||||
const interactionId = typeof interaction?.interactionId === 'string'
|
const interactionId = typeof interaction?.interactionId === 'string'
|
||||||
|
|
@ -566,7 +614,8 @@ export class FeishuHarnessBridge {
|
||||||
await this.#presentInteraction(pending);
|
await this.#presentInteraction(pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
#handleInteractionResolved(resolution) {
|
async #handleInteractionResolved(resolution) {
|
||||||
|
if (await this.#approvals.handleResolved(resolution)) return;
|
||||||
const interactionId = resolution?.interactionId;
|
const interactionId = resolution?.interactionId;
|
||||||
if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
|
if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
|
||||||
const key = this.#interactionKeys.get(interactionId);
|
const key = this.#interactionKeys.get(interactionId);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
harnessQuestionText,
|
harnessQuestionText,
|
||||||
validHarnessQuestion,
|
validHarnessQuestion,
|
||||||
} from '../shared/harness-question.mjs';
|
} from '../shared/harness-question.mjs';
|
||||||
|
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
||||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||||
|
|
||||||
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
||||||
|
|
@ -65,6 +66,8 @@ export class QqHarnessBridge {
|
||||||
#pendingInteractions = new Map();
|
#pendingInteractions = new Map();
|
||||||
#interactionKeys = new Map();
|
#interactionKeys = new Map();
|
||||||
#acceptedMessageIds = new Set();
|
#acceptedMessageIds = new Set();
|
||||||
|
#approvalTasks = new Set();
|
||||||
|
#approvals;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
bot,
|
bot,
|
||||||
|
|
@ -87,6 +90,7 @@ export class QqHarnessBridge {
|
||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
this.#replyTimeoutMs = replyTimeoutMs;
|
this.#replyTimeoutMs = replyTimeoutMs;
|
||||||
this.#signal = signal;
|
this.#signal = signal;
|
||||||
|
this.#approvals = new HarnessApprovalQueue({ label: 'qq', logger });
|
||||||
}
|
}
|
||||||
|
|
||||||
get status() {
|
get status() {
|
||||||
|
|
@ -104,6 +108,35 @@ export class QqHarnessBridge {
|
||||||
const key = conversationKey(message);
|
const key = conversationKey(message);
|
||||||
this.#acceptedMessageIds.add(messageId);
|
this.#acceptedMessageIds.add(messageId);
|
||||||
const pending = this.#pendingInteractions.get(key);
|
const pending = this.#pendingInteractions.get(key);
|
||||||
|
const approval = this.#approvals.claimReply({
|
||||||
|
key,
|
||||||
|
actor: sender,
|
||||||
|
messageId,
|
||||||
|
text: safeText(message),
|
||||||
|
addressed: message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE',
|
||||||
|
hasPendingQuestion: Boolean(pending),
|
||||||
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
||||||
|
? pending.queue
|
||||||
|
: null,
|
||||||
|
isQuestionPending: () => this.#pendingInteractions.has(key),
|
||||||
|
send: (text) => this.#bot.sendText(message.replyTarget, text),
|
||||||
|
});
|
||||||
|
if (approval) {
|
||||||
|
let task;
|
||||||
|
task = approval.process(async () => {
|
||||||
|
if (this.#state.hasSeen(messageId)) return false;
|
||||||
|
await this.#state.markSeen(messageId);
|
||||||
|
this.#status.messagesReceived += 1;
|
||||||
|
this.#status.lastMessageAt = new Date().toISOString();
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.#acceptedMessageIds.delete(messageId);
|
||||||
|
this.#approvalTasks.delete(task);
|
||||||
|
});
|
||||||
|
this.#approvalTasks.add(task);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
if (pending && sender !== pending.actor) {
|
if (pending && sender !== pending.actor) {
|
||||||
return this.#enqueueMessage(message, messageId, key);
|
return this.#enqueueMessage(message, messageId, key);
|
||||||
}
|
}
|
||||||
|
|
@ -152,6 +185,7 @@ export class QqHarnessBridge {
|
||||||
...[...this.#pendingInteractions.values()].flatMap((pending) => (
|
...[...this.#pendingInteractions.values()].flatMap((pending) => (
|
||||||
pending.queue ? [pending.queue] : []
|
pending.queue ? [pending.queue] : []
|
||||||
)),
|
)),
|
||||||
|
...this.#approvalTasks,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -247,7 +281,10 @@ export class QqHarnessBridge {
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
} finally {
|
} finally {
|
||||||
await this.#cancelPendingInteraction(key);
|
await Promise.allSettled([
|
||||||
|
this.#cancelPendingInteraction(key),
|
||||||
|
this.#approvals.closeRoute(key),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
if (stream) {
|
if (stream) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -388,7 +425,14 @@ export class QqHarnessBridge {
|
||||||
target,
|
target,
|
||||||
requiresMention,
|
requiresMention,
|
||||||
}) {
|
}) {
|
||||||
// Approval remains fail-closed until #5 adds an authenticated policy.
|
if (interaction?.kind === 'approval') {
|
||||||
|
return this.#approvals.handleRequested(interaction, {
|
||||||
|
key,
|
||||||
|
actor,
|
||||||
|
requiresMention,
|
||||||
|
send: (text) => this.#bot.sendText(target, text),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (interaction?.kind !== 'question') return;
|
if (interaction?.kind !== 'question') return;
|
||||||
const questions = interaction?.payload?.questions;
|
const questions = interaction?.payload?.questions;
|
||||||
const interactionId = typeof interaction?.interactionId === 'string'
|
const interactionId = typeof interaction?.interactionId === 'string'
|
||||||
|
|
@ -461,7 +505,11 @@ export class QqHarnessBridge {
|
||||||
await this.#presentInteraction(pending);
|
await this.#presentInteraction(pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
#handleInteractionResolved(resolution) {
|
async #handleInteractionResolved(resolution) {
|
||||||
|
if (resolution?.kind === 'approval') {
|
||||||
|
await this.#approvals.handleResolved(resolution);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const interactionId = resolution?.interactionId;
|
const interactionId = resolution?.interactionId;
|
||||||
if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
|
if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
|
||||||
const key = this.#interactionKeys.get(interactionId);
|
const key = this.#interactionKeys.get(interactionId);
|
||||||
|
|
|
||||||
472
src/channels/shared/harness-approval.mjs
Normal file
472
src/channels/shared/harness-approval.mjs
Normal file
|
|
@ -0,0 +1,472 @@
|
||||||
|
const APPROVAL_REPLIES = new Map([
|
||||||
|
['批准', 'allowed-once'],
|
||||||
|
['同意', 'allowed-once'],
|
||||||
|
['yes', 'allowed-once'],
|
||||||
|
['拒绝', 'rejected'],
|
||||||
|
['不同意', 'rejected'],
|
||||||
|
['no', 'rejected'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const APPROVAL_PROMPT = '请精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / no)。';
|
||||||
|
const APPROVAL_AFTER_QUESTION_PROMPT = '请先完成当前问题,再精准回复「批准」或「拒绝」。';
|
||||||
|
const APPROVAL_RESOLVED_TEXT = '该审批已处理,无需再次回复。';
|
||||||
|
const RESOLVED_ROUTE_TTL_MS = 5 * 60_000;
|
||||||
|
const MAX_RESOLVED_ROUTES = 2_048;
|
||||||
|
|
||||||
|
function cleanText(value) {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function printableText(value) {
|
||||||
|
return cleanText(value).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function harnessApprovalDecision(text) {
|
||||||
|
return APPROVAL_REPLIES.get(cleanText(text).toLowerCase()) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validHarnessApproval(payload) {
|
||||||
|
return payload?.type === 'approval/requested'
|
||||||
|
&& Boolean(cleanText(payload.sessionId))
|
||||||
|
&& Boolean(cleanText(payload.approvalId))
|
||||||
|
&& Boolean(cleanText(payload.toolName))
|
||||||
|
&& (payload.callId === undefined || Boolean(cleanText(payload.callId)))
|
||||||
|
&& (payload.reason === undefined || typeof payload.reason === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolArguments(toolCall) {
|
||||||
|
const source = toolCall?.arguments;
|
||||||
|
if (source !== null && typeof source === 'object') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(source, null, 2);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof source !== 'string') return null;
|
||||||
|
const raw = printableText(source);
|
||||||
|
// Harness treats an empty tool argument string as an empty object.
|
||||||
|
if (!raw) return source === '' ? '{}' : null;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(raw), null, 2);
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function harnessApprovalText(payload, {
|
||||||
|
toolCall,
|
||||||
|
requiresMention = false,
|
||||||
|
maxArgumentsLength = 6_000,
|
||||||
|
} = {}) {
|
||||||
|
if (!validHarnessApproval(payload)) return null;
|
||||||
|
const callId = cleanText(payload.callId);
|
||||||
|
if (!callId
|
||||||
|
|| cleanText(toolCall?.callId) !== callId
|
||||||
|
|| cleanText(toolCall?.name) !== cleanText(payload.toolName)) return null;
|
||||||
|
const operation = toolArguments(toolCall);
|
||||||
|
if (!operation || operation.length > maxArgumentsLength) return null;
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'DeepSeek Harness 需要你的审批:',
|
||||||
|
'',
|
||||||
|
`工具:${printableText(payload.toolName)}`,
|
||||||
|
'操作参数:',
|
||||||
|
operation,
|
||||||
|
];
|
||||||
|
const reason = printableText(payload.reason);
|
||||||
|
if (reason) lines.push(`原因:${reason}`);
|
||||||
|
lines.push('', APPROVAL_PROMPT);
|
||||||
|
if (requiresMention) lines.push('', '群聊中请 @机器人 后发送审批决定。');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function approvalResult(pending, outcome) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
sessionId: pending.sessionId,
|
||||||
|
approvalId: pending.approvalId,
|
||||||
|
outcome,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function approvalOutcomeText(outcome) {
|
||||||
|
if (outcome === 'allowed-once') return '已批准,仅对本次操作有效。';
|
||||||
|
if (outcome === 'rejected') return '已拒绝此次操作。';
|
||||||
|
return APPROVAL_RESOLVED_TEXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HarnessApprovalQueue {
|
||||||
|
#label;
|
||||||
|
#logger;
|
||||||
|
#byId = new Map();
|
||||||
|
#routes = new Map();
|
||||||
|
#resolvedRoutes = new Map();
|
||||||
|
|
||||||
|
constructor({ label = 'IM', logger = console } = {}) {
|
||||||
|
this.#label = label;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
claimReply({
|
||||||
|
key,
|
||||||
|
actor,
|
||||||
|
text,
|
||||||
|
addressed = true,
|
||||||
|
hasPendingQuestion = false,
|
||||||
|
questionCompletion,
|
||||||
|
isQuestionPending,
|
||||||
|
send,
|
||||||
|
}) {
|
||||||
|
const route = this.#routes.get(key);
|
||||||
|
const pending = route?.items[0];
|
||||||
|
const decision = harnessApprovalDecision(text);
|
||||||
|
const notice = (value, resolved = false) => ({
|
||||||
|
...(resolved ? { resolved: true } : {}),
|
||||||
|
process: async (before) => {
|
||||||
|
if (typeof before === 'function' && await before() === false) return;
|
||||||
|
await send(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Match Harness' own interaction precedence: a live ask_user_question
|
||||||
|
// outranks sibling approvals. Otherwise a question answer such as "yes"
|
||||||
|
// could accidentally authorize a tool call.
|
||||||
|
const deferredByQuestion = hasPendingQuestion
|
||||||
|
&& pending
|
||||||
|
&& questionCompletion
|
||||||
|
&& typeof questionCompletion.then === 'function';
|
||||||
|
if (hasPendingQuestion && !deferredByQuestion) return null;
|
||||||
|
if (!pending || pending.inactive) {
|
||||||
|
const resolvedUntil = this.#resolvedRoutes.get(key) ?? 0;
|
||||||
|
if (resolvedUntil <= Date.now()) {
|
||||||
|
this.#resolvedRoutes.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!decision) return null;
|
||||||
|
return notice(APPROVAL_RESOLVED_TEXT, true);
|
||||||
|
}
|
||||||
|
if (pending.actor !== actor || (pending.requiresMention && addressed !== true)) {
|
||||||
|
if (!decision) return null;
|
||||||
|
return notice('只有发起当前任务的用户可以处理这条审批。');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
process: async (before) => {
|
||||||
|
const presentedWhenClaimed = pending.presented;
|
||||||
|
const previous = pending.replyTail ?? Promise.resolve();
|
||||||
|
const task = previous
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(async () => {
|
||||||
|
if (typeof before === 'function' && await before() === false) return;
|
||||||
|
if (deferredByQuestion) {
|
||||||
|
await questionCompletion.catch(() => undefined);
|
||||||
|
if (pending.inactive || pending.resolving) return;
|
||||||
|
if (typeof isQuestionPending === 'function' && isQuestionPending()) {
|
||||||
|
await send(APPROVAL_AFTER_QUESTION_PROMPT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await pending.activationTask?.catch(() => undefined);
|
||||||
|
await pending.presentationTask?.catch(() => undefined);
|
||||||
|
if (pending.inactive || pending.resolving) {
|
||||||
|
await send(APPROVAL_RESOLVED_TEXT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending.send = send;
|
||||||
|
// Never turn a decision sent before the operation was visibly
|
||||||
|
// presented into an approval. This also covers a failed presentation
|
||||||
|
// and the small FIFO promotion window before the next item is shown.
|
||||||
|
if (!presentedWhenClaimed || !pending.presented) {
|
||||||
|
if (!pending.presented) await this.#present(pending);
|
||||||
|
if (pending.inactive || pending.resolving) return;
|
||||||
|
await send(APPROVAL_PROMPT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pending.submitting) {
|
||||||
|
await send('审批决定正在提交,请稍候。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!decision) {
|
||||||
|
await send(APPROVAL_PROMPT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.#submit(pending, decision);
|
||||||
|
});
|
||||||
|
pending.replyTail = task;
|
||||||
|
try {
|
||||||
|
await task;
|
||||||
|
} finally {
|
||||||
|
if (pending.replyTail === task) pending.replyTail = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleRequested(interaction, context) {
|
||||||
|
if (interaction?.kind !== 'approval') return false;
|
||||||
|
const payload = interaction.payload;
|
||||||
|
const approvalId = cleanText(payload?.approvalId);
|
||||||
|
if (!cleanText(interaction.rpcId)
|
||||||
|
|| !cleanText(interaction.sessionId)
|
||||||
|
|| !approvalId
|
||||||
|
|| !validHarnessApproval(payload)
|
||||||
|
|| payload.sessionId !== interaction.sessionId
|
||||||
|
|| typeof interaction.respond !== 'function') {
|
||||||
|
this.#logger.warn?.(`[dsh-im:${this.#label}] ignored an invalid Harness approval`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.recovered === true) {
|
||||||
|
await this.#rejectInteraction(interaction, payload);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = this.#byId.get(approvalId);
|
||||||
|
if (existing) {
|
||||||
|
existing.interaction = interaction;
|
||||||
|
existing.toolCall = interaction.toolCall;
|
||||||
|
if (!existing.presented) await this.#present(existing);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const send = context?.send;
|
||||||
|
const key = cleanText(context?.key);
|
||||||
|
const actor = cleanText(context?.actor);
|
||||||
|
if (!key || !actor || typeof send !== 'function') {
|
||||||
|
this.#logger.warn?.(`[dsh-im:${this.#label}] ignored an approval without a reply route`);
|
||||||
|
await this.#rejectInteraction(interaction, payload);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = harnessApprovalText(payload, {
|
||||||
|
toolCall: interaction.toolCall,
|
||||||
|
requiresMention: context.requiresMention === true,
|
||||||
|
});
|
||||||
|
if (!text) {
|
||||||
|
const rejected = await this.#rejectInteraction(interaction, payload);
|
||||||
|
await send(rejected
|
||||||
|
? '无法完整展示这次操作,已安全拒绝此次审批。'
|
||||||
|
: APPROVAL_RESOLVED_TEXT);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = {
|
||||||
|
approvalId,
|
||||||
|
sessionId: interaction.sessionId,
|
||||||
|
interaction,
|
||||||
|
toolCall: interaction.toolCall,
|
||||||
|
key,
|
||||||
|
actor,
|
||||||
|
requiresMention: context.requiresMention === true,
|
||||||
|
send,
|
||||||
|
text,
|
||||||
|
presented: false,
|
||||||
|
presentationTask: null,
|
||||||
|
deliveryCompleted: false,
|
||||||
|
replyTail: null,
|
||||||
|
submitting: false,
|
||||||
|
inactive: false,
|
||||||
|
resolving: false,
|
||||||
|
closedOutcome: null,
|
||||||
|
resolutionNotified: false,
|
||||||
|
activationTask: null,
|
||||||
|
};
|
||||||
|
this.#byId.set(approvalId, pending);
|
||||||
|
const route = this.#routes.get(key) ?? { items: [] };
|
||||||
|
route.items.push(pending);
|
||||||
|
this.#routes.set(key, route);
|
||||||
|
if (route.items[0] === pending) await this.#present(pending);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleResolved(resolution) {
|
||||||
|
if (resolution?.kind !== 'approval') return false;
|
||||||
|
const pending = this.#byId.get(cleanText(resolution.interactionId));
|
||||||
|
if (!pending) return true;
|
||||||
|
// A queued item may already be the next route head while the previous
|
||||||
|
// item's confirmation is still in flight. Preserve that route barrier so
|
||||||
|
// resolving this item cannot expose a later approval out of order.
|
||||||
|
pending.resolving = true;
|
||||||
|
if (pending.activationTask) {
|
||||||
|
await pending.activationTask.catch(() => undefined);
|
||||||
|
}
|
||||||
|
if (pending.inactive || this.#byId.get(pending.approvalId) !== pending) return true;
|
||||||
|
const presentationTask = pending.presentationTask;
|
||||||
|
const shouldNotify = pending.presented || presentationTask;
|
||||||
|
const send = pending.send;
|
||||||
|
const next = this.#remove(pending);
|
||||||
|
await this.#transition(next, async () => {
|
||||||
|
let delivered = pending.presented;
|
||||||
|
if (presentationTask) {
|
||||||
|
delivered = await presentationTask.then(() => true, () => false);
|
||||||
|
}
|
||||||
|
if (shouldNotify && delivered) {
|
||||||
|
pending.resolutionNotified = true;
|
||||||
|
await send(approvalOutcomeText(resolution.outcome)).catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeRoute(key) {
|
||||||
|
const route = this.#routes.get(key);
|
||||||
|
if (!route) return;
|
||||||
|
const pendingItems = [...route.items];
|
||||||
|
for (const pending of pendingItems) this.#remove(pending);
|
||||||
|
await Promise.all(pendingItems.map(async (pending) => {
|
||||||
|
try {
|
||||||
|
await pending.interaction.respond(
|
||||||
|
approvalResult(pending, 'rejected'),
|
||||||
|
{ signal: AbortSignal.timeout(5_000) },
|
||||||
|
);
|
||||||
|
pending.closedOutcome = 'rejected';
|
||||||
|
if ((pending.presented || pending.deliveryCompleted) && !pending.resolutionNotified) {
|
||||||
|
pending.resolutionNotified = true;
|
||||||
|
await pending.send(approvalOutcomeText('rejected')).catch(() => undefined);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'interaction-not-pending') {
|
||||||
|
pending.closedOutcome = 'resolved';
|
||||||
|
if ((pending.presented || pending.deliveryCompleted) && !pending.resolutionNotified) {
|
||||||
|
pending.resolutionNotified = true;
|
||||||
|
await pending.send(APPROVAL_RESOLVED_TEXT).catch(() => undefined);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.#logger.warn?.(`[dsh-im:${this.#label}] failed to reject a closing approval:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async #present(pending) {
|
||||||
|
if (this.#routes.get(pending.key)?.items[0] !== pending
|
||||||
|
|| pending.inactive || pending.resolving || pending.presented) return;
|
||||||
|
await pending.activationTask?.catch(() => undefined);
|
||||||
|
if (this.#routes.get(pending.key)?.items[0] !== pending
|
||||||
|
|| pending.inactive || pending.resolving || pending.presented) return;
|
||||||
|
if (pending.presentationTask) return pending.presentationTask;
|
||||||
|
const task = Promise.resolve().then(() => pending.send(pending.text));
|
||||||
|
pending.presentationTask = task;
|
||||||
|
try {
|
||||||
|
await task;
|
||||||
|
pending.deliveryCompleted = true;
|
||||||
|
if (!pending.inactive) {
|
||||||
|
pending.presented = true;
|
||||||
|
} else if (pending.closedOutcome && !pending.resolutionNotified) {
|
||||||
|
pending.resolutionNotified = true;
|
||||||
|
await pending.send(approvalOutcomeText(pending.closedOutcome)).catch(() => undefined);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (pending.presentationTask === task) pending.presentationTask = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #submit(pending, outcome) {
|
||||||
|
pending.submitting = true;
|
||||||
|
try {
|
||||||
|
await pending.interaction.respond(approvalResult(pending, outcome));
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'interaction-not-pending') {
|
||||||
|
const send = pending.send;
|
||||||
|
const next = this.#remove(pending);
|
||||||
|
await this.#transition(next, async () => {
|
||||||
|
if (!pending.resolutionNotified) {
|
||||||
|
await send(APPROVAL_RESOLVED_TEXT).catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pending.inactive) return;
|
||||||
|
pending.submitting = false;
|
||||||
|
this.#logger.error?.(`[dsh-im:${this.#label}] failed to submit an approval:`, error);
|
||||||
|
await pending.send('审批提交失败,请重新回复「批准」或「拒绝」。').catch(() => undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const send = pending.send;
|
||||||
|
const next = this.#remove(pending);
|
||||||
|
await this.#transition(next, async () => {
|
||||||
|
if (!pending.resolutionNotified) {
|
||||||
|
await send(approvalOutcomeText(outcome)).catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async #transition(next, work) {
|
||||||
|
let release;
|
||||||
|
const barrier = new Promise((resolve) => { release = resolve; });
|
||||||
|
if (next) next.activationTask = barrier;
|
||||||
|
try {
|
||||||
|
await work();
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
if (next?.activationTask === barrier) next.activationTask = null;
|
||||||
|
}
|
||||||
|
await this.#promote(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #promote(pending) {
|
||||||
|
if (!pending) return;
|
||||||
|
try {
|
||||||
|
await this.#present(pending);
|
||||||
|
} catch (error) {
|
||||||
|
this.#logger.error?.(
|
||||||
|
`[dsh-im:${this.#label}] failed to present the next approval:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
pending.interaction.reconnect?.();
|
||||||
|
} catch {
|
||||||
|
// A replay will retry presentation when the transport can reconnect.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#remove(pending) {
|
||||||
|
if (pending.inactive) return null;
|
||||||
|
pending.inactive = true;
|
||||||
|
this.#rememberResolvedRoute(pending.key);
|
||||||
|
this.#byId.delete(pending.approvalId);
|
||||||
|
const route = this.#routes.get(pending.key);
|
||||||
|
if (!route) return null;
|
||||||
|
const wasCurrent = route.items[0] === pending;
|
||||||
|
const index = route.items.indexOf(pending);
|
||||||
|
if (index !== -1) route.items.splice(index, 1);
|
||||||
|
if (route.items.length === 0) {
|
||||||
|
this.#routes.delete(pending.key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return wasCurrent ? route.items[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#rememberResolvedRoute(key) {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [routeKey, expiresAt] of this.#resolvedRoutes) {
|
||||||
|
if (expiresAt <= now) this.#resolvedRoutes.delete(routeKey);
|
||||||
|
}
|
||||||
|
this.#resolvedRoutes.delete(key);
|
||||||
|
this.#resolvedRoutes.set(key, now + RESOLVED_ROUTE_TTL_MS);
|
||||||
|
while (this.#resolvedRoutes.size > MAX_RESOLVED_ROUTES) {
|
||||||
|
this.#resolvedRoutes.delete(this.#resolvedRoutes.keys().next().value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #rejectInteraction(interaction, payload) {
|
||||||
|
try {
|
||||||
|
await interaction.respond({
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
sessionId: interaction.sessionId,
|
||||||
|
approvalId: payload.approvalId,
|
||||||
|
outcome: 'rejected',
|
||||||
|
},
|
||||||
|
}, { signal: AbortSignal.timeout(5_000) });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'interaction-not-pending') return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -154,7 +154,36 @@ function consumeInteractionOwnership(ownership, entries) {
|
||||||
if (event.type === 'turn/end' && event.data?.turn === ownership.turn) {
|
if (event.type === 'turn/end' && event.data?.turn === ownership.turn) {
|
||||||
ownership.active = false;
|
ownership.active = false;
|
||||||
ownership.completed = true;
|
ownership.completed = true;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
let toolCall = null;
|
||||||
|
if (event.type === 'tool/call'
|
||||||
|
&& ownership.active
|
||||||
|
&& event.data?.turn === ownership.turn
|
||||||
|
&& typeof event.data?.callId === 'string'
|
||||||
|
&& event.data.callId) {
|
||||||
|
toolCall = {
|
||||||
|
callId: event.data.callId,
|
||||||
|
name: event.data?.name,
|
||||||
|
arguments: event.data?.arguments,
|
||||||
|
};
|
||||||
|
} else if (event.type === 'tool/code-dispatch-start'
|
||||||
|
&& ownership.active
|
||||||
|
&& typeof event.data?.subCallId === 'string'
|
||||||
|
&& event.data.subCallId) {
|
||||||
|
let argumentsText;
|
||||||
|
try {
|
||||||
|
argumentsText = JSON.stringify(event.data?.arguments);
|
||||||
|
} catch {
|
||||||
|
argumentsText = undefined;
|
||||||
|
}
|
||||||
|
toolCall = {
|
||||||
|
callId: event.data.subCallId,
|
||||||
|
name: event.data?.name,
|
||||||
|
arguments: argumentsText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (toolCall) ownership.toolCalls.set(toolCall.callId, Object.freeze(toolCall));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -558,13 +587,12 @@ export class HarnessClient {
|
||||||
.sort((left, right) => left.order - right.order);
|
.sort((left, right) => left.order - right.order);
|
||||||
if (active.length > 0) return { ownership: active[0], recovered: false };
|
if (active.length > 0) return { ownership: active[0], recovered: false };
|
||||||
|
|
||||||
// Approval recovery must remain fail-closed until #5 can prove the actor
|
|
||||||
// and conversation that originally requested the decision.
|
|
||||||
if (kind !== 'question') return null;
|
|
||||||
|
|
||||||
// A newly attached IM conversation may encounter a question left by
|
// A newly attached IM conversation may encounter a question left by
|
||||||
// an earlier runtime before its queued prompt starts. Let the oldest such
|
// an earlier runtime before its queued prompt starts. Let the oldest such
|
||||||
// ask adopt that replay so the Session can recover instead of deadlocking.
|
// ask adopt that replay so the Session can recover instead of deadlocking.
|
||||||
|
// Approval adopters receive recovered=true and must reject it without ever
|
||||||
|
// presenting it as approvable; the original actor/route cannot be proven
|
||||||
|
// after a runtime restart.
|
||||||
const ownership = owners
|
const ownership = owners
|
||||||
.filter((ownership) => !ownership.started && !ownership.completed)
|
.filter((ownership) => !ownership.started && !ownership.completed)
|
||||||
.sort((left, right) => left.order - right.order)[0] ?? null;
|
.sort((left, right) => left.order - right.order)[0] ?? null;
|
||||||
|
|
@ -614,6 +642,7 @@ export class HarnessClient {
|
||||||
lastSeq: baselineSeq,
|
lastSeq: baselineSeq,
|
||||||
reconnect: null,
|
reconnect: null,
|
||||||
order: -1,
|
order: -1,
|
||||||
|
toolCalls: new Map(),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
let interactionTask = null;
|
let interactionTask = null;
|
||||||
|
|
@ -774,6 +803,9 @@ export class HarnessClient {
|
||||||
if (claim?.ownership !== ownership) return;
|
if (claim?.ownership !== ownership) return;
|
||||||
this.#interactionClaims.set(claimKey, claim);
|
this.#interactionClaims.set(claimKey, claim);
|
||||||
}
|
}
|
||||||
|
const toolCall = kind === 'approval' && ownership && typeof payload.callId === 'string'
|
||||||
|
? this.#interactionClaims.get(claimKey)?.ownership.toolCalls.get(payload.callId)
|
||||||
|
: undefined;
|
||||||
dispatch(onInteraction, Object.freeze({
|
dispatch(onInteraction, Object.freeze({
|
||||||
kind,
|
kind,
|
||||||
interactionId,
|
interactionId,
|
||||||
|
|
@ -783,6 +815,7 @@ export class HarnessClient {
|
||||||
recovered: ownership
|
recovered: ownership
|
||||||
? this.#interactionClaims.get(claimKey)?.recovered === true
|
? this.#interactionClaims.get(claimKey)?.recovered === true
|
||||||
: false,
|
: false,
|
||||||
|
...(toolCall ? { toolCall } : {}),
|
||||||
reconnect: close,
|
reconnect: close,
|
||||||
respond: (result, options = {}) => this.respondInteraction(
|
respond: (result, options = {}) => this.respondInteraction(
|
||||||
envelope.rpcId,
|
envelope.rpcId,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { runWorkspaceCommand } from './workspace-command.mjs';
|
import { runWorkspaceCommand } from './workspace-command.mjs';
|
||||||
import { askInWorkspaceSession } from './workspace-session.mjs';
|
import { askInWorkspaceSession } from './workspace-session.mjs';
|
||||||
|
import { HarnessApprovalQueue } from './harness-approval.mjs';
|
||||||
import {
|
import {
|
||||||
harnessAnswerForQuestion,
|
harnessAnswerForQuestion,
|
||||||
harnessQuestionText,
|
harnessQuestionText,
|
||||||
|
|
@ -43,6 +44,8 @@ export class TextHarnessBridge {
|
||||||
#pendingInteractions = new Map();
|
#pendingInteractions = new Map();
|
||||||
#interactionKeys = new Map();
|
#interactionKeys = new Map();
|
||||||
#acceptedMessageIds = new Set();
|
#acceptedMessageIds = new Set();
|
||||||
|
#approvalTasks = new Set();
|
||||||
|
#approvals;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
descriptor,
|
descriptor,
|
||||||
|
|
@ -65,6 +68,10 @@ export class TextHarnessBridge {
|
||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
this.#replyTimeoutMs = replyTimeoutMs;
|
this.#replyTimeoutMs = replyTimeoutMs;
|
||||||
this.#signal = signal;
|
this.#signal = signal;
|
||||||
|
this.#approvals = new HarnessApprovalQueue({
|
||||||
|
label: descriptor.key,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get status() {
|
get status() {
|
||||||
|
|
@ -86,6 +93,35 @@ export class TextHarnessBridge {
|
||||||
|
|
||||||
const key = `${kind}:${conversationId}`;
|
const key = `${kind}:${conversationId}`;
|
||||||
const pending = this.#pendingInteractions.get(key);
|
const pending = this.#pendingInteractions.get(key);
|
||||||
|
const approval = this.#approvals.claimReply({
|
||||||
|
key,
|
||||||
|
actor: senderId,
|
||||||
|
messageId,
|
||||||
|
text: normalized.content,
|
||||||
|
addressed: normalized.kind !== 'group' || normalized.addressed === true,
|
||||||
|
hasPendingQuestion: Boolean(pending),
|
||||||
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
||||||
|
? pending.queue
|
||||||
|
: null,
|
||||||
|
isQuestionPending: () => this.#pendingInteractions.has(key),
|
||||||
|
send: (text) => this.#bot.sendText(normalized.replyTarget, text),
|
||||||
|
});
|
||||||
|
if (approval) {
|
||||||
|
let task;
|
||||||
|
task = approval.process(async () => {
|
||||||
|
if (this.#state.hasSeen(messageId)) return false;
|
||||||
|
await this.#state.markSeen(messageId);
|
||||||
|
this.#status.messagesReceived += 1;
|
||||||
|
this.#status.lastMessageAt = new Date().toISOString();
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.#acceptedMessageIds.delete(messageId);
|
||||||
|
this.#approvalTasks.delete(task);
|
||||||
|
});
|
||||||
|
this.#approvalTasks.add(task);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
if (pending && pending.actor !== senderId) {
|
if (pending && pending.actor !== senderId) {
|
||||||
return this.#enqueueMessage(normalized, messageId, senderId, key);
|
return this.#enqueueMessage(normalized, messageId, senderId, key);
|
||||||
}
|
}
|
||||||
|
|
@ -147,6 +183,7 @@ export class TextHarnessBridge {
|
||||||
...[...this.#pendingInteractions.values()].flatMap((pending) => (
|
...[...this.#pendingInteractions.values()].flatMap((pending) => (
|
||||||
pending.queue ? [pending.queue] : []
|
pending.queue ? [pending.queue] : []
|
||||||
)),
|
)),
|
||||||
|
...this.#approvalTasks,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,7 +313,10 @@ export class TextHarnessBridge {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await this.#cancelPendingInteraction(conversationKey);
|
await Promise.allSettled([
|
||||||
|
this.#cancelPendingInteraction(conversationKey),
|
||||||
|
this.#approvals.closeRoute(conversationKey),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -434,8 +474,14 @@ export class TextHarnessBridge {
|
||||||
target,
|
target,
|
||||||
requiresMention,
|
requiresMention,
|
||||||
}) {
|
}) {
|
||||||
// Approval remains deliberately unanswered until #5 supplies a policy that
|
if (interaction?.kind === 'approval') {
|
||||||
// can prove both the actor and the conversation allowed to decide it.
|
return this.#approvals.handleRequested(interaction, {
|
||||||
|
key,
|
||||||
|
actor,
|
||||||
|
requiresMention,
|
||||||
|
send: (text) => this.#bot.sendText(target, text),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (interaction?.kind !== 'question') return;
|
if (interaction?.kind !== 'question') return;
|
||||||
const questions = interaction?.payload?.questions;
|
const questions = interaction?.payload?.questions;
|
||||||
const interactionId = cleanText(interaction?.interactionId) || cleanText(interaction?.rpcId);
|
const interactionId = cleanText(interaction?.interactionId) || cleanText(interaction?.rpcId);
|
||||||
|
|
@ -510,7 +556,11 @@ export class TextHarnessBridge {
|
||||||
await this.#presentInteraction(pending);
|
await this.#presentInteraction(pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
#handleInteractionResolved(resolution) {
|
async #handleInteractionResolved(resolution) {
|
||||||
|
if (resolution?.kind === 'approval') {
|
||||||
|
await this.#approvals.handleResolved(resolution);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const interactionId = cleanText(resolution?.interactionId);
|
const interactionId = cleanText(resolution?.interactionId);
|
||||||
if (resolution?.kind !== 'question' || !interactionId) return;
|
if (resolution?.kind !== 'question' || !interactionId) return;
|
||||||
const key = this.#interactionKeys.get(interactionId);
|
const key = this.#interactionKeys.get(interactionId);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
harnessQuestionText,
|
harnessQuestionText,
|
||||||
validHarnessQuestion,
|
validHarnessQuestion,
|
||||||
} from '../shared/harness-question.mjs';
|
} from '../shared/harness-question.mjs';
|
||||||
|
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
||||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||||
|
|
||||||
|
|
@ -114,6 +115,8 @@ export class WecomHarnessBridge {
|
||||||
#pendingInteractions = new Map();
|
#pendingInteractions = new Map();
|
||||||
#interactionKeys = new Map();
|
#interactionKeys = new Map();
|
||||||
#acceptedMessageIds = new Set();
|
#acceptedMessageIds = new Set();
|
||||||
|
#approvalTasks = new Set();
|
||||||
|
#approvals;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
client,
|
client,
|
||||||
|
|
@ -137,6 +140,7 @@ export class WecomHarnessBridge {
|
||||||
this.#replyTimeoutMs = replyTimeoutMs;
|
this.#replyTimeoutMs = replyTimeoutMs;
|
||||||
this.#generateReqId = generateStreamId;
|
this.#generateReqId = generateStreamId;
|
||||||
this.#signal = signal;
|
this.#signal = signal;
|
||||||
|
this.#approvals = new HarnessApprovalQueue({ label: 'wecom', logger });
|
||||||
}
|
}
|
||||||
|
|
||||||
get status() {
|
get status() {
|
||||||
|
|
@ -159,6 +163,35 @@ export class WecomHarnessBridge {
|
||||||
const key = conversationKey(frame);
|
const key = conversationKey(frame);
|
||||||
this.#acceptedMessageIds.add(messageId);
|
this.#acceptedMessageIds.add(messageId);
|
||||||
const pending = this.#pendingInteractions.get(key);
|
const pending = this.#pendingInteractions.get(key);
|
||||||
|
const approval = this.#approvals.claimReply({
|
||||||
|
key,
|
||||||
|
actor: senderId,
|
||||||
|
messageId,
|
||||||
|
text: messageText(frame),
|
||||||
|
addressed: true,
|
||||||
|
hasPendingQuestion: Boolean(pending),
|
||||||
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
||||||
|
? pending.queue
|
||||||
|
: null,
|
||||||
|
isQuestionPending: () => this.#pendingInteractions.has(key),
|
||||||
|
send: (text) => this.#sendImmediate(frame, chatId, text),
|
||||||
|
});
|
||||||
|
if (approval) {
|
||||||
|
let task;
|
||||||
|
task = approval.process(async () => {
|
||||||
|
if (this.#state.hasSeen(messageId)) return false;
|
||||||
|
await this.#state.markSeen(messageId);
|
||||||
|
this.#status.messagesReceived += 1;
|
||||||
|
this.#status.lastMessageAt = new Date().toISOString();
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.#acceptedMessageIds.delete(messageId);
|
||||||
|
this.#approvalTasks.delete(task);
|
||||||
|
});
|
||||||
|
this.#approvalTasks.add(task);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
if (pending && pending.actor !== senderId) {
|
if (pending && pending.actor !== senderId) {
|
||||||
return this.#enqueueMessage(frame, messageId, key);
|
return this.#enqueueMessage(frame, messageId, key);
|
||||||
}
|
}
|
||||||
|
|
@ -215,6 +248,7 @@ export class WecomHarnessBridge {
|
||||||
...[...this.#pendingInteractions.values()].flatMap((pending) => (
|
...[...this.#pendingInteractions.values()].flatMap((pending) => (
|
||||||
pending.queue ? [pending.queue] : []
|
pending.queue ? [pending.queue] : []
|
||||||
)),
|
)),
|
||||||
|
...this.#approvalTasks,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -355,7 +389,10 @@ export class WecomHarnessBridge {
|
||||||
this.#logger.error?.('[dsh-im:wecom] failed to send the safe error reply');
|
this.#logger.error?.('[dsh-im:wecom] failed to send the safe error reply');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await this.#cancelPendingInteraction(key);
|
await Promise.allSettled([
|
||||||
|
this.#cancelPendingInteraction(key),
|
||||||
|
this.#approvals.closeRoute(key),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -481,7 +518,14 @@ export class WecomHarnessBridge {
|
||||||
chatId,
|
chatId,
|
||||||
requiresMention,
|
requiresMention,
|
||||||
}) {
|
}) {
|
||||||
// Approval remains unanswered until #5 supplies an authenticated policy.
|
if (interaction?.kind === 'approval') {
|
||||||
|
return this.#approvals.handleRequested(interaction, {
|
||||||
|
key,
|
||||||
|
actor,
|
||||||
|
requiresMention,
|
||||||
|
send: (text) => this.#sendActive(chatId, text),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (interaction?.kind !== 'question') return;
|
if (interaction?.kind !== 'question') return;
|
||||||
const questions = interaction?.payload?.questions;
|
const questions = interaction?.payload?.questions;
|
||||||
const interactionId = typeof interaction?.interactionId === 'string'
|
const interactionId = typeof interaction?.interactionId === 'string'
|
||||||
|
|
@ -555,7 +599,11 @@ export class WecomHarnessBridge {
|
||||||
await this.#presentInteraction(pending);
|
await this.#presentInteraction(pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
#handleInteractionResolved(resolution) {
|
async #handleInteractionResolved(resolution) {
|
||||||
|
if (resolution?.kind === 'approval') {
|
||||||
|
await this.#approvals.handleResolved(resolution);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const interactionId = resolution?.interactionId;
|
const interactionId = resolution?.interactionId;
|
||||||
if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
|
if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
|
||||||
const key = this.#interactionKeys.get(interactionId);
|
const key = this.#interactionKeys.get(interactionId);
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ function authenticatedHeaders(token) {
|
||||||
function baseInfo() {
|
function baseInfo() {
|
||||||
return {
|
return {
|
||||||
channel_version: WEIXIN_PROTOCOL_VERSION,
|
channel_version: WEIXIN_PROTOCOL_VERSION,
|
||||||
bot_agent: 'DeepSeekHarness/0.7.1',
|
bot_agent: 'DeepSeekHarness/0.7.2',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
harnessQuestionText,
|
harnessQuestionText,
|
||||||
validHarnessQuestion,
|
validHarnessQuestion,
|
||||||
} from '../shared/harness-question.mjs';
|
} from '../shared/harness-question.mjs';
|
||||||
|
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
||||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||||
|
|
||||||
|
|
@ -68,6 +69,8 @@ export class WeixinHarnessBridge {
|
||||||
#pendingInteractions = new Map();
|
#pendingInteractions = new Map();
|
||||||
#interactionKeys = new Map();
|
#interactionKeys = new Map();
|
||||||
#acceptedMessageIds = new Set();
|
#acceptedMessageIds = new Set();
|
||||||
|
#approvalTasks = new Set();
|
||||||
|
#approvals;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
api,
|
api,
|
||||||
|
|
@ -96,6 +99,7 @@ export class WeixinHarnessBridge {
|
||||||
this.#replyTimeoutMs = replyTimeoutMs;
|
this.#replyTimeoutMs = replyTimeoutMs;
|
||||||
this.#maxMessageChars = maxMessageChars;
|
this.#maxMessageChars = maxMessageChars;
|
||||||
this.#signal = signal;
|
this.#signal = signal;
|
||||||
|
this.#approvals = new HarnessApprovalQueue({ label: 'weixin', logger });
|
||||||
}
|
}
|
||||||
|
|
||||||
get status() {
|
get status() {
|
||||||
|
|
@ -111,7 +115,38 @@ export class WeixinHarnessBridge {
|
||||||
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
|
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
|
||||||
this.#acceptedMessageIds.add(messageId);
|
this.#acceptedMessageIds.add(messageId);
|
||||||
const key = conversationKey(sender);
|
const key = conversationKey(sender);
|
||||||
|
const contextToken = nonEmptyString(message?.context_token) ?? undefined;
|
||||||
|
const runId = nonEmptyString(message?.run_id) ?? undefined;
|
||||||
const pending = this.#pendingInteractions.get(key);
|
const pending = this.#pendingInteractions.get(key);
|
||||||
|
const approval = this.#approvals.claimReply({
|
||||||
|
key,
|
||||||
|
actor: sender,
|
||||||
|
messageId,
|
||||||
|
text: extractWeixinText(message),
|
||||||
|
addressed: true,
|
||||||
|
hasPendingQuestion: Boolean(pending),
|
||||||
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
||||||
|
? pending.queue
|
||||||
|
: null,
|
||||||
|
isQuestionPending: () => this.#pendingInteractions.has(key),
|
||||||
|
send: (text) => this.#send(sender, text, contextToken, runId),
|
||||||
|
});
|
||||||
|
if (approval) {
|
||||||
|
let task;
|
||||||
|
task = approval.process(async () => {
|
||||||
|
if (this.#state.hasSeen(messageId)) return false;
|
||||||
|
await this.#state.markSeen(messageId);
|
||||||
|
this.#status.messagesReceived += 1;
|
||||||
|
this.#status.lastMessageAt = new Date().toISOString();
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.#acceptedMessageIds.delete(messageId);
|
||||||
|
this.#approvalTasks.delete(task);
|
||||||
|
});
|
||||||
|
this.#approvalTasks.add(task);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
if (pending?.submitting || pending?.claimedReplyMessageId) {
|
if (pending?.submitting || pending?.claimedReplyMessageId) {
|
||||||
return this.#enqueueMessage(message, messageId, key);
|
return this.#enqueueMessage(message, messageId, key);
|
||||||
}
|
}
|
||||||
|
|
@ -157,6 +192,7 @@ export class WeixinHarnessBridge {
|
||||||
...[...this.#pendingInteractions.values()].flatMap((pending) => (
|
...[...this.#pendingInteractions.values()].flatMap((pending) => (
|
||||||
pending.queue ? [pending.queue] : []
|
pending.queue ? [pending.queue] : []
|
||||||
)),
|
)),
|
||||||
|
...this.#approvalTasks,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -235,7 +271,10 @@ export class WeixinHarnessBridge {
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
} finally {
|
} finally {
|
||||||
await this.#cancelPendingInteraction(key);
|
await Promise.allSettled([
|
||||||
|
this.#cancelPendingInteraction(key),
|
||||||
|
this.#approvals.closeRoute(key),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
await this.#send(sender, answer, contextToken, runId);
|
await this.#send(sender, answer, contextToken, runId);
|
||||||
await this.#state.markSeen(messageId);
|
await this.#state.markSeen(messageId);
|
||||||
|
|
@ -391,7 +430,13 @@ export class WeixinHarnessBridge {
|
||||||
contextToken,
|
contextToken,
|
||||||
runId,
|
runId,
|
||||||
}) {
|
}) {
|
||||||
// Approval remains fail-closed until #5 adds an authenticated policy.
|
if (interaction?.kind === 'approval') {
|
||||||
|
return this.#approvals.handleRequested(interaction, {
|
||||||
|
key,
|
||||||
|
actor,
|
||||||
|
send: (text) => this.#send(actor, text, contextToken, runId),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (interaction?.kind !== 'question') return;
|
if (interaction?.kind !== 'question') return;
|
||||||
const questions = interaction?.payload?.questions;
|
const questions = interaction?.payload?.questions;
|
||||||
const interactionId = typeof interaction?.interactionId === 'string'
|
const interactionId = typeof interaction?.interactionId === 'string'
|
||||||
|
|
@ -466,7 +511,11 @@ export class WeixinHarnessBridge {
|
||||||
await this.#presentInteraction(pending);
|
await this.#presentInteraction(pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
#handleInteractionResolved(resolution) {
|
async #handleInteractionResolved(resolution) {
|
||||||
|
if (resolution?.kind === 'approval') {
|
||||||
|
await this.#approvals.handleResolved(resolution);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const interactionId = resolution?.interactionId;
|
const interactionId = resolution?.interactionId;
|
||||||
if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
|
if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
|
||||||
const key = this.#interactionKeys.get(interactionId);
|
const key = this.#interactionKeys.get(interactionId);
|
||||||
|
|
|
||||||
|
|
@ -427,10 +427,113 @@ test('pending questions are isolated by DingTalk conversation', async () => {
|
||||||
await firstA;
|
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 fixture = stateFixture();
|
||||||
const sent = [];
|
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;
|
let secondQuestionResponse;
|
||||||
const bridge = new DingtalkHarnessBridge({
|
const bridge = new DingtalkHarnessBridge({
|
||||||
api: { sendText: async (request) => sent.push(request) },
|
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',
|
approvalId: 'approval-one',
|
||||||
toolName: 'bash',
|
toolName: 'bash',
|
||||||
},
|
},
|
||||||
respond: async () => { approvalResponses += 1; },
|
respond: async (result) => {
|
||||||
|
approvalResponse = result;
|
||||||
|
return { accepted: true };
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await options.onInteractionResolved({
|
await options.onInteractionResolved({
|
||||||
kind: 'question',
|
kind: 'question',
|
||||||
|
|
@ -504,8 +610,15 @@ test('question replays are deduplicated and approval interactions are never auto
|
||||||
details: {},
|
details: {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(sent.some(({ text }) => text.includes('approval')), false);
|
assert.deepEqual(approvalResponse, {
|
||||||
assert.equal(approvalResponses, 0);
|
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, '交互已取消');
|
assert.equal(sent.at(-1).text, '交互已取消');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -797,6 +797,256 @@ test('ask opens the interaction watcher before prompting and closes it with the
|
||||||
assert.equal(socket.readyState, 3);
|
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 () => {
|
test('interaction callbacks preserve frame order and watcher shutdown drains them', async () => {
|
||||||
const opened = deferred();
|
const opened = deferred();
|
||||||
const releaseRequested = 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]);
|
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 = [
|
const oldHistory = [
|
||||||
{ event: { seq: 1, type: 'turn/start', data: { turn: 1 } } },
|
{ 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),
|
onInteraction: (interaction) => received.push(interaction),
|
||||||
});
|
});
|
||||||
await prompted.promise;
|
await prompted.promise;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
await eventually(() => received.length === 1);
|
||||||
assert.deepEqual(received, []);
|
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();
|
controller.abort();
|
||||||
await Promise.allSettled([asking]);
|
await Promise.allSettled([asking]);
|
||||||
|
|
|
||||||
|
|
@ -382,10 +382,113 @@ test('pending Harness questions are isolated by Feishu conversation', async () =
|
||||||
await firstA;
|
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 fixture = stateFixture();
|
||||||
const sent = [];
|
const sent = [];
|
||||||
let approvalResponses = 0;
|
let approvalResponse;
|
||||||
let parallelQuestionResponse;
|
let parallelQuestionResponse;
|
||||||
const bridge = new FeishuHarnessBridge({
|
const bridge = new FeishuHarnessBridge({
|
||||||
client: textClient(async (message) => sent.push(message)),
|
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',
|
approvalId: 'approval-one',
|
||||||
toolName: 'bash',
|
toolName: 'bash',
|
||||||
},
|
},
|
||||||
respond: async () => { approvalResponses += 1; },
|
respond: async (result) => {
|
||||||
|
approvalResponse = result;
|
||||||
|
return { accepted: true };
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await options.onInteractionResolved({
|
await options.onInteractionResolved({
|
||||||
kind: 'question',
|
kind: 'question',
|
||||||
|
|
@ -461,8 +567,15 @@ test('question replays are deduplicated and approvals remain fail-closed in the
|
||||||
details: {},
|
details: {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(sent.some(({ text }) => text.includes('approval')), false);
|
assert.deepEqual(approvalResponse, {
|
||||||
assert.equal(approvalResponses, 0);
|
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, '交互已取消');
|
assert.equal(sent.at(-1).text, '交互已取消');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
test('QQ deduplicates question replays, cancels orphan questions, and keeps approvals fail-closed', async () => {
|
||||||
const fixture = stateFixture();
|
const fixture = stateFixture();
|
||||||
const sent = [];
|
const sent = [];
|
||||||
let approvalResponses = 0;
|
let approvalResponse;
|
||||||
let parallelResponse;
|
let parallelResponse;
|
||||||
let orphanResponse;
|
let orphanResponse;
|
||||||
const bridge = new QqHarnessBridge({
|
const bridge = new QqHarnessBridge({
|
||||||
|
|
@ -404,7 +503,7 @@ test('QQ deduplicates question replays, cancels orphan questions, and keeps appr
|
||||||
approvalId: 'qq-approval',
|
approvalId: 'qq-approval',
|
||||||
toolName: 'bash',
|
toolName: 'bash',
|
||||||
},
|
},
|
||||||
respond: async () => { approvalResponses += 1; },
|
respond: async (result) => { approvalResponse = result; },
|
||||||
});
|
});
|
||||||
await options.onInteractionResolved({
|
await options.onInteractionResolved({
|
||||||
kind: 'question',
|
kind: 'question',
|
||||||
|
|
@ -446,7 +545,14 @@ test('QQ deduplicates question replays, cancels orphan questions, and keeps appr
|
||||||
details: {},
|
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.equal(sent.some(({ text }) => text.includes('approval')), false);
|
||||||
assert.deepEqual(orphanResponse, {
|
assert.deepEqual(orphanResponse, {
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|
|
||||||
866
test/channels/shared/harness-approval.test.mjs
Normal file
866
test/channels/shared/harness-approval.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
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 { 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() {
|
function deferred() {
|
||||||
let resolve;
|
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 } = {}) {
|
function createBridge({ harness, state, bot, signal, logger } = {}) {
|
||||||
return new TextHarnessBridge({
|
return new TextHarnessBridge({
|
||||||
descriptor: { key: 'test', label: 'Test' },
|
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' });
|
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 () => {
|
test('isolates pending questions by normalized conversation key', async () => {
|
||||||
const fixture = stateFixture({
|
const fixture = stateFixture({
|
||||||
'direct:chat-a': 'session-a',
|
'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);
|
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 fixture = stateFixture();
|
||||||
const sent = [];
|
const sent = [];
|
||||||
let parallelResponse;
|
let parallelResponse;
|
||||||
let recoveredResponse;
|
let recoveredResponse;
|
||||||
let approvalResponses = 0;
|
let approvalResponse;
|
||||||
const bridge = createBridge({
|
const bridge = createBridge({
|
||||||
state: fixture.state,
|
state: fixture.state,
|
||||||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
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: '不应显示的并行问题' }],
|
questions: [{ id: 'parallel', question: '不应显示的并行问题' }],
|
||||||
respond: async (result) => { parallelResponse = result; },
|
respond: async (result) => { parallelResponse = result; },
|
||||||
}));
|
}));
|
||||||
await options.onInteraction({
|
await options.onInteraction(approvalInteraction({
|
||||||
kind: 'approval',
|
id: 'orphan-approval',
|
||||||
interactionId: 'approval-one',
|
|
||||||
rpcId: 'approval-rpc',
|
|
||||||
sessionId,
|
sessionId,
|
||||||
payload: { type: 'approval/requested', approvalId: 'approval-one' },
|
recovered: true,
|
||||||
respond: async () => { approvalResponses += 1; },
|
respond: async (result) => { approvalResponse = result; },
|
||||||
});
|
}));
|
||||||
await options.onInteraction(questionInteraction({
|
await options.onInteraction(questionInteraction({
|
||||||
id: 'orphan-question',
|
id: 'orphan-question',
|
||||||
sessionId,
|
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.',
|
message: 'Test safely cancelled an interaction left by an earlier client.',
|
||||||
details: {},
|
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 () => {
|
test('keeps a failed interaction response pending so the actor can retry', async () => {
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
test('question replays are deduplicated and approvals remain fail-closed', async () => {
|
||||||
const transport = testClient();
|
const transport = testClient();
|
||||||
const answered = deferred();
|
const answered = deferred();
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ test('sendText emits the iLink message envelope without reflecting the token in
|
||||||
assert.equal(body.msg.context_token, 'message-context');
|
assert.equal(body.msg.context_token, 'message-context');
|
||||||
assert.equal(body.msg.item_list[0].text_item.text, 'Harness reply');
|
assert.equal(body.msg.item_list[0].text_item.text, 'Harness reply');
|
||||||
assert.equal(body.base_info.channel_version, '2.4.6');
|
assert.equal(body.base_info.channel_version, '2.4.6');
|
||||||
assert.equal(body.base_info.bot_agent, 'DeepSeekHarness/0.7.1');
|
assert.equal(body.base_info.bot_agent, 'DeepSeekHarness/0.7.2');
|
||||||
assert.doesNotMatch(calls[0].init.body, /host-only-token/);
|
assert.doesNotMatch(calls[0].init.body, /host-only-token/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
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 () => {
|
test('Weixin deduplicates question replays, rejects parallel questions, and keeps approvals fail-closed', async () => {
|
||||||
const fixture = stateFixture();
|
const fixture = stateFixture();
|
||||||
const sent = [];
|
const sent = [];
|
||||||
let approvalResponses = 0;
|
let approvalResponse;
|
||||||
let parallelResponse;
|
let parallelResponse;
|
||||||
let orphanResponse;
|
let orphanResponse;
|
||||||
const bridge = new WeixinHarnessBridge({
|
const bridge = new WeixinHarnessBridge({
|
||||||
|
|
@ -246,7 +317,7 @@ test('Weixin deduplicates question replays, rejects parallel questions, and keep
|
||||||
approvalId: 'weixin-approval',
|
approvalId: 'weixin-approval',
|
||||||
toolName: 'bash',
|
toolName: 'bash',
|
||||||
},
|
},
|
||||||
respond: async () => { approvalResponses += 1; },
|
respond: async (result) => { approvalResponse = result; },
|
||||||
});
|
});
|
||||||
await options.onInteractionResolved({
|
await options.onInteractionResolved({
|
||||||
kind: 'question',
|
kind: 'question',
|
||||||
|
|
@ -288,7 +359,14 @@ test('Weixin deduplicates question replays, rejects parallel questions, and keep
|
||||||
details: {},
|
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.equal(sent.some(({ text }) => text.includes('approval')), false);
|
||||||
assert.deepEqual(orphanResponse, {
|
assert.deepEqual(orphanResponse, {
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue