feat: bind bot chats to existing harness sessions

This commit is contained in:
xmanrui 2026-08-18 02:59:29 +08:00
parent 76d5430f1f
commit 1af8610cd5
21 changed files with 1807 additions and 130 deletions

View file

@ -0,0 +1,175 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { TextHarnessBridge } from '../src/channels/shared/text-harness-bridge.mjs';
import { runWorkspaceCommand } from '../src/channels/shared/workspace-command.mjs';
test('/session binds exactly one safe Session ID to the current conversation', async () => {
const calls = [];
const result = await runWorkspaceCommand('/SESSION session-123', {
async bindWorkspaceSession(key, sessionId) {
calls.push({ key, sessionId });
return {
sessionId,
workspace: '/workspace/project',
title: '安全标题\u202e伪造\n下一行',
archived: true,
};
},
}, 'direct:conversation-1');
assert.deepEqual(calls, [{ key: 'direct:conversation-1', sessionId: 'session-123' }]);
assert.match(result.message, /^当前聊天已绑定会话:/);
assert.match(result.message, /工作区:\/workspace\/project/);
assert.match(result.message, /标题:安全标题 伪造 下一行/);
assert.doesNotMatch(result.message, /\u202e|\n下一行/);
assert.match(result.message, /ID:session-123/);
assert.match(result.message, /归档:是/);
assert.equal(result.messages.join(''), result.message);
});
test('/session strictly rejects missing, multiple, oversized, and unsafe IDs', async () => {
let bindCalls = 0;
const harness = {
async bindWorkspaceSession() {
bindCalls += 1;
throw new Error('must not be called');
},
};
const invalid = [
'/session',
'/session first second',
'/session\nsecond-line',
`/session ${'x'.repeat(257)}`,
'/session unsafe\u202eid',
'/session unsafe\u0000id',
];
for (const command of invalid) {
const result = await runWorkspaceCommand(command, harness, 'direct:conversation-1');
assert.match(result.message, /用法:\/session Session ID/);
}
assert.equal(bindCalls, 0);
});
test('/session requires binding support and a conversation key', async () => {
assert.match(
(await runWorkspaceCommand('/session session-1', {}, 'direct:conversation-1')).message,
/暂不支持绑定已有会话/,
);
assert.match(
(await runWorkspaceCommand('/session session-1', {
async bindWorkspaceSession() { throw new Error('must not be called'); },
})).message,
/缺少可绑定的会话上下文/,
);
});
test('/session maps adoption, lifecycle, and concurrent failures to safe messages', async () => {
const cases = [
['session-id-invalid', /Session ID 格式无效/],
['session-not-registered', /未找到该会话/],
['session-workspace-ambiguous', /工作区归属不明确/],
['session-summary-unavailable', /暂时无法读取该会话的信息/],
['session-subagent-unsupported', /子代理会话不能绑定/],
['workspace-bot-not-found', /正在移除或已重新接入/],
['workspace-session-stale', /状态已发生变化/],
['agent-busy', /状态已发生变化/],
['session-conflict', /状态已发生变化/],
['internal', /暂时无法绑定会话/],
];
for (const [code, expected] of cases) {
const internal = new Error(`private detail for ${code}`);
internal.code = code;
const result = await runWorkspaceCommand('/session session-1', {
async bindWorkspaceSession() { throw internal; },
}, 'direct:conversation-1');
assert.match(result.message, expected);
assert.doesNotMatch(result.message, /private detail/);
}
const stale = new Error('old bot lifecycle');
stale.code = 'workspace-bot-not-found';
const staleAfterBinding = await runWorkspaceCommand('/session session-1', {
async bindWorkspaceSession(sessionKey, sessionId) {
return { sessionId, workspace: '/workspace/project', sessionKey };
},
assertWorkspaceScope() { throw stale; },
}, 'direct:conversation-1');
assert.match(staleAfterBinding.message, /正在移除或已重新接入/);
});
test('the shared bridge binds locally with its conversation key and never prompts or creates', async () => {
const sent = [];
const seen = new Set();
const calls = [];
let forbiddenCalls = 0;
const bridge = new TextHarnessBridge({
descriptor: { key: 'test', label: 'Test' },
bot: { async sendText(_target, text) { sent.push(text); } },
harness: {
async bindWorkspaceSession(key, sessionId) {
calls.push({ key, sessionId });
return {
sessionId,
workspace: '/workspace/project',
title: null,
archived: false,
};
},
async createSession() { forbiddenCalls += 1; },
async ask() { forbiddenCalls += 1; },
},
state: {
hasSeen(messageId) { return seen.has(messageId); },
async markSeen(messageId) { seen.add(messageId); },
},
});
await bridge.accept({
messageId: 'message-session-bind',
senderId: 'sender',
conversationId: 'conversation-1',
kind: 'direct',
content: '/session session-123',
replyTarget: 'target',
});
assert.deepEqual(calls, [{ key: 'direct:conversation-1', sessionId: 'session-123' }]);
assert.equal(forbiddenCalls, 0);
assert.equal(sent.length, 1);
assert.match(sent[0], /当前聊天已绑定会话/);
assert.match(sent[0], /标题:暂无标题/);
assert.match(sent[0], /归档:否/);
assert.equal(seen.has('message-session-bind'), true);
});
test('all nine channel bridges advertise /session and pass their current conversation key', async () => {
const bridgeFamilies = [
['../src/channels/shared/text-harness-bridge.mjs', 'conversationKey'],
['../src/channels/weixin/weixin-bridge.mjs', 'key'],
['../src/channels/feishu/bridge.mjs', 'key'],
['../src/channels/dingtalk/dingtalk-bridge.mjs', 'key'],
['../src/channels/wecom/wecom-bridge.mjs', 'key'],
['../src/channels/qq/qq-bridge.mjs', 'key'],
];
for (const [file, key] of bridgeFamilies) {
const source = await readFile(new URL(file, import.meta.url), 'utf8');
assert.match(source, /\/session Session ID 将当前聊天绑定到指定会话/);
assert.ok(
source.includes(`runWorkspaceCommand(text, this.#harness, ${key})`),
`${file} must pass ${key} to the shared command`,
);
}
for (const file of [
'../src/channels/discord/discord-bridge.mjs',
'../src/channels/slack/slack-bridge.mjs',
'../src/channels/telegram/telegram-bridge.mjs',
'../src/channels/whatsapp/whatsapp-bridge.mjs',
]) {
const source = await readFile(new URL(file, import.meta.url), 'utf8');
assert.match(source, /extends TextHarnessBridge/);
}
});