diff --git a/packages/core/src/services/session-api-history.test.ts b/packages/core/src/services/session-api-history.test.ts index 154cdd8c1ff..0ea0f8a7dd8 100644 --- a/packages/core/src/services/session-api-history.test.ts +++ b/packages/core/src/services/session-api-history.test.ts @@ -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', diff --git a/packages/core/src/services/session-api-history.ts b/packages/core/src/services/session-api-history.ts index ca7a15b42c1..542106930da 100644 --- a/packages/core/src/services/session-api-history.ts +++ b/packages/core/src/services/session-api-history.ts @@ -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 diff --git a/packages/core/src/services/session-transcript-reader.test.ts b/packages/core/src/services/session-transcript-reader.test.ts index b74f564edb0..692a3e8c9e6 100644 --- a/packages/core/src/services/session-transcript-reader.test.ts +++ b/packages/core/src/services/session-transcript-reader.test.ts @@ -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), diff --git a/packages/core/src/services/session-transcript-reader.ts b/packages/core/src/services/session-transcript-reader.ts index a0956dc4131..91f20ff9845 100644 --- a/packages/core/src/services/session-transcript-reader.ts +++ b/packages/core/src/services/session-transcript-reader.ts @@ -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);