fix(core): exclude completed local slash commands from recovery (#12052)

Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
ytahdnand钉萁 authored and GitHub committed 2026-09-17 05:52:03 +00:00
1 parent 9dd689f048
commit 293a5b8112
4 files changed
+274 -1

No files matched your search

@@ -7,6 +7,7 @@
import { describe, expect, it } from 'vitest';
import type { ChatRecord } from './chatRecordingService.js';
import { CompressionStatus } from '../core/turn.js';
import { detectTurnInterruption } from '../core/turn-interruption.js';
import {
buildApiHistoryFromConversation,
buildSessionHistoryFromConversation,
@@ -14,6 +15,153 @@ import {
const permit = { goalId: 'goal', revision: 1, turnId: 'turn' };
describe('completed local slash commands', () => {
function commandRecords(command = '/docs'): ChatRecord[] {
const base = {
sessionId: 'session',
timestamp: '2026-09-17T00:00:00.000Z',
cwd: '/workspace',
version: 'test',
};
return [
{
...base,
uuid: 'user',
parentUuid: null,
type: 'user',
message: { role: 'user', parts: [{ text: command }] },
},
{
...base,
uuid: 'output',
parentUuid: 'user',
type: 'system',
subtype: 'slash_command',
systemPayload: {
phase: 'result',
rawCommand: command,
outputHistoryItems: [{ type: 'assistant', text: 'Done.' }],
},
},
];
}
it.each(['/docs', '/export md', '/effort', '/summary', '/model --fast test'])(
'excludes completed %s from model history without changing the transcript',
(command) => {
const messages = commandRecords(command);
const original = structuredClone(messages);
const history = buildApiHistoryFromConversation({ messages });
expect(history).toEqual([]);
expect(detectTurnInterruption(history).kind).toBe('none');
expect(messages).toEqual(original);
},
);
it('preserves unanswered input before and after a completed command', () => {
const [user, output] = commandRecords();
const pending: ChatRecord = {
...user,
uuid: 'pending',
message: { role: 'user', parts: [{ text: 'unfinished request' }] },
};
for (const messages of [
[pending, user, output, output],
[user, output, pending],
]) {
const history = buildApiHistoryFromConversation({ messages });
expect(history).toEqual([pending.message]);
expect(detectTurnInterruption(history).kind).toBe('interrupted_prompt');
}
expect(buildApiHistoryFromConversation({ messages: [user] })).toEqual([
user.message,
]);
expect(
buildApiHistoryFromConversation({ messages: [user, pending, output] }),
).toEqual([user.message, pending.message]);
});
it.each([true, false])(
'does not pair a TUI invocation (sentToModel=%s) with old input',
(sentToModel) => {
const [user, output] = commandRecords('/custom');
const invocation: ChatRecord = {
...output,
uuid: 'invocation',
systemPayload: {
phase: 'invocation',
rawCommand: '/custom',
sentToModel,
},
};
expect(
buildApiHistoryFromConversation({
messages: [user, invocation, output],
}),
).toEqual([user.message]);
},
);
it.each(['info', 'away_recap', 'error'])(
'does not mistake %s output for an ACP command result',
(type) => {
const [user, output] = commandRecords();
output.systemPayload = {
phase: 'result',
rawCommand: '/docs',
outputHistoryItems: [{ type, text: 'display only' }],
};
expect(
buildApiHistoryFromConversation({ messages: [user, output] }),
).toEqual([user.message]);
},
);
it('does not discard unrelated results or merged mid-turn input', () => {
const [user, output] = commandRecords();
const unrelated = commandRecords('/other')[1];
expect(
buildApiHistoryFromConversation({ messages: [user, unrelated] }),
).toEqual([user.message]);
const midTurn: ChatRecord = {
...user,
uuid: 'mid',
subtype: 'mid_turn_user_message',
};
expect(
buildApiHistoryFromConversation({ messages: [user, midTurn, output] }),
).toEqual([
{
role: 'user',
parts: [...user.message!.parts!, ...midTurn.message!.parts!],
},
]);
});
it('does not pop a compression snapshot when the old command result arrives', () => {
const [user, output] = commandRecords();
const compressedHistory = [{ role: 'model', parts: [{ text: 'summary' }] }];
const compression: ChatRecord = {
...output,
uuid: 'compression',
subtype: 'chat_compression',
systemPayload: {
info: {
originalTokenCount: 100,
newTokenCount: 50,
compressionStatus: CompressionStatus.COMPRESSED,
},
compressedHistory,
},
};
expect(
buildApiHistoryFromConversation({
messages: [user, compression, output],
}),
).toEqual(compressedHistory);
});
});
function records(toolCallId = 'finish'): ChatRecord[] {
const base = {
sessionId: 'session',
@@ -9,6 +9,7 @@ import type {
ChatCompressionRecordPayload,
ChatRecord,
GoalTurnEndRecordPayload,
SlashCommandRecordPayload,
} from './chatRecordingService.js';
export interface BuildApiHistoryOptions {
@@ -103,6 +104,36 @@ export class SessionApiHistoryAccumulator {
add(record: ChatRecord): void {
if (record.type === 'system') {
if (record.subtype === 'slash_command') {
const payload = record.systemPayload as
| SlashCommandRecordPayload
| undefined;
const previous = this.lastMaterialRecord;
const parts = previous?.message?.parts;
// ACP records local command input as user and its output as a system
// result. Neither belongs in model history. TUI invocations fence off
// earlier input, including custom commands submitted to the model.
if (
payload?.phase === 'result' &&
payload.sentToModel !== true &&
Array.isArray(payload.outputHistoryItems) &&
payload.outputHistoryItems.length > 0 &&
payload.outputHistoryItems.every(
(item) => item?.['type'] === 'assistant',
) &&
previous?.type === 'user' &&
previous.subtype === undefined &&
previous.message?.role === 'user' &&
parts?.length === 1 &&
typeof parts[0].text === 'string' &&
Object.keys(parts[0]).length === 1 &&
parts[0].text === payload.rawCommand
) {
this.history.pop();
}
if (previous?.type === 'user') this.lastMaterialRecord = undefined;
return;
}
if (record.subtype === 'goal_turn_end') {
const payload = record.systemPayload as
| GoalTurnEndRecordPayload
@@ -48,6 +48,7 @@ vi.mock('node:fs/promises', async (importOriginal) => {
import { Storage } from '../config/storage.js';
import type { Config } from '../config/config.js';
import { CompressionStatus } from '../core/turn.js';
import { detectTurnInterruption } from '../core/turn-interruption.js';
import {
SessionSourceService,
type SessionSourcesSnapshot,
@@ -129,6 +130,97 @@ describe('SessionTranscriptReader', () => {
return filePath;
}
it.each([false, true])(
'restores completed slash commands consistently with compression=%s',
async (compressed) => {
const answer = record('a1', null, 'previous answer');
const prefix: ChatRecord = compressed
? {
...answer,
type: 'system',
subtype: 'chat_compression',
message: undefined,
systemPayload: {
info: {
originalTokenCount: 100,
newTokenCount: 50,
compressionStatus: CompressionStatus.COMPRESSED,
},
compressedHistory: [answer.message!],
},
}
: answer;
const user = record('u1', 'a1', '/docs');
const output: ChatRecord = {
...record('output', 'u1', ''),
type: 'system',
subtype: 'slash_command',
message: undefined,
systemPayload: {
phase: 'result',
rawCommand: '/docs',
outputHistoryItems: [
{ type: 'assistant', text: 'Documentation URL' },
],
},
};
await writeRecords([prefix, user, output]);
const service = new SessionService(workspaceDir, {
runtimeBaseDir: runtimeDir,
});
const loaded = await service.loadSession(sessionId);
const history = buildApiHistoryFromConversation(loaded!.conversation);
expect(history).toEqual([answer.message]);
expect(detectTurnInterruption(history).kind).toBe('none');
for (const replay of [
{ kind: 'none' },
{ kind: 'all', hideInheritedHistory: false },
] as const) {
const projection = await service.readRestoreProjection(sessionId, {
replay,
});
expect(projection?.runtime.apiHistory).toEqual(history);
if (replay.kind === 'all') {
expect(projection?.replay?.records).toEqual(
expect.arrayContaining([
expect.objectContaining({ uuid: 'u1', message: user.message }),
expect.objectContaining({
uuid: 'output',
systemPayload: output.systemPayload,
}),
]),
);
}
}
const invocation: ChatRecord = {
...output,
uuid: 'invocation',
systemPayload: {
phase: 'invocation',
rawCommand: '/docs',
sentToModel: true,
},
};
await writeRecords([
prefix,
user,
invocation,
{ ...output, parentUuid: 'invocation' },
]);
const custom = await service.readRestoreProjection(sessionId, {
replay: { kind: 'none' },
});
expect(custom?.runtime.apiHistory).toEqual([
answer.message,
user.message,
]);
expect(detectTurnInterruption(custom!.runtime.apiHistory).kind).toBe(
'interrupted_prompt',
);
},
);
it('round-trips a recorded Goal turn end through selective restore, fork, and rewind', async () => {
const config = {
storage: new Storage(workspaceDir),
@@ -2729,7 +2729,9 @@ export class SessionTranscriptReader {
const entry = index.byUuid.get(uuid);
if (
position === compressionPosition ||
((entry?.type !== 'system' || entry?.subtype === 'goal_turn_end') &&
((entry?.type !== 'system' ||
entry?.subtype === 'goal_turn_end' ||
entry?.subtype === 'slash_command') &&
(compressionPosition < 0 || position > compressionPosition))
) {
modelSet.add(uuid);