diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 5a92d6b8..43d7e0de 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -6414,8 +6414,6 @@ export class ClaudeAcpAgent { !firstUnsettledQueuedTurn() ) { await asyncTasks.autonomousTurnStarted(); - } else if (message.state === "idle" && asyncTasks.autonomousTurnOpen) { - await asyncTasks.autonomousTurnEnded("completed"); } if ( message.state === "running" && @@ -6447,6 +6445,9 @@ export class ClaudeAcpAgent { // here. An owed idle belongs to an earlier result: it can lag // past the cycle's first output. if (session.owedTrailingIdles === 0) { + if (asyncTasks.autonomousTurnOpen) { + await asyncTasks.autonomousTurnEnded("completed"); + } await closeCycleToolCalls(); this.endAutonomousCycle(params.sessionId, { outcome: { stopReason: session.cancelled ? "cancelled" : "end_turn" }, diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index af644b9a..2fa0be48 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -25953,6 +25953,82 @@ describe("deferred settlement for live background subagents (issues #864/#866)", ]); }); + // Issue #1453: a previous cycle's trailing idle completed the next turn task. + it.each(["result", "idle"] as const)( + "keeps the next turn task open across an owed idle until its own %s", + async (end) => { + const { agent, asyncTaskUpdates } = await airAgent(); + let wake = () => {}; + const woken = new Promise((resolve) => (wake = resolve)); + let finish = () => {}; + const finished = new Promise((resolve) => (finish = resolve)); + injectGeneratorSession(agent, (input) => { + async function* messageGenerator() { + const iter = input[Symbol.asyncIterator](); + yield userEcho((await iter.next()).value); + yield running(); + yield resultMessage(); + yield idle(); + await woken; + yield running(); + yield assistantText("first autonomous cycle"); + yield resultMessage({ origin: { kind: "task-notification" } }); + // The CLI stays running: the next output opens a new turn task. + yield assistantText("next autonomous cycle"); + yield idle(); // The previous autonomous result's late trailer. + await finished; + yield assistantText("still working in the next cycle"); + if (end === "result") { + yield resultMessage({ origin: { kind: "task-notification" } }); + } + yield idle(); + } + return messageGenerator(); + }); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] }); + wake(); + try { + await waitFor( + () => + asyncTaskUpdates().filter((u) => u.sessionUpdate === "async_task_spawned").length === + 2 && agent.sessions["test-session"].owedTrailingIdles === 0, + ); + const [first, firstEnd, next] = asyncTaskUpdates(); + expect(asyncTaskUpdates()).toEqual([ + first, + { + sessionUpdate: "async_task_state_update", + asyncTaskId: first.asyncTaskId, + state: "completed", + }, + next, + ]); + expect(firstEnd.state).toBe("completed"); + expect(next.sessionUpdate).toBe("async_task_spawned"); + expect(next.asyncTaskId).not.toBe(first.asyncTaskId); + } finally { + finish(); + await agent.sessions["test-session"]?.consumer; + } + const [first, , next] = asyncTaskUpdates(); + expect(asyncTaskUpdates()).toEqual([ + first, + { + sessionUpdate: "async_task_state_update", + asyncTaskId: first.asyncTaskId, + state: "completed", + }, + next, + { + sessionUpdate: "async_task_state_update", + asyncTaskId: next.asyncTaskId, + state: "completed", + }, + ]); + }, + ); + describe("started by a scheduler fire", () => { const lifecycleStarted = (fire: { wakeup_source?: string; scheduled_for?: number }) => ({