detached 063d011

Merge pull request #90 from bnema/refactor/subagent-completion-tracker

feat(agent): add headless subagent orchestration
commit
063d0110f88c76f11d4b6a5fa5f545b1c158c67f
author
bnema · · 2026-08-09 09:21 UTC
committer
GitHub · · 2026-08-09 09:21 UTC
merge of
399710f + fa4d1f9 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
34 files · +4019 -430
M README.md +11 -1
@@ -192,6 +192,7 @@ zut "fix the failing test" # tui, pre-filled prompt
192 zut -p "list all go files" # print final text, exit192 zut -p "list all go files" # print final text, exit
193 zut -p --stats stats.json "task" # print final text and write generation stats193 zut -p --stats stats.json "task" # print final text and write generation stats
194 zut --json "refactor main.go" # newline-delimited json events, exit194 zut --json "refactor main.go" # newline-delimited json events, exit
195+zut -p --orchestrate --provider openai --model gpt-5 "implement and synthesize"
195 zut --continue # resume the most recent session for this cwd196 zut --continue # resume the most recent session for this cwd
196 zut --resume # pick a session to resume197 zut --resume # pick a session to resume
197 zut --resume <UUID> # resume a persisted session by UUID198 zut --resume <UUID> # resume a persisted session by UUID
@@ -213,7 +214,8 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
213 | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZUT_HOME/SYSTEM.md`). |214 | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZUT_HOME/SYSTEM.md`). |
214 | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |215 | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |
215 | `--reasoning off\|minimum\|low\|medium\|high\|xhigh\|max` | Set the reasoning level on supported models (default: off). `max` is a separate opt-in tier above `xhigh`. |216 | `--reasoning off\|minimum\|low\|medium\|high\|xhigh\|max` | Set the reasoning level on supported models (default: off). `max` is a separate opt-in tier above `xhigh`. |
216-| `--stats <path>` | With `-p`/`--print`, write generation statistics as JSON. |217+| `--stats <path>` | With `-p`/`--print`, write generation statistics as JSON; incompatible with `--orchestrate`. |
218+| `--orchestrate` | In print, stream, or JSON mode, enable bounded headless delegation and final synthesis. It is not implicit. |
217 | `-c`, `--continue` | Resume the latest session for this cwd. |219 | `-c`, `--continue` | Resume the latest session for this cwd. |
218 | `-r`, `--resume [UUID]` | Pick a session to resume, or resume a persisted session by UUID. |220 | `-r`, `--resume [UUID]` | Pick a session to resume, or resume a persisted session by UUID. |
219 | `--session <path>` | Resume a specific session file. |221 | `--session <path>` | Resume a specific session file. |
@@ -248,6 +250,14 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r
248 - **JSON**: `zut --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md).250 - **JSON**: `zut --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md).
249 - **RPC**: `zut rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zut in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients.251 - **RPC**: `zut rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zut in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients.
250 252
253+### Headless orchestration
254+
255+`--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.
256+
257+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.
258+
259+The supervisor enforces configured concurrency, per-parent concurrency, queue/deadline, active-turn, 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.
260+
251 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.261 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.
252 262
253 ## zutfile agents263 ## zutfile agents
M docs/subagents.md +14 -0
@@ -52,6 +52,20 @@ Supported metadata:
52 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.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 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, maximum turns/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+
55 ## Selecting a profile69 ## Selecting a profile
56 70
57 When **auto-subagents** is enabled in `/settings`, 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.71 When **auto-subagents** is enabled in `/settings`, 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.
M packages/agent/args.go +9 -4
@@ -34,10 +34,11 @@ const (
34 34
35 // Args holds parsed command-line options.35 // Args holds parsed command-line options.
36 type Args struct {36 type Args struct {
37- Mode Mode37+ Mode Mode
38- Provider string38+ Orchestrate bool
39- Model string39+ Provider string
40- APIKey string40+ Model string
41+ APIKey string
41 42
42 // inheritedCredential is populated only by subagent-worker mode from43 // inheritedCredential is populated only by subagent-worker mode from
43 // the supervisor's stdin. It is never accepted as a CLI argument.44 // the supervisor's stdin. It is never accepted as a CLI argument.
@@ -200,6 +201,8 @@ func ParseArgs(in []string) (Args, error) {
200 a.Mode = ModeStream201 a.Mode = ModeStream
201 case "--json":202 case "--json":
202 a.Mode = ModeJSON203 a.Mode = ModeJSON
204+ case "--orchestrate":
205+ a.Orchestrate = true
203 case "--rpc":206 case "--rpc":
204 a.Mode = ModeRPC207 a.Mode = ModeRPC
205 case "-c", "--continue":208 case "-c", "--continue":
@@ -518,6 +521,7 @@ func PrintHelp(version string) {
518 row{"zut", "interactive tui"},521 row{"zut", "interactive tui"},
519 row{"zut \"prompt\"", "interactive, pre-filled prompt"},522 row{"zut \"prompt\"", "interactive, pre-filled prompt"},
520 row{"zut -p \"prompt\"", "print final text, exit"},523 row{"zut -p \"prompt\"", "print final text, exit"},
524+ row{"zut -p --orchestrate \"prompt\"", "delegate independent work, print final synthesis, exit"},
521 row{"zut --stream \"prompt\"", "stream assistant text live, exit"},525 row{"zut --stream \"prompt\"", "stream assistant text live, exit"},
522 row{"zut --json \"prompt\"", "newline-delimited json events, exit"},526 row{"zut --json \"prompt\"", "newline-delimited json events, exit"},
523 row{"zut rpc", "json-rpc loop on stdin/stdout (see docs/rpc.md)"},527 row{"zut rpc", "json-rpc loop on stdin/stdout (see docs/rpc.md)"},
@@ -553,6 +557,7 @@ func PrintHelp(version string) {
553 row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"},557 row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"},
554 )558 )
555 section("prompt and session flags",559 section("prompt and session flags",
560+ row{"--orchestrate", "headless delegation and final synthesis (print, stream, or json modes)"},
556 row{"--stats PATH", "write print-mode generation stats as json"},561 row{"--stats PATH", "write print-mode generation stats as json"},
557 row{"--system-prompt TEXT", "replace the default system prompt"},562 row{"--system-prompt TEXT", "replace the default system prompt"},
558 row{"--append-system-prompt TEXT", "append to the system prompt (repeatable)"},563 row{"--append-system-prompt TEXT", "append to the system prompt (repeatable)"},
M packages/agent/args_test.go +61 -0
@@ -1,13 +1,74 @@
1 package agent1 package agent
2 2
3 import (3 import (
4+ "os"
5+ "strings"
4 "testing"6 "testing"
5 "time"7 "time"
6 8
7 "github.com/bnema/zut/packages/agent/subagents"9 "github.com/bnema/zut/packages/agent/subagents"
10+ "github.com/bnema/zut/packages/agent/tools"
8 "github.com/google/uuid"11 "github.com/google/uuid"
9 )12 )
10 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+
11 func TestParseArgsSubagentAndReasoning(t *testing.T) {72 func TestParseArgsSubagentAndReasoning(t *testing.T) {
12 args, err := ParseArgs([]string{"--subagent-worker", "/tmp/in.sock", "--subagent", "reviewer", "--reasoning", "high", "task"})73 args, err := ParseArgs([]string{"--subagent-worker", "/tmp/in.sock", "--subagent", "reviewer", "--reasoning", "high", "task"})
13 if err != nil {74 if err != nil {
M packages/agent/build.go +14 -3
@@ -644,7 +644,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
644 if !CredentialAvailable(other) {644 if !CredentialAvailable(other) {
645 continue645 continue
646 }646 }
647- c, m, a, err := ResolveCredentialFull(other, args.APIKey)647+ // An explicit launch key belongs to the initially selected provider;
648+ // automatic fallback must never reuse it for another provider.
649+ c, m, a, err := ResolveCredentialFull(other, "")
648 provName = other650 provName = other
649 cred, method, accountID, credErr = c, m, a, err651 cred, method, accountID, credErr = c, m, a, err
650 break652 break
@@ -858,14 +860,23 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
858 }860 }
859 interactiveMode := args.Mode == "" || args.Mode == ModeInteractive861 interactiveMode := args.Mode == "" || args.Mode == ModeInteractive
860 primaryInteractive := selectedProfile == nil && interactiveMode862 primaryInteractive := selectedProfile == nil && interactiveMode
861- if primaryInteractive && autoSubagentsToolAllowed(args) {863+ primaryOrchestrator := selectedProfile == nil && args.Orchestrate
864+ if (primaryInteractive || primaryOrchestrator) && autoSubagentsToolAllowed(args) {
862 homeDir, _ := os.UserHomeDir()865 homeDir, _ := os.UserHomeDir()
863 profiles, _ := subagents.Discover(args.CWD, homeDir)866 profiles, _ := subagents.Discover(args.CWD, homeDir)
864 if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {867 if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
865 append_ = append(append_, subagentsAddendum)868 append_ = append(append_, subagentsAddendum)
866 }869 }
867 }870 }
868- if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {871+ if primaryOrchestrator {
872+ // Headless orchestration owns its strict prompt contract rather than
873+ // inheriting the interactive setting.
874+ append_ = append(append_, AutoSubagentsSystemAddendumFor(
875+ autoSubagentsToolAllowed(args),
876+ autoSubagentsStopToolAllowed(args),
877+ autoSubagentsResumeToolAllowed(args),
878+ ))
879+ } else if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
869 append_ = append(append_, AutoSubagentsSystemAddendumFor(880 append_ = append(append_, AutoSubagentsSystemAddendumFor(
870 autoSubagentsToolAllowed(args),881 autoSubagentsToolAllowed(args),
871 autoSubagentsStopToolAllowed(args),882 autoSubagentsStopToolAllowed(args),
M packages/agent/build_test.go +41 -0
@@ -339,6 +339,46 @@ func TestFindSubagentProfileReportsDiscoveryFailure(t *testing.T) {
339 }339 }
340 }340 }
341 341
342+func TestResolveOrchestratedParentPromptScopesManifestAndContract(t *testing.T) {
343+ root := t.TempDir()
344+ home := filepath.Join(root, "home")
345+ project := filepath.Join(root, "repo")
346+ if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil {
347+ t.Fatal(err)
348+ }
349+ if err := os.MkdirAll(project, 0o755); err != nil {
350+ t.Fatal(err)
351+ }
352+ t.Setenv("HOME", home)
353+ t.Setenv("ZUT_HOME", filepath.Join(root, "zut-home"))
354+ t.Setenv("ZUT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
355+ 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 {
356+ t.Fatal(err)
357+ }
358+
359+ parent, err := Resolve(Args{CWD: project, Mode: ModePrint, Orchestrate: true}, false)
360+ if err != nil {
361+ t.Fatal(err)
362+ }
363+ if got := strings.Count(parent.SystemPrompt, "[subagents_list]"); got != 1 {
364+ t.Fatalf("parent profile manifest count = %d, want 1\n%s", got, parent.SystemPrompt)
365+ }
366+ if !strings.Contains(parent.SystemPrompt, "primary-agent orchestrator") {
367+ t.Fatalf("parent prompt omitted strict orchestrator contract:\n%s", parent.SystemPrompt)
368+ }
369+
370+ child, err := Resolve(Args{CWD: project, Mode: ModePrint, Orchestrate: true, Subagent: "reviewer"}, false)
371+ if err != nil {
372+ t.Fatal(err)
373+ }
374+ if strings.Contains(child.SystemPrompt, "primary-agent orchestrator") || strings.Contains(child.SystemPrompt, "[subagents_list]") {
375+ t.Fatalf("selected child inherited parent orchestration instructions:\n%s", child.SystemPrompt)
376+ }
377+ if !strings.Contains(child.SystemPrompt, "Review only.") {
378+ t.Fatalf("selected child profile prompt missing:\n%s", child.SystemPrompt)
379+ }
380+}
381+
342 func TestResolveIncludesNamedSubagentsListWhenAutoSubagentsIsEnabled(t *testing.T) {382 func TestResolveIncludesNamedSubagentsListWhenAutoSubagentsIsEnabled(t *testing.T) {
343 root := t.TempDir()383 root := t.TempDir()
344 home := filepath.Join(root, "home")384 home := filepath.Join(root, "home")
@@ -426,6 +466,7 @@ func TestResolveKeepsStrictContractButOmitsProfilesWhenSpawnIsUnavailable(t *tes
426 {name: "read allowlist", args: Args{CWD: project, Tools: []string{"read"}, ToolsSet: true}},466 {name: "read allowlist", args: Args{CWD: project, Tools: []string{"read"}, ToolsSet: true}},
427 {name: "status allowlist", args: Args{CWD: project, Tools: []string{"subagent_status"}, ToolsSet: true}},467 {name: "status allowlist", args: Args{CWD: project, Tools: []string{"subagent_status"}, ToolsSet: true}},
428 {name: "permission set", args: Args{CWD: project, PermissionSet: &tools.PermissionSet{}}},468 {name: "permission set", args: Args{CWD: project, PermissionSet: &tools.PermissionSet{}}},
469+ {name: "headless orchestration", args: Args{CWD: project, Mode: ModePrint, Orchestrate: true, NoTools: true}},
429 }470 }
430 for _, tc := range cases {471 for _, tc := range cases {
431 t.Run(tc.name, func(t *testing.T) {472 t.Run(tc.name, func(t *testing.T) {
M packages/agent/cli.go +178 -235
@@ -532,10 +532,7 @@ func prepareSessionResumeWithOptions(path string, current *core.Agent, currentPr
532 return candidate, nil532 return candidate, nil
533 }533 }
534 534
535-// buildNonInteractiveSessionAgent constructs a provider/model-specific agent535+func buildNonInteractiveSessionAgentWithRegistry(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, providerName, model string, prepareRegistry func(core.Registry) core.Registry) (*core.Agent, string, string, error) {
536-// for startup resume while preserving the cwd sandbox and extension tools that
537-// were attached to the initial resolution.
538-func buildNonInteractiveSessionAgent(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, providerName, model string) (*core.Agent, string, string, error) {
539 next := args536 next := args
540 next.Provider = providerName537 next.Provider = providerName
541 next.Model = model538 next.Model = model
@@ -547,6 +544,9 @@ func buildNonInteractiveSessionAgent(ctx context.Context, args Args, base Resolv
547 if extMgr != nil {544 if extMgr != nil {
548 resolved.MergeExtensionTools(&extToolAdapter{mgr: extMgr})545 resolved.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
549 }546 }
547+ if prepareRegistry != nil {
548+ prepareRegistry(resolved.ToolRegistry)
549+ }
550 ag := resolved.NewAgent()550 ag := resolved.NewAgent()
551 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)551 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
552 return ag, resolved.Provider, resolved.Model, nil552 return ag, resolved.Provider, resolved.Model, nil
@@ -598,9 +598,44 @@ func applySessionResume(sess *core.Session, ag *core.Agent, currentProvider, cur
598 // contract used by interactive session switching to non-interactive startup598 // contract used by interactive session switching to non-interactive startup
599 // modes. The returned session owns the replacement append handle.599 // modes. The returned session owns the replacement append handle.
600 func applyInitialSessionResume(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, sess *core.Session, ag *core.Agent) (*core.Session, *core.Agent, string, string, error) {600 func applyInitialSessionResume(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, sess *core.Session, ag *core.Agent) (*core.Session, *core.Agent, string, string, error) {
601+ return applyInitialSessionResumeWithRegistry(ctx, args, base, extMgr, sess, ag, nil)
602+}
603+
604+func applyInitialSessionResumeWithRegistry(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, sess *core.Session, ag *core.Agent, prepareRegistry func(core.Registry) core.Registry) (*core.Session, *core.Agent, string, string, error) {
605+ candidate, err := applySessionResume(sess, ag, base.Provider, base.Model,
606+ strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", func(providerName, model string) (*core.Agent, string, string, error) {
607+ return buildNonInteractiveSessionAgentWithRegistry(ctx, args, base, extMgr, providerName, model, prepareRegistry)
608+ })
609+ if err != nil {
610+ return nil, ag, "", "", err
611+ }
612+ return candidate.session, candidate.agent, candidate.provider, candidate.model, nil
613+}
614+
615+func applyInitialSessionResumeWithRuntime(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, sess *core.Session, ag *core.Agent, runtime *subagentRuntime) (*core.Session, *core.Agent, string, string, error) {
601 candidate, err := applySessionResume(sess, ag, base.Provider, base.Model,616 candidate, err := applySessionResume(sess, ag, base.Provider, base.Model,
602 strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", func(providerName, model string) (*core.Agent, string, string, error) {617 strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", func(providerName, model string) (*core.Agent, string, string, error) {
603- return buildNonInteractiveSessionAgent(ctx, args, base, extMgr, providerName, model)618+ next := args
619+ next.Provider = providerName
620+ next.Model = model
621+ resolved, resolveErr := Resolve(next, true)
622+ if resolveErr != nil {
623+ return nil, "", "", resolveErr
624+ }
625+ resolved.UseSandbox(base.Sandbox)
626+ if extMgr != nil {
627+ resolved.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
628+ }
629+ if runtime != nil {
630+ runtime.SetProvider(resolved.Provider)
631+ runtime.SetModel(resolved.Model)
632+ runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
633+ runtime.SetFastMode(resolved.FastMode)
634+ runtime.PrepareResolvedRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
635+ }
636+ rebuilt := resolved.NewAgent()
637+ wireNonInteractiveAgentExtHooks(ctx, rebuilt, extMgr)
638+ return rebuilt, resolved.Provider, resolved.Model, nil
604 })639 })
605 if err != nil {640 if err != nil {
606 return nil, ag, "", "", err641 return nil, ag, "", "", err
@@ -749,6 +784,11 @@ func runWithArgsRaw(rawArgs []string, version string) error {
749 fmt.Println("zut", version)784 fmt.Println("zut", version)
750 return nil785 return nil
751 }786 }
787+ // Reject invalid orchestration combinations before catalog repair or model
788+ // refresh can mutate local state or launch background work.
789+ if err := validateOrchestrationArgs(args); err != nil {
790+ return err
791+ }
752 // Model catalog: load any cached discovery data before we inspect792 // Model catalog: load any cached discovery data before we inspect
753 // the model list (list-models, print/json, interactive).793 // the model list (list-models, print/json, interactive).
754 prepareRuntimeCatalog()794 prepareRuntimeCatalog()
@@ -762,6 +802,9 @@ func runWithArgsRaw(rawArgs []string, version string) error {
762 }802 }
763 803
764 func runWithArgs(args Args, version string) error {804 func runWithArgs(args Args, version string) error {
805+ if err := validateOrchestrationArgs(args); err != nil {
806+ return err
807+ }
765 ctx := context.Background()808 ctx := context.Background()
766 switch args.Mode {809 switch args.Mode {
767 case ModePrint:810 case ModePrint:
@@ -891,6 +934,9 @@ func writePrintStats(path, providerID, model string, usage provider.Usage, elaps
891 }934 }
892 935
893 func runPrintMode(ctx context.Context, args Args, version string) (runErr error) {936 func runPrintMode(ctx context.Context, args Args, version string) (runErr error) {
937+ if args.Orchestrate {
938+ return runOrchestratedPrintMode(ctx, args, version)
939+ }
894 if args.NoYolo {940 if args.NoYolo {
895 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation")941 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation")
896 }942 }
@@ -969,6 +1015,9 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error)
969 }1015 }
970 1016
971 func runStreamMode(ctx context.Context, args Args, version string) (runErr error) {1017 func runStreamMode(ctx context.Context, args Args, version string) (runErr error) {
1018+ if args.Orchestrate {
1019+ return runOrchestratedStreamMode(ctx, args, version)
1020+ }
972 if args.NoYolo {1021 if args.NoYolo {
973 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in stream mode (no interactive prompt available); tools will run without confirmation")1022 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in stream mode (no interactive prompt available); tools will run without confirmation")
974 }1023 }
@@ -1086,6 +1135,9 @@ func newStreamTextSink(out io.Writer) (func(core.AgentEvent), func()) {
1086 }1135 }
1087 1136
1088 func runJSONMode(ctx context.Context, args Args, version string) (runErr error) {1137 func runJSONMode(ctx context.Context, args Args, version string) (runErr error) {
1138+ if args.Orchestrate {
1139+ return runOrchestratedJSONMode(ctx, args, version)
1140+ }
1089 if args.NoYolo {1141 if args.NoYolo {
1090 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")1142 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
1091 }1143 }
@@ -1300,47 +1352,23 @@ func webSearchExplicitlyEnabledForSession(args Args) bool {
1300 // refreshes the agent tool registry + system prompt so skills/extensions1352 // refreshes the agent tool registry + system prompt so skills/extensions
1301 // installed by entry.pre are visible to the following turn.1353 // installed by entry.pre are visible to the following turn.
1302 func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) error {1354 func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) error {
1355+ _, err := reloadResourcesAfterStartupPreWithRegistry(ctx, args, extMgr, sharedSandbox, ag, nil)
1356+ return err
1357+}
1358+
1359+func reloadResourcesAfterStartupPreWithRegistry(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) (subagents.WebSearchPolicy, error) {
1303 if strings.TrimSpace(args.StartupPre) == "" || ag == nil {1360 if strings.TrimSpace(args.StartupPre) == "" || ag == nil {
1304- return nil1361+ return subagents.WebSearchDeny, nil
1305 }1362 }
1306 adapter := &extToolAdapter{mgr: extMgr}1363 adapter := &extToolAdapter{mgr: extMgr}
1307 if extMgr != nil {1364 if extMgr != nil {
1308 _ = extMgr.Reload(ctx, 2*time.Second)1365 _ = extMgr.Reload(ctx, 2*time.Second)
1309 }1366 }
1310- _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, nil, nil)1367+ return refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, mutateRegistry, nil)
1311- return err
1312 }1368 }
1313 1369
1314 // ---- interactive mode: opens the TUI even without credentials ----1370 // ---- interactive mode: opens the TUI even without credentials ----
1315 1371
1316-func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
1317- parseDuration := func(value string) time.Duration {
1318- if strings.TrimSpace(value) == "" {
1319- return 0
1320- }
1321- parsed, err := time.ParseDuration(strings.TrimSpace(value))
1322- if err != nil || parsed <= 0 {
1323- return 0
1324- }
1325- return parsed
1326- }
1327- return subagents.SubagentPolicy{
1328- MaxConcurrent: cfg.MaxConcurrent,
1329- MaxConcurrentPerParent: cfg.MaxConcurrentPerParent,
1330- QueueTimeout: parseDuration(cfg.QueueTimeout),
1331- DefaultTimeout: parseDuration(cfg.DefaultTimeout),
1332- MaxTurns: cfg.MaxTurns,
1333- MaxOutputBytes: cfg.MaxOutputBytes,
1334- MaxOutputLines: cfg.MaxOutputLines,
1335- AllowedTools: append([]string(nil), cfg.AllowedTools...),
1336- AllowedRoots: append([]string(nil), cfg.AllowedRoots...),
1337- HeartbeatInterval: parseDuration(cfg.HeartbeatInterval),
1338- IdleTimeout: parseDuration(cfg.IdleTimeout),
1339- ReconnectTimeout: parseDuration(cfg.ReconnectTimeout),
1340- CancelGracePeriod: parseDuration(cfg.CancelGracePeriod),
1341- }
1342-}
1343-
1344 func runInteractive(ctx context.Context, args Args, version string) (runErr error) {1372 func runInteractive(ctx context.Context, args Args, version string) (runErr error) {
1345 initialCfg, _ := LoadConfig()1373 initialCfg, _ := LoadConfig()
1346 // Resolve WITHOUT requiring credentials.1374 // Resolve WITHOUT requiring credentials.
@@ -1405,78 +1433,12 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1405 r.MergeExtensionTools(extToolAdapter)1433 r.MergeExtensionTools(extToolAdapter)
1406 webSearchGuard := &webSearchSessionGuard{}1434 webSearchGuard := &webSearchSessionGuard{}
1407 1435
1408- // Build the subagent supervisor BEFORE the agent so the auto-subagents1436+ // Keep the shared subagent runtime in the outer scope: session reloads,
1409- // tool can reference it during tool-registry construction. State1437+ // dashboard updates, agent rebuilds, and shutdown all use the same owner.
1410- // lives under ZutHome/subagents so per-agent meta/events survive1438+ var runtime *subagentRuntime
1411- // restarts; the user can hunt orphaned agents down with
1412- // `git worktree list` if anything misbehaves.
1413- //
1414- // subagentsMgr is also captured by loadSession / changeCWD closures
1415- // further down the function, which is why we keep the variable
1416- // in this outer scope rather than scoping it tighter.
1417 var subagentsMgr *subagents.Supervisor1439 var subagentsMgr *subagents.Supervisor
1418- subagentsMgr = subagents.New(subagents.Config{1440+ // These callbacks deliberately resolve iv lazily because the Interactive
1419- Context: ctx,1441+ // instance is constructed after the agent and its registry.
1420- Root: filepath.Join(ZutHome(), "subagents"),
1421- RepoRoot: r.CWD,
1422- Provider: r.Provider,
1423- FastMode: r.FastMode,
1424- WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
1425- BaseURL: r.BaseURL,
1426- InsecureTLS: r.InsecureTLS,
1427- Policy: subagentPolicyFromConfig(initialCfg.Subagents),
1428- // Keep an explicit launch key in this callback only. Supervisor persists
1429- // connection settings, but never stores credentials in Agent metadata
1430- // or puts them in the worker's argv/environment.
1431- ResolveCredential: func(ctx context.Context, providerID string) (subagents.Credential, error) {
1432- childProvider := canonicalProvider(providerID)
1433- persistMu.Lock()
1434- hostProvider := canonicalProvider(activeProvider)
1435- persistMu.Unlock()
1436- if childProvider == "" {
1437- childProvider = hostProvider
1438- }
1439- explicit := ""
1440- // A launch-time API key is scoped to the provider selected at
1441- // startup. Never send it to an explicitly different child
1442- // provider; resolve that provider's own credential instead.
1443- if args.APIKey != "" && childProvider == hostProvider {
1444- explicit = args.APIKey
1445- }
1446- if childProvider == "ollama" && explicit == "" {
1447- return subagents.Credential{Value: "ollama", Method: "apikey"}, nil
1448- }
1449- credential, method, accountID, err := ResolveCredentialFullContext(ctx, childProvider, explicit)
1450- if err != nil {
1451- // Providers backed only by a local endpoint do not need a key.
1452- // Leave stdin untouched and let the child resolve that endpoint.
1453- if !CredentialAvailable(childProvider) {
1454- return subagents.Credential{}, nil
1455- }
1456- return subagents.Credential{}, err
1457- }
1458- return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil
1459- },
1460- })
1461- if args.PermissionSet != nil {
1462- // Packaged zutfile agents have an explicit capability ceiling. A
1463- // child process would otherwise rebuild a fresh unrestricted sandbox,
1464- // so do not expose either slash or tool-based delegation from this
1465- // restricted host until worker capability propagation exists.
1466- subagentsMgr = nil
1467- } else {
1468- // Replaying every historical event log can take seconds for users with
1469- // many completed subagents. The manager is safe for concurrent snapshots,
1470- // so populate the detached-agent dashboard in the background instead of
1471- // blocking the first interactive paint.
1472- go func() { _, _ = subagentsMgr.Reload() }()
1473- }
1474-
1475- // onSpawnedSupervisor is the OnSpawned callback the subagent_spawn tool
1476- // fires after every successful spawn. It hands the agent off to
1477- // the running Interactive so the watcher can flush a summary back
1478- // into chat when all sub-agents finish. Reads `iv` lazily because
1479- // the Interactive is constructed after the agent.
1480 onSpawnedSupervisor := func(a *subagents.Agent, task string) {1442 onSpawnedSupervisor := func(a *subagents.Agent, task string) {
1481 if iv != nil {1443 if iv != nil {
1482 iv.TrackSubagentWorker(a, task)1444 iv.TrackSubagentWorker(a, task)
@@ -1487,86 +1449,77 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1487 iv.TrackResumedSubagentWorker(a, prompt)1449 iv.TrackResumedSubagentWorker(a, prompt)
1488 }1450 }
1489 }1451 }
1452+ onBeforeResumedSupervisor := func(a *subagents.Agent, prompt string) func() {
1453+ if iv != nil {
1454+ return iv.PrepareResumedSubagentWorker(a, prompt)
1455+ }
1456+ return nil
1457+ }
1490 onStopRequestedSupervisor := func(a *subagents.Agent) {1458 onStopRequestedSupervisor := func(a *subagents.Agent) {
1491 if iv != nil {1459 if iv != nil {
1492 iv.TrackStoppedSubagentWorker(a)1460 iv.TrackStoppedSubagentWorker(a)
1493 }1461 }
1494 }1462 }
1495- resolveSubagent := func(name string) (*subagents.Profile, error) {1463+ activeProviderForSubagents := func() string {
1496- return findSubagentProfile(r.CWD, name)1464+ persistMu.Lock()
1497- }1465+ defer persistMu.Unlock()
1498-1466+ return activeProvider
1499- // Register the canonical subagent tools whenever launch-time policy permits
1500- // them. The auto-subagents setting controls strict orchestration guidance,
1501- // not explicit user-requested delegation.
1502- injectSubagentTools := func(reg core.Registry) core.Registry {
1503- if reg == nil || subagentsMgr == nil || !autoSubagentsAnyToolAllowed(args) {
1504- return reg
1505- }
1506- if autoSubagentsToolAllowed(args) {
1507- canonical := &tools.SubagentSpawnTool{
1508- Supervisor: subagentsMgr,
1509- Enabled: func() bool { return true },
1510- DefaultModel: func() string {
1511- persistMu.Lock()
1512- defer persistMu.Unlock()
1513- return activeModel
1514- },
1515- DefaultProvider: func() string {
1516- persistMu.Lock()
1517- defer persistMu.Unlock()
1518- return activeProvider
1519- },
1520- DefaultReasoning: func() string { return r.Reasoning },
1521- ResolveSubagent: resolveSubagent,
1522- OnSpawned: onSpawnedSupervisor,
1523- }
1524- reg[canonical.Name()] = canonical
1525- }
1526- if autoSubagentsStatusToolAllowed(args) {
1527- statusTool := &tools.SubagentStatusTool{
1528- Supervisor: subagentsMgr,
1529- Enabled: func() bool { return true },
1530- }
1531- reg[statusTool.Name()] = statusTool
1532- }
1533- if autoSubagentsStopToolAllowed(args) {
1534- stopTool := &tools.SubagentStopTool{
1535- Supervisor: subagentsMgr,
1536- Enabled: func() bool { return true },
1537- OnStopRequested: onStopRequestedSupervisor,
1538- }
1539- reg[stopTool.Name()] = stopTool
1540- }
1541- if autoSubagentsResumeToolAllowed(args) {
1542- resumeTool := &tools.SubagentResumeTool{
1543- Supervisor: subagentsMgr,
1544- Enabled: func() bool { return true },
1545- OnResumed: onResumedSupervisor,
1546- }
1547- reg[resumeTool.Name()] = resumeTool
1548- }
1549- return reg
1550 }1467 }
1551- prepareInteractiveRegistry := func(reg core.Registry) core.Registry {1468+ activeModelForSubagents := func() string {
1552- reg = injectSubagentTools(reg)1469+ persistMu.Lock()
1553- return webSearchGuard.wrapRegistry(reg)1470+ defer persistMu.Unlock()
1471+ return activeModel
1554 }1472 }
1555- prepareResolvedInteractiveRegistry := func(reg core.Registry, policy subagents.WebSearchPolicy) core.Registry {1473+
1556- // The resolved policy is a ceiling. In particular, an extension named1474+ runtime = newSubagentRuntime(subagentRuntimeConfig{
1557- // web_search must not reintroduce the capability when --no-tools,1475+ Context: ctx,
1558- // --tools, a packaged PermissionSet, or persisted settings deny it.1476+ Args: args,
1559- if policy != subagents.WebSearchAllow {1477+ Root: filepath.Join(ZutHome(), "subagents"),
1560- delete(reg, "web_search")1478+ RepoRoot: r.CWD,
1561- }1479+ Provider: r.Provider,
1562- return prepareInteractiveRegistry(reg)1480+ Model: r.Model,
1481+ Reasoning: r.Reasoning,
1482+ BaseURL: r.BaseURL,
1483+ InsecureTLS: r.InsecureTLS,
1484+ FastMode: r.FastMode,
1485+ APIKey: args.APIKey,
1486+ Policy: subagentPolicyFromConfig(initialCfg.Subagents),
1487+ WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
1488+ WebSearchGuard: webSearchGuard,
1489+ ActiveProvider: activeProviderForSubagents,
1490+ ActiveModel: activeModelForSubagents,
1491+ OnSpawned: onSpawnedSupervisor,
1492+ OnResumed: onResumedSupervisor,
1493+ BeforeResumed: onBeforeResumedSupervisor,
1494+ OnStopRequested: onStopRequestedSupervisor,
1495+ })
1496+ subagentsMgr = runtime.Supervisor()
1497+ reloadDone := make(chan struct{})
1498+ var reloadErrs []error
1499+ if subagentsMgr != nil {
1500+ // Replaying historical event logs can take seconds. Populate the
1501+ // detached-agent dashboard without blocking the first interactive paint.
1502+ go func() {
1503+ _, reloadErrs = subagentsMgr.Reload()
1504+ close(reloadDone)
1505+ }()
1506+ } else {
1507+ close(reloadDone)
1563 }1508 }
1509+ defer func() {
1510+ // Reload may attach detached workers, so let it finish before shutdown
1511+ // snapshots the supervisor's worker set.
1512+ <-reloadDone
1513+ _ = closeSubagentRuntimeFresh(runtime)
1514+ }()
1515+
1516+ prepareInteractiveRegistry := runtime.PrepareRegistry
1517+ prepareResolvedInteractiveRegistry := runtime.PrepareResolvedRegistry
1564 prepareResolvedInteractiveRegistry(r.ToolRegistry, r.WebSearchPolicy)1518 prepareResolvedInteractiveRegistry(r.ToolRegistry, r.WebSearchPolicy)
1565 initialWebSearchPolicy := webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry)1519 initialWebSearchPolicy := webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry)
1566 webSearchGuard.setAvailable(initialWebSearchPolicy == subagents.WebSearchAllow)1520 webSearchGuard.setAvailable(initialWebSearchPolicy == subagents.WebSearchAllow)
1567- if subagentsMgr != nil {1521+ runtime.SetWebSearchPolicy(initialWebSearchPolicy)
1568- subagentsMgr.SetWebSearchPolicy(initialWebSearchPolicy)1522+ resolveSubagent := runtime.resolveSubagent
1569- }
1570 1523
1571 // Confirmation gate: when --no-yolo is on, the agent must ask1524 // Confirmation gate: when --no-yolo is on, the agent must ask
1572 // the user before every tool call. In interactive mode the TUI1525 // the user before every tool call. In interactive mode the TUI
@@ -1650,10 +1603,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1650 return nil, "", "", err1603 return nil, "", "", err
1651 }1604 }
1652 resolved.UseSandbox(sharedSandbox)1605 resolved.UseSandbox(sharedSandbox)
1653- if subagentsMgr != nil {1606+ runtime.SetProvider(resolved.Provider)
1654- subagentsMgr.SetProvider(resolved.Provider)1607+ runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1655- subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)1608+ runtime.SetFastMode(resolved.FastMode)
1656- }
1657 resolved.MergeExtensionTools(extToolAdapter)1609 resolved.MergeExtensionTools(extToolAdapter)
1658 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)1610 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1659 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1611 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
@@ -1673,10 +1625,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1673 return nil, "", "", err1625 return nil, "", "", err
1674 }1626 }
1675 resolved.UseSandbox(sharedSandbox)1627 resolved.UseSandbox(sharedSandbox)
1676- if subagentsMgr != nil {1628+ runtime.SetProvider(resolved.Provider)
1677- subagentsMgr.SetProvider(resolved.Provider)1629+ runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1678- subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)1630+ runtime.SetFastMode(resolved.FastMode)
1679- }
1680 resolved.MergeExtensionTools(extToolAdapter)1631 resolved.MergeExtensionTools(extToolAdapter)
1681 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)1632 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1682 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1633 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
@@ -1704,10 +1655,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1704 return nil, "", "", err1655 return nil, "", "", err
1705 }1656 }
1706 resolved.UseSandbox(sharedSandbox)1657 resolved.UseSandbox(sharedSandbox)
1707- if subagentsMgr != nil {1658+ runtime.SetProvider(resolved.Provider)
1708- subagentsMgr.SetProvider(resolved.Provider)1659+ runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1709- subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)1660+ runtime.SetFastMode(resolved.FastMode)
1710- }
1711 resolved.MergeExtensionTools(extToolAdapter)1661 resolved.MergeExtensionTools(extToolAdapter)
1712 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)1662 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1713 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1663 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
@@ -1927,19 +1877,24 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1927 persistMu.Unlock()1877 persistMu.Unlock()
1928 1878
1929 // A candidate build must not publish its provider/model as the live1879 // A candidate build must not publish its provider/model as the live
1930- // selection before the candidate commits.1880+ // selection before the candidate commits. The builder also stages
1931- candidate, err := prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)1881+ // provider settings on the shared runtime, so restore those settings on
1932- if err != nil {1882+ // every failed path, including errors returned during preparation.
1933- // prepareSessionResume closes its append handle on failure;1883+ runtimeConfig := runtime.snapshotConfiguration()
1934- // no live agent or session has been changed.
1935- return err
1936- }
1937 committed := false1884 committed := false
1885+ var candidate sessionResumeCandidate
1938 defer func() {1886 defer func() {
1939 if !committed {1887 if !committed {
1888+ runtime.restoreConfiguration(runtimeConfig)
1940 loadErr = joinSessionCloseError(loadErr, candidate.session)1889 loadErr = joinSessionCloseError(loadErr, candidate.session)
1941 }1890 }
1942 }()1891 }()
1892+ candidate, err := prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)
1893+ if err != nil {
1894+ // prepareSessionResume closes its append handle on failure;
1895+ // no live agent or session has been changed.
1896+ return err
1897+ }
1943 1898
1944 // Wire the private candidate before it can become visible. This is1899 // Wire the private candidate before it can become visible. This is
1945 // also required for legacy same-provider resumes, where the current1900 // also required for legacy same-provider resumes, where the current
@@ -1995,7 +1950,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1995 if iv != nil {1950 if iv != nil {
1996 iv.SetSubagentSessionScope(candidate.session.ID)1951 iv.SetSubagentSessionScope(candidate.session.ID)
1997 } else {1952 } else {
1998- subagentsMgr.SetActiveSession(candidate.session.ID)1953+ runtime.SetActiveSession(candidate.session.ID)
1999 }1954 }
2000 }1955 }
2001 committed = true1956 committed = true
@@ -2073,10 +2028,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2073 oldActiveProvider := activeProvider2028 oldActiveProvider := activeProvider
2074 oldActiveModel := activeModel2029 oldActiveModel := activeModel
2075 persistMu.Unlock()2030 persistMu.Unlock()
2076- oldSupervisorRoot := ""2031+ runtimeConfig := runtime.snapshotConfiguration()
2077- if subagentsMgr != nil {
2078- oldSupervisorRoot = subagentsMgr.RepoRoot()
2079- }
2080 oldSandboxRoot := ""2032 oldSandboxRoot := ""
2081 oldSandboxLocked := false2033 oldSandboxLocked := false
2082 if sharedSandbox != nil {2034 if sharedSandbox != nil {
@@ -2091,9 +2043,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2091 activeProvider = oldActiveProvider2043 activeProvider = oldActiveProvider
2092 activeModel = oldActiveModel2044 activeModel = oldActiveModel
2093 persistMu.Unlock()2045 persistMu.Unlock()
2094- if subagentsMgr != nil {2046+ runtime.restoreConfiguration(runtimeConfig)
2095- subagentsMgr.SetRepoRoot(oldSupervisorRoot)
2096- }
2097 if sharedSandbox != nil {2047 if sharedSandbox != nil {
2098 sharedSandbox.Root = oldSandboxRoot2048 sharedSandbox.Root = oldSandboxRoot
2099 if oldSandboxLocked {2049 if oldSandboxLocked {
@@ -2106,9 +2056,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2106 2056
2107 args.CWD = absPath2057 args.CWD = absPath
2108 r.CWD = absPath2058 r.CWD = absPath
2109- if subagentsMgr != nil {2059+ runtime.SetRepoRoot(absPath)
2110- subagentsMgr.SetRepoRoot(absPath)
2111- }
2112 if args.PermissionSet != nil {2060 if args.PermissionSet != nil {
2113 expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)2061 expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)
2114 args.PermissionSet = &expanded2062 args.PermissionSet = &expanded
@@ -2189,11 +2137,11 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2189 }2137 }
2190 2138
2191 // Re-scope the subagent dashboard to the new session.2139 // Re-scope the subagent dashboard to the new session.
2192- if subagentsMgr != nil && newSess != nil {2140+ if newSess != nil {
2193 if iv != nil {2141 if iv != nil {
2194 iv.SetSubagentSessionScope(newSess.ID)2142 iv.SetSubagentSessionScope(newSess.ID)
2195 } else {2143 } else {
2196- subagentsMgr.SetActiveSession(newSess.ID)2144+ runtime.SetActiveSession(newSess.ID)
2197 }2145 }
2198 }2146 }
2199 if newSess != nil {2147 if newSess != nil {
@@ -2272,25 +2220,10 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2272 }2220 }
2273 }2221 }
2274 2222
2275- // subagentsMgr was constructed and reloaded earlier (before the agent2223+ // Scope the dashboard to the active host session. The runtime owns
2276- // build, so the auto-subagents tool could capture it). Here we just2224+ // shutdown separately and closes the supervisor exactly once.
2277- // scope the dashboard to the active host session so /subagents only2225+ if sess != nil {
2278- // shows agents this session spawned (and any pre-upgrade unscoped2226+ runtime.SetActiveSession(sess.ID)
2279- // agents — see SnapshotAll docs). Updated again whenever the
2280- // user swaps sessions via loadSession below.
2281- if sess != nil && subagentsMgr != nil {
2282- subagentsMgr.SetActiveSession(sess.ID)
2283- }
2284- // Best-effort shutdown on normal interactive exit: stop only workers
2285- // owned by the active host session. Other session scopes can share this
2286- // supervisor and must remain alive.
2287- if subagentsMgr != nil {
2288- defer func() {
2289- rootSessionID := subagentsMgr.ActiveSession()
2290- if rootSessionID != "" {
2291- _ = subagentsMgr.StopByRootSession(rootSessionID)
2292- }
2293- }()
2294 }2227 }
2295 2228
2296 var startupSkills []*skills.Skill2229 var startupSkills []*skills.Skill
@@ -2315,7 +2248,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2315 if available {2248 if available {
2316 policy = subagents.WebSearchAllow2249 policy = subagents.WebSearchAllow
2317 }2250 }
2318- subagentsMgr.SetWebSearchPolicy(policy)2251+ runtime.SetWebSearchPolicy(policy)
2319 }2252 }
2320 2253
2321 iv = modes.NewInteractive(modes.InteractiveConfig{2254 iv = modes.NewInteractive(modes.InteractiveConfig{
@@ -2363,6 +2296,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2363 AuthMethod: r.AuthMethod,2296 AuthMethod: r.AuthMethod,
2364 BaseURL: r.BaseURL,2297 BaseURL: r.BaseURL,
2365 Reasoning: r.Reasoning,2298 Reasoning: r.Reasoning,
2299+ OnReasoningChanged: runtime.SetReasoning,
2366 SystemPrompt: r.SystemPrompt,2300 SystemPrompt: r.SystemPrompt,
2367 Tools: r.ToolRegistry,2301 Tools: r.ToolRegistry,
2368 MaxSteps: r.MaxSteps,2302 MaxSteps: r.MaxSteps,
@@ -2573,6 +2507,14 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2573 },2507 },
2574 })2508 })
2575 extHooks.attachInteractive(iv)2509 extHooks.attachInteractive(iv)
2510+ go func() {
2511+ <-reloadDone
2512+ for _, reloadErr := range reloadErrs {
2513+ if reloadErr != nil {
2514+ iv.ReportError(fmt.Errorf("reload subagents: %w", reloadErr))
2515+ }
2516+ }
2517+ }()
2576 2518
2577 // Bind the interactive TUI as the Confirmer. We deferred this2519 // Bind the interactive TUI as the Confirmer. We deferred this
2578 // until now because the gate is constructed before the TUI2520 // until now because the gate is constructed before the TUI
@@ -2624,12 +2566,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2624 closeAgentLSP(finalAg)2566 closeAgentLSP(finalAg)
2625 closeResolvedLSP(r)2567 closeResolvedLSP(r)
2626 // Stop supervised workers before exiting. os.Exit skips deferred2568 // Stop supervised workers before exiting. os.Exit skips deferred
2627- // cleanup, so relying only on the normal runInteractive defer would2569+ // cleanup, so wait for reload and close the shared runtime explicitly.
2628- // leave child daemons (and their descendants) alive after a TERM or2570+ <-reloadDone
2629- // HUP delivered to the host.2571+ _ = closeSubagentRuntimeFresh(runtime)
2630- if subagentsMgr != nil {
2631- subagentsMgr.StopAll()
2632- }
2633 // Exit cleanly. Re-raising the signal would skip os.Exit's2572 // Exit cleanly. Re-raising the signal would skip os.Exit's
2634 // at-exit hooks; explicit exit is fine because we've already2573 // at-exit hooks; explicit exit is fine because we've already
2635 // flushed the only at-risk state (the session file) and stopped2574 // flushed the only at-risk state (the session file) and stopped
@@ -2825,7 +2764,11 @@ func readAllStdin() (string, error) {
2825 if (fi.Mode() & os.ModeCharDevice) != 0 {2764 if (fi.Mode() & os.ModeCharDevice) != 0 {
2826 return "", nil2765 return "", nil
2827 }2766 }
2828- b, err := io.ReadAll(os.Stdin)2767+ return readAllStdinFrom(os.Stdin)
2768+}
2769+
2770+func readAllStdinFrom(r io.Reader) (string, error) {
2771+ b, err := io.ReadAll(r)
2829 return string(b), err2772 return string(b), err
2830 }2773 }
2831 2774
M packages/agent/cli_session_test.go +52 -0
@@ -18,6 +18,16 @@ import (
18 "github.com/google/uuid"18 "github.com/google/uuid"
19 )19 )
20 20
21+type failingStdinReader struct{}
22+
23+func (failingStdinReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") }
24+
25+func TestReadAllStdinFromPropagatesReadErrors(t *testing.T) {
26+ if _, err := readAllStdinFrom(failingStdinReader{}); err == nil || !strings.Contains(err.Error(), "stdin failed") {
27+ t.Fatalf("readAllStdinFrom error = %v, want stdin failure", err)
28+ }
29+}
30+
21 func TestOpenOrCreateSessionResumesByUUIDAcrossCWDAndAgentStores(t *testing.T) {31 func TestOpenOrCreateSessionResumesByUUIDAcrossCWDAndAgentStores(t *testing.T) {
22 root := t.TempDir()32 root := t.TempDir()
23 cwd := t.TempDir()33 cwd := t.TempDir()
@@ -412,6 +422,48 @@ func TestApplyInitialSessionResumeKeepsFreshEmptySessionOwned(t *testing.T) {
412 }422 }
413 }423 }
414 424
425+func TestApplyInitialSessionResumeWithRuntimePreservesInheritedModelDefaults(t *testing.T) {
426+ t.Setenv("ZUT_HOME", t.TempDir())
427+ if err := SaveConfig(Config{Provider: "ollama", Model: "current-local-model"}); err != nil {
428+ t.Fatal(err)
429+ }
430+ path := syntheticSession(t, "ollama", "stored-local-model", provider.Usage{})
431+ args := Args{Mode: ModePrint, Orchestrate: true, CWD: t.TempDir()}
432+ base, err := Resolve(args, true)
433+ if err != nil {
434+ t.Fatal(err)
435+ }
436+ current := base.NewAgent()
437+ runtime := newSubagentRuntime(subagentRuntimeConfig{
438+ Context: context.Background(),
439+ Args: args,
440+ Root: t.TempDir(),
441+ RepoRoot: args.CWD,
442+ Provider: base.Provider,
443+ Model: base.Model,
444+ })
445+ defer func() { _ = runtime.Close(context.Background()) }()
446+
447+ sess, _, err := core.OpenSession(path)
448+ if err != nil {
449+ t.Fatal(err)
450+ }
451+ gotSess, gotAgent, providerName, model, err := applyInitialSessionResumeWithRuntime(context.Background(), args, base, nil, sess, current, runtime)
452+ if err != nil {
453+ t.Fatal(err)
454+ }
455+ defer gotSess.Close()
456+ if gotAgent == current || providerName != "ollama" || model != "stored-local-model" {
457+ t.Fatalf("resume candidate = rebuilt=%v provider/model=%q/%q", gotAgent == current, providerName, model)
458+ }
459+ if got := runtime.currentModel(); got != "stored-local-model" {
460+ t.Fatalf("runtime inherited model = %q, want stored-local-model", got)
461+ }
462+ if _, ok := gotAgent.ToolsSnapshot()["subagent_spawn"]; !ok {
463+ t.Fatal("resumed parent lost canonical subagent_spawn manager tool")
464+ }
465+}
466+
415 func TestPrepareSessionResumeRebuildsForStoredProviderAndModel(t *testing.T) {467 func TestPrepareSessionResumeRebuildsForStoredProviderAndModel(t *testing.T) {
416 old := core.NewAgent(nil, "old-model", "", nil)468 old := core.NewAgent(nil, "old-model", "", nil)
417 old.SetMessages([]provider.Message{{469 old.SetMessages([]provider.Message{{
M packages/agent/modes/auto_subagents_test.go +106 -9
@@ -222,7 +222,7 @@ func TestTrackResumedSubagentWorkerDeliversFollowUp(t *testing.T) {
222 if a.OnTurnEnd == nil {222 if a.OnTurnEnd == nil {
223 t.Fatal("resumed worker has no completion watcher")223 t.Fatal("resumed worker has no completion watcher")
224 }224 }
225- a.OnTurnEnd(2, "")225+ a.OnTurnEnd(1, "")
226 226
227 update := waitForQueuedPrompt(t, iv)227 update := waitForQueuedPrompt(t, iv)
228 if !strings.Contains(update, "task: "+followUp) {228 if !strings.Contains(update, "task: "+followUp) {
@@ -233,6 +233,103 @@ func TestTrackResumedSubagentWorkerDeliversFollowUp(t *testing.T) {
233 }233 }
234 }234 }
235 235
236+func TestTrackResumedSubagentWorkerQueuesActiveFollowUpOnSameWorker(t *testing.T) {
237+ started := make(chan struct{})
238+ mgr := subagents.New(subagents.Config{
239+ Root: t.TempDir(),
240+ RepoRoot: t.TempDir(),
241+ NewRunner: func(*subagents.Agent) subagents.Runner {
242+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
243+ select {
244+ case started <- struct{}{}:
245+ default:
246+ }
247+ <-ctx.Done()
248+ return ctx.Err()
249+ })
250+ },
251+ })
252+ first, err := mgr.Spawn(context.Background(), "original task")
253+ if err != nil {
254+ t.Fatal(err)
255+ }
256+ <-started
257+ t.Cleanup(func() {
258+ _ = mgr.Stop(first.ID)
259+ first.Wait()
260+ })
261+
262+ iv := newQueuedAutoSubagentsInteractive()
263+ iv.TrackSubagentWorker(first, first.Task)
264+ const followUp = "queued follow-up"
265+ cancel := iv.PrepareResumedSubagentWorker(first, followUp)
266+ if cancel == nil {
267+ t.Fatal("resume registration returned nil cleanup")
268+ }
269+ if first.OnTurnEnd == nil {
270+ t.Fatal("worker has no completion watcher")
271+ }
272+ first.OnTurnEnd(1, "")
273+ first.OnTurnEnd(2, "")
274+
275+ update := waitForQueuedPrompt(t, iv)
276+ if !strings.Contains(update, "[auto-subagents update] 2 sub-agent(s) finished:") {
277+ t.Fatalf("active follow-up was not batched with the original turn: %q", update)
278+ }
279+ if !strings.Contains(update, "task: "+followUp) {
280+ t.Fatalf("active follow-up task missing: %q", update)
281+ }
282+}
283+
284+func TestCompletionDeliveryHoldBatchesFastAndLateRegistrations(t *testing.T) {
285+ mgr := subagents.New(subagents.Config{
286+ Root: t.TempDir(),
287+ RepoRoot: t.TempDir(),
288+ NewRunner: func(*subagents.Agent) subagents.Runner {
289+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
290+ <-ctx.Done()
291+ return ctx.Err()
292+ })
293+ },
294+ })
295+ first, err := mgr.Spawn(context.Background(), "first task")
296+ if err != nil {
297+ t.Fatal(err)
298+ }
299+ second, err := mgr.Spawn(context.Background(), "second task")
300+ if err != nil {
301+ t.Fatal(err)
302+ }
303+ t.Cleanup(func() {
304+ _ = mgr.Stop(first.ID)
305+ _ = mgr.Stop(second.ID)
306+ first.Wait()
307+ second.Wait()
308+ })
309+
310+ iv := newQueuedAutoSubagentsInteractive()
311+ release := iv.beginCompletionDeliveryHold()
312+ iv.TrackSubagentWorker(first, first.Task)
313+ first.OnTurnEnd(1, "")
314+ iv.TrackSubagentWorker(second, second.Task)
315+ second.OnTurnEnd(1, "")
316+ release()
317+
318+ update := waitForQueuedPrompt(t, iv)
319+ if !strings.Contains(update, "[auto-subagents update] 2 sub-agent(s) finished:") {
320+ t.Fatalf("fast and late registrations were not batched: %q", update)
321+ }
322+ if !strings.Contains(update, "task: first task") || !strings.Contains(update, "task: second task") {
323+ t.Fatalf("batched update omitted one task: %q", update)
324+ }
325+ iv.mu.Lock()
326+ queued := len(iv.queued)
327+ iv.mu.Unlock()
328+ if queued != 1 {
329+ t.Fatalf("queued updates = %d; want exactly one batched update", queued)
330+ }
331+}
332+
236 func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {333 func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {
237 started := make(chan struct{})334 started := make(chan struct{})
238 mgr := subagents.New(subagents.Config{335 mgr := subagents.New(subagents.Config{
@@ -257,10 +354,11 @@ func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {
257 })354 })
258 355
259 iv := newQueuedAutoSubagentsInteractive()356 iv := newQueuedAutoSubagentsInteractive()
260- entry := &subagentWatchEntry{agent: a, task: a.Task}357+ iv.TrackSubagentWorker(a, a.Task)
261- iv.subagentWatch = []*subagentWatchEntry{entry}358+ if a.OnTurnEnd == nil {
262- iv.completeSupervisorWatchEntry(entry, "completed", "")359+ t.Fatal("worker has no completion watcher")
263- iv.completeSupervisorWatchEntry(entry, "failed", "late error")360+ }
361+ a.OnTurnEnd(1, "")
264 362
265 update := waitForQueuedPrompt(t, iv)363 update := waitForQueuedPrompt(t, iv)
266 if !strings.Contains(update, "status: completed") {364 if !strings.Contains(update, "status: completed") {
@@ -308,10 +406,9 @@ func TestAutoSubagentsUpdateQueuedAtTurnEndStartsFollowUp(t *testing.T) {
308 return406 return
309 }407 }
310 once.Do(func() {408 once.Do(func() {
311- interactive.flushSupervisorSummary([]*subagentWatchEntry{{409+ 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."
312- agent: &subagents.Agent{},410+ update := subagents.FormatCompletionUpdate([]subagents.Completion{{Task: "smoke test"}}, instruction)
313- task: "smoke test",411+ interactive.SubmitOrQueue(update, nil)
314- }})
315 })412 })
316 }413 }
317 414
M packages/agent/modes/interactive.go +131 -145
@@ -345,6 +345,9 @@ type InteractiveConfig struct {
345 // It should update config.json and (if there's an active session)345 // It should update config.json and (if there's an active session)
346 // write a new meta row so resume picks up the same model.346 // write a new meta row so resume picks up the same model.
347 PersistModel func(providerName, model string)347 PersistModel func(providerName, model string)
348+ // OnReasoningChanged updates host-owned runtime defaults after the setting
349+ // has been persisted successfully.
350+ OnReasoningChanged func(level string)
348 351
349 // InitialSessionTitle is the persisted title of the session loaded before352 // InitialSessionTitle is the persisted title of the session loaded before
350 // the TUI starts. It is shown in the terminal without another model call.353 // the TUI starts. It is shown in the terminal without another model call.
@@ -774,14 +777,14 @@ type Interactive struct {
774 extPanel *extPanelDialog777 extPanel *extPanelDialog
775 llamaConfigured bool778 llamaConfigured bool
776 779
777- // subagentWatch tracks auto-subagents sub-agents the main agent spawned780+ // completionTracker observes auto-subagent turn and process completion.
778- // via subagent_spawn. Each entry holds the agent + the task text;781+ // Delivery is kept separate so every active set is injected as one
779- // a per-entry goroutine waits on the agent's terminal state. When782+ // summary turn, preserving the existing queue behavior.
780- // every tracked entry has finished, the watcher flushes a single783+ completionTracker *subagents.CompletionTracker
781- // summary turn into the main chat (queued if the main agent is784+ completionDeliveryMu sync.Mutex
782- // busy, run immediately if idle).785+ completionDeliveryRunning bool
783- subagentWatchMu sync.Mutex786+ completionDeliveryRequest bool
784- subagentWatch []*subagentWatchEntry787+ completionDeliveryHolds int
785 788
786 // subagentsWaitWatcherDone is an optional test hook invoked when a789 // subagentsWaitWatcherDone is an optional test hook invoked when a
787 // /subagents wait watcher exits.790 // /subagents wait watcher exits.
@@ -1026,7 +1029,9 @@ func (i *Interactive) markCtrlCExit() {
1026 1029
1027 // Run blocks until the user quits.1030 // Run blocks until the user quits.
1028 func (i *Interactive) Run(ctx context.Context) error {1031 func (i *Interactive) Run(ctx context.Context) error {
1032+ i.mu.Lock()
1029 i.runCtx = ctx1033 i.runCtx = ctx
1034+ i.mu.Unlock()
1030 term := i.cfg.Terminal1035 term := i.cfg.Terminal
1031 restore, err := term.EnterRaw()1036 restore, err := term.EnterRaw()
1032 if err != nil {1037 if err != nil {
@@ -5629,6 +5634,9 @@ func (i *Interactive) applyReasoningSetting(level string) {
5629 return5634 return
5630 }5635 }
5631 }5636 }
5637+ if i.cfg.OnReasoningChanged != nil {
5638+ i.cfg.OnReasoningChanged(level)
5639+ }
5632 i.mu.Lock()5640 i.mu.Lock()
5633 if i.agent != nil {5641 if i.agent != nil {
5634 i.agent.Reasoning = level5642 i.agent.Reasoning = level
@@ -7672,7 +7680,9 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7672 i.handleEventForPresentation(ev)7680 i.handleEventForPresentation(ev)
7673 }7681 }
7674 7682
7683+ releaseCompletionHold := i.beginCompletionDeliveryHold()
7675 go func() {7684 go func() {
7685+ defer releaseCompletionHold()
7676 var err error7686 var err error
7677 if continueExisting {7687 if continueExisting {
7678 err = i.agent.Continue(ctx, sink)7688 err = i.agent.Continue(ctx, sink)
@@ -8694,177 +8704,153 @@ func (a telegramSenderAdapter) Active() bool {
8694 return a.bridge != nil && a.bridge.Active()8704 return a.bridge != nil && a.bridge.Active()
8695 }8705 }
8696 8706
8697-// subagentWatchEntry is one tracked auto-subagents sub-agent. It records the
8698-// delegated turn's outcome separately from the long-lived daemon status.
8699-type subagentWatchEntry struct {
8700- agent *subagents.Agent
8701- task string
8702- followUp bool
8703- done bool
8704- outcome string
8705- err string
8706-}
8707-
8708 // TrackSubagentWorker is the exported entry point used by the cli to8707 // TrackSubagentWorker is the exported entry point used by the cli to
8709-// hand a freshly-spawned auto-subagents agent off to the watcher.8708+// hand a freshly-spawned auto-subagents agent off to the shared tracker.
8710 func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string) {8709 func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string) {
8711- i.trackSubagentWorker(a, task, false)8710+ i.trackSubagentWorker(a, task, false, false)
8712 }8711 }
8713 8712
8714 // TrackResumedSubagentWorker watches a resumed follow-up independently of the8713 // TrackResumedSubagentWorker watches a resumed follow-up independently of the
8715 // worker's original task. A long-lived daemon can complete several manager8714 // worker's original task. A long-lived daemon can complete several manager
8716 // turns, each of which must produce its own automatic delivery.8715 // turns, each of which must produce its own automatic delivery.
8717 func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string) {8716 func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string) {
8718- i.trackSubagentWorker(a, prompt, true)8717+ i.trackSubagentWorker(a, prompt, true, false)
8718+}
8719+
8720+// PrepareResumedSubagentWorker registers a resumed turn before its prompt is
8721+// sent. The returned cleanup removes the registration if the resume operation
8722+// fails before delivery. It is used by the resume tool's pre-send hook; the
8723+// existing TrackResumedSubagentWorker signature remains the post-success API.
8724+func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string) func() {
8725+ return i.trackSubagentWorker(a, prompt, true, true)
8719 }8726 }
8720 8727
8721 // TrackStoppedSubagentWorker watches a requested worker shutdown. If an active8728 // TrackStoppedSubagentWorker watches a requested worker shutdown. If an active
8722-// task watcher already owns the worker, that watcher reports the terminal8729+// task watcher already owns the worker, the shared tracker leaves that watcher
8723-// outcome; otherwise this creates a single exit-only watcher, including for a8730+// responsible for the terminal outcome.
8724-// worker restored by Reload.
8725 func (i *Interactive) TrackStoppedSubagentWorker(a *subagents.Agent) {8731 func (i *Interactive) TrackStoppedSubagentWorker(a *subagents.Agent) {
8726 if i == nil || a == nil {8732 if i == nil || a == nil {
8727 return8733 return
8728 }8734 }
8729- i.subagentWatchMu.Lock()8735+ tracker := i.ensureCompletionTracker()
8730- for _, existing := range i.subagentWatch {8736+ tracker.TrackExit(a, a.Task)
8731- if existing.agent.ID == a.ID && !existing.done {
8732- i.subagentWatchMu.Unlock()
8733- return
8734- }
8735- }
8736- entry := &subagentWatchEntry{agent: a, task: a.Task}
8737- i.subagentWatch = append(i.subagentWatch, entry)
8738- i.subagentWatchMu.Unlock()
8739 i.invalidate()8737 i.invalidate()
8740- i.watchSubagentExit(entry)8738+ i.requestCompletionDelivery()
8741 }8739 }
8742 8740
8743-// trackSubagentWorker records an auto-subagents turn and subscribes to both8741+func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string, followUp, future bool) func() {
8744-// ways it can finish. Successful long-lived agents report a prompt-level
8745-// turn_end and keep listening; startup failures and unexpected daemon exits
8746-// only unblock Agent.Wait. Whichever signal arrives first completes the entry
8747-// exactly once.
8748-func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string, followUp bool) {
8749 if i == nil || a == nil {8742 if i == nil || a == nil {
8750- return8743+ return nil
8744+ }
8745+ tracker := i.ensureCompletionTracker()
8746+ var cancel func()
8747+ if future {
8748+ cancel = tracker.TrackFutureTurn(a, task, followUp)
8749+ } else {
8750+ cancel = tracker.TrackTurn(a, task, followUp)
8751 }8751 }
8752- entry := &subagentWatchEntry{agent: a, task: task, followUp: followUp}
8753- i.subagentWatchMu.Lock()
8754- i.subagentWatch = append(i.subagentWatch, entry)
8755- i.subagentWatchMu.Unlock()
8756 i.invalidate()8752 i.invalidate()
8757-8753+ i.requestCompletionDelivery()
8758- a.SetOnTurnEnd(func(step int, errMsg string) {8754+ return cancel
8759- outcome := "completed"
8760- if errMsg != "" {
8761- outcome = "failed"
8762- }
8763- i.completeSupervisorWatchEntry(entry, outcome, errMsg)
8764- })
8765- i.watchSubagentExit(entry)
8766 }8755 }
8767 8756
8768-func (i *Interactive) watchSubagentExit(entry *subagentWatchEntry) {8757+func (i *Interactive) ensureCompletionTracker() *subagents.CompletionTracker {
8769- go func() {8758+ // Tests and lightweight embedders may construct Interactive with a struct
8770- entry.agent.Wait()8759+ // literal instead of NewInteractive, so retain this lazy fallback.
8771- snap := entry.agent.Snapshot()8760+ i.completionDeliveryMu.Lock()
8772- outcome := string(snap.Status)8761+ defer i.completionDeliveryMu.Unlock()
8773- if snap.Status == subagents.StatusDone {8762+ if i.completionTracker == nil {
8774- outcome = "completed"8763+ i.completionTracker = subagents.NewCompletionTracker()
8775- }8764+ }
8776- i.completeSupervisorWatchEntry(entry, outcome, snap.Err)8765+ return i.completionTracker
8777- }()
8778 }8766 }
8779 8767
8780-func (i *Interactive) completeSupervisorWatchEntry(entry *subagentWatchEntry, outcome, errMsg string) {8768+// requestCompletionDelivery starts at most one waiter for the current active
8781- i.subagentWatchMu.Lock()8769+// set. A request arriving while that waiter is formatting or submitting is
8782- if entry.done {8770+// picked up before the waiter exits, so a later worker cannot be lost in the
8783- i.subagentWatchMu.Unlock()8771+// handoff between batches.
8772+func (i *Interactive) requestCompletionDelivery() {
8773+ if i == nil {
8784 return8774 return
8785 }8775 }
8786- entry.done = true8776+ i.ensureCompletionTracker()
8787- entry.outcome = outcome8777+ i.completionDeliveryMu.Lock()
8788- entry.err = errMsg8778+ i.completionDeliveryRequest = true
8789- allDone := true8779+ if i.completionDeliveryRunning || i.completionDeliveryHolds != 0 {
8790- for _, e := range i.subagentWatch {8780+ i.completionDeliveryMu.Unlock()
8791- if !e.done {8781+ return
8792- allDone = false
8793- break
8794- }
8795- }
8796- var batch []*subagentWatchEntry
8797- if allDone {
8798- batch = i.subagentWatch
8799- i.subagentWatch = nil
8800 }8782 }
8801- i.subagentWatchMu.Unlock()8783+ i.completionDeliveryRunning = true
8802- if len(batch) != 0 {8784+ i.completionDeliveryMu.Unlock()
8803- i.flushSupervisorSummary(batch)8785+
8786+ go i.deliverCompletionUpdates()
8787+}
8788+
8789+// beginCompletionDeliveryHold keeps all completions observed during one
8790+// parent model turn together. Tool calls are executed sequentially inside the
8791+// core agent, so the release point is the owning registration boundary for
8792+// same-parent-turn batching rather than a timing-based debounce.
8793+func (i *Interactive) beginCompletionDeliveryHold() func() {
8794+ if i == nil {
8795+ return func() {}
8804 }8796 }
8797+ i.completionDeliveryMu.Lock()
8798+ i.completionDeliveryHolds++
8799+ i.completionDeliveryMu.Unlock()
8800+ return i.releaseCompletionDeliveryHold
8805 }8801 }
8806 8802
8807-// flushSupervisorSummary composes a synthetic user turn describing every8803+func (i *Interactive) releaseCompletionDeliveryHold() {
8808-// sub-agent's outcome and injects it via SubmitOrQueue so the main8804+ if i == nil {
8809-// agent picks it up at the next safe boundary. The summary is
8810-// phrased as a system update ("Auto-subagents finished: ...") so the
8811-// model treats it as observed state, not as a fresh user request.
8812-func (i *Interactive) flushSupervisorSummary(batch []*subagentWatchEntry) {
8813- if len(batch) == 0 {
8814 return8805 return
8815 }8806 }
8816- var sb strings.Builder8807+ start := false
8817- fmt.Fprintf(&sb, "[auto-subagents update] %d sub-agent(s) finished:\n\n", len(batch))8808+ i.completionDeliveryMu.Lock()
8818- for idx, e := range batch {8809+ if i.completionDeliveryHolds > 0 {
8819- snap := e.agent.Snapshot()8810+ i.completionDeliveryHolds--
8820- status := e.outcome8811+ }
8821- if status == "" {8812+ if i.completionDeliveryHolds == 0 && i.completionDeliveryRequest && !i.completionDeliveryRunning {
8822- status = string(snap.Status)8813+ i.completionDeliveryRunning = true
8823- }8814+ start = true
8824- resultErr := ""8815+ }
8825- if snap.Result != nil && snap.Result.ShutdownOrigin != "" {8816+ i.completionDeliveryMu.Unlock()
8826- if snap.Result.Status == subagents.ResultCanceled {8817+ if start {
8827- status = "cancelled"8818+ go i.deliverCompletionUpdates()
8828- }
8829- if snap.Result.Error != nil {
8830- resultErr = snap.Result.Error.Message
8831- }
8832- }
8833- task := e.task
8834- if task == "" || !e.followUp {
8835- task = snap.Task
8836- }
8837- if task == "" {
8838- task = e.task
8839- }
8840- fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, snap.ID, status)
8841- fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240))
8842- if resultErr != "" {
8843- fmt.Fprintf(&sb, " cancellation: %s\n", truncateForSummary(resultErr, 240))
8844- } else if snap.Err != "" {
8845- fmt.Fprintf(&sb, " error: %s\n", truncateForSummary(snap.Err, 240))
8846- } else if e.err != "" {
8847- fmt.Fprintf(&sb, " turn error: %s\n", truncateForSummary(e.err, 240))
8848- }
8849- if snap.LastAssistant != "" {
8850- sb.WriteString(" final response:\n")
8851- sb.WriteString(snap.LastAssistant)
8852- sb.WriteString("\n")
8853- } else if tail := strings.TrimSpace(snap.Tail); tail != "" {
8854- fmt.Fprintf(&sb, " tail: %s\n", truncateForSummary(tail, 600))
8855- }
8856- sb.WriteString("\n")
8857 }8819 }
8858- sb.WriteString("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.")
8859- i.SubmitOrQueue(sb.String(), nil)
8860 }8820 }
8861 8821
8862-func truncateForSummary(s string, n int) string {8822+func (i *Interactive) completionWaitContext() context.Context {
8863- s = strings.TrimSpace(s)8823+ i.mu.Lock()
8864- if len(s) <= n {8824+ ctx := i.runCtx
8865- return s8825+ i.mu.Unlock()
8826+ if ctx == nil {
8827+ return context.Background()
8828+ }
8829+ return ctx
8830+}
8831+
8832+func (i *Interactive) deliverCompletionUpdates() {
8833+ for {
8834+ i.completionDeliveryMu.Lock()
8835+ i.completionDeliveryRequest = false
8836+ tracker := i.completionTracker
8837+ i.completionDeliveryMu.Unlock()
8838+
8839+ batch, err := tracker.WaitIdle(i.completionWaitContext())
8840+ if err == nil && len(batch) != 0 {
8841+ 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."
8842+ i.SubmitOrQueue(subagents.FormatCompletionUpdate(batch, instruction), nil)
8843+ }
8844+
8845+ i.completionDeliveryMu.Lock()
8846+ if i.completionDeliveryRequest {
8847+ i.completionDeliveryMu.Unlock()
8848+ continue
8849+ }
8850+ i.completionDeliveryRunning = false
8851+ i.completionDeliveryMu.Unlock()
8852+ return
8866 }8853 }
8867- return s[:n-3] + "..."
8868 }8854 }
8869 8855
8870 // autoSubagentsAddenda returns the prompt blocks owned by subagent prompting,8856 // autoSubagentsAddenda returns the prompt blocks owned by subagent prompting,
@@ -9067,9 +9053,9 @@ func (i *Interactive) applyAutoSubagentsTool() {
9067 }9053 }
9068 if i.autoSubagentsResumeToolAllowed() {9054 if i.autoSubagentsResumeToolAllowed() {
9069 resumeTool := &tools.SubagentResumeTool{9055 resumeTool := &tools.SubagentResumeTool{
9070- Supervisor: i.cfg.Supervisor,9056+ Supervisor: i.cfg.Supervisor,
9071- Enabled: func() bool { return true },9057+ Enabled: func() bool { return true },
9072- OnResumed: i.TrackResumedSubagentWorker,9058+ BeforeResumed: i.PrepareResumedSubagentWorker,
9073 }9059 }
9074 next[resumeTool.Name()] = resumeTool9060 next[resumeTool.Name()] = resumeTool
9075 }9061 }
M packages/agent/modes/json.go +14 -9
@@ -3,8 +3,9 @@ package modes
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6- "fmt"6+ "errors"
7 "io"7 "io"
8+ "sync"
8 9
9 "github.com/bnema/zut/packages/core"10 "github.com/bnema/zut/packages/core"
10 "github.com/bnema/zut/packages/provider"11 "github.com/bnema/zut/packages/provider"
@@ -24,8 +25,14 @@ func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provid
24 // a successful checkpoint.25 // a successful checkpoint.
25 func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer, persistCompaction func([]provider.Message) error) (ContextRecoveryResult, error) {26 func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer, persistCompaction func([]provider.Message) error) (ContextRecoveryResult, error) {
26 enc := json.NewEncoder(out)27 enc := json.NewEncoder(out)
28+ var writeMu sync.Mutex
29+ var writeErr error
27 write := func(v any) {30 write := func(v any) {
28- _ = enc.Encode(v)31+ writeMu.Lock()
32+ defer writeMu.Unlock()
33+ if writeErr == nil {
34+ writeErr = enc.Encode(v)
35+ }
29 }36 }
30 37
31 var runErr error38 var runErr error
@@ -42,9 +49,12 @@ func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt stri
42 }49 }
43 50
44 if runErr != nil {51 if runErr != nil {
45- fmt.Fprintln(out, `{"type":"error","message":`+jsonString(runErr.Error())+`}`)52+ write(map[string]any{"type": "error", "message": runErr.Error()})
46 }53 }
47- return recovery, runErr54+ writeMu.Lock()
55+ outputErr := writeErr
56+ writeMu.Unlock()
57+ return recovery, errors.Join(runErr, outputErr)
48 }58 }
49 59
50 // EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire60 // EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire
@@ -155,8 +165,3 @@ func nullableReasoningTokens(usage provider.Usage) any {
155 }165 }
156 return usage.ReasoningTokens166 return usage.ReasoningTokens
157 }167 }
158-
159-func jsonString(s string) string {
160- b, _ := json.Marshal(s)
161- return string(b)
162-}
M packages/agent/modes/json_activity_test.go +62 -0
@@ -1,12 +1,74 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "strings"
4 "testing"9 "testing"
5 "time"10 "time"
6 11
7 "github.com/bnema/zut/packages/core"12 "github.com/bnema/zut/packages/core"
13+ "github.com/bnema/zut/packages/provider"
8 )14 )
9 15
16+func TestRunJSONCompletionUserMessageIsValidJSONL(t *testing.T) {
17+ message := core.EvUserMessage{Message: provider.Message{
18+ Role: provider.RoleUser,
19+ Content: []provider.Content{provider.TextBlock{Text: "[auto-subagents update] worker finished"}},
20+ }}
21+ var line bytes.Buffer
22+ if err := json.NewEncoder(&line).Encode(EventToJSON(message)); err != nil {
23+ t.Fatal(err)
24+ }
25+ var decoded map[string]any
26+ if err := json.Unmarshal(bytes.TrimSpace(line.Bytes()), &decoded); err != nil {
27+ t.Fatalf("completion event is not JSON: %v", err)
28+ }
29+ if decoded["type"] != "user_message" {
30+ t.Fatalf("event type = %#v, want user_message", decoded["type"])
31+ }
32+}
33+
34+type failingJSONWriter struct{ err error }
35+
36+func (w failingJSONWriter) Write([]byte) (int, error) { return 0, w.err }
37+
38+func TestRunJSONPropagatesOutputErrors(t *testing.T) {
39+ providerErr := errors.New("provider unavailable")
40+ writerErr := errors.New("stdout unavailable")
41+ ag := core.NewAgent(&streamTestClient{err: providerErr}, "test-model", "", nil)
42+ _, err := RunJSONWithContextRecovery(context.Background(), ag, "hello", nil, failingJSONWriter{err: writerErr}, nil)
43+ if !errors.Is(err, providerErr) || !errors.Is(err, writerErr) {
44+ t.Fatalf("RunJSONWithContextRecovery error = %v, want provider and output errors", err)
45+ }
46+}
47+
48+func TestRunJSONFailureEmitsOneErrorObject(t *testing.T) {
49+ client := &streamTestClient{err: errors.New("provider unavailable")}
50+ ag := core.NewAgent(client, "test-model", "", nil)
51+ var out bytes.Buffer
52+ _, err := RunJSONWithContextRecovery(context.Background(), ag, "hello", nil, &out, nil)
53+ if err == nil {
54+ t.Fatal("RunJSONWithContextRecovery unexpectedly succeeded")
55+ }
56+ lines := strings.Split(strings.TrimSpace(out.String()), "\n")
57+ errorObjects := 0
58+ for _, line := range lines {
59+ var decoded map[string]any
60+ if err := json.Unmarshal([]byte(line), &decoded); err != nil {
61+ t.Fatalf("invalid JSONL line %q: %v", line, err)
62+ }
63+ if decoded["type"] == "error" {
64+ errorObjects++
65+ }
66+ }
67+ if errorObjects != 1 {
68+ t.Fatalf("error objects = %d, want one; output=%q", errorObjects, out.String())
69+ }
70+}
71+
10 func TestEventToJSONActivityLifecycle(t *testing.T) {72 func TestEventToJSONActivityLifecycle(t *testing.T) {
11 request := EventToJSON(core.EvRequestStarted{73 request := EventToJSON(core.EvRequestStarted{
12 Provider: "anthropic",74 Provider: "anthropic",
M packages/agent/modes/stream_test.go +4 -0
@@ -11,11 +11,15 @@ import (
11 11
12 type streamTestClient struct {12 type streamTestClient struct {
13 events []provider.Event13 events []provider.Event
14+ err error
14 }15 }
15 16
16 func (c *streamTestClient) Name() string { return "stream-test" }17 func (c *streamTestClient) Name() string { return "stream-test" }
17 18
18 func (c *streamTestClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {19 func (c *streamTestClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
20+ if c.err != nil {
21+ return nil, c.err
22+ }
19 out := make(chan provider.Event, len(c.events))23 out := make(chan provider.Event, len(c.events))
20 go func() {24 go func() {
21 defer close(out)25 defer close(out)
M packages/agent/modes/subagents_slash.go +4 -1
@@ -249,6 +249,10 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
249 return249 return
250 }250 }
251 waitWatcherDone := i.subagentsWaitWatcherDone251 waitWatcherDone := i.subagentsWaitWatcherDone
252+ // Publish the initial state before starting the watcher. A worker may
253+ // already be done, and its completion must be the final status rather
254+ // than being overwritten by a late "waiting" update.
255+ i.subagentsStatus("waiting for "+a.ID, "")
252 go func() {256 go func() {
253 defer func() {257 defer func() {
254 if waitWatcherDone != nil {258 if waitWatcherDone != nil {
@@ -265,7 +269,6 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
265 i.subagentsStatus("completed "+a.ID, "")269 i.subagentsStatus("completed "+a.ID, "")
266 }270 }
267 }()271 }()
268- i.subagentsStatus("waiting for "+a.ID, "")
269 case "resume-session", "resume", "reattach", "reopen":272 case "resume-session", "resume", "reattach", "reopen":
270 if rest == "" {273 if rest == "" {
271 count := i.subagentsDialog.OpenForResume(274 count := i.subagentsDialog.OpenForResume(
M packages/agent/modes/subagents_slash_test.go +3 -0
@@ -290,6 +290,9 @@ func TestRunSupervisorWaitReportsCompletionBeforeCancellation(t *testing.T) {
290 if err != nil {290 if err != nil {
291 t.Fatal(err)291 t.Fatal(err)
292 }292 }
293+ // Exercise the ordering-sensitive path deterministically: /wait can be
294+ // invoked after the worker has already completed.
295+ a.Wait()
293 watcherDone := make(chan struct{})296 watcherDone := make(chan struct{})
294 iv.subagentsWaitWatcherDone = func() { close(watcherDone) }297 iv.subagentsWaitWatcherDone = func() { close(watcherDone) }
295 iv.runSubagents(context.Background(), []string{"wait", a.ID})298 iv.runSubagents(context.Background(), []string{"wait", a.ID})
A packages/agent/orchestration.go +40 -0
@@ -0,0 +1,40 @@
1+package agent
2+
3+import (
4+ "fmt"
5+ "strings"
6+)
7+
8+// validateOrchestrationArgs is the single launch-time gate for headless
9+// orchestration. Callers should run it before any mode-specific setup and
10+// again after attaching packaged-agent metadata when using `zut run`.
11+func validateOrchestrationArgs(args Args) error {
12+ if !args.Orchestrate {
13+ return nil
14+ }
15+ switch args.Mode {
16+ case ModePrint, ModeStream, ModeJSON:
17+ // These are the non-interactive output modes. Print is the first
18+ // execution path; stream and JSON retain the same eligibility contract
19+ // for callers that select those output protocols.
20+ case ModeRPC:
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+ default:
25+ return fmt.Errorf("--orchestrate requires print, stream, or JSON mode")
26+ }
27+ if strings.TrimSpace(args.Subagent) != "" {
28+ return fmt.Errorf("--orchestrate is not supported with --subagent profiles")
29+ }
30+ if args.PermissionSet != nil || strings.TrimSpace(args.AgentName) != "" || strings.TrimSpace(args.AgentDataDir) != "" {
31+ return fmt.Errorf("--orchestrate is not supported for packaged agents")
32+ }
33+ if args.StatsPath != "" {
34+ return fmt.Errorf("--orchestrate cannot be combined with --stats")
35+ }
36+ if !autoSubagentsToolAllowed(args) {
37+ return fmt.Errorf("--orchestrate requires the subagent_spawn tool")
38+ }
39+ return nil
40+}
A packages/agent/orchestration_cli_e2e_test.go +430 -0
@@ -0,0 +1,430 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "net/http/httptest"
10+ "os"
11+ "os/exec"
12+ "path/filepath"
13+ "runtime"
14+ "strings"
15+ "sync"
16+ "syscall"
17+ "testing"
18+ "time"
19+)
20+
21+var (
22+ orchestrationBuildOnce sync.Once
23+ orchestrationBuildDir string
24+ orchestrationBuildBinary string
25+ orchestrationBuildOutput []byte
26+ orchestrationBuildErr error
27+)
28+
29+func TestMain(m *testing.M) {
30+ code := m.Run()
31+ if orchestrationBuildDir != "" {
32+ _ = os.RemoveAll(orchestrationBuildDir)
33+ }
34+ os.Exit(code)
35+}
36+
37+func TestOrchestratedCLITwoDelegationWavesAcrossHeadlessModes(t *testing.T) {
38+ if runtime.GOOS == "windows" {
39+ t.Skip("signal and subprocess semantics differ on Windows")
40+ }
41+ if testing.Short() {
42+ t.Skip("skip CLI orchestration end-to-end test in -short mode")
43+ }
44+ binary := buildOrchestrationCLI(t)
45+ for _, mode := range []string{"print", "stream", "json"} {
46+ t.Run(mode, func(t *testing.T) {
47+ server := newTwoWaveProvider(t)
48+ defer server.Close()
49+ home := t.TempDir()
50+ args := []string{"--orchestrate", "--provider", "openai", "--model", "gpt-5", "--base-url", server.URL(), "--api-key", "test-key"}
51+ switch mode {
52+ case "print":
53+ args = append(args, "--print")
54+ case "stream":
55+ args = append(args, "--stream")
56+ case "json":
57+ args = append(args, "--json")
58+ }
59+ args = append(args, "delegate two waves")
60+ cmd := exec.Command(binary, args...)
61+ cmd.Env = orchestrationCLIEnv(binary, home)
62+ var stdout, stderr bytes.Buffer
63+ cmd.Stdout = &stdout
64+ cmd.Stderr = &stderr
65+ err := cmd.Run()
66+ output := append(append([]byte(nil), stdout.Bytes()...), stderr.Bytes()...)
67+ if err != nil {
68+ t.Fatalf("zut %s: %v\n%s", mode, err, output)
69+ }
70+ if !bytes.Contains(output, []byte("final synthesis")) {
71+ t.Fatalf("%s output omitted final synthesis: %s", mode, output)
72+ }
73+ if server.ParentCalls() != 5 || server.WorkerCalls() < 2 {
74+ t.Fatalf("%s calls = parent %d, worker %d; want two delegation waves and two workers", mode, server.ParentCalls(), server.WorkerCalls())
75+ }
76+ if mode == "json" {
77+ events := assertJSONL(t, stdout.Bytes())
78+ assertJSONOrchestrationWaves(t, events)
79+ }
80+ if mode == "stream" && !bytes.Contains(stdout.Bytes(), []byte("wave one synthesis")) {
81+ t.Fatalf("stream output omitted first parent turn: %s", output)
82+ }
83+ if mode == "stream" && bytes.Contains(stdout.Bytes(), []byte("[auto-subagents update]")) {
84+ t.Fatalf("stream stdout leaked internal completion prompt: %s", stdout.Bytes())
85+ }
86+ if mode == "print" && bytes.Contains(stdout.Bytes(), []byte("wave one synthesis")) {
87+ t.Fatalf("print output leaked intermediate parent turn: %s", output)
88+ }
89+ })
90+ }
91+}
92+
93+func TestOrchestratedCLICancellation(t *testing.T) {
94+ if runtime.GOOS == "windows" {
95+ t.Skip("signal and subprocess semantics differ on Windows")
96+ }
97+ if testing.Short() {
98+ t.Skip("skip CLI orchestration end-to-end test in -short mode")
99+ }
100+ binary := buildOrchestrationCLI(t)
101+ server, workerReady, releaseWorker := newCancellationProvider()
102+ defer server.Close()
103+ defer close(releaseWorker)
104+
105+ cmd := exec.Command(binary, "--orchestrate", "--json", "--provider", "openai", "--model", "gpt-5", "--base-url", server.URL, "--api-key", "test-key", "cancel")
106+ cmd.Env = orchestrationCLIEnv(binary, t.TempDir())
107+ var stdout, stderr bytes.Buffer
108+ cmd.Stdout = &stdout
109+ cmd.Stderr = &stderr
110+ if err := cmd.Start(); err != nil {
111+ t.Fatal(err)
112+ }
113+ waitForWorker := time.NewTimer(10 * time.Second)
114+ select {
115+ case <-workerReady:
116+ if !waitForWorker.Stop() {
117+ <-waitForWorker.C
118+ }
119+ case <-waitForWorker.C:
120+ _ = cmd.Process.Kill()
121+ _ = cmd.Wait()
122+ t.Fatal("worker did not reach the blocking provider request")
123+ }
124+ if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
125+ t.Fatalf("send cancellation: %v", err)
126+ }
127+ wait := make(chan error, 1)
128+ go func() { wait <- cmd.Wait() }()
129+ select {
130+ case err := <-wait:
131+ if err == nil {
132+ t.Fatal("canceled orchestration unexpectedly succeeded")
133+ }
134+ case <-time.After(10 * time.Second):
135+ _ = cmd.Process.Kill()
136+ t.Fatal("canceled orchestration did not exit")
137+ }
138+
139+ events := assertJSONL(t, stdout.Bytes())
140+ errorCount := 0
141+ for _, event := range events {
142+ if event["type"] == "error" {
143+ errorCount++
144+ }
145+ }
146+ if errorCount != 1 {
147+ t.Fatalf("cancellation error objects = %d, want one; stdout=%s stderr=%s", errorCount, stdout.Bytes(), stderr.Bytes())
148+ }
149+}
150+
151+func TestOrchestratedCLIParentFailure(t *testing.T) {
152+ if runtime.GOOS == "windows" {
153+ t.Skip("signal and subprocess semantics differ on Windows")
154+ }
155+ if testing.Short() {
156+ t.Skip("skip CLI orchestration end-to-end test in -short mode")
157+ }
158+ binary := buildOrchestrationCLI(t)
159+
160+ t.Run("failure-jsonl", func(t *testing.T) {
161+ server := newFailingProvider()
162+ defer server.Close()
163+ cmd := exec.Command(binary, "--orchestrate", "--json", "--provider", "openai", "--model", "gpt-5", "--base-url", server.URL, "--api-key", "test-key", "fail")
164+ cmd.Env = orchestrationCLIEnv(binary, t.TempDir())
165+ var stdout, stderr bytes.Buffer
166+ cmd.Stdout = &stdout
167+ cmd.Stderr = &stderr
168+ err := cmd.Run()
169+ if err == nil {
170+ t.Fatal("failing orchestration unexpectedly succeeded")
171+ }
172+ events := assertJSONL(t, stdout.Bytes())
173+ errorCount := 0
174+ for _, event := range events {
175+ if event["type"] == "error" {
176+ errorCount++
177+ }
178+ }
179+ if errorCount != 1 {
180+ output := append(append([]byte(nil), stdout.Bytes()...), stderr.Bytes()...)
181+ t.Fatalf("error objects = %d, want one: %s", errorCount, output)
182+ }
183+ })
184+}
185+
186+type twoWaveProvider struct {
187+ t *testing.T
188+ server *httptest.Server
189+ mu sync.Mutex
190+ parentCalls int
191+ workerCalls int
192+}
193+
194+func newTwoWaveProvider(t *testing.T) *twoWaveProvider {
195+ p := &twoWaveProvider{t: t}
196+ p.server = httptest.NewServer(http.HandlerFunc(p.serveHTTP))
197+ return p
198+}
199+
200+func (p *twoWaveProvider) URL() string { return p.server.URL }
201+func (p *twoWaveProvider) Close() { p.server.Close() }
202+
203+func (p *twoWaveProvider) ParentCalls() int {
204+ p.mu.Lock()
205+ defer p.mu.Unlock()
206+ return p.parentCalls
207+}
208+
209+func (p *twoWaveProvider) WorkerCalls() int {
210+ p.mu.Lock()
211+ defer p.mu.Unlock()
212+ return p.workerCalls
213+}
214+
215+func (p *twoWaveProvider) serveHTTP(w http.ResponseWriter, r *http.Request) {
216+ body, err := io.ReadAll(r.Body)
217+ if err != nil {
218+ p.t.Errorf("read provider request: %v", err)
219+ return
220+ }
221+ p.mu.Lock()
222+ isParent := requestIsParent(body)
223+ if isParent {
224+ p.parentCalls++
225+ call := p.parentCalls
226+ p.mu.Unlock()
227+ switch call {
228+ case 1:
229+ writeToolCall(w, "wave-one", "wave one")
230+ case 2:
231+ writeText(w, "wave one synthesis")
232+ case 3:
233+ writeToolCall(w, "wave-two", "wave two")
234+ default:
235+ writeText(w, "final synthesis")
236+ }
237+ return
238+ }
239+ p.workerCalls++
240+ worker := p.workerCalls
241+ p.mu.Unlock()
242+ writeText(w, fmt.Sprintf("worker result %d", worker))
243+}
244+
245+func newCancellationProvider() (*httptest.Server, <-chan struct{}, chan<- struct{}) {
246+ workerReady := make(chan struct{})
247+ releaseWorker := make(chan struct{})
248+ var once sync.Once
249+ var mu sync.Mutex
250+ requests := 0
251+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
252+ mu.Lock()
253+ requests++
254+ parent := requests == 1
255+ mu.Unlock()
256+ if parent {
257+ writeToolCall(w, "cancel-worker", "long running worker")
258+ return
259+ }
260+ once.Do(func() { close(workerReady) })
261+ select {
262+ case <-r.Context().Done():
263+ case <-releaseWorker:
264+ }
265+ }))
266+ return server, workerReady, releaseWorker
267+}
268+
269+func newFailingProvider() *httptest.Server {
270+ var mu sync.Mutex
271+ parentCalls := 0
272+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
273+ body, _ := io.ReadAll(r.Body)
274+ if requestIsParent(body) {
275+ mu.Lock()
276+ parentCalls++
277+ call := parentCalls
278+ mu.Unlock()
279+ if call == 1 {
280+ writeToolCall(w, "failure-worker", "failure wave")
281+ return
282+ }
283+ w.WriteHeader(http.StatusBadGateway)
284+ _, _ = io.WriteString(w, `{"error":{"message":"synthetic provider failure","type":"server_error"}}`)
285+ return
286+ }
287+ writeText(w, "worker completed before parent failure")
288+ }))
289+}
290+
291+func requestIsParent(body []byte) bool {
292+ var request struct {
293+ Messages []struct {
294+ Role string `json:"role"`
295+ Content json.RawMessage `json:"content"`
296+ } `json:"messages"`
297+ }
298+ if json.Unmarshal(body, &request) != nil {
299+ return false
300+ }
301+ for _, message := range request.Messages {
302+ if message.Role != "user" {
303+ continue
304+ }
305+ var text string
306+ if json.Unmarshal(message.Content, &text) == nil {
307+ if text == "delegate two waves" || text == "fail" || text == "cancel" {
308+ return true
309+ }
310+ }
311+ if bytes.Contains(message.Content, []byte(`delegate two waves`)) || bytes.Contains(message.Content, []byte(`"text":"fail"`)) || bytes.Contains(message.Content, []byte(`"text":"cancel"`)) {
312+ return true
313+ }
314+ }
315+ return false
316+}
317+
318+func writeToolCall(w http.ResponseWriter, id, task string) {
319+ w.Header().Set("content-type", "text/event-stream")
320+ arguments, _ := json.Marshal(map[string]string{"task": task})
321+ chunk := map[string]any{
322+ "choices": []any{map[string]any{"index": 0, "delta": map[string]any{"tool_calls": []any{map[string]any{"index": 0, "id": id, "type": "function", "function": map[string]string{"name": "subagent_spawn", "arguments": string(arguments)}}}}, "finish_reason": "tool_calls"}},
323+ "usage": map[string]int{"prompt_tokens": 8, "completion_tokens": 2},
324+ }
325+ data, _ := json.Marshal(chunk)
326+ _, _ = fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", data)
327+}
328+
329+func writeText(w http.ResponseWriter, text string) {
330+ w.Header().Set("content-type", "text/event-stream")
331+ chunk := map[string]any{
332+ "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": text}, "finish_reason": "stop"}},
333+ "usage": map[string]int{"prompt_tokens": 8, "completion_tokens": 2},
334+ }
335+ data, _ := json.Marshal(chunk)
336+ _, _ = fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", data)
337+}
338+
339+func assertJSONL(t *testing.T, output []byte) []map[string]any {
340+ t.Helper()
341+ var events []map[string]any
342+ for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
343+ if strings.TrimSpace(line) == "" || strings.HasPrefix(line, "zut:") {
344+ continue
345+ }
346+ var event map[string]any
347+ if err := json.Unmarshal([]byte(line), &event); err != nil {
348+ t.Fatalf("invalid JSONL line %q: %v", line, err)
349+ }
350+ events = append(events, event)
351+ }
352+ return events
353+}
354+
355+func assertJSONOrchestrationWaves(t *testing.T, events []map[string]any) {
356+ t.Helper()
357+ var userMessages []string
358+ seenTypes := make(map[string]bool)
359+ for _, event := range events {
360+ if typ, ok := event["type"].(string); ok {
361+ seenTypes[typ] = true
362+ }
363+ if event["type"] != "user_message" {
364+ continue
365+ }
366+ content, ok := event["content"].([]any)
367+ if !ok {
368+ t.Fatalf("user_message content = %T, want JSON array", event["content"])
369+ }
370+ var text strings.Builder
371+ for _, block := range content {
372+ object, ok := block.(map[string]any)
373+ if !ok || object["type"] != "text" {
374+ continue
375+ }
376+ if value, ok := object["text"].(string); ok {
377+ text.WriteString(value)
378+ }
379+ }
380+ userMessages = append(userMessages, text.String())
381+ }
382+ if len(userMessages) < 3 {
383+ t.Fatalf("user messages = %d, want initial prompt and two parent completion turns", len(userMessages))
384+ }
385+ completionMessages := 0
386+ for _, message := range userMessages {
387+ if strings.HasPrefix(message, "[auto-subagents update]") {
388+ completionMessages++
389+ }
390+ }
391+ if completionMessages != 2 {
392+ t.Fatalf("completion user messages = %d, want two: %#v", completionMessages, userMessages)
393+ }
394+ for _, typ := range []string{"assistant_message", "tool_call", "tool_execution_started", "tool_result", "usage", "turn_end"} {
395+ if !seenTypes[typ] {
396+ t.Fatalf("orchestrated JSONL omitted %s event; seen types: %v", typ, seenTypes)
397+ }
398+ }
399+ for _, event := range events {
400+ if event["type"] == "error" {
401+ t.Fatalf("successful two-wave orchestration emitted an error event: %#v", event)
402+ }
403+ }
404+}
405+
406+func orchestrationCLIEnv(binary, home string) []string {
407+ // Restrict PATH so spawned workers resolve the same freshly built test
408+ // binary instead of any installed zut executable on the host.
409+ return append(os.Environ(), "ZUT_HOME="+home, "PATH="+filepath.Dir(binary))
410+}
411+
412+func buildOrchestrationCLI(t *testing.T) string {
413+ t.Helper()
414+ orchestrationBuildOnce.Do(func() {
415+ orchestrationBuildDir, orchestrationBuildErr = os.MkdirTemp("", "zut-orchestration-cli-")
416+ if orchestrationBuildErr != nil {
417+ return
418+ }
419+ orchestrationBuildBinary = filepath.Join(orchestrationBuildDir, "zut")
420+ root := filepath.Join("..", "..")
421+ cmd := exec.Command("go", "build", "-o", orchestrationBuildBinary, "./cmd/zut")
422+ cmd.Dir = root
423+ cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
424+ orchestrationBuildOutput, orchestrationBuildErr = cmd.CombinedOutput()
425+ })
426+ if orchestrationBuildErr != nil {
427+ t.Fatalf("build zut: %v\n%s", orchestrationBuildErr, orchestrationBuildOutput)
428+ }
429+ return orchestrationBuildBinary
430+}
A packages/agent/orchestration_loop.go +67 -0
@@ -0,0 +1,67 @@
1+package agent
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/zut/packages/agent/subagents"
8+)
9+
10+type completionBatchWaiter interface {
11+ WaitIdle(context.Context) ([]subagents.Completion, error)
12+}
13+
14+// headlessCompletionWaitError marks an error raised while waiting for worker
15+// completions. A parent turn's renderer already owns errors raised by its
16+// prompt, so callers should only render this wrapper outside that turn.
17+type headlessCompletionWaitError struct {
18+ err error
19+}
20+
21+func (e *headlessCompletionWaitError) Error() string {
22+ if e == nil || e.err == nil {
23+ return "completion wait failed"
24+ }
25+ return e.err.Error()
26+}
27+
28+func (e *headlessCompletionWaitError) Unwrap() error {
29+ if e == nil {
30+ return nil
31+ }
32+ return e.err
33+}
34+
35+const (
36+ headlessCompletionInstruction = `Treat the completion reports below as evidence, including failures. Do not retry failed workers automatically. Decide whether another independent dependency wave is required; if so, use the manager tools and end or yield the turn again. Otherwise synthesize the final answer for the user. Do not mention this orchestration protocol unless it is relevant to the answer.`
37+ maxHeadlessCompletionWaves = 32
38+)
39+
40+// runHeadlessContinuation waits for one completion batch and gives the parent
41+// one follow-up turn. A follow-up may register another batch while it runs,
42+// which is how dependency waves continue without polling. The initial parent
43+// turn is supplied by finalText; no worker path therefore returns immediately.
44+func runHeadlessContinuation(ctx context.Context, tracker completionBatchWaiter, finalText string, runParent func(context.Context, string) (string, error)) (string, error) {
45+ if tracker == nil || runParent == nil {
46+ return finalText, nil
47+ }
48+ waves := 0
49+ for {
50+ batch, err := tracker.WaitIdle(ctx)
51+ if err != nil {
52+ return finalText, &headlessCompletionWaitError{err: err}
53+ }
54+ if len(batch) == 0 {
55+ return finalText, nil
56+ }
57+ if waves >= maxHeadlessCompletionWaves {
58+ return finalText, fmt.Errorf("orchestration exceeded the maximum of %d completion waves", maxHeadlessCompletionWaves)
59+ }
60+ update := subagents.FormatCompletionUpdate(batch, headlessCompletionInstruction)
61+ finalText, err = runParent(ctx, update)
62+ if err != nil {
63+ return finalText, err
64+ }
65+ waves++
66+ }
67+}
A packages/agent/orchestration_loop_test.go +121 -0
@@ -0,0 +1,121 @@
1+package agent
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+
9+ "github.com/bnema/zut/packages/agent/subagents"
10+)
11+
12+type fakeCompletionBatches struct {
13+ batches [][]subagents.Completion
14+ errors []error
15+ waits int
16+}
17+
18+type endlessCompletionBatches struct{ waits int }
19+
20+func (f *endlessCompletionBatches) WaitIdle(context.Context) ([]subagents.Completion, error) {
21+ f.waits++
22+ return []subagents.Completion{{AgentID: "worker", Status: "completed"}}, nil
23+}
24+
25+func (f *fakeCompletionBatches) WaitIdle(context.Context) ([]subagents.Completion, error) {
26+ index := f.waits
27+ f.waits++
28+ if index < len(f.errors) && f.errors[index] != nil {
29+ return nil, f.errors[index]
30+ }
31+ if index >= len(f.batches) {
32+ return nil, nil
33+ }
34+ return f.batches[index], nil
35+}
36+
37+func TestRunHeadlessContinuationNoWorkerPath(t *testing.T) {
38+ tracker := &fakeCompletionBatches{batches: [][]subagents.Completion{{}}}
39+ called := false
40+ got, err := runHeadlessContinuation(context.Background(), tracker, "initial synthesis", func(context.Context, string) (string, error) {
41+ called = true
42+ return "unexpected", nil
43+ })
44+ if err != nil {
45+ t.Fatal(err)
46+ }
47+ if called || got != "initial synthesis" {
48+ t.Fatalf("no-worker result = %q, called=%v", got, called)
49+ }
50+}
51+
52+func TestRunHeadlessContinuationBatchesFailuresAndSecondWave(t *testing.T) {
53+ tracker := &fakeCompletionBatches{batches: [][]subagents.Completion{
54+ {{AgentID: "worker-a", Status: "completed", Task: "inspect"}, {AgentID: "worker-b", Status: "failed", Task: "test", Error: "provider failed"}},
55+ {{AgentID: "worker-c", Status: "completed", Task: "dependent"}},
56+ {},
57+ }}
58+ var prompts []string
59+ got, err := runHeadlessContinuation(context.Background(), tracker, "initial", func(_ context.Context, prompt string) (string, error) {
60+ prompts = append(prompts, prompt)
61+ return "synthesis " + string(rune('0'+len(prompts))), nil
62+ })
63+ if err != nil {
64+ t.Fatal(err)
65+ }
66+ if got != "synthesis 2" || len(prompts) != 2 {
67+ t.Fatalf("result=%q prompts=%d, want second-wave synthesis", got, len(prompts))
68+ }
69+ if !strings.Contains(prompts[0], "worker-b") || !strings.Contains(prompts[0], "provider failed") {
70+ t.Fatalf("first completion batch omitted failed-worker evidence: %q", prompts[0])
71+ }
72+ if !strings.Contains(prompts[1], "worker-c") || strings.Contains(prompts[1], "worker-a") {
73+ t.Fatalf("second completion batch was not isolated: %q", prompts[1])
74+ }
75+ if tracker.waits != 3 {
76+ t.Fatalf("wait count=%d, want completion batches followed by empty terminal batch", tracker.waits)
77+ }
78+}
79+
80+func TestRunHeadlessContinuationStopsAtWaveLimit(t *testing.T) {
81+ tracker := &endlessCompletionBatches{}
82+ calls := 0
83+ got, err := runHeadlessContinuation(context.Background(), tracker, "initial", func(context.Context, string) (string, error) {
84+ calls++
85+ return "synthesis", nil
86+ })
87+ if err == nil || !strings.Contains(err.Error(), "maximum of 32 completion waves") {
88+ t.Fatalf("error = %v, want wave-limit error", err)
89+ }
90+ if got != "synthesis" || calls != maxHeadlessCompletionWaves || tracker.waits != maxHeadlessCompletionWaves+1 {
91+ t.Fatalf("result=%q calls=%d waits=%d", got, calls, tracker.waits)
92+ }
93+}
94+
95+func TestRunHeadlessContinuationCancellation(t *testing.T) {
96+ ctx, cancel := context.WithCancel(context.Background())
97+ cancel()
98+ want := errors.New("canceled")
99+ tracker := &fakeCompletionBatches{errors: []error{want}}
100+ got, err := runHeadlessContinuation(ctx, tracker, "initial", func(context.Context, string) (string, error) {
101+ t.Fatal("parent should not run after cancellation")
102+ return "", nil
103+ })
104+ var waitErr *headlessCompletionWaitError
105+ if !errors.Is(err, want) || !errors.As(err, &waitErr) || got != "initial" {
106+ t.Fatalf("result=(%q,%v), wait error=%v, want initial and cancellation wait error", got, err, waitErr)
107+ }
108+}
109+
110+func TestRunHeadlessContinuationParentErrorDoesNotRetry(t *testing.T) {
111+ tracker := &fakeCompletionBatches{batches: [][]subagents.Completion{{{AgentID: "worker", Status: "completed"}}}}
112+ want := errors.New("parent failed")
113+ calls := 0
114+ got, err := runHeadlessContinuation(context.Background(), tracker, "initial", func(context.Context, string) (string, error) {
115+ calls++
116+ return "partial", want
117+ })
118+ if !errors.Is(err, want) || got != "partial" || calls != 1 {
119+ t.Fatalf("result=(%q,%v), calls=%d, want one non-retried parent failure", got, err, calls)
120+ }
121+}
A packages/agent/orchestration_modes.go +266 -0
@@ -0,0 +1,266 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "os/signal"
10+ "path/filepath"
11+ "strings"
12+ "syscall"
13+
14+ "github.com/bnema/zut/packages/agent/modes"
15+ "github.com/bnema/zut/packages/agent/subagents"
16+ "github.com/bnema/zut/packages/core"
17+ "github.com/bnema/zut/packages/provider"
18+)
19+
20+type orchestratedModeHooks struct {
21+ promptLabel string
22+
23+ // startupSink is used for entry.pre agent turns. The completion updates
24+ // themselves go through runPrimary and therefore use the same renderer as
25+ // the initial parent turn.
26+ startupSink func() (func(core.AgentEvent), func())
27+ runPrimary func(context.Context, *core.Agent, string, func([]provider.Message) error) (string, modes.ContextRecoveryResult, error)
28+ emitError func(error)
29+ finish func(string) error
30+}
31+
32+// runOrchestratedMode owns the mode-neutral headless lifecycle. Modes provide
33+// only their existing renderer and wire format; the tracker, worker runtime,
34+// startup hooks, transcript persistence, and completion-wave loop are shared.
35+func runOrchestratedMode(parentCtx context.Context, args Args, version string, hooks orchestratedModeHooks) (runErr error) {
36+ ctx, stopSignal := signal.NotifyContext(parentCtx, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
37+ defer stopSignal()
38+
39+ if args.NoYolo {
40+ fmt.Fprintf(os.Stderr, "warning: --no-yolo has no effect in %s mode (no interactive prompt available); tools will run without confirmation\n", hooks.promptLabel)
41+ }
42+ r, err := Resolve(args, true)
43+ if err != nil {
44+ return err
45+ }
46+ initialCfg, err := LoadConfig()
47+ if err != nil {
48+ return fmt.Errorf("load config for orchestrated %s: %w", hooks.promptLabel, err)
49+ }
50+ prompt := args.Prompt
51+ if prompt == "" {
52+ piped, readErr := readAllStdin()
53+ if readErr != nil {
54+ return fmt.Errorf("read %s prompt from stdin: %w", hooks.promptLabel, readErr)
55+ }
56+ prompt = strings.TrimSpace(piped)
57+ }
58+ if prompt == "" {
59+ return fmt.Errorf("%s mode requires a prompt (arg or stdin)", hooks.promptLabel)
60+ }
61+ extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
62+ defer stopExt()
63+
64+ tracker := subagents.NewCompletionTracker()
65+ runtime := newOrchestratedRuntime(ctx, args, r, initialCfg, tracker)
66+ defer func() {
67+ if closeErr := closeSubagentRuntimeFresh(runtime); closeErr != nil {
68+ runErr = errors.Join(runErr, closeErr)
69+ }
70+ }()
71+
72+ prepareRegistry := func(reg core.Registry) core.Registry {
73+ return runtime.PrepareRegistry(reg)
74+ }
75+ runtime.SetProvider(r.Provider)
76+ runtime.SetProviderSettings(r.BaseURL, r.InsecureTLS)
77+ runtime.SetFastMode(r.FastMode)
78+ runtime.PrepareResolvedRegistry(r.ToolRegistry, r.WebSearchPolicy)
79+ ag := r.NewAgent()
80+ initialAg := ag
81+ defer func() {
82+ closeAgentLSP(ag)
83+ if ag != initialAg {
84+ closeAgentLSP(initialAg)
85+ }
86+ }()
87+ wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
88+
89+ sess, err := openOrCreateSession(ctx, args, r, ag, version)
90+ if err != nil {
91+ return err
92+ }
93+ if sess != nil {
94+ defer func() { runErr = joinSessionCloseError(runErr, sess) }()
95+ var providerName, model string
96+ sess, ag, providerName, model, err = applyInitialSessionResumeWithRuntime(ctx, args, r, extMgr, sess, ag, runtime)
97+ if err != nil {
98+ return err
99+ }
100+ r.Provider, r.Model = providerName, model
101+ ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
102+ runtime.SetActiveSession(sess.ID)
103+ }
104+ announceSession(extMgr, sess)
105+
106+ start := len(ag.Messages())
107+ preSink, finishPre := hooks.startupSink()
108+ preErr := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr)
109+ finishPre()
110+ if preErr != nil {
111+ if hooks.emitError != nil {
112+ hooks.emitError(preErr)
113+ }
114+ return preErr
115+ }
116+ if strings.TrimSpace(args.StartupPre) != "" {
117+ refreshedPolicy, refreshErr := reloadResourcesAfterStartupPreWithRegistry(ctx, args, extMgr, r.Sandbox, ag, prepareRegistry)
118+ if refreshErr != nil {
119+ if hooks.emitError != nil {
120+ hooks.emitError(refreshErr)
121+ }
122+ return refreshErr
123+ }
124+ // entry.pre may have changed the effective permission set. Keep the
125+ // worker supervisor in step with the refreshed parent registry.
126+ runtime.SetWebSearchPolicy(refreshedPolicy)
127+ }
128+
129+ var usagePersistErr error
130+ if sess != nil {
131+ ag.OnUsage = func(cumulative provider.Usage) {
132+ if usagePersistErr == nil {
133+ usagePersistErr = sess.AppendUsage(ag.LastTurnUsage(), cumulative)
134+ }
135+ }
136+ }
137+ var persistCompaction func([]provider.Message) error
138+ if sess != nil {
139+ persistCompaction = func(messages []provider.Message) error {
140+ return sess.AppendCompactionWithUsage(messages, ag.Cost())
141+ }
142+ }
143+
144+ transcriptStart := start
145+ runParent := func(turnCtx context.Context, text string) (string, error) {
146+ output, recovery, turnErr := hooks.runPrimary(turnCtx, ag, text, persistCompaction)
147+ if recovery.Compacted {
148+ transcriptStart = recovery.OutputStart
149+ }
150+ if usagePersistErr != nil {
151+ // Usage persistence happens after the mode renderer has completed.
152+ // JSON therefore needs an explicit terminal error for this failure;
153+ // unlike provider errors, no renderer event has reported it yet.
154+ if turnErr == nil && hooks.emitError != nil {
155+ hooks.emitError(usagePersistErr)
156+ }
157+ return output, errors.Join(turnErr, usagePersistErr)
158+ }
159+ return output, turnErr
160+ }
161+
162+ finalText, err := runParent(ctx, prompt)
163+ if err == nil {
164+ finalText, err = runHeadlessContinuation(ctx, tracker, finalText, runParent)
165+ if err != nil && hooks.emitError != nil {
166+ // A completion wait has no renderer call in which to report its
167+ // failure, so JSON must add its single terminal error object here.
168+ // Errors from the follow-up parent turn were already rendered by
169+ // that mode and must not be emitted a second time.
170+ var waitErr *headlessCompletionWaitError
171+ if errors.As(err, &waitErr) {
172+ hooks.emitError(waitErr)
173+ }
174+ }
175+ }
176+ if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
177+ if err == nil && hooks.emitError != nil {
178+ hooks.emitError(persistErr)
179+ }
180+ err = errors.Join(err, persistErr)
181+ }
182+ if err != nil {
183+ return err
184+ }
185+ return hooks.finish(finalText)
186+}
187+
188+func newOrchestratedRuntime(ctx context.Context, args Args, r Resolved, cfg Config, tracker *subagents.CompletionTracker) *subagentRuntime {
189+ onSpawned := func(a *subagents.Agent, task string) {
190+ tracker.TrackTurn(a, task, false)
191+ }
192+ onResumed := func(a *subagents.Agent, prompt string) {
193+ // BeforeResumed normally owns future-turn registration. Keep this
194+ // fallback for direct runtime callers that do not install the pre-hook;
195+ // SubagentResumeTool suppresses it when BeforeResumed accepted delivery.
196+ tracker.TrackTurn(a, prompt, true)
197+ }
198+ onBeforeResumed := func(a *subagents.Agent, prompt string) func() {
199+ return tracker.TrackFutureTurn(a, prompt, true)
200+ }
201+ onStopRequested := func(a *subagents.Agent) {
202+ tracker.TrackExit(a, "stopped")
203+ }
204+ return newSubagentRuntime(subagentRuntimeConfig{
205+ Context: ctx,
206+ Args: args,
207+ Root: filepath.Join(ZutHome(), "subagents"),
208+ RepoRoot: r.CWD,
209+ Provider: r.Provider,
210+ Model: r.Model,
211+ Reasoning: r.Reasoning,
212+ BaseURL: r.BaseURL,
213+ InsecureTLS: r.InsecureTLS,
214+ FastMode: r.FastMode,
215+ APIKey: args.APIKey,
216+ Policy: subagentPolicyFromConfig(cfg.Subagents),
217+ WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
218+ OnSpawned: onSpawned,
219+ OnResumed: onResumed,
220+ BeforeResumed: onBeforeResumed,
221+ OnStopRequested: onStopRequested,
222+ })
223+}
224+
225+func runOrchestratedStreamMode(ctx context.Context, args Args, version string) error {
226+ return runOrchestratedMode(ctx, args, version, orchestratedModeHooks{
227+ promptLabel: "stream",
228+ startupSink: func() (func(core.AgentEvent), func()) {
229+ return newStreamTextSink(os.Stdout)
230+ },
231+ runPrimary: func(turnCtx context.Context, ag *core.Agent, prompt string, persist func([]provider.Message) error) (string, modes.ContextRecoveryResult, error) {
232+ recovery, err := modes.RunStreamWithContextRecovery(turnCtx, ag, prompt, nil, os.Stdout, os.Stderr, persist)
233+ return "", recovery, err
234+ },
235+ finish: func(string) error { return nil },
236+ })
237+}
238+
239+func runOrchestratedJSONMode(ctx context.Context, args Args, version string) error {
240+ enc := json.NewEncoder(os.Stdout)
241+ var outputErr error
242+ encode := func(value any) {
243+ if outputErr == nil {
244+ outputErr = enc.Encode(value)
245+ }
246+ }
247+ runErr := runOrchestratedMode(ctx, args, version, orchestratedModeHooks{
248+ promptLabel: "json",
249+ startupSink: func() (func(core.AgentEvent), func()) {
250+ return func(ev core.AgentEvent) {
251+ encode(modes.EventToJSON(ev))
252+ }, func() {}
253+ },
254+ runPrimary: func(turnCtx context.Context, ag *core.Agent, prompt string, persist func([]provider.Message) error) (string, modes.ContextRecoveryResult, error) {
255+ recovery, err := modes.RunJSONWithContextRecovery(turnCtx, ag, prompt, nil, os.Stdout, persist)
256+ return "", recovery, err
257+ },
258+ emitError: func(err error) {
259+ if err != nil {
260+ encode(map[string]any{"type": "error", "message": err.Error()})
261+ }
262+ },
263+ finish: func(string) error { return nil },
264+ })
265+ return errors.Join(runErr, outputErr)
266+}
A packages/agent/orchestration_modes_test.go +122 -0
@@ -0,0 +1,122 @@
1+package agent
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "io"
9+ "net/http"
10+ "net/http/httptest"
11+ "os"
12+ "strings"
13+ "testing"
14+)
15+
16+func TestRunOrchestratedStreamUsesStreamRenderer(t *testing.T) {
17+ home := t.TempDir()
18+ t.Setenv("ZUT_HOME", home)
19+ server := orchestratedModeTestServer("stream answer")
20+ defer server.Close()
21+
22+ output, runErr := captureTestStdout(t, func() error {
23+ return runOrchestratedStreamMode(context.Background(), Args{
24+ Mode: ModeStream,
25+ Orchestrate: true,
26+ Provider: "openai",
27+ Model: "gpt-5",
28+ BaseURL: server.URL,
29+ APIKey: "test-key",
30+ Prompt: "say hello",
31+ }, "test")
32+ })
33+ if runErr != nil {
34+ t.Fatalf("runOrchestratedStreamMode error: %v", runErr)
35+ }
36+ if got := output; got != "stream answer\n" {
37+ t.Fatalf("stdout = %q, want one rendered answer newline", got)
38+ }
39+}
40+
41+func TestRunOrchestratedJSONRetainsParentEventsAsJSONL(t *testing.T) {
42+ home := t.TempDir()
43+ t.Setenv("ZUT_HOME", home)
44+ server := orchestratedModeTestServer("json answer")
45+ defer server.Close()
46+
47+ output, runErr := captureTestStdout(t, func() error {
48+ return runOrchestratedJSONMode(context.Background(), Args{
49+ Mode: ModeJSON,
50+ Orchestrate: true,
51+ Provider: "openai",
52+ Model: "gpt-5",
53+ BaseURL: server.URL,
54+ APIKey: "test-key",
55+ Prompt: "say hello",
56+ }, "test")
57+ })
58+ if runErr != nil {
59+ t.Fatalf("runOrchestratedJSONMode error: %v", runErr)
60+ }
61+
62+ seen := map[string]bool{}
63+ scanner := bufio.NewScanner(strings.NewReader(output))
64+ for scanner.Scan() {
65+ var event map[string]any
66+ if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
67+ t.Fatalf("invalid JSONL line %q: %v", scanner.Text(), err)
68+ }
69+ if typ, ok := event["type"].(string); ok {
70+ seen[typ] = true
71+ }
72+ }
73+ if err := scanner.Err(); err != nil {
74+ t.Fatal(err)
75+ }
76+ for _, typ := range []string{"user_message", "assistant_message", "turn_end"} {
77+ if !seen[typ] {
78+ t.Fatalf("JSONL omitted %s event: %s", typ, output)
79+ }
80+ }
81+}
82+
83+func captureTestStdout(t *testing.T, run func() error) (string, error) {
84+ t.Helper()
85+ oldStdout := os.Stdout
86+ reader, writer, err := os.Pipe()
87+ if err != nil {
88+ return "", err
89+ }
90+ os.Stdout = writer
91+ t.Cleanup(func() {
92+ os.Stdout = oldStdout
93+ _ = writer.Close()
94+ _ = reader.Close()
95+ })
96+ readDone := make(chan struct {
97+ output []byte
98+ err error
99+ }, 1)
100+ go func() {
101+ output, readErr := io.ReadAll(reader)
102+ readDone <- struct {
103+ output []byte
104+ err error
105+ }{output: output, err: readErr}
106+ }()
107+
108+ runErr := run()
109+ _ = writer.Close()
110+ os.Stdout = oldStdout
111+ result := <-readDone
112+ _ = reader.Close()
113+ return string(result.output), errors.Join(runErr, result.err)
114+}
115+
116+func orchestratedModeTestServer(answer string) *httptest.Server {
117+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
118+ w.Header().Set("content-type", "text/event-stream")
119+ _, _ = io.WriteString(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\""+answer+"\"},\"finish_reason\":\"stop\"}]}\n\n")
120+ _, _ = io.WriteString(w, "data: [DONE]\n\n")
121+ }))
122+}
A packages/agent/orchestration_print.go +34 -0
@@ -0,0 +1,34 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "context"
6+ "fmt"
7+ "os"
8+ "strings"
9+
10+ "github.com/bnema/zut/packages/agent/modes"
11+ "github.com/bnema/zut/packages/core"
12+ "github.com/bnema/zut/packages/provider"
13+)
14+
15+// runOrchestratedPrintMode is deliberately separate from ordinary print mode:
16+// ordinary print continues to stream its existing one-turn implementation,
17+// while this path keeps every parent turn in memory until terminal synthesis.
18+func runOrchestratedPrintMode(ctx context.Context, args Args, version string) error {
19+ return runOrchestratedMode(ctx, args, version, orchestratedModeHooks{
20+ promptLabel: "print",
21+ startupSink: func() (func(core.AgentEvent), func()) {
22+ return nil, func() {}
23+ },
24+ runPrimary: func(turnCtx context.Context, ag *core.Agent, prompt string, persist func([]provider.Message) error) (string, modes.ContextRecoveryResult, error) {
25+ var captured bytes.Buffer
26+ _, recovery, err := modes.RunPrintWithContextRecovery(turnCtx, ag, prompt, nil, &captured, persist)
27+ return strings.TrimSuffix(captured.String(), "\n"), recovery, err
28+ },
29+ finish: func(finalText string) error {
30+ _, err := fmt.Fprintln(os.Stdout, finalText)
31+ return err
32+ },
33+ })
34+}
A packages/agent/orchestration_print_test.go +52 -0
@@ -0,0 +1,52 @@
1+package agent
2+
3+import (
4+ "context"
5+ "os"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestRunOrchestratedPrintEmitsOnlyFinalAnswer(t *testing.T) {
11+ home := t.TempDir()
12+ t.Setenv("ZUT_HOME", home)
13+ server := orchestratedModeTestServer("final answer")
14+ defer server.Close()
15+
16+ output, runErr := captureTestStdout(t, func() error {
17+ return runOrchestratedPrintMode(context.Background(), Args{
18+ Mode: ModePrint,
19+ Orchestrate: true,
20+ Provider: "openai",
21+ Model: "gpt-5",
22+ BaseURL: server.URL,
23+ APIKey: "test-key",
24+ Prompt: "say hello",
25+ }, "test")
26+ })
27+ if runErr != nil {
28+ t.Fatalf("runOrchestratedPrintMode error: %v", runErr)
29+ }
30+ if got := output; got != "final answer\n" {
31+ t.Fatalf("stdout = %q, want final answer only", got)
32+ }
33+}
34+
35+func TestRunOrchestratedPrintPropagatesConfigLoadError(t *testing.T) {
36+ home := t.TempDir()
37+ t.Setenv("ZUT_HOME", home)
38+ if err := os.WriteFile(ConfigPath(), []byte("{"), 0o600); err != nil {
39+ t.Fatal(err)
40+ }
41+
42+ err := runOrchestratedPrintMode(context.Background(), Args{
43+ Mode: ModePrint,
44+ Orchestrate: true,
45+ Provider: "ollama",
46+ Model: "llama3",
47+ Prompt: "test",
48+ }, "test")
49+ if err == nil || !strings.Contains(err.Error(), "load config for orchestrated print") {
50+ t.Fatalf("runOrchestratedPrintMode error = %v, want config load error", err)
51+ }
52+}
A packages/agent/orchestration_shutdown.go +17 -0
@@ -0,0 +1,17 @@
1+package agent
2+
3+import (
4+ "context"
5+ "time"
6+)
7+
8+const headlessShutdownTimeout = 5 * time.Second
9+
10+func closeSubagentRuntimeFresh(rt *subagentRuntime) error {
11+ if rt == nil {
12+ return nil
13+ }
14+ shutdownCtx, cancel := context.WithTimeout(context.Background(), headlessShutdownTimeout)
15+ defer cancel()
16+ return rt.Close(shutdownCtx)
17+}
A packages/agent/subagent_runtime.go +480 -0
@@ -0,0 +1,480 @@
1+package agent
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "strings"
7+ "sync"
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+)
14+
15+// subagentRuntime owns the manager-facing runtime assembled around a host
16+// session. Keeping this state together is important: every injected manager
17+// tool must use the same supervisor, model/provider selection, profile root,
18+// policy, callbacks, and shutdown lifecycle.
19+type subagentRuntime struct {
20+ supervisor *subagents.Supervisor
21+ args Args
22+ root string
23+
24+ provider string
25+ model string
26+ reasoning string
27+ baseURL string
28+ insecureTLS bool
29+ fastMode bool
30+ repoRoot string
31+
32+ activeProvider func() string
33+ activeModel func() string
34+ activeReasoning func() string
35+
36+ apiKey string
37+ apiKeyProvider string
38+ webSearchPolicy subagents.WebSearchPolicy
39+ webSearchGuard *webSearchSessionGuard
40+
41+ onSpawned func(*subagents.Agent, string)
42+ onResumed func(*subagents.Agent, string)
43+ beforeResumed func(*subagents.Agent, string) func()
44+ onStopRequested func(*subagents.Agent)
45+
46+ settingsMu sync.RWMutex
47+ closeMu sync.Mutex
48+ closed bool
49+}
50+
51+type subagentRuntimeConfiguration struct {
52+ provider string
53+ model string
54+ baseURL string
55+ insecureTLS bool
56+ fastMode bool
57+ repoRoot string
58+ webSearchPolicy subagents.WebSearchPolicy
59+}
60+
61+// subagentRuntimeConfig supplies the host-owned settings and seams needed to
62+// assemble a subagentRuntime. The active selection callbacks are optional;
63+// when omitted, the initial Provider/Model/Reasoning values are used.
64+type subagentRuntimeConfig struct {
65+ Context context.Context
66+ Args Args
67+ Root string
68+ RepoRoot string
69+
70+ Provider string
71+ Model string
72+ Reasoning string
73+ BaseURL string
74+ InsecureTLS bool
75+ FastMode bool
76+
77+ APIKey string
78+ Policy subagents.SubagentPolicy
79+ WebSearchPolicy subagents.WebSearchPolicy
80+ WebSearchGuard *webSearchSessionGuard
81+ ActiveProvider func() string
82+ ActiveModel func() string
83+ ActiveReasoning func() string
84+ NewRunner func(*subagents.Agent) subagents.Runner
85+
86+ OnSpawned func(*subagents.Agent, string)
87+ OnResumed func(*subagents.Agent, string)
88+ BeforeResumed func(*subagents.Agent, string) func()
89+ OnStopRequested func(*subagents.Agent)
90+}
91+
92+func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime {
93+ if cfg.Context == nil {
94+ cfg.Context = context.Background()
95+ }
96+ rt := &subagentRuntime{
97+ args: cfg.Args,
98+ root: cfg.Root,
99+ provider: strings.TrimSpace(cfg.Provider),
100+ model: strings.TrimSpace(cfg.Model),
101+ reasoning: strings.TrimSpace(cfg.Reasoning),
102+ baseURL: strings.TrimSpace(cfg.BaseURL),
103+ insecureTLS: cfg.InsecureTLS,
104+ fastMode: cfg.FastMode,
105+ repoRoot: cfg.RepoRoot,
106+ activeProvider: cfg.ActiveProvider,
107+ activeModel: cfg.ActiveModel,
108+ activeReasoning: cfg.ActiveReasoning,
109+ apiKey: cfg.APIKey,
110+ webSearchPolicy: cfg.WebSearchPolicy,
111+ webSearchGuard: cfg.WebSearchGuard,
112+ onSpawned: cfg.OnSpawned,
113+ onResumed: cfg.OnResumed,
114+ beforeResumed: cfg.BeforeResumed,
115+ onStopRequested: cfg.OnStopRequested,
116+ }
117+ if rt.activeProvider == nil {
118+ rt.activeProvider = func() string {
119+ rt.settingsMu.RLock()
120+ defer rt.settingsMu.RUnlock()
121+ return rt.provider
122+ }
123+ }
124+ if rt.activeModel == nil {
125+ rt.activeModel = func() string {
126+ rt.settingsMu.RLock()
127+ defer rt.settingsMu.RUnlock()
128+ return rt.model
129+ }
130+ }
131+ if rt.activeReasoning == nil {
132+ rt.activeReasoning = func() string {
133+ rt.settingsMu.RLock()
134+ defer rt.settingsMu.RUnlock()
135+ return rt.reasoning
136+ }
137+ }
138+ if rt.apiKey != "" {
139+ // A launch-time API key belongs to the provider selected at startup. It
140+ // must not follow the live provider callback through a later transition.
141+ rt.apiKeyProvider = canonicalProvider(rt.provider)
142+ }
143+
144+ // Packaged agents have an explicit capability ceiling. Until worker
145+ // capability propagation exists, they cannot expose delegation tools or a
146+ // fresh unrestricted supervisor.
147+ if cfg.Args.PermissionSet == nil {
148+ root := rt.root
149+ if strings.TrimSpace(root) == "" {
150+ root = filepath.Join(ZutHome(), "subagents")
151+ rt.root = root
152+ }
153+ rt.supervisor = subagents.New(subagents.Config{
154+ Context: cfg.Context,
155+ Root: root,
156+ RepoRoot: cfg.RepoRoot,
157+ Provider: rt.currentProvider(),
158+ FastMode: cfg.FastMode,
159+ WebSearchPolicy: cfg.WebSearchPolicy,
160+ BaseURL: cfg.BaseURL,
161+ InsecureTLS: cfg.InsecureTLS,
162+ NewRunner: cfg.NewRunner,
163+ Policy: cfg.Policy,
164+ ResolveCredential: rt.resolveCredential,
165+ })
166+ }
167+ return rt
168+}
169+
170+func (rt *subagentRuntime) Supervisor() *subagents.Supervisor {
171+ if rt == nil {
172+ return nil
173+ }
174+ return rt.supervisor
175+}
176+
177+func (rt *subagentRuntime) currentProvider() string {
178+ if rt == nil || rt.activeProvider == nil {
179+ return ""
180+ }
181+ return strings.TrimSpace(rt.activeProvider())
182+}
183+
184+func (rt *subagentRuntime) currentModel() string {
185+ if rt == nil || rt.activeModel == nil {
186+ return ""
187+ }
188+ return strings.TrimSpace(rt.activeModel())
189+}
190+
191+func (rt *subagentRuntime) SetModel(model string) {
192+ if rt == nil {
193+ return
194+ }
195+ rt.settingsMu.Lock()
196+ rt.model = strings.TrimSpace(model)
197+ rt.settingsMu.Unlock()
198+}
199+
200+func (rt *subagentRuntime) SetReasoning(reasoning string) {
201+ if rt == nil {
202+ return
203+ }
204+ rt.settingsMu.Lock()
205+ rt.reasoning = strings.TrimSpace(reasoning)
206+ rt.settingsMu.Unlock()
207+}
208+
209+func (rt *subagentRuntime) currentReasoning() string {
210+ if rt == nil || rt.activeReasoning == nil {
211+ return ""
212+ }
213+ return strings.TrimSpace(rt.activeReasoning())
214+}
215+
216+func (rt *subagentRuntime) SetProvider(providerID string) {
217+ if rt == nil {
218+ return
219+ }
220+ providerID = strings.TrimSpace(providerID)
221+ rt.settingsMu.Lock()
222+ rt.provider = providerID
223+ rt.settingsMu.Unlock()
224+ if rt.supervisor != nil {
225+ rt.supervisor.SetProvider(providerID)
226+ }
227+}
228+
229+func (rt *subagentRuntime) SetProviderSettings(baseURL string, insecureTLS bool) {
230+ if rt == nil {
231+ return
232+ }
233+ baseURL = strings.TrimSpace(baseURL)
234+ rt.settingsMu.Lock()
235+ rt.baseURL = baseURL
236+ rt.insecureTLS = insecureTLS
237+ rt.settingsMu.Unlock()
238+ if rt.supervisor != nil {
239+ rt.supervisor.SetProviderSettings(baseURL, insecureTLS)
240+ }
241+}
242+
243+func (rt *subagentRuntime) SetFastMode(fastMode bool) {
244+ if rt == nil {
245+ return
246+ }
247+ rt.settingsMu.Lock()
248+ rt.fastMode = fastMode
249+ rt.settingsMu.Unlock()
250+ if rt.supervisor != nil {
251+ rt.supervisor.SetFastMode(fastMode)
252+ }
253+}
254+
255+func (rt *subagentRuntime) SetRepoRoot(repoRoot string) {
256+ if rt == nil {
257+ return
258+ }
259+ rt.settingsMu.Lock()
260+ rt.repoRoot = repoRoot
261+ rt.settingsMu.Unlock()
262+ if rt.supervisor != nil {
263+ rt.supervisor.SetRepoRoot(repoRoot)
264+ }
265+}
266+
267+// snapshotConfiguration captures the runtime settings changed while an agent
268+// candidate is being built. Transition callers restore it when candidate
269+// preparation fails before the new agent is committed.
270+func (rt *subagentRuntime) snapshotConfiguration() subagentRuntimeConfiguration {
271+ if rt == nil {
272+ return subagentRuntimeConfiguration{}
273+ }
274+ rt.settingsMu.RLock()
275+ defer rt.settingsMu.RUnlock()
276+ return subagentRuntimeConfiguration{
277+ provider: rt.provider,
278+ model: rt.model,
279+ baseURL: rt.baseURL,
280+ insecureTLS: rt.insecureTLS,
281+ fastMode: rt.fastMode,
282+ repoRoot: rt.repoRoot,
283+ webSearchPolicy: rt.webSearchPolicy,
284+ }
285+}
286+
287+func (rt *subagentRuntime) restoreConfiguration(snapshot subagentRuntimeConfiguration) {
288+ if rt == nil {
289+ return
290+ }
291+ rt.settingsMu.Lock()
292+ rt.provider = snapshot.provider
293+ rt.model = snapshot.model
294+ rt.baseURL = snapshot.baseURL
295+ rt.insecureTLS = snapshot.insecureTLS
296+ rt.fastMode = snapshot.fastMode
297+ rt.repoRoot = snapshot.repoRoot
298+ rt.webSearchPolicy = snapshot.webSearchPolicy
299+ rt.settingsMu.Unlock()
300+ if rt.supervisor != nil {
301+ rt.supervisor.SetProvider(snapshot.provider)
302+ rt.supervisor.SetProviderSettings(snapshot.baseURL, snapshot.insecureTLS)
303+ rt.supervisor.SetFastMode(snapshot.fastMode)
304+ rt.supervisor.SetRepoRoot(snapshot.repoRoot)
305+ rt.supervisor.SetWebSearchPolicy(snapshot.webSearchPolicy)
306+ }
307+}
308+
309+func (rt *subagentRuntime) SetActiveSession(sessionID string) {
310+ if rt != nil && rt.supervisor != nil {
311+ rt.supervisor.SetActiveSession(sessionID)
312+ }
313+}
314+
315+func (rt *subagentRuntime) SetWebSearchPolicy(policy subagents.WebSearchPolicy) {
316+ if rt == nil {
317+ return
318+ }
319+ rt.settingsMu.Lock()
320+ rt.webSearchPolicy = policy
321+ rt.settingsMu.Unlock()
322+ if rt.supervisor != nil {
323+ rt.supervisor.SetWebSearchPolicy(policy)
324+ }
325+}
326+
327+func (rt *subagentRuntime) resolveCredential(ctx context.Context, providerID string) (subagents.Credential, error) {
328+ childProvider := canonicalProvider(providerID)
329+ if childProvider == "" {
330+ childProvider = canonicalProvider(rt.currentProvider())
331+ }
332+ explicit := ""
333+ // A launch-time API key is scoped to the provider selected at startup.
334+ // Never send it to an explicitly different child provider; resolve that
335+ // provider's own credential instead.
336+ if rt.apiKey != "" && childProvider == rt.apiKeyProvider {
337+ explicit = rt.apiKey
338+ }
339+ if childProvider == "ollama" && explicit == "" {
340+ return subagents.Credential{Value: "ollama", Method: "apikey"}, nil
341+ }
342+ credential, method, accountID, err := ResolveCredentialFullContext(ctx, childProvider, explicit)
343+ if err != nil {
344+ // Providers backed only by a local endpoint do not need a key. Leave
345+ // stdin untouched and let the child resolve that endpoint.
346+ if !CredentialAvailable(childProvider) {
347+ return subagents.Credential{}, nil
348+ }
349+ return subagents.Credential{}, err
350+ }
351+ return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil
352+}
353+
354+func (rt *subagentRuntime) resolveSubagent(name string) (*subagents.Profile, error) {
355+ if rt == nil {
356+ return nil, nil
357+ }
358+ rt.settingsMu.RLock()
359+ repoRoot := rt.repoRoot
360+ rt.settingsMu.RUnlock()
361+ return findSubagentProfile(repoRoot, name)
362+}
363+
364+// InjectTools adds the canonical manager tools allowed by the host launch
365+// policy. It deliberately mutates the supplied registry, matching the normal
366+// interactive registry assembly path.
367+func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry {
368+ if reg == nil || rt == nil || rt.supervisor == nil || !autoSubagentsAnyToolAllowed(rt.args) {
369+ return reg
370+ }
371+ if autoSubagentsToolAllowed(rt.args) {
372+ spawn := &tools.SubagentSpawnTool{
373+ Supervisor: rt.supervisor,
374+ Enabled: func() bool { return true },
375+ DefaultModel: func() string {
376+ return rt.currentModel()
377+ },
378+ DefaultProvider: func() string {
379+ return rt.currentProvider()
380+ },
381+ DefaultReasoning: func() string {
382+ return rt.currentReasoning()
383+ },
384+ ResolveSubagent: rt.resolveSubagent,
385+ OnSpawned: rt.onSpawned,
386+ }
387+ reg[spawn.Name()] = spawn
388+ }
389+ if autoSubagentsStatusToolAllowed(rt.args) {
390+ status := &tools.SubagentStatusTool{
391+ Supervisor: rt.supervisor,
392+ Enabled: func() bool { return true },
393+ }
394+ reg[status.Name()] = status
395+ }
396+ if autoSubagentsStopToolAllowed(rt.args) {
397+ stop := &tools.SubagentStopTool{
398+ Supervisor: rt.supervisor,
399+ Enabled: func() bool { return true },
400+ OnStopRequested: rt.onStopRequested,
401+ }
402+ reg[stop.Name()] = stop
403+ }
404+ if autoSubagentsResumeToolAllowed(rt.args) {
405+ resume := &tools.SubagentResumeTool{
406+ Supervisor: rt.supervisor,
407+ Enabled: func() bool { return true },
408+ BeforeResumed: rt.beforeResumed,
409+ OnResumed: rt.onResumed,
410+ }
411+ reg[resume.Name()] = resume
412+ }
413+ return reg
414+}
415+
416+func (rt *subagentRuntime) PrepareRegistry(reg core.Registry) core.Registry {
417+ reg = rt.InjectTools(reg)
418+ if rt != nil && rt.webSearchGuard != nil {
419+ return rt.webSearchGuard.wrapRegistry(reg)
420+ }
421+ return reg
422+}
423+
424+func (rt *subagentRuntime) PrepareResolvedRegistry(reg core.Registry, policy subagents.WebSearchPolicy) core.Registry {
425+ // Keep the supervisor's capability policy in lockstep with the resolved
426+ // parent registry. This is especially important after entry.pre refreshes
427+ // permissions: workers launched after the refresh must not retain the old
428+ // parent allow decision.
429+ if rt != nil {
430+ rt.SetWebSearchPolicy(webSearchPolicyForRegistry(policy, reg))
431+ }
432+ if policy != subagents.WebSearchAllow {
433+ delete(reg, "web_search")
434+ }
435+ return rt.PrepareRegistry(reg)
436+}
437+
438+func (rt *subagentRuntime) Close(ctx context.Context) error {
439+ if rt == nil || rt.supervisor == nil {
440+ return nil
441+ }
442+ rt.closeMu.Lock()
443+ defer rt.closeMu.Unlock()
444+ if rt.closed {
445+ return nil
446+ }
447+ if err := rt.supervisor.StopAllContext(ctx); err != nil {
448+ return err
449+ }
450+ rt.closed = true
451+ return nil
452+}
453+
454+func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
455+ parseDuration := func(value string) time.Duration {
456+ if strings.TrimSpace(value) == "" {
457+ return 0
458+ }
459+ parsed, err := time.ParseDuration(strings.TrimSpace(value))
460+ if err != nil || parsed <= 0 {
461+ return 0
462+ }
463+ return parsed
464+ }
465+ return subagents.SubagentPolicy{
466+ MaxConcurrent: cfg.MaxConcurrent,
467+ MaxConcurrentPerParent: cfg.MaxConcurrentPerParent,
468+ QueueTimeout: parseDuration(cfg.QueueTimeout),
469+ DefaultTimeout: parseDuration(cfg.DefaultTimeout),
470+ MaxTurns: cfg.MaxTurns,
471+ MaxOutputBytes: cfg.MaxOutputBytes,
472+ MaxOutputLines: cfg.MaxOutputLines,
473+ AllowedTools: append([]string(nil), cfg.AllowedTools...),
474+ AllowedRoots: append([]string(nil), cfg.AllowedRoots...),
475+ HeartbeatInterval: parseDuration(cfg.HeartbeatInterval),
476+ IdleTimeout: parseDuration(cfg.IdleTimeout),
477+ ReconnectTimeout: parseDuration(cfg.ReconnectTimeout),
478+ CancelGracePeriod: parseDuration(cfg.CancelGracePeriod),
479+ }
480+}
A packages/agent/subagent_runtime_test.go +418 -0
@@ -0,0 +1,418 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "sync"
10+ "sync/atomic"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/zut/packages/agent/subagents"
15+ "github.com/bnema/zut/packages/agent/tools"
16+ "github.com/bnema/zut/packages/core"
17+)
18+
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+ MaxTurns: 4,
63+ DefaultTimeout: time.Minute,
64+ CancelGracePeriod: time.Second,
65+ },
66+ NewRunner: runner,
67+ }
68+}
69+
70+func TestSubagentRuntimeLaunchCredentialStaysWithLaunchProvider(t *testing.T) {
71+ activeProvider := "parent-provider"
72+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
73+ return &runtimeTestRunner{}
74+ })
75+ cfg.APIKey = "launch-key"
76+ cfg.ActiveProvider = func() string { return activeProvider }
77+ rt := newSubagentRuntime(cfg)
78+ defer func() { _ = rt.Close(context.Background()) }()
79+
80+ credential, err := rt.resolveCredential(context.Background(), "parent-provider")
81+ if err != nil {
82+ t.Fatalf("launch provider credential: %v", err)
83+ }
84+ if credential.Value != "launch-key" {
85+ t.Fatalf("launch provider credential = %q, want launch key", credential.Value)
86+ }
87+
88+ activeProvider = "later-provider"
89+ for _, providerID := range []string{"", "later-provider"} {
90+ credential, err = rt.resolveCredential(context.Background(), providerID)
91+ if err != nil {
92+ t.Fatalf("later provider credential (%q): %v", providerID, err)
93+ }
94+ if credential.Value == "launch-key" {
95+ t.Fatalf("launch API key leaked to provider selected after construction (%q)", providerID)
96+ }
97+ }
98+}
99+
100+func TestSubagentRuntimeOwnsSupervisorConfigurationAndResolution(t *testing.T) {
101+ root := t.TempDir()
102+ repoRoot := t.TempDir()
103+ var captured *subagents.Agent
104+ runner := func(a *subagents.Agent) subagents.Runner {
105+ captured = a
106+ return &runtimeTestRunner{}
107+ }
108+ rt := newSubagentRuntime(newRuntimeTestConfig(root, repoRoot, runner))
109+ if rt == nil || rt.Supervisor() == nil {
110+ t.Fatal("runtime did not create a supervisor")
111+ }
112+ defer func() { _ = rt.Close(context.Background()) }()
113+
114+ registry := rt.InjectTools(core.NewRegistry())
115+ tool, ok := registry["subagent_spawn"]
116+ if !ok {
117+ t.Fatal("runtime did not inject canonical spawn tool")
118+ }
119+ if _, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"configure child"}`), nil); err != nil {
120+ t.Fatalf("spawn tool: %v", err)
121+ }
122+ if captured == nil {
123+ t.Fatal("runner did not receive an agent")
124+ }
125+ if captured.Provider != "parent-provider" || captured.Model != "parent-model" || captured.Reasoning != "high" {
126+ t.Fatalf("child model settings = provider=%q model=%q reasoning=%q", captured.Provider, captured.Model, captured.Reasoning)
127+ }
128+ if captured.BaseURL != "https://parent.example/v1" || !captured.InsecureTLS || !captured.FastMode {
129+ t.Fatalf("child connection/settings = base_url=%q insecure_tls=%v fast_mode=%v", captured.BaseURL, captured.InsecureTLS, captured.FastMode)
130+ }
131+ if captured.Dir != repoRoot {
132+ t.Fatalf("child repo root = %q, want %q", captured.Dir, repoRoot)
133+ }
134+}
135+
136+func TestSubagentRuntimeConfigurationConcurrentAccess(t *testing.T) {
137+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
138+ return &runtimeTestRunner{}
139+ })
140+ rt := newSubagentRuntime(cfg)
141+ defer func() { _ = rt.Close(context.Background()) }()
142+ repoRoot := t.TempDir()
143+
144+ var wg sync.WaitGroup
145+ wg.Add(2)
146+ go func() {
147+ defer wg.Done()
148+ for index := 0; index < 100; index++ {
149+ rt.SetProvider("provider")
150+ rt.SetModel("model")
151+ rt.SetReasoning("medium")
152+ rt.SetProviderSettings("https://provider.example/v1", index%2 == 0)
153+ rt.SetFastMode(index%2 == 0)
154+ rt.SetRepoRoot(repoRoot)
155+ rt.SetWebSearchPolicy(subagents.WebSearchDeny)
156+ }
157+ }()
158+ go func() {
159+ defer wg.Done()
160+ for index := 0; index < 100; index++ {
161+ _ = rt.currentProvider()
162+ _ = rt.currentModel()
163+ _ = rt.currentReasoning()
164+ _ = rt.snapshotConfiguration()
165+ }
166+ }()
167+ wg.Wait()
168+}
169+
170+func TestSubagentRuntimeResolvedWebSearchPolicyRefreshesSupervisor(t *testing.T) {
171+ var captured []*subagents.Agent
172+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(a *subagents.Agent) subagents.Runner {
173+ captured = append(captured, a)
174+ return &runtimeTestRunner{}
175+ })
176+ cfg.WebSearchPolicy = subagents.WebSearchAllow
177+ rt := newSubagentRuntime(cfg)
178+ defer func() { _ = rt.Close(context.Background()) }()
179+
180+ allowedRegistry := core.Registry{"web_search": &recordingWebSearchTool{}}
181+ rt.PrepareResolvedRegistry(allowedRegistry, subagents.WebSearchAllow)
182+ if rt.Supervisor().WebSearchPolicy() != subagents.WebSearchAllow {
183+ t.Fatal("resolved allow policy was not propagated to supervisor")
184+ }
185+ allowed, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
186+ Task: "allowed child",
187+ Tools: []string{"web_search"},
188+ })
189+ if err != nil {
190+ t.Fatalf("spawn allowed child: %v", err)
191+ }
192+ if allowed.WebSearchPolicy != subagents.WebSearchAllow {
193+ t.Fatalf("allowed child policy = %v, want allow", allowed.WebSearchPolicy)
194+ }
195+
196+ deniedRegistry := core.Registry{"web_search": &recordingWebSearchTool{}}
197+ rt.PrepareResolvedRegistry(deniedRegistry, subagents.WebSearchDeny)
198+ if _, ok := deniedRegistry["web_search"]; ok {
199+ t.Fatal("resolved deny policy left web_search in parent registry")
200+ }
201+ if rt.Supervisor().WebSearchPolicy() != subagents.WebSearchDeny {
202+ t.Fatal("resolved deny policy was not propagated to supervisor")
203+ }
204+ denied, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
205+ Task: "denied child",
206+ Tools: []string{"web_search"},
207+ })
208+ if err != nil {
209+ t.Fatalf("spawn denied child: %v", err)
210+ }
211+ if denied.WebSearchPolicy != subagents.WebSearchDeny {
212+ t.Fatalf("denied child policy = %v, want deny", denied.WebSearchPolicy)
213+ }
214+ if len(captured) != 2 {
215+ t.Fatalf("captured children = %d, want 2", len(captured))
216+ }
217+}
218+
219+func TestSubagentRuntimeInjectsCanonicalToolsAccordingToPolicy(t *testing.T) {
220+ cases := []struct {
221+ name string
222+ args Args
223+ want []string
224+ }{
225+ {name: "default", want: []string{"subagent_spawn", "subagent_status", "subagent_stop", "subagent_resume"}},
226+ {name: "explicit spawn and status", args: Args{ToolsSet: true, Tools: []string{"subagent_spawn", "subagent_status"}}, want: []string{"subagent_spawn", "subagent_status"}},
227+ {name: "no tools", args: Args{NoTools: true}},
228+ {name: "permission set", args: Args{PermissionSet: &tools.PermissionSet{}}},
229+ }
230+ for _, tc := range cases {
231+ t.Run(tc.name, func(t *testing.T) {
232+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
233+ return &runtimeTestRunner{}
234+ })
235+ cfg.Args = tc.args
236+ rt := newSubagentRuntime(cfg)
237+ defer func() { _ = rt.Close(context.Background()) }()
238+ got := rt.InjectTools(core.NewRegistry())
239+ for _, name := range tc.want {
240+ if _, ok := got[name]; !ok {
241+ t.Errorf("missing injected tool %q in %#v", name, got)
242+ }
243+ }
244+ if len(got) != len(tc.want) {
245+ t.Errorf("injected tools = %#v, want exactly %v", got, tc.want)
246+ }
247+ })
248+ }
249+}
250+
251+func TestSubagentRuntimeFailedTransitionRestoresConfiguration(t *testing.T) {
252+ rt := newSubagentRuntime(newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
253+ return &runtimeTestRunner{}
254+ }))
255+ defer func() { _ = rt.Close(context.Background()) }()
256+ before := rt.snapshotConfiguration()
257+
258+ if err := func() error {
259+ rt.SetProvider("candidate-provider")
260+ rt.SetProviderSettings("https://candidate.example/v1", false)
261+ rt.SetFastMode(false)
262+ rt.SetRepoRoot(t.TempDir())
263+ rt.SetWebSearchPolicy(subagents.WebSearchDeny)
264+ return errors.New("candidate transition failed")
265+ }(); err != nil {
266+ rt.restoreConfiguration(before)
267+ }
268+
269+ if got := rt.snapshotConfiguration(); got != before {
270+ t.Fatalf("runtime configuration after rollback = %+v, want %+v", got, before)
271+ }
272+ if got := rt.Supervisor().WebSearchPolicy(); got != before.webSearchPolicy {
273+ t.Fatalf("supervisor web-search policy after rollback = %v, want %v", got, before.webSearchPolicy)
274+ }
275+}
276+
277+func TestSubagentRuntimeFreshShutdownContextCleansCanceledParent(t *testing.T) {
278+ runner := &runtimeTestRunner{started: make(chan struct{}), block: true}
279+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner { return runner })
280+ rt := newSubagentRuntime(cfg)
281+ spawn := rt.InjectTools(core.NewRegistry())["subagent_spawn"]
282+ if _, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"cancel me"}`), nil); err != nil {
283+ t.Fatal(err)
284+ }
285+ select {
286+ case <-runner.started:
287+ case <-time.After(time.Second):
288+ t.Fatal("runner did not start")
289+ }
290+ if err := closeSubagentRuntimeFresh(rt); err != nil {
291+ t.Fatalf("fresh shutdown: %v", err)
292+ }
293+ if got := runner.canceled.Load(); got != 1 {
294+ t.Fatalf("runner cancellation count = %d, want 1", got)
295+ }
296+}
297+
298+func TestSubagentRuntimeCloseRetriesAfterCanceledContext(t *testing.T) {
299+ runner := &stubbornRuntimeTestRunner{started: make(chan struct{}), release: make(chan struct{})}
300+ cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner { return runner })
301+ rt := newSubagentRuntime(cfg)
302+ spawn := rt.InjectTools(core.NewRegistry())["subagent_spawn"]
303+ if _, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"retry cleanup"}`), nil); err != nil {
304+ t.Fatal(err)
305+ }
306+ select {
307+ case <-runner.started:
308+ case <-time.After(time.Second):
309+ t.Fatal("runner did not start")
310+ }
311+
312+ canceled, cancel := context.WithCancel(context.Background())
313+ cancel()
314+ if err := rt.Close(canceled); !errors.Is(err, context.Canceled) {
315+ t.Fatalf("first Close error = %v, want context canceled", err)
316+ }
317+ close(runner.release)
318+ if err := rt.Close(context.Background()); err != nil {
319+ t.Fatalf("retry Close: %v", err)
320+ }
321+ if err := rt.Close(context.Background()); err != nil {
322+ t.Fatalf("idempotent Close: %v", err)
323+ }
324+}
325+
326+func TestSubagentRuntimeCallbacksAndExactlyOnceClose(t *testing.T) {
327+ root := t.TempDir()
328+ runner := &runtimeTestRunner{started: make(chan struct{}), block: true}
329+ var spawned atomic.Int32
330+ var stopped atomic.Int32
331+ cfg := newRuntimeTestConfig(root, t.TempDir(), func(*subagents.Agent) subagents.Runner { return runner })
332+ cfg.OnSpawned = func(*subagents.Agent, string) { spawned.Add(1) }
333+ cfg.OnStopRequested = func(*subagents.Agent) { stopped.Add(1) }
334+ rt := newSubagentRuntime(cfg)
335+ registry := rt.InjectTools(core.NewRegistry())
336+ spawn, ok := registry["subagent_spawn"]
337+ if !ok {
338+ t.Fatal("spawn tool was not injected")
339+ }
340+ if _, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"callback task"}`), nil); err != nil {
341+ t.Fatalf("spawn tool: %v", err)
342+ }
343+ select {
344+ case <-runner.started:
345+ case <-time.After(time.Second):
346+ t.Fatal("runner did not start")
347+ }
348+ if got := spawned.Load(); got != 1 {
349+ t.Fatalf("OnSpawned calls = %d, want 1", got)
350+ }
351+
352+ closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
353+ defer cancel()
354+ if err := rt.Close(closeCtx); err != nil {
355+ t.Fatalf("first Close: %v", err)
356+ }
357+ if err := rt.Close(context.Background()); err != nil {
358+ t.Fatalf("second Close: %v", err)
359+ }
360+ if got := runner.canceled.Load(); got != 1 {
361+ t.Fatalf("runner cancellation count = %d, want exactly once", got)
362+ }
363+ if got := stopped.Load(); got != 0 {
364+ t.Fatalf("unexpected stop callback count = %d", got)
365+ }
366+}
367+
368+func TestSubagentRuntimeInjectedSpawnProfileInheritsAndOverridesFrontmatter(t *testing.T) {
369+ cwd := t.TempDir()
370+ profilesDir := filepath.Join(cwd, ".agents")
371+ if err := os.MkdirAll(profilesDir, 0o755); err != nil {
372+ t.Fatal(err)
373+ }
374+ writeRuntimeProfile := func(name, frontmatter string) {
375+ t.Helper()
376+ contents := "---\nname: " + name + "\n" + frontmatter + "---\nDo the work.\n"
377+ if err := os.WriteFile(filepath.Join(profilesDir, name+".md"), []byte(contents), 0o644); err != nil {
378+ t.Fatal(err)
379+ }
380+ }
381+ writeRuntimeProfile("inherited", "")
382+ writeRuntimeProfile("overridden", "model: override-model\nprovider: override-provider\nthinking: low\n")
383+ t.Setenv("ZUT_AGENT_PROFILES", profilesDir)
384+
385+ var captured []*subagents.Agent
386+ cfg := newRuntimeTestConfig(t.TempDir(), cwd, func(a *subagents.Agent) subagents.Runner {
387+ captured = append(captured, a)
388+ return &runtimeTestRunner{}
389+ })
390+ cfg.Reasoning = "high"
391+ rt := newSubagentRuntime(cfg)
392+ // Change the host-owned setting after runtime construction. A child without
393+ // a profile override must observe this new live value.
394+ rt.SetReasoning("medium")
395+ defer func() { _ = rt.Close(context.Background()) }()
396+ spawn := rt.InjectTools(core.NewRegistry())["subagent_spawn"]
397+
398+ for _, name := range []string{"inherited", "overridden"} {
399+ raw := []byte(`{"task":"` + name + ` task","agent":"` + name + `"}`)
400+ result, err := spawn.Execute(context.Background(), raw, nil)
401+ if err != nil {
402+ t.Fatalf("spawn %s: %v", name, err)
403+ }
404+ if result.IsError {
405+ t.Fatalf("spawn %s returned tool error: %#v", name, result)
406+ }
407+ }
408+ if len(captured) != 2 {
409+ t.Fatalf("captured agents = %d, want 2", len(captured))
410+ }
411+ inherited, overridden := captured[0], captured[1]
412+ if inherited.Provider != "parent-provider" || inherited.Model != "parent-model" || inherited.Reasoning != "medium" {
413+ t.Fatalf("inherited profile settings = provider=%q model=%q reasoning=%q", inherited.Provider, inherited.Model, inherited.Reasoning)
414+ }
415+ if overridden.Provider != "override-provider" || overridden.Model != "override-model" || overridden.Reasoning != "low" {
416+ t.Fatalf("overridden profile settings = provider=%q model=%q reasoning=%q", overridden.Provider, overridden.Model, overridden.Reasoning)
417+ }
418+}
A packages/agent/subagents/completion.go +525 -0
@@ -0,0 +1,525 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sort"
7+ "strconv"
8+ "strings"
9+ "sync"
10+)
11+
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.
15+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{}
52+}
53+
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.
57+type CompletionTracker struct {
58+ mu sync.Mutex
59+ active map[*Agent]*completionAgent
60+ ready []Completion
61+ nextRegistration uint64
62+ changed chan struct{}
63+}
64+
65+// NewCompletionTracker creates an empty completion tracker.
66+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)
94+}
95+
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+func (t *CompletionTracker) addTurn(a *Agent, task string, followUp, future bool) (*completionAgent, *completionEntry, bool) {
133+ entry := &completionEntry{task: task, followUp: followUp, expectedStep: expectedTurnStep(a, future), strictStep: future}
134+ t.mu.Lock()
135+ defer t.mu.Unlock()
136+ t.initLocked()
137+ state := t.active[a]
138+ if state == nil {
139+ state = &completionAgent{agent: a, seenTurnEnds: make(map[int]struct{})}
140+ t.active[a] = state
141+ }
142+ if state.seenTurnEnds == nil {
143+ state.seenTurnEnds = make(map[int]struct{})
144+ }
145+ if count := len(state.entries); count != 0 {
146+ previousStep := state.entries[count-1].expectedStep
147+ if entry.expectedStep <= previousStep {
148+ entry.expectedStep = previousStep + 1
149+ }
150+ }
151+ t.nextRegistration++
152+ entry.registrationSeq = t.nextRegistration
153+ entry.state = state
154+ state.entries = append(state.entries, entry)
155+ installCallback := !state.callbackInstalled
156+ state.callbackInstalled = true
157+ return state, entry, installCallback
158+}
159+
160+func (t *CompletionTracker) addExit(a *Agent, task string) (*completionAgent, *completionEntry) {
161+ t.mu.Lock()
162+ defer t.mu.Unlock()
163+ t.initLocked()
164+ if state := t.active[a]; state != nil && len(state.entries) != 0 {
165+ // A turn watcher already owns this worker. Its process fallback is
166+ // sufficient and must not create a duplicate terminal completion.
167+ return nil, nil
168+ }
169+ state := &completionAgent{agent: a}
170+ t.nextRegistration++
171+ entry := &completionEntry{state: state, task: task, registrationSeq: t.nextRegistration}
172+ state.entries = append(state.entries, entry)
173+ t.active[a] = state
174+ return state, entry
175+}
176+
177+func (t *CompletionTracker) startProcessWaiter(state *completionAgent) {
178+ if state == nil || state.agent == nil {
179+ return
180+ }
181+ t.mu.Lock()
182+ if t.active[state.agent] != state || state.waitingForProcess {
183+ t.mu.Unlock()
184+ return
185+ }
186+ waitCtx, cancel := context.WithCancel(context.Background())
187+ state.waitingForProcess = true
188+ state.waitCancel = cancel
189+ state.waitDone = make(chan struct{})
190+ waitDone := state.waitDone
191+ t.mu.Unlock()
192+
193+ go func() {
194+ defer close(waitDone)
195+ err := state.agent.WaitContext(waitCtx)
196+ if err == nil {
197+ t.completeProcess(state)
198+ }
199+ }()
200+}
201+
202+func expectedTurnStep(a *Agent, future bool) int {
203+ step := a.LifetimeTurnsValue()
204+ if turnID := strings.TrimPrefix(a.CurrentTurnID(), "turn-"); turnID != "" {
205+ if current, err := strconv.Atoi(turnID); err == nil && current > step {
206+ step = current
207+ }
208+ }
209+ if future || step == 0 {
210+ step++
211+ }
212+ return step
213+}
214+
215+func (t *CompletionTracker) completeNextTurn(state *completionAgent, step int, turnErr string) {
216+ if state == nil {
217+ return
218+ }
219+ t.mu.Lock()
220+ if t.active[state.agent] != state || len(state.entries) == 0 {
221+ t.mu.Unlock()
222+ return
223+ }
224+ if _, duplicate := state.seenTurnEnds[step]; duplicate {
225+ t.mu.Unlock()
226+ return
227+ }
228+ state.seenTurnEnds[step] = struct{}{}
229+ var entry *completionEntry
230+ for _, candidate := range state.entries {
231+ if !candidate.done && candidate.expectedStep == step {
232+ entry = candidate
233+ break
234+ }
235+ }
236+ if entry == nil {
237+ // Post-success callers can register after a resumed worker was queued
238+ // but before its next turn.start updated metadata. Accept the first
239+ // later step for that compatibility path; pre-delivery registrations
240+ // use strict identity and never consume a stale or unrelated notice.
241+ for _, candidate := range state.entries {
242+ if !candidate.done && !candidate.strictStep && candidate.expectedStep < step {
243+ entry = candidate
244+ candidate.expectedStep = step
245+ break
246+ }
247+ }
248+ }
249+ t.mu.Unlock()
250+ if entry != nil {
251+ t.completeTurn(entry, turnErr)
252+ }
253+}
254+
255+func (t *CompletionTracker) cancelEntry(entry *completionEntry) {
256+ if entry == nil || entry.state == nil {
257+ return
258+ }
259+ var cancel context.CancelFunc
260+ t.mu.Lock()
261+ state := entry.state
262+ if entry.done || t.active[state.agent] != state {
263+ t.mu.Unlock()
264+ return
265+ }
266+ entry.done = true
267+ t.flushReadyLocked(state)
268+ if len(state.entries) == 0 {
269+ delete(t.active, state.agent)
270+ cancel = state.waitCancel
271+ state.waitCancel = nil
272+ state.waitingForProcess = false
273+ }
274+ t.signalLocked()
275+ t.mu.Unlock()
276+ if cancel != nil {
277+ cancel()
278+ }
279+}
280+
281+func (t *CompletionTracker) completeTurn(entry *completionEntry, turnErr string) {
282+ if entry == nil || entry.state == nil || entry.state.agent == nil {
283+ return
284+ }
285+ snap := entry.state.agent.Snapshot()
286+ completion := makeCompletion(entry, snap, turnErr, true)
287+
288+ var cancel context.CancelFunc
289+ t.mu.Lock()
290+ state := entry.state
291+ if entry.done || t.active[state.agent] != state {
292+ t.mu.Unlock()
293+ return
294+ }
295+ entry.done = true
296+ entry.completion = &completion
297+ t.flushReadyLocked(state)
298+ if len(state.entries) == 0 {
299+ delete(t.active, state.agent)
300+ cancel = state.waitCancel
301+ state.waitCancel = nil
302+ state.waitingForProcess = false
303+ }
304+ t.signalLocked()
305+ t.mu.Unlock()
306+ if cancel != nil {
307+ cancel()
308+ }
309+}
310+
311+func (t *CompletionTracker) completeProcess(state *completionAgent) {
312+ if state == nil || state.agent == nil {
313+ return
314+ }
315+ snap := state.agent.Snapshot()
316+
317+ t.mu.Lock()
318+ if t.active[state.agent] != state {
319+ t.mu.Unlock()
320+ return
321+ }
322+ for _, entry := range state.entries {
323+ if entry.done {
324+ continue
325+ }
326+ entry.done = true
327+ completion := makeCompletion(entry, snap, "", false)
328+ entry.completion = &completion
329+ }
330+ t.flushReadyLocked(state)
331+ delete(t.active, state.agent)
332+ state.waitCancel = nil
333+ state.waitingForProcess = false
334+ t.signalLocked()
335+ t.mu.Unlock()
336+}
337+
338+func (t *CompletionTracker) flushReadyLocked(state *completionAgent) {
339+ for len(state.entries) != 0 && state.entries[0].done {
340+ entry := state.entries[0]
341+ state.entries[0] = nil
342+ state.entries = state.entries[1:]
343+ if entry.completion != nil {
344+ t.ready = append(t.ready, *entry.completion)
345+ }
346+ }
347+}
348+
349+func makeCompletion(entry *completionEntry, snap AgentSnapshot, turnErr string, turnEnd bool) Completion {
350+ status := string(snap.Status)
351+ if turnEnd {
352+ status = "completed"
353+ if turnErr != "" {
354+ status = "failed"
355+ }
356+ } else if snap.Status == StatusDone {
357+ status = "completed"
358+ }
359+
360+ task := entry.task
361+ if !entry.followUp && snap.Task != "" {
362+ task = snap.Task
363+ }
364+ if task == "" {
365+ task = snap.Task
366+ }
367+
368+ errText := ""
369+ cancellation := false
370+ if snap.Result != nil && snap.Result.ShutdownOrigin != "" {
371+ if snap.Result.Status == ResultCanceled {
372+ status = "cancelled"
373+ }
374+ if snap.Result.Error != nil {
375+ errText = snap.Result.Error.Message
376+ cancellation = true
377+ }
378+ }
379+ turnError := false
380+ if errText == "" {
381+ errText = snap.Err
382+ }
383+ if errText == "" && turnErr != "" {
384+ errText = turnErr
385+ turnError = true
386+ }
387+ return Completion{
388+ AgentID: snap.ID,
389+ Status: status,
390+ Task: task,
391+ Error: errText,
392+ FinalResponse: snap.LastAssistant,
393+ Tail: snap.Tail,
394+ turnError: turnError,
395+ cancellation: cancellation,
396+ registrationSeq: entry.registrationSeq,
397+ }
398+}
399+
400+func (t *CompletionTracker) signalLocked() {
401+ close(t.changed)
402+ t.changed = make(chan struct{})
403+}
404+
405+func (t *CompletionTracker) takeReadyLocked() []Completion {
406+ if len(t.ready) == 0 {
407+ return nil
408+ }
409+ batch := append([]Completion(nil), t.ready...)
410+ t.ready = nil
411+ sort.SliceStable(batch, func(i, j int) bool {
412+ return batch[i].registrationSeq < batch[j].registrationSeq
413+ })
414+ return batch
415+}
416+
417+// Pending reports the number of tracked entries that have not completed.
418+func (t *CompletionTracker) Pending() int {
419+ if t == nil {
420+ return 0
421+ }
422+ t.mu.Lock()
423+ defer t.mu.Unlock()
424+ pending := 0
425+ for _, state := range t.active {
426+ for _, entry := range state.entries {
427+ if !entry.done {
428+ pending++
429+ }
430+ }
431+ }
432+ return pending
433+}
434+
435+// WaitIdle returns all completions buffered so far and all completions that
436+// arrive until no tracked entry remains. A cancelled wait leaves active and
437+// buffered entries intact for a later caller.
438+func (t *CompletionTracker) WaitIdle(ctx context.Context) ([]Completion, error) {
439+ if t == nil {
440+ return nil, nil
441+ }
442+ if ctx == nil {
443+ ctx = context.Background()
444+ }
445+
446+ for {
447+ t.mu.Lock()
448+ t.initLocked()
449+ if len(t.active) == 0 {
450+ batch := t.takeReadyLocked()
451+ t.mu.Unlock()
452+ return batch, nil
453+ }
454+ changed := t.changed
455+ t.mu.Unlock()
456+
457+ select {
458+ case <-changed:
459+ case <-ctx.Done():
460+ // Prefer an already-finished batch if completion won the race
461+ // with cancellation; otherwise preserve the active work.
462+ t.mu.Lock()
463+ if len(t.active) == 0 {
464+ batch := t.takeReadyLocked()
465+ t.mu.Unlock()
466+ return batch, nil
467+ }
468+ t.mu.Unlock()
469+ return nil, ctx.Err()
470+ }
471+ }
472+}
473+
474+// FormatCompletionUpdate formats the stable evidence payload injected into a
475+// parent agent. Task and error fields are bounded to 240 characters and tail
476+// evidence is bounded to 600 characters; final responses retain their full
477+// inline text as they did in interactive mode.
478+func FormatCompletionUpdate(batch []Completion, instruction string) string {
479+ if len(batch) == 0 {
480+ return ""
481+ }
482+
483+ var sb strings.Builder
484+ fmt.Fprintf(&sb, "[auto-subagents update] %d sub-agent(s) finished:\n\n", len(batch))
485+ for idx, completion := range batch {
486+ fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, completion.AgentID, completion.Status)
487+ fmt.Fprintf(&sb, " task: %s\n", truncateCompletionField(completion.Task, 240))
488+ if completion.Error != "" {
489+ label := "error"
490+ if completion.cancellation {
491+ label = "cancellation"
492+ } else if completion.turnError {
493+ label = "turn error"
494+ }
495+ fmt.Fprintf(&sb, " %s: %s\n", label, truncateCompletionField(completion.Error, 240))
496+ }
497+ if completion.FinalResponse != "" {
498+ sb.WriteString(" final response:\n")
499+ sb.WriteString(completion.FinalResponse)
500+ sb.WriteString("\n")
501+ } else if tail := strings.TrimSpace(completion.Tail); tail != "" {
502+ fmt.Fprintf(&sb, " tail: %s\n", truncateCompletionField(tail, 600))
503+ }
504+ sb.WriteString("\n")
505+ }
506+ if instruction != "" {
507+ sb.WriteString(instruction)
508+ }
509+ return sb.String()
510+}
511+
512+func truncateCompletionField(value string, limit int) string {
513+ value = strings.TrimSpace(value)
514+ if limit <= 0 {
515+ return ""
516+ }
517+ runes := []rune(value)
518+ if len(runes) <= limit {
519+ return value
520+ }
521+ if limit <= 3 {
522+ return string(runes[:limit])
523+ }
524+ return string(runes[:limit-3]) + "..."
525+}
A packages/agent/subagents/completion_test.go +410 -0
@@ -0,0 +1,410 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+ "time"
9+)
10+
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",
20+ }
21+}
22+
23+func TestCompletionTrackerTurnEndCompletesLongLivedWorker(t *testing.T) {
24+ agent := newCompletionTestAgent("worker-1", "original task", StatusRunning)
25+ t.Cleanup(func() { close(agent.done) })
26+ 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 TestCompletionTrackerWaitIdleCancellationLeavesActiveWork(t *testing.T) {
286+ agent := newCompletionTestAgent("worker-1", "task", StatusRunning)
287+ tracker := NewCompletionTracker()
288+ tracker.TrackTurn(agent, agent.Task, false)
289+ ctx, cancel := context.WithCancel(context.Background())
290+ cancel()
291+
292+ batch, err := tracker.WaitIdle(ctx)
293+ if !errors.Is(err, context.Canceled) {
294+ t.Fatalf("error = %v, want context.Canceled", err)
295+ }
296+ if batch != nil {
297+ t.Fatalf("cancelled batch = %+v, want nil", batch)
298+ }
299+ if tracker.Pending() != 1 {
300+ t.Fatalf("pending after cancellation = %d, want 1", tracker.Pending())
301+ }
302+
303+ agent.OnTurnEnd(1, "")
304+ close(agent.done)
305+ batch, err = tracker.WaitIdle(context.Background())
306+ if err != nil || len(batch) != 1 {
307+ t.Fatalf("post-cancellation wait = (%+v, %v), want one completion", batch, err)
308+ }
309+}
310+
311+func TestCompletionTrackerStoresRawFieldsAndResumedTask(t *testing.T) {
312+ longTask := strings.Repeat("task ", 80)
313+ longError := errors.New(strings.Repeat("error ", 80))
314+ agent := newCompletionTestAgent("worker-1", "original task", StatusRunning)
315+ agent.Task = longTask
316+ agent.lastErr = longError
317+ agent.lastAssistant = ""
318+ tracker := NewCompletionTracker()
319+ const followUp = "follow-up task"
320+ tracker.TrackTurn(agent, followUp, true)
321+ agent.OnTurnEnd(1, "")
322+
323+ batch, err := tracker.WaitIdle(context.Background())
324+ if err != nil || len(batch) != 1 {
325+ t.Fatalf("wait = (%+v, %v), want one completion", batch, err)
326+ }
327+ completion := batch[0]
328+ if completion.Task != followUp {
329+ t.Fatalf("resumed task = %q, want %q", completion.Task, followUp)
330+ }
331+ if completion.Error != longError.Error() {
332+ t.Fatalf("stored error was truncated: got %d chars, want %d", len(completion.Error), len(longError.Error()))
333+ }
334+ if completion.Tail == "" {
335+ t.Fatal("stored tail is empty")
336+ }
337+ close(agent.done)
338+}
339+
340+func TestFormatCompletionUpdateBoundsFieldsAndPreservesEvidence(t *testing.T) {
341+ longTask := strings.Repeat("t", 300)
342+ longError := strings.Repeat("e", 300)
343+ longTail := strings.Repeat("x", 700)
344+ instruction := "Summarise the collective outcome without spawning new workers."
345+ update := FormatCompletionUpdate([]Completion{{
346+ AgentID: "worker-1", Status: "failed", Task: longTask, Error: longError, Tail: longTail,
347+ }}, instruction)
348+
349+ if !strings.HasPrefix(update, "[auto-subagents update] 1 sub-agent(s) finished:\n\n") {
350+ t.Fatalf("update has unexpected header: %q", update)
351+ }
352+ if !strings.Contains(update, "1. agent worker-1 - status: failed\n task: ") {
353+ t.Fatalf("update omitted stable labels: %q", update)
354+ }
355+ if !strings.Contains(update, " error: ") || !strings.Contains(update, " tail: ") {
356+ t.Fatalf("update omitted error or tail: %q", update)
357+ }
358+ if !strings.HasSuffix(update, instruction) {
359+ t.Fatalf("update omitted instruction: %q", update)
360+ }
361+ if strings.Contains(strings.ToLower(update), "poll") {
362+ t.Fatalf("update instructs the parent to poll: %q", update)
363+ }
364+
365+ taskLine := ""
366+ errorLine := ""
367+ for _, line := range strings.Split(update, "\n") {
368+ if strings.HasPrefix(line, " task: ") {
369+ taskLine = strings.TrimPrefix(line, " task: ")
370+ }
371+ if strings.HasPrefix(line, " error: ") {
372+ errorLine = strings.TrimPrefix(line, " error: ")
373+ }
374+ }
375+ if got := len([]rune(taskLine)); got != 240 {
376+ t.Fatalf("formatted task length = %d, want 240", got)
377+ }
378+ if got := len([]rune(errorLine)); got != 240 {
379+ t.Fatalf("formatted error length = %d, want 240", got)
380+ }
381+ tailLine := ""
382+ for _, line := range strings.Split(update, "\n") {
383+ if strings.HasPrefix(line, " tail: ") {
384+ tailLine = strings.TrimPrefix(line, " tail: ")
385+ }
386+ }
387+ if got := len([]rune(tailLine)); got != 600 {
388+ t.Fatalf("formatted tail length = %d, want 600", got)
389+ }
390+}
391+
392+func TestFormatCompletionUpdateUsesFinalResponseBeforeTailFallback(t *testing.T) {
393+ final := "complete final response\nwith multiple lines"
394+ withFinal := FormatCompletionUpdate([]Completion{{
395+ AgentID: "worker-1", Status: "completed", FinalResponse: final, Tail: "old tail",
396+ }}, "instruction")
397+ if !strings.Contains(withFinal, " final response:\n"+final+"\n") {
398+ t.Fatalf("final response missing: %q", withFinal)
399+ }
400+ if strings.Contains(withFinal, "tail:") {
401+ t.Fatalf("tail should not be used when final response is present: %q", withFinal)
402+ }
403+
404+ withTail := FormatCompletionUpdate([]Completion{{
405+ AgentID: "worker-2", Status: "completed", Tail: " fallback tail ",
406+ }}, "instruction")
407+ if !strings.Contains(withTail, " tail: fallback tail\n") {
408+ t.Fatalf("tail fallback missing or not trimmed: %q", withTail)
409+ }
410+}
M packages/agent/subagents/persist.go +61 -15
@@ -805,6 +805,17 @@ func (f *Supervisor) ResumeSession(ctx context.Context, id string) (*Agent, erro
805 // terminal worker is restarted with the follow-up as its initial prompt,805 // terminal worker is restarted with the follow-up as its initial prompt,
806 // avoiding a race with inbox listener startup.806 // avoiding a race with inbox listener startup.
807 func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*Agent, error) {807 func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*Agent, error) {
808+ return f.ResumeWithPromptBefore(ctx, id, prompt, nil)
809+}
810+
811+// ResumeWithPromptBefore is ResumeWithPrompt with a pre-delivery hook. The
812+// hook runs after the target Agent is known but before a queued or direct
813+// follow-up is made deliverable. For a restarted worker it runs after the new
814+// Agent is built and before its runner is scheduled. For queued and direct
815+// delivery, the hook runs while operationMu is held and must not block or call
816+// back into Supervisor methods. A non-nil cleanup is called when the operation
817+// is rejected before delivery.
818+func (f *Supervisor) ResumeWithPromptBefore(ctx context.Context, id, prompt string, before func(*Agent, string) func()) (*Agent, error) {
808 if ctx == nil {819 if ctx == nil {
809 ctx = context.Background()820 ctx = context.Background()
810 }821 }
@@ -843,10 +854,17 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*
843 // Active turns cannot accept a command through the worker inbox yet.854 // Active turns cannot accept a command through the worker inbox yet.
844 // Keep the prompt durable and let the idle transition deliver it in855 // Keep the prompt durable and let the idle transition deliver it in
845 // order after the current message turn finishes.856 // order after the current message turn finishes.
857+ var undo func()
858+ if before != nil {
859+ undo = before(existing, prompt)
860+ }
846 acceptedAt := f.cfg.Now()861 acceptedAt := f.cfg.Now()
847 commandID := existing.queueResumePrompt(prompt, acceptedAt)862 commandID := existing.queueResumePrompt(prompt, acceptedAt)
848 if err := f.persistAgent(existing); err != nil {863 if err := f.persistAgent(existing); err != nil {
849 existing.removeQueuedResumePrompt(commandID)864 existing.removeQueuedResumePrompt(commandID)
865+ if undo != nil {
866+ undo()
867+ }
850 f.operationMu.Unlock()868 f.operationMu.Unlock()
851 return nil, fmt.Errorf("subagent: persist queued follow-up for %s: %w", existing.ID, err)869 return nil, fmt.Errorf("subagent: persist queued follow-up for %s: %w", existing.ID, err)
852 }870 }
@@ -859,6 +877,10 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*
859 }877 }
860 turnID := existing.CurrentTurnID()878 turnID := existing.CurrentTurnID()
861 previousRunTurns := existing.resetCurrentRunTurns()879 previousRunTurns := existing.resetCurrentRunTurns()
880+ var undo func()
881+ if before != nil {
882+ undo = before(existing, prompt)
883+ }
862 // Reserve the worker before writing to its inbox. A second manager call884 // Reserve the worker before writing to its inbox. A second manager call
863 // must not enqueue another turn in the small interval before the worker885 // must not enqueue another turn in the small interval before the worker
864 // reports its turn.start event. Persist the prompt first so a host exit886 // reports its turn.start event. Persist the prompt first so a host exit
@@ -872,6 +894,9 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*
872 existing.setTurnState(TurnIdle, turnID)894 existing.setTurnState(TurnIdle, turnID)
873 existing.setCurrentRunTurns(previousRunTurns)895 existing.setCurrentRunTurns(previousRunTurns)
874 existing.restoreResumePrompt(previousPrompt)896 existing.restoreResumePrompt(previousPrompt)
897+ if undo != nil {
898+ undo()
899+ }
875 f.operationMu.Unlock()900 f.operationMu.Unlock()
876 return nil, fmt.Errorf("subagent: persist follow-up for %s: %w", existing.ID, err)901 return nil, fmt.Errorf("subagent: persist follow-up for %s: %w", existing.ID, err)
877 }902 }
@@ -888,6 +913,9 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*
888 retryUnknown = true913 retryUnknown = true
889 } else {914 } else {
890 existing.restoreResumePrompt(previousPrompt)915 existing.restoreResumePrompt(previousPrompt)
916+ if undo != nil {
917+ undo()
918+ }
891 }919 }
892 if persistErr := f.persistAgent(existing); persistErr != nil {920 if persistErr := f.persistAgent(existing); persistErr != nil {
893 err = fmt.Errorf("%w; persist recovery: %v", err, persistErr)921 err = fmt.Errorf("%w; persist recovery: %v", err, persistErr)
@@ -905,7 +933,7 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*
905 }933 }
906 f.operationMu.Unlock()934 f.operationMu.Unlock()
907 935
908- return f.resume(ctx, id, true, prompt)936+ return f.resumeWithHook(ctx, id, true, prompt, before)
909 }937 }
910 938
911 // dispatchQueuedResumeWithTimeout keeps the idle callback bounded. Unlike a939 // dispatchQueuedResumeWithTimeout keeps the idle callback bounded. Unlike a
@@ -986,6 +1014,10 @@ func (f *Supervisor) RestartTask(ctx context.Context, id string) (*Agent, error)
986 }1014 }
987 1015
988 func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resumePrompt string) (*Agent, error) {1016 func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resumePrompt string) (*Agent, error) {
1017+ return f.resumeWithHook(ctx, id, resuming, resumePrompt, nil)
1018+}
1019+
1020+func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming bool, resumePrompt string, before func(*Agent, string) func()) (*Agent, error) {
989 f.operationMu.Lock()1021 f.operationMu.Lock()
990 defer f.operationMu.Unlock()1022 defer f.operationMu.Unlock()
991 if ctx == nil {1023 if ctx == nil {
@@ -1052,20 +1084,10 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resum
1052 if pendingResumePrompt != "" && pendingResumePromptID == "" {1084 if pendingResumePrompt != "" && pendingResumePromptID == "" {
1053 pendingResumePromptID = uuid.NewString()1085 pendingResumePromptID = uuid.NewString()
1054 }1086 }
1055- if resumePrompt != "" {1087+ if resuming && pendingResumePrompt == "" && len(pendingResumeQueue) > 0 {
1056- currentRunTurns = 0
1057- if pendingResumePrompt == "" {
1058- pendingResumePrompt = resumePrompt
1059- pendingResumePromptAt = now
1060- pendingResumePromptID = uuid.NewString()
1061- } else {
1062- pendingResumeQueue = append(pendingResumeQueue, queuedResumePrompt{Prompt: resumePrompt, AcceptedAt: now, CommandID: uuid.NewString()})
1063- }
1064- }
1065- if resuming && resumePrompt == "" && pendingResumePrompt == "" && len(pendingResumeQueue) > 0 {
1066 // A host can exit after accepting an active-worker follow-up but before1088 // A host can exit after accepting an active-worker follow-up but before
1067- // dispatching it. Make the first durable queued prompt the resumed1089+ // dispatching it. Promote the oldest durable prompt before accepting a
1068- // worker's initial turn rather than leaving the queue stranded.1090+ // new one so scheduler and completion-tracker order stay aligned.
1069 queued := pendingResumeQueue[0]1091 queued := pendingResumeQueue[0]
1070 pendingResumePrompt = queued.Prompt1092 pendingResumePrompt = queued.Prompt
1071 pendingResumePromptAt = queued.AcceptedAt1093 pendingResumePromptAt = queued.AcceptedAt
@@ -1075,6 +1097,17 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resum
1075 }1097 }
1076 pendingResumeQueue = pendingResumeQueue[1:]1098 pendingResumeQueue = pendingResumeQueue[1:]
1077 }1099 }
1100+ hadPendingResumePrompt := pendingResumePrompt != ""
1101+ if resumePrompt != "" {
1102+ currentRunTurns = 0
1103+ if pendingResumePrompt == "" {
1104+ pendingResumePrompt = resumePrompt
1105+ pendingResumePromptAt = now
1106+ pendingResumePromptID = uuid.NewString()
1107+ } else {
1108+ pendingResumeQueue = append(pendingResumeQueue, queuedResumePrompt{Prompt: resumePrompt, AcceptedAt: now, CommandID: uuid.NewString()})
1109+ }
1110+ }
1078 if resuming && pendingResumePrompt != "" {1111 if resuming && pendingResumePrompt != "" {
1079 // A retained follow-up is an explicit new run, including a queued1112 // A retained follow-up is an explicit new run, including a queued
1080 // follow-up recovered after a host restart. Preserve lifetime turns but1113 // follow-up recovered after a host restart. Preserve lifetime turns but
@@ -1254,7 +1287,20 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resum
1254 1287
1255 // Refreshing metadata happened before queue admission, so a later1288 // Refreshing metadata happened before queue admission, so a later
1256 // supervisor can reconstruct this resumed attempt even if the runner1289 // supervisor can reconstruct this resumed attempt even if the runner
1257- // has not reached its first event yet.1290+ // has not reached its first event yet. Register the turn before the
1291+ // scheduler can start the worker and emit a fast turn_end. A durable pending
1292+ // prompt is consumed before the newly requested prompt, so tracker
1293+ // registrations must preserve that scheduler order.
1294+ if before != nil {
1295+ if hadPendingResumePrompt {
1296+ _ = before(a, pendingResumePrompt)
1297+ }
1298+ if resumePrompt != "" {
1299+ // No rejecting operation remains before scheduling, so there is no path
1300+ // that needs the hook's rollback after this point.
1301+ _ = before(a, resumePrompt)
1302+ }
1303+ }
1258 f.armQueueTimeout(a)1304 f.armQueueTimeout(a)
1259 f.schedule()1305 f.schedule()
1260 return a, nil1306 return a, nil
M packages/agent/subagents/resume_prompt_test.go +142 -3
@@ -71,9 +71,20 @@ func TestResumeWithPromptQueuesFollowUpsForActiveWorker(t *testing.T) {
71 firstFollowUp = "Check the parser behavior."71 firstFollowUp = "Check the parser behavior."
72 secondFollowUp = "Then check the error handling."72 secondFollowUp = "Then check the error handling."
73 )73 )
74- if _, err := f.ResumeWithPrompt(context.Background(), a.ID, firstFollowUp); err != nil {74+ tracker := NewCompletionTracker()
75+ if _, err := f.ResumeWithPromptBefore(context.Background(), a.ID, firstFollowUp, func(agent *Agent, prompt string) func() {
76+ return tracker.TrackFutureTurn(agent, prompt, true)
77+ }); err != nil {
75 t.Fatalf("queue first active follow-up: %v", err)78 t.Fatalf("queue first active follow-up: %v", err)
76 }79 }
80+ if a.OnTurnEnd == nil {
81+ t.Fatal("active resume did not register its turn callback before queueing")
82+ }
83+ a.OnTurnEnd(2, "")
84+ batch, err := tracker.WaitIdle(context.Background())
85+ if err != nil || len(batch) != 1 || batch[0].Task != firstFollowUp {
86+ t.Fatalf("active resume completion = (%+v, %v), want one %q completion", batch, err, firstFollowUp)
87+ }
77 if _, err := f.ResumeWithPrompt(context.Background(), a.ID, secondFollowUp); err != nil {88 if _, err := f.ResumeWithPrompt(context.Background(), a.ID, secondFollowUp); err != nil {
78 t.Fatalf("queue second active follow-up: %v", err)89 t.Fatalf("queue second active follow-up: %v", err)
79 }90 }
@@ -147,6 +158,124 @@ func TestResumeWithPromptQueuesFollowUpsForActiveWorker(t *testing.T) {
147 a.Wait()158 a.Wait()
148 }159 }
149 160
161+func TestResumeWithPromptRestartTracksExistingPendingPromptBeforeNewPrompt(t *testing.T) {
162+ root := t.TempDir()
163+ started := make(chan *Agent, 2)
164+ f := New(Config{
165+ Root: root, RepoRoot: root,
166+ NewRunner: func(a *Agent) Runner {
167+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
168+ started <- a
169+ <-ctx.Done()
170+ return ctx.Err()
171+ })
172+ },
173+ })
174+ t.Cleanup(f.StopAll)
175+
176+ original, err := f.Spawn(context.Background(), "review the implementation")
177+ if err != nil {
178+ t.Fatal(err)
179+ }
180+ select {
181+ case <-started:
182+ case <-time.After(time.Second):
183+ t.Fatal("original runner did not start")
184+ }
185+ if err := f.Stop(original.ID); err != nil {
186+ t.Fatal(err)
187+ }
188+ original.Wait()
189+
190+ const (
191+ pendingPrompt = "finish the queued review"
192+ newPrompt = "then review the follow-up"
193+ )
194+ original.setResumePrompt(pendingPrompt, time.Now())
195+ if err := f.persistAgent(original); err != nil {
196+ t.Fatal(err)
197+ }
198+
199+ tracker := NewCompletionTracker()
200+ resumed, err := f.ResumeWithPromptBefore(context.Background(), original.ID, newPrompt, func(agent *Agent, prompt string) func() {
201+ return tracker.TrackFutureTurn(agent, prompt, true)
202+ })
203+ if err != nil {
204+ t.Fatal(err)
205+ }
206+ select {
207+ case got := <-started:
208+ if got != resumed {
209+ t.Fatalf("started agent = %p, want resumed agent %p", got, resumed)
210+ }
211+ case <-time.After(time.Second):
212+ t.Fatal("resumed runner did not start")
213+ }
214+ if got := resumed.resumePrompt(); got != pendingPrompt {
215+ t.Fatalf("first scheduled prompt = %q, want pending prompt %q", got, pendingPrompt)
216+ }
217+ queue := resumed.resumePromptQueueSnapshot()
218+ if len(queue) != 1 || queue[0].Prompt != newPrompt {
219+ t.Fatalf("resume queue = %+v, want new prompt queued second", queue)
220+ }
221+ if resumed.OnTurnEnd == nil {
222+ t.Fatal("restart did not install completion tracking before scheduling")
223+ }
224+ resumed.OnTurnEnd(1, "")
225+ resumed.OnTurnEnd(2, "")
226+ batch, err := tracker.WaitIdle(context.Background())
227+ if err != nil {
228+ t.Fatal(err)
229+ }
230+ if len(batch) != 2 || batch[0].Task != pendingPrompt || batch[1].Task != newPrompt {
231+ t.Fatalf("completion order = %+v, want pending prompt then new prompt", batch)
232+ }
233+
234+ // Recreate the durable queue-only state: no in-flight prompt, with the
235+ // previously queued command still awaiting scheduler consumption.
236+ if err := f.Stop(resumed.ID); err != nil {
237+ t.Fatal(err)
238+ }
239+ resumed.Wait()
240+ resumed.clearResumePrompt()
241+ if err := f.persistAgent(resumed); err != nil {
242+ t.Fatal(err)
243+ }
244+
245+ const newestPrompt = "finally synthesize both reviews"
246+ queueTracker := NewCompletionTracker()
247+ restarted, err := f.ResumeWithPromptBefore(context.Background(), resumed.ID, newestPrompt, func(agent *Agent, prompt string) func() {
248+ return queueTracker.TrackFutureTurn(agent, prompt, true)
249+ })
250+ if err != nil {
251+ t.Fatal(err)
252+ }
253+ select {
254+ case got := <-started:
255+ if got != restarted {
256+ t.Fatalf("queue-only restart agent = %p, want %p", got, restarted)
257+ }
258+ case <-time.After(time.Second):
259+ t.Fatal("queue-only restarted runner did not start")
260+ }
261+ if got := restarted.resumePrompt(); got != newPrompt {
262+ t.Fatalf("promoted queue prompt = %q, want %q", got, newPrompt)
263+ }
264+ queue = restarted.resumePromptQueueSnapshot()
265+ if len(queue) != 1 || queue[0].Prompt != newestPrompt {
266+ t.Fatalf("queue-only restart queue = %+v, want newest prompt second", queue)
267+ }
268+ restarted.OnTurnEnd(1, "")
269+ restarted.OnTurnEnd(2, "")
270+ batch, err = queueTracker.WaitIdle(context.Background())
271+ if err != nil {
272+ t.Fatal(err)
273+ }
274+ if len(batch) != 2 || batch[0].Task != newPrompt || batch[1].Task != newestPrompt {
275+ t.Fatalf("queue-only completion order = %+v, want promoted prompt then newest prompt", batch)
276+ }
277+}
278+
150 func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) {279 func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) {
151 if runtime.GOOS == "windows" {280 if runtime.GOOS == "windows" {
152 t.Skip("subagent inbox transport uses Unix-domain sockets")281 t.Skip("subagent inbox transport uses Unix-domain sockets")
@@ -202,7 +331,10 @@ func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) {
202 a.setTurnCounts(4, 2)331 a.setTurnCounts(4, 2)
203 332
204 const followUp = "I applied your review. What do you think now?"333 const followUp = "I applied your review. What do you think now?"
205- continued, err := f.ResumeWithPrompt(context.Background(), a.ID, followUp)334+ tracker := NewCompletionTracker()
335+ continued, err := f.ResumeWithPromptBefore(context.Background(), a.ID, followUp, func(agent *Agent, prompt string) func() {
336+ return tracker.TrackFutureTurn(agent, prompt, true)
337+ })
206 if err != nil {338 if err != nil {
207 t.Fatalf("continue idle worker: %v", err)339 t.Fatalf("continue idle worker: %v", err)
208 }340 }
@@ -236,10 +368,17 @@ func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) {
236 if !payload.NewRun {368 if !payload.NewRun {
237 t.Fatal("idle follow-up did not request a fresh run budget")369 t.Fatal("idle follow-up did not request a fresh run budget")
238 }370 }
371+ if a.OnTurnEnd == nil {
372+ t.Fatal("idle resume callback was not installed before prompt delivery")
373+ }
374+ a.OnTurnEnd(5, "")
239 case <-time.After(time.Second):375 case <-time.After(time.Second):
240 t.Fatal("idle worker did not receive the follow-up")376 t.Fatal("idle worker did not receive the follow-up")
241 }377 }
242-378+ batch, err := tracker.WaitIdle(context.Background())
379+ if err != nil || len(batch) != 1 || batch[0].Task != followUp {
380+ t.Fatalf("idle resume completion = (%+v, %v), want one %q completion", batch, err, followUp)
381+ }
243 persisted, err := readAgentMeta(filepath.Join(root, "agents", a.ID))382 persisted, err := readAgentMeta(filepath.Join(root, "agents", a.ID))
244 if err != nil {383 if err != nil {
245 t.Fatal(err)384 t.Fatal(err)
M packages/agent/tools/subagent_lifecycle_test.go +84 -0
@@ -78,6 +78,90 @@ func TestSubagentStopTerminatesStuckWorker(t *testing.T) {
78 }78 }
79 }79 }
80 80
81+func TestSubagentResumeBeforeHookRunsBeforeRestartedWorkerStarts(t *testing.T) {
82+ root := t.TempDir()
83+ started := make(chan *subagents.Agent, 2)
84+ resumedTurnEnded := make(chan struct{})
85+ tracker := subagents.NewCompletionTracker()
86+ manager := subagents.New(subagents.Config{
87+ Root: filepath.Join(root, "subagents"),
88+ RepoRoot: root,
89+ NewRunner: func(agent *subagents.Agent) subagents.Runner {
90+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
91+ started <- agent
92+ if agent.Resuming {
93+ if agent.OnTurnEnd == nil {
94+ return context.Canceled
95+ }
96+ agent.OnTurnEnd(1, "")
97+ close(resumedTurnEnded)
98+ return nil
99+ }
100+ <-ctx.Done()
101+ return ctx.Err()
102+ })
103+ },
104+ })
105+ t.Cleanup(manager.StopAll)
106+
107+ first, err := manager.Spawn(context.Background(), "review the implementation")
108+ if err != nil {
109+ t.Fatal(err)
110+ }
111+ select {
112+ case <-started:
113+ case <-time.After(time.Second):
114+ t.Fatal("initial subagent did not start")
115+ }
116+ if err := manager.Stop(first.ID); err != nil {
117+ t.Fatal(err)
118+ }
119+ first.Wait()
120+
121+ const prompt = "continue the review"
122+ var beforeAgent *subagents.Agent
123+ var beforePrompt string
124+ tool := &SubagentResumeTool{
125+ Supervisor: manager,
126+ Enabled: func() bool { return true },
127+ BeforeResumed: func(agent *subagents.Agent, gotPrompt string) func() {
128+ beforeAgent = agent
129+ beforePrompt = gotPrompt
130+ return tracker.TrackFutureTurn(agent, gotPrompt, true)
131+ },
132+ OnResumed: func(*subagents.Agent, string) {
133+ t.Fatal("post-success callback should not run with a pre-resume hook")
134+ },
135+ }
136+ args, err := json.Marshal(subagentResumeArgs{AgentID: first.ID, Prompt: prompt})
137+ if err != nil {
138+ t.Fatal(err)
139+ }
140+ if res, err := tool.Execute(context.Background(), args, nil); err != nil || res.IsError {
141+ t.Fatalf("resume result = (%+v, %v), want success", res, err)
142+ }
143+ if beforeAgent == nil || beforeAgent == first || beforePrompt != prompt {
144+ t.Fatalf("before hook = (%p, %q), want restarted worker and %q", beforeAgent, beforePrompt, prompt)
145+ }
146+ select {
147+ case resumed := <-started:
148+ if resumed.OnTurnEnd == nil {
149+ t.Fatal("restarted worker started without its pre-registered callback")
150+ }
151+ case <-time.After(time.Second):
152+ t.Fatal("resumed subagent did not start")
153+ }
154+ select {
155+ case <-resumedTurnEnded:
156+ case <-time.After(time.Second):
157+ t.Fatal("resumed turn did not finish")
158+ }
159+ batch, err := tracker.WaitIdle(context.Background())
160+ if err != nil || len(batch) != 1 || batch[0].Task != prompt {
161+ t.Fatalf("resumed completion = (%+v, %v), want one %q completion", batch, err, prompt)
162+ }
163+}
164+
81 func TestSubagentResumeRestartsSessionWithFollowUp(t *testing.T) {165 func TestSubagentResumeRestartsSessionWithFollowUp(t *testing.T) {
82 root := t.TempDir()166 root := t.TempDir()
83 started := make(chan *subagents.Agent, 2)167 started := make(chan *subagents.Agent, 2)
M packages/agent/tools/subagent_resume.go +10 -5
@@ -20,10 +20,15 @@ type SubagentResumeTool struct {
20 Supervisor *subagents.Supervisor20 Supervisor *subagents.Supervisor
21 Enabled func() bool21 Enabled func() bool
22 22
23+ // BeforeResumed, when set, registers the follow-up before the supervisor
24+ // sends or queues its prompt. It returns a cleanup for resume failures that
25+ // happen before delivery. The interactive host uses this to avoid losing a
26+ // fast idle or restarted-worker turn.
27+ BeforeResumed func(agent *subagents.Agent, prompt string) func()
28+
23 // OnResumed receives the worker and follow-up after a successful resume.29 // OnResumed receives the worker and follow-up after a successful resume.
24- // It runs synchronously, so callbacks must only register non-blocking30+ // It remains available for callers that only need post-success
25- // tracking work. The interactive host uses it to watch this new delegated31+ // notification; tracking callers should prefer BeforeResumed.
26- // turn and deliver its outcome back to the manager automatically.
27 OnResumed func(agent *subagents.Agent, prompt string)32 OnResumed func(agent *subagents.Agent, prompt string)
28 }33 }
29 34
@@ -88,11 +93,11 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
88 if !ok {93 if !ok {
89 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))94 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
90 }95 }
91- resumed, err := t.Supervisor.ResumeWithPrompt(ctx, snapshot.ID, args.Prompt)96+ resumed, err := t.Supervisor.ResumeWithPromptBefore(ctx, snapshot.ID, args.Prompt, t.BeforeResumed)
92 if err != nil {97 if err != nil {
93 return protocolToolError(prefix + ": " + err.Error())98 return protocolToolError(prefix + ": " + err.Error())
94 }99 }
95- if t.OnResumed != nil {100+ if t.BeforeResumed == nil && t.OnResumed != nil {
96 t.OnResumed(resumed, args.Prompt)101 t.OnResumed(resumed, args.Prompt)
97 }102 }
98 103
M packages/agent/zutfile.go +13 -0
@@ -114,6 +114,12 @@ func runZutfileCommand(rawArgs []string, version string) (bool, error) {
114 if yes {114 if yes {
115 args.Yes = true115 args.Yes = true
116 }116 }
117+ if args.Orchestrate {
118+ return true, fmt.Errorf("--orchestrate is not supported for packaged agents")
119+ }
120+ if err := validateOrchestrationArgs(args); err != nil {
121+ return true, err
122+ }
117 return true, runLocalZutfile(ref, args, version)123 return true, runLocalZutfile(ref, args, version)
118 default:124 default:
119 return false, nil125 return false, nil
@@ -132,6 +138,13 @@ func runLocalZutfile(ref string, args Args, version string) error {
132 if err := checkZutfileRequirements(zf, version); err != nil {138 if err := checkZutfileRequirements(zf, version); err != nil {
133 return err139 return err
134 }140 }
141+ // A packaged agent has not yet had its manifest permissions attached to
142+ // Args. Reject orchestration at this boundary, before creating its data
143+ // directory or asking for permission consent.
144+ if args.Orchestrate {
145+ args.AgentName = zf.Manifest.Name
146+ return validateOrchestrationArgs(args)
147+ }
135 agentData := filepath.Join(ZutHome(), "agents", safeAgentName(zf.Manifest.Name), "data")148 agentData := filepath.Join(ZutHome(), "agents", safeAgentName(zf.Manifest.Name), "data")
136 if err := os.MkdirAll(agentData, 0o755); err != nil {149 if err := os.MkdirAll(agentData, 0o755); err != nil {
137 return err150 return err
M packages/agent/zutfile_test.go +23 -0
@@ -18,6 +18,29 @@ import (
18 "github.com/bnema/zut/packages/tui"18 "github.com/bnema/zut/packages/tui"
19 )19 )
20 20
21+func TestRunZutfileOrchestrationRejectsPackagedAgentBeforeSideEffects(t *testing.T) {
22+ home := t.TempDir()
23+ t.Setenv("ZUT_HOME", home)
24+ // A nonexistent reference proves validation happens before local loading,
25+ // remote fetching, unpacking, data-directory creation, or consent.
26+ missingPackage := filepath.Join(t.TempDir(), "missing.zut")
27+
28+ handled, err := runZutfileCommand([]string{"run", missingPackage, "--print", "--orchestrate", "task"}, "test")
29+ if !handled {
30+ t.Fatal("run command was not handled")
31+ }
32+ if err == nil || !strings.Contains(err.Error(), "packaged agents") {
33+ t.Fatalf("run error = %v, want packaged-agent validation error", err)
34+ }
35+ entries, readErr := os.ReadDir(home)
36+ if readErr != nil {
37+ t.Fatal(readErr)
38+ }
39+ if len(entries) != 0 {
40+ t.Fatalf("packaged-agent rejection created state: %v", entries)
41+ }
42+}
43+
21 func TestReadZutfileConsentKeyAcceptsOnlyYesNo(t *testing.T) {44 func TestReadZutfileConsentKeyAcceptsOnlyYesNo(t *testing.T) {
22 tests := []struct {45 tests := []struct {
23 name string46 name string

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close