From 6b2e62816069bd7de27029aaa2bc2124b7618af2 Mon Sep 17 00:00:00 2001 From: tt-a1i <53142663+tt-a1i@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:04:26 +0800 Subject: [PATCH] fix(core): preserve rewind parents after resume --- .../cli/src/acp-integration/acpAgent.test.ts | 21 +++++++--- packages/cli/src/acp-integration/acpAgent.ts | 6 +++ .../src/services/chatRecordingService.test.ts | 41 +++++++++++++++++++ .../core/src/services/chatRecordingService.ts | 9 ++-- 4 files changed, 66 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 8facc009f36..9e47a59e97d 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -6256,9 +6256,11 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { let lastSessionMock: | { getId: ReturnType; + getConfig: ReturnType; sendAvailableCommandsUpdate: ReturnType; replayHistory: ReturnType; installRewriter: ReturnType; + startCronScheduler: ReturnType; dispose: ReturnType; } | undefined; @@ -6320,6 +6322,9 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { resumedConversation?: { messages: unknown[] }; } = {}, ) { + const recording = { + rebuildTurnBoundaries: vi.fn(), + }; return { initialize: vi.fn().mockResolvedValue(undefined), waitForMcpReady: vi.fn().mockResolvedValue(undefined), @@ -6345,6 +6350,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { getHookSystem: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), hasHooksForEvent: vi.fn().mockReturnValue(false), + getChatRecordingService: vi.fn().mockReturnValue(recording), // load path reads back the persisted conversation here and feeds // it to `session.replayHistory`. resume path doesn't read this. getResumedSessionData: vi @@ -6433,10 +6439,11 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { }); it('loadSession returns LoadSessionResponse and replays history on the session', async () => { + const messages = [{ role: 'user', parts: [{ text: 'hi' }] }]; bindRestoreMocks({ sessionExists: true, resumedConversation: { - messages: [{ role: 'user', parts: [{ text: 'hi' }] }], + messages, }, }); const { agent, agentPromise } = await spawnAgent(); @@ -6454,9 +6461,10 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { }); // load semantic: history MUST be replayed so SSE subscribers see // the persisted turns. - expect(lastSessionMock?.replayHistory).toHaveBeenCalledWith([ - { role: 'user', parts: [{ text: 'hi' }] }, - ]); + expect(lastSessionMock?.replayHistory).toHaveBeenCalledWith(messages); + + const recording = lastSessionMock?.getConfig().getChatRecordingService(); + expect(recording?.rebuildTurnBoundaries).toHaveBeenCalledWith(messages); mockConnectionState.resolve(); await agentPromise; @@ -6539,10 +6547,11 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { }); it('unstable_resumeSession returns the response without replaying history', async () => { + const messages = [{ role: 'user', parts: [{ text: 'hi' }] }]; bindRestoreMocks({ sessionExists: true, resumedConversation: { - messages: [{ role: 'user', parts: [{ text: 'hi' }] }], + messages, }, }); const { agent, agentPromise } = await spawnAgent(); @@ -6562,6 +6571,8 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { // the SSE stream stays clean for clients that already have the // history rendered. expect(lastSessionMock?.replayHistory).not.toHaveBeenCalled(); + const recording = lastSessionMock?.getConfig().getChatRecordingService(); + expect(recording?.rebuildTurnBoundaries).toHaveBeenCalledWith(messages); mockConnectionState.resolve(); await agentPromise; diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 3ef3e4b0a2b..d054eafde13 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -7318,6 +7318,12 @@ class QwenAgent implements Agent { .restoreFromSnapshots(sessionData.fileHistorySnapshots); } + if (sessionData?.conversation.messages) { + config + .getChatRecordingService() + ?.rebuildTurnBoundaries(sessionData.conversation.messages); + } + if (options.replayHistory !== false && sessionData?.conversation.messages) { await session.replayHistory(sessionData.conversation.messages); } diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index 841112dce73..e38deb2d55e 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -169,6 +169,47 @@ describe('ChatRecordingService', () => { }); }); + describe('rewindRecording', () => { + it('preserves a resumed user turn parent when rebuilding rewind boundaries', async () => { + vi.mocked(mockConfig.getResumedSessionData).mockReturnValue({ + lastCompletedUuid: 'assistant-1', + } as unknown as ReturnType); + chatRecordingService = new ChatRecordingService(mockConfig); + + chatRecordingService.rebuildTurnBoundaries([ + { + uuid: 'user-1', + parentUuid: 'pre-resume-parent', + sessionId: 'test-session-id', + timestamp: '2026-06-27T00:00:00.000Z', + type: 'user', + cwd: '/test/project/root', + version: '1.0.0', + message: { role: 'user', parts: [{ text: 'first resumed turn' }] }, + }, + { + uuid: 'assistant-1', + parentUuid: 'user-1', + sessionId: 'test-session-id', + timestamp: '2026-06-27T00:00:01.000Z', + type: 'assistant', + cwd: '/test/project/root', + version: '1.0.0', + message: { role: 'model', parts: [{ text: 'response' }] }, + model: 'gemini-pro', + }, + ]); + + chatRecordingService.rewindRecording(0, { truncatedCount: 2 }); + await chatRecordingService.flush(); + + expect(jsonl.writeLine).toHaveBeenCalledTimes(1); + const rewind = vi.mocked(jsonl.writeLine).mock.calls[0][1] as ChatRecord; + expect(rewind.subtype).toBe('rewind'); + expect(rewind.parentUuid).toBe('pre-resume-parent'); + }); + }); + describe('recordAtCommand', () => { it('should record @-command metadata as a system payload', async () => { const userParts: Part[] = [{ text: 'Hello, world!' }]; diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 9218800bfb9..39c1c8a7910 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -1230,10 +1230,6 @@ export class ChatRecordingService { */ rebuildTurnBoundaries(messages: ChatRecord[]): void { this.turnParentUuids = []; - let prevUuid: string | null = - this.config.getResumedSessionData()?.lastCompletedUuid !== undefined - ? null - : this.lastRecordUuid; for (let i = 0; i < messages.length; i++) { const record = messages[i]; @@ -1243,9 +1239,10 @@ export class ChatRecordingService { record.subtype !== 'cron' && record.subtype !== 'mid_turn_user_message' ) { - this.turnParentUuids.push(prevUuid); + // Reconstructed histories can start mid-chain; the persisted edge is + // the source of truth, not the previous item in this sliced list. + this.turnParentUuids.push(record.parentUuid ?? null); } - prevUuid = record.uuid; } // Ensure lastRecordUuid points to the end of the reconstructed chain. if (messages.length > 0) {