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 Unverified
B5690EEEBB952194The 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 | |||
| 268 | 268 | ||
| 269 | 269 | ### Headless orchestration | |
| 270 | 270 | ||
| 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. | |
| 272 | 272 | ||
| 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. | |
| 274 | 274 | ||
| 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. | |
| 276 | 276 | ||
| 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. | |
| 278 | 278 | ||
| 279 | 279 | ## zutfile agents | |
| 280 | 280 | ||
| @@ -318,7 +318,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||
| 318 | 318 | | `/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. | | |
| 319 | 319 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | |
| 320 | 320 | | `/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). | | |
| 322 | 322 | | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. | | |
| 323 | 323 | | `/compact` | Summarize the transcript into one message to free up context. | | |
| 324 | 324 | | `/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 | |||
| 352 | 352 | ||
| 353 | 353 | - **`/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. | |
| 354 | 354 | ||
| 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. | |
| 356 | 356 | - **`/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). | |
| 357 | 357 | - **`/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. | |
| 358 | 358 | - **`/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 | |||
| 382 | 382 | ||
| 383 | 383 | ### `/subagents` | |
| 384 | 384 | ||
| 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. | |
| 386 | 388 | ||
| 387 | 389 | > **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. | |
| 388 | 390 | ||
| @@ -393,39 +395,22 @@ Background subagents that run alongside your main session. Each one is a separat | |||
| 393 | 395 | /subagents new --reasoning high <task> # set child reasoning | |
| 394 | 396 | /subagents logs <id> # open one agent's transcript | |
| 395 | 397 | /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 | |
| 401 | 401 | ``` | |
| 402 | 402 | ||
| 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. | |
| 419 | 404 | ||
| 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. | |
| 421 | 406 | ||
| 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. | |
| 423 | 408 | ||
| 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. | |
| 425 | 410 | ||
| 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`. | |
| 427 | 412 | ||
| 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. | |
| 429 | 414 | ||
| 430 | 415 | ### Named subagent profiles | |
| 431 | 416 | ||
| @@ -448,7 +433,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 448 | 433 | - **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. | |
| 449 | 434 | - **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`. | |
| 450 | 435 | - **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`. | |
| 452 | 437 | - **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`. | |
| 453 | 438 | - **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). | |
| 454 | 439 | - **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 | |||
| 949 | 934 | | `alt+left`, `alt+right` | Jump one word back or forward. | | |
| 950 | 935 | | `ctrl+u`, `ctrl+k` | Delete to start or end of line. | | |
| 951 | 936 | | `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. | | |
| 953 | 938 | ||
| 954 | 939 | ### Chat scroll | |
| 955 | 940 | ||
| 956 | 941 | | Key | Action | | |
| 957 | 942 | |---|---| | |
| 958 | 943 | | `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. | | |
| 960 | 945 | ||
| 961 | 946 | ## Extensions | |
| 962 | 947 | ||
| @@ -1109,7 +1094,7 @@ packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter i | |||
| 1109 | 1094 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | |
| 1110 | 1095 | packages/agent/tools/ read, write, edit, bash, create_worktree, lsp, web search, goals, sandbox | |
| 1111 | 1096 | 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 | |
| 1113 | 1098 | packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk) | |
| 1114 | 1099 | packages/agent/ext/ public Go SDK for writing extensions (package ext) | |
| 1115 | 1100 | ``` | |
M docs/providers.md +35 -5
| @@ -142,11 +142,41 @@ Use `/fast` or `/settings` to enable **fast mode** for subsequent model calls. | |||
| 142 | 142 | The setting is off by default and is stored as `fast_mode` in | |
| 143 | 143 | `$ZUT_HOME/config.json`. | |
| 144 | 144 | ||
| 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 | + | ||
| 150 | 180 | ||
| 151 | 181 | ## Local llama.cpp router | |
| 152 | 182 | ||
M docs/subagents.md +110 -148
| @@ -1,151 +1,127 @@ | |||
| 1 | - | # Named subagent profiles | |
| 1 | + | # Resident subagents | |
| 2 | 2 | ||
| 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. | |
| 4 | 6 | ||
| 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. | |
| 6 | 11 | ||
| 7 | - | The default directory is: | |
| 12 | + | ## Profiles | |
| 8 | 13 | ||
| 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. | |
| 21 | 17 | ||
| 22 | 18 | ```markdown | |
| 23 | 19 | --- | |
| 24 | 20 | name: reviewer | |
| 25 | 21 | description: Read-only code reviewer | |
| 26 | - | tools: read | |
| 22 | + | tools: [read, grep] | |
| 27 | 23 | model: openai-codex/gpt-5.6-luna | |
| 28 | - | thinking: max | |
| 24 | + | thinking: high | |
| 29 | 25 | systemPromptMode: replace | |
| 30 | 26 | inheritProjectContext: false | |
| 31 | 27 | inheritSkills: false | |
| 32 | 28 | fastMode: false | |
| 33 | 29 | --- | |
| 34 | 30 | ||
| 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. | |
| 110 | 32 | ``` | |
| 111 | 33 | ||
| 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. | |
| 113 | 42 | ||
| 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. | |
| 115 | 46 | ||
| 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 | |
| 126 | 48 | ||
| 127 | - | The interactive command also supports the same selection explicitly: | |
| 49 | + | The manager records each accepted child under its managed state root: | |
| 128 | 50 | ||
| 129 | 51 | ```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 | |
| 132 | 57 | ``` | |
| 133 | 58 | ||
| 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: | |
| 149 | 125 | ||
| 150 | 126 | ```text | |
| 151 | 127 | subagent://<id> | |
| @@ -154,28 +130,14 @@ subagent://<id>/result | |||
| 154 | 130 | subagent://<id>/patch | |
| 155 | 131 | ``` | |
| 156 | 132 | ||
| 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 ( | |||
| 5 | 5 | "os" | |
| 6 | 6 | "strconv" | |
| 7 | 7 | "strings" | |
| 8 | - | "time" | |
| 9 | 8 | ||
| 10 | 9 | "github.com/bnema/zut/packages/agent/subagents" | |
| 11 | 10 | "github.com/bnema/zut/packages/agent/tools" | |
| @@ -23,13 +22,6 @@ const ( | |||
| 23 | 22 | ModeStream Mode = "stream" | |
| 24 | 23 | ModeJSON Mode = "json" | |
| 25 | 24 | 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" | |
| 33 | 25 | ) | |
| 34 | 26 | ||
| 35 | 27 | // Args holds parsed command-line options. | |
| @@ -40,24 +32,15 @@ type Args struct { | |||
| 40 | 32 | Model string | |
| 41 | 33 | APIKey string | |
| 42 | 34 | ||
| 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 | - | ||
| 49 | 35 | BaseURL string // override provider base URL (for tests/self-hosted) | |
| 50 | 36 | SystemPrompt string | |
| 51 | 37 | AppendSystemPrompt []string | |
| 52 | 38 | Reasoning string | |
| 53 | 39 | Temperature *float32 | |
| 54 | 40 | ||
| 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. | |
| 57 | 42 | 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. | |
| 61 | 44 | FastModeSet bool | |
| 62 | 45 | ||
| 63 | 46 | Continue bool | |
| @@ -76,9 +59,8 @@ type Args struct { | |||
| 76 | 59 | // capability allowlist. | |
| 77 | 60 | ToolsSet bool | |
| 78 | 61 | ||
| 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. | |
| 82 | 64 | WebSearchPolicy subagents.WebSearchPolicy | |
| 83 | 65 | MaxSteps int | |
| 84 | 66 | ||
| @@ -142,26 +124,6 @@ type Args struct { | |||
| 142 | 124 | // mode auto-submits it once at startup before InitialInput handling. | |
| 143 | 125 | StartupPre string | |
| 144 | 126 | ||
| 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 | - | ||
| 165 | 127 | // AgentName/AgentDataDir/PermissionSet are populated by `zut run` | |
| 166 | 128 | // for local zutfile agents. They scope sessions and enforce the | |
| 167 | 129 | // manifest's declared file/bash permissions. | |
| @@ -174,7 +136,6 @@ type Args struct { | |||
| 174 | 136 | func ParseArgs(in []string) (Args, error) { | |
| 175 | 137 | a := Args{Mode: ModeInteractive, MaxSteps: 0, WithSkills: true} | |
| 176 | 138 | positional := []string{} | |
| 177 | - | webSearchPolicySet := false | |
| 178 | 139 | ||
| 179 | 140 | want := func(i *int, flag string) (string, error) { | |
| 180 | 141 | *i++ | |
| @@ -189,8 +150,7 @@ func ParseArgs(in []string) (Args, error) { | |||
| 189 | 150 | switch arg { | |
| 190 | 151 | case "--": | |
| 191 | 152 | // 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. | |
| 194 | 154 | positional = append(positional, in[i+1:]...) | |
| 195 | 155 | i = len(in) | |
| 196 | 156 | case "-h", "--help": | |
| @@ -310,12 +270,6 @@ func ParseArgs(in []string) (Args, error) { | |||
| 310 | 270 | } | |
| 311 | 271 | t := float32(f) | |
| 312 | 272 | 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 | |
| 319 | 273 | case "--stats": | |
| 320 | 274 | v, err := want(&i, arg) | |
| 321 | 275 | if err != nil { | |
| @@ -334,35 +288,6 @@ func ParseArgs(in []string) (Args, error) { | |||
| 334 | 288 | return a, err | |
| 335 | 289 | } | |
| 336 | 290 | 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 | - | } | |
| 366 | 291 | case "--tools": | |
| 367 | 292 | v, err := want(&i, arg) | |
| 368 | 293 | if err != nil { | |
| @@ -385,36 +310,6 @@ func ParseArgs(in []string) (Args, error) { | |||
| 385 | 310 | return a, fmt.Errorf("--max-steps must be a positive integer") | |
| 386 | 311 | } | |
| 387 | 312 | 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 | |
| 418 | 313 | default: | |
| 419 | 314 | if strings.HasPrefix(arg, "-") && arg != "-" { | |
| 420 | 315 | return a, fmt.Errorf("unknown flag %q", arg) | |
| @@ -427,9 +322,6 @@ func ParseArgs(in []string) (Args, error) { | |||
| 427 | 322 | a.Prompt = strings.Join(positional, " ") | |
| 428 | 323 | } | |
| 429 | 324 | ||
| 430 | - | if webSearchPolicySet && a.Mode != ModeSubagentWorker { | |
| 431 | - | return a, fmt.Errorf("--web-search-policy requires --subagent-worker") | |
| 432 | - | } | |
| 433 | 325 | if a.StatsPath != "" && a.Mode != ModePrint { | |
| 434 | 326 | return a, fmt.Errorf("--stats requires -p or --print") | |
| 435 | 327 | } | |
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) { | |||
| 424 | 424 | return profile, nil | |
| 425 | 425 | } | |
| 426 | 426 | ||
| 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 | - | ||
| 451 | 427 | // resolveWebSearchPolicy applies capability precedence at the owning resolver | |
| 452 | 428 | // 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 { | |
| 456 | 431 | if args.NoTools || args.PermissionSet != nil { | |
| 457 | 432 | return subagents.WebSearchDeny | |
| 458 | 433 | } | |
| 459 | 434 | if args.ToolsSet && !toolListContains(args.Tools, "web_search") { | |
| 460 | 435 | return subagents.WebSearchDeny | |
| 461 | 436 | } | |
| 462 | - | if profile != nil && subagents.NamedWebSearchPolicy(profile.Tools) != subagents.WebSearchAllow { | |
| 463 | - | return subagents.WebSearchDeny | |
| 464 | - | } | |
| 465 | 437 | ||
| 466 | 438 | switch args.WebSearchPolicy { | |
| 467 | 439 | case subagents.WebSearchDeny: | |
| @@ -474,18 +446,12 @@ func resolveWebSearchPolicy(args Args, cfg Config, cfgErr error, profile *subage | |||
| 474 | 446 | default: | |
| 475 | 447 | return subagents.WebSearchDeny | |
| 476 | 448 | } | |
| 477 | - | if args.Mode == ModeSubagentWorker { | |
| 478 | - | return subagents.WebSearchDeny | |
| 479 | - | } | |
| 480 | 449 | if args.ToolsSet { | |
| 481 | 450 | return subagents.WebSearchAllow | |
| 482 | 451 | } | |
| 483 | 452 | if cfgErr != nil { | |
| 484 | 453 | return subagents.WebSearchDeny | |
| 485 | 454 | } | |
| 486 | - | if profile != nil { | |
| 487 | - | return subagents.WebSearchAllow | |
| 488 | - | } | |
| 489 | 455 | if cfg.WebSearchEnabledForCLI() { | |
| 490 | 456 | return subagents.WebSearchAllow | |
| 491 | 457 | } | |
| @@ -503,8 +469,7 @@ func toolListContains(names []string, target string) bool { | |||
| 503 | 469 | ||
| 504 | 470 | // webSearchAllowedForRegistry is kept separate from the resolver so direct | |
| 505 | 471 | // 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. | |
| 508 | 473 | func webSearchAllowedForRegistry(args Args) bool { | |
| 509 | 474 | if args.NoTools || args.PermissionSet != nil { | |
| 510 | 475 | return false | |
| @@ -518,7 +483,7 @@ func webSearchAllowedForRegistry(args Args) bool { | |||
| 518 | 483 | case subagents.WebSearchDeny: | |
| 519 | 484 | return false | |
| 520 | 485 | default: | |
| 521 | - | return args.Mode != ModeSubagentWorker | |
| 486 | + | return true | |
| 522 | 487 | } | |
| 523 | 488 | } | |
| 524 | 489 | ||
| @@ -536,16 +501,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 536 | 501 | if len(args.Tools) > 0 { | |
| 537 | 502 | args.ToolsSet = true | |
| 538 | 503 | } | |
| 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) | |
| 549 | 505 | args.WebSearchPolicy = webSearchPolicy | |
| 550 | 506 | ||
| 551 | 507 | // User-requested provider (explicit > config > default). | |
| @@ -588,11 +544,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 588 | 544 | accountID string | |
| 589 | 545 | credErr error | |
| 590 | 546 | ) | |
| 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" { | |
| 596 | 548 | cred = firstNonEmpty(args.APIKey, "ollama") | |
| 597 | 549 | method = "apikey" | |
| 598 | 550 | } else { | |
| @@ -807,9 +759,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 807 | 759 | if args.PermissionSet != nil { | |
| 808 | 760 | sandbox.SetPermissions(args.PermissionSet) | |
| 809 | 761 | } | |
| 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)) | |
| 813 | 764 | ||
| 814 | 765 | docsDir, _ := zutdocs.EnsureInstalled(ZutHome()) | |
| 815 | 766 | ||
| @@ -827,7 +778,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 827 | 778 | skillAddendum string | |
| 828 | 779 | skillsEnabled bool | |
| 829 | 780 | ) | |
| 830 | - | if !args.NoSkill && (selectedProfile == nil || selectedProfile.InheritSkills == nil || *selectedProfile.InheritSkills) { | |
| 781 | + | if !args.NoSkill { | |
| 831 | 782 | skillsEnabled = true | |
| 832 | 783 | homeDir, _ := os.UserHomeDir() | |
| 833 | 784 | discovered, _ = skills.Discover(ZutHome(), args.CWD, homeDir, args.WithSkills) | |
| @@ -842,7 +793,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 842 | 793 | summaries := toolSummaries(reg, args) | |
| 843 | 794 | ||
| 844 | 795 | contextFiles := []ContextFile(nil) | |
| 845 | - | if !args.NoContextFiles && (selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext) { | |
| 796 | + | if !args.NoContextFiles { | |
| 846 | 797 | contextFiles = loadAgentsContext(args.CWD, ZutHome()) | |
| 847 | 798 | } | |
| 848 | 799 | append_ := []string(nil) | |
| @@ -859,8 +810,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 859 | 810 | append_ = append(append_, skillAddendum) | |
| 860 | 811 | } | |
| 861 | 812 | interactiveMode := args.Mode == "" || args.Mode == ModeInteractive | |
| 862 | - | primaryInteractive := selectedProfile == nil && interactiveMode | |
| 863 | - | primaryOrchestrator := selectedProfile == nil && args.Orchestrate | |
| 813 | + | primaryInteractive := interactiveMode | |
| 814 | + | primaryOrchestrator := args.Orchestrate | |
| 864 | 815 | if (primaryInteractive || primaryOrchestrator) && autoSubagentsToolAllowed(args) { | |
| 865 | 816 | homeDir, _ := os.UserHomeDir() | |
| 866 | 817 | profiles, _ := subagents.Discover(args.CWD, homeDir) | |
| @@ -885,18 +836,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 885 | 836 | } else if primaryInteractive && autoSubagentsAnyToolAllowed(args) { | |
| 886 | 837 | append_ = append(append_, OnDemandSubagentsSystemAddendum) | |
| 887 | 838 | } | |
| 888 | - | if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" { | |
| 889 | - | append_ = append(append_, selectedProfile.SystemPrompt) | |
| 890 | - | } | |
| 891 | - | ||
| 892 | 839 | // Custom system prompt resolution order: | |
| 893 | 840 | // 1. --system-prompt flag (highest priority; ad-hoc per run) | |
| 894 | 841 | // 2. $ZUT_HOME/SYSTEM.md (persistent user override) | |
| 895 | 842 | // 3. built-in default (defaultIdentity + defaultGuidelines) | |
| 896 | 843 | custom := args.SystemPrompt | |
| 897 | - | if selectedProfile != nil && selectedProfile.SystemPromptMode == "replace" { | |
| 898 | - | custom = selectedProfile.SystemPrompt | |
| 899 | - | } else if custom == "" { | |
| 844 | + | if custom == "" { | |
| 900 | 845 | custom = readUserSystemPrompt(ZutHome()) | |
| 901 | 846 | } | |
| 902 | 847 | ||
| @@ -917,13 +862,6 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 917 | 862 | if args.FastModeSet { | |
| 918 | 863 | fastMode = args.FastMode | |
| 919 | 864 | } | |
| 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 | - | ||
| 927 | 865 | max := args.MaxSteps // 0 = unlimited | |
| 928 | 866 | ||
| 929 | 867 | 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 | |||
| 584 | 584 | if err != nil { | |
| 585 | 585 | return sessionResumeCandidate{}, joinSessionCloseError(err, sess) | |
| 586 | 586 | } | |
| 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 | + | } | |
| 587 | 592 | if !candidate.rebuilt { | |
| 588 | 593 | candidate.agent.SetMessages(candidate.messages) | |
| 589 | 594 | candidate.agent.SeedCost(candidate.cumulative) | |
| @@ -793,9 +798,6 @@ func Run(rawArgs []string, version string) error { | |||
| 793 | 798 | if handled, err := runZutfileCommand(rawArgs, version); handled { | |
| 794 | 799 | return err | |
| 795 | 800 | } | |
| 796 | - | if handled, err := runDebugCommand(rawArgs, os.Stdout); handled { | |
| 797 | - | return err | |
| 798 | - | } | |
| 799 | 801 | return runWithArgsRaw(rawArgs, version) | |
| 800 | 802 | } | |
| 801 | 803 | ||
| @@ -854,8 +856,6 @@ func runWithArgs(args Args, version string) error { | |||
| 854 | 856 | return runJSONMode(ctx, args, version) | |
| 855 | 857 | case ModeRPC: | |
| 856 | 858 | return runRPCMode(ctx, args, version) | |
| 857 | - | case ModeSubagentWorker: | |
| 858 | - | return runSubagentWorkerMode(ctx, args, version) | |
| 859 | 859 | default: | |
| 860 | 860 | return runInteractive(ctx, args, version) | |
| 861 | 861 | } | |
| @@ -1481,28 +1481,14 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1481 | 1481 | // Keep the shared subagent runtime in the outer scope: session reloads, | |
| 1482 | 1482 | // dashboard updates, agent rebuilds, and shutdown all use the same owner. | |
| 1483 | 1483 | 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) { | |
| 1488 | 1485 | if iv != nil { | |
| 1489 | - | iv.TrackSubagentWorker(a, task, required) | |
| 1486 | + | iv.TrackResidentSubagent(spec.ID) | |
| 1490 | 1487 | } | |
| 1491 | 1488 | } | |
| 1492 | - | onResumedSupervisor := func(a *subagents.Agent, prompt string, required bool) { | |
| 1489 | + | onResidentCompletion := func(completion subagents.ResidentCompletion) { | |
| 1493 | 1490 | 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) | |
| 1506 | 1492 | } | |
| 1507 | 1493 | } | |
| 1508 | 1494 | activeProviderForSubagents := func() string { | |
| @@ -1517,46 +1503,37 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1517 | 1503 | } | |
| 1518 | 1504 | ||
| 1519 | 1505 | 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, | |
| 1540 | 1522 | }) | |
| 1541 | - | subagentsMgr = runtime.Supervisor() | |
| 1542 | 1523 | reloadDone := make(chan struct{}) | |
| 1543 | 1524 | 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. | |
| 1550 | 1529 | go func() { | |
| 1551 | - | _, reloadErrs = subagentsMgr.Reload() | |
| 1530 | + | reloadErrs = resident.Reconcile() | |
| 1552 | 1531 | close(reloadDone) | |
| 1553 | 1532 | }() | |
| 1554 | 1533 | } else { | |
| 1555 | 1534 | close(reloadDone) | |
| 1556 | 1535 | } | |
| 1557 | 1536 | defer func() { | |
| 1558 | - | // Reload may attach detached workers, so let it finish before shutdown | |
| 1559 | - | // snapshots the supervisor's worker set. | |
| 1560 | 1537 | <-reloadDone | |
| 1561 | 1538 | _ = closeSubagentRuntimeFresh(runtime) | |
| 1562 | 1539 | }() | |
| @@ -2057,13 +2034,12 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2057 | 2034 | } | |
| 2058 | 2035 | activeProvider = candidate.provider | |
| 2059 | 2036 | 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) | |
| 2063 | 2041 | if iv != nil { | |
| 2064 | 2042 | iv.SetSubagentSessionScope(candidate.session.ID) | |
| 2065 | - | } else { | |
| 2066 | - | runtime.SetActiveSession(candidate.session.ID) | |
| 2067 | 2043 | } | |
| 2068 | 2044 | } | |
| 2069 | 2045 | committed = true | |
| @@ -2257,10 +2233,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2257 | 2233 | ||
| 2258 | 2234 | // Re-scope the subagent dashboard to the new session. | |
| 2259 | 2235 | if newSess != nil { | |
| 2236 | + | runtime.SetActiveSession(newSess.ID) | |
| 2260 | 2237 | if iv != nil { | |
| 2261 | 2238 | iv.SetSubagentSessionScope(newSess.ID) | |
| 2262 | - | } else { | |
| 2263 | - | runtime.SetActiveSession(newSess.ID) | |
| 2264 | 2239 | } | |
| 2265 | 2240 | } | |
| 2266 | 2241 | if newSess != nil { | |
| @@ -2340,7 +2315,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2340 | 2315 | } | |
| 2341 | 2316 | ||
| 2342 | 2317 | // 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. | |
| 2344 | 2319 | if sess != nil { | |
| 2345 | 2320 | runtime.SetActiveSession(sess.ID) | |
| 2346 | 2321 | } | |
| @@ -2360,9 +2335,6 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2360 | 2335 | webSearchInvocationOverride := webSearchExplicitlyEnabledForSession(args) | |
| 2361 | 2336 | setWebSearchAvailable := func(available bool) { | |
| 2362 | 2337 | webSearchGuard.setAvailable(available) | |
| 2363 | - | if subagentsMgr == nil { | |
| 2364 | - | return | |
| 2365 | - | } | |
| 2366 | 2338 | policy := subagents.WebSearchDeny | |
| 2367 | 2339 | if available { | |
| 2368 | 2340 | policy = subagents.WebSearchAllow | |
| @@ -2397,7 +2369,6 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2397 | 2369 | TUIInputStyle: initialCfg.TUIInputStyle, | |
| 2398 | 2370 | TUIStatusPosition: initialCfg.TUIStatusPosition, | |
| 2399 | 2371 | TUIWorkingPosition: initialCfg.TUIWorkingPosition, | |
| 2400 | - | TUISubagentPosition: initialCfg.TUISubagentPosition, | |
| 2401 | 2372 | ThemeName: initialCfg.Theme, | |
| 2402 | 2373 | FlatTools: initialCfg.FlatToolRender(), | |
| 2403 | 2374 | CompactUser: initialCfg.CompactUserInput(), | |
| @@ -2552,9 +2523,34 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2552 | 2523 | }, | |
| 2553 | 2524 | SessionTransition: newSessionTransition(&sessionTransitionMu), | |
| 2554 | 2525 | 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, | |
| 2558 | 2554 | OnChangelogDismiss: func() { | |
| 2559 | 2555 | // For development builds, store the actual release version | |
| 2560 | 2556 | // so the same changelog doesn't show again next launch. | |
| @@ -2754,6 +2750,9 @@ func agentSessionsRoot(root string, args Args) string { | |||
| 2754 | 2750 | // with a nil error if session persistence is disabled. | |
| 2755 | 2751 | func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) { | |
| 2756 | 2752 | 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() | |
| 2757 | 2756 | return nil, nil | |
| 2758 | 2757 | } | |
| 2759 | 2758 | // 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 | |||
| 2772 | 2771 | switch { | |
| 2773 | 2772 | case args.Session != "": | |
| 2774 | 2773 | 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. | |
| 2783 | 2776 | if err != nil && errors.Is(err, os.ErrNotExist) { | |
| 2784 | 2777 | s, err = core.NewSessionAtPath(args.Session, args.CWD, r.Provider, r.Model, version) | |
| 2785 | 2778 | msgs = nil | |
| @@ -2813,6 +2806,9 @@ func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Ag | |||
| 2813 | 2806 | return nil, err | |
| 2814 | 2807 | } | |
| 2815 | 2808 | if s != nil { | |
| 2809 | + | if err := ag.BindSessionID(s.Meta.ID); err != nil { | |
| 2810 | + | return nil, err | |
| 2811 | + | } | |
| 2816 | 2812 | ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset) | |
| 2817 | 2813 | ag.SetMessages(msgs) | |
| 2818 | 2814 | 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 | |||
| 2823 | 2819 | } | |
| 2824 | 2820 | s, err = core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version) | |
| 2825 | 2821 | if err == nil { | |
| 2822 | + | if bindErr := ag.BindSessionID(s.Meta.ID); bindErr != nil { | |
| 2823 | + | return nil, bindErr | |
| 2824 | + | } | |
| 2826 | 2825 | ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset) | |
| 2827 | 2826 | } | |
| 2828 | 2827 | return s, err | |
M packages/agent/cli_session_test.go +24 -1
| @@ -660,7 +660,6 @@ func TestApplyInitialSessionResumeWithRuntimePreservesInheritedModelDefaults(t * | |||
| 660 | 660 | } | |
| 661 | 661 | current := base.NewAgent() | |
| 662 | 662 | runtime := newSubagentRuntime(subagentRuntimeConfig{ | |
| 663 | - | Context: context.Background(), | |
| 664 | 663 | Args: args, | |
| 665 | 664 | Root: t.TempDir(), | |
| 666 | 665 | RepoRoot: args.CWD, | |
| @@ -727,6 +726,30 @@ func TestPrepareSessionResumeRebuildsForStoredProviderAndModel(t *testing.T) { | |||
| 727 | 726 | } | |
| 728 | 727 | } | |
| 729 | 728 | ||
| 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 | + | ||
| 730 | 753 | func TestPrepareSessionResumePreservesLegacyMissingMetadata(t *testing.T) { | |
| 731 | 754 | old := core.NewAgent(nil, "old-model", "", nil) | |
| 732 | 755 | old.SetMessages([]provider.Message{{ | |
M packages/agent/config.go +10 -32
| @@ -22,26 +22,15 @@ type QuickModelShortcut struct { | |||
| 22 | 22 | Model string `json:"model"` | |
| 23 | 23 | } | |
| 24 | 24 | ||
| 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. | |
| 27 | 29 | 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"` | |
| 45 | 34 | } | |
| 46 | 35 | ||
| 47 | 36 | // Config is the persisted user configuration. | |
| @@ -112,8 +101,8 @@ type Config struct { | |||
| 112 | 101 | // /settings. | |
| 113 | 102 | LSPEnabled *bool `json:"lsp_enabled,omitempty"` | |
| 114 | 103 | ||
| 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. | |
| 117 | 106 | SubagentLSPEnabled *bool `json:"subagent_lsp_enabled,omitempty"` | |
| 118 | 107 | ||
| 119 | 108 | // LSPDiagnosticsOnWrite enables bounded diagnostics after a successful | |
| @@ -172,11 +161,6 @@ type Config struct { | |||
| 172 | 161 | // (default) and "below_input". | |
| 173 | 162 | TUIWorkingPosition string `json:"tui_working_position,omitempty"` | |
| 174 | 163 | ||
| 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 | - | ||
| 180 | 164 | // Insecure skips TLS verification for custom inference endpoints. | |
| 181 | 165 | Insecure bool `json:"insecure,omitempty"` | |
| 182 | 166 | ||
| @@ -332,12 +316,6 @@ func validateSubagentDurations(cfg SubagentsConfig) error { | |||
| 332 | 316 | value string | |
| 333 | 317 | }{ | |
| 334 | 318 | {"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}, | |
| 341 | 319 | } | |
| 342 | 320 | for _, item := range values { | |
| 343 | 321 | value := strings.TrimSpace(item.value) | |
M packages/agent/fast_mode_test.go +7 -16
| @@ -9,16 +9,12 @@ import ( | |||
| 9 | 9 | "github.com/bnema/zut/packages/provider" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 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") | |
| 19 | 15 | } | |
| 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") | |
| 22 | 18 | } | |
| 23 | 19 | } | |
| 24 | 20 | ||
| @@ -84,18 +80,13 @@ func TestResolveFastModeRequiresExplicitChildOverride(t *testing.T) { | |||
| 84 | 80 | } | |
| 85 | 81 | } | |
| 86 | 82 | ||
| 87 | - | func TestResolveFastModeExplicitChildDisableOverridesConfig(t *testing.T) { | |
| 83 | + | func TestResolveFastModeDurableChildDisableOverridesConfig(t *testing.T) { | |
| 88 | 84 | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 89 | 85 | enabled := true | |
| 90 | 86 | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", FastMode: &enabled}); err != nil { | |
| 91 | 87 | t.Fatal(err) | |
| 92 | 88 | } | |
| 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) | |
| 99 | 90 | if err != nil { | |
| 100 | 91 | t.Fatalf("Resolve failed: %v", err) | |
| 101 | 92 | } | |
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 { | |||
| 79 | 79 | i.llamaDialog != nil && i.llamaDialog.Active() || | |
| 80 | 80 | i.rescueDialog != nil && i.rescueDialog.Active() || | |
| 81 | 81 | i.sessionDialog != nil && i.sessionDialog.Active() || | |
| 82 | - | i.subagentsDialog != nil && i.subagentsDialog.Active() || | |
| 82 | + | i.residentSubagentsDialog != nil && i.residentSubagentsDialog.Active() || | |
| 83 | + | i.residentChildSession != nil || | |
| 83 | 84 | i.jumpDialog != nil && i.jumpDialog.Active() || | |
| 84 | 85 | i.btwDialog != nil && i.btwDialog.Active() || | |
| 85 | 86 | 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 { | |||
| 45 | 45 | return !i.busy && i.agent != nil && len(i.queued) == 0 && i.agent.QueuedMessageCount() == 0 | |
| 46 | 46 | } | |
| 47 | 47 | ||
| 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 | - | ||
| 61 | 48 | func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool { | |
| 62 | 49 | message, ok := i.goalContinuationMessage() | |
| 63 | 50 | 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 © | |
| 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 { | |||
| 169 | 169 | // above or below the main input. | |
| 170 | 170 | TUIWorkingPosition string | |
| 171 | 171 | ||
| 172 | - | // TUISubagentPosition controls whether live subagent activity renders | |
| 173 | - | // above or below the main input. Empty defaults below the input. | |
| 174 | - | TUISubagentPosition string | |
| 175 | - | ||
| 176 | 172 | // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The | |
| 177 | 173 | // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal | |
| 178 | 174 | // forwards Command/Super keypresses, but Ctrl is the displayed chord. | |
| @@ -229,7 +225,7 @@ type InteractiveConfig struct { | |||
| 229 | 225 | // ceilings after a live registry commit or fail-closed revocation. It can | |
| 230 | 226 | // run while agentMu and i.mu are held, so callbacks must not re-enter | |
| 231 | 227 | // 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. | |
| 233 | 229 | SetWebSearchAvailable func(bool) | |
| 234 | 230 | ||
| 235 | 231 | // RefreshPrompt re-resolves the complete live prompt and tool registry | |
| @@ -390,12 +386,14 @@ type InteractiveConfig struct { | |||
| 390 | 386 | // AFTER the built-in catalog so a built-in name always wins. | |
| 391 | 387 | Extensions *extensions.Manager | |
| 392 | 388 | ||
| 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) | |
| 399 | 397 | ||
| 400 | 398 | // ResolveSubagent validates a named markdown profile for direct | |
| 401 | 399 | // /subagents commands. Auto-subagents uses the equivalent callback on its | |
| @@ -495,7 +493,6 @@ type SettingsStore interface { | |||
| 495 | 493 | SetTUIInputStyle(style string) error | |
| 496 | 494 | SetTUIStatusPosition(position string) error | |
| 497 | 495 | SetTUIWorkingPosition(position string) error | |
| 498 | - | SetTUISubagentPosition(position string) error | |
| 499 | 496 | SetReasoning(level string) error | |
| 500 | 497 | SetTheme(name string) error | |
| 501 | 498 | } | |
| @@ -672,33 +669,32 @@ type Interactive struct { | |||
| 672 | 669 | // it. We snapshot the total expected stream length (already | |
| 673 | 670 | // streamed + still pending) at the moment the tool starts, and | |
| 674 | 671 | // 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 | |
| 702 | 698 | ||
| 703 | 699 | // prevChatLen and prevChatCols track the chat buffer's size at the | |
| 704 | 700 | // last redraw so that when content grows below the user's viewport | |
| @@ -771,28 +767,30 @@ type Interactive struct { | |||
| 771 | 767 | // while the check hasn't completed or nothing is available. | |
| 772 | 768 | updateInfo UpdateInfo | |
| 773 | 769 | ||
| 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. | |
| 796 | 794 | // turnCoordinator seals worker waves at manager-turn boundaries and decides | |
| 797 | 795 | // the next wake without relying on timing or polling. | |
| 798 | 796 | completionTracker *subagents.CompletionTracker | |
| @@ -804,18 +802,15 @@ type Interactive struct { | |||
| 804 | 802 | completionDeliveryRequest bool | |
| 805 | 803 | completionDeliveryHolds int | |
| 806 | 804 | ||
| 807 | - | // subagentsWaitWatcherDone is an optional test hook invoked when a | |
| 808 | - | // /subagents wait watcher exits. | |
| 809 | - | subagentsWaitWatcherDone func() | |
| 810 | - | ||
| 811 | 805 | // pendingFork is true when the user ran /session fork: the next | |
| 812 | 806 | // jump-picker selection should branch off that message instead | |
| 813 | 807 | // of scrolling. Flag resets after the action fires or the dialog | |
| 814 | 808 | // 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 | |
| 819 | 814 | ||
| 820 | 815 | // parkedTurn is the 1-based turn number the viewport is currently | |
| 821 | 816 | // scrolled to by /jump. 0 = not parked, showing the tail as usual. | |
| @@ -966,40 +961,40 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 966 | 961 | // Prompt is the standard half-block accent bar used by chat | |
| 967 | 962 | // speaker labels too, so the input gutter matches the rest | |
| 968 | 963 | // 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), | |
| 1003 | 998 | } | |
| 1004 | 999 | i.btwDialog.setCloseHook(func() { | |
| 1005 | 1000 | i.confirmDialog.CancelChildConfirmations("side chat closed") | |
| @@ -1011,6 +1006,18 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 1011 | 1006 | i.llamaConfigured = err == nil && baseURL != "" | |
| 1012 | 1007 | } | |
| 1013 | 1008 | 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 | + | } | |
| 1014 | 1021 | if cfg.Agent != nil { | |
| 1015 | 1022 | i.agent = cfg.Agent | |
| 1016 | 1023 | i.view.Messages = filterHiddenTranscriptMessages(cfg.Agent.Messages()) | |
| @@ -1359,20 +1366,11 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1359 | 1366 | // A session picker that is still loading is an exception so | |
| 1360 | 1367 | // its dialog-local spinner can advance. | |
| 1361 | 1368 | // | |
| 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. | |
| 1370 | 1369 | i.mu.Lock() | |
| 1371 | 1370 | busy := i.busy | |
| 1372 | - | subagentActivityActive := i.subagentActivityActive | |
| 1373 | 1371 | sessionLoading := i.sessionDialog.Loading() | |
| 1374 | 1372 | i.mu.Unlock() | |
| 1375 | - | if busy || subagentActivityActive || sessionLoading || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() { | |
| 1373 | + | if busy || sessionLoading || i.btwDialog.Loading() || i.residentAnimating.Load() { | |
| 1376 | 1374 | requestRedraw() | |
| 1377 | 1375 | } | |
| 1378 | 1376 | } | |
| @@ -1966,17 +1964,12 @@ func (i *Interactive) redraw() { | |||
| 1966 | 1964 | } | |
| 1967 | 1965 | i.sessionDialog.MaxRows = avail | |
| 1968 | 1966 | 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) | |
| 1980 | 1973 | case i.jumpDialog.Active(): | |
| 1981 | 1974 | dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols) | |
| 1982 | 1975 | case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()): | |
| @@ -2151,19 +2144,14 @@ func (i *Interactive) redraw() { | |||
| 2151 | 2144 | i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent) | |
| 2152 | 2145 | } | |
| 2153 | 2146 | 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) | |
| 2167 | 2155 | } | |
| 2168 | 2156 | var workingLines []string | |
| 2169 | 2157 | if busyPrefix != "" && !workingWithStatus { | |
| @@ -2212,21 +2200,14 @@ func (i *Interactive) redraw() { | |||
| 2212 | 2200 | // content. The status block and editor get their own dedicated | |
| 2213 | 2201 | // blanks so spacing stays consistent whether or not a dialog or | |
| 2214 | 2202 | // 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) | |
| 2217 | 2205 | inputStartRow = -1 | |
| 2218 | 2206 | if len(dialog) > 0 { | |
| 2219 | 2207 | bottom = append(bottom, "") | |
| 2220 | 2208 | } | |
| 2221 | 2209 | 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. | |
| 2230 | 2211 | if dashboardActive { | |
| 2231 | 2212 | return bottom, inputStartRow | |
| 2232 | 2213 | } | |
| @@ -2236,8 +2217,6 @@ func (i *Interactive) redraw() { | |||
| 2236 | 2217 | lineInput := inputStyle == tui.InputStyleLines | |
| 2237 | 2218 | statusBelow := statusPosition == tui.StatusPositionBelowInput | |
| 2238 | 2219 | workingBelow := workingPosition == tui.WorkingPositionBelowInput | |
| 2239 | - | subagentBelow := tui.NormalizeSubagentPosition(i.cfg.TUISubagentPosition) == tui.SubagentPositionBelowInput | |
| 2240 | - | ||
| 2241 | 2220 | var aboveInput []string | |
| 2242 | 2221 | aboveInput = append(aboveInput, extensionLines...) | |
| 2243 | 2222 | if !statusBelow { | |
| @@ -2246,13 +2225,10 @@ func (i *Interactive) redraw() { | |||
| 2246 | 2225 | if !workingBelow { | |
| 2247 | 2226 | aboveInput = append(aboveInput, workingLines...) | |
| 2248 | 2227 | } | |
| 2249 | - | if !subagentBelow { | |
| 2250 | - | aboveInput = append(aboveInput, subagentLines...) | |
| 2251 | - | } | |
| 2252 | 2228 | 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...) | |
| 2256 | 2232 | if workingBelow { | |
| 2257 | 2233 | belowInput = append(belowInput, workingLines...) | |
| 2258 | 2234 | } | |
| @@ -2272,9 +2248,9 @@ func (i *Interactive) redraw() { | |||
| 2272 | 2248 | inputStartRow = len(bottom) | |
| 2273 | 2249 | bottom = append(bottom, edLines...) | |
| 2274 | 2250 | 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 { | |
| 2278 | 2254 | bottom = append(bottom, "") | |
| 2279 | 2255 | } | |
| 2280 | 2256 | bottom = append(bottom, belowInput...) | |
| @@ -2282,26 +2258,22 @@ func (i *Interactive) redraw() { | |||
| 2282 | 2258 | return bottom, inputStartRow | |
| 2283 | 2259 | } | |
| 2284 | 2260 | ||
| 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. | |
| 2288 | 2261 | const rendererBottomMarginRows = 1 | |
| 2289 | 2262 | 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) | |
| 2296 | 2270 | if len(candidateBottom) <= maxBottomRows || maxRows == 0 { | |
| 2297 | - | subagentLines = candidate | |
| 2298 | - | bottom = candidateBottom | |
| 2299 | - | inputStartRow = candidateInputStartRow | |
| 2271 | + | residentSubagentLines = candidate | |
| 2300 | 2272 | break | |
| 2301 | 2273 | } | |
| 2302 | 2274 | } | |
| 2303 | 2275 | } | |
| 2304 | - | i.subagentActivityActive = !dashboardActive && len(subagentLines) > 0 | |
| 2276 | + | bottom, inputStartRow = composeBottom(residentSubagentLines) | |
| 2305 | 2277 | ||
| 2306 | 2278 | chatRows := rows - len(bottom) | |
| 2307 | 2279 | if chatRows < 1 { | |
| @@ -2508,20 +2480,6 @@ func (i *Interactive) redraw() { | |||
| 2508 | 2480 | cursorInDialog = true | |
| 2509 | 2481 | } | |
| 2510 | 2482 | } | |
| 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 | - | } | |
| 2525 | 2483 | if i.extPanel.Active() { | |
| 2526 | 2484 | cursorRow = -1 | |
| 2527 | 2485 | cursorCol = 0 | |
| @@ -2834,7 +2792,8 @@ func (i *Interactive) confirmChildActiveLocked() bool { | |||
| 2834 | 2792 | i.llamaDialog.Active() || | |
| 2835 | 2793 | i.rescueDialog.Active() || | |
| 2836 | 2794 | i.sessionDialog.Active() || | |
| 2837 | - | i.subagentsDialog.Active() || | |
| 2795 | + | i.residentSubagentsDialog.Active() || | |
| 2796 | + | i.residentChildSession != nil || | |
| 2838 | 2797 | i.jumpDialog.Active() || | |
| 2839 | 2798 | i.btwDialog.Active() || | |
| 2840 | 2799 | i.skillsDialog.Active() || | |
| @@ -3059,18 +3018,64 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 3059 | 3018 | i.invalidate() | |
| 3060 | 3019 | return false | |
| 3061 | 3020 | } | |
| 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() | |
| 3065 | 3033 | i.invalidate() | |
| 3066 | 3034 | return false | |
| 3067 | 3035 | } | |
| 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: | |
| 3070 | 3048 | i.mu.Lock() | |
| 3071 | - | i.statusOK = msg | |
| 3072 | - | i.statusErr = errMsg | |
| 3049 | + | if i.residentChildSession == residentSession { | |
| 3050 | + | i.residentChildSession = nil | |
| 3051 | + | } | |
| 3073 | 3052 | 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 | + | } | |
| 3074 | 3079 | } | |
| 3075 | 3080 | i.invalidate() | |
| 3076 | 3081 | return false | |
| @@ -3440,6 +3445,12 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 3440 | 3445 | i.invalidate() | |
| 3441 | 3446 | return false | |
| 3442 | 3447 | } | |
| 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 | + | } | |
| 3443 | 3454 | if !k.Alt && i.handleInputHistoryKey(k) { | |
| 3444 | 3455 | return false | |
| 3445 | 3456 | } | |
| @@ -4063,15 +4074,9 @@ func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, provid | |||
| 4063 | 4074 | i.invalidate() | |
| 4064 | 4075 | } | |
| 4065 | 4076 | ||
| 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) { | |
| 4075 | 4080 | i.invalidate() | |
| 4076 | 4081 | } | |
| 4077 | 4082 | ||
| @@ -4582,7 +4587,6 @@ func (i *Interactive) openSettingsDialog() { | |||
| 4582 | 4587 | inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) | |
| 4583 | 4588 | statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition) | |
| 4584 | 4589 | workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition) | |
| 4585 | - | subagentPosition := tui.NormalizeSubagentPosition(i.cfg.TUISubagentPosition) | |
| 4586 | 4590 | quickItems := i.quickModelSettingItems() | |
| 4587 | 4591 | ||
| 4588 | 4592 | autoCompactOptions := []settingsOption{ | |
| @@ -4665,18 +4669,6 @@ func (i *Interactive) openSettingsDialog() { | |||
| 4665 | 4669 | } | |
| 4666 | 4670 | } | |
| 4667 | 4671 | ||
| 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 | - | ||
| 4680 | 4672 | items := []settingsItem{ | |
| 4681 | 4673 | { | |
| 4682 | 4674 | key: "inline_images_enabled", | |
| @@ -4804,13 +4796,6 @@ func (i *Interactive) openSettingsDialog() { | |||
| 4804 | 4796 | options: inputStyleOptions, | |
| 4805 | 4797 | choice: inputStyleChoice, | |
| 4806 | 4798 | }, | |
| 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 | - | }, | |
| 4814 | 4799 | }, | |
| 4815 | 4800 | }, | |
| 4816 | 4801 | reasoningItem, | |
| @@ -4849,8 +4834,6 @@ func (i *Interactive) applySettingChange(act settingsAction) { | |||
| 4849 | 4834 | i.applyTUIStatusPositionSetting(act.StringValue) | |
| 4850 | 4835 | case act.Key == "tui_working_position": | |
| 4851 | 4836 | i.applyTUIWorkingPositionSetting(act.StringValue) | |
| 4852 | - | case act.Key == "tui_subagent_position": | |
| 4853 | - | i.applyTUISubagentPositionSetting(act.StringValue) | |
| 4854 | 4837 | default: | |
| 4855 | 4838 | i.applySettingToggle(act.Key, act.Value) | |
| 4856 | 4839 | } | |
| @@ -5377,9 +5360,6 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 5377 | 5360 | if i.agent != nil { | |
| 5378 | 5361 | i.agent.SetFastMode(value) | |
| 5379 | 5362 | } | |
| 5380 | - | if i.cfg.Supervisor != nil { | |
| 5381 | - | i.cfg.Supervisor.SetFastMode(value) | |
| 5382 | - | } | |
| 5383 | 5363 | i.mu.Lock() | |
| 5384 | 5364 | i.statusOK = "fast mode " + onOff(value) | |
| 5385 | 5365 | i.statusErr = "" | |
| @@ -5619,31 +5599,6 @@ func (i *Interactive) applyTUIWorkingPositionSetting(position string) { | |||
| 5619 | 5599 | i.mu.Unlock() | |
| 5620 | 5600 | } | |
| 5621 | 5601 | ||
| 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 | - | ||
| 5647 | 5602 | func (i *Interactive) applyThemeSetting(name string) { | |
| 5648 | 5603 | if i.cfg.SettingsStore != nil { | |
| 5649 | 5604 | if err := i.cfg.SettingsStore.SetTheme(name); err != nil { | |
| @@ -5794,7 +5749,7 @@ func buildStudyPrompt(arg, cwd string) string { | |||
| 5794 | 5749 | // keeps Tab as a literal no-op. | |
| 5795 | 5750 | // | |
| 5796 | 5751 | // 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 | |
| 5798 | 5753 | // dialog needing its own copy. | |
| 5799 | 5754 | func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool { | |
| 5800 | 5755 | if ed == nil { | |
| @@ -8827,56 +8782,60 @@ func (a telegramSenderAdapter) Active() bool { | |||
| 8827 | 8782 | return a.bridge != nil && a.bridge.Active() | |
| 8828 | 8783 | } | |
| 8829 | 8784 | ||
| 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 { | |
| 8856 | 8789 | return | |
| 8857 | 8790 | } | |
| 8858 | - | tracker := i.ensureCompletionTracker() | |
| 8859 | - | i.registerCoordinatorWorker(a.ID) | |
| 8860 | - | tracker.TrackExit(a, a.Task) | |
| 8791 | + | i.ensureCompletionTracker().TrackResident() | |
| 8792 | + | i.registerCoordinatorWorker(childID) | |
| 8861 | 8793 | i.invalidate() | |
| 8862 | 8794 | i.requestCompletionDelivery() | |
| 8863 | 8795 | } | |
| 8864 | 8796 | ||
| 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 | |
| 8868 | 8802 | } | |
| 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() | |
| 8876 | 8806 | } | |
| 8807 | + | i.ensureCompletionTracker().Report(subagents.Completion{AgentID: completion.ChildID, Status: status, Task: completion.Task, Error: errText, Summary: completion.Summary}) | |
| 8808 | + | i.reloadOpenResidentChildSession(completion.ChildID) | |
| 8877 | 8809 | i.invalidate() | |
| 8878 | 8810 | 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 | + | }() | |
| 8880 | 8839 | } | |
| 8881 | 8840 | ||
| 8882 | 8841 | func (i *Interactive) ensureCompletionTracker() *subagents.CompletionTracker { | |
| @@ -8962,9 +8921,8 @@ func (i *Interactive) cancelCoordinator() { | |||
| 8962 | 8921 | i.completionDeliveryMu.Lock() | |
| 8963 | 8922 | coordinator := i.ensureCoordinatorLocked() | |
| 8964 | 8923 | 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. | |
| 8968 | 8926 | if i.completionTracker != nil { | |
| 8969 | 8927 | i.completionTracker.Reset() | |
| 8970 | 8928 | } | |
| @@ -9136,13 +9094,13 @@ func (i *Interactive) deliverCompletionUpdates() { | |||
| 9136 | 9094 | // mode whenever the primary agent can spawn a worker. | |
| 9137 | 9095 | func autoSubagentsAddenda(cfg InteractiveConfig, orchestrating bool) []string { | |
| 9138 | 9096 | addenda := make([]string, 0, 2) | |
| 9139 | - | if cfg.Supervisor != nil && autoSubagentsToolAllowedConfig(cfg) { | |
| 9097 | + | if cfg.ResidentManager != nil && autoSubagentsToolAllowedConfig(cfg) { | |
| 9140 | 9098 | if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" { | |
| 9141 | 9099 | addenda = append(addenda, addendum) | |
| 9142 | 9100 | } | |
| 9143 | 9101 | } | |
| 9144 | 9102 | if !orchestrating { | |
| 9145 | - | if cfg.Supervisor != nil && autoSubagentsAnyToolAllowedConfig(cfg) { | |
| 9103 | + | if cfg.ResidentManager != nil && autoSubagentsAnyToolAllowedConfig(cfg) { | |
| 9146 | 9104 | if addendum := strings.TrimSpace(cfg.OnDemandSubagentsSystemAddendum); addendum != "" { | |
| 9147 | 9105 | addenda = append(addenda, addendum) | |
| 9148 | 9106 | } | |
| @@ -9225,13 +9183,13 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(orchestrating bool) { | |||
| 9225 | 9183 | // Mirrors applyTelegramTools' snapshot+mutate pattern so extension tools and | |
| 9226 | 9184 | // /reload-ext additions survive a settings change. | |
| 9227 | 9185 | func (i *Interactive) autoSubagentsAvailable() bool { | |
| 9228 | - | return i.cfg.Supervisor != nil && autoSubagentsAnyToolAllowedConfig(i.cfg) | |
| 9186 | + | return i.cfg.ResidentManager != nil && autoSubagentsAnyToolAllowedConfig(i.cfg) | |
| 9229 | 9187 | } | |
| 9230 | 9188 | ||
| 9231 | 9189 | func (i *Interactive) autoSubagentsUnavailableHint() string { | |
| 9232 | 9190 | 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") | |
| 9235 | 9193 | } | |
| 9236 | 9194 | if !autoSubagentsAnyToolAllowedConfig(i.cfg) { | |
| 9237 | 9195 | hints = append(hints, "launch-time tool policy excludes subagent manager tools") | |
| @@ -9312,40 +9270,34 @@ func (i *Interactive) applyAutoSubagentsTool() { | |||
| 9312 | 9270 | if i.autoSubagentsAvailable() { | |
| 9313 | 9271 | if i.autoSubagentsToolAllowed() { | |
| 9314 | 9272 | 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, | |
| 9324 | 9280 | } | |
| 9325 | 9281 | next[canonical.Name()] = canonical | |
| 9326 | 9282 | } | |
| 9327 | 9283 | if i.autoSubagentsStatusToolAllowed() { | |
| 9328 | 9284 | 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 }, | |
| 9331 | 9287 | } | |
| 9332 | 9288 | next[statusTool.Name()] = statusTool | |
| 9333 | 9289 | } | |
| 9334 | 9290 | if i.autoSubagentsStopToolAllowed() { | |
| 9335 | 9291 | stopTool := &tools.SubagentStopTool{ | |
| 9336 | - | Supervisor: i.cfg.Supervisor, | |
| 9292 | + | ResidentManager: i.cfg.ResidentManager, | |
| 9337 | 9293 | Enabled: func() bool { return true }, | |
| 9338 | - | OnStopRequested: i.TrackStoppedSubagentWorker, | |
| 9339 | 9294 | } | |
| 9340 | 9295 | next[stopTool.Name()] = stopTool | |
| 9341 | 9296 | } | |
| 9342 | 9297 | if i.autoSubagentsResumeToolAllowed() { | |
| 9343 | 9298 | 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 }, | |
| 9349 | 9301 | } | |
| 9350 | 9302 | next[resumeTool.Name()] = resumeTool | |
| 9351 | 9303 | } | |
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) { | |||
| 37 | 37 | } | |
| 38 | 38 | } | |
| 39 | 39 | ||
| 40 | - | func TestSlashSuggesterHasSupervisor(t *testing.T) { | |
| 40 | + | func TestSlashSuggesterHasSubagents(t *testing.T) { | |
| 41 | 41 | s := newSlashSuggester() | |
| 42 | 42 | if got := commandNames(s.matches("/su")); !contains(got, "/subagents") { | |
| 43 | 43 | 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 ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/zut/packages/agent/subagents" | |
| 9 | + | "github.com/bnema/zut/packages/agent/tools" | |
| 9 | 10 | "github.com/bnema/zut/packages/provider" | |
| 10 | - | "github.com/bnema/zut/packages/tui" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 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. | |
| 32 | 13 | func (i *Interactive) runSubagents(ctx context.Context, args []string) { | |
| 33 | - | if i.cfg.Supervisor == nil { | |
| 14 | + | if i.cfg.ResidentManager == nil { | |
| 34 | 15 | i.mu.Lock() | |
| 35 | - | i.statusErr = "subagent is disabled in this build" | |
| 16 | + | i.statusErr = i.autoSubagentsUnavailableHint() | |
| 36 | 17 | i.statusOK = "" | |
| 37 | 18 | i.mu.Unlock() | |
| 38 | 19 | return | |
| 39 | 20 | } | |
| 21 | + | i.runResidentSubagents(ctx, args) | |
| 22 | + | } | |
| 40 | 23 | ||
| 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) { | |
| 41 | 28 | sub := "" | |
| 42 | 29 | rest := "" | |
| 43 | 30 | if len(args) > 0 { | |
| 44 | 31 | 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. | |
| 50 | 32 | if len(args) > 1 { | |
| 51 | 33 | rest = strings.TrimSpace(strings.Join(args[1:], " ")) | |
| 52 | 34 | } | |
| 53 | 35 | } | |
| 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 | - | ||
| 88 | 36 | switch sub { | |
| 89 | 37 | 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() | |
| 99 | 45 | case "new", "spawn": | |
| 46 | + | i.spawnResidentSubagent(rest) | |
| 47 | + | case "logs", "log", "view": | |
| 100 | 48 | if rest == "" { | |
| 101 | - | i.subagentsStatus("", "/subagents new <task>: missing task") | |
| 49 | + | i.subagentsStatus("", "/subagents logs <id>: missing id") | |
| 102 | 50 | return | |
| 103 | 51 | } | |
| 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") | |
| 113 | 56 | return | |
| 114 | 57 | } | |
| 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) | |
| 165 | 59 | if err != nil { | |
| 166 | - | i.subagentsStatus("", "spawn: "+err.Error()) | |
| 60 | + | i.subagentsStatus("", "result: "+err.Error()) | |
| 167 | 61 | return | |
| 168 | 62 | } | |
| 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), "") | |
| 176 | 64 | case "kill", "stop": | |
| 177 | 65 | if rest == "" { | |
| 178 | 66 | i.subagentsStatus("", "/subagents kill <id>: missing id") | |
| 179 | 67 | return | |
| 180 | 68 | } | |
| 181 | - | if err := i.cfg.Supervisor.Stop(rest); err != nil { | |
| 69 | + | if err := i.cfg.ResidentManager.Stop(ctx, rest); err != nil { | |
| 182 | 70 | i.subagentsStatus("", "kill: "+err.Error()) | |
| 183 | 71 | return | |
| 184 | 72 | } | |
| 185 | 73 | 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") | |
| 231 | 78 | return | |
| 232 | 79 | } | |
| 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()) | |
| 251 | 82 | return | |
| 252 | 83 | } | |
| 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() { | |
| 258 | 108 | 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) | |
| 272 | 112 | } | |
| 113 | + | i.invalidate() | |
| 273 | 114 | }() | |
| 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") | |
| 304 | 133 | return | |
| 305 | 134 | } | |
| 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 | + | } | |
| 309 | 142 | return | |
| 310 | 143 | } | |
| 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() | |
| 322 | 148 | } | |
| 323 | - | if text == "" { | |
| 324 | - | i.subagentsStatus("", "/subagents send <id> <text>: missing text") | |
| 325 | - | return | |
| 149 | + | if reasoning == "" { | |
| 150 | + | reasoning = profile.Thinking | |
| 326 | 151 | } | |
| 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()) | |
| 329 | 167 | return | |
| 330 | 168 | } | |
| 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)") | |
| 342 | 169 | } | |
| 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, "") | |
| 343 | 176 | } | |
| 344 | 177 | ||
| 345 | 178 | // normalizeSpawnReasoning validates and canonicalizes the reasoning levels | |
| @@ -444,20 +277,6 @@ func splitIDAndRest(s string) (id, text string) { | |||
| 444 | 277 | return s[:cut], strings.TrimLeft(s[cut+1:], " \t") | |
| 445 | 278 | } | |
| 446 | 279 | ||
| 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 | - | ||
| 461 | 280 | func truncateStatus(value string, max int) string { | |
| 462 | 281 | value = strings.ReplaceAll(strings.TrimSpace(value), "\n", " ") | |
| 463 | 282 | 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 { | |||
| 19 | 19 | // for callers that select those output protocols. | |
| 20 | 20 | case ModeRPC: | |
| 21 | 21 | 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") | |
| 24 | 22 | default: | |
| 25 | 23 | return fmt.Errorf("--orchestrate requires print, stream, or JSON mode") | |
| 26 | 24 | } | |
| 27 | - | if strings.TrimSpace(args.Subagent) != "" { | |
| 28 | - | return fmt.Errorf("--orchestrate is not supported with --subagent profiles") | |
| 29 | - | } | |
| 30 | 25 | if args.PermissionSet != nil || strings.TrimSpace(args.AgentName) != "" || strings.TrimSpace(args.AgentDataDir) != "" { | |
| 31 | 26 | return fmt.Errorf("--orchestrate is not supported for packaged agents") | |
| 32 | 27 | } | |
M packages/agent/orchestration_modes.go +14 -26
| @@ -68,9 +68,9 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h | |||
| 68 | 68 | runErr = errors.Join(runErr, closeErr) | |
| 69 | 69 | } | |
| 70 | 70 | }() | |
| 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) | |
| 74 | 74 | } | |
| 75 | 75 | } | |
| 76 | 76 | ||
| @@ -129,7 +129,7 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h | |||
| 129 | 129 | return refreshErr | |
| 130 | 130 | } | |
| 131 | 131 | // 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. | |
| 133 | 133 | runtime.SetWebSearchPolicy(refreshedPolicy) | |
| 134 | 134 | } | |
| 135 | 135 | ||
| @@ -192,24 +192,8 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h | |||
| 192 | 192 | return hooks.finish(finalText) | |
| 193 | 193 | } | |
| 194 | 194 | ||
| 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 { | |
| 211 | 196 | return newSubagentRuntime(subagentRuntimeConfig{ | |
| 212 | - | Context: ctx, | |
| 213 | 197 | Args: args, | |
| 214 | 198 | Root: filepath.Join(ZutHome(), "subagents"), | |
| 215 | 199 | RepoRoot: r.CWD, | |
| @@ -219,13 +203,17 @@ func newOrchestratedRuntime(ctx context.Context, args Args, r Resolved, cfg Conf | |||
| 219 | 203 | BaseURL: r.BaseURL, | |
| 220 | 204 | InsecureTLS: r.InsecureTLS, | |
| 221 | 205 | FastMode: r.FastMode, | |
| 222 | - | APIKey: args.APIKey, | |
| 223 | 206 | Policy: subagentPolicyFromConfig(cfg.Subagents), | |
| 224 | 207 | 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() }, | |
| 229 | 217 | }) | |
| 230 | 218 | } | |
| 231 | 219 | ||
M packages/agent/ponytail_test.go +1 -1
| @@ -153,7 +153,7 @@ func TestResolvePonytailModeAppliesAcrossHeadlessModesAndCustomIdentity(t *testi | |||
| 153 | 153 | t.Setenv("ZUT_HOME", filepath.Join(root, "zut-home")) | |
| 154 | 154 | t.Setenv("HOME", filepath.Join(root, "home")) | |
| 155 | 155 | ||
| 156 | - | modes := []Mode{"", ModePrint, ModeStream, ModeJSON, ModeRPC, ModeSubagentWorker} | |
| 156 | + | modes := []Mode{"", ModePrint, ModeStream, ModeJSON, ModeRPC} | |
| 157 | 157 | for _, mode := range modes { | |
| 158 | 158 | resolved, err := Resolve(Args{ | |
| 159 | 159 | Mode: mode, | |
M packages/agent/settings_store.go +1 -15
| @@ -217,20 +217,6 @@ func (configSettingsStore) SetTUIWorkingPosition(position string) error { | |||
| 217 | 217 | return SaveConfig(cfg) | |
| 218 | 218 | } | |
| 219 | 219 | ||
| 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 | - | ||
| 234 | 220 | func (configSettingsStore) SetReasoning(level string) error { | |
| 235 | 221 | cfg, err := LoadConfig() | |
| 236 | 222 | if err != nil { | |
| @@ -274,7 +260,7 @@ Give every worker a self-contained task because workers start without this conve | |||
| 274 | 260 | ||
| 275 | 261 | 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. | |
| 276 | 262 | ||
| 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. | |
| 278 | 264 | ||
| 279 | 265 | 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.` | |
| 280 | 266 | ||
M packages/agent/settings_store_test.go +0 -32
| @@ -202,38 +202,6 @@ func TestConfigLSPEnabledForDefaultsToTrue(t *testing.T) { | |||
| 202 | 202 | } | |
| 203 | 203 | } | |
| 204 | 204 | ||
| 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 | - | ||
| 237 | 205 | func TestConfigSettingsStorePersistsInheritedTheme(t *testing.T) { | |
| 238 | 206 | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 239 | 207 | 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 @@ | |||
| 1 | 1 | package agent | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 5 | - | "encoding/json" | |
| 6 | - | "errors" | |
| 7 | 4 | "fmt" | |
| 8 | 5 | "strings" | |
| 9 | 6 | ||
| 10 | - | "github.com/bnema/zut/packages/agent/subagents" | |
| 11 | 7 | "github.com/bnema/zut/packages/core" | |
| 12 | - | "github.com/bnema/zut/packages/provider" | |
| 13 | 8 | ) | |
| 14 | 9 | ||
| 15 | 10 | const requiredWorkerContextHeader = "[required-subagents update]" | |
| 16 | 11 | ||
| 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. | |
| 22 | 15 | 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 { | |
| 24 | 17 | return | |
| 25 | 18 | } | |
| 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 | - | ||
| 55 | 19 | previousBeforeAssistant := parent.BeforeAssistantMessage | |
| 56 | 20 | parent.BeforeAssistantMessage = func(text string) (bool, string, string) { | |
| 57 | - | unmet := rt.supervisor.UnmetRequirements() | |
| 21 | + | unmet := rt.residentManagerForTools().UnmetRequired() | |
| 58 | 22 | 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) | |
| 68 | 28 | } | |
| 69 | - | if err := rt.acknowledgeSatisfiedRequirements(); err != nil { | |
| 70 | - | return false, err.Error(), "" | |
| 71 | - | } | |
| 72 | - | return true, reason, replacement | |
| 29 | + | return false, b.String(), "" | |
| 73 | 30 | } | |
| 74 | - | if err := rt.acknowledgeSatisfiedRequirements(); err != nil { | |
| 75 | - | return false, err.Error(), "" | |
| 31 | + | if previousBeforeAssistant != nil { | |
| 32 | + | return previousBeforeAssistant(text) | |
| 76 | 33 | } | |
| 77 | 34 | return true, "", "" | |
| 78 | 35 | } | |
| 79 | 36 | } | |
| 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | - | "fmt" | |
| 7 | - | "os" | |
| 8 | 6 | "path/filepath" | |
| 7 | + | "sort" | |
| 9 | 8 | "strings" | |
| 10 | 9 | "sync" | |
| 11 | 10 | "time" | |
| @@ -13,47 +12,42 @@ import ( | |||
| 13 | 12 | "github.com/bnema/zut/packages/agent/subagents" | |
| 14 | 13 | "github.com/bnema/zut/packages/agent/tools" | |
| 15 | 14 | "github.com/bnema/zut/packages/core" | |
| 15 | + | "github.com/google/uuid" | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | 18 | // 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. | |
| 22 | 21 | 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 | |
| 34 | 37 | ||
| 35 | 38 | activeProvider func() string | |
| 36 | 39 | activeModel func() string | |
| 37 | 40 | activeReasoning func() string | |
| 38 | 41 | ||
| 39 | - | apiKey string | |
| 40 | - | apiKeyProvider string | |
| 41 | 42 | webSearchPolicy subagents.WebSearchPolicy | |
| 43 | + | policy subagents.SubagentPolicy | |
| 44 | + | activeSession string | |
| 42 | 45 | webSearchGuard *webSearchSessionGuard | |
| 43 | 46 | ||
| 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 | |
| 57 | 51 | } | |
| 58 | 52 | ||
| 59 | 53 | type subagentRuntimeConfiguration struct { | |
| @@ -70,12 +64,9 @@ type subagentRuntimeConfiguration struct { | |||
| 70 | 64 | // assemble a subagentRuntime. The active selection callbacks are optional; | |
| 71 | 65 | // when omitted, the initial Provider/Model/Reasoning values are used. | |
| 72 | 66 | 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 | |
| 79 | 70 | ||
| 80 | 71 | Provider string | |
| 81 | 72 | Model string | |
| @@ -84,62 +75,35 @@ type subagentRuntimeConfig struct { | |||
| 84 | 75 | InsecureTLS bool | |
| 85 | 76 | FastMode bool | |
| 86 | 77 | ||
| 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) | |
| 100 | 86 | } | |
| 101 | 87 | ||
| 102 | 88 | 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 | - | } | |
| 123 | 89 | 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, | |
| 143 | 107 | } | |
| 144 | 108 | if rt.activeProvider == nil { | |
| 145 | 109 | rt.activeProvider = func() string { | |
| @@ -162,45 +126,35 @@ func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime { | |||
| 162 | 126 | return rt.reasoning | |
| 163 | 127 | } | |
| 164 | 128 | } | |
| 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. | |
| 174 | 130 | if cfg.Args.PermissionSet == nil { | |
| 175 | 131 | root := rt.root | |
| 176 | 132 | if strings.TrimSpace(root) == "" { | |
| 177 | 133 | root = filepath.Join(ZutHome(), "subagents") | |
| 178 | 134 | rt.root = root | |
| 179 | 135 | } | |
| 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) | |
| 194 | 138 | }) | |
| 139 | + | rt.resident.SetCompletionObserver(cfg.ResidentCompletion) | |
| 140 | + | rt.resident.SetAcceptedObserver(cfg.OnResidentSpawned) | |
| 195 | 141 | } | |
| 196 | 142 | return rt | |
| 197 | 143 | } | |
| 198 | 144 | ||
| 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 { | |
| 200 | 154 | if rt == nil { | |
| 201 | 155 | return nil | |
| 202 | 156 | } | |
| 203 | - | return rt.supervisor | |
| 157 | + | return rt.resident | |
| 204 | 158 | } | |
| 205 | 159 | ||
| 206 | 160 | func (rt *subagentRuntime) currentProvider() string { | |
| @@ -250,9 +204,6 @@ func (rt *subagentRuntime) SetProvider(providerID string) { | |||
| 250 | 204 | rt.settingsMu.Lock() | |
| 251 | 205 | rt.provider = providerID | |
| 252 | 206 | rt.settingsMu.Unlock() | |
| 253 | - | if rt.supervisor != nil { | |
| 254 | - | rt.supervisor.SetProvider(providerID) | |
| 255 | - | } | |
| 256 | 207 | } | |
| 257 | 208 | ||
| 258 | 209 | func (rt *subagentRuntime) SetProviderSettings(baseURL string, insecureTLS bool) { | |
| @@ -264,9 +215,6 @@ func (rt *subagentRuntime) SetProviderSettings(baseURL string, insecureTLS bool) | |||
| 264 | 215 | rt.baseURL = baseURL | |
| 265 | 216 | rt.insecureTLS = insecureTLS | |
| 266 | 217 | rt.settingsMu.Unlock() | |
| 267 | - | if rt.supervisor != nil { | |
| 268 | - | rt.supervisor.SetProviderSettings(baseURL, insecureTLS) | |
| 269 | - | } | |
| 270 | 218 | } | |
| 271 | 219 | ||
| 272 | 220 | func (rt *subagentRuntime) SetFastMode(fastMode bool) { | |
| @@ -276,9 +224,6 @@ func (rt *subagentRuntime) SetFastMode(fastMode bool) { | |||
| 276 | 224 | rt.settingsMu.Lock() | |
| 277 | 225 | rt.fastMode = fastMode | |
| 278 | 226 | rt.settingsMu.Unlock() | |
| 279 | - | if rt.supervisor != nil { | |
| 280 | - | rt.supervisor.SetFastMode(fastMode) | |
| 281 | - | } | |
| 282 | 227 | } | |
| 283 | 228 | ||
| 284 | 229 | func (rt *subagentRuntime) SetRepoRoot(repoRoot string) { | |
| @@ -288,9 +233,6 @@ func (rt *subagentRuntime) SetRepoRoot(repoRoot string) { | |||
| 288 | 233 | rt.settingsMu.Lock() | |
| 289 | 234 | rt.repoRoot = repoRoot | |
| 290 | 235 | rt.settingsMu.Unlock() | |
| 291 | - | if rt.supervisor != nil { | |
| 292 | - | rt.supervisor.SetRepoRoot(repoRoot) | |
| 293 | - | } | |
| 294 | 236 | } | |
| 295 | 237 | ||
| 296 | 238 | // snapshotConfiguration captures the runtime settings changed while an agent | |
| @@ -326,19 +268,91 @@ func (rt *subagentRuntime) restoreConfiguration(snapshot subagentRuntimeConfigur | |||
| 326 | 268 | rt.repoRoot = snapshot.repoRoot | |
| 327 | 269 | rt.webSearchPolicy = snapshot.webSearchPolicy | |
| 328 | 270 | 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 | - | } | |
| 336 | 271 | } | |
| 337 | 272 | ||
| 338 | 273 | 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 | |
| 341 | 341 | } | |
| 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 | |
| 342 | 356 | } | |
| 343 | 357 | ||
| 344 | 358 | func (rt *subagentRuntime) SetWebSearchPolicy(policy subagents.WebSearchPolicy) { | |
| @@ -348,36 +362,6 @@ func (rt *subagentRuntime) SetWebSearchPolicy(policy subagents.WebSearchPolicy) | |||
| 348 | 362 | rt.settingsMu.Lock() | |
| 349 | 363 | rt.webSearchPolicy = policy | |
| 350 | 364 | 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 | |
| 381 | 365 | } | |
| 382 | 366 | ||
| 383 | 367 | func (rt *subagentRuntime) resolveSubagent(name string) (*subagents.Profile, error) { | |
| @@ -394,13 +378,13 @@ func (rt *subagentRuntime) resolveSubagent(name string) (*subagents.Profile, err | |||
| 394 | 378 | // policy. It deliberately mutates the supplied registry, matching the normal | |
| 395 | 379 | // interactive registry assembly path. | |
| 396 | 380 | 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) { | |
| 398 | 382 | return reg | |
| 399 | 383 | } | |
| 400 | 384 | if autoSubagentsToolAllowed(rt.args) { | |
| 401 | 385 | spawn := &tools.SubagentSpawnTool{ | |
| 402 | - | Supervisor: rt.supervisor, | |
| 403 | - | Enabled: func() bool { return true }, | |
| 386 | + | ResidentManager: rt.residentManagerForTools(), | |
| 387 | + | Enabled: func() bool { return true }, | |
| 404 | 388 | DefaultModel: func() string { | |
| 405 | 389 | return rt.currentModel() | |
| 406 | 390 | }, | |
| @@ -411,31 +395,31 @@ func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry { | |||
| 411 | 395 | return rt.currentReasoning() | |
| 412 | 396 | }, | |
| 413 | 397 | 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, | |
| 415 | 402 | } | |
| 416 | 403 | reg[spawn.Name()] = spawn | |
| 417 | 404 | } | |
| 418 | 405 | if autoSubagentsStatusToolAllowed(rt.args) { | |
| 419 | 406 | status := &tools.SubagentStatusTool{ | |
| 420 | - | Supervisor: rt.supervisor, | |
| 421 | - | Enabled: func() bool { return true }, | |
| 407 | + | ResidentManager: rt.residentManagerForTools(), | |
| 408 | + | Enabled: func() bool { return true }, | |
| 422 | 409 | } | |
| 423 | 410 | reg[status.Name()] = status | |
| 424 | 411 | } | |
| 425 | 412 | if autoSubagentsStopToolAllowed(rt.args) { | |
| 426 | 413 | stop := &tools.SubagentStopTool{ | |
| 427 | - | Supervisor: rt.supervisor, | |
| 414 | + | ResidentManager: rt.residentManagerForTools(), | |
| 428 | 415 | Enabled: func() bool { return true }, | |
| 429 | - | OnStopRequested: rt.onStopRequested, | |
| 430 | 416 | } | |
| 431 | 417 | reg[stop.Name()] = stop | |
| 432 | 418 | } | |
| 433 | 419 | if autoSubagentsResumeToolAllowed(rt.args) { | |
| 434 | 420 | 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 }, | |
| 439 | 423 | } | |
| 440 | 424 | reg[resume.Name()] = resume | |
| 441 | 425 | } | |
| @@ -451,10 +435,6 @@ func (rt *subagentRuntime) PrepareRegistry(reg core.Registry) core.Registry { | |||
| 451 | 435 | } | |
| 452 | 436 | ||
| 453 | 437 | 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. | |
| 458 | 438 | if rt != nil { | |
| 459 | 439 | rt.SetWebSearchPolicy(webSearchPolicyForRegistry(policy, reg)) | |
| 460 | 440 | } | |
| @@ -465,7 +445,7 @@ func (rt *subagentRuntime) PrepareResolvedRegistry(reg core.Registry, policy sub | |||
| 465 | 445 | } | |
| 466 | 446 | ||
| 467 | 447 | func (rt *subagentRuntime) Close(ctx context.Context) error { | |
| 468 | - | if rt == nil || rt.supervisor == nil { | |
| 448 | + | if rt == nil { | |
| 469 | 449 | return nil | |
| 470 | 450 | } | |
| 471 | 451 | rt.closeMu.Lock() | |
| @@ -473,13 +453,12 @@ func (rt *subagentRuntime) Close(ctx context.Context) error { | |||
| 473 | 453 | if rt.closed { | |
| 474 | 454 | return nil | |
| 475 | 455 | } | |
| 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)) | |
| 480 | 459 | } | |
| 481 | 460 | rt.closed = true | |
| 482 | - | return nil | |
| 461 | + | return err | |
| 483 | 462 | } | |
| 484 | 463 | ||
| 485 | 464 | func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy { | |
| @@ -494,18 +473,9 @@ func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy { | |||
| 494 | 473 | return parsed | |
| 495 | 474 | } | |
| 496 | 475 | 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...), | |
| 510 | 480 | } | |
| 511 | 481 | } | |
M packages/agent/subagent_runtime_test.go +20 -400
| @@ -2,421 +2,41 @@ package agent | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "encoding/json" | |
| 6 | - | "errors" | |
| 7 | - | "os" | |
| 8 | - | "path/filepath" | |
| 9 | - | "sync" | |
| 10 | - | "sync/atomic" | |
| 11 | 5 | "testing" | |
| 12 | - | "time" | |
| 13 | 6 | ||
| 14 | - | "github.com/bnema/zut/packages/agent/subagents" | |
| 15 | 7 | "github.com/bnema/zut/packages/agent/tools" | |
| 16 | 8 | "github.com/bnema/zut/packages/core" | |
| 17 | 9 | ) | |
| 18 | 10 | ||
| 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, | |
| 73 | 15 | }) | |
| 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}) | |
| 80 | 19 | 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) | |
| 129 | 21 | } | |
| 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) | |
| 132 | 24 | } | |
| 133 | 25 | } | |
| 134 | 26 | ||
| 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, | |
| 187 | 31 | }) | |
| 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}) | |
| 188 | 36 | 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 { | |
| 376 | 37 | t.Fatal(err) | |
| 377 | 38 | } | |
| 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) | |
| 421 | 41 | } | |
| 422 | 42 | } | |
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 © | |
| 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | - | "sort" | |
| 7 | - | "strconv" | |
| 8 | 6 | "strings" | |
| 9 | 7 | "sync" | |
| 10 | 8 | ) | |
| 11 | 9 | ||
| 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. | |
| 15 | 11 | 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 | |
| 52 | 17 | } | |
| 53 | 18 | ||
| 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. | |
| 57 | 21 | 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{} | |
| 63 | 26 | } | |
| 64 | 27 | ||
| 65 | - | // NewCompletionTracker creates an empty completion tracker. | |
| 66 | 28 | 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{})} | |
| 94 | 30 | } | |
| 95 | 31 | ||
| 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() { | |
| 136 | 33 | if t == nil { | |
| 137 | 34 | return | |
| 138 | 35 | } | |
| 139 | - | var cancels []context.CancelFunc | |
| 140 | 36 | 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++ | |
| 154 | 38 | t.signalLocked() | |
| 155 | 39 | 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 | - | } | |
| 282 | 40 | } | |
| 283 | 41 | ||
| 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 { | |
| 286 | 44 | return | |
| 287 | 45 | } | |
| 288 | - | var cancel context.CancelFunc | |
| 289 | 46 | t.mu.Lock() | |
| 290 | - | state := entry.state | |
| 291 | - | if entry.done || t.active[state.agent] != state { | |
| 47 | + | if t.pending == 0 { | |
| 292 | 48 | t.mu.Unlock() | |
| 293 | 49 | return | |
| 294 | 50 | } | |
| 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) | |
| 303 | 53 | t.signalLocked() | |
| 304 | 54 | t.mu.Unlock() | |
| 305 | - | if cancel != nil { | |
| 306 | - | cancel() | |
| 307 | - | } | |
| 308 | 55 | } | |
| 309 | 56 | ||
| 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 | |
| 313 | 60 | } | |
| 314 | - | snap := entry.state.agent.Snapshot() | |
| 315 | - | completion := makeCompletion(entry, snap, turnErr, true) | |
| 316 | - | ||
| 317 | - | var cancel context.CancelFunc | |
| 318 | 61 | 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 | |
| 338 | 64 | } | |
| 339 | 65 | ||
| 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 { | |
| 342 | 69 | return | |
| 343 | 70 | } | |
| 344 | - | snap := state.agent.Snapshot() | |
| 345 | - | ||
| 346 | 71 | 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 | |
| 363 | 74 | t.signalLocked() | |
| 364 | 75 | t.mu.Unlock() | |
| 365 | 76 | } | |
| 366 | 77 | ||
| 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. | |
| 467 | 78 | func (t *CompletionTracker) WaitIdle(ctx context.Context) ([]Completion, error) { | |
| 468 | 79 | if t == nil { | |
| 469 | 80 | return nil, nil | |
| 470 | 81 | } | |
| 471 | - | if ctx == nil { | |
| 472 | - | ctx = context.Background() | |
| 473 | - | } | |
| 474 | - | ||
| 475 | 82 | for { | |
| 476 | 83 | 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 | |
| 480 | 87 | t.mu.Unlock() | |
| 481 | - | return batch, nil | |
| 88 | + | return ready, nil | |
| 482 | 89 | } | |
| 483 | 90 | changed := t.changed | |
| 484 | 91 | t.mu.Unlock() | |
| 485 | - | ||
| 486 | 92 | select { | |
| 487 | - | case <-changed: | |
| 488 | 93 | 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() | |
| 498 | 94 | return nil, ctx.Err() | |
| 95 | + | case <-changed: | |
| 499 | 96 | } | |
| 500 | 97 | } | |
| 501 | 98 | } | |
| 502 | 99 | ||
| 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 | + | ||
| 507 | 102 | func FormatCompletionUpdate(batch []Completion, instruction string) string { | |
| 508 | 103 | if len(batch) == 0 { | |
| 509 | 104 | return "" | |
| 510 | 105 | } | |
| 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) | |
| 517 | 110 | 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) | |
| 525 | 115 | } | |
| 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) | |
| 532 | 118 | } | |
| 533 | - | sb.WriteString("\n") | |
| 119 | + | b.WriteByte('\n') | |
| 534 | 120 | } | |
| 535 | 121 | 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) | |
| 552 | 123 | } | |
| 553 | - | return string(runes[:limit-3]) + "..." | |
| 124 | + | return strings.TrimSpace(b.String()) | |
| 554 | 125 | } | |
M packages/agent/subagents/completion_test.go +10 -419
| @@ -2,436 +2,27 @@ package subagents | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "errors" | |
| 6 | 5 | "strings" | |
| 7 | 6 | "testing" | |
| 8 | - | "time" | |
| 9 | 7 | ) | |
| 10 | 8 | ||
| 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) | |
| 20 | 13 | } | |
| 21 | 14 | } | |
| 22 | 15 | ||
| 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) { | |
| 26 | 17 | 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() | |
| 292 | 19 | tracker.Reset() | |
| 293 | - | batch, err := tracker.WaitIdle(context.Background()) | |
| 20 | + | tracker.Report(Completion{AgentID: "stale"}) | |
| 21 | + | got, err := tracker.WaitIdle(context.Background()) | |
| 294 | 22 | if err != nil { | |
| 295 | 23 | t.Fatal(err) | |
| 296 | 24 | } | |
| 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) | |
| 436 | 27 | } | |
| 437 | 28 | } | |
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 := ¬ifyingContext{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 | |||
| 3 | 3 | import "time" | |
| 4 | 4 | ||
| 5 | 5 | // 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. | |
| 9 | 7 | type WebSearchPolicy uint8 | |
| 10 | 8 | ||
| 11 | 9 | const ( | |
| @@ -16,17 +14,6 @@ const ( | |||
| 16 | 14 | ||
| 17 | 15 | func (p WebSearchPolicy) Allows() bool { return p == WebSearchAllow } | |
| 18 | 16 | ||
| 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 | - | ||
| 30 | 17 | func (p WebSearchPolicy) String() string { | |
| 31 | 18 | switch p { | |
| 32 | 19 | case WebSearchAllow: | |
| @@ -42,53 +29,6 @@ func (p WebSearchPolicy) String() string { | |||
| 42 | 29 | } | |
| 43 | 30 | } | |
| 44 | 31 | ||
| 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 | - | ||
| 92 | 32 | // WorkspaceMode selects how a child is allowed to access the repository. | |
| 93 | 33 | type WorkspaceMode string | |
| 94 | 34 | ||
| @@ -105,61 +45,12 @@ const ( | |||
| 105 | 45 | CaptureDiff CaptureMode = "diff" | |
| 106 | 46 | ) | |
| 107 | 47 | ||
| 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. | |
| 113 | 49 | 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 | |
| 163 | 54 | } | |
| 164 | 55 | ||
| 165 | 56 | func (p SubagentPolicy) allowedTool(name string) bool { | |
| @@ -173,3 +64,7 @@ func (p SubagentPolicy) allowedTool(name string) bool { | |||
| 173 | 64 | } | |
| 174 | 65 | return false | |
| 175 | 66 | } | |
| 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 @@ | |||
| 1 | 1 | // 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: | |
| 3 | 3 | // a YAML-like metadata block followed by the agent's system prompt. | |
| 4 | 4 | // | |
| 5 | 5 | // Discovery prefers explicitly configured directories, then the shared | |
| @@ -26,9 +26,12 @@ type Profile struct { | |||
| 26 | 26 | Description string | |
| 27 | 27 | SystemPrompt string | |
| 28 | 28 | 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 | |
| 32 | 35 | ||
| 33 | 36 | // Nil means the profile did not specify whether fast mode is enabled. | |
| 34 | 37 | FastMode *bool | |
| @@ -122,6 +125,33 @@ func (p *Profile) ModelSelection() (provider, model string) { | |||
| 122 | 125 | return provider, model | |
| 123 | 126 | } | |
| 124 | 127 | ||
| 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 | + | ||
| 125 | 155 | // SystemPromptAddendum renders the compact [subagents_list] manifest for the | |
| 126 | 156 | // parent model. Full profile bodies are intentionally omitted: the selected | |
| 127 | 157 | // child loads its own instructions, and keeping them out of every parent turn | |
| @@ -238,16 +268,18 @@ func load(path, source string) (*Profile, error) { | |||
| 238 | 268 | if provider != "" && strings.Contains(model, "/") { | |
| 239 | 269 | return nil, fmt.Errorf("model must not include a provider when provider is set") | |
| 240 | 270 | } | |
| 271 | + | _, toolsDeclared := lists["tools"] | |
| 241 | 272 | 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, | |
| 251 | 283 | } | |
| 252 | 284 | profile.SystemPromptMode = "append" | |
| 253 | 285 | if mode, ok := values["systempromptmode"]; ok { | |
| @@ -336,7 +368,7 @@ func parseFrontmatter(front string) (map[string]string, map[string][]string) { | |||
| 336 | 368 | items = append(items, unquote(strings.TrimSpace(strings.TrimPrefix(next, "-")))) | |
| 337 | 369 | i = j | |
| 338 | 370 | } | |
| 339 | - | if len(items) > 0 { | |
| 371 | + | if key == "tools" || len(items) > 0 { | |
| 340 | 372 | lists[key] = items | |
| 341 | 373 | } | |
| 342 | 374 | continue | |
M packages/agent/subagents/profiles_test.go +45 -0
| @@ -190,6 +190,51 @@ func TestProfileModelSelection(t *testing.T) { | |||
| 190 | 190 | } | |
| 191 | 191 | } | |
| 192 | 192 | ||
| 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 | + | ||
| 193 | 238 | func TestSystemPromptAddendumIsCompactAndDoesNotExposeBodyOrPath(t *testing.T) { | |
| 194 | 239 | profiles := []*Profile{{ | |
| 195 | 240 | 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 collapsed to keep the page light
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
| @@ -1,488 +0,0 @@ | |||
| 1 | - | package tools | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "encoding/json" | |
| 6 | - | "fmt" | |
| 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 | - | ) | |
| 15 | - | ||
| 16 | - | type noopSupervisorRunner struct{} | |
| 17 | - | ||
| 18 | - | func (noopSupervisorRunner) Run(context.Context, subagents.Sink) error { return nil } | |
| 19 | - | ||
| 20 | - | func newTestSupervisor(t *testing.T) *subagents.Supervisor { | |
| 21 | - | t.Helper() | |
| 22 | - | root := t.TempDir() | |
| 23 | - | manager := subagents.New(subagents.Config{ | |
| 24 | - | Root: filepath.Join(root, "subagents"), | |
| 25 | - | RepoRoot: root, | |
| 26 | - | NewRunner: func(*subagents.Agent) subagents.Runner { | |
| 27 | - | return noopSupervisorRunner{} | |
| 28 | - | }, | |
| 29 | - | }) | |
| 30 | - | t.Cleanup(manager.StopAll) | |
| 31 | - | return manager | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | func TestSubagentSpawnGuidanceUsesHostCompletionUpdatesWithoutPolling(t *testing.T) { | |
| 35 | - | tool := &SubagentSpawnTool{ | |
| 36 | - | Supervisor: newTestSupervisor(t), | |
| 37 | - | Enabled: func() bool { return true }, | |
| 38 | - | } | |
| 39 | - | for _, want := range []string{ | |
| 40 | - | "host-event-driven", | |
| 41 | - | "[auto-subagents update]", | |
| 42 | - | "bash sleep", | |
| 43 | - | "watch", | |
| 44 | - | "tail -f", | |
| 45 | - | "polling loops", | |
| 46 | - | "repeated subagent_status", | |
| 47 | - | "dashboard/metadata/event-log/file checks", | |
| 48 | - | "unrelated independent tasks", | |
| 49 | - | "end/yield your turn", | |
| 50 | - | } { | |
| 51 | - | if !strings.Contains(tool.Description(), want) { | |
| 52 | - | t.Fatalf("spawn description missing %q: %s", want, tool.Description()) | |
| 53 | - | } | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil) | |
| 57 | - | if err != nil { | |
| 58 | - | t.Fatal(err) | |
| 59 | - | } | |
| 60 | - | if res.IsError { | |
| 61 | - | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |
| 62 | - | } | |
| 63 | - | text := textResult(res.Content) | |
| 64 | - | for _, want := range []string{ | |
| 65 | - | "host-event-driven", | |
| 66 | - | "[auto-subagents update]", | |
| 67 | - | "the only completion signal", | |
| 68 | - | "bash sleep", | |
| 69 | - | "watch", | |
| 70 | - | "tail -f", | |
| 71 | - | "polling loops", | |
| 72 | - | "repeated subagent_status", | |
| 73 | - | "dashboard/metadata/event-log/file checks", | |
| 74 | - | "unrelated independent tasks", | |
| 75 | - | "end or yield your turn", | |
| 76 | - | "user-requested commands, provider flows, extensions, or tests", | |
| 77 | - | } { | |
| 78 | - | if !strings.Contains(text, want) { | |
| 79 | - | t.Fatalf("spawn result missing %q:\n%s", want, text) | |
| 80 | - | } | |
| 81 | - | } | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | func TestSubagentSpawnSchemaKeepsManagerBudgetsOutOfModelControl(t *testing.T) { | |
| 85 | - | tool := &SubagentSpawnTool{Supervisor: newTestSupervisor(t)} | |
| 86 | - | var schema struct { | |
| 87 | - | Properties map[string]json.RawMessage `json:"properties"` | |
| 88 | - | } | |
| 89 | - | if err := json.Unmarshal(tool.Schema(), &schema); err != nil { | |
| 90 | - | t.Fatal(err) | |
| 91 | - | } | |
| 92 | - | for _, property := range []string{"timeout", "max_turns", "max_steps"} { | |
| 93 | - | if _, found := schema.Properties[property]; found { | |
| 94 | - | t.Fatalf("spawn schema exposes manager-owned property %q", property) | |
| 95 | - | } | |
| 96 | - | } | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) { | |
| 100 | - | tool := &SubagentSpawnTool{ | |
| 101 | - | Supervisor: newTestSupervisor(t), | |
| 102 | - | Enabled: func() bool { return true }, | |
| 103 | - | DefaultModel: func() string { return "gpt-5" }, | |
| 104 | - | DefaultProvider: func() string { return "openai-codex" }, | |
| 105 | - | DefaultReasoning: func() string { return "medium" }, | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil) | |
| 109 | - | if err != nil { | |
| 110 | - | t.Fatal(err) | |
| 111 | - | } | |
| 112 | - | if res.IsError { | |
| 113 | - | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |
| 114 | - | } | |
| 115 | - | details, ok := res.Details.(map[string]any) | |
| 116 | - | if !ok { | |
| 117 | - | t.Fatalf("details type = %T, want map[string]any", res.Details) | |
| 118 | - | } | |
| 119 | - | if got := details["model"]; got != "gpt-5" { | |
| 120 | - | t.Fatalf("model detail = %v, want gpt-5", got) | |
| 121 | - | } | |
| 122 | - | if got := details["provider"]; got != "openai-codex" { | |
| 123 | - | t.Fatalf("provider detail = %v, want openai-codex", got) | |
| 124 | - | } | |
| 125 | - | if got := details["reasoning"]; got != "medium" { | |
| 126 | - | t.Fatalf("reasoning detail = %v, want medium", got) | |
| 127 | - | } | |
| 128 | - | if got := details["timeout"]; got != "unlimited" { | |
| 129 | - | t.Fatalf("timeout detail = %v, want unlimited", got) | |
| 130 | - | } | |
| 131 | - | text := textResult(res.Content) | |
| 132 | - | if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") || !strings.Contains(text, "reasoning: medium") { | |
| 133 | - | t.Fatalf("result text missing inherited model/provider:\n%s", text) | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | agents := tool.Supervisor.List() | |
| 137 | - | if len(agents) != 1 { | |
| 138 | - | t.Fatalf("spawned agents = %d, want 1", len(agents)) | |
| 139 | - | } | |
| 140 | - | if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" || agents[0].Reasoning != "medium" { | |
| 141 | - | t.Fatalf("agent model/provider/reasoning = %q/%q/%q, want gpt-5/openai-codex/medium", agents[0].Model, agents[0].Provider, agents[0].Reasoning) | |
| 142 | - | } | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | func TestSubagentSpawnDetailsUseEffectiveTimeout(t *testing.T) { | |
| 146 | - | root := t.TempDir() | |
| 147 | - | manager := subagents.New(subagents.Config{ | |
| 148 | - | Root: filepath.Join(root, "subagents"), | |
| 149 | - | RepoRoot: root, | |
| 150 | - | Policy: subagents.SubagentPolicy{ | |
| 151 | - | DefaultTimeout: 37 * time.Minute, | |
| 152 | - | }, | |
| 153 | - | NewRunner: func(*subagents.Agent) subagents.Runner { | |
| 154 | - | return noopSupervisorRunner{} | |
| 155 | - | }, | |
| 156 | - | }) | |
| 157 | - | t.Cleanup(manager.StopAll) | |
| 158 | - | tool := &SubagentSpawnTool{ | |
| 159 | - | Supervisor: manager, | |
| 160 | - | Enabled: func() bool { return true }, | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil) | |
| 164 | - | if err != nil { | |
| 165 | - | t.Fatal(err) | |
| 166 | - | } | |
| 167 | - | if res.IsError { | |
| 168 | - | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |
| 169 | - | } | |
| 170 | - | details, ok := res.Details.(map[string]any) | |
| 171 | - | if !ok { | |
| 172 | - | t.Fatalf("details type = %T, want map[string]any", res.Details) | |
| 173 | - | } | |
| 174 | - | if got := details["timeout"]; got != (37 * time.Minute).String() { | |
| 175 | - | t.Fatalf("omitted timeout detail = %v, want %s", got, (37 * time.Minute).String()) | |
| 176 | - | } | |
| 177 | - | if _, found := details["max_turns"]; found { | |
| 178 | - | t.Fatalf("details retain removed max_turns: %#v", details) | |
| 179 | - | } | |
| 180 | - | if _, found := details["max_steps"]; found { | |
| 181 | - | t.Fatalf("details retain removed max_steps: %#v", details) | |
| 182 | - | } | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | func TestSubagentSpawnRejectsUnknownExecutionLimitFields(t *testing.T) { | |
| 186 | - | manager := newTestSupervisor(t) | |
| 187 | - | tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }} | |
| 188 | - | for _, field := range []string{"timeout", "max_turns", "max_steps"} { | |
| 189 | - | _, err := tool.Execute(context.Background(), json.RawMessage(fmt.Sprintf(`{"task":"research docs",%q:1}`, field)), nil) | |
| 190 | - | if err == nil || !strings.Contains(err.Error(), "unknown field") { | |
| 191 | - | t.Fatalf("field %q error = %v, want unknown field", field, err) | |
| 192 | - | } | |
| 193 | - | } | |
| 194 | - | if agents := manager.List(); len(agents) != 0 { | |
| 195 | - | t.Fatalf("unknown execution limits spawned agents: %#v", agents) | |
| 196 | - | } | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | func TestSubagentSpawnSelectsProfileAndReasoning(t *testing.T) { | |
| 200 | - | tool := &SubagentSpawnTool{ | |
| 201 | - | Supervisor: newTestSupervisor(t), | |
| 202 | - | Enabled: func() bool { return true }, | |
| 203 | - | ResolveSubagent: func(name string) (*subagents.Profile, error) { | |
| 204 | - | if name != "reviewer" { | |
| 205 | - | return nil, nil | |
| 206 | - | } | |
| 207 | - | return &subagents.Profile{ | |
| 208 | - | Name: "reviewer", | |
| 209 | - | Model: "openai-codex/gpt-5.6-luna", | |
| 210 | - | Thinking: "low", | |
| 211 | - | }, nil | |
| 212 | - | }, | |
| 213 | - | } | |
| 214 | - | ||
| 215 | - | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer","reasoning":"high"}`), nil) | |
| 216 | - | if err != nil { | |
| 217 | - | t.Fatal(err) | |
| 218 | - | } | |
| 219 | - | if res.IsError { | |
| 220 | - | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |
| 221 | - | } | |
| 222 | - | text := textResult(res.Content) | |
| 223 | - | for _, want := range []string{"agent: reviewer", "model: gpt-5.6-luna", "provider: openai-codex", "reasoning: high"} { | |
| 224 | - | if !strings.Contains(text, want) { | |
| 225 | - | t.Fatalf("result text missing %q:\n%s", want, text) | |
| 226 | - | } | |
| 227 | - | } | |
| 228 | - | agents := tool.Supervisor.List() | |
| 229 | - | if len(agents) != 1 || agents[0].Subagent != "reviewer" || agents[0].Reasoning != "high" { | |
| 230 | - | t.Fatalf("agent profile/reasoning = %#v", agents) | |
| 231 | - | } | |
| 232 | - | if agents[0].Model != "gpt-5.6-luna" || agents[0].Provider != "openai-codex" { | |
| 233 | - | t.Fatalf("agent model/provider = %q/%q, want gpt-5.6-luna/openai-codex", agents[0].Model, agents[0].Provider) | |
| 234 | - | } | |
| 235 | - | } | |
| 236 | - | ||
| 237 | - | func TestSubagentSpawnAppliesProfileFastModeRestriction(t *testing.T) { | |
| 238 | - | root := t.TempDir() | |
| 239 | - | profileFastMode := false | |
| 240 | - | f := subagents.New(subagents.Config{ | |
| 241 | - | Root: filepath.Join(root, "subagents"), | |
| 242 | - | RepoRoot: root, | |
| 243 | - | FastMode: true, | |
| 244 | - | NewRunner: func(*subagents.Agent) subagents.Runner { | |
| 245 | - | return noopSupervisorRunner{} | |
| 246 | - | }, | |
| 247 | - | }) | |
| 248 | - | t.Cleanup(f.StopAll) | |
| 249 | - | tool := &SubagentSpawnTool{ | |
| 250 | - | Supervisor: f, | |
| 251 | - | Enabled: func() bool { return true }, | |
| 252 | - | ResolveSubagent: func(name string) (*subagents.Profile, error) { | |
| 253 | - | if name != "reviewer" { | |
| 254 | - | return nil, nil | |
| 255 | - | } | |
| 256 | - | return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil | |
| 257 | - | }, | |
| 258 | - | } | |
| 259 | - | ||
| 260 | - | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer"}`), nil) | |
| 261 | - | if err != nil { | |
| 262 | - | t.Fatal(err) | |
| 263 | - | } | |
| 264 | - | if res.IsError { | |
| 265 | - | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |
| 266 | - | } | |
| 267 | - | agents := f.List() | |
| 268 | - | if len(agents) != 1 || agents[0].FastMode { | |
| 269 | - | t.Fatalf("agent fast mode = %#v, want disabled by profile", agents) | |
| 270 | - | } | |
| 271 | - | if got := res.Details.(map[string]any)["fast_mode"]; got != false { | |
| 272 | - | t.Fatalf("fast_mode detail = %v, want false", got) | |
| 273 | - | } | |
| 274 | - | } | |
| 275 | - | ||
| 276 | - | func TestSubagentSpawnSchemaUsesReasoningAndKeepsProfileThinkingInternal(t *testing.T) { | |
| 277 | - | var schema struct { | |
| 278 | - | Properties map[string]struct { | |
| 279 | - | Type string `json:"type"` | |
| 280 | - | Description string `json:"description"` | |
| 281 | - | } `json:"properties"` | |
| 282 | - | } | |
| 283 | - | if err := json.Unmarshal((&SubagentSpawnTool{}).Schema(), &schema); err != nil { | |
| 284 | - | t.Fatal(err) | |
| 285 | - | } | |
| 286 | - | if _, ok := schema.Properties["reasoning"]; !ok { | |
| 287 | - | t.Fatal("subagent_spawn schema does not advertise reasoning") | |
| 288 | - | } | |
| 289 | - | if _, ok := schema.Properties["thinking"]; ok { | |
| 290 | - | t.Fatal("subagent_spawn schema exposes profile-only thinking") | |
| 291 | - | } | |
| 292 | - | if _, ok := schema.Properties["timeout"]; ok { | |
| 293 | - | t.Fatal("subagent_spawn schema exposes manager-owned turn timeout") | |
| 294 | - | } | |
| 295 | - | fastMode, ok := schema.Properties["fast_mode"] | |
| 296 | - | if !ok { | |
| 297 | - | t.Fatal("subagent_spawn schema does not advertise fast_mode") | |
| 298 | - | } | |
| 299 | - | if fastMode.Type != "boolean" { | |
| 300 | - | t.Fatalf("fast_mode type = %q, want boolean", fastMode.Type) | |
| 301 | - | } | |
| 302 | - | if !strings.Contains(strings.ToLower(fastMode.Description), "inherit") { | |
| 303 | - | t.Fatalf("fast_mode description = %q, want inheritance guidance", fastMode.Description) | |
| 304 | - | } | |
| 305 | - | } | |
| 306 | - | ||
| 307 | - | func TestSubagentSpawnExplicitFastModeOverridesProfile(t *testing.T) { | |
| 308 | - | for _, tc := range []struct { | |
| 309 | - | name string | |
| 310 | - | profileFast bool | |
| 311 | - | spawnFast bool | |
| 312 | - | }{ | |
| 313 | - | {name: "enable", profileFast: false, spawnFast: true}, | |
| 314 | - | {name: "disable", profileFast: true, spawnFast: false}, | |
| 315 | - | } { | |
| 316 | - | t.Run(tc.name, func(t *testing.T) { | |
| 317 | - | profileFast := tc.profileFast | |
| 318 | - | tool := &SubagentSpawnTool{ | |
| 319 | - | Supervisor: newTestSupervisor(t), | |
| 320 | - | Enabled: func() bool { return true }, | |
| 321 | - | ResolveSubagent: func(name string) (*subagents.Profile, error) { | |
| 322 | - | return &subagents.Profile{Name: name, FastMode: &profileFast}, nil | |
| 323 | - | }, | |
| 324 | - | } | |
| 325 | - | ||
| 326 | - | raw := fmt.Sprintf(`{"task":"review auth","agent":"worker","fast_mode":%t}`, tc.spawnFast) | |
| 327 | - | res, err := tool.Execute(context.Background(), json.RawMessage(raw), nil) | |
| 328 | - | if err != nil { | |
| 329 | - | t.Fatal(err) | |
| 330 | - | } | |
| 331 | - | if res.IsError { | |
| 332 | - | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |
| 333 | - | } | |
| 334 | - | agents := tool.Supervisor.List() | |
| 335 | - | if len(agents) != 1 || agents[0].FastMode != tc.spawnFast { | |
| 336 | - | t.Fatalf("agent fast mode = %#v, want %t", agents, tc.spawnFast) | |
| 337 | - | } | |
| 338 | - | }) | |
| 339 | - | } | |
| 340 | - | } | |
| 341 | - | ||
| 342 | - | func TestSubagentSpawnProfileFastModeOverridesGlobalOffWithWarning(t *testing.T) { | |
| 343 | - | root := t.TempDir() | |
| 344 | - | profileFastMode := true | |
| 345 | - | f := subagents.New(subagents.Config{ | |
| 346 | - | Root: filepath.Join(root, "subagents"), | |
| 347 | - | RepoRoot: root, | |
| 348 | - | NewRunner: func(*subagents.Agent) subagents.Runner { | |
| 349 | - | return noopSupervisorRunner{} | |
| 350 | - | }, | |
| 351 | - | }) | |
| 352 | - | t.Cleanup(f.StopAll) | |
| 353 | - | tool := &SubagentSpawnTool{ | |
| 354 | - | Supervisor: f, | |
| 355 | - | Enabled: func() bool { return true }, | |
| 356 | - | ResolveSubagent: func(name string) (*subagents.Profile, error) { | |
| 357 | - | if name != "fast-worker" { | |
| 358 | - | return nil, nil | |
| 359 | - | } | |
| 360 | - | return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil | |
| 361 | - | }, | |
| 362 | - | } | |
| 363 | - | ||
| 364 | - | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"fast-worker"}`), nil) | |
| 365 | - | if err != nil { | |
| 366 | - | t.Fatal(err) | |
| 367 | - | } | |
| 368 | - | if res.IsError { | |
| 369 | - | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |
| 370 | - | } | |
| 371 | - | agents := f.List() | |
| 372 | - | if len(agents) != 1 || !agents[0].FastMode { | |
| 373 | - | t.Fatalf("agent fast mode = %#v, want enabled by profile", agents) | |
| 374 | - | } | |
| 375 | - | if got := res.Details.(map[string]any)["fast_mode"]; got != true { | |
| 376 | - | t.Fatalf("fast_mode detail = %v, want true", got) | |
| 377 | - | } | |
| 378 | - | if text := textResult(res.Content); !strings.Contains(text, "warning: subagent profile has fast mode enabled, overriding global fast mode off") { | |
| 379 | - | t.Fatalf("spawn result missing profile override warning:\n%s", text) | |
| 380 | - | } | |
| 381 | - | } | |
| 382 | - | ||
| 383 | - | func TestSubagentSpawnUsesProfileReasoningWhenOmitted(t *testing.T) { | |
| 384 | - | tool := &SubagentSpawnTool{ | |
| 385 | - | Supervisor: newTestSupervisor(t), | |
| 386 | - | Enabled: func() bool { return true }, | |
| 387 | - | ResolveSubagent: func(name string) (*subagents.Profile, error) { | |
| 388 | - | if name != "reviewer" { | |
| 389 | - | return nil, nil | |
| 390 | - | } | |
| 391 | - | return &subagents.Profile{ | |
| 392 | - | Name: "reviewer", | |
| 393 | - | Model: "openai-codex/gpt-5.6-luna", | |
| 394 | - | Thinking: "low", | |
| 395 | - | }, nil | |
| 396 | - | }, | |
| 397 | - | } | |
| 398 | - | ||
| 399 | - | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer"}`), nil) | |
| 400 | - | if err != nil { | |
| 401 | - | t.Fatal(err) | |
| 402 | - | } | |
| 403 | - | if res.IsError { | |
| 404 | - | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |
| 405 | - | } | |
| 406 | - | agents := tool.Supervisor.List() | |
| 407 | - | if len(agents) != 1 || agents[0].Reasoning != "low" { | |
| 408 | - | t.Fatalf("agent reasoning = %#v, want low", agents) | |
| 409 | - | } | |
| 410 | - | } | |
| 411 | - | ||
| 412 | - | func TestSubagentSpawnRejectsUnknownProfile(t *testing.T) { | |
| 413 | - | tool := &SubagentSpawnTool{ | |
| 414 | - | Supervisor: newTestSupervisor(t), | |
| 415 | - | Enabled: func() bool { return true }, | |
| 416 | - | ResolveSubagent: func(string) (*subagents.Profile, error) { | |
| 417 | - | return nil, nil | |
| 418 | - | }, | |
| 419 | - | } | |
| 420 | - | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"missing"}`), nil) | |
| 421 | - | if err != nil { | |
| 422 | - | t.Fatal(err) | |
| 423 | - | } | |
| 424 | - | if !res.IsError || !strings.Contains(textResult(res.Content), "unknown subagent profile") { | |
| 425 | - | t.Fatalf("result = %#v, want unknown profile error", res) | |
| 426 | - | } | |
| 427 | - | if got := len(tool.Supervisor.List()); got != 0 { | |
| 428 | - | t.Fatalf("spawned agents = %d, want 0", got) | |
| 429 | - | } | |
| 430 | - | } | |
| 431 | - | ||
| 432 | - | func TestSubagentSpawnNormalizesMatchingQualifiedModelOverride(t *testing.T) { | |
| 433 | - | tool := &SubagentSpawnTool{ | |
| 434 | - | Supervisor: newTestSupervisor(t), | |
| 435 | - | Enabled: func() bool { return true }, | |
| 436 | - | } | |
| 437 | - | ||
| 438 | - | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","provider":"openai-codex","model":"openai-codex/gpt-5.6-sol"}`), nil) | |
| 439 | - | if err != nil { | |
| 440 | - | t.Fatal(err) | |
| 441 | - | } | |
| 442 | - | if res.IsError { | |
| 443 | - | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |
| 444 | - | } | |
| 445 | - | agents := tool.Supervisor.List() | |
| 446 | - | if len(agents) != 1 { | |
| 447 | - | t.Fatalf("spawned agents = %d, want 1", len(agents)) | |
| 448 | - | } | |
| 449 | - | if got := agents[0].Model; got != "gpt-5.6-sol" { | |
| 450 | - | t.Fatalf("agent model = %q, want unqualified model ID", got) | |
| 451 | - | } | |
| 452 | - | if got := agents[0].Provider; got != "openai-codex" { | |
| 453 | - | t.Fatalf("agent provider = %q, want openai-codex", got) | |
| 454 | - | } | |
| 455 | - | } | |
| 456 | - | ||
| 457 | - | func TestSubagentSpawnRejectsPartialModelProviderOverride(t *testing.T) { | |
| 458 | - | tool := &SubagentSpawnTool{ | |
| 459 | - | Supervisor: newTestSupervisor(t), | |
| 460 | - | Enabled: func() bool { return true }, | |
| 461 | - | DefaultModel: func() string { return "gpt-5" }, | |
| 462 | - | DefaultProvider: func() string { return "openai-codex" }, | |
| 463 | - | } | |
| 464 | - | ||
| 465 | - | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","provider":"openai"}`), nil) | |
| 466 | - | if err != nil { | |
| 467 | - | t.Fatal(err) | |
| 468 | - | } | |
| 469 | - | if !res.IsError { | |
| 470 | - | t.Fatalf("expected partial override to fail") | |
| 471 | - | } | |
| 472 | - | if got := textResult(res.Content); !strings.Contains(got, "omit both model/provider") { | |
| 473 | - | t.Fatalf("error text = %q", got) | |
| 474 | - | } | |
| 475 | - | if got := len(tool.Supervisor.List()); got != 0 { | |
| 476 | - | t.Fatalf("spawned agents = %d, want 0", got) | |
| 477 | - | } | |
| 478 | - | } | |
| 479 | - | ||
| 480 | - | func textResult(content []provider.Content) string { | |
| 481 | - | if len(content) == 0 { | |
| 482 | - | return "" | |
| 483 | - | } | |
| 484 | - | if tb, ok := content[0].(provider.TextBlock); ok { | |
| 485 | - | return tb.Text | |
| 486 | - | } | |
| 487 | - | return "" | |
| 488 | - | } | |
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 { | |||
| 15 | 15 | } | |
| 16 | 16 | ||
| 17 | 17 | type EvTurnStart struct { | |
| 18 | - | Step int | |
| 18 | + | Step int | |
| 19 | + | TurnID string | |
| 19 | 20 | } | |
| 20 | 21 | ||
| 21 | 22 | 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{ | |||
| 353 | 353 | PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125, | |
| 354 | 354 | Speculative: true, | |
| 355 | 355 | }, | |
| 356 | - | ||
| 357 | 356 | // ---- OpenAI Codex / ChatGPT subscription backend ---- | |
| 358 | 357 | // Same model ids as the OpenAI family, but routed through the | |
| 359 | 358 | // ChatGPT Codex OAuth backend rather than api.openai.com. | |