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/); } });