dsh-im-ops/test/session-bind-command.test.mjs

175 lines
6.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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