mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 06:30:45 +08:00
2752 lines
97 KiB
JavaScript
2752 lines
97 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import test from 'node:test';
|
||
import manifest from '../../../package.json' with { type: 'json' };
|
||
|
||
import { DiscordHarnessBridge } from '../../../src/channels/discord/discord-bridge.mjs';
|
||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||
import { COMMAND_PERMISSION_DENIED_MESSAGE } from '../../../src/channels/shared/inbound-access.mjs';
|
||
import { InboundFileError } from '../../../src/channels/shared/inbound-file.mjs';
|
||
import {
|
||
OUTBOUND_ARTIFACT_TOOL,
|
||
OutboundArtifactRegistry,
|
||
createOutboundArtifactTool,
|
||
releaseOutboundArtifact,
|
||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||
import {
|
||
createTextBridgeStatus,
|
||
TextHarnessBridge,
|
||
} from '../../../src/channels/shared/text-harness-bridge.mjs';
|
||
import { SlackHarnessBridge } from '../../../src/channels/slack/slack-bridge.mjs';
|
||
import { TelegramHarnessBridge } from '../../../src/channels/telegram/telegram-bridge.mjs';
|
||
import { WhatsappHarnessBridge } from '../../../src/channels/whatsapp/whatsapp-bridge.mjs';
|
||
|
||
function deferred() {
|
||
let resolve;
|
||
let reject;
|
||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||
resolve = resolvePromise;
|
||
reject = rejectPromise;
|
||
});
|
||
return { promise, resolve, reject };
|
||
}
|
||
|
||
async function eventually(predicate, timeoutMs = 1_000) {
|
||
const deadline = Date.now() + timeoutMs;
|
||
while (Date.now() < deadline) {
|
||
if (predicate()) return;
|
||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||
}
|
||
assert.fail('condition was not met before timeout');
|
||
}
|
||
|
||
async function within(promise, timeoutMs, messageText) {
|
||
let timer;
|
||
try {
|
||
return await Promise.race([
|
||
promise,
|
||
new Promise((_, reject) => {
|
||
timer = setTimeout(() => reject(new Error(messageText)), timeoutMs);
|
||
}),
|
||
]);
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
function stateFixture(initialSessions = {}) {
|
||
const sessions = new Map(Object.entries(initialSessions));
|
||
const seen = new Set();
|
||
return {
|
||
sessions,
|
||
seen,
|
||
state: {
|
||
sessionFor(key) { return sessions.get(key) ?? null; },
|
||
async setSession(key, sessionId) {
|
||
sessions.set(key, sessionId);
|
||
return true;
|
||
},
|
||
async clearSession(key) { sessions.delete(key); },
|
||
hasSeen(messageId) { return seen.has(messageId); },
|
||
async markSeen(messageId) { seen.add(messageId); },
|
||
},
|
||
};
|
||
}
|
||
|
||
function message(messageId, content, overrides = {}) {
|
||
return {
|
||
messageId,
|
||
senderId: 'actor-a',
|
||
senderIsBot: false,
|
||
kind: 'direct',
|
||
conversationId: 'chat-a',
|
||
content,
|
||
addressed: true,
|
||
replyTarget: { id: `target-${messageId}` },
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function accessPolicy({ canExecuteCommands = false } = {}) {
|
||
return {
|
||
direct: {
|
||
mode: 'allowlist',
|
||
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
|
||
allowlist: { users: [{ id: 'actor-a', canExecuteCommands }] },
|
||
},
|
||
group: {
|
||
mode: 'allowlist',
|
||
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
|
||
allowlist: { users: [] },
|
||
},
|
||
};
|
||
}
|
||
|
||
function questionInteraction({
|
||
id = 'question-one',
|
||
sessionId = 'session-one',
|
||
questions = [{ id: 'answer', question: '请回答' }],
|
||
respond = async () => ({ accepted: true }),
|
||
...rest
|
||
} = {}) {
|
||
return {
|
||
kind: 'question',
|
||
interactionId: id,
|
||
rpcId: id,
|
||
sessionId,
|
||
payload: { type: 'question/requested', sessionId, questions },
|
||
respond,
|
||
...rest,
|
||
};
|
||
}
|
||
|
||
function 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,
|
||
reactions,
|
||
} = {}) {
|
||
return new TextHarnessBridge({
|
||
descriptor: { key: 'test', label: 'Test', reactions },
|
||
bot,
|
||
harness,
|
||
state,
|
||
signal,
|
||
logger: logger ?? { warn() {}, error() {} },
|
||
});
|
||
}
|
||
|
||
async function committedArtifact(t, fileName, content, suffix) {
|
||
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-text-artifact-${suffix}-`));
|
||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||
const sessionId = `session-artifact-${suffix}`;
|
||
const rpcId = `rpc-artifact-${suffix}`;
|
||
let nextId = 0;
|
||
const registry = new OutboundArtifactRegistry({
|
||
uuid: () => `${suffix}-${++nextId}`,
|
||
});
|
||
t.after(() => registry.clear());
|
||
const agent = {
|
||
session: {
|
||
header: { id: sessionId, cwd: workspace },
|
||
events: [
|
||
{ type: 'turn/start', data: { turn: 1 } },
|
||
{ type: 'user/message', data: { turn: 1, source: { rpcId } } },
|
||
],
|
||
},
|
||
};
|
||
await writeFile(join(workspace, fileName), content);
|
||
const tool = createOutboundArtifactTool({ registry });
|
||
const exec = {
|
||
name: OUTBOUND_ARTIFACT_TOOL,
|
||
callId: `call-${suffix}`,
|
||
rootCallId: `call-${suffix}`,
|
||
token: Symbol(`call-${suffix}`),
|
||
agent,
|
||
};
|
||
await tool.definition.execute({ path: fileName }, exec);
|
||
tool.onResult(exec, { isError: false });
|
||
const artifact = registry.take(sessionId, 1)[0];
|
||
t.after(() => releaseOutboundArtifact(artifact));
|
||
return artifact;
|
||
}
|
||
|
||
test('status reactions never delay safe errors, the conversation queue, or waitForIdle', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
let asks = 0;
|
||
let reactionAdds = 0;
|
||
const bridge = new TextHarnessBridge({
|
||
descriptor: {
|
||
key: 'test',
|
||
label: 'Test',
|
||
reactions: { processing: 'eyes', success: 'done', error: 'error' },
|
||
},
|
||
state: fixture.state,
|
||
status: createTextBridgeStatus(),
|
||
bot: {
|
||
addReaction: () => {
|
||
reactionAdds += 1;
|
||
return new Promise(() => {});
|
||
},
|
||
removeReaction: async () => undefined,
|
||
sendText: async (_target, text) => sent.push(text),
|
||
},
|
||
harness: {
|
||
sessionExists: async () => false,
|
||
createSession: async () => 'session-reaction-sidecar',
|
||
ask: async () => {
|
||
asks += 1;
|
||
if (asks === 1) throw new Error('private failure detail');
|
||
return '第二条正常完成';
|
||
},
|
||
},
|
||
logger: { warn() {}, error() {} },
|
||
});
|
||
|
||
const first = bridge.accept(message('reaction-hang-one', '第一条', {
|
||
reactionTarget: { id: 'source-one' },
|
||
}));
|
||
const second = bridge.accept(message('reaction-hang-two', '第二条', {
|
||
reactionTarget: { id: 'source-two' },
|
||
}));
|
||
|
||
await within(Promise.all([first, second, bridge.waitForIdle()]), 100,
|
||
'a hanging status reaction blocked normal message processing');
|
||
|
||
assert.equal(asks, 2);
|
||
assert.equal(reactionAdds, 2);
|
||
assert.equal(sent.some((text) => text.includes('第二条正常完成')), true);
|
||
assert.equal(sent.some((text) => text.includes('private failure detail')), false);
|
||
});
|
||
|
||
test('shared status reactions replace processing with success without joining the main task', async () => {
|
||
const fixture = stateFixture();
|
||
const reactions = [];
|
||
const bridge = new TextHarnessBridge({
|
||
descriptor: {
|
||
key: 'test',
|
||
label: 'Test',
|
||
reactions: { processing: 'eyes', success: 'done', error: 'error' },
|
||
},
|
||
state: fixture.state,
|
||
bot: {
|
||
addReaction: async (target, emoji) => {
|
||
reactions.push(['add', target.id, emoji]);
|
||
return emoji;
|
||
},
|
||
removeReaction: async (target, emoji) => {
|
||
reactions.push(['remove', target.id, emoji]);
|
||
},
|
||
sendText: async () => undefined,
|
||
},
|
||
harness: {
|
||
sessionExists: async () => false,
|
||
createSession: async () => 'session-reaction-success',
|
||
ask: async () => '完成',
|
||
},
|
||
logger: { warn() {}, error() {} },
|
||
});
|
||
|
||
await bridge.accept(message('reaction-success', '执行', {
|
||
reactionTarget: { id: 'source-success' },
|
||
}));
|
||
await eventually(() => reactions.length === 3);
|
||
|
||
assert.deepEqual(reactions, [
|
||
['add', 'source-success', 'eyes'],
|
||
['remove', 'source-success', 'eyes'],
|
||
['add', 'source-success', 'done'],
|
||
]);
|
||
});
|
||
|
||
test('all four shared text channels enforce fail-closed live access before side effects', async () => {
|
||
for (const [name, Bridge] of [
|
||
['slack', SlackHarnessBridge],
|
||
['telegram', TelegramHarnessBridge],
|
||
['discord', DiscordHarnessBridge],
|
||
['whatsapp', WhatsappHarnessBridge],
|
||
]) {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const asks = [];
|
||
let imageLoads = 0;
|
||
let sessionClears = 0;
|
||
let policyReadFails = true;
|
||
let settings = null;
|
||
const originalClearSession = fixture.state.clearSession.bind(fixture.state);
|
||
fixture.state.clearSession = async (...args) => {
|
||
sessionClears += 1;
|
||
return originalClearSession(...args);
|
||
};
|
||
const bridge = new Bridge({
|
||
accessPolicy: {
|
||
getSettings() {
|
||
if (policyReadFails) throw new Error('private policy read detail');
|
||
return settings;
|
||
},
|
||
isPrivileged: (senderIds) => senderIds.includes('owner-a'),
|
||
},
|
||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||
state: fixture.state,
|
||
harness: {
|
||
createSession: async () => `session-access-${name}`,
|
||
sessionExists: async () => true,
|
||
ask: async (_sessionId, content) => {
|
||
asks.push(content);
|
||
return `${name} allowed reply`;
|
||
},
|
||
},
|
||
});
|
||
|
||
await bridge.accept(message(`access-blocked-${name}`, 'blocked attachment', {
|
||
images: [{
|
||
mediaType: 'image/png',
|
||
load: async () => {
|
||
imageLoads += 1;
|
||
return Buffer.from('must not load');
|
||
},
|
||
}],
|
||
}));
|
||
assert.equal(imageLoads, 0, `${name} authorizes before downloading attachments`);
|
||
assert.deepEqual(asks, [], `${name} fail-closed denial never reaches Harness`);
|
||
assert.equal(fixture.seen.has(`access-blocked-${name}`), true,
|
||
`${name} records a denial for replay suppression`);
|
||
|
||
await bridge.accept(message(`access-owner-${name}`, '/help', { senderId: 'owner-a' }));
|
||
assert.match(sent.at(-1), /\/help/, `${name} owner bypasses a failed policy read`);
|
||
assert.deepEqual(asks, [], `${name} owner command remains local`);
|
||
|
||
policyReadFails = false;
|
||
settings = accessPolicy();
|
||
await bridge.accept(message(`access-blocked-${name}`, 'replayed after policy update'));
|
||
assert.deepEqual(asks, [], `${name} a denied replay cannot bypass the new policy`);
|
||
|
||
await bridge.accept(message(`access-ordinary-${name}`, 'allowed ordinary message'));
|
||
assert.equal(asks.length, 1, `${name} applies the live policy to a new event`);
|
||
assert.equal(sent.at(-1), `${name} allowed reply`, `${name} keeps the normal reply path`);
|
||
|
||
await bridge.accept(message(`access-command-denied-${name}`, '/new'));
|
||
assert.equal(asks.length, 1, `${name} denied command never reaches Harness`);
|
||
assert.equal(sessionClears, 0, `${name} denied command has no command side effect`);
|
||
assert.equal(sent.at(-1), COMMAND_PERMISSION_DENIED_MESSAGE, `${name} explains command denial`);
|
||
|
||
settings = accessPolicy({ canExecuteCommands: true });
|
||
await bridge.accept(message(`access-command-allowed-${name}`, '/new'));
|
||
assert.equal(sessionClears, 1, `${name} policy hot-update applies without rebuilding the bridge`);
|
||
assert.equal(asks.length, 1, `${name} allowed local command is not a model prompt`);
|
||
}
|
||
});
|
||
|
||
test('all four shared text channels resolve replies only after trigger and command gates', async () => {
|
||
for (const [name, Bridge] of [
|
||
['slack', SlackHarnessBridge],
|
||
['telegram', TelegramHarnessBridge],
|
||
['discord', DiscordHarnessBridge],
|
||
['whatsapp', WhatsappHarnessBridge],
|
||
]) {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const asks = [];
|
||
let replyLoads = 0;
|
||
const replyTo = () => ({
|
||
messageId: `quoted-${name}`,
|
||
load: async () => {
|
||
replyLoads += 1;
|
||
return {
|
||
messageId: `quoted-${name}`,
|
||
authorName: 'Original author',
|
||
content: '/new 只是被引用的原文',
|
||
attachments: [{ kind: 'file', name: 'brief.pdf' }],
|
||
};
|
||
},
|
||
});
|
||
const bridge = new Bridge({
|
||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||
state: fixture.state,
|
||
harness: {
|
||
createSession: async () => `session-reply-${name}`,
|
||
sessionExists: async () => true,
|
||
ask: async (_sessionId, content) => {
|
||
asks.push(content);
|
||
return `${name} reply`;
|
||
},
|
||
},
|
||
});
|
||
|
||
await bridge.accept(message(`reply-unaddressed-${name}`, 'ignored', {
|
||
kind: 'group',
|
||
conversationId: `group-${name}`,
|
||
addressed: false,
|
||
replyTo: replyTo(),
|
||
}));
|
||
await bridge.accept(message(`reply-help-${name}`, '/help', { replyTo: replyTo() }));
|
||
assert.equal(replyLoads, 0, `${name} does not fetch before trigger and command gates`);
|
||
assert.equal(asks.length, 0, name);
|
||
|
||
await bridge.accept(message(`reply-question-${name}`, '当前问题', { replyTo: replyTo() }));
|
||
assert.equal(replyLoads, 1, `${name} resolves once at the model prompt stage`);
|
||
assert.equal(asks.length, 1, name);
|
||
assert.equal(Array.isArray(asks[0]), true, name);
|
||
assert.match(asks[0][0].text, /^<dsh_im_reply_to>/, name);
|
||
assert.match(asks[0][0].text, /\/new 只是被引用的原文/, name);
|
||
assert.deepEqual(asks[0][1], { type: 'text', text: '当前问题' }, name);
|
||
}
|
||
});
|
||
|
||
test('runtime abort clears a queued interaction reply reaction instead of marking success', async () => {
|
||
const fixture = stateFixture();
|
||
const controller = new AbortController();
|
||
const invalidStarted = deferred();
|
||
const releaseInvalid = deferred();
|
||
const originalMarkSeen = fixture.state.markSeen.bind(fixture.state);
|
||
fixture.state.markSeen = async (messageId) => {
|
||
await originalMarkSeen(messageId);
|
||
if (messageId === 'blocking-invalid') {
|
||
invalidStarted.resolve();
|
||
await releaseInvalid.promise;
|
||
}
|
||
};
|
||
const sent = [];
|
||
const reactions = [];
|
||
const bridge = new TextHarnessBridge({
|
||
descriptor: {
|
||
key: 'test',
|
||
label: 'Test',
|
||
reactions: { processing: 'eyes', success: 'done', error: 'error' },
|
||
},
|
||
state: fixture.state,
|
||
signal: controller.signal,
|
||
bot: {
|
||
addReaction: async (target, emoji) => {
|
||
reactions.push(['add', target.id, emoji]);
|
||
return emoji;
|
||
},
|
||
removeReaction: async (target, emoji) => {
|
||
reactions.push(['remove', target.id, emoji]);
|
||
},
|
||
sendText: async (_target, text) => sent.push(text),
|
||
},
|
||
harness: {
|
||
sessionExists: async () => false,
|
||
createSession: async () => 'session-reaction-abort',
|
||
ask: async (sessionId, _text, options) => {
|
||
await options.onInteraction(questionInteraction({ sessionId }));
|
||
await new Promise((resolve, reject) => {
|
||
options.signal.addEventListener('abort', () => reject(options.signal.reason), {
|
||
once: true,
|
||
});
|
||
});
|
||
},
|
||
},
|
||
logger: { warn() {}, error() {} },
|
||
});
|
||
|
||
const processing = bridge.accept(message('interaction-start', '启动交互'));
|
||
await eventually(() => sent.some((text) => text.includes('请回答')));
|
||
const invalid = bridge.accept(message('blocking-invalid', ''));
|
||
await invalidStarted.promise;
|
||
const answer = bridge.accept(message('queued-answer', '有效回答', {
|
||
reactionTarget: { id: 'source-answer' },
|
||
}));
|
||
await eventually(() => reactions.some((call) => call[0] === 'add'));
|
||
|
||
controller.abort(new DOMException('runtime stopped', 'AbortError'));
|
||
releaseInvalid.resolve();
|
||
await Promise.all([processing, invalid, answer]);
|
||
await eventually(() => reactions.some((call) => call[0] === 'remove'));
|
||
|
||
assert.deepEqual(reactions, [
|
||
['add', 'source-answer', 'eyes'],
|
||
['remove', 'source-answer', 'eyes'],
|
||
]);
|
||
});
|
||
|
||
test('all four shared text channels execute /compact outside the model prompt path', async () => {
|
||
for (const [name, Bridge] of [
|
||
['slack', SlackHarnessBridge],
|
||
['telegram', TelegramHarnessBridge],
|
||
['discord', DiscordHarnessBridge],
|
||
['whatsapp', WhatsappHarnessBridge],
|
||
]) {
|
||
const fixture = stateFixture({ 'direct:chat-a': `session-${name}` });
|
||
const sent = [];
|
||
const executed = [];
|
||
const bridge = new Bridge({
|
||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||
state: fixture.state,
|
||
harness: {
|
||
executeCommand: async (sessionId, line) => {
|
||
executed.push({ sessionId, line });
|
||
return {
|
||
commandId: `command-${name}`,
|
||
result: { kind: 'success', text: 'Compacted 12 history items (~3456 tokens).' },
|
||
};
|
||
},
|
||
ask: async () => assert.fail('/compact must not be submitted to the model'),
|
||
},
|
||
});
|
||
|
||
await bridge.accept(message(`compact-${name}`, '/compact'));
|
||
|
||
assert.deepEqual(executed, [{ sessionId: `session-${name}`, line: '/compact' }]);
|
||
assert.deepEqual(sent, ['已压缩 12 条历史记录(约 3456 个 token)。']);
|
||
}
|
||
});
|
||
|
||
test('all four shared text channels expose structured model rate limits without changing connection state', async () => {
|
||
for (const [name, Bridge] of [
|
||
['slack', SlackHarnessBridge],
|
||
['telegram', TelegramHarnessBridge],
|
||
['discord', DiscordHarnessBridge],
|
||
['whatsapp', WhatsappHarnessBridge],
|
||
]) {
|
||
const fixture = stateFixture({ 'direct:chat-a': `session-${name}` });
|
||
const sent = [];
|
||
const status = {
|
||
...createTextBridgeStatus(),
|
||
connected: true,
|
||
connectionState: 'connected',
|
||
};
|
||
const bridge = new Bridge({
|
||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||
state: fixture.state,
|
||
status,
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async () => {
|
||
const error = new Error('private provider rate-limit detail');
|
||
error.code = 'harness-turn-failed';
|
||
error.providerCode = 'RATE_LIMIT';
|
||
throw error;
|
||
},
|
||
},
|
||
logger: { error() {} },
|
||
});
|
||
|
||
await bridge.accept(message(`rate-limit-${name}`, '触发模型限流'));
|
||
|
||
const failure = status.lastMessageError;
|
||
assert.equal(failure.code, 'MODEL_RATE_LIMIT', name);
|
||
assert.equal(failure.reason, 'MODEL_RATE_LIMIT', name);
|
||
assert.match(failure.referenceId, /^MF-[A-F0-9]{8}$/, name);
|
||
assert.match(sent.at(-1), /模型服务正在限流,本次任务未完成。请稍后重试。/, name);
|
||
assert.equal(sent.at(-1).endsWith(`参考号:${failure.referenceId}`), true, name);
|
||
assert.doesNotMatch(sent.at(-1), /private provider rate-limit detail/, name);
|
||
assert.equal(status.connected, true, name);
|
||
assert.equal(status.connectionState, 'connected', name);
|
||
}
|
||
});
|
||
|
||
test('all four shared text channels retain a traceable artifact failure until a clean turn succeeds', async (t) => {
|
||
for (const [name, Bridge] of [
|
||
['slack', SlackHarnessBridge],
|
||
['telegram', TelegramHarnessBridge],
|
||
['discord', DiscordHarnessBridge],
|
||
['whatsapp', WhatsappHarnessBridge],
|
||
]) {
|
||
const failedArtifact = await committedArtifact(
|
||
t,
|
||
`${name}-blocked.txt`,
|
||
'blocked',
|
||
`${name}-blocked`,
|
||
);
|
||
const sentArtifact = await committedArtifact(
|
||
t,
|
||
`${name}-sent.txt`,
|
||
'sent',
|
||
`${name}-sent`,
|
||
);
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const status = {
|
||
...createTextBridgeStatus(),
|
||
connected: true,
|
||
connectionState: 'connected',
|
||
};
|
||
let askCount = 0;
|
||
const bridge = new Bridge({
|
||
bot: {
|
||
sendText: async (_target, text) => {
|
||
sent.push(text);
|
||
return { id: `${name}-text-${sent.length}` };
|
||
},
|
||
sendFile: async (_target, file) => {
|
||
if (file.fileName.endsWith('-blocked.txt')) {
|
||
const error = new Error('private permission detail');
|
||
error.code = 'artifact-permission-required';
|
||
throw error;
|
||
}
|
||
return { id: `${name}-file` };
|
||
},
|
||
},
|
||
state: fixture.state,
|
||
status,
|
||
harness: {
|
||
createSession: async () => `session-${name}`,
|
||
sessionExists: async () => true,
|
||
ask: async (_sessionId, _text, options) => {
|
||
const artifact = askCount === 0 ? failedArtifact : sentArtifact;
|
||
askCount += 1;
|
||
await options.onArtifact(artifact);
|
||
return '文件已生成。';
|
||
},
|
||
},
|
||
logger: { warn() {}, error() {} },
|
||
});
|
||
|
||
await bridge.accept(message(`artifact-failed-${name}`, '生成文件'));
|
||
|
||
const failure = status.lastMessageError;
|
||
assert.equal(failure.code, 'CHANNEL_PERMISSION', name);
|
||
assert.equal(failure.reason, 'ARTIFACT_PERMISSION_REQUIRED', name);
|
||
assert.match(failure.referenceId, /^MF-[A-F0-9]{8}$/, name);
|
||
assert.equal(sent.at(-1).endsWith(`参考号:${failure.referenceId}`), true, name);
|
||
assert.doesNotMatch(sent.at(-1), /private permission detail/, name);
|
||
assert.equal(status.connected, true, name);
|
||
assert.equal(status.connectionState, 'connected', name);
|
||
|
||
await bridge.accept(message(`artifact-clean-${name}`, '再生成一个文件'));
|
||
assert.equal(status.lastMessageError, null, `${name} clears the prior failure after a clean turn`);
|
||
}
|
||
});
|
||
|
||
test('all four shared text channels collect a private batch and submit it in one ordered ask', async () => {
|
||
for (const [name, Bridge] of [
|
||
['slack', SlackHarnessBridge],
|
||
['telegram', TelegramHarnessBridge],
|
||
['discord', DiscordHarnessBridge],
|
||
['whatsapp', WhatsappHarnessBridge],
|
||
]) {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const asks = [];
|
||
const bridge = new Bridge({
|
||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||
state: fixture.state,
|
||
harness: {
|
||
createSession: async () => `session-batch-${name}`,
|
||
sessionExists: async () => true,
|
||
ask: async (sessionId, text) => {
|
||
asks.push({ sessionId, text });
|
||
return `batch reply ${name}`;
|
||
},
|
||
},
|
||
});
|
||
|
||
const accepted = [
|
||
bridge.accept(message(`batch-start-${name}`, '/batch')),
|
||
bridge.accept(message(`batch-first-${name}`, 'first line')),
|
||
bridge.accept(message(`batch-last-${name}`, 'last line')),
|
||
bridge.accept(message(`batch-send-${name}`, '/send')),
|
||
];
|
||
await Promise.all(accepted);
|
||
|
||
assert.equal(asks.length, 1, `${name} sends one ask`);
|
||
assert.equal(asks[0].sessionId, `session-batch-${name}`, name);
|
||
assert.match(asks[0].text, /\[消息 1\]\nfirst line/, name);
|
||
assert.match(asks[0].text, /\[消息 2\]\nlast line/, name);
|
||
assert.ok(
|
||
asks[0].text.indexOf('first line') < asks[0].text.indexOf('last line'),
|
||
`${name} preserves order`,
|
||
);
|
||
assert.equal(sent.filter((text) => text === `batch reply ${name}`).length, 1, name);
|
||
assert.equal(fixture.seen.has(`batch-first-${name}`), true, `${name} records first item`);
|
||
assert.equal(fixture.seen.has(`batch-last-${name}`), true, `${name} records last item`);
|
||
|
||
await bridge.accept(message(`normal-after-batch-${name}`, 'ordinary message'));
|
||
assert.equal(asks.length, 2, `${name} resumes ordinary chat`);
|
||
assert.equal(asks[1].text, 'ordinary message', `${name} ordinary prompt stays unchanged`);
|
||
}
|
||
});
|
||
|
||
test('shared text channels reserve addressed group batch commands without changing group chat', async () => {
|
||
const commands = ['/batch', '/BATCH now', '/send', '/send later', '/cancel', '/cancel all'];
|
||
for (const [name, Bridge] of [
|
||
['slack', SlackHarnessBridge],
|
||
['telegram', TelegramHarnessBridge],
|
||
['discord', DiscordHarnessBridge],
|
||
['whatsapp', WhatsappHarnessBridge],
|
||
]) {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const asks = [];
|
||
const bridge = new Bridge({
|
||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||
state: fixture.state,
|
||
harness: {
|
||
createSession: async () => `group-session-${name}`,
|
||
sessionExists: async () => true,
|
||
ask: async (_sessionId, text) => {
|
||
asks.push(text);
|
||
return `${name} group reply`;
|
||
},
|
||
},
|
||
});
|
||
|
||
for (const [index, command] of commands.entries()) {
|
||
await bridge.accept(message(`group-batch-${name}-${index}`, command, {
|
||
kind: 'group',
|
||
conversationId: `group-${name}`,
|
||
addressed: true,
|
||
}));
|
||
}
|
||
assert.equal(asks.length, 0, `${name} never submits a group batch command`);
|
||
assert.equal(sent.length, commands.length, `${name} replies to every addressed form`);
|
||
assert.equal(
|
||
sent.every((text) => text.includes('仅支持私聊')),
|
||
true,
|
||
`${name} explains the private-only boundary`,
|
||
);
|
||
|
||
const beforeUnaddressed = sent.length;
|
||
await bridge.accept(message(`group-unaddressed-${name}`, '/batch', {
|
||
kind: 'group',
|
||
conversationId: `group-${name}`,
|
||
addressed: false,
|
||
}));
|
||
assert.equal(sent.length, beforeUnaddressed, `${name} keeps mention rules intact`);
|
||
assert.equal(asks.length, 0, `${name} ignores an unaddressed group command`);
|
||
|
||
await bridge.accept(message(`group-normal-${name}`, 'ordinary addressed group message', {
|
||
kind: 'group',
|
||
conversationId: `group-${name}`,
|
||
addressed: true,
|
||
}));
|
||
assert.deepEqual(asks, ['ordinary addressed group message'], `${name} group chat is unchanged`);
|
||
}
|
||
});
|
||
|
||
test('private batch input enforces text-only collection, command blocking, the limit, and cancel', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
let asks = 0;
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||
harness: {
|
||
createSession: async () => 'unused-batch-session',
|
||
ask: async () => { asks += 1; return 'unexpected'; },
|
||
},
|
||
});
|
||
|
||
await bridge.accept(message('limited-start', '/batch'));
|
||
await bridge.accept(message('limited-image', 'image caption', {
|
||
images: [{ data: Buffer.from('image') }],
|
||
}));
|
||
await bridge.accept(message('limited-file', 'file caption', {
|
||
files: [{ name: 'data.txt', data: Buffer.from('data') }],
|
||
}));
|
||
let replyLoads = 0;
|
||
await bridge.accept(message('limited-reply', 'quoted caption', {
|
||
replyTo: {
|
||
content: 'quoted text',
|
||
load: async () => { replyLoads += 1; return { content: 'must not load' }; },
|
||
},
|
||
}));
|
||
await bridge.accept(message('limited-unsupported-media', 'video caption', {
|
||
plainText: false,
|
||
}));
|
||
await bridge.accept(message('limited-command', '/new'));
|
||
|
||
const itemPromises = [];
|
||
for (let index = 1; index <= 11; index += 1) {
|
||
itemPromises.push(bridge.accept(message(`limited-item-${index}`, `item ${index}`)));
|
||
}
|
||
await Promise.all(itemPromises);
|
||
await bridge.accept(message('limited-item-10', 'item 10 replay'));
|
||
await bridge.accept(message('limited-cancel', '/cancel'));
|
||
|
||
assert.equal(asks, 0, 'collection and cancellation never call Harness');
|
||
assert.equal(replyLoads, 0, 'batch collection never resolves quoted content');
|
||
assert.equal(sent.filter((text) => text.includes('目前仅支持文字')).length, 4);
|
||
assert.equal(sent.some((text) => text.includes('先发送 /send 提交或 /cancel 取消')), true);
|
||
assert.equal(sent.some((text) => /10\/10.*已满/.test(text)), true);
|
||
assert.equal(sent.some((text) => text.includes('这条消息未收录')), true);
|
||
assert.equal(sent.at(-1).includes('共丢弃 10 条消息'), true);
|
||
assert.equal(fixture.seen.has('limited-item-11'), true, 'a rejected eleventh item is recorded');
|
||
|
||
await bridge.accept(message('normal-after-cancel', 'normal after cancel'));
|
||
assert.equal(asks, 1, 'cancel restores the ordinary prompt path');
|
||
});
|
||
|
||
test('batch commands cannot answer a pending Harness question or approval', async () => {
|
||
for (const interactionKind of ['question', 'approval']) {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const interactionDone = deferred();
|
||
let interactionResponses = 0;
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||
harness: {
|
||
createSession: async () => `session-pending-${interactionKind}`,
|
||
ask: async (sessionId, _text, options) => {
|
||
const interaction = interactionKind === 'question'
|
||
? questionInteraction({
|
||
id: 'pending-batch-question',
|
||
sessionId,
|
||
questions: [{ id: 'answer', question: 'question waiting' }],
|
||
respond: async () => {
|
||
interactionResponses += 1;
|
||
interactionDone.resolve();
|
||
return { accepted: true };
|
||
},
|
||
})
|
||
: approvalInteraction({
|
||
id: 'pending-batch-approval',
|
||
sessionId,
|
||
reason: 'approval waiting',
|
||
respond: async () => {
|
||
interactionResponses += 1;
|
||
interactionDone.resolve();
|
||
return { accepted: true };
|
||
},
|
||
});
|
||
await options.onInteraction(interaction);
|
||
await interactionDone.promise;
|
||
return `${interactionKind} complete`;
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message(
|
||
`pending-${interactionKind}-start`,
|
||
`start ${interactionKind}`,
|
||
));
|
||
await eventually(() => sent.some((text) => text.includes(`${interactionKind} waiting`)));
|
||
|
||
await within(
|
||
Promise.all([
|
||
bridge.accept(message(`pending-${interactionKind}-batch`, '/batch')),
|
||
bridge.accept(message(`pending-${interactionKind}-send`, '/send')),
|
||
bridge.accept(message(`pending-${interactionKind}-cancel`, '/cancel')),
|
||
]),
|
||
250,
|
||
`batch commands waited behind a pending ${interactionKind}`,
|
||
);
|
||
assert.equal(interactionResponses, 0, `${interactionKind} was not answered by a batch command`);
|
||
assert.equal(sent.some((text) => text.includes('先完成当前交互')), true, interactionKind);
|
||
assert.equal(sent.some((text) => text.includes('没有待提交')), true, interactionKind);
|
||
assert.equal(sent.some((text) => text.includes('没有正在进行')), true, interactionKind);
|
||
|
||
await bridge.accept(message(
|
||
`pending-${interactionKind}-answer`,
|
||
interactionKind === 'question' ? 'yes' : '批准',
|
||
));
|
||
await processing;
|
||
assert.equal(interactionResponses, 1, `${interactionKind} accepts its real answer`);
|
||
}
|
||
});
|
||
|
||
test('a submitting batch rejects duplicate controls while a later ordinary message stays ordinary', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const batchStarted = deferred();
|
||
const releaseBatch = deferred();
|
||
const asks = [];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||
harness: {
|
||
createSession: async () => 'session-submitting-batch',
|
||
sessionExists: async () => true,
|
||
ask: async (_sessionId, text) => {
|
||
asks.push(text);
|
||
if (asks.length === 1) {
|
||
batchStarted.resolve();
|
||
await releaseBatch.promise;
|
||
return 'batch complete';
|
||
}
|
||
return 'ordinary complete';
|
||
},
|
||
},
|
||
});
|
||
|
||
await bridge.accept(message('submitting-start', '/batch'));
|
||
await bridge.accept(message('submitting-item', 'batched item'));
|
||
const submission = bridge.accept(message('submitting-send', '/send'));
|
||
await batchStarted.promise;
|
||
|
||
await within(
|
||
Promise.all([
|
||
bridge.accept(message('submitting-send-again', '/send')),
|
||
bridge.accept(message('submitting-cancel', '/cancel')),
|
||
]),
|
||
250,
|
||
'submitting controls waited behind the batch ask',
|
||
);
|
||
const ordinary = bridge.accept(message('submitting-ordinary', 'ordinary after send'));
|
||
await new Promise((resolve) => setImmediate(resolve));
|
||
assert.equal(asks.length, 1, 'the later ordinary message waits in the existing queue');
|
||
assert.equal(sent.some((text) => text.includes('请勿重复发送 /send')), true);
|
||
assert.equal(sent.some((text) => text.includes('已经提交,无法取消')), true);
|
||
|
||
releaseBatch.resolve();
|
||
await Promise.all([submission, ordinary]);
|
||
assert.equal(asks.length, 2);
|
||
assert.equal(asks[1], 'ordinary after send');
|
||
});
|
||
|
||
test('a failed batch submission is retained with an explicit retry and can be sent once again', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const asks = [];
|
||
let attempt = 0;
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||
harness: {
|
||
createSession: async () => 'session-retry-batch',
|
||
sessionExists: async () => true,
|
||
ask: async (_sessionId, text) => {
|
||
asks.push(text);
|
||
attempt += 1;
|
||
if (attempt === 1) throw new Error('transient Harness failure');
|
||
return 'retry complete';
|
||
},
|
||
},
|
||
});
|
||
|
||
await bridge.accept(message('retry-batch-start', '/batch'));
|
||
await bridge.accept(message('retry-batch-item', 'keep this item'));
|
||
await bridge.accept(message('retry-batch-send-one', '/send'));
|
||
|
||
assert.equal(asks.length, 1);
|
||
assert.equal(sent.at(-1).includes('已保留 1 条消息'), true);
|
||
assert.equal(sent.at(-1).includes('/send 重试'), true);
|
||
|
||
await bridge.accept(message('retry-batch-send-two', '/send'));
|
||
assert.equal(asks.length, 2);
|
||
assert.equal(asks[1], asks[0], 'retry reuses the retained immutable batch content');
|
||
assert.equal(sent.at(-1), 'retry complete');
|
||
|
||
await bridge.accept(message('retry-no-batch', '/send'));
|
||
assert.equal(sent.at(-1).includes('没有待提交'), true, 'successful retry clears the batch');
|
||
});
|
||
|
||
test('stopping a submitted batch clears it instead of offering a duplicate retry', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
let asks = 0;
|
||
const stopped = new Error('stopped by user');
|
||
stopped.code = 'turn-stopped';
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||
harness: {
|
||
createSession: async () => 'session-stopped-batch',
|
||
ask: async () => {
|
||
asks += 1;
|
||
throw stopped;
|
||
},
|
||
},
|
||
});
|
||
|
||
await bridge.accept(message('stopped-batch-start', '/batch'));
|
||
await bridge.accept(message('stopped-batch-item', 'do this once'));
|
||
await bridge.accept(message('stopped-batch-send', '/send'));
|
||
assert.equal(asks, 1);
|
||
assert.equal(sent.some((text) => text.includes('已保留')), false);
|
||
|
||
await bridge.accept(message('stopped-batch-send-again', '/send'));
|
||
assert.equal(asks, 1, 'a stopped submission cannot be retried as the same batch');
|
||
assert.equal(sent.at(-1).includes('没有待提交'), true);
|
||
});
|
||
|
||
test('shared text bridge passes a file-only message through askOptions.files', async () => {
|
||
const fixture = stateFixture();
|
||
const source = Object.freeze({
|
||
name: 'report.bin',
|
||
load: async () => Buffer.from([0x00, 0xff]),
|
||
});
|
||
const asks = [];
|
||
const sent = [];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||
harness: {
|
||
createSession: async () => 'session-file-only-inbound',
|
||
ask: async (sessionId, text, options) => {
|
||
asks.push({ sessionId, text, files: options.files });
|
||
return '文件已交给 Harness。';
|
||
},
|
||
},
|
||
});
|
||
|
||
await bridge.accept(message('inbound-file-only', '', { files: [source] }));
|
||
|
||
assert.deepEqual(asks, [{
|
||
sessionId: 'session-file-only-inbound',
|
||
text: '',
|
||
files: [source],
|
||
}]);
|
||
assert.deepEqual(sent, ['文件已交给 Harness。']);
|
||
});
|
||
|
||
test('shared text bridge keeps caption and native files in one Harness ask', async () => {
|
||
const fixture = stateFixture();
|
||
const sources = [
|
||
{ name: 'one.txt', data: Buffer.from('one') },
|
||
{ name: 'two.zip', load: async () => Buffer.from('two') },
|
||
];
|
||
const asks = [];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async () => undefined },
|
||
harness: {
|
||
createSession: async () => 'session-text-and-files',
|
||
ask: async (sessionId, text, options) => {
|
||
asks.push({ sessionId, text, files: options.files });
|
||
return '完成';
|
||
},
|
||
},
|
||
});
|
||
|
||
await bridge.accept(message('inbound-text-and-files', '请检查这两个文件', { files: sources }));
|
||
|
||
assert.deepEqual(asks, [{
|
||
sessionId: 'session-text-and-files',
|
||
text: '请检查这两个文件',
|
||
files: sources,
|
||
}]);
|
||
});
|
||
|
||
test('a command-looking file caption is an ordinary Harness prompt, not a bridge command', async () => {
|
||
const fixture = stateFixture({ 'direct:chat-a': 'session-existing' });
|
||
const source = { name: 'new.txt', data: Buffer.from('not a command') };
|
||
const asks = [];
|
||
const sent = [];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (sessionId, text, options) => {
|
||
asks.push({ sessionId, text, files: options.files });
|
||
return '附件消息已处理';
|
||
},
|
||
},
|
||
});
|
||
|
||
await bridge.accept(message('file-caption-command', '/new', { files: [source] }));
|
||
|
||
assert.equal(fixture.sessions.get('direct:chat-a'), 'session-existing');
|
||
assert.deepEqual(asks, [{
|
||
sessionId: 'session-existing',
|
||
text: '/new',
|
||
files: [source],
|
||
}]);
|
||
assert.deepEqual(sent, ['附件消息已处理']);
|
||
});
|
||
|
||
test('an inbound file cannot claim a pending Harness interaction answer', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const questionAnswered = deferred();
|
||
let interactionResponses = 0;
|
||
const asks = [];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||
harness: {
|
||
createSession: async () => 'session-file-during-question',
|
||
ask: async (sessionId, text, options) => {
|
||
asks.push({ sessionId, text, files: options.files });
|
||
await options.onInteraction(questionInteraction({
|
||
sessionId,
|
||
questions: [{ id: 'confirm', question: '是否继续?' }],
|
||
respond: async (result) => {
|
||
interactionResponses += 1;
|
||
questionAnswered.resolve(result);
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await questionAnswered.promise;
|
||
return '继续执行';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('question-with-file-start', '先询问我'));
|
||
await eventually(() => sent.some((text) => text.includes('是否继续?')));
|
||
await bridge.accept(message('question-file-attempt', 'yes', {
|
||
files: [{ name: 'answer.txt', data: Buffer.from('yes') }],
|
||
}));
|
||
|
||
assert.equal(interactionResponses, 0);
|
||
assert.equal(asks.length, 1, 'the attachment must not become a sibling ask while interaction is open');
|
||
assert.equal(sent.at(-1), '请用文字回答当前问题。');
|
||
|
||
await bridge.accept(message('question-text-answer', 'yes'));
|
||
await processing;
|
||
assert.equal(interactionResponses, 1);
|
||
assert.equal(sent.at(-1), '继续执行');
|
||
});
|
||
|
||
test('shared text bridge reports a safe native-file download failure', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||
harness: {
|
||
createSession: async () => 'session-file-download-failure',
|
||
ask: async () => {
|
||
throw new InboundFileError(
|
||
'inbound-file-download-failed',
|
||
'private channel URL https://secret.example/token failed',
|
||
'文件下载失败,请重新发送后再试。',
|
||
);
|
||
},
|
||
},
|
||
});
|
||
|
||
await bridge.accept(message('file-download-failure', '', {
|
||
files: [{ name: 'failed.txt', load: async () => Buffer.from('unused') }],
|
||
}));
|
||
|
||
assert.equal(sent.length, 1);
|
||
assert.match(sent[0], /^文件下载失败,请重新发送后再试。/);
|
||
assert.match(sent[0], /错误码:INPUT_INVALID;参考号:MF-[A-F0-9]{8}$/);
|
||
assert.doesNotMatch(sent[0], /secret|token|https:/i);
|
||
});
|
||
|
||
test('all four shared text channels list models and presets locally and advertise fast commands', async () => {
|
||
for (const [name, Bridge] of [
|
||
['slack', SlackHarnessBridge],
|
||
['telegram', TelegramHarnessBridge],
|
||
['discord', DiscordHarnessBridge],
|
||
['whatsapp', WhatsappHarnessBridge],
|
||
]) {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const presetUpdates = [];
|
||
let agentPreset = null;
|
||
let asks = 0;
|
||
let creates = 0;
|
||
const agentPresetCatalog = {
|
||
defaultId: 'preset-001',
|
||
items: Array.from({ length: 70 }, (_, index) => ({
|
||
id: `preset-${String(index + 1).padStart(3, '0')}`,
|
||
label: `${name} Preset ${index + 1} ${'x'.repeat(64)}`,
|
||
})),
|
||
};
|
||
const bridge = new Bridge({
|
||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||
state: fixture.state,
|
||
harness: {
|
||
listModels: async () => ({
|
||
groups: [{
|
||
id: `${name}-provider`,
|
||
name: `${name} Provider`,
|
||
models: [{ id: 'model-one', name: 'Model One' }],
|
||
}],
|
||
failures: [],
|
||
}),
|
||
agentPresetSettings: async () => ({ agentPreset, agentPresetCatalog }),
|
||
updateAgentPreset: async (value) => {
|
||
presetUpdates.push(value);
|
||
agentPreset = value;
|
||
return { agentPreset, agentPresetCatalog };
|
||
},
|
||
createSession: async () => { creates += 1; return `${name}-session`; },
|
||
ask: async () => { asks += 1; return 'unexpected model reply'; },
|
||
},
|
||
});
|
||
|
||
await bridge.accept(message(`models-${name}`, '/models'));
|
||
assert.match(sent.at(-1), new RegExp(`1\\. ${name}-provider/model-one`), name);
|
||
assert.equal(asks, 0, `${name} ask`);
|
||
assert.equal(creates, 0, `${name} create`);
|
||
assert.equal(fixture.sessions.size, 0, `${name} session binding`);
|
||
|
||
await bridge.accept(message(`reasoning-${name}`, '/reasoninglist'));
|
||
assert.match(sent.at(-1), /还没有会话/, `${name} reasoning command`);
|
||
assert.equal(asks, 0, `${name} reasoning ask`);
|
||
assert.equal(creates, 0, `${name} reasoning create`);
|
||
assert.equal(fixture.sessions.size, 0, `${name} reasoning session binding`);
|
||
|
||
const presetReplyStart = sent.length;
|
||
await bridge.accept(message(`presets-${name}`, '/presetlist'));
|
||
const presetReplies = sent.slice(presetReplyStart);
|
||
assert.ok(presetReplies.length > 1, `${name} sends every preset-list chunk`);
|
||
assert.match(presetReplies.join('\n'), /preset-070/, name);
|
||
assert.equal(asks, 0, `${name} preset ask`);
|
||
assert.equal(creates, 0, `${name} preset create`);
|
||
assert.equal(fixture.sessions.size, 0, `${name} preset session binding`);
|
||
|
||
await bridge.accept(message(`preset-current-${name}`, '/preset'));
|
||
assert.match(sent.at(-1), /跟随 Host 默认/, name);
|
||
assert.equal(asks, 0, `${name} preset current ask`);
|
||
assert.equal(creates, 0, `${name} preset current create`);
|
||
|
||
const selectReplyStart = sent.length;
|
||
await bridge.accept(message(`preset-select-${name}`, '/preset 2'));
|
||
assert.deepEqual(presetUpdates, ['preset-002'], `${name} scoped preset update`);
|
||
assert.equal(sent.length, selectReplyStart + 1, `${name} sends the complete select reply`);
|
||
assert.match(sent.at(-1), /preset-002/, name);
|
||
|
||
const defaultReplyStart = sent.length;
|
||
await bridge.accept(message(`preset-default-${name}`, '/preset --default'));
|
||
assert.deepEqual(presetUpdates, ['preset-002', null], `${name} scoped preset reset`);
|
||
assert.equal(sent.length, defaultReplyStart + 1, `${name} sends the complete default reply`);
|
||
assert.match(sent.at(-1), /跟随 Host 默认/, name);
|
||
assert.equal(asks, 0, `${name} mutation ask`);
|
||
assert.equal(creates, 0, `${name} mutation create`);
|
||
assert.equal(fixture.sessions.size, 0, `${name} mutation session binding`);
|
||
|
||
await bridge.accept(message(`help-${name}`, '/help'));
|
||
const help = sent.at(-1);
|
||
for (const command of [
|
||
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
|
||
'/presetlist', '/preset', '/preset --default', '/stop', '/steer', '/version',
|
||
]) {
|
||
assert.match(help, new RegExp(`\\${command}`), `${name} ${command}`);
|
||
}
|
||
assert.match(help, /\/model .*\[推理等级ID\]/, `${name} optional model reasoning effort`);
|
||
assert.match(help, /示例:先发 \/models,再发 \/model 2 \[推理等级ID\]/, `${name} model effort placeholder`);
|
||
assert.doesNotMatch(help, /\/model 2 high\b/, `${name} does not assume a reasoning effort ID`);
|
||
assert.match(help, /\/preset id:<ID>/, `${name} numeric preset ID selection`);
|
||
}
|
||
});
|
||
|
||
test('/version uses the shared command fast lane without accessing Harness', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||
harness: {},
|
||
});
|
||
|
||
await bridge.accept(message('plugin-version', '/version'));
|
||
|
||
assert.deepEqual(sent, [`dsh-im v${manifest.version}`]);
|
||
assert.equal(fixture.sessions.size, 0);
|
||
});
|
||
|
||
test('/stop uses the shared command fast lane without waiting for the running prompt', async () => {
|
||
const fixture = stateFixture({ 'direct:chat-a': 'session-running' });
|
||
const askStarted = deferred();
|
||
const releaseAsk = deferred();
|
||
const sent = [];
|
||
const controls = {};
|
||
let promptSettled = false;
|
||
const session = {
|
||
sessionExists: async () => true,
|
||
ask: async (_text, options) => {
|
||
controls.prompt = options.control;
|
||
askStarted.resolve();
|
||
await releaseAsk.promise;
|
||
return '原任务完成';
|
||
},
|
||
stopActiveTurn: async (control) => {
|
||
controls.stop = control;
|
||
return true;
|
||
},
|
||
};
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||
harness: { workspaceSession: () => session },
|
||
});
|
||
|
||
const prompt = bridge.accept(message('running-prompt', '执行一个长任务'))
|
||
.finally(() => { promptSettled = true; });
|
||
await askStarted.promise;
|
||
|
||
await within(
|
||
bridge.accept(message('running-stop', '/stop')),
|
||
250,
|
||
'/stop waited for the ordinary conversation queue',
|
||
);
|
||
assert.equal(promptSettled, false);
|
||
assert.equal(sent.includes('已请求停止当前任务。'), true);
|
||
assert.equal(controls.stop.owner, controls.prompt.owner);
|
||
assert.equal(controls.stop.key, controls.prompt.key);
|
||
|
||
releaseAsk.resolve();
|
||
await prompt;
|
||
assert.equal(sent.at(-1), '原任务完成');
|
||
});
|
||
|
||
test('a stopped shared-channel turn closes an opened stream instead of leaving a processing placeholder', async () => {
|
||
const fixture = stateFixture({ 'direct:chat-a': 'session-running' });
|
||
const finished = [];
|
||
let cancelled = 0;
|
||
const sent = [];
|
||
const stopped = new Error('stopped');
|
||
stopped.code = 'turn-stopped';
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: {
|
||
sendText: async (_target, text) => sent.push(text),
|
||
openStream: async () => ({
|
||
update() {},
|
||
async finish(text) { finished.push(text); },
|
||
cancel() { cancelled += 1; },
|
||
}),
|
||
},
|
||
harness: {
|
||
workspaceSession: () => ({
|
||
sessionExists: async () => true,
|
||
ask: async () => { throw stopped; },
|
||
}),
|
||
},
|
||
});
|
||
|
||
await bridge.accept(message('stopped-stream', '执行长任务'));
|
||
|
||
assert.deepEqual(finished, ['已停止。']);
|
||
assert.equal(cancelled, 0);
|
||
assert.deepEqual(sent, []);
|
||
});
|
||
|
||
test('a failed shared-channel turn finalizes an editable stream when fail is unavailable', async () => {
|
||
const fixture = stateFixture({ 'direct:chat-a': 'session-failed-stream' });
|
||
const finished = [];
|
||
const sent = [];
|
||
let cancelled = 0;
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: {
|
||
sendText: async (_target, text) => sent.push(text),
|
||
openStream: async () => ({
|
||
update() {},
|
||
async finish(text) { finished.push(text); },
|
||
cancel() { cancelled += 1; },
|
||
}),
|
||
},
|
||
harness: {
|
||
workspaceSession: () => ({
|
||
sessionExists: async () => true,
|
||
ask: async () => {
|
||
const error = new Error('private provider rate-limit details');
|
||
error.code = 'harness-turn-failed';
|
||
error.providerCode = 'RATE_LIMIT';
|
||
throw error;
|
||
},
|
||
}),
|
||
},
|
||
});
|
||
|
||
await bridge.accept(message('failed-editable-stream', '执行任务'));
|
||
|
||
assert.equal(finished.length, 1);
|
||
assert.match(finished[0], /模型服务正在限流/);
|
||
assert.match(finished[0], /错误码:MODEL_RATE_LIMIT;参考号:MF-[A-F0-9]{8}/);
|
||
assert.doesNotMatch(finished[0], /private provider rate-limit details/);
|
||
assert.equal(cancelled, 0);
|
||
assert.deepEqual(sent, []);
|
||
});
|
||
|
||
test('an unknown final delivery receipt is reported without rerunning the prompt', async () => {
|
||
const fixture = stateFixture({ 'direct:chat-a': 'session-unknown-delivery' });
|
||
const notices = [];
|
||
let asks = 0;
|
||
const status = {
|
||
...createTextBridgeStatus(),
|
||
connected: true,
|
||
connectionState: 'connected',
|
||
};
|
||
const bridge = new TextHarnessBridge({
|
||
descriptor: { key: 'test', label: 'Test' },
|
||
state: fixture.state,
|
||
status,
|
||
bot: {
|
||
sendDelivery: async () => ({
|
||
presentation: 'test-final',
|
||
providerMessageIds: ['possibly-sent-answer'],
|
||
deliveryOutcome: 'unknown',
|
||
reason: 'provider-timeout',
|
||
}),
|
||
sendText: async (_target, text) => {
|
||
notices.push(text);
|
||
return { providerMessageIds: ['uncertain-notice'] };
|
||
},
|
||
},
|
||
harness: {
|
||
workspaceSession: () => ({
|
||
sessionExists: async () => true,
|
||
ask: async () => {
|
||
asks += 1;
|
||
return '可能已经送达的回答';
|
||
},
|
||
}),
|
||
},
|
||
logger: { warn() {}, error() {} },
|
||
});
|
||
|
||
const receipt = await bridge.accept(message('unknown-final-delivery', '回答问题'));
|
||
|
||
assert.equal(asks, 1);
|
||
assert.equal(status.lastMessageError.code, 'CHANNEL_DELIVERY_UNCERTAIN');
|
||
assert.match(notices.at(-1), /发送结果未能确认.*不要立即重复提交/);
|
||
assert.equal(notices.at(-1).endsWith(`参考号:${status.lastMessageError.referenceId}`), true);
|
||
assert.deepEqual(receipt.providerMessageIds, ['possibly-sent-answer']);
|
||
assert.equal(receipt.deliveryOutcome, 'unknown');
|
||
assert.equal(status.connected, true);
|
||
assert.equal(status.connectionState, 'connected');
|
||
});
|
||
|
||
test('shared text artifact delivery sends text first and each materialized file in order', async (t) => {
|
||
const first = await committedArtifact(t, 'result.html', '<h1>result</h1>', 'success-html');
|
||
const second = await committedArtifact(t, 'notes.txt', 'notes', 'success-text');
|
||
const fixture = stateFixture();
|
||
const order = [];
|
||
const files = [];
|
||
const target = { id: 'artifact-success-target' };
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: {
|
||
sendText: async (_target, text) => {
|
||
order.push(`text:${text}`);
|
||
return { id: 'text-success' };
|
||
},
|
||
sendFile: async (receivedTarget, file) => {
|
||
order.push(`file:${file.fileName}`);
|
||
files.push({ target: receivedTarget, file });
|
||
return { message_id: `file-${files.length}` };
|
||
},
|
||
},
|
||
harness: {
|
||
createSession: async () => 'session-artifact-success',
|
||
ask: async (_sessionId, _text, options) => {
|
||
await options.onArtifact(first);
|
||
await options.onArtifact(second);
|
||
return '结果文件如下。';
|
||
},
|
||
},
|
||
});
|
||
|
||
const receipt = await bridge.accept(message(
|
||
'artifact-success',
|
||
'生成结果文件',
|
||
{ replyTarget: target },
|
||
));
|
||
|
||
assert.deepEqual(order, [
|
||
'text:结果文件如下。',
|
||
'file:result.html',
|
||
'file:notes.txt',
|
||
]);
|
||
assert.equal(files[0].target, target);
|
||
assert.equal(files[0].file.bytes.toString(), '<h1>result</h1>');
|
||
assert.equal(files[1].file.bytes.toString(), 'notes');
|
||
assert.equal(bridge.status.artifactsSent, 2);
|
||
assert.deepEqual(receipt, {
|
||
schemaVersion: 1,
|
||
deliveryId: 'artifact-success',
|
||
presentation: 'test-text-and-files',
|
||
providerMessageIds: ['text-success', 'file-1', 'file-2'],
|
||
artifacts: [
|
||
{ artifactId: 'success-html-1', outcome: 'sent' },
|
||
{ artifactId: 'success-text-1', outcome: 'sent' },
|
||
],
|
||
});
|
||
});
|
||
|
||
test('a file-only shared text reply shows one neutral completion message before the file', async (t) => {
|
||
const artifact = await committedArtifact(t, 'only.txt', 'only file', 'file-only');
|
||
const fixture = stateFixture();
|
||
const order = [];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: {
|
||
sendText: async (_target, text) => order.push(`text:${text}`),
|
||
sendFile: async (_target, file) => order.push(`file:${file.fileName}`),
|
||
},
|
||
harness: {
|
||
createSession: async () => 'session-file-only',
|
||
ask: async (_sessionId, _text, options) => {
|
||
await options.onArtifact(artifact);
|
||
return ' ';
|
||
},
|
||
},
|
||
});
|
||
|
||
await bridge.accept(message('artifact-file-only', '只生成并发送文件'));
|
||
|
||
assert.deepEqual(order, ['text:任务已完成。', 'file:only.txt']);
|
||
});
|
||
|
||
test('shared text delivery still attempts registered files when its final text cannot be sent', async (t) => {
|
||
const artifact = await committedArtifact(t, 'text-failed.txt', 'still delivered', 'text-failed');
|
||
const fixture = stateFixture();
|
||
const files = [];
|
||
const texts = [];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: {
|
||
sendText: async (_target, text) => {
|
||
texts.push(text);
|
||
if (texts.length === 1) throw new Error('private text transport failure');
|
||
},
|
||
sendFile: async (_target, file) => {
|
||
files.push(file.fileName);
|
||
return { key: { id: 'file-after-text-failure' } };
|
||
},
|
||
},
|
||
harness: {
|
||
createSession: async () => 'session-text-failed',
|
||
ask: async (_sessionId, _text, options) => {
|
||
await options.onArtifact(artifact);
|
||
return '文字回答';
|
||
},
|
||
},
|
||
});
|
||
|
||
const receipt = await bridge.accept(message('artifact-text-failed', '生成文件'));
|
||
|
||
assert.deepEqual(files, ['text-failed.txt']);
|
||
assert.deepEqual(texts, ['文字回答']);
|
||
assert.equal(bridge.status.artifactsSent, 1);
|
||
assert.equal(bridge.status.messagesReplied, 1);
|
||
assert.equal(bridge.status.lastMessageError.code, 'CHANNEL_DELIVERY_UNCERTAIN');
|
||
assert.match(bridge.status.lastMessageError.referenceId, /^MF-[A-F0-9]{8}$/);
|
||
assert.deepEqual(receipt.providerMessageIds, ['file-after-text-failure']);
|
||
assert.deepEqual(receipt.artifacts, [{ artifactId: 'text-failed-1', outcome: 'sent' }]);
|
||
});
|
||
|
||
test('shared text artifact provider failures are isolated from text and later files', async (t) => {
|
||
const providerFailure = await committedArtifact(t, 'provider.txt', 'bad', 'provider-failure');
|
||
const oversized = await committedArtifact(t, 'oversized.txt', '123456789', 'oversized');
|
||
const success = await committedArtifact(t, 'success.txt', 'ok', 'partial-success');
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const attempted = [];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: {
|
||
sendText: async (_target, text) => {
|
||
sent.push(text);
|
||
return { id: `text-partial-${sent.length}` };
|
||
},
|
||
sendFile: async (_target, file) => {
|
||
attempted.push(file.fileName);
|
||
if (file.fileName === 'provider.txt') {
|
||
throw new Error('private provider diagnostic');
|
||
}
|
||
if (file.fileName === 'oversized.txt') {
|
||
const error = new Error('provider upload limit');
|
||
error.code = 'artifact-too-large';
|
||
throw error;
|
||
}
|
||
return { id: 'file-partial-success' };
|
||
},
|
||
},
|
||
harness: {
|
||
createSession: async () => 'session-artifact-partial',
|
||
ask: async (_sessionId, _text, options) => {
|
||
await options.onArtifact(providerFailure);
|
||
await options.onArtifact(oversized);
|
||
await options.onArtifact(success);
|
||
return '三个文件已生成。';
|
||
},
|
||
},
|
||
});
|
||
|
||
const receipt = await bridge.accept(message('artifact-partial', '生成三个文件'));
|
||
|
||
assert.deepEqual(attempted, ['provider.txt', 'oversized.txt', 'success.txt']);
|
||
assert.equal(sent[0], '三个文件已生成。');
|
||
assert.match(sent[1], /provider\.txt.*暂时未能发送/);
|
||
assert.doesNotMatch(sent[1], /private provider diagnostic/);
|
||
assert.match(sent[2], /oversized\.txt.*超过当前渠道大小上限/);
|
||
assert.equal(bridge.status.artifactsSent, 1);
|
||
assert.equal(bridge.status.artifactSendErrors, 2);
|
||
assert.deepEqual(receipt.providerMessageIds, [
|
||
'text-partial-1',
|
||
'text-partial-2',
|
||
'text-partial-3',
|
||
'file-partial-success',
|
||
]);
|
||
assert.deepEqual(receipt.artifacts, [
|
||
{
|
||
artifactId: 'provider-failure-1',
|
||
outcome: 'failed',
|
||
reason: 'artifact-provider-failed',
|
||
},
|
||
{ artifactId: 'oversized-1', outcome: 'rejected', reason: 'artifact-too-large' },
|
||
{ artifactId: 'partial-success-1', outcome: 'sent' },
|
||
]);
|
||
});
|
||
|
||
test('shared text bridge tells users to check the chat before retrying an uncertain file send', async (t) => {
|
||
const artifact = await committedArtifact(t, 'uncertain.txt', 'bytes', 'uncertain');
|
||
const sent = [];
|
||
let textAttempts = 0;
|
||
const bridge = createBridge({
|
||
state: stateFixture().state,
|
||
bot: {
|
||
sendText: async (_target, text) => {
|
||
textAttempts += 1;
|
||
if (textAttempts === 1) throw new Error('initial text delivery failed');
|
||
sent.push(text);
|
||
return { ts: 'unknown-notice' };
|
||
},
|
||
sendFile: async () => {
|
||
const error = new Error('private timeout detail');
|
||
error.code = 'artifact-delivery-uncertain';
|
||
throw error;
|
||
},
|
||
},
|
||
harness: {
|
||
createSession: async () => 'session-uncertain',
|
||
ask: async (_sessionId, _text, options) => {
|
||
await options.onArtifact(artifact);
|
||
return '文件已生成。';
|
||
},
|
||
},
|
||
});
|
||
|
||
const receipt = await bridge.accept(message('artifact-uncertain', '生成文件'));
|
||
|
||
assert.equal(textAttempts, 2, 'must not append a contradictory generic retry notice');
|
||
assert.match(sent[0], /发送结果未能确认.*先检查聊天内是否已收到.*不要立即重试/);
|
||
assert.doesNotMatch(sent[0], /private timeout detail/);
|
||
assert.deepEqual(receipt.providerMessageIds, ['unknown-notice']);
|
||
assert.deepEqual(receipt.artifacts, [{
|
||
artifactId: 'uncertain-1',
|
||
outcome: 'unknown',
|
||
reason: 'artifact-delivery-uncertain',
|
||
}]);
|
||
});
|
||
|
||
test('shared text streaming finalizes once before delivering result files', async (t) => {
|
||
const artifact = await committedArtifact(t, 'stream.txt', 'stream file', 'stream');
|
||
const fixture = stateFixture();
|
||
const order = [];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: {
|
||
sendText: async (_target, text) => order.push(`text:${text}`),
|
||
sendTyping: async () => order.push('typing'),
|
||
openStream: async () => {
|
||
order.push('open');
|
||
return {
|
||
messageId: 'stream-message',
|
||
update: async (text) => order.push(`update:${text}`),
|
||
finish: async (text) => order.push(`finish:${text}`),
|
||
cancel: () => order.push('cancel'),
|
||
};
|
||
},
|
||
sendFile: async (_target, file) => order.push(`file:${file.fileName}`),
|
||
},
|
||
harness: {
|
||
createSession: async () => 'session-artifact-stream',
|
||
ask: async (_sessionId, _text, options) => {
|
||
await options.onUpdate({ type: 'text', text: '处理中' });
|
||
await options.onArtifact(artifact);
|
||
return '流式回答完成';
|
||
},
|
||
},
|
||
});
|
||
|
||
const receipt = await bridge.accept(message('artifact-stream', '流式生成文件'));
|
||
|
||
assert.deepEqual(order, [
|
||
'typing',
|
||
'open',
|
||
'update:处理中',
|
||
'finish:流式回答完成',
|
||
'file:stream.txt',
|
||
]);
|
||
assert.deepEqual(receipt.providerMessageIds, ['stream-message']);
|
||
});
|
||
|
||
test('aborting shared text delivery stops before any registered file is sent', async (t) => {
|
||
const artifact = await committedArtifact(t, 'cancelled.txt', 'cancelled', 'cancelled');
|
||
const fixture = stateFixture();
|
||
const controller = new AbortController();
|
||
const sent = [];
|
||
const files = [];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
signal: controller.signal,
|
||
bot: {
|
||
sendText: async (_target, text) => {
|
||
sent.push(text);
|
||
controller.abort(new DOMException('runtime stopped', 'AbortError'));
|
||
},
|
||
sendFile: async (_target, file) => files.push(file.fileName),
|
||
},
|
||
harness: {
|
||
createSession: async () => 'session-artifact-cancelled',
|
||
ask: async (_sessionId, _text, options) => {
|
||
await options.onArtifact(artifact);
|
||
return '回答完成';
|
||
},
|
||
},
|
||
});
|
||
|
||
await bridge.accept(message('artifact-cancelled', '生成后取消'));
|
||
|
||
assert.deepEqual(sent, ['回答完成']);
|
||
assert.deepEqual(files, []);
|
||
});
|
||
|
||
test('Slack, Telegram, and Discord remember any valid direct message per bot', async () => {
|
||
for (const [name, Bridge] of [
|
||
['slack', SlackHarnessBridge],
|
||
['telegram', TelegramHarnessBridge],
|
||
['discord', DiscordHarnessBridge],
|
||
]) {
|
||
let sessionSequence = 0;
|
||
const harness = {
|
||
createSession: async () => `${name}-session-${++sessionSequence}`,
|
||
ask: async () => `${name} reply`,
|
||
};
|
||
const first = stateFixture();
|
||
const second = stateFixture();
|
||
const firstSent = [];
|
||
const secondSent = [];
|
||
const firstBot = {
|
||
sendText: async (target, text) => firstSent.push({ target, text }),
|
||
};
|
||
const secondBot = {
|
||
sendText: async (target, text) => secondSent.push({ target, text }),
|
||
};
|
||
const firstBridge = new Bridge({ bot: firstBot, harness, state: first.state });
|
||
const secondBridge = new Bridge({ bot: secondBot, harness, state: second.state });
|
||
const firstTarget = { channelId: `${name}-private-a` };
|
||
const secondTarget = { channelId: `${name}-private-b` };
|
||
|
||
await firstBridge.accept(message(`${name}-direct-a`, 'hello', {
|
||
conversationId: `${name}-private-a`,
|
||
replyTarget: { ...firstTarget, replyToMessageId: 'reply-a' },
|
||
connectionTestTarget: firstTarget,
|
||
}));
|
||
assert.deepEqual(connectionTestTarget(first.state), firstTarget, name);
|
||
|
||
await firstBridge.accept(message(`${name}-group`, 'hello group', {
|
||
kind: 'group',
|
||
conversationId: `${name}-group`,
|
||
addressed: true,
|
||
replyTarget: { channelId: `${name}-group` },
|
||
connectionTestTarget: { channelId: `${name}-group` },
|
||
}));
|
||
assert.deepEqual(connectionTestTarget(first.state), firstTarget, `${name} group`);
|
||
|
||
await firstBridge.accept(message(`${name}-direct-a`, 'duplicate', {
|
||
conversationId: `${name}-private-replay`,
|
||
replyTarget: { channelId: `${name}-private-replay` },
|
||
connectionTestTarget: { channelId: `${name}-private-replay` },
|
||
}));
|
||
assert.deepEqual(connectionTestTarget(first.state), firstTarget, `${name} duplicate`);
|
||
|
||
await secondBridge.accept(message(`${name}-direct-b`, 'hello', {
|
||
conversationId: `${name}-private-b`,
|
||
replyTarget: { ...secondTarget, replyToMessageId: 'reply-b' },
|
||
connectionTestTarget: secondTarget,
|
||
}));
|
||
assert.deepEqual(connectionTestTarget(second.state), secondTarget, `${name} second bot`);
|
||
|
||
const reconnectedFirstBridge = new Bridge({ bot: firstBot, harness, state: first.state });
|
||
await reconnectedFirstBridge.sendConnectionTest(`${name} first test`);
|
||
await secondBridge.sendConnectionTest(`${name} second test`);
|
||
assert.deepEqual(
|
||
firstSent.find(({ text }) => text === `${name} first test`)?.target,
|
||
firstTarget,
|
||
`${name} reconnect`,
|
||
);
|
||
assert.deepEqual(
|
||
secondSent.find(({ text }) => text === `${name} second test`)?.target,
|
||
secondTarget,
|
||
`${name} bot isolation`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('answers a multi-question interaction on the fast lane with canonical values', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const asked = [];
|
||
const submitted = deferred();
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push({ sessionId, text });
|
||
await options.onInteraction(questionInteraction({
|
||
sessionId,
|
||
questions: [
|
||
{
|
||
id: 'language',
|
||
question: '选择回答语言',
|
||
options: [{ label: '中文' }, { label: 'English' }],
|
||
},
|
||
{
|
||
id: 'deliverables',
|
||
question: '选择交付内容',
|
||
multiSelect: true,
|
||
options: [{ label: '测试' }, { label: '文档' }],
|
||
},
|
||
],
|
||
respond: async (result) => {
|
||
submitted.resolve(result);
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await submitted.promise;
|
||
return '交互已完成';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('prompt', '请分步提问'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('选择回答语言')));
|
||
await bridge.accept(message('language', '2'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('选择交付内容')));
|
||
await bridge.accept(message('deliverables', '1,文档,发布说明'));
|
||
|
||
assert.deepEqual(await submitted.promise, {
|
||
ok: true,
|
||
value: {
|
||
sessionId: 'session-one',
|
||
answer: {
|
||
answers: [
|
||
{ id: 'language', selected: ['English'] },
|
||
{ id: 'deliverables', selected: ['测试', '文档'], custom: '发布说明' },
|
||
],
|
||
},
|
||
},
|
||
});
|
||
await processing;
|
||
assert.deepEqual(asked, [{ sessionId: 'session-one', text: '请分步提问' }]);
|
||
assert.equal(sent.at(-1).text, '交互已完成');
|
||
assert.deepEqual(sent[1].target, { id: 'target-language' });
|
||
});
|
||
|
||
test('answers approvals on the interaction fast lane with precise text decisions', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const asked = [];
|
||
const submitted = [];
|
||
const cases = [
|
||
{ reply: '批准', outcome: 'allowed-once' },
|
||
{ reply: '同意', outcome: 'allowed-once' },
|
||
{ reply: ' YeS ', outcome: 'allowed-once' },
|
||
{ reply: '拒绝', outcome: 'rejected' },
|
||
{ reply: '不同意', outcome: 'rejected' },
|
||
{ reply: ' NO ', outcome: 'rejected' },
|
||
];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push({ sessionId, text });
|
||
for (const [index, approvalCase] of cases.entries()) {
|
||
const answered = deferred();
|
||
await options.onInteraction(approvalInteraction({
|
||
id: `approval-${index + 1}`,
|
||
sessionId,
|
||
toolName: `tool-${index + 1}`,
|
||
reason: `精准回复测试 ${index + 1}`,
|
||
respond: async (result) => {
|
||
submitted.push(result);
|
||
answered.resolve();
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await answered.promise;
|
||
assert.equal(submitted.at(-1).value.outcome, approvalCase.outcome);
|
||
}
|
||
return '所有审批已完成';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('approval-start', '启动审批测试'));
|
||
for (const [index, approvalCase] of cases.entries()) {
|
||
await eventually(() => sent.some(({ text }) => text.includes(`tool-${index + 1}`)));
|
||
await bridge.accept(message(`approval-reply-${index + 1}`, approvalCase.reply));
|
||
}
|
||
await processing;
|
||
|
||
assert.deepEqual(submitted, cases.map(({ outcome }, index) => ({
|
||
ok: true,
|
||
value: {
|
||
sessionId: 'session-one',
|
||
approvalId: `approval-${index + 1}`,
|
||
outcome,
|
||
},
|
||
})));
|
||
assert.deepEqual(asked, [{ sessionId: 'session-one', text: '启动审批测试' }]);
|
||
assert.equal(sent.at(-1).text, '所有审批已完成');
|
||
});
|
||
|
||
test('all four shared text channel bridges inherit the approval fast lane', async () => {
|
||
const channels = [
|
||
['Slack', SlackHarnessBridge],
|
||
['Discord', DiscordHarnessBridge],
|
||
['Telegram', TelegramHarnessBridge],
|
||
['WhatsApp', WhatsappHarnessBridge],
|
||
];
|
||
|
||
for (const [label, Bridge] of channels) {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const submitted = deferred();
|
||
const asked = [];
|
||
const bridge = new Bridge({
|
||
state: fixture.state,
|
||
logger: { warn() {}, error() {} },
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
await options.onInteraction(approvalInteraction({
|
||
id: `${label.toLowerCase()}-approval`,
|
||
sessionId,
|
||
toolName: `${label.toLowerCase()}-tool`,
|
||
respond: async (result) => {
|
||
submitted.resolve(result);
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await submitted.promise;
|
||
return `${label} 审批完成`;
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message(`${label}-prompt`, `${label} 启动审批`));
|
||
await eventually(() => sent.some(({ text }) => text.includes(`${label.toLowerCase()}-tool`)));
|
||
await bridge.accept(message(`${label}-decision`, '批准'));
|
||
await processing;
|
||
|
||
assert.equal((await submitted.promise).value.outcome, 'allowed-once', label);
|
||
assert.deepEqual(asked, [`${label} 启动审批`], label);
|
||
assert.equal(sent.at(-1).text, `${label} 审批完成`, label);
|
||
}
|
||
});
|
||
|
||
test('keeps an imprecise approval reply out of the Harness prompt queue', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const asked = [];
|
||
const submitted = deferred();
|
||
let responseCalls = 0;
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
await options.onInteraction(approvalInteraction({
|
||
sessionId,
|
||
respond: async (result) => {
|
||
responseCalls += 1;
|
||
submitted.resolve(result);
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await submitted.promise;
|
||
return '审批已继续';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('imprecise-start', '启动精准匹配测试'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('测试审批链路')));
|
||
const imprecise = bridge.accept(message('imprecise-reply', '好的'));
|
||
await imprecise;
|
||
assert.equal(responseCalls, 0);
|
||
assert.deepEqual(asked, ['启动精准匹配测试']);
|
||
|
||
const approval = bridge.accept(message('precise-reply', '批准'));
|
||
await Promise.all([processing, approval]);
|
||
|
||
assert.deepEqual(await submitted.promise, {
|
||
ok: true,
|
||
value: {
|
||
sessionId: 'session-one',
|
||
approvalId: 'approval-one',
|
||
outcome: 'allowed-once',
|
||
},
|
||
});
|
||
assert.deepEqual(asked, ['启动精准匹配测试']);
|
||
});
|
||
|
||
test('presents parallel approvals from one conversation in fifo order without codes', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const submitted = [];
|
||
const firstAnswered = deferred();
|
||
const secondAnswered = deferred();
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, _text, options) => {
|
||
await Promise.all([
|
||
options.onInteraction(approvalInteraction({
|
||
id: 'fifo-first-id',
|
||
sessionId,
|
||
toolName: 'first-tool',
|
||
reason: '第一条审批',
|
||
respond: async (result) => {
|
||
submitted.push(result);
|
||
firstAnswered.resolve();
|
||
return { accepted: true };
|
||
},
|
||
})),
|
||
options.onInteraction(approvalInteraction({
|
||
id: 'fifo-second-id',
|
||
sessionId,
|
||
toolName: 'second-tool',
|
||
reason: '第二条审批',
|
||
respond: async (result) => {
|
||
submitted.push(result);
|
||
secondAnswered.resolve();
|
||
return { accepted: true };
|
||
},
|
||
})),
|
||
]);
|
||
await Promise.all([firstAnswered.promise, secondAnswered.promise]);
|
||
return '并行审批已完成';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('fifo-start', '启动并行审批'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('first-tool')));
|
||
assert.equal(sent.some(({ text }) => text.includes('second-tool')), false);
|
||
|
||
await bridge.accept(message('fifo-first-reply', '批准'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('second-tool')));
|
||
await bridge.accept(message('fifo-second-reply', '拒绝'));
|
||
await processing;
|
||
|
||
const approvalMessages = sent.filter(({ text }) => (
|
||
text.includes('first-tool') || text.includes('second-tool')
|
||
));
|
||
assert.equal(approvalMessages.length, 2);
|
||
assert.match(approvalMessages[0].text, /first-tool/);
|
||
assert.match(approvalMessages[1].text, /second-tool/);
|
||
assert.equal(approvalMessages.some(({ text }) => (
|
||
text.includes('fifo-first-id') || text.includes('fifo-second-id')
|
||
)), false);
|
||
assert.deepEqual(submitted.map(({ value }) => ({
|
||
approvalId: value.approvalId,
|
||
outcome: value.outcome,
|
||
})), [
|
||
{ approvalId: 'fifo-first-id', outcome: 'allowed-once' },
|
||
{ approvalId: 'fifo-second-id', outcome: 'rejected' },
|
||
]);
|
||
});
|
||
|
||
test('a completed approval tombstone never steals yes or no from a live question', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const approvalDone = deferred();
|
||
const questionDone = deferred();
|
||
let questionResponse;
|
||
const asked = [];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
await options.onInteraction(approvalInteraction({
|
||
sessionId,
|
||
respond: async () => {
|
||
approvalDone.resolve();
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await approvalDone.promise;
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'question-after-approval',
|
||
sessionId,
|
||
questions: [{ id: 'continue', question: '是否继续?' }],
|
||
respond: async (result) => {
|
||
questionResponse = result;
|
||
questionDone.resolve();
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await questionDone.promise;
|
||
return '审批和提问均已完成';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('approval-then-question', '开始组合交互'));
|
||
await eventually(() => sent.some(({ text }) => text.includes("printf 'approval-test")));
|
||
await bridge.accept(message('approval-before-question', '批准'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('是否继续?')));
|
||
await bridge.accept(message('question-yes', 'yes'));
|
||
await processing;
|
||
|
||
assert.deepEqual(questionResponse.value.answer.answers, [{
|
||
id: 'continue',
|
||
selected: [],
|
||
custom: 'yes',
|
||
}]);
|
||
assert.deepEqual(asked, ['开始组合交互']);
|
||
assert.equal(sent.at(-1).text, '审批和提问均已完成');
|
||
});
|
||
|
||
test('a sibling approval waits for an in-flight question answer without deadlocking', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const questionResponseStarted = deferred();
|
||
const releaseQuestionResponse = deferred();
|
||
const questionDone = deferred();
|
||
const approvalDone = deferred();
|
||
const asked = [];
|
||
const questionResponses = [];
|
||
const approvalResponses = [];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
await options.onInteraction(approvalInteraction({
|
||
id: 'sibling-approval',
|
||
sessionId,
|
||
respond: async (result) => {
|
||
approvalResponses.push(result);
|
||
approvalDone.resolve();
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'sibling-question',
|
||
sessionId,
|
||
questions: [{ id: 'continue', question: '是否继续执行?' }],
|
||
respond: async (result) => {
|
||
questionResponses.push(result);
|
||
questionResponseStarted.resolve();
|
||
await releaseQuestionResponse.promise;
|
||
questionDone.resolve();
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await Promise.all([questionDone.promise, approvalDone.promise]);
|
||
return '组合交互已完成';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('sibling-start', '启动并行交互'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('是否继续执行?')));
|
||
const answeringQuestion = bridge.accept(message('sibling-question-answer', 'yes'));
|
||
await questionResponseStarted.promise;
|
||
const approving = bridge.accept(message('sibling-approval-answer', '批准'));
|
||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||
assert.equal(approvalResponses.length, 0);
|
||
|
||
releaseQuestionResponse.resolve();
|
||
await Promise.all([answeringQuestion, approving, processing]);
|
||
assert.deepEqual(questionResponses[0].value.answer.answers, [{
|
||
id: 'continue',
|
||
selected: [],
|
||
custom: 'yes',
|
||
}]);
|
||
assert.equal(approvalResponses[0].value.outcome, 'allowed-once');
|
||
assert.deepEqual(asked, ['启动并行交互']);
|
||
assert.equal(sent.at(-1).text, '组合交互已完成');
|
||
});
|
||
|
||
test('isolates pending questions by normalized conversation key', async () => {
|
||
const fixture = stateFixture({
|
||
'direct:chat-a': 'session-a',
|
||
'direct:chat-b': 'session-b',
|
||
});
|
||
const sent = [];
|
||
const answeredA = deferred();
|
||
const asked = [];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push({ sessionId, text });
|
||
if (sessionId === 'session-b') return '乙会话完成';
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'question-a',
|
||
sessionId,
|
||
questions: [{ id: 'a', question: '甲会话的问题' }],
|
||
respond: async (result) => {
|
||
answeredA.resolve(result);
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await answeredA.promise;
|
||
return '甲会话完成';
|
||
},
|
||
},
|
||
});
|
||
|
||
const first = bridge.accept(message('a-start', '启动甲'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('甲会话的问题')));
|
||
await bridge.accept(message('b-normal', '乙的普通问题', {
|
||
senderId: 'actor-b',
|
||
conversationId: 'chat-b',
|
||
}));
|
||
assert.deepEqual(asked, [
|
||
{ sessionId: 'session-a', text: '启动甲' },
|
||
{ sessionId: 'session-b', text: '乙的普通问题' },
|
||
]);
|
||
|
||
await bridge.accept(message('a-answer', '甲的答案'));
|
||
assert.deepEqual((await answeredA.promise).value.answer.answers, [
|
||
{ id: 'a', selected: [], custom: '甲的答案' },
|
||
]);
|
||
await first;
|
||
});
|
||
|
||
test('a group question only accepts an addressed reply from the initiating actor', async () => {
|
||
const fixture = stateFixture({ 'group:room': 'session-group' });
|
||
const sent = [];
|
||
const submitted = deferred();
|
||
const asked = [];
|
||
let responseCalls = 0;
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
if (text !== '甲发起交互') return '普通群消息已处理';
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'group-question',
|
||
sessionId,
|
||
questions: [{ id: 'actor', question: '只能由甲回答' }],
|
||
respond: async (result) => {
|
||
responseCalls += 1;
|
||
submitted.resolve(result);
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await submitted.promise;
|
||
return '甲的交互完成';
|
||
},
|
||
},
|
||
});
|
||
const group = { kind: 'group', conversationId: 'room' };
|
||
|
||
const first = bridge.accept(message('group-start', '甲发起交互', { ...group }));
|
||
await eventually(() => sent.some(({ text }) => text.includes('只能由甲回答')));
|
||
assert.match(sent[0].text, /群聊中请 @机器人/);
|
||
|
||
await bridge.accept(message('group-unaddressed', '没有 @ 的回答', {
|
||
...group,
|
||
addressed: false,
|
||
}));
|
||
const intruder = bridge.accept(message('group-intruder', '乙试图代答', {
|
||
...group,
|
||
senderId: 'actor-b',
|
||
}));
|
||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||
assert.equal(responseCalls, 0);
|
||
assert.deepEqual(asked, ['甲发起交互']);
|
||
|
||
await bridge.accept(message('group-answer', '甲的真正答案', { ...group }));
|
||
assert.deepEqual((await submitted.promise).value.answer.answers, [{
|
||
id: 'actor',
|
||
selected: [],
|
||
custom: '甲的真正答案',
|
||
}]);
|
||
await Promise.all([first, intruder]);
|
||
assert.deepEqual(asked, ['甲发起交互', '乙试图代答']);
|
||
assert.equal(bridge.status.messagesRejected, 1);
|
||
});
|
||
|
||
test('a managed native thread can keep group isolation without asking for another mention', async () => {
|
||
const fixture = stateFixture({ 'group:managed-thread': 'session-managed' });
|
||
const sent = [];
|
||
const submitted = deferred();
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (sessionId, _text, options) => {
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'managed-thread-question',
|
||
sessionId,
|
||
questions: [{ id: 'choice', question: '请选择下一步' }],
|
||
respond: async (result) => {
|
||
submitted.resolve(result);
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await submitted.promise;
|
||
return '已继续';
|
||
},
|
||
},
|
||
});
|
||
const route = {
|
||
kind: 'group',
|
||
conversationId: 'managed-thread',
|
||
addressed: true,
|
||
requiresMention: false,
|
||
};
|
||
|
||
const processing = bridge.accept(message('managed-start', '开始', route));
|
||
await eventually(() => sent.some(({ text }) => text.includes('请选择下一步')));
|
||
assert.doesNotMatch(sent[0].text, /群聊中请 @机器人/);
|
||
await bridge.accept(message('managed-answer', '继续', route));
|
||
assert.deepEqual((await submitted.promise).value.answer.answers, [{
|
||
id: 'choice',
|
||
selected: [],
|
||
custom: '继续',
|
||
}]);
|
||
await processing;
|
||
});
|
||
|
||
test('deduplicates replays and safely closes recovered questions and approvals', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
let parallelResponse;
|
||
let recoveredResponse;
|
||
let approvalResponse;
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, _text, options) => {
|
||
const current = questionInteraction({
|
||
id: 'replayed-question',
|
||
sessionId,
|
||
questions: [{ id: 'current', question: '只应显示一次' }],
|
||
});
|
||
await options.onInteraction(current);
|
||
await options.onInteraction(current);
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'parallel-question',
|
||
sessionId,
|
||
questions: [{ id: 'parallel', question: '不应显示的并行问题' }],
|
||
respond: async (result) => { parallelResponse = result; },
|
||
}));
|
||
await options.onInteraction(approvalInteraction({
|
||
id: 'orphan-approval',
|
||
sessionId,
|
||
recovered: true,
|
||
respond: async (result) => { approvalResponse = result; },
|
||
}));
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'orphan-question',
|
||
sessionId,
|
||
recovered: true,
|
||
questions: [{ id: 'secret', question: '旧会话中的敏感问题内容' }],
|
||
respond: async (result) => { recoveredResponse = result; },
|
||
}));
|
||
await options.onInteractionResolved({
|
||
kind: 'question',
|
||
interactionId: 'replayed-question',
|
||
sessionId,
|
||
outcome: 'cancelled',
|
||
});
|
||
return '已继续处理';
|
||
},
|
||
},
|
||
});
|
||
|
||
await bridge.accept(message('replay', '测试交互重放'));
|
||
assert.equal(sent.filter(({ text }) => text.includes('只应显示一次')).length, 1);
|
||
assert.equal(sent.some(({ text }) => text.includes('旧会话中的敏感问题内容')), false);
|
||
assert.equal(sent.some(({ text }) => text.includes('遗留的待回答问题')), true);
|
||
assert.deepEqual(parallelResponse?.error, {
|
||
code: 'cancelled',
|
||
message: 'Test is already handling another user interaction.',
|
||
details: {},
|
||
});
|
||
assert.deepEqual(recoveredResponse?.error, {
|
||
code: 'cancelled',
|
||
message: 'Test safely cancelled an interaction left by an earlier client.',
|
||
details: {},
|
||
});
|
||
assert.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 () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const reactions = [];
|
||
const completed = deferred();
|
||
const submittedAnswers = [];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
reactions: { processing: 'eyes', success: 'done', error: 'error' },
|
||
bot: {
|
||
addReaction: async (target, emoji) => {
|
||
reactions.push(['add', target.id, emoji]);
|
||
return emoji;
|
||
},
|
||
removeReaction: async (target, emoji) => {
|
||
reactions.push(['remove', target.id, emoji]);
|
||
},
|
||
sendText: async (target, text) => sent.push({ target, text }),
|
||
},
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, _text, options) => {
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'retry-question',
|
||
sessionId,
|
||
respond: async (result) => {
|
||
submittedAnswers.push(result.value.answer.answers[0].custom);
|
||
if (submittedAnswers.length === 1) throw new Error('temporary failure');
|
||
completed.resolve();
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await completed.promise;
|
||
return '重试成功';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('retry-start', '启动可重试交互'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('请回答')));
|
||
await bridge.accept(message('retry-first', '第一次答案', {
|
||
reactionTarget: { id: 'source-retry-first' },
|
||
}));
|
||
await eventually(() => reactions.length === 3);
|
||
assert.equal(sent.some(({ text }) => text.includes('回答提交失败')), true);
|
||
await bridge.accept(message('retry-second', '重试后的答案'));
|
||
await processing;
|
||
|
||
assert.deepEqual(submittedAnswers, ['第一次答案', '重试后的答案']);
|
||
assert.equal(sent.at(-1).text, '重试成功');
|
||
assert.deepEqual(reactions, [
|
||
['add', 'source-retry-first', 'eyes'],
|
||
['remove', 'source-retry-first', 'eyes'],
|
||
['add', 'source-retry-first', 'error'],
|
||
]);
|
||
});
|
||
|
||
test('notifies the actor when an in-flight response resolves elsewhere before rejection', async () => {
|
||
const fixture = stateFixture();
|
||
const sent = [];
|
||
const responseStarted = deferred();
|
||
const asked = [];
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'response-resolved-race',
|
||
sessionId,
|
||
respond: async () => {
|
||
options.onInteractionResolved({
|
||
kind: 'question',
|
||
interactionId: 'response-resolved-race',
|
||
sessionId,
|
||
outcome: 'answered',
|
||
});
|
||
responseStarted.resolve();
|
||
const error = new Error('interaction no longer pending');
|
||
error.code = 'interaction-not-pending';
|
||
throw error;
|
||
},
|
||
}));
|
||
await responseStarted.promise;
|
||
return '原会话已结束';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('response-race-start', '启动提交竞态'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('请回答')));
|
||
await bridge.accept(message('response-race-answer', '已经收到的答案'));
|
||
await processing;
|
||
|
||
assert.deepEqual(asked, ['启动提交竞态']);
|
||
assert.equal(sent.some(({ text }) => text.includes('已在其他客户端处理')), true);
|
||
});
|
||
|
||
test('discards a claimed answer when the interaction resolves during message recording', async () => {
|
||
const fixture = stateFixture({ 'direct:chat-a': 'session-one' });
|
||
const originalMarkSeen = fixture.state.markSeen;
|
||
const answerMarkStarted = deferred();
|
||
const releaseAnswerMark = deferred();
|
||
fixture.state.markSeen = async (messageId) => {
|
||
if (messageId === 'racing-answer') {
|
||
answerMarkStarted.resolve();
|
||
await releaseAnswerMark.promise;
|
||
}
|
||
await originalMarkSeen(messageId);
|
||
};
|
||
const sent = [];
|
||
const asked = [];
|
||
const externallyResolved = deferred();
|
||
let resolveInteraction;
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
sessionExists: async () => true,
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
if (text === '后来的普通问题') return '后来问题的回答';
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'resolved-race-question',
|
||
sessionId,
|
||
}));
|
||
resolveInteraction = () => {
|
||
options.onInteractionResolved({
|
||
kind: 'question',
|
||
interactionId: 'resolved-race-question',
|
||
sessionId,
|
||
outcome: 'answered',
|
||
});
|
||
externallyResolved.resolve();
|
||
};
|
||
await externallyResolved.promise;
|
||
return '第一轮已由其他客户端完成';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('race-start', '启动外部解决竞态'));
|
||
await eventually(() => typeof resolveInteraction === 'function');
|
||
const answer = bridge.accept(message('racing-answer', '原本的问题答案'));
|
||
await answerMarkStarted.promise;
|
||
resolveInteraction();
|
||
releaseAnswerMark.resolve();
|
||
await Promise.all([processing, answer]);
|
||
await bridge.accept(message('later-prompt', '后来的普通问题'));
|
||
|
||
assert.deepEqual(asked, ['启动外部解决竞态', '后来的普通问题']);
|
||
assert.equal(asked.includes('原本的问题答案'), false);
|
||
assert.equal(sent.some(({ text }) => text.includes('已在其他客户端处理')), true);
|
||
});
|
||
|
||
test('accepts a first answer received while its question presentation is still in flight', async () => {
|
||
const fixture = stateFixture();
|
||
const presentationStarted = deferred();
|
||
const releasePresentation = deferred();
|
||
const submitted = deferred();
|
||
const sent = [];
|
||
const asked = [];
|
||
let questionPresentations = 0;
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: {
|
||
sendText: async (target, text) => {
|
||
sent.push({ target, text });
|
||
if (text.includes('发送仍在进行的问题')) {
|
||
questionPresentations += 1;
|
||
presentationStarted.resolve();
|
||
await releasePresentation.promise;
|
||
}
|
||
},
|
||
},
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'first-presentation-race',
|
||
sessionId,
|
||
questions: [{ id: 'first', question: '发送仍在进行的问题' }],
|
||
respond: async (result) => {
|
||
submitted.resolve(result);
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await submitted.promise;
|
||
return '首问已回答';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('presentation-start', '启动首问竞态'));
|
||
await presentationStarted.promise;
|
||
const answer = bridge.accept(message('presentation-answer', '首问答案'));
|
||
releasePresentation.resolve();
|
||
await Promise.all([processing, answer]);
|
||
|
||
assert.deepEqual((await submitted.promise).value.answer.answers, [{
|
||
id: 'first',
|
||
selected: [],
|
||
custom: '首问答案',
|
||
}]);
|
||
assert.deepEqual(asked, ['启动首问竞态']);
|
||
assert.equal(questionPresentations, 1);
|
||
assert.equal(sent.at(-1).text, '首问已回答');
|
||
});
|
||
|
||
test('discards an answer already received when a later question resolves during presentation', async () => {
|
||
const fixture = stateFixture();
|
||
const secondPresentationStarted = deferred();
|
||
const releaseSecondPresentation = deferred();
|
||
const externallyResolved = deferred();
|
||
const sent = [];
|
||
const asked = [];
|
||
let resolveInteraction;
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
bot: {
|
||
sendText: async (target, text) => {
|
||
sent.push({ target, text });
|
||
if (text.includes('仍在发送的第二问')) {
|
||
secondPresentationStarted.resolve();
|
||
await releaseSecondPresentation.promise;
|
||
}
|
||
},
|
||
},
|
||
harness: {
|
||
createSession: async () => 'session-one',
|
||
ask: async (sessionId, text, options) => {
|
||
asked.push(text);
|
||
if (text !== '启动第二问竞态') return '不应把第二问答案作为新 prompt';
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'second-presentation-race',
|
||
sessionId,
|
||
questions: [
|
||
{ id: 'first', question: '第一问' },
|
||
{ id: 'second', question: '仍在发送的第二问' },
|
||
],
|
||
}));
|
||
resolveInteraction = () => {
|
||
options.onInteractionResolved({
|
||
kind: 'question',
|
||
interactionId: 'second-presentation-race',
|
||
sessionId,
|
||
outcome: 'answered',
|
||
});
|
||
externallyResolved.resolve();
|
||
};
|
||
await externallyResolved.promise;
|
||
return '已由其他客户端完成';
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('second-race-start', '启动第二问竞态'));
|
||
await eventually(() => typeof resolveInteraction === 'function');
|
||
const firstAnswer = bridge.accept(message('second-race-first', '第一问答案'));
|
||
await secondPresentationStarted.promise;
|
||
const secondAnswer = bridge.accept(message('second-race-second', '第二问已收到的答案'));
|
||
resolveInteraction();
|
||
releaseSecondPresentation.resolve();
|
||
await Promise.all([processing, firstAnswer, secondAnswer]);
|
||
|
||
assert.deepEqual(asked, ['启动第二问竞态']);
|
||
assert.equal(asked.includes('第二问已收到的答案'), false);
|
||
assert.equal(sent.some(({ text }) => text.includes('已在其他客户端处理')), true);
|
||
});
|
||
|
||
test('passes the runtime signal to Harness and safely cancels a pending question on abort', async () => {
|
||
const controller = new AbortController();
|
||
const fixture = stateFixture({ 'direct:chat-a': 'stale-session' });
|
||
const sent = [];
|
||
const cancelled = deferred();
|
||
let existsSignal;
|
||
let createSignal;
|
||
let askSignal;
|
||
const bridge = createBridge({
|
||
state: fixture.state,
|
||
signal: controller.signal,
|
||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||
harness: {
|
||
sessionExists: async (_sessionId, options) => {
|
||
existsSignal = options?.signal;
|
||
return false;
|
||
},
|
||
createSession: async (options) => {
|
||
createSignal = options?.signal;
|
||
return 'session-one';
|
||
},
|
||
ask: async (sessionId, _text, options) => {
|
||
askSignal = options.signal;
|
||
await options.onInteraction(questionInteraction({
|
||
id: 'abort-question',
|
||
sessionId,
|
||
respond: async (result) => {
|
||
cancelled.resolve(result);
|
||
return { accepted: true };
|
||
},
|
||
}));
|
||
await new Promise((resolve, reject) => {
|
||
options.signal.addEventListener('abort', () => reject(options.signal.reason), {
|
||
once: true,
|
||
});
|
||
});
|
||
},
|
||
},
|
||
});
|
||
|
||
const processing = bridge.accept(message('abort-start', '启动后停止'));
|
||
await eventually(() => sent.some(({ text }) => text.includes('请回答')));
|
||
controller.abort(new DOMException('runtime stopped', 'AbortError'));
|
||
await processing;
|
||
|
||
assert.equal(existsSignal, controller.signal);
|
||
assert.equal(createSignal, controller.signal);
|
||
assert.equal(askSignal, controller.signal);
|
||
assert.deepEqual(await cancelled.promise, {
|
||
ok: false,
|
||
error: {
|
||
code: 'cancelled',
|
||
message: 'The Test interaction ended before the user answered.',
|
||
details: {},
|
||
},
|
||
});
|
||
});
|