detached 8ae9a73

refactor(subagents)!: adopt resident child runtime (#125)

* refactor(subagents)!: adopt resident child runtime

* fix(subagents): ignore legacy state container on startup

* fix(subagents): rebuild stopped children on resume

* fix(subagents): support durable projections on Windows

* fix(subagents): omit unavailable profile tools

* fix(subagents): refresh resident activity views

* fix(subagents): return resident completion summaries

* fix(subagents): raise completion summary limit

* fix(subagents): isolate child credentials and timestamps

* fix(subagents): address resident runtime review findings

* fix(subagents): animate resident activity independently

* fix(ci): restore cross-platform resident checks

* fix resident update delivery failures

* add resident subagent picker shortcut
commit
8ae9a7330fc5cd46d42fc65c5d279a4d7b0b95de
author
bnema · · 2026-08-14 02:21 UTC
committer
GitHub · · 2026-08-14 02:21 UTC
parent
a65a6b0
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
141 files · +8557 -33364

this diff holds invisible characters (direction controls, zero-width characters). They are shown as <U+XXXX> markers, never applied.

M README.md +22 -37
@@ -268,13 +268,13 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r
268268
269269 ### Headless orchestration
270270
271-`--orchestrate` explicitly enables bounded delegation for `-p`/`--print`, `--stream`, or `--json`; ordinary headless runs never delegate implicitly. The parent needs `subagent_spawn`: it is available with the default tool set, or must be named in `--tools subagent_spawn` (and is disabled by `--no-tools`). A packaged agent's `PermissionSet` does not expose `subagent_spawn`, so packaged-agent and profile-worker modes cannot use orchestration. Interactive and RPC modes do not support this flag, and `--stats` cannot be combined with it.
271+`--orchestrate` explicitly enables bounded resident delegation for `-p`/`--print`, `--stream`, or `--json`; ordinary headless runs never delegate implicitly. The parent needs `subagent_spawn`: it is available with the default tool set, or must be named in `--tools subagent_spawn` (and is disabled by `--no-tools`). A packaged agent's `PermissionSet` does not expose `subagent_spawn`, so packaged agents cannot use orchestration. Interactive and RPC modes do not support this flag, and `--stats` cannot be combined with it.
272272
273-Workers inherit the parent CLI provider, model, and reasoning selection. Profile metadata from [docs/subagents.md](docs/subagents.md) is provided to the parent through `[subagents_list]` before selection; only the selected profile body and child-specific provider/model/reasoning configuration are loaded or applied afterward. Print mode writes only the final synthesis; stream mode renders every parent turn to stdout while tool diagnostics remain on stderr; JSON mode retains every parent event as parseable JSONL, including completion updates as existing `user_message` events. Worker output is evidence for the next parent turn, not a host log.
273+Resident children inherit the parent CLI provider, model, and reasoning selection unless a selected profile or spawn explicitly overrides it. Profile metadata from [docs/subagents.md](docs/subagents.md) is provided to the parent through `[subagents_list]` before selection; only the selected profile body and child-specific provider/model/reasoning configuration are loaded or applied afterward. Print mode writes only the final synthesis; stream mode renders every parent turn to stdout while tool diagnostics remain on stderr; JSON mode retains every parent event as parseable JSONL, including completion updates as existing `user_message` events. Child output is evidence for the next parent turn, not a host log.
274274
275-The supervisor enforces configured concurrency, per-parent concurrency, queue/deadline, output, and cancellation-grace limits; a headless invocation permits at most 32 completion follow-up waves. Cancellation stops the parent and workers through the normal graceful shutdown path. Workers cannot spawn descendants. See [docs/subagents.md](docs/subagents.md) for the profile manifest, limits, and unsupported combinations.
275+The resident manager enforces global fair concurrency (six turns by default), optional queue timeout, required-result gating, and cancellation. A headless invocation permits at most 32 completion follow-up waves. Cancellation stops the parent and all resident children through the normal shutdown path. Children cannot spawn descendants. See [docs/subagents.md](docs/subagents.md) for the profile manifest and lifecycle.
276276
277-When an initial print, stream, or JSON request exceeds the provider context window, zut compacts the existing transcript and continues the already-appended prompt once. Print still writes only the recovered final text, stream keeps assistant text on stdout and tool diagnostics on stderr, and JSON suppresses the recoverable first terminal error so stdout remains JSONL for the successful turn. A compaction failure or a second context-window error remains terminal; this is not a general autonomous-follow-up policy. The same one-shot recovery applies to subagent workers. It does not apply to RPC, SDK, bot, or Telegram requests.
277+When an initial print, stream, or JSON request exceeds the provider context window, zut compacts the existing transcript and continues the already-appended prompt once. Print still writes only the recovered final text, stream keeps assistant text on stdout and tool diagnostics on stderr, and JSON suppresses the recoverable first terminal error so stdout remains JSONL for the successful turn. A compaction failure or a second context-window error remains terminal; this is not a general autonomous-follow-up policy. The same one-shot recovery applies to resident child turns. It does not apply to RPC, SDK, bot, or Telegram requests.
278278
279279 ## zutfile agents
280280
@@ -318,7 +318,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
318318 | `/session` | Four ops on the current session: `export` to a portable `.zutsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. `Esc` `Esc` from an idle, empty editor is a shortcut for `/session tree` when the terminal supplies two parsed bare Escape events. |
319319 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
320320 | `/btw` | Side chat with full context that doesn't add to the main thread. |
321-| `/subagents` | Spawn, inspect, and chat with background subagents. Each is a separate long-lived process with structured results, factual execution observations, and optional Git worktree isolation. Named profiles can be selected with `agent`; see [docs/subagents.md](docs/subagents.md). |
321+| `/subagents` | Spawn, inspect, and chat with resident background subagents. Each has a durable journal, structured results, and optional Git worktree isolation. Named profiles can be selected with `agent`; see [docs/subagents.md](docs/subagents.md). |
322322 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |
323323 | `/compact` | Summarize the transcript into one message to free up context. |
324324 | `/study` | Run the canned prompt "Read and understand everything in the current directory." so the agent has full project context before you start asking targeted questions. Pass a path — typed, drag-dropped, or selected via `@` — to target a specific file or directory instead: `/study [dir:packages/]`, `/study cmd/zut/main.go`. |
@@ -352,7 +352,7 @@ Four ops on the current session. `/session` alone opens a picker; each is also r
352352
353353 - **`/session export [path]`**. Writes the running transcript to a portable `.zutsession` file. Default destination is `~/Downloads/<timestamp>-<session-id>-<prompt-slug>.zutsession`. Pass a path to override; a directory is fine (a dated name is built inside), a bare name gets `.zutsession` appended. The meta's cwd is stripped on the way out so the recipient doesn't see your filesystem layout.
354354
355- **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/subagents` subagents are NOT included.** Their transcripts, unix-socket inboxes, and per-agent session files are all machine-local; a `.zutsession` is just a chat transcript and has no way to revive a unix socket on another box. If you want the conversation, copy it out of the dashboard manually.
355+ **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/subagents` child journals are not included.** They remain machine-local managed state; a `.zutsession` is only a main-chat transcript. If you want the child conversation, copy it from the child-session view manually.
356356 - **`/session import <path>`**. Copies a `.zutsession` file into `$ZUT_HOME/sessions/<cwd-hash>/` with a fresh id and the current cwd, then switches the running agent onto it. Imported sessions are first-class: they show up in `/sessions` and `/jump`, and in the current-family tree when they are part of that family. Drag-drop paths in the editor are accepted (zut strips the surrounding quotes automatically).
357357 - **`/fork`** (also available as **`/session fork`**). Opens a turn picker (same shape as `/jump`). Pick any past user message; zut copies the transcript through that turn into a new branch and switches to it without starting a provider request. The parent session stays on disk. Use it to try a different question without polluting the original transcript, or to rewind after the agent went down the wrong path. Use a user-message row in `/session tree` when you want the prompt restored for editing, including image attachments.
358358 - **`/session tree`**. Shows the current session's family: its root ancestor and descendants, not unrelated root sessions in the same cwd. The tree opens immediately while its history loads in the background; rows appear from recent to older content, and `esc` cancels the load or closes the tree. Use `up`/`down` and `enter` to choose an available row. The picker keeps pre-compaction history available as forkable user and assistant messages, while hiding tool calls, tool results, compaction checkpoints, shell/image helper rows, and other internal context. Message rows are indented at their branch points and the current endpoint is tagged `[current]`. Empty and detached branch points are represented by visible boundary rows. A child whose historical fork point no longer matches after compaction stays discoverable as a detached branch under the parent's current tail rather than disappearing. Parentless sessions are roots; orphaned children (whose parent file was deleted) remain roots.
@@ -382,7 +382,9 @@ Inside the overlay: `enter` sends, `esc` cancels an in-flight call (or closes th
382382
383383 ### `/subagents`
384384
385-Background subagents that run alongside your main session. Each one is a separate `zut` subprocess with its own model loop, persistent session file, versioned JSONL control protocol, structured turn result, and lifecycle state. Shared checkout mode preserves the historical behavior; worktree mode creates a temporary detached Git worktree, captures a patch and changed-file list, and never merges automatically.
385+Resident subagents run alongside the main session as independent in-process agent conversations. Each has its own stable session identity, provider client, tool registry, cancellation boundary, durable journal, and structured latest-turn result. Starting one never starts another `zut` process.
386+
387+> **V0.x breaking change:** previous subprocess subagent state is unsupported and ignored. Outstanding jobs are never migrated or replayed; remove the old state directory when you no longer need it.
386388
387389 > **Choose the workspace deliberately.** Use `isolation:"worktree"` for parallel coding so children cannot edit the host checkout. Use shared mode for read-only review or explicitly coordinated work. Worktree isolation is an accident-prevention guardrail, not a security sandbox.
388390
@@ -393,39 +395,22 @@ Background subagents that run alongside your main session. Each one is a separat
393395 /subagents new --reasoning high <task> # set child reasoning
394396 /subagents logs <id> # open one agent's transcript
395397 /subagents send <id> <text> # send a follow-up turn
396-/subagents result <id> # show the structured result reference
397-/subagents resume-session <id> # continue without replaying the task
398-/subagents restart-task <id> # intentionally run the stored task again
399-/subagents kill <id> # cancel gracefully, then force-stop
400-/subagents remove <id> # delete durable state
398+/subagents result <id> # show the bounded final summary
399+/subagents resume <id> <text> # explicitly continue a child session
400+/subagents kill <id> # stop a live child
401401 ```
402402
403-**Dashboard (`/subagents` with no arg)** — a list of every agent for the current session, with its current trace-observed operation (or last fact), age, workspace mode, and task. It does not infer `working`, `alive`, or `idle` from a process lifecycle. Keys:
404-
405-| Key | Action |
406-|---|---|
407-| `↑` / `↓` | Move cursor between rows. |
408-| `enter` | Open the highlighted agent's transcript view. |
409-| `n` | Spawn a new agent (opens an inline task editor; inherits the host's current model). |
410-| `p` | One-off prompt editor for the selected row (without entering the transcript). |
411-| `R` | Resume a stopped agent in place. |
412-| `k` | Kill the selected running agent. Its session and state stay so you can resume it later. |
413-| `r` | Remove the selected agent entirely (session + meta gone). |
414-| `esc` | Close the dashboard. |
415-
416-**Inside an agent's transcript** — a chat overlay with an always-on inline composer at the bottom. The conversation flows above it; type and `enter` to send a follow-up. The view auto-follows streaming output and shows an inline spinner with the agent's current activity (`thinking`, `tool: edit_file`, etc.) while it's busy. `esc` returns to the dashboard.
417-
418-**Switching the spawn model from inside the editor** — while composing a task in the `n`-prompt, type `/model` on its own line and `enter`. The standard `/model` picker pops up; pick a model, the picker closes, and the editor reopens with your typed task intact and the new model pinned for the spawn.
403+**Dashboard (`/subagents` with no arg)** — a bounded list of resident children, ordered by the latest state change. Rows lead with the profile or model, show `completed` rather than the internal success state, a relative update time, and a short ID. Use `up`/`down` then `enter` to open the selected child, or `esc` to close. With any resident child present, `down` from the main composer opens this picker without clearing a draft.
419404
420-**Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back.
405+**Inside an agent's transcript** — the overlay uses the main transcript renderer, combining finalized journal history with an immutable live projection. PgUp loads an older bounded page; Up/Down preserves scrollback and PgDn follows new output. Type a follow-up and press `enter`; the draft stays until the manager durably accepts it. Press `esc` to close.
421406
422-**Persistence across zut restarts** — every spawn writes durable metadata and a session file under `$ZUT_HOME/subagents/agents/<id>/`; a completed or terminated worker writes structured `result.json`. A local execution trace is an optional separate diagnostic bundle. On the next `zut` launch, retained session and result data can be used with `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task.
407+**Lifecycle** — acceptance and finalized transcript records live under managed subagent state as `transcript.jsonl`, with rebuildable `metadata.json`, bounded `result.json` (including up to 256 KiB of final visible assistant text), and optional worktree `patch.diff`. Completion updates carry that bounded final text; open the child transcript for complete history. Host shutdown stops all children. A next launch marks work that was queued or running as `interrupted`; it never replays it. Use an explicit new prompt to resume.
423408
424-**Where state lives** — per-agent metadata, session files, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. When enabled, trace bundles live below `ZUT_SUBAGENT_TRACE_DIR`; `zut debug trace <bundle>` reports the open operation, terminal fact, or last event. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup.
409+The fair global scheduler defaults to six concurrent turns, one active turn per child. `subagents.max_concurrent` overrides that limit. Queueing has no default deadline; a positive `subagents.queue_timeout` durably fails a prompt that waits too long for a slot. Required child work remains an obligation until an explicit successful follow-up.
425410
426-**`/session export` does NOT bundle subagents.** A `.zutsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually.
411+Worktree isolation captures a patch and changed-file list without applying it. Shared children use the host checkout. The available logical references are `subagent://<id>`, `subagent://<id>/history`, `subagent://<id>/result`, and `subagent://<id>/patch`.
427412
428-**Subagent Orchestrator.** With `/settings` → **Subagent Orchestrator** on (or `/orchestrator`), the interactive primary agent enters strict orchestrator mode and gets whichever of the canonical `subagent_spawn` and `subagent_status` tools are permitted by launch-time policy. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. It must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches itself. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize results. Give workers self-contained tasks: shared-worktree workers edit the same working directory and need coordination, while `isolation:"worktree"` is for parallel coding in separate trees; a worktree patch must be integrated by a worker rather than applied by the primary agent. Child workers cannot recursively spawn more sub-agents in v1. The `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns bounded factual metadata: the worker id, current primary operation, last safe observation/event, terminal fact, timestamps, turn counters, a bounded first-line task summary, requirement state, and terminal-result delivery metadata. It omits full prompts, transcripts, result output, provider settings, and filesystem paths. Treat the bounded task summary as session data. The spawn tool always returns the child id and initial state immediately. Set `required:true` when the parent or workflow depends on the result: the worker remains asynchronous, its outcome is persisted and delivered through a host completion update, and failure, timeout, or cancellation remains recoverable until a retry succeeds or the user explicitly removes the terminal worker. An outcome not durably observed before host restart becomes indeterminate and requires the user to inspect side effects before explicitly resuming, restarting, or removing the worker. Completion is a durable `turn.result` with a `subagent://<id>/result` reference. The tool accepts named profiles and shared/worktree isolation. Model steps and delegated turns are counted for observability but are never capped; only an optional manager-owned `subagents.default_timeout` bounds active turn duration. Global concurrency limits apply equally to slash commands, tool calls, and batches. Completion is host-event-driven for every worker through `[auto-subagents update]`, with required state also supplied as high-priority `[required-subagents update]` context. Never use `bash sleep`, `watch`, `tail -f`, polling loops, repeated `subagent_status`, or dashboard/metadata/event-log/file checks solely to wait; work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests remain allowed. Off by default; toggle with `/orchestrator` or from `/settings`. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise performs work itself without the strict orchestrator contract.
413+**Subagent Orchestrator.** With `/settings` → **Subagent Orchestrator** on (or `/orchestrator`), the primary delegates implementation, debugging/testing, and code review to named profiles or explicitly described general children. It coordinates and synthesizes completion updates rather than doing direct implementation. The canonical resident tools remain available for user-requested delegation when the setting is off, subject to launch-time permissions. Do not poll `subagent_status` or journal files solely to wait for completion.
429414
430415 ### Named subagent profiles
431416
@@ -448,7 +433,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
448433 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
449434 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
450435 - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
451-- **TUI settings**: opens a sub-view for input layout in display order: status, working spinner, input, then subagent operations. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. **Running subagent position** places trace-observed open-operation rows above or below the input; it defaults below the input and shows the profile or agent ID, operation, and elapsed operation duration. On a short terminal, omitted rows collapse into one count summary so the editor remains visible. A process lifecycle or heartbeat alone never creates a row. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, `tui_working_position`, and `tui_subagent_position` (`above_input` or `below_input` for the position fields).
436+- **TUI settings**: opens a sub-view for input layout in display order: status, working spinner, input, then subagent operations. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position`.
452437 - **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`.
453438 - **color theme** — choose the built-in auto, inherited (from terminal), dark, or light theme, or any JSON theme discovered under `$ZUT_HOME/themes` or a loaded extension. Built-in and JSON themes follow terminal color capability: truecolor terminals receive RGB output, while RGB values fall back to xterm-256 elsewhere. Theme files can use xterm-256, ANSI, or RGB values for semantic UI colors and can also override syntax colors and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zut resets to auto. See [docs/themes.md](docs/themes.md).
454439 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.
@@ -949,14 +934,14 @@ Type `@` followed by a filter string to narrow the list (e.g. `@read` shows only
949934 | `alt+left`, `alt+right` | Jump one word back or forward. |
950935 | `ctrl+u`, `ctrl+k` | Delete to start or end of line. |
951936 | `ctrl+w`, `alt+backspace` | Delete the previous word. |
952-| `up`, `down` | Move within multi-line input. At the top edge, `up` recalls previous prompts and `down` moves forward through prompt history. |
937+| `up`, `down` | Move within multi-line input. At the top edge, `up` recalls previous prompts and `down` moves forward through prompt history. At the bottom edge, `down` opens the resident-subagent picker when a child exists, preserving the draft. |
953938
954939 ### Chat scroll
955940
956941 | Key | Action |
957942 |---|---|
958943 | `pgup`, `pgdn` | Scroll one page up or down. |
959-| `up`, `down` (editor empty, not browsing prompt history) | Scroll three lines up or down. This is how the mouse wheel reaches the scroll logic on most terminals. |
944+| `up`, `down` (editor empty, not browsing prompt history) | Scroll three lines up or down. When resident children exist, `down` opens their picker first. This is how the mouse wheel reaches the scroll logic on most terminals. |
960945
961946 ## Extensions
962947
@@ -1109,7 +1094,7 @@ packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter i
11091094 packages/agent/modes/telegram/ telegram adapter, api client, daemon
11101095 packages/agent/tools/ read, write, edit, bash, create_worktree, lsp, web search, goals, sandbox
11111096 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
1112-packages/agent/subagents/ named profiles, supervisor, and background runtime
1097+packages/agent/subagents/ named profiles and resident background runtime
11131098 packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk)
11141099 packages/agent/ext/ public Go SDK for writing extensions (package ext)
11151100 ```
M docs/providers.md +35 -5
@@ -142,11 +142,41 @@ Use `/fast` or `/settings` to enable **fast mode** for subsequent model calls.
142142 The setting is off by default and is stored as `fast_mode` in
143143 `$ZUT_HOME/config.json`.
144144
145-Fast mode currently requests OpenAI's Fast service tier for OpenAI, OpenAI
146-Responses, and OpenAI Codex requests. Enabling it while using another provider
147-currently returns an error instead of silently changing that provider's request.
148-Subagent children inherit the setting from their parent unless their profile
149-sets `fastMode` explicitly.
145+Fast mode currently requests OpenAI's Fast service tier for OpenAI Chat,
146+OpenAI Responses, and OpenAI Codex requests. Enabling it while using another
147+provider currently returns an error instead of silently changing that provider's
148+request. Subagent children inherit the setting from their parent unless their
149+profile sets `fastMode` explicitly.
150+
151+## Session identity and caching
152+
153+zut supplies every provider request with a stable logical session ID and a
154+per-prompt turn ID. They are opaque local correlations; adapters translate them
155+only when their exact provider, model, and endpoint declaration supports it.
156+For example, the OpenAI/Codex Responses route uses the stable session as its
157+`prompt_cache_key` and, where required by the declared Codex route, its
158+`session-id` header. A generic OpenAI-compatible endpoint never receives those
159+extensions merely because it accepts an OpenAI-shaped request.
160+
161+### OpenAI Responses WebSocket mode
162+
163+For the exact public endpoint `https://api.openai.com/v1/responses`, zut uses
164+the Responses API WebSocket mode for the OpenAI Responses route. It keeps one
165+connection per logical session, sends one `response.create` at a time, and
166+continues the most recent completed response with `previous_response_id` plus
167+only new tool or user input. The request uses `store:false`; its only
168+provider-specific handshake header is `Authorization: Bearer ...`, never the
169+ChatGPT/Codex account, routing, or session headers.
170+
171+The capability is deliberately not inherited by `--base-url`, compatible
172+providers, Azure, or the ChatGPT Codex subscription endpoint. Those clients
173+continue to use their declared HTTP/SSE contracts. A request without a logical
174+session ID, a failed WebSocket setup,
175+an injected nonstandard RoundTripper, or a configured proxy falls back to
176+HTTP/SSE with the same logical session/cache identity. Cancellation closes
177+only the active session socket; a normally completed turn keeps it warm for
178+the next continuation.
179+
150180
151181 ## Local llama.cpp router
152182
M docs/subagents.md +110 -148
@@ -1,151 +1,127 @@
1-# Named subagent profiles
1+# Resident subagents
22
3-zut can discover reusable named subagent definitions from markdown files with a frontmatter block. This is a common agent-profile layout and is not tied to a particular host application.
3+> **V0.x breaking change:** zut no longer supports subprocess subagent state.
4+> Previous state is ignored rather than read or migrated; remove it when you
5+> no longer need it. Existing child jobs are never resumed.
46
5-## Discovery
7+Subagents are independent `core.Agent` conversations resident in the same zut
8+process as their parent. A child has a stable, private session identity, its own
9+provider client and tool registry, a cancellation boundary, and an authoritative
10+journal. Starting a child never launches another zut executable.
611
7-The default directory is:
12+## Profiles
813
9-```text
10-~/.agents/agents/*.md
11-```
12-
13-zut also reads these optional locations:
14-
15-- directories listed in `ZUT_AGENT_PROFILES` (use the platform path-list separator)
16-- `~/.pi/agent/agents/*.md` as a compatibility fallback
17-
18-Profiles are read-only inputs. zut does not execute files from these directories. Project-local profile directories are not scanned automatically; use `ZUT_AGENT_PROFILES` when a project intentionally opts into an additional profile directory.
19-
20-## File format
14+Profiles are Markdown files discovered from `~/.agents/agents/*.md`, paths in
15+`ZUT_AGENT_PROFILES`, and the `~/.pi/agent/agents/*.md` compatibility discovery
16+location. They are inputs only; zut does not execute them.
2117
2218 ```markdown
2319 ---
2420 name: reviewer
2521 description: Read-only code reviewer
26-tools: read
22+tools: [read, grep]
2723 model: openai-codex/gpt-5.6-luna
28-thinking: max
24+thinking: high
2925 systemPromptMode: replace
3026 inheritProjectContext: false
3127 inheritSkills: false
3228 fastMode: false
3329 ---
3430
35-You are a review worker. Inspect the requested scope, report evidence-backed findings, and do not edit files.
36-```
37-
38-Supported metadata:
39-
40-| Field | Meaning |
41-|---|---|
42-| `name` | Name passed to `subagent_spawn`'s `agent` field. Falls back to the filename. |
43-| `description` | Short description shown to the main agent. |
44-| `tools` | Comma-separated or list-form tool names. zut enforces its built-in `read`, `write`, `edit`, `bash`, `create_worktree`, `lsp`, and `web_search` registry; the conditional `skill` tool is available when skills are enabled. Unknown names do not grant capabilities. |
45-| `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. |
46-| `provider` | Optional separate provider ID for a model without a provider prefix. |
47-| `thinking` | Optional reasoning level: `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, or `max`. This is applied as the child's `reasoning` setting. |
48-| `systemPromptMode` | `append` (default) or `replace`. |
49-| `inheritProjectContext` | Set to `false` to omit `AGENTS.md` context from the child. |
50-| `inheritSkills` | Set to `false` to omit skill discovery and the conditional `skill` loader from the child; `--no-skill` has the same effect for a run. |
51-| `fastMode` | Optional fast-mode override. Omit it to inherit the host setting; `false` disables fast mode for this profile; `true` enables it for this profile even when the host setting is off. |
52-
53-Other frontmatter from another agent host is ignored when zut does not have an equivalent setting. Recursive child spawning is disabled in v1; a worker cannot invoke `subagent_spawn` to create descendants.
54-
55-## Headless orchestration
56-
57-Headless orchestration is explicit: pass `--orchestrate` with `-p`/`--print`, `--stream`, or `--json`. There is no implicit activation. The parent must be allowed to use `subagent_spawn`; the default tool set allows it, while an explicit allowlist must include it (for example, `--tools subagent_spawn`), and `--no-tools` disables it. A packaged agent's `PermissionSet` has no `subagent_spawn` capability, so orchestration is rejected for packaged agents and profile-worker mode. `--orchestrate` is incompatible with `--stats` and unsupported in interactive and RPC modes. Workers are non-recursive: a worker cannot spawn another worker.
58-
59-The parent CLI's `--provider`, `--model`, and `--reasoning` values are inherited by unnamed workers. A named profile can explicitly override provider, model, and reasoning in its frontmatter; the profile's body and other metadata are applied only after the parent selects it. For example:
60-
61-```bash
62-zut -p --orchestrate --provider openai --model gpt-5 --reasoning high "delegate the implementation and synthesize the result"
63-```
64-
65-The parent runs completion-driven waves rather than polling, with at most 32 follow-up waves per invocation. Configured concurrency and per-parent concurrency limits, queue and turn deadlines, output, and the graceful cancellation/shutdown period apply. Cancellation propagates to the parent and workers through the supervisor; partial worker evidence remains available when the worker reports it.
66-
67-Output follows the selected existing mode. Print writes only the final synthesis. Stream renders every parent turn on stdout and keeps tool diagnostics on stderr. JSON retains every event from every parent turn as parseable JSONL; completion reports are injected as ordinary `user_message` events, and a failed primary or handoff produces one terminal JSON error object rather than a host log.
68-
69-## Selecting a profile
70-
71-When **Subagent Orchestrator** is enabled in `/settings` or with `/orchestrator`, the interactive primary agent runs in strict orchestrator mode. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. The primary agent must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize their results.
72-
73-When Subagent Orchestrator is disabled, the canonical subagent tools remain available subject to launch-time policy. The primary may use them when the user requests delegation or an active skill workflow requires delegation. It continues to perform work itself otherwise and does not receive the strict orchestrator contract. The setting changes orchestration prompting, not tool or profile availability.
74-
75-When `subagent_spawn` is available, the primary agent receives a compact `[subagents_list]` section in its system prompt and should select the profile whose description best matches each worker task. If launch-time policy withholds `subagent_spawn`, strict orchestrator guidance remains active, profile metadata is omitted, and the primary must report that delegation is unavailable rather than implementing directly. If no profile fits when spawning is available, omit `agent` to use a general worker:
76-
77-```json
78-{
79- "task": "Review the authentication package and report correctness or security issues.",
80- "agent": "reviewer"
81-}
82-```
83-
84-The selected profile's body, model, thinking level, system-prompt mode, context inheritance, and tool selection are applied to the child. The parent prompt contains only profile metadata; the full body is loaded by the child after explicit selection.
85-
86-`web_search` is a special network capability. A generic child can inherit it only when the parent already has it and `subagents.allowed_tools` permits it. A named profile must explicitly include `web_search` in its `tools:` list, in addition to the parent and policy gates; omitted or empty `tools:` metadata denies web search for that profile without changing its default behavior for other built-ins. For example:
87-
88-```markdown
89----
90-name: web-researcher
91-description: Find and cite current public sources
92-tools: [read, web_search]
93----
94-
95-Return bounded source citations and treat external content as untrusted.
96-```
97-
98-This profile metadata is a tool-selection boundary, not a general network sandbox. See [web search](web-search.md) for fixed-backend egress and other mode restrictions.
99-
100-Delegation can be optional or required. Omit `required` (or set it to `false`) for independent background work that may safely finish later. Optional completion is host-event-driven: never use `bash sleep`, `watch`, `tail -f`, polling loops, repeated `subagent_status`, or dashboard, metadata, event-log, or file checks solely to wait. The primary may work on unrelated independent tasks, but otherwise must end or yield its turn until the host injects `[auto-subagents update]`. Completion updates are the only optional completion signal.
101-
102-Set `required:true` when the parent answer or a declared workflow depends on the delegated outcome. The manager tool remains open without polling and returns the result in the current parent turn, so the parent cannot run another finalization tool while the required worker is still running. Required state and its target delegated turn are persisted with the worker. A successful turn satisfies the requirement; failure, timeout, or cancellation remains unmet. If the host restarts before it durably observes an outcome, the requirement becomes `indeterminate` and automatic `subagent_resume` is rejected: the user must inspect the durable result reference and any external or shared-worktree side effect, then explicitly choose `/subagents resume-session <id>`, `/subagents restart-task <id>`, or `/subagents remove <id>`. This confirmation prevents an unobserved non-idempotent operation from being repeated automatically. While a requirement is unmet, the host permits only manager recovery/status tools and rejects other tool execution and terminal assistant completion. Retry confirmed work successfully before continuing. Removing the terminal worker (`/subagents remove <id>` or `/subagents rm <id>`) is the explicit user waiver.
103-
104-```json
105-{
106- "task": "Inspect the existing release-validation results and report blockers.",
107- "agent": "reviewer",
108- "required": true
109-}
31+Inspect the requested scope and report evidence-backed findings.
11032 ```
11133
112-Legitimate waits inside user-requested commands, provider flows, extensions, or tests are not prohibited.
34+`tools` is a capability boundary. An omitted value inherits the child-safe
35+catalogue, `tools: []` grants no tools, and an explicit list replaces the
36+default. Names unavailable to the child (including host-only extensions) and
37+tools denied by `subagents.allowed_tools` are omitted from an explicit list;
38+they never grant the child additional access. `model`, `provider`, `thinking`,
39+the prompt mode, project/skill inheritance, and `fastMode` are resolved before
40+the child is accepted. A spawn-level model, provider, reasoning, or fast-mode
41+override takes precedence where supplied.
11342
114-Per-spawn reasoning and fast-mode overrides are also accepted:
43+Children are always fresh conversations: they receive the delegated task and
44+resolved host/profile context, never the parent transcript or hidden reasoning.
45+They cannot recursively create more children.
11546
116-```json
117-{
118- "task": "Implement the parser change and add regression tests.",
119- "agent": "implementer",
120- "reasoning": "high",
121- "fast_mode": true
122-}
123-```
124-
125-If `reasoning` is omitted from `subagent_spawn`, the child inherits the host reasoning level for an unnamed spawn, or the selected profile's `thinking` value for a named spawn. An explicit `fast_mode` value takes precedence over the selected profile and host setting; omit it to inherit them.
47+## Lifecycle and persistence
12648
127-The interactive command also supports the same selection explicitly:
49+The manager records each accepted child under its managed state root:
12850
12951 ```text
130-/subagents new --agent reviewer Review the authentication package
131-/subagents new --agent implementer --reasoning high Implement the parser change
52+subagents/<child-id>/
53+ transcript.jsonl # authoritative accepted turns and finalized messages
54+ metadata.json # rebuildable bounded state
55+ result.json # latest state plus a bounded final assistant summary
56+ patch.diff # optional worktree capture
13257 ```
13358
134-Shared mode preserves the historical host working directory, so workers there must be coordinated to avoid conflicting edits and to sequence dependent tasks. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zut creates a detached Git worktree and captures changed files and a patch without merging automatically. In orchestrator mode, assign any worktree patch integration to a worker rather than applying it in the primary session. Named profiles change the child's instructions and configuration; they are not a security sandbox. A profile's `systemPromptMode` controls its own body relative to the built-in identity; globally appended instructions, including enabled Ponytail coding guidance, remain present. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. An explicit `fast_mode` spawn argument has highest precedence. Otherwise, a profile with `fastMode: false` opts out, while `fastMode: true` enables fast mode even when the host setting is off; the `subagent_spawn` result warns the parent session when this profile override occurs. Child providers that do not yet support fast mode return an unsupported-provider error instead of silently ignoring an enabled setting.
135-
136-## Lifecycle, results, and recovery
137-
138-Every child retains narrowly scoped internal process control and durable task/result metadata. These details coordinate startup, cancellation, and recovery; they are not a user-facing progress model. Diagnostic progress comes only from paired trace boundaries and their projection into open operations, terminal facts, or an explicitly insufficient observation.
139-
140-During a trace-observed open operation, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), the most specific open operation, and its elapsed duration. The trace keeps the active turn boundary even after older raw stream fragments leave its bounded in-memory window, so a long-running worker remains visible and its elapsed timer stays anchored to the delegated turn. A recent safe observation is shown alongside it when available, for example `assistant streaming 1s ago`; normal mode never displays message text, reasoning text, tool arguments, or tool output. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted operations collapse into one count summary so the editor remains visible. A process lifecycle, heartbeat, or generic `working` label never creates a row; use `/subagents` for the current factual operation or last observed fact.
141-
142-`/subagents` opens on workers from the current host session. Press **Tab** to switch to the all-sessions history, use **Up/Down** to select a worker, and press **Right** or **l** to expand a bounded live snapshot of its user messages, assistant output, and tool activity (**Left** or **h** collapses it). The dashboard derives its row budget from the terminal height, keeping the selected worker visible on short terminals. Press **Enter** for the complete transcript. `/subagents wait <id>` observes the worker's current delegated turn, not its long-lived daemon process. Canceling that wait only removes the observer; it never stops the worker.
143-
144-The worker and supervisor communicate over newline-delimited JSON. Current messages use version `1` envelopes with `version`, `message_id`, `agent_id`, `turn_id`, `timestamp`, and `payload`. Unknown event names and payload fields are retained. Only versioned JSONL envelopes are accepted on the worker protocol.
145-
146-A completed worker emits a `turn.result` event and writes `result.json`. Intentional cancellation records `shutdown_origin` as `targeted`, `session`, `deadline`, or `process`; manager updates describe that cancellation instead of reporting the worker as generically failed with `context canceled`. Optional manager calls return immediately. Required manager calls remain open until their required outcome is available in the current parent turn. When one manager turn starts multiple workers, their terminal outcomes are delivered as one ordered update after that worker wave is sealed and all registered turns are terminal; an early completion never wakes the manager by itself. Required outcome state is also injected as high-priority `[required-subagents update]` context. The primary remains available for coordination and independent work while optional children run. These host-delivered outcomes, rather than polling or inspecting state, are the signal for the primary to continue. Inline output is bounded; after delivery, inspect the full session through the stable references:
147-
148-If a provider rejects a child request because its payload or context window is too large, the worker compacts its persisted transcript and continues the same request once. If compaction or that retry cannot fit, the result uses error code `context_limit` and directs the caller to narrow the task or reduce gathered context; it does not include the provider's raw request error.
59+Acceptance is durable before `subagent_spawn` reports success. Finalized user,
60+assistant, tool-call, and tool-result messages are journaled; streamed deltas
61+and hidden reasoning are not exposed as ordinary history. A tool call is stored
62+before execution and reconciliation repairs incomplete call/result pairs.
63+
64+All children stop when the host exits. On the next start, queued or running
65+turns are marked `interrupted`; zut never replays their task. Resume only with
66+an explicit new prompt through `subagent_resume`, the child-session composer,
67+or `/subagents resume <id> <prompt>`.
68+
69+The global scheduler admits the oldest eligible accepted prompt, runs at most
70+one turn for each child, and defaults to six concurrent turns. Set
71+`subagents.max_concurrent` to a positive limit to override it. Queueing is
72+unlimited by default; a positive `subagents.queue_timeout` cancels an accepted
73+prompt that has not received a slot and records its terminal failure durably.
74+`subagents.allowed_tools` is an allowlist for child-visible tools and
75+`subagents.allowed_roots` limits eligible child workspaces. Removed legacy
76+settings, including `tui_subagent_position`, are ignored.
77+
78+`required: true` makes a delegated result an obligation of the parent turn.
79+Failed, cancelled, and interrupted required work remains unresolved until an
80+explicit successful follow-up. A host restart never retries it automatically.
81+
82+## Tools and slash commands
83+
84+The model-facing tools retain their logical names:
85+
86+- `subagent_spawn` accepts `task`, optional `agent`, `model` and `provider`,
87+ `reasoning`, `fast_mode`, `required`, and `isolation` (`shared` or
88+ `worktree`). It returns a logical `subagent://<id>` reference.
89+- `subagent_status` returns bounded state for one child or the current set.
90+- `subagent_stop` stops one live child.
91+- `subagent_resume` accepts an explicit follow-up prompt for an existing child.
92+
93+These calls are asynchronous. Completion arrives through the host’s typed
94+completion update, not process polling. Do not use sleep loops, repeated
95+status calls, journal files, or terminal UI inspection as a completion signal.
96+Successful completions include the final visible assistant summary, capped at
97+256 KiB; open the child session for the complete durable transcript.
98+
99+`/subagents` opens the resident-child list, ordered by the latest state change.
100+Each row leads with the profile (or model), shows a human terminal state such
101+as `completed`, a relative update time, and a short ID. Press Down from the
102+main composer to open this list without clearing a draft when a child exists.
103+Use arrows and Enter to open a child session; `/subagents new <task>` creates a child; `/subagents logs <id>`
104+opens its history; `/subagents result <id>` shows the bounded final summary;
105+`kill <id>` stops it; and `resume <id> <prompt>` continues it.
106+
107+The child session uses the same transcript renderer as the main session.
108+Recent history is bounded and loaded asynchronously; PgUp asks for an older
109+page. Up/Down preserves the current reading position, and PgDn returns to the
110+live tail after unread updates accumulate. The screen combines durable finalized
111+history with an immutable in-memory projection of the active turn. Enter submits
112+the local composer only after the manager durably accepts it; Esc closes the session. History may contain task
113+text, tool arguments, tool results, and source-derived output, so treat it as
114+private local session data.
115+
116+## Worktrees and references
117+
118+Shared children use the host working directory. With `isolation: "worktree"`,
119+zut prepares a detached Git worktree, captures a patch and changed-file list on
120+successful completion, and never applies that patch automatically. Failed or
121+interrupted worktrees are retained for inspection; an idle successful worktree
122+is cleaned when its child is explicitly stopped.
123+
124+Logical references avoid exposing runtime paths:
149125
150126 ```text
151127 subagent://<id>
@@ -154,28 +130,14 @@ subagent://<id>/result
154130 subagent://<id>/patch
155131 ```
156132
157-Workers send protocol events to their parent supervisor. The supervisor maintains the live transcript and, when execution tracing is enabled, writes one ordered local trace for the parent/child tree. The trace is diagnostic data, not a second session transcript.
158-
159-Delegated turns have no wall-clock deadline by default. A positive configured `default_timeout` applies only to the active delegated turn; timeout selection is manager-owned and is not exposed to the model-facing spawn tool. When that deadline expires, the worker cancels the turn, writes a failed result with error code `deadline_exceeded`, preserves any output already received, and remains idle so an explicit follow-up can resume its session. Idle time does not consume the next turn's timeout, and each resumed turn receives a fresh deadline. Provider stream-idle detection and retry limits remain independently bounded, so an unlimited turn does not mean a silent connection can hang forever. Process shutdown still allows the configured grace period for the worker to write its session and result before forceful cancellation.
160-
161-Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period.
162-
163-When the relevant manager tool is permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. This includes explicit user-requested delegation and required-work recovery when auto-subagents orchestration is disabled. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use the shared trace projection and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, open operation and its start time when one is observed, the derived `primary_operation`, the last safe live observation and timestamp, a terminal fact or last event, `lifetime_turns` and `current_run_turns`, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result availability/reference/delivery metadata when available. A result is only marked delivered after the parent accepts its required-work notification. It deliberately omits generic lifecycle claims such as `working`, `alive`, `idle`, `process_state`, and `turn_state`, as well as prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data.
164-
165-## Local execution trace
166-
167-Set `ZUT_SUBAGENT_TRACE_DIR` to a private local directory before launching zut to retain an execution-trace bundle for each supervisor. Each bundle contains `manifest.json`, ordered `trace.jsonl`, and (only in detailed mode) private `payloads/` files. The default normal mode records redacted metadata. `ZUT_SUBAGENT_TRACE_MODE=detailed` retains unredacted prompts, tool arguments, and tool output that can contain credentials, secrets, personal data, or private source code. Enable it only when the local bundle can be protected and manually deleted afterwards; do not commit, upload, or otherwise expose trace data. Bundles request restrictive local permissions, but access control remains platform-dependent.\n\nExplicit runtime `TraceDir` and `TraceMode` configuration takes precedence over these environment variables. Environment mode values are case-insensitive (`normal` or `detailed`); an invalid value is ignored with a stderr warning. Detailed mode requires a non-empty trace directory, otherwise tracing remains disabled.
168-
169-Inspect a retained bundle with:
170-
171-```sh
172-zut debug trace <bundle>
173-```
174-
175-Start with the oldest open operation shown by the command. A provider request, tool invocation, or turn is useful evidence; a process that is merely alive without an open operation is reported as `no observable operation` with its last fact, not as progress.
176-
177-Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to start a fresh observable run while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, returns immediately, reports its new terminal outcome asynchronously, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction continue within the current run; multiple active follow-ups are delivered in order. Only same-worker `turn.start` retries have stable in-memory command deduplication; that deduplication is lost when the worker restarts. Do not retry `turn.cancel`, `agent.shutdown`, or `agent.ping` after an ambiguous outcome. After a worker restart, explicitly reconcile durable results and side effects before retrying non-idempotent work. It never replays the original task. Optional and required completion use the normal auto-subagent update; required state is additionally supplied in the required-update context before the parent can finish. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion.
178-
179-## Resource policy
180-
181-The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `queue_timeout`, `startup_timeout`, `default_timeout`, output caps, allowed tools/roots, heartbeat and idle timeouts. Duration strings use Go duration syntax and malformed values fail configuration loading with the field name. A worker fails with an actionable queue-timeout error when no execution slot becomes available before `queue_timeout` (5 minutes by default). Once admitted, it must report `agent_ready` before `startup_timeout` (1 minute by default) or fails with guidance to inspect worker output and retry. Model steps and delegated turns are counted for observability but never capped. A missing or non-positive `default_timeout` leaves each active delegated turn unlimited; a positive value opts into a manager-owned deadline retained across reload and resume. An explicit `subagent_resume` starts a new observable run without replaying the original task or losing session context. Previously saved `max_total_spawned` values are accepted but ignored. Concurrency limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. The shared HTTP streaming providers retry transient request-open failures four times; after opening, a stream may reconnect five times and is canceled after five minutes without an event. Packaged `zut run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary. A primary whose launch-time policy disables delegation still receives the strict orchestrator contract, but does not receive actionable profile metadata or a replacement implementation path.
133+## Provider caching
134+
135+Each main and child conversation sends a stable logical session ID and a new
136+turn ID per accepted prompt to the provider layer. Provider adapters decide
137+whether and how to use them: OpenAI/Codex translates a supported stable session
138+into its cache key and routing header, while other adapters preserve deterministic
139+request input and use only cache controls their provider/model/endpoint supports.
140+An OpenAI-compatible wire shape is not itself evidence that a cache extension
141+or transport feature is available. Models continue through their declared
142+provider transport; a transport change must not change the logical session or
143+turn identity used by the provider request.
M packages/agent/args.go +5 -113
@@ -5,7 +5,6 @@ import (
55 "os"
66 "strconv"
77 "strings"
8- "time"
98
109 "github.com/bnema/zut/packages/agent/subagents"
1110 "github.com/bnema/zut/packages/agent/tools"
@@ -23,13 +22,6 @@ const (
2322 ModeStream Mode = "stream"
2423 ModeJSON Mode = "json"
2524 ModeRPC Mode = "rpc"
26- // ModeSubagentWorker is the long-lived, headless daemon mode used by
27- // subagent-spawned agents. The binary opens a unix-socket inbox at
28- // the path provided by --subagent-worker, reads versioned JSONL
29- // supervisor messages, runs each user turn against a persistent session,
30- // and streams JSONL events on
31- // stdout.
32- ModeSubagentWorker Mode = "subagent-worker"
3325 )
3426
3527 // Args holds parsed command-line options.
@@ -40,24 +32,15 @@ type Args struct {
4032 Model string
4133 APIKey string
4234
43- // inheritedCredential is populated only by subagent-worker mode from
44- // the supervisor's stdin. It is never accepted as a CLI argument.
45- inheritedCredential string
46- inheritedAuthMethod string
47- inheritedAccountID string
48-
4935 BaseURL string // override provider base URL (for tests/self-hosted)
5036 SystemPrompt string
5137 AppendSystemPrompt []string
5238 Reasoning string
5339 Temperature *float32
5440
55- // FastMode is an internal subagent-child propagation flag. Normal users
56- // enable fast mode through the persisted config /settings toggle.
41+ // FastMode is an internal resident-child propagation value.
5742 FastMode bool
58- // FastModeSet distinguishes an explicit child override (including
59- // --no-fast-mode) from an omitted flag so persisted subagent settings
60- // survive a parent config change.
43+ // FastModeSet distinguishes the durable child override from host config.
6144 FastModeSet bool
6245
6346 Continue bool
@@ -76,9 +59,8 @@ type Args struct {
7659 // capability allowlist.
7760 ToolsSet bool
7861
79- // WebSearchPolicy is an internal capability override. Normal CLI args
80- // leave it at Inherit so Resolve applies config and --tools provenance;
81- // SDK, bot, and subagent-worker callers set it explicitly.
62+ // WebSearchPolicy is an internal capability override. CLI users leave it
63+ // at Inherit; host runtimes and SDK callers set it explicitly.
8264 WebSearchPolicy subagents.WebSearchPolicy
8365 MaxSteps int
8466
@@ -142,26 +124,6 @@ type Args struct {
142124 // mode auto-submits it once at startup before InitialInput handling.
143125 StartupPre string
144126
145- // SubagentWorker is the inbox-socket path when this process is a
146- // subagent worker. Empty in every other mode. Set by either
147- // --subagent-worker.
148- SubagentWorker string
149-
150- // SubagentTurnTimeout limits each active delegated turn while leaving the
151- // worker process alive between turns for explicit follow-ups.
152- SubagentTurnTimeout time.Duration
153- // SubagentLifetimeTurns and SubagentRunTurns are supervisor-provided
154- // counters used to resume a worker without losing observability or its
155- // current-run budget. They are internal worker flags, not user policy.
156- SubagentLifetimeTurns int
157- SubagentRunTurns int
158-
159- // Subagent selects a named markdown profile for a subagent child.
160- // It is intentionally an internal child flag; the parent subagent tool
161- // passes only the profile name and the child discovers the definition
162- // locally.
163- Subagent string
164-
165127 // AgentName/AgentDataDir/PermissionSet are populated by `zut run`
166128 // for local zutfile agents. They scope sessions and enforce the
167129 // manifest's declared file/bash permissions.
@@ -174,7 +136,6 @@ type Args struct {
174136 func ParseArgs(in []string) (Args, error) {
175137 a := Args{Mode: ModeInteractive, MaxSteps: 0, WithSkills: true}
176138 positional := []string{}
177- webSearchPolicySet := false
178139
179140 want := func(i *int, flag string) (string, error) {
180141 *i++
@@ -189,8 +150,7 @@ func ParseArgs(in []string) (Args, error) {
189150 switch arg {
190151 case "--":
191152 // Everything after the terminator is prompt text, including
192- // values that begin with a dash. This is used by subagent
193- // workers and is also a conventional CLI escape hatch.
153+ // values that begin with a dash.
194154 positional = append(positional, in[i+1:]...)
195155 i = len(in)
196156 case "-h", "--help":
@@ -310,12 +270,6 @@ func ParseArgs(in []string) (Args, error) {
310270 }
311271 t := float32(f)
312272 a.Temperature = &t
313- case "--fast-mode":
314- a.FastMode = true
315- a.FastModeSet = true
316- case "--no-fast-mode":
317- a.FastMode = false
318- a.FastModeSet = true
319273 case "--stats":
320274 v, err := want(&i, arg)
321275 if err != nil {
@@ -334,35 +288,6 @@ func ParseArgs(in []string) (Args, error) {
334288 return a, err
335289 }
336290 a.CWD = v
337- case "--subagent-worker":
338- v, err := want(&i, arg)
339- if err != nil {
340- return a, err
341- }
342- a.SubagentWorker = v
343- a.Mode = ModeSubagentWorker
344- case "--subagent":
345- v, err := want(&i, arg)
346- if err != nil {
347- return a, err
348- }
349- a.Subagent = v
350- case "--web-search-policy":
351- v, err := want(&i, arg)
352- if err != nil {
353- return a, err
354- }
355- webSearchPolicySet = true
356- switch strings.ToLower(strings.TrimSpace(v)) {
357- case "inherit":
358- a.WebSearchPolicy = subagents.WebSearchInherit
359- case "deny", "off":
360- a.WebSearchPolicy = subagents.WebSearchDeny
361- case "allow", "on":
362- a.WebSearchPolicy = subagents.WebSearchAllow
363- default:
364- return a, fmt.Errorf("--web-search-policy must be inherit|allow|deny")
365- }
366291 case "--tools":
367292 v, err := want(&i, arg)
368293 if err != nil {
@@ -385,36 +310,6 @@ func ParseArgs(in []string) (Args, error) {
385310 return a, fmt.Errorf("--max-steps must be a positive integer")
386311 }
387312 a.MaxSteps = n
388- case "--subagent-turn-timeout":
389- v, err := want(&i, arg)
390- if err != nil {
391- return a, err
392- }
393- timeout, err := time.ParseDuration(v)
394- if err != nil || timeout <= 0 {
395- return a, fmt.Errorf("--subagent-turn-timeout must be a positive duration")
396- }
397- a.SubagentTurnTimeout = timeout
398- case "--subagent-lifetime-turns":
399- v, err := want(&i, arg)
400- if err != nil {
401- return a, err
402- }
403- var n int
404- if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n < 0 {
405- return a, fmt.Errorf("--subagent-lifetime-turns must be a non-negative integer")
406- }
407- a.SubagentLifetimeTurns = n
408- case "--subagent-run-turns":
409- v, err := want(&i, arg)
410- if err != nil {
411- return a, err
412- }
413- var n int
414- if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n < 0 {
415- return a, fmt.Errorf("--subagent-run-turns must be a non-negative integer")
416- }
417- a.SubagentRunTurns = n
418313 default:
419314 if strings.HasPrefix(arg, "-") && arg != "-" {
420315 return a, fmt.Errorf("unknown flag %q", arg)
@@ -427,9 +322,6 @@ func ParseArgs(in []string) (Args, error) {
427322 a.Prompt = strings.Join(positional, " ")
428323 }
429324
430- if webSearchPolicySet && a.Mode != ModeSubagentWorker {
431- return a, fmt.Errorf("--web-search-policy requires --subagent-worker")
432- }
433325 if a.StatsPath != "" && a.Mode != ModePrint {
434326 return a, fmt.Errorf("--stats requires -p or --print")
435327 }
D packages/agent/args_test.go +0 -276
@@ -1,276 +0,0 @@
1-package agent
2-
3-import (
4- "os"
5- "strings"
6- "testing"
7- "time"
8-
9- "github.com/bnema/zut/packages/agent/subagents"
10- "github.com/bnema/zut/packages/agent/tools"
11- "github.com/google/uuid"
12-)
13-
14-func TestParseArgsOrchestrate(t *testing.T) {
15- for _, mode := range []string{"--print", "--stream", "--json"} {
16- args, err := ParseArgs([]string{mode, "--orchestrate", "task"})
17- if err != nil {
18- t.Fatalf("ParseArgs(%q): %v", mode, err)
19- }
20- if !args.Orchestrate {
21- t.Fatalf("ParseArgs(%q): Orchestrate = false", mode)
22- }
23- }
24-}
25-
26-func TestValidateOrchestrationArgs(t *testing.T) {
27- valid := Args{Mode: ModePrint, Orchestrate: true}
28- if err := validateOrchestrationArgs(valid); err != nil {
29- t.Fatalf("valid orchestration args: %v", err)
30- }
31- for _, tc := range []struct {
32- name string
33- args Args
34- want string
35- }{
36- {name: "interactive", args: Args{Mode: ModeInteractive, Orchestrate: true}, want: "print, stream, or JSON"},
37- {name: "rpc", args: Args{Mode: ModeRPC, Orchestrate: true}, want: "RPC"},
38- {name: "worker", args: Args{Mode: ModeSubagentWorker, Orchestrate: true}, want: "subagent-worker"},
39- {name: "profile", args: Args{Mode: ModePrint, Orchestrate: true, Subagent: "reviewer"}, want: "--subagent profiles"},
40- {name: "stats", args: Args{Mode: ModePrint, Orchestrate: true, StatsPath: "stats.json"}, want: "--stats"},
41- {name: "packaged", args: Args{Mode: ModePrint, Orchestrate: true, PermissionSet: &tools.PermissionSet{}}, want: "packaged"},
42- {name: "packaged agent name", args: Args{Mode: ModePrint, Orchestrate: true, AgentName: "bundled"}, want: "packaged"},
43- {name: "packaged agent data dir", args: Args{Mode: ModePrint, Orchestrate: true, AgentDataDir: "/tmp/agent-data"}, want: "packaged"},
44- {name: "missing spawn", args: Args{Mode: ModePrint, Orchestrate: true, ToolsSet: true, Tools: []string{"read"}}, want: "subagent_spawn"},
45- } {
46- t.Run(tc.name, func(t *testing.T) {
47- err := validateOrchestrationArgs(tc.args)
48- if err == nil || !strings.Contains(err.Error(), tc.want) {
49- t.Fatalf("error = %v, want substring %q", err, tc.want)
50- }
51- })
52- }
53-}
54-
55-func TestInvalidOrchestrationRejectsBeforeRuntimeCatalogSideEffects(t *testing.T) {
56- home := t.TempDir()
57- t.Setenv("ZUT_HOME", home)
58-
59- err := runWithArgsRaw([]string{"--orchestrate", "task"}, "test")
60- if err == nil || !strings.Contains(err.Error(), "requires print, stream, or JSON") {
61- t.Fatalf("run error = %v, want mode validation error", err)
62- }
63- entries, readErr := os.ReadDir(home)
64- if readErr != nil {
65- t.Fatal(readErr)
66- }
67- if len(entries) != 0 {
68- t.Fatalf("invalid orchestration created runtime state: %v", entries)
69- }
70-}
71-
72-func TestParseArgsSubagentAndReasoning(t *testing.T) {
73- args, err := ParseArgs([]string{"--subagent-worker", "/tmp/in.sock", "--subagent", "reviewer", "--reasoning", "high", "task"})
74- if err != nil {
75- t.Fatal(err)
76- }
77- if args.Mode != ModeSubagentWorker || args.Subagent != "reviewer" || args.Reasoning != "high" || args.Prompt != "task" {
78- t.Fatalf("parsed args = mode=%q subagent=%q reasoning=%q prompt=%q", args.Mode, args.Subagent, args.Reasoning, args.Prompt)
79- }
80-}
81-
82-func TestParseArgsSubagentTurnTimeout(t *testing.T) {
83- args, err := ParseArgs([]string{"--subagent-worker", "/tmp/in.sock", "--subagent-turn-timeout", "15m"})
84- if err != nil {
85- t.Fatal(err)
86- }
87- if args.SubagentTurnTimeout != 15*time.Minute {
88- t.Fatalf("turn timeout = %s, want %s", args.SubagentTurnTimeout, 15*time.Minute)
89- }
90-
91- for _, value := range []string{"0s", "-1s", "invalid"} {
92- if _, err := ParseArgs([]string{"--subagent-worker", "/tmp/in.sock", "--subagent-turn-timeout", value}); err == nil {
93- t.Errorf("timeout %q unexpectedly accepted", value)
94- }
95- }
96-}
97-
98-func TestParseArgsNoLSP(t *testing.T) {
99- args, err := ParseArgs([]string{"--no-lsp"})
100- if err != nil {
101- t.Fatal(err)
102- }
103- if !args.NoLSP {
104- t.Fatal("--no-lsp did not disable LSP")
105- }
106-}
107-
108-func TestParseArgsResumeOptionalSessionID(t *testing.T) {
109- id := uuid.New()
110-
111- picker, err := ParseArgs([]string{"--resume"})
112- if err != nil {
113- t.Fatal(err)
114- }
115- if !picker.Resume || picker.ResumeSessionID != "" || picker.Prompt != "" {
116- t.Fatalf("no-argument resume = %#v, want picker behavior", picker)
117- }
118-
119- args, err := ParseArgs([]string{"--resume", id.String(), "continue this"})
120- if err != nil {
121- t.Fatal(err)
122- }
123- if !args.Resume || args.ResumeSessionID != id.String() || args.Prompt != "continue this" {
124- t.Fatalf("resume UUID args = %#v", args)
125- }
126-
127- prompt, err := ParseArgs([]string{"--resume", "not-a-session-id"})
128- if err != nil {
129- t.Fatal(err)
130- }
131- if prompt.ResumeSessionID != "" || prompt.Prompt != "not-a-session-id" {
132- t.Fatalf("non-UUID resume argument = %#v, want existing prompt behavior", prompt)
133- }
134-}
135-
136-func TestParseArgsAllowsLeadingDashPromptAfterTerminator(t *testing.T) {
137- args, err := ParseArgs([]string{"--subagent-worker", "/tmp/in.sock", "--", "--inspect", "auth flow"})
138- if err != nil {
139- t.Fatal(err)
140- }
141- if args.Prompt != "--inspect auth flow" {
142- t.Fatalf("prompt = %q, want leading-dash task preserved", args.Prompt)
143- }
144-}
145-
146-func TestParseArgsTemperatureAllowsZero(t *testing.T) {
147- args, err := ParseArgs([]string{"--temperature", "0"})
148- if err != nil {
149- t.Fatalf("ParseArgs returned %v", err)
150- }
151- if args.Temperature == nil || *args.Temperature != 0 {
152- t.Fatalf("Temperature = %v; want 0", args.Temperature)
153- }
154-}
155-
156-func TestParseArgsTemperatureRejectsOutOfRange(t *testing.T) {
157- if _, err := ParseArgs([]string{"--temperature", "2.1"}); err == nil {
158- t.Fatal("ParseArgs accepted out-of-range temperature")
159- }
160-}
161-
162-func TestParseArgsYes(t *testing.T) {
163- for _, flag := range []string{"-y", "--yes"} {
164- args, err := ParseArgs([]string{flag, "--print", "hi"})
165- if err != nil {
166- t.Fatalf("ParseArgs(%q): %v", flag, err)
167- }
168- if !args.Yes {
169- t.Fatalf("ParseArgs(%q): Yes = false", flag)
170- }
171- if args.Mode != ModePrint || args.Prompt != "hi" {
172- t.Fatalf("ParseArgs(%q): Mode=%q Prompt=%q", flag, args.Mode, args.Prompt)
173- }
174- }
175-}
176-
177-func TestParseArgsStatsRequiresPrintMode(t *testing.T) {
178- args, err := ParseArgs([]string{"-p", "--stats", "stats.json", "hi"})
179- if err != nil {
180- t.Fatal(err)
181- }
182- if args.StatsPath != "stats.json" || args.Mode != ModePrint {
183- t.Fatalf("StatsPath=%q Mode=%q", args.StatsPath, args.Mode)
184- }
185-
186- if _, err := ParseArgs([]string{"--stats", "stats.json", "hi"}); err == nil {
187- t.Fatal("ParseArgs accepted --stats without print mode")
188- }
189-}
190-
191-func TestParseArgsNoContextFiles(t *testing.T) {
192- for _, flag := range []string{"--no-context-files", "-nc"} {
193- args, err := ParseArgs([]string{flag})
194- if err != nil {
195- t.Fatalf("ParseArgs(%q): %v", flag, err)
196- }
197- if !args.NoContextFiles {
198- t.Fatalf("ParseArgs(%q): NoContextFiles = false", flag)
199- }
200- }
201-}
202-
203-func TestParseArgsStream(t *testing.T) {
204- args, err := ParseArgs([]string{"--stream", "hi"})
205- if err != nil {
206- t.Fatal(err)
207- }
208- if args.Mode != ModeStream || args.Prompt != "hi" {
209- t.Fatalf("Mode=%q Prompt=%q", args.Mode, args.Prompt)
210- }
211-}
212-
213-func TestParseArgsWebSearchPolicyIsWorkerOnlyRegardlessOfOrder(t *testing.T) {
214- for _, argv := range [][]string{
215- {"--web-search-policy", "allow"},
216- {"--web-search-policy", "allow", "--subagent-worker", "/tmp/in.sock", "--print"},
217- {"--subagent-worker", "/tmp/in.sock", "--web-search-policy", "deny", "--json"},
218- } {
219- if _, err := ParseArgs(argv); err == nil {
220- t.Fatalf("ordinary CLI mode accepted internal policy from %q", argv)
221- }
222- }
223-
224- for _, tc := range []struct {
225- name string
226- argv []string
227- policy subagents.WebSearchPolicy
228- }{
229- {
230- name: "policy before worker",
231- argv: []string{"--web-search-policy", "allow", "--subagent-worker", "/tmp/in.sock", "--tools", "web_search"},
232- policy: subagents.WebSearchAllow,
233- },
234- {
235- name: "policy after worker",
236- argv: []string{"--print", "--subagent-worker", "/tmp/in.sock", "--web-search-policy", "deny"},
237- policy: subagents.WebSearchDeny,
238- },
239- } {
240- t.Run(tc.name, func(t *testing.T) {
241- worker, err := ParseArgs(tc.argv)
242- if err != nil {
243- t.Fatal(err)
244- }
245- if worker.Mode != ModeSubagentWorker || worker.WebSearchPolicy != tc.policy {
246- t.Fatalf("worker policy args = %#v", worker)
247- }
248- })
249- }
250-}
251-
252-func TestParseArgsTracksExplicitToolListProvenance(t *testing.T) {
253- absent, err := ParseArgs(nil)
254- if err != nil {
255- t.Fatal(err)
256- }
257- if absent.ToolsSet || len(absent.Tools) != 0 {
258- t.Fatalf("absent --tools = %#v", absent)
259- }
260-
261- empty, err := ParseArgs([]string{"--tools", ""})
262- if err != nil {
263- t.Fatal(err)
264- }
265- if !empty.ToolsSet || len(empty.Tools) != 0 {
266- t.Fatalf("empty --tools = %#v", empty)
267- }
268-
269- selected, err := ParseArgs([]string{"--tools", "read, web_search"})
270- if err != nil {
271- t.Fatal(err)
272- }
273- if !selected.ToolsSet || len(selected.Tools) != 2 || selected.Tools[1] != "web_search" {
274- t.Fatalf("selected --tools = %#v", selected)
275- }
276-}
M packages/agent/build.go +13 -75
@@ -424,44 +424,16 @@ func findSubagentProfile(cwd, name string) (*subagents.Profile, error) {
424424 return profile, nil
425425 }
426426
427-func applySubagentProfile(args *Args, profile *subagents.Profile) {
428- if args == nil || profile == nil {
429- return
430- }
431- profileProvider, profileModel := profile.ModelSelection()
432- if args.Provider == "" && profileProvider != "" {
433- args.Provider = profileProvider
434- }
435- if args.Model == "" && profileModel != "" {
436- args.Model = profileModel
437- }
438- if args.Reasoning == "" && profile.Thinking != "" {
439- // Keep an explicit "off" value non-empty so it overrides a
440- // persisted global reasoning setting when the child resolves.
441- args.Reasoning = strings.TrimSpace(profile.Thinking)
442- }
443- if !args.NoTools && !args.ToolsSet && len(args.Tools) == 0 && len(profile.Tools) > 0 {
444- args.Tools = append([]string(nil), profile.Tools...)
445- }
446- if profile.InheritSkills != nil && !*profile.InheritSkills {
447- args.NoSkill = true
448- }
449-}
450-
451427 // resolveWebSearchPolicy applies capability precedence at the owning resolver
452428 // boundary. Permission, tool-list, and named-profile restrictions are ceilings
453-// on every caller-provided policy. Workers additionally require an explicit,
454-// valid propagated decision rather than falling back to their local config.
455-func resolveWebSearchPolicy(args Args, cfg Config, cfgErr error, profile *subagents.Profile) subagents.WebSearchPolicy {
429+// on every caller-provided policy.
430+func resolveWebSearchPolicy(args Args, cfg Config, cfgErr error) subagents.WebSearchPolicy {
456431 if args.NoTools || args.PermissionSet != nil {
457432 return subagents.WebSearchDeny
458433 }
459434 if args.ToolsSet && !toolListContains(args.Tools, "web_search") {
460435 return subagents.WebSearchDeny
461436 }
462- if profile != nil && subagents.NamedWebSearchPolicy(profile.Tools) != subagents.WebSearchAllow {
463- return subagents.WebSearchDeny
464- }
465437
466438 switch args.WebSearchPolicy {
467439 case subagents.WebSearchDeny:
@@ -474,18 +446,12 @@ func resolveWebSearchPolicy(args Args, cfg Config, cfgErr error, profile *subage
474446 default:
475447 return subagents.WebSearchDeny
476448 }
477- if args.Mode == ModeSubagentWorker {
478- return subagents.WebSearchDeny
479- }
480449 if args.ToolsSet {
481450 return subagents.WebSearchAllow
482451 }
483452 if cfgErr != nil {
484453 return subagents.WebSearchDeny
485454 }
486- if profile != nil {
487- return subagents.WebSearchAllow
488- }
489455 if cfg.WebSearchEnabledForCLI() {
490456 return subagents.WebSearchAllow
491457 }
@@ -503,8 +469,7 @@ func toolListContains(names []string, target string) bool {
503469
504470 // webSearchAllowedForRegistry is kept separate from the resolver so direct
505471 // registry tests and callers that build a registry without persisted config
506-// still get the normal default-on behavior. Worker registries fail closed if
507-// they have not first received a resolved allow decision.
472+// still get the normal default-on behavior.
508473 func webSearchAllowedForRegistry(args Args) bool {
509474 if args.NoTools || args.PermissionSet != nil {
510475 return false
@@ -518,7 +483,7 @@ func webSearchAllowedForRegistry(args Args) bool {
518483 case subagents.WebSearchDeny:
519484 return false
520485 default:
521- return args.Mode != ModeSubagentWorker
486+ return true
522487 }
523488 }
524489
@@ -536,16 +501,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
536501 if len(args.Tools) > 0 {
537502 args.ToolsSet = true
538503 }
539- var selectedProfile *subagents.Profile
540- if strings.TrimSpace(args.Subagent) != "" {
541- var err error
542- selectedProfile, err = findSubagentProfile(args.CWD, args.Subagent)
543- if err != nil {
544- return Resolved{}, err
545- }
546- applySubagentProfile(&args, selectedProfile)
547- }
548- webSearchPolicy := resolveWebSearchPolicy(args, cfg, cfgErr, selectedProfile)
504+ webSearchPolicy := resolveWebSearchPolicy(args, cfg, cfgErr)
549505 args.WebSearchPolicy = webSearchPolicy
550506
551507 // User-requested provider (explicit > config > default).
@@ -588,11 +544,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
588544 accountID string
589545 credErr error
590546 )
591- if args.inheritedCredential != "" {
592- cred = args.inheritedCredential
593- method = args.inheritedAuthMethod
594- accountID = args.inheritedAccountID
595- } else if provName == "ollama" {
547+ if provName == "ollama" {
596548 cred = firstNonEmpty(args.APIKey, "ollama")
597549 method = "apikey"
598550 } else {
@@ -807,9 +759,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
807759 if args.PermissionSet != nil {
808760 sandbox.SetPermissions(args.PermissionSet)
809761 }
810- subagentSession := args.Mode == ModeSubagentWorker || strings.TrimSpace(args.Subagent) != ""
811- lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(subagentSession)
812- reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(subagentSession), cfg.LSPDiagnosticsOnEditEnabled(subagentSession))
762+ lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(false)
763+ reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(false), cfg.LSPDiagnosticsOnEditEnabled(false))
813764
814765 docsDir, _ := zutdocs.EnsureInstalled(ZutHome())
815766
@@ -827,7 +778,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
827778 skillAddendum string
828779 skillsEnabled bool
829780 )
830- if !args.NoSkill && (selectedProfile == nil || selectedProfile.InheritSkills == nil || *selectedProfile.InheritSkills) {
781+ if !args.NoSkill {
831782 skillsEnabled = true
832783 homeDir, _ := os.UserHomeDir()
833784 discovered, _ = skills.Discover(ZutHome(), args.CWD, homeDir, args.WithSkills)
@@ -842,7 +793,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
842793 summaries := toolSummaries(reg, args)
843794
844795 contextFiles := []ContextFile(nil)
845- if !args.NoContextFiles && (selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext) {
796+ if !args.NoContextFiles {
846797 contextFiles = loadAgentsContext(args.CWD, ZutHome())
847798 }
848799 append_ := []string(nil)
@@ -859,8 +810,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
859810 append_ = append(append_, skillAddendum)
860811 }
861812 interactiveMode := args.Mode == "" || args.Mode == ModeInteractive
862- primaryInteractive := selectedProfile == nil && interactiveMode
863- primaryOrchestrator := selectedProfile == nil && args.Orchestrate
813+ primaryInteractive := interactiveMode
814+ primaryOrchestrator := args.Orchestrate
864815 if (primaryInteractive || primaryOrchestrator) && autoSubagentsToolAllowed(args) {
865816 homeDir, _ := os.UserHomeDir()
866817 profiles, _ := subagents.Discover(args.CWD, homeDir)
@@ -885,18 +836,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
885836 } else if primaryInteractive && autoSubagentsAnyToolAllowed(args) {
886837 append_ = append(append_, OnDemandSubagentsSystemAddendum)
887838 }
888- if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" {
889- append_ = append(append_, selectedProfile.SystemPrompt)
890- }
891-
892839 // Custom system prompt resolution order:
893840 // 1. --system-prompt flag (highest priority; ad-hoc per run)
894841 // 2. $ZUT_HOME/SYSTEM.md (persistent user override)
895842 // 3. built-in default (defaultIdentity + defaultGuidelines)
896843 custom := args.SystemPrompt
897- if selectedProfile != nil && selectedProfile.SystemPromptMode == "replace" {
898- custom = selectedProfile.SystemPrompt
899- } else if custom == "" {
844+ if custom == "" {
900845 custom = readUserSystemPrompt(ZutHome())
901846 }
902847
@@ -917,13 +862,6 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
917862 if args.FastModeSet {
918863 fastMode = args.FastMode
919864 }
920- // A profile's explicit fastMode setting overrides the global setting
921- // for this child. This preserves fastMode: false as an opt-out while
922- // allowing fastMode: true to request the fast tier for a focused worker.
923- if selectedProfile != nil && selectedProfile.FastMode != nil {
924- fastMode = *selectedProfile.FastMode
925- }
926-
927865 max := args.MaxSteps // 0 = unlimited
928866
929867 return Resolved{
D packages/agent/build_test.go +0 -1307
@@ -1,1307 +0,0 @@
1-package agent
2-
3-import (
4- "context"
5- "fmt"
6- "net/http"
7- "net/http/httptest"
8- "os"
9- "path/filepath"
10- "strings"
11- "testing"
12-
13- "github.com/bnema/zut/packages/agent/skills"
14- "github.com/bnema/zut/packages/agent/subagents"
15- "github.com/bnema/zut/packages/agent/tools"
16- "github.com/bnema/zut/packages/core"
17- "github.com/bnema/zut/packages/provider"
18-)
19-
20-type bundledSkillSource struct {
21- bundled []*skills.Skill
22-}
23-
24-func (s bundledSkillSource) Tools() []ExtensionToolInfo { return nil }
25-func (s bundledSkillSource) NewExtensionTool(ExtensionToolInfo) core.Tool {
26- return nil
27-}
28-func (s bundledSkillSource) Skills() []*skills.Skill { return s.bundled }
29-
30-type webSearchConflictSource struct{}
31-
32-func (webSearchConflictSource) Tools() []ExtensionToolInfo {
33- return []ExtensionToolInfo{{Extension: "test", Name: "web_search"}}
34-}
35-func (webSearchConflictSource) NewExtensionTool(ExtensionToolInfo) core.Tool {
36- return &tools.ReadTool{}
37-}
38-
39-type updateGoalConflictSource struct{}
40-
41-func (updateGoalConflictSource) Tools() []ExtensionToolInfo {
42- return []ExtensionToolInfo{{Extension: "test", Name: "update_goal"}}
43-}
44-func (updateGoalConflictSource) NewExtensionTool(ExtensionToolInfo) core.Tool {
45- return &tools.ReadTool{}
46-}
47-
48-type unorderedExtensionSource struct{}
49-
50-func (unorderedExtensionSource) Tools() []ExtensionToolInfo {
51- return []ExtensionToolInfo{
52- {Extension: "test", Name: "zeta"},
53- {Extension: "test", Name: "alpha"},
54- }
55-}
56-func (unorderedExtensionSource) NewExtensionTool(ExtensionToolInfo) core.Tool {
57- return &tools.ReadTool{}
58-}
59-
60-func TestMergeExtensionToolsCreatesSkillToolWhenDiscoveryFoundNoSkills(t *testing.T) {
61- r := Resolved{
62- CWD: t.TempDir(),
63- ToolRegistry: make(core.Registry),
64- skillsEnabled: true,
65- }
66- bundled := &skills.Skill{Name: "bundled", Description: "bundled skill", Source: "extension tasked-phases"}
67-
68- r.MergeExtensionTools(bundledSkillSource{bundled: []*skills.Skill{bundled}})
69-
70- if r.SkillTool == nil {
71- t.Fatal("MergeExtensionTools did not create a skill tool for bundled skills")
72- }
73- if _, ok := r.ToolRegistry[r.SkillTool.Name()]; !ok {
74- t.Fatalf("skill tool %q was not registered", r.SkillTool.Name())
75- }
76- if got := r.SkillTool.Skills(); len(got) != 1 || got[0].Name != bundled.Name {
77- t.Fatalf("merged bundled skills = %#v", got)
78- }
79-}
80-
81-func TestMergeExtensionToolsRespectsDisabledSkillDiscovery(t *testing.T) {
82- r := Resolved{
83- CWD: t.TempDir(),
84- ToolRegistry: make(core.Registry),
85- skillsEnabled: false,
86- }
87- bundled := &skills.Skill{Name: "bundled", Description: "bundled skill", Source: "extension tasked-phases"}
88-
89- r.MergeExtensionTools(bundledSkillSource{bundled: []*skills.Skill{bundled}})
90-
91- if r.SkillTool != nil {
92- t.Fatal("disabled skill discovery created a skill tool")
93- }
94-}
95-
96-func TestMergeExtensionToolsReservesNativeControlNames(t *testing.T) {
97- r := Resolved{ToolRegistry: make(core.Registry)}
98- r.MergeExtensionTools(webSearchConflictSource{})
99- r.MergeExtensionTools(updateGoalConflictSource{})
100- for _, name := range []string{"web_search", "update_goal"} {
101- if _, ok := r.ToolRegistry[name]; ok {
102- t.Fatalf("extension replaced reserved native %s name", name)
103- }
104- }
105-}
106-
107-func TestMergeExtensionToolsKeepsDeterministicNativeSummaryOrder(t *testing.T) {
108- r := Resolved{
109- CWD: t.TempDir(),
110- ToolRegistry: buildToolRegistry(Args{}, t.TempDir(), nil, false, false, false),
111- }
112- r.MergeExtensionTools(unorderedExtensionSource{})
113-
114- var names []string
115- for _, summary := range r.ToolSummary {
116- names = append(names, summary.Name)
117- }
118- want := "read,write,edit,bash,create_worktree,web_search,update_goal,alpha,zeta"
119- if got := strings.Join(names, ","); got != want {
120- t.Fatalf("merged tool summary order = %q, want %q", got, want)
121- }
122-
123- r.MergeExtensionTools(unorderedExtensionSource{})
124- names = names[:0]
125- for _, summary := range r.ToolSummary {
126- names = append(names, summary.Name)
127- }
128- if got := strings.Join(names, ","); got != want {
129- t.Fatalf("reloaded tool summary order = %q, want %q", got, want)
130- }
131-}
132-
133-func TestMergeExtensionToolsRetainsPonytailPromptAddendum(t *testing.T) {
134- r := Resolved{
135- CWD: t.TempDir(),
136- ToolRegistry: make(core.Registry),
137- skillsEnabled: true,
138- systemAppend: []string{PonytailSystemAddendum()},
139- }
140- bundled := &skills.Skill{Name: "bundled", Description: "bundled skill", Source: "extension tasked-phases"}
141-
142- r.MergeExtensionTools(bundledSkillSource{bundled: []*skills.Skill{bundled}})
143-
144- if !strings.Contains(r.SystemPrompt, PonytailSystemAddendum()) {
145- t.Fatalf("extension prompt rebuild lost Ponytail addendum:\n%s", r.SystemPrompt)
146- }
147-}
148-
149-func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
150- root := t.TempDir()
151- sandbox := tools.NewSandbox(root)
152- registry := buildToolRegistry(Args{}, root, sandbox, true, true, false)
153- lspTool, ok := registry["lsp"].(*tools.LSPTool)
154- if !ok || lspTool.Manager == nil {
155- t.Fatalf("lsp tool = %#v", registry["lsp"])
156- }
157- t.Cleanup(func() { _ = lspTool.Manager.Close() })
158- if _, ok := registry["lsp"]; !ok {
159- t.Fatal("default registry does not include lsp")
160- }
161- worktree, ok := registry["create_worktree"].(*tools.CreateWorktreeTool)
162- if !ok || worktree.CWD != root || worktree.Sandbox != sandbox {
163- t.Fatalf("default registry worktree tool = %#v", registry["create_worktree"])
164- }
165- if _, ok := registry["web_search"].(*tools.WebSearchTool); !ok {
166- t.Fatalf("default registry web_search = %#v", registry["web_search"])
167- }
168- if _, ok := registry["update_goal"].(*tools.UpdateGoalTool); !ok {
169- t.Fatalf("default registry update_goal = %#v", registry["update_goal"])
170- }
171- write, ok := registry["write"].(*tools.WriteTool)
172- if !ok || write.LSP == nil || !write.LSPDiagnostics {
173- t.Fatalf("write tool LSP wiring = %#v", write)
174- }
175- edit, ok := registry["edit"].(*tools.EditTool)
176- if !ok || edit.LSP == nil || edit.LSPDiagnostics {
177- t.Fatalf("edit tool LSP wiring = %#v", edit)
178- }
179- if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 7 {
180- t.Fatalf("LSP-disabled registry has %d tools, want 7", len(disabled))
181- }
182- readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true)
183- if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil {
184- t.Fatalf("read-only registry = %#v", readOnly)
185- }
186- if _, ok := readOnly["web_search"]; ok {
187- t.Fatalf("explicit non-matching tools list retained web search: %#v", readOnly)
188- }
189- webOnly := buildToolRegistry(Args{Tools: []string{"web_search"}}, root, tools.NewSandbox(root), true, true, true)
190- if web, ok := webOnly["web_search"].(*tools.WebSearchTool); !ok || web == nil || len(webOnly) != 1 {
191- t.Fatalf("web-search-only registry = %#v", webOnly)
192- }
193- workerMissingPolicy := buildToolRegistry(Args{Mode: ModeSubagentWorker}, root, tools.NewSandbox(root), false, false, false)
194- if _, ok := workerMissingPolicy["web_search"]; ok {
195- t.Fatal("worker registry enabled web_search without an explicit propagated allow")
196- }
197- workerAllowed := buildToolRegistry(Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchAllow}, root, tools.NewSandbox(root), false, false, false)
198- if _, ok := workerAllowed["web_search"].(*tools.WebSearchTool); !ok {
199- t.Fatalf("worker propagated allow registry = %#v", workerAllowed)
200- }
201- workerCapped := buildToolRegistry(Args{Mode: ModeSubagentWorker, ToolsSet: true, Tools: []string{"read"}, WebSearchPolicy: subagents.WebSearchAllow}, root, tools.NewSandbox(root), false, false, false)
202- if _, ok := workerCapped["web_search"]; ok {
203- t.Fatalf("worker tool list ceiling was bypassed: %#v", workerCapped)
204- }
205- worktreeOnly := buildToolRegistry(Args{Tools: []string{"create_worktree"}}, root, tools.NewSandbox(root), true, true, true)
206- if worktree, ok := worktreeOnly["create_worktree"].(*tools.CreateWorktreeTool); !ok || worktree == nil || len(worktreeOnly) != 1 {
207- t.Fatalf("worktree-only registry = %#v", worktreeOnly)
208- }
209-}
210-
211-func TestToolSummariesIncludeGoalUpdates(t *testing.T) {
212- root := t.TempDir()
213- registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, false, false)
214-
215- summaries := toolSummaries(registry, Args{})
216- want := []string{"read", "write", "edit", "bash", "create_worktree", "web_search", "update_goal"}
217- if len(summaries) != len(want) {
218- t.Fatalf("tool summaries = %#v, want %v", summaries, want)
219- }
220- for index, summary := range summaries {
221- if summary.Name != want[index] {
222- t.Fatalf("tool summaries[%d] = %q, want %q", index, summary.Name, want[index])
223- }
224- }
225-}
226-
227-func TestResolvedUseSandboxUpdatesCreateWorktreeTool(t *testing.T) {
228- root := t.TempDir()
229- previous := tools.NewSandbox(root)
230- shared := tools.NewSandbox(root)
231- worktree := &tools.CreateWorktreeTool{CWD: root, Sandbox: previous}
232- resolved := &Resolved{
233- Sandbox: previous,
234- ToolRegistry: core.NewRegistry(worktree),
235- }
236-
237- resolved.UseSandbox(shared)
238-
239- if resolved.Sandbox != shared {
240- t.Fatal("resolved sandbox was not replaced")
241- }
242- if worktree.Sandbox != shared {
243- t.Fatal("worktree tool sandbox was not replaced")
244- }
245-}
246-
247-func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) {
248- for _, tc := range []struct {
249- name string
250- tools []string
251- toolsSet bool
252- wantSpawn bool
253- wantStatus bool
254- wantStop bool
255- wantResume bool
256- wantAny bool
257- }{
258- {name: "default", wantSpawn: true, wantStatus: true, wantStop: true, wantResume: true, wantAny: true},
259- {name: "explicit empty", toolsSet: true},
260- {name: "spawn", tools: []string{"subagent_spawn"}, wantSpawn: true, wantAny: true},
261- {name: "status", tools: []string{"subagent_status"}, wantStatus: true, wantAny: true},
262- {name: "stop", tools: []string{"subagent_stop"}, wantStop: true, wantAny: true},
263- {name: "resume", tools: []string{"subagent_resume"}, wantResume: true, wantAny: true},
264- {name: "other", tools: []string{"read"}},
265- {name: "no tools", wantAny: false, tools: nil},
266- } {
267- t.Run(tc.name, func(t *testing.T) {
268- args := Args{Tools: tc.tools, ToolsSet: tc.toolsSet}
269- if tc.name == "no tools" {
270- args.NoTools = true
271- }
272- if got := autoSubagentsToolAllowed(args); got != tc.wantSpawn {
273- t.Fatalf("spawn allowed = %v, want %v", got, tc.wantSpawn)
274- }
275- if got := autoSubagentsStatusToolAllowed(args); got != tc.wantStatus {
276- t.Fatalf("status allowed = %v, want %v", got, tc.wantStatus)
277- }
278- if got := autoSubagentsStopToolAllowed(args); got != tc.wantStop {
279- t.Fatalf("stop allowed = %v, want %v", got, tc.wantStop)
280- }
281- if got := autoSubagentsResumeToolAllowed(args); got != tc.wantResume {
282- t.Fatalf("resume allowed = %v, want %v", got, tc.wantResume)
283- }
284- if got := autoSubagentsAnyToolAllowed(args); got != tc.wantAny {
285- t.Fatalf("any allowed = %v, want %v", got, tc.wantAny)
286- }
287- })
288- }
289-}
290-
291-func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
292- root := t.TempDir()
293- zutHome := filepath.Join(root, "zut-home")
294- project := filepath.Join(root, "repo")
295- nested := filepath.Join(project, "packages", "app")
296- if err := os.MkdirAll(zutHome, 0o755); err != nil {
297- t.Fatal(err)
298- }
299- if err := os.MkdirAll(nested, 0o755); err != nil {
300- t.Fatal(err)
301- }
302- if err := os.WriteFile(filepath.Join(zutHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil {
303- t.Fatal(err)
304- }
305- if err := os.WriteFile(filepath.Join(project, "AGENTS.md"), []byte("repo rule"), 0o644); err != nil {
306- t.Fatal(err)
307- }
308- if err := os.WriteFile(filepath.Join(nested, "AGENTS.md"), []byte("app rule"), 0o644); err != nil {
309- t.Fatal(err)
310- }
311-
312- files := loadAgentsContext(nested, zutHome)
313- if len(files) != 3 {
314- t.Fatalf("loaded %d context files, want 3: %#v", len(files), files)
315- }
316- wantPaths := []string{
317- filepath.Join(zutHome, "AGENTS.md"),
318- filepath.Join(project, "AGENTS.md"),
319- filepath.Join(nested, "AGENTS.md"),
320- }
321- for idx, want := range wantPaths {
322- if files[idx].Path != want {
323- t.Fatalf("context file %d path = %q, want %q", idx, files[idx].Path, want)
324- }
325- }
326-
327- got := formatAgentsContext(files)
328- for _, want := range []string{"global rule", "repo rule", "app rule"} {
329- if !strings.Contains(got, want) {
330- t.Fatalf("formatAgentsContext missing %q in:\n%s", want, got)
331- }
332- }
333- if strings.Index(got, "global rule") > strings.Index(got, "repo rule") || strings.Index(got, "repo rule") > strings.Index(got, "app rule") {
334- t.Fatalf("AGENTS.md files loaded in wrong order:\n%s", got)
335- }
336-}
337-
338-func TestFindSubagentProfileReportsDiscoveryFailure(t *testing.T) {
339- profileSource := filepath.Join(t.TempDir(), "profiles.md")
340- if err := os.WriteFile(profileSource, []byte("not a directory"), 0o600); err != nil {
341- t.Fatal(err)
342- }
343- t.Setenv("ZUT_AGENT_PROFILES", profileSource)
344-
345- profile, err := findSubagentProfile("", "reviewer")
346- if profile != nil {
347- t.Fatalf("profile = %#v, want nil", profile)
348- }
349- if err == nil || !strings.Contains(err.Error(), "discover subagent profiles") {
350- t.Fatalf("error = %v, want discovery failure", err)
351- }
352- if strings.Contains(err.Error(), profileSource) {
353- t.Fatalf("error leaked profile path: %v", err)
354- }
355-}
356-
357-func TestResolveOrchestratedParentPromptScopesManifestAndContract(t *testing.T) {
358- root := t.TempDir()
359- home := filepath.Join(root, "home")
360- project := filepath.Join(root, "repo")
361- if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil {
362- t.Fatal(err)
363- }
364- if err := os.MkdirAll(project, 0o755); err != nil {
365- t.Fatal(err)
366- }
367- t.Setenv("HOME", home)
368- t.Setenv("ZUT_HOME", filepath.Join(root, "zut-home"))
369- t.Setenv("ZUT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
370- if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte("---\nname: reviewer\ndescription: Read-only reviewer\n---\nReview only.\n"), 0o600); err != nil {
371- t.Fatal(err)
372- }
373-
374- parent, err := Resolve(Args{CWD: project, Mode: ModePrint, Orchestrate: true}, false)
375- if err != nil {
376- t.Fatal(err)
377- }
378- if got := strings.Count(parent.SystemPrompt, "[subagents_list]"); got != 1 {
379- t.Fatalf("parent profile manifest count = %d, want 1\n%s", got, parent.SystemPrompt)
380- }
381- if !strings.Contains(parent.SystemPrompt, "primary-agent orchestrator") {
382- t.Fatalf("parent prompt omitted strict orchestrator contract:\n%s", parent.SystemPrompt)
383- }
384-
385- child, err := Resolve(Args{CWD: project, Mode: ModePrint, Orchestrate: true, Subagent: "reviewer"}, false)
386- if err != nil {
387- t.Fatal(err)
388- }
389- if strings.Contains(child.SystemPrompt, "primary-agent orchestrator") || strings.Contains(child.SystemPrompt, "[subagents_list]") {
390- t.Fatalf("selected child inherited parent orchestration instructions:\n%s", child.SystemPrompt)
391- }
392- if !strings.Contains(child.SystemPrompt, "Review only.") {
393- t.Fatalf("selected child profile prompt missing:\n%s", child.SystemPrompt)
394- }
395-}
396-
397-func TestResolveIncludesNamedSubagentsListWhenAutoSubagentsIsEnabled(t *testing.T) {
398- root := t.TempDir()
399- home := filepath.Join(root, "home")
400- zutHome := filepath.Join(root, "zut-home")
401- project := filepath.Join(root, "repo")
402- if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil {
403- t.Fatal(err)
404- }
405- if err := os.MkdirAll(project, 0o755); err != nil {
406- t.Fatal(err)
407- }
408- t.Setenv("HOME", home)
409- t.Setenv("ZUT_HOME", zutHome)
410- t.Setenv("ZUT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
411- enabled := true
412- if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSubagentsEnabled: &enabled}); err != nil {
413- t.Fatal(err)
414- }
415- profile := `---
416-name: reviewer
417-description: Read-only code reviewer
418-thinking: high
419----
420-Review without editing.
421-`
422- if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte(profile), 0o600); err != nil {
423- t.Fatal(err)
424- }
425-
426- r, err := Resolve(Args{CWD: project}, false)
427- if err != nil {
428- t.Fatal(err)
429- }
430- for _, want := range []string{
431- "[subagents_list]",
432- "reviewer",
433- "Read-only code reviewer",
434- "[/subagents_list]",
435- "primary-agent orchestrator",
436- "Delegate all implementation, debugging/testing, and code-review work",
437- "appropriately named subagent profile",
438- "general worker",
439- "Do not write or edit code yourself",
440- "direct implementation tool calls",
441- "inspect or review code",
442- "apply worker patches",
443- "Shared-worktree",
444- "isolation:\"worktree\"",
445- "Child workers cannot recursively spawn more sub-agents in v1",
446- } {
447- if !strings.Contains(r.SystemPrompt, want) {
448- t.Fatalf("system prompt missing %q:\n%s", want, r.SystemPrompt)
449- }
450- }
451-}
452-
453-func TestResolveKeepsStrictContractButOmitsProfilesWhenSpawnIsUnavailable(t *testing.T) {
454- root := t.TempDir()
455- home := filepath.Join(root, "home")
456- zutHome := filepath.Join(root, "zut-home")
457- project := filepath.Join(root, "repo")
458- if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil {
459- t.Fatal(err)
460- }
461- if err := os.MkdirAll(project, 0o755); err != nil {
462- t.Fatal(err)
463- }
464- t.Setenv("HOME", home)
465- t.Setenv("ZUT_HOME", zutHome)
466- t.Setenv("ZUT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
467- enabled := true
468- if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSubagentsEnabled: &enabled}); err != nil {
469- t.Fatal(err)
470- }
471- profile := "---\nname: reviewer\ndescription: Read-only code reviewer\n---\nReview without editing.\n"
472- if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte(profile), 0o600); err != nil {
473- t.Fatal(err)
474- }
475-
476- cases := []struct {
477- name string
478- args Args
479- }{
480- {name: "no tools", args: Args{CWD: project, NoTools: true}},
481- {name: "read allowlist", args: Args{CWD: project, Tools: []string{"read"}, ToolsSet: true}},
482- {name: "status allowlist", args: Args{CWD: project, Tools: []string{"subagent_status"}, ToolsSet: true}},
483- {name: "permission set", args: Args{CWD: project, PermissionSet: &tools.PermissionSet{}}},
484- {name: "headless orchestration", args: Args{CWD: project, Mode: ModePrint, Orchestrate: true, NoTools: true}},
485- }
486- for _, tc := range cases {
487- t.Run(tc.name, func(t *testing.T) {
488- r, err := Resolve(tc.args, false)
489- if err != nil {
490- t.Fatal(err)
491- }
492- for _, want := range []string{
493- "primary-agent orchestrator",
494- "Do not write or edit code yourself",
495- "subagent_spawn",
496- "report this limitation to the user",
497- "Completion is host-event-driven",
498- } {
499- if !strings.Contains(r.SystemPrompt, want) {
500- t.Fatalf("system prompt missing %q:\n%s", want, r.SystemPrompt)
501- }
502- }
503- for _, unwanted := range []string{"[subagents_list]", "reviewer", "Read-only code reviewer"} {
504- if strings.Contains(r.SystemPrompt, unwanted) {
505- t.Fatalf("system prompt advertises unusable profile %q:\n%s", unwanted, r.SystemPrompt)
506- }
507- }
508- })
509- }
510-}
511-
512-func TestAutoSubagentsSystemAddendumRequiresHostCompletionUpdate(t *testing.T) {
513- for _, want := range []string{
514- "bash sleep",
515- "watch",
516- "tail -f",
517- "polling loops",
518- "repeated \"subagent_status\"",
519- "dashboard, metadata, event-log, or file checks",
520- "unrelated independent tasks",
521- "end or yield your turn",
522- "[auto-subagents update]",
523- "Completion updates are the only completion signal",
524- "user-requested commands, provider flows, extensions, or tests",
525- } {
526- if !strings.Contains(AutoSubagentsSystemAddendum, want) {
527- t.Fatalf("auto-subagents contract missing %q:\n%s", want, AutoSubagentsSystemAddendum)
528- }
529- }
530-}
531-
532-func TestResolveKeepsNamedSubagentsListWithoutOrchestratorContractWhenDisabled(t *testing.T) {
533- root := t.TempDir()
534- home := filepath.Join(root, "home")
535- zutHome := filepath.Join(root, "zut-home")
536- project := filepath.Join(root, "repo")
537- profilesDir := filepath.Join(home, ".agents", "agents")
538- if err := os.MkdirAll(profilesDir, 0o755); err != nil {
539- t.Fatal(err)
540- }
541- if err := os.MkdirAll(project, 0o755); err != nil {
542- t.Fatal(err)
543- }
544- t.Setenv("HOME", home)
545- t.Setenv("ZUT_HOME", zutHome)
546- t.Setenv("ZUT_AGENT_PROFILES", profilesDir)
547- disabled := false
548- if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSubagentsEnabled: &disabled}); err != nil {
549- t.Fatal(err)
550- }
551- profile := "---\nname: reviewer\ndescription: Read-only code reviewer\n---\nReview without editing.\n"
552- if err := os.WriteFile(filepath.Join(profilesDir, "reviewer.md"), []byte(profile), 0o600); err != nil {
553- t.Fatal(err)
554- }
555-
556- r, err := Resolve(Args{CWD: project}, false)
557- if err != nil {
558- t.Fatal(err)
559- }
560- for _, unwanted := range []string{
561- AutoSubagentsSystemAddendum,
562- "primary-agent orchestrator",
563- } {
564- if strings.Contains(r.SystemPrompt, unwanted) {
565- t.Fatalf("disabled system prompt contains auto-subagents contract %q:\n%s", unwanted, r.SystemPrompt)
566- }
567- }
568- for _, required := range []string{
569- "[subagents_list]",
570- "reviewer",
571- "Read-only code reviewer",
572- "[/subagents_list]",
573- "user asks you to delegate",
574- "active skill workflow requires delegation",
575- } {
576- if !strings.Contains(r.SystemPrompt, required) {
577- t.Fatalf("disabled system prompt omits available delegation context %q:\n%s", required, r.SystemPrompt)
578- }
579- }
580-}
581-
582-func TestResolveAppliesSelectedSubagentProfile(t *testing.T) {
583- root := t.TempDir()
584- home := filepath.Join(root, "home")
585- zutHome := filepath.Join(root, "zut-home")
586- project := filepath.Join(root, "repo")
587- if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil {
588- t.Fatal(err)
589- }
590- if err := os.MkdirAll(project, 0o755); err != nil {
591- t.Fatal(err)
592- }
593- t.Setenv("HOME", home)
594- t.Setenv("ZUT_HOME", zutHome)
595- t.Setenv("ZUT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
596- if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", Reasoning: "high"}); err != nil {
597- t.Fatal(err)
598- }
599- if err := os.WriteFile(filepath.Join(zutHome, "AGENTS.md"), []byte("global context"), 0o600); err != nil {
600- t.Fatal(err)
601- }
602- profile := `---
603-name: reviewer
604-description: Read-only code reviewer
605-tools: read
606-thinking: off
607-systemPromptMode: replace
608-inheritProjectContext: false
609-inheritSkills: false
610----
611-You are a read-only reviewer.
612-`
613- if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte(profile), 0o600); err != nil {
614- t.Fatal(err)
615- }
616-
617- r, err := Resolve(Args{CWD: project, Subagent: "reviewer"}, false)
618- if err != nil {
619- t.Fatal(err)
620- }
621- if r.Reasoning != "" {
622- t.Fatalf("profile thinking=off did not override config reasoning: %q", r.Reasoning)
623- }
624- if _, ok := r.ToolRegistry["read"]; !ok || len(r.ToolRegistry) != 1 {
625- t.Fatalf("profile tools = %#v, want only read", r.ToolRegistry)
626- }
627- if len(r.ContextFiles) != 0 {
628- t.Fatalf("profile inheritProjectContext=false loaded %#v", r.ContextFiles)
629- }
630- if !strings.Contains(r.SystemPrompt, "You are a read-only reviewer.") || strings.Contains(r.SystemPrompt, "global context") {
631- t.Fatalf("profile system prompt inheritance is wrong:\n%s", r.SystemPrompt)
632- }
633- if !strings.Contains(r.SystemPrompt, PonytailSystemAddendum()) {
634- t.Fatalf("replace-mode profile lost global Ponytail addendum:\n%s", r.SystemPrompt)
635- }
636-}
637-
638-func TestResolveSubagentFastModeProfileOverridesHostSetting(t *testing.T) {
639- cases := []struct {
640- name string
641- hostFastMode bool
642- profileFast *bool
643- argFastMode bool
644- argFastSet bool
645- wantFastMode bool
646- }{
647- {name: "unset inherits enabled host", hostFastMode: true, wantFastMode: true},
648- {name: "unset inherits disabled host", hostFastMode: false, wantFastMode: false},
649- {name: "false disables enabled host", hostFastMode: true, profileFast: boolPtr(false), wantFastMode: false},
650- {name: "true enables disabled host", hostFastMode: false, profileFast: boolPtr(true), wantFastMode: true},
651- {name: "false stays disabled with disabled host", hostFastMode: false, profileFast: boolPtr(false), wantFastMode: false},
652- {name: "profile false cannot be bypassed", hostFastMode: true, profileFast: boolPtr(false), argFastMode: true, argFastSet: true, wantFastMode: false},
653- }
654- for _, tc := range cases {
655- t.Run(tc.name, func(t *testing.T) {
656- root := t.TempDir()
657- home := filepath.Join(root, "home")
658- zutHome := filepath.Join(root, "zut-home")
659- project := filepath.Join(root, "repo")
660- profilesDir := filepath.Join(home, ".agents", "agents")
661- if err := os.MkdirAll(profilesDir, 0o755); err != nil {
662- t.Fatal(err)
663- }
664- if err := os.MkdirAll(project, 0o755); err != nil {
665- t.Fatal(err)
666- }
667- t.Setenv("HOME", home)
668- t.Setenv("ZUT_HOME", zutHome)
669- t.Setenv("ZUT_AGENT_PROFILES", profilesDir)
670-
671- cfg := Config{Provider: "openai", Model: "gpt-5"}
672- cfg.FastMode = &tc.hostFastMode
673- if err := SaveConfig(cfg); err != nil {
674- t.Fatal(err)
675- }
676- profile := "---\nname: reviewer\n"
677- if tc.profileFast != nil {
678- profile += fmt.Sprintf("fastMode: %t\n", *tc.profileFast)
679- }
680- profile += "---\nReview the requested scope.\n"
681- if err := os.WriteFile(filepath.Join(profilesDir, "reviewer.md"), []byte(profile), 0o600); err != nil {
682- t.Fatal(err)
683- }
684-
685- r, err := Resolve(Args{
686- CWD: project,
687- Subagent: "reviewer",
688- FastMode: tc.argFastMode,
689- FastModeSet: tc.argFastSet,
690- }, false)
691- if err != nil {
692- t.Fatal(err)
693- }
694- if r.FastMode != tc.wantFastMode {
695- t.Fatalf("FastMode = %v, want %v", r.FastMode, tc.wantFastMode)
696- }
697- })
698- }
699-}
700-
701-func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) {
702- files := loadAgentsContext(t.TempDir(), t.TempDir())
703- if len(files) != 0 {
704- t.Fatalf("expected no context files, got %#v", files)
705- }
706- if got := formatAgentsContext(files); got != "" {
707- t.Fatalf("expected no context, got %q", got)
708- }
709-}
710-
711-func TestResolveNoContextFilesSkipsAgentsInstructions(t *testing.T) {
712- zutHome := t.TempDir()
713- cwd := t.TempDir()
714- t.Setenv("ZUT_HOME", zutHome)
715- t.Setenv("OPENAI_API_KEY", "test-key")
716- if err := os.WriteFile(filepath.Join(zutHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil {
717- t.Fatal(err)
718- }
719- if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("project rule"), 0o644); err != nil {
720- t.Fatal(err)
721- }
722-
723- r, err := Resolve(Args{
724- Provider: "openai",
725- Model: "gpt-5",
726- CWD: cwd,
727- NoContextFiles: true,
728- }, false)
729- if err != nil {
730- t.Fatal(err)
731- }
732- if len(r.ContextFiles) != 0 {
733- t.Fatalf("ContextFiles = %#v, want empty", r.ContextFiles)
734- }
735- if strings.Contains(r.SystemPrompt, "global rule") || strings.Contains(r.SystemPrompt, "project rule") {
736- t.Fatalf("system prompt contains disabled context files:\n%s", r.SystemPrompt)
737- }
738-}
739-
740-// TestResolveFallsBackWhenConfiguredModelIsGone reproduces the
741-// startup failure caught by the user's screenshot: the persisted
742-// config.json points at a model id that's no longer in the active
743-// catalogue (because they edited models.json or zut's bundled
744-// catalogue changed). Resolve must NOT error — strands the user
745-// with no way to fix it from the TUI — and should repair the config
746-// so the next launch is silent.
747-func TestResolveFallsBackWhenConfiguredModelIsGone(t *testing.T) {
748- t.Setenv("ZUT_HOME", t.TempDir())
749- t.Setenv("OPENAI_API_KEY", "test-key")
750- // Persist a stale model id.
751- stale := "gpt-5.5-pro-not-real"
752- if err := SaveConfig(Config{Provider: "openai", Model: stale}); err != nil {
753- t.Fatal(err)
754- }
755-
756- r, err := Resolve(Args{}, false)
757- if err != nil {
758- t.Fatalf("Resolve refused to launch with stale model: %v", err)
759- }
760- if r.Model == stale {
761- t.Fatalf("Resolve kept stale model %q", r.Model)
762- }
763- if r.Provider != "openai" {
764- t.Errorf("provider drifted: got %q; want openai", r.Provider)
765- }
766-
767- // Config on disk should now hold the fallback so subsequent
768- // launches don't repeat the warning.
769- cfg, _ := LoadConfig()
770- if cfg.Model == stale {
771- t.Errorf("config.json still pins the stale model %q", cfg.Model)
772- }
773- if cfg.Model == "" {
774- t.Errorf("config.json was emptied; expected the fallback model id")
775- }
776-}
777-
778-func TestResolveAppliesJailByDefault(t *testing.T) {
779- for _, tc := range []struct {
780- name string
781- config Config
782- want bool
783- }{
784- {name: "missing defaults to unlocked", config: Config{Provider: "openai", Model: "gpt-5"}},
785- {name: "enabled starts locked", config: Config{Provider: "openai", Model: "gpt-5", JailByDefault: boolPtr(true)}, want: true},
786- } {
787- t.Run(tc.name, func(t *testing.T) {
788- t.Setenv("ZUT_HOME", t.TempDir())
789- t.Setenv("OPENAI_API_KEY", "test-key")
790- if err := SaveConfig(tc.config); err != nil {
791- t.Fatal(err)
792- }
793-
794- r, err := Resolve(Args{}, false)
795- if err != nil {
796- t.Fatalf("Resolve failed: %v", err)
797- }
798- if got := r.Sandbox.Locked(); got != tc.want {
799- t.Fatalf("sandbox locked = %v, want %v", got, tc.want)
800- }
801- })
802- }
803-}
804-
805-func boolPtr(v bool) *bool { return &v }
806-
807-// TestResolveExplicitFlagStaleDoesNotRepairConfig confirms the
808-// repair-on-disk happens ONLY when the stale id came from the
809-// persisted config. If the user passed --model X explicitly and X is
810-// unknown, we still fall back, but we don't touch their config.
811-func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
812- t.Setenv("ZUT_HOME", t.TempDir())
813- t.Setenv("OPENAI_API_KEY", "test-key")
814- good := "gpt-5"
815- if err := SaveConfig(Config{Provider: "openai", Model: good}); err != nil {
816- t.Fatal(err)
817- }
818-
819- r, err := Resolve(Args{Model: "gpt-totally-fake"}, false)
820- if err != nil {
821- t.Fatalf("Resolve errored on unknown --model: %v", err)
822- }
823- if r.Model == "gpt-totally-fake" {
824- t.Errorf("Resolve kept the bogus --model value")
825- }
826- cfg, _ := LoadConfig()
827- if cfg.Model != good {
828- t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model)
829- }
830-}
831-
832-func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) {
833- t.Setenv("ZUT_HOME", t.TempDir())
834- t.Setenv("OPENROUTER_API_KEY", "test-key")
835- want := "deepseek/deepseek-v4-flash"
836- if err := SaveConfig(Config{Provider: "openrouter", Model: want}); err != nil {
837- t.Fatal(err)
838- }
839-
840- r, err := Resolve(Args{}, true)
841- if err != nil {
842- t.Fatalf("Resolve failed: %v", err)
843- }
844- if r.Provider != "openrouter" {
845- t.Fatalf("provider = %q, want openrouter", r.Provider)
846- }
847- if r.Model != want {
848- t.Fatalf("model = %q, want %q", r.Model, want)
849- }
850- if r.MaxOutput != 64000 {
851- t.Fatalf("MaxOutput = %d, want synthetic gateway default 64000", r.MaxOutput)
852- }
853- if r.ContextWindow != 1_000_000 || r.NewAgent().ContextWindow != 1_000_000 {
854- t.Fatalf("resolved/agent context window = %d/%d, want synthetic gateway metadata", r.ContextWindow, r.NewAgent().ContextWindow)
855- }
856-}
857-
858-func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) {
859- t.Setenv("ZUT_HOME", t.TempDir())
860- t.Setenv("OPENROUTER_API_KEY", "test-key")
861- stale := "not-a-routed-model"
862- if err := SaveConfig(Config{Provider: "openrouter", Model: stale}); err != nil {
863- t.Fatal(err)
864- }
865-
866- r, err := Resolve(Args{}, true)
867- if err != nil {
868- t.Fatalf("Resolve failed: %v", err)
869- }
870- if r.Provider != "openrouter" {
871- t.Fatalf("provider = %q, want openrouter", r.Provider)
872- }
873- if r.Model == stale || r.Model == "" {
874- t.Fatalf("model = %q, want repaired gateway default", r.Model)
875- }
876-}
877-
878-// TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue
879-// #15: pointing ZUT_HOME at a fresh dir drops the persisted
880-// config.json (which pinned provider=amazon-bedrock). Resolve must
881-// still discover bedrock from the AWS env vars instead of falling back
882-// to anthropic and reporting "not logged in".
883-func TestResolveEnvOnlyBedrockDiscoveredWithoutConfig(t *testing.T) {
884- t.Setenv("ZUT_HOME", t.TempDir()) // fresh home: no config.json
885- // Disable the Kimi CLI token fallback so a developer machine with a
886- // real Kimi CLI login doesn't pre-empt bedrock in the scan.
887- if err := SetKimiCLIFallbackDisabled(true); err != nil {
888- t.Fatal(err)
889- }
890- t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-token")
891- t.Setenv("AWS_REGION", "us-east-1")
892- // Make sure no other provider's env credential pre-empts bedrock.
893- for _, k := range []string{"ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "DEEPSEEK_API_KEY", "KIMI_API_KEY", "MOONSHOT_API_KEY", "XIAOMI_API_KEY", "OPENCODE_API_KEY"} {
894- t.Setenv(k, "")
895- }
896-
897- r, err := Resolve(Args{}, true)
898- if err != nil {
899- t.Fatalf("Resolve errored with env-only bedrock: %v", err)
900- }
901- if r.Provider != "amazon-bedrock" {
902- t.Fatalf("provider = %q, want amazon-bedrock", r.Provider)
903- }
904- if !r.HasCredential() {
905- t.Fatalf("bedrock credential not resolved from env")
906- }
907-}
908-
909-func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) {
910- t.Setenv("ZUT_HOME", t.TempDir())
911- provider.SetLiveModels(nil)
912- defer provider.SetLiveModels(nil)
913- provider.SetUserModels([]provider.Model{{
914- Provider: "ollama",
915- ID: "qwen-local",
916- DisplayName: "Qwen Local",
917- ContextWindow: 32768,
918- MaxOutput: 8192,
919- BaseURL: "http://localhost:8000/v1",
920- }})
921-
922- r, err := Resolve(Args{Provider: "ollama", Model: "qwen-local"}, false)
923- if err != nil {
924- t.Fatalf("Resolve failed: %v", err)
925- }
926- if r.BaseURL != "http://localhost:8000/v1" {
927- t.Fatalf("BaseURL = %q, want models.json baseUrl", r.BaseURL)
928- }
929-}
930-
931-func TestResolveUsesInheritedSupervisorCredential(t *testing.T) {
932- t.Setenv("ZUT_HOME", t.TempDir())
933- t.Setenv("OPENAI_API_KEY", "")
934-
935- r, err := Resolve(Args{
936- Provider: "openai",
937- Model: "gpt-5",
938- inheritedCredential: "inherited-key",
939- inheritedAuthMethod: "apikey",
940- inheritedAccountID: "account-id",
941- }, true)
942- if err != nil {
943- t.Fatal(err)
944- }
945- if r.Credential != "inherited-key" || r.AuthMethod != "apikey" || r.AccountID != "account-id" {
946- t.Fatalf("inherited credential was not preserved: %+v", r)
947- }
948-}
949-
950-func TestResolveLlamaCPPUsesRouterInferenceURL(t *testing.T) {
951- t.Setenv("ZUT_HOME", t.TempDir())
952- t.Setenv("LLAMA_BASE_URL", "http://127.0.0.1:8080/v1/")
953- t.Setenv("LLAMA_API_KEY", "")
954- provider.SetManagedModels(nil)
955- t.Cleanup(func() { provider.SetManagedModels(nil) })
956-
957- r, err := Resolve(Args{Provider: "llama.cpp", Model: "local-model"}, true)
958- if err != nil {
959- t.Fatalf("Resolve failed: %v", err)
960- }
961- if r.BaseURL != "http://127.0.0.1:8080/v1" {
962- t.Fatalf("BaseURL = %q", r.BaseURL)
963- }
964- if r.Credential != "local" || !r.HasCredential() {
965- t.Fatalf("credential = %q", r.Credential)
966- }
967- if r.ContextWindow != 128000 || r.NewAgent().ContextWindow != 128000 {
968- t.Fatalf("resolved/agent context window = %d/%d, want synthesized local metadata", r.ContextWindow, r.NewAgent().ContextWindow)
969- }
970- if got := r.NewClient().Name(); got != provider.LlamaCPPProviderID {
971- t.Fatalf("client name = %q, want %q", got, provider.LlamaCPPProviderID)
972- }
973-}
974-
975-func TestResolveLlamaCPPUsesStoredLogin(t *testing.T) {
976- t.Setenv("ZUT_HOME", t.TempDir())
977- t.Setenv("LLAMA_BASE_URL", "")
978- t.Setenv("LLAMA_API_KEY", "")
979- if err := AuthStoreFor().SetEndpointCredential("llama.cpp", "http://localhost:9090", "stored-key"); err != nil {
980- t.Fatal(err)
981- }
982-
983- r, err := Resolve(Args{Provider: "llama.cpp", Model: "stored-model"}, true)
984- if err != nil {
985- t.Fatalf("Resolve failed: %v", err)
986- }
987- if r.BaseURL != "http://localhost:9090/v1" || r.Credential != "stored-key" {
988- t.Fatalf("resolved = base %q credential %q", r.BaseURL, r.Credential)
989- }
990-}
991-
992-func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
993- t.Setenv("ZUT_HOME", t.TempDir())
994- t.Setenv("MY_COMPANY_API_KEY", "test-key")
995- path := filepath.Join(t.TempDir(), "models.json")
996- if err := os.WriteFile(path, []byte(`{
997- "providers": {
998- "my-company": {
999- "baseUrl": "https://provider.example.com/v1",
1000- "api": "openai",
1001- "models": [
1002- {"id": "fast", "baseUrl": "https://model.example.com/v1", "contextWindow": 65536}
1003- ]
1004- }
1005- }
1006- }`), 0o644); err != nil {
1007- t.Fatal(err)
1008- }
1009- models, warnings := provider.LoadUserModelsWithWarnings(path)
1010- if len(warnings) != 0 {
1011- t.Fatalf("warnings = %v", warnings)
1012- }
1013- provider.SetLiveModels(nil)
1014- provider.SetUserModels(models)
1015- t.Cleanup(func() { provider.SetLiveModels(nil) })
1016-
1017- r, err := Resolve(Args{Provider: "my-company", Model: "fast"}, true)
1018- if err != nil {
1019- t.Fatalf("Resolve failed: %v", err)
1020- }
1021- if r.BaseURL != "https://model.example.com/v1" {
1022- t.Fatalf("BaseURL = %q, want model-level baseUrl", r.BaseURL)
1023- }
1024- if r.ContextWindow != 65536 || r.NewAgent().ContextWindow != 65536 {
1025- t.Fatalf("resolved/agent context window = %d/%d, want retained user-model metadata", r.ContextWindow, r.NewAgent().ContextWindow)
1026- }
1027-}
1028-
1029-func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) {
1030- requestPath := make(chan string, 1)
1031- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1032- requestPath <- r.URL.Path
1033- w.Header().Set("Content-Type", "text/event-stream")
1034- w.WriteHeader(http.StatusOK)
1035- }))
1036- t.Cleanup(srv.Close)
1037-
1038- path := filepath.Join(t.TempDir(), "models.json")
1039- if err := os.WriteFile(path, []byte(`{
1040- "providers": {
1041- "company-responses": {
1042- "baseUrl": "`+srv.URL+`/v1",
1043- "api": "openai-responses",
1044- "models": [{"id": "reasoning-model", "reasoning": true}]
1045- }
1046- }
1047- }`), 0o644); err != nil {
1048- t.Fatal(err)
1049- }
1050- models, warnings := provider.LoadUserModelsWithWarnings(path)
1051- if len(warnings) != 0 {
1052- t.Fatalf("warnings = %v", warnings)
1053- }
1054- provider.SetLiveModels(nil)
1055- provider.SetUserModels(models)
1056- t.Cleanup(func() { provider.SetLiveModels(nil) })
1057-
1058- r := Resolved{
1059- Provider: "company-responses",
1060- Credential: "test-key",
1061- BaseURL: srv.URL + "/v1",
1062- }
1063- events, err := r.NewClient().Stream(context.Background(), provider.Request{
1064- Model: "reasoning-model",
1065- Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}},
1066- })
1067- if err != nil {
1068- t.Fatal(err)
1069- }
1070- for range events {
1071- }
1072- if got := <-requestPath; got != "/v1/responses" {
1073- t.Fatalf("request path = %q, want /v1/responses", got)
1074- }
1075-}
1076-
1077-func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
1078- t.Setenv("ZUT_HOME", t.TempDir())
1079- t.Setenv("LOCAL_PROXY_API_KEY", "test-key")
1080- path := filepath.Join(t.TempDir(), "models.json")
1081- if err := os.WriteFile(path, []byte(`{
1082- "providers": {
1083- "local-proxy": {
1084- "baseUrl": "https://proxy.example.com/v1",
1085- "api": "openai",
1086- "models": [{"id": "default"}]
1087- }
1088- }
1089- }`), 0o644); err != nil {
1090- t.Fatal(err)
1091- }
1092- models, warnings := provider.LoadUserModelsWithWarnings(path)
1093- if len(warnings) != 0 {
1094- t.Fatalf("warnings = %v", warnings)
1095- }
1096- provider.SetLiveModels(nil)
1097- provider.SetUserModels(models)
1098- t.Cleanup(func() { provider.SetLiveModels(nil) })
1099-
1100- r, err := Resolve(Args{Provider: "local-proxy", Model: "default", InsecureTLS: true}, true)
1101- if err != nil {
1102- t.Fatalf("Resolve failed: %v", err)
1103- }
1104- if !r.InsecureTLS {
1105- t.Fatal("InsecureTLS must be set for --insecure with models.json custom baseUrl")
1106- }
1107-}
1108-
1109-func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) {
1110- t.Setenv("ZUT_HOME", t.TempDir())
1111- provider.SetLiveModels(nil)
1112- defer provider.SetLiveModels(nil)
1113-
1114- r, err := Resolve(Args{Provider: "ollama", Model: "any-local-model"}, false)
1115- if err != nil {
1116- t.Fatalf("Resolve failed: %v", err)
1117- }
1118- if r.BaseURL != "http://localhost:11434" {
1119- t.Fatalf("BaseURL = %q, want ollama default", r.BaseURL)
1120- }
1121-}
1122-
1123-func TestCanonicalProviderResolvesAliases(t *testing.T) {
1124- cases := map[string]string{
1125- "bedrock": "amazon-bedrock",
1126- "AWS-Bedrock": "amazon-bedrock",
1127- " bedrock ": "amazon-bedrock",
1128- "vertex": "google-vertex",
1129- "gemini": "google",
1130- "azure": "azure-openai-responses",
1131- "copilot": "github-copilot",
1132- "codex": "openai-codex",
1133- "moonshot": "moonshotai",
1134- "vercel": "vercel-ai-gateway",
1135- "hf": "huggingface",
1136- "anthropic": "anthropic", // canonical passes through
1137- "amazon-bedrock": "amazon-bedrock", // already canonical
1138- "totally-unknown": "totally-unknown", // unknown returned unchanged (lowered)
1139- "Totally-UNKNOWN": "totally-unknown",
1140- "": "",
1141- }
1142- for in, want := range cases {
1143- if got := canonicalProvider(in); got != want {
1144- t.Errorf("canonicalProvider(%q) = %q, want %q", in, got, want)
1145- }
1146- }
1147-}
1148-
1149-func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
1150- for alias, canon := range providerAliases {
1151- if !isKnownProvider(canon) {
1152- t.Errorf("alias %q maps to %q which is not a known provider", alias, canon)
1153- }
1154- }
1155-}
1156-
1157-func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) {
1158- orig := http.DefaultTransport
1159- t.Cleanup(func() { http.DefaultTransport = orig })
1160-
1161- t.Setenv("ZUT_HOME", t.TempDir())
1162- t.Setenv("OPENAI_API_KEY", "test-key")
1163-
1164- resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false)
1165- if err != nil {
1166- t.Fatalf("Resolve: %v", err)
1167- }
1168- if resolved.InsecureTLS {
1169- t.Fatal("InsecureTLS must not be set for built-in provider base URLs")
1170- }
1171- assertDefaultTransportStillSecure(t)
1172-
1173- resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
1174- if err != nil {
1175- t.Fatalf("Resolve: %v", err)
1176- }
1177- if !resolved.InsecureTLS {
1178- t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url")
1179- }
1180- assertDefaultTransportStillSecure(t)
1181-}
1182-
1183-func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
1184- orig := http.DefaultTransport
1185- t.Cleanup(func() { http.DefaultTransport = orig })
1186-
1187- t.Setenv("ZUT_HOME", t.TempDir())
1188- t.Setenv("OPENAI_API_KEY", "test-key")
1189- if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil {
1190- t.Fatal(err)
1191- }
1192-
1193- resolved, err := Resolve(Args{Provider: "openai"}, false)
1194- if err != nil {
1195- t.Fatalf("Resolve: %v", err)
1196- }
1197- if resolved.InsecureTLS {
1198- t.Fatal("InsecureTLS must not be set without a custom base URL")
1199- }
1200- assertDefaultTransportStillSecure(t)
1201-
1202- resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
1203- if err != nil {
1204- t.Fatalf("Resolve: %v", err)
1205- }
1206- if !resolved.InsecureTLS {
1207- t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided")
1208- }
1209- assertDefaultTransportStillSecure(t)
1210-}
1211-
1212-func TestDefaultXAIModelIsGrok45(t *testing.T) {
1213- if got := defaultModelForProvider("xai"); got != "grok-4.5" {
1214- t.Fatalf("default xAI model = %q, want grok-4.5", got)
1215- }
1216-}
1217-
1218-func assertDefaultTransportStillSecure(t *testing.T) {
1219- t.Helper()
1220- tr, ok := http.DefaultTransport.(*http.Transport)
1221- if !ok {
1222- return
1223- }
1224- if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify {
1225- t.Fatal("http.DefaultTransport must not be made insecure")
1226- }
1227-}
1228-
1229-func TestResolveWebSearchRegistryRespectsConfigAndExplicitCLI(t *testing.T) {
1230- t.Setenv("ZUT_HOME", t.TempDir())
1231- disabled := false
1232- if err := SaveConfig(Config{WebSearchEnabled: &disabled}); err != nil {
1233- t.Fatal(err)
1234- }
1235- args := Args{CWD: t.TempDir(), Provider: "ollama", Model: "any-local-model"}
1236- resolved, err := Resolve(args, false)
1237- if err != nil {
1238- t.Fatal(err)
1239- }
1240- if _, ok := resolved.ToolRegistry["web_search"]; ok {
1241- t.Fatal("persisted opt-out left web_search in the registry")
1242- }
1243-
1244- args.ToolsSet = true
1245- args.Tools = []string{"web_search"}
1246- resolved, err = Resolve(args, false)
1247- if err != nil {
1248- t.Fatal(err)
1249- }
1250- if _, ok := resolved.ToolRegistry["web_search"]; !ok {
1251- t.Fatal("explicit --tools web_search did not override persisted opt-out")
1252- }
1253-
1254- args.PermissionSet = &tools.PermissionSet{}
1255- resolved, err = Resolve(args, false)
1256- if err != nil {
1257- t.Fatal(err)
1258- }
1259- if _, ok := resolved.ToolRegistry["web_search"]; ok {
1260- t.Fatal("packaged-agent PermissionSet received web_search")
1261- }
1262-}
1263-
1264-func TestResolveWebSearchPolicy(t *testing.T) {
1265- disabled := false
1266- cases := []struct {
1267- name string
1268- args Args
1269- cfg Config
1270- cfgErr error
1271- profile *subagents.Profile
1272- want subagents.WebSearchPolicy
1273- }{
1274- {name: "legacy config", want: subagents.WebSearchAllow},
1275- {name: "persisted opt out", cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchDeny},
1276- {name: "explicit CLI allow overrides opt out", args: Args{ToolsSet: true, Tools: []string{"web_search"}}, cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchAllow},
1277- {name: "explicit empty CLI list denies", args: Args{ToolsSet: true}, want: subagents.WebSearchDeny},
1278- {name: "explicit nonmatching CLI list denies", args: Args{ToolsSet: true, Tools: []string{"read"}}, want: subagents.WebSearchDeny},
1279- {name: "no tools denies", args: Args{NoTools: true}, want: subagents.WebSearchDeny},
1280- {name: "packaged agent denies", args: Args{PermissionSet: &tools.PermissionSet{}}, want: subagents.WebSearchDeny},
1281- {name: "internal policy denies", args: Args{WebSearchPolicy: subagents.WebSearchDeny}, want: subagents.WebSearchDeny},
1282- {name: "internal policy allows", args: Args{WebSearchPolicy: subagents.WebSearchAllow}, cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchAllow},
1283- {name: "normal explicit empty list narrows internal allow", args: Args{ToolsSet: true, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1284- {name: "worker missing policy denies despite legacy config", args: Args{Mode: ModeSubagentWorker}, want: subagents.WebSearchDeny},
1285- {name: "worker explicit inherit denies", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchInherit}, want: subagents.WebSearchDeny},
1286- {name: "worker invalid policy denies", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchPolicy(99)}, want: subagents.WebSearchDeny},
1287- {name: "worker propagated deny remains denied", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchDeny}, want: subagents.WebSearchDeny},
1288- {name: "worker propagated allow", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchAllow}, cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchAllow},
1289- {name: "worker explicit empty list caps allow", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1290- {name: "worker nonmatching list caps allow", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, Tools: []string{"read"}, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1291- {name: "worker matching list preserves allow", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, Tools: []string{"read", "web_search"}, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchAllow},
1292- {name: "worker no tools caps allow", args: Args{Mode: ModeSubagentWorker, NoTools: true, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1293- {name: "worker permission set caps allow", args: Args{Mode: ModeSubagentWorker, PermissionSet: &tools.PermissionSet{}, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1294- {name: "config load failure denies inherited policy", cfgErr: fmt.Errorf("broken config"), want: subagents.WebSearchDeny},
1295- {name: "named profile requires explicit tool", args: Args{WebSearchPolicy: subagents.WebSearchAllow}, profile: &subagents.Profile{Tools: []string{"read"}}, want: subagents.WebSearchDeny},
1296- {name: "named profile allows explicit tool", args: Args{WebSearchPolicy: subagents.WebSearchAllow}, profile: &subagents.Profile{Tools: []string{"read", "web_search"}}, want: subagents.WebSearchAllow},
1297- {name: "named profile cannot rescue worker inherit", args: Args{Mode: ModeSubagentWorker}, profile: &subagents.Profile{Tools: []string{"web_search"}}, want: subagents.WebSearchDeny},
1298- {name: "named profile cannot bypass worker tool list", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, Tools: []string{"read"}, WebSearchPolicy: subagents.WebSearchAllow}, profile: &subagents.Profile{Tools: []string{"web_search"}}, want: subagents.WebSearchDeny},
1299- }
1300- for _, tc := range cases {
1301- t.Run(tc.name, func(t *testing.T) {
1302- if got := resolveWebSearchPolicy(tc.args, tc.cfg, tc.cfgErr, tc.profile); got != tc.want {
1303- t.Fatalf("policy = %v, want %v", got, tc.want)
1304- }
1305- })
1306- }
1307-}
M packages/agent/cli.go +75 -76
@@ -584,6 +584,11 @@ func applySessionResume(sess *core.Session, ag *core.Agent, currentProvider, cur
584584 if err != nil {
585585 return sessionResumeCandidate{}, joinSessionCloseError(err, sess)
586586 }
587+ // A replacement agent is still private at this point. Bind the durable
588+ // identity before it can be published or run any startup work.
589+ if err := candidate.agent.BindSessionID(candidate.session.Meta.ID); err != nil {
590+ return sessionResumeCandidate{}, joinSessionCloseError(err, candidate.session)
591+ }
587592 if !candidate.rebuilt {
588593 candidate.agent.SetMessages(candidate.messages)
589594 candidate.agent.SeedCost(candidate.cumulative)
@@ -793,9 +798,6 @@ func Run(rawArgs []string, version string) error {
793798 if handled, err := runZutfileCommand(rawArgs, version); handled {
794799 return err
795800 }
796- if handled, err := runDebugCommand(rawArgs, os.Stdout); handled {
797- return err
798- }
799801 return runWithArgsRaw(rawArgs, version)
800802 }
801803
@@ -854,8 +856,6 @@ func runWithArgs(args Args, version string) error {
854856 return runJSONMode(ctx, args, version)
855857 case ModeRPC:
856858 return runRPCMode(ctx, args, version)
857- case ModeSubagentWorker:
858- return runSubagentWorkerMode(ctx, args, version)
859859 default:
860860 return runInteractive(ctx, args, version)
861861 }
@@ -1481,28 +1481,14 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
14811481 // Keep the shared subagent runtime in the outer scope: session reloads,
14821482 // dashboard updates, agent rebuilds, and shutdown all use the same owner.
14831483 var runtime *subagentRuntime
1484- var subagentsMgr *subagents.Supervisor
1485- // These callbacks deliberately resolve iv lazily because the Interactive
1486- // instance is constructed after the agent and its registry.
1487- onSpawnedSupervisor := func(a *subagents.Agent, task string, required bool) {
1484+ onResidentAccepted := func(spec subagents.ResidentChildSpec, _ string) {
14881485 if iv != nil {
1489- iv.TrackSubagentWorker(a, task, required)
1486+ iv.TrackResidentSubagent(spec.ID)
14901487 }
14911488 }
1492- onResumedSupervisor := func(a *subagents.Agent, prompt string, required bool) {
1489+ onResidentCompletion := func(completion subagents.ResidentCompletion) {
14931490 if iv != nil {
1494- iv.TrackResumedSubagentWorker(a, prompt, required)
1495- }
1496- }
1497- onBeforeResumedSupervisor := func(a *subagents.Agent, prompt string, required bool) func() {
1498- if iv != nil {
1499- return iv.PrepareResumedSubagentWorker(a, prompt, required)
1500- }
1501- return nil
1502- }
1503- onStopRequestedSupervisor := func(a *subagents.Agent) {
1504- if iv != nil {
1505- iv.TrackStoppedSubagentWorker(a)
1491+ iv.ReportResidentSubagent(completion)
15061492 }
15071493 }
15081494 activeProviderForSubagents := func() string {
@@ -1517,46 +1503,37 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
15171503 }
15181504
15191505 runtime = newSubagentRuntime(subagentRuntimeConfig{
1520- Context: ctx,
1521- Args: args,
1522- Root: filepath.Join(ZutHome(), "subagents"),
1523- RepoRoot: r.CWD,
1524- Provider: r.Provider,
1525- Model: r.Model,
1526- Reasoning: r.Reasoning,
1527- BaseURL: r.BaseURL,
1528- InsecureTLS: r.InsecureTLS,
1529- FastMode: r.FastMode,
1530- APIKey: args.APIKey,
1531- Policy: subagentPolicyFromConfig(initialCfg.Subagents),
1532- WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
1533- WebSearchGuard: webSearchGuard,
1534- ActiveProvider: activeProviderForSubagents,
1535- ActiveModel: activeModelForSubagents,
1536- OnSpawned: onSpawnedSupervisor,
1537- OnResumed: onResumedSupervisor,
1538- BeforeResumed: onBeforeResumedSupervisor,
1539- OnStopRequested: onStopRequestedSupervisor,
1506+ Args: args,
1507+ Root: filepath.Join(ZutHome(), "subagents"),
1508+ RepoRoot: r.CWD,
1509+ Provider: r.Provider,
1510+ Model: r.Model,
1511+ Reasoning: r.Reasoning,
1512+ BaseURL: r.BaseURL,
1513+ InsecureTLS: r.InsecureTLS,
1514+ FastMode: r.FastMode,
1515+ Policy: subagentPolicyFromConfig(initialCfg.Subagents),
1516+ WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
1517+ WebSearchGuard: webSearchGuard,
1518+ ActiveProvider: activeProviderForSubagents,
1519+ ActiveModel: activeModelForSubagents,
1520+ OnResidentSpawned: onResidentAccepted,
1521+ ResidentCompletion: onResidentCompletion,
15401522 })
1541- subagentsMgr = runtime.Supervisor()
15421523 reloadDone := make(chan struct{})
15431524 var reloadErrs []error
1544- runtime.SetRequiredWorkerReady(reloadDone, func() error {
1545- return errors.Join(reloadErrs...)
1546- })
1547- if subagentsMgr != nil {
1548- // Replaying historical event logs can take seconds. Populate the
1549- // detached-agent dashboard without blocking the first interactive paint.
1525+ if resident := runtime.ResidentManager(); resident != nil {
1526+ // Resident reconciliation reads only the new journal format. It marks
1527+ // active durable work interrupted and exposes it for an explicit new
1528+ // prompt; it never constructs a child or replays an accepted task.
15501529 go func() {
1551- _, reloadErrs = subagentsMgr.Reload()
1530+ reloadErrs = resident.Reconcile()
15521531 close(reloadDone)
15531532 }()
15541533 } else {
15551534 close(reloadDone)
15561535 }
15571536 defer func() {
1558- // Reload may attach detached workers, so let it finish before shutdown
1559- // snapshots the supervisor's worker set.
15601537 <-reloadDone
15611538 _ = closeSubagentRuntimeFresh(runtime)
15621539 }()
@@ -2057,13 +2034,12 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
20572034 }
20582035 activeProvider = candidate.provider
20592036 activeModel = candidate.model
2060- if subagentsMgr != nil && candidate.session != nil {
2061- // Keep the dashboard scope in the same commit as the session,
2062- // agent, usage, and persistence baseline.
2037+ if candidate.session != nil {
2038+ // Keep the resident parent's session association in the same commit
2039+ // as the session, agent, usage, and persistence baseline.
2040+ runtime.SetActiveSession(candidate.session.ID)
20632041 if iv != nil {
20642042 iv.SetSubagentSessionScope(candidate.session.ID)
2065- } else {
2066- runtime.SetActiveSession(candidate.session.ID)
20672043 }
20682044 }
20692045 committed = true
@@ -2257,10 +2233,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
22572233
22582234 // Re-scope the subagent dashboard to the new session.
22592235 if newSess != nil {
2236+ runtime.SetActiveSession(newSess.ID)
22602237 if iv != nil {
22612238 iv.SetSubagentSessionScope(newSess.ID)
2262- } else {
2263- runtime.SetActiveSession(newSess.ID)
22642239 }
22652240 }
22662241 if newSess != nil {
@@ -2340,7 +2315,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
23402315 }
23412316
23422317 // Scope the dashboard to the active host session. The runtime owns
2343- // shutdown separately and closes the supervisor exactly once.
2318+ // shutdown separately and closes the resident runtime exactly once.
23442319 if sess != nil {
23452320 runtime.SetActiveSession(sess.ID)
23462321 }
@@ -2360,9 +2335,6 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
23602335 webSearchInvocationOverride := webSearchExplicitlyEnabledForSession(args)
23612336 setWebSearchAvailable := func(available bool) {
23622337 webSearchGuard.setAvailable(available)
2363- if subagentsMgr == nil {
2364- return
2365- }
23662338 policy := subagents.WebSearchDeny
23672339 if available {
23682340 policy = subagents.WebSearchAllow
@@ -2397,7 +2369,6 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
23972369 TUIInputStyle: initialCfg.TUIInputStyle,
23982370 TUIStatusPosition: initialCfg.TUIStatusPosition,
23992371 TUIWorkingPosition: initialCfg.TUIWorkingPosition,
2400- TUISubagentPosition: initialCfg.TUISubagentPosition,
24012372 ThemeName: initialCfg.Theme,
24022373 FlatTools: initialCfg.FlatToolRender(),
24032374 CompactUser: initialCfg.CompactUserInput(),
@@ -2552,9 +2523,34 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
25522523 },
25532524 SessionTransition: newSessionTransition(&sessionTransitionMu),
25542525 Extensions: extMgr,
2555- Supervisor: subagentsMgr,
2556- ResolveSubagent: resolveSubagent,
2557- ChangelogChan: changelogCh,
2526+ ResidentManager: runtime.ResidentManager(),
2527+ SpawnResident: func(ctx context.Context, request tools.ResidentSpawnRequest) (string, error) {
2528+ manager := runtime.ResidentManager()
2529+ if manager == nil {
2530+ return "", errors.New("resident runtime is unavailable")
2531+ }
2532+ catalogue := r.ToolRegistry
2533+ if iv != nil && iv.Agent() != nil {
2534+ catalogue = iv.Agent().ToolsSnapshot()
2535+ }
2536+ spec, err := runtime.buildResidentChildSpec(ctx, request, catalogue)
2537+ if err != nil {
2538+ return "", err
2539+ }
2540+ if _, err := manager.Spawn(ctx, spec, request.Task); err != nil {
2541+ return "", err
2542+ }
2543+ return spec.ID, nil
2544+ },
2545+ BuildResidentSpec: func(ctx context.Context, request tools.ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
2546+ catalogue := r.ToolRegistry
2547+ if iv != nil && iv.Agent() != nil {
2548+ catalogue = iv.Agent().ToolsSnapshot()
2549+ }
2550+ return runtime.buildResidentChildSpec(ctx, request, catalogue)
2551+ },
2552+ ResolveSubagent: resolveSubagent,
2553+ ChangelogChan: changelogCh,
25582554 OnChangelogDismiss: func() {
25592555 // For development builds, store the actual release version
25602556 // so the same changelog doesn't show again next launch.
@@ -2754,6 +2750,9 @@ func agentSessionsRoot(root string, args Args) string {
27542750 // with a nil error if session persistence is disabled.
27552751 func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
27562752 if args.NoSess {
2753+ // Allocate the one logical identity for this non-persisted run during
2754+ // runtime assembly instead of allowing a provider adapter to invent it.
2755+ ag.SessionID()
27572756 return nil, nil
27582757 }
27592758 // Sweep meta-only files left over from older zut versions (and from
@@ -2772,14 +2771,8 @@ func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Ag
27722771 switch {
27732772 case args.Session != "":
27742773 s, msgs, err = core.OpenSession(args.Session)
2775- // The subagent-worker child passes a fixed --session path that
2776- // may not exist yet on first Spawn. Treat ENOENT as "create
2777- // a fresh session AT THIS PATH" so the conversation actually
2778- // gets persisted; without this fallback the subagent child runs
2779- // with sess==nil and every Resume re-starts with no memory
2780- // of the prior turns. Other openers (--continue / --resume /
2781- // the picker) never see ENOENT here because they only choose
2782- // paths that already exist on disk.
2774+ // An explicit session path may be created on first use; session pickers
2775+ // and resume paths still select existing files only.
27832776 if err != nil && errors.Is(err, os.ErrNotExist) {
27842777 s, err = core.NewSessionAtPath(args.Session, args.CWD, r.Provider, r.Model, version)
27852778 msgs = nil
@@ -2813,6 +2806,9 @@ func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Ag
28132806 return nil, err
28142807 }
28152808 if s != nil {
2809+ if err := ag.BindSessionID(s.Meta.ID); err != nil {
2810+ return nil, err
2811+ }
28162812 ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
28172813 ag.SetMessages(msgs)
28182814 if cum, last, uerr := core.SessionUsageDetail(s.Path); uerr == nil {
@@ -2823,6 +2819,9 @@ func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Ag
28232819 }
28242820 s, err = core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
28252821 if err == nil {
2822+ if bindErr := ag.BindSessionID(s.Meta.ID); bindErr != nil {
2823+ return nil, bindErr
2824+ }
28262825 ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
28272826 }
28282827 return s, err
M packages/agent/cli_session_test.go +24 -1
@@ -660,7 +660,6 @@ func TestApplyInitialSessionResumeWithRuntimePreservesInheritedModelDefaults(t *
660660 }
661661 current := base.NewAgent()
662662 runtime := newSubagentRuntime(subagentRuntimeConfig{
663- Context: context.Background(),
664663 Args: args,
665664 Root: t.TempDir(),
666665 RepoRoot: args.CWD,
@@ -727,6 +726,30 @@ func TestPrepareSessionResumeRebuildsForStoredProviderAndModel(t *testing.T) {
727726 }
728727 }
729728
729+func TestApplySessionResumeBindsRebuiltAgentToDurableSessionID(t *testing.T) {
730+ path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
731+ sess, _, err := core.OpenSession(path)
732+ if err != nil {
733+ t.Fatal(err)
734+ }
735+ defer sess.Close()
736+
737+ current := core.NewAgent(nil, "current-model", "", nil)
738+ candidate, err := applySessionResume(sess, current, "current-provider", "current-model", false, false, func(providerName, model string) (*core.Agent, string, string, error) {
739+ return core.NewAgent(nil, model, "", nil), providerName, model, nil
740+ })
741+ if err != nil {
742+ t.Fatal(err)
743+ }
744+ defer candidate.session.Close()
745+ if !candidate.rebuilt {
746+ t.Fatal("resume did not rebuild the agent")
747+ }
748+ if got := candidate.agent.SessionID(); got != candidate.session.Meta.ID {
749+ t.Fatalf("rebuilt agent session ID = %q, want durable session ID %q", got, candidate.session.Meta.ID)
750+ }
751+}
752+
730753 func TestPrepareSessionResumePreservesLegacyMissingMetadata(t *testing.T) {
731754 old := core.NewAgent(nil, "old-model", "", nil)
732755 old.SetMessages([]provider.Message{{
M packages/agent/config.go +10 -32
@@ -22,26 +22,15 @@ type QuickModelShortcut struct {
2222 Model string `json:"model"`
2323 }
2424
25-// SubagentsConfig contains the persisted supervisor policy. Duration values
26-// use Go strings such as "20m" so config files remain readable.
25+// SubagentsConfig contains the resident manager policy. max_concurrent limits
26+// simultaneous turns, queue_timeout bounds admission waits, allowed_tools
27+// limits child capabilities, and allowed_roots limits eligible workspaces.
28+// Obsolete keys such as tui_subagent_position are ignored.
2729 type SubagentsConfig struct {
28- MaxConcurrent int `json:"max_concurrent,omitempty"`
29- MaxConcurrentPerParent int `json:"max_concurrent_per_parent,omitempty"`
30- QueueTimeout string `json:"queue_timeout,omitempty"`
31- StartupTimeout string `json:"startup_timeout,omitempty"`
32- DefaultTimeout string `json:"default_timeout,omitempty"`
33- MaxOutputBytes int `json:"max_output_bytes,omitempty"`
34- MaxOutputLines int `json:"max_output_lines,omitempty"`
35- AllowedTools []string `json:"allowed_tools,omitempty"`
36- AllowedRoots []string `json:"allowed_roots,omitempty"`
37- HeartbeatInterval string `json:"heartbeat_interval,omitempty"`
38- IdleTimeout string `json:"idle_timeout,omitempty"`
39- ReconnectTimeout string `json:"reconnect_timeout,omitempty"`
40- CancelGracePeriod string `json:"cancel_grace_period,omitempty"`
41-
42- // MaxTotalSpawned is retained only to accept existing configuration.
43- // Lifetime total-spawn budgets are no longer enforced.
44- MaxTotalSpawned int `json:"max_total_spawned,omitempty"`
30+ MaxConcurrent int `json:"max_concurrent,omitempty"`
31+ QueueTimeout string `json:"queue_timeout,omitempty"`
32+ AllowedTools []string `json:"allowed_tools,omitempty"`
33+ AllowedRoots []string `json:"allowed_roots,omitempty"`
4534 }
4635
4736 // Config is the persisted user configuration.
@@ -112,8 +101,8 @@ type Config struct {
112101 // /settings.
113102 LSPEnabled *bool `json:"lsp_enabled,omitempty"`
114103
115- // SubagentLSPEnabled controls LSP availability in subagent worker
116- // processes. nil/missing means enabled. Toggle from /settings.
104+ // SubagentLSPEnabled controls LSP availability in resident child agents.
105+ // nil/missing means enabled. Toggle from /settings.
117106 SubagentLSPEnabled *bool `json:"subagent_lsp_enabled,omitempty"`
118107
119108 // LSPDiagnosticsOnWrite enables bounded diagnostics after a successful
@@ -172,11 +161,6 @@ type Config struct {
172161 // (default) and "below_input".
173162 TUIWorkingPosition string `json:"tui_working_position,omitempty"`
174163
175- // TUISubagentPosition controls whether live subagent activity renders
176- // above or below the main input. Supported values: "above_input" and
177- // "below_input" (default).
178- TUISubagentPosition string `json:"tui_subagent_position,omitempty"`
179-
180164 // Insecure skips TLS verification for custom inference endpoints.
181165 Insecure bool `json:"insecure,omitempty"`
182166
@@ -332,12 +316,6 @@ func validateSubagentDurations(cfg SubagentsConfig) error {
332316 value string
333317 }{
334318 {"queue_timeout", cfg.QueueTimeout},
335- {"startup_timeout", cfg.StartupTimeout},
336- {"default_timeout", cfg.DefaultTimeout},
337- {"heartbeat_interval", cfg.HeartbeatInterval},
338- {"idle_timeout", cfg.IdleTimeout},
339- {"reconnect_timeout", cfg.ReconnectTimeout},
340- {"cancel_grace_period", cfg.CancelGracePeriod},
341319 }
342320 for _, item := range values {
343321 value := strings.TrimSpace(item.value)
M packages/agent/fast_mode_test.go +7 -16
@@ -9,16 +9,12 @@ import (
99 "github.com/bnema/zut/packages/provider"
1010 )
1111
12-func TestParseArgsFastModeChildFlag(t *testing.T) {
13- args, err := ParseArgs([]string{"--fast-mode"})
14- if err != nil {
15- t.Fatalf("ParseArgs failed: %v", err)
16- }
17- if !args.FastMode {
18- t.Fatal("FastMode = false, want true")
12+func TestParseArgsRejectsLegacyFastModeChildFlags(t *testing.T) {
13+ if _, err := ParseArgs([]string{"--fast-mode"}); err == nil {
14+ t.Fatal("ParseArgs accepted removed --fast-mode child flag")
1915 }
20- if !args.FastModeSet {
21- t.Fatal("FastModeSet = false, want true")
16+ if _, err := ParseArgs([]string{"--no-fast-mode"}); err == nil {
17+ t.Fatal("ParseArgs accepted removed --no-fast-mode child flag")
2218 }
2319 }
2420
@@ -84,18 +80,13 @@ func TestResolveFastModeRequiresExplicitChildOverride(t *testing.T) {
8480 }
8581 }
8682
87-func TestResolveFastModeExplicitChildDisableOverridesConfig(t *testing.T) {
83+func TestResolveFastModeDurableChildDisableOverridesConfig(t *testing.T) {
8884 t.Setenv("ZUT_HOME", t.TempDir())
8985 enabled := true
9086 if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", FastMode: &enabled}); err != nil {
9187 t.Fatal(err)
9288 }
93- args, err := ParseArgs([]string{"--no-fast-mode"})
94- if err != nil {
95- t.Fatalf("ParseArgs failed: %v", err)
96- }
97-
98- r, err := Resolve(args, false)
89+ r, err := Resolve(Args{FastModeSet: true}, false)
9990 if err != nil {
10091 t.Fatalf("Resolve failed: %v", err)
10192 }
D packages/agent/modes/auto_subagents_policy_test.go +0 -381
@@ -1,381 +0,0 @@
1-package modes
2-
3-import (
4- "context"
5- "errors"
6- "strings"
7- "testing"
8- "time"
9-
10- "github.com/bnema/zut/packages/agent/subagents"
11- "github.com/bnema/zut/packages/agent/tools"
12- "github.com/bnema/zut/packages/core"
13- "github.com/bnema/zut/packages/provider"
14-)
15-
16-type failingAutoSubagentsSettingsStore struct {
17- SettingsStore
18- err error
19- calls int
20-}
21-
22-func (s *failingAutoSubagentsSettingsStore) SetAutoSubagents(bool) error {
23- s.calls++
24- return s.err
25-}
26-
27-func TestAutoSubagentsToggleRollsBackInMemoryStateWhenPersistenceFails(t *testing.T) {
28- previous := true
29- allowed := true
30- supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
31- t.Cleanup(supervisor.StopAll)
32- store := &failingAutoSubagentsSettingsStore{err: errors.New("disk full")}
33- agent := core.NewAgent(nil, "test-model", "base system\n\nauto-subagents guidance", core.Registry{
34- "subagent_spawn": &tools.SubagentSpawnTool{Supervisor: supervisor},
35- "subagent_status": &tools.SubagentStatusTool{Supervisor: supervisor},
36- })
37- interactive := NewInteractive(InteractiveConfig{
38- Agent: agent,
39- Supervisor: supervisor,
40- AutoSubagentsEnabled: &previous,
41- AutoSubagentsToolAllowed: &allowed,
42- AutoSubagentsStatusToolAllowed: &allowed,
43- AutoSubagentsSystemAddendum: "auto-subagents guidance",
44- SettingsStore: store,
45- })
46- interactive.settingsDialog = newSettingsDialog()
47- interactive.rend = nil
48- interactive.openSettingsDialog()
49- beforeSystem, _ := agent.PromptConfig()
50- beforeTools := agent.ToolsSnapshot()
51-
52- interactive.applySettingToggle("auto_subagents_enabled", false)
53-
54- interactive.mu.Lock()
55- gotEnabled := interactive.cfg.AutoSubagentsEnabled != nil && *interactive.cfg.AutoSubagentsEnabled
56- statusErr := interactive.statusErr
57- interactive.mu.Unlock()
58- if !gotEnabled {
59- t.Fatal("auto-subagents flag stayed disabled after persistence failure")
60- }
61- if store.calls != 1 {
62- t.Fatalf("SetAutoSubagents calls = %d, want 1", store.calls)
63- }
64- if !strings.Contains(statusErr, "disk full") {
65- t.Fatalf("status error = %q, want persistence failure", statusErr)
66- }
67- afterSystem, _ := agent.PromptConfig()
68- if afterSystem != beforeSystem {
69- t.Fatalf("system prompt changed after persistence failure: %q -> %q", beforeSystem, afterSystem)
70- }
71- afterTools := agent.ToolsSnapshot()
72- if len(afterTools) != len(beforeTools) {
73- t.Fatalf("tool registry size = %d, want unchanged %d", len(afterTools), len(beforeTools))
74- }
75- for name, before := range beforeTools {
76- if afterTools[name] != before {
77- t.Fatalf("tool %q changed after persistence failure", name)
78- }
79- }
80- item := findSettingsItem(interactive.settingsDialog.items, "auto_subagents_enabled")
81- if item == nil || !item.value {
82- t.Fatalf("settings row = %#v, want restored enabled value", item)
83- }
84-}
85-
86-func TestOrchestratorSlashTogglesAutoSubagents(t *testing.T) {
87- allowed := true
88- enabled := false
89- supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
90- t.Cleanup(supervisor.StopAll)
91- store := &failingAutoSubagentsSettingsStore{}
92- agent := core.NewAgent(nil, "test-model", "base system", core.Registry{})
93- interactive := NewInteractive(InteractiveConfig{
94- Agent: agent,
95- Supervisor: supervisor,
96- AutoSubagentsEnabled: &enabled,
97- AutoSubagentsToolAllowed: &allowed,
98- AutoSubagentsSystemAddendum: "orchestrator guidance",
99- SettingsStore: store,
100- })
101- interactive.rend = nil
102-
103- if interactive.runSlash(context.Background(), "/ORCHESTRATOR") {
104- t.Fatal("/orchestrator requested exit")
105- }
106- if interactive.cfg.AutoSubagentsEnabled == nil || !*interactive.cfg.AutoSubagentsEnabled {
107- t.Fatal("/orchestrator did not enable orchestration")
108- }
109- if store.calls != 1 || interactive.statusOK != "subagent orchestrator enabled" {
110- t.Fatalf("enable persistence/status = calls=%d status=%q", store.calls, interactive.statusOK)
111- }
112- if system, _ := agent.PromptConfig(); !strings.Contains(system, "orchestrator guidance") {
113- t.Fatalf("enabled system prompt = %q, want orchestrator guidance", system)
114- }
115-
116- interactive.runSlash(context.Background(), "/orchestrator")
117- if interactive.cfg.AutoSubagentsEnabled == nil || *interactive.cfg.AutoSubagentsEnabled {
118- t.Fatal("/orchestrator did not disable orchestration")
119- }
120- if store.calls != 2 || interactive.statusOK != "subagent orchestrator disabled" {
121- t.Fatalf("disable persistence/status = calls=%d status=%q", store.calls, interactive.statusOK)
122- }
123- if system, _ := agent.PromptConfig(); strings.Contains(system, "orchestrator guidance") {
124- t.Fatalf("disabled system prompt still contains orchestrator guidance: %q", system)
125- }
126- if _, ok := agent.ToolsSnapshot()["subagent_spawn"]; !ok {
127- t.Fatal("disabling orchestration removed subagent_spawn")
128- }
129-}
130-
131-func TestAutoSubagentsSettingsDisableUnavailablePolicy(t *testing.T) {
132- trueValue := true
133- falseValue := false
134-
135- tests := []struct {
136- name string
137- supervisor *subagents.Supervisor
138- allowed *bool
139- wantHint string
140- }{
141- {
142- name: "supervisor unavailable",
143- allowed: &trueValue,
144- wantHint: "subagent supervisor not available in this mode",
145- },
146- {
147- name: "launch policy excludes tool",
148- supervisor: subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()}),
149- allowed: &falseValue,
150- wantHint: "launch-time tool policy excludes subagent manager tools",
151- },
152- }
153- for _, tt := range tests {
154- t.Run(tt.name, func(t *testing.T) {
155- if tt.supervisor != nil {
156- t.Cleanup(tt.supervisor.StopAll)
157- }
158- iv := &Interactive{
159- cfg: InteractiveConfig{
160- AutoSubagentsEnabled: &trueValue,
161- AutoSubagentsToolAllowed: tt.allowed,
162- Supervisor: tt.supervisor,
163- },
164- settingsDialog: newSettingsDialog(),
165- }
166- iv.openSettingsDialog()
167-
168- var item settingsItem
169- for _, candidate := range iv.settingsDialog.items {
170- if candidate.key == "auto_subagents_enabled" {
171- item = candidate
172- break
173- }
174- }
175- if !item.disabled {
176- t.Fatal("auto-subagents setting is not disabled")
177- }
178- if item.value {
179- t.Fatal("disabled auto-subagents setting is shown as enabled")
180- }
181- if !strings.Contains(item.hint, tt.wantHint) {
182- t.Fatalf("hint = %q; want %q", item.hint, tt.wantHint)
183- }
184- })
185- }
186-}
187-
188-func TestAutoSubagentsToolRegistrationHonorsLaunchPolicy(t *testing.T) {
189- allowed := false
190- enabled := false
191- supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
192- t.Cleanup(supervisor.StopAll)
193- iv := &Interactive{
194- agent: &core.Agent{Tools: core.Registry{}},
195- cfg: InteractiveConfig{
196- AutoSubagentsEnabled: &enabled,
197- AutoSubagentsToolAllowed: &allowed,
198- Supervisor: supervisor,
199- },
200- dirty: make(chan struct{}, 1),
201- }
202-
203- iv.applyAutoSubagentsTool()
204- if _, ok := iv.agent.Tools["subagent_spawn"]; ok {
205- t.Fatal("subagent_spawn registered despite launch-time policy")
206- }
207- if _, ok := iv.agent.Tools["subagent_status"]; ok {
208- t.Fatal("subagent_status registered despite launch-time policy")
209- }
210- if _, ok := iv.agent.Tools["subagent_stop"]; ok {
211- t.Fatal("subagent_stop registered despite launch-time policy")
212- }
213- if _, ok := iv.agent.Tools["subagent_resume"]; ok {
214- t.Fatal("subagent_resume registered despite launch-time policy")
215- }
216- iv.applySettingToggle("auto_subagents_enabled", true)
217- iv.mu.Lock()
218- statusErr := iv.statusErr
219- value := iv.cfg.AutoSubagentsEnabled != nil && *iv.cfg.AutoSubagentsEnabled
220- iv.mu.Unlock()
221- if value {
222- t.Fatal("auto-subagents toggle enabled despite launch-time policy")
223- }
224- if !strings.Contains(statusErr, "launch-time tool policy") {
225- t.Fatalf("toggle error = %q; want launch-time policy hint", statusErr)
226- }
227-}
228-
229-func TestAutoSubagentsToolRegistrationHonorsSeparateLaunchPolicies(t *testing.T) {
230- for _, tc := range []struct {
231- name string
232- spawnAllowed bool
233- statusAllowed bool
234- stopAllowed bool
235- resumeAllowed bool
236- wantSpawn bool
237- wantStatus bool
238- wantStop bool
239- wantResume bool
240- }{
241- {name: "status only", statusAllowed: true, wantStatus: true},
242- {name: "spawn only", spawnAllowed: true, wantSpawn: true},
243- {name: "stop only", stopAllowed: true, wantStop: true},
244- {name: "resume only", resumeAllowed: true, wantResume: true},
245- {name: "all", spawnAllowed: true, statusAllowed: true, stopAllowed: true, resumeAllowed: true, wantSpawn: true, wantStatus: true, wantStop: true, wantResume: true},
246- } {
247- t.Run(tc.name, func(t *testing.T) {
248- enabled := true
249- spawnAllowed := tc.spawnAllowed
250- statusAllowed := tc.statusAllowed
251- stopAllowed := tc.stopAllowed
252- resumeAllowed := tc.resumeAllowed
253- supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
254- t.Cleanup(supervisor.StopAll)
255- iv := &Interactive{
256- agent: &core.Agent{Tools: core.Registry{}},
257- cfg: InteractiveConfig{
258- AutoSubagentsEnabled: &enabled,
259- AutoSubagentsToolAllowed: &spawnAllowed,
260- AutoSubagentsStatusToolAllowed: &statusAllowed,
261- AutoSubagentsStopToolAllowed: &stopAllowed,
262- AutoSubagentsResumeToolAllowed: &resumeAllowed,
263- Supervisor: supervisor,
264- },
265- dirty: make(chan struct{}, 1),
266- }
267-
268- iv.applyAutoSubagentsTool()
269- _, gotSpawn := iv.agent.Tools["subagent_spawn"]
270- _, gotStatus := iv.agent.Tools["subagent_status"]
271- _, gotStop := iv.agent.Tools["subagent_stop"]
272- _, gotResume := iv.agent.Tools["subagent_resume"]
273- if gotSpawn != tc.wantSpawn || gotStatus != tc.wantStatus || gotStop != tc.wantStop || gotResume != tc.wantResume {
274- t.Fatalf("registered tools spawn=%v status=%v stop=%v resume=%v, want spawn=%v status=%v stop=%v resume=%v", gotSpawn, gotStatus, gotStop, gotResume, tc.wantSpawn, tc.wantStatus, tc.wantStop, tc.wantResume)
275- }
276- })
277- }
278-}
279-
280-func TestAutoSubagentsDisabledKeepsToolsForExplicitUserRequests(t *testing.T) {
281- enabled := false
282- allowed := true
283- supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
284- t.Cleanup(supervisor.StopAll)
285- iv := NewInteractive(InteractiveConfig{
286- Agent: &core.Agent{Tools: core.Registry{}},
287- AutoSubagentsEnabled: &enabled,
288- AutoSubagentsToolAllowed: &allowed,
289- AutoSubagentsSystemAddendum: "primary-agent orchestrator",
290- Supervisor: supervisor,
291- })
292-
293- spawn, ok := iv.agent.ToolsSnapshot()["subagent_spawn"].(*tools.SubagentSpawnTool)
294- if !ok {
295- t.Fatal("subagent_spawn is unavailable when automatic subagents are disabled")
296- }
297- if spawn.Enabled == nil || !spawn.Enabled() {
298- t.Fatal("subagent_spawn is disabled when a user explicitly requests delegation")
299- }
300- system, _ := iv.agent.PromptConfig()
301- if strings.Contains(system, "primary-agent orchestrator") {
302- t.Fatalf("disabled automatic subagents retained the orchestrator contract: %q", system)
303- }
304-}
305-
306-type serializedAutoSubagentsClient struct {
307- started chan struct{}
308- request chan provider.Request
309- release chan struct{}
310- finished chan struct{}
311-}
312-
313-func (c *serializedAutoSubagentsClient) Name() string { return "serialized-auto-subagents-test" }
314-
315-func (c *serializedAutoSubagentsClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
316- c.request <- req
317- c.started <- struct{}{}
318- out := make(chan provider.Event, 1)
319- go func() {
320- defer close(out)
321- select {
322- case <-c.release:
323- out <- provider.EventDone{Stop: provider.StopEnd}
324- case <-ctx.Done():
325- out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
326- }
327- close(c.finished)
328- }()
329- return out, nil
330-}
331-
332-func TestAutoSubagentsPromptConfigurationIsSynchronized(t *testing.T) {
333- client := &serializedAutoSubagentsClient{
334- started: make(chan struct{}, 1),
335- request: make(chan provider.Request, 1),
336- release: make(chan struct{}),
337- finished: make(chan struct{}),
338- }
339- agent := core.NewAgent(client, "test-model", "base system", nil)
340- supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
341- t.Cleanup(supervisor.StopAll)
342- allowed := true
343- iv := NewInteractive(InteractiveConfig{
344- Agent: agent,
345- Supervisor: supervisor,
346- AutoSubagentsToolAllowed: &allowed,
347- AutoSubagentsSystemAddendum: "delegation guidance",
348- })
349-
350- iv.startTurn(context.Background(), "hello")
351- select {
352- case <-client.started:
353- case <-time.After(time.Second):
354- t.Fatal("prompt did not reach provider")
355- }
356-
357- request := <-client.request
358- if request.System != "base system" {
359- t.Fatalf("provider request system = %q; want base system snapshot", request.System)
360- }
361- applied := make(chan struct{})
362- go func() {
363- iv.applyAutoSubagentsSystemPrompt(true)
364- close(applied)
365- }()
366- select {
367- case <-applied:
368- case <-time.After(time.Second):
369- t.Fatal("system-prompt update did not apply while provider was running")
370- }
371- if system, _ := agent.PromptConfig(); !strings.Contains(system, "delegation guidance") {
372- t.Fatalf("system prompt missing applied guidance: %q", system)
373- }
374-
375- close(client.release)
376- select {
377- case <-client.finished:
378- case <-time.After(time.Second):
379- t.Fatal("prompt did not finish after provider release")
380- }
381-}
D packages/agent/modes/auto_subagents_test.go +0 -542
@@ -1,542 +0,0 @@
1-package modes
2-
3-import (
4- "context"
5- "errors"
6- "strings"
7- "sync"
8- "testing"
9- "time"
10-
11- "github.com/bnema/zut/packages/agent/subagents"
12- "github.com/bnema/zut/packages/core"
13- "github.com/bnema/zut/packages/provider"
14-)
15-
16-const autoSubagentsTestTimeout = 10 * time.Second
17-
18-func TestAutoSubagentsSystemPromptKeepsWritingGuidanceLast(t *testing.T) {
19- const guidance = "universal writing guidance"
20- iv := &Interactive{
21- agent: &core.Agent{System: "base system\n\n" + guidance},
22- cfg: InteractiveConfig{
23- AutoSubagentsSystemAddendum: "auto-subagents guidance",
24- WritingGuidance: guidance,
25- Supervisor: &subagents.Supervisor{},
26- },
27- }
28-
29- iv.applyAutoSubagentsSystemPrompt(true)
30- if count := strings.Count(iv.agent.System, guidance); count != 1 {
31- t.Fatalf("writing guidance count = %d, want 1: %q", count, iv.agent.System)
32- }
33- if !strings.HasSuffix(iv.agent.System, guidance) {
34- t.Fatalf("writing guidance is not final after enabling: %q", iv.agent.System)
35- }
36-
37- iv.applyAutoSubagentsSystemPrompt(false)
38- if count := strings.Count(iv.agent.System, guidance); count != 1 {
39- t.Fatalf("writing guidance count = %d, want 1: %q", count, iv.agent.System)
40- }
41- if !strings.HasSuffix(iv.agent.System, guidance) {
42- t.Fatalf("writing guidance is not final after disabling: %q", iv.agent.System)
43- }
44-}
45-
46-func TestAutoSubagentsSystemPromptKeepsProfileManifestWhileTogglingDelegationGuidance(t *testing.T) {
47- const onDemand = "delegate only on an explicit user request"
48- iv := &Interactive{
49- agent: &core.Agent{System: "base system"},
50- cfg: InteractiveConfig{
51- AutoSubagentsSystemAddendum: "auto-subagents guidance",
52- OnDemandSubagentsSystemAddendum: onDemand,
53- SubagentsSystemAddendum: "[subagents_list]\n- reviewer\n[/subagents_list]",
54- Supervisor: &subagents.Supervisor{},
55- },
56- }
57-
58- iv.applyAutoSubagentsSystemPrompt(true)
59- if !strings.Contains(iv.agent.System, "[subagents_list]") || !strings.Contains(iv.agent.System, "auto-subagents guidance") {
60- t.Fatalf("enabled system prompt missing subagent blocks: %q", iv.agent.System)
61- }
62- before := iv.agent.System
63- iv.applyAutoSubagentsSystemPrompt(true)
64- if iv.agent.System != before {
65- t.Fatalf("enabling twice changed system prompt: %q -> %q", before, iv.agent.System)
66- }
67-
68- iv.applyAutoSubagentsSystemPrompt(false)
69- if !strings.Contains(iv.agent.System, "[subagents_list]") {
70- t.Fatalf("disabled system prompt omitted profile manifest: %q", iv.agent.System)
71- }
72- if strings.Contains(iv.agent.System, "auto-subagents guidance") {
73- t.Fatalf("disabled system prompt retained orchestrator guidance: %q", iv.agent.System)
74- }
75- if !strings.Contains(iv.agent.System, onDemand) {
76- t.Fatalf("disabled system prompt omitted on-demand delegation guidance: %q", iv.agent.System)
77- }
78-}
79-
80-func TestAutoSubagentsTogglePreservesMatchingBasePromptBlocks(t *testing.T) {
81- manifest := "[subagents_list]\n- reviewer\n[/subagents_list]"
82- guidance := "auto-subagents guidance"
83- base := "base system\n\n" + manifest + "\n\n" + guidance
84- iv := &Interactive{
85- agent: &core.Agent{System: base},
86- cfg: InteractiveConfig{
87- AutoSubagentsSystemAddendum: guidance,
88- SubagentsSystemAddendum: manifest,
89- Supervisor: &subagents.Supervisor{},
90- },
91- }
92-
93- iv.applyAutoSubagentsSystemPrompt(true)
94- if strings.Count(iv.agent.System, manifest) != 2 || strings.Count(iv.agent.System, guidance) != 2 {
95- t.Fatalf("enabling should append owned duplicate blocks: %q", iv.agent.System)
96- }
97- iv.applyAutoSubagentsSystemPrompt(false)
98- want := base + "\n\n" + manifest
99- if got := strings.TrimSpace(iv.agent.System); got != want {
100- t.Fatalf("disabling changed matching base prompt blocks: %q; want %q", got, want)
101- }
102-}
103-
104-func TestAutoSubagentsSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T) {
105- response := "first response line\n" + strings.Repeat("result ", 150) + "final marker"
106- mgr := subagents.New(subagents.Config{
107- Root: t.TempDir(),
108- RepoRoot: t.TempDir(),
109- NewRunner: func(*subagents.Agent) subagents.Runner {
110- return subagents.RunnerFunc(func(_ context.Context, sink subagents.Sink) error {
111- sink.Transcript(response)
112- return nil
113- })
114- },
115- })
116- a, err := mgr.Spawn(context.Background(), strings.Repeat("long task ", 150))
117- if err != nil {
118- t.Fatal(err)
119- }
120-
121- iv := newQueuedAutoSubagentsInteractive()
122- iv.TrackSubagentWorker(a, a.Task, false)
123- update := waitForQueuedPrompt(t, iv)
124- if !strings.Contains(update, "final response:\n"+response) {
125- t.Fatalf("update missing complete final response: %q", update)
126- }
127-}
128-
129-func TestTrackSubagentWorkerReportsStartupFailure(t *testing.T) {
130- mgr := subagents.New(subagents.Config{
131- Root: t.TempDir(),
132- RepoRoot: t.TempDir(),
133- NewRunner: func(*subagents.Agent) subagents.Runner {
134- return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
135- return errors.New("listener startup failed")
136- })
137- },
138- })
139- a, err := mgr.Spawn(context.Background(), "report the date")
140- if err != nil {
141- t.Fatal(err)
142- }
143-
144- iv := newQueuedAutoSubagentsInteractive()
145- iv.TrackSubagentWorker(a, a.Task, false)
146-
147- update := waitForQueuedPrompt(t, iv)
148- if !strings.Contains(update, "status: failed") {
149- t.Fatalf("update missing failed status: %q", update)
150- }
151- if !strings.Contains(update, "listener startup failed") {
152- t.Fatalf("update missing startup error: %q", update)
153- }
154-}
155-
156-func TestTrackRequiredSubagentWorkerReportsFailureAsynchronously(t *testing.T) {
157- mgr := subagents.New(subagents.Config{
158- Root: t.TempDir(),
159- RepoRoot: t.TempDir(),
160- NewRunner: func(*subagents.Agent) subagents.Runner {
161- return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
162- return errors.New("required reviewer failed")
163- })
164- },
165- })
166- a, err := mgr.SpawnReq(context.Background(), subagents.SpawnRequest{Task: "review the release", Required: true})
167- if err != nil {
168- t.Fatal(err)
169- }
170-
171- iv := newQueuedAutoSubagentsInteractive()
172- iv.TrackSubagentWorker(a, a.Task, true)
173- update := waitForQueuedPrompt(t, iv)
174- if !strings.Contains(update, "status: failed") || !strings.Contains(update, "required reviewer failed") {
175- t.Fatalf("required worker failure was not delivered: %q", update)
176- }
177- if !a.Snapshot().Requirement.Unmet() {
178- t.Fatal("delivered failure did not remain recoverable")
179- }
180-}
181-
182-func TestTrackStoppedSubagentWorkerDeliversTerminalUpdate(t *testing.T) {
183- started := make(chan struct{})
184- mgr := subagents.New(subagents.Config{
185- Root: t.TempDir(),
186- RepoRoot: t.TempDir(),
187- NewRunner: func(*subagents.Agent) subagents.Runner {
188- return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
189- close(started)
190- <-ctx.Done()
191- return ctx.Err()
192- })
193- },
194- })
195- a, err := mgr.Spawn(context.Background(), "investigate the stuck worker")
196- if err != nil {
197- t.Fatal(err)
198- }
199- <-started
200- t.Cleanup(func() {
201- _ = mgr.Stop(a.ID)
202- a.Wait()
203- })
204-
205- iv := newQueuedAutoSubagentsInteractive()
206- iv.TrackStoppedSubagentWorker(a)
207- if err := mgr.Stop(a.ID); err != nil {
208- t.Fatal(err)
209- }
210-
211- update := waitForQueuedPrompt(t, iv)
212- if !strings.Contains(update, "task: investigate the stuck worker") {
213- t.Fatalf("stop update missing task: %q", update)
214- }
215- if !strings.Contains(update, "status: cancelled") {
216- t.Fatalf("stop update missing cancellation status: %q", update)
217- }
218- if !strings.Contains(update, "subagent stopped by request") || strings.Contains(update, "context canceled") {
219- t.Fatalf("stop update missing attributed cancellation: %q", update)
220- }
221-}
222-
223-func TestTrackDeadlineFailureUsesAttributedResultError(t *testing.T) {
224- mgr := subagents.New(subagents.Config{
225- Root: t.TempDir(),
226- RepoRoot: t.TempDir(),
227- NewRunner: func(*subagents.Agent) subagents.Runner {
228- return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
229- return context.DeadlineExceeded
230- })
231- },
232- })
233- a, err := mgr.Spawn(context.Background(), "run until deadline")
234- if err != nil {
235- t.Fatal(err)
236- }
237-
238- iv := newQueuedAutoSubagentsInteractive()
239- iv.TrackSubagentWorker(a, a.Task, false)
240- update := waitForQueuedPrompt(t, iv)
241- if !strings.Contains(update, "status: failed") {
242- t.Fatalf("deadline update changed failed status: %q", update)
243- }
244- want := subagents.ShutdownResultError(subagents.ShutdownOriginDeadline).Message
245- if !strings.Contains(update, want) {
246- t.Fatalf("deadline update missing attributed result error: %q", update)
247- }
248-}
249-
250-func TestTrackResumedSubagentWorkerDeliversFollowUp(t *testing.T) {
251- started := make(chan struct{})
252- mgr := subagents.New(subagents.Config{
253- Root: t.TempDir(),
254- RepoRoot: t.TempDir(),
255- NewRunner: func(*subagents.Agent) subagents.Runner {
256- return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
257- close(started)
258- <-ctx.Done()
259- return ctx.Err()
260- })
261- },
262- })
263- a, err := mgr.Spawn(context.Background(), "review the implementation")
264- if err != nil {
265- t.Fatal(err)
266- }
267- <-started
268- t.Cleanup(func() {
269- _ = mgr.Stop(a.ID)
270- a.Wait()
271- })
272-
273- iv := newQueuedAutoSubagentsInteractive()
274- const followUp = "I applied your review. What do you think now?"
275- iv.TrackResumedSubagentWorker(a, followUp, false)
276- if a.OnTurnEnd == nil {
277- t.Fatal("resumed worker has no completion watcher")
278- }
279- a.OnTurnEnd(1, "")
280-
281- update := waitForQueuedPrompt(t, iv)
282- if !strings.Contains(update, "task: "+followUp) {
283- t.Fatalf("follow-up update uses the original task instead of the new prompt: %q", update)
284- }
285- if !strings.Contains(update, "status: completed") {
286- t.Fatalf("follow-up update missing completed status: %q", update)
287- }
288-}
289-
290-func TestTrackResumedSubagentWorkerQueuesActiveFollowUpOnSameWorker(t *testing.T) {
291- started := make(chan struct{})
292- mgr := subagents.New(subagents.Config{
293- Root: t.TempDir(),
294- RepoRoot: t.TempDir(),
295- NewRunner: func(*subagents.Agent) subagents.Runner {
296- return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
297- select {
298- case started <- struct{}{}:
299- default:
300- }
301- <-ctx.Done()
302- return ctx.Err()
303- })
304- },
305- })
306- first, err := mgr.Spawn(context.Background(), "original task")
307- if err != nil {
308- t.Fatal(err)
309- }
310- <-started
311- t.Cleanup(func() {
312- _ = mgr.Stop(first.ID)
313- first.Wait()
314- })
315-
316- iv := newQueuedAutoSubagentsInteractive()
317- release := iv.beginCompletionDeliveryHold()
318- iv.TrackSubagentWorker(first, first.Task, false)
319- const followUp = "queued follow-up"
320- cancel := iv.PrepareResumedSubagentWorker(first, followUp, false)
321- if cancel == nil {
322- t.Fatal("resume registration returned nil cleanup")
323- }
324- if first.OnTurnEnd == nil {
325- t.Fatal("worker has no completion watcher")
326- }
327- first.OnTurnEnd(1, "")
328- first.OnTurnEnd(2, "")
329- release()
330-
331- update := waitForQueuedPrompt(t, iv)
332- if !strings.Contains(update, "[auto-subagents update] 2 sub-agent(s) finished:") {
333- t.Fatalf("active follow-up was not batched with the original turn: %q", update)
334- }
335- if !strings.Contains(update, "task: "+followUp) {
336- t.Fatalf("active follow-up task missing: %q", update)
337- }
338-}
339-
340-func TestCompletionDeliveryHoldBatchesFastAndLateRegistrations(t *testing.T) {
341- mgr := subagents.New(subagents.Config{
342- Root: t.TempDir(),
343- RepoRoot: t.TempDir(),
344- NewRunner: func(*subagents.Agent) subagents.Runner {
345- return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
346- <-ctx.Done()
347- return ctx.Err()
348- })
349- },
350- })
351- first, err := mgr.Spawn(context.Background(), "first task")
352- if err != nil {
353- t.Fatal(err)
354- }
355- second, err := mgr.Spawn(context.Background(), "second task")
356- if err != nil {
357- t.Fatal(err)
358- }
359- t.Cleanup(func() {
360- _ = mgr.Stop(first.ID)
361- _ = mgr.Stop(second.ID)
362- first.Wait()
363- second.Wait()
364- })
365-
366- iv := newQueuedAutoSubagentsInteractive()
367- release := iv.beginCompletionDeliveryHold()
368- iv.TrackSubagentWorker(first, first.Task, false)
369- first.OnTurnEnd(1, "")
370- iv.TrackSubagentWorker(second, second.Task, false)
371- second.OnTurnEnd(1, "")
372- release()
373-
374- update := waitForQueuedPrompt(t, iv)
375- if !strings.Contains(update, "[auto-subagents update] 2 sub-agent(s) finished:") {
376- t.Fatalf("fast and late registrations were not batched: %q", update)
377- }
378- if !strings.Contains(update, "task: first task") || !strings.Contains(update, "task: second task") {
379- t.Fatalf("batched update omitted one task: %q", update)
380- }
381- iv.mu.Lock()
382- queued := len(iv.queued)
383- iv.mu.Unlock()
384- if queued != 1 {
385- t.Fatalf("queued updates = %d; want exactly one batched update", queued)
386- }
387-}
388-
389-func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {
390- started := make(chan struct{})
391- mgr := subagents.New(subagents.Config{
392- Root: t.TempDir(),
393- RepoRoot: t.TempDir(),
394- NewRunner: func(*subagents.Agent) subagents.Runner {
395- return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
396- close(started)
397- <-ctx.Done()
398- return ctx.Err()
399- })
400- },
401- })
402- a, err := mgr.Spawn(context.Background(), "report the date")
403- if err != nil {
404- t.Fatal(err)
405- }
406- <-started
407- t.Cleanup(func() {
408- _ = mgr.Stop(a.ID)
409- a.Wait()
410- })
411-
412- iv := newQueuedAutoSubagentsInteractive()
413- iv.TrackSubagentWorker(a, a.Task, false)
414- if a.OnTurnEnd == nil {
415- t.Fatal("worker has no completion watcher")
416- }
417- a.OnTurnEnd(1, "")
418-
419- update := waitForQueuedPrompt(t, iv)
420- if !strings.Contains(update, "status: completed") {
421- t.Fatalf("update uses daemon status instead of turn outcome: %q", update)
422- }
423- iv.mu.Lock()
424- queued := len(iv.queued)
425- iv.mu.Unlock()
426- if queued != 1 {
427- t.Fatalf("queued updates = %d; want exactly one", queued)
428- }
429-}
430-
431-type autoSubagentsQueueClient struct {
432- requests chan provider.Request
433-}
434-
435-func (c *autoSubagentsQueueClient) Name() string { return "auto-subagents-queue-test" }
436-
437-func (c *autoSubagentsQueueClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
438- c.requests <- req
439- out := make(chan provider.Event, 1)
440- go func() {
441- defer close(out)
442- out <- provider.EventDone{
443- Stop: provider.StopEnd,
444- Message: provider.Message{
445- Role: provider.RoleAssistant,
446- Content: []provider.Content{provider.TextBlock{Text: "done"}},
447- },
448- }
449- }()
450- return out, nil
451-}
452-
453-func TestAutoSubagentsUpdateQueuedAtTurnEndStartsFollowUp(t *testing.T) {
454- client := &autoSubagentsQueueClient{requests: make(chan provider.Request, 2)}
455- agent := core.NewAgent(client, "test-model", "", nil)
456- interactive := NewInteractive(InteractiveConfig{Agent: agent})
457- interactive.runCtx = context.Background()
458-
459- var once sync.Once
460- agent.OnEvent = func(ev core.AgentEvent) {
461- if _, ok := ev.(core.EvDone); !ok {
462- return
463- }
464- once.Do(func() {
465- instruction := "Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks."
466- update := subagents.FormatCompletionUpdate([]subagents.Completion{{Task: "smoke test"}}, instruction)
467- interactive.SubmitOrQueue(update, nil)
468- })
469- }
470-
471- interactive.startTurn(context.Background(), "initial")
472- select {
473- case <-client.requests:
474- case <-time.After(2 * time.Second):
475- t.Fatal("initial request did not start")
476- }
477-
478- var followUp provider.Request
479- select {
480- case followUp = <-client.requests:
481- case <-time.After(2 * time.Second):
482- t.Fatal("queued auto-subagents update did not start a follow-up request")
483- }
484- foundUpdate := false
485- for _, message := range followUp.Messages {
486- for _, content := range message.Content {
487- if text, ok := content.(provider.TextBlock); ok && strings.Contains(text.Text, "[auto-subagents update]") {
488- foundUpdate = true
489- }
490- }
491- }
492- if !foundUpdate {
493- t.Fatalf("follow-up request did not contain the auto-subagents update: %#v", followUp.Messages)
494- }
495-
496- deadline := time.NewTimer(2 * time.Second)
497- defer deadline.Stop()
498- for {
499- interactive.mu.Lock()
500- busy := interactive.busy
501- interactive.mu.Unlock()
502- if !busy && agent.QueuedMessageCount() == 0 {
503- return
504- }
505- select {
506- case <-deadline.C:
507- t.Fatal("interactive turn remained busy or retained a queued update")
508- case <-time.After(time.Millisecond):
509- }
510- }
511-}
512-
513-func newQueuedAutoSubagentsInteractive() *Interactive {
514- return &Interactive{
515- agent: &core.Agent{},
516- busy: true,
517- compacting: true,
518- dirty: make(chan struct{}, 1),
519- }
520-}
521-
522-func waitForQueuedPrompt(t *testing.T, iv *Interactive) string {
523- t.Helper()
524- deadline := time.NewTimer(autoSubagentsTestTimeout)
525- defer deadline.Stop()
526- ticker := time.NewTicker(time.Millisecond)
527- defer ticker.Stop()
528- for {
529- iv.mu.Lock()
530- if len(iv.queued) > 0 {
531- prompt := iv.queued[0]
532- iv.mu.Unlock()
533- return prompt
534- }
535- iv.mu.Unlock()
536- select {
537- case <-deadline.C:
538- t.Fatal("timed out waiting for auto-subagents update")
539- case <-ticker.C:
540- }
541- }
542-}
D packages/agent/modes/dialog_text_test.go +0 -86
@@ -1,86 +0,0 @@
1-package modes
2-
3-import (
4- "strings"
5- "testing"
6-
7- "github.com/mattn/go-runewidth"
8-
9- "github.com/bnema/zut/packages/agent/subagents"
10- "github.com/bnema/zut/packages/tui"
11-)
12-
13-func assertRowsFitWidth(t *testing.T, rows []string, width int) {
14- t.Helper()
15- for i, row := range rows {
16- if got := runewidth.StringWidth(stripANSIBytes(row)); got > width {
17- t.Errorf("row %d width = %d, want <= %d: %q", i, got, width, stripANSIBytes(row))
18- }
19- }
20-}
21-
22-func TestBtwDialogWrapsAssistantAndKeepsCursorAligned(t *testing.T) {
23- const width = 80
24- response := "This response is deliberately long enough to require several wrapped display rows without losing any words."
25- d := &btwDialog{
26- active: true,
27- lineInput: true,
28- turns: []btwTurn{{
29- User: "check wrapping",
30- Assistant: response,
31- }},
32- editor: tui.NewEditor(""),
33- }
34-
35- rows := d.Render(tui.Theme{}, width)
36- assertRowsFitWidth(t, rows, width)
37- plain := strings.Join(rows, "\n")
38- for _, word := range strings.Fields(response) {
39- if !strings.Contains(plain, word) {
40- t.Fatalf("wrapped response lost word %q:\n%s", word, plain)
41- }
42- }
43-
44- cursorRow, _ := d.CursorPos(width)
45- if want := len(rows) - 3; cursorRow != want {
46- t.Fatalf("cursor row = %d, want editor row %d", cursorRow, want)
47- }
48-}
49-
50-func TestSupervisorTranscriptWrapsAssistantAndDiagnosticRows(t *testing.T) {
51- const width = 80
52- long := strings.Repeat("long response text ", 8)
53- rows := renderSupervisorTranscriptBlocks([]string{
54- long,
55- "stderr: " + long,
56- "error: " + long,
57- }, tui.Theme{}, width, false)
58-
59- assertRowsFitWidth(t, rows, width)
60- plain := strings.Join(rows, "\n")
61- for _, want := range []string{"long", "stderr", "✖"} {
62- if !strings.Contains(plain, want) {
63- t.Fatalf("wrapped transcript missing %q:\n%s", want, plain)
64- }
65- }
66-}
67-
68-func TestSubagentsDialogViewingWrapsSpawnedAgentResponse(t *testing.T) {
69- const width = 80
70- response := strings.Repeat("spawned agent response ", 6)
71- rows := []subagents.AgentSnapshot{{
72- ID: "agent-1",
73- Status: subagents.StatusDone,
74- Lines: []string{response},
75- }}
76- d := newSubagentsDialog()
77- d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
78- _ = d.Render(tui.Theme{}, width)
79- d.HandleKey(tui.Key{Kind: tui.KeyEnter})
80-
81- rendered := d.Render(tui.Theme{}, width)
82- assertRowsFitWidth(t, rendered, width)
83- if !strings.Contains(strings.Join(rendered, "\n"), "response") {
84- t.Fatal("spawned agent response missing from transcript")
85- }
86-}
M packages/agent/modes/double_escape.go +2 -1
@@ -79,7 +79,8 @@ func (i *Interactive) sessionTreeEscapeBlocked() bool {
7979 i.llamaDialog != nil && i.llamaDialog.Active() ||
8080 i.rescueDialog != nil && i.rescueDialog.Active() ||
8181 i.sessionDialog != nil && i.sessionDialog.Active() ||
82- i.subagentsDialog != nil && i.subagentsDialog.Active() ||
82+ i.residentSubagentsDialog != nil && i.residentSubagentsDialog.Active() ||
83+ i.residentChildSession != nil ||
8384 i.jumpDialog != nil && i.jumpDialog.Active() ||
8485 i.btwDialog != nil && i.btwDialog.Active() ||
8586 i.skillsDialog != nil && i.skillsDialog.Active() ||
D packages/agent/modes/fast_mode_test.go +0 -195
@@ -1,195 +0,0 @@
1-package modes
2-
3-import (
4- "context"
5- "errors"
6- "strings"
7- "testing"
8-
9- "github.com/bnema/zut/packages/agent/subagents"
10- "github.com/bnema/zut/packages/core"
11- "github.com/bnema/zut/packages/provider"
12-)
13-
14-type recordingFastModeSettingsStore struct {
15- SettingsStore
16- setCalls int
17- last bool
18-}
19-
20-func (s *recordingFastModeSettingsStore) SetFastMode(enabled bool) error {
21- s.setCalls++
22- s.last = enabled
23- return nil
24-}
25-
26-type failingFastModeSettingsStore struct {
27- SettingsStore
28- err error
29-}
30-
31-func (s *failingFastModeSettingsStore) SetFastMode(bool) error { return s.err }
32-
33-type fastModeProviderClient struct{}
34-
35-func (fastModeProviderClient) Name() string { return "openai" }
36-func (fastModeProviderClient) Stream(_ context.Context, _ provider.Request) (<-chan provider.Event, error) {
37- out := make(chan provider.Event)
38- close(out)
39- return out, nil
40-}
41-
42-func TestSettingsDialogOffersFastModeOffByDefault(t *testing.T) {
43- interactive := NewInteractive(InteractiveConfig{Provider: "openai", Model: "gpt-5"})
44- interactive.openSettingsDialog()
45-
46- item := findSettingsItem(interactive.settingsDialog.items, "fast_mode")
47- if item == nil {
48- t.Fatal("settings dialog is missing fast mode")
49- }
50- if item.value {
51- t.Fatal("fast mode is enabled by default")
52- }
53-}
54-
55-func TestApplyFastModeUpdatesAgentAndStore(t *testing.T) {
56- store := &recordingFastModeSettingsStore{}
57- agent := core.NewAgent(fastModeProviderClient{}, "gpt-5", "", nil)
58- interactive := NewInteractive(InteractiveConfig{
59- Provider: "openai",
60- Model: "gpt-5",
61- SettingsStore: store,
62- Agent: agent,
63- })
64- interactive.openSettingsDialog()
65- interactive.rend = nil
66-
67- interactive.applySettingToggle("fast_mode", true)
68-
69- if interactive.cfg.FastMode == nil || !*interactive.cfg.FastMode {
70- t.Fatal("interactive FastMode was not enabled")
71- }
72- if !agent.FastMode {
73- t.Fatal("agent FastMode was not enabled")
74- }
75- if store.setCalls != 1 || !store.last {
76- t.Fatalf("store calls = %d/%v, want one enabled write", store.setCalls, store.last)
77- }
78-}
79-
80-func TestFastSlashTogglesFastMode(t *testing.T) {
81- store := &recordingFastModeSettingsStore{}
82- agent := core.NewAgent(fastModeProviderClient{}, "gpt-5", "", nil)
83- supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
84- interactive := NewInteractive(InteractiveConfig{
85- Provider: "openai",
86- Model: "gpt-5",
87- SettingsStore: store,
88- Agent: agent,
89- Supervisor: supervisor,
90- })
91- interactive.rend = nil
92-
93- if interactive.runSlash(context.Background(), "/FAST") {
94- t.Fatal("/fast requested exit")
95- }
96- if interactive.cfg.FastMode == nil || !*interactive.cfg.FastMode || !agent.FastMode || !supervisor.FastMode() {
97- t.Fatalf("/fast did not enable every live fast-mode target: cfg=%v agent=%v supervisor=%v", interactive.cfg.FastMode, agent.FastMode, supervisor.FastMode())
98- }
99- if store.setCalls != 1 || !store.last || interactive.statusOK != "fast mode enabled" {
100- t.Fatalf("enable persistence/status = calls=%d last=%v status=%q", store.setCalls, store.last, interactive.statusOK)
101- }
102-
103- interactive.runSlash(context.Background(), "/fast")
104- if interactive.cfg.FastMode == nil || *interactive.cfg.FastMode || agent.FastMode || supervisor.FastMode() {
105- t.Fatalf("/fast did not disable every live fast-mode target: cfg=%v agent=%v supervisor=%v", interactive.cfg.FastMode, agent.FastMode, supervisor.FastMode())
106- }
107- if store.setCalls != 2 || store.last || interactive.statusOK != "fast mode disabled" {
108- t.Fatalf("disable persistence/status = calls=%d last=%v status=%q", store.setCalls, store.last, interactive.statusOK)
109- }
110-}
111-
112-func TestApplyFastModeRejectsUnsupportedProvider(t *testing.T) {
113- store := &recordingFastModeSettingsStore{}
114- interactive := NewInteractive(InteractiveConfig{
115- Provider: "anthropic",
116- Model: "claude-sonnet-4-5",
117- SettingsStore: store,
118- })
119- interactive.openSettingsDialog()
120- interactive.rend = nil
121-
122- interactive.applySettingToggle("fast_mode", true)
123-
124- if interactive.cfg.FastMode != nil && *interactive.cfg.FastMode {
125- t.Fatal("unsupported fast mode was enabled")
126- }
127- if store.setCalls != 0 {
128- t.Fatalf("store calls = %d, want none", store.setCalls)
129- }
130- if !strings.Contains(interactive.statusErr, "only supported for OpenAI providers") {
131- t.Fatalf("status error = %q, want unsupported-provider error", interactive.statusErr)
132- }
133-}
134-
135-func TestApplyFastModeCanDisableUnsupportedProvider(t *testing.T) {
136- enabled := true
137- store := &recordingFastModeSettingsStore{}
138- agent := core.NewAgent(fastModeProviderClient{}, "claude-sonnet-4-5", "", nil)
139- agent.FastMode = true
140- interactive := NewInteractive(InteractiveConfig{
141- Provider: "anthropic",
142- Model: "claude-sonnet-4-5",
143- FastMode: &enabled,
144- SettingsStore: store,
145- Agent: agent,
146- })
147- interactive.openSettingsDialog()
148- interactive.rend = nil
149-
150- interactive.applySettingToggle("fast_mode", false)
151-
152- if interactive.cfg.FastMode == nil || *interactive.cfg.FastMode {
153- t.Fatal("fast mode was not disabled")
154- }
155- if agent.FastMode {
156- t.Fatal("agent fast mode was not disabled")
157- }
158- if store.setCalls != 1 || store.last {
159- t.Fatalf("store calls = %d/%v, want one disabled write", store.setCalls, store.last)
160- }
161-}
162-
163-func TestApplyFastModeLeavesLiveStateWhenPersistenceFails(t *testing.T) {
164- store := &failingFastModeSettingsStore{err: errors.New("disk full")}
165- agent := core.NewAgent(fastModeProviderClient{}, "gpt-5", "", nil)
166- interactive := NewInteractive(InteractiveConfig{
167- Provider: "openai",
168- Model: "gpt-5",
169- SettingsStore: store,
170- Agent: agent,
171- })
172- interactive.openSettingsDialog()
173- interactive.rend = nil
174-
175- interactive.applySettingToggle("fast_mode", true)
176-
177- if interactive.cfg.FastMode != nil && *interactive.cfg.FastMode {
178- t.Fatal("fast mode changed despite persistence failure")
179- }
180- if agent.FastMode {
181- t.Fatal("agent fast mode changed despite persistence failure")
182- }
183- if !strings.Contains(interactive.statusErr, "disk full") {
184- t.Fatalf("status error = %q, want persistence error", interactive.statusErr)
185- }
186-}
187-
188-func findSettingsItem(items []settingsItem, key string) *settingsItem {
189- for idx := range items {
190- if items[idx].key == key {
191- return &items[idx]
192- }
193- }
194- return nil
195-}
M packages/agent/modes/goal.go +0 -13
@@ -45,19 +45,6 @@ func (i *Interactive) goalIdleLocked() bool {
4545 return !i.busy && i.agent != nil && len(i.queued) == 0 && i.agent.QueuedMessageCount() == 0
4646 }
4747
48-func (i *Interactive) goalContinuationIfIdle() (provider.Message, bool) {
49- message, ok := i.goalContinuationMessage()
50- if !ok {
51- return provider.Message{}, false
52- }
53- i.mu.Lock()
54- defer i.mu.Unlock()
55- if !i.goalIdleLocked() {
56- return provider.Message{}, false
57- }
58- return message, true
59-}
60-
6148 func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool {
6249 message, ok := i.goalContinuationMessage()
6350 if !ok || i.coordinatorHasPendingWorkers() {
A packages/agent/modes/goal_helpers_test.go +39 -0
@@ -0,0 +1,39 @@
1+package modes
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/zut/packages/agent/tools"
7+ "github.com/bnema/zut/packages/core"
8+ "github.com/mattn/go-runewidth"
9+)
10+
11+func goalToolRegistry() core.Registry {
12+ return core.Registry{tools.UpdateGoalToolName: &tools.UpdateGoalTool{}}
13+}
14+
15+func assertRowsFitWidth(t *testing.T, rows []string, width int) {
16+ t.Helper()
17+ for index, row := range rows {
18+ if got := runewidth.StringWidth(stripANSIBytes(row)); got > width {
19+ t.Errorf("row %d width = %d, want <= %d: %q", index, got, width, stripANSIBytes(row))
20+ }
21+ }
22+}
23+
24+func findSettingsItem(items []settingsItem, key string) *settingsItem {
25+ for index := range items {
26+ if items[index].key == key {
27+ return &items[index]
28+ }
29+ }
30+ return nil
31+}
32+
33+func cloneSessionGoal(goal *core.SessionGoal) *core.SessionGoal {
34+ if goal == nil {
35+ return nil
36+ }
37+ copy := *goal
38+ return &copy
39+}
D packages/agent/modes/goal_test.go +0 -586
@@ -1,586 +0,0 @@
1-package modes
2-
3-import (
4- "context"
5- "encoding/json"
6- "errors"
7- "strings"
8- "sync"
9- "testing"
10- "time"
11-
12- "github.com/bnema/zut/packages/agent/subagents"
13- agenttools "github.com/bnema/zut/packages/agent/tools"
14- "github.com/bnema/zut/packages/core"
15- "github.com/bnema/zut/packages/provider"
16-)
17-
18-func goalToolRegistry() core.Registry {
19- return core.Registry{agenttools.UpdateGoalToolName: &agenttools.UpdateGoalTool{}}
20-}
21-
22-type goalContinuationClient struct {
23- mu sync.Mutex
24- requests []provider.Request
25- onCall func(call int)
26-}
27-
28-func (c *goalContinuationClient) Name() string { return "goal-continuation" }
29-
30-func (c *goalContinuationClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
31- c.mu.Lock()
32- c.requests = append(c.requests, req)
33- call := len(c.requests)
34- onCall := c.onCall
35- c.mu.Unlock()
36- if onCall != nil {
37- onCall(call)
38- }
39- out := make(chan provider.Event, 1)
40- out <- provider.EventDone{
41- Stop: provider.StopEnd,
42- Message: provider.Message{
43- Role: provider.RoleAssistant,
44- Content: []provider.Content{provider.TextBlock{Text: "working"}},
45- },
46- }
47- close(out)
48- return out, nil
49-}
50-
51-type goalWorkerClient struct {
52- mu sync.Mutex
53- requests []provider.Request
54- calls chan int
55- onCall func(call int)
56-}
57-
58-func (c *goalWorkerClient) Name() string { return "goal-worker" }
59-
60-func (c *goalWorkerClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
61- c.mu.Lock()
62- c.requests = append(c.requests, req)
63- call := len(c.requests)
64- onCall := c.onCall
65- c.mu.Unlock()
66- if onCall != nil {
67- onCall(call)
68- }
69- c.calls <- call
70-
71- out := make(chan provider.Event, 1)
72- switch call {
73- case 1:
74- out <- provider.EventDone{
75- Stop: provider.StopToolUse,
76- Message: provider.Message{
77- Role: provider.RoleAssistant,
78- Content: []provider.Content{provider.ToolCallBlock{
79- ID: "worker",
80- Name: "start_worker",
81- Arguments: json.RawMessage(`{}`),
82- }},
83- },
84- }
85- default:
86- out <- provider.EventDone{
87- Stop: provider.StopEnd,
88- Message: provider.Message{
89- Role: provider.RoleAssistant,
90- Content: []provider.Content{provider.TextBlock{Text: "done"}},
91- },
92- }
93- }
94- close(out)
95- return out, nil
96-}
97-
98-func (c *goalWorkerClient) request(call int) provider.Request {
99- c.mu.Lock()
100- defer c.mu.Unlock()
101- return c.requests[call-1]
102-}
103-
104-type trackWorkerTool struct {
105- interactive *Interactive
106- worker *subagents.Agent
107-}
108-
109-func (t *trackWorkerTool) Name() string { return "start_worker" }
110-func (t *trackWorkerTool) Description() string { return "starts a worker" }
111-func (t *trackWorkerTool) Schema() json.RawMessage {
112- return json.RawMessage(`{"type":"object"}`)
113-}
114-func (t *trackWorkerTool) Execute(_ context.Context, _ json.RawMessage, _ func(string)) (core.ToolResult, error) {
115- t.interactive.TrackSubagentWorker(t.worker, t.worker.Task, false)
116- return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "worker started"}}}, nil
117-}
118-
119-func TestActiveGoalWaitsForWorkerAndDeliversCompletion(t *testing.T) {
120- workerRelease := make(chan struct{})
121- var releaseWorkerOnce sync.Once
122- releaseWorker := func() {
123- releaseWorkerOnce.Do(func() { close(workerRelease) })
124- }
125- workerStarted := make(chan struct{})
126- supervisor := subagents.New(subagents.Config{
127- Root: t.TempDir(),
128- RepoRoot: t.TempDir(),
129- NewRunner: func(*subagents.Agent) subagents.Runner {
130- return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
131- close(workerStarted)
132- <-workerRelease
133- return nil
134- })
135- },
136- })
137- worker, err := supervisor.Spawn(context.Background(), "inspect the upstream worktree")
138- if err != nil {
139- t.Fatal(err)
140- }
141- <-workerStarted
142- t.Cleanup(func() {
143- releaseWorker()
144- worker.Wait()
145- })
146-
147- var goalMu sync.Mutex
148- goal := &core.SessionGoal{Objective: "finish the upstream work", Status: core.GoalActive}
149- currentGoal := func() *core.SessionGoal {
150- goalMu.Lock()
151- defer goalMu.Unlock()
152- return cloneSessionGoal(goal)
153- }
154- persistGoal := func(next *core.SessionGoal) error {
155- goalMu.Lock()
156- goal = cloneSessionGoal(next)
157- goalMu.Unlock()
158- return nil
159- }
160- client := &goalWorkerClient{calls: make(chan int, 3), onCall: func(call int) {
161- if call == 3 {
162- _ = persistGoal(&core.SessionGoal{Objective: "finish the upstream work", Status: core.GoalPaused})
163- }
164- }}
165- workerTool := &trackWorkerTool{worker: worker}
166- interactive := NewInteractive(InteractiveConfig{
167- Agent: core.NewAgent(client, "model", "system", core.Registry{
168- "start_worker": workerTool,
169- agenttools.UpdateGoalToolName: &agenttools.UpdateGoalTool{},
170- }),
171- CurrentGoal: currentGoal,
172- PersistGoal: persistGoal,
173- })
174- workerTool.interactive = interactive
175- interactive.runCtx = context.Background()
176-
177- interactive.runSlash(context.Background(), "/goal finish the upstream work")
178- for want := 1; want <= 2; want++ {
179- select {
180- case got := <-client.calls:
181- if got != want {
182- t.Fatalf("provider call = %d, want %d", got, want)
183- }
184- case <-time.After(2 * time.Second):
185- t.Fatalf("provider call %d did not start", want)
186- }
187- }
188- deadline := time.NewTimer(2 * time.Second)
189- defer deadline.Stop()
190- poll := time.NewTicker(time.Millisecond)
191- defer poll.Stop()
192- for !interactive.coordinatorAcceptsUserInput() {
193- select {
194- case <-deadline.C:
195- t.Fatal("active goal started another manager turn while its worker was pending")
196- case <-poll.C:
197- }
198- }
199- select {
200- case got := <-client.calls:
201- t.Fatalf("provider call %d started before the worker completed", got)
202- default:
203- }
204-
205- releaseWorker()
206- select {
207- case got := <-client.calls:
208- if got != 3 {
209- t.Fatalf("provider call = %d, want completion delivery call 3", got)
210- }
211- case <-time.After(2 * time.Second):
212- t.Fatal("worker completion did not wake the manager")
213- }
214- request := client.request(3)
215- if len(request.Messages) == 0 {
216- t.Fatal("completion delivery request has no messages")
217- }
218- if got := userMessageText(request.Messages[len(request.Messages)-1]); !strings.Contains(got, "inspect the upstream worktree") {
219- t.Fatalf("completion delivery = %q", got)
220- }
221-}
222-
223-func TestActiveGoalStartsAnotherTurnWhenThreadBecomesIdle(t *testing.T) {
224- var mu sync.Mutex
225- var goal *core.SessionGoal
226- currentGoal := func() *core.SessionGoal {
227- mu.Lock()
228- defer mu.Unlock()
229- return cloneSessionGoal(goal)
230- }
231- persistGoal := func(next *core.SessionGoal) error {
232- mu.Lock()
233- goal = cloneSessionGoal(next)
234- mu.Unlock()
235- return nil
236- }
237- client := &goalContinuationClient{onCall: func(call int) {
238- if call == 2 {
239- _ = persistGoal(&core.SessionGoal{Objective: "finish issue 97", Status: core.GoalPaused})
240- }
241- }}
242- turnFlushed := make(chan struct{}, 2)
243- interactive := NewInteractive(InteractiveConfig{
244- Agent: core.NewAgent(client, "model", "system", goalToolRegistry()),
245- CurrentGoal: currentGoal,
246- PersistGoal: persistGoal,
247- FlushSession: func() {
248- turnFlushed <- struct{}{}
249- },
250- })
251- interactive.runCtx = context.Background()
252- interactive.runSlash(context.Background(), "/goal finish issue 97")
253- for turn := 1; turn <= 2; turn++ {
254- select {
255- case <-turnFlushed:
256- case <-time.After(2 * time.Second):
257- t.Fatalf("turn %d did not finish", turn)
258- }
259- }
260-
261- client.mu.Lock()
262- defer client.mu.Unlock()
263- if len(client.requests) != 2 {
264- t.Fatalf("provider requests = %d, want 2", len(client.requests))
265- }
266- messages := client.requests[1].Messages
267- if len(messages) == 0 || messages[len(messages)-1].Meta[goalContinueMetaKey] != "true" {
268- t.Fatalf("continuation request tail = %#v", messages)
269- }
270-}
271-
272-func TestReplacementGoalContinuesAfterStaleTurn(t *testing.T) {
273- var mu sync.Mutex
274- goal := &core.SessionGoal{Objective: "replacement objective", Status: core.GoalActive}
275- currentGoal := func() *core.SessionGoal {
276- mu.Lock()
277- defer mu.Unlock()
278- return cloneSessionGoal(goal)
279- }
280- persistGoal := func(next *core.SessionGoal) error {
281- mu.Lock()
282- goal = cloneSessionGoal(next)
283- mu.Unlock()
284- return nil
285- }
286- client := &goalContinuationClient{onCall: func(call int) {
287- if call == 1 {
288- _ = persistGoal(&core.SessionGoal{Objective: "replacement objective", Status: core.GoalPaused})
289- }
290- }}
291- turnFlushed := make(chan struct{}, 1)
292- interactive := NewInteractive(InteractiveConfig{
293- Agent: core.NewAgent(client, "model", "system", goalToolRegistry()),
294- CurrentGoal: currentGoal,
295- PersistGoal: persistGoal,
296- FlushSession: func() {
297- turnFlushed <- struct{}{}
298- },
299- })
300- interactive.runCtx = context.Background()
301- interactive.mu.Lock()
302- interactive.busy = true
303- interactive.mu.Unlock()
304- stale := provider.Message{
305- Role: provider.RoleUser,
306- Content: []provider.Content{provider.TextBlock{Text: "original objective"}},
307- Meta: map[string]string{goalContinueMetaKey: "true"},
308- }
309-
310- interactive.startGoalContinuation(context.Background(), stale)
311- select {
312- case <-turnFlushed:
313- case <-time.After(2 * time.Second):
314- t.Fatal("replacement goal did not start")
315- }
316-
317- client.mu.Lock()
318- defer client.mu.Unlock()
319- if len(client.requests) != 1 {
320- t.Fatalf("provider requests = %d, want 1", len(client.requests))
321- }
322- tail := client.requests[0].Messages[len(client.requests[0].Messages)-1]
323- if got := userMessageText(tail); !strings.Contains(got, "replacement objective") || strings.Contains(got, "original objective") {
324- t.Fatalf("continuation message = %q", got)
325- }
326-}
327-
328-func TestToolResultPersistenceRunsOutsideTUILock(t *testing.T) {
329- var transitionMu sync.RWMutex
330- transitionCompleted := false
331- callbackStarted := make(chan struct{})
332- allowPersistence := make(chan struct{})
333- transitionHeld := make(chan struct{})
334- transitionDone := make(chan struct{})
335- handled := make(chan struct{})
336- agent := core.NewAgent(nil, "model", "system", goalToolRegistry())
337- agent.CommitToolResult = func(_ string, _ core.ToolResult) error {
338- close(callbackStarted)
339- <-allowPersistence
340- transitionMu.RLock()
341- completed := transitionCompleted
342- transitionMu.RUnlock()
343- if !completed {
344- return errors.New("session transition did not complete")
345- }
346- return nil
347- }
348- interactive := NewInteractive(InteractiveConfig{Agent: agent})
349- commitErr := make(chan error, 1)
350-
351- go func() {
352- commitErr <- agent.CommitToolResult("tool", core.ToolResult{})
353- interactive.handleEvent(core.EvToolResult{ID: "tool", Result: core.ToolResult{}})
354- close(handled)
355- }()
356- select {
357- case <-callbackStarted:
358- case <-time.After(2 * time.Second):
359- t.Fatal("tool-result callback did not start")
360- }
361- go func() {
362- transitionMu.Lock()
363- close(transitionHeld)
364- interactive.ApplySessionAgent(core.NewAgent(nil, "model", "system", goalToolRegistry()), "provider", "model")
365- transitionCompleted = true
366- transitionMu.Unlock()
367- close(transitionDone)
368- }()
369- select {
370- case <-transitionHeld:
371- case <-time.After(2 * time.Second):
372- t.Fatal("session transition did not acquire its lock")
373- }
374- select {
375- case <-transitionDone:
376- case <-time.After(2 * time.Second):
377- close(allowPersistence)
378- t.Fatal("session transition deadlocked with tool-result persistence")
379- }
380- close(allowPersistence)
381- select {
382- case <-handled:
383- case <-time.After(2 * time.Second):
384- t.Fatal("tool-result callback did not finish")
385- }
386- if err := <-commitErr; err != nil {
387- t.Fatalf("commit tool result: %v", err)
388- }
389-}
390-
391-func TestGoalBadgeAcceptsNextPersistedManagerGoal(t *testing.T) {
392- interactive := NewInteractive(InteractiveConfig{
393- Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()),
394- CurrentGoal: func() *core.SessionGoal {
395- return &core.SessionGoal{Objective: "implement the fix", Status: core.GoalActive, Owner: core.GoalOwnerManager}
396- },
397- })
398- interactive.mu.Lock()
399- interactive.goalStatus = core.GoalDone
400- interactive.mu.Unlock()
401-
402- interactive.handleEvent(core.EvToolResult{ID: "goal", Result: core.ToolResult{
403- Details: agenttools.GoalUpdate{Status: core.GoalActive, Objective: "implement the fix"},
404- }})
405- interactive.mu.Lock()
406- defer interactive.mu.Unlock()
407- if interactive.goalStatus != core.GoalActive {
408- t.Fatalf("goal badge = %q, want active", interactive.goalStatus)
409- }
410-}
411-
412-func TestGoalBadgeUsesDurableStatusAfterPersistenceFailure(t *testing.T) {
413- goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive}
414- interactive := NewInteractive(InteractiveConfig{
415- Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()),
416- CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) },
417- })
418- // Core converts a goal update to an error result when durable persistence
419- // fails, so the presentation layer must leave the active badge unchanged.
420- result := core.ToolResult{
421- Content: []provider.Content{provider.TextBlock{Text: "tool result state could not be persisted"}},
422- IsError: true,
423- }
424-
425- interactive.handleEvent(core.EvToolResult{ID: "goal", Result: result})
426- interactive.mu.Lock()
427- defer interactive.mu.Unlock()
428- if interactive.goalStatus != core.GoalActive {
429- t.Fatalf("goal badge = %q, want durable active status", interactive.goalStatus)
430- }
431-}
432-
433-func TestGoalSlashCommandStartsAndControlsGoal(t *testing.T) {
434- var goal *core.SessionGoal
435- interactive := NewInteractive(InteractiveConfig{
436- Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()),
437- CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) },
438- PersistGoal: func(next *core.SessionGoal) error {
439- goal = cloneSessionGoal(next)
440- return nil
441- },
442- })
443- interactive.busy = true // exercise controls without launching a provider turn
444-
445- interactive.runSlash(context.Background(), "/goal finish issue 97")
446- if goal == nil || goal.Status != core.GoalActive || goal.Objective != "finish issue 97" {
447- t.Fatalf("started goal = %#v", goal)
448- }
449- if interactive.goalStatus != core.GoalActive {
450- t.Fatalf("started badge = %q", interactive.goalStatus)
451- }
452-
453- interactive.runSlash(context.Background(), "/goal pause")
454- if goal.Status != core.GoalPaused {
455- t.Fatalf("paused goal = %#v", goal)
456- }
457- if interactive.goalStatus != core.GoalPaused {
458- t.Fatalf("paused badge = %q", interactive.goalStatus)
459- }
460- interactive.runSlash(context.Background(), "/goal resume")
461- if goal.Status != core.GoalActive {
462- t.Fatalf("resumed goal = %#v", goal)
463- }
464- if interactive.goalStatus != core.GoalActive {
465- t.Fatalf("resumed badge = %q", interactive.goalStatus)
466- }
467- interactive.runSlash(context.Background(), "/goal clear")
468- if goal != nil {
469- t.Fatalf("cleared goal = %#v", goal)
470- }
471- if interactive.goalStatus != "" {
472- t.Fatalf("cleared badge = %q", interactive.goalStatus)
473- }
474- interactive.runSlash(context.Background(), "/goal pause deployment safely")
475- if goal == nil || goal.Status != core.GoalActive || goal.Objective != "pause deployment safely" {
476- t.Fatalf("multiword control-like objective = %#v", goal)
477- }
478- goal.Status = core.GoalBlocked
479- goal.Reason = "waiting for input"
480- interactive.runSlash(context.Background(), "/goal")
481- if !strings.Contains(interactive.statusOK, "waiting for input") {
482- t.Fatalf("goal status = %q", interactive.statusOK)
483- }
484-}
485-
486-func TestGoalHistoryShowsOwnerAndReason(t *testing.T) {
487- interactive := NewInteractive(InteractiveConfig{
488- CurrentGoal: func() *core.SessionGoal { return nil },
489- PersistGoal: func(*core.SessionGoal) error { return nil },
490- CurrentGoalHistory: func() []core.SessionGoal {
491- return []core.SessionGoal{
492- {Objective: "reproduce", Status: core.GoalDone, Owner: core.GoalOwnerManager},
493- {Objective: "await input", Status: core.GoalBlocked, Owner: core.GoalOwnerUser, Reason: "missing logs"},
494- }
495- },
496- })
497-
498- interactive.runSlash(context.Background(), "/goal history")
499- if !strings.Contains(interactive.statusOK, "done (manager): reproduce") || !strings.Contains(interactive.statusOK, "blocked (user): await input (missing logs)") {
500- t.Fatalf("goal history = %q", interactive.statusOK)
501- }
502-}
503-
504-func TestGoalControlRequiresSessionPersistence(t *testing.T) {
505- interactive := NewInteractive(InteractiveConfig{})
506- interactive.runSlash(context.Background(), "/goal pause")
507- if !strings.Contains(interactive.statusErr, "session persistence is unavailable") {
508- t.Fatalf("status error = %q", interactive.statusErr)
509- }
510-}
511-
512-func TestGoalCommandDoesNotMutateLiveStateWhenPersistenceFails(t *testing.T) {
513- goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive}
514- interactive := NewInteractive(InteractiveConfig{
515- Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()),
516- CurrentGoal: func() *core.SessionGoal { return goal },
517- PersistGoal: func(*core.SessionGoal) error { return errors.New("disk full") },
518- })
519- interactive.busy = true
520-
521- interactive.runSlash(context.Background(), "/goal pause")
522- if goal.Status != core.GoalActive {
523- t.Fatalf("live goal status = %q, want active", goal.Status)
524- }
525- if interactive.goalStatus != core.GoalActive {
526- t.Fatalf("goal badge = %q, want active", interactive.goalStatus)
527- }
528-}
529-
530-func TestGoalSlashCommandRequiresUpdateGoalTool(t *testing.T) {
531- var persisted bool
532- interactive := NewInteractive(InteractiveConfig{
533- Agent: core.NewAgent(nil, "model", "system", nil),
534- CurrentGoal: func() *core.SessionGoal { return nil },
535- PersistGoal: func(*core.SessionGoal) error {
536- persisted = true
537- return nil
538- },
539- })
540-
541- interactive.runSlash(context.Background(), "/goal finish issue 97")
542- if persisted {
543- t.Fatal("goal was persisted without the update_goal tool")
544- }
545- if !strings.Contains(interactive.statusErr, "update_goal is unavailable") {
546- t.Fatalf("status error = %q", interactive.statusErr)
547- }
548-}
549-
550-func TestGoalContinuationRequiresActiveIdleGoalAndYieldsToQueuedUser(t *testing.T) {
551- goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive}
552- agent := core.NewAgent(nil, "model", "system", goalToolRegistry())
553- interactive := &Interactive{
554- agent: agent,
555- cfg: InteractiveConfig{CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) }},
556- }
557-
558- message, ok := interactive.goalContinuationIfIdle()
559- if !ok {
560- t.Fatal("active idle goal did not request continuation")
561- }
562- if message.Meta[goalContinueMetaKey] != "true" || message.Role != provider.RoleUser {
563- t.Fatalf("continuation message = %#v", message)
564- }
565- if !isHiddenTranscriptMessage(message) {
566- t.Fatal("goal continuation was rendered as a user message")
567- }
568-
569- agent.QueueMessage("user follow-up")
570- if _, ok := interactive.goalContinuationIfIdle(); ok {
571- t.Fatal("goal continuation bypassed queued user work")
572- }
573- agent.DrainQueuedMessages()
574- goal.Status = core.GoalPaused
575- if _, ok := interactive.goalContinuationIfIdle(); ok {
576- t.Fatal("paused goal requested continuation")
577- }
578-}
579-
580-func cloneSessionGoal(goal *core.SessionGoal) *core.SessionGoal {
581- if goal == nil {
582- return nil
583- }
584- clone := *goal
585- return &clone
586-}
M packages/agent/modes/interactive.go +271 -319
@@ -169,10 +169,6 @@ type InteractiveConfig struct {
169169 // above or below the main input.
170170 TUIWorkingPosition string
171171
172- // TUISubagentPosition controls whether live subagent activity renders
173- // above or below the main input. Empty defaults below the input.
174- TUISubagentPosition string
175-
176172 // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The
177173 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal
178174 // forwards Command/Super keypresses, but Ctrl is the displayed chord.
@@ -229,7 +225,7 @@ type InteractiveConfig struct {
229225 // ceilings after a live registry commit or fail-closed revocation. It can
230226 // run while agentMu and i.mu are held, so callbacks must not re-enter
231227 // Interactive or wait on either lock. The CLI callback only updates its
232- // web-search guard and supervisor policy.
228+ // web-search guard and resident child policy.
233229 SetWebSearchAvailable func(bool)
234230
235231 // RefreshPrompt re-resolves the complete live prompt and tool registry
@@ -390,12 +386,14 @@ type InteractiveConfig struct {
390386 // AFTER the built-in catalog so a built-in name always wins.
391387 Extensions *extensions.Manager
392388
393- // Supervisor, if non-nil, enables the /subagents slash command and the
394- // dashboard dialog. The cli constructs the Supervisor once per
395- // interactive run and tears it down on exit. nil disables the
396- // feature entirely (used by embedders / tests that don't want
397- // subprocesses).
398- Supervisor *subagents.Supervisor
389+ // ResidentManager owns the /subagents runtime and provides bounded journal
390+ // pages plus live projections to the child-session UI.
391+ ResidentManager *subagents.ResidentManager
392+ // SpawnResident resolves the host's live registry/configuration and commits
393+ // a new resident child. Keeping that construction callback in agent avoids
394+ // leaking provider or credential assembly into modes.
395+ SpawnResident func(context.Context, tools.ResidentSpawnRequest) (string, error)
396+ BuildResidentSpec func(context.Context, tools.ResidentSpawnRequest) (subagents.ResidentChildSpec, error)
399397
400398 // ResolveSubagent validates a named markdown profile for direct
401399 // /subagents commands. Auto-subagents uses the equivalent callback on its
@@ -495,7 +493,6 @@ type SettingsStore interface {
495493 SetTUIInputStyle(style string) error
496494 SetTUIStatusPosition(position string) error
497495 SetTUIWorkingPosition(position string) error
498- SetTUISubagentPosition(position string) error
499496 SetReasoning(level string) error
500497 SetTheme(name string) error
501498 }
@@ -672,33 +669,32 @@ type Interactive struct {
672669 // it. We snapshot the total expected stream length (already
673670 // streamed + still pending) at the moment the tool starts, and
674671 // hold the block back until the pacer reaches it.
675- toolGate map[string]int
676- statusErr string
677- statusOK string
678- goalStatus core.GoalStatus
679- reloadStatusSeq uint64
680- extStatuses map[string]map[string]extensionStatus
681- extWidgets map[string]map[string]extensionWidget
682- rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible
683- liveBlock []string // live streaming/tool progress rendered outside scrollback
684- helpBlock []string // rendered above the chat when /help was typed
685- cumUsage provider.Usage
686- lastCtxInput int // input_tokens of the most recent turn — approximates current context size
687- busy bool
688- ctrlCExit bool
689- activity agentActivity
690- subagentActivityActive bool
691- pendingIdleWork []func()
692- dirty chan struct{}
693- renderScheduler atomic.Pointer[latestFrameScheduler]
694- renderRevision atomic.Uint64
695- renderOutsideLock bool
696- modelRefresh chan modelRefreshResult
697- modelRefreshing bool
698- startupPreDone chan startupPreResult
699- cancelTurn context.CancelFunc
700- scrollOffset int // rows from the bottom; 0 = pinned to latest
701- prevScrollOffset int // last value redraw snapped against; tracks intent
672+ toolGate map[string]int
673+ statusErr string
674+ statusOK string
675+ goalStatus core.GoalStatus
676+ reloadStatusSeq uint64
677+ extStatuses map[string]map[string]extensionStatus
678+ extWidgets map[string]map[string]extensionWidget
679+ rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible
680+ liveBlock []string // live streaming/tool progress rendered outside scrollback
681+ helpBlock []string // rendered above the chat when /help was typed
682+ cumUsage provider.Usage
683+ lastCtxInput int // input_tokens of the most recent turn — approximates current context size
684+ busy bool
685+ ctrlCExit bool
686+ activity agentActivity
687+ pendingIdleWork []func()
688+ dirty chan struct{}
689+ renderScheduler atomic.Pointer[latestFrameScheduler]
690+ renderRevision atomic.Uint64
691+ renderOutsideLock bool
692+ modelRefresh chan modelRefreshResult
693+ modelRefreshing bool
694+ startupPreDone chan startupPreResult
695+ cancelTurn context.CancelFunc
696+ scrollOffset int // rows from the bottom; 0 = pinned to latest
697+ prevScrollOffset int // last value redraw snapped against; tracks intent
702698
703699 // prevChatLen and prevChatCols track the chat buffer's size at the
704700 // last redraw so that when content grows below the user's viewport
@@ -771,28 +767,30 @@ type Interactive struct {
771767 // while the check hasn't completed or nothing is available.
772768 updateInfo UpdateInfo
773769
774- dialog *loginDialog
775- modelDialog *modelDialog
776- llamaDialog *llamaDialog
777- rescueDialog *rescueDialog
778- sessionDialog *sessionDialog
779- subagentsDialog *subagentsDialog
780- jumpDialog *jumpDialog
781- btwDialog *btwDialog
782- skillsDialog *skillsDialog
783- changelogDialog *changelogDialog
784- confirmDialog *confirmDialog
785- logoutDialog *logoutDialog
786- telegramDialog *telegramDialog
787- settingsDialog *settingsDialog
788- quickModelAssign int
789- telegramBridge *telegram.Bridge
790- sessionOpsDialog *sessionOpsDialog
791- sessionTreeDialog *sessionTreeDialog
792- extPanel *extPanelDialog
793- llamaConfigured bool
794-
795- // completionTracker observes auto-subagent turn and process completion.
770+ dialog *loginDialog
771+ modelDialog *modelDialog
772+ llamaDialog *llamaDialog
773+ rescueDialog *rescueDialog
774+ sessionDialog *sessionDialog
775+ residentSubagentsDialog *residentSubagentsDialog
776+ residentChildSession *residentChildSession
777+ jumpDialog *jumpDialog
778+ btwDialog *btwDialog
779+ skillsDialog *skillsDialog
780+ changelogDialog *changelogDialog
781+ confirmDialog *confirmDialog
782+ logoutDialog *logoutDialog
783+ telegramDialog *telegramDialog
784+ settingsDialog *settingsDialog
785+ quickModelAssign int
786+ telegramBridge *telegram.Bridge
787+ sessionOpsDialog *sessionOpsDialog
788+ sessionTreeDialog *sessionTreeDialog
789+ extPanel *extPanelDialog
790+ llamaConfigured bool
791+
792+ // completionTracker collects resident child terminal turns for delivery to
793+ // the parent orchestration wave.
796794 // turnCoordinator seals worker waves at manager-turn boundaries and decides
797795 // the next wake without relying on timing or polling.
798796 completionTracker *subagents.CompletionTracker
@@ -804,18 +802,15 @@ type Interactive struct {
804802 completionDeliveryRequest bool
805803 completionDeliveryHolds int
806804
807- // subagentsWaitWatcherDone is an optional test hook invoked when a
808- // /subagents wait watcher exits.
809- subagentsWaitWatcherDone func()
810-
811805 // pendingFork is true when the user ran /session fork: the next
812806 // jump-picker selection should branch off that message instead
813807 // of scrolling. Flag resets after the action fires or the dialog
814808 // is dismissed, so repeated /jump calls don't turn into forks.
815- pendingFork bool
816- suggest *slashSuggester
817- fileSuggest *fileSuggester
818- spin *spinner
809+ pendingFork bool
810+ suggest *slashSuggester
811+ fileSuggest *fileSuggester
812+ spin *spinner
813+ residentAnimating atomic.Bool
819814
820815 // parkedTurn is the 1-based turn number the viewport is currently
821816 // scrolled to by /jump. 0 = not parked, showing the tail as usual.
@@ -966,40 +961,40 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
966961 // Prompt is the standard half-block accent bar used by chat
967962 // speaker labels too, so the input gutter matches the rest
968963 // of the UI.
969- ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),
970- rend: renderer,
971- toolCalls: map[string]*tui.ToolCallView{},
972- toolGate: map[string]int{},
973- dirty: make(chan struct{}, 8),
974- modelRefresh: make(chan modelRefreshResult, 1),
975- startupPreDone: make(chan startupPreResult, 1),
976- dialog: newLoginDialog(),
977- modelDialog: newModelDialog(),
978- llamaDialog: newLlamaDialog(),
979- rescueDialog: newRescueDialog(),
980- sessionDialog: newSessionDialog(),
981- subagentsDialog: newSubagentsDialog(),
982- jumpDialog: newJumpDialog(),
983- btwDialog: newBtwDialog(),
984- skillsDialog: newSkillsDialog(),
985- changelogDialog: newChangelogDialog(),
986- confirmDialog: newConfirmDialog(),
987- logoutDialog: newLogoutDialog(),
988- telegramDialog: newTelegramDialog(),
989- settingsDialog: newSettingsDialog(),
990- sessionOpsDialog: newSessionOpsDialog(),
991- sessionTreeDialog: newSessionTreeDialog(),
992- extPanel: newExtPanelDialog(),
993- extStatuses: map[string]map[string]extensionStatus{},
994- extWidgets: map[string]map[string]extensionWidget{},
995- suggest: newSlashSuggester(),
996- fileSuggest: newFileSuggester(),
997- spin: newSpinner(cfg.Theme),
998- inputHistoryIndex: -1,
999- clock: time.Now,
1000- goalStatus: initialGoalStatus,
1001- reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
1002- compactContinuation: decodeCompactHandoff(cfg.InitialCompactHandoff),
964+ ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),
965+ rend: renderer,
966+ toolCalls: map[string]*tui.ToolCallView{},
967+ toolGate: map[string]int{},
968+ dirty: make(chan struct{}, 8),
969+ modelRefresh: make(chan modelRefreshResult, 1),
970+ startupPreDone: make(chan startupPreResult, 1),
971+ dialog: newLoginDialog(),
972+ modelDialog: newModelDialog(),
973+ llamaDialog: newLlamaDialog(),
974+ rescueDialog: newRescueDialog(),
975+ sessionDialog: newSessionDialog(),
976+ residentSubagentsDialog: newResidentSubagentsDialog(),
977+ jumpDialog: newJumpDialog(),
978+ btwDialog: newBtwDialog(),
979+ skillsDialog: newSkillsDialog(),
980+ changelogDialog: newChangelogDialog(),
981+ confirmDialog: newConfirmDialog(),
982+ logoutDialog: newLogoutDialog(),
983+ telegramDialog: newTelegramDialog(),
984+ settingsDialog: newSettingsDialog(),
985+ sessionOpsDialog: newSessionOpsDialog(),
986+ sessionTreeDialog: newSessionTreeDialog(),
987+ extPanel: newExtPanelDialog(),
988+ extStatuses: map[string]map[string]extensionStatus{},
989+ extWidgets: map[string]map[string]extensionWidget{},
990+ suggest: newSlashSuggester(),
991+ fileSuggest: newFileSuggester(),
992+ spin: newSpinner(cfg.Theme),
993+ inputHistoryIndex: -1,
994+ clock: time.Now,
995+ goalStatus: initialGoalStatus,
996+ reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
997+ compactContinuation: decodeCompactHandoff(cfg.InitialCompactHandoff),
1003998 }
1004999 i.btwDialog.setCloseHook(func() {
10051000 i.confirmDialog.CancelChildConfirmations("side chat closed")
@@ -1011,6 +1006,18 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
10111006 i.llamaConfigured = err == nil && baseURL != ""
10121007 }
10131008 i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg, cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled)
1009+ if cfg.ResidentManager != nil {
1010+ // Resident updates arrive from the child control goroutine. The
1011+ // renderer reads its immutable live projection on the next throttled
1012+ // frame, so this callback must only request that frame.
1013+ cfg.ResidentManager.SetUpdateObserver(func(string) { i.invalidate() })
1014+ cfg.ResidentManager.SetHistoryUpdateObserver(func(childID string) {
1015+ go i.reloadOpenResidentChildSession(childID)
1016+ })
1017+ cfg.ResidentManager.SetActivityObserver(func(active bool) {
1018+ i.residentAnimating.Store(active)
1019+ })
1020+ }
10141021 if cfg.Agent != nil {
10151022 i.agent = cfg.Agent
10161023 i.view.Messages = filterHiddenTranscriptMessages(cfg.Agent.Messages())
@@ -1359,20 +1366,11 @@ func (i *Interactive) Run(ctx context.Context) error {
13591366 // A session picker that is still loading is an exception so
13601367 // its dialog-local spinner can advance.
13611368 //
1362- // The subagent dashboard is also animated: its rows reflect
1363- // background subprocesses whose activity / age change
1364- // without any user input. Without the tick redraw the
1365- // dashboard freezes on the snapshot taken when the user
1366- // last pressed a key. We exclude the dashboard when one
1367- // of its inline editors (spawn task or prompt composer)
1368- // is active so the cursor blink in those editors works
1369- // the same way it does inside btw.
13701369 i.mu.Lock()
13711370 busy := i.busy
1372- subagentActivityActive := i.subagentActivityActive
13731371 sessionLoading := i.sessionDialog.Loading()
13741372 i.mu.Unlock()
1375- if busy || subagentActivityActive || sessionLoading || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() {
1373+ if busy || sessionLoading || i.btwDialog.Loading() || i.residentAnimating.Load() {
13761374 requestRedraw()
13771375 }
13781376 }
@@ -1966,17 +1964,12 @@ func (i *Interactive) redraw() {
19661964 }
19671965 i.sessionDialog.MaxRows = avail
19681966 dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols)
1969- case i.subagentsDialog.Active():
1970- // The dashboard is composed into the bottom-sticky dialog band.
1971- // Reserve its leading gap, frame padding, one chat row, and the
1972- // renderer margin so a long list or expanded snapshot never pushes
1973- // its top rows out of the terminal viewport.
1974- avail := rows - 4
1975- if avail < 4 {
1976- avail = 4
1977- }
1978- i.subagentsDialog.SetMaxRows(avail)
1979- dialog = i.subagentsDialog.Render(i.cfg.Theme, mainCols)
1967+ case i.residentSubagentsDialog.Active():
1968+ _, terminalRows := i.cfg.Terminal.Size()
1969+ dialog = i.residentSubagentsDialog.Render(i.cfg.Theme, mainCols, terminalRows)
1970+ case i.residentChildSession != nil:
1971+ _, terminalRows := i.cfg.Terminal.Size()
1972+ dialog = i.residentChildSession.Render(mainCols, terminalRows)
19801973 case i.jumpDialog.Active():
19811974 dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols)
19821975 case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()):
@@ -2151,19 +2144,14 @@ func (i *Interactive) redraw() {
21512144 i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
21522145 }
21532146 edLines, curR, curC := i.ed.Render(mainCols)
2154- dashboardActive := i.subagentsDialog.Active()
2155- var allSubagentLines []string
2156- if !dashboardActive {
2157- now := i.clock()
2158- activitySnapshots, traceViews := i.activeSubagentActivitySnapshots()
2159- allSubagentLines = renderSubagentActivityLines(
2160- i.cfg.Theme,
2161- i.spin.FrameAt(now),
2162- activitySnapshots,
2163- traceViews,
2164- mainCols,
2165- now,
2166- )
2147+ dashboardActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil
2148+ var allResidentSubagentLines []string
2149+ residentSubagentHidden := 0
2150+ if !dashboardActive && i.cfg.ResidentManager != nil {
2151+ const residentIndicatorSnapshotLimit = 8
2152+ snapshots, total := i.cfg.ResidentManager.ActiveSnapshotPage(residentIndicatorSnapshotLimit)
2153+ allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock())
2154+ residentSubagentHidden = total - len(snapshots)
21672155 }
21682156 var workingLines []string
21692157 if busyPrefix != "" && !workingWithStatus {
@@ -2212,21 +2200,14 @@ func (i *Interactive) redraw() {
22122200 // content. The status block and editor get their own dedicated
22132201 // blanks so spacing stays consistent whether or not a dialog or
22142202 // popup is showing.
2215- composeBottom := func(subagentLines []string) (bottom []string, inputStartRow int) {
2216- bottom = make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(subagentLines)+len(edLines)+9)
2203+ composeBottom := func(residentSubagentLines []string) (bottom []string, inputStartRow int) {
2204+ bottom = make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9)
22172205 inputStartRow = -1
22182206 if len(dialog) > 0 {
22192207 bottom = append(bottom, "")
22202208 }
22212209 bottom = append(bottom, dialog...)
2222- // The subagent dashboard owns the bottom of the screen while it's
2223- // active: it has its own inline editors for spawn (`n`) and
2224- // prompt (`p`), so the main input would be a confusing second
2225- // caret. The suggest popup, sliding-in queue, status block, and
2226- // main editor are all hidden underneath it. Keystrokes still
2227- // reach handleKey — it routes them to subagentsDialog.HandleKey
2228- // before the editor ever sees them — so the only effect of this
2229- // branch is visual.
2210+ // The resident child views own the bottom of the screen while active.
22302211 if dashboardActive {
22312212 return bottom, inputStartRow
22322213 }
@@ -2236,8 +2217,6 @@ func (i *Interactive) redraw() {
22362217 lineInput := inputStyle == tui.InputStyleLines
22372218 statusBelow := statusPosition == tui.StatusPositionBelowInput
22382219 workingBelow := workingPosition == tui.WorkingPositionBelowInput
2239- subagentBelow := tui.NormalizeSubagentPosition(i.cfg.TUISubagentPosition) == tui.SubagentPositionBelowInput
2240-
22412220 var aboveInput []string
22422221 aboveInput = append(aboveInput, extensionLines...)
22432222 if !statusBelow {
@@ -2246,13 +2225,10 @@ func (i *Interactive) redraw() {
22462225 if !workingBelow {
22472226 aboveInput = append(aboveInput, workingLines...)
22482227 }
2249- if !subagentBelow {
2250- aboveInput = append(aboveInput, subagentLines...)
2251- }
22522228 var belowInput []string
2253- if subagentBelow {
2254- belowInput = append(belowInput, subagentLines...)
2255- }
2229+ // Background resident work belongs directly below the editor. It is
2230+ // deliberately independent of the main turn's busy/status placement.
2231+ belowInput = append(belowInput, residentSubagentLines...)
22562232 if workingBelow {
22572233 belowInput = append(belowInput, workingLines...)
22582234 }
@@ -2272,9 +2248,9 @@ func (i *Interactive) redraw() {
22722248 inputStartRow = len(bottom)
22732249 bottom = append(bottom, edLines...)
22742250 if len(belowInput) > 0 {
2275- // Running subagents sit immediately under the prompt by default.
2276- // Other below-input chrome keeps the existing breathing room.
2277- if !lineInput && (!subagentBelow || len(subagentLines) == 0) {
2251+ // Active resident work sits immediately below the prompt. Other
2252+ // below-input chrome keeps the usual breathing room.
2253+ if !lineInput && len(residentSubagentLines) == 0 {
22782254 bottom = append(bottom, "")
22792255 }
22802256 bottom = append(bottom, belowInput...)
@@ -2282,26 +2258,22 @@ func (i *Interactive) redraw() {
22822258 return bottom, inputStartRow
22832259 }
22842260
2285- // Preserve one chat row and the renderer-owned bottom margin. This keeps
2286- // a bounded set of live indicators from pushing the editor or its cursor
2287- // out of a fixed right-bar frame on short terminals.
22882261 const rendererBottomMarginRows = 1
22892262 maxBottomRows := rows - rendererBottomMarginRows - 1
2290- subagentLines := allSubagentLines
2291- bottom, inputStartRow := composeBottom(subagentLines)
2292- if !dashboardActive && len(allSubagentLines) > 0 {
2293- for maxRows := len(allSubagentLines); maxRows >= 0; maxRows-- {
2294- candidate := limitSubagentActivityLines(i.cfg.Theme, allSubagentLines, maxRows, mainCols)
2295- candidateBottom, candidateInputStartRow := composeBottom(candidate)
2263+ var residentSubagentLines []string
2264+ var bottom []string
2265+ var inputStartRow int
2266+ if !dashboardActive && len(allResidentSubagentLines) > 0 {
2267+ for maxRows := len(allResidentSubagentLines) + 1; maxRows >= 0; maxRows-- {
2268+ candidate := limitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, residentSubagentHidden, maxRows, mainCols)
2269+ candidateBottom, _ := composeBottom(candidate)
22962270 if len(candidateBottom) <= maxBottomRows || maxRows == 0 {
2297- subagentLines = candidate
2298- bottom = candidateBottom
2299- inputStartRow = candidateInputStartRow
2271+ residentSubagentLines = candidate
23002272 break
23012273 }
23022274 }
23032275 }
2304- i.subagentActivityActive = !dashboardActive && len(subagentLines) > 0
2276+ bottom, inputStartRow = composeBottom(residentSubagentLines)
23052277
23062278 chatRows := rows - len(bottom)
23072279 if chatRows < 1 {
@@ -2508,20 +2480,6 @@ func (i *Interactive) redraw() {
25082480 cursorInDialog = true
25092481 }
25102482 }
2511- if i.subagentsDialog.Active() {
2512- if r, c := i.subagentsDialog.CursorPos(mainCols); r >= 0 {
2513- cursorRow = dialogLead + r
2514- cursorCol = c
2515- cursorInDialog = true
2516- } else {
2517- // Dashboard list / transcript view has no caret. Without
2518- // this branch the default cursorRow points at the
2519- // (hidden) main editor row, so the terminal would draw
2520- // a stray block somewhere in the chat region.
2521- cursorRow = -1
2522- cursorCol = 0
2523- }
2524- }
25252483 if i.extPanel.Active() {
25262484 cursorRow = -1
25272485 cursorCol = 0
@@ -2834,7 +2792,8 @@ func (i *Interactive) confirmChildActiveLocked() bool {
28342792 i.llamaDialog.Active() ||
28352793 i.rescueDialog.Active() ||
28362794 i.sessionDialog.Active() ||
2837- i.subagentsDialog.Active() ||
2795+ i.residentSubagentsDialog.Active() ||
2796+ i.residentChildSession != nil ||
28382797 i.jumpDialog.Active() ||
28392798 i.btwDialog.Active() ||
28402799 i.skillsDialog.Active() ||
@@ -3059,18 +3018,64 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
30593018 i.invalidate()
30603019 return false
30613020 }
3062- if i.subagentsDialog.Active() {
3063- if k.Kind == tui.KeyCtrlC {
3064- i.subagentsDialog.Close()
3021+ i.mu.Lock()
3022+ residentDialog := i.residentSubagentsDialog
3023+ residentDialogActive := residentDialog != nil && residentDialog.Active()
3024+ residentSession := i.residentChildSession
3025+ i.mu.Unlock()
3026+ if residentDialogActive {
3027+ if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
3028+ i.mu.Lock()
3029+ if i.residentSubagentsDialog == residentDialog {
3030+ residentDialog.Close()
3031+ }
3032+ i.mu.Unlock()
30653033 i.invalidate()
30663034 return false
30673035 }
3068- _, msg, errMsg := i.subagentsDialog.HandleKey(k)
3069- if msg != "" || errMsg != "" {
3036+ i.mu.Lock()
3037+ childID := residentDialog.HandleKey(k)
3038+ i.mu.Unlock()
3039+ if childID != "" {
3040+ i.openResidentChildSession(childID)
3041+ }
3042+ i.invalidate()
3043+ return false
3044+ }
3045+ if residentSession != nil {
3046+ switch k.Kind {
3047+ case tui.KeyCtrlC, tui.KeyEsc:
30703048 i.mu.Lock()
3071- i.statusOK = msg
3072- i.statusErr = errMsg
3049+ if i.residentChildSession == residentSession {
3050+ i.residentChildSession = nil
3051+ }
30733052 i.mu.Unlock()
3053+ case tui.KeyPageUp:
3054+ session := residentSession
3055+ if session.BeginLoad() {
3056+ go func() {
3057+ err := session.LoadOlder(200)
3058+ if session.FinishLoad(err) {
3059+ i.reloadResidentChildSession(session)
3060+ }
3061+ i.invalidate()
3062+ }()
3063+ }
3064+ case tui.KeyPageDown:
3065+ residentSession.FollowTail()
3066+ case tui.KeyUp:
3067+ residentSession.Scroll(1)
3068+ case tui.KeyDown:
3069+ residentSession.Scroll(-1)
3070+ default:
3071+ session := residentSession
3072+ if prompt, submit := session.HandleKey(k); submit {
3073+ go func() {
3074+ err := i.cfg.ResidentManager.Resume(ctx, session.childID, prompt)
3075+ session.FinishSubmission(err)
3076+ i.invalidate()
3077+ }()
3078+ }
30743079 }
30753080 i.invalidate()
30763081 return false
@@ -3440,6 +3445,12 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
34403445 i.invalidate()
34413446 return false
34423447 }
3448+ if !k.Alt && i.inputHistoryIndex < 0 && i.cfg.ResidentManager != nil {
3449+ if _, total := i.cfg.ResidentManager.RecentSnapshotPage(0, 1); total > 0 {
3450+ i.runResidentSubagents(ctx, nil)
3451+ return false
3452+ }
3453+ }
34433454 if !k.Alt && i.handleInputHistoryKey(k) {
34443455 return false
34453456 }
@@ -4063,15 +4074,9 @@ func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, provid
40634074 i.invalidate()
40644075 }
40654076
4066-// SetSubagentSessionScope changes the live-worker filter and then requests a
4067-// redraw. Session changes must use this boundary after their host/session
4068-// state commits so input-adjacent activity cannot remain scoped to the prior
4069-// session until another UI event arrives.
4070-func (i *Interactive) SetSubagentSessionScope(sessionID string) {
4071- if i.cfg.Supervisor == nil {
4072- return
4073- }
4074- i.cfg.Supervisor.SetActiveSession(sessionID)
4077+// SetSubagentSessionScope redraws after a host session transition. Resident
4078+// children retain their explicit parent session in their durable journal.
4079+func (i *Interactive) SetSubagentSessionScope(_ string) {
40754080 i.invalidate()
40764081 }
40774082
@@ -4582,7 +4587,6 @@ func (i *Interactive) openSettingsDialog() {
45824587 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
45834588 statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
45844589 workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
4585- subagentPosition := tui.NormalizeSubagentPosition(i.cfg.TUISubagentPosition)
45864590 quickItems := i.quickModelSettingItems()
45874591
45884592 autoCompactOptions := []settingsOption{
@@ -4665,18 +4669,6 @@ func (i *Interactive) openSettingsDialog() {
46654669 }
46664670 }
46674671
4668- subagentPositionOptions := []settingsOption{
4669- {value: tui.SubagentPositionAboveInput, label: "above input", desc: "show running subagents above the input"},
4670- {value: tui.SubagentPositionBelowInput, label: "below input", desc: "show running subagents immediately below the input"},
4671- }
4672- subagentPositionChoice := 0
4673- for idx, opt := range subagentPositionOptions {
4674- if opt.value == subagentPosition {
4675- subagentPositionChoice = idx
4676- break
4677- }
4678- }
4679-
46804672 items := []settingsItem{
46814673 {
46824674 key: "inline_images_enabled",
@@ -4804,13 +4796,6 @@ func (i *Interactive) openSettingsDialog() {
48044796 options: inputStyleOptions,
48054797 choice: inputStyleChoice,
48064798 },
4807- {
4808- key: "tui_subagent_position",
4809- label: "running subagent position",
4810- desc: "place live subagent activity above or below the input",
4811- options: subagentPositionOptions,
4812- choice: subagentPositionChoice,
4813- },
48144799 },
48154800 },
48164801 reasoningItem,
@@ -4849,8 +4834,6 @@ func (i *Interactive) applySettingChange(act settingsAction) {
48494834 i.applyTUIStatusPositionSetting(act.StringValue)
48504835 case act.Key == "tui_working_position":
48514836 i.applyTUIWorkingPositionSetting(act.StringValue)
4852- case act.Key == "tui_subagent_position":
4853- i.applyTUISubagentPositionSetting(act.StringValue)
48544837 default:
48554838 i.applySettingToggle(act.Key, act.Value)
48564839 }
@@ -5377,9 +5360,6 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
53775360 if i.agent != nil {
53785361 i.agent.SetFastMode(value)
53795362 }
5380- if i.cfg.Supervisor != nil {
5381- i.cfg.Supervisor.SetFastMode(value)
5382- }
53835363 i.mu.Lock()
53845364 i.statusOK = "fast mode " + onOff(value)
53855365 i.statusErr = ""
@@ -5619,31 +5599,6 @@ func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
56195599 i.mu.Unlock()
56205600 }
56215601
5622-func (i *Interactive) applyTUISubagentPositionSetting(position string) {
5623- defer func() {
5624- i.requestRendererClear()
5625- i.invalidate()
5626- }()
5627- position = tui.NormalizeSubagentPosition(position)
5628- i.cfg.TUISubagentPosition = position
5629- if i.cfg.SettingsStore != nil {
5630- if err := i.cfg.SettingsStore.SetTUISubagentPosition(position); err != nil {
5631- i.mu.Lock()
5632- i.statusErr = "settings: " + err.Error()
5633- i.mu.Unlock()
5634- return
5635- }
5636- }
5637- i.mu.Lock()
5638- label := "below input"
5639- if position == tui.SubagentPositionAboveInput {
5640- label = "above input"
5641- }
5642- i.statusOK = "running subagent position " + label
5643- i.statusErr = ""
5644- i.mu.Unlock()
5645-}
5646-
56475602 func (i *Interactive) applyThemeSetting(name string) {
56485603 if i.cfg.SettingsStore != nil {
56495604 if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
@@ -5794,7 +5749,7 @@ func buildStudyPrompt(arg, cwd string) string {
57945749 // keeps Tab as a literal no-op.
57955750 //
57965751 // Free function (not a method) so the same logic runs against the
5797-// editor instances owned by btwDialog and subagentsDialog without each
5752+// editor instances owned by local dialogs without each
57985753 // dialog needing its own copy.
57995754 func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
58005755 if ed == nil {
@@ -8827,56 +8782,60 @@ func (a telegramSenderAdapter) Active() bool {
88278782 return a.bridge != nil && a.bridge.Active()
88288783 }
88298784
8830-// TrackSubagentWorker is the exported entry point used by the cli to
8831-// hand a freshly-spawned auto-subagents agent off to the shared tracker.
8832-func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string, _ bool) {
8833- i.trackSubagentWorker(a, task, false, false)
8834-}
8835-
8836-// TrackResumedSubagentWorker watches a resumed follow-up independently of the
8837-// worker's original task. A long-lived daemon can complete several manager
8838-// turns, each of which must produce its own automatic delivery.
8839-func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string, _ bool) {
8840- i.trackSubagentWorker(a, prompt, true, false)
8841-}
8842-
8843-// PrepareResumedSubagentWorker registers a resumed turn before its prompt is
8844-// sent. The returned cleanup removes the registration if the resume operation
8845-// fails before delivery. It is used by the resume tool's pre-send hook; the
8846-// existing TrackResumedSubagentWorker signature remains the post-success API.
8847-func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string, _ bool) func() {
8848- return i.trackSubagentWorker(a, prompt, true, true)
8849-}
8850-
8851-// TrackStoppedSubagentWorker watches a requested worker shutdown. If an active
8852-// task watcher already owns the worker, the shared tracker leaves that watcher
8853-// responsible for the terminal outcome.
8854-func (i *Interactive) TrackStoppedSubagentWorker(a *subagents.Agent) {
8855- if i == nil || a == nil {
8785+// TrackResidentSubagent reserves completion delivery after the manager has
8786+// durably accepted a resident child prompt and before it is scheduled.
8787+func (i *Interactive) TrackResidentSubagent(childID string) {
8788+ if i == nil {
88568789 return
88578790 }
8858- tracker := i.ensureCompletionTracker()
8859- i.registerCoordinatorWorker(a.ID)
8860- tracker.TrackExit(a, a.Task)
8791+ i.ensureCompletionTracker().TrackResident()
8792+ i.registerCoordinatorWorker(childID)
88618793 i.invalidate()
88628794 i.requestCompletionDelivery()
88638795 }
88648796
8865-func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string, followUp, future bool) func() {
8866- if i == nil || a == nil {
8867- return nil
8797+// ReportResidentSubagent delivers a typed resident terminal outcome through
8798+// the same coordinator path used by legacy worker completions.
8799+func (i *Interactive) ReportResidentSubagent(completion subagents.ResidentCompletion) {
8800+ if i == nil {
8801+ return
88688802 }
8869- tracker := i.ensureCompletionTracker()
8870- i.registerCoordinatorWorker(a.ID)
8871- var cancel func()
8872- if future {
8873- cancel = tracker.TrackFutureTurn(a, task, followUp)
8874- } else {
8875- cancel = tracker.TrackTurn(a, task, followUp)
8803+ status, errText := "completed", ""
8804+ if completion.Err != nil {
8805+ status, errText = "failed", completion.Err.Error()
88768806 }
8807+ i.ensureCompletionTracker().Report(subagents.Completion{AgentID: completion.ChildID, Status: status, Task: completion.Task, Error: errText, Summary: completion.Summary})
8808+ i.reloadOpenResidentChildSession(completion.ChildID)
88778809 i.invalidate()
88788810 i.requestCompletionDelivery()
8879- return cancel
8811+}
8812+
8813+func (i *Interactive) reloadOpenResidentChildSession(childID string) {
8814+ if i == nil {
8815+ return
8816+ }
8817+ i.mu.Lock()
8818+ session := i.residentChildSession
8819+ i.mu.Unlock()
8820+ if session == nil || session.childID != childID || !session.RequestRecentReload() {
8821+ return
8822+ }
8823+ i.reloadResidentChildSession(session)
8824+}
8825+
8826+func (i *Interactive) reloadResidentChildSession(session *residentChildSession) {
8827+ if i == nil || session == nil {
8828+ return
8829+ }
8830+ go func() {
8831+ err := session.ReloadRecent(200)
8832+ if session.FinishLoad(err) {
8833+ if session.RequestRecentReload() {
8834+ i.reloadResidentChildSession(session)
8835+ }
8836+ }
8837+ i.invalidate()
8838+ }()
88808839 }
88818840
88828841 func (i *Interactive) ensureCompletionTracker() *subagents.CompletionTracker {
@@ -8962,9 +8921,8 @@ func (i *Interactive) cancelCoordinator() {
89628921 i.completionDeliveryMu.Lock()
89638922 coordinator := i.ensureCoordinatorLocked()
89648923 actions := coordinator.Apply(orchestration.Event{Kind: orchestration.EventCancelled}).Actions
8965- // A later user turn starts a fresh wave. Drop tracker registrations as well
8966- // as coordinator identities so late outcomes cannot match a new worker with
8967- // the same Agent.ID.
8924+ // A later user turn starts a fresh wave. Drop resident registrations as
8925+ // well as coordinator identities so late outcomes cannot match a new child.
89688926 if i.completionTracker != nil {
89698927 i.completionTracker.Reset()
89708928 }
@@ -9136,13 +9094,13 @@ func (i *Interactive) deliverCompletionUpdates() {
91369094 // mode whenever the primary agent can spawn a worker.
91379095 func autoSubagentsAddenda(cfg InteractiveConfig, orchestrating bool) []string {
91389096 addenda := make([]string, 0, 2)
9139- if cfg.Supervisor != nil && autoSubagentsToolAllowedConfig(cfg) {
9097+ if cfg.ResidentManager != nil && autoSubagentsToolAllowedConfig(cfg) {
91409098 if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
91419099 addenda = append(addenda, addendum)
91429100 }
91439101 }
91449102 if !orchestrating {
9145- if cfg.Supervisor != nil && autoSubagentsAnyToolAllowedConfig(cfg) {
9103+ if cfg.ResidentManager != nil && autoSubagentsAnyToolAllowedConfig(cfg) {
91469104 if addendum := strings.TrimSpace(cfg.OnDemandSubagentsSystemAddendum); addendum != "" {
91479105 addenda = append(addenda, addendum)
91489106 }
@@ -9225,13 +9183,13 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(orchestrating bool) {
92259183 // Mirrors applyTelegramTools' snapshot+mutate pattern so extension tools and
92269184 // /reload-ext additions survive a settings change.
92279185 func (i *Interactive) autoSubagentsAvailable() bool {
9228- return i.cfg.Supervisor != nil && autoSubagentsAnyToolAllowedConfig(i.cfg)
9186+ return i.cfg.ResidentManager != nil && autoSubagentsAnyToolAllowedConfig(i.cfg)
92299187 }
92309188
92319189 func (i *Interactive) autoSubagentsUnavailableHint() string {
92329190 var hints []string
9233- if i.cfg.Supervisor == nil {
9234- hints = append(hints, "subagent supervisor not available in this mode")
9191+ if i.cfg.ResidentManager == nil {
9192+ hints = append(hints, "resident subagent manager is unavailable in this mode")
92359193 }
92369194 if !autoSubagentsAnyToolAllowedConfig(i.cfg) {
92379195 hints = append(hints, "launch-time tool policy excludes subagent manager tools")
@@ -9312,40 +9270,34 @@ func (i *Interactive) applyAutoSubagentsTool() {
93129270 if i.autoSubagentsAvailable() {
93139271 if i.autoSubagentsToolAllowed() {
93149272 canonical := &tools.SubagentSpawnTool{
9315- Supervisor: i.cfg.Supervisor,
9316- Enabled: func() bool { return true },
9317- DefaultModel: func() string { return i.cfg.Model },
9318- DefaultProvider: func() string { return i.cfg.Provider },
9319- DefaultReasoning: func() string { return i.cfg.Reasoning },
9320- ResolveSubagent: i.cfg.ResolveSubagent,
9321- OnSpawned: func(a *subagents.Agent, task string, required bool) {
9322- i.TrackSubagentWorker(a, task, required)
9323- },
9273+ ResidentManager: i.cfg.ResidentManager,
9274+ BuildResidentSpec: i.cfg.BuildResidentSpec,
9275+ Enabled: func() bool { return true },
9276+ DefaultModel: func() string { return i.cfg.Model },
9277+ DefaultProvider: func() string { return i.cfg.Provider },
9278+ DefaultReasoning: func() string { return i.cfg.Reasoning },
9279+ ResolveSubagent: i.cfg.ResolveSubagent,
93249280 }
93259281 next[canonical.Name()] = canonical
93269282 }
93279283 if i.autoSubagentsStatusToolAllowed() {
93289284 statusTool := &tools.SubagentStatusTool{
9329- Supervisor: i.cfg.Supervisor,
9330- Enabled: func() bool { return true },
9285+ ResidentManager: i.cfg.ResidentManager,
9286+ Enabled: func() bool { return true },
93319287 }
93329288 next[statusTool.Name()] = statusTool
93339289 }
93349290 if i.autoSubagentsStopToolAllowed() {
93359291 stopTool := &tools.SubagentStopTool{
9336- Supervisor: i.cfg.Supervisor,
9292+ ResidentManager: i.cfg.ResidentManager,
93379293 Enabled: func() bool { return true },
9338- OnStopRequested: i.TrackStoppedSubagentWorker,
93399294 }
93409295 next[stopTool.Name()] = stopTool
93419296 }
93429297 if i.autoSubagentsResumeToolAllowed() {
93439298 resumeTool := &tools.SubagentResumeTool{
9344- Supervisor: i.cfg.Supervisor,
9345- Enabled: func() bool { return true },
9346- BeforeResumed: func(a *subagents.Agent, prompt string, required bool) func() {
9347- return i.PrepareResumedSubagentWorker(a, prompt, required)
9348- },
9299+ ResidentManager: i.cfg.ResidentManager,
9300+ Enabled: func() bool { return true },
93499301 }
93509302 next[resumeTool.Name()] = resumeTool
93519303 }
A packages/agent/modes/resident_subagent_indicator.go +134 -0
@@ -0,0 +1,134 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+
8+ "github.com/bnema/zut/packages/agent/subagents"
9+ "github.com/bnema/zut/packages/tui"
10+ "github.com/mattn/go-runewidth"
11+)
12+
13+var residentProviderWaitingSpinner = tui.NewStringSpinner(
14+ []string{"Zzzz", "zZzz", "zzZz", "zzzZ"},
15+ time.Second,
16+)
17+
18+// renderResidentSubagentActivityLines renders the bounded, non-sensitive
19+// background work indicator shown directly below the main editor. A queued
20+// child is waiting for a resident scheduler slot; only running children use
21+// the animated glyph.
22+func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string {
23+ lines := make([]string, 0, len(snapshots))
24+ for _, snapshot := range snapshots {
25+ if snapshot.State != subagents.ResidentQueued && snapshot.State != subagents.ResidentRunning {
26+ continue
27+ }
28+ name := sanitizeSessionTreeText(snapshot.Profile)
29+ if name == "" {
30+ name = sanitizeSessionTreeText(snapshot.ID)
31+ }
32+ if name == "" {
33+ name = "subagent"
34+ }
35+ glyph := "…"
36+ color := theme.Muted
37+ activity := "queued"
38+ if snapshot.State == subagents.ResidentRunning {
39+ glyph = strings.TrimSpace(spinnerGlyph)
40+ if glyph == "" {
41+ glyph = "."
42+ }
43+ color = theme.Spinner
44+ if snapshot.WaitingForModel {
45+ activity = residentProviderWaitingSpinner.FrameAt(activityTime(snapshot), now)
46+ } else {
47+ activity = "running"
48+ }
49+ }
50+ plain := fmt.Sprintf("%s %s · %s · activity %s ago · %s", glyph, name, activity,
51+ formatResidentSubagentActivityAge(activityTime(snapshot), now),
52+ formatResidentSubagentActivityAge(turnStartTime(snapshot), now))
53+ plain = truncateResidentSubagentIndicator(plain, width-2)
54+ if plain == "" {
55+ continue
56+ }
57+ lines = append(lines, " "+theme.FGColor(color, plain))
58+ }
59+ return lines
60+}
61+
62+func turnStartTime(snapshot subagents.ResidentSnapshot) time.Time {
63+ if !snapshot.TurnStartedAt.IsZero() {
64+ return snapshot.TurnStartedAt
65+ }
66+ return snapshot.UpdatedAt
67+}
68+
69+func activityTime(snapshot subagents.ResidentSnapshot) time.Time {
70+ if !snapshot.ActivityUpdatedAt.IsZero() {
71+ return snapshot.ActivityUpdatedAt
72+ }
73+ return snapshot.UpdatedAt
74+}
75+
76+func formatResidentSubagentActivityAge(started, now time.Time) string {
77+ if started.IsZero() {
78+ return "-"
79+ }
80+ if now.IsZero() {
81+ now = time.Now()
82+ }
83+ elapsed := now.Sub(started)
84+ if elapsed < 0 {
85+ elapsed = 0
86+ }
87+ seconds := int(elapsed.Seconds())
88+ switch {
89+ case seconds < 60:
90+ return fmt.Sprintf("%ds", seconds)
91+ case seconds < 60*60:
92+ return fmt.Sprintf("%dm%02ds", seconds/60, seconds%60)
93+ case seconds < 24*60*60:
94+ return fmt.Sprintf("%dh%02dm", seconds/(60*60), (seconds%(60*60))/60)
95+ default:
96+ return fmt.Sprintf("%dd%02dh", seconds/(24*60*60), (seconds%(24*60*60))/(60*60))
97+ }
98+}
99+
100+func limitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxRows, width int) []string {
101+ if maxRows <= 0 || (len(lines) == 0 && hidden == 0) {
102+ return nil
103+ }
104+ if hidden == 0 && len(lines) <= maxRows {
105+ return lines
106+ }
107+ if maxRows == 1 {
108+ return []string{residentSubagentActivityOverflowLine(theme, len(lines)+hidden, width)}
109+ }
110+ visible := len(lines)
111+ if visible > maxRows-1 {
112+ visible = maxRows - 1
113+ }
114+ out := append([]string(nil), lines[:visible]...)
115+ return append(out, residentSubagentActivityOverflowLine(theme, hidden+len(lines)-visible, width))
116+}
117+
118+func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string {
119+ text := fmt.Sprintf(" … %d more active subagents", hidden)
120+ return theme.FGColor(theme.Muted, truncateResidentSubagentIndicator(text, width))
121+}
122+
123+func truncateResidentSubagentIndicator(s string, width int) string {
124+ if width <= 0 {
125+ return ""
126+ }
127+ if runewidth.StringWidth(s) <= width {
128+ return s
129+ }
130+ if width <= 3 {
131+ return strings.Repeat(".", width)
132+ }
133+ return runewidth.Truncate(s, width, "...")
134+}
A packages/agent/modes/resident_subagent_indicator_test.go +75 -0
@@ -0,0 +1,75 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/zut/packages/agent/subagents"
9+ "github.com/bnema/zut/packages/tui"
10+)
11+
12+func TestRenderResidentSubagentActivityLinesShowsOnlyActiveChildren(t *testing.T) {
13+ now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
14+ lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{
15+ {ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning, TurnStartedAt: now.Add(-90 * time.Second), ActivityUpdatedAt: now, WaitingForModel: true},
16+ {ID: "queued-id", Profile: "planner", State: subagents.ResidentQueued, TurnStartedAt: now.Add(-10 * time.Second), ActivityUpdatedAt: now.Add(-5 * time.Second)},
17+ {ID: "idle-id", Profile: "finished", State: subagents.ResidentIdle},
18+ }, 80, now)
19+ got := strings.Join(plainResidentIndicatorLines(lines), "\n")
20+ if !strings.Contains(got, "/ reviewer · Zzzz · activity 0s ago · 1m30s") || !strings.Contains(got, "… planner · queued · activity 5s ago · 10s") {
21+ t.Fatalf("activity lines = %q", got)
22+ }
23+ if strings.Contains(got, "finished") {
24+ t.Fatalf("activity lines include inactive child: %q", got)
25+ }
26+}
27+
28+func TestRenderResidentSubagentActivityLinesAnimatesModelWait(t *testing.T) {
29+ started := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
30+ now := started.Add(2 * time.Second)
31+ lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
32+ ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
33+ TurnStartedAt: started, ActivityUpdatedAt: started, WaitingForModel: true,
34+ }}, 80, now)
35+ got := strings.Join(plainResidentIndicatorLines(lines), "\n")
36+ if !strings.Contains(got, "zzZz") {
37+ t.Fatalf("model-wait indicator = %q, want animated waiting frame", got)
38+ }
39+}
40+
41+func TestRenderResidentSubagentActivityLinesShowsRunningDuringToolWork(t *testing.T) {
42+ now := time.Date(2026, time.August, 13, 12, 0, 2, 0, time.UTC)
43+ lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
44+ ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
45+ TurnStartedAt: now.Add(-time.Minute), ActivityUpdatedAt: now.Add(-time.Second),
46+ }}, 80, now)
47+ got := strings.Join(plainResidentIndicatorLines(lines), "\n")
48+ if !strings.Contains(got, "/ reviewer · running · activity 1s ago · 1m00s") || strings.Contains(got, "Zzzz") {
49+ t.Fatalf("tool-work indicator = %q", got)
50+ }
51+}
52+
53+func TestLimitResidentSubagentActivityLinesSummarizesOverflow(t *testing.T) {
54+ got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 0, 2, 80))
55+ want := []string{"one", " … 2 more active subagents"}
56+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
57+ t.Fatalf("limited lines = %#v, want %#v", got, want)
58+ }
59+}
60+
61+func TestLimitResidentSubagentActivityLinesIncludesAlreadyHiddenChildren(t *testing.T) {
62+ got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 4, 2, 80))
63+ want := []string{"one", " … 6 more active subagents"}
64+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
65+ t.Fatalf("limited lines = %#v, want %#v", got, want)
66+ }
67+}
68+
69+func plainResidentIndicatorLines(lines []string) []string {
70+ plain := make([]string, len(lines))
71+ for index, line := range lines {
72+ plain[index] = stripANSIBytes(line)
73+ }
74+ return plain
75+}
A packages/agent/modes/resident_subagents_dialog.go +170 -0
@@ -0,0 +1,170 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+
8+ "github.com/bnema/zut/packages/agent/subagents"
9+ "github.com/bnema/zut/packages/tui"
10+)
11+
12+// residentSubagentsDialog is intentionally only a discovery/selection list.
13+// Detailed transcript, paging, and composition belong to residentChildSession.
14+type residentSubagentsDialog struct {
15+ active bool
16+ manager *subagents.ResidentManager
17+ rows []subagents.ResidentSnapshot
18+ selected int // absolute index in the sorted manager projection
19+ offset int
20+ total int
21+}
22+
23+func newResidentSubagentsDialog() *residentSubagentsDialog { return &residentSubagentsDialog{} }
24+
25+func (d *residentSubagentsDialog) Open(manager *subagents.ResidentManager) {
26+ d.active, d.manager, d.selected, d.offset, d.total = true, manager, 0, 0, 0
27+ d.refresh(12)
28+}
29+
30+func (d *residentSubagentsDialog) Active() bool { return d != nil && d.active }
31+func (d *residentSubagentsDialog) Close() {
32+ if d != nil {
33+ d.active = false
34+ }
35+}
36+
37+func (d *residentSubagentsDialog) refresh(limit int) {
38+ if d == nil {
39+ return
40+ }
41+ if d.manager == nil {
42+ d.rows = nil
43+ d.total = 0
44+ return
45+ }
46+ if limit < 1 {
47+ limit = 1
48+ }
49+ _, total := d.manager.SnapshotPage(0, 0)
50+ d.total = total
51+ if d.selected >= d.total {
52+ d.selected = d.total - 1
53+ }
54+ if d.selected < 0 || d.total == 0 {
55+ d.selected = 0
56+ }
57+ if d.selected < d.offset {
58+ d.offset = d.selected
59+ }
60+ if d.selected >= d.offset+limit {
61+ d.offset = d.selected - limit + 1
62+ }
63+ if d.offset < 0 {
64+ d.offset = 0
65+ }
66+ d.rows, d.total = d.manager.RecentSnapshotPage(d.offset, limit)
67+}
68+
69+func (d *residentSubagentsDialog) HandleKey(k tui.Key) (openID string) {
70+ if d == nil {
71+ return ""
72+ }
73+ switch k.Kind {
74+ case tui.KeyUp:
75+ if d.selected > 0 {
76+ d.selected--
77+ }
78+ case tui.KeyDown:
79+ if d.selected+1 < d.total {
80+ d.selected++
81+ }
82+ case tui.KeyEnter:
83+ if len(d.rows) > 0 && d.selected >= d.offset && d.selected < d.offset+len(d.rows) {
84+ return d.rows[d.selected-d.offset].ID
85+ }
86+ }
87+ return ""
88+}
89+
90+func (d *residentSubagentsDialog) Render(theme tui.Theme, width, height int) []string {
91+ if d == nil {
92+ return nil
93+ }
94+ maxRows := height - 3 // title, hint, and page indicator/empty state
95+ if maxRows < 1 {
96+ maxRows = 1
97+ }
98+ d.refresh(maxRows)
99+ lines := []string{" Resident subagents", " Enter: open Esc: close /subagents new <task>: spawn"}
100+ if len(d.rows) == 0 {
101+ return append(lines, "", " No resident subagents.")
102+ }
103+ for index, row := range d.rows {
104+ marker := " "
105+ if d.offset+index == d.selected {
106+ marker = theme.AccentBar(theme.Accent) + " "
107+ }
108+ name := residentDisplayName(row)
109+ label := fmt.Sprintf("%s %s %s %s/%s %s", name, residentDisplayState(row.State), formatResidentUpdatedAt(row.UpdatedAt, time.Now()), row.Provider, row.Model, shortResidentID(row.ID))
110+ if row.WorkspaceMode != "" {
111+ label += " " + string(row.WorkspaceMode)
112+ }
113+ if row.Required {
114+ label += " required"
115+ }
116+ lines = append(lines, marker+truncateResidentSubagentIndicator(label, width-2))
117+ }
118+ if d.total > len(d.rows) {
119+ lines = append(lines, fmt.Sprintf(" %d-%d of %d", d.offset+1, d.offset+len(d.rows), d.total))
120+ }
121+ return lines
122+}
123+
124+func residentDisplayName(row subagents.ResidentSnapshot) string {
125+ if name := sanitizeSessionTreeText(row.Profile); name != "" {
126+ return name
127+ }
128+ if model := sanitizeSessionTreeText(row.Model); model != "" {
129+ return model
130+ }
131+ return "subagent"
132+}
133+
134+func residentDisplayState(state subagents.ResidentState) string {
135+ if state == subagents.ResidentIdle {
136+ return "completed"
137+ }
138+ return string(state)
139+}
140+
141+func shortResidentID(id string) string {
142+ id = strings.TrimSpace(id)
143+ if len(id) > 8 {
144+ return id[:8]
145+ }
146+ return id
147+}
148+
149+func formatResidentUpdatedAt(updatedAt, now time.Time) string {
150+ if updatedAt.IsZero() {
151+ return "time unknown"
152+ }
153+ if now.IsZero() {
154+ now = time.Now()
155+ }
156+ elapsed := now.Sub(updatedAt)
157+ if elapsed < 0 {
158+ elapsed = 0
159+ }
160+ switch {
161+ case elapsed < time.Minute:
162+ return fmt.Sprintf("%ds ago", int(elapsed.Seconds()))
163+ case elapsed < time.Hour:
164+ return fmt.Sprintf("%dm ago", int(elapsed.Minutes()))
165+ case elapsed < 24*time.Hour:
166+ return fmt.Sprintf("%dh ago", int(elapsed.Hours()))
167+ default:
168+ return fmt.Sprintf("%dd ago", int(elapsed.Hours()/24))
169+ }
170+}
A packages/agent/modes/resident_subagents_dialog_test.go +36 -0
@@ -0,0 +1,36 @@
1+package modes
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/zut/packages/agent/subagents"
8+)
9+
10+func TestResidentSubagentListUsesHumanLabelTerminalStateAndRelativeTime(t *testing.T) {
11+ row := subagents.ResidentSnapshot{ID: "b6382ac7-ca36-45f1-9549-dd9ee4d195b7", Profile: "go-reviewer", Model: "gpt-5.6-luna", State: subagents.ResidentIdle}
12+ if got := residentDisplayName(row); got != "go-reviewer" {
13+ t.Fatalf("display name = %q", got)
14+ }
15+ if got := residentDisplayState(row.State); got != "completed" {
16+ t.Fatalf("display state = %q", got)
17+ }
18+ if got := shortResidentID(row.ID); got != "b6382ac7" {
19+ t.Fatalf("short ID = %q", got)
20+ }
21+ if got := formatResidentUpdatedAt(time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC), time.Date(2026, time.August, 13, 12, 2, 0, 0, time.UTC)); got != "2m ago" {
22+ t.Fatalf("relative update = %q", got)
23+ }
24+}
25+
26+func TestResidentResultStatusIncludesFinalSummary(t *testing.T) {
27+ got := residentResultStatus("child", subagents.ResidentResult{State: subagents.ResidentIdle, Summary: "the child answer"})
28+ if got != "completed result child: the child answer" {
29+ t.Fatalf("result status = %q", got)
30+ }
31+}
32+
33+func TestResidentSubagentsDialogRefreshAllowsNilReceiver(t *testing.T) {
34+ var dialog *residentSubagentsDialog
35+ dialog.refresh(1)
36+}
M packages/agent/modes/slash_suggest_test.go +1 -1
@@ -37,7 +37,7 @@ func TestSlashSuggesterShowsLlamaOnlyWhenConfigured(t *testing.T) {
3737 }
3838 }
3939
40-func TestSlashSuggesterHasSupervisor(t *testing.T) {
40+func TestSlashSuggesterHasSubagents(t *testing.T) {
4141 s := newSlashSuggester()
4242 if got := commandNames(s.matches("/su")); !contains(got, "/subagents") {
4343 t.Fatalf("/subagents missing from suggestions, got %v", got)
D packages/agent/modes/subagent_indicator.go +0 -217
@@ -1,217 +0,0 @@
1-package modes
2-
3-import (
4- "fmt"
5- "strings"
6- "time"
7-
8- "github.com/bnema/zut/packages/agent/subagents"
9- "github.com/bnema/zut/packages/tui"
10- "github.com/mattn/go-runewidth"
11-)
12-
13-var providerWaitingSpinner = tui.NewStringSpinner(
14- []string{"Zzzz", "zZzz", "zzZz", "zzzZ"},
15- time.Second,
16-)
17-
18-// renderSubagentActivityLines renders only trace-observed open operations.
19-// A living process, generic lifecycle state, or heartbeat never starts a
20-// spinner because none proves that an operation is progressing.
21-func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []subagents.AgentSnapshot, views map[string]subagents.AgentTraceView, width int, now time.Time) []string {
22- lines := make([]string, 0, len(snapshots))
23- for _, snapshot := range snapshots {
24- view := views[snapshot.ID]
25- operation, ok := view.Primary()
26- if !ok {
27- continue
28- }
29- line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, operation, view.TurnStartedAt(operation), view.ObservationFor(operation), width, now)
30- if line != "" {
31- lines = append(lines, line)
32- }
33- }
34- return lines
35-}
36-
37-func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot subagents.AgentSnapshot, operation subagents.Operation, turnStartedAt time.Time, observation *subagents.LiveObservation, width int, now time.Time) string {
38- name := sanitizeSubagentIndicatorText(snapshot.Subagent)
39- if name == "" {
40- name = sanitizeSubagentIndicatorText(snapshot.ID)
41- }
42- if name == "" {
43- name = "subagent"
44- }
45- spinnerGlyph = strings.TrimSpace(spinnerGlyph)
46- if spinnerGlyph == "" {
47- spinnerGlyph = "."
48- }
49- activity := subagentOperationActivity(operation, now)
50- if observation != nil {
51- activity += " · " + observation.Label() + " " + formatSubagentActivityAge(observation.At, now) + " ago"
52- }
53- // The rightmost age is elapsed time for the open delegated turn, stable
54- // across its nested provider/tool cycles. Fall back to the operation start
55- // only for legacy/incomplete traces without a matching turn boundary.
56- if turnStartedAt.IsZero() {
57- turnStartedAt = operation.StartedAt
58- }
59- age := formatSubagentActivityAge(turnStartedAt, now)
60- if operation.Step > 0 {
61- label := "steps"
62- if operation.Step == 1 {
63- label = "step"
64- }
65- age = fmt.Sprintf("%d %s · %s", operation.Step, label, age)
66- }
67- plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2)
68- if plain == "" {
69- return ""
70- }
71-
72- switch layout {
73- case subagentActivityFull:
74- parts := strings.SplitN(plain, " · ", 3)
75- if len(parts) != 3 {
76- return " " + th.FGColor(th.Muted, plain)
77- }
78- name = strings.TrimPrefix(parts[0], spinnerGlyph+" ")
79- return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " +
80- th.FGColor(th.Assistant, name) + th.FGColor(th.Muted, " · ") +
81- th.FGColor(subagentOperationColor(th, operation), parts[1]) + th.FGColor(th.Muted, " · "+parts[2])
82- case subagentActivityNameAge:
83- parts := strings.SplitN(plain, " · ", 2)
84- if len(parts) != 2 {
85- return " " + th.FGColor(th.Muted, plain)
86- }
87- name = strings.TrimPrefix(parts[0], spinnerGlyph+" ")
88- return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " +
89- th.FGColor(th.Assistant, name) + th.FGColor(th.Muted, " · "+parts[1])
90- default:
91- return " " + th.FGColor(th.Muted, plain)
92- }
93-}
94-
95-func subagentOperationActivity(operation subagents.Operation, now time.Time) string {
96- if operation.Type == "provider.request.started" {
97- return providerWaitingSpinner.FrameAt(operation.StartedAt, now)
98- }
99- return operation.Label()
100-}
101-
102-func subagentOperationColor(th tui.Theme, operation subagents.Operation) tui.TerminalColor {
103- switch operation.Type {
104- case "provider.request.started":
105- return th.Spinner
106- case "tool.started":
107- return th.Tool
108- default:
109- return th.Assistant
110- }
111-}
112-
113-type subagentActivityLayout uint8
114-
115-const (
116- subagentActivityFull subagentActivityLayout = iota
117- subagentActivityNameAge
118- subagentActivityCompact
119-)
120-
121-func fitSubagentActivityLine(spinnerGlyph, name, activity, age string, width int) (string, subagentActivityLayout) {
122- if width <= 0 {
123- return "", subagentActivityCompact
124- }
125- full := spinnerGlyph + " " + name + " · " + activity + " · " + age
126- if runewidth.StringWidth(full) <= width {
127- return full, subagentActivityFull
128- }
129- nameAge := spinnerGlyph + " " + name + " · " + age
130- if activityWidth := width - runewidth.StringWidth(nameAge) - runewidth.StringWidth(" · "); activityWidth >= 4 {
131- return spinnerGlyph + " " + name + " · " + truncateSubagentIndicatorText(activity, activityWidth) + " · " + age, subagentActivityFull
132- }
133- if runewidth.StringWidth(nameAge) <= width {
134- return nameAge, subagentActivityNameAge
135- }
136- return truncateSubagentIndicatorText(nameAge, width), subagentActivityCompact
137-}
138-
139-func formatSubagentActivityAge(started, now time.Time) string {
140- if started.IsZero() {
141- return "-"
142- }
143- if now.IsZero() {
144- now = time.Now()
145- }
146- elapsed := now.Sub(started)
147- if elapsed < 0 {
148- elapsed = 0
149- }
150- seconds := int(elapsed.Seconds())
151- switch {
152- case seconds < 60:
153- return fmt.Sprintf("%ds", seconds)
154- case seconds < 60*60:
155- return fmt.Sprintf("%dm%02ds", seconds/60, seconds%60)
156- case seconds < 24*60*60:
157- return fmt.Sprintf("%dh%02dm", seconds/(60*60), (seconds%(60*60))/60)
158- default:
159- return fmt.Sprintf("%dd%02dh", seconds/(24*60*60), (seconds%(24*60*60))/(60*60))
160- }
161-}
162-
163-func truncateSubagentIndicatorText(s string, width int) string {
164- if width <= 0 {
165- return ""
166- }
167- if runewidth.StringWidth(s) <= width {
168- return s
169- }
170- if width <= 3 {
171- return strings.Repeat(".", width)
172- }
173- return runewidth.Truncate(s, width, "...")
174-}
175-
176-func sanitizeSubagentIndicatorText(s string) string { return sanitizeSessionTreeText(s) }
177-
178-func limitSubagentActivityLines(th tui.Theme, lines []string, maxRows, width int) []string {
179- if maxRows <= 0 || len(lines) == 0 {
180- return nil
181- }
182- if len(lines) <= maxRows {
183- return lines
184- }
185- if maxRows == 1 {
186- return []string{subagentActivityOverflowLine(th, len(lines), width)}
187- }
188- out := append([]string(nil), lines[:maxRows-1]...)
189- return append(out, subagentActivityOverflowLine(th, len(lines)-len(out), width))
190-}
191-
192-func subagentActivityOverflowLine(th tui.Theme, hidden, width int) string {
193- text := fmt.Sprintf(" … %d more open subagent operations", hidden)
194- return th.FGColor(th.Muted, truncateSubagentIndicatorText(text, width))
195-}
196-
197-func (i *Interactive) activeSubagentActivitySnapshots() ([]subagents.AgentSnapshot, map[string]subagents.AgentTraceView) {
198- supervisor := i.cfg.Supervisor
199- if supervisor == nil {
200- return nil, nil
201- }
202- views := supervisor.TraceViews()
203- activeSession := supervisor.ActiveSession()
204- agents := supervisor.List()
205- out := make([]subagents.AgentSnapshot, 0, len(agents))
206- for _, agent := range agents {
207- if activeSession != "" && agent.SessionID != "" && agent.SessionID != activeSession {
208- continue
209- }
210- view := views[agent.ID]
211- if _, ok := view.Primary(); !ok {
212- continue
213- }
214- out = append(out, subagents.AgentSnapshot{ID: agent.ID, Started: agent.Started, Subagent: agent.Subagent})
215- }
216- return out, views
217-}
D packages/agent/modes/subagent_indicator_test.go +0 -160
@@ -1,160 +0,0 @@
1-package modes
2-
3-import (
4- "strings"
5- "testing"
6- "time"
7-
8- "github.com/bnema/zut/packages/agent/subagents"
9- "github.com/bnema/zut/packages/tui"
10- "github.com/mattn/go-runewidth"
11-)
12-
13-func TestRenderSubagentActivityLinesShowsOnlyTraceOpenOperations(t *testing.T) {
14- now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
15- snapshots := []subagents.AgentSnapshot{
16- {ID: "open", Subagent: "reviewer"},
17- {ID: "alive", Subagent: "implementer", Activity: "working"},
18- }
19- views := map[string]subagents.AgentTraceView{
20- "open": {AgentID: "open", OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: now.Add(-72 * time.Second)}}},
21- "alive": {AgentID: "alive", LastEvent: subagents.TraceEvent{Type: "agent.ready", Timestamp: now.Add(-time.Second)}},
22- }
23- got := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", snapshots, views, 80, now))
24- want := []string{" / reviewer · running tool · 1m12s"}
25- if strings.Join(got, "\n") != strings.Join(want, "\n") {
26- t.Fatalf("activity lines = %#v, want %#v", got, want)
27- }
28-}
29-
30-func TestRenderSubagentActivityLinesShowsSafeLastObservation(t *testing.T) {
31- now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
32- lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
33- "review-123": {OpenOperations: []subagents.Operation{{Type: "turn.started", TurnID: "turn-1", StartedAt: now.Add(-time.Minute)}}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}},
34- }, 100, now))
35- if got := strings.Join(lines, "\n"); !strings.Contains(got, "processing turn · assistant streaming 1s ago") || strings.Contains(got, "delta") {
36- t.Fatalf("activity lines = %#v", lines)
37- }
38-}
39-
40-func TestRenderSubagentActivityLinesShowsRecentCompletedToolUnderProviderRequest(t *testing.T) {
41- now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
42- lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
43- "review-123": {OpenOperations: []subagents.Operation{{Type: "provider.request.started", TurnID: "turn-1", StartedAt: now.Add(-2 * time.Second)}}, LastObservation: &subagents.LiveObservation{Type: "tool.finished", Name: "read", CallID: "call-1", TurnID: "turn-1", At: now.Add(-3 * time.Second)}},
44- }, 100, now))
45- got := strings.Join(lines, "\n")
46- if !strings.Contains(got, "zzZz · finished read 3s ago · 2s") {
47- t.Fatalf("activity lines = %#v", lines)
48- }
49-}
50-
51-func TestRenderSubagentActivityLineColorsProviderWaitAsActive(t *testing.T) {
52- now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
53- operation := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: now.Add(-2 * time.Second)}
54- line := renderSubagentActivityLine(tui.Dark, "/", subagents.AgentSnapshot{ID: "review-123", Subagent: "reviewer"}, operation, operation.StartedAt, nil, 100, now)
55- if !strings.Contains(line, tui.Dark.FGColor(tui.Dark.Spinner, "zzZz")) {
56- t.Fatalf("provider wait is not rendered with the active color: %q", line)
57- }
58-}
59-
60-func TestRenderSubagentActivityLineAnimatesProviderWaitAtOneHertz(t *testing.T) {
61- started := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
62- operation := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: started}
63- snapshot := subagents.AgentSnapshot{ID: "review-123", Subagent: "reviewer"}
64-
65- wants := []string{"Zzzz", "zZzz", "zzZz", "zzzZ", "Zzzz"}
66- for second, want := range wants {
67- line := stripANSIBytes(renderSubagentActivityLine(tui.Dark, "/", snapshot, operation, started, nil, 100, started.Add(time.Duration(second)*time.Second)))
68- if !strings.Contains(line, "reviewer · "+want+" · ") {
69- t.Fatalf("provider wait at %ds = %q, want frame %q", second, line, want)
70- }
71- }
72-}
73-
74-func TestRenderSubagentActivityLineShowsModelStepBesideElapsedTurnTime(t *testing.T) {
75- started := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
76- operation := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", Step: 3, StartedAt: started.Add(8 * time.Second)}
77- snapshot := subagents.AgentSnapshot{ID: "review-123", Subagent: "reviewer"}
78-
79- line := stripANSIBytes(renderSubagentActivityLine(tui.Dark, "/", snapshot, operation, started, nil, 100, started.Add(12*time.Second)))
80- if !strings.Contains(line, "reviewer · Zzzz · 3 steps · 12s") {
81- t.Fatalf("provider wait line = %q, want model step beside delegated-turn elapsed time", line)
82- }
83-}
84-
85-func TestRenderSubagentActivityLinesOmitsPriorTurnObservation(t *testing.T) {
86- now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
87- lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
88- "review-123": {OpenOperations: []subagents.Operation{{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)}}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}},
89- }, 100, now))
90- if got := strings.Join(lines, "\n"); strings.Contains(got, "assistant streaming") || !strings.Contains(got, "running tool") || !strings.Contains(got, "1m") {
91- t.Fatalf("activity lines = %#v", lines)
92- }
93-}
94-
95-func TestRenderSubagentActivityLinesKeepsNameAndAgeOnNarrowTerminals(t *testing.T) {
96- now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
97- lines := renderSubagentActivityLines(tui.Dark, "|", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
98- "review-123": {OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: now.Add(-72 * time.Second)}}},
99- }, 27, now)
100- if len(lines) != 1 {
101- t.Fatalf("line count = %d, want 1", len(lines))
102- }
103- line := plainActivityLines(lines)[0]
104- if runewidth.StringWidth(line) > 27 || !strings.Contains(line, "reviewer") || !strings.Contains(line, "1m12s") {
105- t.Fatalf("narrow line = %q", line)
106- }
107-}
108-
109-func TestRenderSubagentActivityLinesSanitizesWorkerText(t *testing.T) {
110- now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
111- lines := renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer\x1b]52;c;ignored\a"}}, map[string]subagents.AgentTraceView{
112- "review-123": {OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: now.Add(-time.Second)}}},
113- }, 80, now)
114- plain := plainActivityLines(lines)[0]
115- if strings.ContainsRune(plain, '\x1b') || !strings.Contains(plain, "reviewer") {
116- t.Fatalf("sanitized indicator = %q", plain)
117- }
118-}
119-
120-func TestTruncateSubagentIndicatorTextPreservesGraphemeClusters(t *testing.T) {
121- if got, want := truncateSubagentIndicatorText("a👩<U+200D>💻abcdef", 6), "a👩<U+200D>💻..."; got != want {
122- t.Fatalf("truncated grapheme = %q, want %q", got, want)
123- }
124-}
125-
126-func TestRenderSubagentActivityLinesUsesStableDelegatedTurnElapsedTime(t *testing.T) {
127- now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
128- turn := subagents.Operation{Type: "turn.started", TurnID: "turn-1", StartedAt: now.Add(-12 * time.Second)}
129- request := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: now.Add(-time.Second)}
130- lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer", Started: now.Add(-5*time.Hour - 16*time.Minute)}}, map[string]subagents.AgentTraceView{
131- "review-123": {OpenOperations: []subagents.Operation{request, turn}},
132- }, 100, now))
133- got := strings.Join(lines, "\n")
134- if !strings.Contains(got, "zZzz · 12s") || strings.Contains(got, "5h16m") {
135- t.Fatalf("activity lines = %#v, want current delegated-turn elapsed time", lines)
136- }
137-}
138-
139-func TestFormatSubagentActivityAgeUsesOperationStart(t *testing.T) {
140- now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
141- if got, want := formatSubagentActivityAge(now.Add(-7*time.Second), now), "7s"; got != want {
142- t.Fatalf("age = %q, want %q", got, want)
143- }
144-}
145-
146-func TestLimitSubagentActivityLinesSummarizesOmittedOperations(t *testing.T) {
147- got := plainActivityLines(limitSubagentActivityLines(tui.Dark, []string{"one", "two", "three", "four"}, 3, 80))
148- want := []string{"one", "two", " … 2 more open subagent operations"}
149- if strings.Join(got, "\n") != strings.Join(want, "\n") {
150- t.Fatalf("limited lines = %#v, want %#v", got, want)
151- }
152-}
153-
154-func plainActivityLines(lines []string) []string {
155- plain := make([]string, len(lines))
156- for idx, line := range lines {
157- plain[idx] = stripANSIBytes(line)
158- }
159- return plain
160-}
A packages/agent/modes/subagent_session.go +316 -0
@@ -0,0 +1,316 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "sync"
7+
8+ "github.com/bnema/zut/packages/agent/subagents"
9+ "github.com/bnema/zut/packages/provider"
10+ "github.com/bnema/zut/packages/tui"
11+)
12+
13+// residentChildSession owns a bounded child transcript assembled from the
14+// resident journal plus the child's immutable live projection. It deliberately
15+// has no UI lock or disk path: callers load pages asynchronously and discard
16+// them when their own dialog generation no longer matches.
17+type residentChildSession struct {
18+ mu sync.Mutex
19+ manager *subagents.ResidentManager
20+ childID string
21+ olderCursor string
22+ view tui.View
23+ composer *tui.Editor
24+ loading bool
25+ recentReloadPending bool
26+ err string
27+ // scrollOffset is measured from the rendered live tail. Keeping the
28+ // viewport bottom-relative preserves the same finalized content when an
29+ // older page is prepended or the active turn grows.
30+ scrollOffset int
31+ unread int
32+ lastRows int
33+ recentRows int
34+}
35+
36+func newResidentChildSession(manager *subagents.ResidentManager, childID string, theme tui.Theme) *residentChildSession {
37+ return &residentChildSession{manager: manager, childID: childID, view: tui.View{Theme: theme, ExpandAll: true}, composer: tui.NewEditor("follow-up > ")}
38+}
39+
40+func (s *residentChildSession) LoadRecent(limit int) error {
41+ if s == nil || s.manager == nil {
42+ return fmt.Errorf("resident child session: unavailable")
43+ }
44+ page, err := s.manager.HistoryPage(s.childID, "", limit)
45+ if err != nil {
46+ return err
47+ }
48+ messages, err := subagents.ResidentHistoryMessages(page.Items)
49+ if err != nil {
50+ return err
51+ }
52+ s.replaceRecent(messages, page.OlderCursor)
53+ return nil
54+}
55+
56+// ReloadRecent replaces only the newest bounded history page. Any older
57+// pages already loaded by PgUp remain intact, so following a completed turn
58+// does not discard the user's scrollback.
59+func (s *residentChildSession) ReloadRecent(limit int) error {
60+ return s.LoadRecent(limit)
61+}
62+
63+func (s *residentChildSession) replaceRecent(messages []provider.Message, olderCursor string) {
64+ if s == nil {
65+ return
66+ }
67+ s.mu.Lock()
68+ older := len(s.view.Messages) - s.recentRows
69+ if older < 0 {
70+ older = 0
71+ }
72+ prefix := append([]provider.Message(nil), s.view.Messages[:older]...)
73+ s.view.Messages = append(prefix, messages...)
74+ s.view.MessagesRevision++
75+ s.olderCursor = olderCursor
76+ s.recentRows = len(messages)
77+ s.err = ""
78+ s.refreshLiveLocked()
79+ s.mu.Unlock()
80+}
81+
82+func (s *residentChildSession) LoadOlder(limit int) error {
83+ if s == nil || s.manager == nil {
84+ return fmt.Errorf("resident child session: unavailable")
85+ }
86+ s.mu.Lock()
87+ cursor := s.olderCursor
88+ s.mu.Unlock()
89+ if cursor == "" {
90+ return nil
91+ }
92+ page, err := s.manager.HistoryPage(s.childID, cursor, limit)
93+ if err != nil {
94+ return err
95+ }
96+ messages, err := subagents.ResidentHistoryMessages(page.Items)
97+ if err != nil {
98+ return err
99+ }
100+ s.mu.Lock()
101+ s.view.Messages = append(messages, s.view.Messages...)
102+ s.view.MessagesRevision++
103+ s.olderCursor = page.OlderCursor
104+ s.err = ""
105+ s.refreshLiveLocked()
106+ s.mu.Unlock()
107+ return nil
108+}
109+
110+func (s *residentChildSession) refreshLiveLocked() {
111+ if s == nil || s.manager == nil {
112+ return
113+ }
114+ live, ok := s.manager.Live(s.childID)
115+ if !ok {
116+ s.view.Streaming = ""
117+ s.view.StreamingActive = false
118+ s.view.ToolCalls = nil
119+ return
120+ }
121+ s.view.Streaming = live.AssistantText
122+ s.view.StreamingActive = live.State == subagents.ResidentRunning && live.AssistantText != ""
123+ s.view.ToolCalls = make([]tui.ToolCallView, len(live.Tools))
124+ for index, tool := range live.Tools {
125+ s.view.ToolCalls[index] = tui.ToolCallView{ID: tool.ID, Name: tool.Name, Args: tui.ShortArgs(tool.Name, tool.Args), RawJSONBuf: string(tool.Args), Streaming: tool.State == subagents.ResidentLiveToolComposing, Revision: live.Revision}
126+ }
127+}
128+
129+func (s *residentChildSession) View() *tui.View {
130+ if s == nil {
131+ return nil
132+ }
133+ s.mu.Lock()
134+ s.refreshLiveLocked()
135+ defer s.mu.Unlock()
136+ return s.view.CloneForRender()
137+}
138+
139+func (s *residentChildSession) BeginLoad() bool {
140+ if s == nil {
141+ return false
142+ }
143+ s.mu.Lock()
144+ defer s.mu.Unlock()
145+ if s.loading {
146+ return false
147+ }
148+ s.loading = true
149+ s.err = ""
150+ return true
151+}
152+
153+// RequestRecentReload reserves a newest-page refresh. If another history
154+// read is already in progress, it coalesces one refresh behind that read.
155+func (s *residentChildSession) RequestRecentReload() bool {
156+ if s == nil {
157+ return false
158+ }
159+ s.mu.Lock()
160+ defer s.mu.Unlock()
161+ if s.loading {
162+ s.recentReloadPending = true
163+ return false
164+ }
165+ s.loading = true
166+ s.err = ""
167+ return true
168+}
169+
170+// FinishLoad clears the current history operation and reports whether a
171+// terminal child update arrived while it was loading and needs one coalesced
172+// newest-page refresh.
173+func (s *residentChildSession) FinishLoad(err error) bool {
174+ if s == nil {
175+ return false
176+ }
177+ s.mu.Lock()
178+ s.loading = false
179+ if err != nil {
180+ s.err = err.Error()
181+ }
182+ pending := s.recentReloadPending
183+ s.recentReloadPending = false
184+ s.mu.Unlock()
185+ return pending
186+}
187+
188+// HandleKey updates only the child-local composer. It returns a durable prompt
189+// candidate; the controller leaves it intact until Manager.Resume succeeds.
190+func (s *residentChildSession) HandleKey(k tui.Key) (string, bool) {
191+ if s == nil || s.composer == nil {
192+ return "", false
193+ }
194+ s.mu.Lock()
195+ defer s.mu.Unlock()
196+ if !s.composer.HandleKey(k) {
197+ return "", false
198+ }
199+ prompt := strings.TrimSpace(s.composer.SubmitValue())
200+ return prompt, prompt != ""
201+}
202+
203+func (s *residentChildSession) FinishSubmission(err error) {
204+ if s == nil {
205+ return
206+ }
207+ s.mu.Lock()
208+ if err != nil {
209+ s.err = err.Error()
210+ } else if s.composer != nil {
211+ s.composer.Clear()
212+ s.err = ""
213+ }
214+ s.mu.Unlock()
215+}
216+
217+func (s *residentChildSession) Scroll(delta int) {
218+ if s == nil || delta == 0 {
219+ return
220+ }
221+ s.mu.Lock()
222+ s.scrollOffset += delta
223+ if s.scrollOffset < 0 {
224+ s.scrollOffset = 0
225+ }
226+ if s.scrollOffset == 0 {
227+ s.unread = 0
228+ }
229+ s.mu.Unlock()
230+}
231+
232+func (s *residentChildSession) FollowTail() {
233+ if s == nil {
234+ return
235+ }
236+ s.mu.Lock()
237+ s.scrollOffset, s.unread = 0, 0
238+ s.mu.Unlock()
239+}
240+
241+// Render keeps all transcript rendering in tui.View. Disk-backed history is
242+// loaded by the controller before this is called; this method consults only
243+// the immutable live projection maintained by the resident child.
244+func (s *residentChildSession) Render(width, height int) []string {
245+ if s == nil {
246+ return nil
247+ }
248+ view := s.View()
249+ if view == nil {
250+ return []string{" resident subagent unavailable"}
251+ }
252+ s.mu.Lock()
253+ loading, loadErr := s.loading, s.err
254+ var editorLines []string
255+ if s.composer != nil {
256+ editorLines, _, _ = s.composer.Render(width)
257+ }
258+ s.mu.Unlock()
259+ header := fmt.Sprintf(" Resident subagent %s", s.childID)
260+ if s.manager != nil {
261+ if snapshot, ok := s.manager.SnapshotFor(s.childID); ok {
262+ header += fmt.Sprintf(" %s %s/%s", snapshot.State, snapshot.Provider, snapshot.Model)
263+ if snapshot.Profile != "" {
264+ header += " " + snapshot.Profile
265+ }
266+ if snapshot.WorkspaceMode != "" {
267+ header += " " + string(snapshot.WorkspaceMode)
268+ }
269+ }
270+ }
271+ lines := []string{header}
272+ if loading {
273+ lines = append(lines, " Loading history…")
274+ }
275+ if loadErr != "" {
276+ lines = append(lines, " history: "+loadErr)
277+ }
278+ history := view.Build(width)
279+ s.mu.Lock()
280+ if s.lastRows != 0 && len(history) > s.lastRows && s.scrollOffset > 0 {
281+ s.scrollOffset += len(history) - s.lastRows
282+ s.unread++
283+ }
284+ s.lastRows = len(history)
285+ maxRows := height - len(lines) - len(editorLines) - 3
286+ if maxRows < 0 {
287+ maxRows = 0
288+ }
289+ if s.scrollOffset > len(history)-maxRows {
290+ s.scrollOffset = len(history) - maxRows
291+ if s.scrollOffset < 0 {
292+ s.scrollOffset = 0
293+ }
294+ }
295+ end := len(history) - s.scrollOffset
296+ start := end - maxRows
297+ if start < 0 {
298+ start = 0
299+ }
300+ unread := s.unread
301+ s.mu.Unlock()
302+ lines = append(lines, history[start:end]...)
303+ if unread > 0 {
304+ lines = append(lines, fmt.Sprintf(" %d new updates below; PgDn to follow", unread))
305+ }
306+ lines = append(lines, "")
307+ lines = append(lines, editorLines...)
308+ lines = append(lines, " Enter: send follow-up/resume Esc: close PgUp: load older history")
309+ if height <= 0 {
310+ return nil
311+ }
312+ if len(lines) > height {
313+ return lines[:height]
314+ }
315+ return lines
316+}
A packages/agent/modes/subagent_session_test.go +416 -0
@@ -0,0 +1,416 @@
1+package modes
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/zut/packages/agent/subagents"
11+ "github.com/bnema/zut/packages/core"
12+ "github.com/bnema/zut/packages/provider"
13+ "github.com/bnema/zut/packages/tui"
14+)
15+
16+func TestResidentChildSessionBuildsSharedTranscriptView(t *testing.T) {
17+ root := t.TempDir()
18+ journal, err := subagents.OpenResidentJournal(root, "child")
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ spec := subagents.ResidentChildSpec{ID: "child", SessionID: "session", Provider: "openai", Model: "gpt-5"}
23+ if err := journal.Accept(spec, "task"); err != nil {
24+ t.Fatal(err)
25+ }
26+ if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call", Name: "bash", Arguments: json.RawMessage(`{"command":"pwd"}`)}}}}); err != nil {
27+ t.Fatal(err)
28+ }
29+ if err := journal.RecordAgentEvent(core.EvToolResult{ID: "call", Result: core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "/repo"}}}}); err != nil {
30+ t.Fatal(err)
31+ }
32+ if err := journal.Close(); err != nil {
33+ t.Fatal(err)
34+ }
35+ manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
36+ return func(context.Context, string) error { return nil }, nil
37+ })
38+ session := newResidentChildSession(manager, "child", tui.Dark)
39+ if err := session.LoadRecent(20); err != nil {
40+ t.Fatal(err)
41+ }
42+ view := session.View()
43+ if view == nil || !view.ExpandAll || len(view.Messages) != 2 {
44+ t.Fatalf("view = %#v", view)
45+ }
46+ lines := view.Build(80)
47+ if len(lines) == 0 {
48+ t.Fatal("shared view rendered no child history")
49+ }
50+}
51+
52+func TestResidentChildSessionKeepsComposerUntilFollowUpIsAccepted(t *testing.T) {
53+ root := t.TempDir()
54+ runs := make(chan string, 2)
55+ manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
56+ return func(_ context.Context, prompt string) error {
57+ runs <- prompt
58+ return nil
59+ }, nil
60+ })
61+ t.Cleanup(func() {
62+ if err := manager.Close(context.Background()); err != nil {
63+ t.Error(err)
64+ }
65+ })
66+ spec := subagents.ResidentChildSpec{ID: "child", SessionID: "session", Provider: "openai", Model: "gpt-5"}
67+ if _, err := manager.Spawn(context.Background(), spec, "initial task"); err != nil {
68+ t.Fatal(err)
69+ }
70+ if got := <-runs; got != "initial task" {
71+ t.Fatalf("initial prompt = %q", got)
72+ }
73+ session := newResidentChildSession(manager, "child", tui.Dark)
74+ session.composer.SetValue("follow up")
75+ prompt, submit := session.HandleKey(tui.Key{Kind: tui.KeyEnter})
76+ if !submit || prompt != "follow up" {
77+ t.Fatalf("submission = (%q, %v)", prompt, submit)
78+ }
79+ if got := session.composer.Value(); got != "follow up" {
80+ t.Fatalf("composer cleared before durable acceptance: %q", got)
81+ }
82+ if err := manager.Resume(context.Background(), "child", prompt); err != nil {
83+ t.Fatal(err)
84+ }
85+ session.FinishSubmission(nil)
86+ if got := session.composer.Value(); got != "" {
87+ t.Fatalf("composer after acceptance = %q", got)
88+ }
89+ if got := <-runs; got != "follow up" {
90+ t.Fatalf("follow-up prompt = %q", got)
91+ }
92+}
93+
94+func TestResidentChildSessionPreservesScrolledViewportAndMarksUnread(t *testing.T) {
95+ session := newResidentChildSession(nil, "child", tui.Dark)
96+ session.view.Messages = []provider.Message{
97+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first"}}},
98+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "first response"}}},
99+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second"}}},
100+ }
101+ _ = session.Render(60, 12)
102+ session.Scroll(2)
103+ session.view.Messages = append(session.view.Messages, provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "new response"}}})
104+ lines := session.Render(60, 12)
105+ if !containsLine(lines, "new updates below") {
106+ t.Fatalf("scrolled child session did not mark unread updates: %q", lines)
107+ }
108+ session.FollowTail()
109+ lines = session.Render(60, 12)
110+ if containsLine(lines, "new updates below") {
111+ t.Fatalf("following tail retained unread marker: %q", lines)
112+ }
113+}
114+
115+func TestResidentChildSessionReloadRecentShowsFinalizedTurn(t *testing.T) {
116+ root := t.TempDir()
117+ spec := subagents.ResidentChildSpec{ID: "child", SessionID: "session", Provider: "openai", Model: "gpt-5"}
118+ journal, err := subagents.OpenResidentJournal(root, spec.ID)
119+ if err != nil {
120+ t.Fatal(err)
121+ }
122+ if err := journal.Accept(spec, "task"); err != nil {
123+ t.Fatal(err)
124+ }
125+ if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "first"}}}}); err != nil {
126+ t.Fatal(err)
127+ }
128+ if err := journal.Close(); err != nil {
129+ t.Fatal(err)
130+ }
131+ manager := subagents.NewResidentManager(root, nil)
132+ session := newResidentChildSession(manager, spec.ID, tui.Dark)
133+ if err := session.LoadRecent(20); err != nil {
134+ t.Fatal(err)
135+ }
136+ journal, err = subagents.OpenResidentJournal(root, spec.ID)
137+ if err != nil {
138+ t.Fatal(err)
139+ }
140+ if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "finalized without scrolling"}}}}); err != nil {
141+ t.Fatal(err)
142+ }
143+ if err := journal.Close(); err != nil {
144+ t.Fatal(err)
145+ }
146+ if err := session.ReloadRecent(20); err != nil {
147+ t.Fatal(err)
148+ }
149+ if got := strings.Join(session.View().Build(80), "\n"); !strings.Contains(got, "finalized without scrolling") {
150+ t.Fatalf("reloaded child session omitted finalized turn: %q", got)
151+ }
152+}
153+
154+func TestResidentChildSessionCoalescesCompletionDuringHistoryLoad(t *testing.T) {
155+ session := newResidentChildSession(nil, "child", tui.Dark)
156+ if !session.BeginLoad() {
157+ t.Fatal("initial history load was not accepted")
158+ }
159+ if session.RequestRecentReload() {
160+ t.Fatal("completion refresh started alongside active history load")
161+ }
162+ if !session.FinishLoad(nil) {
163+ t.Fatal("completion refresh was not retained after active history load")
164+ }
165+}
166+
167+func TestResidentChildSessionKeepsReloadReservationUntilFinishLoad(t *testing.T) {
168+ session := newResidentChildSession(nil, "child", tui.Dark)
169+ if !session.BeginLoad() {
170+ t.Fatal("initial history load was not accepted")
171+ }
172+ if session.RequestRecentReload() {
173+ t.Fatal("second history load started alongside active history load")
174+ }
175+ // replaceRecent is the tail of the first reader. Its reservation must
176+ // remain held until the controller consumes the pending reload through
177+ // FinishLoad, otherwise another durable event can start a concurrent read.
178+ session.replaceRecent(nil, "")
179+ if session.RequestRecentReload() {
180+ t.Fatal("history reload started before the first reader finished")
181+ }
182+ if !session.FinishLoad(nil) {
183+ t.Fatal("coalesced history reload was lost")
184+ }
185+ if !session.RequestRecentReload() {
186+ t.Fatal("coalesced history reload did not reserve after first reader finished")
187+ }
188+}
189+
190+func TestResidentChildSessionRenderRespectsRequestedHeight(t *testing.T) {
191+ session := newResidentChildSession(nil, "child", tui.Dark)
192+ for _, height := range []int{0, 1, 2} {
193+ if lines := session.Render(80, height); len(lines) > height {
194+ t.Fatalf("height %d rendered %d lines: %#v", height, len(lines), lines)
195+ }
196+ }
197+}
198+
199+func TestInteractiveResidentUpdatesRefreshOpenChildSession(t *testing.T) {
200+ root := t.TempDir()
201+ started := make(chan struct{})
202+ stream := make(chan struct{})
203+ release := make(chan struct{})
204+ manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
205+ return func(context.Context, string) error {
206+ close(started)
207+ <-stream
208+ if err := journal.RecordAgentEvent(core.EvTextDelta{Delta: "streamed without input"}); err != nil {
209+ return err
210+ }
211+ <-release
212+ return nil
213+ }, nil
214+ })
215+ t.Cleanup(func() {
216+ close(release)
217+ if err := manager.Close(context.Background()); err != nil {
218+ t.Error(err)
219+ }
220+ })
221+ interactive := NewInteractive(InteractiveConfig{ResidentManager: manager, Theme: tui.Dark})
222+ spec := subagents.ResidentChildSpec{ID: "child", SessionID: "session", Provider: "openai", Model: "gpt-5"}
223+ if _, err := manager.Spawn(context.Background(), spec, "initial task"); err != nil {
224+ t.Fatal(err)
225+ }
226+ <-started
227+ interactive.residentChildSession = newResidentChildSession(manager, "child", tui.Dark)
228+ for {
229+ select {
230+ case <-interactive.dirty:
231+ default:
232+ goto drained
233+ }
234+ }
235+
236+drained:
237+ close(stream)
238+ select {
239+ case <-interactive.dirty:
240+ case <-time.After(time.Second):
241+ t.Fatal("resident stream did not request a redraw")
242+ }
243+ lines := interactive.residentChildSession.Render(80, 24)
244+ if !containsLine(lines, "streamed without input") {
245+ t.Fatalf("open resident child session did not render streamed text: %q", lines)
246+ }
247+}
248+
249+func TestInteractiveResidentHistoryUpdatesRefreshOpenChildSession(t *testing.T) {
250+ root := t.TempDir()
251+ started := make(chan struct{})
252+ persist := make(chan struct{})
253+ release := make(chan struct{})
254+ manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
255+ return func(context.Context, string) error {
256+ close(started)
257+ <-persist
258+ if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "bash", Arguments: json.RawMessage(`{"command":"printf fresh"}`)}}}}); err != nil {
259+ return err
260+ }
261+ if err := journal.RecordAgentEvent(core.EvToolResult{ID: "call-1", Result: core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "fresh resident output"}}}}); err != nil {
262+ return err
263+ }
264+ <-release
265+ return nil
266+ }, nil
267+ })
268+ t.Cleanup(func() {
269+ close(release)
270+ if err := manager.Close(context.Background()); err != nil {
271+ t.Error(err)
272+ }
273+ })
274+ interactive := NewInteractive(InteractiveConfig{ResidentManager: manager, Theme: tui.Dark})
275+ spec := subagents.ResidentChildSpec{ID: "child", SessionID: "session", Provider: "openai", Model: "gpt-5"}
276+ if _, err := manager.Spawn(context.Background(), spec, "initial task"); err != nil {
277+ t.Fatal(err)
278+ }
279+ <-started
280+ session := newResidentChildSession(manager, spec.ID, tui.Dark)
281+ if err := session.LoadRecent(200); err != nil {
282+ t.Fatal(err)
283+ }
284+ interactive.residentChildSession = session
285+ close(persist)
286+
287+ deadline := time.Now().Add(time.Second)
288+ for {
289+ if lines := session.Render(100, 30); containsLine(lines, "fresh resident output") {
290+ return
291+ }
292+ if time.Now().After(deadline) {
293+ t.Fatalf("durable resident history did not refresh while running: %q", session.Render(100, 30))
294+ }
295+ time.Sleep(time.Millisecond)
296+ }
297+}
298+
299+func TestInteractiveResidentHistoryUpdateDoesNotBlockChildControlGoroutine(t *testing.T) {
300+ root := t.TempDir()
301+ started := make(chan struct{})
302+ persist := make(chan struct{})
303+ persisted := make(chan struct{})
304+ release := make(chan struct{})
305+ manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
306+ return func(context.Context, string) error {
307+ close(started)
308+ <-persist
309+ if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "durable update"}}}}); err != nil {
310+ return err
311+ }
312+ close(persisted)
313+ <-release
314+ return nil
315+ }, nil
316+ })
317+ t.Cleanup(func() {
318+ close(release)
319+ if err := manager.Close(context.Background()); err != nil {
320+ t.Error(err)
321+ }
322+ })
323+ interactive := NewInteractive(InteractiveConfig{ResidentManager: manager, Theme: tui.Dark})
324+ spec := subagents.ResidentChildSpec{ID: "child", SessionID: "session", Provider: "openai", Model: "gpt-5"}
325+ if _, err := manager.Spawn(context.Background(), spec, "initial task"); err != nil {
326+ t.Fatal(err)
327+ }
328+ <-started
329+
330+ interactive.mu.Lock()
331+ interactive.residentChildSession = newResidentChildSession(manager, spec.ID, tui.Dark)
332+ defer interactive.mu.Unlock()
333+ close(persist)
334+ select {
335+ case <-persisted:
336+ case <-time.After(time.Second):
337+ t.Fatal("resident history update blocked the child control goroutine")
338+ }
339+}
340+
341+func TestInteractiveInputDownOpensResidentPickerAndEnterOpensLiveChild(t *testing.T) {
342+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
343+ return func(context.Context, string) error { return nil }, nil
344+ })
345+ t.Cleanup(func() {
346+ if err := manager.Close(context.Background()); err != nil {
347+ t.Error(err)
348+ }
349+ })
350+ spec := subagents.ResidentChildSpec{ID: "child", SessionID: "session", Provider: "openai", Model: "gpt-5"}
351+ if _, err := manager.Spawn(context.Background(), spec, "initial task"); err != nil {
352+ t.Fatal(err)
353+ }
354+ interactive := NewInteractive(InteractiveConfig{ResidentManager: manager, Theme: tui.Dark})
355+ interactive.ed.SetValue("draft")
356+
357+ interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyDown})
358+ if !interactive.residentSubagentsDialog.Active() {
359+ t.Fatal("Down did not open the resident subagent picker")
360+ }
361+ if got := interactive.ed.Value(); got != "draft" {
362+ t.Fatalf("editor value = %q, want draft preserved", got)
363+ }
364+ interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
365+ if interactive.residentSubagentsDialog.Active() {
366+ t.Fatal("Enter left the resident subagent picker open")
367+ }
368+ if interactive.residentChildSession == nil || interactive.residentChildSession.childID != spec.ID {
369+ t.Fatalf("resident child session = %#v, want %q", interactive.residentChildSession, spec.ID)
370+ }
371+}
372+
373+func TestInteractiveResidentActivityDrivesIndependentAnimation(t *testing.T) {
374+ started := make(chan struct{})
375+ release := make(chan struct{})
376+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
377+ return func(context.Context, string) error {
378+ close(started)
379+ <-release
380+ return nil
381+ }, nil
382+ })
383+ t.Cleanup(func() {
384+ if err := manager.Close(context.Background()); err != nil {
385+ t.Error(err)
386+ }
387+ })
388+ interactive := NewInteractive(InteractiveConfig{ResidentManager: manager, Theme: tui.Dark})
389+ if interactive.residentAnimating.Load() {
390+ t.Fatal("new interactive reported resident animation")
391+ }
392+ if _, err := manager.Spawn(context.Background(), subagents.ResidentChildSpec{ID: "indicator", SessionID: "session", Provider: "openai", Model: "gpt-5"}, "task"); err != nil {
393+ t.Fatal(err)
394+ }
395+ <-started
396+ if !interactive.residentAnimating.Load() {
397+ t.Fatal("running resident did not enable independent animation")
398+ }
399+ close(release)
400+ deadline := time.Now().Add(time.Second)
401+ for interactive.residentAnimating.Load() {
402+ if time.Now().After(deadline) {
403+ t.Fatal("completed resident did not disable independent animation")
404+ }
405+ time.Sleep(time.Millisecond)
406+ }
407+}
408+
409+func containsLine(lines []string, needle string) bool {
410+ for _, line := range lines {
411+ if strings.Contains(line, needle) {
412+ return true
413+ }
414+ }
415+ return false
416+}
D packages/agent/modes/subagents_dialog.go +0 -1712
@@ -1,1712 +0,0 @@
1-package modes
2-
3-import (
4- "errors"
5- "fmt"
6- "strings"
7- "time"
8-
9- "github.com/bnema/zut/packages/agent/subagents"
10- "github.com/bnema/zut/packages/tui"
11-)
12-
13-// subagentsDialog is the dashboard shown by /subagents (no argument) and
14-// /subagents list. It's a read-only window onto a *subagents.Supervisor; the
15-// caller passes a snapshotter so the dialog never holds the subagent
16-// pointer or its lock.
17-//
18-// Keys (list view):
19-//
20-// n spawn a new agent (inline task prompt)
21-// p send a follow-up prompt to the selected agent
22-// R resume (restart) a detached / finished agent
23-// ↑/↓ move cursor
24-// tab toggle this-session / all-sessions agents
25-// ←/→ or h/l collapse / expand the selected agent's live snapshot
26-// enter show the full transcript for the selected agent
27-// k kill (Stop) the selected running agent
28-// r remove a terminated agent (clears its state)
29-// esc / q close
30-//
31-// Keys (transcript view):
32-//
33-// p send a follow-up prompt to this agent (inline editor)
34-// ↑/↓/PgUp/PgDn/Home/End scroll
35-// esc / q return to the list
36-type subagentsDialog struct {
37- active bool
38- compactMode bool
39- lineInput bool
40- snapshot func() []subagents.AgentSnapshot
41- allSnapshots func() []subagents.AgentSnapshot
42- traceViews func() map[string]subagents.AgentTraceView
43- showAll bool
44- expandedID string
45- stop func(id string) error
46- remove func(id string) error
47- // spawn accepts an optional model + provider override (empty
48- // strings mean "let the child resolve its own default"). The cli
49- // adapter forwards these to subagents.Supervisor.SpawnReq.
50- spawn func(task, model, provider string) error
51- // send delivers a follow-up user turn to a running agent's inbox.
52- // Wired by Open(); when nil the inline 'p' shortcut is disabled.
53- send func(id, text string) error
54- // resume restarts a detached or terminated agent on its existing
55- // session. Wired by Open(); when nil the inline 'R' shortcut is
56- // disabled.
57- resume func(id string) error
58- // loadTranscript hydrates a detached agent's durable event history when
59- // its transcript view is opened. Nil preserves snapshot-only behavior.
60- loadTranscript func(id string) error
61-
62- rows []subagents.AgentSnapshot
63- cursor int
64- maxRows int // total dialog rows, including its header and frame rule; zero is unbounded
65- viewing bool // when true, show full transcript of the selected row
66- transcriptScroll int // display rows above the newest transcript viewport
67-
68- // transcriptSpin animates the busy hint shown above the inline
69- // editor while the agent is mid-turn (status running, activity
70- // anything other than idle). Same shape as the /btw spinner and
71- // the main chat's busy line, so the user gets a consistent
72- // "something's happening" signal. Lazily created on first
73- // busy frame; reset by Open / Close.
74- transcriptSpin *spinner
75-
76- // spawning toggles the inline "enter task" editor. While true the
77- // dialog captures keys into a real tui.Editor so subagent-new has the
78- // same editing, paste, drag-drop file chips, and visual style as
79- // the default input and /btw input.
80- spawning bool
81- newTaskEd *tui.Editor
82- fileSuggest *fileSuggester
83- cwd string
84-
85- // pendingModel / pendingProvider are the model + provider every
86- // freshly-spawned agent will be pinned to. They're seeded from
87- // the host's currently-active model (via SetCurrentModel) at the
88- // moment the user opens the dashboard, so subagent agents inherit
89- // whatever the user last picked via /model. Empty means "don't
90- // pin a model; let the child resolve its own default".
91- pendingModel string
92- pendingProvider string
93-
94- // pickingModel + modelPicker drive the in-spawn-editor /model
95- // command: while typing a task into the spawn editor, the user
96- // can submit just "/model" to pop the same picker /model uses
97- // globally, choose a different model for this spawn, and resume
98- // the editor with the new pending model captured. Empty when
99- // the picker isn't on screen.
100- pickingModel bool
101- modelPicker *modelDialog
102- modelPickerLoggedIn []string
103-
104- // spawnDraft remembers the editor buffer when we suspend it to
105- // show the model picker, so the user doesn't lose half-typed
106- // task text just because they wanted to switch models. Restored
107- // when the picker closes (whether by select or Esc).
108- spawnDraft string
109-
110- // prompting toggles the inline "send prompt to selected agent"
111- // editor. Distinct from `spawning` so we don't accidentally
112- // route a follow-up into Supervisor.Spawn. The editor and its
113- // file-suggest live in their own fields so opening the prompt
114- // editor while a spawn editor is half-typed doesn't clobber it
115- // (the dialog enforces one-at-a-time, but the separate state
116- // keeps the lifecycle obvious).
117- prompting bool
118- promptEd *tui.Editor
119- promptSuggest *fileSuggester
120- // promptTargetID is the agent id captured the moment the user
121- // pressed 'p'. We pin it here so the prompt routes to the same
122- // agent even if the user's cursor moves (or rows reorder) while
123- // they're typing.
124- promptTargetID string
125-}
126-
127-func newSubagentsDialog() *subagentsDialog { return &subagentsDialog{} }
128-
129-func (d *subagentsDialog) SetCompactMode(enabled bool) {
130- d.compactMode = enabled
131-}
132-
133-func (d *subagentsDialog) SetLineInput(enabled bool) {
134- d.lineInput = enabled
135-}
136-
137-// SetAllSnapshots supplies the cross-session snapshot source used when the
138-// user presses Tab. Open's snapshot source remains the default session view.
139-func (d *subagentsDialog) SetAllSnapshots(fn func() []subagents.AgentSnapshot) {
140- d.allSnapshots = fn
141-}
142-
143-// SetTraceViews supplies the shared factual execution projection. Lifecycle
144-// snapshots remain only for identity, transcript, and process-control wiring.
145-func (d *subagentsDialog) SetTraceViews(fn func() map[string]subagents.AgentTraceView) {
146- d.traceViews = fn
147-}
148-
149-// SetMaxRows bounds the complete dashboard before the host composes its
150-// bottom-sticky dialog band. Four rows are needed for the frame, column
151-// heading, and selected agent; a non-positive value leaves it unbounded.
152-func (d *subagentsDialog) SetMaxRows(rows int) {
153- if rows > 0 && rows < 4 {
154- rows = 4
155- }
156- d.maxRows = rows
157-}
158-
159-// SetLoadTranscript supplies the optional on-demand durable transcript loader.
160-func (d *subagentsDialog) SetLoadTranscript(fn func(id string) error) {
161- d.loadTranscript = fn
162-}
163-
164-func (d *subagentsDialog) editorPrompt(th tui.Theme) string {
165- if d.lineInput {
166- return ""
167- }
168- return th.AccentBar(th.Accent)
169-}
170-
171-// SetCurrentModel pins the model + provider every fresh spawn will
172-// inherit. The host wires this to the same Model / Provider the rest
173-// of zut is currently using, so agents started from the dashboard
174-// run on whatever the user last picked via /model. Pass empty
175-// strings to clear the override (the child then resolves its own
176-// default the same way a bare `zut` invocation does).
177-func (d *subagentsDialog) SetCurrentModel(model, providerID string) {
178- d.pendingModel = model
179- d.pendingProvider = providerID
180-}
181-
182-// SetLoggedInProviders supplies the list of providers the user has
183-// active credentials for. Forwarded to the embedded modelDialog when
184-// the user invokes /model inside the spawn editor so the picker can
185-// filter out unauthenticated providers, matching the global /model
186-// behaviour.
187-func (d *subagentsDialog) SetLoggedInProviders(provs []string) {
188- d.modelPickerLoggedIn = provs
189-}
190-
191-// Open binds the dialog to a subagent via the provided callbacks. The
192-// dialog refreshes the snapshot every Render so the dashboard reads
193-// live state without needing a goroutine. spawn / send are optional;
194-// when nil the corresponding inline shortcut ('n' / 'p') is disabled.
195-func (d *subagentsDialog) Open(
196- snapshot func() []subagents.AgentSnapshot,
197- stop func(id string) error,
198- remove func(id string) error,
199- spawn func(task, model, provider string) error,
200- send func(id, text string) error,
201- resume func(id string) error,
202- cwd string,
203-) {
204- d.snapshot = snapshot
205- d.stop = stop
206- d.remove = remove
207- d.spawn = spawn
208- d.send = send
209- d.resume = resume
210- d.active = true
211- d.cursor = 0
212- d.viewing = false
213- d.showAll = false
214- d.expandedID = ""
215- d.spawning = false
216- d.newTaskEd = nil
217- d.fileSuggest = nil
218- d.prompting = false
219- d.promptEd = nil
220- d.promptSuggest = nil
221- d.promptTargetID = ""
222- // pendingModel / pendingProvider are NOT cleared here: the host
223- // sets them via SetCurrentModel just before opening the dialog
224- // so the spawn flow inherits whatever /model selected. Resetting
225- // here would undo that.
226- d.pickingModel = false
227- d.modelPicker = nil
228- d.spawnDraft = ""
229- d.transcriptSpin = nil
230- d.cwd = cwd
231- d.refresh()
232-}
233-
234-// CursorPos returns the row/col for the terminal cursor while an
235-// inline editor (spawn or prompt) is active so the caret blinks in
236-// the right spot. Returns -1, -1 when no input is being captured.
237-func (d *subagentsDialog) CursorPos(width int) (row, col int) {
238- if !d.Active() {
239- return -1, -1
240- }
241- // Spawn editor (list view): rendered directly under the frame
242- // header, optionally preceded by the model banner row (when a
243- // model was picked) and the @ file-suggest popup, with one
244- // breathing-room blank row inserted just above the editor. The
245- // constants below must mirror the spawn-editor render path.
246- if d.spawning && d.newTaskEd != nil {
247- modelBannerRows := 0
248- if d.pendingModel != "" {
249- modelBannerRows = 1
250- }
251- popupRows := 0
252- if d.fileSuggest != nil && d.fileSuggest.Active(d.newTaskEd.Value()) {
253- popupRows = len(d.fileSuggest.Render(d.newTaskEd.Value(), tui.Theme{}, width))
254- }
255- d.newTaskEd.Prompt = d.editorPrompt(tui.Theme{})
256- _, eRow, eCol := d.newTaskEd.Render(width - 2)
257- const headerRows = 1
258- const editorBlankRow = 1
259- padAfterHeaderRows := 0
260- if modelBannerRows > 0 {
261- // interactive.Render wraps dialog output with padDialogFrame,
262- // which inserts a blank row after the frame header when the
263- // first body row is non-empty. The model banner is that first
264- // body row in the spawn editor, so mirror the extra row here.
265- padAfterHeaderRows = 1
266- }
267- return headerRows + padAfterHeaderRows + modelBannerRows + popupRows + editorBlankRow + eRow, eCol + 2
268- }
269- // Prompt editor cursor placement. Two flavours:
270- // 1. List-view modal (`p` from the dashboard list): renderPromptEditor
271- // draws frame header + "send to <id>:" hint + popup + editor.
272- // 2. Transcript view (always-on editor, /btw-style): the editor
273- // lives at the bottom of renderTranscript's output, after the
274- // metadata block, the "── transcript ──" rule, the transcript
275- // body itself, optional "↑/↓ N more" rows and the @-popup.
276- //
277- // Both flavours funnel through here. The math must mirror the
278- // corresponding render path exactly.
279- if d.prompting && d.promptEd != nil {
280- popupRows := 0
281- if d.promptSuggest != nil && d.promptSuggest.Active(d.promptEd.Value()) {
282- popupRows = len(d.promptSuggest.Render(d.promptEd.Value(), tui.Theme{}, width))
283- }
284- d.promptEd.Prompt = d.editorPrompt(tui.Theme{})
285- _, eRow, eCol := d.promptEd.Render(width - 2)
286-
287- if d.viewing {
288- return d.transcriptEditorCursorRow(width, popupRows, eRow), eCol + 2
289- }
290-
291- // List-view modal layout after interactive.Render applies
292- // padDialogFrame: frame header + inserted pad row + "send to
293- // <id>:" hint + popup + blank + editor.
294- const headerRows = 1
295- const padAfterHeaderRows = 1
296- const sendToHintRow = 1
297- const editorBlankRow = 1
298- return headerRows + padAfterHeaderRows + sendToHintRow + popupRows + editorBlankRow + eRow, eCol + 2
299- }
300- return -1, -1
301-}
302-
303-// transcriptEditorCursorRow computes the visual row the caret
304-// belongs on when the always-on editor sits at the bottom of the
305-// transcript view. Mirrors renderTranscript's row layout so the
306-// terminal cursor stays glued to the editor as the body grows.
307-func (d *subagentsDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffset int) int {
308- a := d.selected()
309- if a == nil {
310- return -1
311- }
312- row := 1 // frame header
313- row++ // padDialogFrame blank row after header (next row is task metadata)
314- row += 3 // task / dir / status (mirrors renderTranscript's fixed header rows)
315- if a.Model != "" {
316- row++
317- }
318- if a.Subagent != "" {
319- row++
320- }
321- if a.Reasoning != "" {
322- row++
323- }
324- if a.Err != "" {
325- row++
326- }
327- row += 3 // blank + "── transcript ──" + trailing blank
328-
329- // Replay transcript body sizing (no-transcript-yet path vs.
330- // real body path) so the editor row count matches what render
331- // actually emitted.
332- raw := a.Lines
333- if len(raw) == 0 && a.Tail != "" {
334- raw = strings.Split(a.Tail, "\n")
335- }
336- if len(raw) == 0 {
337- row++ // "(no transcript yet)" line
338- } else {
339- // Full body, no scroll window — the host's outer scrollback
340- // owns overflow now. Cursor row math just counts every
341- // rendered line.
342- body := renderSupervisorTranscriptBlocks(raw, tui.Theme{}, width, d.compactMode)
343- row += len(body)
344- }
345-
346- row += popupRows // @-file-suggest popup, if active
347- if a.Status == subagents.StatusRunning && d.send != nil && d.agentHasOpenOperation(a.ID) {
348- row += 2 // blank + spinner row inserted above the editor
349- }
350- row++ // blank above editor (appendTranscriptEditor)
351- return row + editorRowOffset
352-}
353-
354-func (d *subagentsDialog) Active() bool { return d != nil && d.active }
355-func (d *subagentsDialog) Close() { d.active = false }
356-
357-// NeedsTickRefresh reports whether the host's animation tick should
358-// repaint the screen while this dialog is open. True when the
359-// dashboard's list/transcript view is on (rows / activity / age and
360-// the transcript itself are background-driven and need a periodic
361-// redraw — essential for streaming agent replies to actually appear
362-// on screen). False only while a modal editor (spawn, list-view
363-// prompt, model picker) has the dialog to itself; those need the
364-// terminal's cursor blink, which a full-frame redraw cancels.
365-//
366-// Note: the transcript view's always-on editor does NOT suppress
367-// tick refresh. The cursor blink still works because the tick rate
368-// is well below the terminal's blink rate, and we need the redraws
369-// for streaming output. /btw makes the same trade-off.
370-func (d *subagentsDialog) NeedsTickRefresh() bool {
371- if d == nil || !d.active {
372- return false
373- }
374- if d.spawning || d.pickingModel {
375- return false
376- }
377- // The list-view `p` modal (prompting without viewing) suppresses
378- // tick like the other modal editors. The transcript-view
379- // always-on editor (viewing && prompting) does not — streaming
380- // agent output needs periodic redraws to render.
381- if d.prompting && !d.viewing {
382- return false
383- }
384- return true
385-}
386-
387-// OpenViewing opens the dashboard and jumps straight to the
388-// transcript view for the agent with id agentID. Used by /subagents logs
389-// <id>. If no such agent is found the dashboard opens normally so
390-// the user can see why (and pick from the list).
391-func (d *subagentsDialog) OpenViewing(
392- agentID string,
393- snapshot func() []subagents.AgentSnapshot,
394- stop func(id string) error,
395- remove func(id string) error,
396- spawn func(task, model, provider string) error,
397- send func(id, text string) error,
398- resume func(id string) error,
399- cwd string,
400-) bool {
401- d.Open(snapshot, stop, remove, spawn, send, resume, cwd)
402- rows := d.snapshot()
403- for i, r := range rows {
404- if r.ID == agentID || strings.HasPrefix(r.ID, agentID) {
405- if d.loadTranscript != nil {
406- _ = d.loadTranscript(r.ID)
407- }
408- d.cursor = i
409- d.viewing = true
410- // Auto-open the inline editor for running agents so
411- // /subagents logs <id> drops the user straight into a
412- // /btw-style send-ready prompt.
413- if r.Status == subagents.StatusRunning && d.send != nil {
414- d.openPromptEditor(tui.Theme{}, r.ID)
415- }
416- return true
417- }
418- }
419- return false
420-}
421-
422-// OpenForResume opens the dashboard and parks the cursor on the first
423-// resumable agent (any non-running status), so the user can press R
424-// to resume without typing an id. Returns the count of resumable
425-// agents found — callers surface that as "3 resumable, press R" or,
426-// when zero, "no agents available to resume".
427-//
428-// The dialog still shows the full list (live + detached + terminated)
429-// so the user has context: pressing ↑/↓ they can still walk through
430-// running agents, and the R key is a no-op there (Resume rejects
431-// non-terminal agents with a clear error).
432-func (d *subagentsDialog) OpenForResume(
433- snapshot func() []subagents.AgentSnapshot,
434- stop func(id string) error,
435- remove func(id string) error,
436- spawn func(task, model, provider string) error,
437- send func(id, text string) error,
438- resume func(id string) error,
439- cwd string,
440-) (resumable int) {
441- d.Open(snapshot, stop, remove, spawn, send, resume, cwd)
442- // d.rows was populated by Open's refresh(); walk it for the
443- // first resumable row and count the total in one pass.
444- for i, r := range d.rows {
445- if isResumable(r.Status) {
446- if resumable == 0 {
447- d.cursor = i
448- }
449- resumable++
450- }
451- }
452- return resumable
453-}
454-
455-// isResumable reports whether a Status is eligible for Resume. Mirror
456-// of the precondition inside Supervisor.Resume; centralised so the dialog
457-// and the slash handler agree on what "resumable" means.
458-func isResumable(s subagents.Status) bool {
459- switch s {
460- case subagents.StatusDetached,
461- subagents.StatusDone,
462- subagents.StatusFailed,
463- subagents.StatusKilled:
464- return true
465- }
466- return false
467-}
468-
469-// promptDisabledHint returns the status-bar message shown when the
470-// user presses 'p' on an agent that can't receive input. We branch on
471-// status so the hint points at the right next step: resume for the
472-// states Supervisor.Resume accepts; nothing useful otherwise.
473-func promptDisabledHint(s subagents.Status) string {
474- if isResumable(s) {
475- return "prompt: agent is " + string(s) + "; press R to resume first"
476- }
477- return "prompt: agent is " + string(s) + " and can't receive input"
478-}
479-
480-// killDisabledHint mirrors promptDisabledHint for the 'k' shortcut.
481-// Kill only makes sense on running / pending agents; on detached and
482-// terminal ones it's a no-op and the user usually wants 'r' (remove)
483-// to clear out the agent's state instead.
484-func killDisabledHint(s subagents.Status) string {
485- return "kill: agent is " + string(s) + "; nothing to stop (press r to remove)"
486-}
487-
488-// friendlySendErr turns a raw send error into a status-bar message
489-// the user can act on. The most common failure mode — dialling an
490-// agent whose previous subprocess exited — surfaces as
491-// subagents.ErrNotReady; we rewrite it to mention resume instead of
492-// leaking the unix-socket path. Other errors fall through verbatim
493-// because they're rare and almost always indicate a real bug.
494-func friendlySendErr(id string, err error) string {
495- if errors.Is(err, subagents.ErrNotReady) {
496- return "send: agent " + id + " isn't accepting input (press R to resume)"
497- }
498- return "send: " + err.Error()
499-}
500-
501-func (d *subagentsDialog) refresh() {
502- snapshot := d.snapshot
503- if d.showAll && d.allSnapshots != nil {
504- snapshot = d.allSnapshots
505- }
506- if snapshot == nil {
507- d.rows = nil
508- return
509- }
510-
511- selectedID := ""
512- if selected := d.selected(); selected != nil {
513- selectedID = selected.ID
514- }
515- d.rows = snapshot()
516- if selectedID != "" {
517- for i, row := range d.rows {
518- if row.ID == selectedID {
519- d.cursor = i
520- break
521- }
522- }
523- }
524- if d.cursor >= len(d.rows) {
525- d.cursor = len(d.rows) - 1
526- }
527- if d.cursor < 0 {
528- d.cursor = 0
529- }
530- if d.expandedID != "" && d.rowByID(d.expandedID) == nil {
531- d.expandedID = ""
532- }
533-}
534-
535-func (d *subagentsDialog) rowByID(id string) *subagents.AgentSnapshot {
536- for i := range d.rows {
537- if d.rows[i].ID == id {
538- return &d.rows[i]
539- }
540- }
541- return nil
542-}
543-
544-// HandleKey advances state. Returns (closed, statusMsg, statusErr).
545-// statusMsg / statusErr surface in the host's status bar so the user
546-// gets feedback on kill/remove without writing to the chat.
547-func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) {
548- if !d.Active() {
549- return false, "", ""
550- }
551- d.refresh()
552-
553- // Inline editors own key handling. They run before the other
554- // modes so the user's typed letters aren't interpreted as 'k'
555- // (kill), 'p' (prompt), etc.
556- //
557- // Transcript view is a special case: the editor is always on,
558- // but we still need transcript-level keys (PageUp/PageDown/
559- // Home/End scroll, Esc close) to take precedence over the
560- // editor. handleViewingKey owns that routing and delegates
561- // editor input to handlePromptKey itself.
562- if d.viewing {
563- return d.handleViewingKey(k)
564- }
565- if d.prompting {
566- return d.handlePromptKey(k)
567- }
568- if d.pickingModel {
569- return d.handleModelPickerKey(k)
570- }
571- if d.spawning {
572- return d.handleSpawnKey(k)
573- }
574-
575- switch k.Kind {
576- case tui.KeyEsc:
577- d.Close()
578- return true, "", ""
579- case tui.KeyUp:
580- if d.cursor > 0 {
581- d.cursor--
582- d.expandedID = ""
583- }
584- case tui.KeyDown:
585- if d.cursor < len(d.rows)-1 {
586- d.cursor++
587- d.expandedID = ""
588- }
589- case tui.KeyTab:
590- d.showAll = !d.showAll
591- d.expandedID = ""
592- d.refresh()
593- case tui.KeyLeft:
594- d.collapseSelected()
595- case tui.KeyRight:
596- if err := d.expandSelected(); err != nil {
597- return false, "", "load transcript: " + err.Error()
598- }
599- case tui.KeyEnter:
600- if len(d.rows) > 0 {
601- if a := d.selected(); a != nil && d.loadTranscript != nil {
602- if err := d.loadTranscript(a.ID); err != nil {
603- return false, "", "load transcript: " + err.Error()
604- }
605- }
606- d.viewing = true
607- // Auto-open the inline editor (à la /btw) so the user
608- // can start typing immediately. Skipped for non-running
609- // agents; their inbox isn't listening, so an editor
610- // would just frustrate them with bounced sends.
611- if a := d.selected(); a != nil && a.Status == subagents.StatusRunning && d.send != nil {
612- d.openPromptEditor(tui.Theme{}, a.ID)
613- }
614- }
615- case tui.KeyRune:
616- switch k.Rune {
617- case 'q':
618- d.Close()
619- return true, "", ""
620- case 'h':
621- d.collapseSelected()
622- case 'l':
623- if err := d.expandSelected(); err != nil {
624- return false, "", "load transcript: " + err.Error()
625- }
626- case 'n':
627- if d.spawn != nil {
628- // Open the task editor directly. The model the new
629- // agent will run against was already captured via
630- // SetCurrentModel from the host's /model selection;
631- // the user changes models the normal way (/model)
632- // before opening /subagents.
633- d.openSpawnEditor(tui.Theme{})
634- }
635- case 'p':
636- if a := d.selected(); a != nil && d.send != nil {
637- // Only running agents have a live inbox listener;
638- // dialling the unix socket on a detached / done /
639- // failed / killed agent fails with a long ECONNREFUSED
640- // or ENOENT trail that's not actionable. Tell the user
641- // what to do instead.
642- if a.Status != subagents.StatusRunning {
643- return false, "", promptDisabledHint(a.Status)
644- }
645- d.openPromptEditor(tui.Theme{}, a.ID)
646- }
647- case 'R':
648- // Capital R for resume; lowercase r is already taken by
649- // remove. Using a different keystroke avoids a confirm
650- // modal for an action that's already destructive (resume
651- // is reversible, so we don't need the shift-as-guard
652- // dance lowercase r has historically warranted).
653- if a := d.selected(); a != nil && d.resume != nil {
654- if err := d.resume(a.ID); err != nil {
655- return false, "", "resume: " + err.Error()
656- }
657- return false, "resumed " + a.ID, ""
658- }
659- case 'k':
660- if a := d.selected(); a != nil && d.stop != nil {
661- // Only running / pending agents have something to
662- // cancel. Detached and already-terminal agents would
663- // be silent no-ops at the Supervisor layer; surface a hint
664- // so the user knows kill did nothing and what to do
665- // instead (remove via 'r').
666- if a.Status != subagents.StatusRunning && a.Status != subagents.StatusPending {
667- return false, "", killDisabledHint(a.Status)
668- }
669- if err := d.stop(a.ID); err != nil {
670- return false, "", "stop: " + err.Error()
671- }
672- return false, "stopped " + a.ID, ""
673- }
674- case 'r':
675- if a := d.selected(); a != nil && d.remove != nil {
676- if err := d.remove(a.ID); err != nil {
677- return false, "", "remove: " + err.Error()
678- }
679- return false, "removed " + a.ID, ""
680- }
681- }
682- }
683- return false, "", ""
684-}
685-
686-func (d *subagentsDialog) expandSelected() error {
687- a := d.selected()
688- if a == nil {
689- return nil
690- }
691- if d.loadTranscript != nil {
692- if err := d.loadTranscript(a.ID); err != nil {
693- return err
694- }
695- d.refresh()
696- }
697- d.expandedID = a.ID
698- return nil
699-}
700-
701-func (d *subagentsDialog) collapseSelected() {
702- if a := d.selected(); a != nil && d.expandedID == a.ID {
703- d.expandedID = ""
704- }
705-}
706-
707-// handleViewingKey routes one keystroke while the transcript view
708-// is on screen. The transcript view runs /btw-style: an inline
709-// editor is always pinned at the bottom, the transcript flows in
710-// full above it, and the host's outer scrollback handles overflow.
711-// There is no internal scroll, so this handler is small:
712-//
713-// - Esc closes the transcript view (back to the dashboard list).
714-// If the user was in the middle of an @-file-pick we let the
715-// editor handler eat the Esc first so it just cancels the popup.
716-//
717-// Every other key is forwarded to handlePromptKey, which owns the
718-// editor itself (typed runes, Enter to submit, Backspace, file
719-// suggest, arrow keys for caret movement, etc.). Submits stay in
720-// the view and clear the editor for the next message instead of
721-// closing it — see handlePromptKey for the always-on behaviour.
722-func (d *subagentsDialog) handleViewingKey(k tui.Key) (closed bool, msg, errMsg string) {
723- a := d.selected()
724-
725- // Lazy-open the editor on first key into the transcript view, so
726- // re-entering an agent after switching away picks up cleanly.
727- // Skipped for non-running agents (their inbox isn't listening;
728- // sending would just bounce).
729- if a != nil && a.Status == subagents.StatusRunning && d.send != nil && !d.prompting {
730- d.openPromptEditor(tui.Theme{}, a.ID)
731- }
732-
733- // Esc closes the transcript view, regardless of whether the
734- // editor is open. Handled here (not in the editor handler) so
735- // the user gets a consistent "esc backs out one level" feel.
736- // The file-suggest popup, if active, gets to eat the Esc first
737- // inside handlePromptKey — we route there and check whether the
738- // editor consumed it.
739- if k.Kind == tui.KeyEsc {
740- if d.prompting && d.promptSuggest != nil && d.promptEd != nil && d.promptSuggest.Active(d.promptEd.Value()) {
741- // Let the editor's @-suggest popup handle the Esc to
742- // dismiss itself instead of collapsing the whole view.
743- return d.handlePromptKey(k)
744- }
745- d.viewing = false
746- d.closePromptEditor()
747- return false, "", ""
748- }
749-
750- // Transcript navigation owns the vertical navigation keys; horizontal
751- // arrows remain available to the inline editor for caret movement.
752- switch k.Kind {
753- case tui.KeyUp:
754- d.transcriptScroll++
755- return false, "", ""
756- case tui.KeyDown:
757- if d.transcriptScroll > 0 {
758- d.transcriptScroll--
759- }
760- return false, "", ""
761- case tui.KeyPageUp:
762- d.transcriptScroll += 10
763- return false, "", ""
764- case tui.KeyPageDown:
765- d.transcriptScroll -= 10
766- if d.transcriptScroll < 0 {
767- d.transcriptScroll = 0
768- }
769- return false, "", ""
770- case tui.KeyHome:
771- d.transcriptScroll = int(^uint(0) >> 1)
772- return false, "", ""
773- case tui.KeyEnd:
774- d.transcriptScroll = 0
775- return false, "", ""
776- }
777-
778- // Everything else — typed runes, Enter, Backspace, arrow keys
779- // for caret movement, @-file picker — is editor input. If the
780- // agent isn't running there's no live editor; surface a hint.
781- if a == nil || a.Status != subagents.StatusRunning || d.send == nil {
782- if k.Kind == tui.KeyRune && a != nil {
783- return false, "", promptDisabledHint(a.Status)
784- }
785- return false, "", ""
786- }
787- return d.handlePromptKey(k)
788-}
789-
790-// openSpawnEditor enters the inline new-agent prompt. It deliberately
791-// uses tui.Editor (not a one-line string) so subagent-new supports the same
792-// editing model as the normal input: multiline paste collapse, drag-drop
793-// file/dir chips, cursor movement, and the same accent-bar prompt style.
794-func (d *subagentsDialog) openSpawnEditor(th tui.Theme) {
795- d.spawning = true
796- d.newTaskEd = tui.NewEditor(d.editorPrompt(th))
797- d.fileSuggest = newFileSuggester()
798- d.fileSuggest.SetCWD(d.cwd)
799- if d.spawnDraft != "" {
800- d.newTaskEd.SetValue(d.spawnDraft)
801- }
802-}
803-
804-// openModelPicker suspends the spawn editor and shows the same
805-// model dialog the global /model uses. Triggered when the user
806-// submits "/model" from inside the spawn editor; on select we
807-// capture the new pendingModel/Provider and reopen the spawn
808-// editor with the previously-typed task buffer restored.
809-func (d *subagentsDialog) openModelPicker() {
810- d.spawning = false
811- d.newTaskEd = nil
812- d.fileSuggest = nil
813- d.pickingModel = true
814- d.modelPicker = newModelDialog()
815- d.modelPicker.Open(d.pendingModel, d.modelPickerLoggedIn)
816-}
817-
818-// handleModelPickerKey forwards one keystroke to the embedded model
819-// picker and, on select or close, reopens the spawn editor so the
820-// user can continue composing the task. A selection captures the new
821-// pendingModel + pendingProvider; Esc leaves them unchanged (the
822-// user gets to keep whatever was inherited before they invoked
823-// /model).
824-func (d *subagentsDialog) handleModelPickerKey(k tui.Key) (closed bool, msg, errMsg string) {
825- if d.modelPicker == nil {
826- d.openModelPicker()
827- }
828- act := d.modelPicker.HandleKey(k)
829- switch {
830- case act.Select:
831- d.pendingModel = act.Model
832- d.pendingProvider = act.Provider
833- d.pickingModel = false
834- d.modelPicker = nil
835- d.openSpawnEditor(tui.Theme{})
836- return false, "model: " + act.Model, ""
837- case act.Close:
838- d.pickingModel = false
839- d.modelPicker = nil
840- d.openSpawnEditor(tui.Theme{})
841- return false, "", ""
842- }
843- return false, "", ""
844-}
845-
846-// containsSlashModelLine reports whether any line in buf has trimmed
847-// content of "/model" (with optional trailing args). Used to decide
848-// whether to treat a spawn-editor submit as a model-picker command
849-// vs. as a task description.
850-func containsSlashModelLine(buf string) bool {
851- for _, ln := range strings.Split(buf, "\n") {
852- if isSlashModelLine(ln) {
853- return true
854- }
855- }
856- return false
857-}
858-
859-// stripSlashModelLine removes the line containing "/model" (with
860-// optional trailing args) from the spawn-editor buffer so the
861-// remainder can be restored after the picker closes. The /model line
862-// is always exactly one full line in practice — the user typed
863-// /model<Enter> — but we tolerate inline placement at the start of
864-// a line just in case.
865-func isSlashModelLine(line string) bool {
866- fields := strings.Fields(strings.TrimSpace(line))
867- return len(fields) > 0 && strings.EqualFold(fields[0], "/model")
868-}
869-
870-func stripSlashModelLine(buf string) string {
871- lines := strings.Split(buf, "\n")
872- out := lines[:0]
873- for _, ln := range lines {
874- if isSlashModelLine(ln) {
875- continue
876- }
877- out = append(out, ln)
878- }
879- return strings.TrimRight(strings.Join(out, "\n"), "\n")
880-}
881-
882-// openPromptEditor enters the inline "send a follow-up prompt" editor
883-// for the agent with id targetID. Mirrors openSpawnEditor so the
884-// editing experience (paste, @-picker, drop chips) is identical.
885-func (d *subagentsDialog) openPromptEditor(th tui.Theme, targetID string) {
886- d.prompting = true
887- d.promptEd = tui.NewEditor(d.editorPrompt(th))
888- d.promptSuggest = newFileSuggester()
889- d.promptSuggest.SetCWD(d.cwd)
890- d.promptTargetID = targetID
891-}
892-
893-// handlePromptKey owns the keystrokes while the inline prompt editor
894-// is open. It mirrors handleSpawnKey but, on submit, invokes d.send
895-// with the captured target id instead of d.spawn. Esc cancels and
896-// returns the user to whichever view (list or transcript) was active
897-// when 'p' was pressed.
898-func (d *subagentsDialog) handlePromptKey(k tui.Key) (closed bool, msg, errMsg string) {
899- if d.promptEd == nil {
900- d.openPromptEditor(tui.Theme{}, d.promptTargetID)
901- }
902- ed := d.promptEd
903- fs := d.promptSuggest
904- if fs != nil {
905- fs.SetCWD(d.cwd)
906- }
907-
908- // File suggestions: same keys and chip shape as the default input.
909- if fs != nil && fs.Active(ed.Value()) {
910- switch k.Kind {
911- case tui.KeyUp:
912- fs.Up()
913- return false, "", ""
914- case tui.KeyDown:
915- fs.Down()
916- return false, "", ""
917- case tui.KeyRight:
918- fs.Right()
919- return false, "", ""
920- case tui.KeyLeft:
921- fs.Left()
922- return false, "", ""
923- case tui.KeyEnter:
924- if entry, ok := fs.SelectedEntry(ed.Value()); ok {
925- chip := "[file:" + entry.rel + "]"
926- if entry.isDir {
927- chip = "[dir:" + entry.rel + "/]"
928- }
929- val := ed.Value()
930- if idx := strings.LastIndex(val, "@"); idx >= 0 {
931- val = val[:idx]
932- }
933- ed.SetValue(val + chip + " ")
934- fs.Reset()
935- }
936- return false, "", ""
937- case tui.KeyEsc:
938- val := ed.Value()
939- if idx := strings.LastIndex(val, "@"); idx >= 0 {
940- ed.SetValue(val[:idx])
941- }
942- fs.Reset()
943- return false, "", ""
944- }
945- }
946-
947- // Esc cancels the prompt editor when the file picker isn't active.
948- if k.Kind == tui.KeyEsc {
949- d.closePromptEditor()
950- return false, "", ""
951- }
952-
953- // Tab-complete a path-like token before the editor sees the key,
954- // matching the main editor's behaviour. Skipped above by the
955- // @-picker branch when its popup is active.
956- if k.Kind == tui.KeyTab {
957- if tryPathTabCompleteEditor(ed, d.cwd) {
958- return false, "", ""
959- }
960- }
961-
962- if submitted := ed.HandleKey(k); submitted {
963- text := strings.TrimRight(ed.SubmitValue(), "\n")
964- text = expandFileChips(text, d.cwd)
965- text = strings.TrimSpace(text)
966- targetID := d.promptTargetID
967-
968- // Empty submit: nothing to send. If we're in transcript
969- // view (always-on editor), just clear and stay; otherwise
970- // (list-view-p flow) close the modal.
971- if text == "" {
972- if d.viewing {
973- ed.Clear()
974- return false, "", ""
975- }
976- d.closePromptEditor()
977- return false, "", ""
978- }
979- if d.send == nil {
980- if !d.viewing {
981- d.closePromptEditor()
982- }
983- return false, "", "send not wired"
984- }
985- if targetID == "" {
986- if !d.viewing {
987- d.closePromptEditor()
988- }
989- return false, "", "send: no target agent"
990- }
991- if err := d.send(targetID, text); err != nil {
992- if !d.viewing {
993- d.closePromptEditor()
994- }
995- return false, "", friendlySendErr(targetID, err)
996- }
997-
998- // In transcript view (always-on editor) keep the editor
999- // mounted and clear the buffer for the next message; in the
1000- // list-view modal flow, close it so the user returns to the
1001- // list. Either way, snap the transcript to the tail so the
1002- // user sees their just-sent message and the agent's reply.
1003- if d.viewing {
1004- ed.Clear()
1005- } else {
1006- d.closePromptEditor()
1007- }
1008- return false, "sent to " + targetID, ""
1009- }
1010- return false, "", ""
1011-}
1012-
1013-// closePromptEditor resets all prompt-editor state. Called on Esc and
1014-// after submit so a subsequent 'p' starts from a clean slate.
1015-func (d *subagentsDialog) closePromptEditor() {
1016- d.prompting = false
1017- d.promptEd = nil
1018- d.promptSuggest = nil
1019- d.promptTargetID = ""
1020-}
1021-
1022-// handleSpawnKey owns the keystrokes while the inline new-agent editor
1023-// is open. It mirrors the normal input's @ file-picker path and submit
1024-// expansion, then asks the host to spawn.
1025-func (d *subagentsDialog) handleSpawnKey(k tui.Key) (closed bool, msg, errMsg string) {
1026- if d.newTaskEd == nil {
1027- d.openSpawnEditor(tui.Theme{})
1028- }
1029- ed := d.newTaskEd
1030- fs := d.fileSuggest
1031- if fs != nil {
1032- fs.SetCWD(d.cwd)
1033- }
1034-
1035- // File suggestions: same keys and chip shape as the default input.
1036- if fs != nil && fs.Active(ed.Value()) {
1037- switch k.Kind {
1038- case tui.KeyUp:
1039- fs.Up()
1040- return false, "", ""
1041- case tui.KeyDown:
1042- fs.Down()
1043- return false, "", ""
1044- case tui.KeyRight:
1045- fs.Right()
1046- return false, "", ""
1047- case tui.KeyLeft:
1048- fs.Left()
1049- return false, "", ""
1050- case tui.KeyEnter:
1051- if entry, ok := fs.SelectedEntry(ed.Value()); ok {
1052- chip := "[file:" + entry.rel + "]"
1053- if entry.isDir {
1054- chip = "[dir:" + entry.rel + "/]"
1055- }
1056- val := ed.Value()
1057- if idx := strings.LastIndex(val, "@"); idx >= 0 {
1058- val = val[:idx]
1059- }
1060- ed.SetValue(val + chip + " ")
1061- fs.Reset()
1062- }
1063- return false, "", ""
1064- case tui.KeyEsc:
1065- val := ed.Value()
1066- if idx := strings.LastIndex(val, "@"); idx >= 0 {
1067- ed.SetValue(val[:idx])
1068- }
1069- fs.Reset()
1070- return false, "", ""
1071- }
1072- }
1073-
1074- // Esc cancels the spawn prompt when the file picker isn't active.
1075- if k.Kind == tui.KeyEsc {
1076- d.spawning = false
1077- d.newTaskEd = nil
1078- d.fileSuggest = nil
1079- return false, "", ""
1080- }
1081-
1082- // Tab-complete a path-like token before the editor sees the key,
1083- // matching the main editor's behaviour. Skipped above by the
1084- // @-picker branch when its popup is active.
1085- if k.Kind == tui.KeyTab {
1086- if tryPathTabCompleteEditor(ed, d.cwd) {
1087- return false, "", ""
1088- }
1089- }
1090-
1091- if submitted := ed.HandleKey(k); submitted {
1092- raw := strings.TrimRight(ed.SubmitValue(), "\n")
1093-
1094- // In-editor /model command: when the buffer contains a line
1095- // whose trimmed content is "/model" (anywhere — typically on
1096- // its own line, but a trailing /model after a task works
1097- // too), pop the same picker the global /model uses, drop the
1098- // /model line, and preserve the rest of the buffer so the
1099- // user can keep composing once they've picked.
1100- if containsSlashModelLine(raw) {
1101- d.spawnDraft = stripSlashModelLine(raw)
1102- d.openModelPicker()
1103- return false, "", ""
1104- }
1105-
1106- task := expandFileChips(raw, d.cwd)
1107- task = strings.TrimSpace(task)
1108- model := d.pendingModel
1109- provider := d.pendingProvider
1110- d.spawning = false
1111- d.newTaskEd = nil
1112- d.fileSuggest = nil
1113- d.spawnDraft = ""
1114- d.pendingModel = ""
1115- d.pendingProvider = ""
1116- if task == "" {
1117- return false, "", ""
1118- }
1119- if d.spawn == nil {
1120- return false, "", "spawn not wired"
1121- }
1122- if err := d.spawn(task, model, provider); err != nil {
1123- return false, "", "spawn: " + err.Error()
1124- }
1125- if model != "" {
1126- return false, "spawned (model " + model + ")", ""
1127- }
1128- return false, "spawned", ""
1129- }
1130- return false, "", ""
1131-}
1132-
1133-func (d *subagentsDialog) selected() *subagents.AgentSnapshot {
1134- if d.cursor < 0 || d.cursor >= len(d.rows) {
1135- return nil
1136- }
1137- r := d.rows[d.cursor]
1138- return &r
1139-}
1140-
1141-// Render returns the dialog lines. Re-reads the snapshot each call so
1142-// the dashboard updates as agents make progress.
1143-func (d *subagentsDialog) Render(th tui.Theme, width int) []string {
1144- if !d.Active() {
1145- return nil
1146- }
1147- d.refresh()
1148-
1149- if d.viewing {
1150- return d.renderTranscript(th, width)
1151- }
1152-
1153- filter := "this session"
1154- if d.showAll {
1155- filter = "all sessions"
1156- }
1157- out := []string{frameHeader(th, "subagents · "+filter+" (tab filter, ↑/↓ select, ←/→ or h/l snapshot, enter view, esc close)", width)}
1158- if d.prompting {
1159- return d.renderPromptEditor(th, width, out)
1160- }
1161- if d.pickingModel {
1162- // modelDialog renders its own frame; replace ours so the
1163- // picker takes the dashboard cleanly. Trailing hint reminds
1164- // the user Esc returns to the spawn editor.
1165- if d.modelPicker == nil {
1166- d.openModelPicker()
1167- }
1168- out = d.modelPicker.Render(th, width)
1169- out = append(out, " "+th.FGColor(th.Muted, "select model for next spawn, esc to cancel"))
1170- return out
1171- }
1172- if d.spawning {
1173- if d.newTaskEd == nil {
1174- d.openSpawnEditor(th)
1175- }
1176- if d.newTaskEd != nil {
1177- d.newTaskEd.Prompt = d.editorPrompt(th)
1178- }
1179- // One-line banner reminding the user which model the
1180- // agent-to-be will run against. Skipped when no model was
1181- // picked so the layout matches the historical look.
1182- if d.pendingModel != "" {
1183- label := "model: " + d.pendingModel
1184- if d.pendingProvider != "" {
1185- label += " (" + d.pendingProvider + ")"
1186- }
1187- out = append(out, " "+th.FGColor(th.Accent, label))
1188- }
1189- if d.fileSuggest != nil {
1190- d.fileSuggest.SetCWD(d.cwd)
1191- if popup := d.fileSuggest.Render(d.newTaskEd.Value(), th, width); len(popup) > 0 {
1192- out = append(out, popup...)
1193- }
1194- }
1195- // Breathing-room blank row above the editor so the input
1196- // doesn't sit flush against the frame header.
1197- out = append(out, "")
1198- if d.newTaskEd != nil {
1199- edLines, _, _ := d.newTaskEd.Render(width - 2)
1200- for _, l := range edLines {
1201- out = append(out, " "+l)
1202- }
1203- }
1204- // Matching blank row below before the hint, mirroring the
1205- // main input's editor breathing room.
1206- out = append(out, "")
1207- out = append(out, " "+th.FGColor(th.Muted, "enter spawn, /model pick model, @ file/dir picker, paste/drop paths become [file:] / [dir:] chips, esc cancel"))
1208- out = append(out, frameRule(th, width))
1209- return out
1210- }
1211- if len(d.rows) == 0 {
1212- out = append(out, " "+th.FGColor(th.Muted, "no agents — press n to spawn one (or /subagents new <task>)"))
1213- out = append(out, frameRule(th, width))
1214- return out
1215- }
1216-
1217- // Column header for readability.
1218- header := fmt.Sprintf(" %-30s %-26s %-8s %s", "OPERATION", "ID", "AGE", "TASK")
1219- out = append(out, th.FGColor(th.Muted, header))
1220- out = append(out, d.renderVisibleRows(th, width)...)
1221- out = append(out, frameRule(th, width))
1222- return out
1223-}
1224-
1225-// renderVisibleRows keeps the dashboard inside the terminal-height budget.
1226-// The selected row is always rendered, which prevents a bottom-sticky dialog
1227-// from clipping the top rows (and leaving arrow-key selection invisible).
1228-func (d *subagentsDialog) renderVisibleRows(th tui.Theme, width int) []string {
1229- budget := len(d.rows) * (dashboardSnapshotRows + 1)
1230- if d.maxRows > 0 {
1231- budget = d.maxRows - 3 // frame header, column heading, frame rule
1232- if budget < 1 {
1233- budget = 1
1234- }
1235- }
1236-
1237- out := make([]string, 0, budget)
1238- // Do not spend the only available body row on the indicator: the selected
1239- // agent must remain visible even on an extremely short terminal.
1240- if d.cursor > 0 && budget > 1 {
1241- out = append(out, " "+th.FGColor(th.Muted, fmt.Sprintf("↑ %d agents above", d.cursor)))
1242- }
1243- nextRow := d.cursor
1244- for ; nextRow < len(d.rows) && len(out) < budget; nextRow++ {
1245- r := d.rows[nextRow]
1246- row := formatSupervisorRow(r, d.traceView(r.ID), width-2)
1247- if nextRow == d.cursor {
1248- out = append(out, th.PadHighlight(" "+row, width))
1249- } else {
1250- out = append(out, " "+th.FGColor(th.FG, row))
1251- }
1252- if r.ID == d.expandedID && len(out) < budget {
1253- out = append(out, d.renderSnapshot(th, width, r, budget-len(out))...)
1254- }
1255- }
1256- if nextRow < len(d.rows) && len(out) < budget {
1257- out = append(out, " "+th.FGColor(th.Muted, "↓ more agents below"))
1258- }
1259- return out
1260-}
1261-
1262-// dashboardSnapshotRows is deliberately bounded so expanding a running agent
1263-// keeps the list usable while still exposing its current user, assistant, and
1264-// tool activity. Enter opens the complete transcript.
1265-const dashboardSnapshotRows = 12
1266-
1267-func (d *subagentsDialog) renderSnapshot(th tui.Theme, width int, a subagents.AgentSnapshot, remaining int) []string {
1268- raw := a.Lines
1269- if len(raw) == 0 && a.Tail != "" {
1270- raw = strings.Split(a.Tail, "\n")
1271- }
1272- out := []string{" " + th.FGColor(th.Muted, "└─ live snapshot")}
1273- if len(raw) == 0 || remaining <= 1 {
1274- if remaining > 1 {
1275- out = append(out, " "+th.FGColor(th.Muted, " (no activity yet)"))
1276- }
1277- return out
1278- }
1279- rows := renderSupervisorTranscriptBlocks(raw, th, width-4, d.compactMode)
1280- limit := dashboardSnapshotRows
1281- if limit > remaining-1 {
1282- limit = remaining - 1
1283- }
1284- if len(rows) > limit {
1285- rows = rows[len(rows)-limit:]
1286- if limit > 1 {
1287- out = append(out, " "+th.FGColor(th.Muted, " … earlier activity"))
1288- rows = rows[1:]
1289- }
1290- }
1291- for _, row := range rows {
1292- out = append(out, " "+row)
1293- }
1294- return out
1295-}
1296-
1297-func (d *subagentsDialog) renderTranscript(th tui.Theme, width int) []string {
1298- a := d.selected()
1299- if a == nil {
1300- d.viewing = false
1301- return d.Render(th, width)
1302- }
1303- header := []string{
1304- frameHeader(th, "subagent: "+a.ID+" (type to send, esc back)", width),
1305- " " + th.FGColor(th.Muted, "task: "+a.Task),
1306- " " + th.FGColor(th.Muted, "dir: "+a.Dir),
1307- " " + th.FGColor(th.Muted, "observation: "+d.traceSummary(a.ID)),
1308- }
1309- if request := d.traceView(a.ID).LastRequest; request != nil {
1310- diagnostic := "provider request: " + request.Outcome
1311- if request.Provider != "" || request.Model != "" {
1312- diagnostic += " · " + strings.Trim(strings.TrimSpace(request.Provider+" / "+request.Model), " /")
1313- }
1314- if request.Attempt > 0 {
1315- diagnostic += fmt.Sprintf(" · attempt %d", request.Attempt)
1316- if request.MaxAttempts > 0 {
1317- diagnostic += fmt.Sprintf("/%d", request.MaxAttempts)
1318- }
1319- }
1320- if request.ErrorCode != "" {
1321- diagnostic += " · " + request.ErrorCode
1322- }
1323- header = append(header, " "+th.FGColor(th.Muted, diagnostic))
1324- }
1325- if a.Model != "" {
1326- modelLine := "model: " + a.Model
1327- if a.Provider != "" {
1328- modelLine += " (" + a.Provider + ")"
1329- }
1330- header = append(header, " "+th.FGColor(th.Muted, modelLine))
1331- }
1332- if a.Subagent != "" {
1333- header = append(header, " "+th.FGColor(th.Muted, "agent: "+a.Subagent))
1334- }
1335- if a.Reasoning != "" {
1336- header = append(header, " "+th.FGColor(th.Muted, "reasoning: "+a.Reasoning))
1337- }
1338- if a.Err != "" {
1339- header = append(header, " "+th.FGColor(th.Muted, "error: "+a.Err))
1340- }
1341- header = append(header,
1342- "",
1343- " "+th.FGColor(th.Muted, "── transcript ──"),
1344- "",
1345- )
1346-
1347- // Body: prefer the full Lines slice when present, fall back to
1348- // the Tail string for snapshots that only filled the summary.
1349- raw := a.Lines
1350- if len(raw) == 0 && a.Tail != "" {
1351- raw = strings.Split(a.Tail, "\n")
1352- }
1353- if len(raw) == 0 {
1354- header = append(header, " "+th.FGColor(th.Muted, "(no transcript yet)"))
1355- header = d.appendTranscriptEditor(header, th, width, a)
1356- header = append(header, frameRule(th, width))
1357- return header
1358- }
1359-
1360- // Re-render the flat log into proper chat blocks: user-bubble
1361- // rows for echoes, markdown-rendered prose for assistant turns,
1362- // tinted prefixes for stderr / errors. The result is a single
1363- // pre-styled, pre-indented []string of display rows ready to
1364- // hand to the host.
1365- //
1366- // We deliberately do NOT clip into a fixed-height viewport with
1367- // internal scroll — the transcript flows in full, /btw-style,
1368- // and the host's outer scrollback handles overflow naturally.
1369- // That gives the user the chat UX they expect (transcript grows
1370- // with the window, arrow keys belong to the editor, no "↑ N
1371- // more above" cliff).
1372- body := renderSupervisorTranscriptBlocks(raw, th, width, d.compactMode)
1373-
1374- // /btw-style inline editor pinned to the bottom of the transcript.
1375- // Rendered after the body so the editor sits flush above the
1376- // frame rule. Only shown for running agents (others have no live
1377- // inbox listener — we surface a hint instead).
1378- footer := d.appendTranscriptEditor(nil, th, width, a)
1379- if d.maxRows > 0 {
1380- bodyRows := d.maxRows - len(header) - len(footer) - 1
1381- if bodyRows < 1 {
1382- bodyRows = 1
1383- }
1384- maxScroll := len(body) - bodyRows
1385- if maxScroll < 0 {
1386- maxScroll = 0
1387- }
1388- if d.transcriptScroll > maxScroll {
1389- d.transcriptScroll = maxScroll
1390- }
1391- start := len(body) - bodyRows - d.transcriptScroll
1392- if start < 0 {
1393- start = 0
1394- }
1395- end := start + bodyRows
1396- if end > len(body) {
1397- end = len(body)
1398- }
1399- body = body[start:end]
1400- }
1401-
1402- out := append(header, body...)
1403- out = append(out, footer...)
1404- out = append(out, frameRule(th, width))
1405- return out
1406-}
1407-
1408-// appendTranscriptEditor adds the always-on inline composer to the
1409-// transcript view, mimicking the /btw side-chat layout: a breathing
1410-// row, the editor itself, another breathing row, and a single muted
1411-// hint. For non-running agents we render a short "can't send while
1412-// X" note instead so the user knows why typing is disabled.
1413-func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, width int, a *subagents.AgentSnapshot) []string {
1414- if a == nil {
1415- return out
1416- }
1417- if a.Status != subagents.StatusRunning || d.send == nil {
1418- out = append(out, "")
1419- out = append(out, " "+th.FGColor(th.Muted, promptDisabledHint(a.Status)))
1420- return out
1421- }
1422- // Lazy-init the editor here too so the very first render of the
1423- // transcript view (before the user has pressed any key) already
1424- // shows the composer. Mirrors openPromptEditor's setup but uses
1425- // the live theme so the accent bar matches.
1426- if d.promptEd == nil {
1427- d.openPromptEditor(th, a.ID)
1428- }
1429- if d.promptEd != nil {
1430- d.promptEd.Prompt = d.editorPrompt(th)
1431- }
1432- if d.promptSuggest != nil {
1433- d.promptSuggest.SetCWD(d.cwd)
1434- if popup := d.promptSuggest.Render(d.promptEd.Value(), th, width); len(popup) > 0 {
1435- out = append(out, popup...)
1436- }
1437- }
1438-
1439- // Busy-spinner row, /btw-style. It is shown only for a factual open
1440- // trace operation, never for a lifecycle or heartbeat label.
1441- if d.agentHasOpenOperation(a.ID) {
1442- if d.transcriptSpin == nil {
1443- d.transcriptSpin = newSpinner(th)
1444- d.transcriptSpin.Start()
1445- } else {
1446- d.transcriptSpin.Configure(th)
1447- }
1448- out = append(out, "")
1449- prefix := fmt.Sprintf("%s %s, %s",
1450- th.FGColor(th.Assistant, d.transcriptSpin.Frame()),
1451- th.FGColor(th.Assistant, d.traceSummary(a.ID)),
1452- th.FGColor(th.Muted, d.transcriptSpin.Elapsed().String()),
1453- )
1454- out = append(out, " "+prefix)
1455- } else {
1456- // Reset so the next busy phase starts fresh (rotating
1457- // funny-line cycles back to the start, elapsed restarts).
1458- d.transcriptSpin = nil
1459- }
1460-
1461- out = append(out, "")
1462- edLines, _, _ := d.promptEd.Render(width - 2)
1463- for _, l := range edLines {
1464- out = append(out, " "+l)
1465- }
1466- out = append(out, "")
1467- out = append(out, " "+th.FGColor(th.Muted, "enter send, @ file/dir picker, esc back"))
1468- return out
1469-}
1470-
1471-func (d *subagentsDialog) agentHasOpenOperation(id string) bool {
1472- return len(d.traceView(id).OpenOperations) != 0
1473-}
1474-
1475-func (d *subagentsDialog) traceSummary(id string) string {
1476- return d.traceView(id).Summary()
1477-}
1478-
1479-// renderSupervisorTranscriptBlocks converts the agent's flat transcript
1480-// log into a slice of display rows styled like the main chat: user
1481-// echoes in bubble rows, assistant prose through tui.RenderMarkdown,
1482-// stderr / error chatter tinted. Each output row is fully indented
1483-// and ready for the caller to slice into a scroll window.
1484-//
1485-// The flat log mixes role markers and raw text, so we group adjacent
1486-// lines of the same role into one logical "block" and render the
1487-// whole block together — essential for markdown, which needs the
1488-// full message body in one piece to detect fenced code spans,
1489-// bullet lists, and so on.
1490-func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, compactMode bool) []string {
1491- type blockKind int
1492- const (
1493- kindAssistant blockKind = iota
1494- kindUser
1495- kindTool
1496- kindToolResult
1497- kindStderr
1498- kindError
1499- )
1500- type block struct {
1501- kind blockKind
1502- body []string // raw lines, role prefix already stripped
1503- }
1504-
1505- // classify maps one raw line to its role and the stripped body.
1506- classify := func(s string) (blockKind, string) {
1507- switch {
1508- case strings.HasPrefix(s, "user: "):
1509- return kindUser, strings.TrimPrefix(s, "user: ")
1510- case strings.HasPrefix(s, "tool: "):
1511- return kindTool, strings.TrimPrefix(s, "tool: ")
1512- case strings.HasPrefix(s, "tool result: "):
1513- return kindToolResult, strings.TrimPrefix(s, "tool result: ")
1514- case strings.HasPrefix(s, "stderr: "):
1515- return kindStderr, strings.TrimPrefix(s, "stderr: ")
1516- case strings.HasPrefix(s, "error: "):
1517- return kindError, strings.TrimPrefix(s, "error: ")
1518- default:
1519- return kindAssistant, s
1520- }
1521- }
1522-
1523- // Coalesce consecutive same-kind lines into blocks. Empty lines
1524- // keep the kind of the previous block so blank lines inside an
1525- // assistant message stay attached for markdown rendering (an
1526- // empty line between paragraphs is significant in markdown).
1527- var blocks []block
1528- for _, ln := range lines {
1529- if ln == "" {
1530- // Empty lines stay attached to the previous block so
1531- // markdown sees paragraph breaks inside an assistant
1532- // turn. Standalone empties before any block are dropped
1533- // (nothing meaningful to attach them to).
1534- if len(blocks) > 0 {
1535- blocks[len(blocks)-1].body = append(blocks[len(blocks)-1].body, "")
1536- }
1537- continue
1538- }
1539- k, stripped := classify(ln)
1540- if len(blocks) == 0 || blocks[len(blocks)-1].kind != k {
1541- blocks = append(blocks, block{kind: k})
1542- }
1543- blocks[len(blocks)-1].body = append(blocks[len(blocks)-1].body, stripped)
1544- }
1545-
1546- // Render each block. Width budget mirrors the btw dialog: the
1547- // transcript text sits inside two leading frame cells, so the
1548- // inner width is width-4 for markdown and width-2 for the
1549- // user-bubble (the bubble already pads itself).
1550- var out []string
1551- bubbleWidth := width - 2
1552- if bubbleWidth < 10 {
1553- bubbleWidth = 10
1554- }
1555-
1556- for i, b := range blocks {
1557- if i > 0 {
1558- out = append(out, "") // breathing room between turns
1559- }
1560- switch b.kind {
1561- case kindUser:
1562- text := strings.Join(b.body, "\n")
1563- out = append(out, btwUserBubbleRows(th, text, bubbleWidth, compactMode)...)
1564- case kindAssistant:
1565- text := strings.Join(b.body, "\n")
1566- out = append(out, renderDialogMarkdownRows(strings.TrimLeft(text, "\n"), th, width)...)
1567- case kindTool:
1568- for _, line := range b.body {
1569- out = append(out, wrapDialogTextRows(th.FGColor(th.Accent, "⚙ "+line), width)...)
1570- }
1571- case kindToolResult:
1572- for _, line := range b.body {
1573- out = append(out, wrapDialogTextRows(th.FGColor(th.Muted, "↳ "+line), width)...)
1574- }
1575- case kindStderr:
1576- for _, line := range b.body {
1577- out = append(out, wrapDialogTextRows(th.FGColor(th.Muted, "stderr "+line), width)...)
1578- }
1579- case kindError:
1580- for _, line := range b.body {
1581- out = append(out, wrapDialogTextRows(th.FGColor(th.Error, "✖ "+line), width)...)
1582- }
1583- }
1584- }
1585- return out
1586-}
1587-
1588-// renderPromptEditor draws the inline "send a follow-up prompt"
1589-// composer. Reuses the editor + file-suggest popup styling of the
1590-// spawn editor so the two flows are visually consistent. `out` is
1591-// the pre-built header (frame header for list view, or the agent
1592-// metadata block for transcript view); we append onto it and close
1593-// with the standard frame rule.
1594-func (d *subagentsDialog) renderPromptEditor(th tui.Theme, width int, out []string) []string {
1595- if d.promptEd == nil {
1596- d.openPromptEditor(th, d.promptTargetID)
1597- }
1598- if d.promptEd != nil {
1599- d.promptEd.Prompt = d.editorPrompt(th)
1600- }
1601- target := d.promptTargetID
1602- if target == "" {
1603- target = "<unknown>"
1604- }
1605- out = append(out, " "+th.FGColor(th.Muted, "send to "+target+":"))
1606- if d.promptSuggest != nil {
1607- d.promptSuggest.SetCWD(d.cwd)
1608- if popup := d.promptSuggest.Render(d.promptEd.Value(), th, width); len(popup) > 0 {
1609- out = append(out, popup...)
1610- }
1611- }
1612- // Breathing-room blank row above the editor.
1613- out = append(out, "")
1614- if d.promptEd != nil {
1615- edLines, _, _ := d.promptEd.Render(width - 2)
1616- for _, l := range edLines {
1617- out = append(out, " "+l)
1618- }
1619- }
1620- // Matching blank row below before the hint.
1621- out = append(out, "")
1622- out = append(out, " "+th.FGColor(th.Muted, "enter send, @ file/dir picker, esc cancel"))
1623- out = append(out, frameRule(th, width))
1624- return out
1625-}
1626-
1627-// traceView retrieves the latest shared factual trace projection for id.
1628-func (d *subagentsDialog) traceView(id string) subagents.AgentTraceView {
1629- if d != nil && d.traceViews != nil {
1630- return d.traceViews()[id]
1631- }
1632- return subagents.AgentTraceView{AgentID: id}
1633-}
1634-
1635-// formatSupervisorRow is the one-line summary shown per agent.
1636-//
1637-// Layout (fixed-width columns, then free-form task):
1638-//
1639-// OPERATION ID AGE TASK
1640-// tool open 3m fix-login-12345 3m inspect main.go
1641-// completed write-tests-67890 1h add coverage
1642-func formatSupervisorRow(r subagents.AgentSnapshot, view subagents.AgentTraceView, maxWidth int) string {
1643- fact := view.Summary()
1644- if primary, ok := view.Primary(); ok {
1645- fact = truncateLineSafe(fact, 24)
1646- if observation := view.ObservationFor(primary); observation != nil {
1647- fact += " " + formatAge(observation.At)
1648- } else {
1649- fact += " " + formatAge(primary.StartedAt)
1650- }
1651- } else if view.LastEvent.Type != "" {
1652- fact = truncateLineSafe(fact, 24)
1653- fact += " " + formatAge(view.LastEvent.Timestamp)
1654- }
1655- age := formatAge(r.Started)
1656- left := fmt.Sprintf("%-30s %-26s %-8s ", truncateLineSafe(fact, 30), truncateLineSafe(r.ID, 26), age)
1657- room := maxWidth - len([]rune(left))
1658- if room < 10 {
1659- room = 10
1660- }
1661- act := strings.ReplaceAll(r.Task, "\n", " ")
1662- if len([]rune(act)) > room {
1663- act = string([]rune(act)[:room-3]) + "..."
1664- }
1665- row := left + act
1666- rowRunes := []rune(row)
1667- if len(rowRunes) > maxWidth {
1668- if maxWidth <= 3 {
1669- row = strings.Repeat(".", maxWidth)
1670- } else {
1671- row = string(rowRunes[:maxWidth-3]) + "..."
1672- }
1673- }
1674- return row
1675-}
1676-
1677-// statusLabel returns a short, padded status badge. Kept stable across
1678-// runs (no colours embedded) so tests can match it deterministically.
1679-func statusLabel(s subagents.Status) string {
1680- switch s {
1681- case subagents.StatusPending:
1682- return "● pend"
1683- case subagents.StatusRunning:
1684- return "● run"
1685- case subagents.StatusDone:
1686- return "✓ done"
1687- case subagents.StatusFailed:
1688- return "✗ fail"
1689- case subagents.StatusKilled:
1690- return "■ kill"
1691- case subagents.StatusDetached:
1692- return "○ detach"
1693- }
1694- return string(s)
1695-}
1696-
1697-func formatAge(t time.Time) string {
1698- if t.IsZero() {
1699- return "-"
1700- }
1701- d := time.Since(t)
1702- switch {
1703- case d < time.Minute:
1704- return fmt.Sprintf("%ds", int(d.Seconds()))
1705- case d < time.Hour:
1706- return fmt.Sprintf("%dm", int(d.Minutes()))
1707- case d < 24*time.Hour:
1708- return fmt.Sprintf("%dh", int(d.Hours()))
1709- default:
1710- return fmt.Sprintf("%dd", int(d.Hours()/24))
1711- }
1712-}
D packages/agent/modes/subagents_dialog_test.go +0 -1463
@@ -1,1463 +0,0 @@
1-package modes
2-
3-import (
4- "errors"
5- "fmt"
6- "os"
7- "path/filepath"
8- "strings"
9- "testing"
10- "time"
11-
12- "github.com/bnema/zut/packages/agent/subagents"
13- "github.com/bnema/zut/packages/provider"
14- "github.com/bnema/zut/packages/tui"
15-)
16-
17-// staticSnapshots returns a deterministic snapshot slice for the
18-// dialog to render.
19-func staticSnapshots(rows ...subagents.AgentSnapshot) func() []subagents.AgentSnapshot {
20- return func() []subagents.AgentSnapshot {
21- out := make([]subagents.AgentSnapshot, len(rows))
22- copy(out, rows)
23- return out
24- }
25-}
26-
27-func TestFormatSupervisorRowShowsTerminalResultDelivery(t *testing.T) {
28- at := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
29- got := formatSupervisorRow(subagents.AgentSnapshot{ID: "review-1", Started: at}, subagents.AgentTraceView{
30- Terminal: "completed",
31- Result: &subagents.ResultFact{Available: true, Delivered: true, At: at},
32- }, 120)
33- if !strings.Contains(got, "completed · result delivered") {
34- t.Fatalf("row = %q", got)
35- }
36-}
37-
38-func TestFormatSupervisorRowOmitsPriorTurnObservationAge(t *testing.T) {
39- now := time.Now()
40- got := formatSupervisorRow(subagents.AgentSnapshot{ID: "review-1", Started: now.Add(-time.Hour)}, subagents.AgentTraceView{
41- OpenOperations: []subagents.Operation{{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)}},
42- LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)},
43- }, 120)
44- if strings.Contains(got, "assistant streaming") || !strings.Contains(got, "running tool") || !strings.Contains(got, "1m") {
45- t.Fatalf("row = %q", got)
46- }
47-}
48-
49-func TestFormatSupervisorRowPreservesAgeAfterLongSummary(t *testing.T) {
50- now := time.Now()
51- got := formatSupervisorRow(subagents.AgentSnapshot{ID: "review-1", Started: now.Add(-time.Hour)}, subagents.AgentTraceView{
52- Terminal: strings.Repeat("completed ", 8),
53- LastEvent: subagents.TraceEvent{Type: "agent.completed", Timestamp: now.Add(-2 * time.Minute)},
54- }, 120)
55- if !strings.Contains(got, "2m") {
56- t.Fatalf("row lost fact age: %q", got)
57- }
58-}
59-
60-func TestSubagentsDialogTranscriptShowsProviderRequestDiagnostic(t *testing.T) {
61- at := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
62- d := newSubagentsDialog()
63- d.Open(staticSnapshots(subagents.AgentSnapshot{ID: "review-1", Task: "review", Started: at}), nil, nil, nil, nil, nil, "")
64- d.SetTraceViews(func() map[string]subagents.AgentTraceView {
65- return map[string]subagents.AgentTraceView{"review-1": {LastRequest: &subagents.RequestFact{Provider: "openai-codex", Model: "gpt-5.6", Attempt: 2, MaxAttempts: 3, Outcome: "failed", ErrorCode: "deadline_exceeded"}}}
66- })
67- d.viewing = true
68- joined := strings.Join(d.Render(tui.Theme{}, 120), "\n")
69- if !strings.Contains(joined, "provider request: failed · openai-codex / gpt-5.6 · attempt 2/3 · deadline_exceeded") {
70- t.Fatalf("diagnostic missing:\n%s", joined)
71- }
72-}
73-
74-func TestSubagentsDialogEmptyState(t *testing.T) {
75- d := newSubagentsDialog()
76- d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")
77- lines := d.Render(tui.Theme{}, 80)
78- joined := strings.Join(lines, "\n")
79- if !strings.Contains(joined, "no agents") {
80- t.Fatalf("empty state missing hint:\n%s", joined)
81- }
82- if !strings.Contains(joined, "press n") {
83- t.Fatalf("empty state should advertise n shortcut:\n%s", joined)
84- }
85-}
86-
87-func TestSubagentsDialogRendersRows(t *testing.T) {
88- now := time.Now()
89- rows := []subagents.AgentSnapshot{
90- {ID: "alpha-1", Task: "fix login", Status: subagents.StatusRunning, Started: now},
91- {ID: "beta-2", Task: "write tests", Status: subagents.StatusDone, Started: now},
92- }
93- d := newSubagentsDialog()
94- d.SetTraceViews(func() map[string]subagents.AgentTraceView {
95- return map[string]subagents.AgentTraceView{
96- "alpha-1": {OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: now}}},
97- "beta-2": {Terminal: "completed"},
98- }
99- })
100- d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
101- out := strings.Join(d.Render(tui.Theme{}, 100), "\n")
102- for _, want := range []string{"alpha-1", "beta-2", "running tool", "completed", "OPERATION", "TASK"} {
103- if !strings.Contains(out, want) {
104- t.Fatalf("render missing %q:\n%s", want, out)
105- }
106- }
107-}
108-
109-func TestSubagentsDialogStopAndRemoveCallbacks(t *testing.T) {
110- now := time.Now()
111- rows := []subagents.AgentSnapshot{
112- {ID: "alpha-1", Status: subagents.StatusRunning, Started: now},
113- }
114- stops := 0
115- removes := 0
116- d := newSubagentsDialog()
117- d.Open(
118- staticSnapshots(rows...),
119- func(id string) error { stops++; return nil },
120- func(id string) error { removes++; return nil },
121- nil,
122- nil,
123- nil,
124- "",
125- )
126- // Render once so cursor + rows are populated.
127- _ = d.Render(tui.Theme{}, 80)
128-
129- _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'k'})
130- if stops != 1 {
131- t.Fatalf("stop calls = %d", stops)
132- }
133- if !strings.Contains(msg, "alpha-1") || errMsg != "" {
134- t.Fatalf("stop status (%q, %q)", msg, errMsg)
135- }
136-
137- _, msg, errMsg = d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'r'})
138- if removes != 1 {
139- t.Fatalf("remove calls = %d", removes)
140- }
141- if !strings.Contains(msg, "alpha-1") || errMsg != "" {
142- t.Fatalf("remove status (%q, %q)", msg, errMsg)
143- }
144-}
145-
146-func TestSubagentsDialogEnterShowsTranscript(t *testing.T) {
147- now := time.Now()
148- rows := []subagents.AgentSnapshot{
149- {ID: "alpha-1", Task: "do stuff", Status: subagents.StatusDone, Tail: "line a\nline b", Started: now},
150- }
151- d := newSubagentsDialog()
152- d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
153- _ = d.Render(tui.Theme{}, 80)
154- d.HandleKey(tui.Key{Kind: tui.KeyEnter})
155- out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
156- for _, want := range []string{"task:", "dir:", "line a", "line b"} {
157- if !strings.Contains(out, want) {
158- t.Fatalf("transcript view missing %q:\n%s", want, out)
159- }
160- }
161- // Esc should bring us back to the list view.
162- d.HandleKey(tui.Key{Kind: tui.KeyEsc})
163- out = strings.Join(d.Render(tui.Theme{}, 80), "\n")
164- if !strings.Contains(out, "OPERATION") {
165- t.Fatalf("did not return to list view:\n%s", out)
166- }
167-}
168-
169-func TestSubagentsDialogEnterHydratesTranscript(t *testing.T) {
170- now := time.Now()
171- hydrated := false
172- loads := 0
173- snapshot := func() []subagents.AgentSnapshot {
174- row := subagents.AgentSnapshot{ID: "alpha-1", Task: "do stuff", Status: subagents.StatusDone, Started: now}
175- if hydrated {
176- row.Lines = []string{"loaded history"}
177- }
178- return []subagents.AgentSnapshot{row}
179- }
180- d := newSubagentsDialog()
181- d.SetLoadTranscript(func(id string) error {
182- if id != "alpha-1" {
183- t.Fatalf("load id = %q", id)
184- }
185- loads++
186- hydrated = true
187- return nil
188- })
189- d.Open(snapshot, nil, nil, nil, nil, nil, "")
190- _ = d.Render(tui.Theme{}, 80)
191- d.HandleKey(tui.Key{Kind: tui.KeyEnter})
192- if loads != 1 {
193- t.Fatalf("transcript loads = %d, want 1", loads)
194- }
195- if out := strings.Join(d.Render(tui.Theme{}, 80), "\n"); !strings.Contains(out, "loaded history") {
196- t.Fatalf("hydrated transcript missing from view:\n%s", out)
197- }
198-}
199-
200-func TestSubagentsDialogEscClosesList(t *testing.T) {
201- d := newSubagentsDialog()
202- d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")
203- closed, _, _ := d.HandleKey(tui.Key{Kind: tui.KeyEsc})
204- if !closed {
205- t.Fatal("esc did not close")
206- }
207- if d.Active() {
208- t.Fatal("still active after esc")
209- }
210-}
211-
212-// TestSubagentsDialogSpawnFlow drives the inline n-prompt end-to-end:
213-// press n, type a task, press enter, observe the spawn callback
214-// fires with the right text and the prompt closes.
215-func TestSubagentsDialogSpawnFlow(t *testing.T) {
216- var spawned string
217- d := newSubagentsDialog()
218- d.Open(
219- staticSnapshots(),
220- nil, nil,
221- func(task, _, _ string) error { spawned = task; return nil },
222- nil,
223- nil,
224- "",
225- )
226- _ = d.Render(tui.Theme{}, 80)
227-
228- // Press n to open the spawn editor directly. The model is
229- // taken from whatever the host set via SetCurrentModel; this
230- // test doesn't pin one.
231- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
232- if !d.spawning {
233- t.Fatal("n did not enter spawning mode")
234- }
235- out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
236- if !strings.Contains(out, "enter spawn") || !strings.Contains(out, "@ file/dir picker") {
237- t.Fatalf("spawn editor hint not rendered:\n%s", out)
238- }
239-
240- // Type "fix x".
241- for _, r := range "fix x" {
242- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
243- }
244- // Submit.
245- _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
246- if errMsg != "" {
247- t.Fatalf("unexpected err: %s", errMsg)
248- }
249- if msg != "spawned" {
250- t.Fatalf("status msg = %q", msg)
251- }
252- if spawned != "fix x" {
253- t.Fatalf("spawn callback got %q", spawned)
254- }
255- if d.spawning {
256- t.Fatal("prompt did not close after enter")
257- }
258-}
259-
260-func TestSubagentsDialogSpawnFilePickerFlow(t *testing.T) {
261- cwd := t.TempDir()
262- if err := os.WriteFile(filepath.Join(cwd, "README.md"), []byte("hi"), 0o644); err != nil {
263- t.Fatal(err)
264- }
265- var spawned string
266- d := newSubagentsDialog()
267- d.Open(staticSnapshots(), nil, nil, func(task, _, _ string) error { spawned = task; return nil }, nil, nil, cwd)
268- _ = d.Render(tui.Theme{}, 80)
269-
270- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
271- for _, r := range "summarize @READ" {
272- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
273- }
274- out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
275- if !strings.Contains(out, "README.md") {
276- t.Fatalf("@ file picker did not render match:\n%s", out)
277- }
278-
279- // Enter while the file picker is active selects the highlighted file,
280- // inserting the compact [file:] chip instead of submitting yet.
281- d.HandleKey(tui.Key{Kind: tui.KeyEnter})
282- if d.newTaskEd == nil || !strings.Contains(d.newTaskEd.Value(), "[file:README.md]") {
283- t.Fatalf("file picker did not insert chip; editor=%v", d.newTaskEd)
284- }
285-
286- // A second Enter submits. The chip should expand to the absolute
287- // path before the task reaches Supervisor.Spawn.
288- _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
289- if errMsg != "" {
290- t.Fatalf("unexpected err: %s", errMsg)
291- }
292- if msg != "spawned" {
293- t.Fatalf("status msg = %q", msg)
294- }
295- wantPath := filepath.Join(cwd, "README.md")
296- if !strings.Contains(spawned, wantPath) {
297- t.Fatalf("spawned task %q missing expanded path %q", spawned, wantPath)
298- }
299-}
300-
301-func TestSubagentsDialogSpawnEscCancels(t *testing.T) {
302- called := false
303- d := newSubagentsDialog()
304- d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { called = true; return nil }, nil, nil, "")
305- _ = d.Render(tui.Theme{}, 80)
306-
307- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
308- for _, r := range "abc" {
309- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
310- }
311- d.HandleKey(tui.Key{Kind: tui.KeyEsc})
312- if called {
313- t.Fatal("spawn callback ran despite esc")
314- }
315- if d.spawning {
316- t.Fatal("prompt still open after esc")
317- }
318- if d.Active() == false {
319- t.Fatal("esc on spawn prompt should not close the whole dialog")
320- }
321-}
322-
323-func TestSubagentsDialogNoNWithoutSpawnCallback(t *testing.T) {
324- d := newSubagentsDialog()
325- d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") // spawn callback nil
326- _ = d.Render(tui.Theme{}, 80)
327- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
328- if d.spawning {
329- t.Fatal("n opened spawn prompt without a spawn callback wired")
330- }
331-}
332-
333-// TestSubagentsDialogFullTranscriptRendersEverything pins the /btw-style
334-// rendering: the dialog NEVER clips the transcript into a fixed
335-// window with internal scroll. Every line goes into the rendered
336-// output and the host's outer scrollback owns overflow. This is
337-// what makes the transcript grow with the window and with the
338-// content, instead of cutting off at row 16 with a "↑ N more above"
339-// indicator.
340-func TestSubagentsDialogFullTranscriptRendersEverything(t *testing.T) {
341- lines := make([]string, 60)
342- for i := range lines {
343- lines[i] = fmt.Sprintf("line %d", i)
344- }
345- rows := []subagents.AgentSnapshot{{
346- ID: "alpha-1", Task: "x", Status: subagents.StatusDone,
347- Lines: lines, Started: time.Now(),
348- }}
349- d := newSubagentsDialog()
350- d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
351- _ = d.Render(tui.Theme{}, 80)
352- d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open viewer
353-
354- out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
355- // First line must be present (no internal clipping from the top).
356- if !strings.Contains(out, "line 0") {
357- t.Errorf("first transcript line missing — the view appears to be clipping:\n%s", out)
358- }
359- // Last line must be present (no internal clipping from the bottom).
360- if !strings.Contains(out, fmt.Sprintf("line %d", len(lines)-1)) {
361- t.Errorf("last transcript line missing — the view appears to be clipping:\n%s", out)
362- }
363- // The old "↑ N more above" / "↓ N more below" cliff markers
364- // must NOT appear; those belonged to the old viewport model.
365- if strings.Contains(out, "more above") || strings.Contains(out, "more below") {
366- t.Errorf("transcript still uses internal scroll markers:\n%s", out)
367- }
368-
369- // Esc returns to the list view.
370- d.HandleKey(tui.Key{Kind: tui.KeyEsc})
371- if d.viewing {
372- t.Fatal("esc did not exit viewer")
373- }
374-}
375-
376-// TestSubagentsDialogOpenViewingJumpsToAgent regression-tests /subagents logs
377-// <id>: OpenViewing should select the row whose id matches the
378-// (possibly truncated) argument and enter the transcript view
379-// directly without showing the list first.
380-func TestSubagentsDialogOpenViewingJumpsToAgent(t *testing.T) {
381- rows := []subagents.AgentSnapshot{
382- {ID: "alpha-1", Lines: []string{"hi from alpha"}, Started: time.Now()},
383- {ID: "beta-2", Lines: []string{"hi from beta"}, Started: time.Now()},
384- }
385- d := newSubagentsDialog()
386- ok := d.OpenViewing("beta", staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
387- if !ok {
388- t.Fatal("prefix match did not resolve")
389- }
390- if !d.viewing || d.cursor != 1 {
391- t.Fatalf("viewing=%v cursor=%d", d.viewing, d.cursor)
392- }
393- out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
394- if !strings.Contains(out, "hi from beta") {
395- t.Fatalf("wrong agent shown:\n%s", out)
396- }
397-}
398-
399-func TestSubagentsDialogOpenViewingUnknownIDReturnsFalse(t *testing.T) {
400- d := newSubagentsDialog()
401- ok := d.OpenViewing("nope", staticSnapshots(), nil, nil, nil, nil, nil, "")
402- if ok {
403- t.Fatal("expected miss for unknown id")
404- }
405-}
406-
407-// TestSubagentsDialogPromptFlowFromList drives the inline p-prompt:
408-// press p on the selected agent, type a message, press enter,
409-// and assert the send callback fired with the right id+text.
410-func TestSubagentsDialogPromptFlowFromList(t *testing.T) {
411- rows := []subagents.AgentSnapshot{
412- {ID: "alpha-1", Status: subagents.StatusRunning, Started: time.Now()},
413- }
414- var gotID, gotText string
415- sends := 0
416- d := newSubagentsDialog()
417- d.Open(
418- staticSnapshots(rows...),
419- nil, nil, nil,
420- func(id, text string) error { sends++; gotID = id; gotText = text; return nil },
421- nil,
422- "",
423- )
424- _ = d.Render(tui.Theme{}, 80)
425-
426- // Press 'p' to open the inline prompt for alpha-1.
427- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
428- if !d.prompting {
429- t.Fatal("p did not enter prompting mode")
430- }
431- if d.promptTargetID != "alpha-1" {
432- t.Fatalf("prompt target = %q; want alpha-1", d.promptTargetID)
433- }
434- out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
435- if !strings.Contains(out, "send to alpha-1:") || !strings.Contains(out, "enter send") {
436- t.Fatalf("prompt editor hint not rendered:\n%s", out)
437- }
438-
439- // Type "do the thing" and submit.
440- for _, r := range "do the thing" {
441- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
442- }
443- _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
444- if errMsg != "" {
445- t.Fatalf("unexpected err: %s", errMsg)
446- }
447- if msg != "sent to alpha-1" {
448- t.Fatalf("status msg = %q", msg)
449- }
450- if sends != 1 || gotID != "alpha-1" || gotText != "do the thing" {
451- t.Fatalf("send callback got id=%q text=%q (calls=%d)", gotID, gotText, sends)
452- }
453- if d.prompting {
454- t.Fatal("prompt did not close after enter")
455- }
456-}
457-
458-// TestSubagentsDialogPromptFromTranscriptView pins the always-on editor
459-// behaviour for the transcript view: entering an agent immediately
460-// auto-opens the inline composer (no `p` needed), typing flows
461-// straight into it, Enter sends, and the metadata stays visible.
462-// The transcript view is /btw-style now — you never leave the
463-// view to send a follow-up.
464-func TestSubagentsDialogPromptFromTranscriptView(t *testing.T) {
465- rows := []subagents.AgentSnapshot{
466- {ID: "beta-2", Task: "do thing", Status: subagents.StatusRunning, Started: time.Now(), Tail: "hi from beta"},
467- }
468- var gotID, gotText string
469- d := newSubagentsDialog()
470- d.Open(
471- staticSnapshots(rows...),
472- nil, nil, nil,
473- func(id, text string) error { gotID = id; gotText = text; return nil },
474- nil,
475- "",
476- )
477- // Enter the transcript view; for a running agent the editor
478- // must auto-open right away.
479- _ = d.Render(tui.Theme{}, 80)
480- d.HandleKey(tui.Key{Kind: tui.KeyEnter})
481- if !d.viewing {
482- t.Fatal("enter did not open transcript view")
483- }
484- if !d.prompting {
485- t.Fatal("transcript view did not auto-open inline editor for running agent")
486- }
487- out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
488- if !strings.Contains(out, "task: do thing") {
489- t.Fatalf("transcript metadata not visible:\n%s", out)
490- }
491- if !strings.Contains(out, "subagent: beta-2") {
492- t.Fatalf("frame header missing agent id:\n%s", out)
493- }
494-
495- // Typing flows straight into the editor — no `p` modal step.
496- for _, r := range "keep going" {
497- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
498- }
499- _, msg, _ := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
500- if msg != "sent to beta-2" || gotID != "beta-2" || gotText != "keep going" {
501- t.Fatalf("send didn't fire correctly: msg=%q id=%q text=%q", msg, gotID, gotText)
502- }
503-
504- // Editor must stay mounted after submit (/btw-style), with an
505- // empty buffer ready for the next message.
506- if !d.prompting {
507- t.Error("editor closed after submit; /btw-style transcript view should keep it mounted")
508- }
509- if !d.viewing {
510- t.Error("transcript view collapsed after submit; should stay open")
511- }
512- if d.promptEd != nil && d.promptEd.Value() != "" {
513- t.Errorf("editor buffer not cleared after submit: %q", d.promptEd.Value())
514- }
515-}
516-
517-// TestSubagentsDialogPromptEscCancels makes sure Esc inside the prompt
518-// editor aborts without invoking the send callback.
519-func TestSubagentsDialogPromptEscCancels(t *testing.T) {
520- rows := []subagents.AgentSnapshot{{ID: "gamma-3", Status: subagents.StatusRunning, Started: time.Now()}}
521- sends := 0
522- d := newSubagentsDialog()
523- d.Open(
524- staticSnapshots(rows...),
525- nil, nil, nil,
526- func(id, text string) error { sends++; return nil },
527- nil,
528- "",
529- )
530- _ = d.Render(tui.Theme{}, 80)
531- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
532- for _, r := range "oops" {
533- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
534- }
535- d.HandleKey(tui.Key{Kind: tui.KeyEsc})
536- if d.prompting {
537- t.Fatal("esc did not close prompt editor")
538- }
539- if sends != 0 {
540- t.Fatalf("send fired despite esc: calls=%d", sends)
541- }
542-}
543-
544-// TestSubagentsDialogPWithoutSendCallback verifies that pressing 'p'
545-// when no send callback is wired is a silent no-op (mirrors the
546-// existing 'n' / no-spawn-callback contract).
547-func TestSubagentsDialogPWithoutSendCallback(t *testing.T) {
548- rows := []subagents.AgentSnapshot{{ID: "delta-4", Status: subagents.StatusRunning, Started: time.Now()}}
549- d := newSubagentsDialog()
550- d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
551- _ = d.Render(tui.Theme{}, 80)
552- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
553- if d.prompting {
554- t.Fatal("p opened prompt editor without a send callback")
555- }
556-}
557-
558-// TestOpenForResumeParksCursorOnFirstResumable verifies the picker
559-// entry point: with a mix of running and detached agents, the
560-// dialog opens with the cursor on the first detached row (skipping
561-// any leading running ones) so the user can press R immediately.
562-func TestOpenForResumeParksCursorOnFirstResumable(t *testing.T) {
563- rows := []subagents.AgentSnapshot{
564- {ID: "live-1", Status: subagents.StatusRunning, Started: time.Now()},
565- {ID: "live-2", Status: subagents.StatusRunning, Started: time.Now()},
566- {ID: "detached-1", Status: subagents.StatusDetached, Started: time.Now()},
567- {ID: "detached-2", Status: subagents.StatusKilled, Started: time.Now()},
568- }
569- d := newSubagentsDialog()
570- count := d.OpenForResume(
571- staticSnapshots(rows...),
572- nil, nil, nil, nil,
573- func(id string) error { return nil },
574- "",
575- )
576- if count != 2 {
577- t.Fatalf("resumable count = %d; want 2", count)
578- }
579- if d.cursor != 2 {
580- t.Fatalf("cursor = %d; want 2 (first resumable row)", d.cursor)
581- }
582- if sel := d.selected(); sel == nil || sel.ID != "detached-1" {
583- t.Fatalf("selected = %+v; want detached-1", sel)
584- }
585-}
586-
587-// TestOpenForResumeWithNoResumableLeavesDialogActive ensures the
588-// picker still opens the dashboard when nothing is resumable, so the
589-// user sees what's there instead of getting silently nothing. The
590-// slash handler is responsible for surfacing the "no resumable
591-// agents" status string — the dialog itself just opens.
592-func TestOpenForResumeWithNoResumableLeavesDialogActive(t *testing.T) {
593- rows := []subagents.AgentSnapshot{
594- {ID: "live-1", Status: subagents.StatusRunning, Started: time.Now()},
595- }
596- d := newSubagentsDialog()
597- count := d.OpenForResume(
598- staticSnapshots(rows...),
599- nil, nil, nil, nil,
600- func(id string) error { return nil },
601- "",
602- )
603- if count != 0 {
604- t.Fatalf("resumable count = %d; want 0", count)
605- }
606- if !d.Active() {
607- t.Fatal("dialog should remain active so the user sees the empty state")
608- }
609- if d.cursor != 0 {
610- t.Fatalf("cursor = %d; want 0 (no parking)", d.cursor)
611- }
612-}
613-
614-// TestOpenForResumeREnterTriggersResume drives the full picker UX:
615-// /subagents resume → dialog opens parked on a resumable row → user
616-// presses R → the resume callback fires for that exact id.
617-func TestOpenForResumeREnterTriggersResume(t *testing.T) {
618- rows := []subagents.AgentSnapshot{
619- {ID: "live-1", Status: subagents.StatusRunning, Started: time.Now()},
620- {ID: "detached-1", Status: subagents.StatusDetached, Started: time.Now()},
621- }
622- var resumed string
623- d := newSubagentsDialog()
624- d.OpenForResume(
625- staticSnapshots(rows...),
626- nil, nil, nil, nil,
627- func(id string) error { resumed = id; return nil },
628- "",
629- )
630- _ = d.Render(tui.Theme{}, 80)
631- _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'R'})
632- if errMsg != "" {
633- t.Fatalf("unexpected err: %s", errMsg)
634- }
635- if resumed != "detached-1" {
636- t.Fatalf("resume callback got %q; want detached-1", resumed)
637- }
638- if msg != "resumed detached-1" {
639- t.Fatalf("status msg = %q", msg)
640- }
641-}
642-
643-// TestNeedsTickRefresh enumerates the four dialog states so the host's
644-// animation tick wakes the renderer for live updates but stays out of
645-// the way of the inline editors (which need the cursor blink).
646-func TestNeedsTickRefresh(t *testing.T) {
647- // Closed.
648- d := newSubagentsDialog()
649- if d.NeedsTickRefresh() {
650- t.Fatal("closed dialog should not request tick refresh")
651- }
652-
653- // List view (open, no editors): tick should refresh so rows update.
654- d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")
655- if !d.NeedsTickRefresh() {
656- t.Fatal("open list view should request tick refresh")
657- }
658-
659- // Transcript view: still background-driven; tick should refresh.
660- rows := []subagents.AgentSnapshot{{ID: "a", Status: subagents.StatusRunning, Started: time.Now()}}
661- d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
662- _ = d.Render(tui.Theme{}, 80)
663- d.HandleKey(tui.Key{Kind: tui.KeyEnter})
664- if !d.viewing {
665- t.Fatal("setup: enter did not open transcript view")
666- }
667- if !d.NeedsTickRefresh() {
668- t.Fatal("transcript view should request tick refresh")
669- }
670-
671- // Spawn editor open: tick should NOT refresh (cursor blink).
672- d = newSubagentsDialog()
673- d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { return nil }, nil, nil, "")
674- _ = d.Render(tui.Theme{}, 80)
675- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
676- if !d.spawning {
677- t.Fatal("setup: n did not enter spawning mode")
678- }
679- if d.NeedsTickRefresh() {
680- t.Fatal("spawn editor should suppress tick refresh")
681- }
682-
683- // Prompt editor open: same suppression.
684- d = newSubagentsDialog()
685- rows = []subagents.AgentSnapshot{{ID: "a", Status: subagents.StatusRunning, Started: time.Now()}}
686- d.Open(staticSnapshots(rows...), nil, nil, nil, func(string, string) error { return nil }, nil, "")
687- _ = d.Render(tui.Theme{}, 80)
688- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
689- if !d.prompting {
690- t.Fatal("setup: p did not enter prompting mode")
691- }
692- if d.NeedsTickRefresh() {
693- t.Fatal("prompt editor should suppress tick refresh")
694- }
695-}
696-
697-// TestCursorPosPromptEditor verifies the dialog reports a real
698-// caret row for the prompt editor in both list-view and
699-// transcript-view contexts. Without this, the terminal cursor
700-// stayed at the (now-hidden) main editor row when the user
701-// pressed 'p', so the visible caret looked wrong.
702-func TestCursorPosPromptEditor(t *testing.T) {
703- rows := []subagents.AgentSnapshot{
704- {ID: "a", Task: "t", Status: subagents.StatusRunning, Started: time.Now()},
705- }
706-
707- // From the list view.
708- d := newSubagentsDialog()
709- d.Open(staticSnapshots(rows...), nil, nil, nil,
710- func(string, string) error { return nil }, nil, "")
711- _ = d.Render(tui.Theme{}, 80)
712- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
713- if !d.prompting {
714- t.Fatal("setup: p did not enter prompting from list view")
715- }
716- row, _ := d.CursorPos(80)
717- if row < 1 {
718- t.Fatalf("list-view prompt CursorPos row = %d; want >= 1", row)
719- }
720-
721- // From the transcript view (deeper offset due to metadata block).
722- d = newSubagentsDialog()
723- d.Open(staticSnapshots(rows...), nil, nil, nil,
724- func(string, string) error { return nil }, nil, "")
725- _ = d.Render(tui.Theme{}, 80)
726- d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open transcript
727- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
728- if !d.prompting || !d.viewing {
729- t.Fatalf("setup: prompting=%v viewing=%v", d.prompting, d.viewing)
730- }
731- rowT, _ := d.CursorPos(80)
732- if rowT <= row {
733- t.Fatalf("transcript-view prompt row = %d; should be deeper than list row %d", rowT, row)
734- }
735-}
736-
737-func TestCursorPosSpawnEditorWithInheritedModelMatchesPaddedRender(t *testing.T) {
738- d := newSubagentsDialog()
739- d.SetCurrentModel("claude-sonnet-4-5", "anthropic")
740- d.Open(staticSnapshots(), nil, nil, func(task, model, provider string) error { return nil }, nil, nil, "")
741- _ = d.Render(tui.Theme{}, 80)
742- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
743- if !d.spawning {
744- t.Fatal("setup: n did not enter spawning mode")
745- }
746-
747- row, _ := d.CursorPos(80)
748- lines := padDialogFrame(d.Render(tui.Theme{}, 80))
749- if row < 0 || row >= len(lines) {
750- t.Fatalf("spawn CursorPos row = %d outside rendered lines %d", row, len(lines))
751- }
752- if got := stripANSIBytes(lines[row]); !strings.Contains(got, "▌") {
753- t.Fatalf("spawn CursorPos row %d = %q; want editor prompt row", row, got)
754- }
755-}
756-
757-// TestSubagentsDialogPOnNonRunningAgentShowsHint covers the regression
758-// caught by the user's screenshot: pressing 'p' on a detached agent
759-// dialled a dead unix socket and printed the raw path as an error.
760-// We now block the prompt editor for any non-running status and
761-// surface a clear status-bar hint instead.
762-func TestSubagentsDialogPOnNonRunningAgentShowsHint(t *testing.T) {
763- for _, st := range []subagents.Status{
764- subagents.StatusDetached, subagents.StatusDone, subagents.StatusFailed, subagents.StatusKilled,
765- } {
766- t.Run(string(st), func(t *testing.T) {
767- rows := []subagents.AgentSnapshot{{ID: "a", Status: st, Started: time.Now()}}
768- sends := 0
769- d := newSubagentsDialog()
770- d.Open(staticSnapshots(rows...), nil, nil, nil,
771- func(string, string) error { sends++; return nil }, nil, "")
772- _ = d.Render(tui.Theme{}, 80)
773-
774- _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
775- if d.prompting {
776- t.Fatal("prompt editor opened on non-running agent")
777- }
778- if sends != 0 {
779- t.Fatal("send callback fired without an open editor")
780- }
781- if msg != "" {
782- t.Errorf("unexpected ok msg: %q", msg)
783- }
784- if !strings.Contains(errMsg, string(st)) {
785- t.Errorf("hint = %q; should mention status %q", errMsg, st)
786- }
787- if !strings.Contains(errMsg, "resume") && st != subagents.StatusPending && st != subagents.StatusRunning {
788- t.Errorf("hint = %q; should mention resume for resumable state", errMsg)
789- }
790- })
791- }
792-}
793-
794-// TestSubagentsDialogPOnRunningAgentOpensEditor is the positive
795-// complement of the previous test: running agents still open the
796-// editor normally.
797-func TestSubagentsDialogPOnRunningAgentOpensEditor(t *testing.T) {
798- rows := []subagents.AgentSnapshot{{ID: "a", Status: subagents.StatusRunning, Started: time.Now()}}
799- d := newSubagentsDialog()
800- d.Open(staticSnapshots(rows...), nil, nil, nil,
801- func(string, string) error { return nil }, nil, "")
802- _ = d.Render(tui.Theme{}, 80)
803- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
804- if !d.prompting {
805- t.Fatal("prompt editor did not open on running agent")
806- }
807-}
808-
809-// TestFriendlySendErrRewritesNotReady asserts ErrNotReady becomes the
810-// user-friendly "isn't accepting input" hint, and unknown errors
811-// fall through verbatim.
812-func TestFriendlySendErrRewritesNotReady(t *testing.T) {
813- hint := friendlySendErr("alpha-1", subagents.ErrNotReady)
814- if !strings.Contains(hint, "alpha-1") || !strings.Contains(hint, "resume") {
815- t.Errorf("friendlySendErr(ErrNotReady) = %q", hint)
816- }
817- if strings.Contains(hint, "subagent:") {
818- t.Errorf("friendlySendErr leaked raw error text: %q", hint)
819- }
820-
821- other := errors.New("some other failure")
822- got := friendlySendErr("alpha-1", other)
823- if got != "send: some other failure" {
824- t.Errorf("friendlySendErr passthrough = %q", got)
825- }
826-}
827-
828-// TestRenderSupervisorTranscriptBlocks_GroupsByRole asserts the flat
829-// transcript log is regrouped into user / assistant / stderr /
830-// error blocks before rendering. Each output row should be styled
831-// for its role rather than printed verbatim.
832-func TestRenderSupervisorTranscriptBlocks_GroupsByRole(t *testing.T) {
833- lines := []string{
834- "user: hello",
835- "hi there",
836- "second assistant line",
837- "stderr: warning thing",
838- "error: boom",
839- }
840- out := renderSupervisorTranscriptBlocks(lines, tui.Theme{}, 80, false)
841- joined := strings.Join(out, "\n")
842-
843- // The user echo should appear as a bubble row, which contains
844- // the "▌ " left-bar glyph.
845- if !strings.Contains(joined, "▌ ") {
846- t.Errorf("user echo did not render as a bubble row:\n%s", joined)
847- }
848- // The user prefix should be stripped from the rendered output.
849- if strings.Contains(joined, "user: hello") {
850- t.Errorf("user prefix leaked into rendered output")
851- }
852- // Assistant content survives.
853- if !strings.Contains(joined, "hi there") {
854- t.Errorf("assistant text missing from rendered output")
855- }
856- // Stderr line is prefixed with stderr marker (we keep "stderr"
857- // word in the rendered output for clarity).
858- if !strings.Contains(joined, "stderr") || !strings.Contains(joined, "warning thing") {
859- t.Errorf("stderr block missing:\n%s", joined)
860- }
861- // Error line gets the ✖ marker.
862- if !strings.Contains(joined, "✖ boom") {
863- t.Errorf("error block missing ✖ marker:\n%s", joined)
864- }
865-}
866-
867-// TestRenderSupervisorTranscriptBlocks_MarkdownStyling proves the
868-// assistant text goes through tui.RenderMarkdown: a literal **bold**
869-// pair should become an ANSI bold sequence and bullet "-" should
870-// become "•".
871-func TestRenderSupervisorTranscriptBlocks_MarkdownStyling(t *testing.T) {
872- lines := []string{
873- "Here is a list:",
874- "- **bold** item",
875- "- plain item",
876- }
877- out := renderSupervisorTranscriptBlocks(lines, tui.Theme{}, 80, false)
878- joined := strings.Join(out, "\n")
879-
880- // ANSI bold on/off. Markdown rendering uses CSI 1 / 22.
881- if !strings.Contains(joined, "\x1b[1m") || !strings.Contains(joined, "\x1b[22m") {
882- t.Errorf("expected ANSI bold sequences:\n%q", joined)
883- }
884- // Bullet glyph upgrade.
885- if !strings.Contains(joined, "• ") {
886- t.Errorf("expected • bullet:\n%s", joined)
887- }
888- // Literal "**bold**" markdown source must NOT leak through.
889- if strings.Contains(joined, "**bold**") {
890- t.Errorf("unrendered markdown leaked through:\n%s", joined)
891- }
892-}
893-
894-// TestRenderSupervisorTranscriptBlocks_GroupsAdjacentAssistantLines
895-// guards the contract that consecutive assistant lines form one
896-// markdown block. If we render each line in isolation, fenced code
897-// (and other multi-line constructs) would never close.
898-func TestRenderSupervisorTranscriptBlocks_GroupsAdjacentAssistantLines(t *testing.T) {
899- lines := []string{
900- "Some prose.",
901- "```go",
902- "package main",
903- "",
904- "func main() {}",
905- "```",
906- "Done.",
907- }
908- out := renderSupervisorTranscriptBlocks(lines, tui.Theme{}, 80, false)
909- joined := strings.Join(out, "\n")
910-
911- // The literal fence markers should be consumed by the markdown
912- // renderer; if we rendered each line on its own the ``` would
913- // leak as text.
914- if strings.Contains(joined, "```") {
915- t.Errorf("fence markers leaked — lines weren't grouped:\n%s", joined)
916- }
917- // The code content should still be in the output — strip ANSI
918- // before checking because syntax highlighting wraps each token.
919- plain := stripANSIBytes(joined)
920- if !strings.Contains(plain, "package main") || !strings.Contains(plain, "func main") {
921- t.Errorf("code block content missing:\nansi=%q\nplain=%q", joined, plain)
922- }
923-}
924-
925-// TestSubagentsDialogKOnNonRunningAgentShowsHint mirrors the prompt-
926-// disabled test: pressing 'k' on an already-terminal or detached
927-// agent now surfaces a hint instead of routing to Supervisor.Stop, which
928-// is a no-op for those states (and used to segfault for detached
929-// agents before the cancel-nil guard).
930-func TestSubagentsDialogKOnNonRunningAgentShowsHint(t *testing.T) {
931- for _, st := range []subagents.Status{
932- subagents.StatusDetached, subagents.StatusDone, subagents.StatusFailed, subagents.StatusKilled,
933- } {
934- t.Run(string(st), func(t *testing.T) {
935- rows := []subagents.AgentSnapshot{{ID: "a", Status: st, Started: time.Now()}}
936- stops := 0
937- d := newSubagentsDialog()
938- d.Open(staticSnapshots(rows...),
939- func(string) error { stops++; return nil },
940- nil, nil, nil, nil, "")
941- _ = d.Render(tui.Theme{}, 80)
942- _, _, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'k'})
943- if stops != 0 {
944- t.Fatalf("stop callback fired on %s agent", st)
945- }
946- if !strings.Contains(errMsg, string(st)) || !strings.Contains(errMsg, "remove") {
947- t.Errorf("hint = %q; should mention %q and suggest remove", errMsg, st)
948- }
949- })
950- }
951-}
952-
953-// TestSubagentsDialogSpawnFlow_CurrentModelInherited proves the spawn
954-// callback receives whatever model the host pinned via
955-// SetCurrentModel before opening the dialog. This is how /model is
956-// surfaced to subagent agents: the user picks via /model first, then
957-// /subagents n, and the new agent inherits.
958-func TestSubagentsDialogSpawnFlow_CurrentModelInherited(t *testing.T) {
959- var gotTask, gotModel, gotProv string
960- d := newSubagentsDialog()
961- d.SetCurrentModel("claude-sonnet-4-5", "anthropic")
962- d.Open(staticSnapshots(), nil, nil,
963- func(task, model, prov string) error {
964- gotTask, gotModel, gotProv = task, model, prov
965- return nil
966- },
967- nil, nil, "")
968- _ = d.Render(tui.Theme{}, 80)
969-
970- // `n` goes straight to the spawn editor; no picker step.
971- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
972- if !d.spawning {
973- t.Fatal("n did not enter spawning mode")
974- }
975-
976- // The spawn editor advertises the inherited model so the user
977- // knows what they're about to spawn against.
978- out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
979- if !strings.Contains(out, "model: claude-sonnet-4-5") {
980- t.Errorf("spawn editor missing model banner:\n%s", out)
981- }
982-
983- for _, r := range "do thing" {
984- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
985- }
986- _, msg, _ := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
987- if gotTask != "do thing" || gotModel != "claude-sonnet-4-5" || gotProv != "anthropic" {
988- t.Fatalf("spawn args = (%q,%q,%q); want (\"do thing\",\"claude-sonnet-4-5\",\"anthropic\")",
989- gotTask, gotModel, gotProv)
990- }
991- if !strings.Contains(msg, "claude-sonnet-4-5") {
992- t.Errorf("status msg = %q; should mention inherited model", msg)
993- }
994-}
995-
996-// TestSubagentsDialogSpawnEditor_SlashModelOpensPicker proves the in-
997-// editor /model command pops the same model dialog the global
998-// /model uses, that picking a model captures it as the pending
999-// override, and that the spawn editor reopens with any previously-
1000-// typed task text intact so the user doesn't lose work.
1001-func TestSubagentsDialogSpawnEditor_SlashModelOpensPicker(t *testing.T) {
1002- var gotTask, gotModel, gotProv string
1003- d := newSubagentsDialog()
1004- d.SetCurrentModel("old-model", "old-prov")
1005- d.Open(staticSnapshots(), nil, nil,
1006- func(task, model, prov string) error {
1007- gotTask, gotModel, gotProv = task, model, prov
1008- return nil
1009- },
1010- nil, nil, "")
1011- _ = d.Render(tui.Theme{}, 80)
1012-
1013- // Open spawn editor and type some task text plus a /model line.
1014- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
1015- if !d.spawning {
1016- t.Fatal("n did not enter spawning mode")
1017- }
1018- for _, r := range "refactor X" {
1019- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
1020- }
1021- // Newline so the editor has a two-line buffer when we add /model.
1022- d.newTaskEd.SetValue("refactor X\n/model")
1023- d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1024-
1025- if !d.pickingModel {
1026- t.Fatalf("/model submit did not open the picker (spawning=%v picking=%v)", d.spawning, d.pickingModel)
1027- }
1028- if d.spawning {
1029- t.Fatal("spawn editor still marked open while picker is up")
1030- }
1031- if d.spawnDraft != "refactor X" {
1032- t.Fatalf("spawnDraft = %q; want %q (the /model line should be stripped, the task kept)", d.spawnDraft, "refactor X")
1033- }
1034- // The embedded picker has no reasoning control, so H must remain a filter
1035- // character rather than being consumed as a reasoning shortcut.
1036- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'H'})
1037- if d.modelPicker.query != "H" {
1038- t.Fatalf("embedded picker query = %q, want H", d.modelPicker.query)
1039- }
1040-
1041- // Picker render must take over the dialog frame.
1042- out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
1043- if !strings.Contains(out, "select model for next spawn") {
1044- t.Errorf("picker render hint missing:\n%s", out)
1045- }
1046-
1047- // Inject a fake selection into the embedded modelDialog and confirm.
1048- d.modelPicker.all = []provider.Model{{Provider: "acme", ID: "fake-1"}}
1049- d.modelPicker.view = d.modelPicker.all
1050- d.modelPicker.cursor = 0
1051- _, msg, _ := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1052-
1053- if d.pickingModel {
1054- t.Fatal("picker did not close on select")
1055- }
1056- if !d.spawning {
1057- t.Fatal("spawn editor did not reopen after picker select")
1058- }
1059- if d.pendingModel != "fake-1" || d.pendingProvider != "acme" {
1060- t.Errorf("pending = (%q,%q); want (fake-1, acme)", d.pendingModel, d.pendingProvider)
1061- }
1062- if !strings.Contains(msg, "fake-1") {
1063- t.Errorf("status msg = %q; should mention picked model", msg)
1064- }
1065- if got := d.newTaskEd.Value(); got != "refactor X" {
1066- t.Errorf("reopened editor buffer = %q; want preserved task %q", got, "refactor X")
1067- }
1068-
1069- // Now submit the task and confirm the spawn callback gets the
1070- // new model.
1071- d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1072- if gotTask != "refactor X" || gotModel != "fake-1" || gotProv != "acme" {
1073- t.Errorf("spawn args = (%q,%q,%q); want (refactor X, fake-1, acme)", gotTask, gotModel, gotProv)
1074- }
1075-}
1076-
1077-// TestSubagentsDialogSpawnEditor_SlashModelEscRestoresDraft confirms
1078-// pressing Esc inside the picker (no selection) reopens the spawn
1079-// editor with the previously-typed text intact AND leaves the
1080-// pending model untouched, so the user can bail out cleanly.
1081-func TestSubagentsDialogSpawnEditor_SlashModelEscRestoresDraft(t *testing.T) {
1082- d := newSubagentsDialog()
1083- d.SetCurrentModel("keep-me", "keep-prov")
1084- d.Open(staticSnapshots(), nil, nil,
1085- func(string, string, string) error { return nil },
1086- nil, nil, "")
1087- _ = d.Render(tui.Theme{}, 80)
1088-
1089- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
1090- d.newTaskEd.SetValue("task here\n/model")
1091- d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1092- if !d.pickingModel {
1093- t.Fatal("/model did not open the picker")
1094- }
1095-
1096- d.HandleKey(tui.Key{Kind: tui.KeyEsc})
1097- if d.pickingModel {
1098- t.Fatal("esc did not close the picker")
1099- }
1100- if !d.spawning {
1101- t.Fatal("spawn editor did not reopen after picker cancel")
1102- }
1103- if d.pendingModel != "keep-me" || d.pendingProvider != "keep-prov" {
1104- t.Errorf("pending changed on Esc: (%q,%q); want (keep-me, keep-prov)", d.pendingModel, d.pendingProvider)
1105- }
1106- if got := d.newTaskEd.Value(); got != "task here" {
1107- t.Errorf("reopened editor buffer = %q; want %q", got, "task here")
1108- }
1109-}
1110-
1111-// TestStripSlashModelLine pins the helper's behaviour: drop any
1112-// line whose trimmed content is "/model" or starts with "/model ",
1113-// preserve everything else verbatim.
1114-func TestStripSlashModelLine(t *testing.T) {
1115- cases := []struct{ in, want string }{
1116- {"/model", ""},
1117- {" /model ", ""},
1118- {"foo\n/model", "foo"},
1119- {"/model\nfoo", "foo"},
1120- {"foo\n/model\nbar", "foo\nbar"},
1121- {"/model gpt-5", ""},
1122- {"/MODEL GPT-5", ""},
1123- {"a /model in prose", "a /model in prose"}, // not first non-blank token → keep
1124- {"", ""},
1125- {"plain task", "plain task"},
1126- }
1127- for _, c := range cases {
1128- if got := stripSlashModelLine(c.in); got != c.want {
1129- t.Errorf("stripSlashModelLine(%q) = %q; want %q", c.in, got, c.want)
1130- }
1131- }
1132-}
1133-
1134-// TestSubagentsDialogSpawnFlow_NoModelInheritsChildDefault confirms that
1135-// when the host doesn't pin a model (SetCurrentModel never called or
1136-// called with empty strings), the spawn callback receives empty
1137-// model/provider and the child subprocess resolves its own default.
1138-func TestSubagentsDialogSpawnFlow_NoModelInheritsChildDefault(t *testing.T) {
1139- var gotModel, gotProv string
1140- d := newSubagentsDialog()
1141- d.Open(staticSnapshots(), nil, nil,
1142- func(_, model, prov string) error {
1143- gotModel, gotProv = model, prov
1144- return nil
1145- },
1146- nil, nil, "")
1147- _ = d.Render(tui.Theme{}, 80)
1148-
1149- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
1150- for _, r := range "do thing" {
1151- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
1152- }
1153- d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1154- if gotModel != "" || gotProv != "" {
1155- t.Fatalf("spawn args = (model=%q, prov=%q); want both empty", gotModel, gotProv)
1156- }
1157-}
1158-
1159-// TestSupervisorTranscriptGrowsWithNewMessages confirms the /btw-style
1160-// "the transcript grows with the content" property: when the agent
1161-// reply lands in the snapshot, the next render includes it in full,
1162-// alongside everything that was already there. Replaces the old
1163-// followTail/scroll tests that pinned a viewport mechanic we no
1164-// longer have.
1165-func TestSupervisorTranscriptGrowsWithNewMessages(t *testing.T) {
1166- cur := []subagents.AgentSnapshot{{
1167- ID: "agent-1", Task: "x", Status: subagents.StatusRunning,
1168- Lines: []string{"old line 1", "old line 2"}, Started: time.Now(),
1169- }}
1170- snap := func() []subagents.AgentSnapshot { return cur }
1171-
1172- d := newSubagentsDialog()
1173- d.Open(snap, nil, nil, nil,
1174- func(string, string) error { return nil },
1175- nil, "")
1176- _ = d.Render(tui.Theme{}, 80)
1177- d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1178-
1179- out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
1180- if !strings.Contains(out, "old line 1") || !strings.Contains(out, "old line 2") {
1181- t.Fatalf("initial render missing existing lines:\n%s", out)
1182- }
1183-
1184- // Agent appends a fresh reply: the very next render must show
1185- // both the new content AND keep the old content (no clipping).
1186- cur = []subagents.AgentSnapshot{{
1187- ID: "agent-1", Task: "x", Status: subagents.StatusRunning,
1188- Lines: []string{"old line 1", "old line 2", "new reply A", "new reply B"},
1189- Started: cur[0].Started,
1190- }}
1191- out = strings.Join(d.Render(tui.Theme{}, 80), "\n")
1192- for _, want := range []string{"old line 1", "old line 2", "new reply A", "new reply B"} {
1193- if !strings.Contains(out, want) {
1194- t.Errorf("line %q missing after growth:\n%s", want, out)
1195- }
1196- }
1197-}
1198-
1199-// TestSupervisorTranscriptBtwStyleMultipleSends pins the /btw-style flow
1200-// for the transcript view: after one Enter-submit the editor stays
1201-// mounted and clears, and a second message can be composed and sent
1202-// without any modal toggle. Regresses the previous design where
1203-// every send closed the prompt and forced the user to press `p`
1204-// again.
1205-func TestSupervisorTranscriptBtwStyleMultipleSends(t *testing.T) {
1206- rows := []subagents.AgentSnapshot{{
1207- ID: "agent-1", Task: "x", Status: subagents.StatusRunning, Started: time.Now(),
1208- Lines: []string{"warm-up"},
1209- }}
1210- var sent []string
1211- d := newSubagentsDialog()
1212- d.Open(staticSnapshots(rows...), nil, nil, nil,
1213- func(_, text string) error { sent = append(sent, text); return nil },
1214- nil, "")
1215- _ = d.Render(tui.Theme{}, 80)
1216- d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open transcript + auto-editor
1217-
1218- // First message.
1219- for _, r := range "first" {
1220- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
1221- }
1222- d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1223-
1224- // Second message, no `p` toggle in between.
1225- for _, r := range "second" {
1226- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
1227- }
1228- d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1229-
1230- if len(sent) != 2 || sent[0] != "first" || sent[1] != "second" {
1231- t.Fatalf("sent = %v; want [first second]", sent)
1232- }
1233- if !d.viewing || !d.prompting {
1234- t.Errorf("transcript view/editor torn down between sends (viewing=%v prompting=%v)", d.viewing, d.prompting)
1235- }
1236-}
1237-
1238-// TestSupervisorTranscriptEscClosesView pins the Esc semantics for the
1239-// /btw-style transcript view: a single Esc closes the whole view
1240-// (back to the dashboard list), regardless of whether the editor
1241-// is currently composing. Without this routing the editor's own
1242-// Esc handler would just clear the modal and leave the user stuck
1243-// inside an empty transcript view.
1244-func TestSupervisorTranscriptEscClosesView(t *testing.T) {
1245- rows := []subagents.AgentSnapshot{{
1246- ID: "agent-1", Task: "x", Status: subagents.StatusRunning, Started: time.Now(),
1247- Lines: []string{"hi"},
1248- }}
1249- d := newSubagentsDialog()
1250- d.Open(staticSnapshots(rows...), nil, nil, nil,
1251- func(string, string) error { return nil },
1252- nil, "")
1253- _ = d.Render(tui.Theme{}, 80)
1254- d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1255- if !d.viewing || !d.prompting {
1256- t.Fatalf("setup: viewing=%v prompting=%v", d.viewing, d.prompting)
1257- }
1258-
1259- d.HandleKey(tui.Key{Kind: tui.KeyEsc})
1260- if d.viewing {
1261- t.Error("Esc did not close the transcript view")
1262- }
1263- if d.prompting {
1264- t.Error("Esc did not tear down the inline editor")
1265- }
1266-}
1267-
1268-// TestSupervisorTranscriptNonRunningAgentNoEditor pins the safety net:
1269-// transcripts for done / failed / killed agents must NOT auto-open
1270-// an editor (the agent's inbox isn't listening, so a send would
1271-// just bounce). The view shows the transcript plus a hint about
1272-// resuming instead.
1273-func TestSupervisorTranscriptNonRunningAgentNoEditor(t *testing.T) {
1274- rows := []subagents.AgentSnapshot{{
1275- ID: "agent-1", Task: "x", Status: subagents.StatusDone, Started: time.Now(),
1276- Lines: []string{"all done"},
1277- }}
1278- d := newSubagentsDialog()
1279- d.Open(staticSnapshots(rows...), nil, nil, nil,
1280- func(string, string) error { return nil },
1281- nil, "")
1282- _ = d.Render(tui.Theme{}, 80)
1283- d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1284- if d.prompting {
1285- t.Fatal("editor auto-opened for a done agent; should only happen for running ones")
1286- }
1287- out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
1288- if !strings.Contains(out, "prompt: agent is done") {
1289- t.Errorf("hint about non-running status missing:\n%s", out)
1290- }
1291-}
1292-
1293-// TestSupervisorTranscriptTickRefreshWhileEditing pins the streaming
1294-// fix: with the always-on editor in transcript view, the host's
1295-// animation tick MUST still call us so streaming agent output
1296-// renders. Without this, sending a message and watching the reply
1297-// land silently failed because the prompting=true branch was
1298-// suppressing redraws (like the modal editors do).
1299-func TestSupervisorTranscriptTickRefreshWhileEditing(t *testing.T) {
1300- rows := []subagents.AgentSnapshot{{
1301- ID: "agent-1", Task: "x", Status: subagents.StatusRunning,
1302- Activity: "thinking", Started: time.Now(),
1303- Lines: []string{"hello"},
1304- }}
1305- d := newSubagentsDialog()
1306- d.Open(staticSnapshots(rows...), nil, nil, nil,
1307- func(string, string) error { return nil },
1308- nil, "")
1309- _ = d.Render(tui.Theme{}, 80)
1310- d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1311- if !d.viewing || !d.prompting {
1312- t.Fatalf("setup: viewing=%v prompting=%v", d.viewing, d.prompting)
1313- }
1314-
1315- if !d.NeedsTickRefresh() {
1316- t.Fatal("transcript view with always-on editor refused tick refresh; streaming output would freeze")
1317- }
1318-}
1319-
1320-// TestSupervisorTranscriptTickRefreshModalSuppressed pins the inverse:
1321-// the list-view `p` modal (prompting without viewing) still
1322-// suppresses tick like the other modal editors, so the cursor
1323-// blink works cleanly while composing a one-off prompt.
1324-func TestSupervisorTranscriptTickRefreshModalSuppressed(t *testing.T) {
1325- rows := []subagents.AgentSnapshot{{
1326- ID: "agent-1", Status: subagents.StatusRunning, Started: time.Now(),
1327- }}
1328- d := newSubagentsDialog()
1329- d.Open(staticSnapshots(rows...), nil, nil, nil,
1330- func(string, string) error { return nil },
1331- nil, "")
1332- _ = d.Render(tui.Theme{}, 80)
1333- d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
1334- if d.viewing || !d.prompting {
1335- t.Fatalf("setup: viewing=%v prompting=%v; want list-view modal", d.viewing, d.prompting)
1336- }
1337-
1338- if d.NeedsTickRefresh() {
1339- t.Error("list-view prompt modal did not suppress tick refresh; cursor blink may misbehave")
1340- }
1341-}
1342-
1343-// TestSupervisorTranscriptBusySpinnerRenders proves the inline spinner
1344-// appears above the always-on editor while the agent is busy and
1345-// disappears when it goes idle. This is the /btw-style "something
1346-// is happening" feedback the user was missing.
1347-func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) {
1348- cur := []subagents.AgentSnapshot{{
1349- ID: "agent-1", Task: "x", Status: subagents.StatusRunning,
1350- Activity: "thinking", Started: time.Now(),
1351- Lines: []string{"hello"},
1352- }}
1353- snap := func() []subagents.AgentSnapshot { return cur }
1354- d := newSubagentsDialog()
1355- views := map[string]subagents.AgentTraceView{"agent-1": {OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: time.Now()}}}}
1356- d.SetTraceViews(func() map[string]subagents.AgentTraceView { return views })
1357- d.Open(snap, nil, nil, nil,
1358- func(string, string) error { return nil },
1359- nil, "")
1360- _ = d.Render(tui.Theme{}, 80)
1361- d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1362-
1363- out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
1364- if !strings.Contains(out, "running tool") {
1365- t.Fatalf("busy spinner did not surface an open tool near the editor:\n%s", out)
1366- }
1367- if d.transcriptSpin == nil {
1368- t.Error("spinner not initialised while agent is busy")
1369- }
1370-
1371- // Close the operation: spinner should vanish.
1372- views["agent-1"] = subagents.AgentTraceView{LastEvent: subagents.TraceEvent{Type: "tool.finished", Timestamp: time.Now()}}
1373- out = strings.Join(d.Render(tui.Theme{}, 80), "\n")
1374- if d.transcriptSpin != nil {
1375- t.Error("spinner stayed live after agent reported idle")
1376- }
1377- if strings.Contains(out, "running tool, ") {
1378- t.Errorf("busy line still rendered after operation finished:\n%s", out)
1379- }
1380-}
1381-
1382-func TestSubagentsDialogTabTogglesSessionScope(t *testing.T) {
1383- now := time.Now()
1384- current := staticSnapshots(
1385- subagents.AgentSnapshot{ID: "current-1", Status: subagents.StatusRunning, Started: now},
1386- subagents.AgentSnapshot{ID: "current-2", Status: subagents.StatusDone, Started: now},
1387- )
1388- all := staticSnapshots(
1389- subagents.AgentSnapshot{ID: "current-1", Status: subagents.StatusRunning, Started: now},
1390- subagents.AgentSnapshot{ID: "current-2", Status: subagents.StatusDone, Started: now},
1391- subagents.AgentSnapshot{ID: "other-session", Status: subagents.StatusDone, Started: now},
1392- )
1393- d := newSubagentsDialog()
1394- d.SetAllSnapshots(all)
1395- d.Open(current, nil, nil, nil, nil, nil, "")
1396-
1397- out := strings.Join(d.Render(tui.Theme{}, 100), "\n")
1398- if strings.Contains(out, "other-session") || !strings.Contains(out, "this session") {
1399- t.Fatalf("default view is not restricted to the current session:\n%s", out)
1400- }
1401-
1402- d.HandleKey(tui.Key{Kind: tui.KeyTab})
1403- out = strings.Join(d.Render(tui.Theme{}, 100), "\n")
1404- if !strings.Contains(out, "other-session") || !strings.Contains(out, "all sessions") {
1405- t.Fatalf("Tab did not switch to the all-sessions view:\n%s", out)
1406- }
1407-}
1408-
1409-func TestSubagentsDialogNavigatesAndExpandsLiveSnapshotWithinHeight(t *testing.T) {
1410- now := time.Now()
1411- rows := []subagents.AgentSnapshot{
1412- {ID: "first", Status: subagents.StatusDone, Started: now},
1413- {ID: "active", Status: subagents.StatusRunning, Started: now, Lines: []string{
1414- "user: inspect the dashboard", "assistant: I am checking the list", "tool: bash", "tool result: dashboard source", "assistant: rendering snapshot",
1415- }},
1416- {ID: "third", Status: subagents.StatusDone, Started: now},
1417- }
1418- d := newSubagentsDialog()
1419- d.SetMaxRows(20)
1420- d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
1421- _ = d.Render(tui.Theme{}, 100)
1422-
1423- d.HandleKey(tui.Key{Kind: tui.KeyDown})
1424- if selected := d.selected(); selected == nil || selected.ID != "active" {
1425- t.Fatalf("down did not select active agent: %#v", selected)
1426- }
1427- d.HandleKey(tui.Key{Kind: tui.KeyRight})
1428- out := d.Render(tui.Theme{}, 100)
1429- joined := strings.Join(out, "\n")
1430- for _, want := range []string{"live snapshot", "inspect the dashboard", "bash", "dashboard source"} {
1431- if !strings.Contains(joined, want) {
1432- t.Errorf("expanded snapshot missing %q:\n%s", want, joined)
1433- }
1434- }
1435- if len(out) > 20 {
1436- t.Fatalf("dashboard rendered %d rows with 20-row budget:\n%s", len(out), joined)
1437- }
1438-
1439- d.HandleKey(tui.Key{Kind: tui.KeyLeft})
1440- out = d.Render(tui.Theme{}, 100)
1441- if strings.Contains(strings.Join(out, "\n"), "live snapshot") {
1442- t.Fatalf("left did not collapse selected snapshot:\n%s", strings.Join(out, "\n"))
1443- }
1444-
1445- // Even the smallest supported dashboard retains the selected row instead
1446- // of allowing the bottom-sticky compositor to clip it above the viewport.
1447- d.SetMaxRows(4)
1448- out = d.Render(tui.Theme{}, 100)
1449- if len(out) != 4 || !strings.Contains(strings.Join(out, "\n"), "active") {
1450- t.Fatalf("short dashboard lost its selected row (%d rows):\n%s", len(out), strings.Join(out, "\n"))
1451- }
1452-}
1453-
1454-func TestStatusLabelCoversAll(t *testing.T) {
1455- for _, s := range []subagents.Status{
1456- subagents.StatusPending, subagents.StatusRunning,
1457- subagents.StatusDone, subagents.StatusFailed, subagents.StatusKilled,
1458- } {
1459- if statusLabel(s) == "" {
1460- t.Fatalf("no label for %s", s)
1461- }
1462- }
1463-}
M packages/agent/modes/subagents_slash.go +113 -294
@@ -6,340 +6,173 @@ import (
66 "strings"
77
88 "github.com/bnema/zut/packages/agent/subagents"
9+ "github.com/bnema/zut/packages/agent/tools"
910 "github.com/bnema/zut/packages/provider"
10- "github.com/bnema/zut/packages/tui"
1111 )
1212
13-// runSubagents dispatches /subagents subcommands. Layout:
14-//
15-// /subagents -> open the dashboard
16-// /subagents list -> open the dashboard
17-// /subagents new [--agent A] [--model M] [--provider P] [--reasoning L] <task...>
18-// -> spawn an agent with optional profile/model overrides
19-// /subagents kill <id> -> stop a running agent
20-// /subagents cancel <id> -> cancel the active turn
21-// /subagents remove <id> -> tear down a terminated agent
22-// /subagents logs <id> -> open the scrollable transcript view
23-// /subagents result|inspect <id> -> show the completed result
24-// /subagents wait <id> -> wait for the agent to finish
25-// /subagents send <id> <text...> -> send a follow-up user turn to <id>
26-// /subagents resume [id] -> resume an agent (omit id to pick from a list)
27-// /subagents restart-task|restart <id> -> restart the original task
28-// /subagents attach <id> -> (planned) drop into the agent's TUI
29-//
30-// When cfg.Supervisor is nil the command tells the user the feature is
31-// disabled instead of pretending to work.
3213 func (i *Interactive) runSubagents(ctx context.Context, args []string) {
33- if i.cfg.Supervisor == nil {
14+ if i.cfg.ResidentManager == nil {
3415 i.mu.Lock()
35- i.statusErr = "subagent is disabled in this build"
16+ i.statusErr = i.autoSubagentsUnavailableHint()
3617 i.statusOK = ""
3718 i.mu.Unlock()
3819 return
3920 }
21+ i.runResidentSubagents(ctx, args)
22+}
4023
24+// runResidentSubagents is the production slash-command path. The list/editor
25+// migration is deliberately kept separate from the legacy process dashboard;
26+// details open a structured journal-backed child session immediately.
27+func (i *Interactive) runResidentSubagents(ctx context.Context, args []string) {
4128 sub := ""
4229 rest := ""
4330 if len(args) > 0 {
4431 sub = strings.ToLower(args[0])
45- // Guard the args[1:] reslice: when only the subcommand was
46- // typed (e.g. bare "/subagents new"), args has length 1 and the
47- // naive args[1:] is fine, but when args is empty (bare
48- // "/subagents") the reslice is [1:0] and panics. The len>0 branch
49- // here keeps both cases safe.
5032 if len(args) > 1 {
5133 rest = strings.TrimSpace(strings.Join(args[1:], " "))
5234 }
5335 }
54-
55- // spawnAdapter, sendAdapter, resumeAdapter wrap the Supervisor methods
56- // in the signatures the dialog expects. Defined once here so the
57- // three Open()-shaped entry points (list, logs/view-jump, resume)
58- // feed the dialog identical callbacks.
59- spawnAdapter := func(task, model, provider string) error {
60- _, err := i.cfg.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{
61- Task: task, Model: model, Provider: provider, Reasoning: i.cfg.Reasoning,
62- })
63- return err
64- }
65- sendAdapter := func(id, text string) error {
66- return i.cfg.Supervisor.SendUserTurn(id, text)
67- }
68- resumeAdapter := func(id string) error {
69- _, err := i.cfg.Supervisor.ResumeSession(ctx, id)
70- return err
71- }
72-
73- // Pin every fresh spawn to whatever the host's /model selection
74- // is right now. This is captured at /subagents time — if the user
75- // wants a different model for the next subagent agent, they pick it
76- // via /model first (globally), or, while inside the spawn
77- // editor, by typing /model on its own line to pop the picker.
78- i.subagentsDialog.SetCompactMode(i.compactModeEnabled())
79- i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines)
80- i.subagentsDialog.SetAllSnapshots(i.cfg.Supervisor.SnapshotAllSessions)
81- i.subagentsDialog.SetTraceViews(i.cfg.Supervisor.TraceViews)
82- i.subagentsDialog.SetLoadTranscript(i.cfg.Supervisor.LoadTranscript)
83- i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
84- if i.cfg.LoggedInProviders != nil {
85- i.subagentsDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())
86- }
87-
8836 switch sub {
8937 case "", "list", "ls", "ps":
90- i.subagentsDialog.Open(
91- i.cfg.Supervisor.SnapshotCurrentSession,
92- i.cfg.Supervisor.Stop,
93- i.cfg.Supervisor.Remove,
94- spawnAdapter,
95- sendAdapter,
96- resumeAdapter,
97- i.cfg.CWD,
98- )
38+ i.mu.Lock()
39+ if i.residentSubagentsDialog == nil {
40+ i.residentSubagentsDialog = newResidentSubagentsDialog()
41+ }
42+ i.residentSubagentsDialog.Open(i.cfg.ResidentManager)
43+ i.mu.Unlock()
44+ i.invalidate()
9945 case "new", "spawn":
46+ i.spawnResidentSubagent(rest)
47+ case "logs", "log", "view":
10048 if rest == "" {
101- i.subagentsStatus("", "/subagents new <task>: missing task")
49+ i.subagentsStatus("", "/subagents logs <id>: missing id")
10250 return
10351 }
104- // Permit profile/model/provider/reasoning flags before the task
105- // so scripts can pin a child without going through the dialog.
106- // Anything that isn't a recognised flag terminates parsing and
107- // the rest becomes the task; only leading flags are consumed.
108- model, provider, reasoning, subagent, task := parseSpawnFlags(rest)
109- var profileTools []string
110- var fastModeOverride *bool
111- if task == "" {
112- i.subagentsStatus("", "/subagents new: missing task (after any spawn flags)")
52+ i.openResidentChildSession(rest)
53+ case "result", "inspect":
54+ if rest == "" {
55+ i.subagentsStatus("", "/subagents result <id>: missing id")
11356 return
11457 }
115- if subagent != "" {
116- if i.cfg.ResolveSubagent == nil {
117- i.subagentsStatus("", "spawn: named subagent profiles are unavailable")
118- return
119- }
120- profile, err := i.cfg.ResolveSubagent(subagent)
121- if err != nil {
122- i.subagentsStatus("", "spawn: "+err.Error())
123- return
124- }
125- if profile == nil {
126- i.subagentsStatus("", "spawn: unknown subagent profile "+subagent)
127- return
128- }
129- subagent = profile.Name
130- profileTools = append([]string(nil), profile.Tools...)
131- fastModeOverride = profile.FastMode
132- profileProvider, profileModel := profile.ModelSelection()
133- if model == "" {
134- model = profileModel
135- }
136- if provider == "" {
137- provider = profileProvider
138- }
139- if reasoning == "" {
140- reasoning = profile.Thinking
141- }
142- }
143- if model == "" {
144- model = i.cfg.Model
145- }
146- if provider == "" {
147- provider = i.cfg.Provider
148- }
149- if reasoning == "" {
150- reasoning = i.cfg.Reasoning
151- }
152- if reasoning != "" {
153- normalizedReasoning, err := normalizeSpawnReasoning(reasoning)
154- if err != nil {
155- i.subagentsStatus("", "spawn: "+err.Error())
156- return
157- }
158- reasoning = normalizedReasoning
159- }
160- a, err := i.cfg.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{
161- Task: task, Model: model, Provider: provider, Reasoning: reasoning,
162- FastMode: fastModeOverride, Subagent: subagent,
163- Tools: profileTools,
164- })
58+ result, err := i.cfg.ResidentManager.Result(rest)
16559 if err != nil {
166- i.subagentsStatus("", "spawn: "+err.Error())
60+ i.subagentsStatus("", "result: "+err.Error())
16761 return
16862 }
169- status := "spawned " + a.ID
170- if subagent != "" {
171- status += " (agent " + subagent + ")"
172- } else if model != "" {
173- status += " (model " + model + ")"
174- }
175- i.subagentsStatus(status, "")
63+ i.subagentsStatus(residentResultStatus(rest, result), "")
17664 case "kill", "stop":
17765 if rest == "" {
17866 i.subagentsStatus("", "/subagents kill <id>: missing id")
17967 return
18068 }
181- if err := i.cfg.Supervisor.Stop(rest); err != nil {
69+ if err := i.cfg.ResidentManager.Stop(ctx, rest); err != nil {
18270 i.subagentsStatus("", "kill: "+err.Error())
18371 return
18472 }
18573 i.subagentsStatus("stopped "+rest, "")
186- case "cancel":
187- if rest == "" {
188- i.subagentsStatus("", "/subagents cancel <id>: missing id")
189- return
190- }
191- if err := i.cfg.Supervisor.CancelTurn(rest); err != nil {
192- i.subagentsStatus("", "cancel: "+err.Error())
193- return
194- }
195- i.subagentsStatus("canceling turn "+rest, "")
196- case "remove", "rm":
197- if rest == "" {
198- i.subagentsStatus("", "/subagents remove <id>: missing id")
199- return
200- }
201- if err := i.cfg.Supervisor.Remove(rest); err != nil {
202- i.subagentsStatus("", "remove: "+err.Error())
203- return
204- }
205- i.subagentsStatus("removed "+rest, "")
206- case "logs", "log", "view":
207- if rest == "" {
208- i.subagentsStatus("", "/subagents logs <id>: missing id")
209- return
210- }
211- if err := i.cfg.Supervisor.LoadTranscript(rest); err != nil {
212- i.subagentsStatus("", "logs: "+err.Error())
213- return
214- }
215- ok := i.subagentsDialog.OpenViewing(
216- rest,
217- i.cfg.Supervisor.SnapshotAll,
218- i.cfg.Supervisor.Stop,
219- i.cfg.Supervisor.Remove,
220- spawnAdapter,
221- sendAdapter,
222- resumeAdapter,
223- i.cfg.CWD,
224- )
225- if !ok {
226- i.subagentsStatus("", "/subagents logs: no agent matching "+rest)
227- }
228- case "result", "inspect":
229- if rest == "" {
230- i.subagentsStatus("", "/subagents result <id>: missing id")
74+ case "send", "prompt", "msg", "resume":
75+ id, prompt := splitIDAndRest(rest)
76+ if id == "" || prompt == "" {
77+ i.subagentsStatus("", "/subagents "+sub+" <id> <prompt>: missing id or prompt")
23178 return
23279 }
233- result, err := i.cfg.Supervisor.ReadResult(rest)
234- if err != nil {
235- i.subagentsStatus("", "result: "+err.Error())
236- return
237- }
238- status := fmt.Sprintf("%s result %s", result.Status, i.cfg.Supervisor.ResultReference(rest))
239- if result.Summary != "" {
240- status += ": " + truncateStatus(result.Summary, 160)
241- }
242- i.subagentsStatus(status, "")
243- case "wait":
244- if rest == "" {
245- i.subagentsStatus("", "/subagents wait <id>: missing id")
246- return
247- }
248- a := i.cfg.Supervisor.Get(rest)
249- if a == nil {
250- i.subagentsStatus("", "wait: no such agent "+rest)
80+ if err := i.cfg.ResidentManager.Resume(ctx, id, prompt); err != nil {
81+ i.subagentsStatus("", sub+": "+err.Error())
25182 return
25283 }
253- waitWatcherDone := i.subagentsWaitWatcherDone
254- // Publish the initial state before starting the watcher. A worker may
255- // already be done, and its completion must be the final status rather
256- // than being overwritten by a late "waiting" update.
257- i.subagentsStatus("waiting for "+a.ID, "")
84+ i.subagentsStatus("accepted follow-up for "+id, "")
85+ default:
86+ i.subagentsStatus("", "/subagents: resident child sessions support list / new / logs / result / kill / send / resume")
87+ }
88+}
89+
90+func residentResultStatus(id string, result subagents.ResidentResult) string {
91+ status := fmt.Sprintf("%s result %s", residentDisplayState(result.State), id)
92+ if summary := truncateStatus(result.Summary, 240); summary != "" {
93+ status += ": " + summary
94+ }
95+ return status
96+}
97+
98+func (i *Interactive) openResidentChildSession(childID string) {
99+ session := newResidentChildSession(i.cfg.ResidentManager, childID, i.cfg.Theme)
100+ i.mu.Lock()
101+ if i.residentSubagentsDialog != nil {
102+ i.residentSubagentsDialog.Close()
103+ }
104+ i.residentChildSession = session
105+ i.mu.Unlock()
106+ i.invalidate()
107+ if session.BeginLoad() {
258108 go func() {
259- defer func() {
260- if waitWatcherDone != nil {
261- waitWatcherDone()
262- }
263- }()
264- if _, err := a.WaitTurnResult(ctx, a.WaitTargetTurnID()); err != nil {
265- return
266- }
267- // If cancellation became observable at the same time as
268- // completion, do not report a completion that the caller
269- // no longer owns.
270- if ctx.Err() == nil {
271- i.subagentsStatus("completed "+a.ID, "")
109+ err := session.LoadRecent(200)
110+ if session.FinishLoad(err) {
111+ i.reloadResidentChildSession(session)
272112 }
113+ i.invalidate()
273114 }()
274- case "resume-session", "resume", "reattach", "reopen":
275- if rest == "" {
276- count := i.subagentsDialog.OpenForResume(
277- i.cfg.Supervisor.SnapshotCurrentSession,
278- i.cfg.Supervisor.Stop,
279- i.cfg.Supervisor.Remove,
280- spawnAdapter,
281- sendAdapter,
282- resumeAdapter,
283- i.cfg.CWD,
284- )
285- switch count {
286- case 0:
287- i.subagentsStatus("", "/subagents resume-session: no resumable agents")
288- case 1:
289- i.subagentsStatus("1 resumable agent, press R to resume", "")
290- default:
291- i.subagentsStatus(fmt.Sprintf("%d resumable agents, ↑/↓ to pick, R to resume", count), "")
292- }
293- return
294- }
295- a, err := i.cfg.Supervisor.ResumeSession(ctx, rest)
296- if err != nil {
297- i.subagentsStatus("", "resume-session: "+err.Error())
298- return
299- }
300- i.subagentsStatus("resumed "+a.ID, "")
301- case "restart-task", "restart":
302- if rest == "" {
303- i.subagentsStatus("", "/subagents restart-task <id>: missing id")
115+ }
116+}
117+
118+func (i *Interactive) spawnResidentSubagent(rest string) {
119+ if i.cfg.SpawnResident == nil {
120+ i.subagentsStatus("", "spawn: resident construction is unavailable")
121+ return
122+ }
123+ model, providerID, reasoning, profileName, task := parseSpawnFlags(rest)
124+ if task == "" {
125+ i.subagentsStatus("", "/subagents new <task>: missing task")
126+ return
127+ }
128+ var profile *subagents.Profile
129+ var fastModeOverride *bool
130+ if profileName != "" {
131+ if i.cfg.ResolveSubagent == nil {
132+ i.subagentsStatus("", "spawn: named subagent profiles are unavailable")
304133 return
305134 }
306- a, err := i.cfg.Supervisor.RestartTask(ctx, rest)
307- if err != nil {
308- i.subagentsStatus("", "restart-task: "+err.Error())
135+ resolved, err := i.cfg.ResolveSubagent(profileName)
136+ if err != nil || resolved == nil {
137+ if err != nil {
138+ i.subagentsStatus("", "spawn: "+err.Error())
139+ } else {
140+ i.subagentsStatus("", "spawn: unknown subagent profile "+profileName)
141+ }
309142 return
310143 }
311- i.subagentsStatus("restarted task "+a.ID, "")
312- case "send", "prompt", "msg":
313- // /subagents send <id> <text...> is the non-interactive
314- // counterpart of pressing 'p' in the dashboard. We split the
315- // joined `rest` ourselves rather than reusing the dispatcher's
316- // already-fielded args[] because the text may contain spaces
317- // the user expects to be preserved verbatim.
318- id, text := splitIDAndRest(rest)
319- if id == "" {
320- i.subagentsStatus("", "/subagents send <id> <text>: missing id")
321- return
144+ profile = resolved
145+ fastModeOverride = profile.FastMode
146+ if model == "" {
147+ providerID, model = profile.ModelSelection()
322148 }
323- if text == "" {
324- i.subagentsStatus("", "/subagents send <id> <text>: missing text")
325- return
149+ if reasoning == "" {
150+ reasoning = profile.Thinking
326151 }
327- if err := i.cfg.Supervisor.SendUserTurn(id, text); err != nil {
328- i.subagentsStatus("", friendlySendErr(id, err))
152+ }
153+ if model == "" {
154+ model = i.cfg.Model
155+ }
156+ if providerID == "" {
157+ providerID = i.cfg.Provider
158+ }
159+ if reasoning == "" {
160+ reasoning = i.cfg.Reasoning
161+ }
162+ if reasoning != "" {
163+ var err error
164+ reasoning, err = normalizeSpawnReasoning(reasoning)
165+ if err != nil {
166+ i.subagentsStatus("", "spawn: "+err.Error())
329167 return
330168 }
331- i.subagentsStatus("sent to "+id, "")
332- case "attach":
333- // PTY-reparenting is a significant chunk of work I haven't
334- // landed yet (see the design sketch). Recognise the name so
335- // /subagents attach doesn't fall through to the generic "unknown
336- // subcommand" path — that error message is misleading because
337- // it makes attach sound like a typo instead of a planned
338- // feature. Point the user at /subagents logs in the meantime.
339- i.subagentsStatus("", "/subagents attach: not implemented yet (needs PTY reparenting). Use /subagents logs "+firstWord(rest)+" to watch its transcript.")
340- default:
341- i.subagentsStatus("", "/subagents: unknown subcommand "+sub+" (try list / new / kill / cancel / remove / logs / send / result / wait / resume-session / restart-task)")
342169 }
170+ childID, err := i.cfg.SpawnResident(context.Background(), tools.ResidentSpawnRequest{Task: task, Profile: profile, Model: model, Provider: providerID, Reasoning: reasoning, FastMode: fastModeOverride})
171+ if err != nil {
172+ i.subagentsStatus("", "spawn: "+err.Error())
173+ return
174+ }
175+ i.subagentsStatus("spawned "+childID, "")
343176 }
344177
345178 // normalizeSpawnReasoning validates and canonicalizes the reasoning levels
@@ -444,20 +277,6 @@ func splitIDAndRest(s string) (id, text string) {
444277 return s[:cut], strings.TrimLeft(s[cut+1:], " \t")
445278 }
446279
447-// firstWord returns the first whitespace-separated token of s, or
448-// "<id>" when s is empty. Used to keep the "/subagents attach" hint
449-// readable even when the user typed no argument.
450-func firstWord(s string) string {
451- s = strings.TrimSpace(s)
452- if s == "" {
453- return "<id>"
454- }
455- if i := strings.IndexAny(s, " \t"); i >= 0 {
456- return s[:i]
457- }
458- return s
459-}
460-
461280 func truncateStatus(value string, max int) string {
462281 value = strings.ReplaceAll(strings.TrimSpace(value), "\n", " ")
463282 if len([]rune(value)) <= max {
D packages/agent/modes/subagents_slash_test.go +0 -404
@@ -1,404 +0,0 @@
1-package modes
2-
3-import (
4- "context"
5- "runtime"
6- "strings"
7- "testing"
8- "time"
9-
10- "github.com/bnema/zut/packages/agent/subagents"
11-)
12-
13-// newInteractiveForSupervisorTest builds the minimal Interactive scaffolding
14-// runSubagents needs. It does NOT call NewInteractive (which would pull in
15-// the whole TUI); the runSubagents method only touches cfg.Supervisor, the
16-// status mutex, and the subagent dialog, so we hand-build those.
17-func newInteractiveForSupervisorTest(t *testing.T) (*Interactive, *subagents.Supervisor) {
18- t.Helper()
19- root := t.TempDir()
20- f := subagents.New(subagents.Config{
21- Root: root,
22- RepoRoot: root,
23- NewRunner: func(a *subagents.Agent) subagents.Runner {
24- return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error {
25- <-ctx.Done()
26- return ctx.Err()
27- })
28- },
29- })
30- iv := &Interactive{
31- subagentsDialog: newSubagentsDialog(),
32- dirty: make(chan struct{}, 1),
33- }
34- iv.cfg.Supervisor = f
35- return iv, f
36-}
37-
38-// TestRunSupervisorBareDoesNotPanic regression-tests the slice-out-of-range
39-// panic that hit when /subagents was typed with no subcommand: runSubagents
40-// did args[1:] without checking len(args), which panics as [1:0].
41-func TestRunSupervisorBareDoesNotPanic(t *testing.T) {
42- iv, _ := newInteractiveForSupervisorTest(t)
43- defer iv.cfg.Supervisor.StopAll()
44-
45- // Bare /subagents: parts[1:] from the dispatcher is an empty slice.
46- iv.runSubagents(context.Background(), nil)
47-
48- if !iv.subagentsDialog.Active() {
49- t.Fatal("bare /subagents should open the dashboard")
50- }
51-}
52-
53-func TestRunSupervisorSubcommandsDoNotPanic(t *testing.T) {
54- iv, _ := newInteractiveForSupervisorTest(t)
55- defer iv.cfg.Supervisor.StopAll()
56-
57- // Each row is the slice that the dispatcher hands to runSubagents —
58- // i.e. parts[1:] where parts was strings.Fields of the slash
59- // command. Mixing zero-arg and arg'd forms exercises both
60- // branches of the reslice guard.
61- cases := [][]string{
62- {"list"},
63- {"new"},
64- {"new", "fix", "the", "thing"},
65- {"kill"},
66- {"kill", "no-such-id"},
67- {"remove"},
68- {"remove", "no-such-id"},
69- {"send"},
70- {"send", "no-such-id"},
71- {"send", "no-such-id", "hello", "world"},
72- {"bogus"},
73- }
74- for _, args := range cases {
75- func() {
76- defer func() {
77- if r := recover(); r != nil {
78- t.Fatalf("runSubagents(%v) panicked: %v", args, r)
79- }
80- }()
81- iv.runSubagents(context.Background(), args)
82- }()
83- }
84- iv.mu.Lock()
85- statusErr := iv.statusErr
86- iv.mu.Unlock()
87- if !strings.Contains(statusErr, "cancel") || !strings.Contains(statusErr, "wait") {
88- t.Fatalf("unknown-command hint = %q; want cancel and wait", statusErr)
89- }
90-}
91-
92-func TestRunSupervisorRejectsNamedProfileWithoutResolver(t *testing.T) {
93- iv, f := newInteractiveForSupervisorTest(t)
94- defer f.StopAll()
95-
96- iv.runSubagents(context.Background(), []string{"new", "--agent", "reviewer", "review", "auth"})
97- if !strings.Contains(iv.statusErr, "named subagent profiles are unavailable") {
98- t.Fatalf("status error = %q, want unavailable profile resolver", iv.statusErr)
99- }
100- if got := len(f.List()); got != 0 {
101- t.Fatalf("spawned agents = %d, want 0", got)
102- }
103-}
104-
105-func TestRunSupervisorNewSpawnsAgent(t *testing.T) {
106- iv, f := newInteractiveForSupervisorTest(t)
107- defer f.StopAll()
108-
109- iv.runSubagents(context.Background(), []string{"new", "do", "stuff"})
110- agents := f.List()
111- if len(agents) != 1 {
112- t.Fatalf("want 1 agent; got %d", len(agents))
113- }
114- if agents[0].Task != "do stuff" {
115- t.Fatalf("task = %q; want %q", agents[0].Task, "do stuff")
116- }
117-}
118-
119-func TestRunSupervisorNamedProfileAppliesFastModeRestriction(t *testing.T) {
120- root := t.TempDir()
121- profileFastMode := false
122- f := subagents.New(subagents.Config{
123- Root: root,
124- RepoRoot: root,
125- FastMode: true,
126- NewRunner: func(*subagents.Agent) subagents.Runner {
127- return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
128- <-ctx.Done()
129- return ctx.Err()
130- })
131- },
132- })
133- defer f.StopAll()
134- iv := &Interactive{subagentsDialog: newSubagentsDialog(), dirty: make(chan struct{}, 1)}
135- iv.cfg.Supervisor = f
136- iv.cfg.ResolveSubagent = func(name string) (*subagents.Profile, error) {
137- if name != "reviewer" {
138- return nil, nil
139- }
140- return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil
141- }
142-
143- iv.runSubagents(context.Background(), []string{"new", "--agent", "reviewer", "review", "auth"})
144- agents := f.List()
145- if len(agents) != 1 || agents[0].FastMode {
146- t.Fatalf("agent fast mode = %#v, want disabled by profile", agents)
147- }
148-}
149-
150-// TestRunSupervisorSendDeliversToAgentInbox spins up a real agent with a
151-// fake Runner whose only job is to forward inbox lines to a channel,
152-// then asserts the /subagents send <id> <text...> path routes through
153-// Supervisor.SendUserTurn and lands at the agent verbatim.
154-func TestRunSupervisorSendDeliversToAgentInbox(t *testing.T) {
155- if runtime.GOOS == "windows" {
156- t.Skip("subagent inbox transport uses Unix-domain sockets")
157- }
158- root := t.TempDir()
159- recv := make(chan string, 4)
160- ready := make(chan error, 1)
161- f := subagents.New(subagents.Config{
162- Root: root,
163- RepoRoot: root,
164- NewRunner: func(a *subagents.Agent) subagents.Runner {
165- return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error {
166- // Stand up a real Listener on the agent's inbox path so
167- // SendUserTurn (which dials a unix socket) actually has
168- // something to talk to. The runner-test stubs do the
169- // same; this is the minimum to exercise the wire.
170- ln, err := subagents.Listen(a.InboxPath)
171- ready <- err
172- if err != nil {
173- return err
174- }
175- defer ln.Close()
176- for {
177- select {
178- case <-ctx.Done():
179- return ctx.Err()
180- case line, ok := <-ln.Lines():
181- if !ok {
182- return nil
183- }
184- recv <- line
185- }
186- }
187- })
188- },
189- })
190- defer f.StopAll()
191- iv := &Interactive{subagentsDialog: newSubagentsDialog(), dirty: make(chan struct{}, 1)}
192- iv.cfg.Supervisor = f
193-
194- a, err := f.Spawn(context.Background(), "do thing")
195- if err != nil {
196- t.Fatalf("spawn: %v", err)
197- }
198- select {
199- case err := <-ready:
200- if err != nil {
201- t.Fatalf("listen: %v", err)
202- }
203- case <-time.After(5 * time.Second):
204- t.Fatal("timed out waiting for agent inbox listener")
205- }
206-
207- // Run /subagents send <id> <text...>. The dispatcher would have
208- // already strings.Fields-ed the input; mirror that here.
209- iv.runSubagents(context.Background(), []string{"send", a.ID, "please", "continue"})
210-
211- select {
212- case msg := <-recv:
213- command, parseErr := subagents.ParseCommand(msg)
214- if parseErr != nil {
215- t.Fatalf("agent received invalid command %q: %v", msg, parseErr)
216- }
217- if command.Type != subagents.CommandTurnStart {
218- t.Fatalf("agent received command %q; want %q", command.Type, subagents.CommandTurnStart)
219- }
220- var payload subagents.TurnStartPayload
221- if err := command.DecodePayload(&payload); err != nil {
222- t.Fatalf("decode turn.start payload: %v", err)
223- }
224- if payload.Prompt != "please continue" {
225- t.Fatalf("agent received prompt %q; want %q", payload.Prompt, "please continue")
226- }
227- case <-time.After(2 * time.Second):
228- t.Fatal("timed out waiting for agent to receive the prompt")
229- }
230-
231- if iv.statusErr != "" {
232- t.Fatalf("status err set: %q", iv.statusErr)
233- }
234- if iv.statusOK == "" || iv.statusOK[:7] != "sent to" {
235- t.Fatalf("status ok = %q; want \"sent to ...\"", iv.statusOK)
236- }
237-}
238-
239-func TestRunSupervisorWaitHonorsRunContext(t *testing.T) {
240- iv, f := newInteractiveForSupervisorTest(t)
241- defer f.StopAll()
242-
243- a, err := f.Spawn(context.Background(), "long-running task")
244- if err != nil {
245- t.Fatal(err)
246- }
247- ctx, cancel := context.WithCancel(context.Background())
248- watcherDone := make(chan struct{})
249- iv.subagentsWaitWatcherDone = func() { close(watcherDone) }
250- iv.runSubagents(ctx, []string{"wait", a.ID})
251- iv.mu.Lock()
252- status := iv.statusOK
253- iv.mu.Unlock()
254- if status != "waiting for "+a.ID {
255- t.Fatalf("initial wait status = %q", status)
256- }
257- cancel()
258-
259- // The wait watcher must stop observing the run context and must not
260- // later overwrite the status with a false completion after cleanup.
261- deadline := time.NewTimer(time.Second)
262- defer deadline.Stop()
263- select {
264- case <-watcherDone:
265- case <-deadline.C:
266- t.Fatal("timed out waiting for wait watcher to exit")
267- }
268- iv.mu.Lock()
269- status = iv.statusOK
270- iv.mu.Unlock()
271- if strings.Contains(status, "completed ") {
272- t.Fatalf("wait reported completion after cancellation: %q", status)
273- }
274-}
275-
276-func TestRunSupervisorWaitReportsCompletionBeforeCancellation(t *testing.T) {
277- root := t.TempDir()
278- f := subagents.New(subagents.Config{
279- Root: root,
280- RepoRoot: root,
281- NewRunner: func(*subagents.Agent) subagents.Runner {
282- return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })
283- },
284- })
285- defer f.StopAll()
286- iv := &Interactive{subagentsDialog: newSubagentsDialog(), dirty: make(chan struct{}, 1)}
287- iv.cfg.Supervisor = f
288-
289- a, err := f.Spawn(context.Background(), "quick task")
290- if err != nil {
291- t.Fatal(err)
292- }
293- // Exercise the ordering-sensitive path deterministically: /wait can be
294- // invoked after the worker has already completed.
295- a.Wait()
296- watcherDone := make(chan struct{})
297- iv.subagentsWaitWatcherDone = func() { close(watcherDone) }
298- iv.runSubagents(context.Background(), []string{"wait", a.ID})
299- select {
300- case <-watcherDone:
301- case <-time.After(5 * time.Second):
302- t.Fatal("timed out waiting for wait watcher to report completion")
303- }
304-
305- iv.mu.Lock()
306- status := iv.statusOK
307- iv.mu.Unlock()
308- if status != "completed "+a.ID {
309- t.Fatalf("wait completion status = %q; want %q", status, "completed "+a.ID)
310- }
311-}
312-
313-func TestNormalizeSpawnReasoning(t *testing.T) {
314- cases := []struct {
315- in, want string
316- ok bool
317- }{
318- {in: "OFF", want: "off", ok: true},
319- {in: "minimal", want: "minimum", ok: true},
320- {in: "maximum", want: "xhigh", ok: true},
321- {in: "MAX", want: "max", ok: true},
322- {in: "bogus", ok: false},
323- }
324- for _, tc := range cases {
325- got, err := normalizeSpawnReasoning(tc.in)
326- if tc.ok {
327- if err != nil || got != tc.want {
328- t.Errorf("normalizeSpawnReasoning(%q) = %q, %v; want %q", tc.in, got, err, tc.want)
329- }
330- continue
331- }
332- if err == nil {
333- t.Errorf("normalizeSpawnReasoning(%q) succeeded with %q; want error", tc.in, got)
334- }
335- }
336-}
337-
338-func TestParseSpawnFlags(t *testing.T) {
339- cases := []struct {
340- in string
341- wantModel, wantProv string
342- wantReasoning, wantSubagent string
343- wantTask string
344- }{
345- {"do x", "", "", "", "", "do x"},
346- {"--model claude do x", "claude", "", "", "", "do x"},
347- {"--model=claude do x", "claude", "", "", "", "do x"},
348- {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "", "", "do x"},
349- {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "", "", "do x"},
350- {"--agent reviewer --reasoning high review auth", "", "", "high", "reviewer", "review auth"},
351- {"--agent --reasoning high review auth", "", "", "high", "", "review auth"},
352- {"--reasoning --agent reviewer review auth", "", "", "", "reviewer", "review auth"},
353- {"--thinking=max do x", "", "", "max", "", "do x"},
354- // Only LEADING flags are consumed.
355- {"do --model x", "", "", "", "", "do --model x"},
356- // Missing value: --model with no follow-up token leaves model empty
357- // and the next field starts the task.
358- {"--model", "", "", "", "", ""},
359- }
360- for _, c := range cases {
361- m, p, r, a, task := parseSpawnFlags(c.in)
362- if m != c.wantModel || p != c.wantProv || r != c.wantReasoning || a != c.wantSubagent || task != c.wantTask {
363- t.Errorf("parseSpawnFlags(%q) = (%q,%q,%q,%q,%q); want (%q,%q,%q,%q,%q)",
364- c.in, m, p, r, a, task, c.wantModel, c.wantProv, c.wantReasoning, c.wantSubagent, c.wantTask)
365- }
366- }
367-}
368-
369-func TestSplitIDAndRest(t *testing.T) {
370- cases := []struct {
371- in string
372- wantID string
373- wantText string
374- }{
375- {"", "", ""},
376- {" ", "", ""},
377- {"alpha", "alpha", ""},
378- {"alpha hello world", "alpha", "hello world"},
379- {" alpha hello world ", "alpha", "hello world "},
380- {"alpha\thi", "alpha", "hi"},
381- }
382- for _, c := range cases {
383- gotID, gotText := splitIDAndRest(c.in)
384- if gotID != c.wantID || gotText != c.wantText {
385- t.Errorf("splitIDAndRest(%q) = (%q,%q); want (%q,%q)", c.in, gotID, gotText, c.wantID, c.wantText)
386- }
387- }
388-}
389-
390-func TestRunSupervisorWithoutSupervisorIsNoop(t *testing.T) {
391- iv := &Interactive{
392- subagentsDialog: newSubagentsDialog(),
393- dirty: make(chan struct{}, 1),
394- }
395- // cfg.Supervisor stays nil. The command should set a status err and
396- // otherwise be inert.
397- iv.runSubagents(context.Background(), nil)
398- if iv.subagentsDialog.Active() {
399- t.Fatal("dialog opened despite no subagent")
400- }
401- if iv.statusErr == "" {
402- t.Fatal("expected a status error when subagent is nil")
403- }
404-}
M packages/agent/orchestration.go +0 -5
@@ -19,14 +19,9 @@ func validateOrchestrationArgs(args Args) error {
1919 // for callers that select those output protocols.
2020 case ModeRPC:
2121 return fmt.Errorf("--orchestrate is not supported with RPC mode")
22- case ModeSubagentWorker:
23- return fmt.Errorf("--orchestrate is not supported in subagent-worker mode")
2422 default:
2523 return fmt.Errorf("--orchestrate requires print, stream, or JSON mode")
2624 }
27- if strings.TrimSpace(args.Subagent) != "" {
28- return fmt.Errorf("--orchestrate is not supported with --subagent profiles")
29- }
3025 if args.PermissionSet != nil || strings.TrimSpace(args.AgentName) != "" || strings.TrimSpace(args.AgentDataDir) != "" {
3126 return fmt.Errorf("--orchestrate is not supported for packaged agents")
3227 }
M packages/agent/orchestration_modes.go +14 -26
@@ -68,9 +68,9 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h
6868 runErr = errors.Join(runErr, closeErr)
6969 }
7070 }()
71- if runtime.Supervisor() != nil {
72- if _, reloadErrs := runtime.Supervisor().Reload(); len(reloadErrs) != 0 {
73- return fmt.Errorf("reload subagents: %w", errors.Join(reloadErrs...))
71+ if resident := runtime.ResidentManager(); resident != nil {
72+ for _, reconcileErr := range resident.Reconcile() {
73+ fmt.Fprintf(os.Stderr, "zut: reconcile resident subagent: %v\n", reconcileErr)
7474 }
7575 }
7676
@@ -129,7 +129,7 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h
129129 return refreshErr
130130 }
131131 // entry.pre may have changed the effective permission set. Keep the
132- // worker supervisor in step with the refreshed parent registry.
132+ // resident child factory in step with the refreshed parent registry.
133133 runtime.SetWebSearchPolicy(refreshedPolicy)
134134 }
135135
@@ -192,24 +192,8 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h
192192 return hooks.finish(finalText)
193193 }
194194
195-func newOrchestratedRuntime(ctx context.Context, args Args, r Resolved, cfg Config, tracker *subagents.CompletionTracker) *subagentRuntime {
196- onSpawned := func(a *subagents.Agent, task string, _ bool) {
197- tracker.TrackTurn(a, task, false)
198- }
199- onResumed := func(a *subagents.Agent, prompt string, _ bool) {
200- // BeforeResumed normally owns future-turn registration. Keep this
201- // fallback for direct runtime callers that do not install the pre-hook;
202- // SubagentResumeTool suppresses it when BeforeResumed accepted delivery.
203- tracker.TrackTurn(a, prompt, true)
204- }
205- onBeforeResumed := func(a *subagents.Agent, prompt string, _ bool) func() {
206- return tracker.TrackFutureTurn(a, prompt, true)
207- }
208- onStopRequested := func(a *subagents.Agent) {
209- tracker.TrackExit(a, "stopped")
210- }
195+func newOrchestratedRuntime(_ context.Context, args Args, r Resolved, cfg Config, tracker *subagents.CompletionTracker) *subagentRuntime {
211196 return newSubagentRuntime(subagentRuntimeConfig{
212- Context: ctx,
213197 Args: args,
214198 Root: filepath.Join(ZutHome(), "subagents"),
215199 RepoRoot: r.CWD,
@@ -219,13 +203,17 @@ func newOrchestratedRuntime(ctx context.Context, args Args, r Resolved, cfg Conf
219203 BaseURL: r.BaseURL,
220204 InsecureTLS: r.InsecureTLS,
221205 FastMode: r.FastMode,
222- APIKey: args.APIKey,
223206 Policy: subagentPolicyFromConfig(cfg.Subagents),
224207 WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
225- OnSpawned: onSpawned,
226- OnResumed: onResumed,
227- BeforeResumed: onBeforeResumed,
228- OnStopRequested: onStopRequested,
208+ ResidentCompletion: func(completion subagents.ResidentCompletion) {
209+ status := "completed"
210+ errText := ""
211+ if completion.Err != nil {
212+ status, errText = "failed", completion.Err.Error()
213+ }
214+ tracker.Report(subagents.Completion{AgentID: completion.ChildID, Status: status, Task: completion.Task, Error: errText, Summary: completion.Summary})
215+ },
216+ OnResidentSpawned: func(subagents.ResidentChildSpec, string) { tracker.TrackResident() },
229217 })
230218 }
231219
M packages/agent/ponytail_test.go +1 -1
@@ -153,7 +153,7 @@ func TestResolvePonytailModeAppliesAcrossHeadlessModesAndCustomIdentity(t *testi
153153 t.Setenv("ZUT_HOME", filepath.Join(root, "zut-home"))
154154 t.Setenv("HOME", filepath.Join(root, "home"))
155155
156- modes := []Mode{"", ModePrint, ModeStream, ModeJSON, ModeRPC, ModeSubagentWorker}
156+ modes := []Mode{"", ModePrint, ModeStream, ModeJSON, ModeRPC}
157157 for _, mode := range modes {
158158 resolved, err := Resolve(Args{
159159 Mode: mode,
M packages/agent/settings_store.go +1 -15
@@ -217,20 +217,6 @@ func (configSettingsStore) SetTUIWorkingPosition(position string) error {
217217 return SaveConfig(cfg)
218218 }
219219
220-func (configSettingsStore) SetTUISubagentPosition(position string) error {
221- cfg, err := LoadConfig()
222- if err != nil {
223- return err
224- }
225- position = tui.NormalizeSubagentPosition(position)
226- if position == tui.SubagentPositionBelowInput {
227- cfg.TUISubagentPosition = ""
228- } else {
229- cfg.TUISubagentPosition = position
230- }
231- return SaveConfig(cfg)
232-}
233-
234220 func (configSettingsStore) SetReasoning(level string) error {
235221 cfg, err := LoadConfig()
236222 if err != nil {
@@ -274,7 +260,7 @@ Give every worker a self-contained task because workers start without this conve
274260
275261 Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. Required workers remain asynchronous: manager calls return immediately, the parent stays free to coordinate or perform independent work, and the host delivers terminal outcomes through completion updates. Failed, timed-out, or canceled required work remains unmet: use an enabled manager follow-up action to retry it and do not claim completion. An indeterminate outcome after host restart must not be retried automatically; the user must inspect durable results and side effects, then explicitly resume, restart, or remove the worker. Only the user can waive required work by explicitly removing the terminal worker with /subagents rm. Keep required:false only for independent work that may safely finish later.
276262
277-Completion is host-event-driven for every worker. Never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, event-log, or file checks solely to wait. Those are not completion signals. Continue unrelated independent tasks; otherwise end or yield your turn until the host injects a completion update. Completion updates are the only completion signal. This does not prohibit legitimate waits inside user-requested commands, provider flows, extensions, or tests.
263+Completion is host-event-driven for every resident child. Never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, or file checks solely to wait. Those are not completion signals. Continue unrelated independent tasks; otherwise end or yield your turn until the host injects a completion update. Completion updates are the only completion signal. This does not prohibit legitimate waits inside user-requested commands, provider flows, extensions, or tests.
278264
279265 When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to coordinate any follow-up and summarize the outcome. Treat [auto-subagents update] and [required-subagents update] messages as observed worker state, not as new user requests.`
280266
M packages/agent/settings_store_test.go +0 -32
@@ -202,38 +202,6 @@ func TestConfigLSPEnabledForDefaultsToTrue(t *testing.T) {
202202 }
203203 }
204204
205-func TestConfigSettingsStorePersistsSubagentPosition(t *testing.T) {
206- t.Setenv("ZUT_HOME", t.TempDir())
207- if err := SaveConfig(Config{Theme: "dark"}); err != nil {
208- t.Fatal(err)
209- }
210-
211- if err := (configSettingsStore{}).SetTUISubagentPosition("above_input"); err != nil {
212- t.Fatal(err)
213- }
214- cfg, err := LoadConfig()
215- if err != nil {
216- t.Fatal(err)
217- }
218- if cfg.TUISubagentPosition != "above_input" {
219- t.Fatalf("tui_subagent_position = %q, want above_input", cfg.TUISubagentPosition)
220- }
221- if cfg.Theme != "dark" {
222- t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme)
223- }
224-
225- if err := (configSettingsStore{}).SetTUISubagentPosition("below_input"); err != nil {
226- t.Fatal(err)
227- }
228- cfg, err = LoadConfig()
229- if err != nil {
230- t.Fatal(err)
231- }
232- if cfg.TUISubagentPosition != "" {
233- t.Fatalf("default tui_subagent_position = %q, want omitted", cfg.TUISubagentPosition)
234- }
235-}
236-
237205 func TestConfigSettingsStorePersistsInheritedTheme(t *testing.T) {
238206 t.Setenv("ZUT_HOME", t.TempDir())
239207 if err := SaveConfig(Config{Theme: "dark"}); err != nil {
A packages/agent/subagent_factory.go +135 -0
@@ -0,0 +1,135 @@
1+package agent
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "sync"
8+
9+ "github.com/bnema/zut/packages/agent/subagents"
10+ "github.com/bnema/zut/packages/agent/tools"
11+ "github.com/bnema/zut/packages/core"
12+)
13+
14+// newResidentChildRunner is the host-owned construction boundary for one
15+// resident child. It resolves a fresh provider client and a fresh core.Agent
16+// exactly once for the child's durable session; no subprocess configuration or
17+// credential transfer is involved.
18+func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
19+ if strings.TrimSpace(spec.SessionID) == "" {
20+ return nil, fmt.Errorf("resident child %q has no session identity", spec.ID)
21+ }
22+ resolved, err := Resolve(args, true)
23+ if err != nil {
24+ return nil, fmt.Errorf("resolve resident child %q: %w", spec.ID, err)
25+ }
26+
27+ registry, err := residentChildRegistry(resolved.ToolRegistry, spec.Tools)
28+ if err != nil {
29+ return nil, err
30+ }
31+ system := resolved.SystemPrompt
32+ if extra := strings.TrimSpace(spec.SystemPrompt); extra != "" {
33+ if spec.SystemPromptMode == "replace" {
34+ system = extra
35+ } else {
36+ system = strings.TrimSpace(system) + "\n\n" + extra
37+ }
38+ }
39+ agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry)
40+ agent.MaxSteps = resolved.MaxSteps
41+ agent.ContextWindow = resolved.ContextWindow
42+ agent.MaxTokens = resolved.MaxOutput
43+ agent.Reasoning = resolved.Reasoning
44+ agent.Temperature = resolved.Temperature
45+ agent.FastMode = resolved.FastMode
46+ if err := agent.BindSessionID(spec.SessionID); err != nil {
47+ return nil, fmt.Errorf("bind resident child %q session: %w", spec.ID, err)
48+ }
49+ if journal != nil {
50+ messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir())
51+ if err != nil {
52+ return nil, fmt.Errorf("restore resident child %q transcript: %w", spec.ID, err)
53+ }
54+ if len(messages) > 0 {
55+ agent.SetMessages(messages)
56+ }
57+ }
58+ return func(ctx context.Context, prompt string) error {
59+ if ctx == nil {
60+ ctx = context.Background()
61+ }
62+ turnCtx, cancel := context.WithCancel(ctx)
63+ defer cancel()
64+ var journalErr error
65+ var journalMu sync.Mutex
66+ err := agent.Prompt(turnCtx, prompt, nil, func(event core.AgentEvent) {
67+ if err := journal.RecordAgentEvent(event); err != nil {
68+ journalMu.Lock()
69+ if journalErr == nil {
70+ journalErr = err
71+ cancel()
72+ }
73+ journalMu.Unlock()
74+ }
75+ })
76+ journalMu.Lock()
77+ defer journalMu.Unlock()
78+ if journalErr != nil {
79+ return fmt.Errorf("persist resident child transcript: %w", journalErr)
80+ }
81+ return err
82+ }, nil
83+}
84+
85+// residentChildArgs applies the complete durable child specification without
86+// rediscovering a mutable profile file. That preserves the profile inheritance
87+// decision accepted at spawn time across explicit resume and restart.
88+func residentChildArgs(args Args, parentProvider string, spec subagents.ResidentChildSpec) Args {
89+ next := args
90+ // An explicit CLI key belongs to the provider that resolved the parent.
91+ // A child can select another provider, but it must resolve that provider's
92+ // own credential rather than forwarding or persisting the parent's key.
93+ if canonicalProvider(parentProvider) != canonicalProvider(spec.Provider) {
94+ next.APIKey = ""
95+ }
96+ next.Provider = spec.Provider
97+ next.BaseURL = spec.BaseURL
98+ next.InsecureTLS = spec.InsecureTLS
99+ next.Model = spec.Model
100+ next.Reasoning = spec.Reasoning
101+ next.FastMode = spec.FastMode
102+ next.FastModeSet = true
103+ if strings.TrimSpace(spec.Workspace) != "" {
104+ next.CWD = spec.Workspace
105+ }
106+ if spec.InheritSkills != nil && !*spec.InheritSkills {
107+ next.NoSkill = true
108+ }
109+ if spec.InheritProjectContext != nil && !*spec.InheritProjectContext {
110+ next.NoContextFiles = true
111+ }
112+ return next
113+}
114+
115+// residentChildRegistry applies an already validated, exact child tool list.
116+// It always strips delegation and host-goal mutation tools: child agents are
117+// independent workers, not nested orchestrators or goal owners.
118+func residentChildRegistry(catalogue core.Registry, names []string) (core.Registry, error) {
119+ registry := make(core.Registry, len(names))
120+ for _, name := range names {
121+ name = strings.TrimSpace(name)
122+ if name == "" {
123+ return nil, fmt.Errorf("resident child has an empty tool name")
124+ }
125+ if name == tools.SubagentSpawnToolName || name == tools.SubagentStatusToolName || name == tools.SubagentStopToolName || name == tools.SubagentResumeToolName || name == "update_goal" {
126+ return nil, fmt.Errorf("resident child tool %q is not allowed", name)
127+ }
128+ tool, ok := catalogue[name]
129+ if !ok {
130+ return nil, fmt.Errorf("resident child declares unavailable tool %q", name)
131+ }
132+ registry[name] = tool
133+ }
134+ return registry, nil
135+}
A packages/agent/subagent_factory_test.go +54 -0
@@ -0,0 +1,54 @@
1+package agent
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/zut/packages/agent/subagents"
8+ "github.com/bnema/zut/packages/core"
9+)
10+
11+func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T) {
12+ catalogue := core.Registry{
13+ "read": nil,
14+ "bash": nil,
15+ "subagent_spawn": nil,
16+ "update_goal": nil,
17+ }
18+ registry, err := residentChildRegistry(catalogue, []string{"read"})
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ if len(registry) != 1 {
23+ t.Fatalf("registry = %#v", registry)
24+ }
25+ for _, name := range []string{"subagent_spawn", "update_goal", "missing"} {
26+ _, err := residentChildRegistry(catalogue, []string{name})
27+ if err == nil || !strings.Contains(err.Error(), name) {
28+ t.Fatalf("residentChildRegistry(%q) error = %v", name, err)
29+ }
30+ }
31+}
32+
33+func TestResidentChildArgsPreserveDurableProfileInheritance(t *testing.T) {
34+ no := false
35+ next := residentChildArgs(Args{NoSkill: false, NoContextFiles: false, BaseURL: "https://stale.example/v1", InsecureTLS: false}, "openai", subagents.ResidentChildSpec{
36+ Provider: "openai", BaseURL: "https://current.example/v1", InsecureTLS: true, Model: "gpt-5", Workspace: "/repo/child",
37+ InheritSkills: &no, InheritProjectContext: &no,
38+ })
39+ if next.Provider != "openai" || next.BaseURL != "https://current.example/v1" || !next.InsecureTLS || next.Model != "gpt-5" || next.CWD != "/repo/child" || !next.NoSkill || !next.NoContextFiles {
40+ t.Fatalf("resident child args = %#v", next)
41+ }
42+}
43+
44+func TestResidentChildArgsDoNotForwardCLIKeyAcrossProviders(t *testing.T) {
45+ parent := Args{Provider: "openai", APIKey: "parent-key"}
46+ child := subagents.ResidentChildSpec{Provider: "anthropic", Model: "claude"}
47+ if next := residentChildArgs(parent, "openai", child); next.APIKey != "" {
48+ t.Fatalf("cross-provider child inherited API key %q", next.APIKey)
49+ }
50+ child.Provider = "openai"
51+ if next := residentChildArgs(parent, "openai", child); next.APIKey != "parent-key" {
52+ t.Fatalf("same-provider child API key = %q, want parent key", next.APIKey)
53+ }
54+}
D packages/agent/subagent_policy_test.go +0 -64
@@ -1,64 +0,0 @@
1-package agent
2-
3-import (
4- "context"
5- "os"
6- "strings"
7- "testing"
8- "time"
9-
10- "github.com/bnema/zut/packages/agent/subagents"
11-)
12-
13-func TestSubagentPolicyParsesStartupTimeout(t *testing.T) {
14- policy := subagentPolicyFromConfig(SubagentsConfig{StartupTimeout: "42s"})
15- if got := policy.StartupTimeout; got != 42*time.Second {
16- t.Fatalf("StartupTimeout = %s, want 42s", got)
17- }
18-}
19-
20-func TestLoadConfigRejectsMalformedSubagentDuration(t *testing.T) {
21- t.Setenv("ZUT_HOME", t.TempDir())
22- if err := os.WriteFile(ConfigPath(), []byte(`{"subagents":{"default_timeout":"20"}}`), 0o600); err != nil {
23- t.Fatal(err)
24- }
25- _, err := LoadConfig()
26- if err == nil || !strings.Contains(err.Error(), "subagents.default_timeout") {
27- t.Fatalf("LoadConfig error = %v, want field-qualified duration error", err)
28- }
29-}
30-
31-func TestSubagentPolicyIgnoresLegacyMaxTotalSpawned(t *testing.T) {
32- t.Setenv("ZUT_HOME", t.TempDir())
33- if err := os.WriteFile(ConfigPath(), []byte(`{"subagents":{"max_concurrent":1,"max_total_spawned":1}}`), 0o600); err != nil {
34- t.Fatal(err)
35- }
36- cfg, err := LoadConfig()
37- if err != nil {
38- t.Fatal(err)
39- }
40- if got := cfg.Subagents.MaxTotalSpawned; got != 1 {
41- t.Fatalf("legacy max_total_spawned = %d, want 1", got)
42- }
43-
44- supervisor := subagents.New(subagents.Config{
45- Root: t.TempDir(),
46- RepoRoot: t.TempDir(),
47- Policy: subagentPolicyFromConfig(cfg.Subagents),
48- NewRunner: func(*subagents.Agent) subagents.Runner {
49- return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })
50- },
51- })
52- t.Cleanup(supervisor.StopAll)
53-
54- for _, task := range []string{"first", "second"} {
55- agent, err := supervisor.Spawn(context.Background(), task)
56- if err != nil {
57- t.Fatalf("Spawn(%q): %v", task, err)
58- }
59- agent.Wait()
60- if got := agent.Status(); got != subagents.StatusDone {
61- t.Fatalf("%s status = %s, want %s", task, got, subagents.StatusDone)
62- }
63- }
64-}
D packages/agent/subagent_requirement_gate_test.go +0 -255
@@ -1,255 +0,0 @@
1-package agent
2-
3-import (
4- "context"
5- "errors"
6- "strings"
7- "testing"
8-
9- "github.com/bnema/zut/packages/agent/subagents"
10- "github.com/bnema/zut/packages/core"
11- "github.com/bnema/zut/packages/provider"
12-)
13-
14-func TestRequiredWorkerGateKeepsParentFreeWhileWorkerRuns(t *testing.T) {
15- started := make(chan struct{})
16- release := make(chan struct{})
17- cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
18- return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
19- close(started)
20- select {
21- case <-release:
22- return nil
23- case <-ctx.Done():
24- return ctx.Err()
25- }
26- })
27- })
28- rt := newSubagentRuntime(cfg)
29- defer func() { _ = rt.Close(context.Background()) }()
30- rt.SetActiveSession("parent-session")
31- a, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
32- Task: "required review",
33- RootSessionID: "parent-session",
34- Required: true,
35- })
36- if err != nil {
37- t.Fatal(err)
38- }
39- <-started
40-
41- parent := &core.Agent{}
42- rt.WireRequiredWorkerGate(parent)
43- allowed, reason, turnContext := parent.BeforeTurnContext(context.Background(), 1)
44- if !allowed || reason != "" || turnContext != "" {
45- t.Fatalf("parent turn while worker runs = (%v, %q, %q), want immediate access", allowed, reason, turnContext)
46- }
47- if allowed, reason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}); !allowed || reason != "" {
48- t.Fatalf("independent parent tool allowed=%v reason=%q", allowed, reason)
49- }
50- if allowed, reason, _ := parent.BeforeAssistantMessage("premature final answer"); allowed || !strings.Contains(reason, a.ID) {
51- t.Fatalf("terminal parent answer allowed=%v reason=%q, want deferred finalization", allowed, reason)
52- }
53-
54- close(release)
55- a.Wait()
56- allowed, reason, turnContext = parent.BeforeTurnContext(context.Background(), 2)
57- if !allowed || reason != "" || !strings.Contains(turnContext, a.ID) {
58- t.Fatalf("completion context = (%v, %q, %q), want required outcome", allowed, reason, turnContext)
59- }
60-}
61-
62-func TestRequiredWorkerUpdateIsAcknowledgedOnlyAfterParentCompletion(t *testing.T) {
63- cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
64- return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })
65- })
66- rt := newSubagentRuntime(cfg)
67- defer func() { _ = rt.Close(context.Background()) }()
68- rt.SetActiveSession("parent-session")
69- worker, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
70- Task: "required validation", RootSessionID: "parent-session", Required: true,
71- })
72- if err != nil {
73- t.Fatal(err)
74- }
75- worker.Wait()
76- parent := &core.Agent{}
77- rt.WireRequiredWorkerGate(parent)
78-
79- allowed, reason, update := parent.BeforeTurnContext(context.Background(), 1)
80- if !allowed || reason != "" || !strings.Contains(update, worker.ID) {
81- t.Fatalf("first required context allowed=%v reason=%q update=%q", allowed, reason, update)
82- }
83- if worker.Snapshot().Requirement.Notified {
84- t.Fatal("before-turn context was acknowledged before parent completion")
85- }
86- allowed, reason, update = parent.BeforeTurnContext(context.Background(), 1)
87- if !allowed || reason != "" || !strings.Contains(update, worker.ID) {
88- t.Fatalf("replayed required context allowed=%v reason=%q update=%q", allowed, reason, update)
89- }
90- if allowed, reason, _ := parent.BeforeAssistantMessage("consumed the required result"); !allowed || reason != "" {
91- t.Fatalf("parent completion allowed=%v reason=%q", allowed, reason)
92- }
93- if !worker.Snapshot().Requirement.Notified {
94- t.Fatal("successful parent completion did not acknowledge required context")
95- }
96-}
97-
98-func TestRequiredWorkerGateFailsClosedOnReloadError(t *testing.T) {
99- cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
100- return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })
101- })
102- rt := newSubagentRuntime(cfg)
103- defer func() { _ = rt.Close(context.Background()) }()
104- worker, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{Task: "persisted requirement", Required: true})
105- if err != nil {
106- t.Fatal(err)
107- }
108- worker.Wait()
109- ready := make(chan struct{})
110- close(ready)
111- rt.SetRequiredWorkerReady(ready, func() error { return errors.New("malformed persisted worker") })
112- parent := &core.Agent{}
113- rt.WireRequiredWorkerGate(parent)
114- allowed, reason, _ := parent.BeforeTurnContext(context.Background(), 1)
115- if allowed || reason != "loading persisted subagents failed: malformed persisted worker; resolve the supervisor reload error before continuing" {
116- t.Fatalf("reload gate allowed=%v reason=%q, want fail-closed reload error", allowed, reason)
117- }
118-}
119-
120-func TestRequiredWorkerGateTreatsManualTerminalRemovalAsWaiver(t *testing.T) {
121- cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
122- return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
123- return errors.New("worker failed")
124- })
125- })
126- rt := newSubagentRuntime(cfg)
127- defer func() { _ = rt.Close(context.Background()) }()
128- rt.SetActiveSession("parent-session")
129- worker, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
130- Task: "required validation", RootSessionID: "parent-session", Required: true,
131- })
132- if err != nil {
133- t.Fatal(err)
134- }
135- worker.Wait()
136- parent := &core.Agent{}
137- rt.WireRequiredWorkerGate(parent)
138- if allowed, reason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}); !allowed || reason != "" {
139- t.Fatalf("unmet requirement blocked independent tool: allowed=%v reason=%q", allowed, reason)
140- }
141- if allowed, _, _ := parent.BeforeAssistantMessage("done"); allowed {
142- t.Fatal("unmet requirement allowed terminal answer")
143- }
144- if err := rt.Supervisor().Remove(worker.ID); err != nil {
145- t.Fatalf("explicit user removal waiver: %v", err)
146- }
147- if allowed, reason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}); !allowed || reason != "" {
148- t.Fatalf("tool after explicit waiver allowed=%v reason=%q", allowed, reason)
149- }
150-}
151-
152-func TestRequiredWorkerGateWaitsForEveryRequiredWorker(t *testing.T) {
153- releases := []chan struct{}{make(chan struct{}), make(chan struct{})}
154- started := make(chan struct{}, len(releases))
155- runnerIndex := 0
156- cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
157- index := runnerIndex
158- runnerIndex++
159- return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
160- started <- struct{}{}
161- select {
162- case <-releases[index]:
163- return nil
164- case <-ctx.Done():
165- return ctx.Err()
166- }
167- })
168- })
169- rt := newSubagentRuntime(cfg)
170- defer func() { _ = rt.Close(context.Background()) }()
171- rt.SetActiveSession("parent-session")
172- first, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
173- Task: "first required check", RootSessionID: "parent-session", Required: true,
174- })
175- if err != nil {
176- t.Fatal(err)
177- }
178- second, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
179- Task: "second required check", RootSessionID: "parent-session", Required: true,
180- })
181- if err != nil {
182- t.Fatal(err)
183- }
184- <-started
185- <-started
186-
187- parent := &core.Agent{}
188- rt.WireRequiredWorkerGate(parent)
189- close(releases[0])
190- first.Wait()
191- if allowed, reason, _ := parent.BeforeAssistantMessage("first worker completed"); allowed || !strings.Contains(reason, second.ID) {
192- t.Fatalf("terminal response after one required worker = (%v, %q), want remaining worker block", allowed, reason)
193- }
194-
195- close(releases[1])
196- second.Wait()
197- if allowed, reason, _ := parent.BeforeAssistantMessage("both workers completed"); !allowed || reason != "" {
198- t.Fatalf("terminal response after all required workers = (%v, %q), want allowed", allowed, reason)
199- }
200-}
201-
202-func TestRequiredWorkerGateRejectsTerminalParentResponseUntilRetrySucceeds(t *testing.T) {
203- attempt := 0
204- cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
205- attempt++
206- current := attempt
207- return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
208- if current == 1 {
209- return errors.New("review crashed")
210- }
211- return nil
212- })
213- })
214- rt := newSubagentRuntime(cfg)
215- defer func() { _ = rt.Close(context.Background()) }()
216- rt.SetActiveSession("parent-session")
217- worker, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
218- Task: "required review",
219- RootSessionID: "parent-session",
220- Required: true,
221- })
222- if err != nil {
223- t.Fatal(err)
224- }
225- worker.Wait()
226-
227- parent := &core.Agent{}
228- rt.WireRequiredWorkerGate(parent)
229- turnAllowed, turnReason, turnContext := parent.BeforeTurnContext(context.Background(), 1)
230- if !turnAllowed || turnReason != "" || !strings.Contains(turnContext, worker.ID) || !strings.Contains(turnContext, "subagent_resume") {
231- t.Fatalf("failed worker recovery context = (%v, %q, %q)", turnAllowed, turnReason, turnContext)
232- }
233- toolAllowed, toolReason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"})
234- if !toolAllowed || toolReason != "" {
235- t.Fatalf("independent tool allowed=%v reason=%q, want parent to remain usable", toolAllowed, toolReason)
236- }
237- toolAllowed, toolReason, _ = parent.BeforeToolExecute(provider.ToolCallBlock{Name: "subagent_resume"})
238- if !toolAllowed || toolReason != "" {
239- t.Fatalf("recovery tool allowed=%v reason=%q, want allowed", toolAllowed, toolReason)
240- }
241- allowed, reason, _ := parent.BeforeAssistantMessage("I opened the pull request")
242- if allowed || !strings.Contains(reason, worker.ID) {
243- t.Fatalf("terminal response allowed=%v reason=%q, want failed required worker gate", allowed, reason)
244- }
245-
246- retried, err := rt.Supervisor().ResumeRequiredWithPrompt(context.Background(), worker.ID, "retry review")
247- if err != nil {
248- t.Fatal(err)
249- }
250- retried.Wait()
251- allowed, reason, _ = parent.BeforeAssistantMessage("The required review passed")
252- if !allowed || reason != "" {
253- t.Fatalf("terminal response after retry allowed=%v reason=%q, want allowed", allowed, reason)
254- }
255-}
M packages/agent/subagent_requirements.go +13 -229
@@ -1,252 +1,36 @@
11 package agent
22
33 import (
4- "context"
5- "encoding/json"
6- "errors"
74 "fmt"
85 "strings"
96
10- "github.com/bnema/zut/packages/agent/subagents"
117 "github.com/bnema/zut/packages/core"
12- "github.com/bnema/zut/packages/provider"
138 )
149
1510 const requiredWorkerContextHeader = "[required-subagents update]"
1611
17-// WireRequiredWorkerGate composes host-enforced requirement handling with any
18-// extension hooks already installed on the parent. Parent turns and tools stay
19-// available while required work runs; terminal outcomes are injected on the
20-// next turn, and only a terminal assistant response is held while an obligation
21-// remains unmet.
12+// WireRequiredWorkerGate prevents a parent terminal response while required
13+// resident work remains unresolved. The manager is the sole authority; there
14+// is no worker reload or process-side acknowledgement path.
2215 func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) {
23- if rt == nil || rt.supervisor == nil || parent == nil {
16+ if rt == nil || parent == nil || rt.residentManagerForTools() == nil {
2417 return
2518 }
26- previousBeforeTurn := parent.BeforeTurnContext
27- parent.BeforeTurnContext = func(ctx context.Context, step int) (bool, string, string) {
28- var inherited string
29- if previousBeforeTurn != nil {
30- allowed, reason, contextText := previousBeforeTurn(ctx, step)
31- if !allowed {
32- return false, reason, contextText
33- }
34- inherited = contextText
35- }
36- if err := rt.waitRequiredWorkerReady(ctx); err != nil {
37- return false, fmt.Sprintf("loading persisted subagents failed: %v; resolve the supervisor reload error before continuing", err), inherited
38- }
39- required := rt.supervisor.RequiredSnapshots()
40- update := rt.formatRequiredWorkerContext(required)
41- if update == "" {
42- return true, "", inherited
43- }
44- return true, "", joinRequiredContext(inherited, update)
45- }
46-
47- previousBeforeTool := parent.BeforeToolExecute
48- parent.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
49- if previousBeforeTool != nil {
50- return previousBeforeTool(call)
51- }
52- return true, "", nil
53- }
54-
5519 previousBeforeAssistant := parent.BeforeAssistantMessage
5620 parent.BeforeAssistantMessage = func(text string) (bool, string, string) {
57- unmet := rt.supervisor.UnmetRequirements()
21+ unmet := rt.residentManagerForTools().UnmetRequired()
5822 if len(unmet) != 0 {
59- // Worker completion is delivered through the coordinator-owned tracker.
60- // Do not queue a synthetic reminder here: it races that terminal wave
61- // and can wake the parent before every required result is available.
62- return false, formatRequiredWorkerBlock(unmet), ""
63- }
64- if previousBeforeAssistant != nil {
65- allowed, reason, replacement := previousBeforeAssistant(text)
66- if !allowed {
67- return false, reason, replacement
23+ var b strings.Builder
24+ b.WriteString(requiredWorkerContextHeader)
25+ b.WriteString("\nTerminal completion is blocked by required resident delegated work:")
26+ for _, child := range unmet {
27+ fmt.Fprintf(&b, "\n- %s: %s", child.ID, child.State)
6828 }
69- if err := rt.acknowledgeSatisfiedRequirements(); err != nil {
70- return false, err.Error(), ""
71- }
72- return true, reason, replacement
29+ return false, b.String(), ""
7330 }
74- if err := rt.acknowledgeSatisfiedRequirements(); err != nil {
75- return false, err.Error(), ""
31+ if previousBeforeAssistant != nil {
32+ return previousBeforeAssistant(text)
7633 }
7734 return true, "", ""
7835 }
7936 }
80-
81-// SetRequiredWorkerReady makes the gate wait for an asynchronous supervisor
82-// reload before it checks persisted obligations. A nil channel means no reload
83-// is pending. The error callback is read only after ready closes.
84-func (rt *subagentRuntime) SetRequiredWorkerReady(ready <-chan struct{}, result func() error) {
85- if rt == nil {
86- return
87- }
88- rt.requiredReadyMu.Lock()
89- rt.requiredReady = ready
90- rt.requiredReadyErr = result
91- rt.requiredReadyMu.Unlock()
92-}
93-
94-func (rt *subagentRuntime) waitRequiredWorkerReady(ctx context.Context) error {
95- rt.requiredReadyMu.RLock()
96- ready := rt.requiredReady
97- result := rt.requiredReadyErr
98- rt.requiredReadyMu.RUnlock()
99- if ready == nil {
100- return nil
101- }
102- select {
103- case <-ctx.Done():
104- return ctx.Err()
105- case <-ready:
106- if result != nil {
107- return result()
108- }
109- return nil
110- }
111-}
112-
113-func (rt *subagentRuntime) acknowledgeSatisfiedRequirements() error {
114- var result error
115- for _, snapshot := range rt.supervisor.RequiredSnapshots() {
116- if snapshot.Requirement.State == subagents.RequirementSatisfied && !snapshot.Requirement.Notified {
117- result = errors.Join(result, rt.supervisor.MarkRequirementNotified(snapshot.ID))
118- }
119- }
120- return result
121-}
122-
123-const requiredWorkerContextRuneBudget = 6000
124-
125-func (rt *subagentRuntime) formatRequiredWorkerContext(required []subagents.AgentSnapshot) string {
126- rt.requiredContextMu.Lock()
127- defer rt.requiredContextMu.Unlock()
128- contextText := formatRequiredWorkerContextWithReported(required, rt.reportedRequiredUnmet)
129- if rt.reportedRequiredUnmet == nil {
130- rt.reportedRequiredUnmet = make(map[string]struct{})
131- }
132- for _, snapshot := range required {
133- if snapshot.Requirement.Unmet() {
134- rt.reportedRequiredUnmet[snapshot.ID] = struct{}{}
135- }
136- }
137- return contextText
138-}
139-
140-func formatRequiredWorkerContextWithReported(required []subagents.AgentSnapshot, previouslyReported map[string]struct{}) string {
141- var items []subagents.AgentSnapshot
142- for _, snapshot := range required {
143- requirement := snapshot.Requirement
144- if !requirement.Required || requirement.State == subagents.RequirementPending {
145- continue
146- }
147- if requirement.Notified && !requirement.Unmet() {
148- continue
149- }
150- items = append(items, snapshot)
151- }
152- if len(items) == 0 {
153- return ""
154- }
155- var sb strings.Builder
156- sb.WriteString(requiredWorkerContextHeader)
157- sb.WriteString("\nThe host waited for required delegated work before this model turn. These outcomes are durable and authoritative:\n")
158- for _, snapshot := range items {
159- requirement := snapshot.Requirement
160- if requirement.Unmet() {
161- if _, reported := previouslyReported[snapshot.ID]; reported {
162- fmt.Fprintf(&sb, "\n- %s: %s (turn %d; previously reported; diagnostic: %s)\n", snapshot.ID, requirement.State, requirement.TargetTurn, subagents.ResultRef(snapshot.ID))
163- continue
164- }
165- }
166- fmt.Fprintf(&sb, "\n- %s: %s (turn %d)\n task: %s\n", snapshot.ID, requirement.State, requirement.TargetTurn, strings.TrimSpace(snapshot.Task))
167- if requirement.ErrorCode != "" {
168- fmt.Fprintf(&sb, " error_code: %s\n diagnostic: %s\n", requirement.ErrorCode, subagents.ResultRef(snapshot.ID))
169- }
170- output := strings.TrimSpace(snapshot.LastAssistant)
171- if snapshot.Result != nil && strings.TrimSpace(snapshot.Result.Output) != "" {
172- output = strings.TrimSpace(snapshot.Result.Output)
173- }
174- if output != "" {
175- fmt.Fprintf(&sb, " result:\n%s\n", indentRequiredOutput(output, " ", 6000))
176- }
177- }
178- if hasIndeterminateRequirement(items) {
179- sb.WriteString("\nA required outcome is indeterminate after host restart. Do not retry it automatically: the user must inspect the durable result or external side effect, then explicitly resume, restart, or remove the terminal worker.")
180- } else if hasUnmetRequirement(items) {
181- sb.WriteString("\nAt least one required outcome is unresolved. Retry that worker with subagent_resume; do not emit a terminal answer. Only the user can waive it by removing the terminal worker with /subagents rm.")
182- } else {
183- sb.WriteString("\nAll listed requirements are satisfied. Use their results before answering.")
184- }
185- return truncateRequiredContext(sb.String(), requiredWorkerContextRuneBudget)
186-}
187-
188-func truncateRequiredContext(text string, limit int) string {
189- runes := []rune(text)
190- if len(runes) <= limit {
191- return text
192- }
193- return string(runes[:limit])
194-}
195-
196-func formatRequiredWorkerBlock(unmet []subagents.AgentSnapshot) string {
197- var sb strings.Builder
198- sb.WriteString(requiredWorkerContextHeader)
199- sb.WriteString("\nTerminal completion is blocked by required delegated work:")
200- for _, snapshot := range unmet {
201- fmt.Fprintf(&sb, "\n- %s: %s — %s", snapshot.ID, snapshot.Requirement.State, strings.TrimSpace(snapshot.Task))
202- if snapshot.Requirement.ErrorCode != "" {
203- fmt.Fprintf(&sb, " (%s; diagnostic: %s)", snapshot.Requirement.ErrorCode, subagents.ResultRef(snapshot.ID))
204- }
205- }
206- if hasIndeterminateRequirement(unmet) {
207- sb.WriteString("\nDo not retry an indeterminate outcome automatically. The user must inspect its durable result or external side effect, then explicitly resume, restart, or remove the worker.")
208- } else {
209- sb.WriteString("\nRetry unresolved work with subagent_resume. Do not claim completion. The user may explicitly waive a terminal worker with /subagents rm.")
210- }
211- return sb.String()
212-}
213-
214-func hasIndeterminateRequirement(required []subagents.AgentSnapshot) bool {
215- for _, snapshot := range required {
216- if snapshot.Requirement.State == subagents.RequirementIndeterminate {
217- return true
218- }
219- }
220- return false
221-}
222-
223-func hasUnmetRequirement(required []subagents.AgentSnapshot) bool {
224- for _, snapshot := range required {
225- if snapshot.Requirement.Unmet() {
226- return true
227- }
228- }
229- return false
230-}
231-
232-func joinRequiredContext(first, second string) string {
233- first = strings.TrimSpace(first)
234- second = strings.TrimSpace(second)
235- switch {
236- case first == "":
237- return second
238- case second == "":
239- return first
240- default:
241- return first + "\n\n" + second
242- }
243-}
244-
245-func indentRequiredOutput(text, prefix string, maxRunes int) string {
246- text = strings.TrimSpace(text)
247- runes := []rune(text)
248- if maxRunes > 0 && len(runes) > maxRunes {
249- text = string(runes[:maxRunes]) + "\n…(truncated)"
250- }
251- return prefix + strings.ReplaceAll(text, "\n", "\n"+prefix)
252-}
M packages/agent/subagent_runtime.go +170 -200
@@ -3,9 +3,8 @@ package agent
33 import (
44 "context"
55 "errors"
6- "fmt"
7- "os"
86 "path/filepath"
7+ "sort"
98 "strings"
109 "sync"
1110 "time"
@@ -13,47 +12,42 @@ import (
1312 "github.com/bnema/zut/packages/agent/subagents"
1413 "github.com/bnema/zut/packages/agent/tools"
1514 "github.com/bnema/zut/packages/core"
15+ "github.com/google/uuid"
1616 )
1717
1818 // subagentRuntime owns the manager-facing runtime assembled around a host
19-// session. Keeping this state together is important: every injected manager
20-// tool must use the same supervisor, model/provider selection, profile root,
21-// policy, callbacks, and shutdown lifecycle.
19+// session. Every injected manager tool shares the same resident manager,
20+// model/provider selection, profile root, policy, and shutdown lifecycle.
2221 type subagentRuntime struct {
23- supervisor *subagents.Supervisor
24- args Args
25- root string
26-
27- provider string
28- model string
29- reasoning string
30- baseURL string
31- insecureTLS bool
32- fastMode bool
33- repoRoot string
22+ resident *subagents.ResidentManager
23+ args Args
24+ root string
25+
26+ provider string
27+ // credentialProvider is immutable: an explicit CLI key belongs to the
28+ // provider resolved when this runtime was assembled, not to later UI
29+ // provider selections.
30+ credentialProvider string
31+ model string
32+ reasoning string
33+ baseURL string
34+ insecureTLS bool
35+ fastMode bool
36+ repoRoot string
3437
3538 activeProvider func() string
3639 activeModel func() string
3740 activeReasoning func() string
3841
39- apiKey string
40- apiKeyProvider string
4142 webSearchPolicy subagents.WebSearchPolicy
43+ policy subagents.SubagentPolicy
44+ activeSession string
4245 webSearchGuard *webSearchSessionGuard
4346
44- onSpawned func(*subagents.Agent, string, bool)
45- onResumed func(*subagents.Agent, string, bool)
46- beforeResumed func(*subagents.Agent, string, bool) func()
47- onStopRequested func(*subagents.Agent)
48-
49- requiredReadyMu sync.RWMutex
50- requiredReady <-chan struct{}
51- requiredReadyErr func() error
52- requiredContextMu sync.Mutex
53- reportedRequiredUnmet map[string]struct{}
54- settingsMu sync.RWMutex
55- closeMu sync.Mutex
56- closed bool
47+ onResidentSpawned func(subagents.ResidentChildSpec, string)
48+ settingsMu sync.RWMutex
49+ closeMu sync.Mutex
50+ closed bool
5751 }
5852
5953 type subagentRuntimeConfiguration struct {
@@ -70,12 +64,9 @@ type subagentRuntimeConfiguration struct {
7064 // assemble a subagentRuntime. The active selection callbacks are optional;
7165 // when omitted, the initial Provider/Model/Reasoning values are used.
7266 type subagentRuntimeConfig struct {
73- Context context.Context
74- Args Args
75- Root string
76- RepoRoot string
77- TraceDir string
78- TraceMode subagents.TraceMode
67+ Args Args
68+ Root string
69+ RepoRoot string
7970
8071 Provider string
8172 Model string
@@ -84,62 +75,35 @@ type subagentRuntimeConfig struct {
8475 InsecureTLS bool
8576 FastMode bool
8677
87- APIKey string
88- Policy subagents.SubagentPolicy
89- WebSearchPolicy subagents.WebSearchPolicy
90- WebSearchGuard *webSearchSessionGuard
91- ActiveProvider func() string
92- ActiveModel func() string
93- ActiveReasoning func() string
94- NewRunner func(*subagents.Agent) subagents.Runner
95-
96- OnSpawned func(*subagents.Agent, string, bool)
97- OnResumed func(*subagents.Agent, string, bool)
98- BeforeResumed func(*subagents.Agent, string, bool) func()
99- OnStopRequested func(*subagents.Agent)
78+ Policy subagents.SubagentPolicy
79+ WebSearchPolicy subagents.WebSearchPolicy
80+ WebSearchGuard *webSearchSessionGuard
81+ ActiveProvider func() string
82+ ActiveModel func() string
83+ ActiveReasoning func() string
84+ ResidentCompletion func(subagents.ResidentCompletion)
85+ OnResidentSpawned func(subagents.ResidentChildSpec, string)
10086 }
10187
10288 func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime {
103- if cfg.Context == nil {
104- cfg.Context = context.Background()
105- }
106- if cfg.TraceDir == "" {
107- cfg.TraceDir = strings.TrimSpace(os.Getenv("ZUT_SUBAGENT_TRACE_DIR"))
108- }
109- if cfg.TraceMode == "" {
110- switch strings.ToLower(strings.TrimSpace(os.Getenv("ZUT_SUBAGENT_TRACE_MODE"))) {
111- case "", string(subagents.TraceModeNormal):
112- cfg.TraceMode = subagents.TraceModeNormal
113- case string(subagents.TraceModeDetailed):
114- cfg.TraceMode = subagents.TraceModeDetailed
115- default:
116- fmt.Fprintf(os.Stderr, "zut: ignoring invalid ZUT_SUBAGENT_TRACE_MODE\n")
117- cfg.TraceMode = subagents.TraceModeNormal
118- }
119- }
120- if cfg.TraceDir == "" {
121- cfg.TraceMode = subagents.TraceModeNormal
122- }
12389 rt := &subagentRuntime{
124- args: cfg.Args,
125- root: cfg.Root,
126- provider: strings.TrimSpace(cfg.Provider),
127- model: strings.TrimSpace(cfg.Model),
128- reasoning: strings.TrimSpace(cfg.Reasoning),
129- baseURL: strings.TrimSpace(cfg.BaseURL),
130- insecureTLS: cfg.InsecureTLS,
131- fastMode: cfg.FastMode,
132- repoRoot: cfg.RepoRoot,
133- activeProvider: cfg.ActiveProvider,
134- activeModel: cfg.ActiveModel,
135- activeReasoning: cfg.ActiveReasoning,
136- apiKey: cfg.APIKey,
137- webSearchPolicy: cfg.WebSearchPolicy,
138- webSearchGuard: cfg.WebSearchGuard,
139- onSpawned: cfg.OnSpawned,
140- onResumed: cfg.OnResumed,
141- beforeResumed: cfg.BeforeResumed,
142- onStopRequested: cfg.OnStopRequested,
90+ args: cfg.Args,
91+ root: cfg.Root,
92+ provider: strings.TrimSpace(cfg.Provider),
93+ credentialProvider: strings.TrimSpace(cfg.Provider),
94+ model: strings.TrimSpace(cfg.Model),
95+ reasoning: strings.TrimSpace(cfg.Reasoning),
96+ baseURL: strings.TrimSpace(cfg.BaseURL),
97+ insecureTLS: cfg.InsecureTLS,
98+ fastMode: cfg.FastMode,
99+ repoRoot: cfg.RepoRoot,
100+ activeProvider: cfg.ActiveProvider,
101+ activeModel: cfg.ActiveModel,
102+ activeReasoning: cfg.ActiveReasoning,
103+ webSearchPolicy: cfg.WebSearchPolicy,
104+ policy: cfg.Policy,
105+ webSearchGuard: cfg.WebSearchGuard,
106+ onResidentSpawned: cfg.OnResidentSpawned,
143107 }
144108 if rt.activeProvider == nil {
145109 rt.activeProvider = func() string {
@@ -162,45 +126,35 @@ func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime {
162126 return rt.reasoning
163127 }
164128 }
165- if rt.apiKey != "" {
166- // A launch-time API key belongs to the provider selected at startup. It
167- // must not follow the live provider callback through a later transition.
168- rt.apiKeyProvider = canonicalProvider(rt.provider)
169- }
170-
171- // Packaged agents have an explicit capability ceiling. Until worker
172- // capability propagation exists, they cannot expose delegation tools or a
173- // fresh unrestricted supervisor.
129+ // Packaged agents have an explicit capability ceiling and cannot delegate.
174130 if cfg.Args.PermissionSet == nil {
175131 root := rt.root
176132 if strings.TrimSpace(root) == "" {
177133 root = filepath.Join(ZutHome(), "subagents")
178134 rt.root = root
179135 }
180- rt.supervisor = subagents.New(subagents.Config{
181- Context: cfg.Context,
182- Root: root,
183- RepoRoot: cfg.RepoRoot,
184- TraceDir: cfg.TraceDir,
185- TraceMode: cfg.TraceMode,
186- Provider: rt.currentProvider(),
187- FastMode: cfg.FastMode,
188- WebSearchPolicy: cfg.WebSearchPolicy,
189- BaseURL: cfg.BaseURL,
190- InsecureTLS: cfg.InsecureTLS,
191- NewRunner: cfg.NewRunner,
192- Policy: cfg.Policy,
193- ResolveCredential: rt.resolveCredential,
136+ rt.resident = subagents.NewResidentManagerWithPolicy(root, cfg.Policy, func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
137+ return newResidentChildRunner(residentChildArgs(rt.args, rt.credentialProvider, spec), spec, journal)
194138 })
139+ rt.resident.SetCompletionObserver(cfg.ResidentCompletion)
140+ rt.resident.SetAcceptedObserver(cfg.OnResidentSpawned)
195141 }
196142 return rt
197143 }
198144
199-func (rt *subagentRuntime) Supervisor() *subagents.Supervisor {
145+// ResidentManager returns the in-process runtime.
146+func (rt *subagentRuntime) ResidentManager() *subagents.ResidentManager {
147+ if rt == nil {
148+ return nil
149+ }
150+ return rt.resident
151+}
152+
153+func (rt *subagentRuntime) residentManagerForTools() *subagents.ResidentManager {
200154 if rt == nil {
201155 return nil
202156 }
203- return rt.supervisor
157+ return rt.resident
204158 }
205159
206160 func (rt *subagentRuntime) currentProvider() string {
@@ -250,9 +204,6 @@ func (rt *subagentRuntime) SetProvider(providerID string) {
250204 rt.settingsMu.Lock()
251205 rt.provider = providerID
252206 rt.settingsMu.Unlock()
253- if rt.supervisor != nil {
254- rt.supervisor.SetProvider(providerID)
255- }
256207 }
257208
258209 func (rt *subagentRuntime) SetProviderSettings(baseURL string, insecureTLS bool) {
@@ -264,9 +215,6 @@ func (rt *subagentRuntime) SetProviderSettings(baseURL string, insecureTLS bool)
264215 rt.baseURL = baseURL
265216 rt.insecureTLS = insecureTLS
266217 rt.settingsMu.Unlock()
267- if rt.supervisor != nil {
268- rt.supervisor.SetProviderSettings(baseURL, insecureTLS)
269- }
270218 }
271219
272220 func (rt *subagentRuntime) SetFastMode(fastMode bool) {
@@ -276,9 +224,6 @@ func (rt *subagentRuntime) SetFastMode(fastMode bool) {
276224 rt.settingsMu.Lock()
277225 rt.fastMode = fastMode
278226 rt.settingsMu.Unlock()
279- if rt.supervisor != nil {
280- rt.supervisor.SetFastMode(fastMode)
281- }
282227 }
283228
284229 func (rt *subagentRuntime) SetRepoRoot(repoRoot string) {
@@ -288,9 +233,6 @@ func (rt *subagentRuntime) SetRepoRoot(repoRoot string) {
288233 rt.settingsMu.Lock()
289234 rt.repoRoot = repoRoot
290235 rt.settingsMu.Unlock()
291- if rt.supervisor != nil {
292- rt.supervisor.SetRepoRoot(repoRoot)
293- }
294236 }
295237
296238 // snapshotConfiguration captures the runtime settings changed while an agent
@@ -326,19 +268,91 @@ func (rt *subagentRuntime) restoreConfiguration(snapshot subagentRuntimeConfigur
326268 rt.repoRoot = snapshot.repoRoot
327269 rt.webSearchPolicy = snapshot.webSearchPolicy
328270 rt.settingsMu.Unlock()
329- if rt.supervisor != nil {
330- rt.supervisor.SetProvider(snapshot.provider)
331- rt.supervisor.SetProviderSettings(snapshot.baseURL, snapshot.insecureTLS)
332- rt.supervisor.SetFastMode(snapshot.fastMode)
333- rt.supervisor.SetRepoRoot(snapshot.repoRoot)
334- rt.supervisor.SetWebSearchPolicy(snapshot.webSearchPolicy)
335- }
336271 }
337272
338273 func (rt *subagentRuntime) SetActiveSession(sessionID string) {
339- if rt != nil && rt.supervisor != nil {
340- rt.supervisor.SetActiveSession(sessionID)
274+ if rt == nil {
275+ return
276+ }
277+ rt.settingsMu.Lock()
278+ rt.activeSession = strings.TrimSpace(sessionID)
279+ rt.settingsMu.Unlock()
280+}
281+
282+func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request tools.ResidentSpawnRequest, catalogue core.Registry) (subagents.ResidentChildSpec, error) {
283+ if rt == nil {
284+ return subagents.ResidentChildSpec{}, errors.New("resident runtime is unavailable")
285+ }
286+ rt.settingsMu.RLock()
287+ activeProvider := strings.TrimSpace(rt.provider)
288+ providerID, model, reasoning := activeProvider, rt.model, rt.reasoning
289+ baseURL, insecureTLS := rt.baseURL, rt.insecureTLS
290+ workspace, parentSession := rt.repoRoot, rt.activeSession
291+ fastMode, policy := rt.fastMode, rt.policy
292+ rt.settingsMu.RUnlock()
293+ if strings.TrimSpace(request.Provider) != "" {
294+ providerID = strings.TrimSpace(request.Provider)
295+ }
296+ if strings.TrimSpace(request.Model) != "" {
297+ model = request.Model
298+ }
299+ if strings.TrimSpace(request.Reasoning) != "" {
300+ reasoning = request.Reasoning
301+ }
302+ if request.FastMode != nil {
303+ fastMode = *request.FastMode
304+ }
305+ if strings.TrimSpace(providerID) == "" || strings.TrimSpace(model) == "" {
306+ return subagents.ResidentChildSpec{}, errors.New("resident child needs a provider and model")
307+ }
308+ if providerID != activeProvider {
309+ // A custom endpoint and TLS exception belong to the provider that
310+ // declared them. Never carry a parent route into an explicitly
311+ // selected provider: Resolve must use that provider's own settings.
312+ baseURL, insecureTLS = "", false
313+ }
314+ allTools := make([]string, 0, len(catalogue))
315+ for name := range catalogue {
316+ switch name {
317+ case tools.SubagentSpawnToolName, tools.SubagentStatusToolName, tools.SubagentStopToolName, tools.SubagentResumeToolName, "update_goal":
318+ continue
319+ }
320+ allTools = append(allTools, name)
321+ }
322+ sort.Strings(allTools)
323+ permitted := func(name string) bool { return policy.AllowsTool(name) }
324+ var childTools []string
325+ var err error
326+ if request.Profile == nil || !request.Profile.ToolsDeclared {
327+ for _, name := range allTools {
328+ if permitted(name) {
329+ childTools = append(childTools, name)
330+ }
331+ }
332+ } else {
333+ childTools, err = subagents.ResolveProfileTools(request.Profile, allTools, permitted)
334+ if err != nil {
335+ return subagents.ResidentChildSpec{}, err
336+ }
337+ }
338+ workspaceMode := request.WorkspaceMode
339+ if workspaceMode == "" {
340+ workspaceMode = subagents.WorkspaceShared
341341 }
342+ spec := subagents.ResidentChildSpec{
343+ ID: uuid.NewString(), SessionID: uuid.NewString(), ParentSessionID: parentSession,
344+ Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model),
345+ Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools,
346+ RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required,
347+ }
348+ if request.Profile != nil {
349+ spec.Profile = request.Profile.Name
350+ spec.SystemPrompt = request.Profile.SystemPrompt
351+ spec.SystemPromptMode = request.Profile.SystemPromptMode
352+ spec.InheritProjectContext = request.Profile.InheritProjectContext
353+ spec.InheritSkills = request.Profile.InheritSkills
354+ }
355+ return spec, nil
342356 }
343357
344358 func (rt *subagentRuntime) SetWebSearchPolicy(policy subagents.WebSearchPolicy) {
@@ -348,36 +362,6 @@ func (rt *subagentRuntime) SetWebSearchPolicy(policy subagents.WebSearchPolicy)
348362 rt.settingsMu.Lock()
349363 rt.webSearchPolicy = policy
350364 rt.settingsMu.Unlock()
351- if rt.supervisor != nil {
352- rt.supervisor.SetWebSearchPolicy(policy)
353- }
354-}
355-
356-func (rt *subagentRuntime) resolveCredential(ctx context.Context, providerID string) (subagents.Credential, error) {
357- childProvider := canonicalProvider(providerID)
358- if childProvider == "" {
359- childProvider = canonicalProvider(rt.currentProvider())
360- }
361- explicit := ""
362- // A launch-time API key is scoped to the provider selected at startup.
363- // Never send it to an explicitly different child provider; resolve that
364- // provider's own credential instead.
365- if rt.apiKey != "" && childProvider == rt.apiKeyProvider {
366- explicit = rt.apiKey
367- }
368- if childProvider == "ollama" && explicit == "" {
369- return subagents.Credential{Value: "ollama", Method: "apikey"}, nil
370- }
371- credential, method, accountID, err := ResolveCredentialFullContext(ctx, childProvider, explicit)
372- if err != nil {
373- // Providers backed only by a local endpoint do not need a key. Leave
374- // stdin untouched and let the child resolve that endpoint.
375- if !CredentialAvailable(childProvider) {
376- return subagents.Credential{}, nil
377- }
378- return subagents.Credential{}, err
379- }
380- return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil
381365 }
382366
383367 func (rt *subagentRuntime) resolveSubagent(name string) (*subagents.Profile, error) {
@@ -394,13 +378,13 @@ func (rt *subagentRuntime) resolveSubagent(name string) (*subagents.Profile, err
394378 // policy. It deliberately mutates the supplied registry, matching the normal
395379 // interactive registry assembly path.
396380 func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry {
397- if reg == nil || rt == nil || rt.supervisor == nil || !autoSubagentsAnyToolAllowed(rt.args) {
381+ if reg == nil || rt == nil || rt.resident == nil || !autoSubagentsAnyToolAllowed(rt.args) {
398382 return reg
399383 }
400384 if autoSubagentsToolAllowed(rt.args) {
401385 spawn := &tools.SubagentSpawnTool{
402- Supervisor: rt.supervisor,
403- Enabled: func() bool { return true },
386+ ResidentManager: rt.residentManagerForTools(),
387+ Enabled: func() bool { return true },
404388 DefaultModel: func() string {
405389 return rt.currentModel()
406390 },
@@ -411,31 +395,31 @@ func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry {
411395 return rt.currentReasoning()
412396 },
413397 ResolveSubagent: rt.resolveSubagent,
414- OnSpawned: rt.onSpawned,
398+ BuildResidentSpec: func(ctx context.Context, request tools.ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
399+ return rt.buildResidentChildSpec(ctx, request, reg)
400+ },
401+ OnResidentSpawned: rt.onResidentSpawned,
415402 }
416403 reg[spawn.Name()] = spawn
417404 }
418405 if autoSubagentsStatusToolAllowed(rt.args) {
419406 status := &tools.SubagentStatusTool{
420- Supervisor: rt.supervisor,
421- Enabled: func() bool { return true },
407+ ResidentManager: rt.residentManagerForTools(),
408+ Enabled: func() bool { return true },
422409 }
423410 reg[status.Name()] = status
424411 }
425412 if autoSubagentsStopToolAllowed(rt.args) {
426413 stop := &tools.SubagentStopTool{
427- Supervisor: rt.supervisor,
414+ ResidentManager: rt.residentManagerForTools(),
428415 Enabled: func() bool { return true },
429- OnStopRequested: rt.onStopRequested,
430416 }
431417 reg[stop.Name()] = stop
432418 }
433419 if autoSubagentsResumeToolAllowed(rt.args) {
434420 resume := &tools.SubagentResumeTool{
435- Supervisor: rt.supervisor,
436- Enabled: func() bool { return true },
437- BeforeResumed: rt.beforeResumed,
438- OnResumed: rt.onResumed,
421+ ResidentManager: rt.residentManagerForTools(),
422+ Enabled: func() bool { return true },
439423 }
440424 reg[resume.Name()] = resume
441425 }
@@ -451,10 +435,6 @@ func (rt *subagentRuntime) PrepareRegistry(reg core.Registry) core.Registry {
451435 }
452436
453437 func (rt *subagentRuntime) PrepareResolvedRegistry(reg core.Registry, policy subagents.WebSearchPolicy) core.Registry {
454- // Keep the supervisor's capability policy in lockstep with the resolved
455- // parent registry. This is especially important after entry.pre refreshes
456- // permissions: workers launched after the refresh must not retain the old
457- // parent allow decision.
458438 if rt != nil {
459439 rt.SetWebSearchPolicy(webSearchPolicyForRegistry(policy, reg))
460440 }
@@ -465,7 +445,7 @@ func (rt *subagentRuntime) PrepareResolvedRegistry(reg core.Registry, policy sub
465445 }
466446
467447 func (rt *subagentRuntime) Close(ctx context.Context) error {
468- if rt == nil || rt.supervisor == nil {
448+ if rt == nil {
469449 return nil
470450 }
471451 rt.closeMu.Lock()
@@ -473,13 +453,12 @@ func (rt *subagentRuntime) Close(ctx context.Context) error {
473453 if rt.closed {
474454 return nil
475455 }
476- stopErr := rt.supervisor.StopAllContext(ctx)
477- closeErr := rt.supervisor.CloseContext(ctx)
478- if err := errors.Join(stopErr, closeErr); err != nil {
479- return err
456+ var err error
457+ if rt.resident != nil {
458+ err = errors.Join(err, rt.resident.Close(ctx))
480459 }
481460 rt.closed = true
482- return nil
461+ return err
483462 }
484463
485464 func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
@@ -494,18 +473,9 @@ func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
494473 return parsed
495474 }
496475 return subagents.SubagentPolicy{
497- MaxConcurrent: cfg.MaxConcurrent,
498- MaxConcurrentPerParent: cfg.MaxConcurrentPerParent,
499- QueueTimeout: parseDuration(cfg.QueueTimeout),
500- StartupTimeout: parseDuration(cfg.StartupTimeout),
501- DefaultTimeout: parseDuration(cfg.DefaultTimeout),
502- MaxOutputBytes: cfg.MaxOutputBytes,
503- MaxOutputLines: cfg.MaxOutputLines,
504- AllowedTools: append([]string(nil), cfg.AllowedTools...),
505- AllowedRoots: append([]string(nil), cfg.AllowedRoots...),
506- HeartbeatInterval: parseDuration(cfg.HeartbeatInterval),
507- IdleTimeout: parseDuration(cfg.IdleTimeout),
508- ReconnectTimeout: parseDuration(cfg.ReconnectTimeout),
509- CancelGracePeriod: parseDuration(cfg.CancelGracePeriod),
476+ MaxConcurrent: cfg.MaxConcurrent,
477+ QueueTimeout: parseDuration(cfg.QueueTimeout),
478+ AllowedTools: append([]string(nil), cfg.AllowedTools...),
479+ AllowedRoots: append([]string(nil), cfg.AllowedRoots...),
510480 }
511481 }
M packages/agent/subagent_runtime_test.go +20 -400
@@ -2,421 +2,41 @@ package agent
22
33 import (
44 "context"
5- "encoding/json"
6- "errors"
7- "os"
8- "path/filepath"
9- "sync"
10- "sync/atomic"
115 "testing"
12- "time"
136
14- "github.com/bnema/zut/packages/agent/subagents"
157 "github.com/bnema/zut/packages/agent/tools"
168 "github.com/bnema/zut/packages/core"
179 )
1810
19-type runtimeTestRunner struct {
20- started chan struct{}
21- startOne sync.Once
22- canceled atomic.Int32
23- block bool
24-}
25-
26-type stubbornRuntimeTestRunner struct {
27- started chan struct{}
28- release chan struct{}
29-}
30-
31-func (r *runtimeTestRunner) Run(ctx context.Context, _ subagents.Sink) error {
32- if r.started != nil {
33- r.startOne.Do(func() { close(r.started) })
34- }
35- if !r.block {
36- return nil
37- }
38- <-ctx.Done()
39- r.canceled.Add(1)
40- return ctx.Err()
41-}
42-
43-func (r *stubbornRuntimeTestRunner) Run(context.Context, subagents.Sink) error {
44- close(r.started)
45- <-r.release
46- return nil
47-}
48-
49-func newRuntimeTestConfig(root, repoRoot string, runner func(*subagents.Agent) subagents.Runner) subagentRuntimeConfig {
50- return subagentRuntimeConfig{
51- Context: context.Background(),
52- Root: root,
53- RepoRoot: repoRoot,
54- Provider: "parent-provider",
55- Model: "parent-model",
56- Reasoning: "high",
57- BaseURL: "https://parent.example/v1",
58- InsecureTLS: true,
59- FastMode: true,
60- Policy: subagents.SubagentPolicy{
61- MaxConcurrent: 2,
62- DefaultTimeout: time.Minute,
63- CancelGracePeriod: time.Second,
64- },
65- NewRunner: runner,
66- }
67-}
68-
69-func TestSubagentRuntimeLaunchCredentialStaysWithLaunchProvider(t *testing.T) {
70- activeProvider := "parent-provider"
71- cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
72- return &runtimeTestRunner{}
11+func TestResidentChildSpecSnapshotsCurrentProviderTransportSettings(t *testing.T) {
12+ runtime := newSubagentRuntime(subagentRuntimeConfig{
13+ Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
14+ Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://old.example/v1", InsecureTLS: false,
7315 })
74- cfg.APIKey = "launch-key"
75- cfg.ActiveProvider = func() string { return activeProvider }
76- rt := newSubagentRuntime(cfg)
77- defer func() { _ = rt.Close(context.Background()) }()
78-
79- credential, err := rt.resolveCredential(context.Background(), "parent-provider")
16+ t.Cleanup(func() { _ = runtime.Close(context.Background()) })
17+ runtime.SetProviderSettings("https://current.example/v1", true)
18+ spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})
8019 if err != nil {
81- t.Fatalf("launch provider credential: %v", err)
82- }
83- if credential.Value != "launch-key" {
84- t.Fatalf("launch provider credential = %q, want launch key", credential.Value)
85- }
86-
87- activeProvider = "later-provider"
88- for _, providerID := range []string{"", "later-provider"} {
89- credential, err = rt.resolveCredential(context.Background(), providerID)
90- if err != nil {
91- t.Fatalf("later provider credential (%q): %v", providerID, err)
92- }
93- if credential.Value == "launch-key" {
94- t.Fatalf("launch API key leaked to provider selected after construction (%q)", providerID)
95- }
96- }
97-}
98-
99-func TestSubagentRuntimeOwnsSupervisorConfigurationAndResolution(t *testing.T) {
100- root := t.TempDir()
101- repoRoot := t.TempDir()
102- var captured *subagents.Agent
103- runner := func(a *subagents.Agent) subagents.Runner {
104- captured = a
105- return &runtimeTestRunner{}
106- }
107- rt := newSubagentRuntime(newRuntimeTestConfig(root, repoRoot, runner))
108- if rt == nil || rt.Supervisor() == nil {
109- t.Fatal("runtime did not create a supervisor")
110- }
111- defer func() { _ = rt.Close(context.Background()) }()
112-
113- registry := rt.InjectTools(core.NewRegistry())
114- tool, ok := registry["subagent_spawn"]
115- if !ok {
116- t.Fatal("runtime did not inject canonical spawn tool")
117- }
118- if _, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"configure child"}`), nil); err != nil {
119- t.Fatalf("spawn tool: %v", err)
120- }
121- if captured == nil {
122- t.Fatal("runner did not receive an agent")
123- }
124- if captured.Provider != "parent-provider" || captured.Model != "parent-model" || captured.Reasoning != "high" {
125- t.Fatalf("child model settings = provider=%q model=%q reasoning=%q", captured.Provider, captured.Model, captured.Reasoning)
126- }
127- if captured.BaseURL != "https://parent.example/v1" || !captured.InsecureTLS || !captured.FastMode {
128- t.Fatalf("child connection/settings = base_url=%q insecure_tls=%v fast_mode=%v", captured.BaseURL, captured.InsecureTLS, captured.FastMode)
20+ t.Fatal(err)
12921 }
130- if captured.Dir != repoRoot {
131- t.Fatalf("child repo root = %q, want %q", captured.Dir, repoRoot)
22+ if spec.BaseURL != "https://current.example/v1" || !spec.InsecureTLS {
23+ t.Fatalf("resident spec transport = %q insecure=%t", spec.BaseURL, spec.InsecureTLS)
13224 }
13325 }
13426
135-func TestSubagentRuntimeConfigurationConcurrentAccess(t *testing.T) {
136- cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
137- return &runtimeTestRunner{}
138- })
139- rt := newSubagentRuntime(cfg)
140- defer func() { _ = rt.Close(context.Background()) }()
141- repoRoot := t.TempDir()
142-
143- var wg sync.WaitGroup
144- wg.Add(2)
145- go func() {
146- defer wg.Done()
147- for index := 0; index < 100; index++ {
148- rt.SetProvider("provider")
149- rt.SetModel("model")
150- rt.SetReasoning("medium")
151- rt.SetProviderSettings("https://provider.example/v1", index%2 == 0)
152- rt.SetFastMode(index%2 == 0)
153- rt.SetRepoRoot(repoRoot)
154- rt.SetWebSearchPolicy(subagents.WebSearchDeny)
155- }
156- }()
157- go func() {
158- defer wg.Done()
159- for index := 0; index < 100; index++ {
160- _ = rt.currentProvider()
161- _ = rt.currentModel()
162- _ = rt.currentReasoning()
163- _ = rt.snapshotConfiguration()
164- }
165- }()
166- wg.Wait()
167-}
168-
169-func TestSubagentRuntimeResolvedWebSearchPolicyRefreshesSupervisor(t *testing.T) {
170- var captured []*subagents.Agent
171- cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(a *subagents.Agent) subagents.Runner {
172- captured = append(captured, a)
173- return &runtimeTestRunner{}
174- })
175- cfg.WebSearchPolicy = subagents.WebSearchAllow
176- rt := newSubagentRuntime(cfg)
177- defer func() { _ = rt.Close(context.Background()) }()
178-
179- allowedRegistry := core.Registry{"web_search": &recordingWebSearchTool{}}
180- rt.PrepareResolvedRegistry(allowedRegistry, subagents.WebSearchAllow)
181- if rt.Supervisor().WebSearchPolicy() != subagents.WebSearchAllow {
182- t.Fatal("resolved allow policy was not propagated to supervisor")
183- }
184- allowed, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
185- Task: "allowed child",
186- Tools: []string{"web_search"},
27+func TestResidentChildSpecDoesNotCarryTransportAcrossProviderOverride(t *testing.T) {
28+ runtime := newSubagentRuntime(subagentRuntimeConfig{
29+ Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
30+ Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://parent.example/v1", InsecureTLS: true,
18731 })
32+ t.Cleanup(func() { _ = runtime.Close(context.Background()) })
33+ spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
34+ Task: "review", Provider: "anthropic", Model: "claude-sonnet-4-5",
35+ }, core.Registry{"read": nil})
18836 if err != nil {
189- t.Fatalf("spawn allowed child: %v", err)
190- }
191- if allowed.WebSearchPolicy != subagents.WebSearchAllow {
192- t.Fatalf("allowed child policy = %v, want allow", allowed.WebSearchPolicy)
193- }
194-
195- deniedRegistry := core.Registry{"web_search": &recordingWebSearchTool{}}
196- rt.PrepareResolvedRegistry(deniedRegistry, subagents.WebSearchDeny)
197- if _, ok := deniedRegistry["web_search"]; ok {
198- t.Fatal("resolved deny policy left web_search in parent registry")
199- }
200- if rt.Supervisor().WebSearchPolicy() != subagents.WebSearchDeny {
201- t.Fatal("resolved deny policy was not propagated to supervisor")
202- }
203- denied, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
204- Task: "denied child",
205- Tools: []string{"web_search"},
206- })
207- if err != nil {
208- t.Fatalf("spawn denied child: %v", err)
209- }
210- if denied.WebSearchPolicy != subagents.WebSearchDeny {
211- t.Fatalf("denied child policy = %v, want deny", denied.WebSearchPolicy)
212- }
213- if len(captured) != 2 {
214- t.Fatalf("captured children = %d, want 2", len(captured))
215- }
216-}
217-
218-func TestSubagentRuntimeInjectsCanonicalToolsAccordingToPolicy(t *testing.T) {
219- cases := []struct {
220- name string
221- args Args
222- want []string
223- }{
224- {name: "default", want: []string{"subagent_spawn", "subagent_status", "subagent_stop", "subagent_resume"}},
225- {name: "explicit spawn and status", args: Args{ToolsSet: true, Tools: []string{"subagent_spawn", "subagent_status"}}, want: []string{"subagent_spawn", "subagent_status"}},
226- {name: "no tools", args: Args{NoTools: true}},
227- {name: "permission set", args: Args{PermissionSet: &tools.PermissionSet{}}},
228- }
229- for _, tc := range cases {
230- t.Run(tc.name, func(t *testing.T) {
231- cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
232- return &runtimeTestRunner{}
233- })
234- cfg.Args = tc.args
235- rt := newSubagentRuntime(cfg)
236- defer func() { _ = rt.Close(context.Background()) }()
237- got := rt.InjectTools(core.NewRegistry())
238- for _, name := range tc.want {
239- if _, ok := got[name]; !ok {
240- t.Errorf("missing injected tool %q in %#v", name, got)
241- }
242- }
243- if len(got) != len(tc.want) {
244- t.Errorf("injected tools = %#v, want exactly %v", got, tc.want)
245- }
246- })
247- }
248-}
249-
250-func TestSubagentRuntimeFailedTransitionRestoresConfiguration(t *testing.T) {
251- rt := newSubagentRuntime(newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
252- return &runtimeTestRunner{}
253- }))
254- defer func() { _ = rt.Close(context.Background()) }()
255- before := rt.snapshotConfiguration()
256-
257- if err := func() error {
258- rt.SetProvider("candidate-provider")
259- rt.SetProviderSettings("https://candidate.example/v1", false)
260- rt.SetFastMode(false)
261- rt.SetRepoRoot(t.TempDir())
262- rt.SetWebSearchPolicy(subagents.WebSearchDeny)
263- return errors.New("candidate transition failed")
264- }(); err != nil {
265- rt.restoreConfiguration(before)
266- }
267-
268- if got := rt.snapshotConfiguration(); got != before {
269- t.Fatalf("runtime configuration after rollback = %+v, want %+v", got, before)
270- }
271- if got := rt.Supervisor().WebSearchPolicy(); got != before.webSearchPolicy {
272- t.Fatalf("supervisor web-search policy after rollback = %v, want %v", got, before.webSearchPolicy)
273- }
274-}
275-
276-func TestSubagentRuntimeFreshShutdownContextCleansCanceledParent(t *testing.T) {
277- runner := &runtimeTestRunner{started: make(chan struct{}), block: true}
278- cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner { return runner })
279- rt := newSubagentRuntime(cfg)
280- spawn := rt.InjectTools(core.NewRegistry())["subagent_spawn"]
281- if _, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"cancel me"}`), nil); err != nil {
282- t.Fatal(err)
283- }
284- select {
285- case <-runner.started:
286- case <-time.After(time.Second):
287- t.Fatal("runner did not start")
288- }
289- if err := closeSubagentRuntimeFresh(rt); err != nil {
290- t.Fatalf("fresh shutdown: %v", err)
291- }
292- if got := runner.canceled.Load(); got != 1 {
293- t.Fatalf("runner cancellation count = %d, want 1", got)
294- }
295-}
296-
297-func TestSubagentRuntimeCloseRetriesAfterCanceledContext(t *testing.T) {
298- runner := &stubbornRuntimeTestRunner{started: make(chan struct{}), release: make(chan struct{})}
299- cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner { return runner })
300- rt := newSubagentRuntime(cfg)
301- spawn := rt.InjectTools(core.NewRegistry())["subagent_spawn"]
302- if _, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"retry cleanup"}`), nil); err != nil {
303- t.Fatal(err)
304- }
305- select {
306- case <-runner.started:
307- case <-time.After(time.Second):
308- t.Fatal("runner did not start")
309- }
310-
311- canceled, cancel := context.WithCancel(context.Background())
312- cancel()
313- if err := rt.Close(canceled); !errors.Is(err, context.Canceled) {
314- t.Fatalf("first Close error = %v, want context canceled", err)
315- }
316- close(runner.release)
317- if err := rt.Close(context.Background()); err != nil {
318- t.Fatalf("retry Close: %v", err)
319- }
320- if err := rt.Close(context.Background()); err != nil {
321- t.Fatalf("idempotent Close: %v", err)
322- }
323-}
324-
325-func TestSubagentRuntimeCallbacksAndExactlyOnceClose(t *testing.T) {
326- root := t.TempDir()
327- runner := &runtimeTestRunner{started: make(chan struct{}), block: true}
328- var spawned atomic.Int32
329- var stopped atomic.Int32
330- cfg := newRuntimeTestConfig(root, t.TempDir(), func(*subagents.Agent) subagents.Runner { return runner })
331- cfg.OnSpawned = func(_ *subagents.Agent, _ string, required bool) {
332- if required {
333- t.Errorf("optional spawn callback required = true")
334- }
335- spawned.Add(1)
336- }
337- cfg.OnStopRequested = func(*subagents.Agent) { stopped.Add(1) }
338- rt := newSubagentRuntime(cfg)
339- registry := rt.InjectTools(core.NewRegistry())
340- spawn, ok := registry["subagent_spawn"]
341- if !ok {
342- t.Fatal("spawn tool was not injected")
343- }
344- if _, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"callback task"}`), nil); err != nil {
345- t.Fatalf("spawn tool: %v", err)
346- }
347- select {
348- case <-runner.started:
349- case <-time.After(time.Second):
350- t.Fatal("runner did not start")
351- }
352- if got := spawned.Load(); got != 1 {
353- t.Fatalf("OnSpawned calls = %d, want 1", got)
354- }
355-
356- closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
357- defer cancel()
358- if err := rt.Close(closeCtx); err != nil {
359- t.Fatalf("first Close: %v", err)
360- }
361- if err := rt.Close(context.Background()); err != nil {
362- t.Fatalf("second Close: %v", err)
363- }
364- if got := runner.canceled.Load(); got != 1 {
365- t.Fatalf("runner cancellation count = %d, want exactly once", got)
366- }
367- if got := stopped.Load(); got != 0 {
368- t.Fatalf("unexpected stop callback count = %d", got)
369- }
370-}
371-
372-func TestSubagentRuntimeInjectedSpawnProfileInheritsAndOverridesFrontmatter(t *testing.T) {
373- cwd := t.TempDir()
374- profilesDir := filepath.Join(cwd, ".agents")
375- if err := os.MkdirAll(profilesDir, 0o755); err != nil {
37637 t.Fatal(err)
37738 }
378- writeRuntimeProfile := func(name, frontmatter string) {
379- t.Helper()
380- contents := "---\nname: " + name + "\n" + frontmatter + "---\nDo the work.\n"
381- if err := os.WriteFile(filepath.Join(profilesDir, name+".md"), []byte(contents), 0o644); err != nil {
382- t.Fatal(err)
383- }
384- }
385- writeRuntimeProfile("inherited", "")
386- writeRuntimeProfile("overridden", "model: override-model\nprovider: override-provider\nthinking: low\n")
387- t.Setenv("ZUT_AGENT_PROFILES", profilesDir)
388-
389- var captured []*subagents.Agent
390- cfg := newRuntimeTestConfig(t.TempDir(), cwd, func(a *subagents.Agent) subagents.Runner {
391- captured = append(captured, a)
392- return &runtimeTestRunner{}
393- })
394- cfg.Reasoning = "high"
395- rt := newSubagentRuntime(cfg)
396- // Change the host-owned setting after runtime construction. A child without
397- // a profile override must observe this new live value.
398- rt.SetReasoning("medium")
399- defer func() { _ = rt.Close(context.Background()) }()
400- spawn := rt.InjectTools(core.NewRegistry())["subagent_spawn"]
401-
402- for _, name := range []string{"inherited", "overridden"} {
403- raw := []byte(`{"task":"` + name + ` task","agent":"` + name + `"}`)
404- result, err := spawn.Execute(context.Background(), raw, nil)
405- if err != nil {
406- t.Fatalf("spawn %s: %v", name, err)
407- }
408- if result.IsError {
409- t.Fatalf("spawn %s returned tool error: %#v", name, result)
410- }
411- }
412- if len(captured) != 2 {
413- t.Fatalf("captured agents = %d, want 2", len(captured))
414- }
415- inherited, overridden := captured[0], captured[1]
416- if inherited.Provider != "parent-provider" || inherited.Model != "parent-model" || inherited.Reasoning != "medium" {
417- t.Fatalf("inherited profile settings = provider=%q model=%q reasoning=%q", inherited.Provider, inherited.Model, inherited.Reasoning)
418- }
419- if overridden.Provider != "override-provider" || overridden.Model != "override-model" || overridden.Reasoning != "low" {
420- t.Fatalf("overridden profile settings = provider=%q model=%q reasoning=%q", overridden.Provider, overridden.Model, overridden.Reasoning)
39+ if spec.BaseURL != "" || spec.InsecureTLS {
40+ t.Fatalf("cross-provider spec inherited transport: %#v", spec)
42141 }
42242 }
D packages/agent/subagent_worker.go +0 -726
@@ -1,726 +0,0 @@
1-package agent
2-
3-import (
4- "context"
5- "encoding/json"
6- "errors"
7- "fmt"
8- "io"
9- "os"
10- "strconv"
11- "strings"
12- "sync"
13- "time"
14- "unicode/utf8"
15-
16- "github.com/bnema/zut/packages/agent/modes"
17- "github.com/bnema/zut/packages/agent/subagents"
18- "github.com/bnema/zut/packages/core"
19- "github.com/bnema/zut/packages/provider"
20-)
21-
22-// runSubagentWorkerMode is the daemon-mode entry point used by every
23-// subagent-spawned zut subprocess. It's intentionally close in shape to
24-// runJSONMode but with two key differences:
25-//
26-// - Lifetime: the process stays alive across many user turns. The
27-// initial positional task (if any) is the first turn; subsequent
28-// turns arrive through the inbox unix socket at args.SubagentWorker.
29-//
30-// - Output: every emitted JSON line goes to the parent supervisor, which
31-// owns the optional execution trace. Workers never persist trace data.
32-//
33-// The runner in packages/agent/subagents/runner.go is the only caller in
34-// production; tests use the stubchild binary under
35-// packages/agent/subagents/testdata/cmd/stubchild instead of the real model
36-// loop.
37-// workerTurnCounters track stable delegated-turn identity and observability.
38-// Retries, compaction, and provider/tool loops remain inside one message turn.
39-type workerTurnCounters struct {
40- sequence int
41- lifetime int
42- current int
43-}
44-
45-type sessionPersistenceState struct {
46- mu sync.Mutex
47- errValue error
48-}
49-
50-func (s *sessionPersistenceState) record(err error) {
51- if err == nil {
52- return
53- }
54- s.mu.Lock()
55- if s.errValue == nil {
56- s.errValue = err
57- }
58- s.mu.Unlock()
59-}
60-
61-func (s *sessionPersistenceState) failed() bool {
62- s.mu.Lock()
63- defer s.mu.Unlock()
64- return s.errValue != nil
65-}
66-
67-func (s *sessionPersistenceState) err() error {
68- s.mu.Lock()
69- defer s.mu.Unlock()
70- return s.errValue
71-}
72-
73-func (b *workerTurnCounters) start(newRun bool) (step, lifetime, current int) {
74- if newRun {
75- b.current = 0
76- }
77- b.sequence++
78- b.lifetime++
79- b.current++
80- return b.sequence, b.lifetime, b.current
81-}
82-
83-func subagentTurnContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
84- if timeout > 0 {
85- return context.WithTimeout(parent, timeout)
86- }
87- return context.WithCancel(parent)
88-}
89-
90-func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runErr error) {
91- if args.SubagentWorker == "" {
92- return fmt.Errorf("--subagent-worker requires a socket path")
93- }
94- if os.Getenv("ZUT_SUBAGENT_CREDENTIAL_STDIN") == "1" {
95- var inherited subagents.Credential
96- dec := json.NewDecoder(io.LimitReader(os.Stdin, 1<<20))
97- if err := dec.Decode(&inherited); err != nil {
98- return fmt.Errorf("read inherited subagent credential: %w", err)
99- }
100- if inherited.Value == "" || inherited.Method == "" {
101- return fmt.Errorf("read inherited subagent credential: missing value or method")
102- }
103- args.inheritedCredential = inherited.Value
104- args.inheritedAuthMethod = inherited.Method
105- args.inheritedAccountID = inherited.AccountID
106- }
107-
108- cfg, err := loadSubagentWorkerConfig()
109- if err != nil {
110- return err
111- }
112- r, err := Resolve(args, true)
113- if err != nil {
114- return err
115- }
116- // A delegated worker has one bounded task and must not inherit arbitrary
117- // local extensions, their tools, hooks, or persisted extension state.
118- // Extensions remain available to explicitly selected skills only through
119- // the parent process; the worker runs with its resolved built-in registry.
120- ag := r.NewAgent()
121- initialAg := ag
122- defer func() {
123- closeAgentLSP(ag)
124- if ag != initialAg {
125- closeAgentLSP(initialAg)
126- }
127- }()
128- sess, err := openOrCreateSession(ctx, args, r, ag, version)
129- if err != nil {
130- return err
131- }
132- var sessionPersistence sessionPersistenceState
133- if sess != nil {
134- var providerName, model string
135- sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, nil, sess, ag)
136- if err != nil {
137- return err
138- }
139- r.Provider, r.Model = providerName, model
140- // A prompt is acknowledged by the supervisor only after this callback
141- // has appended and synced the matching user message. This closes the
142- // crash window between accepting a follow-up and writing session.json.
143- ag.OnMessageAppended = func(message provider.Message) {
144- if err := sess.AppendMessage(message); err != nil {
145- sessionPersistence.record(err)
146- return
147- }
148- if err := sess.Sync(); err != nil {
149- sessionPersistence.record(err)
150- }
151- }
152- ag.OnUsage = func(usage provider.Usage) {
153- if err := sess.AppendUsage(usage, usage); err != nil {
154- sessionPersistence.record(err)
155- }
156- }
157- ag.OnRetryLifecycle = sess.RecordRetryLifecycle
158- defer func() {
159- runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost()))
160- runErr = joinSessionCloseError(runErr, sess)
161- }()
162- }
163- // Resolve retains metadata for catalog models and for valid synthesized
164- // local/routed/open-catalog models. The active agent also survives a session
165- // resume that rebuilt it for a different provider/model pair.
166- contextWindow := ag.ContextWindow
167-
168- // Open the inbox listener BEFORE emitting agent_ready so the
169- // supervisor can dial through on the very first send. The
170- // subagent supervisor's Inbox retries dialing for a
171- // short window, but emitting ready first and then listening
172- // would still race in tight loops.
173- ln, err := subagents.Listen(args.SubagentWorker)
174- if err != nil {
175- return fmt.Errorf("subagent-worker listen: %w", err)
176- }
177- defer ln.Close()
178-
179- em := newSubagentEmitter(os.Stdout)
180- em.setProtocolIdentity(os.Getenv("ZUT_SUBAGENT_AGENT_ID"))
181- em.emit("agent.ready", map[string]any{
182- "version": version,
183- "cwd": r.CWD,
184- "model": r.Model,
185- "lifetime_turns": args.SubagentLifetimeTurns,
186- "current_run_turns": args.SubagentRunTurns,
187- })
188-
189- // Keep a per-turn cancel so the "cancel" inbox message can interrupt
190- // an in-flight turn without tearing down the whole daemon.
191- var (
192- mu sync.Mutex
193- cancelFn context.CancelFunc
194- busyTurn bool
195- counters = workerTurnCounters{
196- sequence: args.SubagentLifetimeTurns,
197- lifetime: args.SubagentLifetimeTurns,
198- current: args.SubagentRunTurns,
199- }
200- turnWG sync.WaitGroup
201- closing bool
202- turnPending bool
203- seenCommands = make(map[string]struct{})
204- shutdown = make(chan struct{})
205- shutdownOnce sync.Once
206- shutdownOrigin subagents.ShutdownOrigin
207- )
208- maxOutputBytes, maxOutputLines := workerOutputLimits()
209-
210- runOne := func(prompt string, newRun bool, commandID string) {
211- mu.Lock()
212- // launchTurn admits the goroutine to turnWG before it starts so
213- // shutdown can join it. A shutdown can win the scheduling race
214- // after that admission but before this goroutine acquires mu; in
215- // that case do not start a new Prompt after waitForActiveTurn has
216- // declared the worker closing.
217- if closing || busyTurn {
218- mu.Unlock()
219- em.emit("error", map[string]any{"code": "turn_rejected", "reason": "busy", "command_id": commandID, "message": "worker is busy"})
220- return
221- }
222- busyTurn = true
223- turnPending = false
224- step, lifetime, currentRun := counters.start(newRun)
225- turnID := fmt.Sprintf("turn-%d", step)
226- em.setTurnID(turnID)
227- c, cancel := subagentTurnContext(ctx, args.SubagentTurnTimeout)
228- cancelFn = cancel
229- mu.Unlock()
230-
231- turnStarted := false
232- sink := func(ev core.AgentEvent) {
233- data := subagentEventData(ev)
234- if ev.Type() == "user_message" && !turnStarted {
235- // Prompt invokes OnMessageAppended before delivering this sink
236- // event, so the session row is durable before the lifecycle
237- // acknowledgement is written.
238- em.emit(ev.Type(), data)
239- if !sessionPersistence.failed() {
240- em.emit("turn.started", map[string]any{"step": step, "turn_id": turnID, "command_id": commandID, "lifetime_turns": lifetime, "current_run_turns": currentRun})
241- turnStarted = true
242- }
243- return
244- }
245- if ev.Type() == "turn_start" {
246- // A provider/model-loop turn is nested inside the one delegated
247- // message turn admitted above. Mark it on the wire so the
248- // supervisor cannot mistake it for a new worker turn.
249- data["nested_turn"] = true
250- }
251- em.emit(ev.Type(), data)
252- }
253-
254- baseline := workerTranscriptState{
255- messages: ag.Messages(),
256- cost: ag.Cost(),
257- lastTurn: ag.LastTurnUsage(),
258- }
259- var persistCompaction func([]provider.Message) error
260- if sess != nil {
261- // Buffer the checkpoint until the continued turn completes. The final
262- // transcript and cumulative usage are then persisted in one JSONL row.
263- persistCompaction = func([]provider.Message) error {
264- return c.Err()
265- }
266- }
267- recovery, err := promptWithContextRecovery(c, ag, prompt, sink, modes.ContextRecoveryOptions{
268- PersistCompaction: persistCompaction,
269- ContextWindow: contextWindow,
270- AutoCompactThreshold: cfg.AutoCompactThreshold,
271- })
272- output := finalAssistantText(ag.Messages()[recovery.OutputStart:])
273- // Message and usage callbacks persist ordinary turns incrementally. A
274- // compacted turn still needs one atomic replacement checkpoint so the
275- // durable transcript cannot mix generations.
276- if recovery.Compacted || ag.OnMessageAppended == nil {
277- if persistErr := persistWorkerTranscript(c, ag, sess, recovery.OutputStart, recovery.Compacted, baseline); persistErr != nil {
278- if err != nil {
279- err = errors.Join(err, persistErr)
280- } else {
281- err = persistErr
282- }
283- }
284- }
285- if sess != nil {
286- if persistErr := sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost()); persistErr != nil {
287- err = errors.Join(err, persistErr)
288- }
289- }
290- if sessionErr := sessionPersistence.err(); err == nil && sessionErr != nil {
291- err = sessionErr
292- }
293-
294- resultStatus := "succeeded"
295- if err != nil {
296- if errors.Is(err, context.Canceled) {
297- resultStatus = "canceled"
298- } else {
299- resultStatus = "failed"
300- }
301- }
302- mu.Lock()
303- resultShutdownOrigin := shutdownOrigin.Sanitized()
304- mu.Unlock()
305- switch {
306- case errors.Is(err, context.DeadlineExceeded):
307- resultShutdownOrigin = subagents.ShutdownOriginDeadline
308- case !errors.Is(err, context.Canceled):
309- resultShutdownOrigin = ""
310- }
311- resultPayload := map[string]any{
312- "status": resultStatus,
313- "turn_id": turnID,
314- "summary": boundedResultSummary(output),
315- "output": boundedResultOutput(output, maxOutputBytes, maxOutputLines),
316- "error": resultErrorPayload(err, resultShutdownOrigin),
317- }
318- if resultShutdownOrigin != "" {
319- resultPayload["shutdown_origin"] = resultShutdownOrigin
320- }
321- em.emit("turn.result", resultPayload)
322- if err != nil {
323- em.emit("turn.failed", map[string]any{"turn_id": turnID, "error": resultErrorPayload(err, resultShutdownOrigin)})
324- }
325- em.emit("turn_end", map[string]any{
326- "step": step,
327- "turn_id": turnID,
328- "error": turnErrorMessage(err, resultShutdownOrigin),
329- })
330-
331- cancel()
332- mu.Lock()
333- // Clear the emitter identity before reopening the busy gate: a newly
334- // admitted turn must not race this completed turn's cleanup.
335- em.setTurnID("")
336- busyTurn = false
337- cancelFn = nil
338- mu.Unlock()
339- // The supervisor treats agent.idle as permission to start a follow-up.
340- // Publish it only after the busy gate has opened, otherwise a manager
341- // can send a turn that the inbox loop drops as still busy.
342- em.emit("agent.idle", map[string]any{"turn_id": turnID, "lifetime_turns": lifetime, "current_run_turns": currentRun})
343- }
344-
345- launchTurn := func(prompt string, newRun bool, commandID string) bool {
346- mu.Lock()
347- if closing || busyTurn || turnPending {
348- mu.Unlock()
349- em.emit("error", map[string]any{"code": "turn_rejected", "reason": "busy", "command_id": commandID, "message": "worker is busy"})
350- return false
351- }
352- // Reserve the worker before launching the goroutine. A second command
353- // cannot be accepted in the gap before runOne acquires mu.
354- turnPending = true
355- turnWG.Add(1)
356- mu.Unlock()
357- go func() {
358- defer turnWG.Done()
359- runOne(prompt, newRun, commandID)
360- }()
361- return true
362- }
363-
364- waitForActiveTurn := func(cancel bool) {
365- mu.Lock()
366- closing = true
367- activeCancel := cancelFn
368- mu.Unlock()
369- if cancel && activeCancel != nil {
370- activeCancel()
371- }
372- turnWG.Wait()
373- }
374-
375- requestShutdown := func(origin subagents.ShutdownOrigin) {
376- shutdownOnce.Do(func() {
377- mu.Lock()
378- closing = true
379- shutdownOrigin = origin.Sanitized()
380- mu.Unlock()
381- close(shutdown)
382- })
383- }
384-
385- // Initial task: run before processing the inbox so the agent
386- // "starts working" the moment it boots, matching what users
387- // expect from `/subagents new <task>`.
388- if args.Prompt != "" {
389- launchTurn(args.Prompt, false, "")
390- }
391-
392- // Inbox loop: one supervisor message at a time. We don't spawn
393- // a goroutine per turn because runOne already serialises them
394- // via the busyTurn flag; doing the dispatch on the main
395- // goroutine keeps the daemon's lifecycle easy to follow.
396- for {
397- select {
398- case <-ctx.Done():
399- waitForActiveTurn(true)
400- em.emit("agent.exited", map[string]any{"reason": "cancelled"})
401- return ctx.Err()
402- case <-shutdown:
403- // Shutdown is an explicit supervisor stop. Cancel an active
404- // turn before joining it so a detached worker cannot remain
405- // alive indefinitely while the host waits for its socket to
406- // disappear.
407- waitForActiveTurn(true)
408- em.emit("agent.exited", shutdownExitPayload(shutdownOrigin))
409- return nil
410- case msg, ok := <-ln.Lines():
411- if !ok {
412- waitForActiveTurn(true)
413- em.emit("agent.exited", map[string]any{"reason": "inbox-closed"})
414- return nil
415- }
416- command, parseErr := subagents.ParseCommand(msg)
417- if parseErr != nil {
418- em.emit("error", map[string]any{"message": "invalid supervisor command"})
419- continue
420- }
421- switch command.Type {
422- case subagents.CommandAgentShutdown:
423- var payload subagents.AgentShutdownPayload
424- if err := command.DecodePayload(&payload); err != nil {
425- em.emit("error", map[string]any{"message": "invalid agent.shutdown payload"})
426- continue
427- }
428- requestShutdown(payload.Origin)
429- case subagents.CommandTurnCancel:
430- mu.Lock()
431- if cancelFn != nil {
432- cancelFn()
433- }
434- mu.Unlock()
435- case subagents.CommandAgentPing:
436- em.emit("agent.heartbeat", map[string]any{"activity": "alive"})
437- case subagents.CommandTurnStart:
438- var payload subagents.TurnStartPayload
439- if err := command.DecodePayload(&payload); err != nil {
440- em.emit("error", map[string]any{"code": "turn_rejected", "reason": "invalid_payload", "command_id": command.MessageID, "message": "invalid turn.start payload"})
441- continue
442- }
443- mu.Lock()
444- _, duplicate := seenCommands[command.MessageID]
445- mu.Unlock()
446- if duplicate && command.MessageID != "" {
447- continue
448- }
449- if launchTurn(payload.Prompt, payload.NewRun, command.MessageID) && command.MessageID != "" {
450- mu.Lock()
451- seenCommands[command.MessageID] = struct{}{}
452- mu.Unlock()
453- }
454- default:
455- // Unknown commands are rejected rather than interpreted as
456- // child-originated control requests.
457- em.emit("error", map[string]any{"message": "unknown supervisor command"})
458- }
459- }
460- }
461-}
462-
463-// subagentEmitter serialises protocol events to stdout. The supervisor is
464-// the single trace writer; workers never persist execution events themselves.
465-type subagentEmitter struct {
466- mu sync.Mutex
467- w *os.File
468- agentID string
469- turnID string
470-}
471-
472-func newSubagentEmitter(w *os.File) *subagentEmitter {
473- return &subagentEmitter{w: w}
474-}
475-
476-func (e *subagentEmitter) setProtocolIdentity(agentID string) {
477- e.mu.Lock()
478- e.agentID = agentID
479- e.mu.Unlock()
480-}
481-
482-// setTurnID associates subsequent worker events with the currently admitted
483-// delegated turn. Core events do not carry the worker protocol turn ID.
484-func (e *subagentEmitter) setTurnID(turnID string) {
485- e.mu.Lock()
486- e.turnID = turnID
487- e.mu.Unlock()
488-}
489-
490-func (e *subagentEmitter) emit(typ string, data map[string]any) {
491- if data == nil {
492- data = map[string]any{}
493- }
494- payload := make(map[string]any, len(data))
495- for key, value := range data {
496- payload[key] = value
497- }
498- turnID, _ := payload["turn_id"].(string)
499-
500- e.mu.Lock()
501- defer e.mu.Unlock()
502- if turnID == "" {
503- turnID = e.turnID
504- }
505-
506- wireType := canonicalWorkerEvent(typ)
507- envelope := subagents.NewEventEnvelope(wireType, e.agentID, turnID, payload)
508- line, err := subagents.MarshalJSONL(envelope)
509-
510- if err == nil {
511- _, _ = e.w.Write(line)
512- }
513-}
514-
515-func subagentEventData(ev core.AgentEvent) map[string]any {
516- data := modes.EventToJSON(ev)
517- if turnEnd, ok := ev.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) {
518- data["error"] = subagentContextLimitMessage
519- }
520- return data
521-}
522-
523-func canonicalWorkerEvent(typ string) string {
524- switch typ {
525- case "agent_ready":
526- return subagents.EventAgentReady
527- case "agent_stopped":
528- return subagents.EventAgentExited
529- case "turn_start":
530- return subagents.EventTurnStarted
531- case "turn_progress":
532- return subagents.EventTurnProgress
533- case "tool_execution_started":
534- return subagents.EventToolStarted
535- case "tool_result":
536- return subagents.EventToolFinished
537- case "text_delta":
538- return subagents.EventMessageDelta
539- default:
540- return typ
541- }
542-}
543-
544-const subagentContextLimitMessage = "request exceeds the model context window; narrow the task or reduce gathered context"
545-
546-func loadSubagentWorkerConfig() (Config, error) {
547- cfg, err := LoadConfig()
548- if err != nil {
549- return Config{}, fmt.Errorf("load subagent worker config: %w", err)
550- }
551- return cfg, nil
552-}
553-
554-// workerTranscriptState is restored when an atomic post-compaction checkpoint
555-// cannot be persisted.
556-type workerTranscriptState struct {
557- messages []provider.Message
558- cost provider.Usage
559- lastTurn provider.Usage
560-}
561-
562-func persistWorkerTranscript(ctx context.Context, ag *core.Agent, sess *core.Session, from int, compacted bool, baseline workerTranscriptState) error {
563- if sess == nil || ag == nil {
564- return nil
565- }
566- if !compacted {
567- if err := ctx.Err(); err != nil {
568- return err
569- }
570- if err := WriteNewTranscript(ag, sess, from); err != nil {
571- return fmt.Errorf("persist subagent transcript: %w", err)
572- }
573- return nil
574- }
575-
576- rollback := func() {
577- ag.SetMessages(baseline.messages)
578- ag.SeedCost(baseline.cost)
579- ag.SeedLastTurnUsage(baseline.lastTurn)
580- }
581- if err := ctx.Err(); err != nil {
582- rollback()
583- return err
584- }
585- if err := sess.AppendCompactionWithUsage(ag.Messages(), ag.Cost()); err != nil {
586- rollback()
587- return fmt.Errorf("persist compacted subagent transcript: %w", err)
588- }
589- return nil
590-}
591-
592-// promptWithContextRecovery preserves the worker's existing helper boundary
593-// while delegating proactive compaction and one-shot overflow recovery to
594-// modes. The result includes both the output index and compaction state.
595-func promptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, sink func(core.AgentEvent), options modes.ContextRecoveryOptions) (modes.ContextRecoveryResult, error) {
596- return modes.PromptWithContextRecovery(ctx, ag, prompt, nil, sink, options)
597-}
598-
599-func finalAssistantText(messages []provider.Message) string {
600- var out strings.Builder
601- for _, message := range messages {
602- if message.Role != provider.RoleAssistant {
603- continue
604- }
605- for _, block := range message.Content {
606- if text, ok := block.(provider.TextBlock); ok {
607- if out.Len() > 0 {
608- out.WriteByte('\n')
609- }
610- out.WriteString(text.Text)
611- }
612- }
613- }
614- return out.String()
615-}
616-
617-func firstLine(text string) string {
618- return strings.Split(strings.TrimSpace(text), "\n")[0]
619-}
620-
621-func boundedResultSummary(text string) string {
622- return boundedResultOutput(firstLine(text), 4*1024, 1)
623-}
624-
625-func shutdownExitPayload(origin subagents.ShutdownOrigin) map[string]any {
626- payload := map[string]any{"reason": "shutdown"}
627- if origin = origin.Sanitized(); origin != "" {
628- payload["origin"] = origin
629- }
630- return payload
631-}
632-
633-func workerOutputLimits() (maxBytes, maxLines int) {
634- maxBytes, maxLines = 500_000, 5_000
635- if value, err := strconv.Atoi(strings.TrimSpace(os.Getenv("ZUT_SUBAGENT_MAX_OUTPUT_BYTES"))); err == nil && value > 0 {
636- maxBytes = value
637- }
638- if value, err := strconv.Atoi(strings.TrimSpace(os.Getenv("ZUT_SUBAGENT_MAX_OUTPUT_LINES"))); err == nil && value > 0 {
639- maxLines = value
640- }
641- return maxBytes, maxLines
642-}
643-
644-func boundedResultOutput(text string, maxBytes, maxLines int) string {
645- const marker = "...[output truncated]"
646- lines := strings.Split(text, "\n")
647- if maxLines > 0 && len(lines) > maxLines {
648- if maxLines == 1 {
649- text = marker
650- } else {
651- text = strings.Join(append(lines[:maxLines-1], marker), "\n")
652- }
653- }
654- if maxBytes > 0 && len([]byte(text)) > maxBytes {
655- if maxBytes <= len(marker) {
656- return truncateOutputUTF8(text, maxBytes)
657- }
658- return truncateOutputUTF8(text, maxBytes-len(marker)) + marker
659- }
660- return text
661-}
662-
663-func truncateOutputUTF8(value string, maxBytes int) string {
664- if maxBytes <= 0 {
665- return ""
666- }
667- data := []byte(value)
668- if len(data) <= maxBytes {
669- return value
670- }
671- data = data[:maxBytes]
672- for len(data) > 0 && !utf8.Valid(data) {
673- data = data[:len(data)-1]
674- }
675- return string(data)
676-}
677-
678-func resultErrorPayload(err error, shutdownOrigin subagents.ShutdownOrigin) map[string]any {
679- if err == nil {
680- return nil
681- }
682- if provider.IsContextOverflowError(err) {
683- return map[string]any{"code": "context_limit", "message": subagentContextLimitMessage}
684- }
685- if errors.Is(err, core.ErrStreamIdleTimeout) {
686- return map[string]any{"code": "stream_idle_timeout", "message": "provider stream produced no events for five minutes; the delegated turn stopped safely"}
687- }
688- if errors.Is(err, context.DeadlineExceeded) || shutdownOrigin == subagents.ShutdownOriginDeadline {
689- return map[string]any{"code": "deadline_exceeded", "message": "subagent turn deadline exceeded; partial output is preserved in the result and history"}
690- }
691- if errors.Is(err, context.Canceled) {
692- if resultErr := subagents.ShutdownResultError(shutdownOrigin); resultErr != nil {
693- return map[string]any{"code": resultErr.Code, "message": resultErr.Message}
694- }
695- }
696- return map[string]any{"code": "turn_failed", "message": truncateForLog(err.Error(), 500)}
697-}
698-
699-func turnErrorMessage(err error, shutdownOrigin subagents.ShutdownOrigin) string {
700- if provider.IsContextOverflowError(err) {
701- return subagentContextLimitMessage
702- }
703- if err != nil {
704- if resultErr := subagents.ShutdownResultError(shutdownOrigin); resultErr != nil {
705- return resultErr.Message
706- }
707- }
708- return errString(err)
709-}
710-
711-func errString(err error) string {
712- if err == nil {
713- return ""
714- }
715- return err.Error()
716-}
717-
718-func truncateForLog(s string, n int) string {
719- if len(s) <= n {
720- return s
721- }
722- if n <= 3 {
723- return strings.Repeat(".", n)
724- }
725- return s[:n-3] + "..."
726-}
D packages/agent/subagent_worker_test.go +0 -881
@@ -1,881 +0,0 @@
1-package agent
2-
3-import (
4- "bytes"
5- "context"
6- "encoding/json"
7- "errors"
8- "io"
9- "os"
10- "path/filepath"
11- "reflect"
12- "strings"
13- "sync"
14- "testing"
15- "time"
16-
17- "github.com/bnema/zut/packages/agent/modes"
18- "github.com/bnema/zut/packages/agent/subagents"
19- "github.com/bnema/zut/packages/core"
20- "github.com/bnema/zut/packages/provider"
21-)
22-
23-func TestSubagentTurnContextResetsDeadlineAndHonorsShutdown(t *testing.T) {
24- expired, stopExpired := subagentTurnContext(context.Background(), time.Nanosecond)
25- defer stopExpired()
26- <-expired.Done()
27- if !errors.Is(expired.Err(), context.DeadlineExceeded) {
28- t.Fatalf("expired turn error = %v, want context deadline exceeded", expired.Err())
29- }
30-
31- parent, stopParent := context.WithCancel(context.Background())
32- first, stopFirst := subagentTurnContext(parent, time.Hour)
33- if _, ok := first.Deadline(); !ok {
34- t.Fatal("active turn context has no deadline")
35- }
36- stopFirst()
37- if !errors.Is(first.Err(), context.Canceled) {
38- t.Fatalf("first turn error = %v, want context canceled", first.Err())
39- }
40-
41- second, stopSecond := subagentTurnContext(parent, time.Hour)
42- defer stopSecond()
43- select {
44- case <-second.Done():
45- t.Fatalf("new turn inherited previous turn cancellation: %v", second.Err())
46- default:
47- }
48- stopParent()
49- <-second.Done()
50- if !errors.Is(second.Err(), context.Canceled) {
51- t.Fatalf("shutdown error = %v, want context canceled", second.Err())
52- }
53-}
54-
55-func TestResultErrorPayloadClassifiesStreamIdleTimeout(t *testing.T) {
56- payload := resultErrorPayload(core.ErrStreamIdleTimeout, "")
57- if payload["code"] != "stream_idle_timeout" {
58- t.Fatalf("idle timeout error code = %v, want stream_idle_timeout", payload["code"])
59- }
60-}
61-
62-func TestResultErrorPayloadClassifiesTurnDeadline(t *testing.T) {
63- payload := resultErrorPayload(context.DeadlineExceeded, "")
64- if payload["code"] != "deadline_exceeded" {
65- t.Fatalf("deadline error code = %v, want deadline_exceeded", payload["code"])
66- }
67-}
68-
69-func TestResultErrorPayloadAttributesShutdownCancellation(t *testing.T) {
70- payload := resultErrorPayload(context.Canceled, subagents.ShutdownOriginSession)
71- if payload["code"] != "shutdown" || payload["message"] != "subagent stopped during session shutdown" {
72- t.Fatalf("session shutdown error = %#v", payload)
73- }
74-}
75-
76-func TestShutdownExitPayloadIncludesOnlyKnownOrigin(t *testing.T) {
77- payload := shutdownExitPayload(subagents.ShutdownOriginSession)
78- if payload["reason"] != "shutdown" || payload["origin"] != subagents.ShutdownOriginSession {
79- t.Fatalf("known shutdown payload = %#v", payload)
80- }
81- payload = shutdownExitPayload(subagents.ShutdownOrigin("private detail"))
82- if _, ok := payload["origin"]; ok {
83- t.Fatalf("unknown shutdown origin was persisted: %#v", payload)
84- }
85-}
86-
87-// TestWorkerOutputLimitsUseSupervisorPolicy verifies that the child reads
88-// the effective output caps propagated by the supervisor and retains safe
89-// defaults for standalone workers.
90-func TestWorkerOutputLimitsUseSupervisorPolicy(t *testing.T) {
91- t.Setenv("ZUT_SUBAGENT_MAX_OUTPUT_BYTES", "123")
92- t.Setenv("ZUT_SUBAGENT_MAX_OUTPUT_LINES", "7")
93- bytesLimit, linesLimit := workerOutputLimits()
94- if bytesLimit != 123 || linesLimit != 7 {
95- t.Fatalf("worker output limits = (%d, %d), want (123, 7)", bytesLimit, linesLimit)
96- }
97- t.Setenv("ZUT_SUBAGENT_MAX_OUTPUT_BYTES", "invalid")
98- t.Setenv("ZUT_SUBAGENT_MAX_OUTPUT_LINES", "0")
99- bytesLimit, linesLimit = workerOutputLimits()
100- if bytesLimit != 500_000 || linesLimit != 5_000 {
101- t.Fatalf("invalid worker output limits = (%d, %d), want defaults", bytesLimit, linesLimit)
102- }
103-}
104-
105-type subagentContextRecoveryClient struct {
106- mu sync.Mutex
107- calls int
108- requests []provider.Request
109- retried chan provider.Request
110- compactionErr error
111- retryOverflow bool
112-}
113-
114-func (c *subagentContextRecoveryClient) Name() string { return "subagent-context-recovery-test" }
115-
116-func (c *subagentContextRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
117- c.mu.Lock()
118- c.calls++
119- call := c.calls
120- c.requests = append(c.requests, req)
121- c.mu.Unlock()
122-
123- events := make(chan provider.Event, 2)
124- go func() {
125- defer close(events)
126- switch call {
127- case 1:
128- events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("provider error: input exceeds the context window")}
129- case 2:
130- if c.compactionErr != nil {
131- events <- provider.EventDone{Stop: provider.StopError, Err: c.compactionErr}
132- return
133- }
134- events <- provider.EventTextDelta{Delta: "summary"}
135- events <- provider.EventDone{Stop: provider.StopEnd}
136- default:
137- c.retried <- req
138- if c.retryOverflow {
139- events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("context window exceeded after compaction")}
140- return
141- }
142- events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
143- Role: provider.RoleAssistant,
144- Content: []provider.Content{provider.TextBlock{Text: "recovered"}},
145- }}
146- }
147- }()
148- return events, nil
149-}
150-
151-type subagentProactiveClient struct {
152- mu sync.Mutex
153- requests []provider.Request
154-}
155-
156-func (c *subagentProactiveClient) Name() string { return "subagent-proactive-test" }
157-
158-func (c *subagentProactiveClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
159- c.mu.Lock()
160- c.requests = append(c.requests, req)
161- c.mu.Unlock()
162- events := make(chan provider.Event, 2)
163- go func() {
164- defer close(events)
165- if strings.Contains(req.System, "context summarization assistant") {
166- events <- provider.EventTextDelta{Delta: "worker summary"}
167- events <- provider.EventDone{Stop: provider.StopEnd}
168- return
169- }
170- events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
171- Role: provider.RoleAssistant,
172- Content: []provider.Content{provider.TextBlock{Text: "continued same task"}},
173- }}
174- }()
175- return events, nil
176-}
177-
178-func TestPromptWithContextRecoveryCompactsAndContinues(t *testing.T) {
179- client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
180- agent := core.NewAgent(client, "test-model", "", nil)
181- agent.SetMessages([]provider.Message{
182- {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
183- {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
184- {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
185- {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
186- })
187-
188- recovery, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, modes.ContextRecoveryOptions{})
189- if err != nil {
190- t.Fatalf("promptWithContextRecovery returned %v", err)
191- }
192- if !recovery.Compacted {
193- t.Fatal("promptWithContextRecovery did not report compaction")
194- }
195- if got := finalAssistantText(agent.Messages()[recovery.OutputStart:]); got != "recovered" {
196- t.Fatalf("final assistant text = %q, want recovered", got)
197- }
198-
199- request := <-client.retried
200- if got := workerRequestUserTextCount(request, "finish the task"); got != 1 {
201- t.Fatalf("retried request contains prompt %d times, want 1: %#v", got, request.Messages)
202- }
203- client.mu.Lock()
204- calls := client.calls
205- requests := append([]provider.Request(nil), client.requests...)
206- client.mu.Unlock()
207- if calls != 3 || len(requests) != 3 {
208- t.Fatalf("provider calls/requests = %d/%d, want 3/3", calls, len(requests))
209- }
210- if !workerRequestContainsText(requests[1], "<conversation>") || !workerRequestContainsText(requests[1], "one") {
211- t.Fatalf("compaction request does not include the older transcript: %#v", requests[1].Messages)
212- }
213-}
214-
215-func TestPromptWithContextRecoveryStopsAfterOneRetry(t *testing.T) {
216- client := &subagentContextRecoveryClient{
217- retried: make(chan provider.Request, 1),
218- retryOverflow: true,
219- }
220- agent := core.NewAgent(client, "test-model", "", nil)
221- agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
222-
223- _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, modes.ContextRecoveryOptions{})
224- if !provider.IsContextOverflowError(err) {
225- t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)
226- }
227- <-client.retried
228- client.mu.Lock()
229- calls := client.calls
230- client.mu.Unlock()
231- if calls != 3 {
232- t.Fatalf("provider calls = %d, want one initial prompt, compaction, and one continuation", calls)
233- }
234-}
235-
236-func TestPromptWithContextRecoveryDoesNotCompactAfterCancellation(t *testing.T) {
237- client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
238- agent := core.NewAgent(client, "test-model", "", nil)
239- agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
240- ctx, cancel := context.WithCancel(context.Background())
241- defer cancel()
242-
243- _, err := promptWithContextRecovery(ctx, agent, "finish the task", func(event core.AgentEvent) {
244- if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) {
245- cancel()
246- }
247- }, modes.ContextRecoveryOptions{})
248- if !provider.IsContextOverflowError(err) {
249- t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)
250- }
251- if err := ctx.Err(); !errors.Is(err, context.Canceled) {
252- t.Fatalf("context error = %v, want %v", err, context.Canceled)
253- }
254- client.mu.Lock()
255- calls := client.calls
256- client.mu.Unlock()
257- if calls != 1 {
258- t.Fatalf("provider calls = %d, want only the initial prompt after cancellation", calls)
259- }
260-}
261-
262-func TestPromptWithContextRecoveryPropagatesCompactionFailure(t *testing.T) {
263- client := &subagentContextRecoveryClient{
264- retried: make(chan provider.Request, 1),
265- compactionErr: errors.New("compaction provider unavailable"),
266- }
267- agent := core.NewAgent(client, "test-model", "", nil)
268- agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
269-
270- _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, modes.ContextRecoveryOptions{})
271- if err == nil || !strings.Contains(err.Error(), "compact transcript after initial overflow") {
272- t.Fatalf("promptWithContextRecovery error = %v, want compaction failure", err)
273- }
274- payload := resultErrorPayload(err, "")
275- if payload["code"] != "turn_failed" {
276- t.Fatalf("result error payload = %#v, want turn_failed", payload)
277- }
278-}
279-
280-func TestPromptWithContextRecoveryKeepsSinglePromptIntact(t *testing.T) {
281- client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
282- agent := core.NewAgent(client, "test-model", "", nil)
283-
284- _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, modes.ContextRecoveryOptions{})
285- if !provider.IsContextOverflowError(err) {
286- t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)
287- }
288- client.mu.Lock()
289- calls := client.calls
290- requests := append([]provider.Request(nil), client.requests...)
291- client.mu.Unlock()
292- if len(requests) != 1 || workerRequestUserTextCount(requests[0], "finish the task") != 1 {
293- t.Fatalf("initial request does not retain the prompt: %#v", requests)
294- }
295- if len(agent.Messages()) != 1 || workerRequestMessageTextCount(agent.Messages(), "finish the task") != 1 {
296- t.Fatalf("single prompt was not retained: %#v", agent.Messages())
297- }
298- if calls != 1 {
299- t.Fatalf("provider calls = %d, want 1 without compaction", calls)
300- }
301-}
302-
303-func TestPromptWithContextRecoveryRetainsPromptWhenPersistenceFails(t *testing.T) {
304- client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
305- history := []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}
306- session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
307- if err != nil {
308- t.Fatalf("create session: %v", err)
309- }
310- if err := session.AppendMessage(history[0]); err != nil {
311- t.Fatalf("append history: %v", err)
312- }
313- agent := core.NewAgent(client, "test-model", "", nil)
314- agent.SetMessages(history)
315-
316- recovery, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, modes.ContextRecoveryOptions{
317- PersistCompaction: func([]provider.Message) error { return errors.New("persist failed") },
318- })
319- if err == nil || !strings.Contains(err.Error(), "persist compacted transcript") {
320- t.Fatalf("promptWithContextRecovery error = %v, want persistence failure", err)
321- }
322- if got := workerRequestMessageTextCount(agent.Messages(), "prior context"); got != 1 || workerRequestMessageTextCount(agent.Messages(), "finish the task") != 1 {
323- t.Fatalf("agent transcript lost the pending task after persistence failure: %#v", agent.Messages())
324- }
325- if err := WriteNewTranscript(agent, session, recovery.OutputStart); err != nil {
326- t.Fatalf("persist transcript: %v", err)
327- }
328- if err := session.Close(); err != nil {
329- t.Fatalf("close session: %v", err)
330- }
331- reopened, messages, err := core.OpenSession(session.Path)
332- if err != nil {
333- t.Fatalf("reopen session: %v", err)
334- }
335- t.Cleanup(func() {
336- if err := reopened.Close(); err != nil {
337- t.Errorf("close reopened session: %v", err)
338- }
339- })
340- if got := workerRequestMessageTextCount(messages, "finish the task"); got != 1 {
341- t.Fatalf("persisted prompt count = %d, want 1: %#v", got, messages)
342- }
343- client.mu.Lock()
344- calls := client.calls
345- client.mu.Unlock()
346- if calls != 2 {
347- t.Fatalf("provider calls = %d, want initial prompt and compaction only", calls)
348- }
349-}
350-
351-func TestPromptWithContextRecoveryPersistsCompaction(t *testing.T) {
352- client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
353- history := []provider.Message{
354- {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
355- {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
356- {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
357- {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
358- }
359- session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
360- if err != nil {
361- t.Fatalf("create session: %v", err)
362- }
363- for _, message := range history {
364- if err := session.AppendMessage(message); err != nil {
365- t.Fatalf("append history: %v", err)
366- }
367- }
368-
369- agent := core.NewAgent(client, "test-model", "", nil)
370- agent.SetMessages(history)
371- recovery, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, modes.ContextRecoveryOptions{
372- PersistCompaction: session.AppendCompaction,
373- })
374- if err != nil {
375- t.Fatalf("promptWithContextRecovery returned %v", err)
376- }
377- if err := WriteNewTranscript(agent, session, recovery.OutputStart); err != nil {
378- t.Fatalf("persist transcript: %v", err)
379- }
380- if err := session.Close(); err != nil {
381- t.Fatalf("close session: %v", err)
382- }
383-
384- records, err := os.ReadFile(session.Path)
385- if err != nil {
386- t.Fatalf("read session: %v", err)
387- }
388- compactions := 0
389- for _, record := range bytes.Split(bytes.TrimSpace(records), []byte("\n")) {
390- var entry struct {
391- Type string `json:"type"`
392- }
393- if err := json.Unmarshal(record, &entry); err != nil {
394- t.Fatalf("decode session record: %v", err)
395- }
396- if entry.Type == "compaction" {
397- compactions++
398- }
399- }
400- if compactions != 1 {
401- t.Fatalf("compaction records = %d, want 1", compactions)
402- }
403-
404- reopened, messages, err := core.OpenSession(session.Path)
405- if err != nil {
406- t.Fatalf("reopen session: %v", err)
407- }
408- t.Cleanup(func() {
409- if err := reopened.Close(); err != nil {
410- t.Errorf("close reopened session: %v", err)
411- }
412- })
413- if got := workerRequestMessageTextCount(messages, "finish the task"); got != 1 {
414- t.Fatalf("persisted prompt count = %d, want 1: %#v", got, messages)
415- }
416- if got := finalAssistantText(messages); got != "two\nfour\nrecovered" {
417- t.Fatalf("persisted assistant text = %q, want prior tail plus recovered result", got)
418- }
419-}
420-
421-func TestWorkerUsesConfiguredAutoCompactionThreshold(t *testing.T) {
422- t.Setenv("ZUT_HOME", t.TempDir())
423- threshold := 70
424- if err := SaveConfig(Config{AutoCompactThreshold: &threshold}); err != nil {
425- t.Fatalf("save config: %v", err)
426- }
427- cfg, err := loadSubagentWorkerConfig()
428- if err != nil {
429- t.Fatalf("load worker config: %v", err)
430- }
431-
432- client := &subagentProactiveClient{}
433- agent := core.NewAgent(client, "model", "", nil)
434- agent.SetMessages([]provider.Message{
435- {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "history"}}},
436- {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer"}}},
437- })
438- agent.SeedLastTurnUsage(provider.Usage{CacheReadTokens: 75})
439- recovery, err := promptWithContextRecovery(context.Background(), agent, "finish exact task", nil, modes.ContextRecoveryOptions{
440- ContextWindow: 100,
441- AutoCompactThreshold: cfg.AutoCompactThreshold,
442- })
443- if err != nil {
444- t.Fatalf("promptWithContextRecovery: %v", err)
445- }
446- if got := finalAssistantText(agent.Messages()[recovery.OutputStart:]); got != "continued same task" {
447- t.Fatalf("continued output = %q", got)
448- }
449- client.mu.Lock()
450- requests := append([]provider.Request(nil), client.requests...)
451- client.mu.Unlock()
452- if len(requests) != 2 || workerRequestUserTextCount(requests[1], "finish exact task") != 1 {
453- t.Fatalf("requests = %#v, want compaction then the same task once", requests)
454- }
455-}
456-
457-func TestWorkerConfigLoadRejectsMalformedConfig(t *testing.T) {
458- t.Setenv("ZUT_HOME", t.TempDir())
459- if err := os.MkdirAll(filepath.Dir(ConfigPath()), 0o755); err != nil {
460- t.Fatal(err)
461- }
462- if err := os.WriteFile(ConfigPath(), []byte("{malformed"), 0o600); err != nil {
463- t.Fatal(err)
464- }
465- if _, err := loadSubagentWorkerConfig(); err == nil || !strings.Contains(err.Error(), "load subagent worker config") {
466- t.Fatalf("loadSubagentWorkerConfig error = %v, want parse failure", err)
467- }
468-}
469-
470-type subagentSummaryUsageClient struct{}
471-
472-func (c *subagentSummaryUsageClient) Name() string { return "subagent-summary-usage-test" }
473-
474-func (c *subagentSummaryUsageClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
475- events := make(chan provider.Event, 3)
476- go func() {
477- defer close(events)
478- if strings.Contains(req.System, "context summarization assistant") {
479- events <- provider.EventUsage{Usage: provider.Usage{InputTokens: 3, OutputTokens: 1, CostUSD: 0.03}}
480- events <- provider.EventTextDelta{Delta: "worker summary"}
481- events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
482- Role: provider.RoleAssistant,
483- Content: []provider.Content{provider.TextBlock{Text: "worker summary"}},
484- }}
485- return
486- }
487- events <- provider.EventUsage{Usage: provider.Usage{InputTokens: 5, OutputTokens: 2, CostUSD: 0.05}}
488- events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
489- Role: provider.RoleAssistant,
490- Content: []provider.Content{provider.TextBlock{Text: "continued"}},
491- }}
492- }()
493- return events, nil
494-}
495-
496-func TestPersistWorkerSummaryUsageReopensWithCumulativeUsage(t *testing.T) {
497- session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
498- if err != nil {
499- t.Fatal(err)
500- }
501- history := []provider.Message{
502- {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "old task"}}},
503- {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "old answer"}}},
504- }
505- for _, message := range history {
506- if err := session.AppendMessage(message); err != nil {
507- t.Fatal(err)
508- }
509- }
510- baseline := provider.Usage{InputTokens: 10, OutputTokens: 2, CostUSD: 0.1}
511- if err := session.AppendUsage(baseline, baseline); err != nil {
512- t.Fatal(err)
513- }
514- agent := core.NewAgent(&subagentSummaryUsageClient{}, "model", "", nil)
515- agent.SetMessages(history)
516- agent.SeedCost(baseline)
517- agent.SeedLastTurnUsage(provider.Usage{CacheReadTokens: 90})
518- recovery, err := promptWithContextRecovery(context.Background(), agent, "continue task", nil, modes.ContextRecoveryOptions{
519- ContextWindow: 100,
520- })
521- if err != nil {
522- t.Fatalf("promptWithContextRecovery: %v", err)
523- }
524- if recovery.OutputStart != len(history) || !recovery.Compacted {
525- t.Fatalf("compaction recovery = %+v, want output start %d and compacted", recovery, len(history))
526- }
527- expected := provider.Usage{InputTokens: 18, OutputTokens: 5, CostUSD: 0.18}
528- if !reflect.DeepEqual(agent.Cost(), expected) {
529- t.Fatalf("worker cumulative usage = %#v, want %#v", agent.Cost(), expected)
530- }
531- if err := persistWorkerTranscript(context.Background(), agent, session, recovery.OutputStart, recovery.Compacted, workerTranscriptState{messages: history, cost: baseline}); err != nil {
532- t.Fatalf("persistWorkerTranscript: %v", err)
533- }
534- if err := session.Close(); err != nil {
535- t.Fatal(err)
536- }
537- cumulative, lastTurn, err := core.SessionUsageDetail(session.Path)
538- if err != nil {
539- t.Fatal(err)
540- }
541- if !reflect.DeepEqual(cumulative, expected) {
542- t.Fatalf("reopened cumulative usage = %#v, want %#v", cumulative, expected)
543- }
544- wantLast := provider.Usage{InputTokens: 8, OutputTokens: 3, CostUSD: expected.CostUSD - baseline.CostUSD}
545- if !reflect.DeepEqual(lastTurn, wantLast) {
546- t.Fatalf("reopened last-turn usage = %#v, want %#v", lastTurn, wantLast)
547- }
548-}
549-
550-func TestPersistWorkerCompactedTranscriptReopensWithTurnAndUsage(t *testing.T) {
551- session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
552- if err != nil {
553- t.Fatal(err)
554- }
555- baselineMessage := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "baseline"}}}
556- if err := session.AppendMessage(baselineMessage); err != nil {
557- t.Fatal(err)
558- }
559- baselineUsage := provider.Usage{InputTokens: 10, OutputTokens: 2}
560- if err := session.AppendUsage(baselineUsage, baselineUsage); err != nil {
561- t.Fatal(err)
562- }
563-
564- agent := core.NewAgent(&subagentProactiveClient{}, "model", "", nil)
565- agent.SetMessages([]provider.Message{
566- {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "summary"}}},
567- {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "current task"}}},
568- {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "current answer"}}},
569- })
570- cumulative := provider.Usage{InputTokens: 30, OutputTokens: 8, CacheReadTokens: 4}
571- agent.SeedCost(cumulative)
572- if err := persistWorkerTranscript(context.Background(), agent, session, 1, true, workerTranscriptState{
573- messages: []provider.Message{baselineMessage},
574- cost: baselineUsage,
575- }); err != nil {
576- t.Fatalf("persistWorkerTranscript: %v", err)
577- }
578- if err := session.Close(); err != nil {
579- t.Fatal(err)
580- }
581-
582- snapshot, err := core.ReadSessionSnapshot(session.Path)
583- if err != nil {
584- t.Fatal(err)
585- }
586- if workerRequestMessageTextCount(snapshot.Messages, "current task") != 1 || finalAssistantText(snapshot.Messages) != "current answer" {
587- t.Fatalf("reopened messages = %#v, want full continued turn", snapshot.Messages)
588- }
589- if len(snapshot.UsageCheckpoints) == 0 || !reflect.DeepEqual(snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative, cumulative) {
590- t.Fatalf("usage checkpoints = %#v, want cumulative %#v", snapshot.UsageCheckpoints, cumulative)
591- }
592-}
593-
594-func TestPersistWorkerCompactionFailureRollsBackAndKeepsSessionReopenable(t *testing.T) {
595- session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
596- if err != nil {
597- t.Fatal(err)
598- }
599- baselineMessage := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "baseline"}}}
600- if err := session.AppendMessage(baselineMessage); err != nil {
601- t.Fatal(err)
602- }
603- baselineUsage := provider.Usage{InputTokens: 10, OutputTokens: 2}
604- if err := session.AppendUsage(baselineUsage, baselineUsage); err != nil {
605- t.Fatal(err)
606- }
607- if err := session.Close(); err != nil {
608- t.Fatal(err)
609- }
610-
611- agent := core.NewAgent(&subagentProactiveClient{}, "model", "", nil)
612- agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "unsaved compacted turn"}}}})
613- agent.SeedCost(provider.Usage{InputTokens: 99})
614- baseline := workerTranscriptState{messages: []provider.Message{baselineMessage}, cost: baselineUsage, lastTurn: baselineUsage}
615- if err := persistWorkerTranscript(context.Background(), agent, session, 0, true, baseline); err == nil {
616- t.Fatal("persistWorkerTranscript succeeded on closed session")
617- }
618- if !reflect.DeepEqual(agent.Messages(), baseline.messages) || !reflect.DeepEqual(agent.Cost(), baselineUsage) {
619- t.Fatalf("agent was not rolled back: messages=%#v cost=%#v", agent.Messages(), agent.Cost())
620- }
621- snapshot, err := core.ReadSessionSnapshot(session.Path)
622- if err != nil {
623- t.Fatalf("reopen snapshot: %v", err)
624- }
625- if !reflect.DeepEqual(snapshot.Messages, baseline.messages) {
626- t.Fatalf("reopened transcript = %#v, want baseline %#v", snapshot.Messages, baseline.messages)
627- }
628- if len(snapshot.UsageCheckpoints) != 1 || !reflect.DeepEqual(snapshot.UsageCheckpoints[0].Cumulative, baselineUsage) {
629- t.Fatalf("reopened usage = %#v, want baseline", snapshot.UsageCheckpoints)
630- }
631-}
632-
633-func TestPersistWorkerTranscriptSkipsCanceledNonCompactedTurn(t *testing.T) {
634- session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
635- if err != nil {
636- t.Fatal(err)
637- }
638- baselineMessage := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "baseline"}}}
639- if err := session.AppendMessage(baselineMessage); err != nil {
640- t.Fatal(err)
641- }
642- agent := core.NewAgent(&subagentProactiveClient{}, "model", "", nil)
643- agent.SetMessages([]provider.Message{
644- baselineMessage,
645- {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "canceled answer"}}},
646- })
647- ctx, cancel := context.WithCancel(context.Background())
648- cancel()
649- if err := persistWorkerTranscript(ctx, agent, session, 1, false, workerTranscriptState{messages: []provider.Message{baselineMessage}}); !errors.Is(err, context.Canceled) {
650- t.Fatalf("persistWorkerTranscript error = %v, want cancellation", err)
651- }
652- if err := session.Close(); err != nil {
653- t.Fatal(err)
654- }
655- snapshot, err := core.ReadSessionSnapshot(session.Path)
656- if err != nil {
657- t.Fatal(err)
658- }
659- if !reflect.DeepEqual(snapshot.Messages, []provider.Message{baselineMessage}) {
660- t.Fatalf("reopened messages = %#v, want baseline only", snapshot.Messages)
661- }
662-}
663-
664-func TestPersistWorkerCompactionHonorsCancellation(t *testing.T) {
665- session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
666- if err != nil {
667- t.Fatal(err)
668- }
669- baselineMessage := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "baseline"}}}
670- if err := session.AppendMessage(baselineMessage); err != nil {
671- t.Fatal(err)
672- }
673- baseline := workerTranscriptState{messages: []provider.Message{baselineMessage}}
674- agent := core.NewAgent(&subagentProactiveClient{}, "model", "", nil)
675- agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "must not persist"}}}})
676- ctx, cancel := context.WithCancel(context.Background())
677- cancel()
678- if err := persistWorkerTranscript(ctx, agent, session, 0, true, baseline); !errors.Is(err, context.Canceled) {
679- t.Fatalf("persistWorkerTranscript error = %v, want cancellation", err)
680- }
681- if !reflect.DeepEqual(agent.Messages(), baseline.messages) {
682- t.Fatalf("agent messages = %#v, want rollback %#v", agent.Messages(), baseline.messages)
683- }
684- if err := session.Close(); err != nil {
685- t.Fatal(err)
686- }
687- snapshot, err := core.ReadSessionSnapshot(session.Path)
688- if err != nil {
689- t.Fatal(err)
690- }
691- if !reflect.DeepEqual(snapshot.Messages, baseline.messages) {
692- t.Fatalf("reopened messages = %#v, want baseline", snapshot.Messages)
693- }
694-}
695-
696-func TestWriteNewTranscriptReturnsPersistenceErrors(t *testing.T) {
697- session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
698- if err != nil {
699- t.Fatal(err)
700- }
701- if err := session.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "baseline"}}}); err != nil {
702- t.Fatal(err)
703- }
704- if err := session.Close(); err != nil {
705- t.Fatal(err)
706- }
707- agent := core.NewAgent(&subagentProactiveClient{}, "model", "", nil)
708- agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "new"}}}})
709- if err := WriteNewTranscript(agent, session, 0); err == nil || !strings.Contains(err.Error(), "append transcript message") {
710- t.Fatalf("WriteNewTranscript error = %v, want append failure", err)
711- }
712-}
713-
714-func workerRequestMessageTextCount(messages []provider.Message, want string) int {
715- count := 0
716- for _, message := range messages {
717- for _, block := range message.Content {
718- if text, ok := block.(provider.TextBlock); ok && text.Text == want {
719- count++
720- }
721- }
722- }
723- return count
724-}
725-
726-func workerRequestContainsText(req provider.Request, want string) bool {
727- for _, message := range req.Messages {
728- for _, block := range message.Content {
729- if text, ok := block.(provider.TextBlock); ok && strings.Contains(text.Text, want) {
730- return true
731- }
732- }
733- }
734- return false
735-}
736-
737-func workerRequestUserTextCount(req provider.Request, want string) int {
738- count := 0
739- for _, message := range req.Messages {
740- if message.Role != provider.RoleUser {
741- continue
742- }
743- for _, block := range message.Content {
744- if text, ok := block.(provider.TextBlock); ok && text.Text == want {
745- count++
746- }
747- }
748- }
749- return count
750-}
751-
752-func TestResultErrorPayloadSanitizesContextOverflow(t *testing.T) {
753- payload := resultErrorPayload(errors.New("provider error: input exceeds the context window; echoed task text"), "")
754- if payload["code"] != "context_limit" {
755- t.Fatalf("error code = %q, want context_limit", payload["code"])
756- }
757- if payload["message"] != subagentContextLimitMessage {
758- t.Fatalf("error message = %q, want %q", payload["message"], subagentContextLimitMessage)
759- }
760-}
761-
762-func TestSubagentEventDataSanitizesContextOverflow(t *testing.T) {
763- payload := subagentEventData(core.EvTurnEnd{Err: errors.New("provider error: input exceeds the context window; echoed task text")})
764- if payload["error"] != subagentContextLimitMessage {
765- t.Fatalf("event error = %q, want %q", payload["error"], subagentContextLimitMessage)
766- }
767-}
768-
769-// TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire regresses the
770-// large-result wire duplication bug.
771-func TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire(t *testing.T) {
772- path := filepath.Join(t.TempDir(), "events.jsonl")
773- file, err := os.Create(path)
774- if err != nil {
775- t.Fatal(err)
776- }
777- defer file.Close()
778-
779- em := newSubagentEmitter(file)
780- em.setProtocolIdentity("agent-1")
781- em.emit("turn.result", map[string]any{
782- "status": "succeeded",
783- "turn_id": "turn-1",
784- "output": strings.Repeat("x", 500_000),
785- })
786- if err := file.Close(); err != nil {
787- t.Fatal(err)
788- }
789- wire, err := os.ReadFile(path)
790- if err != nil {
791- t.Fatal(err)
792- }
793- if len(wire) >= 900_000 {
794- t.Fatalf("large result appears duplicated on wire: %d bytes", len(wire))
795- }
796- var object map[string]any
797- if err := json.Unmarshal(bytes.TrimSpace(wire), &object); err != nil {
798- t.Fatal(err)
799- }
800- if _, exists := object["output"]; exists {
801- t.Fatal("large output was flattened a second time")
802- }
803- payload, ok := object["payload"].(map[string]any)
804- if !ok || len(payload["output"].(string)) != 500_000 {
805- t.Fatal("canonical payload lost the complete bounded output")
806- }
807- events, err := subagents.ReadEventLog(path)
808- if err != nil || len(events) != 1 {
809- t.Fatalf("supervisor event parser could not recover the large result: events=%d err=%v", len(events), err)
810- }
811- if output, ok := events[0].Data["output"].(string); !ok || len(output) != 500_000 {
812- t.Fatal("supervisor parser lost the large result payload")
813- }
814-}
815-
816-// TestSupervisorEmitterStdoutShapeMatchesSupervisorParser pins the
817-// wire-format contract: each emitted event lands on stdout as one
818-// JSON object per line with type+time at top level alongside the
819-// data fields. The supervisor's runner parses this exact shape.
820-func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) {
821- // Pipe so we can read what the emitter wrote.
822- r, w, err := os.Pipe()
823- if err != nil {
824- t.Fatalf("pipe: %v", err)
825- }
826- defer r.Close()
827-
828- em := newSubagentEmitter(w)
829- em.setTurnID("turn-1")
830- em.emit("text_delta", map[string]any{"delta": "sensitive partial answer"})
831- _ = w.Close()
832-
833- body, err := io.ReadAll(r)
834- if err != nil {
835- t.Fatalf("read pipe: %v", err)
836- }
837- // One trailing newline => one event line.
838- lines := bytes.Split(bytes.TrimRight(body, "\n"), []byte("\n"))
839- if len(lines) != 1 {
840- t.Fatalf("expected 1 event line, got %d: %q", len(lines), body)
841- }
842- var object map[string]any
843- if err := json.Unmarshal(lines[0], &object); err != nil {
844- t.Fatalf("not valid json: %v\n%s", err, lines[0])
845- }
846- if object["type"] != "message.delta" {
847- t.Errorf("type field missing or wrong: %v", object["type"])
848- }
849- if object["turn_id"] != "turn-1" {
850- t.Errorf("turn_id = %v, want turn-1", object["turn_id"])
851- }
852- if _, ok := object["timestamp"].(string); !ok {
853- t.Errorf("timestamp field missing: %v", object["timestamp"])
854- }
855- payload, ok := object["payload"].(map[string]any)
856- if !ok || payload["delta"] != "sensitive partial answer" {
857- t.Errorf("payload delta field missing or wrong: %v", object["payload"])
858- }
859-}
860-
861-func TestWorkerTurnCountersRemainObservableWithoutAdmissionLimit(t *testing.T) {
862- var counters workerTurnCounters
863-
864- if step, lifetime, current := counters.start(false); step != 1 || lifetime != 1 || current != 1 {
865- t.Fatalf("first turn = (%d, %d, %d), want (1, 1, 1)", step, lifetime, current)
866- }
867- // Provider retries, model loops, and compaction stay inside the delegated
868- // turn and therefore do not advance the message-turn counters.
869- if counters.lifetime != 1 || counters.current != 1 {
870- t.Fatalf("nested work changed counters to (lifetime=%d, current=%d)", counters.lifetime, counters.current)
871- }
872- if step, lifetime, current := counters.start(false); step != 2 || lifetime != 2 || current != 2 {
873- t.Fatalf("second turn = (%d, %d, %d), want (2, 2, 2)", step, lifetime, current)
874- }
875- if step, lifetime, current := counters.start(false); step != 3 || lifetime != 3 || current != 3 {
876- t.Fatalf("third turn = (%d, %d, %d), want (3, 3, 3)", step, lifetime, current)
877- }
878- if step, lifetime, current := counters.start(true); step != 4 || lifetime != 4 || current != 1 {
879- t.Fatalf("explicit resumed turn = (%d, %d, %d), want (4, 4, 1)", step, lifetime, current)
880- }
881-}
D packages/agent/subagents/ack_test.go +0 -65
@@ -1,65 +0,0 @@
1-package subagents
2-
3-import (
4- "testing"
5- "time"
6-)
7-
8-func TestResumePromptAcknowledgementRequiresDurableUserMessageAndTurnStart(t *testing.T) {
9- acceptedAt := time.Now().Add(-time.Second)
10- prompt := "follow-up prompt"
11- user := NewEvent("user_message", map[string]any{
12- "content": []any{map[string]any{"type": "text", "text": prompt}},
13- })
14- started := NewEvent(EventTurnStarted, map[string]any{
15- "lifetime_turns": 2,
16- "current_run_turns": 1,
17- })
18-
19- if resumePromptAcknowledged([]Event{started}, prompt, acceptedAt) {
20- t.Fatal("turn.started alone acknowledged a pending prompt")
21- }
22- if resumePromptAcknowledged([]Event{user}, prompt, acceptedAt) {
23- t.Fatal("crash point after durable user message acknowledged before turn.started")
24- }
25- if !resumePromptAcknowledged([]Event{user, started}, prompt, acceptedAt) {
26- t.Fatal("matching durable user message followed by delegated turn.started was not acknowledged")
27- }
28-}
29-
30-func TestResumePromptAcknowledgementIgnoresNestedTurnStart(t *testing.T) {
31- acceptedAt := time.Now().Add(-time.Second)
32- prompt := "follow-up prompt"
33- user := NewEvent("user_message", map[string]any{
34- "content": []any{map[string]any{"type": "text", "text": prompt}},
35- })
36- nested := NewEvent(EventTurnStarted, map[string]any{"nested_turn": true})
37- if resumePromptAcknowledged([]Event{user, nested}, prompt, acceptedAt) {
38- t.Fatal("nested turn.started acknowledged a pending delegated prompt")
39- }
40-}
41-
42-func TestResumePromptAcknowledgementIgnoresStaleMatchingUserMessage(t *testing.T) {
43- acceptedAt := time.Now()
44- prompt := "follow-up prompt"
45- user := NewEvent("user_message", map[string]any{
46- "content": []any{map[string]any{"type": "text", "text": prompt}},
47- })
48- user.Time = acceptedAt.Add(-time.Millisecond)
49- started := NewEvent(EventTurnStarted, map[string]any{"lifetime_turns": 2, "current_run_turns": 1})
50- if resumePromptAcknowledged([]Event{user, started}, prompt, acceptedAt) {
51- t.Fatal("stale matching user message acknowledged a pending prompt")
52- }
53-}
54-
55-func TestResumePromptAcknowledgementIgnoresAmbiguousUserMessage(t *testing.T) {
56- acceptedAt := time.Now().Add(-time.Second)
57- prompt := "follow-up prompt"
58- user := NewEvent("user_message", map[string]any{
59- "content": []any{map[string]any{"type": "text", "text": "older prompt"}},
60- })
61- started := NewEvent(EventTurnStarted, map[string]any{"lifetime_turns": 2, "current_run_turns": 1})
62- if resumePromptAcknowledged([]Event{user, started}, prompt, acceptedAt) {
63- t.Fatal("ambiguous user message acknowledged a pending prompt")
64- }
65-}
D packages/agent/subagents/agent.go +0 -1001
@@ -1,1001 +0,0 @@
1-package subagents
2-
3-import (
4- "context"
5- "fmt"
6- "path/filepath"
7- "strings"
8- "sync"
9- "time"
10-
11- "github.com/google/uuid"
12-)
13-
14-// Agent is one supervised task. Public fields are immutable after
15-// Spawn; mutable state (status, activity, transcript) lives behind
16-// the embedded mutex.
17-type turnEndNotice struct {
18- step int
19- errMsg string
20-}
21-
22-type queuedResumePrompt struct {
23- Prompt string `json:"prompt"`
24- AcceptedAt time.Time `json:"accepted_at"`
25- CommandID string `json:"command_id,omitempty"`
26-}
27-
28-type resumePromptSnapshot struct {
29- Prompt string
30- AcceptedAt time.Time
31- CommandID string
32-}
33-
34-type Agent struct {
35- ID string
36- Task string
37- Dir string // shared checkout or isolated worktree used by the child.
38- Started time.Time
39-
40- // ParentID and RootSessionID identify the supervisor/session that owns
41- // this worker. Empty ParentID means the main supervisor created it;
42- // child-originated spawning is rejected in v1.
43- ParentID string
44- BatchID string
45- RootSessionID string
46- OriginalTask string
47- RepositoryRoot string
48- WorkspaceMode WorkspaceMode
49- WorkspacePath string
50- WorkspaceBase string
51- WorkspaceCapture CaptureMode
52-
53- // LifetimeTurns counts every accepted message turn for this worker across
54- // all explicit runs. CurrentRunTurns resets only when an explicit
55- // subagent_resume follow-up starts a new run; retries, model loops,
56- // compaction, and cancellation do not reset it.
57- LifetimeTurns int
58- CurrentRunTurns int
59-
60- // Timeout is the effective per-turn deadline. Zero means unlimited. It is
61- // persisted so a resumed worker keeps the policy selected at spawn time.
62- Timeout time.Duration
63- HeartbeatInterval time.Duration
64- Tools []string
65- WebSearchPolicy WebSearchPolicy
66-
67- // Model and Provider, when non-empty, override the child
68- // subprocess's model resolution. BaseURL and InsecureTLS carry
69- // the effective provider connection settings from the supervisor.
70- // Empty values inherit whatever the child resolves on its own from
71- // config / env / flags. All four settings are persisted in meta.json
72- // so Resume keeps using the same provider configuration across zut
73- // restarts.
74- Model string
75- Provider string
76- BaseURL string
77- InsecureTLS bool
78- Reasoning string
79- FastMode bool
80- Subagent string
81-
82- // fastModeOverridesHost records the spawn-time case where an explicit
83- // profile fastMode: true enabled the child while the host was off. It is
84- // intentionally transient: the parent only needs it to report the initial
85- // spawn warning, while the effective FastMode remains durable state.
86- fastModeOverridesHost bool
87-
88- // SessionID, when non-empty, scopes the agent to a particular
89- // host zut session: the dashboard only surfaces agents whose
90- // SessionID matches the active session. Empty means "unscoped" and
91- // applies to agents spawned without a session context such as in tests. Set at
92- // Spawn time from Supervisor.activeSession and persisted in
93- // meta.json so the scope survives restarts.
94- SessionID string
95-
96- // Resuming is true when this Agent struct was built by Resume
97- // rather than Spawn. The runner consults it to decide whether
98- // to pass the original Task as a positional argv to the child:
99- // on first Spawn we want the child to run the task immediately;
100- // on Resume the task was already run last time and must not replay.
101- // Not persisted — every Resume sets it explicitly.
102- Resuming bool
103-
104- // resumePromptText is the manager follow-up currently handed to the
105- // child. It remains in durable metadata until the child reports
106- // turn.started, so a host exit while the worker is queued cannot lose it.
107- // Additional follow-ups accepted while the child is busy stay in
108- // resumePromptQueue until the next agent idle turn.
109- resumePromptText string
110- resumePromptAt time.Time
111- resumePromptID string
112- resumePromptQueue []queuedResumePrompt
113-
114- // InboxPath is the unix socket the child agent listens on for
115- // follow-up prompts and control messages. The supervisor opens
116- // an Inbox at this path; the child opens a Listener.
117- InboxPath string
118-
119- // Attempt increments whenever the supervisor starts a process for this
120- // agent. It is persisted so recovery can distinguish a resumed worker
121- // from a first launch.
122- Attempt int
123-
124- // ProcessPID identifies the current worker when known. It is persisted
125- // so a reloaded supervisor can force-kill a detached worker after a
126- // graceful shutdown deadline.
127- ProcessPID int
128-
129- // EventLogPath is the durable JSONL event log for this agent.
130- // The runner appends every well-formed event from the child
131- // (plus lifecycle events of its own) here. /subagents open in any
132- // zut process reads from this file to replay the full history.
133- EventLogPath string
134-
135- // SessionPath is the child's persistent session file. Surfaced
136- // so the dashboard / /subagents open can resume the agent later.
137- SessionPath string
138-
139- // inbox is the supervisor-side socket handle. Populated by
140- // Supervisor.Spawn so callers do not need to manage the socket directly.
141- inbox *Inbox
142-
143- mu sync.Mutex
144- status Status
145- activity string
146- transcript []string
147- lastAssistant string
148- streamingAssistantText string
149- streamingAssistantTruncated bool
150- streamingAssistantStart int
151- streamingAssistantLines int
152- finished time.Time
153- lastErr error
154- startupErr error
155- shutdownOrigin ShutdownOrigin
156- transcriptLoaded bool
157- legacyEventState bool
158- transcriptMu sync.Mutex
159-
160- lifecycleMu sync.Mutex
161- processState ProcessState
162- turnState TurnState
163- currentTurnID string
164- updatedAt time.Time
165- lastActivity time.Time
166- result *TurnResult
167- resultRef string
168- requirement RequirementSnapshot
169- requirementPersisting bool
170- requirementChanged chan struct{}
171- patchRef string
172- changedFiles []string
173- outputBytes int
174- outputLines int
175- outputTruncated bool
176- maxOutputBytes int
177- maxOutputLines int
178- persistFn func(*Agent) error
179- persistMu sync.Mutex
180- stateDir string
181- workspaceCleanup func() error
182- workspaceCapture func() (WorkspaceCapture, error)
183- onTurnIdle func()
184- trace *TraceWriter
185-
186- // OnTurnEnd, if set, fires once per prompt-level turn_end event
187- // emitted by the subagent daemon wrapper. Provider/tool-loop
188- // turn_end events (for example stop=tool_use) are ignored by the
189- // runner because they do not mean the sub-agent task finished.
190- // Used by auto-subagents watchers to detect that a sub-agent's first
191- // (or n-th) task has finished without waiting for the long-lived
192- // daemon itself to exit — sub-agents keep running on the inbox
193- // even after the initial task completes, so Wait() never unblocks
194- // for them.
195- OnTurnEnd func(step int, errMsg string)
196- pendingTurnEnds []turnEndNotice
197-
198- ctx context.Context
199- cancel context.CancelFunc
200- runner Runner
201- lease *agentLease
202-
203- // launchState is an admission gate for the scheduler's goroutine:
204- // 0=not admitted, 1=runner admitted, 2=stopped before admission. It
205- // prevents an immediate Stop from starting a runner after cancellation.
206- launchState int32
207-
208- // done closes when the run goroutine finalises the agent's
209- // status (done / failed / killed). Wait blocks on this so
210- // callers don't have to poll.
211- done chan struct{}
212- results map[string]*TurnResult
213- resultWaiters map[string]int
214- resultChanged chan struct{}
215- doneOnce sync.Once
216-}
217-
218-// Inbox exposes the supervisor-side socket handle. Returns nil for
219-// agents without inboxes (e.g. tests using a custom Runner that
220-// doesn't speak the daemon protocol).
221-func (a *Agent) Inbox() *Inbox { return a.inbox }
222-
223-func (a *Agent) recordTrace(event TraceEvent) {
224- if a == nil || a.trace == nil {
225- return
226- }
227- if event.AgentID == "" {
228- event.AgentID = a.ID
229- }
230- a.trace.Record(event)
231-}
232-
233-// FastModeOverridesHost reports whether this child was created with an
234-// explicit fast-mode override while the host setting was disabled.
235-func (a *Agent) FastModeOverridesHost() bool {
236- return a != nil && a.fastModeOverridesHost
237-}
238-
239-// Status returns the current high-level status. Cheap; safe from any goroutine.
240-func (a *Agent) Status() Status {
241- a.mu.Lock()
242- defer a.mu.Unlock()
243- return a.status
244-}
245-
246-// Activity returns the current one-line activity string.
247-func (a *Agent) Activity() string {
248- a.mu.Lock()
249- defer a.mu.Unlock()
250- return a.activity
251-}
252-
253-// ProcessState returns the child process lifecycle state.
254-func (a *Agent) ProcessState() ProcessState {
255- a.lifecycleMu.Lock()
256- defer a.lifecycleMu.Unlock()
257- return a.processState
258-}
259-
260-// TurnState returns the current delegated-turn state.
261-func (a *Agent) TurnState() TurnState {
262- a.lifecycleMu.Lock()
263- defer a.lifecycleMu.Unlock()
264- return a.turnState
265-}
266-
267-// CurrentTurnID returns the stable id of the current or most recent turn.
268-func (a *Agent) CurrentTurnID() string {
269- a.lifecycleMu.Lock()
270- defer a.lifecycleMu.Unlock()
271- return a.currentTurnID
272-}
273-
274-// WaitTargetTurnID returns the delegated turn that a user-facing wait should
275-// observe. A queued follow-up has not emitted turn.started yet, so derive its
276-// stable next id from the persisted lifetime counter instead of returning the
277-// previous turn's completed result.
278-func (a *Agent) WaitTargetTurnID() string {
279- a.lifecycleMu.Lock()
280- defer a.lifecycleMu.Unlock()
281- if a.turnState == TurnQueued {
282- return fmt.Sprintf("turn-%d", a.LifetimeTurns+1)
283- }
284- return a.currentTurnID
285-}
286-
287-// AttemptValue returns the current process attempt number.
288-func (a *Agent) AttemptValue() int {
289- a.lifecycleMu.Lock()
290- defer a.lifecycleMu.Unlock()
291- return a.Attempt
292-}
293-
294-// LifetimeTurnsValue returns the number of accepted message turns across all
295-// explicit runs of this worker.
296-func (a *Agent) LifetimeTurnsValue() int {
297- a.lifecycleMu.Lock()
298- defer a.lifecycleMu.Unlock()
299- return a.LifetimeTurns
300-}
301-
302-// CurrentRunTurnsValue returns the number of accepted message turns in the
303-// current explicit run.
304-func (a *Agent) CurrentRunTurnsValue() int {
305- a.lifecycleMu.Lock()
306- defer a.lifecycleMu.Unlock()
307- return a.CurrentRunTurns
308-}
309-
310-func (a *Agent) setTurnCounts(lifetime, currentRun int) {
311- if lifetime < 0 {
312- lifetime = 0
313- }
314- if currentRun < 0 {
315- currentRun = 0
316- }
317- a.lifecycleMu.Lock()
318- a.LifetimeTurns = lifetime
319- a.CurrentRunTurns = currentRun
320- a.updatedAt = time.Now()
321- a.lifecycleMu.Unlock()
322-}
323-
324-func (a *Agent) incrementTurnCounts() (lifetime, currentRun int) {
325- a.lifecycleMu.Lock()
326- a.LifetimeTurns++
327- a.CurrentRunTurns++
328- a.updatedAt = time.Now()
329- lifetime = a.LifetimeTurns
330- currentRun = a.CurrentRunTurns
331- a.lifecycleMu.Unlock()
332- return lifetime, currentRun
333-}
334-
335-func (a *Agent) resetCurrentRunTurns() (previous int) {
336- a.lifecycleMu.Lock()
337- previous = a.CurrentRunTurns
338- a.CurrentRunTurns = 0
339- a.updatedAt = time.Now()
340- a.lifecycleMu.Unlock()
341- return previous
342-}
343-
344-func (a *Agent) setCurrentRunTurns(currentRun int) {
345- if currentRun < 0 {
346- currentRun = 0
347- }
348- a.lifecycleMu.Lock()
349- a.CurrentRunTurns = currentRun
350- a.updatedAt = time.Now()
351- a.lifecycleMu.Unlock()
352-}
353-
354-// ProcessPIDValue returns the current worker pid, when known.
355-func (a *Agent) ProcessPIDValue() int {
356- a.lifecycleMu.Lock()
357- defer a.lifecycleMu.Unlock()
358- return a.ProcessPID
359-}
360-
361-// LastActivity returns the last heartbeat or meaningful child event time.
362-func (a *Agent) LastActivity() time.Time {
363- a.lifecycleMu.Lock()
364- defer a.lifecycleMu.Unlock()
365- return a.lastActivity
366-}
367-
368-// UpdatedAt returns the last lifecycle-state update time.
369-func (a *Agent) UpdatedAt() time.Time {
370- a.lifecycleMu.Lock()
371- defer a.lifecycleMu.Unlock()
372- return a.updatedAt
373-}
374-
375-// Result returns a copy of the most recent structured turn result.
376-func (a *Agent) Result() *TurnResult {
377- a.lifecycleMu.Lock()
378- defer a.lifecycleMu.Unlock()
379- return cloneTurnResult(a.result)
380-}
381-
382-func (a *Agent) setProcessState(state ProcessState) {
383- a.lifecycleMu.Lock()
384- a.processState = state
385- a.updatedAt = time.Now()
386- a.lifecycleMu.Unlock()
387-}
388-
389-// markStartupTimeout atomically claims a worker that is still waiting for
390-// agent_ready. Process-state transitions use lifecycleMu too, so a ready
391-// worker cannot be timed out based on a stale ProcessStarting observation.
392-func (a *Agent) markStartupTimeout(cause error) bool {
393- if cause == nil {
394- return false
395- }
396- a.lifecycleMu.Lock()
397- defer a.lifecycleMu.Unlock()
398- if a.processState != ProcessStarting {
399- return false
400- }
401- a.mu.Lock()
402- defer a.mu.Unlock()
403- if a.status != StatusRunning {
404- return false
405- }
406- a.startupErr = cause
407- a.lastErr = cause
408- a.activity = "startup timeout: worker did not become ready"
409- a.updatedAt = time.Now()
410- return true
411-}
412-
413-func (a *Agent) incrementAttempt() int {
414- a.lifecycleMu.Lock()
415- a.Attempt++
416- a.updatedAt = time.Now()
417- attempt := a.Attempt
418- a.lifecycleMu.Unlock()
419- return attempt
420-}
421-
422-func (a *Agent) setProcessPID(pid int) {
423- a.lifecycleMu.Lock()
424- a.ProcessPID = pid
425- a.updatedAt = time.Now()
426- a.lifecycleMu.Unlock()
427-}
428-
429-func (a *Agent) releaseLease() error {
430- a.lifecycleMu.Lock()
431- lease := a.lease
432- a.lease = nil
433- a.lifecycleMu.Unlock()
434- if lease == nil {
435- return nil
436- }
437- return lease.Close()
438-}
439-
440-func (a *Agent) setTurnState(state TurnState, turnID string) {
441- a.lifecycleMu.Lock()
442- a.turnState = state
443- if turnID != "" {
444- a.currentTurnID = turnID
445- }
446- a.updatedAt = time.Now()
447- a.lifecycleMu.Unlock()
448-}
449-
450-func (a *Agent) markActivity(now time.Time) {
451- if now.IsZero() {
452- now = time.Now()
453- }
454- a.lifecycleMu.Lock()
455- a.lastActivity = now
456- a.updatedAt = now
457- a.lifecycleMu.Unlock()
458-}
459-
460-func (a *Agent) setResult(result *TurnResult) {
461- a.lifecycleMu.Lock()
462- a.result = cloneTurnResult(result)
463- if result != nil && result.TurnID != "" && a.resultWaiters[result.TurnID] > 0 {
464- if a.results == nil {
465- a.results = make(map[string]*TurnResult)
466- }
467- a.results[result.TurnID] = cloneTurnResult(result)
468- }
469- a.updatedAt = time.Now()
470- if a.resultChanged != nil {
471- close(a.resultChanged)
472- }
473- a.resultChanged = make(chan struct{})
474- a.lifecycleMu.Unlock()
475-}
476-
477-func (a *Agent) shutdownOriginValue() ShutdownOrigin {
478- a.mu.Lock()
479- defer a.mu.Unlock()
480- return a.shutdownOrigin
481-}
482-
483-// prepareShutdown publishes the shutdown origin in the same critical section
484-// as the killed status. Finalization can therefore observe either the previous
485-// terminal state or a fully attributed cancellation, never an intermediate one.
486-func (a *Agent) prepareShutdown(origin ShutdownOrigin) (Status, bool) {
487- a.mu.Lock()
488- defer a.mu.Unlock()
489- status := a.status
490- if status == StatusDone || status == StatusFailed || status == StatusKilled {
491- return status, false
492- }
493- a.shutdownOrigin = origin.Sanitized()
494- if status != StatusDetached {
495- a.status = StatusKilled
496- a.activity = "canceling"
497- }
498- return status, true
499-}
500-
501-// Transcript returns a copy of the running transcript. Detached agents
502-// hydrate their durable event history the first time it is requested.
503-func (a *Agent) Transcript() []string {
504- _ = a.loadTranscript()
505- a.mu.Lock()
506- defer a.mu.Unlock()
507- out := make([]string, len(a.transcript))
508- copy(out, a.transcript)
509- return out
510-}
511-
512-// Err returns the runner's terminal error, if any.
513-func (a *Agent) Err() error {
514- a.mu.Lock()
515- defer a.mu.Unlock()
516- return a.lastErr
517-}
518-
519-// Wait blocks until the agent process reaches a terminal state.
520-func (a *Agent) Wait() { <-a.done }
521-
522-// WaitContext waits for the agent to finish or for ctx cancellation.
523-func (a *Agent) WaitContext(ctx context.Context) error {
524- if ctx == nil {
525- ctx = context.Background()
526- }
527- select {
528- case <-a.done:
529- return nil
530- case <-ctx.Done():
531- return ctx.Err()
532- }
533-}
534-
535-func (a *Agent) waitForTurnResult(ctx context.Context) (*TurnResult, error) {
536- return a.WaitTurnResult(ctx, a.CurrentTurnID())
537-}
538-
539-// WaitTurnResult observes one delegated turn without consuming its result or
540-// affecting the worker when the observer is canceled. An empty turnID accepts
541-// the next available result.
542-func (a *Agent) WaitTurnResult(ctx context.Context, turnID string) (*TurnResult, error) {
543- if ctx == nil {
544- ctx = context.Background()
545- }
546- if turnID != "" {
547- a.lifecycleMu.Lock()
548- if a.resultWaiters == nil {
549- a.resultWaiters = make(map[string]int)
550- }
551- a.resultWaiters[turnID]++
552- a.lifecycleMu.Unlock()
553- defer func() {
554- a.lifecycleMu.Lock()
555- a.resultWaiters[turnID]--
556- if a.resultWaiters[turnID] == 0 {
557- delete(a.resultWaiters, turnID)
558- delete(a.results, turnID)
559- }
560- a.lifecycleMu.Unlock()
561- }()
562- }
563- for {
564- a.lifecycleMu.Lock()
565- result := cloneTurnResult(a.result)
566- if turnID != "" && a.results != nil {
567- result = cloneTurnResult(a.results[turnID])
568- }
569- if result != nil && (turnID == "" || result.TurnID == turnID) {
570- a.lifecycleMu.Unlock()
571- return result, nil
572- }
573- if a.resultChanged == nil {
574- a.resultChanged = make(chan struct{})
575- }
576- changed := a.resultChanged
577- done := a.done
578- a.lifecycleMu.Unlock()
579-
580- select {
581- case <-changed:
582- continue
583- case <-done:
584- a.lifecycleMu.Lock()
585- result := cloneTurnResult(a.result)
586- if turnID != "" && a.results != nil {
587- result = cloneTurnResult(a.results[turnID])
588- }
589- a.lifecycleMu.Unlock()
590- if result != nil && (turnID == "" || result.TurnID == turnID) {
591- return result, nil
592- }
593- return nil, fmt.Errorf("subagents: agent %s exited without turn result %q", a.ID, turnID)
594- case <-ctx.Done():
595- return nil, ctx.Err()
596- }
597- }
598-}
599-
600-func (a *Agent) closeDone() { a.doneOnce.Do(func() { close(a.done) }) }
601-
602-func (a *Agent) stateDirectory(defaultRoot string) string {
603- if a.stateDir != "" {
604- return a.stateDir
605- }
606- return filepath.Join(defaultRoot, "agents", a.ID)
607-}
608-
609-// SetOnTurnEnd installs (or clears, with nil) the callback fired
610-// from the runner when the child daemon emits a prompt-level
611-// turn_end event with a step field. Safe to call from any goroutine:
612-// the runner reads the callback under the same mutex.
613-func (a *Agent) resumePrompt() string {
614- prompt, _ := a.ResumePromptInfo()
615- return prompt
616-}
617-
618-// ResumePromptInfo returns the queued manager follow-up and its acceptance
619-// time. Both values are read as one synchronized snapshot.
620-func (a *Agent) ResumePromptInfo() (string, time.Time) {
621- a.mu.Lock()
622- defer a.mu.Unlock()
623- return a.resumePromptText, a.resumePromptAt
624-}
625-
626-func (a *Agent) setResumePrompt(prompt string, acceptedAt time.Time) resumePromptSnapshot {
627- a.mu.Lock()
628- defer a.mu.Unlock()
629- previous := resumePromptSnapshot{
630- Prompt: a.resumePromptText,
631- AcceptedAt: a.resumePromptAt,
632- CommandID: a.resumePromptID,
633- }
634- a.resumePromptText = prompt
635- a.resumePromptAt = acceptedAt
636- a.resumePromptID = uuid.NewString()
637- return previous
638-}
639-
640-func (a *Agent) restoreResumePrompt(previous resumePromptSnapshot) {
641- a.mu.Lock()
642- defer a.mu.Unlock()
643- a.resumePromptText = previous.Prompt
644- a.resumePromptAt = previous.AcceptedAt
645- a.resumePromptID = previous.CommandID
646- if a.resumePromptText != "" && a.resumePromptID == "" {
647- a.resumePromptID = uuid.NewString()
648- }
649-}
650-
651-func (a *Agent) resumePromptState() resumePromptSnapshot {
652- a.mu.Lock()
653- defer a.mu.Unlock()
654- return resumePromptSnapshot{
655- Prompt: a.resumePromptText,
656- AcceptedAt: a.resumePromptAt,
657- CommandID: a.resumePromptID,
658- }
659-}
660-
661-func (a *Agent) resumePromptCommandID() string {
662- a.mu.Lock()
663- defer a.mu.Unlock()
664- return a.resumePromptID
665-}
666-
667-func (a *Agent) clearResumePrompt() bool {
668- a.mu.Lock()
669- defer a.mu.Unlock()
670- if a.resumePromptText == "" {
671- return false
672- }
673- a.resumePromptText = ""
674- a.resumePromptAt = time.Time{}
675- a.resumePromptID = ""
676- return true
677-}
678-
679-func (a *Agent) queueResumePrompt(prompt string, acceptedAt time.Time) string {
680- commandID := uuid.NewString()
681- a.mu.Lock()
682- a.resumePromptQueue = append(a.resumePromptQueue, queuedResumePrompt{Prompt: prompt, AcceptedAt: acceptedAt, CommandID: commandID})
683- a.mu.Unlock()
684- return commandID
685-}
686-
687-func (a *Agent) removeQueuedResumePrompt(commandID string) bool {
688- if commandID == "" {
689- return false
690- }
691- a.mu.Lock()
692- defer a.mu.Unlock()
693- for i := len(a.resumePromptQueue) - 1; i >= 0; i-- {
694- if a.resumePromptQueue[i].CommandID == commandID {
695- a.resumePromptQueue = append(a.resumePromptQueue[:i], a.resumePromptQueue[i+1:]...)
696- return true
697- }
698- }
699- return false
700-}
701-
702-func (a *Agent) resumePromptQueueSnapshot() []queuedResumePrompt {
703- a.mu.Lock()
704- defer a.mu.Unlock()
705- return append([]queuedResumePrompt(nil), a.resumePromptQueue...)
706-}
707-
708-func (a *Agent) hasQueuedResumePrompt() bool {
709- a.mu.Lock()
710- defer a.mu.Unlock()
711- return len(a.resumePromptQueue) != 0
712-}
713-
714-// startQueuedResumePrompt moves the oldest active follow-up into the
715-// in-flight prompt slot. The caller must persist the returned state before
716-// delivering the command so a host exit cannot lose the prompt.
717-func (a *Agent) startQueuedResumePrompt() (string, time.Time, string, bool) {
718- a.mu.Lock()
719- defer a.mu.Unlock()
720- if a.resumePromptText != "" || len(a.resumePromptQueue) == 0 {
721- return "", time.Time{}, "", false
722- }
723- queued := a.resumePromptQueue[0]
724- a.resumePromptQueue = a.resumePromptQueue[1:]
725- a.resumePromptText = queued.Prompt
726- a.resumePromptAt = queued.AcceptedAt
727- a.resumePromptID = queued.CommandID
728- if a.resumePromptID == "" {
729- a.resumePromptID = uuid.NewString()
730- }
731- return queued.Prompt, queued.AcceptedAt, a.resumePromptID, true
732-}
733-
734-func (a *Agent) restoreResumePromptToQueue(prompt string, acceptedAt time.Time) bool {
735- a.mu.Lock()
736- defer a.mu.Unlock()
737- if a.resumePromptText != prompt || !a.resumePromptAt.Equal(acceptedAt) {
738- return false
739- }
740- commandID := a.resumePromptID
741- if commandID == "" {
742- commandID = uuid.NewString()
743- }
744- a.resumePromptText = ""
745- a.resumePromptAt = time.Time{}
746- a.resumePromptID = ""
747- a.resumePromptQueue = append([]queuedResumePrompt{{Prompt: prompt, AcceptedAt: acceptedAt, CommandID: commandID}}, a.resumePromptQueue...)
748- return true
749-}
750-
751-func (a *Agent) rejectActiveResumePrompt() bool {
752- a.mu.Lock()
753- defer a.mu.Unlock()
754- if a.resumePromptText == "" {
755- return false
756- }
757- a.resumePromptQueue = append([]queuedResumePrompt{{Prompt: a.resumePromptText, AcceptedAt: a.resumePromptAt, CommandID: a.resumePromptID}}, a.resumePromptQueue...)
758- a.resumePromptText = ""
759- a.resumePromptAt = time.Time{}
760- a.resumePromptID = ""
761- return true
762-}
763-
764-func (a *Agent) setOnTurnIdle(fn func()) {
765- a.mu.Lock()
766- a.onTurnIdle = fn
767- a.mu.Unlock()
768-}
769-
770-func (a *Agent) notifyTurnIdle() {
771- a.mu.Lock()
772- fn := a.onTurnIdle
773- a.mu.Unlock()
774- if fn != nil {
775- fn()
776- }
777-}
778-
779-func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) {
780- a.mu.Lock()
781- a.OnTurnEnd = fn
782- pending := append([]turnEndNotice(nil), a.pendingTurnEnds...)
783- a.pendingTurnEnds = nil
784- a.mu.Unlock()
785- if fn != nil {
786- for _, notice := range pending {
787- fn(notice.step, notice.errMsg)
788- }
789- }
790-}
791-
792-func (a *Agent) recordPersistenceError(err error) {
793- if a == nil || err == nil {
794- return
795- }
796- a.mu.Lock()
797- if a.lastErr == nil {
798- a.lastErr = fmt.Errorf("subagent metadata persistence: %w", err)
799- }
800- if a.status != StatusDone && a.status != StatusFailed && a.status != StatusKilled {
801- a.activity = "metadata error: " + truncate(err.Error(), 120)
802- }
803- a.mu.Unlock()
804-}
805-
806-func (a *Agent) setActivity(msg string) {
807- a.mu.Lock()
808- a.activity = strings.TrimSpace(msg)
809- a.mu.Unlock()
810- a.markActivity(time.Now())
811-}
812-
813-func (a *Agent) appendTranscript(chunk string) {
814- a.appendTranscriptChunk(chunk, "", false)
815-}
816-
817-func (a *Agent) appendUserMessage(text string) {
818- a.appendTranscriptChunk(text, "user: ", false)
819-}
820-
821-func (a *Agent) appendAssistantMessage(text string) {
822- if text == "" {
823- return
824- }
825- a.mu.Lock()
826- if a.streamingAssistantText != "" {
827- a.replaceStreamingAssistantLocked(text)
828- a.clearStreamingAssistantLocked()
829- } else {
830- a.appendTranscriptLinesLocked(text, "", true)
831- a.boundTranscriptLocked()
832- }
833- a.mu.Unlock()
834- a.markActivity(time.Now())
835-}
836-
837-// appendAssistantDelta exposes an in-progress assistant response in the
838-// dashboard without duplicating it when the final assistant_message arrives.
839-// The JSONL event log remains the durable source; this maintains its live and
840-// replayed in-memory projection.
841-func (a *Agent) appendAssistantDelta(text string) {
842- if text == "" {
843- return
844- }
845- a.mu.Lock()
846- if !a.streamingAssistantTruncated {
847- value := a.streamingAssistantText + text
848- byteCap, lineCap := a.outputLimits()
849- a.streamingAssistantText = boundInlineText(value, byteCap, lineCap)
850- a.streamingAssistantTruncated = a.streamingAssistantText != value
851- }
852- a.replaceStreamingAssistantLocked(a.streamingAssistantText)
853- a.mu.Unlock()
854- a.markActivity(time.Now())
855-}
856-
857-func (a *Agent) appendTranscriptChunk(chunk, linePrefix string, assistant bool) {
858- if strings.TrimRight(chunk, "\n") == "" {
859- return
860- }
861- a.mu.Lock()
862- a.appendTranscriptLinesLocked(chunk, linePrefix, assistant)
863- a.boundTranscriptLocked()
864- a.mu.Unlock()
865- a.markActivity(time.Now())
866-}
867-
868-func (a *Agent) resetStreamingAssistant() {
869- a.mu.Lock()
870- a.clearStreamingAssistantLocked()
871- a.mu.Unlock()
872-}
873-
874-func (a *Agent) replaceStreamingAssistantLocked(text string) {
875- a.removeStreamingAssistantLocked()
876- a.streamingAssistantStart = len(a.transcript)
877- a.streamingAssistantLines = a.appendTranscriptLinesLocked(text, "", true)
878- a.boundTranscriptLocked()
879-}
880-
881-func (a *Agent) removeStreamingAssistantLocked() {
882- if a.streamingAssistantLines == 0 {
883- return
884- }
885- start := a.streamingAssistantStart
886- if start < 0 {
887- start = 0
888- }
889- if start > len(a.transcript) {
890- start = len(a.transcript)
891- }
892- end := start + a.streamingAssistantLines
893- if end > len(a.transcript) {
894- end = len(a.transcript)
895- }
896- for _, line := range a.transcript[start:end] {
897- a.outputBytes -= len(line) + 1
898- a.outputLines--
899- }
900- a.transcript = append(a.transcript[:start], a.transcript[end:]...)
901- a.streamingAssistantLines = 0
902- if a.outputLines == 0 {
903- a.outputBytes = 0
904- }
905-}
906-
907-func (a *Agent) clearStreamingAssistantLocked() {
908- a.streamingAssistantText = ""
909- a.streamingAssistantTruncated = false
910- a.streamingAssistantStart = 0
911- a.streamingAssistantLines = 0
912-}
913-
914-func (a *Agent) appendTranscriptLinesLocked(chunk, linePrefix string, assistant bool) int {
915- message := chunk
916- chunk = strings.TrimRight(chunk, "\n")
917- if chunk == "" {
918- return 0
919- }
920- lines := strings.Split(chunk, "\n")
921- for _, line := range lines {
922- line = linePrefix + line
923- a.transcript = append(a.transcript, line)
924- a.outputBytes += len(line) + 1
925- a.outputLines++
926- }
927- if assistant {
928- a.lastAssistant = boundInlineText(message, a.maxOutputBytes, a.maxOutputLines)
929- }
930- return len(lines)
931-}
932-
933-func (a *Agent) outputLimits() (byteCap, lineCap int) {
934- byteCap = a.maxOutputBytes
935- if byteCap <= 0 {
936- byteCap = 500_000
937- }
938- lineCap = a.maxOutputLines
939- if lineCap <= 0 {
940- lineCap = 2_000
941- }
942- return byteCap, lineCap
943-}
944-
945-func (a *Agent) boundTranscriptLocked() {
946- // Bound the inline dashboard copy. The durable event log and session
947- // remain available through the logical history reference.
948- byteCap, lineCap := a.outputLimits()
949- for a.outputLines > lineCap || (a.outputLines > 0 && a.outputBytes > byteCap) {
950- line := a.transcript[0]
951- a.transcript = a.transcript[1:]
952- a.outputBytes -= len(line) + 1
953- a.outputLines--
954- if a.streamingAssistantLines > 0 {
955- if a.streamingAssistantStart > 0 {
956- a.streamingAssistantStart--
957- } else {
958- a.streamingAssistantLines--
959- }
960- }
961- a.outputTruncated = true
962- }
963- if a.outputLines == 0 {
964- a.outputBytes = 0
965- }
966-}
967-
968-// newAgentID returns a short, mostly-collision-free identifier of the
969-// form "<slug>-<nano>". The slug is derived from the task text so
970-// dashboards stay readable; the nano suffix guarantees uniqueness
971-// even when two agents are spawned in the same millisecond.
972-func newAgentID(task string, now time.Time) string {
973- slug := taskSlug(task)
974- return fmt.Sprintf("%s-%d", slug, now.UnixNano()%1_000_000)
975-}
976-
977-func taskSlug(task string) string {
978- task = strings.ToLower(task)
979- var b strings.Builder
980- dash := false
981- for _, r := range task {
982- switch {
983- case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
984- b.WriteRune(r)
985- dash = false
986- default:
987- if !dash && b.Len() > 0 {
988- b.WriteByte('-')
989- dash = true
990- }
991- }
992- if b.Len() >= 24 {
993- break
994- }
995- }
996- out := strings.Trim(b.String(), "-")
997- if out == "" {
998- out = "agent"
999- }
1000- return out
1001-}
D packages/agent/subagents/agent_test.go +0 -189
@@ -1,189 +0,0 @@
1-package subagents
2-
3-import (
4- "context"
5- "runtime"
6- "sync"
7- "testing"
8- "time"
9-)
10-
11-func TestAgentAttemptValueAndTranscriptAccounting(t *testing.T) {
12- a := &Agent{maxOutputBytes: 6, maxOutputLines: 10}
13- if got := a.AttemptValue(); got != 0 {
14- t.Fatalf("initial attempt = %d, want 0", got)
15- }
16- if got := a.incrementAttempt(); got != 1 || a.AttemptValue() != 1 {
17- t.Fatalf("attempt after increment = %d, value = %d; want 1", got, a.AttemptValue())
18- }
19-
20- a.appendTranscript("a\nbb")
21- if got := a.outputBytes; got != 5 {
22- t.Fatalf("output bytes after append = %d, want 5", got)
23- }
24- if got := a.outputLines; got != 2 {
25- t.Fatalf("output lines after append = %d, want 2", got)
26- }
27- a.appendTranscript("ccc")
28- if got, want := a.Transcript(), []string{"ccc"}; len(got) != 1 || got[0] != want[0] {
29- t.Fatalf("transcript after byte eviction = %q, want %q", got, want)
30- }
31- if a.outputBytes != 4 || a.outputLines != 1 {
32- t.Fatalf("accounting after byte eviction = (%d bytes, %d lines), want (4, 1)", a.outputBytes, a.outputLines)
33- }
34-
35- a.maxOutputBytes = 1
36- a.appendTranscript("z")
37- if got := a.Transcript(); len(got) != 0 {
38- t.Fatalf("transcript after evicting every line = %q, want empty", got)
39- }
40- if a.outputBytes != 0 || a.outputLines != 0 {
41- t.Fatalf("empty accounting = (%d bytes, %d lines), want (0, 0)", a.outputBytes, a.outputLines)
42- }
43-}
44-
45-func TestAgentTranscriptLineCapAccounting(t *testing.T) {
46- a := &Agent{maxOutputBytes: 100, maxOutputLines: 2}
47- a.appendTranscript("one\ntwo\nthree")
48-
49- if got, want := a.Transcript(), []string{"two", "three"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
50- t.Fatalf("transcript = %q, want %q", got, want)
51- }
52- if a.outputBytes != len("two")+1+len("three")+1 || a.outputLines != 2 {
53- t.Fatalf("line-cap accounting = (%d bytes, %d lines), want (10, 2)", a.outputBytes, a.outputLines)
54- }
55-}
56-
57-func TestAgentSnapshotConcurrentTranscriptTruncation(t *testing.T) {
58- a := &Agent{maxOutputBytes: 8, maxOutputLines: 1}
59- start := make(chan struct{})
60- var wg sync.WaitGroup
61- wg.Add(2)
62- go func() {
63- defer wg.Done()
64- <-start
65- for range 10_000 {
66- a.appendTranscript("overflow")
67- }
68- }()
69- go func() {
70- defer wg.Done()
71- <-start
72- for range 10_000 {
73- _ = a.Snapshot()
74- }
75- }()
76- close(start)
77- wg.Wait()
78-
79- if !a.Snapshot().OutputTruncated {
80- t.Fatal("snapshot did not report transcript truncation")
81- }
82-}
83-
84-func TestWaitTurnResultBroadcastsToMultipleObservers(t *testing.T) {
85- a := &Agent{ID: "worker-1", done: make(chan struct{})}
86- ctx, cancel := context.WithTimeout(context.Background(), time.Second)
87- defer cancel()
88-
89- results := make(chan *TurnResult, 2)
90- errs := make(chan error, 2)
91- for range 2 {
92- go func() {
93- result, err := a.WaitTurnResult(ctx, "turn-1")
94- results <- result
95- errs <- err
96- }()
97- }
98- a.setResult(&TurnResult{Version: ProtocolVersion, AgentID: a.ID, TurnID: "turn-1", Status: ResultSucceeded})
99-
100- for range 2 {
101- if err := <-errs; err != nil {
102- t.Fatalf("wait error = %v", err)
103- }
104- if result := <-results; result == nil || result.TurnID != "turn-1" {
105- t.Fatalf("wait result = %#v, want turn-1", result)
106- }
107- }
108-}
109-
110-func TestWaitTurnResultRetainsEarlierCompletedTurn(t *testing.T) {
111- a := &Agent{ID: "worker-1", done: make(chan struct{})}
112- resultReady := make(chan *TurnResult, 1)
113- errReady := make(chan error, 1)
114- go func() {
115- result, err := a.WaitTurnResult(context.Background(), "turn-1")
116- resultReady <- result
117- errReady <- err
118- }()
119- deadline := time.Now().Add(time.Second)
120- for {
121- a.lifecycleMu.Lock()
122- registered := a.resultWaiters["turn-1"] > 0
123- a.lifecycleMu.Unlock()
124- if registered {
125- break
126- }
127- if time.Now().After(deadline) {
128- t.Fatal("turn-1 waiter did not register")
129- }
130- runtime.Gosched()
131- }
132- a.setResult(&TurnResult{Version: ProtocolVersion, AgentID: a.ID, TurnID: "turn-1", Status: ResultSucceeded})
133- a.setResult(&TurnResult{Version: ProtocolVersion, AgentID: a.ID, TurnID: "turn-2", Status: ResultSucceeded})
134-
135- if err := <-errReady; err != nil {
136- t.Fatal(err)
137- }
138- result := <-resultReady
139- if result == nil || result.TurnID != "turn-1" {
140- t.Fatalf("wait result = %#v, want retained turn-1", result)
141- }
142- a.lifecycleMu.Lock()
143- defer a.lifecycleMu.Unlock()
144- if len(a.results) != 0 || len(a.resultWaiters) != 0 {
145- t.Fatalf("completed waiter state leaked: results=%d waiters=%d", len(a.results), len(a.resultWaiters))
146- }
147-}
148-
149-func TestWaitTurnResultReturnsRequestedResultAfterAgentExit(t *testing.T) {
150- a := &Agent{ID: "worker-1", done: make(chan struct{}), resultChanged: make(chan struct{})}
151- resultReady := make(chan *TurnResult, 1)
152- errReady := make(chan error, 1)
153- go func() {
154- result, err := a.WaitTurnResult(context.Background(), "turn-1")
155- resultReady <- result
156- errReady <- err
157- }()
158- deadline := time.Now().Add(time.Second)
159- for {
160- a.lifecycleMu.Lock()
161- registered := a.resultWaiters["turn-1"] > 0
162- a.lifecycleMu.Unlock()
163- if registered {
164- break
165- }
166- if time.Now().After(deadline) {
167- t.Fatal("turn-1 waiter did not register")
168- }
169- runtime.Gosched()
170- }
171- a.lifecycleMu.Lock()
172- a.results = map[string]*TurnResult{"turn-1": {Version: ProtocolVersion, AgentID: a.ID, TurnID: "turn-1", Status: ResultSucceeded}}
173- a.result = &TurnResult{Version: ProtocolVersion, AgentID: a.ID, TurnID: "turn-2", Status: ResultSucceeded}
174- a.lifecycleMu.Unlock()
175- a.closeDone()
176- if err := <-errReady; err != nil {
177- t.Fatal(err)
178- }
179- if result := <-resultReady; result == nil || result.TurnID != "turn-1" {
180- t.Fatalf("wait result = %#v, want retained turn-1", result)
181- }
182-}
183-
184-func TestWaitTargetTurnIDAdvancesQueuedResume(t *testing.T) {
185- a := &Agent{LifetimeTurns: 1, turnState: TurnQueued, currentTurnID: "turn-1"}
186- if got := a.WaitTargetTurnID(); got != "turn-2" {
187- t.Fatalf("wait target = %q, want turn-2", got)
188- }
189-}
D packages/agent/subagents/batch.go +0 -444
@@ -1,444 +0,0 @@
1-package subagents
2-
3-import (
4- "context"
5- "encoding/json"
6- "errors"
7- "fmt"
8- "io"
9- "os"
10- "path/filepath"
11- "strings"
12- "sync"
13- "time"
14-)
15-
16-// BatchRequest starts independent child tasks under one aggregate id. Batch
17-// scheduling is deliberately shallow; dependency graphs belong in workflows.
18-type BatchRequest struct {
19- Context string
20- Tasks []string
21- MaxConcurrent int
22- Spawn SpawnRequest
23-}
24-
25-type BatchStatus string
26-
27-const (
28- BatchQueued BatchStatus = "queued"
29- BatchRunning BatchStatus = "running"
30- BatchSucceeded BatchStatus = "succeeded"
31- BatchFailed BatchStatus = "failed"
32- BatchCanceled BatchStatus = "canceled"
33-)
34-
35-type BatchResult struct {
36- Version int `json:"version"`
37- BatchID string `json:"batch_id"`
38- TaskCount int `json:"task_count,omitempty"`
39- Status BatchStatus `json:"status"`
40- ChildIDs []string `json:"child_ids"`
41- Results map[string]*TurnResult `json:"results,omitempty"`
42- Error string `json:"error,omitempty"`
43- CreatedAt time.Time `json:"created_at"`
44- UpdatedAt time.Time `json:"updated_at"`
45-}
46-
47-const maxBatchFileBytes = 32 * 1024 * 1024
48-
49-type Batch struct {
50- ID string
51- Context string
52- ChildIDs []string
53- MaxConcurrent int
54- TaskCount int
55- CreatedAt time.Time
56-
57- mu sync.Mutex
58- status BatchStatus
59- result *BatchResult
60- done chan struct{}
61- once sync.Once
62-}
63-
64-func (b *Batch) Status() BatchStatus {
65- b.mu.Lock()
66- defer b.mu.Unlock()
67- return b.status
68-}
69-
70-func (b *Batch) Result() *BatchResult {
71- b.mu.Lock()
72- defer b.mu.Unlock()
73- return cloneBatchResult(b.result)
74-}
75-
76-func (b *Batch) Wait() { <-b.done }
77-func (b *Batch) closeDone() { b.once.Do(func() { close(b.done) }) }
78-
79-func (b *Batch) WaitContext(ctx context.Context) error {
80- if ctx == nil {
81- ctx = context.Background()
82- }
83- select {
84- case <-b.done:
85- return nil
86- case <-ctx.Done():
87- return ctx.Err()
88- }
89-}
90-
91-func (f *Supervisor) SpawnBatch(ctx context.Context, req BatchRequest) (*Batch, error) {
92- if len(req.Tasks) == 0 {
93- return nil, fmt.Errorf("subagents: batch requires at least one task")
94- }
95- if ctx == nil {
96- ctx = context.Background()
97- }
98- now := f.cfg.Now()
99- baseID := newAgentID("batch "+now.Format("150405.000"), now)
100- f.mu.Lock()
101- if f.batches == nil {
102- f.batches = make(map[string]*Batch)
103- }
104- batchID := baseID
105- for suffix := 2; ; suffix++ {
106- if _, exists := f.batches[batchID]; !exists {
107- break
108- }
109- batchID = fmt.Sprintf("%s-%d", baseID, suffix)
110- }
111- batch := &Batch{
112- ID: batchID,
113- Context: strings.TrimSpace(req.Context),
114- MaxConcurrent: req.MaxConcurrent,
115- TaskCount: len(req.Tasks),
116- CreatedAt: now,
117- status: BatchQueued,
118- result: &BatchResult{
119- Version: ProtocolVersion, BatchID: batchID, TaskCount: len(req.Tasks), Status: BatchQueued,
120- CreatedAt: now, UpdatedAt: now,
121- },
122- done: make(chan struct{}),
123- }
124- f.batches[batch.ID] = batch
125- f.mu.Unlock()
126- // Write a queued manifest before admitting children so a crash during
127- // batch admission still leaves an aggregate id to reload and reconcile.
128- f.persistBatch(batch)
129-
130- for _, task := range req.Tasks {
131- spawnReq := req.Spawn
132- spawnReq.Task = task
133- spawnReq.ParentID = batch.ID
134- spawnReq.BatchID = batch.ID
135- a, err := f.SpawnReq(ctx, spawnReq)
136- if err != nil {
137- childIDs := append([]string(nil), batch.ChildIDs...)
138- for _, childID := range childIDs {
139- _ = f.Stop(childID)
140- }
141- for _, childID := range childIDs {
142- if child := f.Get(childID); child != nil {
143- child.Wait()
144- }
145- }
146- batch.mu.Lock()
147- batch.status = BatchFailed
148- batch.result = &BatchResult{
149- Version: ProtocolVersion, BatchID: batch.ID, TaskCount: batch.TaskCount, Status: BatchFailed,
150- ChildIDs: childIDs, Error: err.Error(), CreatedAt: now, UpdatedAt: f.cfg.Now(),
151- }
152- batch.mu.Unlock()
153- f.persistBatch(batch)
154- batch.closeDone()
155- return batch, err
156- }
157- batch.ChildIDs = append(batch.ChildIDs, a.ID)
158- batch.mu.Lock()
159- batch.result = &BatchResult{
160- Version: ProtocolVersion, BatchID: batch.ID, TaskCount: batch.TaskCount, Status: BatchQueued,
161- ChildIDs: append([]string(nil), batch.ChildIDs...), CreatedAt: batch.CreatedAt,
162- UpdatedAt: f.cfg.Now(),
163- }
164- batch.mu.Unlock()
165- f.persistBatch(batch)
166- }
167- batch.mu.Lock()
168- batch.status = BatchRunning
169- batch.mu.Unlock()
170- f.persistBatch(batch)
171- go f.collectBatch(batch)
172- return batch, nil
173-}
174-
175-func (f *Supervisor) collectBatch(batch *Batch) {
176- if batch.TaskCount > 0 && len(batch.ChildIDs) < batch.TaskCount {
177- batch.mu.Lock()
178- batch.status = BatchFailed
179- batch.result = &BatchResult{
180- Version: ProtocolVersion, BatchID: batch.ID, TaskCount: batch.TaskCount,
181- Status: BatchFailed, ChildIDs: append([]string(nil), batch.ChildIDs...),
182- Error: "batch admission was incomplete", CreatedAt: batch.CreatedAt, UpdatedAt: f.cfg.Now(),
183- }
184- batch.mu.Unlock()
185- f.persistBatch(batch)
186- batch.closeDone()
187- return
188- }
189- results := make(map[string]*TurnResult, len(batch.ChildIDs))
190- status := BatchSucceeded
191- var firstErr string
192- for _, id := range batch.ChildIDs {
193- a := f.Get(id)
194- if a == nil {
195- status = BatchFailed
196- if firstErr == "" {
197- firstErr = "missing child " + id
198- }
199- continue
200- }
201- result, err := f.waitForBatchResult(f.lifetimeCtx, a)
202- if err == nil {
203- results[id] = result
204- switch result.Status {
205- case ResultFailed:
206- if status == BatchSucceeded {
207- status = BatchFailed
208- if result.Error != nil {
209- firstErr = result.Error.Message
210- }
211- }
212- case ResultCanceled:
213- if status == BatchSucceeded {
214- status = BatchCanceled
215- firstErr = "one or more batch children were canceled"
216- }
217- }
218- } else {
219- status = BatchFailed
220- if firstErr == "" {
221- firstErr = err.Error()
222- }
223- }
224- }
225- batch.mu.Lock()
226- batch.status = status
227- batch.result = &BatchResult{
228- Version: ProtocolVersion, BatchID: batch.ID, TaskCount: batch.TaskCount, Status: status,
229- ChildIDs: append([]string(nil), batch.ChildIDs...), Results: results,
230- Error: firstErr, CreatedAt: batch.CreatedAt, UpdatedAt: f.cfg.Now(),
231- }
232- batch.mu.Unlock()
233- f.persistBatch(batch)
234- batch.closeDone()
235-}
236-
237-func (f *Supervisor) waitForBatchResult(ctx context.Context, a *Agent) (*TurnResult, error) {
238- if a == nil {
239- return nil, errors.New("subagents: missing batch child")
240- }
241- if ctx == nil {
242- ctx = context.Background()
243- }
244- if result := a.Result(); result != nil {
245- return result, nil
246- }
247- if a.Status() != StatusDetached {
248- return a.waitForTurnResult(ctx)
249- }
250-
251- // A reloaded worker has a closed supervisor-side done channel even when
252- // its orphan process is still alive. Reconcile durable result.json/events
253- // before treating that closed channel as a failed child.
254- readDurable := func() *TurnResult {
255- if result, err := readTurnResult(a.stateDirectory(f.cfg.Root)); err == nil && validateTurnResultAgent(result, a.ID) == nil {
256- result = result.Bounded(f.cfg.Policy.MaxOutputBytes, f.cfg.Policy.MaxOutputLines)
257- a.setResult(result)
258- return result
259- }
260- events, err := ReadEventLog(a.EventLogPath)
261- if err != nil {
262- return nil
263- }
264- for _, event := range events {
265- if event.Type != EventTurnResult && event.Type != "turn_result" {
266- continue
267- }
268- result, decodeErr := decodeTurnResultEvent(event, a.ID, f.cfg.Policy.MaxOutputBytes, f.cfg.Policy.MaxOutputLines)
269- if decodeErr != nil {
270- continue
271- }
272- a.setResult(result)
273- return result
274- }
275- return nil
276- }
277-
278- if result := readDurable(); result != nil {
279- return result, nil
280- }
281- if !inboxLive(a.InboxPath) {
282- return nil, fmt.Errorf("subagents: agent %s exited without a turn result", a.ID)
283- }
284-
285- interval := 100 * time.Millisecond
286- ticker := time.NewTicker(interval)
287- defer ticker.Stop()
288- for {
289- select {
290- case <-ticker.C:
291- if result := readDurable(); result != nil {
292- return result, nil
293- }
294- if !inboxLive(a.InboxPath) {
295- return nil, fmt.Errorf("subagents: agent %s exited without a turn result", a.ID)
296- }
297- case <-ctx.Done():
298- return nil, ctx.Err()
299- }
300- }
301-}
302-
303-func (f *Supervisor) WaitBatch(id string) (*BatchResult, error) {
304- return f.WaitBatchContext(context.Background(), id)
305-}
306-
307-// WaitBatchContext waits for the aggregate result without changing the batch
308-// or any child when the observer's context ends.
309-func (f *Supervisor) WaitBatchContext(ctx context.Context, id string) (*BatchResult, error) {
310- f.mu.Lock()
311- batch := f.batches[id]
312- f.mu.Unlock()
313- if batch == nil {
314- return nil, fmt.Errorf("subagents: no such batch %q", id)
315- }
316- if err := batch.WaitContext(ctx); err != nil {
317- return nil, err
318- }
319- return batch.Result(), nil
320-}
321-
322-func (f *Supervisor) GetBatch(id string) *Batch {
323- f.mu.Lock()
324- defer f.mu.Unlock()
325- return f.batches[id]
326-}
327-
328-func cloneBatchResult(result *BatchResult) *BatchResult {
329- if result == nil {
330- return nil
331- }
332- copy := *result
333- copy.ChildIDs = append([]string(nil), result.ChildIDs...)
334- if result.Results != nil {
335- copy.Results = make(map[string]*TurnResult, len(result.Results))
336- for id, child := range result.Results {
337- copy.Results[id] = cloneTurnResult(child)
338- }
339- }
340- return &copy
341-}
342-
343-func (f *Supervisor) reloadBatches(root string) []error {
344- entries, err := os.ReadDir(filepath.Join(root, "batches"))
345- if err != nil {
346- if errors.Is(err, os.ErrNotExist) {
347- return nil
348- }
349- return []error{fmt.Errorf("subagents reload batches %s: %w", root, err)}
350- }
351- var errs []error
352- for _, entry := range entries {
353- if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
354- continue
355- }
356- fileID := strings.TrimSuffix(entry.Name(), ".json")
357- if !safeAgentID(fileID) {
358- errs = append(errs, fmt.Errorf("subagents reload batch %s: unsafe batch id", entry.Name()))
359- continue
360- }
361- path := filepath.Join(root, "batches", entry.Name())
362- file, openErr := os.Open(path)
363- if openErr != nil {
364- errs = append(errs, fmt.Errorf("subagents reload batch %s: %w", path, openErr))
365- continue
366- }
367- data, readErr := io.ReadAll(io.LimitReader(file, maxBatchFileBytes+1))
368- _ = file.Close()
369- if readErr != nil {
370- errs = append(errs, fmt.Errorf("subagents reload batch %s: %w", path, readErr))
371- continue
372- }
373- if len(data) > maxBatchFileBytes {
374- errs = append(errs, fmt.Errorf("subagents reload batch %s: exceeds %d bytes", path, maxBatchFileBytes))
375- continue
376- }
377- var result BatchResult
378- if unmarshalErr := json.Unmarshal(data, &result); unmarshalErr != nil {
379- errs = append(errs, fmt.Errorf("subagents reload batch %s: %w", path, unmarshalErr))
380- continue
381- }
382- if result.Version == 0 {
383- result.Version = ProtocolVersion
384- }
385- if result.Version != ProtocolVersion || result.BatchID != fileID || !safeAgentID(result.BatchID) || result.TaskCount < 0 || len(result.ChildIDs) > result.TaskCount {
386- errs = append(errs, fmt.Errorf("subagents reload batch %s: invalid result metadata", path))
387- continue
388- }
389- batch := &Batch{
390- ID: result.BatchID,
391- ChildIDs: append([]string(nil), result.ChildIDs...),
392- TaskCount: result.TaskCount,
393- CreatedAt: result.CreatedAt,
394- status: result.Status,
395- result: cloneBatchResult(&result),
396- done: make(chan struct{}),
397- }
398- if batch.CreatedAt.IsZero() {
399- batch.CreatedAt = result.UpdatedAt
400- }
401- f.mu.Lock()
402- _, exists := f.batches[batch.ID]
403- if !exists {
404- f.batches[batch.ID] = batch
405- }
406- f.mu.Unlock()
407- if exists {
408- continue
409- }
410- switch batch.status {
411- case BatchSucceeded, BatchFailed, BatchCanceled:
412- batch.closeDone()
413- default:
414- go f.collectBatch(batch)
415- }
416- }
417- return errs
418-}
419-
420-func (f *Supervisor) persistBatch(batch *Batch) {
421- if batch == nil || !safeAgentID(batch.ID) {
422- return
423- }
424- result := batch.Result()
425- if result == nil {
426- return
427- }
428- data, err := json.MarshalIndent(result, "", " ")
429- if err != nil || len(data) > maxBatchFileBytes {
430- return
431- }
432- dir := filepath.Join(f.cfg.Root, "batches")
433- if err := os.MkdirAll(dir, 0o700); err != nil {
434- return
435- }
436- path := filepath.Join(dir, batch.ID+".json")
437- tmp := path + ".tmp"
438- if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil {
439- return
440- }
441- if err := os.Rename(tmp, path); err != nil {
442- _ = os.Remove(tmp)
443- }
444-}
D packages/agent/subagents/batch_test.go +0 -210
@@ -1,210 +0,0 @@
1-package subagents
2-
3-import (
4- "context"
5- "encoding/json"
6- "errors"
7- "fmt"
8- "os"
9- "path/filepath"
10- "testing"
11- "time"
12-)
13-
14-func TestBatchIDsRemainUniqueWithFixedClock(t *testing.T) {
15- now := time.Unix(123, 0)
16- f := New(Config{
17- Root: t.TempDir(), RepoRoot: t.TempDir(), Now: func() time.Time { return now },
18- Policy: SubagentPolicy{MaxConcurrent: 2},
19- NewRunner: func(*Agent) Runner {
20- return RunnerFunc(func(context.Context, Sink) error { return nil })
21- },
22- })
23- t.Cleanup(f.StopAll)
24- first, err := f.SpawnBatch(context.Background(), BatchRequest{Tasks: []string{"one"}})
25- if err != nil {
26- t.Fatal(err)
27- }
28- second, err := f.SpawnBatch(context.Background(), BatchRequest{Tasks: []string{"two"}})
29- if err != nil {
30- t.Fatal(err)
31- }
32- first.Wait()
33- second.Wait()
34- if first.ID == second.ID {
35- t.Fatalf("duplicate batch ids: %q", first.ID)
36- }
37-}
38-
39-func TestBatchReloadsDurableResult(t *testing.T) {
40- root := t.TempDir()
41- newSupervisor := func() *Supervisor {
42- return New(Config{
43- Root: root, RepoRoot: root,
44- NewRunner: func(*Agent) Runner {
45- return RunnerFunc(func(context.Context, Sink) error { return nil })
46- },
47- })
48- }
49- first := newSupervisor()
50- t.Cleanup(first.StopAll)
51- batch, err := first.SpawnBatch(context.Background(), BatchRequest{Context: "review", Tasks: []string{"one", "two"}})
52- if err != nil {
53- t.Fatal(err)
54- }
55- want, err := first.WaitBatch(batch.ID)
56- if err != nil {
57- t.Fatal(err)
58- }
59- if want.Status != BatchSucceeded {
60- t.Fatalf("first batch status = %s", want.Status)
61- }
62- // WaitBatch returns once the aggregate result is available, which can
63- // precede each child's final metadata write. Wait for that finalisation
64- // before another supervisor reloads the same durable state.
65- for _, childID := range batch.ChildIDs {
66- child := first.Get(childID)
67- if child == nil {
68- t.Fatalf("first supervisor is missing child %q", childID)
69- }
70- child.Wait()
71- }
72-
73- second := newSupervisor()
74- t.Cleanup(second.StopAll)
75- loaded, errs := second.Reload()
76- if len(errs) != 0 {
77- t.Fatalf("reload errors: %v", errs)
78- }
79- if loaded != 2 {
80- t.Fatalf("reload loaded %d agents, want 2", loaded)
81- }
82- got, err := second.WaitBatch(batch.ID)
83- if err != nil {
84- t.Fatal(err)
85- }
86- if got.Status != BatchSucceeded || len(got.Results) != 2 {
87- t.Fatalf("reloaded batch = %#v", got)
88- }
89-}
90-
91-func TestBatchReloadRejectsUnsafeIDs(t *testing.T) {
92- root := t.TempDir()
93- batchDir := filepath.Join(root, "batches")
94- if err := os.MkdirAll(batchDir, 0o700); err != nil {
95- t.Fatal(err)
96- }
97- data, err := json.Marshal(BatchResult{
98- Version: ProtocolVersion, BatchID: "../escape", TaskCount: 0, Status: BatchRunning,
99- })
100- if err != nil {
101- t.Fatal(err)
102- }
103- if err := os.WriteFile(filepath.Join(batchDir, "queued.json"), data, 0o600); err != nil {
104- t.Fatal(err)
105- }
106- f := New(Config{Root: root, RepoRoot: root})
107- if _, errs := f.Reload(); len(errs) == 0 {
108- t.Fatal("unsafe batch metadata was accepted")
109- }
110- if _, err := os.Stat(filepath.Join(root, "escape.json")); !os.IsNotExist(err) {
111- t.Fatalf("unsafe batch path was written: %v", err)
112- }
113-}
114-
115-func TestBatchSpawnWaitAndCollect(t *testing.T) {
116- root := t.TempDir()
117- f := New(Config{
118- Root: root, RepoRoot: root,
119- Policy: SubagentPolicy{MaxConcurrent: 2, MaxConcurrentPerParent: 2, DefaultTimeout: 0, IdleTimeout: 0},
120- NewRunner: func(*Agent) Runner {
121- return RunnerFunc(func(context.Context, Sink) error { return nil })
122- },
123- })
124- t.Cleanup(f.StopAll)
125- batch, err := f.SpawnBatch(context.Background(), BatchRequest{Context: "review", Tasks: []string{"one", "two"}, MaxConcurrent: 1})
126- if err != nil {
127- t.Fatal(err)
128- }
129- result, err := f.WaitBatch(batch.ID)
130- if err != nil {
131- t.Fatal(err)
132- }
133- if result.Status != BatchSucceeded || len(result.ChildIDs) != 2 || len(result.Results) != 2 {
134- t.Fatalf("batch result = %#v", result)
135- }
136- if batch.Status() != BatchSucceeded {
137- t.Fatalf("batch status = %s", batch.Status())
138- }
139-}
140-
141-func TestBatchResultWaitCancellationDoesNotCancelWorker(t *testing.T) {
142- started := make(chan struct{})
143- release := make(chan struct{})
144- f := New(Config{
145- Root: t.TempDir(), RepoRoot: t.TempDir(),
146- NewRunner: func(*Agent) Runner {
147- return RunnerFunc(func(context.Context, Sink) error {
148- close(started)
149- <-release
150- return nil
151- })
152- },
153- })
154- t.Cleanup(f.StopAll)
155- batch, err := f.SpawnBatch(context.Background(), BatchRequest{Tasks: []string{"one"}})
156- if err != nil {
157- t.Fatal(err)
158- }
159- <-started
160- ctx, cancel := context.WithCancel(context.Background())
161- cancel()
162-
163- result, err := f.WaitBatchContext(ctx, batch.ID)
164- if !errors.Is(err, context.Canceled) {
165- t.Fatalf("wait error = %v, want context.Canceled", err)
166- }
167- if result != nil {
168- t.Fatalf("wait result = %#v, want nil", result)
169- }
170- child := f.Get(batch.ChildIDs[0])
171- if child == nil || child.Status() != StatusRunning {
172- t.Fatalf("worker after observer cancellation = %#v, want running", child)
173- }
174- close(release)
175- completed, err := f.WaitBatch(batch.ID)
176- if err != nil {
177- t.Fatal(err)
178- }
179- if completed.Status != BatchSucceeded || completed.Results[child.ID] == nil {
180- t.Fatalf("completed batch = %#v, want successful retained worker result", completed)
181- }
182-}
183-
184-func TestBatchAllowsMoreThanFormerLifetimeLimit(t *testing.T) {
185- const workersBeyondFormerLifetimeLimit = 33
186- tasks := make([]string, workersBeyondFormerLifetimeLimit)
187- for i := range tasks {
188- tasks[i] = fmt.Sprintf("worker-%d", i)
189- }
190- f := New(Config{
191- Root: t.TempDir(), RepoRoot: t.TempDir(),
192- Policy: SubagentPolicy{MaxConcurrent: 1, MaxConcurrentPerParent: 1},
193- NewRunner: func(*Agent) Runner {
194- return RunnerFunc(func(context.Context, Sink) error { return nil })
195- },
196- })
197- t.Cleanup(f.StopAll)
198-
199- batch, err := f.SpawnBatch(context.Background(), BatchRequest{Tasks: tasks, MaxConcurrent: 1})
200- if err != nil {
201- t.Fatal(err)
202- }
203- result, err := f.WaitBatch(batch.ID)
204- if err != nil {
205- t.Fatal(err)
206- }
207- if result.Status != BatchSucceeded || len(result.ChildIDs) != len(tasks) || len(result.Results) != len(tasks) {
208- t.Fatalf("batch result = %#v", result)
209- }
210-}
M packages/agent/subagents/completion.go +50 -479
@@ -3,552 +3,123 @@ package subagents
33 import (
44 "context"
55 "fmt"
6- "sort"
7- "strconv"
86 "strings"
97 "sync"
108 )
119
12-// Completion is the observed outcome of one delegated turn. The values are
13-// kept unbounded so callers can decide how much evidence to retain; the
14-// shared formatter applies the bounds used in parent-agent updates.
10+// Completion is one resident child's terminal turn outcome.
1511 type Completion struct {
16- AgentID string
17- Status string
18- Task string
19- Error string
20- FinalResponse string
21- Tail string
22-
23- // turnError preserves the label used by the interactive update for an
24- // error reported by a prompt-level turn_end rather than by the worker
25- // process itself.
26- turnError bool
27- cancellation bool
28-
29- registrationSeq uint64
30-}
31-
32-type completionEntry struct {
33- state *completionAgent
34- task string
35- followUp bool
36- expectedStep int
37- strictStep bool
38- registrationSeq uint64
39- done bool
40- completion *Completion
41-}
42-
43-type completionAgent struct {
44- agent *Agent
45- entries []*completionEntry
46- seenTurnEnds map[int]struct{}
47- turnMu sync.Mutex
48- callbackInstalled bool
49- waitCancel context.CancelFunc
50- waitingForProcess bool
51- waitDone chan struct{}
12+ AgentID string
13+ Status string
14+ Task string
15+ Error string
16+ Summary string
5217 }
5318
54-// CompletionTracker observes delegated turns through Agent's event and
55-// lifecycle callbacks. Completed values are buffered so a worker can finish
56-// before the parent begins waiting for the batch.
19+// CompletionTracker coordinates resident child completions with the parent
20+// turn. It has no process or worker-event dependency.
5721 type CompletionTracker struct {
58- mu sync.Mutex
59- active map[*Agent]*completionAgent
60- ready []Completion
61- nextRegistration uint64
62- changed chan struct{}
22+ mu sync.Mutex
23+ pending int
24+ ready []Completion
25+ changed chan struct{}
6326 }
6427
65-// NewCompletionTracker creates an empty completion tracker.
6628 func NewCompletionTracker() *CompletionTracker {
67- return &CompletionTracker{
68- active: make(map[*Agent]*completionAgent),
69- changed: make(chan struct{}),
70- }
71-}
72-
73-func (t *CompletionTracker) initLocked() {
74- if t.active == nil {
75- t.active = make(map[*Agent]*completionAgent)
76- }
77- if t.changed == nil {
78- t.changed = make(chan struct{})
79- }
80-}
81-
82-// TrackTurn tracks the worker's current prompt-level turn, or its first turn
83-// when no turn has started yet. Multiple turns for the same long-lived worker
84-// are retained and dispatched in registration order.
85-func (t *CompletionTracker) TrackTurn(a *Agent, task string, followUp bool) func() {
86- return t.trackTurn(a, task, followUp, false)
87-}
88-
89-// TrackFutureTurn tracks a prompt-level turn before its prompt is delivered.
90-// The returned function removes the registration without producing a
91-// completion; callers use it when prompt delivery is rejected.
92-func (t *CompletionTracker) TrackFutureTurn(a *Agent, task string, followUp bool) func() {
93- return t.trackTurn(a, task, followUp, true)
29+ return &CompletionTracker{changed: make(chan struct{})}
9430 }
9531
96-func (t *CompletionTracker) trackTurn(a *Agent, task string, followUp, future bool) func() {
97- if t == nil || a == nil {
98- return nil
99- }
100-
101- state, entry, installCallback := t.addTurn(a, task, followUp, future)
102- if state == nil {
103- return nil
104- }
105- if installCallback {
106- // SetOnTurnEnd is deliberately installed before the process waiter and
107- // before the caller sends a follow-up. It also replays notices that
108- // arrived while no callback was registered.
109- a.SetOnTurnEnd(func(step int, errMsg string) {
110- state.turnMu.Lock()
111- defer state.turnMu.Unlock()
112- t.completeNextTurn(state, step, errMsg)
113- })
114- }
115- t.startProcessWaiter(state)
116- return func() { t.cancelEntry(entry) }
117-}
118-
119-// TrackExit tracks only process termination. It is used for explicit worker
120-// stops when no prompt-level turn watcher owns the worker.
121-func (t *CompletionTracker) TrackExit(a *Agent, task string) {
122- if t == nil || a == nil {
123- return
124- }
125- state, entry := t.addExit(a, task)
126- if state == nil || entry == nil {
127- return
128- }
129- t.startProcessWaiter(state)
130-}
131-
132-// Reset discards all active and ready completions. Hosts use it when the
133-// owning turn is cancelled so a late result cannot be attributed to a later
134-// registration for the same worker.
135-func (t *CompletionTracker) Reset() {
32+func (t *CompletionTracker) TrackResident() {
13633 if t == nil {
13734 return
13835 }
139- var cancels []context.CancelFunc
14036 t.mu.Lock()
141- t.initLocked()
142- for _, state := range t.active {
143- for _, entry := range state.entries {
144- entry.done = true
145- }
146- if state.waitCancel != nil {
147- cancels = append(cancels, state.waitCancel)
148- state.waitCancel = nil
149- }
150- state.waitingForProcess = false
151- }
152- t.active = make(map[*Agent]*completionAgent)
153- t.ready = nil
37+ t.pending++
15438 t.signalLocked()
15539 t.mu.Unlock()
156- for _, cancel := range cancels {
157- cancel()
158- }
159-}
160-
161-func (t *CompletionTracker) addTurn(a *Agent, task string, followUp, future bool) (*completionAgent, *completionEntry, bool) {
162- entry := &completionEntry{task: task, followUp: followUp, expectedStep: expectedTurnStep(a, future), strictStep: future}
163- t.mu.Lock()
164- defer t.mu.Unlock()
165- t.initLocked()
166- state := t.active[a]
167- if state == nil {
168- state = &completionAgent{agent: a, seenTurnEnds: make(map[int]struct{})}
169- t.active[a] = state
170- }
171- if state.seenTurnEnds == nil {
172- state.seenTurnEnds = make(map[int]struct{})
173- }
174- if count := len(state.entries); count != 0 {
175- previousStep := state.entries[count-1].expectedStep
176- if entry.expectedStep <= previousStep {
177- entry.expectedStep = previousStep + 1
178- }
179- }
180- t.nextRegistration++
181- entry.registrationSeq = t.nextRegistration
182- entry.state = state
183- state.entries = append(state.entries, entry)
184- installCallback := !state.callbackInstalled
185- state.callbackInstalled = true
186- return state, entry, installCallback
187-}
188-
189-func (t *CompletionTracker) addExit(a *Agent, task string) (*completionAgent, *completionEntry) {
190- t.mu.Lock()
191- defer t.mu.Unlock()
192- t.initLocked()
193- if state := t.active[a]; state != nil && len(state.entries) != 0 {
194- // A turn watcher already owns this worker. Its process fallback is
195- // sufficient and must not create a duplicate terminal completion.
196- return nil, nil
197- }
198- state := &completionAgent{agent: a}
199- t.nextRegistration++
200- entry := &completionEntry{state: state, task: task, registrationSeq: t.nextRegistration}
201- state.entries = append(state.entries, entry)
202- t.active[a] = state
203- return state, entry
204-}
205-
206-func (t *CompletionTracker) startProcessWaiter(state *completionAgent) {
207- if state == nil || state.agent == nil {
208- return
209- }
210- t.mu.Lock()
211- if t.active[state.agent] != state || state.waitingForProcess {
212- t.mu.Unlock()
213- return
214- }
215- waitCtx, cancel := context.WithCancel(context.Background())
216- state.waitingForProcess = true
217- state.waitCancel = cancel
218- state.waitDone = make(chan struct{})
219- waitDone := state.waitDone
220- t.mu.Unlock()
221-
222- go func() {
223- defer close(waitDone)
224- err := state.agent.WaitContext(waitCtx)
225- if err == nil {
226- t.completeProcess(state)
227- }
228- }()
229-}
230-
231-func expectedTurnStep(a *Agent, future bool) int {
232- step := a.LifetimeTurnsValue()
233- if turnID := strings.TrimPrefix(a.CurrentTurnID(), "turn-"); turnID != "" {
234- if current, err := strconv.Atoi(turnID); err == nil && current > step {
235- step = current
236- }
237- }
238- if future || step == 0 {
239- step++
240- }
241- return step
242-}
243-
244-func (t *CompletionTracker) completeNextTurn(state *completionAgent, step int, turnErr string) {
245- if state == nil {
246- return
247- }
248- t.mu.Lock()
249- if t.active[state.agent] != state || len(state.entries) == 0 {
250- t.mu.Unlock()
251- return
252- }
253- if _, duplicate := state.seenTurnEnds[step]; duplicate {
254- t.mu.Unlock()
255- return
256- }
257- state.seenTurnEnds[step] = struct{}{}
258- var entry *completionEntry
259- for _, candidate := range state.entries {
260- if !candidate.done && candidate.expectedStep == step {
261- entry = candidate
262- break
263- }
264- }
265- if entry == nil {
266- // Post-success callers can register after a resumed worker was queued
267- // but before its next turn.start updated metadata. Accept the first
268- // later step for that compatibility path; pre-delivery registrations
269- // use strict identity and never consume a stale or unrelated notice.
270- for _, candidate := range state.entries {
271- if !candidate.done && !candidate.strictStep && candidate.expectedStep < step {
272- entry = candidate
273- candidate.expectedStep = step
274- break
275- }
276- }
277- }
278- t.mu.Unlock()
279- if entry != nil {
280- t.completeTurn(entry, turnErr)
281- }
28240 }
28341
284-func (t *CompletionTracker) cancelEntry(entry *completionEntry) {
285- if entry == nil || entry.state == nil {
42+func (t *CompletionTracker) Report(completion Completion) {
43+ if t == nil {
28644 return
28745 }
288- var cancel context.CancelFunc
28946 t.mu.Lock()
290- state := entry.state
291- if entry.done || t.active[state.agent] != state {
47+ if t.pending == 0 {
29248 t.mu.Unlock()
29349 return
29450 }
295- entry.done = true
296- t.flushReadyLocked(state)
297- if len(state.entries) == 0 {
298- delete(t.active, state.agent)
299- cancel = state.waitCancel
300- state.waitCancel = nil
301- state.waitingForProcess = false
302- }
51+ t.pending--
52+ t.ready = append(t.ready, completion)
30353 t.signalLocked()
30454 t.mu.Unlock()
305- if cancel != nil {
306- cancel()
307- }
30855 }
30956
310-func (t *CompletionTracker) completeTurn(entry *completionEntry, turnErr string) {
311- if entry == nil || entry.state == nil || entry.state.agent == nil {
312- return
57+func (t *CompletionTracker) Pending() int {
58+ if t == nil {
59+ return 0
31360 }
314- snap := entry.state.agent.Snapshot()
315- completion := makeCompletion(entry, snap, turnErr, true)
316-
317- var cancel context.CancelFunc
31861 t.mu.Lock()
319- state := entry.state
320- if entry.done || t.active[state.agent] != state {
321- t.mu.Unlock()
322- return
323- }
324- entry.done = true
325- entry.completion = &completion
326- t.flushReadyLocked(state)
327- if len(state.entries) == 0 {
328- delete(t.active, state.agent)
329- cancel = state.waitCancel
330- state.waitCancel = nil
331- state.waitingForProcess = false
332- }
333- t.signalLocked()
334- t.mu.Unlock()
335- if cancel != nil {
336- cancel()
337- }
62+ defer t.mu.Unlock()
63+ return t.pending
33864 }
33965
340-func (t *CompletionTracker) completeProcess(state *completionAgent) {
341- if state == nil || state.agent == nil {
66+// Reset drops buffered outcomes at a parent-turn cancellation boundary.
67+func (t *CompletionTracker) Reset() {
68+ if t == nil {
34269 return
34370 }
344- snap := state.agent.Snapshot()
345-
34671 t.mu.Lock()
347- if t.active[state.agent] != state {
348- t.mu.Unlock()
349- return
350- }
351- for _, entry := range state.entries {
352- if entry.done {
353- continue
354- }
355- entry.done = true
356- completion := makeCompletion(entry, snap, "", false)
357- entry.completion = &completion
358- }
359- t.flushReadyLocked(state)
360- delete(t.active, state.agent)
361- state.waitCancel = nil
362- state.waitingForProcess = false
72+ t.pending = 0
73+ t.ready = nil
36374 t.signalLocked()
36475 t.mu.Unlock()
36576 }
36677
367-func (t *CompletionTracker) flushReadyLocked(state *completionAgent) {
368- for len(state.entries) != 0 && state.entries[0].done {
369- entry := state.entries[0]
370- state.entries[0] = nil
371- state.entries = state.entries[1:]
372- if entry.completion != nil {
373- t.ready = append(t.ready, *entry.completion)
374- }
375- }
376-}
377-
378-func makeCompletion(entry *completionEntry, snap AgentSnapshot, turnErr string, turnEnd bool) Completion {
379- status := string(snap.Status)
380- if turnEnd {
381- status = "completed"
382- if turnErr != "" {
383- status = "failed"
384- }
385- } else if snap.Status == StatusDone {
386- status = "completed"
387- }
388-
389- task := entry.task
390- if !entry.followUp && snap.Task != "" {
391- task = snap.Task
392- }
393- if task == "" {
394- task = snap.Task
395- }
396-
397- errText := ""
398- cancellation := false
399- if snap.Result != nil && snap.Result.ShutdownOrigin != "" {
400- if snap.Result.Status == ResultCanceled {
401- status = "cancelled"
402- }
403- if snap.Result.Error != nil {
404- errText = snap.Result.Error.Message
405- cancellation = true
406- }
407- }
408- turnError := false
409- if errText == "" {
410- errText = snap.Err
411- }
412- if errText == "" && turnErr != "" {
413- errText = turnErr
414- turnError = true
415- }
416- return Completion{
417- AgentID: snap.ID,
418- Status: status,
419- Task: task,
420- Error: errText,
421- FinalResponse: snap.LastAssistant,
422- Tail: snap.Tail,
423- turnError: turnError,
424- cancellation: cancellation,
425- registrationSeq: entry.registrationSeq,
426- }
427-}
428-
429-func (t *CompletionTracker) signalLocked() {
430- close(t.changed)
431- t.changed = make(chan struct{})
432-}
433-
434-func (t *CompletionTracker) takeReadyLocked() []Completion {
435- if len(t.ready) == 0 {
436- return nil
437- }
438- batch := append([]Completion(nil), t.ready...)
439- t.ready = nil
440- sort.SliceStable(batch, func(i, j int) bool {
441- return batch[i].registrationSeq < batch[j].registrationSeq
442- })
443- return batch
444-}
445-
446-// Pending reports the number of tracked entries that have not completed.
447-func (t *CompletionTracker) Pending() int {
448- if t == nil {
449- return 0
450- }
451- t.mu.Lock()
452- defer t.mu.Unlock()
453- pending := 0
454- for _, state := range t.active {
455- for _, entry := range state.entries {
456- if !entry.done {
457- pending++
458- }
459- }
460- }
461- return pending
462-}
463-
464-// WaitIdle returns all completions buffered so far and all completions that
465-// arrive until no tracked entry remains. A cancelled wait leaves active and
466-// buffered entries intact for a later caller.
46778 func (t *CompletionTracker) WaitIdle(ctx context.Context) ([]Completion, error) {
46879 if t == nil {
46980 return nil, nil
47081 }
471- if ctx == nil {
472- ctx = context.Background()
473- }
474-
47582 for {
47683 t.mu.Lock()
477- t.initLocked()
478- if len(t.active) == 0 {
479- batch := t.takeReadyLocked()
84+ if t.pending == 0 {
85+ ready := append([]Completion(nil), t.ready...)
86+ t.ready = nil
48087 t.mu.Unlock()
481- return batch, nil
88+ return ready, nil
48289 }
48390 changed := t.changed
48491 t.mu.Unlock()
485-
48692 select {
487- case <-changed:
48893 case <-ctx.Done():
489- // Prefer an already-finished batch if completion won the race
490- // with cancellation; otherwise preserve the active work.
491- t.mu.Lock()
492- if len(t.active) == 0 {
493- batch := t.takeReadyLocked()
494- t.mu.Unlock()
495- return batch, nil
496- }
497- t.mu.Unlock()
49894 return nil, ctx.Err()
95+ case <-changed:
49996 }
50097 }
50198 }
50299
503-// FormatCompletionUpdate formats the stable evidence payload injected into a
504-// parent agent. Task and error fields are bounded to 240 characters and tail
505-// evidence is bounded to 600 characters; final responses retain their full
506-// inline text as they did in interactive mode.
100+func (t *CompletionTracker) signalLocked() { close(t.changed); t.changed = make(chan struct{}) }
101+
507102 func FormatCompletionUpdate(batch []Completion, instruction string) string {
508103 if len(batch) == 0 {
509104 return ""
510105 }
511-
512- var sb strings.Builder
513- fmt.Fprintf(&sb, "[auto-subagents update] %d sub-agent(s) finished:\n\n", len(batch))
514- for idx, completion := range batch {
515- fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, completion.AgentID, completion.Status)
516- fmt.Fprintf(&sb, " task: %s\n", truncateCompletionField(completion.Task, 240))
106+ var b strings.Builder
107+ b.WriteString("[auto-subagents update]\n")
108+ for _, completion := range batch {
109+ fmt.Fprintf(&b, "- %s: %s", completion.AgentID, completion.Status)
517110 if completion.Error != "" {
518- label := "error"
519- if completion.cancellation {
520- label = "cancellation"
521- } else if completion.turnError {
522- label = "turn error"
523- }
524- fmt.Fprintf(&sb, " %s: %s\n", label, truncateCompletionField(completion.Error, 240))
111+ fmt.Fprintf(&b, " (%s)", completion.Error)
112+ }
113+ if completion.Task != "" {
114+ fmt.Fprintf(&b, " — %s", completion.Task)
525115 }
526- if completion.FinalResponse != "" {
527- sb.WriteString(" final response:\n")
528- sb.WriteString(completion.FinalResponse)
529- sb.WriteString("\n")
530- } else if tail := strings.TrimSpace(completion.Tail); tail != "" {
531- fmt.Fprintf(&sb, " tail: %s\n", truncateCompletionField(tail, 600))
116+ if completion.Summary != "" {
117+ fmt.Fprintf(&b, "\n final: %s", completion.Summary)
532118 }
533- sb.WriteString("\n")
119+ b.WriteByte('\n')
534120 }
535121 if instruction != "" {
536- sb.WriteString(instruction)
537- }
538- return sb.String()
539-}
540-
541-func truncateCompletionField(value string, limit int) string {
542- value = strings.TrimSpace(value)
543- if limit <= 0 {
544- return ""
545- }
546- runes := []rune(value)
547- if len(runes) <= limit {
548- return value
549- }
550- if limit <= 3 {
551- return string(runes[:limit])
122+ b.WriteString(instruction)
552123 }
553- return string(runes[:limit-3]) + "..."
124+ return strings.TrimSpace(b.String())
554125 }
M packages/agent/subagents/completion_test.go +10 -419
@@ -2,436 +2,27 @@ package subagents
22
33 import (
44 "context"
5- "errors"
65 "strings"
76 "testing"
8- "time"
97 )
108
11-func newCompletionTestAgent(id, task string, status Status) *Agent {
12- return &Agent{
13- ID: id,
14- Task: task,
15- status: status,
16- activity: "testing",
17- done: make(chan struct{}),
18- transcript: []string{"tail line 1", "tail line 2"},
19- lastAssistant: "final response",
9+func TestFormatCompletionUpdateIncludesFinalSummary(t *testing.T) {
10+ got := FormatCompletionUpdate([]Completion{{AgentID: "child", Status: "completed", Task: "review", Summary: "found the regression"}}, "")
11+ if !strings.Contains(got, "final: found the regression") {
12+ t.Fatalf("completion update = %q", got)
2013 }
2114 }
2215
23-func TestCompletionTrackerTurnEndCompletesLongLivedWorker(t *testing.T) {
24- agent := newCompletionTestAgent("worker-1", "original task", StatusRunning)
25- t.Cleanup(func() { close(agent.done) })
16+func TestCompletionTrackerDropsReportsAfterCancellationReset(t *testing.T) {
2617 tracker := NewCompletionTracker()
27- tracker.TrackTurn(agent, "ignored task", false)
28-
29- if agent.OnTurnEnd == nil {
30- t.Fatal("TrackTurn did not install an event callback")
31- }
32- agent.OnTurnEnd(1, "")
33-
34- batch, err := tracker.WaitIdle(context.Background())
35- if err != nil {
36- t.Fatal(err)
37- }
38- if len(batch) != 1 {
39- t.Fatalf("completion count = %d, want 1", len(batch))
40- }
41- if got := batch[0].Status; got != "completed" {
42- t.Fatalf("status = %q, want completed", got)
43- }
44- if got := batch[0].Task; got != agent.Task {
45- t.Fatalf("task = %q, want %q", got, agent.Task)
46- }
47- if tracker.Pending() != 0 {
48- t.Fatalf("pending = %d, want 0", tracker.Pending())
49- }
50-}
51-
52-func TestCompletionTrackerStartupAndProcessFailuresUseExitPath(t *testing.T) {
53- tests := []struct {
54- name string
55- status Status
56- err error
57- want string
58- }{
59- {name: "startup failure", status: StatusFailed, err: errors.New("listener startup failed"), want: "failed"},
60- {name: "stopped process", status: StatusKilled, err: context.Canceled, want: "killed"},
61- }
62- for _, tt := range tests {
63- t.Run(tt.name, func(t *testing.T) {
64- agent := newCompletionTestAgent("worker-"+tt.name, "task", tt.status)
65- agent.lastErr = tt.err
66- tracker := NewCompletionTracker()
67- tracker.TrackExit(agent, agent.Task)
68- close(agent.done)
69-
70- batch, err := tracker.WaitIdle(context.Background())
71- if err != nil {
72- t.Fatal(err)
73- }
74- if len(batch) != 1 || batch[0].Status != tt.want {
75- t.Fatalf("batch = %+v, want one %s completion", batch, tt.want)
76- }
77- if batch[0].Error != tt.err.Error() {
78- t.Fatalf("error = %q, want %q", batch[0].Error, tt.err)
79- }
80- })
81- }
82-}
83-
84-func TestCompletionTrackerQueuesSequentialTurnsForOneWorker(t *testing.T) {
85- agent := newCompletionTestAgent("worker-1", "original task", StatusRunning)
86- tracker := NewCompletionTracker()
87- tracker.TrackTurn(agent, agent.Task, false)
88- tracker.TrackTurn(agent, "follow-up task", true)
89- tracker.TrackTurn(agent, "follow-up task", true)
90-
91- if got := tracker.Pending(); got != 3 {
92- t.Fatalf("pending after queued turns = %d, want 3", got)
93- }
94- // Callback goroutines may be scheduled out of order. Completion still has
95- // to match the registered turn identity and flush in registration order.
96- agent.OnTurnEnd(2, "")
97- if got := tracker.Pending(); got != 2 {
98- t.Fatalf("pending after second turn completed first = %d, want 2", got)
99- }
100- // A duplicate lifecycle event for the same prompt-level turn must not
101- // consume the next accepted follow-up, even when its prompt is identical.
102- agent.OnTurnEnd(2, "")
103- if got := tracker.Pending(); got != 2 {
104- t.Fatalf("pending after duplicate second turn = %d, want 2", got)
105- }
106- agent.OnTurnEnd(1, "")
107- if got := tracker.Pending(); got != 1 {
108- t.Fatalf("pending after first turn = %d, want 1", got)
109- }
110- agent.OnTurnEnd(3, "")
111-
112- batch, err := tracker.WaitIdle(context.Background())
113- if err != nil || len(batch) != 3 {
114- t.Fatalf("wait = (%+v, %v), want three turn completions", batch, err)
115- }
116- if batch[0].Task != agent.Task || batch[1].Task != "follow-up task" || batch[2].Task != "follow-up task" {
117- t.Fatalf("queued tasks = %q, %q, %q; want original then both follow-ups", batch[0].Task, batch[1].Task, batch[2].Task)
118- }
119- close(agent.done)
120-}
121-
122-func TestCompletionTrackerTurnEndReleasesProcessWaiterWithoutDuplicate(t *testing.T) {
123- agent := newCompletionTestAgent("worker-1", "task", StatusRunning)
124- agent.lastErr = errors.New("late process failure")
125- tracker := NewCompletionTracker()
126- tracker.TrackTurn(agent, agent.Task, false)
127-
128- tracker.mu.Lock()
129- state := tracker.active[agent]
130- waitDone := state.waitDone
131- tracker.mu.Unlock()
132- if waitDone == nil {
133- t.Fatal("turn tracking did not start a process fallback waiter")
134- }
135- agent.OnTurnEnd(1, "")
136- select {
137- case <-waitDone:
138- case <-time.After(time.Second):
139- t.Fatal("process fallback waiter was not released after turn_end")
140- }
141- close(agent.done)
142-
143- batch, err := tracker.WaitIdle(context.Background())
144- if err != nil || len(batch) != 1 {
145- t.Fatalf("wait = (%+v, %v), want one completion", batch, err)
146- }
147- if batch[0].Status != "completed" {
148- t.Fatalf("status = %q, want completed", batch[0].Status)
149- }
150-}
151-
152-func TestCompletionTrackerSingleWinnerDoesNotDuplicate(t *testing.T) {
153- agent := newCompletionTestAgent("worker-1", "task", StatusRunning)
154- tracker := NewCompletionTracker()
155- tracker.TrackTurn(agent, agent.Task, false)
156-
157- agent.OnTurnEnd(1, "")
158- close(agent.done)
159-
160- batch, err := tracker.WaitIdle(context.Background())
161- if err != nil {
162- t.Fatal(err)
163- }
164- if len(batch) != 1 {
165- t.Fatalf("completion count = %d, want exactly 1", len(batch))
166- }
167- if second, err := tracker.WaitIdle(context.Background()); err != nil || len(second) != 0 {
168- t.Fatalf("second wait = (%+v, %v), want no duplicate", second, err)
169- }
170-}
171-
172-func TestCompletionTrackerBatchesUntilAllActiveEntriesComplete(t *testing.T) {
173- first := newCompletionTestAgent("worker-1", "first", StatusRunning)
174- second := newCompletionTestAgent("worker-2", "second", StatusRunning)
175- tracker := NewCompletionTracker()
176- tracker.TrackTurn(first, first.Task, false)
177- tracker.TrackTurn(second, second.Task, false)
178-
179- waited := make(chan []Completion, 1)
180- go func() {
181- batch, _ := tracker.WaitIdle(context.Background())
182- waited <- batch
183- }()
184- // Complete the workers in reverse order. The batch must still follow the
185- // registration sequence rather than callback arrival order.
186- second.OnTurnEnd(1, "")
187- if got := tracker.Pending(); got != 1 {
188- t.Fatalf("pending after second completion = %d, want 1", got)
189- }
190- first.OnTurnEnd(1, "")
191-
192- batch := <-waited
193- if len(batch) != 2 {
194- t.Fatalf("batch count = %d, want 2", len(batch))
195- }
196- if batch[0].AgentID != first.ID || batch[1].AgentID != second.ID {
197- t.Fatalf("batch order = %q, %q; want %q, %q", batch[0].AgentID, batch[1].AgentID, first.ID, second.ID)
198- }
199- close(first.done)
200- close(second.done)
201-}
202-
203-func TestCompletionTrackerReplaysTurnEndReceivedBeforeTracking(t *testing.T) {
204- agent := newCompletionTestAgent("worker-1", "task", StatusRunning)
205- notifyPromptTurnEnd(agent, NewEvent("turn_end", map[string]any{"step": float64(1)}))
206- tracker := NewCompletionTracker()
207- tracker.TrackTurn(agent, agent.Task, false)
208-
209- batch, err := tracker.WaitIdle(context.Background())
210- if err != nil || len(batch) != 1 {
211- t.Fatalf("wait = (%+v, %v), want replayed completion", batch, err)
212- }
213- if batch[0].Status != "completed" {
214- t.Fatalf("replayed status = %q, want completed", batch[0].Status)
215- }
216- close(agent.done)
217-}
218-
219-func TestCompletionTrackerFutureTurnIgnoresPriorPendingTurnEnd(t *testing.T) {
220- agent := newCompletionTestAgent("worker-1", "original task", StatusRunning)
221- agent.setTurnCounts(1, 1)
222- agent.setTurnState(TurnIdle, "turn-1")
223- notifyPromptTurnEnd(agent, NewEvent("turn_end", map[string]any{"step": float64(1)}))
224-
225- tracker := NewCompletionTracker()
226- tracker.TrackFutureTurn(agent, "follow-up task", true)
227- if got := tracker.Pending(); got != 1 {
228- t.Fatalf("pending after stale turn_end replay = %d, want 1", got)
229- }
230- agent.OnTurnEnd(2, "")
231-
232- batch, err := tracker.WaitIdle(context.Background())
233- if err != nil || len(batch) != 1 || batch[0].Task != "follow-up task" {
234- t.Fatalf("future turn completion = (%+v, %v), want follow-up turn", batch, err)
235- }
236- close(agent.done)
237-}
238-
239-func TestCompletionTrackerCurrentTurnReplaysFastCompletion(t *testing.T) {
240- agent := newCompletionTestAgent("worker-1", "original task", StatusRunning)
241- agent.setTurnCounts(2, 1)
242- agent.setTurnState(TurnSucceeded, "turn-2")
243- notifyPromptTurnEnd(agent, NewEvent("turn_end", map[string]any{"step": float64(2)}))
244-
245- tracker := NewCompletionTracker()
246- tracker.TrackTurn(agent, "follow-up task", true)
247- batch, err := tracker.WaitIdle(context.Background())
248- if err != nil || len(batch) != 1 || batch[0].Task != "follow-up task" {
249- t.Fatalf("current turn replay = (%+v, %v), want follow-up turn", batch, err)
250- }
251- close(agent.done)
252-}
253-
254-func TestCompletionTrackerPostSuccessRegistrationAcceptsNextStartedTurn(t *testing.T) {
255- agent := newCompletionTestAgent("worker-1", "original task", StatusRunning)
256- agent.setTurnCounts(1, 1)
257- agent.setTurnState(TurnQueued, "turn-1")
258-
259- tracker := NewCompletionTracker()
260- tracker.TrackTurn(agent, "follow-up task", true)
261- agent.OnTurnEnd(2, "")
262- batch, err := tracker.WaitIdle(context.Background())
263- if err != nil || len(batch) != 1 || batch[0].Task != "follow-up task" {
264- t.Fatalf("post-success next turn = (%+v, %v), want follow-up turn", batch, err)
265- }
266- close(agent.done)
267-}
268-
269-func TestCompletionTrackerKeepsCompletionsBeforeWaiting(t *testing.T) {
270- agent := newCompletionTestAgent("worker-1", "task", StatusRunning)
271- tracker := NewCompletionTracker()
272- tracker.TrackTurn(agent, agent.Task, false)
273- agent.OnTurnEnd(1, "")
274-
275- batch, err := tracker.WaitIdle(context.Background())
276- if err != nil {
277- t.Fatal(err)
278- }
279- if len(batch) != 1 || batch[0].AgentID != agent.ID {
280- t.Fatalf("batch = %+v, want buffered worker completion", batch)
281- }
282- close(agent.done)
283-}
284-
285-func TestCompletionTrackerResetDropsLateAndBufferedCompletions(t *testing.T) {
286- agent := newCompletionTestAgent("worker-1", "task", StatusRunning)
287- t.Cleanup(func() { close(agent.done) })
288- tracker := NewCompletionTracker()
289- tracker.TrackTurn(agent, agent.Task, false)
290- agent.OnTurnEnd(1, "")
291-
18+ tracker.TrackResident()
29219 tracker.Reset()
293- batch, err := tracker.WaitIdle(context.Background())
20+ tracker.Report(Completion{AgentID: "stale"})
21+ got, err := tracker.WaitIdle(context.Background())
29422 if err != nil {
29523 t.Fatal(err)
29624 }
297- if len(batch) != 0 {
298- t.Fatalf("reset batch = %+v, want no stale completion", batch)
299- }
300- if tracker.Pending() != 0 {
301- t.Fatalf("pending after reset = %d, want 0", tracker.Pending())
302- }
303-
304- tracker.TrackTurn(agent, "new task", true)
305- agent.OnTurnEnd(2, "")
306- batch, err = tracker.WaitIdle(context.Background())
307- if err != nil || len(batch) != 1 || batch[0].Task != "new task" {
308- t.Fatalf("new registration completion = (%+v, %v), want one fresh completion", batch, err)
309- }
310-}
311-
312-func TestCompletionTrackerWaitIdleCancellationLeavesActiveWork(t *testing.T) {
313- agent := newCompletionTestAgent("worker-1", "task", StatusRunning)
314- tracker := NewCompletionTracker()
315- tracker.TrackTurn(agent, agent.Task, false)
316- ctx, cancel := context.WithCancel(context.Background())
317- cancel()
318-
319- batch, err := tracker.WaitIdle(ctx)
320- if !errors.Is(err, context.Canceled) {
321- t.Fatalf("error = %v, want context.Canceled", err)
322- }
323- if batch != nil {
324- t.Fatalf("cancelled batch = %+v, want nil", batch)
325- }
326- if tracker.Pending() != 1 {
327- t.Fatalf("pending after cancellation = %d, want 1", tracker.Pending())
328- }
329-
330- agent.OnTurnEnd(1, "")
331- close(agent.done)
332- batch, err = tracker.WaitIdle(context.Background())
333- if err != nil || len(batch) != 1 {
334- t.Fatalf("post-cancellation wait = (%+v, %v), want one completion", batch, err)
335- }
336-}
337-
338-func TestCompletionTrackerStoresRawFieldsAndResumedTask(t *testing.T) {
339- longTask := strings.Repeat("task ", 80)
340- longError := errors.New(strings.Repeat("error ", 80))
341- agent := newCompletionTestAgent("worker-1", "original task", StatusRunning)
342- agent.Task = longTask
343- agent.lastErr = longError
344- agent.lastAssistant = ""
345- tracker := NewCompletionTracker()
346- const followUp = "follow-up task"
347- tracker.TrackTurn(agent, followUp, true)
348- agent.OnTurnEnd(1, "")
349-
350- batch, err := tracker.WaitIdle(context.Background())
351- if err != nil || len(batch) != 1 {
352- t.Fatalf("wait = (%+v, %v), want one completion", batch, err)
353- }
354- completion := batch[0]
355- if completion.Task != followUp {
356- t.Fatalf("resumed task = %q, want %q", completion.Task, followUp)
357- }
358- if completion.Error != longError.Error() {
359- t.Fatalf("stored error was truncated: got %d chars, want %d", len(completion.Error), len(longError.Error()))
360- }
361- if completion.Tail == "" {
362- t.Fatal("stored tail is empty")
363- }
364- close(agent.done)
365-}
366-
367-func TestFormatCompletionUpdateBoundsFieldsAndPreservesEvidence(t *testing.T) {
368- longTask := strings.Repeat("t", 300)
369- longError := strings.Repeat("e", 300)
370- longTail := strings.Repeat("x", 700)
371- instruction := "Summarise the collective outcome without spawning new workers."
372- update := FormatCompletionUpdate([]Completion{{
373- AgentID: "worker-1", Status: "failed", Task: longTask, Error: longError, Tail: longTail,
374- }}, instruction)
375-
376- if !strings.HasPrefix(update, "[auto-subagents update] 1 sub-agent(s) finished:\n\n") {
377- t.Fatalf("update has unexpected header: %q", update)
378- }
379- if !strings.Contains(update, "1. agent worker-1 - status: failed\n task: ") {
380- t.Fatalf("update omitted stable labels: %q", update)
381- }
382- if !strings.Contains(update, " error: ") || !strings.Contains(update, " tail: ") {
383- t.Fatalf("update omitted error or tail: %q", update)
384- }
385- if !strings.HasSuffix(update, instruction) {
386- t.Fatalf("update omitted instruction: %q", update)
387- }
388- if strings.Contains(strings.ToLower(update), "poll") {
389- t.Fatalf("update instructs the parent to poll: %q", update)
390- }
391-
392- taskLine := ""
393- errorLine := ""
394- for _, line := range strings.Split(update, "\n") {
395- if strings.HasPrefix(line, " task: ") {
396- taskLine = strings.TrimPrefix(line, " task: ")
397- }
398- if strings.HasPrefix(line, " error: ") {
399- errorLine = strings.TrimPrefix(line, " error: ")
400- }
401- }
402- if got := len([]rune(taskLine)); got != 240 {
403- t.Fatalf("formatted task length = %d, want 240", got)
404- }
405- if got := len([]rune(errorLine)); got != 240 {
406- t.Fatalf("formatted error length = %d, want 240", got)
407- }
408- tailLine := ""
409- for _, line := range strings.Split(update, "\n") {
410- if strings.HasPrefix(line, " tail: ") {
411- tailLine = strings.TrimPrefix(line, " tail: ")
412- }
413- }
414- if got := len([]rune(tailLine)); got != 600 {
415- t.Fatalf("formatted tail length = %d, want 600", got)
416- }
417-}
418-
419-func TestFormatCompletionUpdateUsesFinalResponseBeforeTailFallback(t *testing.T) {
420- final := "complete final response\nwith multiple lines"
421- withFinal := FormatCompletionUpdate([]Completion{{
422- AgentID: "worker-1", Status: "completed", FinalResponse: final, Tail: "old tail",
423- }}, "instruction")
424- if !strings.Contains(withFinal, " final response:\n"+final+"\n") {
425- t.Fatalf("final response missing: %q", withFinal)
426- }
427- if strings.Contains(withFinal, "tail:") {
428- t.Fatalf("tail should not be used when final response is present: %q", withFinal)
429- }
430-
431- withTail := FormatCompletionUpdate([]Completion{{
432- AgentID: "worker-2", Status: "completed", Tail: " fallback tail ",
433- }}, "instruction")
434- if !strings.Contains(withTail, " tail: fallback tail\n") {
435- t.Fatalf("tail fallback missing or not trimmed: %q", withTail)
25+ if len(got) != 0 {
26+ t.Fatalf("stale completions = %#v", got)
43627 }
43728 }
D packages/agent/subagents/event.go +0 -437
@@ -1,437 +0,0 @@
1-package subagents
2-
3-import (
4- "bufio"
5- "encoding/json"
6- "errors"
7- "fmt"
8- "io"
9- "os"
10- "path/filepath"
11- "reflect"
12- "sync"
13- "time"
14-)
15-
16-// Event is one structured datum in an agent's durable event log.
17-//
18-// The shape is intentionally a thin map+timestamp wrapper rather
19-// than a strict struct so the subagent package doesn't have to track
20-// every field added to core.AgentEvent. The child writes
21-// `modes.EventToJSON(ev)` plus a few subagent-internal types
22-// (lifecycle, user_input echo, error). The supervisor doesn't
23-// interpret most fields; it just keeps them for replay and lifts
24-// a few well-known ones into AgentSnapshot (Activity, Status,
25-// Tail) for the dashboard.
26-//
27-// The on-disk file (events.jsonl) is append-only, one event per
28-// line, newline-terminated JSON. Reading is forward-only; readers
29-// stat the file size and read from their last offset on every
30-// poll.
31-type Event struct {
32- Time time.Time `json:"time"`
33- Type string `json:"type"`
34- Version int `json:"version,omitempty"`
35- MessageID string `json:"message_id,omitempty"`
36- AgentID string `json:"agent_id,omitempty"`
37- TurnID string `json:"turn_id,omitempty"`
38- Data map[string]any `json:"-"`
39- Raw map[string]any `json:"-"` // original canonical event for replay
40-}
41-
42-// MarshalJSON writes the canonical versioned event shape used by the
43-// worker protocol and durable event logs.
44-func (e Event) MarshalJSON() ([]byte, error) {
45- version := e.Version
46- if version == 0 {
47- version = ProtocolVersion
48- }
49- when := e.Time
50- if when.IsZero() {
51- when = time.Now().UTC()
52- }
53- payload := e.Data
54- if payload == nil {
55- payload = map[string]any{}
56- }
57- out := map[string]any{
58- "version": version,
59- "type": e.Type,
60- "timestamp": when,
61- "payload": payload,
62- }
63- if e.MessageID != "" {
64- out["message_id"] = e.MessageID
65- }
66- if e.AgentID != "" {
67- out["agent_id"] = e.AgentID
68- }
69- if e.TurnID != "" {
70- out["turn_id"] = e.TurnID
71- }
72- return json.Marshal(out)
73-}
74-
75-// UnmarshalJSON accepts the canonical versioned event shape and retains the
76-// decoded payload for dashboard replay.
77-func (e *Event) UnmarshalJSON(b []byte) error {
78- var wire struct {
79- Version int `json:"version"`
80- Type string `json:"type"`
81- MessageID string `json:"message_id,omitempty"`
82- AgentID string `json:"agent_id,omitempty"`
83- TurnID string `json:"turn_id,omitempty"`
84- Timestamp time.Time `json:"timestamp"`
85- Payload map[string]any `json:"payload"`
86- }
87- if err := json.Unmarshal(b, &wire); err != nil {
88- return err
89- }
90- if wire.Version != ProtocolVersion {
91- return fmt.Errorf("subagent event: unsupported version %d", wire.Version)
92- }
93- if wire.Type == "" {
94- return errors.New("subagent event: missing type")
95- }
96- if wire.Timestamp.IsZero() {
97- return errors.New("subagent event: missing timestamp")
98- }
99- if wire.Payload == nil {
100- wire.Payload = map[string]any{}
101- }
102- e.Raw = map[string]any{}
103- if err := json.Unmarshal(b, &e.Raw); err != nil {
104- return err
105- }
106- e.Time = wire.Timestamp
107- e.Type = wire.Type
108- e.Version = wire.Version
109- e.MessageID = wire.MessageID
110- e.AgentID = wire.AgentID
111- e.TurnID = wire.TurnID
112- e.Data = wire.Payload
113- return nil
114-}
115-
116-const (
117- // Event log lines are produced by a child process and may contain model
118- // payloads. Bound both per-line decoding and replay memory so a corrupt or
119- // hostile log cannot force an unbounded allocation during Reload.
120- maxEventLogLineBytes = 2 * 1024 * 1024
121- maxEventLogEvents = 100_000
122-)
123-
124-// EventLog is an append-only writer for events.jsonl. Safe for
125-// concurrent writers (the child only has one writer, but tests
126-// occasionally fan out).
127-type EventLog struct {
128- path string
129- mu sync.Mutex
130- f *os.File
131-}
132-
133-// OpenEventLog opens (or creates) the events.jsonl file at path.
134-// Parent directories are created as needed.
135-func OpenEventLog(path string) (*EventLog, error) {
136- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
137- return nil, fmt.Errorf("event log dir: %w", err)
138- }
139- f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
140- if err != nil {
141- return nil, fmt.Errorf("event log open: %w", err)
142- }
143- _ = f.Chmod(0o600)
144- return &EventLog{path: path, f: f}, nil
145-}
146-
147-// Path returns the absolute log path.
148-func (l *EventLog) Path() string { return l.path }
149-
150-// Append writes one event. The encoding is `<json>\n`. Concurrent
151-// callers are serialised; small enough events never need partial
152-// writes since unix guarantees atomicity for writes ≤ PIPE_BUF on
153-// regular files, and we hold a per-process mutex on top.
154-func (l *EventLog) Append(ev Event) error {
155- l.mu.Lock()
156- defer l.mu.Unlock()
157- if l.f == nil {
158- return errors.New("event log: closed")
159- }
160- if ev.Time.IsZero() {
161- ev.Time = time.Now().UTC()
162- }
163- b, err := json.Marshal(ev)
164- if err != nil {
165- return err
166- }
167- if len(b) > maxEventLogLineBytes {
168- return fmt.Errorf("event log: event exceeds %d bytes", maxEventLogLineBytes)
169- }
170- b = append(b, '\n')
171- n, err := l.f.Write(b)
172- if err == nil && n != len(b) {
173- return io.ErrShortWrite
174- }
175- return err
176-}
177-
178-// Close flushes and closes the underlying file.
179-func (l *EventLog) Close() error {
180- l.mu.Lock()
181- defer l.mu.Unlock()
182- if l.f == nil {
183- return nil
184- }
185- err := l.f.Close()
186- l.f = nil
187- return err
188-}
189-
190-// NewEvent is a convenience constructor that fills the timestamp.
191-func NewEvent(typ string, data map[string]any) Event {
192- if data == nil {
193- data = map[string]any{}
194- }
195- return Event{Time: time.Now().UTC(), Type: typ, Version: ProtocolVersion, Data: data}
196-}
197-
198-// ReadEventLog parses every event currently in the file. Used by
199-// the dashboard on first open and by tests; live tailing uses
200-// FollowEventLog below.
201-func ReadEventLog(path string) ([]Event, error) {
202- f, err := os.Open(path)
203- if err != nil {
204- if os.IsNotExist(err) {
205- return nil, nil
206- }
207- return nil, err
208- }
209- defer f.Close()
210- return readEvents(f)
211-}
212-
213-type eventRing struct {
214- events []Event
215- next int
216- count int
217-}
218-
219-func newEventRing() eventRing {
220- return eventRing{events: make([]Event, 0)}
221-}
222-
223-func (r *eventRing) append(ev Event) {
224- if r.count < maxEventLogEvents {
225- r.events = append(r.events, ev)
226- r.count++
227- if r.count == maxEventLogEvents {
228- r.next = 0
229- } else {
230- r.next = r.count
231- }
232- return
233- }
234-
235- r.events[r.next] = ev
236- r.next = (r.next + 1) % len(r.events)
237-}
238-
239-func (r *eventRing) last() (Event, bool) {
240- if r.count == 0 {
241- return Event{}, false
242- }
243- i := r.next - 1
244- if i < 0 {
245- i += len(r.events)
246- }
247- return r.events[i], true
248-}
249-
250-func (r *eventRing) isLikelyDoubleWrite(ev Event) bool {
251- previous, ok := r.last()
252- return ok && isLikelyDoubleWrite(ev, previous)
253-}
254-
255-func (r *eventRing) materialize() []Event {
256- out := make([]Event, r.count)
257- if r.count == 0 {
258- return out
259- }
260- start := r.next - r.count
261- if start < 0 {
262- start += len(r.events)
263- }
264- n := copy(out, r.events[start:])
265- copy(out[n:], r.events[:r.count-n])
266- return out
267-}
268-
269-func readEvents(r io.Reader) ([]Event, error) {
270- br := bufio.NewReader(r)
271- ring := newEventRing()
272- for {
273- line, err, truncated := readBoundedLine(br, maxEventLogLineBytes)
274- if len(line) > 0 && !truncated {
275- var ev Event
276- if jerr := json.Unmarshal(line, &ev); jerr == nil && !ring.isLikelyDoubleWrite(ev) {
277- ring.append(ev)
278- }
279- // Malformed and oversized lines are skipped silently; the
280- // dashboard renders only well-formed events and the child is
281- // the only writer.
282- }
283- if err != nil {
284- if errors.Is(err, io.EOF) {
285- return ring.materialize(), nil
286- }
287- return ring.materialize(), err
288- }
289- }
290-}
291-
292-// readFollowerEvents parses only newline-terminated records from a live log.
293-// A follower must not advance past an incomplete final line: another process
294-// may be in the middle of appending that record, and skipping its start would
295-// make the completed event invisible on the next poll.
296-func readFollowerEvents(r io.Reader, offset int64) ([]Event, int64) {
297- br := bufio.NewReader(r)
298- ring := newEventRing()
299- committed := offset
300- for {
301- line, truncated, consumed, complete, err := readBoundedLineStats(br, maxEventLogLineBytes)
302- if !complete {
303- break
304- }
305- committed += consumed
306- if len(line) > 0 && !truncated {
307- var ev Event
308- if jerr := json.Unmarshal(line, &ev); jerr == nil && !ring.isLikelyDoubleWrite(ev) {
309- ring.append(ev)
310- }
311- }
312- if err != nil {
313- break
314- }
315- }
316- return ring.materialize(), committed
317-}
318-
319-// isLikelyDoubleWrite reports whether ev is a back-to-back duplicate
320-// of previous — same type and identical Data payload, with timestamps
321-// within a small window (the supervisor and the child's mirror used to
322-// BOTH write each event to disk, landing the same content twice in quick
323-// succession in events.jsonl). The historical behaviour was fixed at write
324-// time (the child's mirror is now dormant unless the supervisor is gone),
325-// but on-disk files from before the fix are still polluted, so we dedupe
326-// defensively at read time too.
327-//
328-// We deliberately bound by time (250ms) so two genuinely identical adjacent
329-// events that happen seconds apart — e.g. an agent that runs the same tool
330-// twice in a row — still both render.
331-func isLikelyDoubleWrite(ev, previous Event) bool {
332- if previous.Type != ev.Type {
333- return false
334- }
335- if !ev.Time.IsZero() && !previous.Time.IsZero() {
336- dt := ev.Time.Sub(previous.Time)
337- if dt < 0 {
338- dt = -dt
339- }
340- if dt > 250*time.Millisecond {
341- return false
342- }
343- }
344- return sameEventData(previous.Data, ev.Data)
345-}
346-
347-// sameEventData deep-compares two event payloads without re-encoding them.
348-// Reload invokes this for adjacent same-type events across every persisted
349-// subagent log, so serializing large streamed payloads here delays startup.
350-func sameEventData(a, b map[string]any) bool {
351- return len(a) == len(b) && reflect.DeepEqual(a, b)
352-}
353-
354-// EventFollower polls an events.jsonl file and emits new events as
355-// they're appended. The supervisor uses one per agent so dashboard
356-// snapshots stay current without reparsing the whole file on every
357-// frame. Close stops the goroutine; the channel returned by Events
358-// is closed when Close is called or the file is removed.
359-type EventFollower struct {
360- path string
361- interval time.Duration
362- out chan Event
363- done chan struct{}
364- closed chan struct{}
365- once sync.Once
366-}
367-
368-// FollowEventLog starts polling path every interval. interval ≤ 0
369-// defaults to 50ms which is invisible to the user and cheap on
370-// disk (one stat + one short read per agent per tick).
371-func FollowEventLog(path string, interval time.Duration) *EventFollower {
372- if interval <= 0 {
373- interval = 50 * time.Millisecond
374- }
375- f := &EventFollower{
376- path: path,
377- interval: interval,
378- out: make(chan Event, 256),
379- done: make(chan struct{}),
380- closed: make(chan struct{}),
381- }
382- go f.loop()
383- return f
384-}
385-
386-// Events returns the receive end of the event stream.
387-func (f *EventFollower) Events() <-chan Event { return f.out }
388-
389-// Close stops polling and closes the events channel. It waits for
390-// the polling goroutine to exit so tests on Windows can remove the
391-// temp directory without racing an open file handle.
392-func (f *EventFollower) Close() {
393- f.once.Do(func() {
394- close(f.done)
395- <-f.closed
396- })
397-}
398-
399-func (f *EventFollower) loop() {
400- defer close(f.closed)
401- defer close(f.out)
402- var offset int64
403- tick := time.NewTicker(f.interval)
404- defer tick.Stop()
405- for {
406- select {
407- case <-f.done:
408- return
409- case <-tick.C:
410- }
411- fi, err := os.Stat(f.path)
412- if err != nil {
413- continue
414- }
415- if fi.Size() <= offset {
416- continue
417- }
418- fh, err := os.Open(f.path)
419- if err != nil {
420- continue
421- }
422- if _, err := fh.Seek(offset, io.SeekStart); err != nil {
423- _ = fh.Close()
424- continue
425- }
426- evs, newOffset := readFollowerEvents(fh, offset)
427- _ = fh.Close()
428- offset = newOffset
429- for _, ev := range evs {
430- select {
431- case f.out <- ev:
432- case <-f.done:
433- return
434- }
435- }
436- }
437-}
D packages/agent/subagents/event_test.go +0 -380
@@ -1,380 +0,0 @@
1-package subagents
2-
3-import (
4- "bytes"
5- "encoding/json"
6- "fmt"
7- "math"
8- "os"
9- "path/filepath"
10- "strings"
11- "testing"
12- "time"
13-)
14-
15-// TestEventLogAppendAndRead is the simplest possible invariant:
16-// what we write goes back through ReadEventLog exactly, including
17-// the payload fields and the timestamp.
18-func TestEventLogAppendAndRead(t *testing.T) {
19- path := filepath.Join(t.TempDir(), "events.jsonl")
20- log, err := OpenEventLog(path)
21- if err != nil {
22- t.Fatalf("open: %v", err)
23- }
24- want := []Event{
25- NewEvent("turn_start", map[string]any{"step": 0}),
26- NewEvent("assistant_message", map[string]any{
27- "content": []any{map[string]any{"type": "text", "text": "hi"}},
28- }),
29- NewEvent("turn_end", map[string]any{"stop": "end"}),
30- }
31- for _, ev := range want {
32- if err := log.Append(ev); err != nil {
33- t.Fatalf("append: %v", err)
34- }
35- }
36- _ = log.Close()
37-
38- got, err := ReadEventLog(path)
39- if err != nil {
40- t.Fatalf("read: %v", err)
41- }
42- if len(got) != len(want) {
43- t.Fatalf("got %d events, want %d", len(got), len(want))
44- }
45- for i := range want {
46- if got[i].Type != want[i].Type {
47- t.Errorf("event %d type = %q; want %q", i, got[i].Type, want[i].Type)
48- }
49- }
50-}
51-
52-func TestEventLogAppendUsesUTCForZeroTimestamp(t *testing.T) {
53- path := filepath.Join(t.TempDir(), "events.jsonl")
54- log, err := OpenEventLog(path)
55- if err != nil {
56- t.Fatal(err)
57- }
58- if err := log.Append(Event{Type: "zero_time", Version: ProtocolVersion, Data: map[string]any{}}); err != nil {
59- t.Fatal(err)
60- }
61- if err := log.Close(); err != nil {
62- t.Fatal(err)
63- }
64-
65- got, err := ReadEventLog(path)
66- if err != nil {
67- t.Fatal(err)
68- }
69- if len(got) != 1 {
70- t.Fatalf("got %d events, want 1", len(got))
71- }
72- if got[0].Time.Location() != time.UTC {
73- t.Fatalf("zero timestamp location = %v, want UTC", got[0].Time.Location())
74- }
75-}
76-
77-// TestReadEventLogIgnoresGarbage documents that one malformed line
78-// doesn't break the whole replay. The runner's responsibility is to
79-// only write well-formed events, but a partial crash could leave
80-// a half-written line; we don't want that to prevent the dashboard
81-// from showing the rest.
82-func TestReadVersionedEventMergesPayloadFields(t *testing.T) {
83- path := filepath.Join(t.TempDir(), "events.jsonl")
84- wire := `{"version":1,"type":"turn.progress","message_id":"m1","agent_id":"a1","turn_id":"t1","timestamp":"2026-08-04T12:00:00Z","payload":{"text":"still working","future":{"step":2}}}` + "\n"
85- if err := os.WriteFile(path, []byte(wire), 0o600); err != nil {
86- t.Fatal(err)
87- }
88- got, err := ReadEventLog(path)
89- if err != nil {
90- t.Fatal(err)
91- }
92- if len(got) != 1 || got[0].Data["text"] != "still working" {
93- t.Fatalf("versioned payload was not replayed: %+v", got)
94- }
95- if future, ok := got[0].Data["future"].(map[string]any); !ok || future["step"] != float64(2) {
96- t.Fatalf("future payload field was lost: %+v", got[0].Data)
97- }
98-}
99-
100-func TestReadEventLogSkipsOversizedLines(t *testing.T) {
101- path := filepath.Join(t.TempDir(), "events.jsonl")
102- oversized := fmt.Sprintf("%s\n", strings.Repeat("x", maxEventLogLineBytes+100))
103- valid := NewEvent("valid", map[string]any{"ok": true})
104- data, err := json.Marshal(valid)
105- if err != nil {
106- t.Fatal(err)
107- }
108- if err := os.WriteFile(path, append([]byte(oversized), append(data, '\n')...), 0o600); err != nil {
109- t.Fatal(err)
110- }
111- got, err := ReadEventLog(path)
112- if err != nil {
113- t.Fatal(err)
114- }
115- if len(got) != 1 || got[0].Type != "valid" {
116- t.Fatalf("oversized line affected replay: %+v", got)
117- }
118-}
119-
120-func TestEventReadersRetainLatestEventsInOrder(t *testing.T) {
121- total := maxEventLogEvents + 2
122- var input bytes.Buffer
123- for i := 0; i < total; i++ {
124- ev := Event{
125- Time: time.Unix(int64(i), 0).UTC(),
126- Type: "indexed",
127- Version: ProtocolVersion,
128- Data: map[string]any{"index": i},
129- }
130- line, err := json.Marshal(ev)
131- if err != nil {
132- t.Fatal(err)
133- }
134- input.Write(line)
135- input.WriteByte('\n')
136- }
137-
138- check := func(name string, got []Event) {
139- t.Helper()
140- if len(got) != maxEventLogEvents {
141- t.Fatalf("%s retained %d events, want %d", name, len(got), maxEventLogEvents)
142- }
143- for i, ev := range got {
144- index, ok := ev.Data["index"].(float64)
145- if !ok || int(index) != i+2 {
146- t.Fatalf("%s event %d index = %v, want %d", name, i, ev.Data["index"], i+2)
147- }
148- }
149- }
150-
151- got, err := readEvents(bytes.NewReader(input.Bytes()))
152- if err != nil {
153- t.Fatal(err)
154- }
155- check("readEvents", got)
156-
157- got, offset := readFollowerEvents(bytes.NewReader(input.Bytes()), 0)
158- check("readFollowerEvents", got)
159- if offset != int64(input.Len()) {
160- t.Fatalf("follower offset = %d, want %d", offset, input.Len())
161- }
162-}
163-
164-func TestReadEventLogIgnoresGarbage(t *testing.T) {
165- path := filepath.Join(t.TempDir(), "events.jsonl")
166- log, err := OpenEventLog(path)
167- if err != nil {
168- t.Fatalf("open: %v", err)
169- }
170- if err := log.Append(NewEvent("first", nil)); err != nil {
171- t.Fatalf("append: %v", err)
172- }
173- // Sneak a garbage line in by writing directly.
174- if _, err := log.f.WriteString("not json at all\n"); err != nil {
175- t.Fatalf("raw write: %v", err)
176- }
177- if err := log.Append(NewEvent("second", nil)); err != nil {
178- t.Fatalf("append: %v", err)
179- }
180- _ = log.Close()
181-
182- got, err := ReadEventLog(path)
183- if err != nil {
184- t.Fatalf("read: %v", err)
185- }
186- if len(got) != 2 || got[0].Type != "first" || got[1].Type != "second" {
187- t.Fatalf("garbage tripped parser: %+v", got)
188- }
189-}
190-
191-// TestReadEventLogDeduplicatesDoubleWrites pins the read-time fix
192-// for already-polluted events.jsonl files. The historical bug: the
193-// supervisor parsed child stdout and called log.Append(ev), AND the
194-// child's mirror appended the same event. Two writes per event
195-// landed in the file in quick succession with near-identical
196-// timestamps, and the next zut launch replayed everything twice —
197-// the user saw every transcript line duplicated.
198-//
199-// The write-time fix makes the child's mirror dormant when the
200-// supervisor is alive, but old files are still polluted. ReadEventLog
201-// now folds back-to-back same-type identical-payload events whose
202-// timestamps are within ~250ms of each other.
203-func TestReadEventLogDeduplicatesDoubleWrites(t *testing.T) {
204- path := filepath.Join(t.TempDir(), "events.jsonl")
205- log, err := OpenEventLog(path)
206- if err != nil {
207- t.Fatalf("open: %v", err)
208- }
209-
210- // Two writes per logical event, mimicking the supervisor +
211- // mirror double-write pattern. Timestamps differ by a few ms
212- // like they would in the wild.
213- now := time.Now()
214- writePair := func(typ string, data map[string]any) {
215- e1 := NewEvent(typ, data)
216- e1.Time = now
217- if err := log.Append(e1); err != nil {
218- t.Fatalf("append: %v", err)
219- }
220- e2 := NewEvent(typ, data)
221- e2.Time = now.Add(2 * time.Millisecond)
222- if err := log.Append(e2); err != nil {
223- t.Fatalf("append: %v", err)
224- }
225- now = now.Add(500 * time.Millisecond) // next event well outside the dedup window
226- }
227-
228- writePair("assistant_message", map[string]any{
229- "content": []any{map[string]any{"type": "text", "text": "hi"}},
230- })
231- writePair("turn_end", map[string]any{"step": float64(1)})
232- _ = log.Close()
233-
234- got, err := ReadEventLog(path)
235- if err != nil {
236- t.Fatalf("read: %v", err)
237- }
238- if len(got) != 2 {
239- t.Fatalf("expected double-writes to collapse to 2 events; got %d", len(got))
240- }
241- if got[0].Type != "assistant_message" || got[1].Type != "turn_end" {
242- t.Errorf("unexpected types after dedup: %q, %q", got[0].Type, got[1].Type)
243- }
244-}
245-
246-// TestReadEventLogKeepsLegitimateAdjacentEvents pins the inverse:
247-// two events of the same type with identical payloads but separated
248-// by more than the dedup window (250ms) must both survive. The
249-// agent legitimately can emit identical adjacent events (think: a
250-// retry running the same tool seconds apart).
251-func TestSameEventDataDoesNotConflateFailedEncodings(t *testing.T) {
252- payload := math.NaN()
253- if sameEventData(map[string]any{"value": payload}, map[string]any{"value": payload}) {
254- t.Fatal("payloads with non-equal values must not be treated as equal when encoding fails")
255- }
256-}
257-
258-func TestReadEventLogKeepsLegitimateAdjacentEvents(t *testing.T) {
259- path := filepath.Join(t.TempDir(), "events.jsonl")
260- log, err := OpenEventLog(path)
261- if err != nil {
262- t.Fatalf("open: %v", err)
263- }
264- now := time.Now()
265- for i := 0; i < 3; i++ {
266- e := NewEvent("tool_call", map[string]any{"name": "read"})
267- e.Time = now.Add(time.Duration(i) * time.Second)
268- if err := log.Append(e); err != nil {
269- t.Fatalf("append: %v", err)
270- }
271- }
272- _ = log.Close()
273-
274- got, err := ReadEventLog(path)
275- if err != nil {
276- t.Fatalf("read: %v", err)
277- }
278- if len(got) != 3 {
279- t.Errorf("dedup collapsed legitimately separated events; got %d, want 3", len(got))
280- }
281-}
282-
283-// TestFollowerEmitsNewEvents follows a live file and ensures events
284-// appended after the follower starts make it to the channel.
285-func TestFollowerEmitsNewEvents(t *testing.T) {
286- path := filepath.Join(t.TempDir(), "events.jsonl")
287- // Pre-seed one event so the file exists. The follower's
288- // contract is "emit events appended AFTER it starts", so this
289- // first event is not expected on the channel.
290- log, err := OpenEventLog(path)
291- if err != nil {
292- t.Fatalf("open: %v", err)
293- }
294- defer log.Close()
295- if err := log.Append(NewEvent("preexisting", nil)); err != nil {
296- t.Fatalf("append: %v", err)
297- }
298-
299- fol := FollowEventLog(path, 10*time.Millisecond)
300- defer fol.Close()
301-
302- // Give the follower one tick to read the pre-existing event so
303- // its offset advances past the first line. After that, any new
304- // append should show up.
305- time.Sleep(40 * time.Millisecond)
306- drain(fol.Events())
307-
308- for i := 0; i < 3; i++ {
309- if err := log.Append(NewEvent("live", map[string]any{"i": i})); err != nil {
310- t.Fatalf("append %d: %v", i, err)
311- }
312- }
313-
314- got := waitFor(t, fol.Events(), 3, time.Second)
315- if len(got) != 3 {
316- t.Fatalf("got %d live events, want 3", len(got))
317- }
318- for i, ev := range got {
319- if ev.Type != "live" {
320- t.Errorf("event %d type = %q; want live", i, ev.Type)
321- }
322- }
323-}
324-
325-func TestFollowerDoesNotSkipPartialFinalEvent(t *testing.T) {
326- seed := NewEvent("preexisting", nil)
327- seedBytes, err := json.Marshal(seed)
328- if err != nil {
329- t.Fatal(err)
330- }
331- seedBytes = append(seedBytes, '\n')
332- partial := NewEvent("partial", map[string]any{"ok": true})
333- partialBytes, err := json.Marshal(partial)
334- if err != nil {
335- t.Fatal(err)
336- }
337- mid := len(partialBytes) / 2
338- incomplete := append(append([]byte(nil), seedBytes...), partialBytes[:mid]...)
339- events, offset := readFollowerEvents(bytes.NewReader(incomplete), 0)
340- if len(events) != 1 || events[0].Type != "preexisting" {
341- t.Fatalf("complete prefix = %+v", events)
342- }
343- if offset != int64(len(seedBytes)) {
344- t.Fatalf("offset = %d, want complete prefix %d", offset, len(seedBytes))
345- }
346-
347- complete := append(append([]byte(nil), seedBytes...), append(partialBytes, '\n')...)
348- events, finalOffset := readFollowerEvents(bytes.NewReader(complete[offset:]), offset)
349- if len(events) != 1 || events[0].Type != "partial" {
350- t.Fatalf("completed event = %+v", events)
351- }
352- if finalOffset != int64(len(complete)) {
353- t.Fatalf("final offset = %d, want %d", finalOffset, len(complete))
354- }
355-}
356-
357-func drain(ch <-chan Event) {
358- for {
359- select {
360- case <-ch:
361- default:
362- return
363- }
364- }
365-}
366-
367-func waitFor(t *testing.T, ch <-chan Event, n int, timeout time.Duration) []Event {
368- t.Helper()
369- var got []Event
370- deadline := time.After(timeout)
371- for len(got) < n {
372- select {
373- case ev := <-ch:
374- got = append(got, ev)
375- case <-deadline:
376- return got
377- }
378- }
379- return got
380-}
D packages/agent/subagents/fast_mode_test.go +0 -185
@@ -1,185 +0,0 @@
1-package subagents
2-
3-import (
4- "context"
5- "strings"
6- "testing"
7-)
8-
9-func TestSupervisorFastModePropagatesToChild(t *testing.T) {
10- root := t.TempDir()
11- f := New(Config{
12- Root: root,
13- RepoRoot: root,
14- FastMode: true,
15- NewRunner: func(a *Agent) Runner {
16- return RunnerFunc(func(ctx context.Context, _ Sink) error {
17- <-ctx.Done()
18- return ctx.Err()
19- })
20- },
21- })
22-
23- a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "x"})
24- if err != nil {
25- t.Fatalf("spawn: %v", err)
26- }
27- cleanedUp := false
28- t.Cleanup(func() {
29- if cleanedUp {
30- return
31- }
32- if err := f.Stop(a.ID); err != nil {
33- t.Errorf("cleanup stop: %v", err)
34- }
35- a.Wait()
36- })
37- if !a.FastMode {
38- t.Fatal("spawned agent FastMode = false, want true")
39- }
40- args := defaultChildArgs("/zut", a, "/session", "/inbox")
41- if !containsArg(args, "--fast-mode") {
42- t.Fatalf("child args = %v, want --fast-mode", args)
43- }
44-
45- if err := f.Stop(a.ID); err != nil {
46- t.Fatalf("stop: %v", err)
47- }
48- a.Wait()
49- cleanedUp = true
50-
51- g := New(Config{Root: root, RepoRoot: root})
52- if loaded, errs := g.Reload(); loaded != 1 || len(errs) != 0 {
53- t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
54- }
55- reloaded := g.Get(a.ID)
56- if reloaded == nil || !reloaded.FastMode {
57- t.Fatalf("reloaded FastMode = %v, want true", reloaded != nil && reloaded.FastMode)
58- }
59-}
60-
61-func TestSpawnRequestFastModeProfileOverridesHostSetting(t *testing.T) {
62- falseValue, trueValue := false, true
63- cases := []struct {
64- name string
65- hostFast bool
66- profileFast *bool
67- want bool
68- wantHostOverride bool
69- }{
70- {name: "unset inherits enabled host", hostFast: true, want: true},
71- {name: "unset inherits disabled host", hostFast: false, want: false},
72- {name: "false disables enabled host", hostFast: true, profileFast: &falseValue, want: false},
73- {name: "true enables disabled host", hostFast: false, profileFast: &trueValue, want: true, wantHostOverride: true},
74- {name: "true preserves enabled host", hostFast: true, profileFast: &trueValue, want: true},
75- }
76- for _, tc := range cases {
77- t.Run(tc.name, func(t *testing.T) {
78- root := t.TempDir()
79- f := New(Config{
80- Root: root,
81- RepoRoot: root,
82- FastMode: tc.hostFast,
83- NewRunner: func(*Agent) Runner {
84- return RunnerFunc(func(context.Context, Sink) error { return nil })
85- },
86- })
87- a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "x", FastMode: tc.profileFast})
88- if err != nil {
89- t.Fatalf("spawn: %v", err)
90- }
91- a.Wait()
92- if a.FastMode != tc.want {
93- t.Fatalf("FastMode = %v, want %v", a.FastMode, tc.want)
94- }
95- if a.FastModeOverridesHost() != tc.wantHostOverride {
96- t.Fatalf("FastModeOverridesHost() = %v, want %v", a.FastModeOverridesHost(), tc.wantHostOverride)
97- }
98- args := defaultChildArgs("/zut", a, "/session", "/inbox")
99- wantFlag := "--no-fast-mode"
100- if tc.want {
101- wantFlag = "--fast-mode"
102- }
103- if !containsArg(args, wantFlag) {
104- t.Fatalf("child args = %v, want %s", args, wantFlag)
105- }
106- })
107- }
108-}
109-
110-func TestSupervisorReloadPreservesExplicitFastModeOff(t *testing.T) {
111- root := t.TempDir()
112- newSupervisor := func(fastMode bool) *Supervisor {
113- return New(Config{
114- Root: root,
115- RepoRoot: root,
116- FastMode: fastMode,
117- NewRunner: func(a *Agent) Runner {
118- return RunnerFunc(func(ctx context.Context, _ Sink) error {
119- <-ctx.Done()
120- return ctx.Err()
121- })
122- },
123- })
124- }
125-
126- f := newSupervisor(false)
127- a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "x"})
128- if err != nil {
129- t.Fatalf("spawn: %v", err)
130- }
131- if a.FastMode {
132- t.Fatal("spawned agent FastMode = true, want false")
133- }
134- if err := f.Stop(a.ID); err != nil {
135- t.Fatalf("stop: %v", err)
136- }
137- a.Wait()
138-
139- g := newSupervisor(true)
140- if loaded, errs := g.Reload(); loaded != 1 || len(errs) != 0 {
141- t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
142- }
143- if reloaded := g.Get(a.ID); reloaded == nil || reloaded.FastMode {
144- t.Fatalf("reloaded FastMode = %v, want false", reloaded != nil && reloaded.FastMode)
145- }
146-}
147-
148-func TestSubagentWorkerArgsOmitFastModeWhenUnset(t *testing.T) {
149- args := subagentWorkerArgs(subagentWorkerArgsOpts{Exe: "/zut", Dir: "/wt", SessionPath: "/s", InboxPath: "/i"})
150- if strings.Contains(strings.Join(args, " "), "--fast-mode") || strings.Contains(strings.Join(args, " "), "--no-fast-mode") {
151- t.Fatalf("child args = %v, want fast mode omitted when unset", args)
152- }
153-}
154-
155-func TestSubagentWorkerArgsExplicitlyDisablesFastMode(t *testing.T) {
156- args := subagentWorkerArgs(subagentWorkerArgsOpts{
157- Exe: "/zut",
158- Dir: "/wt",
159- SessionPath: "/s",
160- InboxPath: "/i",
161- FastModeSet: true,
162- })
163- if !containsArg(args, "--no-fast-mode") {
164- t.Fatalf("child args = %v, want --no-fast-mode", args)
165- }
166- if containsArg(args, "--fast-mode") {
167- t.Fatalf("child args = %v, want no positive fast-mode flag", args)
168- }
169-}
170-
171-func TestDefaultChildArgsExplicitlyDisablesFastMode(t *testing.T) {
172- args := defaultChildArgs("/zut", &Agent{Dir: "/wt"}, "/s", "/i")
173- if !containsArg(args, "--no-fast-mode") {
174- t.Fatalf("child args = %v, want --no-fast-mode", args)
175- }
176-}
177-
178-func containsArg(args []string, want string) bool {
179- for _, arg := range args {
180- if arg == want {
181- return true
182- }
183- }
184- return false
185-}
D packages/agent/subagents/inbox.go +0 -454
@@ -1,454 +0,0 @@
1-package subagents
2-
3-import (
4- "bufio"
5- "context"
6- "errors"
7- "fmt"
8- "net"
9- "os"
10- "path/filepath"
11- "sync"
12- "syscall"
13- "time"
14-)
15-
16-// Inbox is the supervisor-side handle on a per-agent unix socket.
17-// The wire format is newline-delimited, versioned JSON commands. The child
18-// listens on the same socket via Listener (below); the parent never reads
19-// from it.
20-//
21-// Why a socket and not a FIFO: FIFOs are POSIX-only and have
22-// awkward blocking-open semantics. A unix socket gives us:
23-//
24-// - the ability to fail fast if no child is listening yet,
25-// so the supervisor gets a useful error to surface in the TUI;
26-// - clean shutdown semantics when the child closes the
27-// listener;
28-// - works under tests with a temp dir; and
29-// - portable enough; Windows support is a follow-up that can
30-// swap in a named pipe behind this same interface.
31-type Inbox struct {
32- path string
33-
34- mu sync.Mutex
35- conn net.Conn // lazily dialed; one persistent connection per agent
36-
37- // send is a one-slot semaphore that serializes JSONL writes without making
38- // a caller wait behind another blocked write after its context is canceled.
39- send chan struct{}
40-}
41-
42-// NewInbox returns a handle that will dial the socket at path on
43-// the first SendCommand call. The socket file is expected to be
44-// created by the child (Listener.Open below). This split lets the
45-// parent build the Inbox before the child has booted; sends that
46-// happen before the child is ready get a clear "not yet" error
47-// the caller can retry or surface.
48-func NewInbox(path string) *Inbox {
49- inbox := &Inbox{path: path, send: make(chan struct{}, 1)}
50- inbox.send <- struct{}{}
51- return inbox
52-}
53-
54-// Path returns the absolute socket path. Used by the runner to
55-// pass the path to the child as a flag.
56-func (b *Inbox) Path() string { return b.path }
57-
58-// SendCommand sends a versioned JSONL command.
59-func (b *Inbox) SendCommand(command Envelope) error {
60- return b.SendCommandContext(context.Background(), command)
61-}
62-
63-// SendCommandContext sends a command while honoring cancellation during both
64-// connection setup and a blocked write. Cancellation closes the persistent
65-// connection so a later command redials instead of reusing a half-written
66-// stream.
67-func (b *Inbox) SendCommandContext(ctx context.Context, command Envelope) error {
68- line, err := MarshalJSONL(command)
69- if err != nil {
70- return err
71- }
72- return b.sendBytesContext(ctx, line)
73-}
74-
75-func (b *Inbox) sendBytes(data []byte) error {
76- return b.sendBytesContext(context.Background(), data)
77-}
78-
79-func (b *Inbox) sendBytesContext(ctx context.Context, data []byte) error {
80- if ctx == nil {
81- ctx = context.Background()
82- }
83- if err := ctx.Err(); err != nil {
84- return err
85- }
86- send, err := b.acquireSend(ctx)
87- if err != nil {
88- return err
89- }
90- defer releaseSend(send)
91- if err := ctx.Err(); err != nil {
92- return err
93- }
94-
95- b.mu.Lock()
96- if b.conn == nil {
97- c, err := dialUnixContext(ctx, b.path, 200*time.Millisecond)
98- if err != nil {
99- b.mu.Unlock()
100- return err
101- }
102- b.conn = c
103- }
104- conn := b.conn
105- b.mu.Unlock()
106-
107- stopCancel := context.AfterFunc(ctx, func() { _ = conn.Close() })
108- if deadline, ok := ctx.Deadline(); ok {
109- if err := conn.SetWriteDeadline(deadline); err != nil {
110- stopCancel()
111- return err
112- }
113- defer conn.SetWriteDeadline(time.Time{})
114- }
115- n, writeErr := conn.Write(data)
116- if writeErr != nil {
117- // Drop the connection so the next call redials. Once any bytes have
118- // crossed the socket boundary, the outcome is ambiguous; returning the
119- // caller's context error would invite a duplicate command with a new
120- // identity.
121- stopCancel()
122- b.dropConnection(conn)
123- if n > 0 {
124- return fmt.Errorf("%w: wrote %d of %d bytes: %v", ErrDeliveryUnknown, n, len(data), writeErr)
125- }
126- if ctxErr := ctx.Err(); ctxErr != nil {
127- return ctxErr
128- }
129- return writeErr
130- }
131- if n != len(data) {
132- stopCancel()
133- b.dropConnection(conn)
134- return fmt.Errorf("%w: wrote %d of %d bytes", ErrDeliveryUnknown, n, len(data))
135- }
136- if !stopCancel() {
137- // Cancellation closed this connection after the complete command was
138- // written. The worker can parse the newline-delimited command, so the
139- // delivery succeeded even though the context is now canceled.
140- b.mu.Lock()
141- if b.conn == conn {
142- b.conn = nil
143- }
144- b.mu.Unlock()
145- }
146- return nil
147-}
148-
149-func (b *Inbox) dropConnection(conn net.Conn) {
150- b.mu.Lock()
151- defer b.mu.Unlock()
152- if b.conn == conn {
153- _ = b.conn.Close()
154- b.conn = nil
155- }
156-}
157-
158-func (b *Inbox) acquireSend(ctx context.Context) (chan struct{}, error) {
159- b.mu.Lock()
160- if b.send == nil {
161- b.send = make(chan struct{}, 1)
162- b.send <- struct{}{}
163- }
164- send := b.send
165- b.mu.Unlock()
166- select {
167- case <-ctx.Done():
168- return nil, ctx.Err()
169- case <-send:
170- return send, nil
171- }
172-}
173-
174-func releaseSend(send chan struct{}) {
175- send <- struct{}{}
176-}
177-
178-// Close drops any persistent connection. Safe to call repeatedly.
179-// Does not unlink the socket file — that's the child's job
180-// (Listener.Close).
181-func (b *Inbox) Close() error {
182- b.mu.Lock()
183- defer b.mu.Unlock()
184- if b.conn != nil {
185- err := b.conn.Close()
186- b.conn = nil
187- return err
188- }
189- return nil
190-}
191-
192-// ErrNotReady is returned when the child hasn't yet
193-// opened its listener. Callers can retry after a short backoff;
194-// the TUI surfaces it as "agent <id> not listening yet".
195-var ErrNotReady = errors.New("subagent: agent inbox not ready")
196-
197-// ErrDeliveryUnknown means a command write made progress but did not finish.
198-// Callers must not replace the command with a fresh identity: the worker may
199-// have received enough bytes to parse it, so retries must reuse the same
200-// message id and be handled idempotently.
201-var ErrDeliveryUnknown = errors.New("subagent: command delivery unknown")
202-
203-// dialUnixContext retries a Unix-socket connection briefly so the happy path
204-// "child just started, supervisor sends right away" works without forcing the
205-// caller to poll. Errors collapse onto ErrNotReady whenever the failure means
206-// no worker is listening, without exposing the socket path to the caller.
207-func dialUnixContext(ctx context.Context, path string, timeout time.Duration) (net.Conn, error) {
208- if ctx == nil {
209- ctx = context.Background()
210- }
211- deadline := time.Now().Add(timeout)
212- var lastErr error
213- for {
214- if err := ctx.Err(); err != nil {
215- return nil, err
216- }
217- attemptTimeout := 50 * time.Millisecond
218- if remaining := time.Until(deadline); remaining < attemptTimeout {
219- attemptTimeout = remaining
220- }
221- if attemptTimeout <= 0 {
222- break
223- }
224- c, err := (&net.Dialer{Timeout: attemptTimeout}).DialContext(ctx, "unix", path)
225- if err == nil {
226- return c, nil
227- }
228- if ctxErr := ctx.Err(); ctxErr != nil {
229- return nil, ctxErr
230- }
231- lastErr = err
232- if time.Now().After(deadline) {
233- break
234- }
235- wait := time.NewTimer(20 * time.Millisecond)
236- select {
237- case <-ctx.Done():
238- wait.Stop()
239- return nil, ctx.Err()
240- case <-wait.C:
241- }
242- }
243- if lastErr == nil {
244- return nil, ErrNotReady
245- }
246- if _, statErr := os.Stat(path); os.IsNotExist(statErr) {
247- return nil, ErrNotReady
248- }
249- if isNoListenerErr(lastErr) {
250- return nil, ErrNotReady
251- }
252- return nil, fmt.Errorf("subagent: dial %s: %w", path, lastErr)
253-}
254-
255-// isNoListenerErr reports whether err means "the socket file exists
256-// but no process is listening on it" — i.e. ECONNREFUSED on unix
257-// domain sockets. We pattern-match on errno via errors.Is rather
258-// than the error string so platform variants (linux vs darwin) all
259-// fold to the same case.
260-func isNoListenerErr(err error) bool {
261- return errors.Is(err, syscall.ECONNREFUSED)
262-}
263-
264-// inboxLive reports whether a process currently accepts connections on path.
265-// A Unix socket inode can survive its owner, so os.Stat alone cannot
266-// distinguish a live worker from a stale path. Callers use this probe before
267-// resume/remove operations to avoid racing a detached worker's session and
268-// workspace ownership.
269-const inboxProbeLine = "__zut_subagent_probe__"
270-
271-func inboxLive(path string) bool {
272- if path == "" {
273- return false
274- }
275- conn, err := net.DialTimeout("unix", path, 100*time.Millisecond)
276- if err != nil {
277- return false
278- }
279- // Listener treats this first line as a health probe and does not
280- // replace the current supervisor connection. A bare connect would be
281- // enough to detect liveness, but sending an explicit probe lets the
282- // listener distinguish this side-effect-free check from a takeover.
283- _, writeErr := conn.Write([]byte(inboxProbeLine + "\n"))
284- _ = conn.Close()
285- return writeErr == nil
286-}
287-
288-// Listener is the child-side end of the inbox. The subagent-worker
289-// daemon mode constructs one with Listen, then iterates Lines
290-// to receive supervisor messages. Designed so a single goroutine
291-// in the child can drive prompting without juggling raw net code.
292-type Listener struct {
293- path string
294- ln net.Listener
295- // active is the most recent accepted connection. The protocol
296- // only expects one supervisor (the parent zut), so newer
297- // connections preempt older ones — the previous parent
298- // presumably crashed.
299- mu sync.Mutex
300- active net.Conn
301- conns map[net.Conn]struct{}
302- out chan string
303- done chan struct{}
304- wg sync.WaitGroup
305- closed bool
306- closeOnce sync.Once
307-}
308-
309-// Listen creates the socket at path and starts accepting. The
310-// caller must call Close to remove the socket file on shutdown.
311-// A live endpoint is preserved and rejected; only a refused probe
312-// is treated as a stale socket eligible for removal.
313-func Listen(path string) (*Listener, error) {
314- dir := filepath.Dir(path)
315- if err := os.MkdirAll(dir, 0o700); err != nil {
316- return nil, fmt.Errorf("inbox dir: %w", err)
317- }
318- if err := os.Chmod(dir, 0o700); err != nil {
319- return nil, fmt.Errorf("inbox dir permissions: %w", err)
320- }
321- // Probe an existing endpoint before removing it. Unlinking an active
322- // Unix socket does not stop its listener, so blindly removing the path
323- // could let Resume start a second worker on the same session and event log.
324- if _, statErr := os.Stat(path); statErr == nil {
325- if inboxLive(path) {
326- return nil, fmt.Errorf("subagent inbox already has a live listener")
327- }
328- // A refused probe means the socket inode is stale. Remove only that
329- // endpoint; a live listener was handled above without unlinking it.
330- _ = os.Remove(path)
331- }
332- ln, err := net.Listen("unix", path)
333- if err != nil {
334- return nil, fmt.Errorf("inbox listen: %w", err)
335- }
336- // Unix sockets inherit the process umask but may otherwise be
337- // world-writable. The inbox carries prompts and control commands,
338- // so fail closed for other local users.
339- _ = os.Chmod(path, 0o600)
340- l := &Listener{
341- path: path,
342- ln: ln,
343- conns: make(map[net.Conn]struct{}),
344- out: make(chan string, 16),
345- done: make(chan struct{}),
346- }
347- go l.acceptLoop()
348- return l, nil
349-}
350-
351-// Lines returns a channel of newline-stripped messages received
352-// from the supervisor. The channel closes when Close is called.
353-func (l *Listener) Lines() <-chan string { return l.out }
354-
355-// Close stops accepting, drops the active connection, removes
356-// the socket file, and closes Lines. Idempotent.
357-func (l *Listener) Close() error {
358- l.closeOnce.Do(func() {
359- l.mu.Lock()
360- l.closed = true
361- close(l.done)
362- for conn := range l.conns {
363- _ = conn.Close()
364- }
365- l.mu.Unlock()
366- _ = l.ln.Close()
367- _ = os.Remove(l.path)
368- })
369- return nil
370-}
371-
372-func (l *Listener) acceptLoop() {
373- defer func() {
374- l.wg.Wait()
375- close(l.out)
376- }()
377- for {
378- c, err := l.ln.Accept()
379- if err != nil {
380- select {
381- case <-l.done:
382- return
383- default:
384- // Accept can fail transiently (signal); back off briefly.
385- time.Sleep(20 * time.Millisecond)
386- continue
387- }
388- }
389- l.mu.Lock()
390- if l.closed {
391- l.mu.Unlock()
392- _ = c.Close()
393- return
394- }
395- l.conns[c] = struct{}{}
396- l.wg.Add(1)
397- l.mu.Unlock()
398- go func() {
399- defer l.wg.Done()
400- l.readLoop(c)
401- }()
402- }
403-}
404-
405-func (l *Listener) readLoop(c net.Conn) {
406- defer func() {
407- _ = c.Close()
408- l.mu.Lock()
409- delete(l.conns, c)
410- if l.active == c {
411- l.active = nil
412- }
413- l.mu.Unlock()
414- }()
415-
416- br := bufio.NewReader(c)
417- firstLine := true
418- for {
419- line, err := br.ReadString('\n')
420- if line != "" {
421- message := trimRightNL(line)
422- if firstLine {
423- firstLine = false
424- if message == inboxProbeLine {
425- return
426- }
427- // The first real command claims ownership. Health probes do
428- // not preempt the current supervisor, while a replacement
429- // supervisor still gets the historical takeover behavior.
430- l.mu.Lock()
431- if l.active != nil && l.active != c {
432- _ = l.active.Close()
433- }
434- l.active = c
435- l.mu.Unlock()
436- }
437- select {
438- case l.out <- message:
439- case <-l.done:
440- return
441- }
442- }
443- if err != nil {
444- return
445- }
446- }
447-}
448-
449-func trimRightNL(s string) string {
450- for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
451- s = s[:len(s)-1]
452- }
453- return s
454-}
D packages/agent/subagents/inbox_test.go +0 -433
@@ -1,433 +0,0 @@
1-package subagents
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "net"
8- "os"
9- "path/filepath"
10- "runtime"
11- "sync"
12- "syscall"
13- "testing"
14- "time"
15-)
16-
17-// shortSocketDir returns a tempdir short enough that we can append
18-// "in.sock" and still fit under the unix-socket path cap. The Go
19-// runner's t.TempDir() lives under a long /var/folders/... path on
20-// macOS which blows the 104-byte limit by itself, so the inbox tests
21-// can't use it.
22-func shortSocketDir(t *testing.T) string {
23- t.Helper()
24- if runtime.GOOS == "windows" {
25- t.Skip("subagent inbox transport uses Unix-domain sockets; Windows support will use a named-pipe backend")
26- }
27- dir, err := os.MkdirTemp("/tmp", "zut-in-")
28- if err != nil {
29- t.Fatalf("mkdir: %v", err)
30- }
31- t.Cleanup(func() { os.RemoveAll(dir) })
32- return dir
33-}
34-
35-// TestInboxRoundTrip is the happy path: child listens, supervisor
36-// dials and sends three lines, child receives them in order, and
37-// closing both sides is clean. Locks in the line-framed protocol.
38-func TestInboxRoundTrip(t *testing.T) {
39- path := filepath.Join(shortSocketDir(t), "in.sock")
40- ln, err := Listen(path)
41- if err != nil {
42- t.Fatalf("Listen: %v", err)
43- }
44- defer ln.Close()
45-
46- got := collectFor(t, ln.Lines(), 4, time.Second)
47- inbox := NewInbox(path)
48- defer inbox.Close()
49-
50- want := []string{"user hello", "user world", "cancel"}
51- for _, msg := range want {
52- if err := inbox.sendBytes([]byte(msg + "\n")); err != nil {
53- t.Fatalf("sendBytes(%q): %v", msg, err)
54- }
55- }
56- if err := inbox.SendCommand(NewCommand(CommandTurnStart, "agent-1", "turn-1", TurnStartPayload{Prompt: "round trip"})); err != nil {
57- t.Fatalf("SendCommand: %v", err)
58- }
59-
60- gotLines := <-got
61- if len(gotLines) != len(want)+1 {
62- t.Fatalf("got %d lines, want %d: %v", len(gotLines), len(want)+1, gotLines)
63- }
64- for i, w := range want {
65- if gotLines[i] != w {
66- t.Errorf("line %d = %q; want %q", i, gotLines[i], w)
67- }
68- }
69- parsed, err := ParseCommand(gotLines[len(want)])
70- if err != nil {
71- t.Fatalf("ParseCommand: %v", err)
72- }
73- var payload TurnStartPayload
74- if err := parsed.DecodePayload(&payload); err != nil {
75- t.Fatalf("DecodePayload: %v", err)
76- }
77- if payload.Prompt != "round trip" {
78- t.Fatalf("decoded prompt = %q, want %q", payload.Prompt, "round trip")
79- }
80-}
81-
82-// TestInboxNotReady regression-tests the explicit "child hasn't
83-// opened the socket yet" case. The TUI needs an error it can show
84-// as "agent not listening yet" rather than a generic dial failure.
85-func TestInboxNotReady(t *testing.T) {
86- path := filepath.Join(shortSocketDir(t), "in.sock")
87- inbox := NewInbox(path)
88- defer inbox.Close()
89- err := inbox.sendBytes([]byte("user hi\n"))
90- if err == nil {
91- t.Fatal("sendBytes on missing socket: want error")
92- }
93- if !errors.Is(err, ErrNotReady) {
94- t.Fatalf("sendBytes err = %v; want ErrNotReady", err)
95- }
96-}
97-
98-func TestDialUnixContextNonPositiveTimeoutIsNotReady(t *testing.T) {
99- path := filepath.Join(t.TempDir(), "existing")
100- if err := os.WriteFile(path, nil, 0o600); err != nil {
101- t.Fatal(err)
102- }
103- for _, timeout := range []time.Duration{0, -time.Millisecond} {
104- _, err := dialUnixContext(context.Background(), path, timeout)
105- if !errors.Is(err, ErrNotReady) {
106- t.Fatalf("dial timeout %s error = %v, want ErrNotReady", timeout, err)
107- }
108- }
109-}
110-
111-type blockingWriteConn struct {
112- net.Conn
113- started chan struct{}
114- release chan struct{}
115- startOnce sync.Once
116- closeOnce sync.Once
117-}
118-
119-func (c *blockingWriteConn) Write(data []byte) (int, error) {
120- c.startOnce.Do(func() { close(c.started) })
121- <-c.release
122- return c.Conn.Write(data)
123-}
124-
125-func (c *blockingWriteConn) Close() error {
126- c.closeOnce.Do(func() { close(c.release) })
127- return c.Conn.Close()
128-}
129-
130-type cancelAfterWriteConn struct {
131- net.Conn
132- cancel context.CancelFunc
133-}
134-
135-func (c *cancelAfterWriteConn) Write(data []byte) (int, error) {
136- c.cancel()
137- return len(data), nil
138-}
139-
140-type partialWriteConn struct {
141- net.Conn
142- withError bool
143-}
144-
145-func (c *partialWriteConn) Write(data []byte) (int, error) {
146- written := len(data) / 2
147- if written == 0 {
148- written = 1
149- }
150- if c.withError {
151- return written, errors.New("partial write")
152- }
153- return written, nil
154-}
155-
156-type notifyingContext struct {
157- context.Context
158- doneCalled chan struct{}
159- once sync.Once
160-}
161-
162-func (c *notifyingContext) Done() <-chan struct{} {
163- c.once.Do(func() { close(c.doneCalled) })
164- return c.Context.Done()
165-}
166-
167-func TestInboxSendContextTreatsCompleteWriteBeforeCancellationAsDelivered(t *testing.T) {
168- local, remote := net.Pipe()
169- defer remote.Close()
170- ctx, cancel := context.WithCancel(context.Background())
171- inbox := NewInbox("unused")
172- inbox.conn = &cancelAfterWriteConn{Conn: local, cancel: cancel}
173- defer inbox.Close()
174-
175- if err := inbox.sendBytesContext(ctx, []byte("follow-up\n")); err != nil {
176- t.Fatalf("complete write after cancellation = %v, want success", err)
177- }
178-}
179-
180-func TestInboxSendContextTreatsPartialWritesAsUnknownDelivery(t *testing.T) {
181- for _, withError := range []bool{false, true} {
182- name := "short write"
183- if withError {
184- name = "partial write error"
185- }
186- t.Run(name, func(t *testing.T) {
187- local, remote := net.Pipe()
188- defer remote.Close()
189- inbox := NewInbox("unused")
190- inbox.conn = &partialWriteConn{Conn: local, withError: withError}
191- defer inbox.Close()
192-
193- err := inbox.sendBytesContext(context.Background(), []byte("follow-up\n"))
194- if !errors.Is(err, ErrDeliveryUnknown) {
195- t.Fatalf("partial write error = %v, want ErrDeliveryUnknown", err)
196- }
197- })
198- }
199-}
200-
201-func TestInboxSendContextCancelsBlockedWrite(t *testing.T) {
202- local, remote := net.Pipe()
203- defer remote.Close()
204- conn := &blockingWriteConn{
205- Conn: local,
206- started: make(chan struct{}),
207- release: make(chan struct{}),
208- }
209- inbox := NewInbox("unused")
210- inbox.conn = conn
211- defer inbox.Close()
212-
213- ctx, cancel := context.WithCancel(context.Background())
214- sent := make(chan error, 1)
215- go func() { sent <- inbox.sendBytesContext(ctx, []byte("follow-up\n")) }()
216- select {
217- case <-conn.started:
218- case <-time.After(time.Second):
219- t.Fatal("write did not start")
220- }
221- cancel()
222- select {
223- case err := <-sent:
224- if !errors.Is(err, context.Canceled) {
225- t.Fatalf("blocked write error = %v, want context canceled", err)
226- }
227- case <-time.After(time.Second):
228- t.Fatal("blocked write did not honor context cancellation")
229- }
230-}
231-
232-func TestInboxSendContextCancelsWhileAnotherWriteIsBlocked(t *testing.T) {
233- local, remote := net.Pipe()
234- defer remote.Close()
235- conn := &blockingWriteConn{
236- Conn: local,
237- started: make(chan struct{}),
238- release: make(chan struct{}),
239- }
240- inbox := NewInbox("unused")
241- inbox.conn = conn
242- defer inbox.Close()
243-
244- first := make(chan error, 1)
245- go func() { first <- inbox.sendBytesContext(context.Background(), []byte("first\n")) }()
246- select {
247- case <-conn.started:
248- case <-time.After(time.Second):
249- t.Fatal("first write did not start")
250- }
251-
252- base, cancel := context.WithCancel(context.Background())
253- secondCtx := &notifyingContext{Context: base, doneCalled: make(chan struct{})}
254- second := make(chan error, 1)
255- go func() { second <- inbox.sendBytesContext(secondCtx, []byte("second\n")) }()
256- select {
257- case <-secondCtx.doneCalled:
258- case <-time.After(time.Second):
259- t.Fatal("second write did not wait for the send semaphore")
260- }
261- cancel()
262- select {
263- case err := <-second:
264- if !errors.Is(err, context.Canceled) {
265- t.Fatalf("waiting write error = %v, want context canceled", err)
266- }
267- case <-time.After(time.Second):
268- t.Fatal("waiting write did not honor context cancellation")
269- }
270-
271- _ = conn.Close()
272- select {
273- case <-first:
274- case <-time.After(time.Second):
275- t.Fatal("first write did not unblock")
276- }
277-}
278-
279-// TestIsNoListenerErrMatchesECONNREFUSED documents the contract
280-// that the user's screenshot regression depends on: any dial error
281-// whose root cause is "the kernel refused the connect because nobody
282-// is listening" must fold into ErrNotReady at the sendBytes layer,
283-// so the TUI shows "agent not accepting input (press R to resume)"
284-// instead of leaking the unix-socket path. We pattern-match via
285-// errors.Is on syscall.ECONNREFUSED rather than string-matching the
286-// error text so platform variants all hit the same branch.
287-func TestIsNoListenerErrMatchesECONNREFUSED(t *testing.T) {
288- if !isNoListenerErr(syscall.ECONNREFUSED) {
289- t.Error("isNoListenerErr(ECONNREFUSED) = false; want true")
290- }
291- // Wrapped via fmt.Errorf — common form of the dial error.
292- if !isNoListenerErr(fmt.Errorf("dial unix /tmp/x: %w", syscall.ECONNREFUSED)) {
293- t.Error("isNoListenerErr(wrapped) = false; want true")
294- }
295- if isNoListenerErr(syscall.ECONNRESET) {
296- t.Error("isNoListenerErr(ECONNRESET) = true; want false (unrelated errno)")
297- }
298- if isNoListenerErr(nil) {
299- t.Error("isNoListenerErr(nil) = true; want false")
300- }
301-}
302-
303-// TestInboxNewSupervisorPreemptsOld documents that only one
304-// supervisor connection at a time is honoured. The subagent design
305-// assumes a single parent zut owns each agent; if a second
306-// parent dials, the listener boots the first one. The first
307-// supervisor's already-delivered messages still land in the
308-// channel — what we're guarding against is silent dual ownership,
309-// not message loss.
310-func TestInboxNewSupervisorPreemptsOld(t *testing.T) {
311- path := filepath.Join(shortSocketDir(t), "in.sock")
312- ln, err := Listen(path)
313- if err != nil {
314- t.Fatalf("Listen: %v", err)
315- }
316- defer ln.Close()
317-
318- first := NewInbox(path)
319- if err := first.sendBytes([]byte("user one\n")); err != nil {
320- t.Fatalf("first send: %v", err)
321- }
322- // Wait for the listener to absorb "user one" before dialing the
323- // second supervisor; otherwise we race against the connection
324- // preemption and "user one" can be dropped (the first conn is
325- // closed by Accept before its read loop processes the byte).
326- waitN(t, ln.Lines(), 1, time.Second)
327-
328- second := NewInbox(path)
329- defer second.Close()
330- if err := second.sendBytes([]byte("user two\n")); err != nil {
331- t.Fatalf("second send: %v", err)
332- }
333-
334- secondLines := waitN(t, ln.Lines(), 1, time.Second)
335- if len(secondLines) != 1 || secondLines[0] != "user two" {
336- t.Fatalf("second supervisor not received: %v", secondLines)
337- }
338-
339- // The original supervisor's connection was preempted by the
340- // listener accepting the second dial. The next sendBytes on the
341- // first handle sees a broken pipe and drops the cached connection;
342- // the call after that redials successfully. Both behaviours
343- // matter: the broken-pipe error must surface (so a real
344- // supervisor knows it lost ownership) AND the next call must
345- // recover so a flaky reconnect doesn't permanently wedge the
346- // inbox.
347- if err := first.sendBytes([]byte("user three\n")); err == nil {
348- t.Fatal("expected broken-pipe error on stale connection")
349- }
350- if err := first.sendBytes([]byte("user four\n")); err != nil {
351- t.Fatalf("first redial after eviction: %v", err)
352- }
353-}
354-
355-func waitN(t *testing.T, ch <-chan string, n int, timeout time.Duration) []string {
356- t.Helper()
357- var got []string
358- deadline := time.After(timeout)
359- for len(got) < n {
360- select {
361- case msg := <-ch:
362- got = append(got, msg)
363- case <-deadline:
364- return got
365- }
366- }
367- return got
368-}
369-
370-// collectFor consumes up to n items from ch within timeout and
371-// pushes the slice onto the returned channel. Used by the tests
372-// above so they don't race against the listener's accept goroutine.
373-func collectFor(t *testing.T, ch <-chan string, n int, timeout time.Duration) <-chan []string {
374- t.Helper()
375- out := make(chan []string, 1)
376- go func() {
377- var got []string
378- deadline := time.After(timeout)
379- for len(got) < n {
380- select {
381- case msg, ok := <-ch:
382- if !ok {
383- out <- got
384- return
385- }
386- got = append(got, msg)
387- case <-deadline:
388- out <- got
389- return
390- }
391- }
392- out <- got
393- }()
394- return out
395-}
396-
397-// TestInboxConcurrentSends serialises the sendBytes call so two
398-// goroutines writing at once still produce two whole lines, never
399-// a tearing of bytes between them.
400-func TestInboxConcurrentSends(t *testing.T) {
401- path := filepath.Join(shortSocketDir(t), "in.sock")
402- ln, err := Listen(path)
403- if err != nil {
404- t.Fatalf("Listen: %v", err)
405- }
406- defer ln.Close()
407- inbox := NewInbox(path)
408- defer inbox.Close()
409-
410- var wg sync.WaitGroup
411- const n = 50
412- wg.Add(n)
413- for i := 0; i < n; i++ {
414- go func(i int) {
415- defer wg.Done()
416- if err := inbox.sendBytes([]byte("user msg\n")); err != nil {
417- t.Errorf("send %d: %v", i, err)
418- }
419- }(i)
420- }
421- wg.Wait()
422-
423- collected := collectFor(t, ln.Lines(), n, 2*time.Second)
424- got := <-collected
425- if len(got) != n {
426- t.Fatalf("got %d lines, want %d", len(got), n)
427- }
428- for i, g := range got {
429- if g != "user msg" {
430- t.Fatalf("line %d garbled: %q", i, g)
431- }
432- }
433-}
D packages/agent/subagents/lease.go +0 -30
@@ -1,30 +0,0 @@
1-package subagents
2-
3-import (
4- "errors"
5- "fmt"
6- "os"
7- "path/filepath"
8-)
9-
10-// ErrAgentLeaseHeld means another supervisor currently owns an agent's
11-// durable state. The lease is advisory; socket probing remains the fence for
12-// workers that are still live.
13-var ErrAgentLeaseHeld = errors.New("subagents: agent state is owned by another supervisor")
14-
15-type agentLease struct {
16- file *os.File
17-}
18-
19-func openAgentLeaseFile(stateDir string) (*os.File, error) {
20- if err := os.MkdirAll(stateDir, 0o700); err != nil {
21- return nil, fmt.Errorf("subagents lease directory: %w", err)
22- }
23- path := filepath.Join(stateDir, "owner.lock")
24- file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
25- if err != nil {
26- return nil, fmt.Errorf("subagents lease open: %w", err)
27- }
28- _ = file.Chmod(0o600)
29- return file, nil
30-}
D packages/agent/subagents/lease_test.go +0 -32
@@ -1,32 +0,0 @@
1-package subagents
2-
3-import (
4- "errors"
5- "testing"
6-)
7-
8-func TestAgentLeaseIsExclusive(t *testing.T) {
9- stateDir := t.TempDir()
10- first, err := acquireAgentLease(stateDir)
11- if err != nil {
12- t.Fatal(err)
13- }
14- defer first.Close()
15- second, err := acquireAgentLease(stateDir)
16- if !errors.Is(err, ErrAgentLeaseHeld) {
17- if second != nil {
18- _ = second.Close()
19- }
20- t.Fatalf("second lease = %v, want ErrAgentLeaseHeld", err)
21- }
22- if err := first.Close(); err != nil {
23- t.Fatal(err)
24- }
25- third, err := acquireAgentLease(stateDir)
26- if err != nil {
27- t.Fatal(err)
28- }
29- if err := third.Close(); err != nil {
30- t.Fatal(err)
31- }
32-}
D packages/agent/subagents/lease_unix.go +0 -36
@@ -1,36 +0,0 @@
1-//go:build !windows
2-
3-package subagents
4-
5-import (
6- "errors"
7- "syscall"
8-)
9-
10-func acquireAgentLease(stateDir string) (*agentLease, error) {
11- file, err := openAgentLeaseFile(stateDir)
12- if err != nil {
13- return nil, err
14- }
15- if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
16- _ = file.Close()
17- if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
18- return nil, ErrAgentLeaseHeld
19- }
20- return nil, err
21- }
22- return &agentLease{file: file}, nil
23-}
24-
25-func (l *agentLease) Close() error {
26- if l == nil || l.file == nil {
27- return nil
28- }
29- unlockErr := syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
30- closeErr := l.file.Close()
31- l.file = nil
32- if unlockErr != nil {
33- return unlockErr
34- }
35- return closeErr
36-}
D packages/agent/subagents/lease_windows.go +0 -40
@@ -1,40 +0,0 @@
1-//go:build windows
2-
3-package subagents
4-
5-import (
6- "errors"
7-
8- "golang.org/x/sys/windows"
9-)
10-
11-func acquireAgentLease(stateDir string) (*agentLease, error) {
12- file, err := openAgentLeaseFile(stateDir)
13- if err != nil {
14- return nil, err
15- }
16- flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
17- var overlapped windows.Overlapped
18- if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &overlapped); err != nil {
19- _ = file.Close()
20- if errors.Is(err, windows.ERROR_LOCK_VIOLATION) {
21- return nil, ErrAgentLeaseHeld
22- }
23- return nil, err
24- }
25- return &agentLease{file: file}, nil
26-}
27-
28-func (l *agentLease) Close() error {
29- if l == nil || l.file == nil {
30- return nil
31- }
32- var overlapped windows.Overlapped
33- unlockErr := windows.UnlockFileEx(windows.Handle(l.file.Fd()), 0, 1, 0, &overlapped)
34- closeErr := l.file.Close()
35- l.file = nil
36- if unlockErr != nil {
37- return unlockErr
38- }
39- return closeErr
40-}
M packages/agent/subagents/model.go +10 -115
@@ -3,9 +3,7 @@ package subagents
33 import "time"
44
55 // WebSearchPolicy is the capability decision propagated from a host agent to
6-// a subagent worker. Inherit is used only before a supervisor resolves the
7-// child decision; persisted and launched workers use Allow or Deny so their
8-// capability does not change with a later config edit.
6+// a resident child.
97 type WebSearchPolicy uint8
108
119 const (
@@ -16,17 +14,6 @@ const (
1614
1715 func (p WebSearchPolicy) Allows() bool { return p == WebSearchAllow }
1816
19-// childPolicy turns an unresolved or corrupt policy into an explicit deny.
20-// Inherit is useful while a supervisor is resolving a spawn request, but it
21-// must never cross the worker boundary because the child may otherwise apply
22-// its own default-enabled configuration.
23-func (p WebSearchPolicy) childPolicy() WebSearchPolicy {
24- if p == WebSearchAllow {
25- return WebSearchAllow
26- }
27- return WebSearchDeny
28-}
29-
3017 func (p WebSearchPolicy) String() string {
3118 switch p {
3219 case WebSearchAllow:
@@ -42,53 +29,6 @@ func (p WebSearchPolicy) String() string {
4229 }
4330 }
4431
45-func childWebSearchPolicy(policy WebSearchPolicy, subagent string, toolNames []string) WebSearchPolicy {
46- if subagent != "" && NamedWebSearchPolicy(toolNames) != WebSearchAllow {
47- return WebSearchDeny
48- }
49- return policy.childPolicy()
50-}
51-
52-// NamedWebSearchPolicy requires a named profile to opt in explicitly. An
53-// omitted or empty profile tools list therefore denies web search without
54-// changing the default selection of the other built-in tools.
55-func NamedWebSearchPolicy(toolNames []string) WebSearchPolicy {
56- for _, name := range toolNames {
57- if name == "web_search" {
58- return WebSearchAllow
59- }
60- }
61- return WebSearchDeny
62-}
63-
64-// ProcessState describes the lifetime of the supervised child process. It is
65-// intentionally independent from TurnState: an alive worker can be idle
66-// between turns, and a disconnected supervisor does not imply that the
67-// worker's current turn failed.
68-type ProcessState string
69-
70-const (
71- ProcessPending ProcessState = "pending"
72- ProcessStarting ProcessState = "starting"
73- ProcessAlive ProcessState = "alive"
74- ProcessDetached ProcessState = "detached"
75- ProcessExited ProcessState = "exited"
76- ProcessKilled ProcessState = "killed"
77-)
78-
79-// TurnState describes one delegated prompt inside a worker process.
80-type TurnState string
81-
82-const (
83- TurnIdle TurnState = "idle"
84- TurnQueued TurnState = "queued"
85- TurnRunning TurnState = "running"
86- TurnCanceling TurnState = "canceling"
87- TurnSucceeded TurnState = "succeeded"
88- TurnFailed TurnState = "failed"
89- TurnCanceled TurnState = "canceled"
90-)
91-
9232 // WorkspaceMode selects how a child is allowed to access the repository.
9333 type WorkspaceMode string
9434
@@ -105,61 +45,12 @@ const (
10545 CaptureDiff CaptureMode = "diff"
10646 )
10747
108-// SubagentPolicy contains manager-owned safety and resource limits. normalize
109-// replaces zero MaxOutputBytes, MaxOutputLines, QueueTimeout, and StartupTimeout
110-// values with positive manager defaults. Zero DefaultTimeout leaves delegated
111-// turns unlimited; queue, startup, stream-idle, and idle-process limits remain
112-// independently bounded.
48+// SubagentPolicy contains the resident manager's limits and capability ceiling.
11349 type SubagentPolicy struct {
114- MaxConcurrent int
115- MaxConcurrentPerParent int
116- QueueTimeout time.Duration
117- StartupTimeout time.Duration
118- DefaultTimeout time.Duration
119- MaxOutputBytes int
120- MaxOutputLines int
121- AllowedTools []string
122- AllowedRoots []string
123- HeartbeatInterval time.Duration
124- IdleTimeout time.Duration
125- ReconnectTimeout time.Duration
126- CancelGracePeriod time.Duration
127-}
128-
129-func (p *SubagentPolicy) normalize() {
130- if p.DefaultTimeout < 0 {
131- p.DefaultTimeout = 0
132- }
133- if p.MaxConcurrent <= 0 {
134- p.MaxConcurrent = 8
135- }
136- if p.MaxConcurrentPerParent <= 0 {
137- p.MaxConcurrentPerParent = 4
138- }
139- if p.QueueTimeout <= 0 {
140- p.QueueTimeout = 5 * time.Minute
141- }
142- if p.StartupTimeout <= 0 {
143- p.StartupTimeout = time.Minute
144- }
145- if p.MaxOutputBytes <= 0 {
146- p.MaxOutputBytes = 500_000
147- }
148- if p.MaxOutputLines <= 0 {
149- p.MaxOutputLines = 5_000
150- }
151- if p.HeartbeatInterval <= 0 {
152- p.HeartbeatInterval = 10 * time.Second
153- }
154- if p.IdleTimeout <= 0 {
155- p.IdleTimeout = 7 * time.Minute
156- }
157- if p.ReconnectTimeout <= 0 {
158- p.ReconnectTimeout = 5 * time.Second
159- }
160- if p.CancelGracePeriod <= 0 {
161- p.CancelGracePeriod = 10 * time.Second
162- }
50+ MaxConcurrent int
51+ QueueTimeout time.Duration
52+ AllowedTools []string
53+ AllowedRoots []string
16354 }
16455
16556 func (p SubagentPolicy) allowedTool(name string) bool {
@@ -173,3 +64,7 @@ func (p SubagentPolicy) allowedTool(name string) bool {
17364 }
17465 return false
17566 }
67+
68+// AllowsTool reports whether a child may receive name under the host policy.
69+// An empty allowlist retains the historical unrestricted policy.
70+func (p SubagentPolicy) AllowsTool(name string) bool { return p.allowedTool(name) }
D packages/agent/subagents/persist.go +0 -1430 collapsed to keep the page light
D packages/agent/subagents/persist_test.go +0 -1684 collapsed to keep the page light
D packages/agent/subagents/process_unix.go +0 -69
@@ -1,69 +0,0 @@
1-//go:build !windows
2-
3-package subagents
4-
5-import (
6- "io"
7- "os/exec"
8- "sync"
9- "syscall"
10- "time"
11-)
12-
13-// configureWorkerProcess makes cancellation target the worker's process
14-// group rather than only the worker itself. CommandContext calls cmd.Cancel
15-// from its context watcher, so descendants that inherited the worker's group
16-// are terminated before execRunner waits for their output pipes to close.
17-func configureWorkerProcess(cmd *exec.Cmd, pipes ...io.Closer) {
18- setProcessGroup(cmd)
19- cmd.WaitDelay = workerProcessWaitDelay
20- var closePipes sync.Once
21- cmd.Cancel = func() error {
22- err := killProcessGroup(cmd)
23- closePipes.Do(func() { closeWorkerPipes(pipes) })
24- return err
25- }
26-}
27-
28-const workerProcessWaitDelay = 5 * time.Second
29-
30-// closeWorkerPipes closes the parent-side readers returned by StdoutPipe and
31-// StderrPipe. Closing these descriptors lets the custom reader goroutines exit
32-// if a descendant has outlived the worker process.
33-func closeWorkerPipes(pipes []io.Closer) {
34- for _, pipe := range pipes {
35- if pipe != nil {
36- _ = pipe.Close()
37- }
38- }
39-}
40-
41-// setProcessGroup puts a worker in a process group of its own. This keeps a
42-// canceled worker from sharing a group with the supervising zut process.
43-func setProcessGroup(cmd *exec.Cmd) {
44- cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
45-}
46-
47-// killProcessGroup forcefully terminates the worker group. Cancellation is a
48-// forceful path: the supervisor already offers a graceful shutdown through
49-// the worker inbox, while SIGKILL ensures a descendant cannot keep the
50-// worker's stdout or stderr pipe open indefinitely.
51-func killProcessGroup(cmd *exec.Cmd) error {
52- if cmd == nil || cmd.Process == nil {
53- return nil
54- }
55- return forceKillProcess(cmd.Process.Pid)
56-}
57-
58-func forceKillProcess(pid int) error {
59- if pid <= 0 {
60- return nil
61- }
62- // With Setpgid, the worker's PID is also the process-group ID. A negative
63- // PID addresses the whole group rather than just the worker process.
64- err := syscall.Kill(-pid, syscall.SIGKILL)
65- if err == syscall.ESRCH {
66- return nil
67- }
68- return err
69-}
D packages/agent/subagents/process_unix_test.go +0 -106
@@ -1,106 +0,0 @@
1-//go:build !windows
2-
3-package subagents
4-
5-import (
6- "context"
7- "errors"
8- "fmt"
9- "io"
10- "os"
11- "os/exec"
12- "strconv"
13- "strings"
14- "syscall"
15- "testing"
16- "time"
17-)
18-
19-func TestWorkerCancellationKillsProcessGroup(t *testing.T) {
20- shell, err := exec.LookPath("sh")
21- if err != nil {
22- t.Skip("sh is not available")
23- }
24-
25- pidPath := t.TempDir() + "/child.pid"
26- ctx, cancel := context.WithCancel(context.Background())
27- defer cancel()
28-
29- cmd := exec.CommandContext(ctx, shell, "-c", fmt.Sprintf("sleep 30 & echo $! > %q; wait", pidPath))
30- configureWorkerProcess(cmd)
31- if cmd.WaitDelay != workerProcessWaitDelay {
32- t.Fatalf("WaitDelay = %s, want %s", cmd.WaitDelay, workerProcessWaitDelay)
33- }
34- if err := cmd.Start(); err != nil {
35- t.Fatal(err)
36- }
37-
38- childPID := waitForWorkerChildPID(t, pidPath)
39- cancel()
40-
41- waitDone := make(chan error, 1)
42- go func() { waitDone <- cmd.Wait() }()
43- select {
44- case <-time.After(2 * time.Second):
45- t.Fatal("worker did not exit after context cancellation")
46- case err := <-waitDone:
47- if err == nil {
48- t.Fatal("worker exited successfully after context cancellation")
49- }
50- }
51-
52- deadline := time.Now().Add(2 * time.Second)
53- for time.Now().Before(deadline) && processExists(childPID) {
54- time.Sleep(10 * time.Millisecond)
55- }
56- if processExists(childPID) {
57- t.Fatalf("worker descendant pid %d survived process-group cancellation", childPID)
58- }
59-}
60-
61-func TestWorkerCancellationClosesOutputPipes(t *testing.T) {
62- cmd := exec.Command("sh")
63- stdout, err := cmd.StdoutPipe()
64- if err != nil {
65- t.Fatal(err)
66- }
67- stderr, err := cmd.StderrPipe()
68- if err != nil {
69- t.Fatal(err)
70- }
71- defer stdout.Close()
72- defer stderr.Close()
73-
74- configureWorkerProcess(cmd, stdout, stderr)
75- if err := cmd.Cancel(); err != nil {
76- t.Fatal(err)
77- }
78- for name, pipe := range map[string]io.Reader{"stdout": stdout, "stderr": stderr} {
79- data := make([]byte, 1)
80- if _, err := pipe.Read(data); !errors.Is(err, os.ErrClosed) {
81- t.Errorf("%s read error = %v, want os.ErrClosed", name, err)
82- }
83- }
84-}
85-
86-func waitForWorkerChildPID(t *testing.T, path string) int {
87- t.Helper()
88- deadline := time.Now().Add(2 * time.Second)
89- for time.Now().Before(deadline) {
90- data, err := os.ReadFile(path)
91- if err == nil {
92- pid, parseErr := strconv.Atoi(strings.TrimSpace(string(data)))
93- if parseErr == nil && pid > 0 {
94- return pid
95- }
96- }
97- time.Sleep(10 * time.Millisecond)
98- }
99- t.Fatalf("timed out waiting for child pid in %s", path)
100- return 0
101-}
102-
103-func processExists(pid int) bool {
104- err := syscall.Kill(pid, 0)
105- return err == nil || errors.Is(err, syscall.EPERM)
106-}
D packages/agent/subagents/process_windows.go +0 -99
@@ -1,99 +0,0 @@
1-//go:build windows
2-
3-package subagents
4-
5-import (
6- "errors"
7- "fmt"
8- "io"
9- "os"
10- "os/exec"
11- "strconv"
12- "strings"
13- "sync"
14- "syscall"
15- "time"
16-)
17-
18-// configureWorkerProcess makes CommandContext cancellation terminate the
19-// worker and its descendants on Windows. Windows does not provide a Unix-like
20-// process-group kill operation, so killProcessGroup uses taskkill's tree mode.
21-func configureWorkerProcess(cmd *exec.Cmd, pipes ...io.Closer) {
22- setProcessGroup(cmd)
23- cmd.WaitDelay = workerProcessWaitDelay
24- var closePipes sync.Once
25- cmd.Cancel = func() error {
26- err := killProcessGroup(cmd)
27- closePipes.Do(func() { closeWorkerPipes(pipes) })
28- return err
29- }
30-}
31-
32-func closeWorkerPipes(pipes []io.Closer) {
33- for _, pipe := range pipes {
34- if pipe != nil {
35- _ = pipe.Close()
36- }
37- }
38-}
39-
40-const workerProcessWaitDelay = 5 * time.Second
41-
42-// setProcessGroup gives the worker its own Windows process group. The group
43-// flag prevents it from being attached to the supervisor's console group;
44-// taskkill /T below is still needed because Windows group creation alone does
45-// not terminate descendants.
46-func setProcessGroup(cmd *exec.Cmd) {
47- cmd.SysProcAttr = &syscall.SysProcAttr{
48- CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP,
49- }
50-}
51-
52-// killProcessGroup terminates the worker process tree. taskkill is part of
53-// Windows and /T follows the parent-child relationships to include workers
54-// that spawned their own subprocesses.
55-func killProcessGroup(cmd *exec.Cmd) error {
56- if cmd == nil || cmd.Process == nil {
57- return nil
58- }
59- err := forceKillProcess(cmd.Process.Pid)
60- var taskkillErr *taskkillError
61- if errors.As(err, &taskkillErr) && taskkillErr.processDone() {
62- return os.ErrProcessDone
63- }
64- return err
65-}
66-
67-func forceKillProcess(pid int) error {
68- if pid <= 0 {
69- return nil
70- }
71- output, err := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F").CombinedOutput()
72- if err == nil {
73- return nil
74- }
75- return &taskkillError{pid: pid, err: err, diagnostics: string(output)}
76-}
77-
78-type taskkillError struct {
79- pid int
80- err error
81- diagnostics string
82-}
83-
84-func (e *taskkillError) Error() string {
85- if diagnostics := strings.TrimSpace(e.diagnostics); diagnostics != "" {
86- return fmt.Sprintf("taskkill process tree for PID %d: %v: %s", e.pid, e.err, diagnostics)
87- }
88- return fmt.Sprintf("taskkill process tree for PID %d: %v", e.pid, e.err)
89-}
90-
91-func (e *taskkillError) Unwrap() error { return e.err }
92-
93-// taskkill returns exit code 128 when the requested process no longer exists.
94-// Other taskkill failures remain errors so callers can distinguish a
95-// cancellation race from a real process-control failure.
96-func (e *taskkillError) processDone() bool {
97- var exitErr *exec.ExitError
98- return errors.As(e.err, &exitErr) && exitErr.ExitCode() == 128
99-}
M packages/agent/subagents/profiles.go +46 -14
@@ -1,5 +1,5 @@
11 // Package subagents discovers named agent profiles that can be selected by
2-// zut's subagent supervisor. Profiles use the common markdown/frontmatter layout:
2+// zut's resident subagent runtime. Profiles use the common markdown/frontmatter layout:
33 // a YAML-like metadata block followed by the agent's system prompt.
44 //
55 // Discovery prefers explicitly configured directories, then the shared
@@ -26,9 +26,12 @@ type Profile struct {
2626 Description string
2727 SystemPrompt string
2828 Tools []string
29- Model string
30- Provider string
31- Thinking string
29+ // ToolsDeclared distinguishes an omitted tools key (inherit the
30+ // child-safe catalogue) from an explicit empty list (grant no tools).
31+ ToolsDeclared bool
32+ Model string
33+ Provider string
34+ Thinking string
3235
3336 // Nil means the profile did not specify whether fast mode is enabled.
3437 FastMode *bool
@@ -122,6 +125,33 @@ func (p *Profile) ModelSelection() (provider, model string) {
122125 return provider, model
123126 }
124127
128+// ResolveProfileTools applies a profile's exact frontmatter declaration to a
129+// host-provided child-safe catalogue. An omitted declaration inherits that
130+// catalogue; an explicit empty list grants no tools. Explicit names outside
131+// the child catalogue or denied by host policy are omitted so portable profile
132+// files can name host-only extensions without granting them to a child.
133+func ResolveProfileTools(profile *Profile, childSafe []string, permitted func(string) bool) ([]string, error) {
134+ if profile == nil || !profile.ToolsDeclared {
135+ return append([]string(nil), childSafe...), nil
136+ }
137+ catalogue := make(map[string]struct{}, len(childSafe))
138+ for _, name := range childSafe {
139+ catalogue[name] = struct{}{}
140+ }
141+ resolved := make([]string, 0, len(profile.Tools))
142+ for _, name := range profile.Tools {
143+ name = strings.TrimSpace(name)
144+ if _, ok := catalogue[name]; !ok {
145+ continue
146+ }
147+ if permitted != nil && !permitted(name) {
148+ continue
149+ }
150+ resolved = append(resolved, name)
151+ }
152+ return resolved, nil
153+}
154+
125155 // SystemPromptAddendum renders the compact [subagents_list] manifest for the
126156 // parent model. Full profile bodies are intentionally omitted: the selected
127157 // child loads its own instructions, and keeping them out of every parent turn
@@ -238,16 +268,18 @@ func load(path, source string) (*Profile, error) {
238268 if provider != "" && strings.Contains(model, "/") {
239269 return nil, fmt.Errorf("model must not include a provider when provider is set")
240270 }
271+ _, toolsDeclared := lists["tools"]
241272 profile := &Profile{
242- Name: strings.TrimSpace(name),
243- Description: strings.TrimSpace(unquote(values["description"])),
244- SystemPrompt: strings.TrimSpace(body),
245- Tools: lists["tools"],
246- Model: model,
247- Provider: provider,
248- Thinking: thinking,
249- Path: path,
250- Source: source,
273+ Name: strings.TrimSpace(name),
274+ Description: strings.TrimSpace(unquote(values["description"])),
275+ SystemPrompt: strings.TrimSpace(body),
276+ Tools: lists["tools"],
277+ ToolsDeclared: toolsDeclared,
278+ Model: model,
279+ Provider: provider,
280+ Thinking: thinking,
281+ Path: path,
282+ Source: source,
251283 }
252284 profile.SystemPromptMode = "append"
253285 if mode, ok := values["systempromptmode"]; ok {
@@ -336,7 +368,7 @@ func parseFrontmatter(front string) (map[string]string, map[string][]string) {
336368 items = append(items, unquote(strings.TrimSpace(strings.TrimPrefix(next, "-"))))
337369 i = j
338370 }
339- if len(items) > 0 {
371+ if key == "tools" || len(items) > 0 {
340372 lists[key] = items
341373 }
342374 continue
M packages/agent/subagents/profiles_test.go +45 -0
@@ -190,6 +190,51 @@ func TestProfileModelSelection(t *testing.T) {
190190 }
191191 }
192192
193+func TestProfileToolsPreserveDeclarationAndResolve(t *testing.T) {
194+ dir := t.TempDir()
195+ writeProfile(t, filepath.Join(dir, "absent.md"), "---\nname: absent\n---\nInstructions.\n")
196+ writeProfile(t, filepath.Join(dir, "empty.md"), "---\nname: empty\ntools: []\n---\nInstructions.\n")
197+ writeProfile(t, filepath.Join(dir, "listed.md"), "---\nname: listed\ntools: [read, bash]\n---\nInstructions.\n")
198+
199+ for _, tc := range []struct {
200+ name string
201+ declared bool
202+ want []string
203+ }{
204+ {name: "absent", declared: false, want: []string{"read", "bash", "lsp"}},
205+ {name: "empty", declared: true, want: []string{}},
206+ {name: "listed", declared: true, want: []string{"read", "bash"}},
207+ } {
208+ t.Run(tc.name, func(t *testing.T) {
209+ profile, err := load(filepath.Join(dir, tc.name+".md"), "test")
210+ if err != nil {
211+ t.Fatal(err)
212+ }
213+ if profile.ToolsDeclared != tc.declared {
214+ t.Fatalf("ToolsDeclared = %v, want %v", profile.ToolsDeclared, tc.declared)
215+ }
216+ got, err := ResolveProfileTools(profile, []string{"read", "bash", "lsp"}, func(name string) bool { return name != "lsp" })
217+ if err != nil {
218+ t.Fatal(err)
219+ }
220+ if strings.Join(got, ",") != strings.Join(tc.want, ",") {
221+ t.Fatalf("resolved tools = %#v, want %#v", got, tc.want)
222+ }
223+ })
224+ }
225+}
226+
227+func TestResolveProfileToolsIgnoresUnknownAndDeniedNames(t *testing.T) {
228+ profile := &Profile{Name: "reviewer", ToolsDeclared: true, Tools: []string{"read", "grep", "bash", "tasked_phases"}}
229+ got, err := ResolveProfileTools(profile, []string{"read", "bash"}, func(name string) bool { return name != "bash" })
230+ if err != nil {
231+ t.Fatal(err)
232+ }
233+ if want := []string{"read"}; strings.Join(got, ",") != strings.Join(want, ",") {
234+ t.Fatalf("resolved tools = %#v, want %#v", got, want)
235+ }
236+}
237+
193238 func TestSystemPromptAddendumIsCompactAndDoesNotExposeBodyOrPath(t *testing.T) {
194239 profiles := []*Profile{{
195240 Name: "reviewer",
D packages/agent/subagents/projection.go +0 -459
@@ -1,459 +0,0 @@
1-package subagents
2-
3-import (
4- "sort"
5- "strings"
6- "time"
7-)
8-
9-// Operation is a currently open factual operation inferred from its start
10-// boundary. Completed operations are deliberately not retained in the public
11-// projection: their terminal trace fact remains available as LastEvent.
12-type Operation struct {
13- Type string
14- AgentID string
15- TurnID string
16- CallID string
17- Name string
18- Provider string
19- Model string
20- Step int
21- Attempt int
22- MaxAttempts int
23- StartedAt time.Time
24-}
25-
26-// Open is always true because the projection exposes only unmatched starts.
27-func (o Operation) Open() bool { return true }
28-
29-// Duration measures this open operation at now; callers supply now to make
30-// rendering and tests deterministic.
31-func (o Operation) Duration(now time.Time) time.Duration {
32- if now.Before(o.StartedAt) {
33- return 0
34- }
35- return now.Sub(o.StartedAt)
36-}
37-
38-// Label returns a concise user-facing description of an open operation.
39-func (o Operation) Label() string {
40- switch o.Type {
41- case "provider.request.started":
42- return "waiting for model response"
43- case "tool.started":
44- if o.Name != "" {
45- return "running " + o.Name
46- }
47- return "running tool"
48- case "turn.started":
49- return "processing turn"
50- case "agent.wait.started":
51- return "waiting"
52- default:
53- return strings.ReplaceAll(strings.TrimSuffix(o.Type, ".started"), ".", " ")
54- }
55-}
56-
57-// LiveObservation is the last safe, user-facing signal received from a
58-// worker. It intentionally contains no worker text, tool arguments, output,
59-// or low-level protocol event names.
60-type LiveObservation struct {
61- Type string
62- Source string
63- At time.Time
64- TurnID string
65- CallID string
66- Name string
67-}
68-
69-// Label returns a concise safe description of this observation.
70-func (o LiveObservation) Label() string {
71- switch o.Type {
72- case "assistant.stream.observed":
73- return "assistant streaming"
74- case "reasoning.stream.observed":
75- return "reasoning stream observed"
76- case "tool.output.observed":
77- if o.Name != "" {
78- return "output from " + o.Name
79- }
80- return "tool output"
81- case "tool.finished":
82- if o.Name != "" {
83- return "finished " + o.Name
84- }
85- return "tool finished"
86- default:
87- return strings.ReplaceAll(o.Type, ".", " ")
88- }
89-}
90-
91-// ResultFact describes a durable result and whether the parent accepted its
92-// required-work notification. Available is not equivalent to Delivered.
93-type ResultFact struct {
94- Available bool
95- Ref string
96- Delivered bool
97- Failed bool
98- At time.Time
99-}
100-
101-// RequestFact is a bounded provider-attempt diagnostic. It excludes request
102-// bodies, streamed text, tool arguments, and raw provider errors.
103-type RequestFact struct {
104- TurnID string
105- Provider string
106- Model string
107- Attempt int
108- MaxAttempts int
109- Outcome string
110- ErrorCode string
111- StartedAt time.Time
112- EndedAt time.Time
113-}
114-
115-// AgentTraceView is the only user-facing execution state. It intentionally
116-// contains operations and terminal facts rather than lifecycle guesses such as
117-// working, idle, or alive.
118-type AgentTraceView struct {
119- AgentID string
120- LastEvent TraceEvent
121- LastObservation *LiveObservation
122- OpenOperations []Operation
123- Terminal string
124- Result *ResultFact
125- LastRequest *RequestFact
126-}
127-
128-// Summary returns the most specific factual activity or terminal result fact.
129-func (v AgentTraceView) Summary() string {
130- if v.Terminal != "" {
131- if v.Result != nil && v.Result.Available {
132- if v.Result.Delivered {
133- return v.Terminal + " · result delivered"
134- }
135- if v.Result.Failed {
136- return v.Terminal + " · result delivery failed"
137- }
138- return v.Terminal + " · result available"
139- }
140- return v.Terminal
141- }
142- if primary, ok := v.Primary(); ok {
143- if observation := v.ObservationFor(primary); observation != nil {
144- return primary.Label() + " · " + observation.Label()
145- }
146- return primary.Label()
147- }
148- if v.LastEvent.Type != "" {
149- return "last event " + v.LastEvent.Type
150- }
151- return "no observable operation"
152-}
153-
154-// ProjectTrace reduces a chronologically ordered trace into per-agent facts.
155-// Unknown worker protocol events remain factual observations and are never
156-// guessed into operations.
157-func ProjectTrace(events []TraceEvent) map[string]AgentTraceView {
158- projection := newTraceProjection()
159- for _, event := range events {
160- projection.apply(event)
161- }
162- return projection.snapshot()
163-}
164-
165-// traceProjection incrementally derives user-facing facts from the trace. It
166-// deliberately stores only compact facts and currently open operations, so a
167-// TraceWriter can evict old raw events without losing the start boundary of a
168-// long-running delegated turn.
169-type traceProjection struct {
170- views map[string]AgentTraceView
171- open map[string]map[string]Operation
172-}
173-
174-func newTraceProjection() *traceProjection {
175- return &traceProjection{
176- views: make(map[string]AgentTraceView),
177- open: make(map[string]map[string]Operation),
178- }
179-}
180-
181-func (p *traceProjection) apply(event TraceEvent) {
182- if event.AgentID == "" {
183- return
184- }
185- view := p.views[event.AgentID]
186- view.AgentID = event.AgentID
187- view.LastEvent = event
188- key, starts, ends, terminal := traceBoundary(event)
189- var endedOperation Operation
190- var endedOperationFound bool
191- if ends {
192- endedOperation, endedOperationFound = p.open[event.AgentID][key]
193- }
194- if starts {
195- if event.Type == "provider.request.started" {
196- view.LastRequest = requestFact(event, "open")
197- }
198- if p.open[event.AgentID] == nil {
199- p.open[event.AgentID] = make(map[string]Operation)
200- }
201- callID, _ := event.Data["call_id"].(string)
202- name, _ := event.Data["name"].(string)
203- provider, _ := event.Data["provider"].(string)
204- model, _ := event.Data["model"].(string)
205- attempt := traceInt(event.Data["attempt"])
206- maxAttempts := traceInt(event.Data["max_attempts"])
207- step := traceInt(event.Data["step"])
208- p.open[event.AgentID][key] = Operation{Type: event.Type, AgentID: event.AgentID, TurnID: event.TurnID, CallID: callID, Name: name, Provider: provider, Model: model, Step: step, Attempt: attempt, MaxAttempts: maxAttempts, StartedAt: event.Timestamp}
209- }
210- if ends {
211- if event.Type == "provider.request.finished" || event.Type == "provider.request.failed" || event.Type == "provider.request.cancelled" || event.Type == "provider.request.retry.scheduled" {
212- if active, found := p.open[event.AgentID][key]; found {
213- outcome := strings.TrimPrefix(event.Type, "provider.request.")
214- request := requestFact(event, outcome)
215- request.Provider, request.Model = active.Provider, active.Model
216- request.Attempt, request.MaxAttempts = active.Attempt, active.MaxAttempts
217- request.StartedAt = active.StartedAt
218- view.LastRequest = request
219- }
220- }
221- delete(p.open[event.AgentID], key)
222- }
223- if observation := traceObservation(event); observation != nil {
224- if observation.Type == "tool.finished" && observation.Name == "" {
225- // tool_result carries only the call ID. Recover the safe tool name
226- // from its matching open execution boundary; an unscoped generic
227- // "tool finished" fact is too low-value for the live indicator.
228- if endedOperationFound && endedOperation.Type == "tool.started" && endedOperation.Name != "" {
229- observation.Name = endedOperation.Name
230- } else {
231- observation = nil
232- }
233- }
234- if observation != nil {
235- view.LastObservation = observation
236- }
237- }
238- if result := traceResultFact(event, view.Result); result != nil {
239- view.Result = result
240- }
241- if terminal != "" {
242- view.Terminal = terminal
243- delete(p.open, event.AgentID)
244- }
245- p.views[event.AgentID] = view
246-}
247-
248-func (p *traceProjection) snapshot() map[string]AgentTraceView {
249- views := make(map[string]AgentTraceView, len(p.views))
250- for agentID, stored := range p.views {
251- view := cloneAgentTraceView(stored)
252- operations := p.open[agentID]
253- for _, operation := range operations {
254- view.OpenOperations = append(view.OpenOperations, operation)
255- }
256- sort.Slice(view.OpenOperations, func(i, j int) bool {
257- left, right := view.OpenOperations[i], view.OpenOperations[j]
258- if operationPriority(left) != operationPriority(right) {
259- return operationPriority(left) < operationPriority(right)
260- }
261- if !left.StartedAt.Equal(right.StartedAt) {
262- return left.StartedAt.Before(right.StartedAt)
263- }
264- if left.Type != right.Type {
265- return left.Type < right.Type
266- }
267- if left.TurnID != right.TurnID {
268- return left.TurnID < right.TurnID
269- }
270- return left.CallID < right.CallID
271- })
272- views[agentID] = view
273- }
274- return views
275-}
276-
277-func cloneAgentTraceView(view AgentTraceView) AgentTraceView {
278- view.OpenOperations = nil
279- if view.LastObservation != nil {
280- observation := *view.LastObservation
281- view.LastObservation = &observation
282- }
283- if view.Result != nil {
284- result := *view.Result
285- view.Result = &result
286- }
287- if view.LastRequest != nil {
288- request := *view.LastRequest
289- view.LastRequest = &request
290- }
291- return view
292-}
293-
294-// Primary returns the highest-priority factual operation from the ordered
295-// open-operation projection.
296-func (v AgentTraceView) Primary() (Operation, bool) {
297- if len(v.OpenOperations) == 0 {
298- return Operation{}, false
299- }
300- return v.OpenOperations[0], true
301-}
302-
303-// TurnStartedAt returns the factual start of the open delegated turn owning
304-// operation. It is stable across nested provider requests and tool calls.
305-func (v AgentTraceView) TurnStartedAt(operation Operation) time.Time {
306- if operation.TurnID == "" {
307- return time.Time{}
308- }
309- for _, candidate := range v.OpenOperations {
310- if candidate.Type == "turn.started" && candidate.TurnID == operation.TurnID {
311- return candidate.StartedAt
312- }
313- }
314- return time.Time{}
315-}
316-
317-// ObservationFor returns the last user-facing observation only when it belongs
318-// to this operation's turn and scope. Tool observations are shown for their
319-// matching open tool and may remain as a factual recent fact under the next
320-// provider request in that turn. Provider-stream observations never decorate
321-// a tool operation, and another tool's observation never crosses call scope.
322-func (v AgentTraceView) ObservationFor(operation Operation) *LiveObservation {
323- observation := v.LastObservation
324- if observation == nil || operation.TurnID == "" || observation.TurnID == "" || operation.TurnID != observation.TurnID {
325- return nil
326- }
327- switch operation.Type {
328- case "tool.started":
329- if !isToolObservation(observation.Type) || operation.CallID == "" || observation.CallID == "" || operation.CallID != observation.CallID {
330- return nil
331- }
332- case "provider.request.started":
333- // The provider request is the current primary operation. A completed
334- // tool from this turn remains useful context without pretending the
335- // closed tool is still running.
336- case "turn.started":
337- // Keep even a very short tool perceptible after its factual end boundary
338- // and before the next provider request boundary arrives. Other call-scoped
339- // facts do not decorate the broader turn operation.
340- if observation.CallID != "" && observation.Type != "tool.finished" {
341- return nil
342- }
343- default:
344- if observation.CallID != "" {
345- return nil
346- }
347- }
348- return observation
349-}
350-
351-func isToolObservation(typeName string) bool {
352- switch typeName {
353- case "tool.output.observed", "tool.finished":
354- return true
355- default:
356- return false
357- }
358-}
359-
360-func operationPriority(o Operation) int {
361- switch o.Type {
362- case "tool.started":
363- return 0
364- case "provider.request.started":
365- return 1
366- case "agent.wait.started":
367- return 2
368- default:
369- return 3
370- }
371-}
372-
373-func traceObservation(event TraceEvent) *LiveObservation {
374- typeName := event.Type
375- source, _ := event.Data["source_event"].(string)
376- if source == "" {
377- source, _ = event.Data["worker_event"].(string)
378- }
379- name, _ := event.Data["name"].(string)
380- callID, _ := event.Data["call_id"].(string)
381- switch typeName {
382- case "assistant.stream.observed", "reasoning.stream.observed", "tool.output.observed", "tool.finished":
383- return &LiveObservation{Type: typeName, Source: source, At: event.Timestamp, TurnID: event.TurnID, CallID: callID, Name: name}
384- }
385- return nil
386-}
387-
388-func traceInt(value any) int {
389- switch value := value.(type) {
390- case int:
391- return value
392- case float64:
393- return int(value)
394- default:
395- return 0
396- }
397-}
398-
399-func requestFact(event TraceEvent, outcome string) *RequestFact {
400- providerName, _ := event.Data["provider"].(string)
401- model, _ := event.Data["model"].(string)
402- attempt := traceInt(event.Data["attempt"])
403- maxAttempts := traceInt(event.Data["max_attempts"])
404- errorCode, _ := event.Data["error_code"].(string)
405- return &RequestFact{TurnID: event.TurnID, Provider: providerName, Model: model, Attempt: attempt, MaxAttempts: maxAttempts, Outcome: outcome, ErrorCode: errorCode, StartedAt: event.Timestamp, EndedAt: event.Timestamp}
406-}
407-
408-func traceResultFact(event TraceEvent, previous *ResultFact) *ResultFact {
409- if event.Type != "result.available" && event.Type != "result.delivered" && event.Type != "result.delivery.failed" {
410- return previous
411- }
412- result := ResultFact{}
413- if previous != nil {
414- result = *previous
415- }
416- if ref, _ := event.Data["ref"].(string); ref != "" {
417- result.Ref = ref
418- }
419- result.At = event.Timestamp
420- switch event.Type {
421- case "result.available":
422- result.Available, result.Delivered, result.Failed = true, false, false
423- case "result.delivered":
424- result.Available, result.Delivered, result.Failed = true, true, false
425- case "result.delivery.failed":
426- result.Available, result.Delivered, result.Failed = true, false, true
427- }
428- return &result
429-}
430-
431-func traceBoundary(event TraceEvent) (key string, starts, ends bool, terminal string) {
432- callID, _ := event.Data["call_id"].(string)
433- key = event.Type + ":" + event.TurnID + ":" + callID
434- switch event.Type {
435- case "turn.started":
436- return "turn:" + event.TurnID, true, false, ""
437- case "turn.finished", "turn.failed", "turn.cancelled":
438- return "turn:" + event.TurnID, false, true, ""
439- case "tool.started":
440- return "tool:" + event.TurnID + ":" + callID, true, false, ""
441- case "tool.finished", "tool.failed", "tool.cancelled":
442- return "tool:" + event.TurnID + ":" + callID, false, true, ""
443- case "provider.request.started":
444- return "provider:" + event.TurnID, true, false, ""
445- case "provider.request.finished", "provider.request.failed", "provider.request.cancelled", "provider.request.retry.scheduled":
446- return "provider:" + event.TurnID, false, true, ""
447- case "agent.wait.started":
448- return "wait:" + event.TurnID, true, false, ""
449- case "agent.wait.finished":
450- return "wait:" + event.TurnID, false, true, ""
451- case "agent.finished":
452- return key, false, false, "completed"
453- case "agent.failed":
454- return key, false, false, "failed"
455- case "agent.cancelled":
456- return key, false, false, "cancelled"
457- }
458- return key, false, false, ""
459-}
D packages/agent/subagents/projection_test.go +0 -284
@@ -1,284 +0,0 @@
1-package subagents
2-
3-import (
4- "os"
5- "path/filepath"
6- "reflect"
7- "testing"
8- "time"
9-)
10-
11-func primaryOperation(t testing.TB, view AgentTraceView) *Operation {
12- t.Helper()
13- operation, ok := view.Primary()
14- if !ok {
15- t.Fatal("trace view has no primary operation")
16- }
17- return &operation
18-}
19-
20-func TestProjectTraceRetainsJSONDecodedRequestAttempts(t *testing.T) {
21- bundle := t.TempDir()
22- if err := os.WriteFile(filepath.Join(bundle, "manifest.json"), []byte(`{"version":1,"trace_file":"trace.jsonl"}`), 0o600); err != nil {
23- t.Fatal(err)
24- }
25- line := `{"seq":1,"timestamp":"2026-08-12T07:00:00Z","type":"provider.request.started","agent_id":"agent-1","turn_id":"turn-1","data":{"attempt":2,"max_attempts":6}}` + "\n"
26- if err := os.WriteFile(filepath.Join(bundle, "trace.jsonl"), []byte(line), 0o600); err != nil {
27- t.Fatal(err)
28- }
29- events, err := ReadTrace(bundle)
30- if err != nil {
31- t.Fatal(err)
32- }
33- operation := primaryOperation(t, ProjectTrace(events)["agent-1"])
34- if operation == nil || operation.Attempt != 2 || operation.MaxAttempts != 6 {
35- t.Fatalf("projected operation = %#v, want attempt 2/6", operation)
36- }
37-}
38-
39-func TestProjectTraceIgnoresProtocolFactsAndShowsCompletedToolDuringNextProviderRequest(t *testing.T) {
40- started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
41- view := ProjectTrace([]TraceEvent{
42- {Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
43- {Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},
44- {Timestamp: started.Add(2 * time.Second), Type: "tool.finished", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},
45- {Timestamp: started.Add(2500 * time.Millisecond), Type: "worker.protocol.observed", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"source_event": "tool.use.args"}},
46- {Timestamp: started.Add(3 * time.Second), Type: "provider.request.started", AgentID: "agent-1", TurnID: "turn-1"},
47- })["agent-1"]
48- if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "provider.request.started" {
49- t.Fatalf("primary operation = %#v", primaryOperation(t, view))
50- }
51- observation := view.ObservationFor(*primaryOperation(t, view))
52- if observation == nil || observation.Label() != "finished read" {
53- t.Fatalf("observation = %#v, want finished read", observation)
54- }
55- if view.LastObservation != observation {
56- t.Fatalf("protocol fact replaced user-facing observation: %#v", view.LastObservation)
57- }
58-}
59-
60-func TestProjectTraceSuppressesGenericUnmatchedToolFinished(t *testing.T) {
61- view := ProjectTrace([]TraceEvent{
62- {Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
63- {Type: "tool.finished", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "unknown"}},
64- })["agent-1"]
65- if view.LastObservation != nil {
66- t.Fatalf("generic tool-finished observation = %#v, want omitted", view.LastObservation)
67- }
68-}
69-
70-func TestProjectTraceKeepsShortCompletedToolVisibleBeforeNextRequest(t *testing.T) {
71- started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
72- view := ProjectTrace([]TraceEvent{
73- {Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
74- {Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},
75- {Timestamp: started.Add(time.Second + time.Millisecond), Type: "tool.finished", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},
76- })["agent-1"]
77- if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "turn.started" {
78- t.Fatalf("primary operation = %#v, want turn", primaryOperation(t, view))
79- }
80- if observation := view.ObservationFor(*primaryOperation(t, view)); observation == nil || observation.Label() != "finished read" {
81- t.Fatalf("completed tool observation = %#v, want finished read", observation)
82- }
83-}
84-
85-func TestProjectTraceDoesNotDecorateToolWithProviderStream(t *testing.T) {
86- started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
87- view := ProjectTrace([]TraceEvent{
88- {Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
89- {Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},
90- {Timestamp: started.Add(2 * time.Second), Type: "assistant.stream.observed", AgentID: "agent-1", TurnID: "turn-1"},
91- })["agent-1"]
92- if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "tool.started" {
93- t.Fatalf("primary operation = %#v, want tool", primaryOperation(t, view))
94- }
95- if observation := view.ObservationFor(*primaryOperation(t, view)); observation != nil {
96- t.Fatalf("provider stream decorated tool operation: %#v", observation)
97- }
98-}
99-
100-func TestAgentTraceViewTurnStartedAtMatchesOwningTurn(t *testing.T) {
101- first := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
102- second := first.Add(time.Minute)
103- view := AgentTraceView{OpenOperations: []Operation{
104- {Type: "turn.started", TurnID: "turn-1", StartedAt: first},
105- {Type: "turn.started", TurnID: "turn-2", StartedAt: second},
106- }}
107- operation := Operation{Type: "provider.request.started", TurnID: "turn-2", StartedAt: second.Add(time.Second)}
108- if got := view.TurnStartedAt(operation); !got.Equal(second) {
109- t.Fatalf("turn start = %v, want %v", got, second)
110- }
111-}
112-
113-func TestProjectTraceReportsOpenOperationInsteadOfLifecycleState(t *testing.T) {
114- started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
115- views := ProjectTrace([]TraceEvent{
116- {Seq: 1, Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
117- {Seq: 2, Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1"},
118- })
119- view := views["agent-1"]
120- if view.Terminal != "" {
121- t.Fatalf("terminal = %q, want empty", view.Terminal)
122- }
123- if len(view.OpenOperations) != 2 {
124- t.Fatalf("open operations = %#v, want turn and tool", view.OpenOperations)
125- }
126- for _, operation := range view.OpenOperations {
127- if !operation.Open() || operation.Duration(started.Add(time.Minute)) <= 0 {
128- t.Fatalf("operation = %#v, want open operation with duration", operation)
129- }
130- }
131-}
132-
133-func TestProjectTraceClosesPairedOperationsAndKeepsTerminalFact(t *testing.T) {
134- started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
135- views := ProjectTrace([]TraceEvent{
136- {Seq: 1, Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
137- {Seq: 2, Timestamp: started.Add(time.Second), Type: "turn.finished", AgentID: "agent-1", TurnID: "turn-1"},
138- {Seq: 3, Timestamp: started.Add(2 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}},
139- {Seq: 4, Timestamp: started.Add(3 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two"}},
140- {Seq: 5, Timestamp: started.Add(4 * time.Second), Type: "tool.finished", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}},
141- {Seq: 6, Timestamp: started.Add(5 * time.Second), Type: "agent.finished", AgentID: "agent-1"},
142- })
143- view := views["agent-1"]
144- if len(view.OpenOperations) != 0 {
145- t.Fatalf("open operations = %#v, want none after terminal agent event", view.OpenOperations)
146- }
147- if view.Terminal != "completed" {
148- t.Fatalf("terminal = %q, want completed", view.Terminal)
149- }
150-}
151-
152-func TestProjectTraceClosesFailedToolByCallID(t *testing.T) {
153- started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
154- view := ProjectTrace([]TraceEvent{
155- {Timestamp: started, Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}},
156- {Timestamp: started, Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two"}},
157- {Timestamp: started.Add(time.Second), Type: "tool.failed", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}},
158- })["agent-1"]
159- if len(view.OpenOperations) != 1 || view.OpenOperations[0].CallID != "two" {
160- t.Fatalf("open operations = %#v, want only call two", view.OpenOperations)
161- }
162-}
163-
164-func TestProjectTraceSelectsSpecificActivityAndProjectsResultDelivery(t *testing.T) {
165- started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
166- view := ProjectTrace([]TraceEvent{
167- {Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
168- {Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one", "name": "bash"}},
169- {Timestamp: started.Add(2 * time.Second), Type: "assistant.stream.observed", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"source_event": "message.delta"}},
170- {Timestamp: started.Add(3 * time.Second), Type: "result.available", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"ref": "subagent://agent-1/result"}},
171- {Timestamp: started.Add(4 * time.Second), Type: "result.delivered", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"ref": "subagent://agent-1/result"}},
172- })["agent-1"]
173- if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "tool.started" || primaryOperation(t, view).Name != "bash" {
174- t.Fatalf("primary operation = %#v, want bash tool", primaryOperation(t, view))
175- }
176- if view.LastObservation == nil || view.LastObservation.Type != "assistant.stream.observed" || view.LastObservation.Label() != "assistant streaming" {
177- t.Fatalf("last observation = %#v", view.LastObservation)
178- }
179- if view.Result == nil || !view.Result.Available || !view.Result.Delivered || view.Result.Ref != "subagent://agent-1/result" {
180- t.Fatalf("result = %#v", view.Result)
181- }
182- if got, want := view.Summary(), "running bash"; got != want {
183- t.Fatalf("summary = %q, want %q", got, want)
184- }
185-}
186-
187-func TestOperationLabelUsesHumanFacingActivity(t *testing.T) {
188- cases := []struct {
189- operation Operation
190- want string
191- }{
192- {operation: Operation{Type: "provider.request.started"}, want: "waiting for model response"},
193- {operation: Operation{Type: "tool.started", Name: "bash"}, want: "running bash"},
194- {operation: Operation{Type: "turn.started"}, want: "processing turn"},
195- }
196- for _, tc := range cases {
197- if got := tc.operation.Label(); got != tc.want {
198- t.Fatalf("Label(%q) = %q, want %q", tc.operation.Type, got, tc.want)
199- }
200- }
201-}
202-
203-func TestProjectTraceRetainsSafeProviderAttemptDiagnostic(t *testing.T) {
204- started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
205- view := ProjectTrace([]TraceEvent{
206- {Timestamp: started, Type: "provider.request.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"provider": "openai-codex", "model": "gpt-5.6", "attempt": 2, "max_attempts": 4}},
207- {Timestamp: started.Add(time.Second), Type: "provider.request.failed", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"error_code": "deadline_exceeded"}},
208- })["agent-1"]
209- if len(view.OpenOperations) != 0 {
210- t.Fatalf("open operations = %#v, want none", view.OpenOperations)
211- }
212- if got, want := view.LastRequest, (&RequestFact{TurnID: "turn-1", Provider: "openai-codex", Model: "gpt-5.6", Attempt: 2, MaxAttempts: 4, Outcome: "failed", ErrorCode: "deadline_exceeded", StartedAt: started, EndedAt: started.Add(time.Second)}); !reflect.DeepEqual(got, want) {
213- t.Fatalf("request diagnostic = %#v, want %#v", got, want)
214- }
215-}
216-
217-func TestProjectTraceOmitsObservationForAnotherToolInSameTurn(t *testing.T) {
218- started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
219- view := ProjectTrace([]TraceEvent{
220- {Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
221- {Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one", "name": "bash"}},
222- {Timestamp: started.Add(2 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two", "name": "read"}},
223- {Timestamp: started.Add(3 * time.Second), Type: "tool.output.observed", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two", "name": "read"}},
224- })["agent-1"]
225- if primaryOperation(t, view) == nil || primaryOperation(t, view).CallID != "one" {
226- t.Fatalf("primary operation = %#v, want call one", primaryOperation(t, view))
227- }
228- if observation := view.ObservationFor(*primaryOperation(t, view)); observation != nil {
229- t.Fatalf("another tool's observation = %#v, want omitted", observation)
230- }
231- if got, want := view.Summary(), "running bash"; got != want {
232- t.Fatalf("summary = %q, want %q", got, want)
233- }
234-}
235-
236-func TestProjectTraceOmitsPriorTurnObservation(t *testing.T) {
237- started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
238- view := ProjectTrace([]TraceEvent{
239- {Timestamp: started, Type: "assistant.stream.observed", AgentID: "agent-1", TurnID: "turn-1"},
240- {Timestamp: started.Add(time.Second), Type: "turn.started", AgentID: "agent-1", TurnID: "turn-2"},
241- })["agent-1"]
242- if view.LastObservation == nil {
243- t.Fatal("last observation missing")
244- }
245- if primaryOperation(t, view) == nil {
246- t.Fatal("primary operation missing")
247- }
248- if observation := view.ObservationFor(*primaryOperation(t, view)); observation != nil {
249- t.Fatalf("prior turn observation = %#v, want omitted", observation)
250- }
251- if got, want := view.Summary(), "processing turn"; got != want {
252- t.Fatalf("summary = %q, want %q", got, want)
253- }
254-}
255-
256-func TestProjectTraceNewAvailableResultResetsDelivery(t *testing.T) {
257- view := ProjectTrace([]TraceEvent{
258- {Type: "result.delivered", AgentID: "agent-1", Data: map[string]any{"ref": "subagent://agent-1/result-1"}},
259- {Type: "result.available", AgentID: "agent-1", Data: map[string]any{"ref": "subagent://agent-1/result-2"}},
260- })["agent-1"]
261- if view.Result == nil || !view.Result.Available || view.Result.Delivered || view.Result.Failed || view.Result.Ref != "subagent://agent-1/result-2" {
262- t.Fatalf("result = %#v", view.Result)
263- }
264-}
265-
266-func TestProjectTraceDeliveryFailureClearsDelivered(t *testing.T) {
267- view := ProjectTrace([]TraceEvent{
268- {Type: "result.delivered", AgentID: "agent-1", Data: map[string]any{"ref": "subagent://agent-1/result"}},
269- {Type: "result.delivery.failed", AgentID: "agent-1", Data: map[string]any{"ref": "subagent://agent-1/result"}},
270- })["agent-1"]
271- if view.Result == nil || !view.Result.Available || view.Result.Delivered || !view.Result.Failed {
272- t.Fatalf("result = %#v, want available undelivered failure", view.Result)
273- }
274-}
275-
276-func TestProjectTraceFailedAgentClosesOpenOperations(t *testing.T) {
277- view := ProjectTrace([]TraceEvent{
278- {Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}},
279- {Type: "agent.failed", AgentID: "agent-1"},
280- })["agent-1"]
281- if len(view.OpenOperations) != 0 || view.Terminal != "failed" {
282- t.Fatalf("view = %#v, want failed terminal with no open operations", view)
283- }
284-}
D packages/agent/subagents/protocol.go +0 -655 collapsed to keep the page light
D packages/agent/subagents/protocol_test.go +0 -303 collapsed to keep the page light
D packages/agent/subagents/requirement.go +0 -389 collapsed to keep the page light
D packages/agent/subagents/requirement_test.go +0 -458 collapsed to keep the page light
A packages/agent/subagents/resident_child.go +428 -0 collapsed to keep the page light
A packages/agent/subagents/resident_child_test.go +135 -0
@@ -0,0 +1,135 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/zut/packages/core"
10+)
11+
12+func TestJournaledResidentChildReceivesLiveJournalEvents(t *testing.T) {
13+ journal, err := OpenResidentJournal(t.TempDir(), "live-child")
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ child := newJournaledResidentChild(ResidentChildSpec{}, journal, func(context.Context, string) error { return nil }, nil)
18+ defer child.Close(context.Background())
19+ oldActivity := time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC)
20+ child.mu.Lock()
21+ child.activityUpdatedAt = oldActivity
22+ child.mu.Unlock()
23+ child.live.Start("turn-1")
24+ if err := journal.RecordAgentEvent(core.EvTextDelta{Delta: "streaming"}); err != nil {
25+ t.Fatal(err)
26+ }
27+ if got := child.Live(); got.AssistantText != "streaming" || got.Revision == 0 {
28+ t.Fatalf("live child = %#v", got)
29+ }
30+ if got := child.ActivityUpdatedAt(); !got.After(oldActivity) {
31+ t.Fatalf("activity time = %s, want after %s", got, oldActivity)
32+ }
33+}
34+
35+func TestResidentChildRunsAcceptedPromptsFIFOWithOneActiveTurn(t *testing.T) {
36+ started := make(chan string, 2)
37+ releaseFirst := make(chan struct{})
38+ completed := make(chan string, 2)
39+ child := NewResidentChild(func(ctx context.Context, prompt string) error {
40+ started <- prompt
41+ if prompt == "first" {
42+ select {
43+ case <-releaseFirst:
44+ case <-ctx.Done():
45+ return ctx.Err()
46+ }
47+ }
48+ completed <- prompt
49+ return nil
50+ })
51+ defer child.Close(context.Background())
52+
53+ if err := child.Resume(context.Background(), "first"); err != nil {
54+ t.Fatalf("first Resume: %v", err)
55+ }
56+ if err := child.Resume(context.Background(), "second"); err != nil {
57+ t.Fatalf("second Resume: %v", err)
58+ }
59+ select {
60+ case got := <-started:
61+ if got != "first" {
62+ t.Fatalf("first start = %q", got)
63+ }
64+ case <-time.After(time.Second):
65+ t.Fatal("first turn did not start")
66+ }
67+ select {
68+ case got := <-started:
69+ t.Fatalf("second turn started before first completed: %q", got)
70+ default:
71+ }
72+ close(releaseFirst)
73+ for _, want := range []string{"first", "second"} {
74+ select {
75+ case got := <-completed:
76+ if got != want {
77+ t.Fatalf("completion = %q, want %q", got, want)
78+ }
79+ case <-time.After(time.Second):
80+ t.Fatalf("%s turn did not complete", want)
81+ }
82+ }
83+}
84+
85+func TestResidentChildReportsAndPreservesFailureWhenTurnStartCannotPersist(t *testing.T) {
86+ journal, err := OpenResidentJournal(t.TempDir(), "start-persistence-failure")
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ if err := journal.Close(); err != nil {
91+ t.Fatal(err)
92+ }
93+ completed := make(chan ResidentCompletion, 1)
94+ run := make(chan struct{}, 1)
95+ child := newJournaledResidentChild(
96+ ResidentChildSpec{ID: "start-persistence-failure"},
97+ journal,
98+ func(context.Context, string) error {
99+ run <- struct{}{}
100+ return nil
101+ },
102+ func(completion ResidentCompletion) { completed <- completion },
103+ )
104+
105+ request := residentPrompt{turnID: "turn", prompt: "task", ack: make(chan error, 1)}
106+ select {
107+ case child.inbox <- request:
108+ case <-time.After(time.Second):
109+ t.Fatal("child did not accept test prompt")
110+ }
111+ select {
112+ case completion := <-completed:
113+ if completion.ChildID != "start-persistence-failure" || completion.Task != "task" {
114+ t.Fatalf("completion = %#v", completion)
115+ }
116+ if completion.Err == nil || !strings.Contains(completion.Err.Error(), "persist resident child start state") {
117+ t.Fatalf("completion error = %v", completion.Err)
118+ }
119+ case <-time.After(time.Second):
120+ t.Fatal("failed turn did not report completion")
121+ }
122+ select {
123+ case <-child.done:
124+ case <-time.After(time.Second):
125+ t.Fatal("child did not stop after start persistence failure")
126+ }
127+ if state := child.State(); state != ResidentFailed {
128+ t.Fatalf("state = %q, want %q", state, ResidentFailed)
129+ }
130+ select {
131+ case <-run:
132+ t.Fatal("runner started after turn-start persistence failure")
133+ default:
134+ }
135+}
A packages/agent/subagents/resident_history.go +421 -0 collapsed to keep the page light
A packages/agent/subagents/resident_history_test.go +246 -0
@@ -0,0 +1,246 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "github.com/bnema/zut/packages/core"
11+ "github.com/bnema/zut/packages/provider"
12+)
13+
14+func TestReadResidentHistoryReturnsCompleteFinalizedItems(t *testing.T) {
15+ root := t.TempDir()
16+ journal, err := OpenResidentJournal(root, "history-child")
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ spec := ResidentChildSpec{ID: "history-child", SessionID: "history-session", Provider: "openai", Model: "gpt-5"}
21+ if err := journal.Accept(spec, "task"); err != nil {
22+ t.Fatal(err)
23+ }
24+ if err := journal.RecordAgentEvent(core.EvUserMessage{Message: provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "question"}}}}); err != nil {
25+ t.Fatal(err)
26+ }
27+ if err := journal.RecordAgentEvent(core.EvToolCall{ID: "call-1", Name: "bash", Args: json.RawMessage(`{"command":"pwd"}`)}); err != nil {
28+ t.Fatal(err)
29+ }
30+ if err := journal.RecordAgentEvent(core.EvToolResult{ID: "call-1", Result: core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "/repo"}}}}); err != nil {
31+ t.Fatal(err)
32+ }
33+ if err := journal.Close(); err != nil {
34+ t.Fatal(err)
35+ }
36+ history, err := ReadResidentHistory(filepath.Join(root, spec.ID), 10)
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ if len(history) != 3 || history[1].ToolName != "bash" || string(history[1].ToolArgs) != `{"command":"pwd"}` || len(history[2].ToolResult) == 0 {
41+ t.Fatalf("history = %#v", history)
42+ }
43+ if _, err := ResidentHistoryDir(root, "../escape"); err == nil {
44+ t.Fatal("ResidentHistoryDir accepted traversal")
45+ }
46+}
47+
48+func TestResidentHistoryMessagesRestoresToolCallsAndResults(t *testing.T) {
49+ assistant, err := json.Marshal(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "bash", Arguments: json.RawMessage(`{"command":"pwd"}`)}}})
50+ if err != nil {
51+ t.Fatal(err)
52+ }
53+ result, err := json.Marshal(core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "/repo"}}})
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ messages, err := ResidentHistoryMessages([]ResidentHistoryItem{
58+ {Type: residentRecordAssistant, Message: assistant},
59+ {Type: residentRecordToolCall, ToolID: "call-1", ToolName: "bash", ToolArgs: json.RawMessage(`{"command":"pwd"}`)},
60+ {Type: residentRecordToolResult, ToolID: "call-1", ToolResult: result},
61+ })
62+ if err != nil {
63+ t.Fatal(err)
64+ }
65+ if len(messages) != 2 {
66+ t.Fatalf("messages = %#v", messages)
67+ }
68+ call, ok := messages[0].Content[0].(provider.ToolCallBlock)
69+ if !ok || call.ID != "call-1" {
70+ t.Fatalf("assistant call = %#v", messages[0])
71+ }
72+ tool, ok := messages[1].Content[0].(provider.ToolResultBlock)
73+ if !ok || tool.CallID != "call-1" || len(tool.Content) != 1 {
74+ t.Fatalf("tool message = %#v", messages[1])
75+ }
76+}
77+
78+func TestReadResidentTranscriptMessagesRestoresFinalizedConversation(t *testing.T) {
79+ root := t.TempDir()
80+ journal, err := OpenResidentJournal(root, "transcript")
81+ if err != nil {
82+ t.Fatal(err)
83+ }
84+ spec := ResidentChildSpec{ID: "transcript", SessionID: "session", Provider: "openai", Model: "gpt-5"}
85+ if err := journal.Accept(spec, "task"); err != nil {
86+ t.Fatal(err)
87+ }
88+ if err := journal.RecordAgentEvent(core.EvUserMessage{Message: provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "question"}}}}); err != nil {
89+ t.Fatal(err)
90+ }
91+ if err := journal.Close(); err != nil {
92+ t.Fatal(err)
93+ }
94+ messages, err := ReadResidentTranscriptMessages(filepath.Join(root, spec.ID))
95+ if err != nil {
96+ t.Fatal(err)
97+ }
98+ if len(messages) != 1 || messages[0].Role != provider.RoleUser {
99+ t.Fatalf("messages = %#v", messages)
100+ }
101+}
102+
103+func TestReadResidentHistoryPageKeepsToolGroupsTogether(t *testing.T) {
104+ root := t.TempDir()
105+ journal, err := OpenResidentJournal(root, "history-page")
106+ if err != nil {
107+ t.Fatal(err)
108+ }
109+ spec := ResidentChildSpec{ID: "history-page", SessionID: "history-session", Provider: "openai", Model: "gpt-5"}
110+ if err := journal.Accept(spec, "task"); err != nil {
111+ t.Fatal(err)
112+ }
113+ for _, group := range []struct{ id, text string }{{"old", "old answer"}, {"new", "new answer"}} {
114+ if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: group.text}}}}); err != nil {
115+ t.Fatal(err)
116+ }
117+ if err := journal.RecordAgentEvent(core.EvToolCall{ID: group.id, Name: "bash", Args: json.RawMessage(`{"command":"pwd"}`)}); err != nil {
118+ t.Fatal(err)
119+ }
120+ if err := journal.RecordAgentEvent(core.EvToolResult{ID: group.id, Result: core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "/repo"}}}}); err != nil {
121+ t.Fatal(err)
122+ }
123+ }
124+ if err := journal.Close(); err != nil {
125+ t.Fatal(err)
126+ }
127+
128+ page, err := ReadResidentHistoryPage(filepath.Join(root, spec.ID), "", 2)
129+ if err != nil {
130+ t.Fatal(err)
131+ }
132+ if len(page.Items) != 3 || page.Items[0].Type != residentRecordAssistant || page.Items[1].ToolID != "new" || page.Items[2].ToolID != "new" {
133+ t.Fatalf("latest page split a tool group: %#v", page.Items)
134+ }
135+ if page.OlderCursor == "" {
136+ t.Fatal("latest page has no cursor for the older group")
137+ }
138+ older, err := ReadResidentHistoryPage(filepath.Join(root, spec.ID), page.OlderCursor, 2)
139+ if err != nil {
140+ t.Fatal(err)
141+ }
142+ if len(older.Items) != 3 || older.Items[0].Type != residentRecordAssistant || older.Items[1].ToolID != "old" || older.Items[2].ToolID != "old" || older.OlderCursor != "" {
143+ t.Fatalf("older page = %#v", older)
144+ }
145+}
146+
147+func TestReadResidentHistoryPageDefersIncompleteTailAndInvalidatesReplacedJournal(t *testing.T) {
148+ root := t.TempDir()
149+ journal, err := OpenResidentJournal(root, "history-cursor")
150+ if err != nil {
151+ t.Fatal(err)
152+ }
153+ spec := ResidentChildSpec{ID: "history-cursor", SessionID: "history-session", Provider: "openai", Model: "gpt-5"}
154+ if err := journal.Accept(spec, "task"); err != nil {
155+ t.Fatal(err)
156+ }
157+ if err := journal.RecordAgentEvent(core.EvUserMessage{Message: provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "saved"}}}}); err != nil {
158+ t.Fatal(err)
159+ }
160+ if err := journal.RecordAgentEvent(core.EvUserMessage{Message: provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "newer"}}}}); err != nil {
161+ t.Fatal(err)
162+ }
163+ if err := journal.Close(); err != nil {
164+ t.Fatal(err)
165+ }
166+ path := filepath.Join(root, spec.ID, residentTranscriptName)
167+ f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600)
168+ if err != nil {
169+ t.Fatal(err)
170+ }
171+ if _, err := f.WriteString(`{"type":"message.assistant"}`); err != nil {
172+ t.Fatal(err)
173+ }
174+ if err := f.Close(); err != nil {
175+ t.Fatal(err)
176+ }
177+ page, err := ReadResidentHistoryPage(filepath.Join(root, spec.ID), "", 1)
178+ if err != nil {
179+ t.Fatal(err)
180+ }
181+ if len(page.Items) != 1 || page.Items[0].Type != residentRecordUser {
182+ t.Fatalf("incomplete tail leaked into page: %#v", page.Items)
183+ }
184+ if page.OlderCursor == "" {
185+ t.Fatal("page has no cursor before replacement")
186+ }
187+
188+ if err := os.WriteFile(path, []byte("{}\n"), 0o600); err != nil {
189+ t.Fatal(err)
190+ }
191+ if _, err := ReadResidentHistoryPage(filepath.Join(root, spec.ID), page.OlderCursor, 10); err == nil {
192+ t.Fatal("replaced journal cursor was accepted")
193+ }
194+}
195+
196+func TestReadResidentHistoryPageCursorSurvivesJournalAppend(t *testing.T) {
197+ root := t.TempDir()
198+ journal, err := OpenResidentJournal(root, "history-append")
199+ if err != nil {
200+ t.Fatal(err)
201+ }
202+ spec := ResidentChildSpec{ID: "history-append", SessionID: "history-session", Provider: "openai", Model: "gpt-5"}
203+ if err := journal.Accept(spec, "task"); err != nil {
204+ t.Fatal(err)
205+ }
206+ for _, text := range []string{"first", "second"} {
207+ if err := journal.RecordAgentEvent(core.EvUserMessage{Message: provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: text}}}}); err != nil {
208+ t.Fatal(err)
209+ }
210+ }
211+ if err := journal.Close(); err != nil {
212+ t.Fatal(err)
213+ }
214+ page, err := ReadResidentHistoryPage(filepath.Join(root, spec.ID), "", 1)
215+ if err != nil || page.OlderCursor == "" {
216+ t.Fatalf("latest page = %#v, err=%v", page, err)
217+ }
218+ journal, err = OpenResidentJournal(root, spec.ID)
219+ if err != nil {
220+ t.Fatal(err)
221+ }
222+ if err := journal.RecordAgentEvent(core.EvUserMessage{Message: provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "third"}}}}); err != nil {
223+ t.Fatal(err)
224+ }
225+ if err := journal.Close(); err != nil {
226+ t.Fatal(err)
227+ }
228+ older, err := ReadResidentHistoryPage(filepath.Join(root, spec.ID), page.OlderCursor, 1)
229+ if err != nil || len(older.Items) != 1 {
230+ t.Fatalf("older page = %#v, err=%v", older, err)
231+ }
232+}
233+
234+func TestResidentManagerHistoryRejectsPathTraversal(t *testing.T) {
235+ manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
236+ return func(context.Context, string) error { return nil }, nil
237+ })
238+ if _, err := manager.History("../escape", 10); err == nil {
239+ t.Fatal("History accepted traversal")
240+ }
241+ for _, childID := range []string{"../escape", "..", ".", "sub/child"} {
242+ if _, err := manager.HistoryPage(childID, "", 10); err == nil {
243+ t.Fatalf("HistoryPage(%q) accepted traversal", childID)
244+ }
245+ }
246+}
A packages/agent/subagents/resident_journal.go +768 -0 collapsed to keep the page light
A packages/agent/subagents/resident_journal_test.go +403 -0 collapsed to keep the page light
A packages/agent/subagents/resident_manager.go +862 -0 collapsed to keep the page light
A packages/agent/subagents/resident_manager_test.go +711 -0 collapsed to keep the page light
A packages/agent/subagents/resident_projection.go +170 -0 collapsed to keep the page light
A packages/agent/subagents/resident_projection_test.go +53 -0
@@ -0,0 +1,53 @@
1+package subagents
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+ "testing"
7+
8+ "github.com/bnema/zut/packages/core"
9+ "github.com/bnema/zut/packages/provider"
10+)
11+
12+func TestResidentLiveProjectionPublishesCopiesAndFinalizesLiveItems(t *testing.T) {
13+ projection := newResidentLiveProjection()
14+ projection.Start("turn-1")
15+ projection.Apply(core.EvTextDelta{Delta: "draft"})
16+ projection.Apply(core.EvToolUseStart{ID: "call-1", Name: "bash"})
17+ projection.Apply(core.EvToolUseArgs{ID: "call-1", Delta: `{"command":"pwd"}`})
18+ projection.Apply(core.EvToolExecutionStarted{ID: "call-1", Name: "bash"})
19+
20+ live := projection.Snapshot()
21+ if live.TurnID != "turn-1" || live.State != ResidentRunning || live.AssistantText != "draft" || len(live.Tools) != 1 || live.Tools[0].State != ResidentLiveToolRunning || string(live.Tools[0].Args) != `{"command":"pwd"}` || live.Revision == 0 {
22+ t.Fatalf("live projection = %#v", live)
23+ }
24+ live.Tools[0].Args[0] = 'x'
25+ if got := projection.Snapshot(); string(got.Tools[0].Args) != `{"command":"pwd"}` {
26+ t.Fatalf("snapshot mutated projection: %#v", got)
27+ }
28+
29+ projection.Apply(core.EvToolResult{ID: "call-1", Result: core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "done"}}}})
30+ projection.Apply(core.EvRequestStarted{})
31+ if !projection.Snapshot().WaitingForModel {
32+ t.Fatal("provider request did not mark the resident as waiting for the model")
33+ }
34+ projection.Apply(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "final"}}}})
35+ projection.Finish(ResidentIdle)
36+ final := projection.Snapshot()
37+ if final.AssistantText != "" || len(final.Tools) != 0 || final.State != ResidentIdle || final.Revision <= live.Revision {
38+ t.Fatalf("final projection = %#v", final)
39+ }
40+}
41+
42+func TestResidentLiveProjectionBoundsToolArguments(t *testing.T) {
43+ projection := newResidentLiveProjection()
44+ projection.Start("turn-1")
45+ projection.Apply(core.EvToolCall{ID: "call-1", Name: "bash", Args: json.RawMessage(`{"command":"pwd"}`)})
46+ if got := projection.Snapshot(); len(got.Tools) != 1 || string(got.Tools[0].Args) != `{"command":"pwd"}` {
47+ t.Fatalf("live tool = %#v", got)
48+ }
49+ projection.Apply(core.EvToolCall{ID: "call-2", Name: "bash", Args: json.RawMessage(strings.Repeat("a", residentLiveArgumentBytes+1024))})
50+ if got := projection.Snapshot(); len(got.Tools) != 2 || len(got.Tools[1].Args) != residentLiveArgumentBytes {
51+ t.Fatalf("oversized tool args = %d bytes", len(got.Tools[1].Args))
52+ }
53+}
A packages/agent/subagents/resident_scheduler.go +91 -0
@@ -0,0 +1,91 @@
1+package subagents
2+
3+import (
4+ "errors"
5+ "sync"
6+)
7+
8+const DefaultResidentConcurrency = 6
9+
10+// ResidentTicket is an accepted prompt's stable global admission position.
11+type ResidentTicket struct {
12+ Sequence uint64
13+ ChildID string
14+ Prompt string
15+ ready chan struct{}
16+}
17+
18+// ResidentScheduler provides fair global admission while enforcing one active
19+// turn per child. It intentionally does not perform provider work.
20+type ResidentScheduler struct {
21+ mu sync.Mutex
22+ limit int
23+ next uint64
24+ active int
25+ running map[string]bool
26+ pending []ResidentTicket
27+}
28+
29+func NewResidentScheduler(limit int) *ResidentScheduler {
30+ if limit <= 0 {
31+ limit = DefaultResidentConcurrency
32+ }
33+ return &ResidentScheduler{limit: limit, running: make(map[string]bool)}
34+}
35+
36+func (s *ResidentScheduler) Enqueue(childID, prompt string) ResidentTicket {
37+ s.mu.Lock()
38+ defer s.mu.Unlock()
39+ s.next++
40+ ticket := ResidentTicket{Sequence: s.next, ChildID: childID, Prompt: prompt, ready: make(chan struct{})}
41+ s.pending = append(s.pending, ticket)
42+ return ticket
43+}
44+
45+// Admit returns the oldest ticket whose child is not already active. An older
46+// ineligible ticket therefore cannot prevent a later independent child from
47+// using an available global slot.
48+func (s *ResidentScheduler) Admit() (ResidentTicket, bool) {
49+ s.mu.Lock()
50+ defer s.mu.Unlock()
51+ if s.active >= s.limit {
52+ return ResidentTicket{}, false
53+ }
54+ for i, ticket := range s.pending {
55+ if s.running[ticket.ChildID] {
56+ continue
57+ }
58+ s.pending = append(s.pending[:i], s.pending[i+1:]...)
59+ s.running[ticket.ChildID] = true
60+ s.active++
61+ close(ticket.ready)
62+ return ticket, true
63+ }
64+ return ResidentTicket{}, false
65+}
66+
67+// Cancel removes an admitted-waiting ticket. Once admitted, the caller owns
68+// the active reservation and must Release it instead.
69+func (s *ResidentScheduler) Cancel(sequence uint64) bool {
70+ s.mu.Lock()
71+ defer s.mu.Unlock()
72+ for i, ticket := range s.pending {
73+ if ticket.Sequence != sequence {
74+ continue
75+ }
76+ s.pending = append(s.pending[:i], s.pending[i+1:]...)
77+ return true
78+ }
79+ return false
80+}
81+
82+func (s *ResidentScheduler) Release(childID string) error {
83+ s.mu.Lock()
84+ defer s.mu.Unlock()
85+ if !s.running[childID] {
86+ return errors.New("resident scheduler: child has no active turn")
87+ }
88+ delete(s.running, childID)
89+ s.active--
90+ return nil
91+}
A packages/agent/subagents/resident_scheduler_test.go +33 -0 collapsed to keep the page light
A packages/agent/subagents/resident_snapshot_test.go +41 -0 collapsed to keep the page light
D packages/agent/subagents/result.go +0 -639 collapsed to keep the page light
D packages/agent/subagents/result_test.go +0 -416 collapsed to keep the page light
D packages/agent/subagents/resume_prompt_test.go +0 -421 collapsed to keep the page light
D packages/agent/subagents/runner.go +0 -1121 collapsed to keep the page light
D packages/agent/subagents/runner_e2e_test.go +0 -288 collapsed to keep the page light
D packages/agent/subagents/runner_test.go +0 -1028 collapsed to keep the page light
D packages/agent/subagents/scheduler.go +0 -433 collapsed to keep the page light
D packages/agent/subagents/scheduler_test.go +0 -276 collapsed to keep the page light
D packages/agent/subagents/socketpath.go +0 -120 collapsed to keep the page light
D packages/agent/subagents/socketpath_test.go +0 -67 collapsed to keep the page light
D packages/agent/subagents/supervisor.go +0 -1358 collapsed to keep the page light
D packages/agent/subagents/supervisor_test.go +0 -956 collapsed to keep the page light
M packages/agent/subagents/syncdir_windows.go +1 -4 collapsed to keep the page light
D packages/agent/subagents/testdata/cmd/stubchild/main.go +0 -183 collapsed to keep the page light
D packages/agent/subagents/trace.go +0 -658 collapsed to keep the page light
D packages/agent/subagents/trace_read.go +0 -88 collapsed to keep the page light
D packages/agent/subagents/trace_test.go +0 -310 collapsed to keep the page light
D packages/agent/subagents/turn_classification.go +0 -93 collapsed to keep the page light
D packages/agent/subagents/turn_classification_test.go +0 -63 collapsed to keep the page light
D packages/agent/subagents/web_search_policy_test.go +0 -116 collapsed to keep the page light
M packages/agent/subagents/workspace.go +48 -3 collapsed to keep the page light
D packages/agent/subagents/workspace_manager_test.go +0 -65 collapsed to keep the page light
A packages/agent/tools/resident_tools_test.go +138 -0 collapsed to keep the page light
D packages/agent/tools/subagent_lifecycle_test.go +0 -255 collapsed to keep the page light
D packages/agent/tools/subagent_required_test.go +0 -217 collapsed to keep the page light
M packages/agent/tools/subagent_resume.go +17 -57 collapsed to keep the page light
M packages/agent/tools/subagent_spawn.go +50 -90 collapsed to keep the page light
D packages/agent/tools/subagent_spawn_test.go +0 -488 collapsed to keep the page light
M packages/agent/tools/subagent_status.go +24 -140 collapsed to keep the page light
D packages/agent/tools/subagent_status_test.go +0 -473 collapsed to keep the page light
M packages/agent/tools/subagent_stop.go +17 -45 collapsed to keep the page light
D packages/agent/tracecmd.go +0 -58 collapsed to keep the page light
D packages/agent/tracecmd_test.go +0 -37 collapsed to keep the page light
D packages/agent/web_search_integration_test.go +0 -324 collapsed to keep the page light
M packages/core/agent.go +53 -6 collapsed to keep the page light
A packages/core/agent_identity_test.go +108 -0 collapsed to keep the page light
M packages/core/core_test.go +3 -8 collapsed to keep the page light
M packages/core/events.go +2 -1
@@ -15,7 +15,8 @@ type AgentEvent interface {
1515 }
1616
1717 type EvTurnStart struct {
18- Step int
18+ Step int
19+ TurnID string
1920 }
2021
2122 func (EvTurnStart) Type() string { return "turn_start" }
A packages/core/message_decode_test.go +28 -0 collapsed to keep the page light
M packages/core/session.go +10 -5 collapsed to keep the page light
M packages/provider/google_vertex.go +7 -0 collapsed to keep the page light
M packages/provider/httpclient.go +10 -0 collapsed to keep the page light
M packages/provider/models.go +0 -1
@@ -353,7 +353,6 @@ var Catalog = []Model{
353353 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,
354354 Speculative: true,
355355 },
356-
357356 // ---- OpenAI Codex / ChatGPT subscription backend ----
358357 // Same model ids as the OpenAI family, but routed through the
359358 // ChatGPT Codex OAuth backend rather than api.openai.com.
M packages/provider/openai_codex.go +254 -233 collapsed to keep the page light
M packages/provider/openai_codex_test.go +45 -18 collapsed to keep the page light
M packages/provider/openai_responses.go +35 -6 collapsed to keep the page light
A packages/provider/openai_responses_websocket.go +516 -0 collapsed to keep the page light
A packages/provider/openai_responses_websocket_test.go +333 -0 collapsed to keep the page light
M packages/provider/provider.go +27 -0 collapsed to keep the page light
M packages/provider/retry.go +13 -3 collapsed to keep the page light
M packages/provider/retry_test.go +13 -0 collapsed to keep the page light

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close