mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-12 05:40:45 +08:00
feat: add bounded history previews across nine channels
Add shared /history parsing, read-only session access, message filtering, and bounded replies. Default to three messages, cap at five, and exclude saved history commands. Include channel integration tests, translations, command help, and the Issue #62 implementation plan.
This commit is contained in:
parent
bf0e157cd4
commit
8c6c31a1b6
23 changed files with 1965 additions and 221 deletions
|
|
@ -191,6 +191,62 @@ test('explicit baseUrl still selects the existing HTTP transport', async () => {
|
|||
assert.equal(requests, 1);
|
||||
});
|
||||
|
||||
test('history reading uses only the existing read RPC in both Host connection modes', async () => {
|
||||
const page = { events: [], hasMore: false };
|
||||
for (const mode of ['local', 'http']) {
|
||||
const calls = [];
|
||||
const client = localClient({
|
||||
sessions: {
|
||||
history({ rpcId, payload }, signal) {
|
||||
assert.equal(mode, 'local');
|
||||
assert.ok(signal instanceof AbortSignal);
|
||||
calls.push(payload);
|
||||
return { rpcId, result: { ok: true, value: page } };
|
||||
},
|
||||
},
|
||||
}, mode === 'http' ? {
|
||||
baseUrl: 'http://127.0.0.1:1234',
|
||||
fetchImpl: async (url, options) => {
|
||||
assert.equal(url.pathname, '/api/session.history');
|
||||
const request = JSON.parse(options.body);
|
||||
calls.push(request.payload);
|
||||
return { ok: true, json: async () => ({
|
||||
type: 'server-response', rpcId: request.rpcId, result: { ok: true, value: page },
|
||||
}) };
|
||||
},
|
||||
} : {});
|
||||
assert.deepEqual(await client.readSessionHistory('cold-session'), page);
|
||||
assert.deepEqual(await client.readSessionHistory('cold-session', { beforeSeq: 40, maxMessages: 10 }), page);
|
||||
assert.deepEqual(calls, [
|
||||
{ sessionId: 'cold-session', maxMessages: 50 },
|
||||
{ sessionId: 'cold-session', beforeSeq: 40, maxMessages: 10 },
|
||||
]);
|
||||
await assert.rejects(client.readSessionHistory('', {}), TypeError);
|
||||
await assert.rejects(client.readSessionHistory('cold-session', { maxMessages: 0 }), TypeError);
|
||||
await assert.rejects(client.readSessionHistory('cold-session', { beforeSeq: -1 }), TypeError);
|
||||
assert.equal(calls.length, 2, 'invalid input must not make another RPC');
|
||||
}
|
||||
});
|
||||
|
||||
test('history reads preserve cancellation, timeout and missing-session errors without retrying', async () => {
|
||||
let calls = 0;
|
||||
const client = localClient({ sessions: {
|
||||
async history({ rpcId, payload }) {
|
||||
calls += 1;
|
||||
if (payload.sessionId === 'missing') {
|
||||
return { rpcId, result: { ok: false, error: { code: 'session-not-found', message: 'missing' } } };
|
||||
}
|
||||
await delay(30);
|
||||
return { rpcId, result: { ok: true, value: { events: [], hasMore: false } } };
|
||||
},
|
||||
} });
|
||||
await assert.rejects(client.readSessionHistory('missing'), { code: 'session-not-found' });
|
||||
await assert.rejects(client.readSessionHistory('slow', { timeoutMs: 2 }), { code: 'harness-timeout' });
|
||||
const signal = AbortSignal.abort(new DOMException('Cancelled', 'AbortError'));
|
||||
await assert.rejects(client.readSessionHistory('cancelled', { signal }), { name: 'AbortError' });
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('in-process interaction responses preserve the full envelope and rejection receipts', async () => {
|
||||
let received;
|
||||
let receipt = { accepted: true };
|
||||
|
|
|
|||
449
test/history-bridge.test.mjs
Normal file
449
test/history-bridge.test.mjs
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { DingtalkHarnessBridge } from '../src/channels/dingtalk/dingtalk-bridge.mjs';
|
||||
import { DiscordHarnessBridge } from '../src/channels/discord/discord-bridge.mjs';
|
||||
import { FeishuHarnessBridge } from '../src/channels/feishu/bridge.mjs';
|
||||
import { QqHarnessBridge } from '../src/channels/qq/qq-bridge.mjs';
|
||||
import { setImHostLanguage } from '../src/channels/shared/i18n.mjs';
|
||||
import { SlackHarnessBridge } from '../src/channels/slack/slack-bridge.mjs';
|
||||
import { normalizeSlackEvent } from '../src/channels/slack/slack-runtime.mjs';
|
||||
import { TelegramHarnessBridge } from '../src/channels/telegram/telegram-bridge.mjs';
|
||||
import { WecomHarnessBridge } from '../src/channels/wecom/wecom-bridge.mjs';
|
||||
import { WeixinHarnessBridge } from '../src/channels/weixin/weixin-bridge.mjs';
|
||||
import { WhatsappHarnessBridge } from '../src/channels/whatsapp/whatsapp-bridge.mjs';
|
||||
|
||||
const CHANNELS = ['weixin', 'feishu', 'dingtalk', 'wecom', 'qq', 'slack', 'telegram', 'discord', 'whatsapp'];
|
||||
const TEXT_BRIDGES = {
|
||||
slack: SlackHarnessBridge,
|
||||
telegram: TelegramHarnessBridge,
|
||||
discord: DiscordHarnessBridge,
|
||||
whatsapp: WhatsappHarnessBridge,
|
||||
};
|
||||
const SESSION_ID = 'history-session';
|
||||
const MARKERS = [
|
||||
'history-user-1', 'history-assistant-1',
|
||||
'history-user-2', 'history-assistant-2',
|
||||
'history-user-3', 'history-assistant-3',
|
||||
];
|
||||
|
||||
function history() {
|
||||
let seq = 0;
|
||||
const entry = (type, data, surfaceOp) => ({
|
||||
event: { seq: ++seq, time: seq, type, data, ...(surfaceOp ? { surfaceOp } : {}) },
|
||||
});
|
||||
return {
|
||||
hasMore: false,
|
||||
events: [1, 2, 3].flatMap((turn) => [
|
||||
entry('turn/start', { turn }),
|
||||
entry('user/message', {
|
||||
turn,
|
||||
source: { kind: 'user' },
|
||||
content: [{ type: 'text', text: `history-user-${turn}` }],
|
||||
}, 'append'),
|
||||
entry('assistant/message', {
|
||||
turn,
|
||||
step: 1,
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: `history-assistant-${turn}` }] },
|
||||
}, 'append'),
|
||||
entry('turn/end', { turn, reason: 'completed' }),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((settle) => { resolve = settle; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function within(promise, description) {
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(description)), 1_500);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Real bridge entrypoints with only the platform transport and Host replaced. */
|
||||
function fixture(channel, { sessionId = SESSION_ID, ask, readHistory, send } = {}) {
|
||||
const sent = [];
|
||||
const seen = new Set();
|
||||
const calls = { reads: [], asks: [], mutations: [], downloads: [] };
|
||||
const key = ['weixin', 'feishu', 'dingtalk'].includes(channel) ? 'p2p:owner'
|
||||
: channel === 'qq' ? 'c2c:owner'
|
||||
: channel === 'wecom' ? 'direct:owner' : 'direct:chat';
|
||||
const sessions = new Map(sessionId ? [[key, sessionId]] : []);
|
||||
const forbidden = (name) => async (...args) => {
|
||||
calls.mutations.push({ name, args });
|
||||
throw new Error(`unexpected ${name}`);
|
||||
};
|
||||
const state = {
|
||||
hasSeen: (id) => seen.has(id),
|
||||
markSeen: async (id) => seen.add(id),
|
||||
sessionFor: (requestedKey) => sessions.get(requestedKey) ?? null,
|
||||
setSession: forbidden('setSession'),
|
||||
clearSession: forbidden('clearSession'),
|
||||
};
|
||||
const harness = {
|
||||
sessionExists: async () => true,
|
||||
readSessionHistory: async (id, options) => {
|
||||
calls.reads.push({ id, options });
|
||||
return readHistory ? readHistory(id, options) : history();
|
||||
},
|
||||
ask: async (id, text, options) => {
|
||||
calls.asks.push({ id, text });
|
||||
if (!ask) throw new Error('history must not reach the model');
|
||||
return ask(id, text, options);
|
||||
},
|
||||
createSession: forbidden('createSession'),
|
||||
ensureRunning: forbidden('ensureRunning'),
|
||||
executeCommand: forbidden('executeCommand'),
|
||||
stopActiveTurn: forbidden('stopActiveTurn'),
|
||||
steerActiveTurn: forbidden('steerActiveTurn'),
|
||||
workspaceSession(id) {
|
||||
return {
|
||||
readHistory: (options) => harness.readSessionHistory(id, options),
|
||||
sessionExists: () => harness.sessionExists(id),
|
||||
ask: (text, options) => harness.ask(id, text, options),
|
||||
};
|
||||
},
|
||||
};
|
||||
const record = async (target, text) => {
|
||||
sent.push({ target, text });
|
||||
if (send) await send(target, text);
|
||||
return { messageId: `out-${sent.length}` };
|
||||
};
|
||||
const download = async () => {
|
||||
calls.downloads.push('download');
|
||||
throw new Error('history must not download attachments');
|
||||
};
|
||||
const options = { harness, state, logger: { warn() {}, error() {} } };
|
||||
let bridge;
|
||||
let message;
|
||||
if (channel === 'weixin') {
|
||||
bridge = new WeixinHarnessBridge({
|
||||
...options,
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/', token: 'test-token', ownerUserId: 'owner',
|
||||
api: { sendText: ({ toUserId, contextToken, runId, text }) => record({ toUserId, contextToken, runId }, text) },
|
||||
});
|
||||
message = (id, text, { image = false, file = false, sender = 'owner', echo = false } = {}) => ({
|
||||
message_id: id, message_type: echo ? 2 : 1, from_user_id: sender,
|
||||
context_token: `context-${id}`, run_id: `run-${id}`,
|
||||
item_list: [
|
||||
{ type: 1, text_item: { text } },
|
||||
...(image ? [{ type: 2, image_item: { media: {} } }] : []),
|
||||
...(file ? [{ type: 4, file_item: { file_name: 'test.txt', media: {} } }] : []),
|
||||
],
|
||||
});
|
||||
} else if (channel === 'feishu') {
|
||||
bridge = new FeishuHarnessBridge({
|
||||
...options,
|
||||
channel: {}, status: {}, allowedSenderOpenIds: new Set(['owner']),
|
||||
client: { im: { v1: { message: { create: async ({ data }) => {
|
||||
await record(data.receive_id, data.msg_type === 'text' ? JSON.parse(data.content).text : data.content);
|
||||
return { code: 0, data: { message_id: `out-${sent.length}` } };
|
||||
} }, messageResource: { get: download } } } },
|
||||
});
|
||||
message = (id, text, { image = false, group = false, sender = 'owner', echo = false, post = false, addressed = true } = {}) => ({
|
||||
sender: { sender_type: echo ? 'bot' : 'user', sender_id: { open_id: sender } },
|
||||
message: {
|
||||
message_id: id, message_type: image || post ? 'post' : 'text',
|
||||
chat_type: group ? 'group' : 'p2p', chat_id: group ? 'group-chat' : 'chat',
|
||||
mentions: group && addressed ? [{ key: '@bot', id: { open_id: 'bot' } }] : [],
|
||||
content: JSON.stringify(image || post ? {
|
||||
content: [[{ tag: 'text', text }, ...(image ? [{ tag: 'img', image_key: 'image' }] : [])]],
|
||||
} : { text }),
|
||||
},
|
||||
});
|
||||
} else if (channel === 'dingtalk') {
|
||||
bridge = new DingtalkHarnessBridge({
|
||||
...options, clientId: 'test-client', clientSecret: 'test-secret',
|
||||
api: { sendText: ({ sessionWebhook, text }) => record(sessionWebhook, text), downloadImage: download },
|
||||
});
|
||||
message = (id, text, { image = false, group = false, addressed = true } = {}) => ({
|
||||
msgId: id, msgtype: image ? 'richText' : 'text', text: { content: text },
|
||||
...(image ? { content: { richText: [{ type: 'text', text }, { type: 'picture', downloadCode: 'image' }] } } : {}),
|
||||
conversationType: group ? '2' : '1', conversationId: group ? 'group-chat' : 'chat',
|
||||
senderStaffId: 'owner', isInAtList: addressed,
|
||||
sessionWebhook: `https://oapi.dingtalk.com/robot/reply?ticket=${id}`,
|
||||
});
|
||||
} else if (channel === 'wecom') {
|
||||
bridge = new WecomHarnessBridge({
|
||||
...options,
|
||||
client: {
|
||||
replyStream: async (frame, _streamId, text, finish) => {
|
||||
if (finish) return record(frame.body.chattype === 'group' ? frame.body.chatid : frame.body.from.userid, text);
|
||||
return undefined;
|
||||
},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: (target, body) => record(target, body.markdown?.content ?? body.text?.content),
|
||||
downloadFile: download,
|
||||
},
|
||||
});
|
||||
message = (id, text, { image = false, group = false } = {}) => ({
|
||||
headers: { req_id: id },
|
||||
body: {
|
||||
msgid: id, chattype: group ? 'group' : 'single', chatid: group ? 'group-chat' : 'chat',
|
||||
from: { userid: 'owner' }, msgtype: image ? 'mixed' : 'text', text: { content: text },
|
||||
...(image ? { mixed: { msg_item: [
|
||||
{ msgtype: 'text', text: { content: text } },
|
||||
{ msgtype: 'image', image: { url: 'https://example.invalid/image' } },
|
||||
] } } : {}),
|
||||
},
|
||||
});
|
||||
} else if (channel === 'qq') {
|
||||
bridge = new QqHarnessBridge({ ...options, ownerUserOpenid: 'owner', bot: { sendText: record }, fetchImpl: download });
|
||||
message = (id, text, { image = false, file = false, group = false, sender = 'owner', echo = false } = {}) => ({
|
||||
messageId: id, senderId: sender, senderIsBot: echo, content: text,
|
||||
kind: group ? 'group' : 'c2c', groupOpenid: group ? 'group-chat' : undefined,
|
||||
rawEventType: group ? 'GROUP_AT_MESSAGE_CREATE' : 'C2C_MESSAGE_CREATE',
|
||||
replyTarget: { scope: group ? 'group' : 'c2c', targetId: group ? 'group-chat' : sender, msgId: id },
|
||||
attachments: image || file ? [{ content_type: image ? 'image/png' : 'text/plain', filename: image ? 'test.png' : 'test.txt', url: 'https://example.invalid/attachment' }] : [],
|
||||
});
|
||||
} else {
|
||||
bridge = new TEXT_BRIDGES[channel]({ ...options, bot: { sendText: record } });
|
||||
message = (id, text, { image = false, file = false, group = false, echo = false, addressed = true } = {}) => ({
|
||||
messageId: id, senderId: 'owner', senderIsBot: echo, content: text,
|
||||
kind: group ? 'group' : 'direct', conversationId: group ? 'group-chat' : 'chat', addressed,
|
||||
replyTarget: { channel, chat: group ? 'group-chat' : 'chat', messageId: id },
|
||||
images: image ? [{ load: download }] : [],
|
||||
files: file ? [{ name: 'test.txt', load: download }] : [],
|
||||
});
|
||||
}
|
||||
return { bridge, message, sent, calls, sessions, key, seen };
|
||||
}
|
||||
|
||||
function previewMarkers(sent) {
|
||||
return sent.map(({ text }) => text).join('\n').match(/history-(?:user|assistant)-[123]/g) ?? [];
|
||||
}
|
||||
|
||||
for (const channel of CHANNELS) {
|
||||
test(`${channel}: /history defaults to 3 and /history 10 clamps to 5 on its existing reply route`, async () => {
|
||||
const f = fixture(channel);
|
||||
for (const [text, count] of [['/history', 3], ['/history 10', 5]]) {
|
||||
f.sent.length = 0;
|
||||
const id = `count-${count}`;
|
||||
await f.bridge.accept(f.message(id, text));
|
||||
assert.deepEqual(previewMarkers(f.sent), MARKERS.slice(-count));
|
||||
assert.ok(f.sent.length > 0 && f.sent.length <= 3);
|
||||
assert.ok(f.sent.every(({ text: reply }) => reply.length <= 1_800));
|
||||
if (channel === 'weixin') {
|
||||
assert.deepEqual(f.sent[0].target, { toUserId: 'owner', contextToken: `context-${id}`, runId: `run-${id}` });
|
||||
} else if (channel === 'dingtalk') {
|
||||
assert.equal(f.sent[0].target, `https://oapi.dingtalk.com/robot/reply?ticket=${id}`);
|
||||
} else if (channel === 'feishu') {
|
||||
assert.equal(f.sent[0].target, 'chat');
|
||||
} else if (channel === 'wecom') {
|
||||
assert.equal(f.sent[0].target, 'owner');
|
||||
} else {
|
||||
assert.deepEqual(f.sent[0].target, f.message(id, text).replyTarget);
|
||||
}
|
||||
}
|
||||
assert.equal(f.calls.reads.length, 2);
|
||||
assert.ok(f.calls.reads.every(({ id }) => id === SESSION_ID));
|
||||
assert.deepEqual(f.calls.asks, []);
|
||||
assert.deepEqual(f.calls.mutations, []);
|
||||
});
|
||||
|
||||
test(`${channel}: invalid counts, attachments and missing bindings stay local`, async () => {
|
||||
const f = fixture(channel);
|
||||
for (const [index, text] of ['/history 0', '/history -1', '/history 1.5', '/history foo', '/history 1 2'].entries()) {
|
||||
await f.bridge.accept(f.message(`invalid-${index}`, text));
|
||||
assert.match(f.sent.at(-1).text, /用法/);
|
||||
}
|
||||
await f.bridge.accept(f.message('with-image', '/history', { image: true }));
|
||||
assert.match(f.sent.at(-1).text, /文字/);
|
||||
// These native envelopes can carry both text and ordinary files. The other
|
||||
// adapters have standalone file messages, not a text command plus a file.
|
||||
if (['weixin', 'qq', ...Object.keys(TEXT_BRIDGES)].includes(channel)) {
|
||||
await f.bridge.accept(f.message('with-file', '/history', { file: true }));
|
||||
assert.match(f.sent.at(-1).text, /文字/);
|
||||
}
|
||||
f.sessions.clear();
|
||||
await f.bridge.accept(f.message('unbound', '/history'));
|
||||
assert.match(f.sent.at(-1).text, /会话/);
|
||||
assert.deepEqual(f.calls, { reads: [], asks: [], mutations: [], downloads: [] });
|
||||
});
|
||||
|
||||
test(`${channel}: duplicate history delivery is processed once`, async () => {
|
||||
const f = fixture(channel);
|
||||
const event = f.message('duplicate', '/history');
|
||||
await Promise.all([f.bridge.accept(event), f.bridge.accept(event)]);
|
||||
await f.bridge.accept(event);
|
||||
assert.equal(f.calls.reads.length, 1);
|
||||
assert.deepEqual(previewMarkers(f.sent), MARKERS.slice(-3));
|
||||
assert.deepEqual(f.calls.asks, []);
|
||||
});
|
||||
|
||||
test(`${channel}: a history read failure is safe and never becomes a model prompt`, async () => {
|
||||
const f = fixture(channel, { readHistory: async () => { throw new Error('private-host-history-detail'); } });
|
||||
await f.bridge.accept(f.message('failed-read', '/history'));
|
||||
assert.equal(f.calls.reads.length, 1);
|
||||
assert.match(f.sent.at(-1).text, /无法读取/);
|
||||
assert.doesNotMatch(f.sent.at(-1).text, /private-host-history-detail/);
|
||||
assert.deepEqual(f.calls.asks, []);
|
||||
assert.deepEqual(f.calls.mutations, []);
|
||||
});
|
||||
|
||||
test(`${channel}: existing transport failure handling never re-reads history or prompts`, async () => {
|
||||
let attempts = 0;
|
||||
const f = fixture(channel, { send: async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new Error('private-channel-failure-detail');
|
||||
} });
|
||||
await f.bridge.accept(f.message('failed-delivery', '/history'));
|
||||
assert.equal(f.calls.reads.length, 1);
|
||||
assert.ok(attempts >= 1);
|
||||
assert.doesNotMatch(f.sent.map(({ text }) => text).join('\n'), /private-channel-failure-detail/);
|
||||
assert.deepEqual(f.calls.asks, []);
|
||||
assert.deepEqual(f.calls.mutations, []);
|
||||
});
|
||||
|
||||
test(`${channel}: collecting /batch blocks history without recording it as input`, async () => {
|
||||
const f = fixture(channel, { ask: async () => 'batch complete' });
|
||||
await f.bridge.accept(f.message('batch', '/batch'));
|
||||
await f.bridge.accept(f.message('batch-text', 'the only batch input'));
|
||||
await f.bridge.accept(f.message('batch-history', '/history 10'));
|
||||
assert.match(f.sent.at(-1).text, /\/send.*\/cancel/);
|
||||
assert.deepEqual(f.calls.reads, []);
|
||||
await f.bridge.accept(f.message('batch-send', '/send'));
|
||||
assert.equal(f.calls.asks.length, 1);
|
||||
assert.match(f.calls.asks[0].text, /the only batch input/);
|
||||
assert.doesNotMatch(f.calls.asks[0].text, /\/history/);
|
||||
assert.deepEqual(f.calls.mutations, []);
|
||||
});
|
||||
|
||||
for (const kind of ['running', 'question', 'approval']) {
|
||||
test(`${channel}: history bypasses a ${kind} task without answering or stopping it`, async (t) => {
|
||||
const ready = deferred();
|
||||
const release = deferred();
|
||||
const responses = [];
|
||||
const f = fixture(channel, { ask: async (sessionId, _text, options) => {
|
||||
if (kind !== 'running') {
|
||||
await options.onInteraction({
|
||||
kind,
|
||||
interactionId: `${kind}-history`, rpcId: `${kind}-history`, sessionId,
|
||||
payload: kind === 'question'
|
||||
? { type: 'question/requested', sessionId, questions: [{ id: 'answer', question: 'choose an answer' }] }
|
||||
: { type: 'approval/requested', sessionId, approvalId: 'approval-history', toolName: 'bash', callId: 'call-history' },
|
||||
...(kind === 'approval' ? { toolCall: { name: 'bash', callId: 'call-history', arguments: '{}' } } : {}),
|
||||
respond: async (value) => { responses.push(value); return { accepted: true }; },
|
||||
});
|
||||
}
|
||||
ready.resolve();
|
||||
await release.promise;
|
||||
return 'original task complete';
|
||||
} });
|
||||
const processing = f.bridge.accept(f.message('active-task', 'original task'));
|
||||
t.after(async () => { release.resolve(); await processing; });
|
||||
await within(ready.promise, `${channel} did not begin the ${kind} task`);
|
||||
const before = f.sent.length;
|
||||
await within(f.bridge.accept(f.message('during-task', '/history 10')), `${channel} history waited for the active task`);
|
||||
assert.deepEqual(previewMarkers(f.sent.slice(before)), MARKERS.slice(-5));
|
||||
assert.equal(f.calls.reads.length, 1);
|
||||
assert.deepEqual(responses, []);
|
||||
assert.deepEqual(f.calls.asks.map(({ text }) => text), ['original task']);
|
||||
assert.deepEqual(f.calls.mutations, []);
|
||||
release.resolve();
|
||||
await processing;
|
||||
});
|
||||
}
|
||||
|
||||
test(`${channel}: /help advertises the same history defaults in both languages`, async () => {
|
||||
for (const language of ['zh', 'en']) {
|
||||
setImHostLanguage(language);
|
||||
try {
|
||||
const f = fixture(channel);
|
||||
await f.bridge.accept(f.message(`help-${language}`, '/help'));
|
||||
const help = f.sent.map(({ text }) => text).join('\n');
|
||||
assert.match(help, /\/history.*3.*5/);
|
||||
if (language === 'en') assert.doesNotMatch(help, /[\u3400-\u9fff]/u);
|
||||
assert.deepEqual(f.calls.reads, []);
|
||||
assert.deepEqual(f.calls.asks, []);
|
||||
} finally {
|
||||
setImHostLanguage('zh');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (channel !== 'weixin') {
|
||||
test(`${channel}: an accepted group history command refuses before reading any Session`, async () => {
|
||||
const f = fixture(channel);
|
||||
f.sessions.set('group:group-chat', SESSION_ID);
|
||||
await f.bridge.accept(f.message('group-history', '/history', { group: true }));
|
||||
assert.match(f.sent.at(-1).text, /私聊/);
|
||||
assert.deepEqual(f.calls, { reads: [], asks: [], mutations: [], downloads: [] });
|
||||
});
|
||||
}
|
||||
|
||||
if (['weixin', 'feishu', 'qq', ...Object.keys(TEXT_BRIDGES)].includes(channel)) {
|
||||
test(`${channel}: a bot echo cannot trigger history or a new model request`, async () => {
|
||||
const f = fixture(channel);
|
||||
await f.bridge.accept(f.message('history-echo', '/history', { echo: true }));
|
||||
assert.deepEqual(f.sent, []);
|
||||
assert.deepEqual(f.calls, { reads: [], asks: [], mutations: [], downloads: [] });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const channel of ['weixin', 'feishu', 'qq']) {
|
||||
test(`${channel}: the existing owner/allowlist guard still blocks history`, async () => {
|
||||
const f = fixture(channel);
|
||||
await f.bridge.accept(f.message('denied-history', '/history', { sender: 'untrusted' }));
|
||||
assert.deepEqual(f.sent, []);
|
||||
assert.deepEqual(f.calls, { reads: [], asks: [], mutations: [], downloads: [] });
|
||||
});
|
||||
}
|
||||
|
||||
test('Feishu accepts its existing plain post normalization for /history', async () => {
|
||||
const f = fixture('feishu');
|
||||
await f.bridge.accept(f.message('post-history', '/history 1', { post: true }));
|
||||
assert.deepEqual(previewMarkers(f.sent), MARKERS.slice(-1));
|
||||
assert.deepEqual(f.calls.asks, []);
|
||||
});
|
||||
|
||||
test('Feishu all-message group mode refuses unmentioned history without prompting', async () => {
|
||||
const f = fixture('feishu');
|
||||
f.sessions.set('group:group-chat', SESSION_ID);
|
||||
await f.bridge.accept(f.message('group-unmentioned-history', '/history', { group: true, addressed: false }));
|
||||
assert.match(f.sent.at(-1).text, /私聊/);
|
||||
assert.deepEqual(f.calls, { reads: [], asks: [], mutations: [], downloads: [] });
|
||||
});
|
||||
|
||||
test('Feishu mention-only group mode still ignores an unmentioned history command', async () => {
|
||||
const f = fixture('feishu');
|
||||
f.bridge.setGroupResponseMode('mention');
|
||||
await f.bridge.accept(f.message('group-unmentioned-ignored', '/history', { group: true, addressed: false }));
|
||||
assert.deepEqual(f.sent, []);
|
||||
assert.deepEqual(f.calls, { reads: [], asks: [], mutations: [], downloads: [] });
|
||||
});
|
||||
|
||||
test('Slack leading-space history messages normalize into the shared command without prompting', async () => {
|
||||
const f = fixture('slack');
|
||||
for (const [text, count] of [[' /history', 3], [' /history 10', 5]]) {
|
||||
f.sent.length = 0;
|
||||
const message = normalizeSlackEvent({
|
||||
event_id: `history-leading-space-${count}`,
|
||||
team_id: 'team',
|
||||
event: {
|
||||
type: 'message', channel_type: 'im', channel: 'chat', user: 'owner',
|
||||
ts: `1700000000.00${count}`, text,
|
||||
},
|
||||
}, 'bot');
|
||||
assert.equal(message.content, text.trim());
|
||||
await f.bridge.accept(message);
|
||||
assert.deepEqual(previewMarkers(f.sent), MARKERS.slice(-count));
|
||||
assert.deepEqual(f.sent[0].target, message.replyTarget);
|
||||
}
|
||||
assert.equal(f.calls.reads.length, 2);
|
||||
assert.ok(f.calls.reads.every(({ id }) => id === SESSION_ID));
|
||||
assert.deepEqual(f.calls.asks, []);
|
||||
assert.deepEqual(f.calls.mutations, []);
|
||||
});
|
||||
601
test/history-command.test.mjs
Normal file
601
test/history-command.test.mjs
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
isHistoryCommand,
|
||||
runHistoryCommand,
|
||||
} from '../src/channels/shared/history-command.mjs';
|
||||
import {
|
||||
getImHostLanguage,
|
||||
setImHostLanguage,
|
||||
} from '../src/channels/shared/i18n.mjs';
|
||||
|
||||
const KEY = 'direct:history-test';
|
||||
const PRIVATE_DETAIL = 'DO_NOT_DISPLAY_PRIVATE_HISTORY_DETAIL';
|
||||
const SAFE_FAILURE = /失败|无法|暂不支持|重试|不存在|变化|取消|unavailable|retry|failed|not exist|changed|cancel/iu;
|
||||
|
||||
function entry(seq, type, data, extra = {}) {
|
||||
return { event: { seq, type, data, ...extra } };
|
||||
}
|
||||
|
||||
function user(seq, text, extra = {}) {
|
||||
return entry(seq, 'user/message', {
|
||||
role: 'user',
|
||||
source: { kind: 'user' },
|
||||
content: [{ type: 'text', text }],
|
||||
...extra,
|
||||
}, { surfaceOp: 'append' });
|
||||
}
|
||||
|
||||
function assistant(seq, turn, text, extra = {}) {
|
||||
return entry(seq, 'assistant/message', {
|
||||
turn,
|
||||
step: 1,
|
||||
message: { role: 'assistant', content: [{ type: 'text', text }] },
|
||||
...extra,
|
||||
}, { surfaceOp: 'append' });
|
||||
}
|
||||
|
||||
function end(seq, turn, reason = { kind: 'completed' }) {
|
||||
return entry(seq, 'turn/end', { turn, reason });
|
||||
}
|
||||
|
||||
function dialogue(turns = 4) {
|
||||
return Array.from({ length: turns }, (_, index) => {
|
||||
const turn = index + 1;
|
||||
return [
|
||||
user(turn * 10, `history_user_${turn}`),
|
||||
assistant(turn * 10 + 1, turn, `history_assistant_${turn}`),
|
||||
end(turn * 10 + 2, turn),
|
||||
];
|
||||
}).flat();
|
||||
}
|
||||
|
||||
function fixture({ sessionId = 'hist42', events = dialogue(), read } = {}) {
|
||||
let binding = sessionId;
|
||||
const calls = [];
|
||||
const state = { sessionFor: () => binding };
|
||||
const forbidden = (method) => () => assert.fail(`/history called mutating method ${method}`);
|
||||
const session = {
|
||||
async readHistory(options) {
|
||||
calls.push({ sessionId: binding, options });
|
||||
return read ? read(options, calls.length) : { events, hasMore: false };
|
||||
},
|
||||
ask: forbidden('ask'),
|
||||
stopActiveTurn: forbidden('stopActiveTurn'),
|
||||
steerActiveTurn: forbidden('steerActiveTurn'),
|
||||
};
|
||||
const harness = {
|
||||
workspaceSession(id) {
|
||||
assert.equal(id, sessionId, 'only the originally bound Session may be read');
|
||||
return session;
|
||||
},
|
||||
createSession: forbidden('createSession'),
|
||||
ensureRunning: forbidden('ensureRunning'),
|
||||
executeCommand: forbidden('executeCommand'),
|
||||
rpc: forbidden('raw RPC outside the scoped history handle'),
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
harness,
|
||||
session,
|
||||
state,
|
||||
bind(nextSessionId) { binding = nextSessionId; },
|
||||
run(text = '/history', options = {}) {
|
||||
return runHistoryCommand(text, harness, state, KEY, { isDirect: true, ...options });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function reply(result) {
|
||||
assert.equal(result?.handled, true);
|
||||
assert.equal(typeof result.message, 'string');
|
||||
assert.ok(result.message.length > 0);
|
||||
assert.ok(Array.isArray(result.messages));
|
||||
assert.ok(result.messages.length > 0 && result.messages.length <= 3);
|
||||
assert.equal(result.messages.join(''), result.message);
|
||||
assert.ok(result.messages.every((part) => typeof part === 'string' && part.length <= 1_800));
|
||||
assert.ok(result.message.length <= 3_000);
|
||||
return result.message;
|
||||
}
|
||||
|
||||
function selectedMarkers(message) {
|
||||
return message.match(/history_(?:user|assistant)_\d+/gu) ?? [];
|
||||
}
|
||||
|
||||
test('history reserves its command token, including malformed arguments, but not other text', async () => {
|
||||
for (const text of [
|
||||
'/history', ' /HiStOrY ', '/history 3', '/history 0', '/history 1 2',
|
||||
'/history -1', '/history\nnot-a-number',
|
||||
]) {
|
||||
assert.equal(isHistoryCommand(text), true, text);
|
||||
}
|
||||
for (const text of [null, undefined, 42, '', 'history', '/history2', '/histories', 'hi /history']) {
|
||||
assert.equal(isHistoryCommand(text), false, String(text));
|
||||
assert.equal(await runHistoryCommand(text, {}, {}, KEY), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('/history defaults to three individual messages, newest selected then oldest displayed', async () => {
|
||||
const current = fixture();
|
||||
const text = reply(await current.run());
|
||||
assert.deepEqual(selectedMarkers(text), [
|
||||
'history_assistant_3', 'history_user_4', 'history_assistant_4',
|
||||
]);
|
||||
assert.match(text, /hist42/);
|
||||
assert.equal(current.calls.length, 1);
|
||||
assert.equal(current.calls[0].options.maxMessages, 50);
|
||||
assert.equal(current.calls[0].options.beforeSeq, undefined);
|
||||
});
|
||||
|
||||
test('/history accepts positive integer counts and clamps every count above five', async () => {
|
||||
const all = Array.from({ length: 4 }, (_, index) => [
|
||||
`history_user_${index + 1}`, `history_assistant_${index + 1}`,
|
||||
]).flat();
|
||||
for (const [argument, count] of [
|
||||
['1', 1], ['2', 2], ['3', 3], ['4', 4], ['5', 5], ['6', 5], ['100', 5],
|
||||
['9'.repeat(400), 5], ['0003', 3],
|
||||
]) {
|
||||
const current = fixture();
|
||||
const text = reply(await current.run(`/history ${argument}`));
|
||||
assert.deepEqual(selectedMarkers(text), all.slice(-count), argument);
|
||||
assert.equal(current.calls.length, 1, argument);
|
||||
}
|
||||
});
|
||||
|
||||
test('history omits stored history commands before applying the default and maximum counts', async () => {
|
||||
const oldCommand = user(1, '/history');
|
||||
oldCommand.event.time = Date.UTC(2020, 0, 1);
|
||||
const currentCommand = user(40, ' /HiStOrY 3 ');
|
||||
currentCommand.event.time = Date.now();
|
||||
const events = [
|
||||
oldCommand,
|
||||
...dialogue(3),
|
||||
user(33, '/history'),
|
||||
user(34, '/history 10'),
|
||||
currentCommand,
|
||||
];
|
||||
const normalMessages = Array.from({ length: 3 }, (_, index) => [
|
||||
`history_user_${index + 1}`, `history_assistant_${index + 1}`,
|
||||
]).flat();
|
||||
for (const [command, count] of [['/history', 3], ['/history 10', 5]]) {
|
||||
const current = fixture({ events });
|
||||
const text = reply(await current.run(command));
|
||||
assert.deepEqual(selectedMarkers(text), normalMessages.slice(-count));
|
||||
assert.doesNotMatch(text, /\/history(?:\s|$)/iu);
|
||||
}
|
||||
});
|
||||
|
||||
test('ordinary history text can mention /history or start with a different command token', async () => {
|
||||
const bodies = ['/history2', '请解释 /history 这个命令', '文档里也有 /history 10 的示例'];
|
||||
const current = fixture({ events: bodies.map((body, index) => user(index + 1, body)) });
|
||||
const text = reply(await current.run());
|
||||
for (const body of bodies) assert.ok(text.includes(body), body);
|
||||
});
|
||||
|
||||
test('filtered history backfills older pages to reach three or five normal messages', async () => {
|
||||
const pages = [
|
||||
{ events: [user(60, '/history'), user(61, '/history 10'), user(62, '/history 3')], hasMore: true },
|
||||
{ events: [
|
||||
user(50, 'history_user_3'), user(51, 'history_user_4'),
|
||||
user(52, 'history_user_5'), user(53, 'history_user_6'), user(54, '/history 2'),
|
||||
], hasMore: true },
|
||||
{ events: [
|
||||
user(10, 'history_user_1'), user(11, 'history_user_2'), user(12, '/history 10'),
|
||||
], hasMore: false },
|
||||
];
|
||||
for (const [command, count, reads] of [['/history', 3, 2], ['/history 10', 5, 3]]) {
|
||||
const current = fixture({ read: (_options, call) => pages[call - 1] });
|
||||
const text = reply(await current.run(command));
|
||||
assert.deepEqual(selectedMarkers(text), Array.from({ length: count }, (_, index) => (
|
||||
`history_user_${7 - count + index}`
|
||||
)));
|
||||
assert.equal(current.calls.length, reads);
|
||||
assert.deepEqual(current.calls.map(({ options }) => options.beforeSeq), [undefined, 60, 50].slice(0, reads));
|
||||
assert.doesNotMatch(text, /\/history(?:\s|$)/iu);
|
||||
}
|
||||
});
|
||||
|
||||
test('only stored history commands produce the empty-history response', async () => {
|
||||
const current = fixture({ events: [
|
||||
user(1, '/history'), user(2, '/history 10'), user(3, ' /HISTORY 5 '),
|
||||
] });
|
||||
const text = reply(await current.run());
|
||||
assert.match(text, /暂无|没有|no .*history|no .*message/iu);
|
||||
assert.doesNotMatch(text, /\/history(?:\s|$)/iu);
|
||||
});
|
||||
|
||||
test('invalid history counts are consumed as usage errors without querying the Session', async () => {
|
||||
for (const argument of ['0', '000', '-1', '+3', '1.5', '1e3', 'no', '1 2', '3\n4', '3']) {
|
||||
const current = fixture();
|
||||
const text = reply(await current.run(`/history ${argument}`));
|
||||
assert.match(text, /用法|usage/iu, argument);
|
||||
assert.match(text, /\/history/u, argument);
|
||||
assert.equal(current.calls.length, 0, argument);
|
||||
}
|
||||
});
|
||||
|
||||
test('history is private and text-only, including when private status is omitted', async () => {
|
||||
for (const options of [
|
||||
{ isDirect: false }, { isDirect: true, hasImages: true }, { isDirect: true, hasFiles: true },
|
||||
]) {
|
||||
const current = fixture();
|
||||
const text = reply(await current.run('/history', options));
|
||||
assert.match(text, /私聊|文字|图片|文件|direct|text|image|file/iu);
|
||||
assert.equal(current.calls.length, 0);
|
||||
}
|
||||
const current = fixture();
|
||||
reply(await runHistoryCommand('/history', current.harness, current.state, KEY));
|
||||
assert.equal(current.calls.length, 0);
|
||||
});
|
||||
|
||||
test('history never creates a Session and returns only the available records', async () => {
|
||||
const unbound = fixture({ sessionId: null });
|
||||
assert.match(reply(await unbound.run()), /绑定|会话|bound|session/iu);
|
||||
assert.equal(unbound.calls.length, 0);
|
||||
|
||||
const empty = fixture({ events: [] });
|
||||
assert.match(reply(await empty.run()), /暂无|没有|no .*history|no .*message/iu);
|
||||
const short = fixture({ events: [user(1, 'history_user_1')] });
|
||||
assert.deepEqual(selectedMarkers(reply(await short.run('/history 5'))), ['history_user_1']);
|
||||
});
|
||||
|
||||
test('history accepts completed reasons from both current and older Host protocols', async () => {
|
||||
for (const reason of [{ kind: 'completed' }, 'completed']) {
|
||||
const current = fixture({ events: [
|
||||
user(1, 'history_user_1'),
|
||||
assistant(2, 1, 'history_assistant_1'),
|
||||
end(3, 1, reason),
|
||||
] });
|
||||
assert.deepEqual(selectedMarkers(reply(await current.run())), [
|
||||
'history_user_1', 'history_assistant_1',
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('history filters injections, replacements, tools, reasoning, and intermediate replies', async () => {
|
||||
const replacedUser = user(8, PRIVATE_DETAIL);
|
||||
replacedUser.event.surfaceOp = 'replace';
|
||||
const replacedAssistant = assistant(9, 1, PRIVATE_DETAIL);
|
||||
replacedAssistant.event.surfaceOp = 'replace';
|
||||
const unknownSource = user(10, PRIVATE_DETAIL, { source: undefined });
|
||||
const missingOperation = user(11, PRIVATE_DETAIL);
|
||||
delete missingOperation.event.surfaceOp;
|
||||
const current = fixture({ events: [
|
||||
user(1, 'history_user_1'),
|
||||
user(2, PRIVATE_DETAIL, { source: { kind: 'plugin', pluginId: 'time-context' } }),
|
||||
user(3, PRIVATE_DETAIL, { source: { kind: 'goal' } }),
|
||||
assistant(4, 1, PRIVATE_DETAIL),
|
||||
entry(5, 'tool/call', { name: 'bash', arguments: PRIVATE_DETAIL, turn: 1 }),
|
||||
entry(6, 'tool/result', { text: PRIVATE_DETAIL, turn: 1 }, { surfaceOp: 'append' }),
|
||||
entry(7, 'assistant/chunk', { turn: 1, chunk: { type: 'text-delta', text: PRIVATE_DETAIL } }),
|
||||
replacedUser,
|
||||
replacedAssistant,
|
||||
unknownSource,
|
||||
missingOperation,
|
||||
entry(12, 'approval/requested', { text: PRIVATE_DETAIL }),
|
||||
assistant(13, 1, '', {
|
||||
step: 2,
|
||||
message: { role: 'assistant', content: [
|
||||
{ type: 'reasoning', text: PRIVATE_DETAIL },
|
||||
{ type: 'text', text: 'history_assistant_1' },
|
||||
{ type: 'tool-call', name: PRIVATE_DETAIL, arguments: PRIVATE_DETAIL },
|
||||
] },
|
||||
}),
|
||||
end(14, 1),
|
||||
] });
|
||||
const text = reply(await current.run('/history 5'));
|
||||
assert.deepEqual(selectedMarkers(text), ['history_user_1', 'history_assistant_1']);
|
||||
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
|
||||
});
|
||||
|
||||
test('history keeps a running user input but never shows its unfinished assistant output', async () => {
|
||||
const current = fixture({ events: [
|
||||
...dialogue(1),
|
||||
user(20, 'history_user_2'),
|
||||
assistant(21, 2, PRIVATE_DETAIL),
|
||||
entry(22, 'assistant/chunk', { turn: 2, chunk: { type: 'text-delta', text: PRIVATE_DETAIL } }),
|
||||
] });
|
||||
const text = reply(await current.run());
|
||||
assert.deepEqual(selectedMarkers(text), [
|
||||
'history_user_1', 'history_assistant_1', 'history_user_2',
|
||||
]);
|
||||
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
|
||||
});
|
||||
|
||||
test('history excludes assistant replies from every unsuccessful terminal state', async () => {
|
||||
for (const reason of ['error', 'aborted', 'blocked', 'max-tokens', 'interrupted']) {
|
||||
for (const terminal of [reason, { kind: reason }]) {
|
||||
const current = fixture({ events: [
|
||||
user(1, 'history_user_1'),
|
||||
assistant(2, 1, PRIVATE_DETAIL),
|
||||
end(3, 1, terminal),
|
||||
] });
|
||||
const text = reply(await current.run());
|
||||
assert.deepEqual(selectedMarkers(text), ['history_user_1'], reason);
|
||||
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'), reason);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('history does not substitute an intermediate answer for an interrupted final message', async () => {
|
||||
const current = fixture({ events: [
|
||||
user(1, 'history_user_1'),
|
||||
assistant(2, 1, PRIVATE_DETAIL),
|
||||
assistant(3, 1, PRIVATE_DETAIL, { step: 2, interrupted: true }),
|
||||
end(4, 1),
|
||||
] });
|
||||
const text = reply(await current.run());
|
||||
assert.deepEqual(selectedMarkers(text), ['history_user_1']);
|
||||
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
|
||||
});
|
||||
|
||||
test('a final reply with no text gets a placeholder, not an earlier step or hidden blocks', async () => {
|
||||
const current = fixture({ events: [
|
||||
user(1, 'history_user_1'),
|
||||
assistant(2, 1, PRIVATE_DETAIL),
|
||||
assistant(3, 1, '', {
|
||||
step: 2,
|
||||
message: { role: 'assistant', content: [{ type: 'reasoning', text: PRIVATE_DETAIL }] },
|
||||
}),
|
||||
end(4, 1),
|
||||
] });
|
||||
const text = reply(await current.run('/history 1'));
|
||||
assert.deepEqual(selectedMarkers(text), []);
|
||||
assert.match(text, /文字|text/iu);
|
||||
assert.match(text, /助手|assistant/iu);
|
||||
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
|
||||
});
|
||||
|
||||
test('structured attachments are represented without exposing their payload or fetching files', async () => {
|
||||
const current = fixture({ events: [user(1, '', {
|
||||
content: [
|
||||
{ type: 'text', text: 'history_user_1' },
|
||||
{ type: 'image', data: PRIVATE_DETAIL, mediaType: 'image/png' },
|
||||
],
|
||||
})] });
|
||||
const text = reply(await current.run());
|
||||
assert.deepEqual(selectedMarkers(text), ['history_user_1']);
|
||||
assert.match(text, /图片|image/iu);
|
||||
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
|
||||
});
|
||||
|
||||
test('history sorts and deduplicates sequence numbers before selecting messages', async () => {
|
||||
const events = dialogue(2);
|
||||
const current = fixture({ events: [
|
||||
events[5], events[3], events[0], events[2], events[4], events[1],
|
||||
structuredClone(events[4]),
|
||||
] });
|
||||
assert.deepEqual(selectedMarkers(reply(await current.run('/history 5'))), [
|
||||
'history_user_1', 'history_assistant_1', 'history_user_2', 'history_assistant_2',
|
||||
]);
|
||||
});
|
||||
|
||||
test('history combines older pages with later terminal events and stops once enough is known', async () => {
|
||||
const pages = [
|
||||
{ events: [end(32, 3)], hasMore: true },
|
||||
{ events: [
|
||||
user(20, 'history_user_2'),
|
||||
assistant(21, 2, 'history_assistant_2'),
|
||||
end(22, 2),
|
||||
user(30, 'history_user_3'),
|
||||
assistant(31, 3, 'history_assistant_3'),
|
||||
], hasMore: true },
|
||||
];
|
||||
const current = fixture({ read: (_options, call) => {
|
||||
assert.ok(call <= pages.length, 'must not fetch beyond the requested messages');
|
||||
return pages[call - 1];
|
||||
} });
|
||||
const text = reply(await current.run());
|
||||
assert.deepEqual(selectedMarkers(text), [
|
||||
'history_assistant_2', 'history_user_3', 'history_assistant_3',
|
||||
]);
|
||||
assert.equal(current.calls.length, 2);
|
||||
assert.deepEqual(current.calls.map(({ options }) => options.beforeSeq), [undefined, 32]);
|
||||
assert.ok(current.calls.every(({ options }) => options.maxMessages === 50));
|
||||
});
|
||||
|
||||
test('a multi-page tool turn contributes only its final reply beside the next running input', async () => {
|
||||
const pages = [
|
||||
{ events: [
|
||||
assistant(90, 1, 'history_assistant_1', { step: 50 }),
|
||||
end(91, 1),
|
||||
user(100, 'history_user_2'),
|
||||
assistant(101, 2, PRIVATE_DETAIL),
|
||||
], hasMore: true },
|
||||
{ events: [
|
||||
assistant(60, 1, PRIVATE_DETAIL, { step: 30 }),
|
||||
assistant(70, 1, PRIVATE_DETAIL, { step: 40 }),
|
||||
], hasMore: true },
|
||||
{ events: [
|
||||
user(1, 'history_user_1'),
|
||||
assistant(2, 1, PRIVATE_DETAIL),
|
||||
], hasMore: false },
|
||||
];
|
||||
for (const count of [1, 3, 5]) {
|
||||
const current = fixture({ read: (_options, call) => pages[call - 1] });
|
||||
const text = reply(await current.run(`/history ${count}`));
|
||||
assert.deepEqual(selectedMarkers(text), [
|
||||
'history_user_1', 'history_assistant_1', 'history_user_2',
|
||||
].slice(-count));
|
||||
assert.equal(current.calls.length, count === 1 ? 1 : 3);
|
||||
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
|
||||
}
|
||||
});
|
||||
|
||||
test('history makes at most three backward reads and returns the confirmed records it has', async () => {
|
||||
const current = fixture({ read: (_options, call) => ({
|
||||
events: [user(100 - call, `history_user_${4 - call}`)], hasMore: true,
|
||||
}) });
|
||||
const text = reply(await current.run('/history 5'));
|
||||
assert.deepEqual(selectedMarkers(text), ['history_user_1', 'history_user_2', 'history_user_3']);
|
||||
assert.equal(current.calls.length, 3);
|
||||
assert.deepEqual(current.calls.map(({ options }) => options.beforeSeq), [undefined, 99, 98]);
|
||||
});
|
||||
|
||||
test('history rejects conflicting or invalid event sequences without echoing any response data', async () => {
|
||||
for (const events of [
|
||||
[user(1, PRIVATE_DETAIL), user(1, `${PRIVATE_DETAIL}_conflict`)],
|
||||
[user(-1, PRIVATE_DETAIL)],
|
||||
[user(1.5, PRIVATE_DETAIL)],
|
||||
[user('1', PRIVATE_DETAIL)],
|
||||
[{ event: { type: 'user/message', data: { text: PRIVATE_DETAIL } } }],
|
||||
]) {
|
||||
const current = fixture({ events });
|
||||
const text = reply(await current.run());
|
||||
assert.match(text, SAFE_FAILURE);
|
||||
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
|
||||
}
|
||||
});
|
||||
|
||||
test('history refuses a non-advancing cursor or a later event in a backward page', async () => {
|
||||
for (const nextSeq of [50, 51]) {
|
||||
const current = fixture({ read: (_options, call) => ({
|
||||
events: [user(call === 1 ? 50 : nextSeq, PRIVATE_DETAIL)], hasMore: true,
|
||||
}) });
|
||||
const text = reply(await current.run('/history 5'));
|
||||
assert.match(text, SAFE_FAILURE);
|
||||
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
|
||||
assert.ok(current.calls.length <= 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('backfill keeps the first tail snapshot even if the Host includes newly appended events', async () => {
|
||||
const current = fixture({ read: (_options, call) => call === 1
|
||||
? { events: [user(50, 'history_user_2')], hasMore: true }
|
||||
: { events: [user(40, 'history_user_1'), user(60, PRIVATE_DETAIL)], hasMore: false }
|
||||
});
|
||||
const text = reply(await current.run());
|
||||
assert.deepEqual(selectedMarkers(text), ['history_user_1', 'history_user_2']);
|
||||
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
|
||||
assert.equal(current.calls.length, 2);
|
||||
});
|
||||
|
||||
test('history returns a safe error for malformed pages and unsupported history handles', async () => {
|
||||
for (const page of [null, {}, { events: PRIVATE_DETAIL }, { events: [null], hasMore: false }]) {
|
||||
const current = fixture({ read: () => page });
|
||||
const text = reply(await current.run());
|
||||
assert.match(text, SAFE_FAILURE);
|
||||
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
|
||||
}
|
||||
const current = fixture();
|
||||
delete current.session.readHistory;
|
||||
assert.match(reply(await current.run()), SAFE_FAILURE);
|
||||
});
|
||||
|
||||
test('history discards a fetched snapshot if the chat is rebound during the read', async () => {
|
||||
const current = fixture({ read: () => {
|
||||
current.bind('another-session');
|
||||
return { events: [user(1, PRIVATE_DETAIL)], hasMore: false };
|
||||
} });
|
||||
const text = reply(await current.run());
|
||||
assert.match(text, SAFE_FAILURE);
|
||||
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
|
||||
});
|
||||
|
||||
test('workspace invalidation, missing Sessions and Host errors never reveal internal details', async () => {
|
||||
for (const code of [
|
||||
'workspace-session-stale', 'workspace-bot-not-found', 'session-not-found',
|
||||
'harness-api-not-found', 'harness-timeout', 'unrecognized-host-error',
|
||||
]) {
|
||||
const current = fixture({ read: () => { throw Object.assign(new Error(PRIVATE_DETAIL), { code }); } });
|
||||
const text = reply(await current.run());
|
||||
assert.match(text, SAFE_FAILURE, code);
|
||||
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'), code);
|
||||
}
|
||||
});
|
||||
|
||||
test('a stale backfill discards every earlier page instead of returning partial private data', async () => {
|
||||
const current = fixture({ read: (_options, call) => {
|
||||
if (call === 1) return { events: [user(30, PRIVATE_DETAIL)], hasMore: true };
|
||||
throw Object.assign(new Error(PRIVATE_DETAIL), { code: 'workspace-session-stale' });
|
||||
} });
|
||||
const text = reply(await current.run('/history 5'));
|
||||
assert.equal(current.calls.length, 2);
|
||||
assert.match(text, SAFE_FAILURE);
|
||||
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
|
||||
});
|
||||
|
||||
test('history uses bounded read options and honors cancellation without exposing old data', async () => {
|
||||
const controller = new AbortController();
|
||||
const current = fixture({ read: (options) => {
|
||||
assert.ok(options.signal instanceof AbortSignal);
|
||||
if (options.timeoutMs !== undefined) {
|
||||
assert.ok(options.timeoutMs > 0 && options.timeoutMs <= 10_000);
|
||||
}
|
||||
controller.abort(new Error(PRIVATE_DETAIL));
|
||||
return { events: [user(1, PRIVATE_DETAIL)], hasMore: false };
|
||||
} });
|
||||
const text = reply(await current.run('/history', { signal: controller.signal }));
|
||||
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
|
||||
assert.match(text, SAFE_FAILURE);
|
||||
});
|
||||
|
||||
test('an already cancelled history request does not query the Host', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error(PRIVATE_DETAIL));
|
||||
const current = fixture();
|
||||
const text = reply(await current.run('/history', { signal: controller.signal }));
|
||||
assert.equal(current.calls.length, 0);
|
||||
assert.match(text, SAFE_FAILURE);
|
||||
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
|
||||
});
|
||||
|
||||
test('history applies one ten-second deadline across backfill requests', async (t) => {
|
||||
let now = 1_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const current = fixture({ read: () => {
|
||||
now += 10_001;
|
||||
return { events: [user(1, PRIVATE_DETAIL)], hasMore: true };
|
||||
} });
|
||||
const text = reply(await current.run('/history 5'));
|
||||
assert.equal(current.calls.length, 1);
|
||||
assert.match(text, SAFE_FAILURE);
|
||||
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
|
||||
});
|
||||
|
||||
test('history truncates long Unicode and code text within shared message and chunk budgets', async () => {
|
||||
const events = Array.from({ length: 6 }, (_, index) => user(index + 1,
|
||||
`history_user_${index + 1}\n\`\`\`js\n${'中文🙂\n'.repeat(600)}\n\`\`\``,
|
||||
));
|
||||
const current = fixture({ events });
|
||||
const result = await current.run('/history 100');
|
||||
const text = reply(result);
|
||||
assert.deepEqual(selectedMarkers(text), [
|
||||
'history_user_2', 'history_user_3', 'history_user_4', 'history_user_5', 'history_user_6',
|
||||
]);
|
||||
assert.match(text, /截断|truncat/iu);
|
||||
assert.equal(text.isWellFormed(), true);
|
||||
assert.ok(result.messages.every((part) => part.isWellFormed()));
|
||||
assert.ok(text.length <= 3_000);
|
||||
assert.ok(result.messages.length <= 3);
|
||||
assert.ok([...text.matchAll(/中文/gu)].length <= 500,
|
||||
'body text should be cut to the shared per-record budget, not merely split into more messages');
|
||||
});
|
||||
|
||||
test('each history body has its own five-hundred-character bound', async () => {
|
||||
const current = fixture({ events: [user(1, `${'Z'.repeat(499)}🙂${'Z'.repeat(2_000)}`)] });
|
||||
const text = reply(await current.run('/history 1'));
|
||||
assert.ok((text.match(/Z/gu) ?? []).length <= 500);
|
||||
assert.match(text, /截断|truncat/iu);
|
||||
assert.equal(text.isWellFormed(), true);
|
||||
});
|
||||
|
||||
test('history English messages translate the command envelope without translating saved content', async () => {
|
||||
const previousLanguage = getImHostLanguage();
|
||||
setImHostLanguage('en');
|
||||
try {
|
||||
const current = fixture({ events: [user(1, 'Saved user text.')] });
|
||||
const text = reply(await current.run());
|
||||
assert.match(text, /history/iu);
|
||||
assert.match(text, /user/iu);
|
||||
assert.match(text, /Saved user text\./u);
|
||||
assert.doesNotMatch(text, /[一-鿿]/u);
|
||||
const invalid = reply(await current.run('/history 0'));
|
||||
assert.match(invalid, /usage/iu);
|
||||
assert.match(invalid, /\/history/u);
|
||||
assert.doesNotMatch(invalid, /[一-鿿]/u);
|
||||
} finally {
|
||||
setImHostLanguage(previousLanguage);
|
||||
}
|
||||
});
|
||||
110
test/history-i18n.test.mjs
Normal file
110
test/history-i18n.test.mjs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { afterEach, beforeEach, test } from 'node:test';
|
||||
|
||||
import { runHistoryCommand } from '../src/channels/shared/history-command.mjs';
|
||||
import { getImHostLanguage, setImHostLanguage, t } from '../src/channels/shared/i18n.mjs';
|
||||
|
||||
let previousLanguage;
|
||||
beforeEach(() => {
|
||||
previousLanguage = getImHostLanguage();
|
||||
setImHostLanguage('en');
|
||||
});
|
||||
afterEach(() => setImHostLanguage(previousLanguage));
|
||||
|
||||
function entry(seq, type, data) {
|
||||
return { event: { seq, type, data, surfaceOp: 'append' } };
|
||||
}
|
||||
|
||||
function user(seq, content) {
|
||||
return entry(seq, 'user/message', { source: { kind: 'user' }, content });
|
||||
}
|
||||
|
||||
function assistant(seq, turn, content) {
|
||||
return entry(seq, 'assistant/message', { turn, message: { content } });
|
||||
}
|
||||
|
||||
function end(seq, turn) {
|
||||
return entry(seq, 'turn/end', { turn, reason: { kind: 'completed' } });
|
||||
}
|
||||
|
||||
function textBlock(text) {
|
||||
return { type: 'text', text };
|
||||
}
|
||||
|
||||
function run(text, { events = [], error, sessionId = 'history-test', ...options } = {}) {
|
||||
return runHistoryCommand(text, {
|
||||
workspaceSession() {
|
||||
return {
|
||||
async readHistory() {
|
||||
if (error) throw error;
|
||||
return { events, hasMore: false };
|
||||
},
|
||||
};
|
||||
},
|
||||
}, { sessionFor: () => sessionId }, 'direct:test', { isDirect: true, ...options });
|
||||
}
|
||||
|
||||
test('/history English preview translates labels, truncation, and media placeholders', async () => {
|
||||
const result = await run('/history', {
|
||||
events: [
|
||||
user(1, [textBlock('Earlier user message')]),
|
||||
assistant(2, 1, [textBlock('Earlier assistant answer')]),
|
||||
end(3, 1),
|
||||
user(4, [textBlock('Recent user message'), { type: 'image' }, { type: 'file' }]),
|
||||
assistant(5, 2, [textBlock('Long final answer. '.repeat(100))]),
|
||||
end(6, 2),
|
||||
],
|
||||
});
|
||||
assert.match(result.message, /^Session history \| history-test \| Recent messages: 3/);
|
||||
assert.match(result.message, /1\. Assistant\nEarlier assistant answer/);
|
||||
assert.match(result.message, /2\. User\nRecent user message\n\[Image\]\n\[File\]/);
|
||||
assert.match(result.message, /3\. Assistant\nLong final answer\./);
|
||||
assert.match(result.message, / \(truncated\)/);
|
||||
assert.ok(result.message.endsWith('These are history records, not a new reply.'));
|
||||
assert.doesNotMatch(result.message, /Earlier user message|[一-鿿]/);
|
||||
assert.ok(result.messages.length <= 3);
|
||||
});
|
||||
|
||||
test('/history English usage covers the indirect HISTORY_USAGE translation key', async () => {
|
||||
for (const command of ['/history 0', '/history -1', '/history 1.5', '/history abc', '/history 1 2']) {
|
||||
assert.equal((await run(command)).message, 'Usage: /history [count] (default 3, maximum 5)');
|
||||
}
|
||||
assert.equal(
|
||||
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
|
||||
'/history [count] Preview recent messages (default 3, maximum 5)',
|
||||
);
|
||||
assert.equal(t('发送 /history 查看最近对话。'), 'Send /history to preview recent conversation messages.');
|
||||
});
|
||||
|
||||
test('/history English validation and empty-history messages never fall back to Chinese', async () => {
|
||||
assert.equal((await run('/history', { isDirect: false })).message, 'Please use /history in a direct chat with the bot.');
|
||||
for (const options of [{ hasImages: true }, { hasFiles: true }]) {
|
||||
assert.equal((await run('/history', options)).message, '/history supports text commands only. Remove images or files and try again.');
|
||||
}
|
||||
assert.equal((await run('/history', { sessionId: null })).message, 'This chat has no bound Session. Send a message or use /session to bind one first.');
|
||||
assert.equal((await run('/history')).message, 'This Session has no history messages available to preview yet.');
|
||||
const result = await run('/history', {
|
||||
events: [assistant(1, 1, []), end(2, 1)],
|
||||
});
|
||||
assert.match(result.message, /1\. Assistant\nThis message has no text to preview\./);
|
||||
assert.match(result.message, /Messages available to preview in this Session: 1\./);
|
||||
assert.doesNotMatch(result.message, /[一-鿿]/);
|
||||
});
|
||||
|
||||
test('/history English errors preserve safe messages and hide raw details', async () => {
|
||||
const cases = [
|
||||
[{ code: 'session-not-found' }, 'The Session bound to this chat no longer exists. Please bind a Session again.'],
|
||||
[{ code: 'workspace-session-stale' }, 'The Session, workspace, or bot state has changed. Please run /history again.'],
|
||||
[{ code: 'workspace-bot-not-found' }, 'The Session, workspace, or bot state has changed. Please run /history again.'],
|
||||
[{ code: 'session-binding-changed' }, 'The Session, workspace, or bot state has changed. Please run /history again.'],
|
||||
[{ code: 'harness-api-not-found' }, 'This Harness does not support reading Session history.'],
|
||||
[{ name: 'AbortError' }, 'History reading was cancelled.'],
|
||||
[{ name: 'TimeoutError' }, 'Reading history timed out. Please try again later.'],
|
||||
[{ code: 'harness-timeout' }, 'Reading history timed out. Please try again later.'],
|
||||
[{}, 'Unable to read Session history right now. Please try again later.'],
|
||||
];
|
||||
for (const [properties, expected] of cases) {
|
||||
const error = Object.assign(new Error('private upstream history details'), properties);
|
||||
assert.equal((await run('/history', { error })).message, expected);
|
||||
}
|
||||
});
|
||||
|
|
@ -238,12 +238,13 @@ test('an old session cannot be written back while RPC switches the bot workspace
|
|||
assert.equal(existenceChecks, 0, 'stale sessions are rejected before asking Harness');
|
||||
});
|
||||
|
||||
test('an old workspace session handle cannot list, select, stop, or steer after a switch', async (t) => {
|
||||
test('an old workspace session handle cannot read history, list, select, stop, or steer after a switch', async (t) => {
|
||||
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_session_controls');
|
||||
const targetCalls = [];
|
||||
const harness = {
|
||||
async readSessionHistory(...args) { targetCalls.push(['history', ...args]); },
|
||||
async getSessionModels(...args) { targetCalls.push(['models', ...args]); },
|
||||
async selectSessionModel(...args) { targetCalls.push(['select', ...args]); },
|
||||
async stopActiveTurn(...args) { targetCalls.push(['stop', ...args]); },
|
||||
|
|
@ -261,6 +262,7 @@ test('an old workspace session handle cannot list, select, stop, or steer after
|
|||
await controller.updateWorkspace('bot_session_controls', alternateWorkspace);
|
||||
const control = { owner: {}, key: 'direct:one' };
|
||||
for (const operation of [
|
||||
() => oldSession.readHistory(),
|
||||
() => oldSession.models(),
|
||||
() => oldSession.selectModel({ provider: 'provider', model: 'model' }),
|
||||
() => oldSession.stopActiveTurn(control),
|
||||
|
|
@ -271,6 +273,33 @@ test('an old workspace session handle cannot list, select, stop, or steer after
|
|||
assert.deepEqual(targetCalls, []);
|
||||
});
|
||||
|
||||
test('history results cannot escape a workspace change that happens while reading', async (t) => {
|
||||
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_history');
|
||||
let finishRead;
|
||||
const calls = [];
|
||||
const harness = {
|
||||
readSessionHistory(sessionId, options) {
|
||||
calls.push({ sessionId, options });
|
||||
return new Promise((resolve) => { finishRead = resolve; });
|
||||
},
|
||||
};
|
||||
const state = { async clearSessions() {} };
|
||||
const scope = createBotWorkspaceScope(harness, { botId: 'bot_history', workspaces, state });
|
||||
const session = scope.harness.workspaceSession('history-session');
|
||||
const options = { maxMessages: 50 };
|
||||
const pending = session.readHistory(options);
|
||||
const rejected = assert.rejects(pending, { code: WORKSPACE_SESSION_STALE });
|
||||
const controller = createWorkspaceAwareController({
|
||||
status() { return { bots: [{ botId: 'bot_history' }] }; },
|
||||
}, { workspaces, stateFor: async () => state });
|
||||
await controller.updateWorkspace('bot_history', alternateWorkspace);
|
||||
finishRead({ events: [], hasMore: false });
|
||||
await rejected;
|
||||
assert.deepEqual(calls, [{ sessionId: 'history-session', options }]);
|
||||
});
|
||||
|
||||
test('a control mutation that already started keeps its result across a workspace switch', async (t) => {
|
||||
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue