mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 06:30:45 +08:00
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.
601 lines
24 KiB
JavaScript
601 lines
24 KiB
JavaScript
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);
|
||
}
|
||
});
|