From 1f82ac53c88b1720edbaf88f74ce9850e69ac1f2 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 23 Aug 2026 02:13:32 +0800 Subject: [PATCH 1/6] fix(cli): keep in-flight tool calls pending during live session replay Loading a live session while a prompt is still running replayed the transcript with dangling-call finalization hard-coded on, so a tool call whose result had not been persisted yet was surfaced as the permanent 'Tool result missing from saved history' failure even though the tool was still executing (#9704). Thread finalizeDangling through the bulk replay path and apply the same active-prompt guard the transcript paging path already uses: while a prompt is active in this process, a trailing unmatched call stays pending and its result arrives through the live stream. Cold restores of non-live sessions keep finalizing as before. --- .../cli/src/acp-integration/acpAgent.test.ts | 128 +++++++++++++++++- packages/cli/src/acp-integration/acpAgent.ts | 12 ++ .../session/history-replay-page.test.ts | 32 +++++ .../session/history-replay-page.ts | 8 ++ .../session/history-replayer.test.ts | 28 ++++ .../session/history-replayer.ts | 3 +- 6 files changed, 208 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 6ad752811cf..0e4ac2ec9a9 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -11736,6 +11736,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { expect.anything(), expect.anything(), gaps, + expect.anything(), ); mockConnectionState.resolve(); @@ -12052,6 +12053,128 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('live session load keeps dangling transcript calls in flight while a prompt is active', async () => { + const innerConfig = await setupSessionMocks(VALID_SESSION_ID); + innerConfig.getSessionRuntimeBaseDir = vi + .fn() + .mockReturnValue('/tmp/qwen-runtime-test'); + vi.mocked(SessionService).mockImplementation( + () => + ({ + readLiveRestoreProjection: vi.fn().mockResolvedValue({ + replay: { + records: [{ role: 'user' }], + gaps: [], + }, + }), + }) as unknown as InstanceType, + ); + mockHistoryReplay.mockResolvedValue(undefined); + const { agent, agentPromise } = await bootAcpAgent(); + const loadSession = (params: Record) => + ( + agent as unknown as { + loadSession: (p: Record) => Promise; + } + ).loadSession(params); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + let finishPrompt: ((value: unknown) => void) | undefined; + lastSessionMock!.prompt.mockImplementation( + () => + new Promise((resolve) => { + finishPrompt = resolve; + }), + ); + + const prompt = agent.prompt({ sessionId: VALID_SESSION_ID, prompt: [] }); + await vi.waitFor(() => expect(lastSessionMock!.prompt).toHaveBeenCalled()); + + await loadSession({ + cwd: '/tmp', + sessionId: VALID_SESSION_ID, + mcpServers: [], + }); + expect(mockHistoryReplay).toHaveBeenLastCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ finalizeDangling: false }), + ); + + finishPrompt?.({ stopReason: 'end_turn' }); + await prompt; + + await loadSession({ + cwd: '/tmp', + sessionId: VALID_SESSION_ID, + mcpServers: [], + }); + expect(mockHistoryReplay).toHaveBeenLastCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ finalizeDangling: true }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('qwen/session/loadUpdates keeps dangling transcript calls in flight while a prompt is active', async () => { + const innerConfig = await setupSessionMocks(VALID_SESSION_ID); + innerConfig.getSessionRuntimeBaseDir = vi + .fn() + .mockReturnValue('/tmp/qwen-runtime-test'); + mockSessionServiceLoad({ + conversation: { + messages: [{ role: 'user' }], + startTime: 'start', + lastUpdated: 'end', + }, + }); + mockHistoryReplay.mockResolvedValue(undefined); + const { agent, agentPromise } = await bootAcpAgent(); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + let finishPrompt: ((value: unknown) => void) | undefined; + lastSessionMock!.prompt.mockImplementation( + () => + new Promise((resolve) => { + finishPrompt = resolve; + }), + ); + + const prompt = agent.prompt({ sessionId: VALID_SESSION_ID, prompt: [] }); + await vi.waitFor(() => expect(lastSessionMock!.prompt).toHaveBeenCalled()); + + await agent.extMethod('qwen/session/loadUpdates', { + sessionId: VALID_SESSION_ID, + cwd: '/tmp', + }); + expect(mockHistoryReplay).toHaveBeenLastCalledWith( + expect.anything(), + expect.anything(), + undefined, + expect.objectContaining({ finalizeDangling: false }), + ); + + finishPrompt?.({ stopReason: 'end_turn' }); + await prompt; + + await agent.extMethod('qwen/session/loadUpdates', { + sessionId: VALID_SESSION_ID, + cwd: '/tmp', + }); + expect(mockHistoryReplay).toHaveBeenLastCalledWith( + expect.anything(), + expect.anything(), + undefined, + expect.objectContaining({ finalizeDangling: true }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('disposes a pending transcript config superseded by newer settings', async () => { const oldSettings = makeCoreSettings('English'); const newSettings = makeCoreSettings('Japanese'); @@ -19355,6 +19478,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { }); expect(replayOptions).toEqual({ + finalizeDangling: true, goalBootstrap: { goalStatus: { kind: 'set', @@ -19440,7 +19564,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { }, }); - expect(replayOptions).toBeUndefined(); + expect(replayOptions).toEqual({ finalizeDangling: true }); mockConnectionState.resolve(); await agentPromise; @@ -19512,7 +19636,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { }, }); - expect(replayOptions).toBeUndefined(); + expect(replayOptions).toEqual({ finalizeDangling: true }); mockConnectionState.resolve(); await agentPromise; diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 45ff95d5d62..726892f96b3 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -5370,6 +5370,7 @@ class QwenAgent implements Agent { loadSettingsCached(params.cwd), ); const liveConfig = liveSession.getConfig(); + const activePromptBeforeRead = this.activePromptCalls.has(sessionId); return profiler.time('live_restore', async () => { await this.assertLiveSessionScope(liveConfig, settings, params.cwd); return this.withLiveSessionRestore( @@ -5405,6 +5406,13 @@ class QwenAgent implements Agent { replayState: replayPage.replay, goalBootstrap: replayGoalBootstrap(projection), suppressRestoreAskUserQuestion, + // A trailing unmatched call is in-flight, not abandoned, + // while a prompt is still running in this process (#9704); + // keep it pending instead of finalizing it as a permanent + // failure. Mirrors the transcript paging guard below. + finalizeDangling: + !activePromptBeforeRead && + !this.activePromptCalls.has(sessionId), ...(restoreOptions.replay.kind === 'recent' ? { limits: { @@ -11876,6 +11884,7 @@ class QwenAgent implements Agent { } const liveSession = this.sessions.get(sessionId); + const activePromptBeforeRead = this.activePromptCalls.has(sessionId); let replayConfig = this.config; let sessionData: ResumedSessionData | undefined; if (liveSession) { @@ -11917,6 +11926,9 @@ class QwenAgent implements Agent { // Read-only history dump never re-hangs the question. Skip // finalize only on load/resume that will actually restore. suppressRestoreAskUserQuestion: true, + // Same in-flight guard as the session-load replay (#9704). + finalizeDangling: + !activePromptBeforeRead && !this.activePromptCalls.has(sessionId), }); return { diff --git a/packages/cli/src/acp-integration/session/history-replay-page.test.ts b/packages/cli/src/acp-integration/session/history-replay-page.test.ts index 512326844e6..d03d0bcdce2 100644 --- a/packages/cli/src/acp-integration/session/history-replay-page.test.ts +++ b/packages/cli/src/acp-integration/session/history-replay-page.test.ts @@ -286,6 +286,38 @@ describe('history replay page', () => { ).toBe(false); }); + it('finalizes a dangling tool call as failed by default', async () => { + const result = await collectHistoryReplayUpdates({ + sessionId: SESSION_ID, + records: [userRecord(), toolCallRecord()], + cumulativeUsage: createReplayCumulativeUsage(), + }); + + expect(result.replayError).toBeUndefined(); + expect(result.updates).toContainEqual( + expect.objectContaining({ + sessionUpdate: 'tool_call_update', + status: 'failed', + }), + ); + }); + + it('keeps a dangling tool call in flight when finalizeDangling is false', async () => { + const result = await collectHistoryReplayUpdates({ + sessionId: SESSION_ID, + records: [userRecord(), toolCallRecord()], + cumulativeUsage: createReplayCumulativeUsage(), + finalizeDangling: false, + }); + + expect(result.replayError).toBeUndefined(); + expect( + result.updates.some( + (update) => update.sessionUpdate === 'tool_call_update', + ), + ).toBe(false); + }); + it('bounds textual tool results collected for bulk replay', async () => { const source = 'x'.repeat(499_999); const result = await collectHistoryReplayUpdates({ diff --git a/packages/cli/src/acp-integration/session/history-replay-page.ts b/packages/cli/src/acp-integration/session/history-replay-page.ts index 37955947240..3f875dab589 100644 --- a/packages/cli/src/acp-integration/session/history-replay-page.ts +++ b/packages/cli/src/acp-integration/session/history-replay-page.ts @@ -254,6 +254,7 @@ export async function collectHistoryReplayUpdates({ goalBootstrap, limits, suppressRestoreAskUserQuestion, + finalizeDangling, }: { sessionId: string; config?: Config; @@ -270,6 +271,12 @@ export async function collectHistoryReplayUpdates({ * so the replayed card doesn't spin forever with no restore prompt coming. */ suppressRestoreAskUserQuestion?: boolean; + /** + * Live-session loads while a prompt is still active must not finalize + * dangling calls: a trailing unmatched call is in-flight, not abandoned, + * and its result arrives through the live stream (#9704). + */ + finalizeDangling?: boolean; }): Promise<{ updates: SessionUpdate[]; replayError?: string }> { const updates: SessionUpdate[] = []; try { @@ -299,6 +306,7 @@ export async function collectHistoryReplayUpdates({ ...(initial.goalCause ? { initialGoalCause: initial.goalCause } : {}), ...(goalBootstrap ? { goalBootstrap } : {}), ...(skipFinalizeCallIds ? { skipFinalizeCallIds } : {}), + ...(finalizeDangling === undefined ? {} : { finalizeDangling }), }); } catch (error) { if (error instanceof HistoryReplayLimitError) throw error; diff --git a/packages/cli/src/acp-integration/session/history-replayer.test.ts b/packages/cli/src/acp-integration/session/history-replayer.test.ts index ee0f9a212e5..9e5995d196e 100644 --- a/packages/cli/src/acp-integration/session/history-replayer.test.ts +++ b/packages/cli/src/acp-integration/session/history-replayer.test.ts @@ -433,6 +433,34 @@ describe('HistoryReplayer', () => { ]); }); + it('keeps a dangling call in flight when finalizeDangling is false', async () => { + const record: ChatRecord = { + ...createAssistantRecord(''), + message: { + role: 'model', + parts: [ + { + functionCall: { + id: 'call-inflight', + name: 'run_shell_command', + args: { command: 'sleep 10' }, + }, + }, + ], + }, + }; + + await replayer.replay([record], undefined, { finalizeDangling: false }); + + const updates = sentUpdates(); + expect(updates.map((update) => update['sessionUpdate'])).toEqual([ + 'tool_call', + ]); + expect(replayer.getPendingToolCalls()).toEqual([ + expect.objectContaining({ callId: 'call-inflight' }), + ]); + }); + it('should carry dangling function calls across replay pages', async () => { const record: ChatRecord = { ...createAssistantRecord(''), diff --git a/packages/cli/src/acp-integration/session/history-replayer.ts b/packages/cli/src/acp-integration/session/history-replayer.ts index d3690d7b7ea..05d938220ba 100644 --- a/packages/cli/src/acp-integration/session/history-replayer.ts +++ b/packages/cli/src/acp-integration/session/history-replayer.ts @@ -91,6 +91,7 @@ export class HistoryReplayer { initialGoalCause?: GoalStateCause; goalBootstrap?: HistoryReplayGoalBootstrap; skipFinalizeCallIds?: ReadonlySet; + finalizeDangling?: boolean; } = {}, ): Promise { try { @@ -108,7 +109,7 @@ export class HistoryReplayer { await this.sendUpdate(update); } await this.replayPage(records, { - finalizeDangling: true, + finalizeDangling: options.finalizeDangling ?? true, gaps, ...(options.skipFinalizeCallIds ? { skipFinalizeCallIds: options.skipFinalizeCallIds } From 984582f07022748f34cd52cee6709b0d9d263477 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 23 Aug 2026 13:05:14 +0800 Subject: [PATCH 2/6] fix(cli): guard restore replay on session turn activity, not client prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round on f806ae7d found the activePromptCalls predicate cannot see the daemon's autonomous turns (goal continuations, cron, background notifications), which bypass the client-prompt handler entirely, and that the live-load drain makes parts of the guard unreachable. Sample Session.isTurnIdle() — the same predicate family the live restore drain already waits on — before the transcript read and again at replay time, through a single shared helper used by both restore surfaces. Add settle-during-read regression tests at both sites; a mutant dropping the before-read sample now fails them. Also disclose the remaining windows honestly: the live-load drain still times out loads during long-running tools, and cold restores of non-live sessions keep finalizing pending ownership evidence (#9483), so the PR no longer claims to fully close #9704. --- .../cli/src/acp-integration/acpAgent.test.ts | 87 +++++++++++++++++++ packages/cli/src/acp-integration/acpAgent.ts | 39 ++++++--- 2 files changed, 116 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 0e4ac2ec9a9..d336719d5a1 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -12078,6 +12078,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { } ).loadSession(params); await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + lastSessionMock!.isTurnIdle.mockReturnValue(false); let finishPrompt: ((value: unknown) => void) | undefined; lastSessionMock!.prompt.mockImplementation( () => @@ -12103,6 +12104,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { finishPrompt?.({ stopReason: 'end_turn' }); await prompt; + lastSessionMock!.isTurnIdle.mockReturnValue(true); await loadSession({ cwd: '/tmp', @@ -12120,6 +12122,52 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('live session load keeps a dangling call pending when a turn settles during the restore read', async () => { + const innerConfig = await setupSessionMocks(VALID_SESSION_ID); + innerConfig.getSessionRuntimeBaseDir = vi + .fn() + .mockReturnValue('/tmp/qwen-runtime-test'); + vi.mocked(SessionService).mockImplementation( + () => + ({ + readLiveRestoreProjection: vi.fn().mockResolvedValue({ + replay: { + records: [{ role: 'user' }], + gaps: [], + }, + }), + }) as unknown as InstanceType, + ); + mockHistoryReplay.mockResolvedValue(undefined); + const { agent, agentPromise } = await bootAcpAgent(); + const loadSession = (params: Record) => + ( + agent as unknown as { + loadSession: (p: Record) => Promise; + } + ).loadSession(params); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + // A turn that is active before the transcript read but settles inside + // the restore window: only the before-read sample keeps the trailing + // call pending. + lastSessionMock!.isTurnIdle.mockReturnValueOnce(false); + + await loadSession({ + cwd: '/tmp', + sessionId: VALID_SESSION_ID, + mcpServers: [], + }); + expect(mockHistoryReplay).toHaveBeenLastCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ finalizeDangling: false }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('qwen/session/loadUpdates keeps dangling transcript calls in flight while a prompt is active', async () => { const innerConfig = await setupSessionMocks(VALID_SESSION_ID); innerConfig.getSessionRuntimeBaseDir = vi @@ -12135,6 +12183,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { mockHistoryReplay.mockResolvedValue(undefined); const { agent, agentPromise } = await bootAcpAgent(); await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + lastSessionMock!.isTurnIdle.mockReturnValue(false); let finishPrompt: ((value: unknown) => void) | undefined; lastSessionMock!.prompt.mockImplementation( () => @@ -12159,6 +12208,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { finishPrompt?.({ stopReason: 'end_turn' }); await prompt; + lastSessionMock!.isTurnIdle.mockReturnValue(true); await agent.extMethod('qwen/session/loadUpdates', { sessionId: VALID_SESSION_ID, @@ -12175,6 +12225,40 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('qwen/session/loadUpdates keeps a dangling call pending when a turn settles during the read', async () => { + const innerConfig = await setupSessionMocks(VALID_SESSION_ID); + innerConfig.getSessionRuntimeBaseDir = vi + .fn() + .mockReturnValue('/tmp/qwen-runtime-test'); + mockSessionServiceLoad({ + conversation: { + messages: [{ role: 'user' }], + startTime: 'start', + lastUpdated: 'end', + }, + }); + mockHistoryReplay.mockResolvedValue(undefined); + const { agent, agentPromise } = await bootAcpAgent(); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + // Active before the read, idle by replay time: the before-read sample + // alone must keep the trailing call pending. + lastSessionMock!.isTurnIdle.mockReturnValueOnce(false); + + await agent.extMethod('qwen/session/loadUpdates', { + sessionId: VALID_SESSION_ID, + cwd: '/tmp', + }); + expect(mockHistoryReplay).toHaveBeenLastCalledWith( + expect.anything(), + expect.anything(), + undefined, + expect.objectContaining({ finalizeDangling: false }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('disposes a pending transcript config superseded by newer settings', async () => { const oldSettings = makeCoreSettings('English'); const newSettings = makeCoreSettings('Japanese'); @@ -17031,6 +17115,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { waitForActiveTurnsToSettle: vi.fn().mockResolvedValue(undefined), cancelPendingPrompt: vi.fn().mockResolvedValue(undefined), assertCanStartTurn: vi.fn().mockResolvedValue(undefined), + isTurnIdle: vi.fn().mockReturnValue(true), sendUpdate: opts.recoveredGoalSendError ? vi.fn().mockRejectedValue(opts.recoveredGoalSendError) : vi.fn().mockResolvedValue(undefined), @@ -18151,6 +18236,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { assertCanStartTurn: vi.fn().mockResolvedValue(undefined), beginClose: vi.fn().mockReturnValue(vi.fn()), waitForActiveTurnsToSettle: vi.fn().mockResolvedValue(undefined), + isTurnIdle: vi.fn().mockReturnValue(true), sendUpdate: vi.fn().mockResolvedValue(undefined), clearActiveTodoPlanRevision: vi.fn(), }; @@ -19163,6 +19249,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { waitForCloseGateToRelease: vi.fn().mockResolvedValue(undefined), cancelPendingPrompt: vi.fn().mockResolvedValue(undefined), waitForActiveTurnsToSettle: vi.fn().mockResolvedValue(undefined), + isTurnIdle: vi.fn().mockReturnValue(true), dispose: replacementDispose, } as unknown as InstanceType; const sessions = ( diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 726892f96b3..951fecf2491 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -4979,6 +4979,21 @@ class QwenAgent implements Agent { return runWithAcpRuntimeOutputDir(settings, cwd, operation); } + /** + * Whether a restore replay may finalize dangling tool calls. A session + * with an active turn — a client prompt or an autonomous goal/cron/ + * notification turn — may still owe the trailing call's result, so the + * replay keeps it pending and lets the live stream deliver it (#9704). + * Samples the turn state before the transcript read and again at replay + * time so a turn that starts or settles inside the read window is seen. + */ + private finalizeDanglingForRestore( + session: Session | undefined, + turnIdleBeforeRead: boolean, + ): boolean { + return turnIdleBeforeRead && (session?.isTurnIdle() ?? true); + } + private async assertLiveSessionScope( config: Config, settings: LoadedSettings, @@ -5370,7 +5385,7 @@ class QwenAgent implements Agent { loadSettingsCached(params.cwd), ); const liveConfig = liveSession.getConfig(); - const activePromptBeforeRead = this.activePromptCalls.has(sessionId); + const turnIdleBeforeRead = liveSession.isTurnIdle(); return profiler.time('live_restore', async () => { await this.assertLiveSessionScope(liveConfig, settings, params.cwd); return this.withLiveSessionRestore( @@ -5407,12 +5422,14 @@ class QwenAgent implements Agent { goalBootstrap: replayGoalBootstrap(projection), suppressRestoreAskUserQuestion, // A trailing unmatched call is in-flight, not abandoned, - // while a prompt is still running in this process (#9704); - // keep it pending instead of finalizing it as a permanent - // failure. Mirrors the transcript paging guard below. - finalizeDangling: - !activePromptBeforeRead && - !this.activePromptCalls.has(sessionId), + // while the session still has an active turn (#9704); keep + // it pending instead of finalizing it as a permanent + // failure. The paged transcript read applies the same + // guard on its own predicate. + finalizeDangling: this.finalizeDanglingForRestore( + liveSession, + turnIdleBeforeRead, + ), ...(restoreOptions.replay.kind === 'recent' ? { limits: { @@ -11884,7 +11901,7 @@ class QwenAgent implements Agent { } const liveSession = this.sessions.get(sessionId); - const activePromptBeforeRead = this.activePromptCalls.has(sessionId); + const turnIdleBeforeRead = liveSession?.isTurnIdle() ?? true; let replayConfig = this.config; let sessionData: ResumedSessionData | undefined; if (liveSession) { @@ -11927,8 +11944,10 @@ class QwenAgent implements Agent { // finalize only on load/resume that will actually restore. suppressRestoreAskUserQuestion: true, // Same in-flight guard as the session-load replay (#9704). - finalizeDangling: - !activePromptBeforeRead && !this.activePromptCalls.has(sessionId), + finalizeDangling: this.finalizeDanglingForRestore( + liveSession, + turnIdleBeforeRead, + ), }); return { From 7dc680dfc590616691e83db76bce046f65883860 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 23 Aug 2026 13:15:27 +0800 Subject: [PATCH 3/6] ci: retrigger CI for 2c736923 (pull_request event did not register) From b1047ad5fd635ffb1c07e9d8dfcb6f522460edfd Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 23 Aug 2026 22:24:03 +0800 Subject: [PATCH 4/6] fix(cli): finalize dangling calls on the gated live restore path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 review found the turn-activity guard blinded by the restore's own close gate: withLiveSessionRestore holds beginClose() across the replay, and isTurnIdle() is structurally false while closing=true, so every live load — including fully idle sessions whose trailing call is genuinely abandoned — skipped finalization and replayed the call as running forever. The gate already drains active turns and blocks new ones (a drain timeout rejects before any replay), so the gated live loadSession path now finalizes unconditionally. The two-sample isTurnIdle() guard stays on the ungated qwen/session/loadUpdates surface, with a debug line at the decision point and new tests covering the replay-time sample, the non-live default, and the gate contract. --- .../cli/src/acp-integration/acpAgent.test.ts | 118 +++++++----------- packages/cli/src/acp-integration/acpAgent.ts | 49 +++++--- .../session/history-replay-page.ts | 8 +- 3 files changed, 80 insertions(+), 95 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index d336719d5a1..f4c7dd948bb 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -11736,7 +11736,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => { expect.anything(), expect.anything(), gaps, - expect.anything(), + // Non-live loadUpdates replays have no live stream to deliver a + // trailing result, so dangling calls must still finalize. + expect.objectContaining({ finalizeDangling: true }), ); mockConnectionState.resolve(); @@ -12053,7 +12055,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); - it('live session load keeps dangling transcript calls in flight while a prompt is active', async () => { + it('live session load finalizes dangling calls because the restore gate drains active turns', async () => { const innerConfig = await setupSessionMocks(VALID_SESSION_ID); innerConfig.getSessionRuntimeBaseDir = vi .fn() @@ -12078,33 +12080,11 @@ describe('QwenAgent MCP SSE/HTTP support', () => { } ).loadSession(params); await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + // The restore gate drains active turns and blocks new ones before the + // replay runs, so the live path must finalize regardless of turn state; + // sampling isTurnIdle() under the gate is structurally false and would + // keep genuinely abandoned calls pending forever. lastSessionMock!.isTurnIdle.mockReturnValue(false); - let finishPrompt: ((value: unknown) => void) | undefined; - lastSessionMock!.prompt.mockImplementation( - () => - new Promise((resolve) => { - finishPrompt = resolve; - }), - ); - - const prompt = agent.prompt({ sessionId: VALID_SESSION_ID, prompt: [] }); - await vi.waitFor(() => expect(lastSessionMock!.prompt).toHaveBeenCalled()); - - await loadSession({ - cwd: '/tmp', - sessionId: VALID_SESSION_ID, - mcpServers: [], - }); - expect(mockHistoryReplay).toHaveBeenLastCalledWith( - expect.anything(), - expect.anything(), - expect.anything(), - expect.objectContaining({ finalizeDangling: false }), - ); - - finishPrompt?.({ stopReason: 'end_turn' }); - await prompt; - lastSessionMock!.isTurnIdle.mockReturnValue(true); await loadSession({ cwd: '/tmp', @@ -12122,52 +12102,6 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); - it('live session load keeps a dangling call pending when a turn settles during the restore read', async () => { - const innerConfig = await setupSessionMocks(VALID_SESSION_ID); - innerConfig.getSessionRuntimeBaseDir = vi - .fn() - .mockReturnValue('/tmp/qwen-runtime-test'); - vi.mocked(SessionService).mockImplementation( - () => - ({ - readLiveRestoreProjection: vi.fn().mockResolvedValue({ - replay: { - records: [{ role: 'user' }], - gaps: [], - }, - }), - }) as unknown as InstanceType, - ); - mockHistoryReplay.mockResolvedValue(undefined); - const { agent, agentPromise } = await bootAcpAgent(); - const loadSession = (params: Record) => - ( - agent as unknown as { - loadSession: (p: Record) => Promise; - } - ).loadSession(params); - await agent.newSession({ cwd: '/tmp', mcpServers: [] }); - // A turn that is active before the transcript read but settles inside - // the restore window: only the before-read sample keeps the trailing - // call pending. - lastSessionMock!.isTurnIdle.mockReturnValueOnce(false); - - await loadSession({ - cwd: '/tmp', - sessionId: VALID_SESSION_ID, - mcpServers: [], - }); - expect(mockHistoryReplay).toHaveBeenLastCalledWith( - expect.anything(), - expect.anything(), - expect.anything(), - expect.objectContaining({ finalizeDangling: false }), - ); - - mockConnectionState.resolve(); - await agentPromise; - }); - it('qwen/session/loadUpdates keeps dangling transcript calls in flight while a prompt is active', async () => { const innerConfig = await setupSessionMocks(VALID_SESSION_ID); innerConfig.getSessionRuntimeBaseDir = vi @@ -12259,6 +12193,42 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('qwen/session/loadUpdates keeps a dangling call pending when a turn starts during the read', async () => { + const innerConfig = await setupSessionMocks(VALID_SESSION_ID); + innerConfig.getSessionRuntimeBaseDir = vi + .fn() + .mockReturnValue('/tmp/qwen-runtime-test'); + mockSessionServiceLoad({ + conversation: { + messages: [{ role: 'user' }], + startTime: 'start', + lastUpdated: 'end', + }, + }); + mockHistoryReplay.mockResolvedValue(undefined); + const { agent, agentPromise } = await bootAcpAgent(); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + // Idle before the read, active by replay time: the replay-time sample + // alone must keep the trailing call pending. + lastSessionMock!.isTurnIdle + .mockReturnValueOnce(true) + .mockReturnValue(false); + + await agent.extMethod('qwen/session/loadUpdates', { + sessionId: VALID_SESSION_ID, + cwd: '/tmp', + }); + expect(mockHistoryReplay).toHaveBeenLastCalledWith( + expect.anything(), + expect.anything(), + undefined, + expect.objectContaining({ finalizeDangling: false }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('disposes a pending transcript config superseded by newer settings', async () => { const oldSettings = makeCoreSettings('English'); const newSettings = makeCoreSettings('Japanese'); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 951fecf2491..630b4c11875 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -4980,18 +4980,32 @@ class QwenAgent implements Agent { } /** - * Whether a restore replay may finalize dangling tool calls. A session - * with an active turn — a client prompt or an autonomous goal/cron/ - * notification turn — may still owe the trailing call's result, so the - * replay keeps it pending and lets the live stream deliver it (#9704). - * Samples the turn state before the transcript read and again at replay - * time so a turn that starts or settles inside the read window is seen. + * Whether an ungated restore replay (qwen/session/loadUpdates) may + * finalize dangling tool calls. A session with an active turn — a client + * prompt or an autonomous goal/cron/notification turn — may still owe the + * trailing call's result, so the replay keeps it pending and lets the + * live stream deliver it (#9704). Samples the turn state before the + * transcript read and again at replay time so a turn that starts or + * settles inside the read window is seen. Not for the live loadSession + * path: that restore runs under the close gate, which drains active + * turns, blocks new ones, and reports closing=true — so isTurnIdle() + * there is structurally false and would keep genuinely abandoned calls + * pending forever. */ private finalizeDanglingForRestore( session: Session | undefined, turnIdleBeforeRead: boolean, ): boolean { - return turnIdleBeforeRead && (session?.isTurnIdle() ?? true); + const idleAtReplay = session?.isTurnIdle() ?? true; + const finalize = turnIdleBeforeRead && idleAtReplay; + debugLogger.debug( + '[ACP] restore replay finalizeDangling=%s (idleBeforeRead=%s, idleAtReplay=%s) session=%s', + finalize, + turnIdleBeforeRead, + idleAtReplay, + session?.getId() ?? '(non-live)', + ); + return finalize; } private async assertLiveSessionScope( @@ -5385,7 +5399,6 @@ class QwenAgent implements Agent { loadSettingsCached(params.cwd), ); const liveConfig = liveSession.getConfig(); - const turnIdleBeforeRead = liveSession.isTurnIdle(); return profiler.time('live_restore', async () => { await this.assertLiveSessionScope(liveConfig, settings, params.cwd); return this.withLiveSessionRestore( @@ -5421,15 +5434,13 @@ class QwenAgent implements Agent { replayState: replayPage.replay, goalBootstrap: replayGoalBootstrap(projection), suppressRestoreAskUserQuestion, - // A trailing unmatched call is in-flight, not abandoned, - // while the session still has an active turn (#9704); keep - // it pending instead of finalizing it as a permanent - // failure. The paged transcript read applies the same - // guard on its own predicate. - finalizeDangling: this.finalizeDanglingForRestore( - liveSession, - turnIdleBeforeRead, - ), + // The restore gate already drained active turns and blocks + // new ones (and a drain timeout rejects before replay), so + // a trailing unmatched call here is genuinely abandoned — + // finalize it. The turn-activity guard cannot be sampled + // under the gate: isTurnIdle() is structurally false while + // the close gate is held (#9704). + finalizeDangling: true, ...(restoreOptions.replay.kind === 'recent' ? { limits: { @@ -11943,7 +11954,9 @@ class QwenAgent implements Agent { // Read-only history dump never re-hangs the question. Skip // finalize only on load/resume that will actually restore. suppressRestoreAskUserQuestion: true, - // Same in-flight guard as the session-load replay (#9704). + // Ungated read: unlike the live loadSession restore (whose gate + // drains turns), a turn may still be running here, so guard on + // turn activity instead of finalizing unconditionally (#9704). finalizeDangling: this.finalizeDanglingForRestore( liveSession, turnIdleBeforeRead, diff --git a/packages/cli/src/acp-integration/session/history-replay-page.ts b/packages/cli/src/acp-integration/session/history-replay-page.ts index 3f875dab589..5102c27c18d 100644 --- a/packages/cli/src/acp-integration/session/history-replay-page.ts +++ b/packages/cli/src/acp-integration/session/history-replay-page.ts @@ -272,9 +272,11 @@ export async function collectHistoryReplayUpdates({ */ suppressRestoreAskUserQuestion?: boolean; /** - * Live-session loads while a prompt is still active must not finalize - * dangling calls: a trailing unmatched call is in-flight, not abandoned, - * and its result arrives through the live stream (#9704). + * Ungated live-session loads (qwen/session/loadUpdates) while the + * session still has an active turn must not finalize dangling calls: a + * trailing unmatched call is in-flight, not abandoned, and its result + * arrives through the live stream (#9704). The gated live loadSession + * path and non-live loads pass true. */ finalizeDangling?: boolean; }): Promise<{ updates: SessionUpdate[]; replayError?: string }> { From 53de0af392bcaf586432ef70fea419f57463d836 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Mon, 24 Aug 2026 02:09:03 +0800 Subject: [PATCH 5/6] fix(cli): interpolate restore replay finalizeDangling diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createDebugLogger's formatArgs performs no util.format substitution — it stringifies and space-joins its arguments — so the printf-style %s placeholders shipped as literal markers with unlabeled values appended. Interpolate the values via a template literal (the convention at every other debugLogger call site in this file) and pin the single-interpolated-string shape in the loadUpdates test. --- packages/cli/src/acp-integration/acpAgent.test.ts | 7 +++++++ packages/cli/src/acp-integration/acpAgent.ts | 8 +++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index f4c7dd948bb..ff0bf49b5cd 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -12139,6 +12139,13 @@ describe('QwenAgent MCP SSE/HTTP support', () => { undefined, expect.objectContaining({ finalizeDangling: false }), ); + // The diagnostic must be a single interpolated string: debugLogger does + // no printf substitution, so %s placeholders would ship unexpanded. + expect(mockDebugLogger.debug).toHaveBeenCalledWith( + expect.stringContaining( + '[ACP] restore replay finalizeDangling=false (idleBeforeRead=false, idleAtReplay=false)', + ), + ); finishPrompt?.({ stopReason: 'end_turn' }); await prompt; diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 630b4c11875..0bca0627615 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -4998,12 +4998,10 @@ class QwenAgent implements Agent { ): boolean { const idleAtReplay = session?.isTurnIdle() ?? true; const finalize = turnIdleBeforeRead && idleAtReplay; + // Template literal, not printf-style placeholders: createDebugLogger's + // formatArgs does no util.format substitution, it space-joins the args. debugLogger.debug( - '[ACP] restore replay finalizeDangling=%s (idleBeforeRead=%s, idleAtReplay=%s) session=%s', - finalize, - turnIdleBeforeRead, - idleAtReplay, - session?.getId() ?? '(non-live)', + `[ACP] restore replay finalizeDangling=${finalize} (idleBeforeRead=${turnIdleBeforeRead}, idleAtReplay=${idleAtReplay}) session=${session?.getId() ?? '(non-live)'}`, ); return finalize; } From a55295ab895f762e02ac1c60d4db7f57b0bb34f2 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Mon, 24 Aug 2026 03:10:09 +0800 Subject: [PATCH 6/6] fix(cli): anchor loadUpdates race tests at the transcript read boundary --- .../cli/src/acp-integration/acpAgent.test.ts | 54 ++++++++++++------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index ff0bf49b5cd..09a36eb6269 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -11645,11 +11645,14 @@ describe('QwenAgent MCP SSE/HTTP support', () => { const VALID_SESSION_ID = '12345678-1234-1234-1234-1234567890ab'; - function mockSessionServiceLoad(result: unknown) { + function mockSessionServiceLoad(result: unknown, onRead?: () => void) { vi.mocked(SessionService).mockImplementation( () => ({ - loadSession: vi.fn().mockResolvedValue(result), + loadSession: vi.fn().mockImplementation(async () => { + onRead?.(); + return result; + }), }) as unknown as InstanceType, ); } @@ -12171,19 +12174,28 @@ describe('QwenAgent MCP SSE/HTTP support', () => { innerConfig.getSessionRuntimeBaseDir = vi .fn() .mockReturnValue('/tmp/qwen-runtime-test'); - mockSessionServiceLoad({ - conversation: { - messages: [{ role: 'user' }], - startTime: 'start', - lastUpdated: 'end', + // Anchor the settle flip to the read boundary: the session is active + // before the read and the mocked sessionService.loadSession flips it to + // idle inside the read window (mirrors the sessionTranscript test that + // toggles state inside readPage.mockImplementationOnce). Keying the + // isTurnIdle mock by call order instead would let a mutation that moves + // the before-read sample across the read survive. + mockSessionServiceLoad( + { + conversation: { + messages: [{ role: 'user' }], + startTime: 'start', + lastUpdated: 'end', + }, }, - }); + () => lastSessionMock!.isTurnIdle.mockReturnValue(true), + ); mockHistoryReplay.mockResolvedValue(undefined); const { agent, agentPromise } = await bootAcpAgent(); await agent.newSession({ cwd: '/tmp', mcpServers: [] }); // Active before the read, idle by replay time: the before-read sample // alone must keep the trailing call pending. - lastSessionMock!.isTurnIdle.mockReturnValueOnce(false); + lastSessionMock!.isTurnIdle.mockReturnValue(false); await agent.extMethod('qwen/session/loadUpdates', { sessionId: VALID_SESSION_ID, @@ -12205,21 +12217,27 @@ describe('QwenAgent MCP SSE/HTTP support', () => { innerConfig.getSessionRuntimeBaseDir = vi .fn() .mockReturnValue('/tmp/qwen-runtime-test'); - mockSessionServiceLoad({ - conversation: { - messages: [{ role: 'user' }], - startTime: 'start', - lastUpdated: 'end', + // Anchor the start flip to the read boundary: the session is idle + // before the read and the mocked sessionService.loadSession flips it to + // active inside the read window. Keying the isTurnIdle mock by call + // order instead would let a mutation that moves the replay-time sample + // across the read survive. + mockSessionServiceLoad( + { + conversation: { + messages: [{ role: 'user' }], + startTime: 'start', + lastUpdated: 'end', + }, }, - }); + () => lastSessionMock!.isTurnIdle.mockReturnValue(false), + ); mockHistoryReplay.mockResolvedValue(undefined); const { agent, agentPromise } = await bootAcpAgent(); await agent.newSession({ cwd: '/tmp', mcpServers: [] }); // Idle before the read, active by replay time: the replay-time sample // alone must keep the trailing call pending. - lastSessionMock!.isTurnIdle - .mockReturnValueOnce(true) - .mockReturnValue(false); + lastSessionMock!.isTurnIdle.mockReturnValue(true); await agent.extMethod('qwen/session/loadUpdates', { sessionId: VALID_SESSION_ID,