mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-09 19:02:05 +00:00
fix: keep autonomous turn tasks open across owed trailing idles (#1455)
When Claude Code starts a new autonomous cycle before the previous
cycle's trailing idle arrives, that late idle completes the new cycle's
AIR async task while Claude is still working. The next output then
creates a second task for the same cycle.
This change completes the autonomous task inside the existing cycle-end
branch, which already checks that `owedTrailingIdles` is zero. An owed
idle is now only consumed by the existing accounting and leaves the
current task open. The task still completes at its own terminal result
or at an unowed idle, so the task and the cycle end on the same
decision.
It adds a scripted SDK-stream regression for two consecutive autonomous
cycles with no running transition between them. The test checks that the
late idle leaves the second task open and that later output stays on the
same task, for a cycle that ends at a result and for one that ends at an
unowed idle. Both cases fail before the fix and pass after it.
Fixes #1453.
Validation, rebased on 0.98.0 (c90f856):
- `npm run build` and `npm run check` pass.
- `npm run test:run`: 3,378 passed, 32 skipped. One unrelated test,
`tools.test.ts` "picks up a rewrite that restored the modification time
of the file", failed once in the full run on a heavily loaded machine.
It passed 3 of 3 runs alone, both on this branch and on `main`, and this
change does not touch `tools.ts` or its tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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" },
|
||||
|
||||
@@ -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<void>((resolve) => (wake = resolve));
|
||||
let finish = () => {};
|
||||
const finished = new Promise<void>((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 }) =>
|
||||
({
|
||||
|
||||
Reference in New Issue
Block a user