diff --git a/docs/design/daemon-todo-stop-guard.md b/docs/design/daemon-todo-stop-guard.md new file mode 100644 index 00000000000..5d196bdec44 --- /dev/null +++ b/docs/design/daemon-todo-stop-guard.md @@ -0,0 +1,179 @@ +# Daemon Todo Stop Guard + +## Problem + +Daemon and ACP clients can keep a session alive after a model turn ends. When +the model has just written an unfinished top-level Todo list, a natural model +stop can leave the daemon request incomplete even though the session has enough +trusted state to continue. The client currently has no bounded, built-in way to +distinguish that case from an ordinary completed turn. + +This design adds an opt-in daemon-only stop guard. It deliberately does not +change the TUI, the Core Todo tool, or the general agent loop. + +## Configuration and safety boundary + +`experimental.todoStopGuard` defaults to `false`, requires a restart, and is +not shown in the TUI settings dialog. The guard is forced off in safe mode, +bare mode, and Approval `plan` mode. `disableAllHooks` does not disable the +built-in guard because it is not an external hook. + +Each uninterrupted automatic-continuation stage may create at most two extra +primary-model streams. A mid-turn user message explicitly starts a fresh +two-attempt stage because it is new user input, while retry/continue and +background results retain the current stage's budget. Existing permission +checks, cancellation, token limits, loop protection, ACP grace periods, and +daemon resource limits remain authoritative. In particular, a disconnected +client never implies permission approval. + +## Trusted state + +The CLI `Session` owns a small in-memory `DaemonTodoStopGuard` state machine. +It stores whether the current work chain is armed, the latest unfinished item +count, committed continuation attempts, suspension/queued-prompt state, and +whether exhaustion was already reported. The Session separately snapshots the +IDs of background agents, shells, monitors, and wakeups at the start of a work +chain, including terminal notifications and wakeups already queued at that +boundary. + +Only a successful top-level `TodoWriteTool.execute()` result with the structured +`{ type: 'todo_list', todos: [...] }` envelope can arm the guard. The observation +happens after tool execution and status calculation, before Session +`PostToolUse` hooks. Arguments, replayed history, disk state, failed or +duplicate tool calls, sub-agent Todo lists, and discovered tools that shadow +the `todo_write` wire name are not trusted. The newest successful result +replaces the count; an empty or fully completed list disarms the guard +immediately. Disarming prevents another natural-stop continuation; it does not +truncate a tool loop already opened by a committed Guard stream. + +A new ordinary user prompt starts an unarmed work chain and resets its +background baseline. It cannot inherit activation from an earlier request even +if Todo state remains in memory. Trusted retry/continue keeps the work chain +only while trusted unfinished Guard state still exists; after a trust-clearing +lifecycle event it starts with a fresh background baseline and must arm again. +A mid-turn user message keeps its activation and starts a fresh two-attempt +stage. This means the hard bound is two consecutive automatic streams without +new user input, not two streams across the entire lifetime of a work chain. +Cron and notification turns can establish their own chain through a successful +top-level Todo write; when they process background results for an armed chain, +they retain that chain's budget. A related background result is also a trusted +continuation that clears an API/network retry pause without clearing a hard +suspension. + +The guard is not persisted. Rewind and history restoration clear trust, as do +branch/fork, a successful working-directory change, a new Session, disk +restoration, and daemon or agent restart. A live client attach to the same +Session keeps the in-memory state; changing models or non-Plan approval modes +does not by itself start a new work chain. A lifecycle invalidation also blocks +late tool results from the superseded live turn from re-arming the guard; the +next independent prompt or automatic turn establishes a fresh boundary. +Deferred automatic queues are released once an invalidated foreground prompt +settles, including when that prompt exits through an error path. + +## Stop ordering + +The guard participates only at a natural model stop. When it is active, Session +applies this order: + +1. Drain mid-turn user messages. If any exist, skip Stop hooks and the guard, + reset the guard budget, and run the user continuation in the current loop. +2. If the daemon FIFO contains a complete, non-aborted prompt, finish the + current request and mark the old chain as awaiting that prompt. A cancelled + queued request cannot later let background activity revive the old chain. + When the last queued prompt is aborted, the bridge explicitly tells the + live Session to terminate the awaiting guard and release unrelated automatic + queues. If one drain observes both a mid-turn message and a queued full + prompt, the mid-turn message runs first and FIFO priority remains in force + even if that continuation completes the Todo list or hard-stops the guard. +3. On foreground turns, evaluate existing external Stop hooks with their + existing cap and error semantics. +4. Evaluate the guard only when it is armed, not suspended or awaiting a queued + prompt, has unfinished items, is outside Approval `plan`, and has no relevant + background input. +5. If both an external hook and the guard block the same stop, combine their + reasons into one continuation model call. Their counters remain independent. + +Relevant background input is a still-live background agent, shell, monitor, or +`@wakeup` whose ID was not in the work-chain baseline, plus queued notifications +or wakeups with the same relationship. Background work and ordinary cron jobs +inherited from an older request do not block a new request. Automatic +cron/notification turns run the built-in guard only; they do not introduce +external Stop-hook calls. A related result retains the current budget, while an +old-task notification or ordinary cron turn is delayed until the active chain +can no longer resume, then starts an independent unarmed chain. Deferred +unrelated recurring cron fires are coalesced per task and bounded so a stalled +background dependency cannot grow the queue without limit. Daemon follow-up +suggestions are also suppressed while a Guard chain can still resume or a +complete FIFO prompt has priority, so unfinished work does not trigger a +competing suggestion-model call. + +Hard terminal paths suspend the current work chain: user or permission +cancellation, `PostToolUse.shouldStop`, loop or repeated-call protection, token +limits, and the external Stop-hook cap. API and network errors preserve state +for an explicit trusted retry/continue. + +## Continuations and observability + +The first guard continuation sends: + +> [Todo Stop Guard] N todo item(s) are still pending or in progress. Continue executing the current task now. Do not ask the user whether to continue. If progress requires user input, use the structured question or permission flow. If progress depends on external state, report the blocker explicitly. + +The second also sends: + +> This is the final automatic continuation. Before ending, either complete/update the todos or report the completed progress and the exact blocker. + +The counter is committed only after `responseStream` is successfully returned. +Cancellation, compaction failure, or token rejection before that point does not +consume an attempt; a later stream failure does. Free-form blocker text is not +parsed. A compaction failure suspends that guard chain so it cannot leave +automatic queues blocked behind an unreachable retry; when an external Stop +hook was coalesced, its reason may still continue under the hook's existing +semantics. The budget counts every primary-model stream attributable to the +guard, including a follow-up that sends tool results from the preceding guard +stream. If the second stream returns more tool calls, Session executes and +preserves their results but does not open a third guard-attributable stream. +If the first stream completes every Todo through a tool call, the remaining +attempt may send the tool result without another unfinished-Todo prompt so the +model can finish its response. Mid-turn input sponsors that tool-result send +instead and takes priority without consuming the remaining Guard attempt. +When that stream was coalesced with an external Stop hook, the hook's existing +tool loop may still send those results without another Guard prompt or Guard +attempt; enabling the Guard must not truncate an external hook continuation. + +Each committed continuation emits a replayable discrete +`agent_message_chunk` with `_meta.source = 'todo_stop_guard'` and the attempt, +maximum attempt count, and unfinished count. Exhaustion similarly emits: + +> [Todo Stop Guard] Automatic continuation stopped after 2 attempts; N todo item(s) remain unfinished. + +Todo text is never included in guard telemetry. Normal usage metadata still +accounts for the additional calls. Replay compaction preserves Guard events +that carry both `qwenDiscreteMessage` and the Guard source independently, so it +does not merge attempts or discard their per-attempt metadata after the live +event ring rolls over. + +## Bridge compatibility + +`craft/drainMidTurnQueue` adds optional `hasQueuedPrompt`. The bridge sets it +only when its pending-prompt list contains a complete entry whose state is +`queued` and whose abort signal is not aborted. Older Desktop/channel clients +may omit the field; Session treats omission as `false`. If the drain times out, +late responses may restore message contents, but their queued-prompt snapshot is +discarded because it may already be stale. + +REST/SSE disconnect behavior and the event ring are unchanged. ACP HTTP retains +its existing ten-second grace period and replay path; grace expiry and explicit +close/cancel retain their current termination behavior. + +## Verification + +Unit tests cover strict activation, lifecycle resets, suspension, budget and +stream-commit semantics, bridge queue reporting, configuration gates, Stop-hook +coalescing, and terminal paths. Concurrency tests cover prompt FIFO priority, +late drain recovery, background-baseline isolation, and automatic turns. +Daemon E2E testing covers prompt admission without an SSE subscriber and later +ring replay of the bounded attempts. Existing ACP transport regressions cover +reconnect within the grace window, grace expiry, and permission round trips; +the manual E2E plan also exercises those paths with the guard armed. With the +setting disabled, existing Stop-hook, cron, notification, and prompt behavior +must remain unchanged. diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 8c0e5e1edb5..db7b6d49f8e 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -547,13 +547,14 @@ LSP server configuration is done through `.lsp.json` files in your project root > > **Experimental features.** These toggles gate in-development capabilities and may change or be removed in future releases. -| Setting | Type | Description | Default | -| -------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -| `experimental.cron` | boolean | Enable in-session cron/loop tools (`cron_create`, `cron_list`, `cron_delete`) so the model can create recurring prompts. Can be disabled via the `QWEN_CODE_DISABLE_CRON=1` environment variable. Requires restart. | `true` | -| `experimental.cronRecurringMaxAgeDays` | number | Days a recurring cron/loop job lives before auto-expiring (it fires one final time, then is deleted). Set to `0` to disable expiry so jobs run until deleted — useful for long-running daemon deployments. Can be overridden via the `QWEN_CODE_CRON_MAX_AGE_DAYS` environment variable. Requires restart. | `7` | -| `experimental.agentTeam` | boolean | Enable agent-team collaboration tools (`team_create`, `task_create`, `task_update`, `send_message`, etc.) for multi-agent coordination. Can also be enabled via `QWEN_CODE_ENABLE_AGENT_TEAM=1`. Requires restart. | `false` | -| `experimental.artifact` | boolean | Enable artifact tools. Enabled by default. In interactive, non-SDK sessions, the model can publish a self-contained HTML page as an interactive Artifact and open it in the browser. Non-SDK daemon sessions can use metadata-only `record_artifact`. Set this to `false` or use `QWEN_CODE_DISABLE_ARTIFACT=1` to disable both. Requires restart. | `true` | -| `experimental.emitToolUseSummaries` | boolean | Generate a short LLM-based label after each tool-call batch completes. See [Tool-Use Summaries](../features/tool-use-summaries). Requires a fast model to be configured (`fastModel`); silently skipped otherwise. Can be overridden per-session with `QWEN_CODE_EMIT_TOOL_USE_SUMMARIES=0` or `=1`. | `true` | +| Setting | Type | Description | Default | +| -------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | +| `experimental.cron` | boolean | Enable in-session cron/loop tools (`cron_create`, `cron_list`, `cron_delete`) so the model can create recurring prompts. Can be disabled via the `QWEN_CODE_DISABLE_CRON=1` environment variable. Requires restart. | `true` | +| `experimental.todoStopGuard` | boolean | Allow daemon and ACP sessions to continue after a natural model stop when the current work chain successfully wrote an unfinished top-level Todo list. Adds at most two consecutive primary-model calls without new user input; mid-turn user input starts a fresh two-attempt stage. It is not restored after process restart and is forced off in safe, bare, and Approval `plan` modes. Requires restart. | `false` | +| `experimental.cronRecurringMaxAgeDays` | number | Days a recurring cron/loop job lives before auto-expiring (it fires one final time, then is deleted). Set to `0` to disable expiry so jobs run until deleted — useful for long-running daemon deployments. Can be overridden via the `QWEN_CODE_CRON_MAX_AGE_DAYS` environment variable. Requires restart. | `7` | +| `experimental.agentTeam` | boolean | Enable agent-team collaboration tools (`team_create`, `task_create`, `task_update`, `send_message`, etc.) for multi-agent coordination. Can also be enabled via `QWEN_CODE_ENABLE_AGENT_TEAM=1`. Requires restart. | `false` | +| `experimental.artifact` | boolean | Enable artifact tools. Enabled by default. In interactive, non-SDK sessions, the model can publish a self-contained HTML page as an interactive Artifact and open it in the browser. Non-SDK daemon sessions can use metadata-only `record_artifact`. Set this to `false` or use `QWEN_CODE_DISABLE_ARTIFACT=1` to disable both. Requires restart. | `true` | +| `experimental.emitToolUseSummaries` | boolean | Generate a short LLM-based label after each tool-call batch completes. See [Tool-Use Summaries](../features/tool-use-summaries). Requires a fast model to be configured (`fastModel`); silently skipped otherwise. Can be overridden per-session with `QWEN_CODE_EMIT_TOOL_USE_SUMMARIES=0` or `=1`. | `true` | #### mcpServers diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index 2d6f77adb07..779087ef17d 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -277,6 +277,42 @@ curl -X POST http://127.0.0.1:4170/session/$SESSION_ID/prompt \ The `curl -N` from step 4 will print frames as they arrive. +### Optional Todo Stop Guard + +Long-running daemon clients can opt into a bounded continuation when the +current work chain successfully writes a top-level Todo list and then stops +with items still pending or in progress. Add this to `settings.json` and +restart the daemon: + +```json +{ + "experimental": { + "todoStopGuard": true + } +} +``` + +The guard adds at most two consecutive primary-model calls without new user +input. A mid-turn user message runs first and starts a fresh two-attempt stage; +retry/continue and related background results retain the current stage's +budget. Every call and the final exhaustion state appear as replayable +`session_update` events with `_meta.source: "todo_stop_guard"`; the metadata +includes the attempt and unfinished count but never Todo text. A queued full +prompt also runs first, and existing permission/cancellation rules are +unchanged. + +While an armed chain waits on related background work, unrelated cron/loop +fires and old-task notifications are deferred. Recurring work is bounded and +coalesced per task until the chain yields. + +The option defaults to `false`, requires restart, and is forced off in safe +mode, bare mode, and Approval `plan` mode. It is in-memory only: loading Todo +state from disk or restarting the daemon does not arm it. A new ordinary prompt +must successfully run its own top-level `todo_write`; retry/continue and live +client reattach keep the current in-memory work chain. Successfully changing +the session working directory clears it so an old Todo cannot resume in a new +workspace. + ## Authentication For anything beyond loopback, you **must** pass a bearer token: diff --git a/integration-tests/cli/qwen-serve-streaming.test.ts b/integration-tests/cli/qwen-serve-streaming.test.ts index bdf9dc483e3..a9ab2b2ce8f 100644 --- a/integration-tests/cli/qwen-serve-streaming.test.ts +++ b/integration-tests/cli/qwen-serve-streaming.test.ts @@ -21,10 +21,13 @@ * 3. SSE consumer disconnects after seeing N events; reconnect with * `Last-Event-ID: N` resumes the stream from id N+1 via the bus's * replay ring. + * 4. An admitted prompt keeps running with no SSE subscriber while the Todo + * Stop Guard performs its bounded continuations; a later subscriber + * replays each discrete status event. * */ import { spawn, execSync, type ChildProcess } from 'node:child_process'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -84,6 +87,27 @@ beforeAll(async () => { const hasToolResult = messages.includes('"role":"tool"') || messages.includes('"tool_call_id"'); + const guardMarker = messages.match(/todo-guard-e2e-\d+/g)?.at(-1); + if (guardMarker) { + const guardTodoId = `${guardMarker}-item`; + if (!messages.includes(guardTodoId)) { + return { + toolCalls: [ + fakeToolCall('todo_write', { + todos: [ + { + id: guardTodoId, + content: 'Keep this item unfinished for the guard test', + status: 'pending', + }, + ], + }), + ], + }; + } + return { content: 'The test Todo remains unfinished.' }; + } + if (pendingWritePath && messages.includes('fan-out') && !hasToolResult) { return { toolCalls: [ @@ -98,6 +122,15 @@ beforeAll(async () => { return { content: 'fake response complete' }; }); homeDir = mkdtempSync(path.join(tmpdir(), 'qwen-serve-streaming-home-')); + const qwenHome = path.join(homeDir, '.qwen'); + mkdirSync(qwenHome, { recursive: true }); + writeFileSync( + path.join(qwenHome, 'settings.json'), + JSON.stringify({ + experimental: { todoStopGuard: true }, + ui: { enableFollowupSuggestions: false }, + }), + ); daemon = spawn( process.execPath, [ @@ -457,3 +490,63 @@ describePOSIX('qwen serve — Last-Event-ID resume', () => { expect(resumedFirst!.id!).toBeGreaterThan(lastId); }, 60_000); }); + +describePOSIX('qwen serve — daemon Todo Stop Guard replay', () => { + it('continues after prompt admission without an SSE client and replays the bounded attempts', async () => { + const session = await client.createOrAttachSession({ + workspaceCwd: REPO_ROOT, + }); + const requestStart = fakeServer.requests.length; + const guardMarker = `todo-guard-e2e-${requestStart}`; + const accepted = await client.promptNonBlocking(session.sessionId, { + prompt: [{ type: 'text', text: guardMarker }], + }); + expect('promptId' in accepted).toBe(true); + if (!('promptId' in accepted)) return; + + await expect + .poll( + () => + fakeServer.requests + .slice(requestStart) + .filter((request) => + JSON.stringify(request.body['messages'] ?? []).includes( + guardMarker, + ), + ).length, + { timeout: 30_000 }, + ) + .toBe(4); + + const events: DaemonEvent[] = []; + const ac = new AbortController(); + for await (const event of sseFrames(session.sessionId, { + lastEventId: accepted.lastEventId, + signal: ac.signal, + })) { + events.push(event); + if (event.type === 'turn_complete') break; + } + ac.abort(); + + const guardUpdates = events.filter((event) => { + if (event.type !== 'session_update') return false; + const update = (event.data as { update?: Record }) + .update; + const meta = update?.['_meta'] as Record | undefined; + return meta?.['source'] === 'todo_stop_guard'; + }); + expect(guardUpdates).toHaveLength(3); + expect( + guardUpdates.map((event) => { + const update = (event.data as { update: Record }) + .update; + return (update['_meta'] as Record)['attempt']; + }), + ).toEqual([1, 2, 2]); + expect(events.some((event) => event.type === 'turn_complete')).toBe(true); + expect(JSON.stringify(guardUpdates)).not.toContain( + 'Keep this item unfinished for the guard test', + ); + }, 60_000); +}); diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index baf6948d28a..d48ebbf89ec 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -73,6 +73,10 @@ import { SESS_A, } from './internal/testUtils.js'; import { SessionArtifactAuthorizationError } from './sessionArtifacts.js'; +import { + MID_TURN_QUEUE_DRAIN_METHOD, + TODO_STOP_GUARD_QUEUE_RELEASE_METHOD, +} from './bridgeTypes.js'; function deferred(): { promise: Promise; @@ -6222,6 +6226,12 @@ describe('createAcpSessionBridge', () => { } return { stopReason: 'end_turn' } as PromptResponse; }, + extMethodImpl: async (method) => { + if (method === TODO_STOP_GUARD_QUEUE_RELEASE_METHOD) { + throw new Error('release failed'); + } + return {}; + }, }); const bridge = makeBridge({ channelFactory: async () => handle.channel, @@ -6259,9 +6269,36 @@ describe('createAcpSessionBridge', () => { const queuedId = pending[1]?.promptId; expect(queuedId).toBe('prompt-removed'); - const result = bridge.removePendingPrompt(session.sessionId, queuedId!); - expect(result.removed).toBe(true); - expect(bridge.getPendingPrompts(session.sessionId)).toHaveLength(1); + await expect( + handle.agentConnection.extMethod(MID_TURN_QUEUE_DRAIN_METHOD, { + sessionId: session.sessionId, + todoStopGuardWatchQueuedPrompt: true, + }), + ).resolves.toMatchObject({ hasQueuedPrompt: true }); + + const stderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + try { + const result = bridge.removePendingPrompt(session.sessionId, queuedId!); + expect(result.removed).toBe(true); + expect(bridge.getPendingPrompts(session.sessionId)).toHaveLength(1); + await vi.waitFor(() => { + expect(handle.agent.extMethodCalls).toContainEqual({ + method: TODO_STOP_GUARD_QUEUE_RELEASE_METHOD, + params: { sessionId: session.sessionId }, + }); + }); + await vi.waitFor(() => { + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Todo Stop Guard queued-prompt release failed', + ), + ); + }); + } finally { + stderrSpy.mockRestore(); + } const again = bridge.removePendingPrompt( session.sessionId, @@ -16057,13 +16094,16 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa 'craft/drainMidTurnQueue', { sessionId: session.sessionId }, ); - expect(drained).toEqual({ messages: ['m1', 'm2'] }); + expect(drained).toEqual({ + messages: ['m1', 'm2'], + hasQueuedPrompt: false, + }); // Spliced out, so the next batch's drain is empty. expect( await handle.agentConnection.extMethod('craft/drainMidTurnQueue', { sessionId: session.sessionId, }), - ).toEqual({ messages: [] }); + ).toEqual({ messages: [], hasQueuedPrompt: false }); release?.(); await prompt; @@ -16112,7 +16152,10 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa // line makes this return ['leftover'].) const t2 = send('t2'); await new Promise((r) => setTimeout(r, 10)); - expect(await drain()).toEqual({ messages: [] }); + expect(await drain()).toEqual({ + messages: [], + hasQueuedPrompt: false, + }); releases[1]!(); await t2; @@ -16155,6 +16198,11 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa const p1 = send('p1', 'prompt-1'); await new Promise((r) => setTimeout(r, 10)); const p2 = send('p2', 'prompt-2'); // queued behind p1 ⇒ pendingPromptCount = 2 + expect( + await handle.agentConnection.extMethod('craft/drainMidTurnQueue', { + sessionId: session.sessionId, + }), + ).toEqual({ messages: [], hasQueuedPrompt: true }); expect(bridge.enqueueMidTurnMessage(session.sessionId, 'x')).toEqual({ accepted: true, }); @@ -16166,7 +16214,7 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa await handle.agentConnection.extMethod('craft/drainMidTurnQueue', { sessionId: session.sessionId, }), - ).toEqual({ messages: ['x'] }); + ).toEqual({ messages: ['x'], hasQueuedPrompt: false }); expect((await injected).promptId).toBe('prompt-2'); releases[1]!(); @@ -16326,7 +16374,7 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa 'craft/drainMidTurnQueue', { sessionId: session.sessionId }, ); - expect(drained).toEqual({ messages: ['hi'] }); + expect(drained).toEqual({ messages: ['hi'], hasQueuedPrompt: false }); const it = iter[Symbol.asyncIterator](); const next = await it.next(); @@ -16405,7 +16453,10 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa 'craft/drainMidTurnQueue', { sessionId: session.sessionId }, ); - expect(drained).toEqual({ messages: ['hello'] }); + expect(drained).toEqual({ + messages: ['hello'], + hasQueuedPrompt: false, + }); release?.(); await prompt; diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 9f8e89ae892..62da2a81bff 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -90,6 +90,7 @@ import { LOAD_REPLAY_MODE_META_KEY, LOAD_REPLAY_PAGE_SIZE_META_KEY, LOAD_REPLAY_VERSION, + TODO_STOP_GUARD_QUEUE_RELEASE_METHOD, } from './bridgeTypes.js'; import type { BridgeSession, @@ -457,6 +458,8 @@ interface SessionEntry { * tail of `sendPrompt`. */ pendingPromptList: PendingPromptEntry[]; + /** Set only when the child Guard explicitly yielded to this FIFO. */ + todoStopGuardAwaitingQueuedPrompt?: boolean; /** * Mid-turn user messages pushed by the browser (`POST * /session/:id/mid-turn-message`) while a turn is running. The ACP child @@ -4721,6 +4724,30 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { }; entry.pendingPromptList.push(pendingEntry); if (isQueued) { + pendingAbort.signal.addEventListener( + 'abort', + () => { + if (pendingEntry.state !== 'queued') return; + if (!entry.todoStopGuardAwaitingQueuedPrompt) return; + const hasAnotherQueuedPrompt = entry.pendingPromptList.some( + (candidate) => + candidate !== pendingEntry && + candidate.state === 'queued' && + !candidate.abortController.signal.aborted, + ); + if (hasAnotherQueuedPrompt) return; + entry.todoStopGuardAwaitingQueuedPrompt = false; + void entry.connection + .extMethod(TODO_STOP_GUARD_QUEUE_RELEASE_METHOD, { sessionId }) + .catch((error) => { + writeStderrLine( + `qwen serve: Todo Stop Guard queued-prompt release failed for ` + + `${JSON.stringify(sessionId)}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + }, + { once: true }, + ); entry.events.publish({ type: 'pending_prompt_added', promptId: pendingEntry.promptId, @@ -4750,6 +4777,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // 'running' and publish a started event now that it has // reached the head of the FIFO. if (pendingEntry.state === 'queued') { + entry.todoStopGuardAwaitingQueuedPrompt = false; pendingEntry.state = 'running'; entry.events.publish({ type: 'pending_prompt_started', diff --git a/packages/acp-bridge/src/bridgeClient.test.ts b/packages/acp-bridge/src/bridgeClient.test.ts index f557569f565..1dc7c5a8780 100644 --- a/packages/acp-bridge/src/bridgeClient.test.ts +++ b/packages/acp-bridge/src/bridgeClient.test.ts @@ -57,6 +57,7 @@ import type { BridgeFileSystem } from './bridgeFileSystem.js'; import type { BridgePendingInteraction, MidTurnQueueEntry, + PendingPromptEntry, } from './bridgeTypes.js'; import type { ClientMcpMessageSender } from './bridgeOptions.js'; import { CancelSentinelCollisionError } from './bridgeErrors.js'; @@ -2273,13 +2274,18 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = | { sessionId: string; midTurnMessageQueue: MidTurnQueueEntry[]; + pendingPromptList?: PendingPromptEntry[]; events: { publish: ReturnType }; activePromptId?: string; } | undefined, ): BridgeClient { + const resolvedEntry = entry + ? { ...entry, pendingPromptList: entry.pendingPromptList ?? [] } + : undefined; return new BridgeClient( - ((sid: string) => (sid === sessionId ? entry : undefined)) as never, + ((sid: string) => + sid === sessionId ? resolvedEntry : undefined) as never, thrower as never, { request: thrower } as never, 0, @@ -2301,7 +2307,10 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = sessionId: 'sess:drain', }); - expect(result).toEqual({ messages: ['first', 'second'] }); + expect(result).toEqual({ + messages: ['first', 'second'], + hasQueuedPrompt: false, + }); // Queue emptied so the same messages can't be re-injected on the next batch. expect(entry.midTurnMessageQueue).toEqual([]); // Exactly one SSE frame carrying the drained text for the browser to dedupe. @@ -2337,7 +2346,10 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = const result = await client.extMethod('craft/drainMidTurnQueue', { sessionId: 'sess:multi', }); - expect(result).toEqual({ messages: ['a', 'b', 'c'] }); + expect(result).toEqual({ + messages: ['a', 'b', 'c'], + hasQueuedPrompt: false, + }); expect(entry.midTurnMessageQueue).toEqual([]); // One frame per originator: client-1 gets ['a','c'], client-2 gets ['b']. @@ -2381,7 +2393,10 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = }); // (a) the child still receives the message despite the dropped echo. - expect(result).toEqual({ messages: ['still-delivered'] }); + expect(result).toEqual({ + messages: ['still-delivered'], + hasQueuedPrompt: false, + }); expect(entry.midTurnMessageQueue).toEqual([]); // (b) the dropped-echo degradation is logged. const logged = stderr.mock.calls.map((c) => String(c[0])).join(''); @@ -2404,7 +2419,7 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = sessionId: 'sess:empty', }); - expect(result).toEqual({ messages: [] }); + expect(result).toEqual({ messages: [], hasQueuedPrompt: false }); expect(publish).not.toHaveBeenCalled(); }); @@ -2413,7 +2428,7 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = const result = await client.extMethod('craft/drainMidTurnQueue', { sessionId: 'sess:absent', }); - expect(result).toEqual({ messages: [] }); + expect(result).toEqual({ messages: [], hasQueuedPrompt: false }); }); it('short-circuits to an empty drain when no sessionId is supplied', async () => { @@ -2433,7 +2448,45 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = Infinity, ); const result = await client.extMethod('craft/drainMidTurnQueue', {}); - expect(result).toEqual({ messages: [] }); + expect(result).toEqual({ messages: [], hasQueuedPrompt: false }); + }); + + it('reports only complete, non-aborted queued prompts', async () => { + const publish = vi.fn().mockReturnValue(true); + const queued = { + promptId: 'queued', + queuedAt: Date.now(), + text: 'next', + state: 'queued' as const, + abortController: new AbortController(), + }; + const running = { + promptId: 'running', + queuedAt: Date.now(), + text: 'current', + state: 'running' as const, + abortController: new AbortController(), + }; + const entry = { + sessionId: 'sess:queued', + midTurnMessageQueue: [] as MidTurnQueueEntry[], + pendingPromptList: [running, queued], + events: { publish }, + }; + const client = makeClientWithEntry('sess:queued', entry); + + await expect( + client.extMethod('craft/drainMidTurnQueue', { + sessionId: 'sess:queued', + }), + ).resolves.toEqual({ messages: [], hasQueuedPrompt: true }); + + queued.abortController.abort(); + await expect( + client.extMethod('craft/drainMidTurnQueue', { + sessionId: 'sess:queued', + }), + ).resolves.toEqual({ messages: [], hasQueuedPrompt: false }); }); it('rejects an unknown ext-method with JSON-RPC methodNotFound (-32601)', async () => { diff --git a/packages/acp-bridge/src/bridgeClient.ts b/packages/acp-bridge/src/bridgeClient.ts index ee83a01310e..9bba08a8256 100644 --- a/packages/acp-bridge/src/bridgeClient.ts +++ b/packages/acp-bridge/src/bridgeClient.ts @@ -29,6 +29,7 @@ import type { BridgeGenerationNotificationEvent, BridgePendingInteraction, MidTurnQueueEntry, + PendingPromptEntry, } from './bridgeTypes.js'; import { SERVE_CONTROL_EXT_METHODS } from './status.js'; import type { @@ -464,6 +465,10 @@ export interface BridgeClientSessionEntry { * `extMethod` can splice it. See `SessionEntry.midTurnMessageQueue`. */ midTurnMessageQueue: MidTurnQueueEntry[]; + /** Complete prompts waiting behind the currently running prompt. */ + pendingPromptList: PendingPromptEntry[]; + /** The child reported that its Todo Stop Guard yielded to the FIFO. */ + todoStopGuardAwaitingQueuedPrompt?: boolean; /** True while a prompt is executing for this session. */ promptActive?: boolean; /** Admitted id for the prompt currently executing on this session. */ @@ -1021,11 +1026,18 @@ export class BridgeClient implements Client { // The drain always carries a sessionId; without one we can't route it on a // multi-session channel (and `resolveEntry(undefined)` would throw there), // so answer with an empty drain rather than poisoning the turn. - if (!sessionId) return { messages: [] }; + if (!sessionId) return { messages: [], hasQueuedPrompt: false }; const entry = this.resolveEntry(sessionId); - if (!entry) return { messages: [] }; + if (!entry) return { messages: [], hasQueuedPrompt: false }; const drained = entry.midTurnMessageQueue.splice(0); const messages = drained.map((item) => item.text); + const hasQueuedPrompt = entry.pendingPromptList.some( + (prompt) => + prompt.state === 'queued' && !prompt.abortController.signal.aborted, + ); + if (params['todoStopGuardWatchQueuedPrompt'] === true) { + entry.todoStopGuardAwaitingQueuedPrompt = hasQueuedPrompt; + } if (drained.length > 0) { // `publish()` never throws — it returns `undefined` on a closed bus (see // EventBus.publish's never-throws contract: "Don't add try/catch wrappers @@ -1059,7 +1071,7 @@ export class BridgeClient implements Client { ); } } - return { messages }; + return { messages, hasQueuedPrompt }; } /** diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 284745a3e31..bdeb2292044 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -449,10 +449,21 @@ export interface BridgeHeartbeatState { * silently desync them into a runtime `-32601 methodNotFound` (which would * latch the drain off for the session). The desktop ACP client answers the same * method from its own in-memory queue; in `qwen serve` the daemon answers it - * from `SessionEntry.midTurnMessageQueue`. + * from `SessionEntry.midTurnMessageQueue`. Responses may also carry + * `hasQueuedPrompt` so an armed daemon Todo guard yields to complete FIFO + * prompts; older clients can omit it. */ export const MID_TURN_QUEUE_DRAIN_METHOD = 'craft/drainMidTurnQueue'; +/** + * Parent-to-agent request reporting that the daemon FIFO no longer contains the + * complete prompt an active Todo Stop Guard yielded to. The child clears the + * old guard instead of letting background work revive it or leaving unrelated + * automatic turns blocked forever. + */ +export const TODO_STOP_GUARD_QUEUE_RELEASE_METHOD = + 'craft/todoStopGuardQueueReleased'; + /** * Reverse tool channel marker (issue #5626, Phase 2). The parent serve process * stamps this boolean on a client-hosted (extension) MCP server's diff --git a/packages/acp-bridge/src/compactionEngine.test.ts b/packages/acp-bridge/src/compactionEngine.test.ts index 23674fb7429..2acf2e22044 100644 --- a/packages/acp-bridge/src/compactionEngine.test.ts +++ b/packages/acp-bridge/src/compactionEngine.test.ts @@ -23,6 +23,21 @@ function makeTextChunk(id: number, text: string): BridgeEvent { }; } +function makeDiscreteTextChunk( + id: number, + text: string, + attempt: number, +): BridgeEvent { + const event = makeTextChunk(id, text); + (event.data as { update: Record }).update['_meta'] = { + source: 'todo_stop_guard', + qwenDiscreteMessage: true, + attempt, + maxAttempts: 2, + }; + return event; +} + function makeThoughtChunk(id: number, text: string): BridgeEvent { return { id, @@ -228,6 +243,41 @@ describe('TurnBoundaryCompactionEngine', () => { expect(data.update.content.text).toBe('Let me think...'); }); + it('preserves discrete agent messages and their metadata', () => { + const engine = new TurnBoundaryCompactionEngine(); + engine.ingest(makeTextChunk(1, 'Before')); + engine.ingest(makeDiscreteTextChunk(2, 'Guard one', 1)); + engine.ingest(makeDiscreteTextChunk(3, 'Guard two', 2)); + engine.ingest(makeDiscreteTextChunk(4, 'Guard exhausted', 2)); + engine.ingest(makeTextChunk(5, 'After')); + engine.ingest(makeTurnComplete(6)); + + const events = engine.snapshot().compactedTurns; + expect(extractTexts(events)).toEqual([ + 'Before', + 'Guard one', + 'Guard two', + 'Guard exhausted', + 'After', + ]); + const guardEvents = events.filter((event) => { + const data = event.data as { + update?: { _meta?: Record }; + }; + return data.update?._meta?.['source'] === 'todo_stop_guard'; + }); + expect(guardEvents).toHaveLength(3); + expect( + guardEvents.map((event) => { + const data = event.data as { + update: { _meta: Record }; + }; + return data.update._meta['attempt']; + }), + ).toEqual([1, 2, 2]); + expect(guardEvents.map((event) => event.id)).toEqual([2, 3, 4]); + }); + it('keeps user messages as-is', () => { const engine = new TurnBoundaryCompactionEngine(); engine.ingest(makeUserMessage(1, 'How are you?')); diff --git a/packages/acp-bridge/src/compactionEngine.ts b/packages/acp-bridge/src/compactionEngine.ts index 583e50e90ed..cb652fd808e 100644 --- a/packages/acp-bridge/src/compactionEngine.ts +++ b/packages/acp-bridge/src/compactionEngine.ts @@ -235,6 +235,10 @@ export class TurnBoundaryCompactionEngine implements CompactionEngine { switch (updateType) { case 'agent_message_chunk': { + if (hasTodoStopGuardDiscreteMeta(data?.update?._meta)) { + this.slots.push({ kind: 'misc', event }); + break; + } this.mergeTextSlot('text', event, data); break; } @@ -545,6 +549,15 @@ function extractParentToolCallIdFromMeta(meta: unknown): string | undefined { return undefined; } +function hasTodoStopGuardDiscreteMeta(meta: unknown): boolean { + return ( + typeof meta === 'object' && + meta !== null && + (meta as Record)['qwenDiscreteMessage'] === true && + (meta as Record)['source'] === 'todo_stop_guard' + ); +} + function mergeToolCallEvent( existing: BridgeEvent, incoming: BridgeEvent, diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 0ecf3d96067..b4bd40a4c61 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -752,6 +752,7 @@ import { registerAcpEventLoopLagGauge, SESSION_ARTIFACT_PERSISTENCE_VERSION, mcpServerRequiresOAuth, + APPROVAL_MODES, } from '@qwen-code/qwen-code-core'; import type { McpServer } from '@agentclientprotocol/sdk'; import { AgentSideConnection } from '@agentclientprotocol/sdk'; @@ -768,6 +769,7 @@ import { SERVE_STATUS_EXT_METHODS, SERVE_CONTROL_EXT_METHODS, } from '@qwen-code/acp-bridge/status'; +import { TODO_STOP_GUARD_QUEUE_RELEASE_METHOD } from '@qwen-code/acp-bridge/bridgeTypes'; import type { ServeWorkspaceSkillsStatus } from '@qwen-code/acp-bridge/status'; import { updateOutputLanguageFile, @@ -1349,6 +1351,8 @@ describe('QwenAgent MCP SSE/HTTP support', () => { restoreHistory: ReturnType; rewindToTurn: ReturnType; getRewindableUserTurnCount: ReturnType; + clearTodoStopGuardTrust: ReturnType; + releaseTodoStopGuardQueuedPromptWait: ReturnType; } | undefined; let processExitSpy: MockInstance; @@ -2132,6 +2136,8 @@ describe('QwenAgent MCP SSE/HTTP support', () => { .fn() .mockReturnValue({ targetTurnIndex: 1, apiTruncateIndex: 2 }), getRewindableUserTurnCount: vi.fn().mockReturnValue(1), + clearTodoStopGuardTrust: vi.fn(), + releaseTodoStopGuardQueuedPromptWait: vi.fn().mockReturnValue(true), }; lastSessionMock = sessionMock; return sessionMock as unknown as InstanceType; @@ -2216,6 +2222,23 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('releases a Todo Stop Guard that yielded to a cancelled FIFO prompt', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + await setupSessionMocks(sessionId); + const { agent, agentPromise } = await bootAcpAgent(); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + await expect( + agent.extMethod(TODO_STOP_GUARD_QUEUE_RELEASE_METHOD, { sessionId }), + ).resolves.toEqual({ released: true }); + expect( + lastSessionMock?.releaseTodoStopGuardQueuedPromptWait, + ).toHaveBeenCalledOnce(); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('reconnects an MCP server in every live non-pooled runtime', async () => { const server = { command: 'node', args: ['server.js'] }; const workspaceDiscover = vi.fn().mockResolvedValue(undefined); @@ -2348,6 +2371,73 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('clears Todo Stop Guard trust when approval mode enters plan', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + let approvalMode = 'default'; + Object.assign(innerConfig, { + getApprovalMode: vi.fn(() => approvalMode), + setApprovalMode: vi.fn((mode: string) => { + approvalMode = mode; + }), + }); + const { agent, agentPromise } = await bootAcpAgent(); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const approvalModes = APPROVAL_MODES as unknown as string[]; + approvalModes.push('default', 'plan'); + try { + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionApprovalMode, { + sessionId, + mode: 'plan', + }), + ).resolves.toEqual({ previous: 'default', current: 'plan' }); + expect(lastSessionMock?.clearTodoStopGuardTrust).toHaveBeenCalledOnce(); + } finally { + approvalModes.splice(0); + } + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('clears Todo Stop Guard trust after a successful working-directory change', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const targetDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-todo-guard-cwd-'), + ); + const canonicalTargetDir = await fs.realpath(targetDir); + const innerConfig = await setupSessionMocks(sessionId); + Object.assign(innerConfig, { + getTargetDir: vi.fn().mockReturnValue('/tmp'), + isRestrictiveSandbox: vi.fn().mockReturnValue(false), + relocateWorkingDirectory: vi.fn().mockResolvedValue({}), + }); + Object.assign(innerConfig.getGeminiClient(), { + addWorkingDirectoryChangedContext: vi.fn().mockResolvedValue(undefined), + }); + const { agent, agentPromise } = await bootAcpAgent(); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + try { + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionCd, { + sessionId, + path: targetDir, + }), + ).resolves.toMatchObject({ + previousCwd: '/tmp', + newCwd: canonicalTargetDir, + }); + expect(lastSessionMock?.clearTodoStopGuardTrust).toHaveBeenCalledOnce(); + } finally { + await fs.rm(targetDir, { recursive: true, force: true }); + } + + mockConnectionState.resolve(); + await agentPromise; + }); + it('sessionArtifactsPersist rejects a missing session id', async () => { const { agent, agentPromise } = await bootAcpAgent(); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index af0dc7dcd04..e4aa5c876eb 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -289,6 +289,7 @@ import { LOAD_REPLAY_MODE_META_KEY, LOAD_REPLAY_PAGE_SIZE_META_KEY, LOAD_REPLAY_VERSION, + TODO_STOP_GUARD_QUEUE_RELEASE_METHOD, type ClientMcpOverWsRuntimeConfig, type BridgeLoadReplayEnvelope, } from '@qwen-code/acp-bridge/bridgeTypes'; @@ -5986,6 +5987,25 @@ class QwenAgent implements Agent { const SESSION_ID_RE = /^[0-9a-fA-F-]{32,36}$/; switch (method) { + case TODO_STOP_GUARD_QUEUE_RELEASE_METHOD: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + const session = this.sessions.get(sessionId); + if (!session) { + throw RequestError.invalidParams( + undefined, + `Session not found for id: ${sessionId}`, + ); + } + return { + released: session.releaseTodoStopGuardQueuedPromptWait(), + }; + } case 'qwen/providers/list': { return { providers: ALL_PROVIDERS.map((provider) => @@ -7496,6 +7516,8 @@ class QwenAgent implements Agent { ); } + session.clearTodoStopGuardTrust(); + return { previousCwd, newCwd: canonicalPath, warnings }; } case SERVE_CONTROL_EXT_METHODS.sessionApprovalMode: { @@ -7536,6 +7558,9 @@ class QwenAgent implements Agent { throw err; } const current = config.getApprovalMode(); + if (current === 'plan') { + session.clearTodoStopGuardTrust(); + } return { previous, current }; } case SERVE_CONTROL_EXT_METHODS.sessionLanguage: { @@ -9039,6 +9064,9 @@ class QwenAgent implements Agent { ) { try { config.setApprovalMode(newMode as ApprovalMode); + if (newMode === 'plan') { + session.clearTodoStopGuardTrust(); + } } catch (err) { debugLogger.warn( `reload: setApprovalMode failed for session ${id}: ${err}`, diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index cc4e7c449e5..e6936b2218f 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -248,6 +248,14 @@ function createPreparationResponse( return response; } +function createFailingStream(message: string, beforeThrow?: () => void) { + return (async function* () { + beforeThrow?.(); + yield* []; + throw new Error(message); + })(); +} + function expectCompressBeforeSend( compressMock: ReturnType, sendMock: ReturnType, @@ -297,12 +305,15 @@ describe('Session', () => { let mockBackgroundTaskRegistry: { setNotificationCallback: ReturnType; hasUnfinalizedTasks: ReturnType; + getAll: ReturnType; }; let mockMonitorRegistry: { setNotificationCallback: ReturnType; + getAll: ReturnType; }; let mockBackgroundShellRegistry: { setNotificationCallback: ReturnType; + getAll: ReturnType; }; let mockToolRegistry: { getTool: ReturnType; @@ -437,12 +448,15 @@ describe('Session', () => { mockBackgroundTaskRegistry = { setNotificationCallback: vi.fn(), hasUnfinalizedTasks: vi.fn().mockReturnValue(false), + getAll: vi.fn().mockReturnValue([]), }; mockMonitorRegistry = { setNotificationCallback: vi.fn(), + getAll: vi.fn().mockReturnValue([]), }; mockBackgroundShellRegistry = { setNotificationCallback: vi.fn(), + getAll: vi.fn().mockReturnValue([]), }; mockChatRecordingService = { @@ -1025,6 +1039,53 @@ describe('Session', () => { expect(mockChat.truncateHistory).toHaveBeenCalledWith(4); }); + it('does not count Todo Stop Guard continuations as user turns', () => { + const guardPrompt = + '[Todo Stop Guard] 1 todo item(s) are still pending or in progress. Continue executing the current task now. Do not ask the user whether to continue. If progress requires user input, use the structured question or permission flow. If progress depends on external state, report the blocker explicitly.'; + const history: Content[] = [ + { role: 'user', parts: [{ text: 'first' }] }, + { role: 'model', parts: [{ text: 'first reply' }] }, + { role: 'user', parts: [{ text: guardPrompt }] }, + { role: 'model', parts: [{ text: 'guard reply 1' }] }, + { + role: 'user', + parts: [ + { + text: `${guardPrompt} This is the final automatic continuation. Before ending, either complete/update the todos or report the completed progress and the exact blocker.`, + }, + ], + }, + { role: 'model', parts: [{ text: 'guard reply 2' }] }, + { role: 'user', parts: [{ text: 'second' }] }, + { role: 'model', parts: [{ text: 'second reply' }] }, + ]; + vi.mocked(mockChat.getHistory).mockReturnValue(history); + vi.mocked(mockChat.getHistoryShallow).mockReturnValue(history); + + expect(session.getRewindableUserTurnCount()).toBe(2); + expect(session.rewindToTurn(1)).toEqual({ + targetTurnIndex: 1, + apiTruncateIndex: 6, + }); + expect(mockChat.truncateHistory).toHaveBeenCalledWith(6); + }); + + it('counts user text that only resembles a Todo Stop Guard prompt', () => { + const history: Content[] = [ + { + role: 'user', + parts: [ + { + text: '[Todo Stop Guard] 1 todo item(s) are still pending or in progress. Continue executing the current task now. Do not ask the user whether to continue. This is quoted user text.', + }, + ], + }, + ]; + vi.mocked(mockChat.getHistoryShallow).mockReturnValue(history); + + expect(session.getRewindableUserTurnCount()).toBe(1); + }); + it('rejects unreachable user turns', () => { const history: Content[] = [{ role: 'user', parts: [{ text: 'first' }] }]; vi.mocked(mockChat.getHistory).mockReturnValue(history); @@ -14395,6 +14456,4359 @@ describe('Session', () => { }); }); + describe('daemon Todo Stop Guard', () => { + const pendingTodos = [ + { id: 'task-1', content: 'finish task', status: 'pending' as const }, + ]; + + function rebuildSessionWithGuard( + options: { + safe?: boolean; + bare?: boolean; + plan?: boolean; + disableHooks?: boolean; + } = {}, + ) { + session.dispose(); + (mockSettings as unknown as { merged: Record }).merged = + { experimental: { todoStopGuard: true } }; + mockConfig.getBareMode = vi.fn().mockReturnValue(options.bare ?? false); + mockConfig.isSafeMode = vi.fn().mockReturnValue(options.safe ?? false); + mockConfig.getApprovalMode = vi + .fn() + .mockReturnValue( + options.plan ? ApprovalMode.PLAN : ApprovalMode.DEFAULT, + ); + mockConfig.getDisableAllHooks = vi + .fn() + .mockReturnValue(options.disableHooks ?? false); + session = new Session( + 'test-session-id', + mockConfig, + mockClient, + mockSettings, + ); + } + + function installPendingTodoTool(options: { trusted?: boolean } = {}) { + const execute = vi.fn().mockResolvedValue({ + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }); + mockToolRegistry.getTool.mockReturnValue({ + constructor: { + name: options.trusted === false ? 'DiscoveredTool' : 'TodoWriteTool', + }, + name: core.ToolNames.TODO_WRITE, + kind: options.trusted === false ? core.Kind.Other : core.Kind.Think, + displayName: 'TodoWrite', + description: 'Write todos', + build: vi.fn().mockImplementation((args) => ({ + params: args, + execute, + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + getDescription: vi.fn().mockReturnValue('Write todos'), + toolLocations: vi.fn().mockReturnValue([]), + })), + canUpdateOutput: false, + isOutputMarkdown: true, + }); + return execute; + } + + function queuePendingTodoThenNaturalStops() { + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-1', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValue(createEmptyStream()); + } + + async function runGuardPrompt() { + return session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'finish everything' }], + }); + } + + function createDeferredAbortStream() { + let markStarted!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + let rejectStream!: (reason?: unknown) => void; + const gate = new Promise((_resolve, reject) => { + rejectStream = reject; + }); + async function* stream() { + markStarted(); + yield await gate; + } + return { + responseStream: stream(), + started, + abort() { + const error = new Error('aborted'); + error.name = 'AbortError'; + rejectStream(error); + }, + }; + } + + it('is off by default', async () => { + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.some( + ([params]) => + params.update.sessionUpdate === 'agent_message_chunk' && + params.update._meta?.['source'] === 'todo_stop_guard', + ), + ).toBe(false); + }); + + it('preserves feature-off Stop hook loop reporting before token rejection', async () => { + mockConfig.getSessionTokenLimit = vi.fn().mockReturnValue(100); + mockConfig.getStopHookBlockingCap = vi.fn().mockReturnValue(3); + mockGeminiClient.tryCompressChat.mockResolvedValue({ + originalTokenCount: 50, + newTokenCount: 50, + compressionStatus: core.CompressionStatus.NOOP, + }); + const highUsageStream = () => + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + usageMetadata: { + totalTokenCount: 101, + promptTokenCount: 101, + }, + }, + }, + ]); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce(highUsageStream()) + .mockResolvedValueOnce(highUsageStream()); + const messageBus = { + request: vi.fn().mockImplementation(async (request) => ({ + success: true, + output: + request.eventName === 'Stop' + ? { + decision: 'block', + reason: 'feature-off hook continuation', + } + : {}, + })), + }; + mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus); + mockConfig.hasHooksForEvent = vi + .fn() + .mockImplementation((name: string) => name === 'Stop'); + + await expect(runGuardPrompt()).resolves.toEqual({ + stopReason: 'max_tokens', + }); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update._meta?.['stopHookLoop']) + .filter((meta) => meta !== undefined), + ).toContainEqual( + expect.objectContaining({ + iterationCount: 2, + reasons: [ + 'feature-off hook continuation', + 'feature-off hook continuation', + ], + }), + ); + }); + + it('preserves the feature-off Stop-loop result when cancellation arrives before it starts', async () => { + let enterWait!: () => void; + const waitStarted = new Promise((resolve) => { + enterWait = resolve; + }); + let releaseWait!: () => void; + const waitGate = new Promise((resolve) => { + releaseWait = resolve; + }); + session.messageRewriter = { + interceptUpdate: vi.fn().mockResolvedValue(undefined), + waitForPendingRewrites: vi.fn(async () => { + enterWait(); + await waitGate; + }), + } as unknown as NonNullable; + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + + const prompt = runGuardPrompt(); + await waitStarted; + await session.cancelPendingPrompt(); + releaseWait(); + + await expect(prompt).resolves.toEqual({ stopReason: 'end_turn' }); + }); + + it('runs exactly two continuations and emits replayable status', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(4); + const guardUpdates = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update) + .filter( + (update) => + update.sessionUpdate === 'agent_message_chunk' && + update._meta?.['source'] === 'todo_stop_guard', + ); + expect(guardUpdates).toHaveLength(3); + expect(guardUpdates.map((update) => update._meta?.['attempt'])).toEqual([ + 1, 2, 2, + ]); + expect(guardUpdates[0]?._meta).toMatchObject({ + qwenDiscreteMessage: true, + maxAttempts: 2, + unfinishedCount: 1, + }); + expect( + guardUpdates.every( + (update) => + update.content.type !== 'text' || + !update.content.text.includes('finish task'), + ), + ).toBe(true); + + const firstContinuation = vi.mocked(mockChat.sendMessageStream).mock + .calls[2]?.[1] as { message: Part[] }; + const finalContinuation = vi.mocked(mockChat.sendMessageStream).mock + .calls[3]?.[1] as { message: Part[] }; + expect(textParts(firstContinuation.message).join('\n')).toContain( + 'Do not ask the user whether to continue.', + ); + expect(textParts(finalContinuation.message).join('\n')).toContain( + 'This is the final automatic continuation.', + ); + }); + + it('does not arm from Todo arguments when the result is not structured', async () => { + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + execute.mockResolvedValue({ + llmContent: 'Todo updated', + returnDisplay: 'Todo updated', + }); + queuePendingTodoThenNaturalStops(); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + }); + + it('does not arm from a discovered tool that shadows todo_write', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool({ trusted: false }); + queuePendingTodoThenNaturalStops(); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.some( + ([params]) => + params.update.sessionUpdate === 'agent_message_chunk' && + params.update._meta?.['source'] === 'todo_stop_guard', + ), + ).toBe(false); + }); + + it('counts a started stream that fails and resumes only the final attempt on trusted retry', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + const failedGuardStream = createFailingStream('guard stream failed'); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-before-stream-error', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce(failedGuardStream) + .mockResolvedValue(createEmptyStream()); + + await expect(runGuardPrompt()).rejects.toThrow('guard stream failed'); + const firstGuardUpdates = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update) + .filter( + (update) => + update.sessionUpdate === 'agent_message_chunk' && + update._meta?.['source'] === 'todo_stop_guard', + ); + expect( + firstGuardUpdates.map((update) => update._meta?.['attempt']), + ).toEqual([1]); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'retry the failed stream' }], + _meta: { 'qwen.daemon.retry': true }, + } as Parameters[0]); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(5); + const allGuardAttempts = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update) + .filter( + (update) => + update.sessionUpdate === 'agent_message_chunk' && + update._meta?.['source'] === 'todo_stop_guard', + ) + .map((update) => update._meta?.['attempt']); + expect(allGuardAttempts).toEqual([1, 2, 2]); + }); + + it('resumes an API-paused chain for its related background result', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + const failedGuardStream = createFailingStream( + 'guard stream failed after background result queued', + () => { + callback('background done', '', { + agentId: 'related-after-api-error', + status: 'completed', + }); + }, + ); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-before-related-result', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce(failedGuardStream) + .mockResolvedValue(createEmptyStream()); + + await expect(runGuardPrompt()).rejects.toThrow( + 'guard stream failed after background result queued', + ); + + await vi.waitFor(() => { + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(5); + }); + const guardAttempts = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update) + .filter( + (update) => + update.sessionUpdate === 'agent_message_chunk' && + update._meta?.['source'] === 'todo_stop_guard', + ) + .map((update) => update._meta?.['attempt']); + expect(guardAttempts).toEqual([1, 2, 2]); + }); + + it('does not change error-time queue draining before the Guard is armed', async () => { + rebuildSessionWithGuard(); + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + const failedStream = createFailingStream('unarmed stream failed', () => { + callback('background done', '', { + agentId: 'unrelated-after-unarmed-error', + status: 'completed', + }); + }); + mockChat.sendMessageStream = vi.fn().mockResolvedValue(failedStream); + + await expect(runGuardPrompt()).rejects.toThrow('unarmed stream failed'); + + const internals = session as unknown as { + notificationProcessing: boolean; + notificationQueue: Array<{ taskId: string }>; + }; + expect(internals.notificationProcessing).toBe(false); + expect(internals.notificationQueue).toEqual([ + expect.objectContaining({ taskId: 'unrelated-after-unarmed-error' }), + ]); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(1); + }); + + it('clears a failed guard chain when a new ordinary prompt starts', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + const failedGuardStream = createFailingStream('guard stream failed'); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-before-new-prompt', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce(failedGuardStream) + .mockResolvedValue(createEmptyStream()); + + await expect(runGuardPrompt()).rejects.toThrow('guard stream failed'); + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'unrelated ordinary prompt' }], + }); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(4); + const guardUpdates = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update) + .filter( + (update) => + update.sessionUpdate === 'agent_message_chunk' && + update._meta?.['source'] === 'todo_stop_guard', + ); + expect(guardUpdates.map((update) => update._meta?.['attempt'])).toEqual([ + 1, + ]); + }); + + it('recaptures the background baseline when retry has no trusted Guard state', async () => { + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + execute.mockImplementation(async () => { + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'task-from-cleared-chain', + isBackgrounded: true, + status: 'running', + notified: false, + }, + ]); + return { + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }; + }); + queuePendingTodoThenNaturalStops(); + + await runGuardPrompt(); + session.clearTodoStopGuardTrust(); + queuePendingTodoThenNaturalStops(); + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'trusted retry after trust clear' }], + _meta: { 'qwen.daemon.retry': true }, + } as Parameters[0]); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(4); + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update) + .filter( + (update) => + update.sessionUpdate === 'agent_message_chunk' && + update._meta?.['source'] === 'todo_stop_guard', + ) + .map((update) => update._meta?.['attempt']), + ).toEqual([1, 2, 2]); + }); + + it.each([ + ['safe mode', { safe: true }], + ['bare mode', { bare: true }], + ['Approval plan mode', { plan: true }], + ])('is forced off in %s', async (_label, options) => { + rebuildSessionWithGuard(options); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + }); + + it('clears stale FIFO priority when a Plan prompt begins', async () => { + rebuildSessionWithGuard({ plan: true }); + const internals = session as unknown as { + todoStopGuardQueuedPromptPriority: boolean; + }; + internals.todoStopGuardQueuedPromptPriority = true; + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + + await runGuardPrompt(); + + expect(internals.todoStopGuardQueuedPromptPriority).toBe(false); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(1); + }); + + it('keeps observed FIFO priority when an out-of-band mode change clears Guard trust', async () => { + rebuildSessionWithGuard(); + const internals = session as unknown as { + todoStopGuardQueuedPromptPriority: boolean; + }; + internals.todoStopGuardQueuedPromptPriority = true; + + await session.setMode({ + sessionId: 'test-session-id', + modeId: 'plan', + }); + + expect(internals.todoStopGuardQueuedPromptPriority).toBe(true); + expect(session.releaseTodoStopGuardQueuedPromptWait()).toBe(true); + expect(internals.todoStopGuardQueuedPromptPriority).toBe(false); + }); + + it('keeps FIFO priority when Guard trust clears during queue inspection', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + let queueInspectionStarted!: () => void; + const queueInspectionStart = new Promise((resolve) => { + queueInspectionStarted = resolve; + }); + let resolveQueueInspection!: (value: { + messages: never[]; + hasQueuedPrompt: boolean; + }) => void; + vi.mocked(mockClient.extMethod) + .mockResolvedValueOnce({ messages: [], hasQueuedPrompt: false }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveQueueInspection = resolve; + queueInspectionStarted(); + }), + ); + + const prompt = runGuardPrompt(); + await queueInspectionStart; + await session.setMode({ + sessionId: 'test-session-id', + modeId: 'plan', + }); + resolveQueueInspection({ messages: [], hasQueuedPrompt: true }); + await prompt; + + const internals = session as unknown as { + todoStopGuardQueuedPromptPriority: boolean; + }; + expect(internals.todoStopGuardQueuedPromptPriority).toBe(true); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + expect(session.releaseTodoStopGuardQueuedPromptWait()).toBe(true); + }); + + it('does not let a late Todo write re-arm after Guard trust clears as its stream starts', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + let guardSendStarted!: () => void; + const guardSendStart = new Promise((resolve) => { + guardSendStarted = resolve; + }); + let resolveGuardStream!: ( + stream: ReturnType, + ) => void; + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'arm-before-trust-clear', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveGuardStream = resolve; + guardSendStarted(); + }), + ) + .mockImplementation(async () => createEmptyStream()); + + const prompt = runGuardPrompt(); + await guardSendStart; + session.clearTodoStopGuardTrust(); + resolveGuardStream( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'tool-after-trust-clear', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ); + await prompt; + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(3); + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update) + .filter( + (update) => + update.sessionUpdate === 'agent_message_chunk' && + update._meta?.['source'] === 'todo_stop_guard', + ) + .map((update) => update._meta?.['attempt']), + ).toEqual([]); + }); + + it('drains deferred automatic work after an active Guard is invalidated and the prompt errors', async () => { + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'old-before-invalidation-error', + isBackgrounded: true, + status: 'running', + notified: false, + }, + ]); + rebuildSessionWithGuard(); + installPendingTodoTool(); + let guardSendStarted!: () => void; + const guardSendStart = new Promise((resolve) => { + guardSendStarted = resolve; + }); + let resolveGuardStream!: ( + stream: ReturnType, + ) => void; + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-before-invalidation-error', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveGuardStream = resolve; + guardSendStarted(); + }), + ) + .mockResolvedValue(createEmptyStream()); + + const prompt = runGuardPrompt(); + await guardSendStart; + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'old-before-invalidation-error', + isBackgrounded: true, + status: 'completed', + notified: true, + }, + ]); + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + callback('old background done', '', { + agentId: 'old-before-invalidation-error', + status: 'completed', + }); + session.clearTodoStopGuardTrust(); + resolveGuardStream( + createFailingStream('guard failed after invalidation'), + ); + + await expect(prompt).rejects.toThrow('guard failed after invalidation'); + await vi.waitFor(() => { + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(4); + }); + const automaticCall = vi.mocked(mockChat.sendMessageStream).mock + .calls[3]?.[1] as { message: Part[] }; + expect(textParts(automaticCall.message).join('\n')).toContain( + '', + ); + }); + + it('still runs when external hooks are disabled', async () => { + rebuildSessionWithGuard({ disableHooks: true }); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(4); + }); + + it('does not consume or revive the guard when token limits block the continuation stream', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + mockConfig.getSessionTokenLimit = vi.fn().mockReturnValue(100); + mockGeminiClient.tryCompressChat + .mockResolvedValueOnce({ + originalTokenCount: 50, + newTokenCount: 50, + compressionStatus: core.CompressionStatus.NOOP, + }) + .mockResolvedValueOnce({ + originalTokenCount: 50, + newTokenCount: 50, + compressionStatus: core.CompressionStatus.NOOP, + }) + .mockResolvedValueOnce({ + originalTokenCount: 101, + newTokenCount: 101, + compressionStatus: core.CompressionStatus.NOOP, + }); + + await expect(runGuardPrompt()).resolves.toEqual({ + stopReason: 'max_tokens', + }); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.some( + ([params]) => + params.update.sessionUpdate === 'agent_message_chunk' && + params.update._meta?.['source'] === 'todo_stop_guard', + ), + ).toBe(false); + + mockConfig.getSessionTokenLimit = vi.fn().mockReturnValue(0); + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + callback('background done', '', { + agentId: 'new-after-token-limit', + status: 'completed', + }); + await vi.waitFor(() => { + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(3); + }); + }); + + it('revalidates plan mode after continuation compression', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + let compressionStarted!: () => void; + const compressionStartedPromise = new Promise((resolve) => { + compressionStarted = resolve; + }); + let releaseCompression!: () => void; + const compressionGate = new Promise((resolve) => { + releaseCompression = resolve; + }); + const noCompression = { + originalTokenCount: 50, + newTokenCount: 50, + compressionStatus: core.CompressionStatus.NOOP, + }; + mockGeminiClient.tryCompressChat + .mockResolvedValueOnce(noCompression) + .mockResolvedValueOnce(noCompression) + .mockImplementationOnce(async () => { + compressionStarted(); + await compressionGate; + return noCompression; + }); + + const prompt = runGuardPrompt(); + await compressionStartedPromise; + await session.setMode({ + sessionId: 'test-session-id', + modeId: 'plan', + }); + releaseCompression(); + await prompt; + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.some( + ([params]) => + params.update.sessionUpdate === 'agent_message_chunk' && + params.update._meta?.['source'] === 'todo_stop_guard', + ), + ).toBe(false); + }); + + it('rechecks queued prompts after continuation compression', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + vi.mocked(mockClient.extMethod).mockResolvedValue({ + messages: [], + hasQueuedPrompt: false, + }); + let compressionStarted!: () => void; + const compressionStartedPromise = new Promise((resolve) => { + compressionStarted = resolve; + }); + let releaseCompression!: () => void; + const compressionGate = new Promise((resolve) => { + releaseCompression = resolve; + }); + const noCompression = { + originalTokenCount: 50, + newTokenCount: 50, + compressionStatus: core.CompressionStatus.NOOP, + }; + mockGeminiClient.tryCompressChat + .mockResolvedValueOnce(noCompression) + .mockResolvedValueOnce(noCompression) + .mockImplementationOnce(async () => { + compressionStarted(); + await compressionGate; + return noCompression; + }); + + const prompt = runGuardPrompt(); + await compressionStartedPromise; + vi.mocked(mockClient.extMethod).mockResolvedValue({ + messages: [], + hasQueuedPrompt: true, + }); + releaseCompression(); + await prompt; + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.some( + ([params]) => + params.update.sessionUpdate === 'agent_message_chunk' && + params.update._meta?.['source'] === 'todo_stop_guard', + ), + ).toBe(false); + }); + + it('does not persist an unsent Guard prompt when compression is cancelled', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + let compressionStarted!: () => void; + const compressionStartedPromise = new Promise((resolve) => { + compressionStarted = resolve; + }); + let releaseCompression!: () => void; + const compressionGate = new Promise((resolve) => { + releaseCompression = resolve; + }); + const noCompression = { + originalTokenCount: 50, + newTokenCount: 50, + compressionStatus: core.CompressionStatus.NOOP, + }; + mockGeminiClient.tryCompressChat + .mockResolvedValueOnce(noCompression) + .mockResolvedValueOnce(noCompression) + .mockImplementationOnce(async () => { + compressionStarted(); + await compressionGate; + return noCompression; + }); + + const prompt = runGuardPrompt(); + await compressionStartedPromise; + const addHistory = vi.mocked(mockChat.addHistory); + addHistory.mockClear(); + await session.cancelPendingPrompt(); + releaseCompression(); + await prompt; + + const preservedText = addHistory.mock.calls + .flatMap(([content]) => content.parts ?? []) + .flatMap((part) => ('text' in part && part.text ? [part.text] : [])) + .join('\n'); + expect(preservedText).not.toContain('[Todo Stop Guard]'); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + }); + + it('does not count a failed Guard compression or block later automatic work', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + const noCompression = { + originalTokenCount: 50, + newTokenCount: 50, + compressionStatus: core.CompressionStatus.NOOP, + }; + mockGeminiClient.tryCompressChat + .mockResolvedValueOnce(noCompression) + .mockResolvedValueOnce(noCompression) + .mockResolvedValueOnce({ + originalTokenCount: 120, + newTokenCount: 120, + compressionStatus: + core.CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY, + }) + .mockResolvedValue(noCompression); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.some( + ([params]) => + params.update.sessionUpdate === 'agent_message_chunk' && + params.update._meta?.['source'] === 'todo_stop_guard', + ), + ).toBe(false); + + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + callback('independent background done', '', { + agentId: 'after-guard-compression-failure', + status: 'completed', + }); + + await vi.waitFor(() => { + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(3); + }); + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.some( + ([params]) => + params.update.sessionUpdate === 'agent_message_chunk' && + params.update._meta?.['source'] === 'todo_stop_guard', + ), + ).toBe(false); + }); + + it('keeps external Stop hook continuation when Guard compression throws', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + const noCompression = { + originalTokenCount: 50, + newTokenCount: 50, + compressionStatus: core.CompressionStatus.NOOP, + }; + mockGeminiClient.tryCompressChat + .mockResolvedValueOnce(noCompression) + .mockResolvedValueOnce(noCompression) + .mockRejectedValueOnce(new Error('compression unavailable')) + .mockResolvedValue(noCompression); + let stopCalls = 0; + const messageBus = { + request: vi.fn().mockImplementation(async (request) => { + if (request.eventName !== 'Stop') { + return { success: true, output: {} }; + } + stopCalls++; + return stopCalls === 1 + ? { + success: true, + output: { + decision: 'block', + reason: 'external hook still continues', + }, + } + : { success: true, output: {} }; + }), + }; + mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus); + mockConfig.hasHooksForEvent = vi + .fn() + .mockImplementation((name: string) => name === 'Stop'); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(3); + const externalOnly = vi.mocked(mockChat.sendMessageStream).mock + .calls[2]?.[1] as { message: Part[] }; + expect(textParts(externalOnly.message).join('\n')).toContain( + 'external hook still continues', + ); + expect(textParts(externalOnly.message).join('\n')).not.toContain( + '[Todo Stop Guard]', + ); + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.some( + ([params]) => + params.update.sessionUpdate === 'agent_message_chunk' && + params.update._meta?.['source'] === 'todo_stop_guard', + ), + ).toBe(false); + }); + + it.each([ + { + label: 'mid-turn input', + priorityResponse: { + messages: ['user input queued during failed compression'], + hasQueuedPrompt: false, + }, + expectedCalls: 5, + expectedGuardAttempts: [1, 2, 2], + }, + { + label: 'a complete FIFO prompt', + priorityResponse: { messages: [], hasQueuedPrompt: true }, + expectedCalls: 2, + expectedGuardAttempts: [], + }, + ])( + 'keeps $label ahead of an external hook when Guard compression fails', + async ({ priorityResponse, expectedCalls, expectedGuardAttempts }) => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + const noCompression = { + originalTokenCount: 50, + newTokenCount: 50, + compressionStatus: core.CompressionStatus.NOOP, + }; + mockGeminiClient.tryCompressChat + .mockResolvedValueOnce(noCompression) + .mockResolvedValueOnce(noCompression) + .mockResolvedValueOnce({ + originalTokenCount: 120, + newTokenCount: 120, + compressionStatus: + core.CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY, + }) + .mockResolvedValue(noCompression); + vi.mocked(mockClient.extMethod) + .mockResolvedValueOnce({ messages: [], hasQueuedPrompt: false }) + .mockResolvedValueOnce({ messages: [], hasQueuedPrompt: false }) + .mockResolvedValueOnce({ messages: [], hasQueuedPrompt: false }) + .mockResolvedValueOnce(priorityResponse) + .mockResolvedValue({ messages: [], hasQueuedPrompt: false }); + let stopCalls = 0; + const messageBus = { + request: vi.fn().mockImplementation(async (request) => { + if (request.eventName !== 'Stop') { + return { success: true, output: {} }; + } + stopCalls++; + return stopCalls === 1 + ? { + success: true, + output: { + decision: 'block', + reason: 'external hook must yield', + }, + } + : { success: true, output: {} }; + }), + }; + mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus); + mockConfig.hasHooksForEvent = vi + .fn() + .mockImplementation((name: string) => name === 'Stop'); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(expectedCalls); + if (priorityResponse.messages.length > 0) { + const userCall = vi.mocked(mockChat.sendMessageStream).mock + .calls[2]?.[1] as { message: Part[] }; + const text = textParts(userCall.message).join('\n'); + expect(text).toContain('user input queued during failed compression'); + expect(text).not.toContain('external hook must yield'); + expect(text).not.toContain('[Todo Stop Guard]'); + } + const guardAttempts = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update) + .filter( + (update) => + update.sessionUpdate === 'agent_message_chunk' && + update._meta?.['source'] === 'todo_stop_guard', + ) + .map((update) => update._meta?.['attempt']); + expect(guardAttempts).toEqual(expectedGuardAttempts); + }, + ); + + it('counts every Guard-attributable tool follow-up as a model call', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + const todoTool = mockToolRegistry.getTool(core.ToolNames.TODO_WRITE); + const readExecute = vi.fn().mockResolvedValue({ + llmContent: 'file contents', + returnDisplay: 'file contents', + }); + const readTool = { + name: 'read_file', + kind: core.Kind.Read, + build: vi.fn().mockReturnValue({ + params: { path: '/tmp/test.txt' }, + execute: readExecute, + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + getDescription: vi.fn().mockReturnValue('Read file'), + toolLocations: vi.fn().mockReturnValue([]), + }), + }; + mockToolRegistry.getTool.mockImplementation((name: string) => + name === core.ToolNames.TODO_WRITE ? todoTool : readTool, + ); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-before-guard-tools', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'guard-read-1', + name: 'read_file', + args: { path: '/tmp/test.txt' }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'guard-read-2', + name: 'read_file', + args: { path: '/tmp/test.txt' }, + }, + ], + }, + }, + ]), + ); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(4); + expect(readExecute).toHaveBeenCalledTimes(2); + const secondGuardCall = vi.mocked(mockChat.sendMessageStream).mock + .calls[3]?.[1] as { message: Part[] }; + expect(textParts(secondGuardCall.message).join('\n')).toContain( + 'This is the final automatic continuation.', + ); + const guardAttempts = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update) + .filter( + (update) => + update.sessionUpdate === 'agent_message_chunk' && + update._meta?.['source'] === 'todo_stop_guard', + ) + .map((update) => update._meta?.['attempt']); + expect(guardAttempts).toEqual([1, 2, 2]); + }); + + it('closes Guard tools with the remaining attempt after Todo completion', async () => { + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + const completedTodos = pendingTodos.map((todo) => ({ + ...todo, + status: 'completed' as const, + })); + execute + .mockResolvedValueOnce({ + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }) + .mockResolvedValueOnce({ + llmContent: JSON.stringify(completedTodos), + returnDisplay: { + type: 'todo_list', + todos: completedTodos, + changes: {}, + }, + }); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-before-guard-completion', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-completed-by-guard', + name: core.ToolNames.TODO_WRITE, + args: { todos: completedTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()); + + await runGuardPrompt(); + + expect(execute).toHaveBeenCalledTimes(2); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(4); + const toolClosure = vi.mocked(mockChat.sendMessageStream).mock + .calls[3]?.[1] as { + message: Part[]; + }; + expect( + toolClosure.message.some( + (part) => + 'functionResponse' in part && + part.functionResponse?.id === 'todo-completed-by-guard', + ), + ).toBe(true); + expect(textParts(toolClosure.message).join('\n')).not.toContain( + '[Todo Stop Guard]', + ); + const guardUpdates = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update) + .filter( + (update) => + update.sessionUpdate === 'agent_message_chunk' && + update._meta?.['source'] === 'todo_stop_guard', + ); + expect(guardUpdates.map((update) => update._meta?.['attempt'])).toEqual([ + 1, 2, + ]); + expect(guardUpdates.at(-1)?._meta?.['unfinishedCount']).toBe(0); + }); + + it('drains background input when completed-Todo tool closure fails', async () => { + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + const completedTodos = pendingTodos.map((todo) => ({ + ...todo, + status: 'completed' as const, + })); + execute + .mockResolvedValueOnce({ + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }) + .mockResolvedValueOnce({ + llmContent: JSON.stringify(completedTodos), + returnDisplay: { + type: 'todo_list', + todos: completedTodos, + changes: {}, + }, + }); + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-before-failed-closure', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-completed-before-failed-closure', + name: core.ToolNames.TODO_WRITE, + args: { todos: completedTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce( + createFailingStream('completed Todo closure failed', () => { + callback('background done', '', { + agentId: 'after-failed-tool-closure', + status: 'completed', + }); + }), + ) + .mockResolvedValue(createEmptyStream()); + + await expect(runGuardPrompt()).rejects.toThrow( + 'completed Todo closure failed', + ); + await vi.waitFor(() => { + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(5); + }); + const notificationCall = vi.mocked(mockChat.sendMessageStream).mock + .calls[4]?.[1] as { message: Part[] }; + expect(textParts(notificationCall.message).join('\n')).toContain( + '', + ); + }); + + it('lets mid-turn input sponsor the tool response after Todo completion', async () => { + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + const completedTodos = pendingTodos.map((todo) => ({ + ...todo, + status: 'completed' as const, + })); + execute.mockResolvedValueOnce({ + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }); + execute.mockResolvedValueOnce({ + llmContent: JSON.stringify(completedTodos), + returnDisplay: { + type: 'todo_list', + todos: completedTodos, + changes: {}, + }, + }); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-before-completed-midturn', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-completed-before-midturn', + name: core.ToolNames.TODO_WRITE, + args: { todos: completedTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()); + vi.mocked(mockClient.extMethod).mockImplementation(async () => ({ + messages: + vi.mocked(mockChat.sendMessageStream).mock.calls.length === 3 + ? ['user direction after completion'] + : [], + hasQueuedPrompt: false, + })); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(4); + const userContinuation = vi.mocked(mockChat.sendMessageStream).mock + .calls[3]?.[1] as { message: Part[] }; + expect(textParts(userContinuation.message).join('\n')).toContain( + 'user direction after completion', + ); + expect(textParts(userContinuation.message).join('\n')).not.toContain( + '[Todo Stop Guard]', + ); + const guardAttempts = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update) + .filter( + (update) => + update.sessionUpdate === 'agent_message_chunk' && + update._meta?.['source'] === 'todo_stop_guard', + ) + .map((update) => update._meta?.['attempt']); + expect(guardAttempts).toEqual([1]); + }); + + it('does not close completed Todo tools after PostToolUse stops the chain', async () => { + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + const completedTodos = pendingTodos.map((todo) => ({ + ...todo, + status: 'completed' as const, + })); + execute + .mockResolvedValueOnce({ + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }) + .mockResolvedValueOnce({ + llmContent: JSON.stringify(completedTodos), + returnDisplay: { + type: 'todo_list', + todos: completedTodos, + changes: {}, + }, + }); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-before-post-hook-stop', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-completed-before-post-hook-stop', + name: core.ToolNames.TODO_WRITE, + args: { todos: completedTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValue(createEmptyStream()); + let postToolUseCalls = 0; + const messageBus = { + request: vi.fn().mockImplementation(async (request) => { + if (request.eventName !== 'PostToolUse') { + return { success: true, output: {} }; + } + postToolUseCalls++; + return postToolUseCalls === 2 + ? { + success: true, + output: { + continue: false, + reason: 'stop after completed Todo', + }, + } + : { success: true, output: {} }; + }), + }; + mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus); + mockConfig.hasHooksForEvent = vi + .fn() + .mockImplementation((name: string) => name === 'PostToolUse'); + + await runGuardPrompt(); + + expect(execute).toHaveBeenCalledTimes(2); + expect(postToolUseCalls).toBe(2); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(3); + const guardAttempts = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update) + .filter( + (update) => + update.sessionUpdate === 'agent_message_chunk' && + update._meta?.['source'] === 'todo_stop_guard', + ) + .map((update) => update._meta?.['attempt']); + expect(guardAttempts).toEqual([1]); + }); + + it('lets a queued prompt preempt completed Todo tool closure', async () => { + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + const completedTodos = pendingTodos.map((todo) => ({ + ...todo, + status: 'completed' as const, + })); + execute + .mockResolvedValueOnce({ + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }) + .mockResolvedValueOnce({ + llmContent: JSON.stringify(completedTodos), + returnDisplay: { + type: 'todo_list', + todos: completedTodos, + changes: {}, + }, + }); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-before-queued-tool-closure', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-completed-before-queued-tool-closure', + name: core.ToolNames.TODO_WRITE, + args: { todos: completedTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValue(createEmptyStream()); + vi.mocked(mockClient.extMethod).mockImplementation(async () => ({ + messages: [], + hasQueuedPrompt: + vi.mocked(mockChat.sendMessageStream).mock.calls.length === 3, + })); + + await runGuardPrompt(); + + expect(execute).toHaveBeenCalledTimes(2); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(3); + expect(session.releaseTodoStopGuardQueuedPromptWait()).toBe(true); + }); + + it('keeps tool responses when mid-turn input supersedes a nested Guard call', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + const todoTool = mockToolRegistry.getTool(core.ToolNames.TODO_WRITE); + const readTool = { + name: 'read_file', + kind: core.Kind.Read, + build: vi.fn().mockReturnValue({ + params: { path: '/tmp/test.txt' }, + execute: vi.fn().mockResolvedValue({ + llmContent: 'file contents', + returnDisplay: 'file contents', + }), + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + getDescription: vi.fn().mockReturnValue('Read file'), + toolLocations: vi.fn().mockReturnValue([]), + }), + }; + mockToolRegistry.getTool.mockImplementation((name: string) => + name === core.ToolNames.TODO_WRITE ? todoTool : readTool, + ); + vi.mocked(mockClient.extMethod) + .mockResolvedValueOnce({ messages: [], hasQueuedPrompt: false }) + .mockResolvedValueOnce({ messages: [], hasQueuedPrompt: false }) + .mockResolvedValueOnce({ messages: [], hasQueuedPrompt: false }) + .mockResolvedValueOnce({ messages: [], hasQueuedPrompt: false }) + .mockResolvedValueOnce({ + messages: ['user input before the nested Guard send'], + hasQueuedPrompt: false, + }) + .mockResolvedValue({ messages: [], hasQueuedPrompt: false }); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-before-nested-midturn', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'read-before-nested-midturn', + name: 'read_file', + args: { path: '/tmp/test.txt' }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValue(createEmptyStream()); + + await runGuardPrompt(); + + const nestedUserCall = vi.mocked(mockChat.sendMessageStream).mock + .calls[3]?.[1] as { message: Part[] }; + expect( + nestedUserCall.message.some((part) => 'functionResponse' in part), + ).toBe(true); + expect(textParts(nestedUserCall.message).join('\n')).toContain( + 'user input before the nested Guard send', + ); + expect(textParts(nestedUserCall.message).join('\n')).not.toContain( + 'This is the final automatic continuation.', + ); + }); + + it('keeps the external Stop hook count when nested Guard work yields to the user', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + const todoTool = mockToolRegistry.getTool(core.ToolNames.TODO_WRITE); + const readTool = { + name: 'read_file', + kind: core.Kind.Read, + build: vi.fn().mockReturnValue({ + params: { path: '/tmp/test.txt' }, + execute: vi.fn().mockResolvedValue({ + llmContent: 'file contents', + returnDisplay: 'file contents', + }), + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + getDescription: vi.fn().mockReturnValue('Read file'), + toolLocations: vi.fn().mockReturnValue([]), + }), + }; + mockToolRegistry.getTool.mockImplementation((name: string) => + name === core.ToolNames.TODO_WRITE ? todoTool : readTool, + ); + vi.mocked(mockClient.extMethod) + .mockResolvedValueOnce({ messages: [], hasQueuedPrompt: false }) + .mockResolvedValueOnce({ messages: [], hasQueuedPrompt: false }) + .mockResolvedValueOnce({ messages: [], hasQueuedPrompt: false }) + .mockResolvedValueOnce({ messages: [], hasQueuedPrompt: false }) + .mockResolvedValueOnce({ + messages: ['user input before the nested combined send'], + hasQueuedPrompt: false, + }) + .mockResolvedValue({ messages: [], hasQueuedPrompt: false }); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-before-nested-combined-midturn', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'read-before-nested-combined-midturn', + name: 'read_file', + args: { path: '/tmp/test.txt' }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValue(createEmptyStream()); + let stopCalls = 0; + const messageBus = { + request: vi.fn().mockImplementation(async (request) => { + if (request.eventName !== 'Stop') { + return { success: true, output: {} }; + } + stopCalls++; + return stopCalls <= 2 + ? { + success: true, + output: { + decision: 'block', + reason: `hook continuation ${stopCalls}`, + }, + } + : { success: true, output: {} }; + }), + }; + mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus); + mockConfig.hasHooksForEvent = vi + .fn() + .mockImplementation((name: string) => name === 'Stop'); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(6); + const stopHookLoops = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update._meta?.['stopHookLoop']) + .filter((meta) => meta !== undefined); + expect(stopHookLoops).toContainEqual( + expect.objectContaining({ + iterationCount: 2, + reasons: ['hook continuation 1', 'hook continuation 2'], + }), + ); + }); + + it('does not let mid-turn input revive a PostToolUse-stopped guard', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + vi.mocked(mockClient.extMethod).mockResolvedValue({ + messages: ['new direction after the stopped tool'], + hasQueuedPrompt: false, + }); + const messageBus = { + request: vi.fn().mockImplementation(async (request) => ({ + success: true, + output: + request.eventName === 'PostToolUse' + ? { continue: false, reason: 'stop after Todo' } + : { continue: true }, + })), + }; + mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus); + mockConfig.hasHooksForEvent = vi + .fn() + .mockImplementation((name: string) => name === 'PostToolUse'); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + const postToolContinuation = vi.mocked(mockChat.sendMessageStream).mock + .calls[1]?.[1] as { message: Part[] }; + expect(textParts(postToolContinuation.message).join('\n')).toContain( + 'new direction after the stopped tool', + ); + expect(messageBus.request).toHaveBeenCalledWith( + expect.objectContaining({ eventName: 'PostToolUse' }), + expect.anything(), + ); + }); + + it('does not revive a deferred chain after entering and leaving plan mode', async () => { + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + execute.mockImplementation(async () => { + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'plan-boundary-agent', + isBackgrounded: true, + status: 'running', + notified: false, + }, + ]); + return { + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }; + }); + queuePendingTodoThenNaturalStops(); + + await runGuardPrompt(); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + + await session.setMode({ + sessionId: 'test-session-id', + modeId: 'plan', + }); + await session.setMode({ + sessionId: 'test-session-id', + modeId: 'default', + }); + + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'plan-boundary-agent', + isBackgrounded: true, + status: 'completed', + notified: true, + }, + ]); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + callback('background done', '', { + agentId: 'plan-boundary-agent', + status: 'completed', + }); + + await vi.waitFor(() => { + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(1); + }); + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.some( + ([params]) => + params.update.sessionUpdate === 'agent_message_chunk' && + params.update._meta?.['source'] === 'todo_stop_guard', + ), + ).toBe(false); + }); + + it('yields to a queued complete prompt without consuming an attempt', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + vi.mocked(mockChat.getHistory).mockReturnValue([ + { role: 'model', parts: [{ text: 'unfinished' }] }, + ]); + vi.mocked(core.generatePromptSuggestion).mockClear(); + vi.mocked(mockClient.extMethod).mockResolvedValue({ + messages: [], + hasQueuedPrompt: true, + }); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.some( + ([params]) => + params.update.sessionUpdate === 'agent_message_chunk' && + params.update._meta?.['source'] === 'todo_stop_guard', + ), + ).toBe(false); + expect(core.generatePromptSuggestion).not.toHaveBeenCalled(); + }); + + it('releases FIFO priority when the queued prompt is a trusted retry', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + vi.mocked(mockClient.extMethod).mockResolvedValue({ + messages: [], + hasQueuedPrompt: true, + }); + + await runGuardPrompt(); + vi.mocked(mockClient.extMethod).mockResolvedValue({ + messages: [], + hasQueuedPrompt: false, + }); + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'trusted queued retry' }], + _meta: { 'qwen.daemon.retry': true }, + } as Parameters[0]); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(5); + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update._meta?.['attempt']) + .filter((attempt) => attempt !== undefined), + ).toEqual([1, 2, 2]); + }); + + it('keeps a queued prompt ahead of related automatic input', async () => { + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + execute.mockImplementation(async () => { + callback('background done', '', { + agentId: 'related-before-queued-prompt', + status: 'completed', + }); + return { + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }; + }); + queuePendingTodoThenNaturalStops(); + vi.mocked(mockClient.extMethod).mockResolvedValue({ + messages: [], + hasQueuedPrompt: true, + }); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + const queuedPrompt = session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'queued user prompt wins' }], + }); + await queuedPrompt; + await vi.waitFor(() => { + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(4); + }); + const firstAfterYield = vi.mocked(mockChat.sendMessageStream).mock + .calls[2]?.[1] as { message: Part[] }; + const secondAfterYield = vi.mocked(mockChat.sendMessageStream).mock + .calls[3]?.[1] as { message: Part[] }; + expect(textParts(firstAfterYield.message).join('\n')).toContain( + 'queued user prompt wins', + ); + expect(textParts(secondAfterYield.message).join('\n')).toContain( + '', + ); + }); + + it('retains observed FIFO priority after mid-turn input completes the Todo', async () => { + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + execute + .mockImplementationOnce(async () => { + callback('background done', '', { + agentId: 'related-after-mid-turn', + status: 'completed', + }); + return { + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }; + }) + .mockResolvedValueOnce({ + llmContent: 'completed', + returnDisplay: { + type: 'todo_list', + todos: [ + { + id: 'task-1', + content: 'finish task', + status: 'completed', + }, + ], + changes: {}, + }, + }); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'pending-before-combined-priority', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'complete-after-mid-turn', + name: core.ToolNames.TODO_WRITE, + args: { + todos: [ + { + id: 'task-1', + content: 'finish task', + status: 'completed', + }, + ], + }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValue(createEmptyStream()); + vi.mocked(mockClient.extMethod) + .mockResolvedValueOnce({ messages: [], hasQueuedPrompt: false }) + .mockResolvedValueOnce({ + messages: ['mid-turn direction before queued prompt'], + hasQueuedPrompt: true, + }) + .mockResolvedValue({ messages: [], hasQueuedPrompt: false }); + const messageBus = { + request: vi.fn().mockImplementation(async (request) => ({ + success: true, + output: + request.eventName === 'Stop' + ? { decision: 'block', reason: 'must not run before FIFO' } + : {}, + })), + }; + mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus); + mockConfig.hasHooksForEvent = vi + .fn() + .mockImplementation((name: string) => name === 'Stop'); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(4); + expect( + messageBus.request.mock.calls.some( + ([request]) => request.eventName === 'Stop', + ), + ).toBe(false); + const midTurnCall = vi.mocked(mockChat.sendMessageStream).mock + .calls[2]?.[1] as { message: Part[] }; + expect(textParts(midTurnCall.message).join('\n')).toContain( + 'mid-turn direction before queued prompt', + ); + mockConfig.hasHooksForEvent = vi.fn().mockReturnValue(false); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'observed FIFO prompt' }], + }); + await vi.waitFor(() => { + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(6); + }); + const fifoCall = vi.mocked(mockChat.sendMessageStream).mock + .calls[4]?.[1] as { + message: Part[]; + }; + const notificationCall = vi.mocked(mockChat.sendMessageStream).mock + .calls[5]?.[1] as { message: Part[] }; + expect(textParts(fifoCall.message).join('\n')).toContain( + 'observed FIFO prompt', + ); + expect(textParts(notificationCall.message).join('\n')).toContain( + '', + ); + }); + + it('terminates a yielded guard when the queued prompt is cancelled', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + vi.mocked(mockClient.extMethod).mockResolvedValue({ + messages: [], + hasQueuedPrompt: true, + }); + + await runGuardPrompt(); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + expect(session.releaseTodoStopGuardQueuedPromptWait()).toBe(true); + + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-after-queued-prompt-cancel', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValue(createEmptyStream()); + callback('background done', '', { + agentId: 'after-queued-prompt-cancel', + status: 'completed', + }); + + await vi.waitFor(() => { + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + }); + expect(session.releaseTodoStopGuardQueuedPromptWait()).toBe(false); + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.some( + ([params]) => + params.update.sessionUpdate === 'agent_message_chunk' && + params.update._meta?.['source'] === 'todo_stop_guard', + ), + ).toBe(false); + }); + + it('drains old deferred work when the FIFO prompt errors before arming', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + vi.mocked(mockClient.extMethod).mockResolvedValue({ + messages: [], + hasQueuedPrompt: true, + }); + + await runGuardPrompt(); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + callback('background done', '', { + agentId: 'deferred-before-fifo-error', + status: 'completed', + }); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce(createFailingStream('FIFO prompt failed')) + .mockResolvedValue(createEmptyStream()); + + await expect( + session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'queued FIFO work' }], + }), + ).rejects.toThrow('FIFO prompt failed'); + await vi.waitFor(() => { + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + }); + const notificationCall = vi.mocked(mockChat.sendMessageStream).mock + .calls[1]?.[1] as { message: Part[] }; + expect(textParts(notificationCall.message).join('\n')).toContain( + '', + ); + }); + + it('drains deferred work when FIFO cancellation precedes a mid-turn error', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-before-midturn-fifo-cancel', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce( + createFailingStream('mid-turn continuation failed', () => { + callback('background done', '', { + agentId: 'after-midturn-fifo-cancel', + status: 'completed', + }); + expect(session.releaseTodoStopGuardQueuedPromptWait()).toBe(true); + }), + ) + .mockResolvedValue(createEmptyStream()); + vi.mocked(mockClient.extMethod) + .mockResolvedValueOnce({ messages: [], hasQueuedPrompt: false }) + .mockResolvedValueOnce({ + messages: ['mid-turn before queued prompt cancellation'], + hasQueuedPrompt: true, + }) + .mockResolvedValue({ messages: [], hasQueuedPrompt: false }); + + await expect(runGuardPrompt()).rejects.toThrow( + 'mid-turn continuation failed', + ); + await vi.waitFor(() => { + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(4); + }); + const notificationCall = vi.mocked(mockChat.sendMessageStream).mock + .calls[3]?.[1] as { message: Part[] }; + expect(textParts(notificationCall.message).join('\n')).toContain( + '', + ); + }); + + it('does not re-arm when queue cancellation races the drain response', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + vi.mocked(mockClient.extMethod) + .mockResolvedValueOnce({ messages: [], hasQueuedPrompt: false }) + .mockImplementationOnce(async () => { + expect(session.releaseTodoStopGuardQueuedPromptWait()).toBe(true); + return { messages: [], hasQueuedPrompt: true }; + }) + .mockResolvedValue({ messages: [], hasQueuedPrompt: false }); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + expect(session.releaseTodoStopGuardQueuedPromptWait()).toBe(false); + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + callback('background done', '', { + agentId: 'after-racing-queue-cancel', + status: 'completed', + }); + await vi.waitFor(() => { + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(3); + }); + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.some( + ([params]) => + params.update.sessionUpdate === 'agent_message_chunk' && + params.update._meta?.['source'] === 'todo_stop_guard', + ), + ).toBe(false); + }); + + it('runs mid-turn user input first and resets the continuation budget', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + vi.mocked(mockClient.extMethod) + .mockResolvedValueOnce({ messages: [], hasQueuedPrompt: false }) + .mockResolvedValueOnce({ messages: [], hasQueuedPrompt: false }) + .mockResolvedValueOnce({ messages: [], hasQueuedPrompt: false }) + .mockResolvedValueOnce({ + messages: ['new user direction'], + hasQueuedPrompt: false, + }) + .mockResolvedValue({ messages: [], hasQueuedPrompt: false }); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(6); + const midTurnCall = vi.mocked(mockChat.sendMessageStream).mock + .calls[3]?.[1] as { message: Part[] }; + expect(textParts(midTurnCall.message).join('\n')).toContain( + 'new user direction', + ); + const guardAttempts = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update) + .filter( + (update) => + update.sessionUpdate === 'agent_message_chunk' && + update._meta?.['source'] === 'todo_stop_guard', + ) + .map((update) => update._meta?.['attempt']); + expect(guardAttempts).toEqual([1, 1, 2, 2]); + }); + + it('ignores background tasks that predate the work chain', async () => { + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'old-agent', + isBackgrounded: true, + status: 'running', + notified: false, + }, + ]); + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(4); + }); + + it('includes tasks created while the superseded prompt unwinds in the new baseline', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + let enterWait!: () => void; + const waitStarted = new Promise((resolve) => { + enterWait = resolve; + }); + let releaseWait!: () => void; + const waitGate = new Promise((resolve) => { + releaseWait = resolve; + }); + let waitCalls = 0; + session.messageRewriter = { + interceptUpdate: vi.fn().mockResolvedValue(undefined), + waitForPendingRewrites: vi.fn(async () => { + if (++waitCalls !== 1) return; + enterWait(); + await waitGate; + }), + } as unknown as NonNullable; + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'second-prompt-todo', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValue(createEmptyStream()); + + const firstPrompt = runGuardPrompt(); + await waitStarted; + const secondPrompt = session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'new work chain' }], + }); + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'old-unwind-agent', + isBackgrounded: true, + status: 'running', + notified: false, + }, + ]); + releaseWait(); + + await Promise.all([firstPrompt, secondPrompt]); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(5); + }); + + it('defers while a background task created by the work chain is running', async () => { + rebuildSessionWithGuard(); + vi.mocked(mockChat.getHistory).mockReturnValue([ + { role: 'model', parts: [{ text: 'waiting for background work' }] }, + ]); + vi.mocked(core.generatePromptSuggestion).mockClear(); + const execute = installPendingTodoTool(); + execute.mockImplementation(async () => { + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'new-agent', + isBackgrounded: true, + status: 'running', + notified: false, + }, + ]); + return { + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }; + }); + queuePendingTodoThenNaturalStops(); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + expect(core.generatePromptSuggestion).not.toHaveBeenCalled(); + }); + + it('defers while a background shell created by the work chain is running', async () => { + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + execute.mockImplementation(async () => { + mockBackgroundShellRegistry.getAll.mockReturnValue([ + { id: 'new-shell', status: 'running' }, + ]); + return { + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }; + }); + queuePendingTodoThenNaturalStops(); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + }); + + it('defers while a monitor created by the work chain is running', async () => { + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + execute.mockImplementation(async () => { + mockMonitorRegistry.getAll.mockReturnValue([ + { id: 'new-monitor', status: 'running' }, + ]); + return { + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }; + }); + queuePendingTodoThenNaturalStops(); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + }); + + it('does not resume an old Guard after the working directory changes', async () => { + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + execute.mockImplementation(async () => { + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'cwd-agent', + isBackgrounded: true, + status: 'running', + notified: false, + }, + ]); + return { + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }; + }); + queuePendingTodoThenNaturalStops(); + + await runGuardPrompt(); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + + session.clearTodoStopGuardTrust(); + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'cwd-agent', + isBackgrounded: true, + status: 'completed', + notified: true, + }, + ]); + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + callback('background done', '', { + agentId: 'cwd-agent', + status: 'completed', + }); + + const internals = session as unknown as { + notificationProcessing: boolean; + notificationQueue: unknown[]; + }; + await vi.waitFor(() => { + expect(internals.notificationProcessing).toBe(false); + expect(internals.notificationQueue).toHaveLength(0); + }); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(3); + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.some( + ([params]) => + params.update.sessionUpdate === 'agent_message_chunk' && + params.update._meta?.['source'] === 'todo_stop_guard', + ), + ).toBe(false); + }); + + it('defers while a background task created by the work chain is paused', async () => { + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + execute.mockImplementation(async () => { + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'paused-agent', + isBackgrounded: true, + status: 'paused', + notified: false, + }, + ]); + return { + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }; + }); + queuePendingTodoThenNaturalStops(); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + }); + + it('continues the same guard when its background task reports completion', async () => { + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + execute.mockImplementation(async () => { + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'new-agent', + isBackgrounded: true, + status: 'running', + notified: false, + }, + ]); + return { + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }; + }); + queuePendingTodoThenNaturalStops(); + + await runGuardPrompt(); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'new-agent', + isBackgrounded: true, + status: 'completed', + notified: true, + }, + ]); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + callback('background done', '', { + agentId: 'new-agent', + status: 'completed', + }); + + await vi.waitFor(() => { + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(3); + }); + }); + + it('does not let an old notification displace a chain waiting on its own task', async () => { + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'old-agent', + isBackgrounded: true, + status: 'running', + notified: false, + }, + ]); + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + execute.mockImplementation(async () => { + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'old-agent', + isBackgrounded: true, + status: 'running', + notified: false, + }, + { + id: 'new-agent', + isBackgrounded: true, + status: 'running', + notified: false, + }, + ]); + return { + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }; + }); + queuePendingTodoThenNaturalStops(); + + await runGuardPrompt(); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + callback('old background done', '', { + agentId: 'old-agent', + status: 'completed', + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'old-agent', + isBackgrounded: true, + status: 'completed', + notified: true, + }, + { + id: 'new-agent', + isBackgrounded: true, + status: 'completed', + notified: true, + }, + ]); + callback('new background done', '', { + agentId: 'new-agent', + status: 'completed', + }); + + await vi.waitFor(() => { + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(6); + }); + const guardAttempts = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update) + .filter( + (update) => + update.sessionUpdate === 'agent_message_chunk' && + update._meta?.['source'] === 'todo_stop_guard', + ) + .map((update) => update._meta?.['attempt']); + expect(guardAttempts).toEqual([1, 2, 2]); + }); + + it('protects a related notification from unrelated queue overflow', async () => { + const oldAgents = Array.from({ length: 20 }, (_value, index) => ({ + id: `old-agent-${index}`, + isBackgrounded: true, + status: 'running', + notified: false, + })); + mockBackgroundTaskRegistry.getAll.mockReturnValue(oldAgents); + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + execute.mockImplementation(async () => { + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + ...oldAgents, + { + id: 'new-agent', + isBackgrounded: true, + status: 'running', + notified: false, + }, + ]); + return { + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }; + }); + queuePendingTodoThenNaturalStops(); + + await runGuardPrompt(); + + const internals = session as unknown as { + notificationProcessing: boolean; + notificationQueue: Array<{ taskId: string }>; + }; + internals.notificationProcessing = true; + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + callback('new result', '', { + agentId: 'new-agent', + status: 'completed', + }); + for (const oldAgent of oldAgents) { + callback('old result', '', { + agentId: oldAgent.id, + status: 'completed', + }); + } + + expect(internals.notificationQueue).toHaveLength(20); + expect( + internals.notificationQueue.some((item) => item.taskId === 'new-agent'), + ).toBe(true); + internals.notificationProcessing = false; + }); + + it('preserves queued related notifications when the queue is full', async () => { + const relatedAgents = Array.from({ length: 21 }, (_value, index) => ({ + id: `related-agent-${index}`, + isBackgrounded: true, + status: 'running', + notified: false, + })); + mockBackgroundTaskRegistry.getAll.mockReturnValue([]); + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + execute.mockImplementation(async () => { + mockBackgroundTaskRegistry.getAll.mockReturnValue(relatedAgents); + return { + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }; + }); + queuePendingTodoThenNaturalStops(); + + await runGuardPrompt(); + + const internals = session as unknown as { + notificationProcessing: boolean; + notificationQueue: Array<{ taskId: string }>; + }; + internals.notificationProcessing = true; + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + for (let index = 0; index < 20; index++) { + callback('related result', '', { + agentId: `related-agent-${index}`, + status: 'completed', + }); + } + debugLoggerWarnSpy.mockClear(); + callback('overflow result', '', { + agentId: 'related-agent-20', + status: 'completed', + }); + + expect(internals.notificationQueue).toHaveLength(20); + expect(internals.notificationQueue[0]?.taskId).toBe('related-agent-0'); + expect( + internals.notificationQueue.some( + (item) => item.taskId === 'related-agent-20', + ), + ).toBe(false); + expect(debugLoggerWarnSpy).toHaveBeenCalledWith( + 'Notification queue overflow: dropping related task=related-agent-20 kind=agent because all queued items are related', + ); + internals.notificationProcessing = false; + }); + + it('protects a related notification while FIFO priority outlives guard trust', () => { + const oldAgents = Array.from({ length: 20 }, (_value, index) => ({ + id: `fifo-old-agent-${index}`, + isBackgrounded: true, + status: 'running', + notified: false, + })); + mockBackgroundTaskRegistry.getAll.mockReturnValue(oldAgents); + rebuildSessionWithGuard(); + const internals = session as unknown as { + todoStopGuardQueuedPromptPriority: boolean; + notificationProcessing: boolean; + notificationQueue: Array<{ taskId: string }>; + }; + internals.todoStopGuardQueuedPromptPriority = true; + internals.notificationProcessing = true; + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + callback('related result', '', { + agentId: 'fifo-related-agent', + status: 'completed', + }); + for (const oldAgent of oldAgents) { + callback('old result', '', { + agentId: oldAgent.id, + status: 'completed', + }); + } + + expect(internals.notificationQueue).toHaveLength(20); + expect( + internals.notificationQueue.some( + (item) => item.taskId === 'fifo-related-agent', + ), + ).toBe(true); + internals.notificationProcessing = false; + }); + + it('unblocks deferred automatic work when history restoration clears trust', async () => { + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'old-agent', + isBackgrounded: true, + status: 'running', + notified: false, + }, + ]); + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + execute.mockImplementation(async () => { + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'old-agent', + isBackgrounded: true, + status: 'running', + notified: false, + }, + { + id: 'new-agent', + isBackgrounded: true, + status: 'running', + notified: false, + }, + ]); + return { + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }; + }); + queuePendingTodoThenNaturalStops(); + await runGuardPrompt(); + + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + callback('old result', '', { + agentId: 'old-agent', + status: 'completed', + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + session.restoreHistory([]); + + await vi.waitFor(() => { + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(1); + }); + }); + + it('does not let a related background result re-arm after rewind', async () => { + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + execute.mockImplementation(async () => { + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'pre-rewind-agent', + isBackgrounded: true, + status: 'running', + notified: false, + }, + ]); + return { + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }; + }); + queuePendingTodoThenNaturalStops(); + await runGuardPrompt(); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + + const history: Content[] = [ + { role: 'user', parts: [{ text: 'finish everything' }] }, + { role: 'model', parts: [{ text: 'working' }] }, + ]; + vi.mocked(mockChat.getHistoryShallow).mockReturnValue(history); + session.rewindToTurn(0); + + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'pre-rewind-agent', + isBackgrounded: true, + status: 'completed', + notified: true, + }, + ]); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-after-rewind', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValue(createEmptyStream()); + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + callback('background done', '', { + agentId: 'pre-rewind-agent', + status: 'completed', + }); + + await vi.waitFor(() => { + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + }); + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.some( + ([params]) => + params.update.sessionUpdate === 'agent_message_chunk' && + params.update._meta?.['source'] === 'todo_stop_guard', + ), + ).toBe(false); + }); + + it('does not let a related background result revive a hard-stopped guard', async () => { + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + execute.mockImplementation(async () => { + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'hard-stopped-agent', + isBackgrounded: true, + status: 'running', + notified: false, + }, + ]); + return { + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }; + }); + queuePendingTodoThenNaturalStops(); + + await runGuardPrompt(); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + + session.clearTodoStopGuardTrust(); + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'hard-stopped-agent', + isBackgrounded: true, + status: 'completed', + notified: true, + }, + ]); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'related-notification-todo-after-hard-stop', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValue(createEmptyStream()); + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + callback('background done', '', { + agentId: 'hard-stopped-agent', + status: 'completed', + }); + + await vi.waitFor(() => { + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + }); + const guardAttempts = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update) + .filter( + (update) => + update.sessionUpdate === 'agent_message_chunk' && + update._meta?.['source'] === 'todo_stop_guard', + ); + expect(guardAttempts).toHaveLength(0); + }); + + it('retains an already-used attempt when a related background task completes', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-before-background', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()) + .mockImplementationOnce(async () => { + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'guard-agent', + isBackgrounded: true, + status: 'running', + notified: false, + }, + ]); + return createEmptyStream(); + }) + .mockResolvedValue(createEmptyStream()); + + await runGuardPrompt(); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(3); + + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'guard-agent', + isBackgrounded: true, + status: 'completed', + notified: true, + }, + ]); + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + callback('background done', '', { + agentId: 'guard-agent', + status: 'completed', + }); + + await vi.waitFor(() => { + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(5); + }); + const guardAttempts = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update) + .filter( + (update) => + update.sessionUpdate === 'agent_message_chunk' && + update._meta?.['source'] === 'todo_stop_guard', + ) + .map((update) => update._meta?.['attempt']); + expect(guardAttempts).toEqual([1, 2, 2]); + }); + + it('lets a pre-existing task notification establish an independent guard', async () => { + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'old-agent', + isBackgrounded: true, + status: 'running', + notified: false, + }, + ]); + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + await runGuardPrompt(); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(4); + + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'old-agent', + isBackgrounded: true, + status: 'completed', + notified: true, + }, + ]); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'notification-todo', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValue(createEmptyStream()); + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + callback('old background done', '', { + agentId: 'old-agent', + status: 'completed', + }); + + await vi.waitFor(() => { + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(4); + }); + }); + + it('coalesces a blocking external Stop hook with the guard', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + let stopCalls = 0; + const messageBus = { + request: vi.fn().mockImplementation(async (request) => { + if (request.eventName !== 'Stop') + return { success: true, output: {} }; + stopCalls++; + return stopCalls === 1 + ? { + success: true, + output: { decision: 'block', reason: 'hook says continue' }, + } + : { success: true, output: {} }; + }), + }; + mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus); + mockConfig.hasHooksForEvent = vi + .fn() + .mockImplementation((name: string) => name === 'Stop'); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(4); + const combined = vi.mocked(mockChat.sendMessageStream).mock + .calls[2]?.[1] as { + message: Part[]; + }; + expect(textParts(combined.message).join('\n')).toContain( + 'hook says continue', + ); + expect(textParts(combined.message).join('\n')).toContain( + '[Todo Stop Guard]', + ); + }); + + it('preserves the external Stop hook tool loop after Guard exhaustion', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + const todoTool = mockToolRegistry.getTool(core.ToolNames.TODO_WRITE); + const readExecute = vi.fn().mockResolvedValue({ + llmContent: 'file contents', + returnDisplay: 'file contents', + }); + const readTool = { + name: 'read_file', + kind: core.Kind.Read, + build: vi.fn().mockReturnValue({ + params: { path: '/tmp/test.txt' }, + execute: readExecute, + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + getDescription: vi.fn().mockReturnValue('Read file'), + toolLocations: vi.fn().mockReturnValue([]), + }), + }; + mockToolRegistry.getTool.mockImplementation((name: string) => + name === core.ToolNames.TODO_WRITE ? todoTool : readTool, + ); + const readCall = (id: string) => + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id, + name: 'read_file', + args: { path: '/tmp/test.txt' }, + }, + ], + }, + }, + ]); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-before-coalesced-tool-loop', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce(readCall('coalesced-read-1')) + .mockResolvedValueOnce(readCall('coalesced-read-2')) + .mockResolvedValueOnce(createEmptyStream()); + let stopCalls = 0; + const messageBus = { + request: vi.fn().mockImplementation(async (request) => { + if (request.eventName !== 'Stop') { + return { success: true, output: {} }; + } + stopCalls++; + return stopCalls === 1 + ? { + success: true, + output: { decision: 'block', reason: 'hook says continue' }, + } + : { success: true, output: {} }; + }), + }; + mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus); + mockConfig.hasHooksForEvent = vi + .fn() + .mockImplementation((name: string) => name === 'Stop'); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(5); + expect(readExecute).toHaveBeenCalledTimes(2); + const externalToolFollowup = vi.mocked(mockChat.sendMessageStream).mock + .calls[4]?.[1] as { message: Part[] }; + expect( + externalToolFollowup.message.some( + (part) => + 'functionResponse' in part && + part.functionResponse?.id === 'coalesced-read-2', + ), + ).toBe(true); + expect(textParts(externalToolFollowup.message).join('\n')).not.toContain( + '[Todo Stop Guard]', + ); + const guardAttempts = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update) + .filter( + (update) => + update.sessionUpdate === 'agent_message_chunk' && + update._meta?.['source'] === 'todo_stop_guard', + ) + .map((update) => update._meta?.['attempt']); + expect(guardAttempts).toEqual([1, 2, 2]); + }); + + it('preserves an external Stop hook tool result when nested Guard validation defers', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + const todoTool = mockToolRegistry.getTool(core.ToolNames.TODO_WRITE); + const readExecute = vi.fn().mockResolvedValue({ + llmContent: 'file contents', + returnDisplay: 'file contents', + }); + const readTool = { + name: 'read_file', + kind: core.Kind.Read, + build: vi.fn().mockReturnValue({ + params: { path: '/tmp/test.txt' }, + execute: readExecute, + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + getDescription: vi.fn().mockReturnValue('Read file'), + toolLocations: vi.fn().mockReturnValue([]), + }), + }; + mockToolRegistry.getTool.mockImplementation((name: string) => + name === core.ToolNames.TODO_WRITE ? todoTool : readTool, + ); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-before-coalesced-background-race', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'coalesced-read-before-background-race', + name: 'read_file', + args: { path: '/tmp/test.txt' }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValue(createEmptyStream()); + let stopCalls = 0; + const messageBus = { + request: vi.fn().mockImplementation(async (request) => { + if (request.eventName !== 'Stop') { + return { success: true, output: {} }; + } + stopCalls++; + return stopCalls === 1 + ? { + success: true, + output: { + decision: 'block', + reason: 'external hook owns the tool loop', + }, + } + : { success: true, output: {} }; + }), + }; + mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus); + mockConfig.hasHooksForEvent = vi + .fn() + .mockImplementation((name: string) => name === 'Stop'); + let drainCalls = 0; + vi.mocked(mockClient.extMethod).mockImplementation(async () => { + drainCalls++; + if (drainCalls === 6) { + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'related-during-nested-validation', + isBackgrounded: true, + status: 'running', + notified: false, + }, + ]); + } + return { messages: [], hasQueuedPrompt: false }; + }); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(4); + const externalToolResult = vi.mocked(mockChat.sendMessageStream).mock + .calls[3]?.[1] as { message: Part[] }; + expect( + externalToolResult.message.some( + (part) => + 'functionResponse' in part && + part.functionResponse?.id === + 'coalesced-read-before-background-race', + ), + ).toBe(true); + expect(textParts(externalToolResult.message).join('\n')).not.toContain( + '[Todo Stop Guard]', + ); + }); + + it('reports Guard exhaustion before a coalesced external Stop hook reaches its cap', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + mockConfig.getStopHookBlockingCap = vi.fn().mockReturnValue(3); + const messageBus = { + request: vi.fn().mockImplementation(async (request) => ({ + success: true, + output: + request.eventName === 'Stop' + ? { decision: 'block', reason: 'hook keeps blocking' } + : {}, + })), + }; + mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus); + mockConfig.hasHooksForEvent = vi + .fn() + .mockImplementation((name: string) => name === 'Stop'); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(4); + const guardUpdates = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update) + .filter( + (update) => + update.sessionUpdate === 'agent_message_chunk' && + update._meta?.['source'] === 'todo_stop_guard', + ); + expect(guardUpdates.map((update) => update._meta?.['attempt'])).toEqual([ + 1, 2, 2, + ]); + expect( + guardUpdates.at(-1)?.content.type === 'text' + ? guardUpdates.at(-1)?.content.text + : '', + ).toContain('Automatic continuation stopped after 2 attempts'); + }); + + it('preserves a coalesced Stop-loop event when token rejection skips the final send', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + mockConfig.getSessionTokenLimit = vi.fn().mockReturnValue(100); + mockConfig.getStopHookBlockingCap = vi.fn().mockReturnValue(3); + mockGeminiClient.tryCompressChat.mockResolvedValue({ + originalTokenCount: 50, + newTokenCount: 50, + compressionStatus: core.CompressionStatus.NOOP, + }); + const highUsageStream = createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + usageMetadata: { + totalTokenCount: 101, + promptTokenCount: 101, + }, + }, + }, + ]); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'todo-before-coalesced-token-limit', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce(highUsageStream); + const messageBus = { + request: vi.fn().mockImplementation(async (request) => ({ + success: true, + output: + request.eventName === 'Stop' + ? { + decision: 'block', + reason: 'coalesced hook continuation', + } + : {}, + })), + }; + mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus); + mockConfig.hasHooksForEvent = vi + .fn() + .mockImplementation((name: string) => name === 'Stop'); + + await expect(runGuardPrompt()).resolves.toEqual({ + stopReason: 'max_tokens', + }); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(3); + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update._meta?.['stopHookLoop']) + .filter((meta) => meta !== undefined), + ).toContainEqual( + expect.objectContaining({ + iterationCount: 2, + reasons: [ + 'coalesced hook continuation', + 'coalesced hook continuation', + ], + }), + ); + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update._meta?.['attempt']) + .filter((attempt) => attempt !== undefined), + ).toEqual([1]); + }); + + it('rechecks user input after a slow external Stop hook', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + vi.mocked(mockClient.extMethod) + .mockResolvedValueOnce({ messages: [], hasQueuedPrompt: false }) + .mockResolvedValueOnce({ messages: [], hasQueuedPrompt: false }) + .mockResolvedValueOnce({ + messages: ['direction queued while the Stop hook was running'], + hasQueuedPrompt: false, + }) + .mockResolvedValue({ messages: [], hasQueuedPrompt: false }); + + let hookStarted!: () => void; + const hookStartedPromise = new Promise((resolve) => { + hookStarted = resolve; + }); + let releaseHook!: () => void; + const hookGate = new Promise((resolve) => { + releaseHook = resolve; + }); + let stopCalls = 0; + const messageBus = { + request: vi.fn().mockImplementation(async (request) => { + if (request.eventName !== 'Stop') { + return { success: true, output: {} }; + } + if (++stopCalls === 1) { + hookStarted(); + await hookGate; + } + return { success: true, output: {} }; + }), + }; + mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus); + mockConfig.hasHooksForEvent = vi + .fn() + .mockImplementation((name: string) => name === 'Stop'); + + const prompt = runGuardPrompt(); + await hookStartedPromise; + releaseHook(); + await prompt; + + const userContinuation = vi.mocked(mockChat.sendMessageStream).mock + .calls[2]?.[1] as { message: Part[] }; + expect(textParts(userContinuation.message).join('\n')).toContain( + 'direction queued while the Stop hook was running', + ); + const guardAttempts = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update) + .filter( + (update) => + update.sessionUpdate === 'agent_message_chunk' && + update._meta?.['source'] === 'todo_stop_guard', + ) + .map((update) => update._meta?.['attempt']); + expect(guardAttempts).toEqual([1, 2, 2]); + }); + + it('accounts for a slow blocking Stop hook after handling mid-turn input', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + mockConfig.getStopHookBlockingCap = vi.fn().mockReturnValue(1); + vi.mocked(mockClient.extMethod) + .mockResolvedValueOnce({ messages: [], hasQueuedPrompt: false }) + .mockResolvedValueOnce({ messages: [], hasQueuedPrompt: false }) + .mockResolvedValueOnce({ + messages: ['direction queued while the Stop hook was running'], + hasQueuedPrompt: false, + }) + .mockResolvedValue({ messages: [], hasQueuedPrompt: false }); + + let hookStarted!: () => void; + const hookStartedPromise = new Promise((resolve) => { + hookStarted = resolve; + }); + let releaseHook!: () => void; + const hookGate = new Promise((resolve) => { + releaseHook = resolve; + }); + let stopCalls = 0; + const messageBus = { + request: vi.fn().mockImplementation(async (request) => { + if (request.eventName !== 'Stop') { + return { success: true, output: {} }; + } + if (++stopCalls === 1) { + hookStarted(); + await hookGate; + return { + success: true, + output: { + decision: 'block', + reason: 'continue after the slow Stop hook', + systemMessage: 'slow Stop hook system message', + }, + }; + } + return { success: true, output: {} }; + }), + }; + mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus); + mockConfig.hasHooksForEvent = vi + .fn() + .mockImplementation((name: string) => name === 'Stop'); + + const prompt = runGuardPrompt(); + await hookStartedPromise; + releaseHook(); + + await expect(prompt).resolves.toEqual({ stopReason: 'end_turn' }); + + expect(stopCalls).toBe(1); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(3); + const userContinuation = vi.mocked(mockChat.sendMessageStream).mock + .calls[2]?.[1] as { message: Part[] }; + expect(textParts(userContinuation.message).join('\n')).toContain( + 'direction queued while the Stop hook was running', + ); + expect(agentMessageChunks()).toContain('slow Stop hook system message'); + expect(agentMessageChunks()).toContain( + 'Stop hook blocked continuation 1 consecutive time; overriding and ending the turn.', + ); + }); + + it('lets an independent background notification arm its own guard', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + + callback('background done', '', { + agentId: 'automatic-agent', + status: 'completed', + }); + + await vi.waitFor(() => { + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(4); + }); + expect(mockClient.extNotification).toHaveBeenCalledWith( + '_qwencode/end_turn', + { + sessionId: 'test-session-id', + reason: 'end_turn', + source: 'background_notification', + }, + ); + }); + + it('suspends an armed guard when a background notification stream aborts', async () => { + rebuildSessionWithGuard(); + const aborting = createDeferredAbortStream(); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce(aborting.responseStream); + + await runGuardPrompt(); + const callback = + mockBackgroundTaskRegistry.setNotificationCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { agentId: string; status: string }, + ) => void; + callback('background done', '', { + agentId: 'automatic-agent', + status: 'completed', + }); + + await aborting.started; + const internals = session as unknown as { + notificationAbortController: AbortController | null; + notificationProcessing: boolean; + todoStopGuard: { + blocksUnrelatedAutomaticTurns: boolean; + observeTodoWrite(resultDisplay: unknown, allowArm: boolean): boolean; + }; + }; + internals.todoStopGuard.observeTodoWrite( + { type: 'todo_list', todos: pendingTodos }, + true, + ); + expect(internals.todoStopGuard.blocksUnrelatedAutomaticTurns).toBe(true); + internals.notificationAbortController?.abort(); + aborting.abort(); + + await vi.waitFor(() => { + expect(internals.notificationProcessing).toBe(false); + }); + expect(internals.todoStopGuard.blocksUnrelatedAutomaticTurns).toBe(false); + }); + + it('lets an independent cron turn arm its own guard', async () => { + const scheduler = { + hasPendingWork: true, + enableDurable: vi.fn().mockResolvedValue(undefined), + start: vi.fn( + ( + callback: (job: { + prompt: string; + cronExpr: string; + missed?: boolean; + }) => void, + ) => callback({ prompt: 'scheduled work', cronExpr: '* * * * *' }), + ), + stop: vi.fn(), + list: vi.fn().mockReturnValue([]), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + rebuildSessionWithGuard(); + installPendingTodoTool(); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'cron-todo', + name: core.ToolNames.TODO_WRITE, + args: { todos: pendingTodos }, + }, + ], + }, + }, + ]), + ) + .mockResolvedValue(createEmptyStream()); + + await runGuardPrompt(); + + await vi.waitFor(() => { + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(5); + }); + const guardUpdates = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.filter( + ([params]) => + params.update.sessionUpdate === 'agent_message_chunk' && + params.update._meta?.['source'] === 'todo_stop_guard', + ); + expect(guardUpdates).toHaveLength(3); + }); + + it('suspends an armed guard when a cron stream aborts', async () => { + const scheduler = { + hasPendingWork: true, + enableDurable: vi.fn().mockResolvedValue(undefined), + start: vi.fn( + ( + callback: (job: { + prompt: string; + cronExpr: string; + missed?: boolean; + }) => void, + ) => callback({ prompt: 'scheduled work', cronExpr: '* * * * *' }), + ), + stop: vi.fn(), + list: vi.fn().mockReturnValue([]), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + rebuildSessionWithGuard(); + const aborting = createDeferredAbortStream(); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce(createEmptyStream()) + .mockResolvedValueOnce(aborting.responseStream); + + await runGuardPrompt(); + await aborting.started; + const internals = session as unknown as { + cronAbortController: AbortController | null; + cronProcessing: boolean; + todoStopGuard: { + blocksUnrelatedAutomaticTurns: boolean; + observeTodoWrite(resultDisplay: unknown, allowArm: boolean): boolean; + }; + }; + internals.todoStopGuard.observeTodoWrite( + { type: 'todo_list', todos: pendingTodos }, + true, + ); + expect(internals.todoStopGuard.blocksUnrelatedAutomaticTurns).toBe(true); + internals.cronAbortController?.abort(); + aborting.abort(); + + await vi.waitFor(() => { + expect(internals.cronProcessing).toBe(false); + }); + expect(internals.todoStopGuard.blocksUnrelatedAutomaticTurns).toBe(false); + }); + + it('treats a queued pre-prompt wakeup as part of the new baseline', async () => { + rebuildSessionWithGuard(); + installPendingTodoTool(); + queuePendingTodoThenNaturalStops(); + const internals = session as unknown as { + cronQueue: Array<{ + prompt: string; + source: 'cron' | 'loop'; + taskId?: string; + }>; + }; + internals.cronQueue.push({ + prompt: 'old wakeup result', + source: 'loop', + taskId: 'old-wakeup', + }); + + await runGuardPrompt(); + await vi.waitFor(() => { + expect( + vi + .mocked(mockClient.sessionUpdate) + .mock.calls.some( + ([params]) => params.update._meta?.['source'] === 'loop', + ), + ).toBe(true); + }); + + const updates = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update); + const firstGuardIndex = updates.findIndex( + (update) => update._meta?.['source'] === 'todo_stop_guard', + ); + const wakeupIndex = updates.findIndex( + (update) => update._meta?.['source'] === 'loop', + ); + expect(firstGuardIndex).toBeGreaterThanOrEqual(0); + expect(wakeupIndex).toBeGreaterThan(firstGuardIndex); + }); + + it('defers for a wakeup created by the current work chain', async () => { + const scheduler = { + hasPendingWork: false, + enableDurable: vi.fn().mockResolvedValue(undefined), + start: vi.fn(), + stop: vi.fn(), + list: vi.fn().mockReturnValue([]), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + execute.mockImplementation(async () => { + scheduler.list.mockReturnValue([ + { id: 'new-wakeup', cronExpr: '@wakeup' }, + ]); + return { + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }; + }); + queuePendingTodoThenNaturalStops(); + + await runGuardPrompt(); + + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + }); + + it('coalesces unrelated recurring cron fires while a guard is waiting', async () => { + let fireCron!: (job: { + id?: string; + prompt: string; + cronExpr?: string; + }) => void; + const scheduler = { + hasPendingWork: true, + enableDurable: vi.fn().mockResolvedValue(undefined), + start: vi.fn( + ( + callback: (job: { + id?: string; + prompt: string; + cronExpr?: string; + }) => void, + ) => { + fireCron = callback; + }, + ), + stop: vi.fn(), + list: vi.fn().mockReturnValue([]), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + rebuildSessionWithGuard(); + const execute = installPendingTodoTool(); + execute.mockImplementation(async () => { + mockBackgroundTaskRegistry.getAll.mockReturnValue([ + { + id: 'guard-agent', + isBackgrounded: true, + status: 'running', + notified: false, + }, + ]); + return { + llmContent: JSON.stringify(pendingTodos), + returnDisplay: { + type: 'todo_list', + todos: pendingTodos, + changes: {}, + }, + }; + }); + queuePendingTodoThenNaturalStops(); + + await runGuardPrompt(); + expect(scheduler.start).toHaveBeenCalled(); + + for (let index = 0; index < 25; index++) { + fireCron({ + id: 'old-recurring-cron', + prompt: 'scheduled work', + cronExpr: '* * * * *', + }); + } + + const internals = session as unknown as { + cronQueue: Array<{ taskId?: string }>; + }; + expect(internals.cronQueue).toEqual([ + expect.objectContaining({ taskId: 'old-recurring-cron' }), + ]); + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + }); + + it('coalesces recurring cron fires while FIFO priority outlives guard trust', async () => { + let fireCron!: (job: { + id?: string; + prompt: string; + cronExpr?: string; + }) => void; + const scheduler = { + hasPendingWork: true, + enableDurable: vi.fn().mockResolvedValue(undefined), + start: vi.fn( + ( + callback: (job: { + id?: string; + prompt: string; + cronExpr?: string; + }) => void, + ) => { + fireCron = callback; + }, + ), + stop: vi.fn(), + list: vi.fn().mockReturnValue([]), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + rebuildSessionWithGuard(); + const internals = session as unknown as { + todoStopGuardQueuedPromptPriority: boolean; + cronQueue: Array<{ taskId?: string }>; + }; + internals.todoStopGuardQueuedPromptPriority = true; + + session.startCronScheduler(); + await vi.waitFor(() => expect(scheduler.start).toHaveBeenCalled()); + for (let index = 0; index < 25; index++) { + fireCron({ + id: 'fifo-deferred-recurring-cron', + prompt: 'scheduled work', + cronExpr: '* * * * *', + }); + } + + expect(internals.cronQueue).toEqual([ + expect.objectContaining({ taskId: 'fifo-deferred-recurring-cron' }), + ]); + expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); + }); + }); + describe('follow-up suggestion (daemon assist push)', () => { let generateMock: ReturnType; let logMock: ReturnType; diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 23148b7caa8..16562750e27 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -65,6 +65,7 @@ import { readManyFiles, clampInlineMediaPart, Storage, + Kind, ToolNames, ToolErrorType, fireNotificationHook, @@ -237,11 +238,55 @@ import { MessageRewriteMiddleware, loadRewriteConfig, } from './rewrite/index.js'; +import { + DaemonTodoStopGuard, + type TodoStopGuardContinuation, +} from './daemon-todo-stop-guard.js'; const debugLogger = createDebugLogger('SESSION'); const USER_CANCEL_ABORT_REASON = 'qwen:user-cancel'; const DAEMON_RETRY_META_KEY = 'qwen.daemon.retry'; const DAEMON_CONTINUE_META_KEY = 'qwen.daemon.continueLastTurn'; +const TODO_STOP_GUARD_PROMPT_PREFIX = '[Todo Stop Guard] '; +const TODO_STOP_GUARD_PROMPT_BODY_SUFFIX = + ' todo item(s) are still pending or in progress. Continue executing the current task now. Do not ask the user whether to continue. If progress requires user input, use the structured question or permission flow. If progress depends on external state, report the blocker explicitly.'; +const TODO_STOP_GUARD_FINAL_PROMPT_SUFFIX = + ' This is the final automatic continuation. Before ending, either complete/update the todos or report the completed progress and the exact blocker.'; + +// Content has no private metadata slot, so history cleanup recognizes only +// these exact templates; byte-identical user text is intentionally ambiguous. +function isTodoStopGuardPromptText(text: unknown): text is string { + if (typeof text !== 'string') return false; + if (!text.startsWith(TODO_STOP_GUARD_PROMPT_PREFIX)) return false; + + const remainder = text.slice(TODO_STOP_GUARD_PROMPT_PREFIX.length); + const separator = remainder.indexOf(' '); + if (separator <= 0) return false; + const countText = remainder.slice(0, separator); + const count = Number(countText); + if ( + !Number.isSafeInteger(count) || + count <= 0 || + String(count) !== countText + ) { + return false; + } + + const body = `${countText}${TODO_STOP_GUARD_PROMPT_BODY_SUFFIX}`; + return ( + remainder === body || + remainder === body + TODO_STOP_GUARD_FINAL_PROMPT_SUFFIX + ); +} + +function isCompressionFailureStatus(status: CompressionStatus): boolean { + return ( + status === CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT || + status === CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR || + status === CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY || + status === CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED + ); +} /** Finalizes preparations without allowing ACP cleanup to change the stream outcome. */ async function finalizeToolCallPreparations( @@ -278,6 +323,44 @@ type RunToolResult = { memoryWriteCandidates?: MemoryWriteCandidate[]; }; +type MidTurnDrainResult = { + parts: Part[]; + hasQueuedPrompt: boolean; +}; + +type NextMessageAfterToolRun = { + message: Content | null; + hadMidTurnUserInput: boolean; +}; + +type TodoStopGuardBackgroundBaseline = { + agents: Set; + shells: Set; + monitors: Set; + wakeups: Set; +}; + +type TodoStopGuardPromptPreparation = { + startsWorkChain: boolean; + drainSupersededAutomaticQueues: boolean; +}; + +type StopContinuationResult = + | { kind: 'natural_stop'; supersededAutomaticContinuation?: boolean } + | { + kind: 'terminal'; + stopReason: PromptResponse['stopReason']; + supersededAutomaticContinuation?: boolean; + }; + +type BeforeModelSendDecision = + | { kind: 'send'; message: Part[] } + | { kind: 'stop'; stopReason: PromptResponse['stopReason'] }; + +type BeforeModelSendContext = { + compressionFailed: boolean; +}; + type DaemonToolLoopState = { totalToolCalls: number; invalidToolParamErrors: Map; @@ -654,9 +737,11 @@ interface CronFire { interface CronQueueItem { prompt: string; source: 'cron' | 'loop'; + taskId?: string; } const MAX_NOTIFICATION_QUEUE = 20; +const MAX_DEFERRED_UNRELATED_CRON_QUEUE = 20; export function resolveHomeLoopResolverRoots({ homeQwenDir = Storage.getGlobalQwenDir(), @@ -1008,6 +1093,10 @@ export class Session implements SessionContext { // batch so a transient stall can't silently lose them. See // `#drainMidTurnUserMessages`. private midTurnRecoveredMessages: DrainedMidTurnMessage[] = []; + private readonly todoStopGuard: DaemonTodoStopGuard; + private todoStopGuardBackgroundBaseline: TodoStopGuardBackgroundBaseline; + private todoStopGuardQueuedPromptPriority = false; + private todoStopGuardDrainAutomaticQueuesWhenIdle = false; // Background notification drain state. ACP does not have the TUI's idle // hook, so the session serializes registry callbacks through this queue. @@ -1059,6 +1148,19 @@ export class Session implements SessionContext { ) { this.sessionId = id; this.runtimeBaseDir = Storage.getRuntimeBaseDir(); + const todoStopGuardEnabled = + this.settings.merged.experimental?.todoStopGuard === true && + !this.config.getBareMode() && + !this.config.isSafeMode(); + this.todoStopGuard = new DaemonTodoStopGuard(todoStopGuardEnabled); + this.todoStopGuardBackgroundBaseline = todoStopGuardEnabled + ? this.#captureTodoStopGuardBackgroundBaseline() + : { + agents: new Set(), + shells: new Set(), + monitors: new Set(), + wakeups: new Set(), + }; // Initialize modular components with this session as context this.toolCallEmitter = new ToolCallEmitter(this); @@ -1071,6 +1173,236 @@ export class Session implements SessionContext { this.#registerSubSessionSpawner(); } + #prepareTodoStopGuardForPrompt( + params: PromptRequest, + ): TodoStopGuardPromptPreparation { + if (!this.todoStopGuard.enabled) { + return { + startsWorkChain: false, + drainSupersededAutomaticQueues: false, + }; + } + + const drainSupersededAutomaticQueues = + this.todoStopGuard.blocksUnrelatedAutomaticTurns || + this.todoStopGuard.hasCommittedContinuation || + this.todoStopGuardQueuedPromptPriority; + + if (this.config.getApprovalMode() === ApprovalMode.PLAN) { + this.todoStopGuardQueuedPromptPriority = false; + this.todoStopGuard.blockUntilOrdinaryPromptStarts(); + return { + startsWorkChain: false, + drainSupersededAutomaticQueues, + }; + } + + const metadata = (params as { _meta?: Record })._meta; + const isRetry = + (params as { retry?: boolean }).retry === true || + metadata?.[DAEMON_RETRY_META_KEY] === true; + const isContinue = metadata?.[DAEMON_CONTINUE_META_KEY] === true; + if (isRetry || isContinue) { + this.todoStopGuardQueuedPromptPriority = false; + if (this.todoStopGuard.hasTrustedUnfinishedState) { + this.todoStopGuard.resumeTrustedPrompt(); + return { + startsWorkChain: false, + drainSupersededAutomaticQueues: false, + }; + } + this.todoStopGuard.blockUntilOrdinaryPromptStarts(); + return { + startsWorkChain: true, + drainSupersededAutomaticQueues, + }; + } + + this.todoStopGuardQueuedPromptPriority = false; + this.todoStopGuard.blockUntilOrdinaryPromptStarts(); + return { + startsWorkChain: true, + drainSupersededAutomaticQueues, + }; + } + + #prepareTodoStopGuardForAutomaticTurn( + continuesCurrentWorkChain: boolean, + ): void { + if (!this.todoStopGuard.enabled) return; + if (this.config.getApprovalMode() === ApprovalMode.PLAN) { + this.todoStopGuard.blockUntilOrdinaryPromptStarts(); + return; + } + if (continuesCurrentWorkChain && this.todoStopGuard.isHardSuspended) { + return; + } + if ( + continuesCurrentWorkChain && + this.todoStopGuard.hasTrustedUnfinishedState + ) { + this.todoStopGuard.resumeTrustedPrompt(); + return; + } + + this.todoStopGuard.clearTrust(); + this.todoStopGuardBackgroundBaseline = + this.#captureTodoStopGuardBackgroundBaseline(); + } + + #clearTodoStopGuardTrustAndDrainAutomaticQueues(): void { + const preserveQueuedPromptPriority = this.todoStopGuardQueuedPromptPriority; + const shouldDrain = + (this.todoStopGuard.blocksUnrelatedAutomaticTurns || + this.todoStopGuard.hasCommittedContinuation) && + !preserveQueuedPromptPriority; + this.todoStopGuard.blockUntilOrdinaryPromptStarts(); + if (preserveQueuedPromptPriority || !shouldDrain) return; + if (this.pendingPrompt) { + this.todoStopGuardDrainAutomaticQueuesWhenIdle = true; + return; + } + void this.#drainCronQueue(); + void this.#drainNotificationQueue(); + } + + releaseTodoStopGuardQueuedPromptWait(): boolean { + if (!this.todoStopGuardQueuedPromptPriority) return false; + this.todoStopGuardQueuedPromptPriority = false; + this.todoStopGuard.blockUntilOrdinaryPromptStarts(); + if (this.pendingPrompt) { + this.todoStopGuardDrainAutomaticQueuesWhenIdle = true; + return true; + } + void this.#drainCronQueue(); + void this.#drainNotificationQueue(); + return true; + } + + clearTodoStopGuardTrust(): void { + this.#clearTodoStopGuardTrustAndDrainAutomaticQueues(); + } + + #beginTodoStopGuardQueuedPromptCheck(): void { + this.todoStopGuardQueuedPromptPriority = + this.todoStopGuard.awaitQueuedPrompt(); + } + + #finishTodoStopGuardQueuedPromptCheck(hasQueuedPrompt: boolean): boolean { + const shouldWait = + hasQueuedPrompt && this.todoStopGuardQueuedPromptPriority; + this.todoStopGuardQueuedPromptPriority = shouldWait; + if (!shouldWait) this.todoStopGuard.resumeTrustedPrompt(); + return shouldWait; + } + + #notificationContinuesTodoStopGuardWorkChain( + item: BackgroundNotificationQueueItem, + ): boolean { + const baseline = this.todoStopGuardBackgroundBaseline; + if (item.kind === 'agent') return !baseline.agents.has(item.taskId); + if (item.kind === 'shell') return !baseline.shells.has(item.taskId); + return !baseline.monitors.has(item.taskId); + } + + #cronContinuesTodoStopGuardWorkChain(item: CronQueueItem): boolean { + return ( + item.source === 'loop' && + item.taskId !== undefined && + !this.todoStopGuardBackgroundBaseline.wakeups.has(item.taskId) + ); + } + + #captureTodoStopGuardBackgroundBaseline(): TodoStopGuardBackgroundBaseline { + const agents = this.config.getBackgroundTaskRegistry?.()?.getAll?.() ?? []; + const shells = this.config.getBackgroundShellRegistry?.()?.getAll?.() ?? []; + const monitors = this.config.getMonitorRegistry?.()?.getAll?.() ?? []; + const wakeups = this.config.isCronEnabled?.() + ? (this.config.getCronScheduler?.()?.list?.() ?? []).filter( + (job) => job.cronExpr === '@wakeup', + ) + : []; + + return { + agents: new Set([ + ...agents.map((task) => task.id), + ...this.notificationQueue + .filter((item) => item.kind === 'agent') + .map((item) => item.taskId), + ]), + shells: new Set([ + ...shells.map((task) => task.id), + ...this.notificationQueue + .filter((item) => item.kind === 'shell') + .map((item) => item.taskId), + ]), + monitors: new Set([ + ...monitors.map((task) => task.id), + ...this.notificationQueue + .filter((item) => item.kind === 'monitor') + .map((item) => item.taskId), + ]), + wakeups: new Set([ + ...wakeups.map((job) => job.id), + ...this.cronQueue.flatMap((item) => + item.source === 'loop' && item.taskId ? [item.taskId] : [], + ), + ]), + }; + } + + #hasRelevantTodoStopGuardBackgroundInput(): boolean { + if ( + this.notificationQueue.some((item) => + this.#notificationContinuesTodoStopGuardWorkChain(item), + ) || + this.cronQueue.some((item) => + this.#cronContinuesTodoStopGuardWorkChain(item), + ) + ) { + return true; + } + + const baseline = this.todoStopGuardBackgroundBaseline; + const agents = this.config.getBackgroundTaskRegistry?.()?.getAll?.() ?? []; + if ( + agents.some( + (task) => + !baseline.agents.has(task.id) && + task.isBackgrounded && + (task.status === 'running' || + task.status === 'paused' || + (task.status === 'cancelled' && !task.notified)), + ) + ) { + return true; + } + + const shells = this.config.getBackgroundShellRegistry?.()?.getAll?.() ?? []; + if ( + shells.some( + (task) => !baseline.shells.has(task.id) && task.status === 'running', + ) + ) { + return true; + } + + const monitors = this.config.getMonitorRegistry?.()?.getAll?.() ?? []; + if ( + monitors.some( + (task) => !baseline.monitors.has(task.id) && task.status === 'running', + ) + ) { + return true; + } + + if (!this.config.isCronEnabled?.()) return false; + const wakeups = this.config.getCronScheduler?.()?.list?.() ?? []; + return wakeups.some( + (job) => job.cronExpr === '@wakeup' && !baseline.wakeups.has(job.id), + ); + } + /** * Wire the sub-session spawner to the daemon over the ACP `extMethod` request * channel. The `create_sub_session` tool (model-initiated) is its caller. ONLY @@ -1159,6 +1491,9 @@ export class Session implements SessionContext { dispose(): void { this.disposed = true; + this.todoStopGuardQueuedPromptPriority = false; + this.todoStopGuardDrainAutomaticQueuesWhenIdle = false; + this.todoStopGuard.clearTrust(); this.notificationQueue = []; this.cronQueue = []; this.notificationAbortController?.abort(); @@ -1287,6 +1622,12 @@ export class Session implements SessionContext { chat.truncateHistory(apiTruncateIndex); chat.stripThoughtsFromHistory(); + const preserveQueuedPromptPriority = this.todoStopGuardQueuedPromptPriority; + const shouldDrainAutomaticQueues = + (this.todoStopGuard.blocksUnrelatedAutomaticTurns || + this.todoStopGuard.hasCommittedContinuation) && + !preserveQueuedPromptPriority; + this.todoStopGuard.blockUntilOrdinaryPromptStarts(); const rewindFiles = opts?.rewindFiles !== false; const fileHistoryService = this.config.getFileHistoryService(); @@ -1306,6 +1647,11 @@ export class Session implements SessionContext { survivingSnapshots, ); + if (shouldDrainAutomaticQueues) { + void this.#drainCronQueue(); + void this.#drainNotificationQueue(); + } + return { targetTurnIndex, apiTruncateIndex }; } @@ -1347,6 +1693,7 @@ export class Session implements SessionContext { .getGeminiClient()! .getChat() .setHistory(structuredClone(history)); + this.#clearTodoStopGuardTrustAndDrainAutomaticQueues(); } #computeApiTruncationIndexForUserTurn( @@ -1394,6 +1741,14 @@ export class Session implements SessionContext { // is NOT excluded. if (isSystemReminderContent(content)) return false; + if ( + content.parts.some( + (part) => 'text' in part && isTodoStopGuardPromptText(part.text), + ) + ) { + return false; + } + return content.parts.some((part) => 'text' in part && part.text); } @@ -1411,6 +1766,8 @@ export class Session implements SessionContext { throw new Error(NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE); } + this.todoStopGuard.suspend(); + if (this.pendingPrompt) { this.pendingPrompt.abort(USER_CANCEL_ABORT_REASON); this.pendingPrompt = null; @@ -1445,6 +1802,8 @@ export class Session implements SessionContext { } async prompt(params: PromptRequest): Promise { + const todoStopGuardPreparation = + this.#prepareTodoStopGuardForPrompt(params); // Install this prompt's AbortController before awaiting the previous // prompt, so that a session/cancel during the wait targets us. this.pendingPrompt?.abort(); @@ -1506,6 +1865,13 @@ export class Session implements SessionContext { return { stopReason: 'cancelled' }; } + if (todoStopGuardPreparation.startsWorkChain) { + this.todoStopGuardQueuedPromptPriority = false; + this.todoStopGuard.startOrdinaryPrompt(); + this.todoStopGuardBackgroundBaseline = + this.#captureTodoStopGuardBackgroundBaseline(); + } + this.duplicateProviderToolCallResponseIds.clear(); // Track this prompt's completion for the next prompt to await @@ -1524,6 +1890,17 @@ export class Session implements SessionContext { return result; } finally { this.pendingPrompt = null; + const shouldDrainAutomaticQueues = + todoStopGuardPreparation.drainSupersededAutomaticQueues || + this.todoStopGuardDrainAutomaticQueuesWhenIdle || + this.todoStopGuard.blocksUnrelatedAutomaticTurns || + this.todoStopGuard.hasCommittedContinuation || + this.todoStopGuardQueuedPromptPriority; + this.todoStopGuardDrainAutomaticQueuesWhenIdle = false; + if (shouldDrainAutomaticQueues) { + void this.#drainCronQueue(); + void this.#drainNotificationQueue(); + } // Start the scheduler in finally, not the success path: a turn can arm // a wakeup via LoopWakeup and then throw on a later step. Gated on // hasPendingWork/disposed/disabled, so it only starts when a wakeup (or @@ -1615,6 +1992,12 @@ export class Session implements SessionContext { */ #maybeEmitFollowupSuggestion(result: PromptResponse): void { if (result.stopReason !== 'end_turn') return; + if ( + this.todoStopGuard.blocksUnrelatedAutomaticTurns || + this.todoStopGuardQueuedPromptPriority + ) { + return; + } // Enabled by default — only an explicit `false` opts out. The schema // `default: true` isn't applied at runtime by `mergeSettings`, so an unset // value must be treated as enabled here. @@ -1999,6 +2382,7 @@ export class Session implements SessionContext { while (nextMessage !== null) { turnCount++; if (pendingSend.signal.aborted) { + this.todoStopGuard.suspend(); this.#getCurrentChat().addHistory(nextMessage); return { stopReason: 'cancelled' }; } @@ -2023,6 +2407,7 @@ export class Session implements SessionContext { { modelOverride: fullTurnModelOverride }, ); if (!sendResult.responseStream) { + this.todoStopGuard.suspend(); // Preserve the full message (not just functionResponse // parts) for a continuation: its content was stripped from // history before the send, so dropping it here on a @@ -2041,6 +2426,7 @@ export class Session implements SessionContext { try { for await (const resp of responseStream) { if (pendingSend.signal.aborted) { + this.todoStopGuard.suspend(); return { stopReason: 'cancelled' }; } @@ -2128,9 +2514,12 @@ export class Session implements SessionContext { pendingSend.signal.reason === USER_CANCEL_ABORT_REASON && this.#isAbortError(error) ) { + this.todoStopGuard.suspend(); return { stopReason: 'cancelled' }; } + this.todoStopGuard.pauseForTrustedRetry(); + // Fire StopFailure hook (fire-and-forget, replaces Stop event for API errors) // Aligned with useGeminiStream.ts handleFinishedWithErrorEvent const errorStatus = getErrorStatus(error); @@ -2199,18 +2588,22 @@ export class Session implements SessionContext { ), ); if (toolRun.stopAfterPermissionCancel) { + this.todoStopGuard.suspend(); await this.#preserveStoppedToolRun( toolRun, pendingSend.signal, ); return { stopReason: 'end_turn' }; } - nextMessage = await this.#buildNextMessageAfterToolRun( - toolRun, - pendingSend.signal, - onFullTurnModel, - ); + const nextAfterTools = + await this.#buildNextMessageAfterToolRun( + toolRun, + pendingSend.signal, + onFullTurnModel, + ); + nextMessage = nextAfterTools.message; if (toolRun.loopDetected) { + this.todoStopGuard.suspend(); await this.#preserveStoppedToolRun( toolRun, pendingSend.signal, @@ -2232,6 +2625,7 @@ export class Session implements SessionContext { promptId, hooksEnabled, messageBus, + true, fullTurnModelOverride, ); } finally { @@ -2251,23 +2645,12 @@ export class Session implements SessionContext { ); } - /** - * Handles the Stop hook iteration loop. - * This method processes Stop hooks after a model response completes with no pending tool calls. - * If a Stop hook requests continuation, it sends a follow-up message and loops back. - * Maximum iterations (100) prevent infinite loops. - * - * @param pendingSend - The abort controller for the current prompt - * @param promptId - The prompt ID for tracking - * @param hooksEnabled - Whether hooks are enabled - * @param messageBus - The MessageBus for hook communication (may be undefined) - * @returns The ACP stop reason for the prompt. - */ async #handleStopHookLoop( pendingSend: AbortController, promptId: string, hooksEnabled: boolean, messageBus: MessageBus | undefined, + allowExternalHooks = true, modelOverride?: string, ): Promise<{ stopReason: PromptResponse['stopReason'] }> { const stopHookBlockingCap = this.config.getStopHookBlockingCap(); @@ -2280,288 +2663,718 @@ export class Session implements SessionContext { modelOverride = model; return true; }; + let midTurnContinuationCount = 0; - while (stopHookIterationCount < stopHookBlockingCap) { - if ( - !hooksEnabled || - !messageBus || - pendingSend.signal.aborted || - !this.config.hasHooksForEvent?.('Stop') - ) { + while (true) { + if (pendingSend.signal.aborted) { + this.todoStopGuard.suspend(); return { stopReason: 'end_turn' }; } - // Extract last model text without cloning the full history. - const responseText = - this.#getCurrentChat().getLastModelMessageText?.() || - '[no response text]'; - - const contextUsage = buildContextUsage( - this.config.getContentGeneratorConfig()?.contextWindowSize ?? - DEFAULT_TOKEN_LIMIT, - this.lastPromptTokenCount, - ); - - const response = await messageBus.request< - HookExecutionRequest, - HookExecutionResponse - >( - { - type: MessageBusType.HOOK_EXECUTION_REQUEST, - eventName: 'Stop', - input: { - stop_hook_active: true, - last_assistant_message: responseText, - ...contextUsage, - }, - signal: pendingSend.signal, - }, - MessageBusType.HOOK_EXECUTION_RESPONSE, - ); - - // Check if aborted after hook execution - if (pendingSend.signal.aborted) { - return { stopReason: 'cancelled' }; + if (this.config.getApprovalMode() === ApprovalMode.PLAN) { + this.#clearTodoStopGuardTrustAndDrainAutomaticQueues(); } - const hookOutput = response.output - ? createHookOutput('Stop', response.output) - : undefined; - - const stopOutput = hookOutput as StopHookOutput | undefined; + if (this.todoStopGuardQueuedPromptPriority) { + return { stopReason: 'end_turn' }; + } - // Emit system message if provided by hook - if (stopOutput?.systemMessage) { - await this.messageEmitter.emitAgentMessage(stopOutput.systemMessage); + if (this.todoStopGuard.needsStopInspection) { + this.#beginTodoStopGuardQueuedPromptCheck(); + const drained = await this.#drainMidTurnInput(pendingSend.signal, { + watchQueuedPromptForTodoStopGuard: true, + onFullTurnModel, + }); + const waitsForQueuedPrompt = this.#finishTodoStopGuardQueuedPromptCheck( + drained.hasQueuedPrompt, + ); + if (drained.parts.length > 0) { + this.todoStopGuard.acceptMidTurnUserInput(); + const continuation = await this.#runStopContinuation( + pendingSend, + promptId + '_mid_turn_' + ++midTurnContinuationCount, + promptId, + drained.parts, + false, + { + onFullTurnModel, + getModelOverride: () => modelOverride, + }, + ); + if (continuation.kind === 'terminal') { + return { stopReason: continuation.stopReason }; + } + continue; + } + if (waitsForQueuedPrompt) { + return { stopReason: 'end_turn' }; + } } - // For Stop hooks, blocking/stop execution should force continuation + let externalReason: string | null = null; + let stopHookCount = 1; + let queuedPromptArrivedDuringStopHook = false; if ( - stopOutput?.isBlockingDecision() || - stopOutput?.shouldStopExecution() + allowExternalHooks && + hooksEnabled && + messageBus && + stopHookIterationCount < stopHookBlockingCap && + this.config.hasHooksForEvent?.('Stop') ) { - const continueReason = stopOutput.getEffectiveReason(); + const responseText = + this.#getCurrentChat().getLastModelMessageText?.() || + '[no response text]'; + const contextUsage = buildContextUsage( + this.config.getContentGeneratorConfig()?.contextWindowSize ?? + DEFAULT_TOKEN_LIMIT, + this.lastPromptTokenCount, + ); + let response: HookExecutionResponse; + try { + response = await messageBus.request< + HookExecutionRequest, + HookExecutionResponse + >( + { + type: MessageBusType.HOOK_EXECUTION_REQUEST, + eventName: 'Stop', + input: { + stop_hook_active: true, + last_assistant_message: responseText, + ...contextUsage, + }, + signal: pendingSend.signal, + }, + MessageBusType.HOOK_EXECUTION_RESPONSE, + ); + } catch (error) { + this.todoStopGuard.pauseForTrustedRetry(); + throw error; + } - // Track Stop hook iterations - stopHookIterationCount++; - stopHookReasons = [...stopHookReasons, continueReason]; + if (pendingSend.signal.aborted) { + this.todoStopGuard.suspend(); + return { stopReason: 'cancelled' }; + } - if (stopHookIterationCount >= stopHookBlockingCap) { - const warning = formatStopHookBlockingCapWarning( - 'Stop', - stopHookBlockingCap, - ); - abortGoalForStopHookCap( - this.config, - this.config.getSessionId(), - warning, - ); - await this.messageEmitter.emitAgentMessage(warning); - debugLogger.warn(warning); - return { stopReason: 'end_turn' }; + if (this.todoStopGuard.needsStopInspection) { + this.#beginTodoStopGuardQueuedPromptCheck(); + const drained = await this.#drainMidTurnInput(pendingSend.signal, { + watchQueuedPromptForTodoStopGuard: true, + onFullTurnModel, + }); + const waitsForQueuedPrompt = + this.#finishTodoStopGuardQueuedPromptCheck(drained.hasQueuedPrompt); + queuedPromptArrivedDuringStopHook = waitsForQueuedPrompt; + if (drained.parts.length > 0) { + this.todoStopGuard.acceptMidTurnUserInput(); + const continuation = await this.#runStopContinuation( + pendingSend, + promptId + '_mid_turn_' + ++midTurnContinuationCount, + promptId, + drained.parts, + false, + { + onFullTurnModel, + getModelOverride: () => modelOverride, + }, + ); + if (continuation.kind === 'terminal') { + return { stopReason: continuation.stopReason }; + } + // The hook already completed. Process its output below so its + // message and cap accounting survive the mid-turn continuation. + } } - if (stopHookIterationCount > 1) { - await this.messageEmitter.emitStopHookLoop( - stopHookIterationCount, - stopHookReasons, - response.stopHookCount ?? 1, - ); + const hookOutput = response.output + ? createHookOutput('Stop', response.output) + : undefined; + const stopOutput = hookOutput as StopHookOutput | undefined; + + if (stopOutput?.systemMessage) { + await this.messageEmitter.emitAgentMessage(stopOutput.systemMessage); } - // Continue the conversation with the hook's reason - const continueParts: Part[] = [{ text: continueReason }]; - let nextMessage: Content | null = { - role: 'user', - parts: continueParts, + if ( + stopOutput?.isBlockingDecision() || + stopOutput?.shouldStopExecution() + ) { + externalReason = stopOutput.getEffectiveReason(); + stopHookIterationCount++; + stopHookReasons = [...stopHookReasons, externalReason]; + stopHookCount = response.stopHookCount ?? 1; + } + } + + const guardDecision = queuedPromptArrivedDuringStopHook + ? null + : this.todoStopGuard.decide( + this.todoStopGuard.needsStopInspection + ? this.#hasRelevantTodoStopGuardBackgroundInput() + : false, + ); + const guardContinuation = + guardDecision?.kind === 'continue' ? guardDecision : null; + + if (guardDecision?.kind === 'exhausted') { + await this.#emitTodoStopGuardExhausted(guardDecision); + if (!externalReason) return { stopReason: 'end_turn' }; + } + + if (externalReason && stopHookIterationCount >= stopHookBlockingCap) { + const warning = formatStopHookBlockingCapWarning( + 'Stop', + stopHookBlockingCap, + ); + abortGoalForStopHookCap( + this.config, + this.config.getSessionId(), + warning, + ); + this.todoStopGuard.suspend(); + await this.messageEmitter.emitAgentMessage(warning); + debugLogger.warn(warning); + return { stopReason: 'end_turn' }; + } + + if (queuedPromptArrivedDuringStopHook) { + return { stopReason: 'end_turn' }; + } + + if (!externalReason && !guardContinuation) { + return { stopReason: 'end_turn' }; + } + + const continueParts: Part[] = []; + if (externalReason) continueParts.push({ text: externalReason }); + if (guardContinuation) { + continueParts.push({ + text: this.#buildTodoStopGuardPrompt(guardContinuation), + }); + } + + const continuationPromptId = externalReason + ? promptId + '_stop_hook_' + stopHookIterationCount + : promptId + '_todo_stop_guard_' + guardContinuation!.attempt; + if (externalReason && stopHookIterationCount > 1 && !guardContinuation) { + await this.messageEmitter.emitStopHookLoop( + stopHookIterationCount, + stopHookReasons, + stopHookCount, + ); + } + const continuation = await this.#runStopContinuation( + pendingSend, + continuationPromptId, + promptId, + continueParts, + stopHookIterationCount > 1 || (guardContinuation?.attempt ?? 0) > 1, + { + ...(guardContinuation ? { guardContinuation } : {}), + ...(externalReason + ? { externalParts: [{ text: externalReason }] } + : {}), + ...(externalReason && stopHookIterationCount > 1 && guardContinuation + ? { + onAutomaticContinuationValidated: () => + this.messageEmitter.emitStopHookLoop( + stopHookIterationCount, + stopHookReasons, + stopHookCount, + ), + } + : {}), + onFullTurnModel, + getModelOverride: () => modelOverride, + }, + ); + if (continuation.supersededAutomaticContinuation && externalReason) { + stopHookIterationCount--; + stopHookReasons = stopHookReasons.slice(0, -1); + } + if (continuation.kind === 'terminal') { + return { stopReason: continuation.stopReason }; + } + } + } + + async #runStopContinuation( + pendingSend: AbortController, + streamPromptId: string, + toolPromptId: string, + parts: Part[], + skipCompression: boolean, + options: { + guardContinuation?: TodoStopGuardContinuation; + externalParts?: Part[]; + onAutomaticContinuationValidated?: () => Promise; + onFullTurnModel?: (model: string) => boolean; + getModelOverride?: () => string | undefined; + } = {}, + ): Promise { + let nextMessage: Content | null = { role: 'user', parts }; + let nextGuardContinuation = options.guardContinuation; + const toolLoopState = createDaemonToolLoopState(); + let initialSend = true; + let automaticContinuationValidated = false; + let supersededAutomaticContinuation = false; + + while (nextMessage !== null) { + if (pendingSend.signal.aborted) { + this.todoStopGuard.suspend(); + return { + kind: 'terminal', + stopReason: 'cancelled', + ...(supersededAutomaticContinuation + ? { supersededAutomaticContinuation: true } + : {}), }; - const toolLoopState = createDaemonToolLoopState(); + } - // Process the follow-up message and any tool calls that result - while (nextMessage !== null) { - if (pendingSend.signal.aborted) { - return { stopReason: 'cancelled' }; - } + const functionCalls: FunctionCall[] = []; + const preparationTracker = new ToolCallPreparationTracker( + this.toolCallEmitter, + ); + let usageMetadata: GenerateContentResponseUsageMetadata | null = null; + const streamStartTime = Date.now(); + let streamFailed = false; + let guardForThisSend = nextGuardContinuation; + let preserveGuardOnSkippedSend = false; + let messageForPreservation = nextMessage; + const externalParts = initialSend ? options.externalParts : undefined; + const promptIdForSend = + guardForThisSend && + guardForThisSend.attempt !== options.guardContinuation?.attempt + ? toolPromptId + '_todo_stop_guard_' + guardForThisSend.attempt + : streamPromptId; + const messageDisplay = this.#createMessageDisplayDispatcher( + pendingSend.signal, + ); - const functionCalls: FunctionCall[] = []; - const preparationTracker = new ToolCallPreparationTracker( - this.toolCallEmitter, - ); - let usageMetadata: GenerateContentResponseUsageMetadata | null = null; - const streamStartTime = Date.now(); - const messageDisplay = this.#createMessageDisplayDispatcher( - pendingSend.signal, - ); + try { + const sendResult = await this.#sendMessageStreamWithAutoCompression( + promptIdForSend, + nextMessage.parts ?? [], + pendingSend.signal, + { + skipCompression: + skipCompression || (guardForThisSend?.attempt ?? 0) > 1, + getModelOverride: options.getModelOverride, + beforeSend: + guardForThisSend || + (!automaticContinuationValidated && + options.onAutomaticContinuationValidated) + ? async ({ compressionFailed }) => { + const inspectGuardPriority = guardForThisSend !== undefined; + const guardCompressionFailed = + inspectGuardPriority && compressionFailed; + + if (inspectGuardPriority) { + this.#beginTodoStopGuardQueuedPromptCheck(); + const drained = await this.#drainMidTurnInput( + pendingSend.signal, + { + watchQueuedPromptForTodoStopGuard: true, + onFullTurnModel: options.onFullTurnModel, + }, + ); + const waitsForQueuedPrompt = + this.#finishTodoStopGuardQueuedPromptCheck( + drained.hasQueuedPrompt, + ); + if (drained.parts.length > 0) { + this.todoStopGuard.acceptMidTurnUserInput(); + guardForThisSend = undefined; + nextGuardContinuation = undefined; + if (initialSend) { + supersededAutomaticContinuation = true; + } + const replacementMessage = initialSend + ? drained.parts + : [ + ...(nextMessage?.parts ?? []).filter( + (part) => + !( + 'text' in part && + isTodoStopGuardPromptText(part.text) + ), + ), + ...drained.parts, + ]; + messageForPreservation = { + role: 'user', + parts: replacementMessage, + }; + return { + kind: 'send', + message: replacementMessage, + }; + } + if (waitsForQueuedPrompt) { + guardForThisSend = undefined; + nextGuardContinuation = undefined; + preserveGuardOnSkippedSend = true; + if (initialSend) { + supersededAutomaticContinuation = true; + } + return { kind: 'stop', stopReason: 'end_turn' }; + } - try { - const continueSendResult = - await this.#sendMessageStreamWithAutoCompression( - promptId + '_stop_hook_' + stopHookIterationCount, - nextMessage?.parts ?? [], - pendingSend.signal, - { - skipCompression: stopHookIterationCount > 1, - modelOverride, - }, - ); - if (!continueSendResult.responseStream) { - this.#preserveUnsentMessageHistory( - nextMessage, - continueSendResult.stopReason === 'cancelled', - ); - return { stopReason: continueSendResult.stopReason }; - } - const continueResponseStream = continueSendResult.responseStream; - nextMessage = null; + if (guardCompressionFailed) { + this.todoStopGuard.suspend(); + guardForThisSend = undefined; + nextGuardContinuation = undefined; + if (!externalParts || externalParts.length === 0) { + preserveGuardOnSkippedSend = true; + return { kind: 'stop', stopReason: 'end_turn' }; + } + } - let streamFailed = false; - try { - for await (const resp of continueResponseStream) { - if (pendingSend.signal.aborted) { - return { stopReason: 'cancelled' }; - } + if ( + guardForThisSend && + this.config.getApprovalMode() === ApprovalMode.PLAN + ) { + this.#clearTodoStopGuardTrustAndDrainAutomaticQueues(); + } + if (guardForThisSend) { + const hasRelevantBackgroundInput = + this.#hasRelevantTodoStopGuardBackgroundInput(); + const refreshedDecision = guardForThisSend.toolClosure + ? this.todoStopGuard.decideToolClosure( + guardForThisSend.attempt - 1, + hasRelevantBackgroundInput, + ) + : this.todoStopGuard.decide( + hasRelevantBackgroundInput, + ); + if ( + refreshedDecision.kind !== 'continue' || + refreshedDecision.attempt !== guardForThisSend.attempt + ) { + guardForThisSend = undefined; + nextGuardContinuation = undefined; + if (!options.externalParts) { + preserveGuardOnSkippedSend = true; + return { kind: 'stop', stopReason: 'end_turn' }; + } + if (!initialSend && nextMessage) { + nextMessage = { + ...nextMessage, + parts: (nextMessage.parts ?? []).filter( + (part) => + !( + 'text' in part && + isTodoStopGuardPromptText(part.text) + ), + ), + }; + } + } + } + } - if ( - resp.type === StreamEventType.CHUNK && - resp.value.candidates && - resp.value.candidates.length > 0 - ) { - const candidate = resp.value.candidates[0]; - for (const part of candidate.content?.parts ?? []) { - if (!part.text) continue; - this.messageEmitter.emitMessage( - part.text, - 'assistant', - part.thought, - ); - if (!part.thought) { - messageDisplay?.addChunk(part.text); + if ( + !automaticContinuationValidated && + options.onAutomaticContinuationValidated + ) { + await options.onAutomaticContinuationValidated(); + automaticContinuationValidated = true; } + const selectedMessage = + guardForThisSend || !externalParts + ? (nextMessage?.parts ?? []) + : externalParts; + messageForPreservation = { + role: 'user', + parts: selectedMessage, + }; + return { + kind: 'send', + message: selectedMessage, + }; } - } - - if ( - resp.type === StreamEventType.CHUNK && - resp.value.usageMetadata - ) { - usageMetadata = resp.value.usageMetadata; - } + : undefined, + }, + ); + if (!sendResult.responseStream) { + if ( + !automaticContinuationValidated && + !supersededAutomaticContinuation && + options.onAutomaticContinuationValidated + ) { + await options.onAutomaticContinuationValidated(); + automaticContinuationValidated = true; + } + if (!preserveGuardOnSkippedSend) { + this.todoStopGuard.suspend(); + } + const preservedParts = (messageForPreservation.parts ?? []).filter( + (part) => !('text' in part && isTodoStopGuardPromptText(part.text)), + ); + this.#preserveUnsentMessageHistory( + preservedParts.length > 0 + ? { ...messageForPreservation, parts: preservedParts } + : null, + sendResult.stopReason === 'cancelled', + ); + return { + kind: 'terminal', + stopReason: sendResult.stopReason, + ...(supersededAutomaticContinuation + ? { supersededAutomaticContinuation: true } + : {}), + }; + } - if (resp.type === StreamEventType.CHUNK) { - await preparationTracker.observe(resp.value); - if (resp.value.functionCalls) { - preparationTracker.resolve(resp.value.functionCalls); - functionCalls.push(...resp.value.functionCalls); - } - } - if ( - resp.type === StreamEventType.RETRY || - resp.type === StreamEventType.MODEL_FALLBACK - ) { - await finalizeToolCallPreparations( - preparationTracker, - true, - `Stop Hook continuation ${resp.type}`, - ); - functionCalls.length = 0; - } - } - } catch (error) { - streamFailed = true; - throw error; - } finally { - await finalizeToolCallPreparations( - preparationTracker, - streamFailed || pendingSend.signal.aborted, - 'Stop Hook continuation', - ); - } - } catch (error) { - // Fire StopFailure hook (fire-and-forget) - const errorStatus = getErrorStatus(error); - const errorMessage = - error instanceof Error ? error.message : String(error); - const errorType = classifyApiError({ - message: errorMessage, - status: errorStatus, - }); + const responseStream = sendResult.responseStream; + nextMessage = null; + initialSend = false; + if (guardForThisSend) { + const guardCommitted = this.todoStopGuard.commitContinuation( + guardForThisSend.attempt, + ); + if (guardCommitted) { + await this.#emitTodoStopGuardContinuation(guardForThisSend); + } + if (!guardCommitted && externalParts) { + guardForThisSend = undefined; + } + } - const hookSystem = this.config.getHookSystem?.(); - const hooksEnabledForStopFailure = - !this.config.getDisableAllHooks?.(); - if ( - hooksEnabledForStopFailure && - hookSystem && - this.config.hasHooksForEvent?.('StopFailure') - ) { - hookSystem - .fireStopFailureEvent(errorType, errorMessage) - .catch((err) => { - debugLogger.warn(`StopFailure hook failed: ${err}`); - }); - } + for await (const response of responseStream) { + if (pendingSend.signal.aborted) { + this.todoStopGuard.suspend(); + return { + kind: 'terminal', + stopReason: 'cancelled', + ...(supersededAutomaticContinuation + ? { supersededAutomaticContinuation: true } + : {}), + }; + } - if (errorStatus === 429) { - throw new RequestError( - 429, - 'Rate limit exceeded. Try again later.', + if ( + response.type === StreamEventType.CHUNK && + response.value.candidates && + response.value.candidates.length > 0 + ) { + const candidate = response.value.candidates[0]; + for (const part of candidate.content?.parts ?? []) { + if (!part.text) continue; + this.messageEmitter.emitMessage( + part.text, + 'assistant', + part.thought, ); + if (!part.thought) messageDisplay?.addChunk(part.text); } - - throw error; - } finally { - // Same contract as the main prompt loop: is_final (skipped on - // abort) is delivered and drained on every exit path. - await messageDisplay?.finish(); } - if (usageMetadata) { - this.#recordPromptTokenCount(usageMetadata); - const durationMs = Date.now() - streamStartTime; - await this.messageEmitter.emitUsageMetadata( - usageMetadata, - '', - durationMs, - ); + if ( + response.type === StreamEventType.CHUNK && + response.value.usageMetadata + ) { + usageMetadata = response.value.usageMetadata; } - - // Process tool calls from the follow-up message - if (functionCalls.length > 0) { - const toolRun = await this.#runWithFullTurnModel( - modelOverride, - () => - this.runToolCalls( - pendingSend.signal, - promptId, - functionCalls, - toolLoopState, - ), - ); - if (toolRun.stopAfterPermissionCancel) { - await this.#preserveStoppedToolRun(toolRun, pendingSend.signal); - return { stopReason: 'end_turn' }; + if (response.type === StreamEventType.CHUNK) { + await preparationTracker.observe(response.value); + if (response.value.functionCalls) { + preparationTracker.resolve(response.value.functionCalls); + functionCalls.push(...response.value.functionCalls); } - nextMessage = await this.#buildNextMessageAfterToolRun( - toolRun, - pendingSend.signal, - onFullTurnModel, + } + if ( + response.type === StreamEventType.RETRY || + response.type === StreamEventType.MODEL_FALLBACK + ) { + await finalizeToolCallPreparations( + preparationTracker, + true, + `daemon continuation ${response.type}`, ); - if (toolRun.loopDetected) { - await this.#preserveStoppedToolRun(toolRun, pendingSend.signal); - return { stopReason: 'end_turn' }; - } + functionCalls.length = 0; } } + } catch (error) { + streamFailed = true; + this.todoStopGuard.pauseForTrustedRetry(); + const errorStatus = getErrorStatus(error); + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorType = classifyApiError({ + message: errorMessage, + status: errorStatus, + }); + const hookSystem = this.config.getHookSystem?.(); + if ( + !this.config.getDisableAllHooks?.() && + hookSystem && + this.config.hasHooksForEvent?.('StopFailure') + ) { + hookSystem + .fireStopFailureEvent(errorType, errorMessage) + .catch((err) => { + debugLogger.warn(`StopFailure hook failed: ${err}`); + }); + } + if (errorStatus === 429) { + throw new RequestError(429, 'Rate limit exceeded. Try again later.'); + } + throw error; + } finally { + try { + await finalizeToolCallPreparations( + preparationTracker, + streamFailed || pendingSend.signal.aborted, + 'daemon continuation', + ); + } finally { + await messageDisplay?.finish(); + } + } - // Loop continues to check Stop hook again after processing the follow-up - continue; + if (usageMetadata) { + this.#recordPromptTokenCount(usageMetadata); + const durationMs = Date.now() - streamStartTime; + await this.messageEmitter.emitUsageMetadata( + usageMetadata, + '', + durationMs, + ); } - // Stop hook allowed stopping, exit the loop - break; + if (functionCalls.length > 0) { + const toolRun = await this.#runWithFullTurnModel( + options.getModelOverride?.(), + () => + this.runToolCalls( + pendingSend.signal, + toolPromptId, + functionCalls, + toolLoopState, + ), + ); + if (toolRun.stopAfterPermissionCancel || toolRun.loopDetected) { + this.todoStopGuard.suspend(); + await this.#preserveStoppedToolRun(toolRun, pendingSend.signal); + return { + kind: 'terminal', + stopReason: 'end_turn', + ...(supersededAutomaticContinuation + ? { supersededAutomaticContinuation: true } + : {}), + }; + } + const nextAfterTools = await this.#buildNextMessageAfterToolRun( + toolRun, + pendingSend.signal, + options.onFullTurnModel, + ); + nextMessage = nextAfterTools.message; + if (nextAfterTools.hadMidTurnUserInput) { + nextGuardContinuation = undefined; + continue; + } + if (guardForThisSend && nextMessage) { + const nextDecision = this.todoStopGuard.decideToolClosure( + guardForThisSend.attempt, + this.#hasRelevantTodoStopGuardBackgroundInput(), + ); + if ( + nextDecision.kind === 'continue' && + nextDecision.attempt > guardForThisSend.attempt + ) { + nextGuardContinuation = nextDecision; + if (!nextDecision.toolClosure) { + nextMessage = { + ...nextMessage, + parts: [ + ...(nextMessage.parts ?? []), + { text: this.#buildTodoStopGuardPrompt(nextDecision) }, + ], + }; + } + } else if ( + nextDecision.kind === 'continue' && + nextDecision.attempt <= guardForThisSend.attempt + ) { + nextGuardContinuation = undefined; + } else if (options.externalParts) { + // This tool loop was also started by an external Stop hook. Once + // the Guard can no longer sponsor another stream, keep the + // pre-existing hook continuation alive without appending another + // Guard prompt or charging another Guard attempt. + nextGuardContinuation = undefined; + } else { + this.#preserveUnsentMessageHistory(nextMessage, true); + return { + kind: 'natural_stop', + ...(supersededAutomaticContinuation + ? { supersededAutomaticContinuation: true } + : {}), + }; + } + } else { + nextGuardContinuation = undefined; + } + } } - return { stopReason: 'end_turn' }; + return { + kind: 'natural_stop', + ...(supersededAutomaticContinuation + ? { supersededAutomaticContinuation: true } + : {}), + }; + } + + #buildTodoStopGuardPrompt(state: TodoStopGuardContinuation): string { + const prompt = `${TODO_STOP_GUARD_PROMPT_PREFIX}${state.unfinishedCount}${TODO_STOP_GUARD_PROMPT_BODY_SUFFIX}`; + if (state.attempt < state.maxAttempts) return prompt; + return prompt + TODO_STOP_GUARD_FINAL_PROMPT_SUFFIX; + } + + async #emitTodoStopGuardContinuation( + state: TodoStopGuardContinuation, + ): Promise { + await this.#emitTodoStopGuardMessageSafely( + `[Todo Stop Guard] Automatic continuation ${state.attempt}/${state.maxAttempts} started; ${state.unfinishedCount} todo item(s) remain unfinished.`, + state, + ); + } + + async #emitTodoStopGuardExhausted( + state: TodoStopGuardContinuation, + ): Promise { + if (!this.todoStopGuard.markExhaustionReported()) return; + await this.#emitTodoStopGuardMessageSafely( + `[Todo Stop Guard] Automatic continuation stopped after ${state.maxAttempts} attempts; ${state.unfinishedCount} todo item(s) remain unfinished.`, + state, + ); + } + + async #emitTodoStopGuardMessageSafely( + text: string, + state: TodoStopGuardContinuation, + ): Promise { + try { + await this.sendUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text }, + _meta: { + source: 'todo_stop_guard', + qwenDiscreteMessage: true, + attempt: state.attempt, + maxAttempts: state.maxAttempts, + unfinishedCount: state.unfinishedCount, + }, + }); + } catch (error) { + debugLogger.warn( + `Failed to emit Todo Stop Guard status: ${this.#formatError(error)}`, + ); + } } async sendUpdate(update: SessionUpdate): Promise { @@ -2590,14 +3403,6 @@ export class Session implements SessionContext { return runWithRuntimeContentGenerator(runtimeView, fn); } - /** - * Mirrors the core send path for ACP model sends. - * - * Attempts automatic chat compression first, checks the session token limit, - * emits an ACP-visible notice when compression succeeds, and returns the ACP - * stop reason when the provider send should be skipped because the request - * was cancelled or the session token limit was exceeded. - */ /** * Create the MessageDisplay hook dispatcher for one model call's streamed * reply, or null when the hook isn't registered (the common case — keeps @@ -2627,16 +3432,35 @@ export class Session implements SessionContext { ); } + /** + * Mirrors the core send path for ACP model sends. + * + * Attempts automatic chat compression first, checks the session token limit, + * emits an ACP-visible notice when compression succeeds, and returns the ACP + * stop reason when the provider send should be skipped because the request + * was cancelled or the session token limit was exceeded. + */ async #sendMessageStreamWithAutoCompression( promptId: string, message: Part[], abortSignal: AbortSignal, - options: { skipCompression?: boolean; modelOverride?: string } = {}, + options: { + skipCompression?: boolean; + modelOverride?: string; + getModelOverride?: () => string | undefined; + beforeSend?: ( + context: BeforeModelSendContext, + ) => Promise; + } = {}, ): Promise { const geminiClient = this.config.getGeminiClient()!; let compressionDiagnostic: string | null = null; let compressionInfo: ChatCompressionInfo | null = null; - if (!options.skipCompression && !options.modelOverride) { + let compressionFailed = false; + if ( + !options.skipCompression && + !(options.getModelOverride?.() ?? options.modelOverride) + ) { try { const compressed = await geminiClient.tryCompressChat( promptId, @@ -2645,6 +3469,9 @@ export class Session implements SessionContext { ); compressionInfo = compressed; this.#recordCompressionTokenCount(compressed); + compressionFailed = isCompressionFailureStatus( + compressed.compressionStatus, + ); if (compressed.compressionStatus === CompressionStatus.COMPRESSED) { // Context was just compacted; a loop.md tick must re-deliver the full // task block (a short reminder refers back to a message that is no @@ -2669,6 +3496,7 @@ export class Session implements SessionContext { `Auto-compression failed for prompt ${promptId}; proceeding without compression: ` + this.#formatError(compressionError), ); + compressionFailed = true; } } @@ -2713,8 +3541,25 @@ export class Session implements SessionContext { return { responseStream: null, stopReason: 'cancelled' }; } + if (options.beforeSend) { + const decision = await options.beforeSend({ compressionFailed }); + if (decision.kind === 'stop') { + return { responseStream: null, stopReason: decision.stopReason }; + } + message = decision.message; + } + + if (abortSignal.aborted) { + debugLogger.debug( + `Send aborted after pre-send validation for prompt ${promptId}`, + ); + return { responseStream: null, stopReason: 'cancelled' }; + } + const responseStream = await this.#getCurrentChat().sendMessageStream( - options.modelOverride ?? this.config.getModel(), + options.getModelOverride?.() ?? + options.modelOverride ?? + this.config.getModel(), { message, config: { @@ -2780,22 +3625,30 @@ export class Session implements SessionContext { toolRun: RunToolResult, abortSignal: AbortSignal, onFullTurnModel?: (model: string) => boolean, - ): Promise { + ): Promise { if (toolRun.loopDetected) { debugLogger.debug('Stopping ACP turn after daemon loop detection.'); - return null; + return { message: null, hadMidTurnUserInput: false }; } if (toolRun.repeatedDuplicateProviderToolCall) { + this.todoStopGuard.suspend(); debugLogger.debug( 'Stopping ACP turn after dropping repeated duplicate provider tool-call response.', ); - return null; + return { message: null, hadMidTurnUserInput: false }; + } + const drained = await this.#drainMidTurnInput(abortSignal, { + onFullTurnModel, + }); + const hadMidTurnUserInput = drained.parts.length > 0; + if (hadMidTurnUserInput) { + this.todoStopGuard.acceptMidTurnUserInput(); } - const parts = [ - ...toolRun.parts, - ...(await this.#drainMidTurnUserMessages(abortSignal, onFullTurnModel)), - ]; - return { role: 'user', parts }; + const parts = [...toolRun.parts, ...drained.parts]; + return { + message: { role: 'user', parts }, + hadMidTurnUserInput, + }; } #recordCompressionTokenCount(info: ChatCompressionInfo): void { @@ -2908,6 +3761,17 @@ export class Session implements SessionContext { abortSignal: AbortSignal, onFullTurnModel?: (model: string) => boolean, ): Promise { + return (await this.#drainMidTurnInput(abortSignal, { onFullTurnModel })) + .parts; + } + + async #drainMidTurnInput( + abortSignal: AbortSignal, + options: { + watchQueuedPromptForTodoStopGuard?: boolean; + onFullTurnModel?: (model: string) => boolean; + } = {}, + ): Promise { // Flush anything recovered from a PRIOR timed-out drain first: the daemon // splices + SSE-publishes synchronously, so on a timeout the browser has // already deduped those messages — discarding the late response would lose @@ -2916,13 +3780,23 @@ export class Session implements SessionContext { const recovered = this.#takeRecoveredMidTurnMessages(); if (this.midTurnDrainUnavailable) { - return this.#buildMidTurnParts(recovered, abortSignal, onFullTurnModel); + return { + parts: await this.#buildMidTurnParts( + recovered, + abortSignal, + options.onFullTurnModel, + ), + hasQueuedPrompt: false, + }; } let drainPromise: ReturnType | undefined; try { drainPromise = this.client.extMethod(MID_TURN_QUEUE_DRAIN_METHOD, { sessionId: this.sessionId, + ...(options.watchQueuedPromptForTodoStopGuard + ? { todoStopGuardWatchQueuedPrompt: true } + : {}), }); let timeoutHandle: NodeJS.Timeout | undefined; const timeoutPromise = new Promise((_, reject) => { @@ -2938,11 +3812,15 @@ export class Session implements SessionContext { clearTimeout(timeoutHandle); } this.midTurnDrainTimeoutStrikes = 0; - return this.#buildMidTurnParts( - [...recovered, ...parseMidTurnDrainResponse(response)], - abortSignal, - onFullTurnModel, - ); + return { + parts: await this.#buildMidTurnParts( + [...recovered, ...parseMidTurnDrainResponse(response)], + abortSignal, + options.onFullTurnModel, + ), + hasQueuedPrompt: + isRecord(response) && response['hasQueuedPrompt'] === true, + }; } catch (error) { // The ACP SDK rejects with the raw JSON-RPC error object // (`{ code, message, data }`), which is not an `Error` instance, so @@ -2992,7 +3870,14 @@ export class Session implements SessionContext { ); // Even on a failed/timed-out drain, still inject anything recovered from // an EARLIER timeout so a transient stall never strands those messages. - return this.#buildMidTurnParts(recovered, abortSignal, onFullTurnModel); + return { + parts: await this.#buildMidTurnParts( + recovered, + abortSignal, + options.onFullTurnModel, + ), + hasQueuedPrompt: false, + }; } } @@ -3134,14 +4019,50 @@ export class Session implements SessionContext { scheduler.start((job: CronFire) => { if (this.cronDisabledByTokenLimit) return; if (job.missed && detectAutonomousSentinel(job.prompt)) return; - this.cronQueue.push({ + this.#enqueueCronPrompt({ prompt: job.prompt, source: job.cronExpr === '@wakeup' ? 'loop' : 'cron', + ...(job.id ? { taskId: job.id } : {}), }); void this.#drainCronQueue(); }); } + #enqueueCronPrompt(item: CronQueueItem): void { + if ( + (this.todoStopGuard.blocksUnrelatedAutomaticTurns || + this.todoStopGuardQueuedPromptPriority) && + !this.#cronContinuesTodoStopGuardWorkChain(item) + ) { + if (item.taskId) { + const duplicateIndex = this.cronQueue.findIndex( + (queued) => + queued.taskId === item.taskId && + !this.#cronContinuesTodoStopGuardWorkChain(queued), + ); + if (duplicateIndex >= 0) { + this.cronQueue[duplicateIndex] = item; + return; + } + } + + const unrelatedIndices = this.cronQueue + .map((queued, index) => + this.#cronContinuesTodoStopGuardWorkChain(queued) ? -1 : index, + ) + .filter((index) => index >= 0); + if (unrelatedIndices.length >= MAX_DEFERRED_UNRELATED_CRON_QUEUE) { + const evictedIndex = unrelatedIndices[0]!; + const [evicted] = this.cronQueue.splice(evictedIndex, 1); + debugLogger.warn( + `Cron queue overflow while automatic work is deferred: evicting task=${evicted?.taskId ?? 'unknown'}`, + ); + } + } + + this.cronQueue.push(item); + } + /** * Processes queued cron prompts one at a time. Uses `cronProcessing` * as a mutex to prevent concurrent access to the chat. @@ -3153,6 +4074,7 @@ export class Session implements SessionContext { // drained after the prompt completes (see end of prompt()). if (this.pendingPrompt) return; if (this.notificationProcessing) return; + if (this.#nextCronQueueIndex() < 0) return; this.cronProcessing = true; let resolveCompletion!: () => void; @@ -3162,7 +4084,10 @@ export class Session implements SessionContext { try { while (this.cronQueue.length > 0) { - const item = this.cronQueue.shift()!; + const nextIndex = this.#nextCronQueueIndex(); + if (nextIndex < 0) break; + const [item] = this.cronQueue.splice(nextIndex, 1); + if (!item) break; await this.#executeCronPrompt(item); } } finally { @@ -3185,6 +4110,15 @@ export class Session implements SessionContext { } } + #nextCronQueueIndex(): number { + if (this.cronQueue.length === 0) return -1; + if (this.todoStopGuardQueuedPromptPriority) return -1; + if (!this.todoStopGuard.blocksUnrelatedAutomaticTurns) return 0; + return this.cronQueue.findIndex((item) => + this.#cronContinuesTodoStopGuardWorkChain(item), + ); + } + #getLoopTickResolver(): LoopTickResolver { const root = this.config.getWorkingDir(); // Rebuild if the working dir changed (e.g. /cd) so loop.md resolves against @@ -3231,6 +4165,9 @@ export class Session implements SessionContext { async () => { const ac = new AbortController(); this.cronAbortController = ac; + this.#prepareTodoStopGuardForAutomaticTurn( + this.#cronContinuesTodoStopGuardWorkChain(item), + ); const promptId = this.config.getSessionId() + '########cron' + Date.now(); let cronHadError = false; @@ -3384,7 +4321,10 @@ export class Session implements SessionContext { while (nextMessage !== null) { turnCount++; - if (ac.signal.aborted) return; + if (ac.signal.aborted) { + this.todoStopGuard.suspend(); + return; + } const functionCalls: FunctionCall[] = []; const preparationTracker = new ToolCallPreparationTracker( @@ -3401,6 +4341,7 @@ export class Session implements SessionContext { ac.signal, ); if (!sendResult.responseStream) { + this.todoStopGuard.suspend(); this.#preserveUnsentMessageHistory( nextMessage, sendResult.stopReason === 'cancelled', @@ -3426,7 +4367,10 @@ export class Session implements SessionContext { let streamFailed = false; try { for await (const resp of responseStream) { - if (ac.signal.aborted) return; + if (ac.signal.aborted) { + this.todoStopGuard.suspend(); + return; + } if ( resp.type === StreamEventType.CHUNK && @@ -3511,21 +4455,41 @@ export class Session implements SessionContext { toolLoopState, ); if (toolRun.stopAfterPermissionCancel) { + this.todoStopGuard.suspend(); await this.#preserveStoppedToolRun(toolRun, ac.signal); return; } - nextMessage = await this.#buildNextMessageAfterToolRun( - toolRun, - ac.signal, - ); + const nextAfterTools = + await this.#buildNextMessageAfterToolRun( + toolRun, + ac.signal, + ); + nextMessage = nextAfterTools.message; if (toolRun.loopDetected) { + this.todoStopGuard.suspend(); await this.#preserveStoppedToolRun(toolRun, ac.signal); return; } } } + if (this.todoStopGuard.needsStopInspection) { + const guardStop = await this.#handleStopHookLoop( + ac, + promptId, + false, + undefined, + false, + ); + if (guardStop.stopReason === 'max_tokens') { + this.#stopCronAfterTokenLimit(); + } + } } catch (error) { - if (ac.signal.aborted) return; + if (ac.signal.aborted) { + this.todoStopGuard.suspend(); + return; + } + this.todoStopGuard.pauseForTrustedRetry(); cronHadError = true; debugLogger.error('Error processing cron prompt:', error); const msg = @@ -3557,6 +4521,7 @@ export class Session implements SessionContext { } #stopCronAfterTokenLimit(): void { + this.todoStopGuard.suspend(); this.cronDisabledByTokenLimit = true; this.cronQueue = []; if (!this.config.isCronEnabled()) return; @@ -3654,9 +4619,33 @@ export class Session implements SessionContext { #enqueueBackgroundNotification(item: BackgroundNotificationQueueItem): void { while (this.notificationQueue.length >= MAX_NOTIFICATION_QUEUE) { - const evicted = this.notificationQueue.shift()!; + let evictedIndex = 0; + if ( + this.todoStopGuard.blocksUnrelatedAutomaticTurns || + this.todoStopGuardQueuedPromptPriority + ) { + const incomingIsRelated = + this.#notificationContinuesTodoStopGuardWorkChain(item); + evictedIndex = this.notificationQueue.findIndex( + (queued) => + !this.#notificationContinuesTodoStopGuardWorkChain(queued), + ); + if (evictedIndex < 0 && !incomingIsRelated) { + debugLogger.warn( + `Notification queue overflow: dropping unrelated task=${item.taskId} kind=${item.kind} while automatic work is deferred`, + ); + return; + } + if (evictedIndex < 0) { + debugLogger.warn( + `Notification queue overflow: dropping related task=${item.taskId} kind=${item.kind} because all queued items are related`, + ); + return; + } + } + const [evicted] = this.notificationQueue.splice(evictedIndex, 1); debugLogger.warn( - `Notification queue overflow: evicting task=${evicted.taskId} kind=${evicted.kind}`, + `Notification queue overflow: evicting task=${evicted?.taskId ?? 'unknown'} kind=${evicted?.kind ?? 'unknown'}`, ); } this.notificationQueue.push(item); @@ -3670,6 +4659,7 @@ export class Session implements SessionContext { return; } if (this.notificationQueue.length === 0) return; + if (this.#nextNotificationQueueIndex() < 0) return; this.notificationProcessing = true; let resolveCompletion!: () => void; @@ -3690,7 +4680,10 @@ export class Session implements SessionContext { // notification carries distinct task metadata (taskId, status, kind, // toolUseId) used in display and response _meta. Merging would // misattribute the combined response to a single task. - const item = this.notificationQueue.shift()!; + const nextIndex = this.#nextNotificationQueueIndex(); + if (nextIndex < 0) break; + const [item] = this.notificationQueue.splice(nextIndex, 1); + if (!item) break; await sessionIdContext.run(this.config.getSessionId(), () => this.#executeBackgroundNotificationPromptInner(item), ); @@ -3713,6 +4706,15 @@ export class Session implements SessionContext { } } + #nextNotificationQueueIndex(): number { + if (this.notificationQueue.length === 0) return -1; + if (this.todoStopGuardQueuedPromptPriority) return -1; + if (!this.todoStopGuard.blocksUnrelatedAutomaticTurns) return 0; + return this.notificationQueue.findIndex((item) => + this.#notificationContinuesTodoStopGuardWorkChain(item), + ); + } + async #executeBackgroundNotificationPromptInner( item: BackgroundNotificationQueueItem, ): Promise { @@ -3722,6 +4724,9 @@ export class Session implements SessionContext { async () => { const ac = new AbortController(); this.notificationAbortController = ac; + this.#prepareTodoStopGuardForAutomaticTurn( + this.#notificationContinuesTodoStopGuardWorkChain(item), + ); const promptId = this.config.getSessionId() + '########notification' + Date.now(); try { @@ -3742,6 +4747,7 @@ export class Session implements SessionContext { while (nextMessage !== null) { if (ac.signal.aborted) { + this.todoStopGuard.suspend(); await this.#emitBackgroundNotificationEndTurn('cancelled'); return; } @@ -3761,6 +4767,7 @@ export class Session implements SessionContext { ac.signal, ); if (!sendResult.responseStream) { + this.todoStopGuard.suspend(); this.#preserveUnsentMessageHistory( nextMessage, sendResult.stopReason === 'cancelled', @@ -3781,6 +4788,7 @@ export class Session implements SessionContext { try { for await (const resp of responseStream) { if (ac.signal.aborted) { + this.todoStopGuard.suspend(); await this.#emitBackgroundNotificationEndTurn('cancelled'); return; } @@ -3879,15 +4887,18 @@ export class Session implements SessionContext { toolLoopState, ); if (toolRun.stopAfterPermissionCancel) { + this.todoStopGuard.suspend(); await this.#preserveStoppedToolRun(toolRun, ac.signal); await this.#emitBackgroundNotificationEndTurn('end_turn'); return; } - nextMessage = await this.#buildNextMessageAfterToolRun( + const nextAfterTools = await this.#buildNextMessageAfterToolRun( toolRun, ac.signal, ); + nextMessage = nextAfterTools.message; if (toolRun.loopDetected) { + this.todoStopGuard.suspend(); await this.#preserveStoppedToolRun(toolRun, ac.signal); await this.#emitBackgroundNotificationEndTurn('end_turn'); return; @@ -3899,12 +4910,26 @@ export class Session implements SessionContext { await this.messageRewriter.waitForPendingRewrites(); } - await this.#emitBackgroundNotificationEndTurn('end_turn'); + let stopReason: PromptResponse['stopReason'] = 'end_turn'; + if (this.todoStopGuard.needsStopInspection) { + stopReason = ( + await this.#handleStopHookLoop( + ac, + promptId, + false, + undefined, + false, + ) + ).stopReason; + } + await this.#emitBackgroundNotificationEndTurn(stopReason); } catch (error) { if (ac.signal.aborted) { + this.todoStopGuard.suspend(); await this.#emitBackgroundNotificationEndTurn('cancelled'); return; } + this.todoStopGuard.pauseForTrustedRetry(); debugLogger.error('Error processing background notification:', error); const msg = error instanceof Error ? error.message : String(error); try { @@ -4087,6 +5112,9 @@ export class Session implements SessionContext { ); } this.config.setApprovalMode(approvalMode); + if (approvalMode === ApprovalMode.PLAN) { + this.clearTodoStopGuardTrust(); + } // A2 (#4511): notify attached clients of an in-session mode switch. // Mirrors the model-update extNotification in `setModel`. @@ -4929,6 +5957,12 @@ export class Session implements SessionContext { // Detect TodoWriteTool early - route to plan updates instead of tool_call events const isTodoWriteTool = tool.name === ToolNames.TODO_WRITE; + // Core exposes TodoWriteTool as a type only. The bundle's keepNames + // preserves this class check; name and kind also reject MCP shadows. + const isTrustedTodoWriteTool = + isTodoWriteTool && + tool.kind === Kind.Think && + tool.constructor.name === 'TodoWriteTool'; const isAgentTool = tool.name === ToolNames.AGENT; const isExitPlanModeTool = tool.name === ToolNames.EXIT_PLAN_MODE; const isEnterPlanModeTool = tool.name === ToolNames.ENTER_PLAN_MODE; @@ -5566,6 +6600,9 @@ export class Session implements SessionContext { this.config.getApprovalMode() !== approvalMode ) { await this.sendCurrentModeUpdateNotification(); + if (this.config.getApprovalMode() === ApprovalMode.PLAN) { + this.#clearTodoStopGuardTrustAndDrainAutomaticQueues(); + } } // Create response parts first (needed for emitResult and recordToolResult) @@ -5601,6 +6638,14 @@ export class Session implements SessionContext { ? new Error('Tool execution was cancelled') : undefined; + if (isTrustedTodoWriteTool && !toolResult.error) { + this.todoStopGuard.observeTodoWrite( + toolResult.returnDisplay, + this.config.getApprovalMode() !== ApprovalMode.PLAN, + ); + if (aborted) this.todoStopGuard.suspend(); + } + // Fire PostToolUse hook on successful execution (aligned with core path) if ( hooksEnabledForTool && @@ -5633,6 +6678,7 @@ export class Session implements SessionContext { debugLogger.info( `PostToolUse hook requested stop for ${toolName}: ${stopMessage}`, ); + this.todoStopGuard.suspend(); return earlyErrorResponse(new Error(stopMessage), toolName); } diff --git a/packages/cli/src/acp-integration/session/daemon-todo-stop-guard.test.ts b/packages/cli/src/acp-integration/session/daemon-todo-stop-guard.test.ts new file mode 100644 index 00000000000..79e747b1cbe --- /dev/null +++ b/packages/cli/src/acp-integration/session/daemon-todo-stop-guard.test.ts @@ -0,0 +1,254 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + DaemonTodoStopGuard, + TODO_STOP_GUARD_MAX_ATTEMPTS, +} from './daemon-todo-stop-guard.js'; + +const pendingResult = { + type: 'todo_list', + todos: [{ id: '1', content: 'finish', status: 'pending' }], +}; + +describe('DaemonTodoStopGuard', () => { + it('arms only from a strict successful Todo result', () => { + const guard = new DaemonTodoStopGuard(true); + + expect(guard.observeTodoWrite({ todos: pendingResult.todos }, true)).toBe( + false, + ); + expect(guard.observeTodoWrite(JSON.stringify(pendingResult), true)).toBe( + false, + ); + expect( + guard.observeTodoWrite( + { type: 'todo_list', todos: [{ status: 'pending' }] }, + true, + ), + ).toBe(false); + expect(guard.decide(false)).toEqual({ kind: 'inactive' }); + + expect(guard.observeTodoWrite(pendingResult, true)).toBe(true); + expect(guard.decide(false)).toMatchObject({ + kind: 'continue', + attempt: 1, + unfinishedCount: 1, + }); + }); + + it('disarms when the latest Todo list is completed or empty', () => { + const guard = new DaemonTodoStopGuard(true); + guard.observeTodoWrite(pendingResult, true); + + guard.observeTodoWrite( + { + type: 'todo_list', + todos: [{ id: '1', content: 'finish', status: 'completed' }], + }, + true, + ); + expect(guard.decide(false)).toEqual({ kind: 'inactive' }); + + guard.observeTodoWrite(pendingResult, true); + guard.observeTodoWrite({ type: 'todo_list', todos: [] }, true); + expect(guard.decide(false)).toEqual({ kind: 'inactive' }); + }); + + it('commits exactly two attempts only when explicitly told', () => { + const guard = new DaemonTodoStopGuard(true); + guard.observeTodoWrite(pendingResult, true); + + expect(guard.decide(false)).toMatchObject({ attempt: 1 }); + expect(guard.decide(false)).toMatchObject({ attempt: 1 }); + guard.commitContinuation(1); + expect(guard.decide(false)).toMatchObject({ attempt: 2 }); + guard.commitContinuation(2); + expect(guard.decide(false)).toEqual({ + kind: 'exhausted', + attempt: 2, + maxAttempts: TODO_STOP_GUARD_MAX_ATTEMPTS, + unfinishedCount: 1, + }); + }); + + it('uses the remaining attempt to close tools after Todo completion', () => { + const guard = new DaemonTodoStopGuard(true); + guard.observeTodoWrite(pendingResult, true); + expect(guard.commitContinuation(1)).toBe(true); + expect(guard.hasCommittedContinuation).toBe(true); + guard.observeTodoWrite({ type: 'todo_list', todos: [] }, true); + + expect(guard.awaitQueuedPrompt()).toBe(true); + expect(guard.decideToolClosure(1, false)).toEqual({ kind: 'inactive' }); + guard.resumeTrustedPrompt(); + expect(guard.decideToolClosure(1, true)).toEqual({ kind: 'deferred' }); + expect(guard.decideToolClosure(1, false)).toEqual({ + kind: 'continue', + attempt: 2, + maxAttempts: TODO_STOP_GUARD_MAX_ATTEMPTS, + unfinishedCount: 0, + toolClosure: true, + }); + expect(guard.commitContinuation(2)).toBe(true); + expect(guard.decideToolClosure(2, false)).toEqual({ kind: 'inactive' }); + }); + + it('does not close completed Todo tools after a hard stop', () => { + const guard = new DaemonTodoStopGuard(true); + guard.observeTodoWrite(pendingResult, true); + expect(guard.commitContinuation(1)).toBe(true); + guard.observeTodoWrite({ type: 'todo_list', todos: [] }, true); + + guard.suspend(); + + expect(guard.decideToolClosure(1, false)).toEqual({ kind: 'inactive' }); + expect(guard.awaitQueuedPrompt()).toBe(false); + expect(guard.commitContinuation(2)).toBe(false); + }); + + it('resets trust for an ordinary prompt but preserves it for retry', () => { + const guard = new DaemonTodoStopGuard(true); + guard.observeTodoWrite(pendingResult, true); + guard.commitContinuation(1); + + guard.resumeTrustedPrompt(); + expect(guard.decide(false)).toMatchObject({ attempt: 2 }); + + guard.startOrdinaryPrompt(); + expect(guard.decide(false)).toEqual({ kind: 'inactive' }); + }); + + it('pauses API failures until a trusted retry resumes the chain', () => { + const guard = new DaemonTodoStopGuard(true); + guard.observeTodoWrite(pendingResult, true); + guard.commitContinuation(1); + guard.pauseForTrustedRetry(); + expect(guard.decide(false)).toEqual({ kind: 'inactive' }); + + guard.resumeTrustedPrompt(); + expect(guard.decide(false)).toMatchObject({ kind: 'continue', attempt: 2 }); + }); + + it('rejects late writes from a superseded prompt', () => { + const guard = new DaemonTodoStopGuard(true); + guard.blockUntilOrdinaryPromptStarts(); + guard.observeTodoWrite(pendingResult, true); + expect(guard.decide(false)).toEqual({ kind: 'inactive' }); + expect(guard.hasTrustedUnfinishedState).toBe(false); + + guard.startOrdinaryPrompt(); + expect(guard.decide(false)).toEqual({ kind: 'inactive' }); + }); + + it('lets mid-turn user input retain activation and reset the budget', () => { + const guard = new DaemonTodoStopGuard(true); + guard.observeTodoWrite(pendingResult, true); + guard.commitContinuation(1); + guard.acceptMidTurnUserInput(); + + expect(guard.decide(false)).toMatchObject({ attempt: 1 }); + }); + + it('gives a mid-turn reactivation a fresh budget after completion', () => { + const guard = new DaemonTodoStopGuard(true); + guard.observeTodoWrite(pendingResult, true); + guard.commitContinuation(1); + guard.observeTodoWrite({ type: 'todo_list', todos: [] }, true); + + guard.acceptMidTurnUserInput(); + guard.observeTodoWrite(pendingResult, true); + + expect(guard.decide(false)).toMatchObject({ attempt: 1 }); + }); + + it('does not let mid-turn user input revive a hard-suspended chain', () => { + const guard = new DaemonTodoStopGuard(true); + guard.observeTodoWrite(pendingResult, true); + guard.commitContinuation(1); + guard.suspend(); + guard.acceptMidTurnUserInput(); + + expect(guard.decide(false)).toEqual({ kind: 'inactive' }); + expect(guard.isHardSuspended).toBe(true); + }); + + it('records a hard stop even when no Todo is currently active', () => { + const guard = new DaemonTodoStopGuard(true); + + guard.suspend(); + guard.observeTodoWrite(pendingResult, true); + + expect(guard.isHardSuspended).toBe(true); + expect(guard.decide(false)).toEqual({ kind: 'inactive' }); + + guard.startOrdinaryPrompt(); + guard.observeTodoWrite(pendingResult, true); + expect(guard.decide(false)).toMatchObject({ kind: 'continue', attempt: 1 }); + }); + + it('defers for background work and locks a suspended work chain', () => { + const guard = new DaemonTodoStopGuard(true); + guard.observeTodoWrite(pendingResult, true); + expect(guard.decide(true)).toEqual({ kind: 'deferred' }); + + guard.suspend(); + guard.observeTodoWrite(pendingResult, true); + expect(guard.decide(false)).toEqual({ kind: 'inactive' }); + expect(guard.blocksUnrelatedAutomaticTurns).toBe(false); + }); + + it('blocks unrelated automatic turns while a chain can still resume', () => { + const guard = new DaemonTodoStopGuard(true); + guard.observeTodoWrite(pendingResult, true); + expect(guard.blocksUnrelatedAutomaticTurns).toBe(true); + + guard.pauseForTrustedRetry(); + expect(guard.blocksUnrelatedAutomaticTurns).toBe(true); + }); + + it('does not let automatic Todo writes revive a chain awaiting a prompt', () => { + const guard = new DaemonTodoStopGuard(true); + guard.observeTodoWrite(pendingResult, true); + guard.awaitQueuedPrompt(); + guard.observeTodoWrite(pendingResult, true); + + expect(guard.decide(false)).toEqual({ kind: 'inactive' }); + }); + + it('terminates an awaiting chain when the queued prompt disappears', () => { + const guard = new DaemonTodoStopGuard(true); + guard.observeTodoWrite(pendingResult, true); + expect(guard.awaitQueuedPrompt()).toBe(true); + + expect(guard.blocksUnrelatedAutomaticTurns).toBe(true); + guard.clearTrust(); + expect(guard.blocksUnrelatedAutomaticTurns).toBe(false); + expect(guard.decide(false)).toEqual({ kind: 'inactive' }); + expect(guard.awaitQueuedPrompt()).toBe(false); + expect(guard.blocksUnrelatedAutomaticTurns).toBe(false); + }); + + it('does not arm while the current mode disallows the guard', () => { + const guard = new DaemonTodoStopGuard(true); + expect(guard.observeTodoWrite(pendingResult, false)).toBe(true); + expect(guard.decide(false)).toEqual({ kind: 'inactive' }); + + guard.observeTodoWrite(pendingResult, true); + expect(guard.decide(false)).toEqual({ kind: 'inactive' }); + + guard.startOrdinaryPrompt(); + guard.observeTodoWrite(pendingResult, true); + expect(guard.decide(false)).toMatchObject({ kind: 'continue', attempt: 1 }); + }); + + it('stays inert when disabled', () => { + const guard = new DaemonTodoStopGuard(false); + expect(guard.observeTodoWrite(pendingResult, true)).toBe(false); + expect(guard.decide(false)).toEqual({ kind: 'inactive' }); + }); +}); diff --git a/packages/cli/src/acp-integration/session/daemon-todo-stop-guard.ts b/packages/cli/src/acp-integration/session/daemon-todo-stop-guard.ts new file mode 100644 index 00000000000..d6fefcab96f --- /dev/null +++ b/packages/cli/src/acp-integration/session/daemon-todo-stop-guard.ts @@ -0,0 +1,252 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +export const TODO_STOP_GUARD_MAX_ATTEMPTS = 2; + +export type TodoStopGuardContinuation = { + attempt: number; + maxAttempts: number; + unfinishedCount: number; + toolClosure?: true; +}; + +export type TodoStopGuardDecision = + | { kind: 'inactive' } + | { kind: 'deferred' } + | ({ kind: 'continue' } & TodoStopGuardContinuation) + | ({ kind: 'exhausted' } & TodoStopGuardContinuation); + +type StructuredTodo = { + id: string; + content: string; + status: 'pending' | 'in_progress' | 'completed'; +}; + +function parseStructuredTodos(value: unknown): StructuredTodo[] | null { + if (typeof value !== 'object' || value === null) return null; + + const record = value as Record; + if (record['type'] !== 'todo_list' || !Array.isArray(record['todos'])) { + return null; + } + + for (const item of record['todos']) { + if (typeof item !== 'object' || item === null) return null; + const todo = item as Record; + if ( + typeof todo['id'] !== 'string' || + typeof todo['content'] !== 'string' || + (todo['status'] !== 'pending' && + todo['status'] !== 'in_progress' && + todo['status'] !== 'completed') + ) { + return null; + } + } + + return record['todos'] as StructuredTodo[]; +} + +export class DaemonTodoStopGuard { + #armed = false; + #unfinishedCount = 0; + #attempts = 0; + #suspended = false; + #retryPaused = false; + #awaitingQueuedPrompt = false; + #exhaustionReported = false; + + constructor(readonly enabled: boolean) {} + + get hasTrustedUnfinishedState(): boolean { + return ( + this.enabled && + this.#armed && + !this.#suspended && + this.#unfinishedCount > 0 + ); + } + + get isHardSuspended(): boolean { + return this.enabled && this.#suspended; + } + + get hasCommittedContinuation(): boolean { + return this.enabled && this.#attempts > 0; + } + + get blocksUnrelatedAutomaticTurns(): boolean { + return ( + this.enabled && + this.#armed && + !this.#suspended && + this.#unfinishedCount > 0 + ); + } + + get needsStopInspection(): boolean { + return ( + this.enabled && + this.#armed && + !this.#suspended && + !this.#retryPaused && + !this.#awaitingQueuedPrompt && + this.#unfinishedCount > 0 + ); + } + + clearTrust(): void { + this.#armed = false; + this.#unfinishedCount = 0; + this.#attempts = 0; + this.#suspended = false; + this.#retryPaused = false; + this.#awaitingQueuedPrompt = false; + this.#exhaustionReported = false; + } + + startOrdinaryPrompt(): void { + this.clearTrust(); + } + + resumeTrustedPrompt(): void { + this.#awaitingQueuedPrompt = false; + this.#retryPaused = false; + } + + blockUntilOrdinaryPromptStarts(): void { + this.clearTrust(); + this.#suspended = true; + } + + acceptMidTurnUserInput(): void { + if (!this.enabled || this.#suspended) return; + if (!this.#armed && this.#attempts === 0) return; + this.#attempts = 0; + this.#retryPaused = false; + this.#awaitingQueuedPrompt = false; + this.#exhaustionReported = false; + } + + observeTodoWrite(resultDisplay: unknown, allowArm: boolean): boolean { + if (!this.enabled) return false; + + const todos = parseStructuredTodos(resultDisplay); + if (todos === null) return false; + + if (!allowArm) { + this.blockUntilOrdinaryPromptStarts(); + return true; + } + + this.#unfinishedCount = todos.filter( + (todo) => todo.status === 'pending' || todo.status === 'in_progress', + ).length; + this.#exhaustionReported = false; + + if (this.#unfinishedCount === 0) { + this.#armed = false; + return true; + } + + if (!this.#suspended) this.#armed = true; + return true; + } + + suspend(): void { + if (!this.enabled) return; + this.#suspended = true; + this.#awaitingQueuedPrompt = false; + } + + pauseForTrustedRetry(): void { + if (!this.#armed) return; + this.#retryPaused = true; + this.#awaitingQueuedPrompt = false; + } + + awaitQueuedPrompt(): boolean { + if ((!this.#armed && this.#attempts === 0) || this.#suspended) return false; + this.#awaitingQueuedPrompt = true; + return true; + } + + decide(hasRelevantBackgroundInput: boolean): TodoStopGuardDecision { + if (!this.needsStopInspection) return { kind: 'inactive' }; + if (hasRelevantBackgroundInput) return { kind: 'deferred' }; + + if (this.#attempts >= TODO_STOP_GUARD_MAX_ATTEMPTS) { + return { + kind: 'exhausted', + attempt: this.#attempts, + maxAttempts: TODO_STOP_GUARD_MAX_ATTEMPTS, + unfinishedCount: this.#unfinishedCount, + }; + } + + return { + kind: 'continue', + attempt: this.#attempts + 1, + maxAttempts: TODO_STOP_GUARD_MAX_ATTEMPTS, + unfinishedCount: this.#unfinishedCount, + }; + } + + decideToolClosure( + currentAttempt: number, + hasRelevantBackgroundInput: boolean, + ): TodoStopGuardDecision { + if (this.#unfinishedCount > 0) { + return this.decide(hasRelevantBackgroundInput); + } + if ( + !this.enabled || + this.#suspended || + this.#retryPaused || + this.#awaitingQueuedPrompt || + this.#attempts !== currentAttempt + ) { + return { kind: 'inactive' }; + } + if (hasRelevantBackgroundInput) return { kind: 'deferred' }; + if (currentAttempt >= TODO_STOP_GUARD_MAX_ATTEMPTS) { + return { kind: 'inactive' }; + } + return { + kind: 'continue', + attempt: currentAttempt + 1, + maxAttempts: TODO_STOP_GUARD_MAX_ATTEMPTS, + unfinishedCount: 0, + toolClosure: true, + }; + } + + commitContinuation(attempt: number): boolean { + const canCloseTools = + this.enabled && + this.#unfinishedCount === 0 && + this.#attempts > 0 && + !this.#suspended && + !this.#retryPaused && + !this.#awaitingQueuedPrompt; + if ( + (!this.needsStopInspection && !canCloseTools) || + attempt !== this.#attempts + 1 || + attempt > TODO_STOP_GUARD_MAX_ATTEMPTS + ) { + return false; + } + this.#attempts = attempt; + return true; + } + + markExhaustionReported(): boolean { + if (this.#exhaustionReported) return false; + this.#exhaustionReported = true; + this.#suspended = true; + return true; + } +} diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 878fc762cfc..91d86d8c54c 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -3170,6 +3170,16 @@ const SETTINGS_SCHEMA = { 'Enable in-session cron/loop tools. When enabled, the model can create recurring prompts using cron_create, cron_list, and cron_delete tools. Can be disabled via QWEN_CODE_DISABLE_CRON=1 environment variable.', showInDialog: true, }, + todoStopGuard: { + type: 'boolean', + label: 'Enable Daemon Todo Stop Guard', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Allow daemon and ACP sessions to continue an unfinished top-level Todo list for at most two consecutive primary-model calls without new user input. Mid-turn user input starts a fresh two-attempt stage. Disabled in safe, bare, and Approval plan modes.', + showInDialog: false, + }, cronRecurringMaxAgeDays: { type: 'number', label: 'Recurring Cron Max Age (Days)', diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 191c6ab10d5..4bbb79b8561 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -2947,6 +2947,11 @@ "type": "boolean", "default": true }, + "todoStopGuard": { + "description": "Allow daemon and ACP sessions to continue an unfinished top-level Todo list for at most two consecutive primary-model calls without new user input. Mid-turn user input starts a fresh two-attempt stage. Disabled in safe, bare, and Approval plan modes.", + "type": "boolean", + "default": false + }, "cronRecurringMaxAgeDays": { "type": "number", "minimum": 0,