Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 210 additions & 4 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof SessionService>,
);
}
Expand Down Expand Up @@ -11736,6 +11739,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
expect.anything(),
expect.anything(),
gaps,
// 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();
Expand Down Expand Up @@ -12052,6 +12058,202 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});

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()
.mockReturnValue('/tmp/qwen-runtime-test');
vi.mocked(SessionService).mockImplementation(
() =>
({
readLiveRestoreProjection: vi.fn().mockResolvedValue({
replay: {
records: [{ role: 'user' }],
gaps: [],
},
}),
}) as unknown as InstanceType<typeof SessionService>,
);
mockHistoryReplay.mockResolvedValue(undefined);
const { agent, agentPromise } = await bootAcpAgent();
const loadSession = (params: Record<string, unknown>) =>
(
agent as unknown as {
loadSession: (p: Record<string, unknown>) => Promise<unknown>;
}
).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);

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: [] });
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 agent.extMethod('qwen/session/loadUpdates', {
sessionId: VALID_SESSION_ID,
cwd: '/tmp',
});
expect(mockHistoryReplay).toHaveBeenLastCalledWith(
expect.anything(),
expect.anything(),
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;
lastSessionMock!.isTurnIdle.mockReturnValue(true);

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('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');
// 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.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('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');
// 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.mockReturnValue(true);

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');
Expand Down Expand Up @@ -16908,6 +17110,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),
Expand Down Expand Up @@ -18028,6 +18231,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(),
};
Expand Down Expand Up @@ -19040,6 +19244,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<typeof Session>;
const sessions = (
Expand Down Expand Up @@ -19355,6 +19560,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
});

expect(replayOptions).toEqual({
finalizeDangling: true,
goalBootstrap: {
goalStatus: {
kind: 'set',
Expand Down Expand Up @@ -19440,7 +19646,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
},
});

expect(replayOptions).toBeUndefined();
expect(replayOptions).toEqual({ finalizeDangling: true });

mockConnectionState.resolve();
await agentPromise;
Expand Down Expand Up @@ -19512,7 +19718,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
},
});

expect(replayOptions).toBeUndefined();
expect(replayOptions).toEqual({ finalizeDangling: true });

mockConnectionState.resolve();
await agentPromise;
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4979,6 +4979,33 @@ class QwenAgent implements Agent {
return runWithAcpRuntimeOutputDir(settings, cwd, operation);
}

/**
* 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(
Comment thread
yiliang114 marked this conversation as resolved.
session: Session | undefined,
turnIdleBeforeRead: boolean,
): 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=${finalize} (idleBeforeRead=${turnIdleBeforeRead}, idleAtReplay=${idleAtReplay}) session=${session?.getId() ?? '(non-live)'}`,
);
return finalize;
}

private async assertLiveSessionScope(
config: Config,
settings: LoadedSettings,
Expand Down Expand Up @@ -5405,6 +5432,13 @@ class QwenAgent implements Agent {
replayState: replayPage.replay,
goalBootstrap: replayGoalBootstrap(projection),
suppressRestoreAskUserQuestion,
// 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: {
Expand Down Expand Up @@ -11876,6 +11910,7 @@ class QwenAgent implements Agent {
}

const liveSession = this.sessions.get(sessionId);
const turnIdleBeforeRead = liveSession?.isTurnIdle() ?? true;
Comment thread
yiliang114 marked this conversation as resolved.
let replayConfig = this.config;
let sessionData: ResumedSessionData | undefined;
if (liveSession) {
Expand Down Expand Up @@ -11917,6 +11952,13 @@ 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,
// 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,
),
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
});

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading