From 1dd94e73b8f2c4a25b248edde16e9ccd3a4fc70d Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:34:33 +0800 Subject: [PATCH 1/3] fix(core): persist Goal turn endings outside model history --- .../acp-integration/session/Session.test.ts | 173 ++++++++++++---- .../src/acp-integration/session/Session.ts | 28 +++ packages/cli/src/nonInteractive/session.ts | 1 + packages/cli/src/nonInteractiveCli.ts | 1 + .../cli/src/serve/prompt-terminal-ledger.ts | 7 +- packages/cli/src/ui/hooks/use-llm-stream.ts | 5 +- packages/core/src/core/client.test.ts | 122 +++++++++-- packages/core/src/core/client.ts | 25 ++- packages/core/src/core/llm-chat.test.ts | 88 ++++++++ packages/core/src/core/llm-chat.ts | 48 ++++- packages/core/src/core/session-recovery.ts | 15 +- .../core/src/core/turn-interruption.test.ts | 39 ++++ packages/core/src/core/turn-interruption.ts | 29 ++- packages/core/src/index.ts | 1 + .../src/services/chatRecordingService.test.ts | 42 ++++ .../core/src/services/chatRecordingService.ts | 19 ++ .../services/memoryPressureMonitor.test.ts | 36 +++- .../src/services/memoryPressureMonitor.ts | 2 +- .../src/services/session-api-history.test.ts | 190 ++++++++++++++++++ .../core/src/services/session-api-history.ts | 96 ++++++++- .../session-transcript-reader.test.ts | 114 ++++++++++- .../src/services/session-transcript-reader.ts | 5 +- .../core/src/utils/conversation-branches.ts | 1 + packages/core/src/utils/transcript-records.ts | 1 + 24 files changed, 1000 insertions(+), 88 deletions(-) create mode 100644 packages/core/src/services/session-api-history.test.ts diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 72179a7d017..cf679cd1802 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -518,6 +518,7 @@ describe('Session', () => { recordTurnResult: ReturnType; recordUserMessage: ReturnType; recordGoalRuntimeMessage: ReturnType; + recordGoalTurnEnd: ReturnType; recordMidTurnUserMessage: ReturnType; recordUiTelemetryEvent: ReturnType; recordToolResult: ReturnType; @@ -753,10 +754,15 @@ describe('Session', () => { }); const getHistoryMock = vi.fn().mockReturnValue([]); + let completedToolCallIds: readonly string[] = []; mockChat = { sendMessageStream: vi.fn(), addHistory: vi.fn(), getHistory: getHistoryMock, + setCompletedToolCallIds: vi.fn((ids: readonly string[]) => { + completedToolCallIds = ids; + }), + getCompletedToolCallIds: vi.fn(() => completedToolCallIds), // continueLastTurn classifies from a bounded tail; delegate to getHistory // so tests that set getHistory drive detection (fixtures are small). getHistoryTail: vi.fn(() => getHistoryMock()), @@ -871,6 +877,7 @@ describe('Session', () => { recordTurnResult: vi.fn(), recordUserMessage: vi.fn(), recordGoalRuntimeMessage: vi.fn(), + recordGoalTurnEnd: vi.fn().mockResolvedValue(undefined), recordMidTurnUserMessage: vi.fn(), recordUiTelemetryEvent: vi.fn(), recordToolResult: vi.fn(), @@ -28199,63 +28206,151 @@ describe('Session', () => { }, ]); - it('ends a Goal turn without another model request', async () => { - // The proposal only reaches the verifier at a turn boundary, so a - // continuation that keeps the turn alive parks it indefinitely: - // the objective is already met, and the runtime refuses every - // later proposal for the same turn. + it.each([false, true])( + 'ends a Goal turn without another model request (recording fails: %s)', + async (recordingFails) => { + // The proposal only reaches the verifier at a turn boundary, so a + // continuation that keeps the turn alive parks it indefinitely: + // the objective is already met, and the runtime refuses every + // later proposal for the same turn. + const permit: core.GoalTurnPermit = { + goalId: 'goal-1', + revision: 1, + turnId: 'turn-terminating-tool', + }; + const turnKey = 'goal-runtime:turn-terminating-tool'; + mockGoalRuntime.getSnapshot.mockReturnValue(activeGoalSnapshot); + mockGoalRuntime.permitForTurn.mockImplementation((key: string) => + key === turnKey ? permit : undefined, + ); + agentTelemetry.getActiveInteractionSpan.mockReturnValue( + agentTelemetry.span, + ); + mockToolsWithTerminatingUpdateGoal(); + if (recordingFails) { + mockChatRecordingService.recordGoalTurnEnd.mockRejectedValue( + new Error('writer failed'), + ); + } + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + streamCalling({ id: 'call-update', name: 'update_goal' }), + ) + .mockResolvedValue(createEmptyStream()); + + expect(boundGoalHost).toBeDefined(); + await boundGoalHost!.startGoalTurn({ + permit, + continuationContext: 'write a poem', + }); + + await vi.waitFor(() => { + expect(mockGoalRuntime.finishTurn).toHaveBeenCalledWith(permit); + }); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(1); + // Settled as a completed iteration, not paused as a failure. + expect(mockGoalRuntime.dispatch).not.toHaveBeenCalled(); + // The turn ends, but its own tool response still has to reach the + // transcript, or the next request carries a call with no result. + expect(mockChat.addHistory).toHaveBeenCalledWith({ + role: 'user', + parts: expect.arrayContaining([ + expect.objectContaining({ + functionResponse: expect.objectContaining({ + id: 'call-update', + }) as unknown, + }), + ]) as unknown, + }); + expect(mockClient.extNotification).toHaveBeenCalledWith( + '_qwencode/end_turn', + expect.objectContaining({ reason: 'end_turn', source: 'goal' }), + ); + expect( + agentTelemetry.captures[0]?.writeToSpan, + ).toHaveBeenCalledWith(agentTelemetry.span); + expect( + mockChatRecordingService.recordGoalTurnEnd, + ).toHaveBeenCalledWith('call-update', permit); + const history = vi + .mocked(mockChat.addHistory) + .mock.calls.map(([entry]) => entry); + vi.mocked(mockChat.getHistory).mockReturnValue(history); + expect(session.getRecoveryStatus()).toEqual({ + kind: recordingFails ? 'interrupted_prompt' : 'clean', + canContinue: recordingFails, + }); + expect( + mockChatRecordingService.recordGoalRuntimeMessage, + ).toHaveBeenCalledTimes(1); + history.push({ + role: 'user', + parts: [{ text: 'new unanswered request' }], + }); + expect(session.getRecoveryStatus()).toEqual({ + kind: 'interrupted_prompt', + canContinue: true, + }); + }, + ); + + it('does not record a clean boundary when cancellation arrives during tool-result rewriting', async () => { const permit: core.GoalTurnPermit = { goalId: 'goal-1', revision: 1, - turnId: 'turn-terminating-tool', + turnId: 'cancelled-tool-end', }; - const turnKey = 'goal-runtime:turn-terminating-tool'; + const turnKey = `goal-runtime:${permit.turnId}`; mockGoalRuntime.getSnapshot.mockReturnValue(activeGoalSnapshot); mockGoalRuntime.permitForTurn.mockImplementation((key: string) => key === turnKey ? permit : undefined, ); - agentTelemetry.getActiveInteractionSpan.mockReturnValue( - agentTelemetry.span, - ); mockToolsWithTerminatingUpdateGoal(); + let releaseRewrite!: () => void; + const waitForPendingRewrites = vi.fn( + () => + new Promise((resolve) => { + releaseRewrite = resolve; + }), + ); + session.messageRewriter = { + interceptUpdate: vi.fn().mockResolvedValue(undefined), + flushTurn: vi.fn().mockResolvedValue(undefined), + waitForPendingRewrites, + } as unknown as Session['messageRewriter']; mockChat.sendMessageStream = vi .fn() .mockResolvedValueOnce( streamCalling({ id: 'call-update', name: 'update_goal' }), - ) - .mockResolvedValue(createEmptyStream()); - - expect(boundGoalHost).toBeDefined(); + ); await boundGoalHost!.startGoalTurn({ permit, - continuationContext: 'write a poem', - }); - - await vi.waitFor(() => { - expect(mockGoalRuntime.finishTurn).toHaveBeenCalledWith(permit); - }); - expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(1); - // Settled as a completed iteration, not paused as a failure. - expect(mockGoalRuntime.dispatch).not.toHaveBeenCalled(); - // The turn ends, but its own tool response still has to reach the - // transcript, or the next request carries a call with no result. - expect(mockChat.addHistory).toHaveBeenCalledWith({ - role: 'user', - parts: expect.arrayContaining([ - expect.objectContaining({ - functionResponse: expect.objectContaining({ - id: 'call-update', - }) as unknown, - }), - ]) as unknown, + continuationContext: 'finish the goal', }); - expect(mockClient.extNotification).toHaveBeenCalledWith( - '_qwencode/end_turn', - expect.objectContaining({ reason: 'end_turn', source: 'goal' }), + await vi.waitFor(() => + expect(waitForPendingRewrites).toHaveBeenCalled(), ); - expect(agentTelemetry.captures[0]?.writeToSpan).toHaveBeenCalledWith( - agentTelemetry.span, + await session.cancelPendingPrompt(); + releaseRewrite(); + await vi.waitFor(() => + expect(mockClient.extNotification).toHaveBeenCalledWith( + '_qwencode/end_turn', + expect.objectContaining({ reason: 'cancelled', source: 'goal' }), + ), ); + expect( + mockChatRecordingService.recordGoalTurnEnd, + ).not.toHaveBeenCalled(); + expect(mockChat.setCompletedToolCallIds).not.toHaveBeenCalled(); + expect(mockChat.sendMessageStream).toHaveBeenCalledOnce(); + vi.mocked(mockChat.getHistory).mockReturnValue( + vi.mocked(mockChat.addHistory).mock.calls.map(([entry]) => entry), + ); + expect(session.getRecoveryStatus()).toEqual({ + kind: 'interrupted_prompt', + canContinue: true, + }); }); it('runs managed memory effects after an early Goal turn end', async () => { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index c5313bc265a..e2d73d8f27b 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -649,6 +649,7 @@ interface AcpGoalTurn extends GoalContinuationTurn { controller: AbortController; origin: 'runtime' | 'user'; modelStarted: boolean; + endingToolCallId?: string; } function sameGoalPermit( @@ -2787,6 +2788,29 @@ export class Session implements SessionContext { const cancelledByUser = result?.stopReason === 'cancelled' && turn.controller.signal.reason === USER_CANCEL_ABORT_REASON; + if ( + turn.endingToolCallId && + result?.stopReason === 'end_turn' && + failureMessage === undefined && + !turn.controller.signal.aborted + ) { + const recorder = this.config.getChatRecordingService(); + if (recorder) { + try { + await recorder.recordGoalTurnEnd( + turn.endingToolCallId, + turn.permit, + ); + const chat = this.#getCurrentChat(); + chat.setCompletedToolCallIds([ + ...chat.getCompletedToolCallIds(), + turn.endingToolCallId, + ]); + } catch (error) { + debugLogger.warn('Failed to record ACP Goal turn end:', error); + } + } + } // A turn preempted by a newly arrived user prompt is a handoff, not a // failure. `this.pendingPrompt` is the goal turn's own controller while // a goal turn is in flight, so a new prompt aborts it with @@ -5437,6 +5461,7 @@ export class Session implements SessionContext { : undefined); return buildSessionRecoveryPlanFromApiHistory({ sessionId: this.sessionId, + completedToolCallIds: chat.getCompletedToolCallIds?.(), apiHistory: fullHistory ? chat.getHistory() : (chat.getHistoryTailShallow?.(TURN_INTERRUPTION_HISTORY_TAIL_COUNT) ?? @@ -8694,6 +8719,9 @@ export class Session implements SessionContext { true, ); await this.messageRewriter?.waitForPendingRewrites(); + goalTurn.endingToolCallId = toolRun.parts.findLast( + (part) => part.functionResponse?.id, + )?.functionResponse?.id; return true; } diff --git a/packages/cli/src/nonInteractive/session.ts b/packages/cli/src/nonInteractive/session.ts index c5b9b619dfb..afd6ef9de5b 100644 --- a/packages/cli/src/nonInteractive/session.ts +++ b/packages/cli/src/nonInteractive/session.ts @@ -555,6 +555,7 @@ class Session { const recoveryPlan = buildSessionRecoveryPlanFromApiHistory({ sessionId: this.sessionId, apiHistory: historyTail, + completedToolCallIds: chat.getCompletedToolCallIds?.(), }); debugLogger.info('[Session] requestContinueLastTurn recovery', { sessionId: this.sessionId, diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index bfb9d559cf7..2c5a5aa90aa 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -1141,6 +1141,7 @@ export async function runNonInteractive( const recoveryPlan = buildSessionRecoveryPlanFromApiHistory({ sessionId, apiHistory: llmClient.getChat().getHistory(), + completedToolCallIds: llmClient.getChat().getCompletedToolCallIds?.(), }); debugLogger.info('[runNonInteractive] continueInterrupted recovery', { kind: recoveryPlan.kind, diff --git a/packages/cli/src/serve/prompt-terminal-ledger.ts b/packages/cli/src/serve/prompt-terminal-ledger.ts index 13b921117fe..52a604328b5 100644 --- a/packages/cli/src/serve/prompt-terminal-ledger.ts +++ b/packages/cli/src/serve/prompt-terminal-ledger.ts @@ -7,7 +7,7 @@ import type { Content } from '@google/genai'; import { closeSync, openSync, readSync, statSync } from 'node:fs'; import { - buildApiHistoryFromConversation, + buildSessionHistoryFromConversation, detectTurnInterruption, SessionService, TURN_INTERRUPTION_HISTORY_TAIL_COUNT, @@ -258,9 +258,10 @@ export async function reconcileDanglingPromptTerminals( ) { return; } - const apiHistory = buildApiHistoryFromConversation(resumed.conversation); + const { apiHistory, completedToolCallIds } = + buildSessionHistoryFromConversation(resumed.conversation); const historyTail = apiHistory.slice(-TURN_INTERRUPTION_HISTORY_TAIL_COUNT); - const verdict = detectTurnInterruption(historyTail); + const verdict = detectTurnInterruption(historyTail, completedToolCallIds); // Id-less tool-call guard: `detectTurnInterruption` ignores functionCalls // without an id (they cannot be paired on the wire), but reconciliation // needs no wire pairing — a model tail holding ANY functionCall means the diff --git a/packages/cli/src/ui/hooks/use-llm-stream.ts b/packages/cli/src/ui/hooks/use-llm-stream.ts index 995fc4f313a..b01953a5bb4 100644 --- a/packages/cli/src/ui/hooks/use-llm-stream.ts +++ b/packages/cli/src/ui/hooks/use-llm-stream.ts @@ -4516,7 +4516,10 @@ export const useLlmStream = ( }; const orphanedEntries: Part[][] = []; try { - const history = llmClient?.getHistoryShallow?.() ?? []; + const history = + llmClient?.getChat?.()?.getHistoryForRecovery?.() ?? + llmClient?.getHistoryShallow?.() ?? + []; for (let i = history.length - 1; i >= 0; i--) { const entry = history[i]; if (!entry || entry.role !== 'user') break; diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 329d05c436d..57fb4acea3d 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -729,6 +729,40 @@ describe('Gemini Client (client.ts)', () => { }); describe('initialize', () => { + it('keeps the restored tool boundary through startup reminder refresh', async () => { + const result: Content = { + role: 'user', + parts: [ + { + functionResponse: { + id: 'ended', + name: 'update_goal', + response: {}, + }, + }, + ], + }; + vi.mocked(mockConfig.getSessionRestoreRuntime).mockReturnValue({ + apiHistory: [result], + completedToolCallIds: ['ended'], + uiTelemetryEvents: [], + } as unknown as ReturnType); + const resumedClient = new LlmClient(mockConfig); + await resumedClient.initialize(); + expect(resumedClient.getChat().getHistoryForRecovery()).toEqual([]); + await resumedClient.refreshStartupContextReminder(); + expect(resumedClient.getChat().getHistoryForRecovery()).toEqual([]); + const input: Content = { + role: 'user', + parts: [{ text: 'next request' }], + }; + resumedClient.getChat().addHistory(input); + expect(resumedClient.stripOrphanedUserEntriesFromHistory()).toEqual([ + input, + ]); + expect(resumedClient.getHistory().at(-1)).toEqual(result); + }); + it('initializes from the selective runtime projection without the full transcript', async () => { // Crossing a macrotask boundary is what makes this an oracle for the // `await`: a mock that returns `undefined` (or resolves in the same @@ -1912,6 +1946,7 @@ describe('Gemini Client (client.ts)', () => { { role: 'model', parts: [{ text: 'hi' }] }, ]; const mockChat: Partial = { + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(currentHistory), setHistory: vi.fn(), }; @@ -1920,7 +1955,10 @@ describe('Gemini Client (client.ts)', () => { await client.refreshStartupContextReminder(); - expect(mockChat.setHistory).toHaveBeenCalledWith(currentHistory.slice(1)); + expect(mockChat.setHistory).toHaveBeenCalledWith( + currentHistory.slice(1), + undefined, + ); }); it('removes the full legacy 2-entry prelude, not just the first entry', async () => { @@ -1950,6 +1988,7 @@ describe('Gemini Client (client.ts)', () => { ], }; const mockChat: Partial = { + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(currentHistory), setHistory: vi.fn(), }; @@ -1962,10 +2001,10 @@ describe('Gemini Client (client.ts)', () => { await client.refreshStartupContextReminder(); // slice(2) drops BOTH legacy entries; slice(1) would have left legacyAck. - expect(mockChat.setHistory).toHaveBeenCalledWith([ - newPrelude, - ...currentHistory.slice(2), - ]); + expect(mockChat.setHistory).toHaveBeenCalledWith( + [newPrelude, ...currentHistory.slice(2)], + undefined, + ); }); }); @@ -3976,6 +4015,7 @@ describe('Gemini Client (client.ts)', () => { const setHistory = vi.fn(); client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(history), setHistory, } as unknown as LlmChat; @@ -4008,6 +4048,7 @@ describe('Gemini Client (client.ts)', () => { const { history } = await makeReadFileResponses(6); client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(history), setHistory: vi.fn(), } as unknown as LlmChat; @@ -4036,6 +4077,7 @@ describe('Gemini Client (client.ts)', () => { const setHistory = vi.fn(); client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(history), setHistory, } as unknown as LlmChat; @@ -4072,6 +4114,7 @@ describe('Gemini Client (client.ts)', () => { const { history } = await makeReadFileResponses(6); client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(history), setHistory: vi.fn(), } as unknown as LlmChat; @@ -4109,6 +4152,7 @@ describe('Gemini Client (client.ts)', () => { const setHistory = vi.fn(); client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(history), setHistory, } as unknown as LlmChat; @@ -4158,6 +4202,7 @@ describe('Gemini Client (client.ts)', () => { const setHistory = vi.fn(); client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(history), setHistory, } as unknown as LlmChat; @@ -4189,6 +4234,7 @@ describe('Gemini Client (client.ts)', () => { const setHistory = vi.fn(); client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(history), setHistory, } as unknown as LlmChat; @@ -4217,6 +4263,7 @@ describe('Gemini Client (client.ts)', () => { const setHistory = vi.fn(); client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(history), setHistory, } as unknown as LlmChat; @@ -4276,6 +4323,7 @@ describe('Gemini Client (client.ts)', () => { } client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(idless), setHistory: vi.fn(), } as unknown as LlmChat; @@ -4335,6 +4383,7 @@ describe('Gemini Client (client.ts)', () => { } client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(history), setHistory: vi.fn(), } as unknown as LlmChat; @@ -4406,6 +4455,7 @@ describe('Gemini Client (client.ts)', () => { } client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(history), setHistory: vi.fn(), } as unknown as LlmChat; @@ -4438,6 +4488,7 @@ describe('Gemini Client (client.ts)', () => { const { history } = await makeReadFileResponses(6); client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(history), setHistory: vi.fn(), } as unknown as LlmChat; @@ -4464,6 +4515,7 @@ describe('Gemini Client (client.ts)', () => { const { history } = await makeReadFileResponses(6); client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(history), setHistory: vi.fn(), } as unknown as LlmChat; @@ -4490,6 +4542,7 @@ describe('Gemini Client (client.ts)', () => { const setHistory = vi.fn(); client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(history), setHistory, } as unknown as LlmChat; @@ -4516,6 +4569,7 @@ describe('Gemini Client (client.ts)', () => { const setHistory = vi.fn(); client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(history), setHistory, } as unknown as LlmChat; @@ -4543,6 +4597,7 @@ describe('Gemini Client (client.ts)', () => { const setHistory = vi.fn(); client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(history), setHistory, } as unknown as LlmChat; @@ -4600,6 +4655,7 @@ describe('Gemini Client (client.ts)', () => { const setHistory = vi.fn(); client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(history), setHistory, } as unknown as LlmChat; @@ -4650,6 +4706,7 @@ describe('Gemini Client (client.ts)', () => { const setHistory = vi.fn(); client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(history), setHistory, } as unknown as LlmChat; @@ -4696,6 +4753,7 @@ describe('Gemini Client (client.ts)', () => { const setHistory = vi.fn(); client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(history), setHistory, } as unknown as LlmChat; @@ -4721,6 +4779,7 @@ describe('Gemini Client (client.ts)', () => { const setHistory = vi.fn(); client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(history), setHistory, } as unknown as LlmChat; @@ -4750,6 +4809,7 @@ describe('Gemini Client (client.ts)', () => { const setHistory = vi.fn(); client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(history), getHistoryLength: vi.fn().mockReturnValue(history.length), stripOrphanedUserEntriesFromHistory: vi.fn(), @@ -4779,6 +4839,7 @@ describe('Gemini Client (client.ts)', () => { const setHistory = vi.fn(); client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(history), setHistory, } as unknown as LlmChat; @@ -5006,6 +5067,7 @@ describe('Gemini Client (client.ts)', () => { compressionStatus: CompressionStatus.COMPRESSED, }), isLastPromptTokenCountEstimated: vi.fn().mockReturnValue(false), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue([]), } as unknown as LlmChat; client['forceFullIdeContext'] = false; @@ -5020,11 +5082,29 @@ describe('Gemini Client (client.ts)', () => { const compressedHistory: Content[] = [ { role: 'user', parts: [{ text: 'summary' }] }, { role: 'model', parts: [{ text: 'ok' }] }, + { + role: 'model', + parts: [ + { functionCall: { id: 'completed-call', name: 'update_goal' } }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'completed-call', + name: 'update_goal', + response: { readyForVerification: true }, + }, + }, + ], + }, ]; const originalChat = client.getChat(); originalChat.setLastPromptTokenCount(200, true); vi.spyOn(originalChat, 'tryCompress').mockImplementation(async () => { - originalChat.setHistory(compressedHistory); + originalChat.setHistory(compressedHistory, ['completed-call']); return { originalTokenCount: 1000, newTokenCount: 200, @@ -5050,6 +5130,10 @@ describe('Gemini Client (client.ts)', () => { ]); expect(client.getChat().getLastPromptTokenCount()).toBe(200); expect(client.getChat().isLastPromptTokenCountEstimated()).toBe(true); + expect(client.getChat().getCompletedToolCallIds()).toEqual([ + 'completed-call', + ]); + expect(client.getChat().getHistoryForRecovery()).toEqual([]); expect(client['forceFullIdeContext']).toBe(true); }); @@ -5486,6 +5570,7 @@ describe('Gemini Client (client.ts)', () => { ); client['chat'] = { addHistory: vi.fn(), + getCompletedToolCallIds: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue(compactedHistory), setHistory, } as unknown as LlmChat; @@ -5500,17 +5585,20 @@ describe('Gemini Client (client.ts)', () => { /* drain */ } - expect(setHistory).toHaveBeenCalledWith([ - { - role: 'user', - parts: [ - { - text: '\nMocked env context\n', - }, - ], - }, - ...compactedHistory, - ]); + expect(setHistory).toHaveBeenCalledWith( + [ + { + role: 'user', + parts: [ + { + text: '\nMocked env context\n', + }, + ], + }, + ...compactedHistory, + ], + undefined, + ); }); }); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index a26b76bdc84..f103e9358fc 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -139,10 +139,8 @@ import { type AvailableSkillEntry, } from '../tools/skill-utils.js'; import type { DeferredToolSummary } from '../tools/tool-registry.js'; -import { - buildApiHistoryFromConversation, - replayUiTelemetryFromConversation, -} from '../services/sessionService.js'; +import { replayUiTelemetryFromConversation } from '../services/sessionService.js'; +import { buildSessionHistoryFromConversation } from '../services/session-api-history.js'; import { reportError } from '../utils/errorReporting.js'; import { getErrorMessage, @@ -588,6 +586,7 @@ export class LlmClient { ); await this.restoreLoadedSkillsFromHistory(restoreRuntime.apiHistory); const chat = this.getChat(); + chat.setCompletedToolCallIds(restoreRuntime.completedToolCallIds); if (restoreRuntime.resumeTokenCounts) { const counts = restoreRuntime.resumeTokenCounts; uiTelemetryService.setLastPromptTokenCount(counts.promptTokenCount); @@ -605,9 +604,10 @@ export class LlmClient { ); // Convert resumed session to API history format // Each ChatRecord's message field is already a Content object - const resumedHistory = buildApiHistoryFromConversation( + const restored = buildSessionHistoryFromConversation( resumedSessionData.conversation, ); + const resumedHistory = restored.apiHistory; this.seedRecentCompletedToolNamesFromHistory(resumedHistory); await this.startChat( resumedHistory, @@ -616,6 +616,7 @@ export class LlmClient { ); await this.restoreLoadedSkillsFromHistory(resumedHistory); const chat = this.getChat(); + chat.setCompletedToolCallIds(restored.completedToolCallIds); if (resumeTokenCounts) { chat.seedResumeTokenCounts( resumeTokenCounts.promptTokenCount, @@ -1667,6 +1668,7 @@ export class LlmClient { await this.seedAgentReminderDedupFromCurrent(); this.getChat().setHistory( startupContext ? [startupContext, ...remaining] : remaining, + this.getChat().getCompletedToolCallIds(), ); } @@ -1701,7 +1703,10 @@ export class LlmClient { this.seedSkillReminderDedupFromSnapshot(snapshotEntries); await this.seedAgentReminderDedupFromCurrent(); if (startupContext) { - this.getChat().setHistory([startupContext, ...currentHistory]); + this.getChat().setHistory( + [startupContext, ...currentHistory], + this.getChat().getCompletedToolCallIds(), + ); } } @@ -2811,7 +2816,10 @@ export class LlmClient { const changed = m.tokensSaved > 0; if (changed) { // setHistory conservatively clears loaded-skill tracking. - this.getChat().setHistory(mcResult.history); + this.getChat().setHistory( + mcResult.history, + this.getChat().getCompletedToolCallIds(), + ); await this.disarmFileReadCacheAfterEviction(m, 'microcompaction'); } if (m.triggerReason === 'size') { @@ -5081,6 +5089,9 @@ export class LlmClient { const compressedHistory = previousChat.getHistoryShallow?.() ?? previousChat.getHistory(); await this.startChat(compressedHistory, SessionStartSource.Compact); + this.getChat().setCompletedToolCallIds( + previousChat.getCompletedToolCallIds(), + ); if ( !this.lastSessionStartContext && previousSessionStartContext && diff --git a/packages/core/src/core/llm-chat.test.ts b/packages/core/src/core/llm-chat.test.ts index 2b80d076b38..ece8d01fac2 100644 --- a/packages/core/src/core/llm-chat.test.ts +++ b/packages/core/src/core/llm-chat.test.ts @@ -8020,6 +8020,94 @@ describe('LlmChat', async () => { }); }); + describe('completed tool boundary', () => { + const result: Content = { + role: 'user', + parts: [ + { + functionResponse: { id: 'ended', name: 'update_goal', response: {} }, + }, + ], + }; + + it('restores the earlier completed boundary when rewind removes a later one', () => { + const later: Content = { + role: 'user', + parts: [ + { + functionResponse: { + id: 'later', + name: 'update_goal', + response: {}, + }, + }, + ], + }; + chat.setHistory( + [ + structuredClone(result), + { role: 'user', parts: [{ text: 'next goal' }] }, + later, + ], + ['ended', 'later'], + ); + expect(chat.getHistoryForRecovery()).toEqual([]); + chat.truncateHistory(1); + expect(chat.getCompletedToolCallIds()).toEqual(['ended']); + expect(chat.getHistoryForRecovery()).toEqual([]); + const input: Content = { role: 'user', parts: [{ text: 'unanswered' }] }; + chat.addHistory(input); + expect(chat.stripOrphanedUserEntriesFromHistory()).toEqual([input]); + expect(chat.getHistory()).toEqual([result]); + }); + + it('keeps completed results out of recovery and retry without altering model history', () => { + chat.setHistory([structuredClone(result)]); + chat.setCompletedToolCallIds(['ended']); + expect(chat.getHistory()).toEqual([result]); + expect(chat.getHistory(true)).toEqual([result]); + expect(chat.getHistoryForRecovery()).toEqual([]); + const input: Content = { + role: 'user', + parts: [{ text: 'next request' }], + }; + chat.addHistory(input); + expect(chat.getHistoryForRecovery()).toEqual([input]); + expect(chat.stripOrphanedUserEntriesFromHistory()).toEqual([input]); + expect(chat.getHistory()).toEqual([result]); + }); + + it('preserves the boundary through deliberate history transforms and invalidates removed IDs', () => { + chat.setHistory([structuredClone(result)], ['ended']); + chat.setHistory( + [{ role: 'user', parts: [{ text: 'startup' }] }, ...chat.getHistory()], + chat.getCompletedToolCallIds(), + ); + chat.stripThoughtsFromHistory(); + expect(chat.getHistoryForRecovery()).toEqual([]); + chat.truncateHistory(1); + expect(chat.getCompletedToolCallIds()).toEqual([]); + chat.addHistory(structuredClone(result)); + expect(chat.getHistoryForRecovery()).toHaveLength(2); + chat.setCompletedToolCallIds(['ended']); + chat.setHistory([structuredClone(result)]); + expect(chat.getCompletedToolCallIds()).toEqual([]); + }); + + it('rejects ambiguous imported IDs and clears the boundary on clear', () => { + chat.setHistory( + [structuredClone(result), structuredClone(result)], + ['ended'], + ); + expect(chat.getCompletedToolCallIds()).toEqual([]); + chat.setHistory([structuredClone(result)], ['ended']); + chat.clearHistory(); + chat.addHistory(structuredClone(result)); + expect(chat.getCompletedToolCallIds()).toEqual([]); + expect(chat.getHistoryForRecovery()).toEqual([result]); + }); + }); + describe('addHistory', () => { it('should add a new content item to the history', () => { const newContent: Content = { diff --git a/packages/core/src/core/llm-chat.ts b/packages/core/src/core/llm-chat.ts index 7bb76b4a0af..c0026c8f369 100644 --- a/packages/core/src/core/llm-chat.ts +++ b/packages/core/src/core/llm-chat.ts @@ -71,6 +71,7 @@ import { clearLoadedSkillTracking } from '../tools/skill-utils.js'; import * as fs from 'node:fs'; import { PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES } from '../tools/exitPlanMode.js'; import { isManagedMemoryPath } from '../memory/paths.js'; +import { completedToolCallBoundary } from './turn-interruption.js'; import { STRUCTURED_OUTPUT_REDACTED_ARGS } from '../tools/syntheticOutput.js'; import type { StructuredError } from './turn.js'; import { @@ -2278,6 +2279,25 @@ export class LlmChat { */ private userContentPushCount = 0; private manualPlanExitNoticesEnabled = false; + private completedToolCallIds: string[] = []; + + setCompletedToolCallIds(toolCallIds: readonly string[] | undefined): void { + this.completedToolCallIds = [...new Set(toolCallIds)].filter( + (id) => completedToolCallBoundary(this.history, [id]) > 0, + ); + } + + getCompletedToolCallIds(): readonly string[] { + return [...this.completedToolCallIds]; + } + + getHistoryForRecovery(): Content[] { + const boundary = completedToolCallBoundary( + this.history, + this.completedToolCallIds, + ); + return this.history.slice(boundary).map(copyContentContainer); + } /** * True for forked/speculative chats built by `createForkedChat` on the @@ -2700,9 +2720,10 @@ export class LlmChat { this.chatRecordingService?.recordChatCompression({ info, compressedHistory: newHistory, + completedToolCallIds: this.completedToolCallIds, }); } - this.setHistory(newHistory); + this.setHistory(newHistory, this.completedToolCallIds); debugLogger.debug('[FILE_READ_CACHE] clear after auto tryCompress'); this.config.getFileReadCache().clear(); // Compression rewrote the shared history every retained entry sizes, @@ -2833,6 +2854,7 @@ export class LlmChat { this.chatRecordingService?.recordChatCompression({ info, compressedHistory: newHistory, + completedToolCallIds: this.completedToolCallIds, }); logChatCompression( this.config, @@ -2841,7 +2863,7 @@ export class LlmChat { tokens_after: info.newTokenCount, }), ); - this.setHistory(newHistory); + this.setHistory(newHistory, this.completedToolCallIds); this.lastPromptTokenCount = adjustedTokenCount; this.lastPromptTokenCountIsEstimated = true; this.tokenCountsRouteKey = this.currentRouteKey(); @@ -3092,6 +3114,7 @@ export class LlmChat { const historyBeforeHardRescue = shouldForceFromHard ? this.getHistoryShallow() : undefined; + const completedToolCallIdsBeforeHardRescue = this.completedToolCallIds; const lastPromptTokenCountBeforeHardRescue = this.lastPromptTokenCount; const lastPromptTokenCountWasEstimatedBeforeHardRescue = this.lastPromptTokenCountIsEstimated; @@ -3190,7 +3213,10 @@ export class LlmChat { // prompt is still too large to send, restore the pre-compression // state. The JSONL compression checkpoint is intentionally not // written because the send is about to be rejected. - this.setHistory(historyBeforeHardRescue); + this.setHistory( + historyBeforeHardRescue, + completedToolCallIdsBeforeHardRescue, + ); // setHistory conservatively cleared loaded-skill tracking; the // restored bodies re-arm it on their next invoke. this.lastPromptTokenCount = lastPromptTokenCountBeforeHardRescue; @@ -3237,6 +3263,7 @@ export class LlmChat { this.chatRecordingService?.recordChatCompression({ info: compressionInfo, compressedHistory: this.getHistoryShallow(), + completedToolCallIds: this.completedToolCallIds, }); } @@ -5349,6 +5376,7 @@ export class LlmChat { */ clearHistory(): void { this.history = []; + this.completedToolCallIds = []; // Any pending partial-push state points into the now-empty history; // resetting prevents `popPendingPartialAssistantTurn` from splicing whatever // shows up at that index in a future send (defense-in-depth — the @@ -5500,8 +5528,12 @@ export class LlmChat { } } - setHistory(history: Content[]): void { + setHistory( + history: Content[], + completedToolCallIds?: readonly string[], + ): void { this.history = history; + this.setCompletedToolCallIds(completedToolCallIds); // History replacement (compression, /clear, --resume reload) wipes // the index basis the partial-push marker was captured against. The // marker MUST be cleared — otherwise `popPendingPartialAssistantTurn` could find @@ -5524,6 +5556,7 @@ export class LlmChat { truncateHistory(keepCount: number): void { const prevLen = this.history.length; this.history = this.history.slice(0, keepCount); + this.setCompletedToolCallIds(this.completedToolCallIds); // Truncation can drop the entry the partial-push marker points at, // or leave it valid but shift the meaning of nearby indices. Reset // both fields rather than try to fix them up — they're per-send and @@ -5545,6 +5578,7 @@ export class LlmChat { this.history = this.history .map(stripThoughtPartsFromContent) .filter((content): content is Content => content !== null); + this.setCompletedToolCallIds(this.completedToolCallIds); // Filter+map replaces `this.history` with a new array, so any pending // partial-push marker is now indexed against an array that no longer // exists. Clear it for the same reason setHistory does — and drop @@ -5560,8 +5594,12 @@ export class LlmChat { */ stripOrphanedUserEntriesFromHistory(): Content[] { const strippedEntries: Content[] = []; + const boundary = completedToolCallBoundary( + this.history, + this.completedToolCallIds, + ); while ( - this.history.length > 0 && + this.history.length > boundary && this.history[this.history.length - 1]!.role === 'user' ) { // Never pop a *pure* system-reminder user entry. These are structural, diff --git a/packages/core/src/core/session-recovery.ts b/packages/core/src/core/session-recovery.ts index 1501ca210c5..2553c375ec8 100644 --- a/packages/core/src/core/session-recovery.ts +++ b/packages/core/src/core/session-recovery.ts @@ -5,10 +5,8 @@ */ import type { Content, Part } from '@google/genai'; -import { - buildApiHistoryFromConversation, - type ConversationRecord, -} from '../services/sessionService.js'; +import type { ConversationRecord } from '../services/sessionService.js'; +import { buildSessionHistoryFromConversation } from '../services/session-api-history.js'; import type { HistoryGap } from '../utils/conversation-chain.js'; import { detectTurnInterruption, @@ -62,6 +60,7 @@ export interface BuildSessionRecoveryPlanInput { export interface BuildSessionRecoveryPlanFromApiHistoryInput { sessionId: string; apiHistory: Content[]; + completedToolCallIds?: readonly string[]; historyGaps?: HistoryGap[]; options?: { allowAutoContinue?: boolean; @@ -114,7 +113,7 @@ export function buildSessionRecoveryPlan({ }: BuildSessionRecoveryPlanInput): SessionRecoveryPlan { return buildSessionRecoveryPlanFromApiHistory({ sessionId, - apiHistory: buildApiHistoryFromConversation(conversation), + ...buildSessionHistoryFromConversation(conversation), historyGaps, options, }); @@ -123,6 +122,7 @@ export function buildSessionRecoveryPlan({ export function buildSessionRecoveryPlanFromApiHistory({ sessionId, apiHistory: inputApiHistory, + completedToolCallIds, historyGaps, options, }: BuildSessionRecoveryPlanFromApiHistoryInput): SessionRecoveryPlan { @@ -167,7 +167,10 @@ export function buildSessionRecoveryPlanFromApiHistory({ }; } - const interruption = detectTurnInterruption(originalApiHistory); + const interruption = detectTurnInterruption( + originalApiHistory, + completedToolCallIds, + ); if (interruption.kind === 'none') { return { planId, diff --git a/packages/core/src/core/turn-interruption.test.ts b/packages/core/src/core/turn-interruption.test.ts index 3f793fb2dc6..c3a6f33ce7b 100644 --- a/packages/core/src/core/turn-interruption.test.ts +++ b/packages/core/src/core/turn-interruption.test.ts @@ -17,6 +17,45 @@ const reminder = (text: string) => ({ }); describe('detectTurnInterruption', () => { + it('recovers only input after a recorded tool boundary', () => { + const result: Content = { + role: 'user', + parts: [ + { + functionResponse: { id: 'ended', name: 'update_goal', response: {} }, + }, + ], + }; + expect(detectTurnInterruption([result], ['ended'])).toEqual({ + kind: 'none', + }); + const input: Content = { role: 'user', parts: [{ text: 'next request' }] }; + expect(detectTurnInterruption([result, input], ['ended'])).toEqual({ + kind: 'interrupted_prompt', + parts: input.parts, + }); + expect(detectTurnInterruption([result, input], ['missing']).kind).toBe( + 'interrupted_prompt', + ); + expect( + detectTurnInterruption([result, input, result], ['ended']).kind, + ).toBe('interrupted_prompt'); + expect( + detectTurnInterruption( + [ + result, + { + role: 'model', + parts: [{ functionCall: { id: 'pending', name: 'read_file' } }], + }, + ], + ['ended'], + ), + ).toEqual({ + kind: 'interrupted_turn', + danglingCalls: [{ callId: 'pending', name: 'read_file' }], + }); + }); it('uses a bounded history tail count for continuation detection callers', () => { expect(TURN_INTERRUPTION_HISTORY_TAIL_COUNT).toBe(50); }); diff --git a/packages/core/src/core/turn-interruption.ts b/packages/core/src/core/turn-interruption.ts index 95a751de164..b45d2f561fc 100644 --- a/packages/core/src/core/turn-interruption.ts +++ b/packages/core/src/core/turn-interruption.ts @@ -47,6 +47,26 @@ export type TurnInterruption = // still leaving ample room for repeated failed sends and tool-result retries. export const TURN_INTERRUPTION_HISTORY_TAIL_COUNT = 50; +export function completedToolCallBoundary( + history: readonly Content[], + toolCallIds: readonly string[] | undefined, +): number { + if (!toolCallIds?.length) return 0; + const ids = new Set(toolCallIds); + const boundaries = new Map(); + for (let i = 0; i < history.length; i++) { + for (const part of history[i].parts ?? []) { + const id = part.functionResponse?.id; + if (!id || !ids.has(id)) continue; + boundaries.set( + id, + boundaries.has(id) || history[i].role !== 'user' ? 0 : i + 1, + ); + } + } + return Math.max(0, ...boundaries.values()); +} + /** * Detect whether the last turn of `history` was left unfinished, and if so * what kind of continuation applies. Pure read — never mutates `history`. @@ -58,7 +78,12 @@ export const TURN_INTERRUPTION_HISTORY_TAIL_COUNT = 50; * @param history - Chat history in Gemini `Content[]` form, oldest first. * @returns The interruption classification; see {@link TurnInterruption}. */ -export function detectTurnInterruption(history: Content[]): TurnInterruption { +export function detectTurnInterruption( + history: Content[], + completedToolCallIds?: readonly string[], +): TurnInterruption { + const boundary = completedToolCallBoundary(history, completedToolCallIds); + if (boundary === history.length) return { kind: 'none' }; const last = history[history.length - 1]; if (!last) { return { kind: 'none' }; @@ -66,7 +91,7 @@ export function detectTurnInterruption(history: Content[]): TurnInterruption { if (last.role === 'user') { const trailingUserEntries: Content[] = []; - for (let i = history.length - 1; i >= 0; i--) { + for (let i = history.length - 1; i >= boundary; i--) { const entry = history[i]; if (!entry || entry.role !== 'user') { break; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5ce3eece7ed..e4cad33a706 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -95,6 +95,7 @@ export * from './core/prompts.js'; export * from './core/output-styles.js'; export * from './core/output-style-files.js'; export * from './core/session-recovery.js'; +export { buildSessionHistoryFromConversation } from './services/session-api-history.js'; export * from './core/ask-user-question-restore.js'; export * from './core/tokenLimits.js'; export * from './core/tool-call-preparation.js'; diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index 9bedc2615c4..219e302e34e 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -1557,6 +1557,48 @@ describe('ChatRecordingService', () => { }); }); + describe('recordGoalTurnEnd', () => { + it('waits for the durable system record and copies the Goal permit', async () => { + let resolveWrite!: () => void; + vi.mocked(jsonl.writeLine).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveWrite = resolve; + }), + ); + const permit = { goalId: 'goal', revision: 1, turnId: 'turn' }; + const pending = chatRecordingService.recordGoalTurnEnd('finish', permit); + permit.turnId = 'changed'; + let settled = false; + void pending.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + resolveWrite(); + await pending; + const record = vi.mocked(jsonl.writeLine).mock.calls[0]![1] as ChatRecord; + expect(record).toMatchObject({ + type: 'system', + subtype: 'goal_turn_end', + goalContext: { goalId: 'goal', revision: 1, turnId: 'turn' }, + systemPayload: { toolCallId: 'finish' }, + }); + expect(record.message).toBeUndefined(); + }); + + it('rejects a failed append instead of reporting a persisted boundary', async () => { + vi.mocked(jsonl.writeLine).mockRejectedValueOnce(new Error('disk full')); + await expect( + chatRecordingService.recordGoalTurnEnd('finish', { + goalId: 'goal', + revision: 1, + turnId: 'turn', + }), + ).rejects.toThrow('disk full'); + }); + }); + describe('recordTurnResult', () => { it('normalizes hostile and oversized error fields without throwing', () => { const hostile = Object.create(null, { diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index e5e0bbac321..b78b2f34268 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -326,6 +326,7 @@ export interface ChatRecord { | 'branch_checkpoint' | 'goal_state' | 'goal_runtime' + | 'goal_turn_end' | 'realtime_message' | 'turn_result'; /** Explicit source classification used by Goal evidence validation. */ @@ -392,6 +393,7 @@ export interface ChatRecord { | SessionSourcesSnapshot | BranchCheckpointRecordPayloadV1 | GoalStateRecordPayloadV2 + | GoalTurnEndRecordPayload | TurnResultRecordPayload; /** Background subagent that produced this record (e.g. "explore-7f3c"). */ @@ -518,6 +520,11 @@ export interface ChatCompressionRecordPayload { * resume reconstruction. */ compressedHistory: Content[]; + completedToolCallIds?: string[]; +} + +export interface GoalTurnEndRecordPayload { + toolCallId: string; } export interface SlashCommandRecordPayload { @@ -1953,6 +1960,18 @@ export class ChatRecordingService { } } + async recordGoalTurnEnd( + toolCallId: string, + goalContext: GoalTurnPermit, + ): Promise { + await this.appendRecordStrict({ + ...this.createBaseRecord('system'), + subtype: 'goal_turn_end', + goalContext: copyGoalContext(goalContext), + systemPayload: { toolCallId }, + }); + } + /** * Records a user message drained while tool results are being submitted. * diff --git a/packages/core/src/services/memoryPressureMonitor.test.ts b/packages/core/src/services/memoryPressureMonitor.test.ts index af5e0d9a944..d8277420bfe 100644 --- a/packages/core/src/services/memoryPressureMonitor.test.ts +++ b/packages/core/src/services/memoryPressureMonitor.test.ts @@ -142,7 +142,11 @@ function createMockConfig( getChat?: () => { getHistoryShallow?: () => unknown[]; getHistory?: () => unknown[]; - setHistory?: (h: unknown[]) => void; + setHistory?: ( + h: unknown[], + completedToolCallIds?: readonly string[], + ) => void; + getCompletedToolCallIds?: () => readonly string[] | undefined; }; } | null; clearContextOnIdle?: { @@ -157,6 +161,7 @@ function createMockConfig( ? { isInitialized: () => true, getChat: () => ({ + getCompletedToolCallIds: () => undefined, getHistoryShallow: () => [], getHistory: () => [], setHistory: vi.fn(), @@ -1251,6 +1256,7 @@ describe('MemoryPressureMonitor', () => { llmClient: { isInitialized: () => false, getChat: () => ({ + getCompletedToolCallIds: () => undefined, getHistoryShallow: () => [{ role: 'user' }], getHistory: () => [{ role: 'user' }], setHistory, @@ -1275,6 +1281,7 @@ describe('MemoryPressureMonitor', () => { llmClient: { isInitialized: () => true, getChat: () => ({ + getCompletedToolCallIds: () => undefined, getHistoryShallow: () => originalHistory, getHistory: () => [...originalHistory], setHistory, @@ -1299,6 +1306,7 @@ describe('MemoryPressureMonitor', () => { llmClient: { isInitialized: () => true, getChat: () => ({ + getCompletedToolCallIds: () => undefined, getHistoryShallow: () => [], getHistory: () => [], setHistory, @@ -1390,11 +1398,30 @@ describe('MemoryPressureMonitor', () => { }, ); } + toolHistory.push( + { + role: 'model', + parts: [{ functionCall: { id: 'goal-end', name: 'update_goal' } }], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'goal-end', + name: 'update_goal', + response: {}, + }, + }, + ], + }, + ); const monitor = new MemoryPressureMonitor( createMockConfig({ llmClient: { isInitialized: () => true, getChat: () => ({ + getCompletedToolCallIds: () => ['goal-end'], getHistoryShallow: () => toolHistory, setHistory, }), @@ -1418,6 +1445,10 @@ describe('MemoryPressureMonitor', () => { expect(setHistory).toHaveBeenCalled(); expect(clearCache).toHaveBeenCalled(); const compacted = setHistory.mock.calls[0][0] as Content[]; + expect(setHistory.mock.calls[0][1]).toEqual(['goal-end']); + expect(compacted.at(-1)?.parts?.[0]?.functionResponse?.id).toBe( + 'goal-end', + ); // microcompactHistory blanks old tool responses with a cleared message // rather than removing entries — verify some were blanked. const blankedResponses = compacted.filter((entry) => @@ -1472,6 +1503,7 @@ describe('MemoryPressureMonitor', () => { llmClient: { isInitialized: () => true, getChat: () => ({ + getCompletedToolCallIds: () => undefined, getHistoryShallow: () => toolHistory, setHistory, }), @@ -1532,6 +1564,7 @@ describe('MemoryPressureMonitor', () => { llmClient: { isInitialized: () => true, getChat: () => ({ + getCompletedToolCallIds: () => undefined, getHistoryShallow: () => toolHistory, setHistory, }), @@ -1592,6 +1625,7 @@ describe('MemoryPressureMonitor', () => { llmClient: { isInitialized: () => true, getChat: () => ({ + getCompletedToolCallIds: () => undefined, getHistoryShallow: () => toolHistory, setHistory, }), diff --git a/packages/core/src/services/memoryPressureMonitor.ts b/packages/core/src/services/memoryPressureMonitor.ts index 393cf2f3f6a..22750f53f8a 100644 --- a/packages/core/src/services/memoryPressureMonitor.ts +++ b/packages/core/src/services/memoryPressureMonitor.ts @@ -735,7 +735,7 @@ export class MemoryPressureMonitor extends EventEmitter { }, ); if (result.meta) { - chat.setHistory(result.history); + chat.setHistory(result.history, chat.getCompletedToolCallIds()); // Explicitly clear fileReadCache here instead of relying on // the subsequent clear_file_cache step. This removes the // implicit coupling between step ordering. diff --git a/packages/core/src/services/session-api-history.test.ts b/packages/core/src/services/session-api-history.test.ts new file mode 100644 index 00000000000..04d4343b886 --- /dev/null +++ b/packages/core/src/services/session-api-history.test.ts @@ -0,0 +1,190 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import type { ChatRecord } from './chatRecordingService.js'; +import { CompressionStatus } from '../core/turn.js'; +import { + buildApiHistoryFromConversation, + buildSessionHistoryFromConversation, +} from './session-api-history.js'; + +const permit = { goalId: 'goal', revision: 1, turnId: 'turn' }; + +function records(toolCallId = 'finish'): ChatRecord[] { + const base = { + sessionId: 'session', + timestamp: '2026-09-15T00:00:00.000Z', + cwd: '/workspace', + version: 'test', + goalContext: permit, + }; + return [ + { + ...base, + uuid: 'call', + parentUuid: null, + type: 'assistant', + message: { + role: 'model', + parts: [{ functionCall: { id: toolCallId, name: 'update_goal' } }], + }, + }, + { + ...base, + uuid: 'result', + parentUuid: 'call', + type: 'tool_result', + message: { + role: 'user', + parts: [ + { + functionResponse: { + id: toolCallId, + name: 'update_goal', + response: { readyForVerification: true }, + }, + }, + ], + }, + }, + { + ...base, + uuid: 'end', + parentUuid: 'result', + type: 'system', + subtype: 'goal_turn_end', + systemPayload: { toolCallId }, + }, + ]; +} + +describe('Goal turn end history metadata', () => { + it('keeps the boundary outside model history and across a later user prompt', () => { + const messages = records(); + const before = buildApiHistoryFromConversation({ + messages: messages.slice(0, 2), + }); + expect(buildSessionHistoryFromConversation({ messages })).toEqual({ + apiHistory: before, + completedToolCallIds: ['finish'], + }); + messages.push({ + ...messages[1]!, + uuid: 'next', + parentUuid: 'end', + type: 'user', + subtype: 'mid_turn_user_message', + message: { role: 'user', parts: [{ text: 'new request' }] }, + }); + const restored = buildSessionHistoryFromConversation({ messages }); + expect(restored.completedToolCallIds).toEqual(['finish']); + expect(restored.apiHistory).toEqual([ + ...before, + { role: 'user', parts: [{ text: 'new request' }] }, + ]); + }); + + it.each(['goalId', 'revision', 'turnId'] as const)( + 'ignores a boundary with a mismatched %s', + (field) => { + const messages = records(); + messages[2]!.goalContext = { + ...permit, + [field]: field === 'revision' ? 2 : 'other', + }; + expect( + buildSessionHistoryFromConversation({ messages }).completedToolCallIds, + ).toBeUndefined(); + }, + ); + + it('requires the most recent material record to contain the ending result', () => { + const messages = records(); + messages.splice(2, 0, { + ...messages[1]!, + uuid: 'new-prompt', + type: 'user', + message: { role: 'user', parts: [{ text: 'new request' }] }, + }); + expect( + buildSessionHistoryFromConversation({ messages }).completedToolCallIds, + ).toBeUndefined(); + messages.splice(2, 1); + messages[2]!.systemPayload = { toolCallId: 'unrelated' }; + expect( + buildSessionHistoryFromConversation({ messages }).completedToolCallIds, + ).toBeUndefined(); + }); + + it('invalidates a boundary when its tool id is reused later', () => { + const messages = records(); + messages.push({ + ...messages[0]!, + uuid: 'duplicate-call', + parentUuid: 'end', + }); + expect( + buildSessionHistoryFromConversation({ messages }).completedToolCallIds, + ).toBeUndefined(); + messages.pop(); + messages.unshift({ ...messages[1]!, uuid: 'duplicate-result' }); + expect( + buildSessionHistoryFromConversation({ messages }).completedToolCallIds, + ).toBeUndefined(); + }); + + it('retains earlier boundaries and removes only a reused tool id', () => { + const messages = [...records(), ...records('finish-2')]; + messages.push({ ...messages.at(-1)! }); + expect( + buildSessionHistoryFromConversation({ messages }).completedToolCallIds, + ).toEqual(['finish', 'finish-2']); + messages.push({ ...records()[0]!, uuid: 'reused-call' }); + expect( + buildSessionHistoryFromConversation({ messages }).completedToolCallIds, + ).toEqual(['finish-2']); + }); + + it('restores only an explicitly preserved compression boundary', () => { + const messages = records(); + const compressedHistory = buildApiHistoryFromConversation({ messages }); + const payload = { + info: { + originalTokenCount: 100, + newTokenCount: 50, + compressionStatus: CompressionStatus.COMPRESSED, + }, + compressedHistory, + }; + const compression: ChatRecord = { + ...messages[2]!, + uuid: 'compression', + parentUuid: 'end', + subtype: 'chat_compression', + systemPayload: payload, + }; + messages.push(compression); + expect( + buildSessionHistoryFromConversation({ messages }).completedToolCallIds, + ).toBeUndefined(); + compression.systemPayload = { + ...payload, + completedToolCallIds: ['finish', 'missing', 'finish'], + }; + expect( + buildSessionHistoryFromConversation({ messages }).completedToolCallIds, + ).toEqual(['finish']); + compression.systemPayload = { + ...payload, + completedToolCallIds: ['finish'], + compressedHistory: [{ role: 'model', parts: [{ text: 'summary' }] }], + }; + expect( + buildSessionHistoryFromConversation({ messages }).completedToolCallIds, + ).toBeUndefined(); + }); +}); diff --git a/packages/core/src/services/session-api-history.ts b/packages/core/src/services/session-api-history.ts index f8d9a748985..ca7a15b42c1 100644 --- a/packages/core/src/services/session-api-history.ts +++ b/packages/core/src/services/session-api-history.ts @@ -8,6 +8,7 @@ import type { Content, Part } from '@google/genai'; import type { ChatCompressionRecordPayload, ChatRecord, + GoalTurnEndRecordPayload, } from './chatRecordingService.js'; export interface BuildApiHistoryOptions { @@ -55,13 +56,24 @@ function copyContentForApiHistory(content: Content): Content { }; } -function appendApiHistoryRecord(history: Content[], record: ChatRecord): void { +function appendApiHistoryRecord( + history: Content[], + record: ChatRecord, + completedToolCallIds: ReadonlySet, +): void { if (!record.message || record.subtype === 'realtime_message') return; const message = copyContentForApiHistory(record.message); if (record.subtype === 'mid_turn_user_message') { const previous = history.at(-1); - if (previous?.role === 'user') { + if ( + previous?.role === 'user' && + !previous.parts?.some( + (part) => + part.functionResponse?.id !== undefined && + completedToolCallIds.has(part.functionResponse.id), + ) + ) { previous.parts = [...(previous.parts ?? []), ...(message.parts ?? [])]; return; } @@ -70,18 +82,66 @@ function appendApiHistoryRecord(history: Content[], record: ChatRecord): void { history.push(message); } +function hasUniqueToolResult(history: Content[], toolCallId: unknown): boolean { + if (typeof toolCallId !== 'string' || toolCallId.length === 0) return false; + let calls = 0; + let results = 0; + for (const content of history) { + for (const part of content.parts ?? []) { + if (part.functionCall?.id === toolCallId) calls += 1; + if (part.functionResponse?.id === toolCallId) results += 1; + } + } + return calls === 1 && results === 1; +} + export class SessionApiHistoryAccumulator { private history: Content[] = []; private compressionCandidate: unknown; + private completedToolCallIds = new Set(); + private lastMaterialRecord?: ChatRecord; add(record: ChatRecord): void { if (record.type === 'system') { + if (record.subtype === 'goal_turn_end') { + const payload = record.systemPayload as + | GoalTurnEndRecordPayload + | undefined; + const previous = this.lastMaterialRecord; + const permit = record.goalContext; + if ( + previous?.type === 'tool_result' && + typeof permit?.goalId === 'string' && + permit.goalId.length > 0 && + typeof permit.turnId === 'string' && + permit.turnId.length > 0 && + Number.isInteger(permit.revision) && + previous.goalContext?.goalId === permit.goalId && + previous.goalContext.revision === permit.revision && + previous.goalContext.turnId === permit.turnId && + previous.message?.parts?.some( + (part) => part.functionResponse?.id === payload?.toolCallId, + ) && + hasUniqueToolResult(this.history, payload?.toolCallId) + ) { + this.completedToolCallIds.add(payload!.toolCallId); + } + return; + } if (!isApiHistoryCompressionCandidate(record)) return; const payload = record.systemPayload as ChatCompressionRecordPayload; this.compressionCandidate = payload.compressedHistory; this.history = Array.isArray(payload.compressedHistory) ? payload.compressedHistory.map(copyContentForApiHistory) : []; + this.completedToolCallIds = new Set( + Array.isArray(payload.completedToolCallIds) + ? payload.completedToolCallIds.filter((toolCallId) => + hasUniqueToolResult(this.history, toolCallId), + ) + : [], + ); + this.lastMaterialRecord = undefined; return; } @@ -91,7 +151,21 @@ export class SessionApiHistoryAccumulator { ) { return; } - appendApiHistoryRecord(this.history, record); + if (!record.message || record.subtype === 'realtime_message') return; + for (const part of record.message.parts ?? []) { + if (part.functionCall?.id) { + this.completedToolCallIds.delete(part.functionCall.id); + } + if (part.functionResponse?.id) { + this.completedToolCallIds.delete(part.functionResponse.id); + } + } + appendApiHistoryRecord(this.history, record, this.completedToolCallIds); + this.lastMaterialRecord = record; + } + + getCompletedToolCallIds(): string[] { + return [...this.completedToolCallIds]; } finish(options: BuildApiHistoryOptions = {}): Content[] { @@ -124,7 +198,21 @@ export function buildApiHistoryFromConversation( conversation: { messages: readonly ChatRecord[] }, options: BuildApiHistoryOptions = {}, ): Content[] { + return buildSessionHistoryFromConversation(conversation, options).apiHistory; +} + +export function buildSessionHistoryFromConversation( + conversation: { messages: readonly ChatRecord[] }, + options: BuildApiHistoryOptions = {}, +): { apiHistory: Content[]; completedToolCallIds?: string[] } { const accumulator = new SessionApiHistoryAccumulator(); for (const record of conversation.messages) accumulator.add(record); - return accumulator.finish(options); + const apiHistory = accumulator.finish(options); + const completedToolCallIds = accumulator + .getCompletedToolCallIds() + .filter((toolCallId) => hasUniqueToolResult(apiHistory, toolCallId)); + return { + apiHistory, + ...(completedToolCallIds.length > 0 ? { completedToolCallIds } : {}), + }; } diff --git a/packages/core/src/services/session-transcript-reader.test.ts b/packages/core/src/services/session-transcript-reader.test.ts index 37c9f05b1af..b74f564edb0 100644 --- a/packages/core/src/services/session-transcript-reader.test.ts +++ b/packages/core/src/services/session-transcript-reader.test.ts @@ -46,12 +46,17 @@ 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 { SessionSourceService, type SessionSourcesSnapshot, } from './session-sources.js'; -import type { ChatRecord } from './chatRecordingService.js'; +import { + ChatRecordingService, + type ChatRecord, +} from './chatRecordingService.js'; +import { buildSessionHistoryFromConversation } from './session-api-history.js'; import { buildApiHistoryFromConversation, getResumeTokenCounts, @@ -124,6 +129,113 @@ describe('SessionTranscriptReader', () => { return filePath; } + it('round-trips a recorded Goal turn end through selective restore, fork, and rewind', async () => { + const config = { + storage: new Storage(workspaceDir), + getSessionId: () => sessionId, + getProjectRoot: () => workspaceDir, + getCliVersion: () => 'test', + getResumedSessionData: () => undefined, + } as unknown as Config; + const recorder = new ChatRecordingService(config, undefined, false); + const permit = { goalId: 'goal', revision: 1, turnId: 'turn' }; + recorder.recordUserMessage([{ text: 'complete the Goal' }]); + recorder.recordAssistantTurn({ + model: 'test', + goalContext: permit, + message: [{ functionCall: { id: 'finish', name: 'update_goal' } }], + }); + recorder.recordToolResult( + [ + { + functionResponse: { + id: 'finish', + name: 'update_goal', + response: { readyForVerification: true }, + }, + }, + ], + undefined, + { goalContext: permit, provenance: 'goal_runtime' }, + ); + await recorder.recordGoalTurnEnd('finish', permit); + const service = new SessionService(workspaceDir, { + runtimeBaseDir: runtimeDir, + }); + const loaded = await service.loadSession(sessionId); + const legacy = buildSessionHistoryFromConversation(loaded!.conversation); + const projection = await service.readRestoreProjection(sessionId, { + replay: { kind: 'none' }, + }); + expect(projection?.runtime.completedToolCallIds).toEqual(['finish']); + expect(projection?.runtime.apiHistory).toEqual(legacy.apiHistory); + expect(legacy.completedToolCallIds).toEqual(['finish']); + expect(legacy.apiHistory).toHaveLength(3); + expect(legacy.apiHistory.at(-1)?.parts?.[0]?.functionResponse?.id).toBe( + 'finish', + ); + + const secondPermit = { ...permit, turnId: 'second-turn' }; + recorder.recordUserMessage([{ text: 'another Goal turn' }]); + recorder.recordAssistantTurn({ + model: 'test', + goalContext: secondPermit, + message: [{ functionCall: { id: 'finish-2', name: 'update_goal' } }], + }); + recorder.recordToolResult( + [ + { + functionResponse: { + id: 'finish-2', + name: 'update_goal', + response: { readyForVerification: true }, + }, + }, + ], + undefined, + { goalContext: secondPermit, provenance: 'goal_runtime' }, + ); + await recorder.recordGoalTurnEnd('finish-2', secondPermit); + + const forkId = '660e8400-e29b-41d4-a716-446655440001'; + await service.forkSession(sessionId, forkId); + const fork = await service.readRestoreProjection(forkId, { + replay: { kind: 'none' }, + }); + expect(fork?.runtime.completedToolCallIds).toEqual(['finish', 'finish-2']); + + recorder.recordMidTurnUserMessage( + [{ text: 'next request' }], + 'next request', + ); + await recorder.flush(); + const next = await service.readRestoreProjection(sessionId, { + replay: { kind: 'none' }, + }); + expect(next?.runtime.completedToolCallIds).toEqual(['finish', 'finish-2']); + expect(next?.runtime.apiHistory).toHaveLength(7); + expect(next?.runtime.apiHistory.at(-1)).toEqual({ + role: 'user', + parts: [{ text: 'next request' }], + }); + + recorder.rewindRecording(1, { truncatedCount: 4 }); + await recorder.flush(); + const earlier = await service.readRestoreProjection(sessionId, { + replay: { kind: 'none' }, + }); + expect(earlier?.runtime.completedToolCallIds).toEqual(['finish']); + expect(earlier?.runtime.apiHistory).toEqual(legacy.apiHistory); + + recorder.rewindRecording(0, { truncatedCount: 3 }); + await recorder.flush(); + const rewound = await service.readRestoreProjection(sessionId, { + replay: { kind: 'none' }, + }); + expect(rewound?.runtime.completedToolCallIds).toBeUndefined(); + expect(rewound?.runtime.apiHistory).toEqual([]); + }); + it('restores the latest session sources across rewind and compression without changing model history', async () => { const persisted: SessionSourcesSnapshot[] = []; const sources = new SessionSourceService({ diff --git a/packages/core/src/services/session-transcript-reader.ts b/packages/core/src/services/session-transcript-reader.ts index 690ddaaa4df..a0956dc4131 100644 --- a/packages/core/src/services/session-transcript-reader.ts +++ b/packages/core/src/services/session-transcript-reader.ts @@ -265,6 +265,7 @@ export interface SessionRestoreReplayPage { export interface SessionRuntimeResumeState extends SessionSourcesRestoreState { apiHistory: Content[]; + completedToolCallIds?: string[]; resumeTokenCounts?: ResumeTokenCounts; uiTelemetryEvents: UiEvent[]; attributionSnapshot?: AttributionSnapshot; @@ -2728,7 +2729,7 @@ export class SessionTranscriptReader { const entry = index.byUuid.get(uuid); if ( position === compressionPosition || - (entry?.type !== 'system' && + ((entry?.type !== 'system' || entry?.subtype === 'goal_turn_end') && (compressionPosition < 0 || position > compressionPosition)) ) { modelSet.add(uuid); @@ -3120,8 +3121,10 @@ export class SessionTranscriptReader { const restoredTokenCounts = resumeTokenCounts.finish(); const restoredFileHistory = fileHistory.finish(); const artifactSnapshot = artifacts.finish(); + const completedToolCallIds = apiHistory.getCompletedToolCallIds(); const runtime: SessionRuntimeResumeState = { apiHistory: apiHistory.finish(), + ...(completedToolCallIds.length > 0 ? { completedToolCallIds } : {}), ...(restoredTokenCounts ? { resumeTokenCounts: restoredTokenCounts } : {}), diff --git a/packages/core/src/utils/conversation-branches.ts b/packages/core/src/utils/conversation-branches.ts index 4180ff00e09..23b2cc00030 100644 --- a/packages/core/src/utils/conversation-branches.ts +++ b/packages/core/src/utils/conversation-branches.ts @@ -19,6 +19,7 @@ const NEUTRAL_TAIL_SUBTYPES = new Set([ 'session_artifact_snapshot', 'session_sources_snapshot', 'turn_result', + 'goal_turn_end', ]); export type ConversationBranchClassification = diff --git a/packages/core/src/utils/transcript-records.ts b/packages/core/src/utils/transcript-records.ts index b44668c9d2b..89d183d61bb 100644 --- a/packages/core/src/utils/transcript-records.ts +++ b/packages/core/src/utils/transcript-records.ts @@ -134,6 +134,7 @@ const KNOWN_RECORD_SUBTYPES = new Set([ 'branch_checkpoint', 'goal_state', 'goal_runtime', + 'goal_turn_end', 'turn_result', ...ARTIFACT_RECORD_SUBTYPES, ]); From 139ae5decff8b60934a10ac5c061c8954f4714d2 Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:50:21 +0800 Subject: [PATCH 2/3] test(core): update telemetry swap chat mock for Goal recovery --- packages/core/src/core/client.telemetrySwap.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/core/client.telemetrySwap.test.ts b/packages/core/src/core/client.telemetrySwap.test.ts index 44d7750382c..4f5fdbe6f90 100644 --- a/packages/core/src/core/client.telemetrySwap.test.ts +++ b/packages/core/src/core/client.telemetrySwap.test.ts @@ -103,6 +103,7 @@ function makeEnv() { const fakeChat = { seedResumeTokenCounts: vi.fn(), setLastPromptTokenCount: vi.fn(), + setCompletedToolCallIds: vi.fn(), } as unknown as LlmChat; const startChat = vi .spyOn(client, 'startChat') From 9d585ca4925aea29ed111576e77db7ac0f633291 Mon Sep 17 00:00:00 2001 From: qqqys <266654365+qqqys@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:07:10 +0800 Subject: [PATCH 3/3] test(cli): complete session swap chat mock --- packages/cli/src/ui/hooks/session-swap-telemetry.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/ui/hooks/session-swap-telemetry.test.ts b/packages/cli/src/ui/hooks/session-swap-telemetry.test.ts index 33832c4e59d..67239ebaf45 100644 --- a/packages/cli/src/ui/hooks/session-swap-telemetry.test.ts +++ b/packages/cli/src/ui/hooks/session-swap-telemetry.test.ts @@ -170,6 +170,7 @@ function makeFakeEnv() { const fakeChat = { seedResumeTokenCounts: vi.fn(), setLastPromptTokenCount: vi.fn(), + setCompletedToolCallIds: vi.fn(), }; // One shared session-service object: every getSessionService() call sees