swarm: introduce /swarm dashboard, /btw-style transcript view, and per-session scope
A /swarm subsystem for long-running parallel subagents. Each agent runs in its own subprocess against a fresh git worktree (branch swarm/<id>) with its own persistent session file and unix-socket inbox; the parent zot stays in the main session and pokes / observes them via the dashboard. Highlights: - New internal/swarm package: Agent, Spawn/Resume/Kill/Remove, event log (events.jsonl), inbox protocol (listen/dial), worktree manager, exec runner that spawns "zot --swarm-agent ...". - New internal/agent/swarm_agent.go: daemon-mode child entry point. Reuses the standard agent loop but persists turns to the supervisor- chosen session.json and streams events as JSONL on stdout. Mirror to events.jsonl is dormant while the supervisor's stdout pipe is alive so events do not get double-written. - Resume reattaches in place: reuses the same worktree, session, branch and inbox path; carries forward the prior transcript replayed from events.jsonl. Resume no longer re-fires the original Task as a fresh user turn -- that was producing "agent busy; send cancel first" races. - core.NewSessionAtPath plus an openOrCreateSession fallback so the child actually persists its session.json at the supervisor-chosen path on first spawn instead of running with sess==nil. - Dashboard in internal/agent/modes/swarm_dialog.go + swarm_slash.go: list / new / kill / remove / resume / logs / send subcommands plus an interactive picker. Transcript view is /btw-style: an always-on inline editor at the bottom, streaming auto-follow, inline busy spinner with the agent's current activity such as "thinking" or "tool: edit". /model inside the spawn editor pops the global model picker. - Per-session scope: each spawn is stamped with the host session's id and only shows in that session's /swarm dashboard. Pre-upgrade agents -- empty session_id -- remain visible everywhere as a safety net. The active scope is re-applied whenever loadSession swaps sessions. - Resolve falls back to the provider's default model when the persisted cfg.Model is no longer in the catalogue, warns on stderr, and rewrites config.json so the next launch is silent. - ReadEventLog folds back-to-back same-type identical-payload events within 250ms so events.jsonl files polluted by the old supervisor + mirror double-write read back cleanly. - DrawLog gains an idle no-op fast path: identical buffer plus identical cursor = emit nothing, so the terminal's cursor blink keeps ticking in dialogs whose underlying agent is idle. Slash UX: - New /swarm command with subcommands; the suggester picks it up. - README.md documents the full dashboard, CLI, and persistence story, and explicitly notes that /session export does NOT bundle subagents -- their worktree and unix-socket inbox cannot round-trip through a .zotsession. Tests cover: SpawnReq + Resume lifecycle, session-id scoping + persistence, default-child-args spawn vs resume contract, NewSessionAtPath at a fixed path, model fallback when the configured model is gone, swarm dialog behaviour -- auto-open editor, /model in spawn editor, transcript grows without internal scroll, busy spinner, multi-message send -- event-log dedup, swarm emitter dormant-until-orphan, and the DrawLog idle no-op + change-breaks-fast-path invariants.
- commit
- b11e6ed4e44a9e0a011000ce8fffc89807fa30fb
- author
- patriceckhart · · 2026-05-16 09:53 UTC
- parent
- 7970412
- signature
- unsigned
35 files · +8356 -13
M README.md +45 -0
| @@ -187,6 +187,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands: | |||||
| 187 | | `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. | | 187 | | `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. | | ||
| 188 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | 188 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | ||
| 189 | | `/btw` | Side chat with full context that doesn't add to the main thread. | | 189 | | `/btw` | Side chat with full context that doesn't add to the main thread. | | ||
| 190 | + | | `/swarm` | Spawn, monitor, and chat with background subagents. Each gets its own git worktree and runs in parallel with your main session. | | |||
| 190 | | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. | | 191 | | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. | | ||
| 191 | | `/compact` | Summarize the transcript into one message to free up context. | | 192 | | `/compact` | Summarize the transcript into one message to free up context. | | ||
| 192 | | `/study` | Run the canned prompt "Read and understand everything in the current directory." so the agent has full project context before you start asking targeted questions. | | 193 | | `/study` | Run the canned prompt "Read and understand everything in the current directory." so the agent has full project context before you start asking targeted questions. | | ||
| @@ -208,6 +209,8 @@ Shows previous sessions for the current working directory, newest first, with ti | |||||
| 208 | Four ops on the current session. `/session` alone opens a picker; each is also runnable directly. | 209 | Four ops on the current session. `/session` alone opens a picker; each is also runnable directly. | ||
| 209 | 210 | ||||
| 210 | - **`/session export [path]`**. Writes the running transcript to a portable `.zotsession` file. Default destination is `~/Downloads/<timestamp>-<session-id>-<prompt-slug>.zotsession`. Pass a path to override; a directory is fine (a dated name is built inside), a bare name gets `.zotsession` appended. The meta's cwd is stripped on the way out so the recipient doesn't see your filesystem layout. | 211 | - **`/session export [path]`**. Writes the running transcript to a portable `.zotsession` file. Default destination is `~/Downloads/<timestamp>-<session-id>-<prompt-slug>.zotsession`. Pass a path to override; a directory is fine (a dated name is built inside), a bare name gets `.zotsession` appended. The meta's cwd is stripped on the way out so the recipient doesn't see your filesystem layout. | ||
| 212 | + | ||||
| 213 | + | **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/swarm` subagents are NOT included.** Their transcripts, worktrees (which are real git checkouts under `$ZOT_HOME/swarm/worktrees/<id>` on branch `swarm/<id>`), unix-socket inboxes, and per-agent session files are all machine-local; a `.zotsession` is just a chat transcript and has no way to bundle a git worktree or revive a unix socket on another box. If you want to share a subagent's work, use normal git tooling on its branch (`git push`, `git format-patch`, etc.); if you want the conversation, copy it out of the dashboard manually. | |||
| 211 | - **`/session import <path>`**. Copies a `.zotsession` file into `$ZOT_HOME/sessions/<cwd-hash>/` with a fresh id and the current cwd, then switches the running agent onto it. Imported sessions are first-class: they show up in `/sessions`, `/jump`, and the tree. Drag-drop paths in the editor are accepted (zot strips the surrounding quotes automatically). | 214 | - **`/session import <path>`**. Copies a `.zotsession` file into `$ZOT_HOME/sessions/<cwd-hash>/` with a fresh id and the current cwd, then switches the running agent onto it. Imported sessions are first-class: they show up in `/sessions`, `/jump`, and the tree. Drag-drop paths in the editor are accepted (zot strips the surrounding quotes automatically). | ||
| 212 | - **`/session fork`**. Opens a turn picker (same shape as `/jump`). Pick any past user message; zot copies every message up to and including that turn into a new session, records `parent` + `fork_point` in the new meta, and switches onto the branch. The parent session stays on disk. Use it to try a different question without polluting the original transcript, or to rewind after the agent went down the wrong path. | 215 | - **`/session fork`**. Opens a turn picker (same shape as `/jump`). Pick any past user message; zot copies every message up to and including that turn into a new session, records `parent` + `fork_point` in the new meta, and switches onto the branch. The parent session stays on disk. Use it to try a different question without polluting the original transcript, or to rewind after the agent went down the wrong path. | ||
| 213 | - **`/session tree`**. Shows every session in the current cwd arranged by parent/child relationships, depth-first with indent per level. The current session is tagged `[current]`. Pick any entry to switch into it. Parentless sessions are roots; branches created via `/session fork` nest under whichever session they were forked from. Orphaned children (whose parent file was deleted) still show as roots so they stay discoverable. | 216 | - **`/session tree`**. Shows every session in the current cwd arranged by parent/child relationships, depth-first with indent per level. The current session is tagged `[current]`. Pick any entry to switch into it. Parentless sessions are roots; branches created via `/session fork` nest under whichever session they were forked from. Orphaned children (whose parent file was deleted) still show as roots so they stay discoverable. | ||
| @@ -231,6 +234,48 @@ Each question fires a one-off model call against `system + main transcript + sid | |||||
| 231 | 234 | ||||
| 232 | Inside the overlay: `enter` sends, `esc` cancels an in-flight call (or closes the overlay if idle), `ctrl+c` closes immediately. Side-chat exchanges never touch the transcript and aren't persisted to the session file. | 235 | Inside the overlay: `enter` sends, `esc` cancels an in-flight call (or closes the overlay if idle), `ctrl+c` closes immediately. Side-chat exchanges never touch the transcript and aren't persisted to the session file. | ||
| 233 | 236 | ||||
| 237 | + | ### `/swarm` | |||
| 238 | + | ||||
| 239 | + | Long-running parallel subagents. Each one gets its own git worktree off your current repo (branch `swarm/<id>`), its own persistent session file, and its own background subprocess driving the model. You stay in the main session and check in on them whenever you want — the dashboard is a `/btw`-style chat per agent. | |||
| 240 | + | ||||
| 241 | + | Why: “go write the test harness for module X” / “investigate this stack trace” / “draft the migration” are perfect side tasks. Spawn an agent, keep working in your main session, come back to its results later — or chat with it interactively while it works. | |||
| 242 | + | ||||
| 243 | + | ``` | |||
| 244 | + | /swarm # open the dashboard | |||
| 245 | + | /swarm new <task> # spawn an agent on a fresh worktree | |||
| 246 | + | /swarm new --model gpt-5 <task> # pin the new agent to a specific model | |||
| 247 | + | /swarm logs <id> # jump straight into one agent's transcript | |||
| 248 | + | /swarm send <id> <text> # send a follow-up without opening the dashboard | |||
| 249 | + | /swarm resume # pick a stopped agent to bring back | |||
| 250 | + | /swarm resume <id> # bring a specific agent back | |||
| 251 | + | /swarm kill <id> # stop a running agent (worktree stays) | |||
| 252 | + | /swarm remove <id> # delete the worktree + session for an agent | |||
| 253 | + | /swarm list # alias for opening the dashboard | |||
| 254 | + | ``` | |||
| 255 | + | ||||
| 256 | + | **Dashboard (`/swarm` with no arg)** — a list of every agent for the current session with status, age, and current activity. Keys: | |||
| 257 | + | ||||
| 258 | + | - `↑` / `↓` move the cursor between rows. | |||
| 259 | + | - `enter` opens the highlighted agent's transcript view. | |||
| 260 | + | - `n` spawns a new agent. Type the task, `enter` to confirm; the new agent inherits the model your main session is currently on (see `/model` and the in-editor `/model` command below). | |||
| 261 | + | - `p` opens a one-off prompt editor for the selected row (alternative to entering the transcript). | |||
| 262 | + | - `R` resumes a stopped agent in place. | |||
| 263 | + | - `k` kills the selected running agent (its worktree and session stay so you can resume later). | |||
| 264 | + | - `r` removes the selected agent entirely (worktree + session + meta gone). | |||
| 265 | + | - `esc` closes the dashboard. | |||
| 266 | + | ||||
| 267 | + | **Inside an agent's transcript** — a chat overlay just like `/btw`. The agent's conversation flows above an always-on inline composer; type and hit `enter` to send a follow-up. The view auto-follows streaming output and shows an inline spinner with the agent's current activity (`thinking`, `tool: edit_file`, etc.) while it's busy. `esc` returns to the dashboard. | |||
| 268 | + | ||||
| 269 | + | **Switching the spawn model from inside the editor** — while composing a task in the `n`-prompt, type `/model` on its own line and `enter`. The same model picker the global `/model` uses pops up; pick a model, the picker closes, and the editor reopens with your typed task intact and the new model pinned for the spawn. | |||
| 270 | + | ||||
| 271 | + | **Session scoping** — each agent is stamped with the session that spawned it and only shows up in that session's `/swarm` dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents you spawned in another session keep running and reappear when you switch back. Pre-upgrade agents (no session stamp) are visible from every session as a safety net. | |||
| 272 | + | ||||
| 273 | + | **Persistence across zot restarts** — every spawn writes a `meta.json` next to its event log and session file under `$ZOT_HOME/swarm/agents/<id>/`. On the next `zot` launch they show up in the dashboard as **detached**; press `R` (or `/swarm resume <id>`) to bring one back. Resumed agents reattach to the same worktree, session, branch, and inbox socket, so the conversation continues from where it left off. | |||
| 274 | + | ||||
| 275 | + | **Where their work lives** — worktrees go under `$ZOT_HOME/swarm/worktrees/<id>` on the branch `swarm/<id>`. Use the normal git tooling to inspect, merge, or rebase (`git worktree list`, `git log swarm/<id>`, etc.). `/swarm remove` deletes both the worktree and the swarm bookkeeping. | |||
| 276 | + | ||||
| 277 | + | **`/session export` does NOT bundle subagents.** A `.zotsession` is just the main chat transcript; swarm worktrees and per-agent state are machine-local (a real git worktree on disk and a unix-socket inbox, neither of which round-trips through a JSONL file). To share a subagent's actual work, push its `swarm/<id>` branch with the normal git tooling. To share what it said, copy it out of the transcript view manually. | |||
| 278 | + | ||||
| 234 | ### `/skills` | 279 | ### `/skills` | ||
| 235 | 280 | ||||
| 236 | Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each row shows the skill name, source, and description. `enter` opens the body inline (scrollable with `up`/`down`/`pgup`/`pgdn`); `esc` goes back. Re-runs discovery each time it opens, so edits to a SKILL.md during a session are reflected immediately. | 281 | Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each row shows the skill name, source, and description. `enter` opens the body inline (scrollable with `up`/`down`/`pgup`/`pgdn`); `esc` goes back. Re-runs discovery each time it opens, so edits to a SKILL.md during a session are reflected immediately. | ||
M internal/agent/args.go +19 -0
| @@ -17,6 +17,13 @@ const ( | |||||
| 17 | ModePrint Mode = "print" | 17 | ModePrint Mode = "print" | ||
| 18 | ModeJSON Mode = "json" | 18 | ModeJSON Mode = "json" | ||
| 19 | ModeRPC Mode = "rpc" | 19 | ModeRPC Mode = "rpc" | ||
| 20 | + | // ModeSwarmAgent is the long-lived, headless daemon mode used by | |||
| 21 | + | // swarm-spawned agents. The binary opens a unix-socket inbox at | |||
| 22 | + | // the path provided by --swarm-agent, reads supervisor messages | |||
| 23 | + | // off it ("user ...", "cancel", "shutdown"), runs each user turn | |||
| 24 | + | // against a persistent session, and streams JSONL events on | |||
| 25 | + | // stdout. See internal/swarm/inbox.go for the wire protocol. | |||
| 26 | + | ModeSwarmAgent Mode = "swarm-agent" | |||
| 20 | ) | 27 | ) | ||
| 21 | 28 | ||||
| 22 | // Args holds parsed command-line options. | 29 | // Args holds parsed command-line options. | ||
| @@ -85,6 +92,11 @@ type Args struct { | |||||
| 85 | Version bool | 92 | Version bool | ||
| 86 | 93 | ||||
| 87 | Prompt string // concatenated positional args | 94 | Prompt string // concatenated positional args | ||
| 95 | + | ||||
| 96 | + | // SwarmAgent is the inbox-socket path when this process is a | |||
| 97 | + | // swarm-spawned agent. Empty in every other mode. Set by | |||
| 98 | + | // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent. | |||
| 99 | + | SwarmAgent string | |||
| 88 | } | 100 | } | ||
| 89 | 101 | ||||
| 90 | // ParseArgs parses the process arguments (excluding argv[0]). | 102 | // ParseArgs parses the process arguments (excluding argv[0]). | ||
| @@ -202,6 +214,13 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 202 | return a, err | 214 | return a, err | ||
| 203 | } | 215 | } | ||
| 204 | a.CWD = v | 216 | a.CWD = v | ||
| 217 | + | case "--swarm-agent": | |||
| 218 | + | v, err := want(&i, arg) | |||
| 219 | + | if err != nil { | |||
| 220 | + | return a, err | |||
| 221 | + | } | |||
| 222 | + | a.SwarmAgent = v | |||
| 223 | + | a.Mode = ModeSwarmAgent | |||
| 205 | case "--tools": | 224 | case "--tools": | ||
| 206 | v, err := want(&i, arg) | 225 | v, err := want(&i, arg) | ||
| 207 | if err != nil { | 226 | if err != nil { | ||
M internal/agent/build.go +63 -3
| @@ -118,6 +118,31 @@ func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []To | |||||
| 118 | return out | 118 | return out | ||
| 119 | } | 119 | } | ||
| 120 | 120 | ||||
| 121 | + | // defaultModelForProvider returns the model id zot prefers when the | |||
| 122 | + | // caller didn't pick one. Mirrors the per-provider switch used at | |||
| 123 | + | // multiple points in Resolve; centralised so the unknown-model | |||
| 124 | + | // recovery path and the no-model-configured path can't drift. | |||
| 125 | + | // | |||
| 126 | + | // Returns the empty string for "ollama", which has no built-in | |||
| 127 | + | // default — the caller is expected to special-case ollama and | |||
| 128 | + | // error or use whatever the user passed. | |||
| 129 | + | func defaultModelForProvider(prov string) string { | |||
| 130 | + | switch prov { | |||
| 131 | + | case "openai": | |||
| 132 | + | return "gpt-5" | |||
| 133 | + | case "kimi": | |||
| 134 | + | return "kimi-for-coding" | |||
| 135 | + | case "deepseek": | |||
| 136 | + | return "deepseek-v4-pro" | |||
| 137 | + | case "google": | |||
| 138 | + | return "gemini-2.5-pro" | |||
| 139 | + | case "ollama": | |||
| 140 | + | return "" | |||
| 141 | + | default: | |||
| 142 | + | return provider.DefaultModel.ID | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | ||||
| 121 | // Resolve merges args, config, and env into a Resolved set. | 146 | // Resolve merges args, config, and env into a Resolved set. | ||
| 122 | // | 147 | // | ||
| 123 | // Unlike the earlier version, Resolve NEVER returns an error for | 148 | // Unlike the earlier version, Resolve NEVER returns an error for | ||
| @@ -221,10 +246,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 221 | } | 246 | } | ||
| 222 | } | 247 | } | ||
| 223 | resolvedModel, err := provider.FindModel(provName, model) | 248 | resolvedModel, err := provider.FindModel(provName, model) | ||
| 224 | - | if err != nil && provName != "ollama" { | |||
| 225 | - | return Resolved{}, err | |||
| 226 | - | } | |||
| 227 | if err != nil && provName == "ollama" { | 249 | if err != nil && provName == "ollama" { | ||
| 250 | + | // ollama is intentionally open-catalogue: any model id the | |||
| 251 | + | // local server understands is valid, even if not in the | |||
| 252 | + | // baked-in catalog. | |||
| 228 | resolvedModel = provider.Model{ | 253 | resolvedModel = provider.Model{ | ||
| 229 | Provider: "ollama", | 254 | Provider: "ollama", | ||
| 230 | ID: model, | 255 | ID: model, | ||
| @@ -234,6 +259,41 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 234 | BaseURL: args.BaseURL, | 259 | BaseURL: args.BaseURL, | ||
| 235 | Source: "ollama", | 260 | Source: "ollama", | ||
| 236 | } | 261 | } | ||
| 262 | + | err = nil | |||
| 263 | + | } | |||
| 264 | + | if err != nil { | |||
| 265 | + | // The model the user (or persisted config) asked for is no | |||
| 266 | + | // longer in the active catalogue — they probably removed it | |||
| 267 | + | // from their models.json or upgraded zot and the id changed. | |||
| 268 | + | // Refusing to launch is the wrong move: it strands the user | |||
| 269 | + | // with no way to even open the TUI and pick a new model. | |||
| 270 | + | // Fall back to the provider's default, warn on stderr, and, | |||
| 271 | + | // when the stale id came from the persisted config (not an | |||
| 272 | + | // explicit --model flag), repair the config so the warning | |||
| 273 | + | // doesn't repeat on every launch. | |||
| 274 | + | fallback := defaultModelForProvider(provName) | |||
| 275 | + | fm, ferr := provider.FindModel(provName, fallback) | |||
| 276 | + | if ferr != nil { | |||
| 277 | + | // Even the provider default is gone (catastrophic | |||
| 278 | + | // catalogue trim). Last resort: any model on this | |||
| 279 | + | // provider, then the global DefaultModel. | |||
| 280 | + | if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 { | |||
| 281 | + | fm = candidates[0] | |||
| 282 | + | ferr = nil | |||
| 283 | + | } else { | |||
| 284 | + | fm = provider.DefaultModel | |||
| 285 | + | ferr = nil | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | fmt.Fprintf(os.Stderr, | |||
| 289 | + | "zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n", | |||
| 290 | + | model, fm.ID) | |||
| 291 | + | if args.Model == "" && cfg.Model == model { | |||
| 292 | + | cfg.Model = fm.ID | |||
| 293 | + | _ = SaveConfig(cfg) | |||
| 294 | + | } | |||
| 295 | + | resolvedModel = fm | |||
| 296 | + | model = fm.ID | |||
| 237 | } | 297 | } | ||
| 238 | 298 | ||||
| 239 | // If the model defines a base URL (e.g. local ollama) and the | 299 | // If the model defines a base URL (e.g. local ollama) and the | ||
M internal/agent/build_test.go +63 -0
| @@ -45,3 +45,66 @@ func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) { | |||||
| 45 | t.Fatalf("expected no context, got %q", got) | 45 | t.Fatalf("expected no context, got %q", got) | ||
| 46 | } | 46 | } | ||
| 47 | } | 47 | } | ||
| 48 | + | ||||
| 49 | + | // TestResolveFallsBackWhenConfiguredModelIsGone reproduces the | |||
| 50 | + | // startup failure caught by the user's screenshot: the persisted | |||
| 51 | + | // config.json points at a model id that's no longer in the active | |||
| 52 | + | // catalogue (because they edited models.json or zot's bundled | |||
| 53 | + | // catalogue changed). Resolve must NOT error — strands the user | |||
| 54 | + | // with no way to fix it from the TUI — and should repair the config | |||
| 55 | + | // so the next launch is silent. | |||
| 56 | + | func TestResolveFallsBackWhenConfiguredModelIsGone(t *testing.T) { | |||
| 57 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 58 | + | t.Setenv("OPENAI_API_KEY", "test-key") | |||
| 59 | + | // Persist a stale model id. | |||
| 60 | + | stale := "gpt-5.5-pro-not-real" | |||
| 61 | + | if err := SaveConfig(Config{Provider: "openai", Model: stale}); err != nil { | |||
| 62 | + | t.Fatal(err) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | r, err := Resolve(Args{}, false) | |||
| 66 | + | if err != nil { | |||
| 67 | + | t.Fatalf("Resolve refused to launch with stale model: %v", err) | |||
| 68 | + | } | |||
| 69 | + | if r.Model == stale { | |||
| 70 | + | t.Fatalf("Resolve kept stale model %q", r.Model) | |||
| 71 | + | } | |||
| 72 | + | if r.Provider != "openai" { | |||
| 73 | + | t.Errorf("provider drifted: got %q; want openai", r.Provider) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // Config on disk should now hold the fallback so subsequent | |||
| 77 | + | // launches don't repeat the warning. | |||
| 78 | + | cfg, _ := LoadConfig() | |||
| 79 | + | if cfg.Model == stale { | |||
| 80 | + | t.Errorf("config.json still pins the stale model %q", cfg.Model) | |||
| 81 | + | } | |||
| 82 | + | if cfg.Model == "" { | |||
| 83 | + | t.Errorf("config.json was emptied; expected the fallback model id") | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // TestResolveExplicitFlagStaleDoesNotRepairConfig confirms the | |||
| 88 | + | // repair-on-disk happens ONLY when the stale id came from the | |||
| 89 | + | // persisted config. If the user passed --model X explicitly and X is | |||
| 90 | + | // unknown, we still fall back, but we don't touch their config. | |||
| 91 | + | func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) { | |||
| 92 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 93 | + | t.Setenv("OPENAI_API_KEY", "test-key") | |||
| 94 | + | good := "gpt-5" | |||
| 95 | + | if err := SaveConfig(Config{Provider: "openai", Model: good}); err != nil { | |||
| 96 | + | t.Fatal(err) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | r, err := Resolve(Args{Model: "gpt-totally-fake"}, false) | |||
| 100 | + | if err != nil { | |||
| 101 | + | t.Fatalf("Resolve errored on unknown --model: %v", err) | |||
| 102 | + | } | |||
| 103 | + | if r.Model == "gpt-totally-fake" { | |||
| 104 | + | t.Errorf("Resolve kept the bogus --model value") | |||
| 105 | + | } | |||
| 106 | + | cfg, _ := LoadConfig() | |||
| 107 | + | if cfg.Model != good { | |||
| 108 | + | t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model) | |||
| 109 | + | } | |||
| 110 | + | } | |||
M internal/agent/cli.go +54 -0
| @@ -4,10 +4,12 @@ import ( | |||||
| 4 | "bufio" | 4 | "bufio" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | + | "errors" | |||
| 7 | "fmt" | 8 | "fmt" | ||
| 8 | "io" | 9 | "io" | ||
| 9 | "os" | 10 | "os" | ||
| 10 | "os/signal" | 11 | "os/signal" | ||
| 12 | + | "path/filepath" | |||
| 11 | "strings" | 13 | "strings" | ||
| 12 | "sync" | 14 | "sync" | ||
| 13 | "syscall" | 15 | "syscall" | ||
| @@ -20,6 +22,7 @@ import ( | |||||
| 20 | "github.com/patriceckhart/zot/internal/extproto" | 22 | "github.com/patriceckhart/zot/internal/extproto" | ||
| 21 | "github.com/patriceckhart/zot/internal/provider" | 23 | "github.com/patriceckhart/zot/internal/provider" | ||
| 22 | "github.com/patriceckhart/zot/internal/skills" | 24 | "github.com/patriceckhart/zot/internal/skills" | ||
| 25 | + | "github.com/patriceckhart/zot/internal/swarm" | |||
| 23 | "github.com/patriceckhart/zot/internal/tui" | 26 | "github.com/patriceckhart/zot/internal/tui" | ||
| 24 | ) | 27 | ) | ||
| 25 | 28 | ||||
| @@ -218,6 +221,8 @@ func Run(rawArgs []string, version string) error { | |||||
| 218 | return runJSONMode(ctx, args, version) | 221 | return runJSONMode(ctx, args, version) | ||
| 219 | case ModeRPC: | 222 | case ModeRPC: | ||
| 220 | return runRPCMode(ctx, args, version) | 223 | return runRPCMode(ctx, args, version) | ||
| 224 | + | case ModeSwarmAgent: | |||
| 225 | + | return runSwarmAgentMode(ctx, args, version) | |||
| 221 | default: | 226 | default: | ||
| 222 | return runInteractive(ctx, args, version) | 227 | return runInteractive(ctx, args, version) | ||
| 223 | } | 228 | } | ||
| @@ -534,6 +539,11 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 534 | 539 | ||||
| 535 | var sess *core.Session | 540 | var sess *core.Session | ||
| 536 | var sessBaselineMsgs int // messages already on disk when current session opened | 541 | var sessBaselineMsgs int // messages already on disk when current session opened | ||
| 542 | + | // swarmMgr is constructed below, but loadSession (defined before | |||
| 543 | + | // the construction site) needs to re-scope it whenever the user | |||
| 544 | + | // swaps sessions. Forward-declare here so the closure can | |||
| 545 | + | // reference it; the assignment happens at the construction line. | |||
| 546 | + | var swarmMgr *swarm.Swarm | |||
| 537 | // persistMu guards sess + sessBaselineMsgs against concurrent access | 547 | // persistMu guards sess + sessBaselineMsgs against concurrent access | ||
| 538 | // from the agent loop's per-message persistence hook (runs on the | 548 | // from the agent loop's per-message persistence hook (runs on the | ||
| 539 | // agent goroutine) and the TUI's session swap / flush callbacks | 549 | // agent goroutine) and the TUI's session swap / flush callbacks | ||
| @@ -654,6 +664,14 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 654 | // the hydrated tail. | 664 | // the hydrated tail. | ||
| 655 | sessBaselineMsgs = fullMsgCount | 665 | sessBaselineMsgs = fullMsgCount | ||
| 656 | persistMu.Unlock() | 666 | persistMu.Unlock() | ||
| 667 | + | // Re-scope the swarm dashboard to the new session so /swarm | |||
| 668 | + | // only shows agents this session spawned. swarmMgr may be nil | |||
| 669 | + | // here if we haven't reached the construction site yet (it | |||
| 670 | + | // shouldn't be, since the interactive loop is what triggers | |||
| 671 | + | // loadSession, but the nil check is cheap insurance). | |||
| 672 | + | if swarmMgr != nil && newSess != nil { | |||
| 673 | + | swarmMgr.SetActiveSession(newSess.ID) | |||
| 674 | + | } | |||
| 657 | return nil | 675 | return nil | ||
| 658 | } | 676 | } | ||
| 659 | 677 | ||||
| @@ -708,6 +726,29 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 708 | }() | 726 | }() | ||
| 709 | 727 | ||||
| 710 | initialCfg, _ := LoadConfig() | 728 | initialCfg, _ := LoadConfig() | ||
| 729 | + | ||||
| 730 | + | // Build the swarm supervisor. Root lives under ZotHome/swarm so | |||
| 731 | + | // worktrees survive across zot sessions and the user can hunt | |||
| 732 | + | // them down with `git worktree list` if anything misbehaves. | |||
| 733 | + | swarmMgr = swarm.New(swarm.Config{ | |||
| 734 | + | Root: filepath.Join(ZotHome(), "swarm"), | |||
| 735 | + | RepoRoot: r.CWD, | |||
| 736 | + | }) | |||
| 737 | + | // Pull any previously-spawned agents off disk so the user can see, | |||
| 738 | + | // resume, or remove them from the dashboard. Failures here aren't | |||
| 739 | + | // fatal — the supervisor still works for new agents. | |||
| 740 | + | _, _ = swarmMgr.Reload() | |||
| 741 | + | // Scope the dashboard to the active host session so /swarm only | |||
| 742 | + | // shows agents this session spawned (and any pre-upgrade unscoped | |||
| 743 | + | // agents — see SnapshotAll docs). Updated again whenever the | |||
| 744 | + | // user swaps sessions via loadSession below. | |||
| 745 | + | if sess != nil { | |||
| 746 | + | swarmMgr.SetActiveSession(sess.ID) | |||
| 747 | + | } | |||
| 748 | + | // Best-effort shutdown on interactive exit: stop all running | |||
| 749 | + | // agents so they don't outlive their parent zot. | |||
| 750 | + | defer swarmMgr.StopAll() | |||
| 751 | + | ||||
| 711 | iv = modes.NewInteractive(modes.InteractiveConfig{ | 752 | iv = modes.NewInteractive(modes.InteractiveConfig{ | ||
| 712 | Terminal: term, | 753 | Terminal: term, | ||
| 713 | Theme: tui.DetectThemeFromBackground(80 * time.Millisecond), | 754 | Theme: tui.DetectThemeFromBackground(80 * time.Millisecond), | ||
| @@ -773,6 +814,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 773 | sessBaselineMsgs = len(currentAg.Messages()) | 814 | sessBaselineMsgs = len(currentAg.Messages()) | ||
| 774 | }, | 815 | }, | ||
| 775 | Extensions: extMgr, | 816 | Extensions: extMgr, | ||
| 817 | + | Swarm: swarmMgr, | |||
| 776 | ChangelogChan: changelogCh, | 818 | ChangelogChan: changelogCh, | ||
| 777 | OnChangelogDismiss: func() { | 819 | OnChangelogDismiss: func() { | ||
| 778 | // For dev builds (0.0.0) store the actual release version | 820 | // For dev builds (0.0.0) store the actual release version | ||
| @@ -894,6 +936,18 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||||
| 894 | switch { | 936 | switch { | ||
| 895 | case args.Session != "": | 937 | case args.Session != "": | ||
| 896 | s, msgs, err = core.OpenSession(args.Session) | 938 | s, msgs, err = core.OpenSession(args.Session) | ||
| 939 | + | // The swarm-agent child passes a fixed --session path that | |||
| 940 | + | // may not exist yet on first Spawn. Treat ENOENT as "create | |||
| 941 | + | // a fresh session AT THIS PATH" so the conversation actually | |||
| 942 | + | // gets persisted; without this fallback the swarm child runs | |||
| 943 | + | // with sess==nil and every Resume re-starts with no memory | |||
| 944 | + | // of the prior turns. Other openers (--continue / --resume / | |||
| 945 | + | // the picker) never see ENOENT here because they only choose | |||
| 946 | + | // paths that already exist on disk. | |||
| 947 | + | if err != nil && errors.Is(err, os.ErrNotExist) { | |||
| 948 | + | s, err = core.NewSessionAtPath(args.Session, args.CWD, r.Provider, r.Model, version) | |||
| 949 | + | msgs = nil | |||
| 950 | + | } | |||
| 897 | case args.Continue: | 951 | case args.Continue: | ||
| 898 | latest := core.LatestSession(ZotHome(), args.CWD) | 952 | latest := core.LatestSession(ZotHome(), args.CWD) | ||
| 899 | if latest != "" { | 953 | if latest != "" { | ||
M internal/agent/modes/file_suggest.go +10 -2
| @@ -274,8 +274,13 @@ func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string { | |||||
| 274 | return out | 274 | return out | ||
| 275 | } | 275 | } | ||
| 276 | 276 | ||||
| 277 | - | // fileChipRE matches [file:N:name] and [dir:N:name/] chips. | 277 | + | // fileChipRE matches @-picker chips inserted by modes: |
| 278 | - | var fileChipRE = regexp.MustCompile(`\[(file|dir):(\d+):[^\]]+\]`) | 278 | + | // [file:relative/path] and [dir:relative/path/]. The lower-level |
| 279 | + | // tui.Editor has its own drag/drop chips ([file:N:basename]) which | |||
| 280 | + | // Editor.SubmitValue expands before callers reach this helper; if one | |||
| 281 | + | // leaks through, leave it untouched rather than guessing at a path. | |||
| 282 | + | var fileChipRE = regexp.MustCompile(`\[(file|dir):([^\]]+)\]`) | |||
| 283 | + | var editorFileChipRE = regexp.MustCompile(`^\d+:`) | |||
| 279 | 284 | ||||
| 280 | // expandFileChips replaces [file:name] and [dir:name/] chips with | 285 | // expandFileChips replaces [file:name] and [dir:name/] chips with | ||
| 281 | // the full path relative to cwd. | 286 | // the full path relative to cwd. | ||
| @@ -286,6 +291,9 @@ func expandFileChips(text, cwd string) string { | |||||
| 286 | return match | 291 | return match | ||
| 287 | } | 292 | } | ||
| 288 | name := groups[2] | 293 | name := groups[2] | ||
| 294 | + | if editorFileChipRE.MatchString(name) { | |||
| 295 | + | return match | |||
| 296 | + | } | |||
| 289 | // Strip trailing slash from dir names. | 297 | // Strip trailing slash from dir names. | ||
| 290 | name = strings.TrimRight(name, "/") | 298 | name = strings.TrimRight(name, "/") | ||
| 291 | return filepath.Join(cwd, name) | 299 | return filepath.Join(cwd, name) | ||
A internal/agent/modes/file_suggest_test.go +26 -0
| @@ -0,0 +1,26 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "path/filepath" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestExpandFileChipsAtPickerShape(t *testing.T) { | |||
| 9 | + | cwd := filepath.Join(string(filepath.Separator), "repo") | |||
| 10 | + | in := "read [file:README.md] and [dir:docs/]" | |||
| 11 | + | want := "read " + filepath.Join(cwd, "README.md") + " and " + filepath.Join(cwd, "docs") | |||
| 12 | + | if got := expandFileChips(in, cwd); got != want { | |||
| 13 | + | t.Fatalf("expandFileChips() = %q, want %q", got, want) | |||
| 14 | + | } | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func TestExpandFileChipsLeavesEditorPlaceholderShapeAlone(t *testing.T) { | |||
| 18 | + | cwd := filepath.Join(string(filepath.Separator), "repo") | |||
| 19 | + | // tui.Editor.SubmitValue should expand [file:N:name] using its | |||
| 20 | + | // private path map before modes see the text. If such a token leaks | |||
| 21 | + | // through, modes must not guess that "1:foo.txt" is a relative path. | |||
| 22 | + | in := "read [file:1:foo.txt]" | |||
| 23 | + | if got := expandFileChips(in, cwd); got != in { | |||
| 24 | + | t.Fatalf("editor placeholder was changed: %q", got) | |||
| 25 | + | } | |||
| 26 | + | } | |||
M internal/agent/modes/interactive.go +69 -7
| @@ -17,6 +17,7 @@ import ( | |||||
| 17 | "github.com/patriceckhart/zot/internal/extproto" | 17 | "github.com/patriceckhart/zot/internal/extproto" | ||
| 18 | "github.com/patriceckhart/zot/internal/provider" | 18 | "github.com/patriceckhart/zot/internal/provider" | ||
| 19 | "github.com/patriceckhart/zot/internal/skills" | 19 | "github.com/patriceckhart/zot/internal/skills" | ||
| 20 | + | "github.com/patriceckhart/zot/internal/swarm" | |||
| 20 | "github.com/patriceckhart/zot/internal/tui" | 21 | "github.com/patriceckhart/zot/internal/tui" | ||
| 21 | ) | 22 | ) | ||
| 22 | 23 | ||||
| @@ -131,6 +132,13 @@ type InteractiveConfig struct { | |||||
| 131 | // AFTER the built-in catalog so a built-in name always wins. | 132 | // AFTER the built-in catalog so a built-in name always wins. | ||
| 132 | Extensions *extensions.Manager | 133 | Extensions *extensions.Manager | ||
| 133 | 134 | ||||
| 135 | + | // Swarm, if non-nil, enables the /swarm slash command and the | |||
| 136 | + | // dashboard dialog. The cli constructs the Swarm once per | |||
| 137 | + | // interactive run and tears it down on exit. nil disables the | |||
| 138 | + | // feature entirely (used by embedders / tests that don't want | |||
| 139 | + | // subprocesses). | |||
| 140 | + | Swarm *swarm.Swarm | |||
| 141 | + | ||||
| 134 | // SkillSnapshot, if non-nil, returns the current list of | 142 | // SkillSnapshot, if non-nil, returns the current list of | ||
| 135 | // discovered SKILL.md files. Re-invoked each time /skills opens | 143 | // discovered SKILL.md files. Re-invoked each time /skills opens | ||
| 136 | // so the picker reflects edits made during the session. | 144 | // so the picker reflects edits made during the session. | ||
| @@ -280,6 +288,7 @@ type Interactive struct { | |||||
| 280 | modelDialog *modelDialog | 288 | modelDialog *modelDialog | ||
| 281 | rescueDialog *rescueDialog | 289 | rescueDialog *rescueDialog | ||
| 282 | sessionDialog *sessionDialog | 290 | sessionDialog *sessionDialog | ||
| 291 | + | swarmDialog *swarmDialog | |||
| 283 | jumpDialog *jumpDialog | 292 | jumpDialog *jumpDialog | ||
| 284 | btwDialog *btwDialog | 293 | btwDialog *btwDialog | ||
| 285 | skillsDialog *skillsDialog | 294 | skillsDialog *skillsDialog | ||
| @@ -367,6 +376,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 367 | modelDialog: newModelDialog(), | 376 | modelDialog: newModelDialog(), | ||
| 368 | rescueDialog: newRescueDialog(), | 377 | rescueDialog: newRescueDialog(), | ||
| 369 | sessionDialog: newSessionDialog(), | 378 | sessionDialog: newSessionDialog(), | ||
| 379 | + | swarmDialog: newSwarmDialog(), | |||
| 370 | jumpDialog: newJumpDialog(), | 380 | jumpDialog: newJumpDialog(), | ||
| 371 | btwDialog: newBtwDialog(), | 381 | btwDialog: newBtwDialog(), | ||
| 372 | skillsDialog: newSkillsDialog(), | 382 | skillsDialog: newSkillsDialog(), | ||
| @@ -616,7 +626,16 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 616 | // the tick and firing it cancels the terminal's cursor | 626 | // the tick and firing it cancels the terminal's cursor | ||
| 617 | // blink inside dialogs that host their own editor (btw), | 627 | // blink inside dialogs that host their own editor (btw), | ||
| 618 | // because each frame re-emits hide-cursor + show-cursor. | 628 | // because each frame re-emits hide-cursor + show-cursor. | ||
| 619 | - | if i.busy || i.btwDialog.Loading() { | 629 | + | // |
| 630 | + | // The swarm dashboard is also animated: its rows reflect | |||
| 631 | + | // background subprocesses whose activity / age change | |||
| 632 | + | // without any user input. Without the tick redraw the | |||
| 633 | + | // dashboard freezes on the snapshot taken when the user | |||
| 634 | + | // last pressed a key. We exclude the dashboard when one | |||
| 635 | + | // of its inline editors (spawn task or prompt composer) | |||
| 636 | + | // is active so the cursor blink in those editors works | |||
| 637 | + | // the same way it does inside btw. | |||
| 638 | + | if i.busy || i.btwDialog.Loading() || i.swarmDialog.NeedsTickRefresh() { | |||
| 620 | requestRedraw() | 639 | requestRedraw() | ||
| 621 | } | 640 | } | ||
| 622 | } | 641 | } | ||
| @@ -877,6 +896,8 @@ func (i *Interactive) redraw() { | |||||
| 877 | } | 896 | } | ||
| 878 | i.sessionDialog.MaxRows = avail | 897 | i.sessionDialog.MaxRows = avail | ||
| 879 | dialog = i.sessionDialog.Render(i.cfg.Theme, cols) | 898 | dialog = i.sessionDialog.Render(i.cfg.Theme, cols) | ||
| 899 | + | case i.swarmDialog.Active(): | |||
| 900 | + | dialog = i.swarmDialog.Render(i.cfg.Theme, cols) | |||
| 880 | case i.jumpDialog.Active(): | 901 | case i.jumpDialog.Active(): | ||
| 881 | dialog = i.jumpDialog.Render(i.cfg.Theme, cols) | 902 | dialog = i.jumpDialog.Render(i.cfg.Theme, cols) | ||
| 882 | case i.btwDialog.Active(): | 903 | case i.btwDialog.Active(): | ||
| @@ -1024,12 +1045,22 @@ func (i *Interactive) redraw() { | |||||
| 1024 | bottom = append(bottom, "") | 1045 | bottom = append(bottom, "") | ||
| 1025 | } | 1046 | } | ||
| 1026 | bottom = append(bottom, dialog...) | 1047 | bottom = append(bottom, dialog...) | ||
| 1027 | - | bottom = append(bottom, suggest...) | 1048 | + | // The swarm dashboard owns the bottom of the screen while it's |
| 1028 | - | bottom = append(bottom, queue...) | 1049 | + | // active: it has its own inline editors for spawn (`n`) and |
| 1029 | - | bottom = append(bottom, "") | 1050 | + | // prompt (`p`), so the main input would be a confusing second |
| 1030 | - | bottom = append(bottom, statusLines...) | 1051 | + | // caret. The suggest popup, sliding-in queue, status block, and |
| 1031 | - | bottom = append(bottom, "") | 1052 | + | // main editor are all hidden underneath it. Keystrokes still |
| 1032 | - | bottom = append(bottom, edLines...) | 1053 | + | // reach handleKey — it routes them to swarmDialog.HandleKey |
| 1054 | + | // before the editor ever sees them — so the only effect of this | |||
| 1055 | + | // branch is visual. | |||
| 1056 | + | if !i.swarmDialog.Active() { | |||
| 1057 | + | bottom = append(bottom, suggest...) | |||
| 1058 | + | bottom = append(bottom, queue...) | |||
| 1059 | + | bottom = append(bottom, "") | |||
| 1060 | + | bottom = append(bottom, statusLines...) | |||
| 1061 | + | bottom = append(bottom, "") | |||
| 1062 | + | bottom = append(bottom, edLines...) | |||
| 1063 | + | } | |||
| 1033 | 1064 | ||||
| 1034 | _, rows := i.cfg.Terminal.Size() | 1065 | _, rows := i.cfg.Terminal.Size() | ||
| 1035 | chatRows := rows - len(bottom) | 1066 | chatRows := rows - len(bottom) | ||
| @@ -1167,6 +1198,19 @@ func (i *Interactive) redraw() { | |||||
| 1167 | cursorCol = c | 1198 | cursorCol = c | ||
| 1168 | } | 1199 | } | ||
| 1169 | } | 1200 | } | ||
| 1201 | + | if i.swarmDialog.Active() { | |||
| 1202 | + | if r, c := i.swarmDialog.CursorPos(cols); r >= 0 { | |||
| 1203 | + | cursorRow = dialogLead + r | |||
| 1204 | + | cursorCol = c | |||
| 1205 | + | } else { | |||
| 1206 | + | // Dashboard list / transcript view has no caret. Without | |||
| 1207 | + | // this branch the default cursorRow points at the | |||
| 1208 | + | // (hidden) main editor row, so the terminal would draw | |||
| 1209 | + | // a stray block somewhere in the chat region. | |||
| 1210 | + | cursorRow = -1 | |||
| 1211 | + | cursorCol = 0 | |||
| 1212 | + | } | |||
| 1213 | + | } | |||
| 1170 | if i.extPanel.Active() { | 1214 | if i.extPanel.Active() { | ||
| 1171 | cursorRow = -1 | 1215 | cursorRow = -1 | ||
| 1172 | cursorCol = 0 | 1216 | cursorCol = 0 | ||
| @@ -1477,6 +1521,22 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 1477 | } | 1521 | } | ||
| 1478 | return false | 1522 | return false | ||
| 1479 | } | 1523 | } | ||
| 1524 | + | if i.swarmDialog.Active() { | |||
| 1525 | + | if k.Kind == tui.KeyCtrlC { | |||
| 1526 | + | i.swarmDialog.Close() | |||
| 1527 | + | i.invalidate() | |||
| 1528 | + | return false | |||
| 1529 | + | } | |||
| 1530 | + | _, msg, errMsg := i.swarmDialog.HandleKey(k) | |||
| 1531 | + | if msg != "" || errMsg != "" { | |||
| 1532 | + | i.mu.Lock() | |||
| 1533 | + | i.statusOK = msg | |||
| 1534 | + | i.statusErr = errMsg | |||
| 1535 | + | i.mu.Unlock() | |||
| 1536 | + | } | |||
| 1537 | + | i.invalidate() | |||
| 1538 | + | return false | |||
| 1539 | + | } | |||
| 1480 | if i.logoutDialog.Active() { | 1540 | if i.logoutDialog.Active() { | ||
| 1481 | if k.Kind == tui.KeyCtrlC { | 1541 | if k.Kind == tui.KeyCtrlC { | ||
| 1482 | i.logoutDialog.Close() | 1542 | i.logoutDialog.Close() | ||
| @@ -2395,6 +2455,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 2395 | break | 2455 | break | ||
| 2396 | } | 2456 | } | ||
| 2397 | i.openSessionOpsDialog() | 2457 | i.openSessionOpsDialog() | ||
| 2458 | + | case "/swarm": | |||
| 2459 | + | i.runSwarm(ctx, parts[1:]) | |||
| 2398 | default: | 2460 | default: | ||
| 2399 | // Last-resort fallback: try the extension manager. Built-in | 2461 | // Last-resort fallback: try the extension manager. Built-in | ||
| 2400 | // cases above always win; this branch only fires for slash | 2462 | // cases above always win; this branch only fires for slash | ||
M internal/agent/modes/slash_suggest.go +1 -0
| @@ -45,6 +45,7 @@ var slashCatalog = []slashCommand{ | |||||
| 45 | {Name: "/jail", Desc: "confine tools to the current directory"}, | 45 | {Name: "/jail", Desc: "confine tools to the current directory"}, | ||
| 46 | {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"}, | 46 | {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"}, | ||
| 47 | {Name: "/skills", Desc: "list discovered skills (SKILL.md files)"}, | 47 | {Name: "/skills", Desc: "list discovered skills (SKILL.md files)"}, | ||
| 48 | + | {Name: "/swarm", Desc: "supervise background agents working in their own worktrees"}, | |||
| 48 | {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"}, | 49 | {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"}, | ||
| 49 | {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"}, | 50 | {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"}, | ||
| 50 | {Name: "/clear", Desc: "clear the chat transcript"}, | 51 | {Name: "/clear", Desc: "clear the chat transcript"}, | ||
M internal/agent/modes/slash_suggest_test.go +7 -0
| @@ -21,6 +21,13 @@ func TestSlashSuggesterHidesUnjailUntilJailed(t *testing.T) { | |||||
| 21 | } | 21 | } | ||
| 22 | } | 22 | } | ||
| 23 | 23 | ||||
| 24 | + | func TestSlashSuggesterHasSwarm(t *testing.T) { | |||
| 25 | + | s := newSlashSuggester() | |||
| 26 | + | if got := commandNames(s.matches("/sw")); !contains(got, "/swarm") { | |||
| 27 | + | t.Fatalf("/swarm missing from suggestions, got %v", got) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 24 | func commandNames(cmds []slashCommand) []string { | 31 | func commandNames(cmds []slashCommand) []string { | ||
| 25 | out := make([]string, 0, len(cmds)) | 32 | out := make([]string, 0, len(cmds)) | ||
| 26 | for _, c := range cmds { | 33 | for _, c := range cmds { | ||
A internal/agent/modes/swarm_dialog.go +1382 -0
| @@ -0,0 +1,1382 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/patriceckhart/zot/internal/swarm" | |||
| 10 | + | "github.com/patriceckhart/zot/internal/tui" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // swarmDialog is the dashboard shown by /swarm (no argument) and | |||
| 14 | + | // /swarm list. It's a read-only window onto a *swarm.Swarm; the | |||
| 15 | + | // caller passes a snapshotter so the dialog never holds the swarm | |||
| 16 | + | // pointer or its lock. | |||
| 17 | + | // | |||
| 18 | + | // Keys (list view): | |||
| 19 | + | // | |||
| 20 | + | // n spawn a new agent (inline task prompt) | |||
| 21 | + | // p send a follow-up prompt to the selected agent | |||
| 22 | + | // R resume (restart) a detached / finished agent | |||
| 23 | + | // ↑/↓ move cursor | |||
| 24 | + | // enter show transcript tail for the selected agent | |||
| 25 | + | // k kill (Stop) the selected running agent | |||
| 26 | + | // r remove a terminated agent (deletes worktree) | |||
| 27 | + | // esc / q close | |||
| 28 | + | // | |||
| 29 | + | // Keys (transcript view): | |||
| 30 | + | // | |||
| 31 | + | // p send a follow-up prompt to this agent (inline editor) | |||
| 32 | + | // ↑/↓/PgUp/PgDn/Home/End scroll | |||
| 33 | + | // esc / q return to the list | |||
| 34 | + | type swarmDialog struct { | |||
| 35 | + | active bool | |||
| 36 | + | snapshot func() []swarm.AgentSnapshot | |||
| 37 | + | stop func(id string) error | |||
| 38 | + | remove func(id string) error | |||
| 39 | + | // spawn accepts an optional model + provider override (empty | |||
| 40 | + | // strings mean "let the child resolve its own default"). The cli | |||
| 41 | + | // adapter forwards these to swarm.Swarm.SpawnReq. | |||
| 42 | + | spawn func(task, model, provider string) error | |||
| 43 | + | // send delivers a follow-up user turn to a running agent's inbox. | |||
| 44 | + | // Wired by Open(); when nil the inline 'p' shortcut is disabled. | |||
| 45 | + | send func(id, text string) error | |||
| 46 | + | // resume restarts a detached or terminated agent on its existing | |||
| 47 | + | // worktree/session. Wired by Open(); when nil the inline 'R' | |||
| 48 | + | // shortcut is disabled. | |||
| 49 | + | resume func(id string) error | |||
| 50 | + | ||||
| 51 | + | rows []swarm.AgentSnapshot | |||
| 52 | + | cursor int | |||
| 53 | + | viewing bool // when true, show full transcript of the selected row | |||
| 54 | + | ||||
| 55 | + | // transcriptSpin animates the busy hint shown above the inline | |||
| 56 | + | // editor while the agent is mid-turn (status running, activity | |||
| 57 | + | // anything other than idle). Same shape as the /btw spinner and | |||
| 58 | + | // the main chat's busy line, so the user gets a consistent | |||
| 59 | + | // "something's happening" signal. Lazily created on first | |||
| 60 | + | // busy frame; reset by Open / Close. | |||
| 61 | + | transcriptSpin *spinner | |||
| 62 | + | ||||
| 63 | + | // spawning toggles the inline "enter task" editor. While true the | |||
| 64 | + | // dialog captures keys into a real tui.Editor so swarm-new has the | |||
| 65 | + | // same editing, paste, drag-drop file chips, and visual style as | |||
| 66 | + | // the default input and /btw input. | |||
| 67 | + | spawning bool | |||
| 68 | + | newTaskEd *tui.Editor | |||
| 69 | + | fileSuggest *fileSuggester | |||
| 70 | + | cwd string | |||
| 71 | + | ||||
| 72 | + | // pendingModel / pendingProvider are the model + provider every | |||
| 73 | + | // freshly-spawned agent will be pinned to. They're seeded from | |||
| 74 | + | // the host's currently-active model (via SetCurrentModel) at the | |||
| 75 | + | // moment the user opens the dashboard, so swarm agents inherit | |||
| 76 | + | // whatever the user last picked via /model. Empty means "don't | |||
| 77 | + | // pin a model; let the child resolve its own default". | |||
| 78 | + | pendingModel string | |||
| 79 | + | pendingProvider string | |||
| 80 | + | ||||
| 81 | + | // pickingModel + modelPicker drive the in-spawn-editor /model | |||
| 82 | + | // command: while typing a task into the spawn editor, the user | |||
| 83 | + | // can submit just "/model" to pop the same picker /model uses | |||
| 84 | + | // globally, choose a different model for this spawn, and resume | |||
| 85 | + | // the editor with the new pending model captured. Empty when | |||
| 86 | + | // the picker isn't on screen. | |||
| 87 | + | pickingModel bool | |||
| 88 | + | modelPicker *modelDialog | |||
| 89 | + | modelPickerLoggedIn []string | |||
| 90 | + | ||||
| 91 | + | // spawnDraft remembers the editor buffer when we suspend it to | |||
| 92 | + | // show the model picker, so the user doesn't lose half-typed | |||
| 93 | + | // task text just because they wanted to switch models. Restored | |||
| 94 | + | // when the picker closes (whether by select or Esc). | |||
| 95 | + | spawnDraft string | |||
| 96 | + | ||||
| 97 | + | // prompting toggles the inline "send prompt to selected agent" | |||
| 98 | + | // editor. Distinct from `spawning` so we don't accidentally | |||
| 99 | + | // route a follow-up into Swarm.Spawn. The editor and its | |||
| 100 | + | // file-suggest live in their own fields so opening the prompt | |||
| 101 | + | // editor while a spawn editor is half-typed doesn't clobber it | |||
| 102 | + | // (the dialog enforces one-at-a-time, but the separate state | |||
| 103 | + | // keeps the lifecycle obvious). | |||
| 104 | + | prompting bool | |||
| 105 | + | promptEd *tui.Editor | |||
| 106 | + | promptSuggest *fileSuggester | |||
| 107 | + | // promptTargetID is the agent id captured the moment the user | |||
| 108 | + | // pressed 'p'. We pin it here so the prompt routes to the same | |||
| 109 | + | // agent even if the user's cursor moves (or rows reorder) while | |||
| 110 | + | // they're typing. | |||
| 111 | + | promptTargetID string | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func newSwarmDialog() *swarmDialog { return &swarmDialog{} } | |||
| 115 | + | ||||
| 116 | + | // SetCurrentModel pins the model + provider every fresh spawn will | |||
| 117 | + | // inherit. The host wires this to the same Model / Provider the rest | |||
| 118 | + | // of zot is currently using, so agents started from the dashboard | |||
| 119 | + | // run on whatever the user last picked via /model. Pass empty | |||
| 120 | + | // strings to clear the override (the child then resolves its own | |||
| 121 | + | // default the same way a bare `zot` invocation does). | |||
| 122 | + | func (d *swarmDialog) SetCurrentModel(model, providerID string) { | |||
| 123 | + | d.pendingModel = model | |||
| 124 | + | d.pendingProvider = providerID | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // SetLoggedInProviders supplies the list of providers the user has | |||
| 128 | + | // active credentials for. Forwarded to the embedded modelDialog when | |||
| 129 | + | // the user invokes /model inside the spawn editor so the picker can | |||
| 130 | + | // filter out unauthenticated providers, matching the global /model | |||
| 131 | + | // behaviour. | |||
| 132 | + | func (d *swarmDialog) SetLoggedInProviders(provs []string) { | |||
| 133 | + | d.modelPickerLoggedIn = provs | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // Open binds the dialog to a swarm via the provided callbacks. The | |||
| 137 | + | // dialog refreshes the snapshot every Render so the dashboard reads | |||
| 138 | + | // live state without needing a goroutine. spawn / send are optional; | |||
| 139 | + | // when nil the corresponding inline shortcut ('n' / 'p') is disabled. | |||
| 140 | + | func (d *swarmDialog) Open( | |||
| 141 | + | snapshot func() []swarm.AgentSnapshot, | |||
| 142 | + | stop func(id string) error, | |||
| 143 | + | remove func(id string) error, | |||
| 144 | + | spawn func(task, model, provider string) error, | |||
| 145 | + | send func(id, text string) error, | |||
| 146 | + | resume func(id string) error, | |||
| 147 | + | cwd string, | |||
| 148 | + | ) { | |||
| 149 | + | d.snapshot = snapshot | |||
| 150 | + | d.stop = stop | |||
| 151 | + | d.remove = remove | |||
| 152 | + | d.spawn = spawn | |||
| 153 | + | d.send = send | |||
| 154 | + | d.resume = resume | |||
| 155 | + | d.active = true | |||
| 156 | + | d.cursor = 0 | |||
| 157 | + | d.viewing = false | |||
| 158 | + | d.spawning = false | |||
| 159 | + | d.newTaskEd = nil | |||
| 160 | + | d.fileSuggest = nil | |||
| 161 | + | d.prompting = false | |||
| 162 | + | d.promptEd = nil | |||
| 163 | + | d.promptSuggest = nil | |||
| 164 | + | d.promptTargetID = "" | |||
| 165 | + | // pendingModel / pendingProvider are NOT cleared here: the host | |||
| 166 | + | // sets them via SetCurrentModel just before opening the dialog | |||
| 167 | + | // so the spawn flow inherits whatever /model selected. Resetting | |||
| 168 | + | // here would undo that. | |||
| 169 | + | d.pickingModel = false | |||
| 170 | + | d.modelPicker = nil | |||
| 171 | + | d.spawnDraft = "" | |||
| 172 | + | d.transcriptSpin = nil | |||
| 173 | + | d.cwd = cwd | |||
| 174 | + | d.refresh() | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // CursorPos returns the row/col for the terminal cursor while an | |||
| 178 | + | // inline editor (spawn or prompt) is active so the caret blinks in | |||
| 179 | + | // the right spot. Returns -1, -1 when no input is being captured. | |||
| 180 | + | func (d *swarmDialog) CursorPos(width int) (row, col int) { | |||
| 181 | + | if !d.Active() { | |||
| 182 | + | return -1, -1 | |||
| 183 | + | } | |||
| 184 | + | // Spawn editor (list view): rendered directly under the frame | |||
| 185 | + | // header, optionally preceded by the model banner row (when a | |||
| 186 | + | // model was picked) and the @ file-suggest popup, with one | |||
| 187 | + | // breathing-room blank row inserted just above the editor. The | |||
| 188 | + | // constants below must mirror the spawn-editor render path. | |||
| 189 | + | if d.spawning && d.newTaskEd != nil { | |||
| 190 | + | modelBannerRows := 0 | |||
| 191 | + | if d.pendingModel != "" { | |||
| 192 | + | modelBannerRows = 1 | |||
| 193 | + | } | |||
| 194 | + | popupRows := 0 | |||
| 195 | + | if d.fileSuggest != nil && d.fileSuggest.Active(d.newTaskEd.Value()) { | |||
| 196 | + | popupRows = len(d.fileSuggest.Render(d.newTaskEd.Value(), tui.Theme{}, width)) | |||
| 197 | + | } | |||
| 198 | + | _, eRow, eCol := d.newTaskEd.Render(width - 2) | |||
| 199 | + | const editorBlankRow = 1 | |||
| 200 | + | return 1 + modelBannerRows + popupRows + editorBlankRow + eRow, eCol + 2 | |||
| 201 | + | } | |||
| 202 | + | // Prompt editor cursor placement. Two flavours: | |||
| 203 | + | // 1. List-view modal (`p` from the dashboard list): renderPromptEditor | |||
| 204 | + | // draws frame header + "send to <id>:" hint + popup + editor. | |||
| 205 | + | // 2. Transcript view (always-on editor, /btw-style): the editor | |||
| 206 | + | // lives at the bottom of renderTranscript's output, after the | |||
| 207 | + | // metadata block, the "── transcript ──" rule, the transcript | |||
| 208 | + | // body itself, optional "↑/↓ N more" rows and the @-popup. | |||
| 209 | + | // | |||
| 210 | + | // Both flavours funnel through here. The math must mirror the | |||
| 211 | + | // corresponding render path exactly. | |||
| 212 | + | if d.prompting && d.promptEd != nil { | |||
| 213 | + | popupRows := 0 | |||
| 214 | + | if d.promptSuggest != nil && d.promptSuggest.Active(d.promptEd.Value()) { | |||
| 215 | + | popupRows = len(d.promptSuggest.Render(d.promptEd.Value(), tui.Theme{}, width)) | |||
| 216 | + | } | |||
| 217 | + | _, eRow, eCol := d.promptEd.Render(width - 2) | |||
| 218 | + | ||||
| 219 | + | if d.viewing { | |||
| 220 | + | return d.transcriptEditorCursorRow(width, popupRows, eRow), eCol + 2 | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // List-view modal layout: frame header + "send to <id>:" hint | |||
| 224 | + | // + popup + blank + editor. | |||
| 225 | + | const headerRows = 1 | |||
| 226 | + | const sendToHintRow = 1 | |||
| 227 | + | const editorBlankRow = 1 | |||
| 228 | + | return headerRows + sendToHintRow + popupRows + editorBlankRow + eRow, eCol + 2 | |||
| 229 | + | } | |||
| 230 | + | return -1, -1 | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | // transcriptEditorCursorRow computes the visual row the caret | |||
| 234 | + | // belongs on when the always-on editor sits at the bottom of the | |||
| 235 | + | // transcript view. Mirrors renderTranscript's row layout so the | |||
| 236 | + | // terminal cursor stays glued to the editor as the body grows. | |||
| 237 | + | func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffset int) int { | |||
| 238 | + | a := d.selected() | |||
| 239 | + | if a == nil { | |||
| 240 | + | return -1 | |||
| 241 | + | } | |||
| 242 | + | row := 1 // frame header | |||
| 243 | + | row += 4 // task / branch / dir / status | |||
| 244 | + | if a.Model != "" { | |||
| 245 | + | row++ | |||
| 246 | + | } | |||
| 247 | + | if a.Err != "" { | |||
| 248 | + | row++ | |||
| 249 | + | } | |||
| 250 | + | row += 3 // blank + "── transcript ──" + trailing blank | |||
| 251 | + | ||||
| 252 | + | // Replay transcript body sizing (no-transcript-yet path vs. | |||
| 253 | + | // real body path) so the editor row count matches what render | |||
| 254 | + | // actually emitted. | |||
| 255 | + | raw := a.Lines | |||
| 256 | + | if len(raw) == 0 && a.Tail != "" { | |||
| 257 | + | raw = strings.Split(a.Tail, "\n") | |||
| 258 | + | } | |||
| 259 | + | if len(raw) == 0 { | |||
| 260 | + | row++ // "(no transcript yet)" line | |||
| 261 | + | } else { | |||
| 262 | + | // Full body, no scroll window — the host's outer scrollback | |||
| 263 | + | // owns overflow now. Cursor row math just counts every | |||
| 264 | + | // rendered line. | |||
| 265 | + | body := renderSwarmTranscriptBlocks(raw, tui.Theme{}, width) | |||
| 266 | + | row += len(body) | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | row += popupRows // @-file-suggest popup, if active | |||
| 270 | + | if agentIsBusy(a) { | |||
| 271 | + | row += 2 // blank + spinner row inserted above the editor | |||
| 272 | + | } | |||
| 273 | + | row++ // blank above editor (appendTranscriptEditor) | |||
| 274 | + | return row + editorRowOffset | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | func (d *swarmDialog) Active() bool { return d != nil && d.active } | |||
| 278 | + | func (d *swarmDialog) Close() { d.active = false } | |||
| 279 | + | ||||
| 280 | + | // NeedsTickRefresh reports whether the host's animation tick should | |||
| 281 | + | // repaint the screen while this dialog is open. True when the | |||
| 282 | + | // dashboard's list/transcript view is on (rows / activity / age and | |||
| 283 | + | // the transcript itself are background-driven and need a periodic | |||
| 284 | + | // redraw — essential for streaming agent replies to actually appear | |||
| 285 | + | // on screen). False only while a modal editor (spawn, list-view | |||
| 286 | + | // prompt, model picker) has the dialog to itself; those need the | |||
| 287 | + | // terminal's cursor blink, which a full-frame redraw cancels. | |||
| 288 | + | // | |||
| 289 | + | // Note: the transcript view's always-on editor does NOT suppress | |||
| 290 | + | // tick refresh. The cursor blink still works because the tick rate | |||
| 291 | + | // is well below the terminal's blink rate, and we need the redraws | |||
| 292 | + | // for streaming output. /btw makes the same trade-off. | |||
| 293 | + | func (d *swarmDialog) NeedsTickRefresh() bool { | |||
| 294 | + | if d == nil || !d.active { | |||
| 295 | + | return false | |||
| 296 | + | } | |||
| 297 | + | if d.spawning || d.pickingModel { | |||
| 298 | + | return false | |||
| 299 | + | } | |||
| 300 | + | // The list-view `p` modal (prompting without viewing) suppresses | |||
| 301 | + | // tick like the other modal editors. The transcript-view | |||
| 302 | + | // always-on editor (viewing && prompting) does not — streaming | |||
| 303 | + | // agent output needs periodic redraws to render. | |||
| 304 | + | if d.prompting && !d.viewing { | |||
| 305 | + | return false | |||
| 306 | + | } | |||
| 307 | + | return true | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | // OpenViewing opens the dashboard and jumps straight to the | |||
| 311 | + | // transcript view for the agent with id agentID. Used by /swarm logs | |||
| 312 | + | // <id>. If no such agent is found the dashboard opens normally so | |||
| 313 | + | // the user can see why (and pick from the list). | |||
| 314 | + | func (d *swarmDialog) OpenViewing( | |||
| 315 | + | agentID string, | |||
| 316 | + | snapshot func() []swarm.AgentSnapshot, | |||
| 317 | + | stop func(id string) error, | |||
| 318 | + | remove func(id string) error, | |||
| 319 | + | spawn func(task, model, provider string) error, | |||
| 320 | + | send func(id, text string) error, | |||
| 321 | + | resume func(id string) error, | |||
| 322 | + | cwd string, | |||
| 323 | + | ) bool { | |||
| 324 | + | d.Open(snapshot, stop, remove, spawn, send, resume, cwd) | |||
| 325 | + | rows := d.snapshot() | |||
| 326 | + | for i, r := range rows { | |||
| 327 | + | if r.ID == agentID || strings.HasPrefix(r.ID, agentID) { | |||
| 328 | + | d.cursor = i | |||
| 329 | + | d.viewing = true | |||
| 330 | + | // Auto-open the inline editor for running agents so | |||
| 331 | + | // /swarm logs <id> drops the user straight into a | |||
| 332 | + | // /btw-style send-ready prompt. | |||
| 333 | + | if r.Status == swarm.StatusRunning && d.send != nil { | |||
| 334 | + | d.openPromptEditor(tui.Theme{}, r.ID) | |||
| 335 | + | } | |||
| 336 | + | return true | |||
| 337 | + | } | |||
| 338 | + | } | |||
| 339 | + | return false | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | // OpenForResume opens the dashboard and parks the cursor on the first | |||
| 343 | + | // resumable agent (any non-running status), so the user can press R | |||
| 344 | + | // to resume without typing an id. Returns the count of resumable | |||
| 345 | + | // agents found — callers surface that as "3 resumable, press R" or, | |||
| 346 | + | // when zero, "no agents available to resume". | |||
| 347 | + | // | |||
| 348 | + | // The dialog still shows the full list (live + detached + terminated) | |||
| 349 | + | // so the user has context: pressing ↑/↓ they can still walk through | |||
| 350 | + | // running agents, and the R key is a no-op there (Resume rejects | |||
| 351 | + | // non-terminal agents with a clear error). | |||
| 352 | + | func (d *swarmDialog) OpenForResume( | |||
| 353 | + | snapshot func() []swarm.AgentSnapshot, | |||
| 354 | + | stop func(id string) error, | |||
| 355 | + | remove func(id string) error, | |||
| 356 | + | spawn func(task, model, provider string) error, | |||
| 357 | + | send func(id, text string) error, | |||
| 358 | + | resume func(id string) error, | |||
| 359 | + | cwd string, | |||
| 360 | + | ) (resumable int) { | |||
| 361 | + | d.Open(snapshot, stop, remove, spawn, send, resume, cwd) | |||
| 362 | + | // d.rows was populated by Open's refresh(); walk it for the | |||
| 363 | + | // first resumable row and count the total in one pass. | |||
| 364 | + | for i, r := range d.rows { | |||
| 365 | + | if isResumable(r.Status) { | |||
| 366 | + | if resumable == 0 { | |||
| 367 | + | d.cursor = i | |||
| 368 | + | } | |||
| 369 | + | resumable++ | |||
| 370 | + | } | |||
| 371 | + | } | |||
| 372 | + | return resumable | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | // isResumable reports whether a Status is eligible for Resume. Mirror | |||
| 376 | + | // of the precondition inside Swarm.Resume; centralised so the dialog | |||
| 377 | + | // and the slash handler agree on what "resumable" means. | |||
| 378 | + | func isResumable(s swarm.Status) bool { | |||
| 379 | + | switch s { | |||
| 380 | + | case swarm.StatusDetached, | |||
| 381 | + | swarm.StatusDone, | |||
| 382 | + | swarm.StatusFailed, | |||
| 383 | + | swarm.StatusKilled: | |||
| 384 | + | return true | |||
| 385 | + | } | |||
| 386 | + | return false | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | // promptDisabledHint returns the status-bar message shown when the | |||
| 390 | + | // user presses 'p' on an agent that can't receive input. We branch on | |||
| 391 | + | // status so the hint points at the right next step: resume for the | |||
| 392 | + | // states Swarm.Resume accepts; nothing useful otherwise. | |||
| 393 | + | func promptDisabledHint(s swarm.Status) string { | |||
| 394 | + | if isResumable(s) { | |||
| 395 | + | return "prompt: agent is " + string(s) + "; press R to resume first" | |||
| 396 | + | } | |||
| 397 | + | return "prompt: agent is " + string(s) + " and can't receive input" | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | // killDisabledHint mirrors promptDisabledHint for the 'k' shortcut. | |||
| 401 | + | // Kill only makes sense on running / pending agents; on detached and | |||
| 402 | + | // terminal ones it's a no-op and the user usually wants 'r' (remove) | |||
| 403 | + | // to clean up the worktree instead. | |||
| 404 | + | func killDisabledHint(s swarm.Status) string { | |||
| 405 | + | return "kill: agent is " + string(s) + "; nothing to stop (press r to remove)" | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | // friendlySendErr turns a raw send error into a status-bar message | |||
| 409 | + | // the user can act on. The most common failure mode — dialling an | |||
| 410 | + | // agent whose previous subprocess exited — surfaces as | |||
| 411 | + | // swarm.ErrNotReady; we rewrite it to mention resume instead of | |||
| 412 | + | // leaking the unix-socket path. Other errors fall through verbatim | |||
| 413 | + | // because they're rare and almost always indicate a real bug. | |||
| 414 | + | func friendlySendErr(id string, err error) string { | |||
| 415 | + | if errors.Is(err, swarm.ErrNotReady) { | |||
| 416 | + | return "send: agent " + id + " isn't accepting input (press R to resume)" | |||
| 417 | + | } | |||
| 418 | + | return "send: " + err.Error() | |||
| 419 | + | } | |||
| 420 | + | ||||
| 421 | + | func (d *swarmDialog) refresh() { | |||
| 422 | + | if d.snapshot == nil { | |||
| 423 | + | d.rows = nil | |||
| 424 | + | return | |||
| 425 | + | } | |||
| 426 | + | d.rows = d.snapshot() | |||
| 427 | + | if d.cursor >= len(d.rows) { | |||
| 428 | + | d.cursor = len(d.rows) - 1 | |||
| 429 | + | } | |||
| 430 | + | if d.cursor < 0 { | |||
| 431 | + | d.cursor = 0 | |||
| 432 | + | } | |||
| 433 | + | } | |||
| 434 | + | ||||
| 435 | + | // HandleKey advances state. Returns (closed, statusMsg, statusErr). | |||
| 436 | + | // statusMsg / statusErr surface in the host's status bar so the user | |||
| 437 | + | // gets feedback on kill/remove without writing to the chat. | |||
| 438 | + | func (d *swarmDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) { | |||
| 439 | + | if !d.Active() { | |||
| 440 | + | return false, "", "" | |||
| 441 | + | } | |||
| 442 | + | d.refresh() | |||
| 443 | + | ||||
| 444 | + | // Inline editors own key handling. They run before the other | |||
| 445 | + | // modes so the user's typed letters aren't interpreted as 'k' | |||
| 446 | + | // (kill), 'p' (prompt), etc. | |||
| 447 | + | // | |||
| 448 | + | // Transcript view is a special case: the editor is always on, | |||
| 449 | + | // but we still need transcript-level keys (PageUp/PageDown/ | |||
| 450 | + | // Home/End scroll, Esc close) to take precedence over the | |||
| 451 | + | // editor. handleViewingKey owns that routing and delegates | |||
| 452 | + | // editor input to handlePromptKey itself. | |||
| 453 | + | if d.viewing { | |||
| 454 | + | return d.handleViewingKey(k) | |||
| 455 | + | } | |||
| 456 | + | if d.prompting { | |||
| 457 | + | return d.handlePromptKey(k) | |||
| 458 | + | } | |||
| 459 | + | if d.pickingModel { | |||
| 460 | + | return d.handleModelPickerKey(k) | |||
| 461 | + | } | |||
| 462 | + | if d.spawning { | |||
| 463 | + | return d.handleSpawnKey(k) | |||
| 464 | + | } | |||
| 465 | + | ||||
| 466 | + | switch k.Kind { | |||
| 467 | + | case tui.KeyEsc: | |||
| 468 | + | d.Close() | |||
| 469 | + | return true, "", "" | |||
| 470 | + | case tui.KeyUp: | |||
| 471 | + | if d.cursor > 0 { | |||
| 472 | + | d.cursor-- | |||
| 473 | + | } | |||
| 474 | + | case tui.KeyDown: | |||
| 475 | + | if d.cursor < len(d.rows)-1 { | |||
| 476 | + | d.cursor++ | |||
| 477 | + | } | |||
| 478 | + | case tui.KeyEnter: | |||
| 479 | + | if len(d.rows) > 0 { | |||
| 480 | + | d.viewing = true | |||
| 481 | + | // Auto-open the inline editor (à la /btw) so the user | |||
| 482 | + | // can start typing immediately. Skipped for non-running | |||
| 483 | + | // agents; their inbox isn't listening, so an editor | |||
| 484 | + | // would just frustrate them with bounced sends. | |||
| 485 | + | if a := d.selected(); a != nil && a.Status == swarm.StatusRunning && d.send != nil { | |||
| 486 | + | d.openPromptEditor(tui.Theme{}, a.ID) | |||
| 487 | + | } | |||
| 488 | + | } | |||
| 489 | + | case tui.KeyRune: | |||
| 490 | + | switch k.Rune { | |||
| 491 | + | case 'q': | |||
| 492 | + | d.Close() | |||
| 493 | + | return true, "", "" | |||
| 494 | + | case 'n': | |||
| 495 | + | if d.spawn != nil { | |||
| 496 | + | // Open the task editor directly. The model the new | |||
| 497 | + | // agent will run against was already captured via | |||
| 498 | + | // SetCurrentModel from the host's /model selection; | |||
| 499 | + | // the user changes models the normal way (/model) | |||
| 500 | + | // before opening /swarm. | |||
| 501 | + | d.openSpawnEditor(tui.Theme{}) | |||
| 502 | + | } | |||
| 503 | + | case 'p': | |||
| 504 | + | if a := d.selected(); a != nil && d.send != nil { | |||
| 505 | + | // Only running agents have a live inbox listener; | |||
| 506 | + | // dialling the unix socket on a detached / done / | |||
| 507 | + | // failed / killed agent fails with a long ECONNREFUSED | |||
| 508 | + | // or ENOENT trail that's not actionable. Tell the user | |||
| 509 | + | // what to do instead. | |||
| 510 | + | if a.Status != swarm.StatusRunning { | |||
| 511 | + | return false, "", promptDisabledHint(a.Status) | |||
| 512 | + | } | |||
| 513 | + | d.openPromptEditor(tui.Theme{}, a.ID) | |||
| 514 | + | } | |||
| 515 | + | case 'R': | |||
| 516 | + | // Capital R for resume; lowercase r is already taken by | |||
| 517 | + | // remove. Using a different keystroke avoids a confirm | |||
| 518 | + | // modal for an action that's already destructive (resume | |||
| 519 | + | // is reversible, so we don't need the shift-as-guard | |||
| 520 | + | // dance lowercase r has historically warranted). | |||
| 521 | + | if a := d.selected(); a != nil && d.resume != nil { | |||
| 522 | + | if err := d.resume(a.ID); err != nil { | |||
| 523 | + | return false, "", "resume: " + err.Error() | |||
| 524 | + | } | |||
| 525 | + | return false, "resumed " + a.ID, "" | |||
| 526 | + | } | |||
| 527 | + | case 'k': | |||
| 528 | + | if a := d.selected(); a != nil && d.stop != nil { | |||
| 529 | + | // Only running / pending agents have something to | |||
| 530 | + | // cancel. Detached and already-terminal agents would | |||
| 531 | + | // be silent no-ops at the Swarm layer; surface a hint | |||
| 532 | + | // so the user knows kill did nothing and what to do | |||
| 533 | + | // instead (remove via 'r'). | |||
| 534 | + | if a.Status != swarm.StatusRunning && a.Status != swarm.StatusPending { | |||
| 535 | + | return false, "", killDisabledHint(a.Status) | |||
| 536 | + | } | |||
| 537 | + | if err := d.stop(a.ID); err != nil { | |||
| 538 | + | return false, "", "stop: " + err.Error() | |||
| 539 | + | } | |||
| 540 | + | return false, "stopped " + a.ID, "" | |||
| 541 | + | } | |||
| 542 | + | case 'r': | |||
| 543 | + | if a := d.selected(); a != nil && d.remove != nil { | |||
| 544 | + | if err := d.remove(a.ID); err != nil { | |||
| 545 | + | return false, "", "remove: " + err.Error() | |||
| 546 | + | } | |||
| 547 | + | return false, "removed " + a.ID, "" | |||
| 548 | + | } | |||
| 549 | + | } | |||
| 550 | + | } | |||
| 551 | + | return false, "", "" | |||
| 552 | + | } | |||
| 553 | + | ||||
| 554 | + | // handleViewingKey routes one keystroke while the transcript view | |||
| 555 | + | // is on screen. The transcript view runs /btw-style: an inline | |||
| 556 | + | // editor is always pinned at the bottom, the transcript flows in | |||
| 557 | + | // full above it, and the host's outer scrollback handles overflow. | |||
| 558 | + | // There is no internal scroll, so this handler is small: | |||
| 559 | + | // | |||
| 560 | + | // - Esc closes the transcript view (back to the dashboard list). | |||
| 561 | + | // If the user was in the middle of an @-file-pick we let the | |||
| 562 | + | // editor handler eat the Esc first so it just cancels the popup. | |||
| 563 | + | // | |||
| 564 | + | // Every other key is forwarded to handlePromptKey, which owns the | |||
| 565 | + | // editor itself (typed runes, Enter to submit, Backspace, file | |||
| 566 | + | // suggest, arrow keys for caret movement, etc.). Submits stay in | |||
| 567 | + | // the view and clear the editor for the next message instead of | |||
| 568 | + | // closing it — see handlePromptKey for the always-on behaviour. | |||
| 569 | + | func (d *swarmDialog) handleViewingKey(k tui.Key) (closed bool, msg, errMsg string) { | |||
| 570 | + | a := d.selected() | |||
| 571 | + | ||||
| 572 | + | // Lazy-open the editor on first key into the transcript view, so | |||
| 573 | + | // re-entering an agent after switching away picks up cleanly. | |||
| 574 | + | // Skipped for non-running agents (their inbox isn't listening; | |||
| 575 | + | // sending would just bounce). | |||
| 576 | + | if a != nil && a.Status == swarm.StatusRunning && d.send != nil && !d.prompting { | |||
| 577 | + | d.openPromptEditor(tui.Theme{}, a.ID) | |||
| 578 | + | } | |||
| 579 | + | ||||
| 580 | + | // Esc closes the transcript view, regardless of whether the | |||
| 581 | + | // editor is open. Handled here (not in the editor handler) so | |||
| 582 | + | // the user gets a consistent "esc backs out one level" feel. | |||
| 583 | + | // The file-suggest popup, if active, gets to eat the Esc first | |||
| 584 | + | // inside handlePromptKey — we route there and check whether the | |||
| 585 | + | // editor consumed it. | |||
| 586 | + | if k.Kind == tui.KeyEsc { | |||
| 587 | + | if d.prompting && d.promptSuggest != nil && d.promptEd != nil && d.promptSuggest.Active(d.promptEd.Value()) { | |||
| 588 | + | // Let the editor's @-suggest popup handle the Esc to | |||
| 589 | + | // dismiss itself instead of collapsing the whole view. | |||
| 590 | + | return d.handlePromptKey(k) | |||
| 591 | + | } | |||
| 592 | + | d.viewing = false | |||
| 593 | + | d.closePromptEditor() | |||
| 594 | + | return false, "", "" | |||
| 595 | + | } | |||
| 596 | + | ||||
| 597 | + | // Everything else — typed runes, Enter, Backspace, arrow keys | |||
| 598 | + | // for caret movement, @-file picker — is editor input. If the | |||
| 599 | + | // agent isn't running there's no live editor; surface a hint. | |||
| 600 | + | if a == nil || a.Status != swarm.StatusRunning || d.send == nil { | |||
| 601 | + | if k.Kind == tui.KeyRune && a != nil { | |||
| 602 | + | return false, "", promptDisabledHint(a.Status) | |||
| 603 | + | } | |||
| 604 | + | return false, "", "" | |||
| 605 | + | } | |||
| 606 | + | return d.handlePromptKey(k) | |||
| 607 | + | } | |||
| 608 | + | ||||
| 609 | + | // openSpawnEditor enters the inline new-agent prompt. It deliberately | |||
| 610 | + | // uses tui.Editor (not a one-line string) so swarm-new supports the same | |||
| 611 | + | // editing model as the normal input: multiline paste collapse, drag-drop | |||
| 612 | + | // file/dir chips, cursor movement, and the same accent-bar prompt style. | |||
| 613 | + | func (d *swarmDialog) openSpawnEditor(th tui.Theme) { | |||
| 614 | + | d.spawning = true | |||
| 615 | + | d.newTaskEd = tui.NewEditor(th.AccentBar(th.Accent)) | |||
| 616 | + | d.fileSuggest = newFileSuggester() | |||
| 617 | + | d.fileSuggest.SetCWD(d.cwd) | |||
| 618 | + | if d.spawnDraft != "" { | |||
| 619 | + | d.newTaskEd.SetValue(d.spawnDraft) | |||
| 620 | + | } | |||
| 621 | + | } | |||
| 622 | + | ||||
| 623 | + | // openModelPicker suspends the spawn editor and shows the same | |||
| 624 | + | // model dialog the global /model uses. Triggered when the user | |||
| 625 | + | // submits "/model" from inside the spawn editor; on select we | |||
| 626 | + | // capture the new pendingModel/Provider and reopen the spawn | |||
| 627 | + | // editor with the previously-typed task buffer restored. | |||
| 628 | + | func (d *swarmDialog) openModelPicker() { | |||
| 629 | + | d.spawning = false | |||
| 630 | + | d.newTaskEd = nil | |||
| 631 | + | d.fileSuggest = nil | |||
| 632 | + | d.pickingModel = true | |||
| 633 | + | d.modelPicker = newModelDialog() | |||
| 634 | + | d.modelPicker.Open(d.pendingModel, d.modelPickerLoggedIn) | |||
| 635 | + | } | |||
| 636 | + | ||||
| 637 | + | // handleModelPickerKey forwards one keystroke to the embedded model | |||
| 638 | + | // picker and, on select or close, reopens the spawn editor so the | |||
| 639 | + | // user can continue composing the task. A selection captures the new | |||
| 640 | + | // pendingModel + pendingProvider; Esc leaves them unchanged (the | |||
| 641 | + | // user gets to keep whatever was inherited before they invoked | |||
| 642 | + | // /model). | |||
| 643 | + | func (d *swarmDialog) handleModelPickerKey(k tui.Key) (closed bool, msg, errMsg string) { | |||
| 644 | + | if d.modelPicker == nil { | |||
| 645 | + | d.openModelPicker() | |||
| 646 | + | } | |||
| 647 | + | act := d.modelPicker.HandleKey(k) | |||
| 648 | + | switch { | |||
| 649 | + | case act.Select: | |||
| 650 | + | d.pendingModel = act.Model | |||
| 651 | + | d.pendingProvider = act.Provider | |||
| 652 | + | d.pickingModel = false | |||
| 653 | + | d.modelPicker = nil | |||
| 654 | + | d.openSpawnEditor(tui.Theme{}) | |||
| 655 | + | return false, "model: " + act.Model, "" | |||
| 656 | + | case act.Close: | |||
| 657 | + | d.pickingModel = false | |||
| 658 | + | d.modelPicker = nil | |||
| 659 | + | d.openSpawnEditor(tui.Theme{}) | |||
| 660 | + | return false, "", "" | |||
| 661 | + | } | |||
| 662 | + | return false, "", "" | |||
| 663 | + | } | |||
| 664 | + | ||||
| 665 | + | // containsSlashModelLine reports whether any line in buf has trimmed | |||
| 666 | + | // content of "/model" (with optional trailing args). Used to decide | |||
| 667 | + | // whether to treat a spawn-editor submit as a model-picker command | |||
| 668 | + | // vs. as a task description. | |||
| 669 | + | func containsSlashModelLine(buf string) bool { | |||
| 670 | + | for _, ln := range strings.Split(buf, "\n") { | |||
| 671 | + | t := strings.TrimSpace(ln) | |||
| 672 | + | if t == "/model" || strings.HasPrefix(t, "/model ") { | |||
| 673 | + | return true | |||
| 674 | + | } | |||
| 675 | + | } | |||
| 676 | + | return false | |||
| 677 | + | } | |||
| 678 | + | ||||
| 679 | + | // stripSlashModelLine removes the line containing "/model" (with | |||
| 680 | + | // optional trailing args) from the spawn-editor buffer so the | |||
| 681 | + | // remainder can be restored after the picker closes. The /model line | |||
| 682 | + | // is always exactly one full line in practice — the user typed | |||
| 683 | + | // /model<Enter> — but we tolerate inline placement at the start of | |||
| 684 | + | // a line just in case. | |||
| 685 | + | func stripSlashModelLine(buf string) string { | |||
| 686 | + | lines := strings.Split(buf, "\n") | |||
| 687 | + | out := lines[:0] | |||
| 688 | + | for _, ln := range lines { | |||
| 689 | + | t := strings.TrimSpace(ln) | |||
| 690 | + | if t == "/model" || strings.HasPrefix(t, "/model ") { | |||
| 691 | + | continue | |||
| 692 | + | } | |||
| 693 | + | out = append(out, ln) | |||
| 694 | + | } | |||
| 695 | + | return strings.TrimRight(strings.Join(out, "\n"), "\n") | |||
| 696 | + | } | |||
| 697 | + | ||||
| 698 | + | // openPromptEditor enters the inline "send a follow-up prompt" editor | |||
| 699 | + | // for the agent with id targetID. Mirrors openSpawnEditor so the | |||
| 700 | + | // editing experience (paste, @-picker, drop chips) is identical. | |||
| 701 | + | func (d *swarmDialog) openPromptEditor(th tui.Theme, targetID string) { | |||
| 702 | + | d.prompting = true | |||
| 703 | + | d.promptEd = tui.NewEditor(th.AccentBar(th.Accent)) | |||
| 704 | + | d.promptSuggest = newFileSuggester() | |||
| 705 | + | d.promptSuggest.SetCWD(d.cwd) | |||
| 706 | + | d.promptTargetID = targetID | |||
| 707 | + | } | |||
| 708 | + | ||||
| 709 | + | // handlePromptKey owns the keystrokes while the inline prompt editor | |||
| 710 | + | // is open. It mirrors handleSpawnKey but, on submit, invokes d.send | |||
| 711 | + | // with the captured target id instead of d.spawn. Esc cancels and | |||
| 712 | + | // returns the user to whichever view (list or transcript) was active | |||
| 713 | + | // when 'p' was pressed. | |||
| 714 | + | func (d *swarmDialog) handlePromptKey(k tui.Key) (closed bool, msg, errMsg string) { | |||
| 715 | + | if d.promptEd == nil { | |||
| 716 | + | d.openPromptEditor(tui.Theme{}, d.promptTargetID) | |||
| 717 | + | } | |||
| 718 | + | ed := d.promptEd | |||
| 719 | + | fs := d.promptSuggest | |||
| 720 | + | if fs != nil { | |||
| 721 | + | fs.SetCWD(d.cwd) | |||
| 722 | + | } | |||
| 723 | + | ||||
| 724 | + | // File suggestions: same keys and chip shape as the default input. | |||
| 725 | + | if fs != nil && fs.Active(ed.Value()) { | |||
| 726 | + | switch k.Kind { | |||
| 727 | + | case tui.KeyUp: | |||
| 728 | + | fs.Up() | |||
| 729 | + | return false, "", "" | |||
| 730 | + | case tui.KeyDown: | |||
| 731 | + | fs.Down() | |||
| 732 | + | return false, "", "" | |||
| 733 | + | case tui.KeyRight: | |||
| 734 | + | fs.Right() | |||
| 735 | + | return false, "", "" | |||
| 736 | + | case tui.KeyLeft: | |||
| 737 | + | fs.Left() | |||
| 738 | + | return false, "", "" | |||
| 739 | + | case tui.KeyEnter: | |||
| 740 | + | if entry, ok := fs.SelectedEntry(ed.Value()); ok { | |||
| 741 | + | chip := "[file:" + entry.rel + "]" | |||
| 742 | + | if entry.isDir { | |||
| 743 | + | chip = "[dir:" + entry.rel + "/]" | |||
| 744 | + | } | |||
| 745 | + | val := ed.Value() | |||
| 746 | + | if idx := strings.LastIndex(val, "@"); idx >= 0 { | |||
| 747 | + | val = val[:idx] | |||
| 748 | + | } | |||
| 749 | + | ed.SetValue(val + chip + " ") | |||
| 750 | + | fs.Reset() | |||
| 751 | + | } | |||
| 752 | + | return false, "", "" | |||
| 753 | + | case tui.KeyEsc: | |||
| 754 | + | val := ed.Value() | |||
| 755 | + | if idx := strings.LastIndex(val, "@"); idx >= 0 { | |||
| 756 | + | ed.SetValue(val[:idx]) | |||
| 757 | + | } | |||
| 758 | + | fs.Reset() | |||
| 759 | + | return false, "", "" | |||
| 760 | + | } | |||
| 761 | + | } | |||
| 762 | + | ||||
| 763 | + | // Esc cancels the prompt editor when the file picker isn't active. | |||
| 764 | + | if k.Kind == tui.KeyEsc { | |||
| 765 | + | d.closePromptEditor() | |||
| 766 | + | return false, "", "" | |||
| 767 | + | } | |||
| 768 | + | ||||
| 769 | + | if submitted := ed.HandleKey(k); submitted { | |||
| 770 | + | text := strings.TrimRight(ed.SubmitValue(), "\n") | |||
| 771 | + | text = expandFileChips(text, d.cwd) | |||
| 772 | + | text = strings.TrimSpace(text) | |||
| 773 | + | targetID := d.promptTargetID | |||
| 774 | + | ||||
| 775 | + | // Empty submit: nothing to send. If we're in transcript | |||
| 776 | + | // view (always-on editor), just clear and stay; otherwise | |||
| 777 | + | // (list-view-p flow) close the modal. | |||
| 778 | + | if text == "" { | |||
| 779 | + | if d.viewing { | |||
| 780 | + | ed.Clear() | |||
| 781 | + | return false, "", "" | |||
| 782 | + | } | |||
| 783 | + | d.closePromptEditor() | |||
| 784 | + | return false, "", "" | |||
| 785 | + | } | |||
| 786 | + | if d.send == nil { | |||
| 787 | + | if !d.viewing { | |||
| 788 | + | d.closePromptEditor() | |||
| 789 | + | } | |||
| 790 | + | return false, "", "send not wired" | |||
| 791 | + | } | |||
| 792 | + | if targetID == "" { | |||
| 793 | + | if !d.viewing { | |||
| 794 | + | d.closePromptEditor() | |||
| 795 | + | } | |||
| 796 | + | return false, "", "send: no target agent" | |||
| 797 | + | } | |||
| 798 | + | if err := d.send(targetID, text); err != nil { | |||
| 799 | + | if !d.viewing { | |||
| 800 | + | d.closePromptEditor() | |||
| 801 | + | } | |||
| 802 | + | return false, "", friendlySendErr(targetID, err) | |||
| 803 | + | } | |||
| 804 | + | ||||
| 805 | + | // In transcript view (always-on editor) keep the editor | |||
| 806 | + | // mounted and clear the buffer for the next message; in the | |||
| 807 | + | // list-view modal flow, close it so the user returns to the | |||
| 808 | + | // list. Either way, snap the transcript to the tail so the | |||
| 809 | + | // user sees their just-sent message and the agent's reply. | |||
| 810 | + | if d.viewing { | |||
| 811 | + | ed.Clear() | |||
| 812 | + | } else { | |||
| 813 | + | d.closePromptEditor() | |||
| 814 | + | } | |||
| 815 | + | return false, "sent to " + targetID, "" | |||
| 816 | + | } | |||
| 817 | + | return false, "", "" | |||
| 818 | + | } | |||
| 819 | + | ||||
| 820 | + | // closePromptEditor resets all prompt-editor state. Called on Esc and | |||
| 821 | + | // after submit so a subsequent 'p' starts from a clean slate. | |||
| 822 | + | func (d *swarmDialog) closePromptEditor() { | |||
| 823 | + | d.prompting = false | |||
| 824 | + | d.promptEd = nil | |||
| 825 | + | d.promptSuggest = nil | |||
| 826 | + | d.promptTargetID = "" | |||
| 827 | + | } | |||
| 828 | + | ||||
| 829 | + | // handleSpawnKey owns the keystrokes while the inline new-agent editor | |||
| 830 | + | // is open. It mirrors the normal input's @ file-picker path and submit | |||
| 831 | + | // expansion, then asks the host to spawn. | |||
| 832 | + | func (d *swarmDialog) handleSpawnKey(k tui.Key) (closed bool, msg, errMsg string) { | |||
| 833 | + | if d.newTaskEd == nil { | |||
| 834 | + | d.openSpawnEditor(tui.Theme{}) | |||
| 835 | + | } | |||
| 836 | + | ed := d.newTaskEd | |||
| 837 | + | fs := d.fileSuggest | |||
| 838 | + | if fs != nil { | |||
| 839 | + | fs.SetCWD(d.cwd) | |||
| 840 | + | } | |||
| 841 | + | ||||
| 842 | + | // File suggestions: same keys and chip shape as the default input. | |||
| 843 | + | if fs != nil && fs.Active(ed.Value()) { | |||
| 844 | + | switch k.Kind { | |||
| 845 | + | case tui.KeyUp: | |||
| 846 | + | fs.Up() | |||
| 847 | + | return false, "", "" | |||
| 848 | + | case tui.KeyDown: | |||
| 849 | + | fs.Down() | |||
| 850 | + | return false, "", "" | |||
| 851 | + | case tui.KeyRight: | |||
| 852 | + | fs.Right() | |||
| 853 | + | return false, "", "" | |||
| 854 | + | case tui.KeyLeft: | |||
| 855 | + | fs.Left() | |||
| 856 | + | return false, "", "" | |||
| 857 | + | case tui.KeyEnter: | |||
| 858 | + | if entry, ok := fs.SelectedEntry(ed.Value()); ok { | |||
| 859 | + | chip := "[file:" + entry.rel + "]" | |||
| 860 | + | if entry.isDir { | |||
| 861 | + | chip = "[dir:" + entry.rel + "/]" | |||
| 862 | + | } | |||
| 863 | + | val := ed.Value() | |||
| 864 | + | if idx := strings.LastIndex(val, "@"); idx >= 0 { | |||
| 865 | + | val = val[:idx] | |||
| 866 | + | } | |||
| 867 | + | ed.SetValue(val + chip + " ") | |||
| 868 | + | fs.Reset() | |||
| 869 | + | } | |||
| 870 | + | return false, "", "" | |||
| 871 | + | case tui.KeyEsc: | |||
| 872 | + | val := ed.Value() | |||
| 873 | + | if idx := strings.LastIndex(val, "@"); idx >= 0 { | |||
| 874 | + | ed.SetValue(val[:idx]) | |||
| 875 | + | } | |||
| 876 | + | fs.Reset() | |||
| 877 | + | return false, "", "" | |||
| 878 | + | } | |||
| 879 | + | } | |||
| 880 | + | ||||
| 881 | + | // Esc cancels the spawn prompt when the file picker isn't active. | |||
| 882 | + | if k.Kind == tui.KeyEsc { | |||
| 883 | + | d.spawning = false | |||
| 884 | + | d.newTaskEd = nil | |||
| 885 | + | d.fileSuggest = nil | |||
| 886 | + | return false, "", "" | |||
| 887 | + | } | |||
| 888 | + | ||||
| 889 | + | if submitted := ed.HandleKey(k); submitted { | |||
| 890 | + | raw := strings.TrimRight(ed.SubmitValue(), "\n") | |||
| 891 | + | ||||
| 892 | + | // In-editor /model command: when the buffer contains a line | |||
| 893 | + | // whose trimmed content is "/model" (anywhere — typically on | |||
| 894 | + | // its own line, but a trailing /model after a task works | |||
| 895 | + | // too), pop the same picker the global /model uses, drop the | |||
| 896 | + | // /model line, and preserve the rest of the buffer so the | |||
| 897 | + | // user can keep composing once they've picked. | |||
| 898 | + | if containsSlashModelLine(raw) { | |||
| 899 | + | d.spawnDraft = stripSlashModelLine(raw) | |||
| 900 | + | d.openModelPicker() | |||
| 901 | + | return false, "", "" | |||
| 902 | + | } | |||
| 903 | + | ||||
| 904 | + | task := expandFileChips(raw, d.cwd) | |||
| 905 | + | task = strings.TrimSpace(task) | |||
| 906 | + | model := d.pendingModel | |||
| 907 | + | provider := d.pendingProvider | |||
| 908 | + | d.spawning = false | |||
| 909 | + | d.newTaskEd = nil | |||
| 910 | + | d.fileSuggest = nil | |||
| 911 | + | d.spawnDraft = "" | |||
| 912 | + | d.pendingModel = "" | |||
| 913 | + | d.pendingProvider = "" | |||
| 914 | + | if task == "" { | |||
| 915 | + | return false, "", "" | |||
| 916 | + | } | |||
| 917 | + | if d.spawn == nil { | |||
| 918 | + | return false, "", "spawn not wired" | |||
| 919 | + | } | |||
| 920 | + | if err := d.spawn(task, model, provider); err != nil { | |||
| 921 | + | return false, "", "spawn: " + err.Error() | |||
| 922 | + | } | |||
| 923 | + | if model != "" { | |||
| 924 | + | return false, "spawned (model " + model + ")", "" | |||
| 925 | + | } | |||
| 926 | + | return false, "spawned", "" | |||
| 927 | + | } | |||
| 928 | + | return false, "", "" | |||
| 929 | + | } | |||
| 930 | + | ||||
| 931 | + | func (d *swarmDialog) selected() *swarm.AgentSnapshot { | |||
| 932 | + | if d.cursor < 0 || d.cursor >= len(d.rows) { | |||
| 933 | + | return nil | |||
| 934 | + | } | |||
| 935 | + | r := d.rows[d.cursor] | |||
| 936 | + | return &r | |||
| 937 | + | } | |||
| 938 | + | ||||
| 939 | + | // Render returns the dialog lines. Re-reads the snapshot each call so | |||
| 940 | + | // the dashboard updates as agents make progress. | |||
| 941 | + | func (d *swarmDialog) Render(th tui.Theme, width int) []string { | |||
| 942 | + | if !d.Active() { | |||
| 943 | + | return nil | |||
| 944 | + | } | |||
| 945 | + | d.refresh() | |||
| 946 | + | ||||
| 947 | + | if d.viewing { | |||
| 948 | + | return d.renderTranscript(th, width) | |||
| 949 | + | } | |||
| 950 | + | ||||
| 951 | + | out := []string{frameHeader(th, "swarm (n new · p prompt · R resume · ↑/↓ move · enter view · k kill · r remove · esc close)", width)} | |||
| 952 | + | if d.prompting { | |||
| 953 | + | return d.renderPromptEditor(th, width, out) | |||
| 954 | + | } | |||
| 955 | + | if d.pickingModel { | |||
| 956 | + | // modelDialog renders its own frame; replace ours so the | |||
| 957 | + | // picker takes the dashboard cleanly. Trailing hint reminds | |||
| 958 | + | // the user Esc returns to the spawn editor. | |||
| 959 | + | if d.modelPicker == nil { | |||
| 960 | + | d.openModelPicker() | |||
| 961 | + | } | |||
| 962 | + | out = d.modelPicker.Render(th, width) | |||
| 963 | + | out = append(out, " "+th.FG256(th.Muted, "select model for next spawn · esc to cancel")) | |||
| 964 | + | return out | |||
| 965 | + | } | |||
| 966 | + | if d.spawning { | |||
| 967 | + | if d.newTaskEd == nil { | |||
| 968 | + | d.openSpawnEditor(th) | |||
| 969 | + | } | |||
| 970 | + | if d.newTaskEd != nil { | |||
| 971 | + | d.newTaskEd.Prompt = th.AccentBar(th.Accent) | |||
| 972 | + | } | |||
| 973 | + | // One-line banner reminding the user which model the | |||
| 974 | + | // agent-to-be will run against. Skipped when no model was | |||
| 975 | + | // picked so the layout matches the historical look. | |||
| 976 | + | if d.pendingModel != "" { | |||
| 977 | + | label := "model: " + d.pendingModel | |||
| 978 | + | if d.pendingProvider != "" { | |||
| 979 | + | label += " (" + d.pendingProvider + ")" | |||
| 980 | + | } | |||
| 981 | + | out = append(out, " "+th.FG256(th.Accent, label)) | |||
| 982 | + | } | |||
| 983 | + | if d.fileSuggest != nil { | |||
| 984 | + | d.fileSuggest.SetCWD(d.cwd) | |||
| 985 | + | if popup := d.fileSuggest.Render(d.newTaskEd.Value(), th, width); len(popup) > 0 { | |||
| 986 | + | out = append(out, popup...) | |||
| 987 | + | } | |||
| 988 | + | } | |||
| 989 | + | // Breathing-room blank row above the editor so the input | |||
| 990 | + | // doesn't sit flush against the frame header. | |||
| 991 | + | out = append(out, "") | |||
| 992 | + | if d.newTaskEd != nil { | |||
| 993 | + | edLines, _, _ := d.newTaskEd.Render(width - 2) | |||
| 994 | + | for _, l := range edLines { | |||
| 995 | + | out = append(out, " "+l) | |||
| 996 | + | } | |||
| 997 | + | } | |||
| 998 | + | // Matching blank row below before the hint, mirroring the | |||
| 999 | + | // main input's editor breathing room. | |||
| 1000 | + | out = append(out, "") | |||
| 1001 | + | out = append(out, " "+th.FG256(th.Muted, "enter spawn · /model pick model · @ file/dir picker · paste/drop paths become [file:] / [dir:] chips · esc cancel")) | |||
| 1002 | + | out = append(out, frameRule(th, width)) | |||
| 1003 | + | return out | |||
| 1004 | + | } | |||
| 1005 | + | if len(d.rows) == 0 { | |||
| 1006 | + | out = append(out, " "+th.FG256(th.Muted, "no agents — press n to spawn one (or /swarm new <task>)")) | |||
| 1007 | + | out = append(out, frameRule(th, width)) | |||
| 1008 | + | return out | |||
| 1009 | + | } | |||
| 1010 | + | ||||
| 1011 | + | // Column header for readability. | |||
| 1012 | + | header := fmt.Sprintf(" %-9s %-26s %-8s %s", "STATUS", "ID", "AGE", "ACTIVITY") | |||
| 1013 | + | out = append(out, th.FG256(th.Muted, header)) | |||
| 1014 | + | ||||
| 1015 | + | for i, r := range d.rows { | |||
| 1016 | + | row := formatSwarmRow(r, width-2) | |||
| 1017 | + | if i == d.cursor { | |||
| 1018 | + | out = append(out, th.PadHighlight(" "+row, width)) | |||
| 1019 | + | } else { | |||
| 1020 | + | out = append(out, " "+th.FG256(th.FG, row)) | |||
| 1021 | + | } | |||
| 1022 | + | } | |||
| 1023 | + | out = append(out, frameRule(th, width)) | |||
| 1024 | + | return out | |||
| 1025 | + | } | |||
| 1026 | + | ||||
| 1027 | + | func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string { | |||
| 1028 | + | a := d.selected() | |||
| 1029 | + | if a == nil { | |||
| 1030 | + | d.viewing = false | |||
| 1031 | + | return d.Render(th, width) | |||
| 1032 | + | } | |||
| 1033 | + | header := []string{ | |||
| 1034 | + | frameHeader(th, "swarm: "+a.ID+" (type to send · esc back)", width), | |||
| 1035 | + | " " + th.FG256(th.Muted, "task: "+a.Task), | |||
| 1036 | + | " " + th.FG256(th.Muted, "branch: "+a.Branch), | |||
| 1037 | + | " " + th.FG256(th.Muted, "dir: "+a.Dir), | |||
| 1038 | + | " " + th.FG256(th.Muted, fmt.Sprintf("status: %s · %s", a.Status, a.Activity)), | |||
| 1039 | + | } | |||
| 1040 | + | if a.Model != "" { | |||
| 1041 | + | modelLine := "model: " + a.Model | |||
| 1042 | + | if a.Provider != "" { | |||
| 1043 | + | modelLine += " (" + a.Provider + ")" | |||
| 1044 | + | } | |||
| 1045 | + | header = append(header, " "+th.FG256(th.Muted, modelLine)) | |||
| 1046 | + | } | |||
| 1047 | + | if a.Err != "" { | |||
| 1048 | + | header = append(header, " "+th.FG256(th.Muted, "error: "+a.Err)) | |||
| 1049 | + | } | |||
| 1050 | + | header = append(header, | |||
| 1051 | + | "", | |||
| 1052 | + | " "+th.FG256(th.Muted, "── transcript ──"), | |||
| 1053 | + | "", | |||
| 1054 | + | ) | |||
| 1055 | + | ||||
| 1056 | + | // Body: prefer the full Lines slice when present, fall back to | |||
| 1057 | + | // the Tail string for snapshots that only filled the summary. | |||
| 1058 | + | raw := a.Lines | |||
| 1059 | + | if len(raw) == 0 && a.Tail != "" { | |||
| 1060 | + | raw = strings.Split(a.Tail, "\n") | |||
| 1061 | + | } | |||
| 1062 | + | if len(raw) == 0 { | |||
| 1063 | + | header = append(header, " "+th.FG256(th.Muted, "(no transcript yet)")) | |||
| 1064 | + | header = d.appendTranscriptEditor(header, th, width, a) | |||
| 1065 | + | header = append(header, frameRule(th, width)) | |||
| 1066 | + | return header | |||
| 1067 | + | } | |||
| 1068 | + | ||||
| 1069 | + | // Re-render the flat log into proper chat blocks: user-bubble | |||
| 1070 | + | // rows for echoes, markdown-rendered prose for assistant turns, | |||
| 1071 | + | // tinted prefixes for stderr / errors. The result is a single | |||
| 1072 | + | // pre-styled, pre-indented []string of display rows ready to | |||
| 1073 | + | // hand to the host. | |||
| 1074 | + | // | |||
| 1075 | + | // We deliberately do NOT clip into a fixed-height viewport with | |||
| 1076 | + | // internal scroll — the transcript flows in full, /btw-style, | |||
| 1077 | + | // and the host's outer scrollback handles overflow naturally. | |||
| 1078 | + | // That gives the user the chat UX they expect (transcript grows | |||
| 1079 | + | // with the window, arrow keys belong to the editor, no "↑ N | |||
| 1080 | + | // more above" cliff). | |||
| 1081 | + | body := renderSwarmTranscriptBlocks(raw, th, width) | |||
| 1082 | + | ||||
| 1083 | + | out := header | |||
| 1084 | + | out = append(out, body...) | |||
| 1085 | + | ||||
| 1086 | + | // /btw-style inline editor pinned to the bottom of the transcript. | |||
| 1087 | + | // Rendered after the body so the editor sits flush above the | |||
| 1088 | + | // frame rule. Only shown for running agents (others have no live | |||
| 1089 | + | // inbox listener — we surface a hint instead). | |||
| 1090 | + | out = d.appendTranscriptEditor(out, th, width, a) | |||
| 1091 | + | ||||
| 1092 | + | out = append(out, frameRule(th, width)) | |||
| 1093 | + | return out | |||
| 1094 | + | } | |||
| 1095 | + | ||||
| 1096 | + | // appendTranscriptEditor adds the always-on inline composer to the | |||
| 1097 | + | // transcript view, mimicking the /btw side-chat layout: a breathing | |||
| 1098 | + | // row, the editor itself, another breathing row, and a single muted | |||
| 1099 | + | // hint. For non-running agents we render a short "can't send while | |||
| 1100 | + | // X" note instead so the user knows why typing is disabled. | |||
| 1101 | + | func (d *swarmDialog) appendTranscriptEditor(out []string, th tui.Theme, width int, a *swarm.AgentSnapshot) []string { | |||
| 1102 | + | if a == nil { | |||
| 1103 | + | return out | |||
| 1104 | + | } | |||
| 1105 | + | if a.Status != swarm.StatusRunning || d.send == nil { | |||
| 1106 | + | out = append(out, "") | |||
| 1107 | + | out = append(out, " "+th.FG256(th.Muted, promptDisabledHint(a.Status))) | |||
| 1108 | + | return out | |||
| 1109 | + | } | |||
| 1110 | + | // Lazy-init the editor here too so the very first render of the | |||
| 1111 | + | // transcript view (before the user has pressed any key) already | |||
| 1112 | + | // shows the composer. Mirrors openPromptEditor's setup but uses | |||
| 1113 | + | // the live theme so the accent bar matches. | |||
| 1114 | + | if d.promptEd == nil { | |||
| 1115 | + | d.openPromptEditor(th, a.ID) | |||
| 1116 | + | } | |||
| 1117 | + | if d.promptEd != nil { | |||
| 1118 | + | d.promptEd.Prompt = th.AccentBar(th.Accent) | |||
| 1119 | + | } | |||
| 1120 | + | if d.promptSuggest != nil { | |||
| 1121 | + | d.promptSuggest.SetCWD(d.cwd) | |||
| 1122 | + | if popup := d.promptSuggest.Render(d.promptEd.Value(), th, width); len(popup) > 0 { | |||
| 1123 | + | out = append(out, popup...) | |||
| 1124 | + | } | |||
| 1125 | + | } | |||
| 1126 | + | ||||
| 1127 | + | // Busy-spinner row, /btw-style. Shown whenever the agent's | |||
| 1128 | + | // current activity isn't "idle" — so "thinking", "tool: foo", | |||
| 1129 | + | // "starting", etc. all animate. The spinner is stopped (and the | |||
| 1130 | + | // row hidden) once the agent reports idle, mirroring the main | |||
| 1131 | + | // chat's busy line. | |||
| 1132 | + | if agentIsBusy(a) { | |||
| 1133 | + | if d.transcriptSpin == nil { | |||
| 1134 | + | d.transcriptSpin = newSpinner() | |||
| 1135 | + | d.transcriptSpin.Start() | |||
| 1136 | + | } | |||
| 1137 | + | out = append(out, "") | |||
| 1138 | + | prefix := fmt.Sprintf("%s %s · %s", | |||
| 1139 | + | th.FG256(th.Assistant, d.transcriptSpin.Frame()), | |||
| 1140 | + | th.FG256(th.Assistant, a.Activity), | |||
| 1141 | + | th.FG256(th.Muted, d.transcriptSpin.Elapsed().String()), | |||
| 1142 | + | ) | |||
| 1143 | + | out = append(out, " "+prefix) | |||
| 1144 | + | } else { | |||
| 1145 | + | // Reset so the next busy phase starts fresh (rotating | |||
| 1146 | + | // funny-line cycles back to the start, elapsed restarts). | |||
| 1147 | + | d.transcriptSpin = nil | |||
| 1148 | + | } | |||
| 1149 | + | ||||
| 1150 | + | out = append(out, "") | |||
| 1151 | + | edLines, _, _ := d.promptEd.Render(width - 2) | |||
| 1152 | + | for _, l := range edLines { | |||
| 1153 | + | out = append(out, " "+l) | |||
| 1154 | + | } | |||
| 1155 | + | out = append(out, "") | |||
| 1156 | + | out = append(out, " "+th.FG256(th.Muted, "enter send · @ file/dir picker · esc back")) | |||
| 1157 | + | return out | |||
| 1158 | + | } | |||
| 1159 | + | ||||
| 1160 | + | // agentIsBusy reports whether the agent is currently mid-turn and | |||
| 1161 | + | // should render a spinner. Conservative: only known idle markers | |||
| 1162 | + | // suppress the spinner so transient / unfamiliar activity strings | |||
| 1163 | + | // still animate. Detached / done / failed / killed never animate — | |||
| 1164 | + | // they aren't actively producing output. | |||
| 1165 | + | func agentIsBusy(a *swarm.AgentSnapshot) bool { | |||
| 1166 | + | if a == nil { | |||
| 1167 | + | return false | |||
| 1168 | + | } | |||
| 1169 | + | if a.Status != swarm.StatusRunning && a.Status != swarm.StatusPending { | |||
| 1170 | + | return false | |||
| 1171 | + | } | |||
| 1172 | + | switch strings.TrimSpace(a.Activity) { | |||
| 1173 | + | case "", "idle": | |||
| 1174 | + | return false | |||
| 1175 | + | } | |||
| 1176 | + | return true | |||
| 1177 | + | } | |||
| 1178 | + | ||||
| 1179 | + | // renderSwarmTranscriptBlocks converts the agent's flat transcript | |||
| 1180 | + | // log into a slice of display rows styled like the main chat: user | |||
| 1181 | + | // echoes in bubble rows, assistant prose through tui.RenderMarkdown, | |||
| 1182 | + | // stderr / error chatter tinted. Each output row is fully indented | |||
| 1183 | + | // and ready for the caller to slice into a scroll window. | |||
| 1184 | + | // | |||
| 1185 | + | // The flat log mixes role markers and raw text, so we group adjacent | |||
| 1186 | + | // lines of the same role into one logical "block" and render the | |||
| 1187 | + | // whole block together — essential for markdown, which needs the | |||
| 1188 | + | // full message body in one piece to detect fenced code spans, | |||
| 1189 | + | // bullet lists, and so on. | |||
| 1190 | + | func renderSwarmTranscriptBlocks(lines []string, th tui.Theme, width int) []string { | |||
| 1191 | + | type blockKind int | |||
| 1192 | + | const ( | |||
| 1193 | + | kindAssistant blockKind = iota | |||
| 1194 | + | kindUser | |||
| 1195 | + | kindStderr | |||
| 1196 | + | kindError | |||
| 1197 | + | ) | |||
| 1198 | + | type block struct { | |||
| 1199 | + | kind blockKind | |||
| 1200 | + | body []string // raw lines, role prefix already stripped | |||
| 1201 | + | } | |||
| 1202 | + | ||||
| 1203 | + | // classify maps one raw line to its role and the stripped body. | |||
| 1204 | + | classify := func(s string) (blockKind, string) { | |||
| 1205 | + | switch { | |||
| 1206 | + | case strings.HasPrefix(s, "user: "): | |||
| 1207 | + | return kindUser, strings.TrimPrefix(s, "user: ") | |||
| 1208 | + | case strings.HasPrefix(s, "stderr: "): | |||
| 1209 | + | return kindStderr, strings.TrimPrefix(s, "stderr: ") | |||
| 1210 | + | case strings.HasPrefix(s, "error: "): | |||
| 1211 | + | return kindError, strings.TrimPrefix(s, "error: ") | |||
| 1212 | + | default: | |||
| 1213 | + | return kindAssistant, s | |||
| 1214 | + | } | |||
| 1215 | + | } | |||
| 1216 | + | ||||
| 1217 | + | // Coalesce consecutive same-kind lines into blocks. Empty lines | |||
| 1218 | + | // keep the kind of the previous block so blank lines inside an | |||
| 1219 | + | // assistant message stay attached for markdown rendering (an | |||
| 1220 | + | // empty line between paragraphs is significant in markdown). | |||
| 1221 | + | var blocks []block | |||
| 1222 | + | for _, ln := range lines { | |||
| 1223 | + | if ln == "" { | |||
| 1224 | + | // Empty lines stay attached to the previous block so | |||
| 1225 | + | // markdown sees paragraph breaks inside an assistant | |||
| 1226 | + | // turn. Standalone empties before any block are dropped | |||
| 1227 | + | // (nothing meaningful to attach them to). | |||
| 1228 | + | if len(blocks) > 0 { | |||
| 1229 | + | blocks[len(blocks)-1].body = append(blocks[len(blocks)-1].body, "") | |||
| 1230 | + | } | |||
| 1231 | + | continue | |||
| 1232 | + | } | |||
| 1233 | + | k, stripped := classify(ln) | |||
| 1234 | + | if len(blocks) == 0 || blocks[len(blocks)-1].kind != k { | |||
| 1235 | + | blocks = append(blocks, block{kind: k}) | |||
| 1236 | + | } | |||
| 1237 | + | blocks[len(blocks)-1].body = append(blocks[len(blocks)-1].body, stripped) | |||
| 1238 | + | } | |||
| 1239 | + | ||||
| 1240 | + | // Render each block. Width budget mirrors the btw dialog: the | |||
| 1241 | + | // transcript text sits inside two leading frame cells, so the | |||
| 1242 | + | // inner width is width-4 for markdown and width-2 for the | |||
| 1243 | + | // user-bubble (the bubble already pads itself). | |||
| 1244 | + | var out []string | |||
| 1245 | + | innerMD := width - 4 | |||
| 1246 | + | if innerMD < 20 { | |||
| 1247 | + | innerMD = 20 | |||
| 1248 | + | } | |||
| 1249 | + | bubbleWidth := width - 2 | |||
| 1250 | + | if bubbleWidth < 10 { | |||
| 1251 | + | bubbleWidth = 10 | |||
| 1252 | + | } | |||
| 1253 | + | ||||
| 1254 | + | for i, b := range blocks { | |||
| 1255 | + | if i > 0 { | |||
| 1256 | + | out = append(out, "") // breathing room between turns | |||
| 1257 | + | } | |||
| 1258 | + | switch b.kind { | |||
| 1259 | + | case kindUser: | |||
| 1260 | + | text := strings.Join(b.body, "\n") | |||
| 1261 | + | out = append(out, btwUserBubbleRows(th, text, bubbleWidth)...) | |||
| 1262 | + | case kindAssistant: | |||
| 1263 | + | text := strings.Join(b.body, "\n") | |||
| 1264 | + | md := tui.RenderMarkdown(strings.TrimLeft(text, "\n"), th, innerMD) | |||
| 1265 | + | for _, line := range strings.Split(md, "\n") { | |||
| 1266 | + | if len(line) > 0 && line[0] == tui.FlushLeftSentinel { | |||
| 1267 | + | line = line[1:] | |||
| 1268 | + | } | |||
| 1269 | + | out = append(out, " "+line) | |||
| 1270 | + | } | |||
| 1271 | + | case kindStderr: | |||
| 1272 | + | for _, line := range b.body { | |||
| 1273 | + | out = append(out, " "+th.FG256(th.Muted, "stderr "+line)) | |||
| 1274 | + | } | |||
| 1275 | + | case kindError: | |||
| 1276 | + | for _, line := range b.body { | |||
| 1277 | + | out = append(out, " "+th.FG256(th.Error, "✖ "+line)) | |||
| 1278 | + | } | |||
| 1279 | + | } | |||
| 1280 | + | } | |||
| 1281 | + | return out | |||
| 1282 | + | } | |||
| 1283 | + | ||||
| 1284 | + | // renderPromptEditor draws the inline "send a follow-up prompt" | |||
| 1285 | + | // composer. Reuses the editor + file-suggest popup styling of the | |||
| 1286 | + | // spawn editor so the two flows are visually consistent. `out` is | |||
| 1287 | + | // the pre-built header (frame header for list view, or the agent | |||
| 1288 | + | // metadata block for transcript view); we append onto it and close | |||
| 1289 | + | // with the standard frame rule. | |||
| 1290 | + | func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string) []string { | |||
| 1291 | + | if d.promptEd == nil { | |||
| 1292 | + | d.openPromptEditor(th, d.promptTargetID) | |||
| 1293 | + | } | |||
| 1294 | + | if d.promptEd != nil { | |||
| 1295 | + | d.promptEd.Prompt = th.AccentBar(th.Accent) | |||
| 1296 | + | } | |||
| 1297 | + | target := d.promptTargetID | |||
| 1298 | + | if target == "" { | |||
| 1299 | + | target = "<unknown>" | |||
| 1300 | + | } | |||
| 1301 | + | out = append(out, " "+th.FG256(th.Muted, "send to "+target+":")) | |||
| 1302 | + | if d.promptSuggest != nil { | |||
| 1303 | + | d.promptSuggest.SetCWD(d.cwd) | |||
| 1304 | + | if popup := d.promptSuggest.Render(d.promptEd.Value(), th, width); len(popup) > 0 { | |||
| 1305 | + | out = append(out, popup...) | |||
| 1306 | + | } | |||
| 1307 | + | } | |||
| 1308 | + | // Breathing-room blank row above the editor. | |||
| 1309 | + | out = append(out, "") | |||
| 1310 | + | if d.promptEd != nil { | |||
| 1311 | + | edLines, _, _ := d.promptEd.Render(width - 2) | |||
| 1312 | + | for _, l := range edLines { | |||
| 1313 | + | out = append(out, " "+l) | |||
| 1314 | + | } | |||
| 1315 | + | } | |||
| 1316 | + | // Matching blank row below before the hint. | |||
| 1317 | + | out = append(out, "") | |||
| 1318 | + | out = append(out, " "+th.FG256(th.Muted, "enter send · @ file/dir picker · esc cancel")) | |||
| 1319 | + | out = append(out, frameRule(th, width)) | |||
| 1320 | + | return out | |||
| 1321 | + | } | |||
| 1322 | + | ||||
| 1323 | + | // formatSwarmRow is the one-line summary shown per agent. | |||
| 1324 | + | func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string { | |||
| 1325 | + | status := statusLabel(r.Status) | |||
| 1326 | + | age := formatAge(r.Started) | |||
| 1327 | + | left := fmt.Sprintf("%-9s %-26s %-8s ", status, truncateLineSafe(r.ID, 26), age) | |||
| 1328 | + | room := maxWidth - len([]rune(left)) | |||
| 1329 | + | if room < 10 { | |||
| 1330 | + | room = 10 | |||
| 1331 | + | } | |||
| 1332 | + | act := strings.ReplaceAll(r.Activity, "\n", " ") | |||
| 1333 | + | if act == "" { | |||
| 1334 | + | act = r.Task | |||
| 1335 | + | } | |||
| 1336 | + | if len([]rune(act)) > room { | |||
| 1337 | + | act = string([]rune(act)[:room-1]) + "…" | |||
| 1338 | + | } | |||
| 1339 | + | row := left + act | |||
| 1340 | + | rowRunes := []rune(row) | |||
| 1341 | + | if len(rowRunes) > maxWidth { | |||
| 1342 | + | row = string(rowRunes[:maxWidth-1]) + "…" | |||
| 1343 | + | } | |||
| 1344 | + | return row | |||
| 1345 | + | } | |||
| 1346 | + | ||||
| 1347 | + | // statusLabel returns a short, padded status badge. Kept stable across | |||
| 1348 | + | // runs (no colours embedded) so tests can match it deterministically. | |||
| 1349 | + | func statusLabel(s swarm.Status) string { | |||
| 1350 | + | switch s { | |||
| 1351 | + | case swarm.StatusPending: | |||
| 1352 | + | return "● pend" | |||
| 1353 | + | case swarm.StatusRunning: | |||
| 1354 | + | return "● run" | |||
| 1355 | + | case swarm.StatusDone: | |||
| 1356 | + | return "✓ done" | |||
| 1357 | + | case swarm.StatusFailed: | |||
| 1358 | + | return "✗ fail" | |||
| 1359 | + | case swarm.StatusKilled: | |||
| 1360 | + | return "■ kill" | |||
| 1361 | + | case swarm.StatusDetached: | |||
| 1362 | + | return "○ detach" | |||
| 1363 | + | } | |||
| 1364 | + | return string(s) | |||
| 1365 | + | } | |||
| 1366 | + | ||||
| 1367 | + | func formatAge(t time.Time) string { | |||
| 1368 | + | if t.IsZero() { | |||
| 1369 | + | return "-" | |||
| 1370 | + | } | |||
| 1371 | + | d := time.Since(t) | |||
| 1372 | + | switch { | |||
| 1373 | + | case d < time.Minute: | |||
| 1374 | + | return fmt.Sprintf("%ds", int(d.Seconds())) | |||
| 1375 | + | case d < time.Hour: | |||
| 1376 | + | return fmt.Sprintf("%dm", int(d.Minutes())) | |||
| 1377 | + | case d < 24*time.Hour: | |||
| 1378 | + | return fmt.Sprintf("%dh", int(d.Hours())) | |||
| 1379 | + | default: | |||
| 1380 | + | return fmt.Sprintf("%dd", int(d.Hours()/24)) | |||
| 1381 | + | } | |||
| 1382 | + | } | |||
A internal/agent/modes/swarm_dialog_test.go +1279 -0
| @@ -0,0 +1,1279 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/patriceckhart/zot/internal/provider" | |||
| 13 | + | "github.com/patriceckhart/zot/internal/swarm" | |||
| 14 | + | "github.com/patriceckhart/zot/internal/tui" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // staticSnapshots returns a deterministic snapshot slice for the | |||
| 18 | + | // dialog to render. | |||
| 19 | + | func staticSnapshots(rows ...swarm.AgentSnapshot) func() []swarm.AgentSnapshot { | |||
| 20 | + | return func() []swarm.AgentSnapshot { | |||
| 21 | + | out := make([]swarm.AgentSnapshot, len(rows)) | |||
| 22 | + | copy(out, rows) | |||
| 23 | + | return out | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestSwarmDialogEmptyState(t *testing.T) { | |||
| 28 | + | d := newSwarmDialog() | |||
| 29 | + | d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") | |||
| 30 | + | lines := d.Render(tui.Theme{}, 80) | |||
| 31 | + | joined := strings.Join(lines, "\n") | |||
| 32 | + | if !strings.Contains(joined, "no agents") { | |||
| 33 | + | t.Fatalf("empty state missing hint:\n%s", joined) | |||
| 34 | + | } | |||
| 35 | + | if !strings.Contains(joined, "press n") { | |||
| 36 | + | t.Fatalf("empty state should advertise n shortcut:\n%s", joined) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestSwarmDialogRendersRows(t *testing.T) { | |||
| 41 | + | now := time.Now() | |||
| 42 | + | rows := []swarm.AgentSnapshot{ | |||
| 43 | + | {ID: "alpha-1", Task: "fix login", Status: swarm.StatusRunning, Activity: "editing file", Started: now}, | |||
| 44 | + | {ID: "beta-2", Task: "write tests", Status: swarm.StatusDone, Activity: "done", Started: now}, | |||
| 45 | + | } | |||
| 46 | + | d := newSwarmDialog() | |||
| 47 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | |||
| 48 | + | out := strings.Join(d.Render(tui.Theme{}, 100), "\n") | |||
| 49 | + | for _, want := range []string{"alpha-1", "beta-2", "editing file", "done", "STATUS", "ACTIVITY"} { | |||
| 50 | + | if !strings.Contains(out, want) { | |||
| 51 | + | t.Fatalf("render missing %q:\n%s", want, out) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestSwarmDialogStopAndRemoveCallbacks(t *testing.T) { | |||
| 57 | + | now := time.Now() | |||
| 58 | + | rows := []swarm.AgentSnapshot{ | |||
| 59 | + | {ID: "alpha-1", Status: swarm.StatusRunning, Started: now}, | |||
| 60 | + | } | |||
| 61 | + | stops := 0 | |||
| 62 | + | removes := 0 | |||
| 63 | + | d := newSwarmDialog() | |||
| 64 | + | d.Open( | |||
| 65 | + | staticSnapshots(rows...), | |||
| 66 | + | func(id string) error { stops++; return nil }, | |||
| 67 | + | func(id string) error { removes++; return nil }, | |||
| 68 | + | nil, | |||
| 69 | + | nil, | |||
| 70 | + | nil, | |||
| 71 | + | "", | |||
| 72 | + | ) | |||
| 73 | + | // Render once so cursor + rows are populated. | |||
| 74 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 75 | + | ||||
| 76 | + | _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'k'}) | |||
| 77 | + | if stops != 1 { | |||
| 78 | + | t.Fatalf("stop calls = %d", stops) | |||
| 79 | + | } | |||
| 80 | + | if !strings.Contains(msg, "alpha-1") || errMsg != "" { | |||
| 81 | + | t.Fatalf("stop status (%q, %q)", msg, errMsg) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | _, msg, errMsg = d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'r'}) | |||
| 85 | + | if removes != 1 { | |||
| 86 | + | t.Fatalf("remove calls = %d", removes) | |||
| 87 | + | } | |||
| 88 | + | if !strings.Contains(msg, "alpha-1") || errMsg != "" { | |||
| 89 | + | t.Fatalf("remove status (%q, %q)", msg, errMsg) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestSwarmDialogEnterShowsTranscript(t *testing.T) { | |||
| 94 | + | now := time.Now() | |||
| 95 | + | rows := []swarm.AgentSnapshot{ | |||
| 96 | + | {ID: "alpha-1", Task: "do stuff", Status: swarm.StatusDone, Tail: "line a\nline b", Started: now}, | |||
| 97 | + | } | |||
| 98 | + | d := newSwarmDialog() | |||
| 99 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | |||
| 100 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 101 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 102 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |||
| 103 | + | for _, want := range []string{"task:", "branch:", "line a", "line b"} { | |||
| 104 | + | if !strings.Contains(out, want) { | |||
| 105 | + | t.Fatalf("transcript view missing %q:\n%s", want, out) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | // Esc should bring us back to the list view. | |||
| 109 | + | d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |||
| 110 | + | out = strings.Join(d.Render(tui.Theme{}, 80), "\n") | |||
| 111 | + | if !strings.Contains(out, "STATUS") { | |||
| 112 | + | t.Fatalf("did not return to list view:\n%s", out) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestSwarmDialogEscClosesList(t *testing.T) { | |||
| 117 | + | d := newSwarmDialog() | |||
| 118 | + | d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") | |||
| 119 | + | closed, _, _ := d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |||
| 120 | + | if !closed { | |||
| 121 | + | t.Fatal("esc did not close") | |||
| 122 | + | } | |||
| 123 | + | if d.Active() { | |||
| 124 | + | t.Fatal("still active after esc") | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // TestSwarmDialogSpawnFlow drives the inline n-prompt end-to-end: | |||
| 129 | + | // press n, type a task, press enter, observe the spawn callback | |||
| 130 | + | // fires with the right text and the prompt closes. | |||
| 131 | + | func TestSwarmDialogSpawnFlow(t *testing.T) { | |||
| 132 | + | var spawned string | |||
| 133 | + | d := newSwarmDialog() | |||
| 134 | + | d.Open( | |||
| 135 | + | staticSnapshots(), | |||
| 136 | + | nil, nil, | |||
| 137 | + | func(task, _, _ string) error { spawned = task; return nil }, | |||
| 138 | + | nil, | |||
| 139 | + | nil, | |||
| 140 | + | "", | |||
| 141 | + | ) | |||
| 142 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 143 | + | ||||
| 144 | + | // Press n to open the spawn editor directly. The model is | |||
| 145 | + | // taken from whatever the host set via SetCurrentModel; this | |||
| 146 | + | // test doesn't pin one. | |||
| 147 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | |||
| 148 | + | if !d.spawning { | |||
| 149 | + | t.Fatal("n did not enter spawning mode") | |||
| 150 | + | } | |||
| 151 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |||
| 152 | + | if !strings.Contains(out, "enter spawn") || !strings.Contains(out, "@ file/dir picker") { | |||
| 153 | + | t.Fatalf("spawn editor hint not rendered:\n%s", out) | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // Type "fix x". | |||
| 157 | + | for _, r := range "fix x" { | |||
| 158 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |||
| 159 | + | } | |||
| 160 | + | // Submit. | |||
| 161 | + | _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 162 | + | if errMsg != "" { | |||
| 163 | + | t.Fatalf("unexpected err: %s", errMsg) | |||
| 164 | + | } | |||
| 165 | + | if msg != "spawned" { | |||
| 166 | + | t.Fatalf("status msg = %q", msg) | |||
| 167 | + | } | |||
| 168 | + | if spawned != "fix x" { | |||
| 169 | + | t.Fatalf("spawn callback got %q", spawned) | |||
| 170 | + | } | |||
| 171 | + | if d.spawning { | |||
| 172 | + | t.Fatal("prompt did not close after enter") | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func TestSwarmDialogSpawnFilePickerFlow(t *testing.T) { | |||
| 177 | + | cwd := t.TempDir() | |||
| 178 | + | if err := os.WriteFile(filepath.Join(cwd, "README.md"), []byte("hi"), 0o644); err != nil { | |||
| 179 | + | t.Fatal(err) | |||
| 180 | + | } | |||
| 181 | + | var spawned string | |||
| 182 | + | d := newSwarmDialog() | |||
| 183 | + | d.Open(staticSnapshots(), nil, nil, func(task, _, _ string) error { spawned = task; return nil }, nil, nil, cwd) | |||
| 184 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 185 | + | ||||
| 186 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | |||
| 187 | + | for _, r := range "summarize @READ" { | |||
| 188 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |||
| 189 | + | } | |||
| 190 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |||
| 191 | + | if !strings.Contains(out, "README.md") { | |||
| 192 | + | t.Fatalf("@ file picker did not render match:\n%s", out) | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // Enter while the file picker is active selects the highlighted file, | |||
| 196 | + | // inserting the compact [file:] chip instead of submitting yet. | |||
| 197 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 198 | + | if d.newTaskEd == nil || !strings.Contains(d.newTaskEd.Value(), "[file:README.md]") { | |||
| 199 | + | t.Fatalf("file picker did not insert chip; editor=%v", d.newTaskEd) | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | // A second Enter submits. The chip should expand to the absolute | |||
| 203 | + | // path before the task reaches Swarm.Spawn. | |||
| 204 | + | _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 205 | + | if errMsg != "" { | |||
| 206 | + | t.Fatalf("unexpected err: %s", errMsg) | |||
| 207 | + | } | |||
| 208 | + | if msg != "spawned" { | |||
| 209 | + | t.Fatalf("status msg = %q", msg) | |||
| 210 | + | } | |||
| 211 | + | wantPath := filepath.Join(cwd, "README.md") | |||
| 212 | + | if !strings.Contains(spawned, wantPath) { | |||
| 213 | + | t.Fatalf("spawned task %q missing expanded path %q", spawned, wantPath) | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func TestSwarmDialogSpawnEscCancels(t *testing.T) { | |||
| 218 | + | called := false | |||
| 219 | + | d := newSwarmDialog() | |||
| 220 | + | d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { called = true; return nil }, nil, nil, "") | |||
| 221 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 222 | + | ||||
| 223 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | |||
| 224 | + | for _, r := range "abc" { | |||
| 225 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |||
| 226 | + | } | |||
| 227 | + | d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |||
| 228 | + | if called { | |||
| 229 | + | t.Fatal("spawn callback ran despite esc") | |||
| 230 | + | } | |||
| 231 | + | if d.spawning { | |||
| 232 | + | t.Fatal("prompt still open after esc") | |||
| 233 | + | } | |||
| 234 | + | if d.Active() == false { | |||
| 235 | + | t.Fatal("esc on spawn prompt should not close the whole dialog") | |||
| 236 | + | } | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | func TestSwarmDialogNoNWithoutSpawnCallback(t *testing.T) { | |||
| 240 | + | d := newSwarmDialog() | |||
| 241 | + | d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") // spawn callback nil | |||
| 242 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 243 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | |||
| 244 | + | if d.spawning { | |||
| 245 | + | t.Fatal("n opened spawn prompt without a spawn callback wired") | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // TestSwarmDialogFullTranscriptRendersEverything pins the /btw-style | |||
| 250 | + | // rendering: the dialog NEVER clips the transcript into a fixed | |||
| 251 | + | // window with internal scroll. Every line goes into the rendered | |||
| 252 | + | // output and the host's outer scrollback owns overflow. This is | |||
| 253 | + | // what makes the transcript grow with the window and with the | |||
| 254 | + | // content, instead of cutting off at row 16 with a "↑ N more above" | |||
| 255 | + | // indicator. | |||
| 256 | + | func TestSwarmDialogFullTranscriptRendersEverything(t *testing.T) { | |||
| 257 | + | lines := make([]string, 60) | |||
| 258 | + | for i := range lines { | |||
| 259 | + | lines[i] = fmt.Sprintf("line %d", i) | |||
| 260 | + | } | |||
| 261 | + | rows := []swarm.AgentSnapshot{{ | |||
| 262 | + | ID: "alpha-1", Task: "x", Status: swarm.StatusDone, | |||
| 263 | + | Lines: lines, Started: time.Now(), | |||
| 264 | + | }} | |||
| 265 | + | d := newSwarmDialog() | |||
| 266 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | |||
| 267 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 268 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open viewer | |||
| 269 | + | ||||
| 270 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |||
| 271 | + | // First line must be present (no internal clipping from the top). | |||
| 272 | + | if !strings.Contains(out, "line 0") { | |||
| 273 | + | t.Errorf("first transcript line missing — the view appears to be clipping:\n%s", out) | |||
| 274 | + | } | |||
| 275 | + | // Last line must be present (no internal clipping from the bottom). | |||
| 276 | + | if !strings.Contains(out, fmt.Sprintf("line %d", len(lines)-1)) { | |||
| 277 | + | t.Errorf("last transcript line missing — the view appears to be clipping:\n%s", out) | |||
| 278 | + | } | |||
| 279 | + | // The old "↑ N more above" / "↓ N more below" cliff markers | |||
| 280 | + | // must NOT appear; those belonged to the old viewport model. | |||
| 281 | + | if strings.Contains(out, "more above") || strings.Contains(out, "more below") { | |||
| 282 | + | t.Errorf("transcript still uses internal scroll markers:\n%s", out) | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | // Esc returns to the list view. | |||
| 286 | + | d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |||
| 287 | + | if d.viewing { | |||
| 288 | + | t.Fatal("esc did not exit viewer") | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | // TestSwarmDialogOpenViewingJumpsToAgent regression-tests /swarm logs | |||
| 293 | + | // <id>: OpenViewing should select the row whose id matches the | |||
| 294 | + | // (possibly truncated) argument and enter the transcript view | |||
| 295 | + | // directly without showing the list first. | |||
| 296 | + | func TestSwarmDialogOpenViewingJumpsToAgent(t *testing.T) { | |||
| 297 | + | rows := []swarm.AgentSnapshot{ | |||
| 298 | + | {ID: "alpha-1", Lines: []string{"hi from alpha"}, Started: time.Now()}, | |||
| 299 | + | {ID: "beta-2", Lines: []string{"hi from beta"}, Started: time.Now()}, | |||
| 300 | + | } | |||
| 301 | + | d := newSwarmDialog() | |||
| 302 | + | ok := d.OpenViewing("beta", staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | |||
| 303 | + | if !ok { | |||
| 304 | + | t.Fatal("prefix match did not resolve") | |||
| 305 | + | } | |||
| 306 | + | if !d.viewing || d.cursor != 1 { | |||
| 307 | + | t.Fatalf("viewing=%v cursor=%d", d.viewing, d.cursor) | |||
| 308 | + | } | |||
| 309 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |||
| 310 | + | if !strings.Contains(out, "hi from beta") { | |||
| 311 | + | t.Fatalf("wrong agent shown:\n%s", out) | |||
| 312 | + | } | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | func TestSwarmDialogOpenViewingUnknownIDReturnsFalse(t *testing.T) { | |||
| 316 | + | d := newSwarmDialog() | |||
| 317 | + | ok := d.OpenViewing("nope", staticSnapshots(), nil, nil, nil, nil, nil, "") | |||
| 318 | + | if ok { | |||
| 319 | + | t.Fatal("expected miss for unknown id") | |||
| 320 | + | } | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | // TestSwarmDialogPromptFlowFromList drives the inline p-prompt: | |||
| 324 | + | // press p on the selected agent, type a message, press enter, | |||
| 325 | + | // and assert the send callback fired with the right id+text. | |||
| 326 | + | func TestSwarmDialogPromptFlowFromList(t *testing.T) { | |||
| 327 | + | rows := []swarm.AgentSnapshot{ | |||
| 328 | + | {ID: "alpha-1", Status: swarm.StatusRunning, Started: time.Now()}, | |||
| 329 | + | } | |||
| 330 | + | var gotID, gotText string | |||
| 331 | + | sends := 0 | |||
| 332 | + | d := newSwarmDialog() | |||
| 333 | + | d.Open( | |||
| 334 | + | staticSnapshots(rows...), | |||
| 335 | + | nil, nil, nil, | |||
| 336 | + | func(id, text string) error { sends++; gotID = id; gotText = text; return nil }, | |||
| 337 | + | nil, | |||
| 338 | + | "", | |||
| 339 | + | ) | |||
| 340 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 341 | + | ||||
| 342 | + | // Press 'p' to open the inline prompt for alpha-1. | |||
| 343 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) | |||
| 344 | + | if !d.prompting { | |||
| 345 | + | t.Fatal("p did not enter prompting mode") | |||
| 346 | + | } | |||
| 347 | + | if d.promptTargetID != "alpha-1" { | |||
| 348 | + | t.Fatalf("prompt target = %q; want alpha-1", d.promptTargetID) | |||
| 349 | + | } | |||
| 350 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |||
| 351 | + | if !strings.Contains(out, "send to alpha-1:") || !strings.Contains(out, "enter send") { | |||
| 352 | + | t.Fatalf("prompt editor hint not rendered:\n%s", out) | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | // Type "do the thing" and submit. | |||
| 356 | + | for _, r := range "do the thing" { | |||
| 357 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |||
| 358 | + | } | |||
| 359 | + | _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 360 | + | if errMsg != "" { | |||
| 361 | + | t.Fatalf("unexpected err: %s", errMsg) | |||
| 362 | + | } | |||
| 363 | + | if msg != "sent to alpha-1" { | |||
| 364 | + | t.Fatalf("status msg = %q", msg) | |||
| 365 | + | } | |||
| 366 | + | if sends != 1 || gotID != "alpha-1" || gotText != "do the thing" { | |||
| 367 | + | t.Fatalf("send callback got id=%q text=%q (calls=%d)", gotID, gotText, sends) | |||
| 368 | + | } | |||
| 369 | + | if d.prompting { | |||
| 370 | + | t.Fatal("prompt did not close after enter") | |||
| 371 | + | } | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | // TestSwarmDialogPromptFromTranscriptView pins the always-on editor | |||
| 375 | + | // behaviour for the transcript view: entering an agent immediately | |||
| 376 | + | // auto-opens the inline composer (no `p` needed), typing flows | |||
| 377 | + | // straight into it, Enter sends, and the metadata stays visible. | |||
| 378 | + | // The transcript view is /btw-style now — you never leave the | |||
| 379 | + | // view to send a follow-up. | |||
| 380 | + | func TestSwarmDialogPromptFromTranscriptView(t *testing.T) { | |||
| 381 | + | rows := []swarm.AgentSnapshot{ | |||
| 382 | + | {ID: "beta-2", Task: "do thing", Status: swarm.StatusRunning, Started: time.Now(), Tail: "hi from beta"}, | |||
| 383 | + | } | |||
| 384 | + | var gotID, gotText string | |||
| 385 | + | d := newSwarmDialog() | |||
| 386 | + | d.Open( | |||
| 387 | + | staticSnapshots(rows...), | |||
| 388 | + | nil, nil, nil, | |||
| 389 | + | func(id, text string) error { gotID = id; gotText = text; return nil }, | |||
| 390 | + | nil, | |||
| 391 | + | "", | |||
| 392 | + | ) | |||
| 393 | + | // Enter the transcript view; for a running agent the editor | |||
| 394 | + | // must auto-open right away. | |||
| 395 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 396 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 397 | + | if !d.viewing { | |||
| 398 | + | t.Fatal("enter did not open transcript view") | |||
| 399 | + | } | |||
| 400 | + | if !d.prompting { | |||
| 401 | + | t.Fatal("transcript view did not auto-open inline editor for running agent") | |||
| 402 | + | } | |||
| 403 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |||
| 404 | + | if !strings.Contains(out, "task: do thing") { | |||
| 405 | + | t.Fatalf("transcript metadata not visible:\n%s", out) | |||
| 406 | + | } | |||
| 407 | + | if !strings.Contains(out, "swarm: beta-2") { | |||
| 408 | + | t.Fatalf("frame header missing agent id:\n%s", out) | |||
| 409 | + | } | |||
| 410 | + | ||||
| 411 | + | // Typing flows straight into the editor — no `p` modal step. | |||
| 412 | + | for _, r := range "keep going" { | |||
| 413 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |||
| 414 | + | } | |||
| 415 | + | _, msg, _ := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 416 | + | if msg != "sent to beta-2" || gotID != "beta-2" || gotText != "keep going" { | |||
| 417 | + | t.Fatalf("send didn't fire correctly: msg=%q id=%q text=%q", msg, gotID, gotText) | |||
| 418 | + | } | |||
| 419 | + | ||||
| 420 | + | // Editor must stay mounted after submit (/btw-style), with an | |||
| 421 | + | // empty buffer ready for the next message. | |||
| 422 | + | if !d.prompting { | |||
| 423 | + | t.Error("editor closed after submit; /btw-style transcript view should keep it mounted") | |||
| 424 | + | } | |||
| 425 | + | if !d.viewing { | |||
| 426 | + | t.Error("transcript view collapsed after submit; should stay open") | |||
| 427 | + | } | |||
| 428 | + | if d.promptEd != nil && d.promptEd.Value() != "" { | |||
| 429 | + | t.Errorf("editor buffer not cleared after submit: %q", d.promptEd.Value()) | |||
| 430 | + | } | |||
| 431 | + | } | |||
| 432 | + | ||||
| 433 | + | // TestSwarmDialogPromptEscCancels makes sure Esc inside the prompt | |||
| 434 | + | // editor aborts without invoking the send callback. | |||
| 435 | + | func TestSwarmDialogPromptEscCancels(t *testing.T) { | |||
| 436 | + | rows := []swarm.AgentSnapshot{{ID: "gamma-3", Status: swarm.StatusRunning, Started: time.Now()}} | |||
| 437 | + | sends := 0 | |||
| 438 | + | d := newSwarmDialog() | |||
| 439 | + | d.Open( | |||
| 440 | + | staticSnapshots(rows...), | |||
| 441 | + | nil, nil, nil, | |||
| 442 | + | func(id, text string) error { sends++; return nil }, | |||
| 443 | + | nil, | |||
| 444 | + | "", | |||
| 445 | + | ) | |||
| 446 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 447 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) | |||
| 448 | + | for _, r := range "oops" { | |||
| 449 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |||
| 450 | + | } | |||
| 451 | + | d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |||
| 452 | + | if d.prompting { | |||
| 453 | + | t.Fatal("esc did not close prompt editor") | |||
| 454 | + | } | |||
| 455 | + | if sends != 0 { | |||
| 456 | + | t.Fatalf("send fired despite esc: calls=%d", sends) | |||
| 457 | + | } | |||
| 458 | + | } | |||
| 459 | + | ||||
| 460 | + | // TestSwarmDialogPWithoutSendCallback verifies that pressing 'p' | |||
| 461 | + | // when no send callback is wired is a silent no-op (mirrors the | |||
| 462 | + | // existing 'n' / no-spawn-callback contract). | |||
| 463 | + | func TestSwarmDialogPWithoutSendCallback(t *testing.T) { | |||
| 464 | + | rows := []swarm.AgentSnapshot{{ID: "delta-4", Status: swarm.StatusRunning, Started: time.Now()}} | |||
| 465 | + | d := newSwarmDialog() | |||
| 466 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | |||
| 467 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 468 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) | |||
| 469 | + | if d.prompting { | |||
| 470 | + | t.Fatal("p opened prompt editor without a send callback") | |||
| 471 | + | } | |||
| 472 | + | } | |||
| 473 | + | ||||
| 474 | + | // TestOpenForResumeParksCursorOnFirstResumable verifies the picker | |||
| 475 | + | // entry point: with a mix of running and detached agents, the | |||
| 476 | + | // dialog opens with the cursor on the first detached row (skipping | |||
| 477 | + | // any leading running ones) so the user can press R immediately. | |||
| 478 | + | func TestOpenForResumeParksCursorOnFirstResumable(t *testing.T) { | |||
| 479 | + | rows := []swarm.AgentSnapshot{ | |||
| 480 | + | {ID: "live-1", Status: swarm.StatusRunning, Started: time.Now()}, | |||
| 481 | + | {ID: "live-2", Status: swarm.StatusRunning, Started: time.Now()}, | |||
| 482 | + | {ID: "detached-1", Status: swarm.StatusDetached, Started: time.Now()}, | |||
| 483 | + | {ID: "detached-2", Status: swarm.StatusKilled, Started: time.Now()}, | |||
| 484 | + | } | |||
| 485 | + | d := newSwarmDialog() | |||
| 486 | + | count := d.OpenForResume( | |||
| 487 | + | staticSnapshots(rows...), | |||
| 488 | + | nil, nil, nil, nil, | |||
| 489 | + | func(id string) error { return nil }, | |||
| 490 | + | "", | |||
| 491 | + | ) | |||
| 492 | + | if count != 2 { | |||
| 493 | + | t.Fatalf("resumable count = %d; want 2", count) | |||
| 494 | + | } | |||
| 495 | + | if d.cursor != 2 { | |||
| 496 | + | t.Fatalf("cursor = %d; want 2 (first resumable row)", d.cursor) | |||
| 497 | + | } | |||
| 498 | + | if sel := d.selected(); sel == nil || sel.ID != "detached-1" { | |||
| 499 | + | t.Fatalf("selected = %+v; want detached-1", sel) | |||
| 500 | + | } | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | // TestOpenForResumeWithNoResumableLeavesDialogActive ensures the | |||
| 504 | + | // picker still opens the dashboard when nothing is resumable, so the | |||
| 505 | + | // user sees what's there instead of getting silently nothing. The | |||
| 506 | + | // slash handler is responsible for surfacing the "no resumable | |||
| 507 | + | // agents" status string — the dialog itself just opens. | |||
| 508 | + | func TestOpenForResumeWithNoResumableLeavesDialogActive(t *testing.T) { | |||
| 509 | + | rows := []swarm.AgentSnapshot{ | |||
| 510 | + | {ID: "live-1", Status: swarm.StatusRunning, Started: time.Now()}, | |||
| 511 | + | } | |||
| 512 | + | d := newSwarmDialog() | |||
| 513 | + | count := d.OpenForResume( | |||
| 514 | + | staticSnapshots(rows...), | |||
| 515 | + | nil, nil, nil, nil, | |||
| 516 | + | func(id string) error { return nil }, | |||
| 517 | + | "", | |||
| 518 | + | ) | |||
| 519 | + | if count != 0 { | |||
| 520 | + | t.Fatalf("resumable count = %d; want 0", count) | |||
| 521 | + | } | |||
| 522 | + | if !d.Active() { | |||
| 523 | + | t.Fatal("dialog should remain active so the user sees the empty state") | |||
| 524 | + | } | |||
| 525 | + | if d.cursor != 0 { | |||
| 526 | + | t.Fatalf("cursor = %d; want 0 (no parking)", d.cursor) | |||
| 527 | + | } | |||
| 528 | + | } | |||
| 529 | + | ||||
| 530 | + | // TestOpenForResumeREnterTriggersResume drives the full picker UX: | |||
| 531 | + | // /swarm resume → dialog opens parked on a resumable row → user | |||
| 532 | + | // presses R → the resume callback fires for that exact id. | |||
| 533 | + | func TestOpenForResumeREnterTriggersResume(t *testing.T) { | |||
| 534 | + | rows := []swarm.AgentSnapshot{ | |||
| 535 | + | {ID: "live-1", Status: swarm.StatusRunning, Started: time.Now()}, | |||
| 536 | + | {ID: "detached-1", Status: swarm.StatusDetached, Started: time.Now()}, | |||
| 537 | + | } | |||
| 538 | + | var resumed string | |||
| 539 | + | d := newSwarmDialog() | |||
| 540 | + | d.OpenForResume( | |||
| 541 | + | staticSnapshots(rows...), | |||
| 542 | + | nil, nil, nil, nil, | |||
| 543 | + | func(id string) error { resumed = id; return nil }, | |||
| 544 | + | "", | |||
| 545 | + | ) | |||
| 546 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 547 | + | _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'R'}) | |||
| 548 | + | if errMsg != "" { | |||
| 549 | + | t.Fatalf("unexpected err: %s", errMsg) | |||
| 550 | + | } | |||
| 551 | + | if resumed != "detached-1" { | |||
| 552 | + | t.Fatalf("resume callback got %q; want detached-1", resumed) | |||
| 553 | + | } | |||
| 554 | + | if msg != "resumed detached-1" { | |||
| 555 | + | t.Fatalf("status msg = %q", msg) | |||
| 556 | + | } | |||
| 557 | + | } | |||
| 558 | + | ||||
| 559 | + | // TestNeedsTickRefresh enumerates the four dialog states so the host's | |||
| 560 | + | // animation tick wakes the renderer for live updates but stays out of | |||
| 561 | + | // the way of the inline editors (which need the cursor blink). | |||
| 562 | + | func TestNeedsTickRefresh(t *testing.T) { | |||
| 563 | + | // Closed. | |||
| 564 | + | d := newSwarmDialog() | |||
| 565 | + | if d.NeedsTickRefresh() { | |||
| 566 | + | t.Fatal("closed dialog should not request tick refresh") | |||
| 567 | + | } | |||
| 568 | + | ||||
| 569 | + | // List view (open, no editors): tick should refresh so rows update. | |||
| 570 | + | d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") | |||
| 571 | + | if !d.NeedsTickRefresh() { | |||
| 572 | + | t.Fatal("open list view should request tick refresh") | |||
| 573 | + | } | |||
| 574 | + | ||||
| 575 | + | // Transcript view: still background-driven; tick should refresh. | |||
| 576 | + | rows := []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}} | |||
| 577 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | |||
| 578 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 579 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 580 | + | if !d.viewing { | |||
| 581 | + | t.Fatal("setup: enter did not open transcript view") | |||
| 582 | + | } | |||
| 583 | + | if !d.NeedsTickRefresh() { | |||
| 584 | + | t.Fatal("transcript view should request tick refresh") | |||
| 585 | + | } | |||
| 586 | + | ||||
| 587 | + | // Spawn editor open: tick should NOT refresh (cursor blink). | |||
| 588 | + | d = newSwarmDialog() | |||
| 589 | + | d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { return nil }, nil, nil, "") | |||
| 590 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 591 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | |||
| 592 | + | if !d.spawning { | |||
| 593 | + | t.Fatal("setup: n did not enter spawning mode") | |||
| 594 | + | } | |||
| 595 | + | if d.NeedsTickRefresh() { | |||
| 596 | + | t.Fatal("spawn editor should suppress tick refresh") | |||
| 597 | + | } | |||
| 598 | + | ||||
| 599 | + | // Prompt editor open: same suppression. | |||
| 600 | + | d = newSwarmDialog() | |||
| 601 | + | rows = []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}} | |||
| 602 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, func(string, string) error { return nil }, nil, "") | |||
| 603 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 604 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) | |||
| 605 | + | if !d.prompting { | |||
| 606 | + | t.Fatal("setup: p did not enter prompting mode") | |||
| 607 | + | } | |||
| 608 | + | if d.NeedsTickRefresh() { | |||
| 609 | + | t.Fatal("prompt editor should suppress tick refresh") | |||
| 610 | + | } | |||
| 611 | + | } | |||
| 612 | + | ||||
| 613 | + | // TestCursorPosPromptEditor verifies the dialog reports a real | |||
| 614 | + | // caret row for the prompt editor in both list-view and | |||
| 615 | + | // transcript-view contexts. Without this, the terminal cursor | |||
| 616 | + | // stayed at the (now-hidden) main editor row when the user | |||
| 617 | + | // pressed 'p', so the visible caret looked wrong. | |||
| 618 | + | func TestCursorPosPromptEditor(t *testing.T) { | |||
| 619 | + | rows := []swarm.AgentSnapshot{ | |||
| 620 | + | {ID: "a", Task: "t", Status: swarm.StatusRunning, Started: time.Now()}, | |||
| 621 | + | } | |||
| 622 | + | ||||
| 623 | + | // From the list view. | |||
| 624 | + | d := newSwarmDialog() | |||
| 625 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, | |||
| 626 | + | func(string, string) error { return nil }, nil, "") | |||
| 627 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 628 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) | |||
| 629 | + | if !d.prompting { | |||
| 630 | + | t.Fatal("setup: p did not enter prompting from list view") | |||
| 631 | + | } | |||
| 632 | + | row, _ := d.CursorPos(80) | |||
| 633 | + | if row < 1 { | |||
| 634 | + | t.Fatalf("list-view prompt CursorPos row = %d; want >= 1", row) | |||
| 635 | + | } | |||
| 636 | + | ||||
| 637 | + | // From the transcript view (deeper offset due to metadata block). | |||
| 638 | + | d = newSwarmDialog() | |||
| 639 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, | |||
| 640 | + | func(string, string) error { return nil }, nil, "") | |||
| 641 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 642 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open transcript | |||
| 643 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) | |||
| 644 | + | if !d.prompting || !d.viewing { | |||
| 645 | + | t.Fatalf("setup: prompting=%v viewing=%v", d.prompting, d.viewing) | |||
| 646 | + | } | |||
| 647 | + | rowT, _ := d.CursorPos(80) | |||
| 648 | + | if rowT <= row { | |||
| 649 | + | t.Fatalf("transcript-view prompt row = %d; should be deeper than list row %d", rowT, row) | |||
| 650 | + | } | |||
| 651 | + | } | |||
| 652 | + | ||||
| 653 | + | // TestSwarmDialogPOnNonRunningAgentShowsHint covers the regression | |||
| 654 | + | // caught by the user's screenshot: pressing 'p' on a detached agent | |||
| 655 | + | // dialled a dead unix socket and printed the raw path as an error. | |||
| 656 | + | // We now block the prompt editor for any non-running status and | |||
| 657 | + | // surface a clear status-bar hint instead. | |||
| 658 | + | func TestSwarmDialogPOnNonRunningAgentShowsHint(t *testing.T) { | |||
| 659 | + | for _, st := range []swarm.Status{ | |||
| 660 | + | swarm.StatusDetached, swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled, | |||
| 661 | + | } { | |||
| 662 | + | t.Run(string(st), func(t *testing.T) { | |||
| 663 | + | rows := []swarm.AgentSnapshot{{ID: "a", Status: st, Started: time.Now()}} | |||
| 664 | + | sends := 0 | |||
| 665 | + | d := newSwarmDialog() | |||
| 666 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, | |||
| 667 | + | func(string, string) error { sends++; return nil }, nil, "") | |||
| 668 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 669 | + | ||||
| 670 | + | _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) | |||
| 671 | + | if d.prompting { | |||
| 672 | + | t.Fatal("prompt editor opened on non-running agent") | |||
| 673 | + | } | |||
| 674 | + | if sends != 0 { | |||
| 675 | + | t.Fatal("send callback fired without an open editor") | |||
| 676 | + | } | |||
| 677 | + | if msg != "" { | |||
| 678 | + | t.Errorf("unexpected ok msg: %q", msg) | |||
| 679 | + | } | |||
| 680 | + | if !strings.Contains(errMsg, string(st)) { | |||
| 681 | + | t.Errorf("hint = %q; should mention status %q", errMsg, st) | |||
| 682 | + | } | |||
| 683 | + | if !strings.Contains(errMsg, "resume") && st != swarm.StatusPending && st != swarm.StatusRunning { | |||
| 684 | + | t.Errorf("hint = %q; should mention resume for resumable state", errMsg) | |||
| 685 | + | } | |||
| 686 | + | }) | |||
| 687 | + | } | |||
| 688 | + | } | |||
| 689 | + | ||||
| 690 | + | // TestSwarmDialogPOnRunningAgentOpensEditor is the positive | |||
| 691 | + | // complement of the previous test: running agents still open the | |||
| 692 | + | // editor normally. | |||
| 693 | + | func TestSwarmDialogPOnRunningAgentOpensEditor(t *testing.T) { | |||
| 694 | + | rows := []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}} | |||
| 695 | + | d := newSwarmDialog() | |||
| 696 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, | |||
| 697 | + | func(string, string) error { return nil }, nil, "") | |||
| 698 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 699 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) | |||
| 700 | + | if !d.prompting { | |||
| 701 | + | t.Fatal("prompt editor did not open on running agent") | |||
| 702 | + | } | |||
| 703 | + | } | |||
| 704 | + | ||||
| 705 | + | // TestFriendlySendErrRewritesNotReady asserts ErrNotReady becomes the | |||
| 706 | + | // user-friendly "isn't accepting input" hint, and unknown errors | |||
| 707 | + | // fall through verbatim. | |||
| 708 | + | func TestFriendlySendErrRewritesNotReady(t *testing.T) { | |||
| 709 | + | hint := friendlySendErr("alpha-1", swarm.ErrNotReady) | |||
| 710 | + | if !strings.Contains(hint, "alpha-1") || !strings.Contains(hint, "resume") { | |||
| 711 | + | t.Errorf("friendlySendErr(ErrNotReady) = %q", hint) | |||
| 712 | + | } | |||
| 713 | + | if strings.Contains(hint, "swarm:") { | |||
| 714 | + | t.Errorf("friendlySendErr leaked raw error text: %q", hint) | |||
| 715 | + | } | |||
| 716 | + | ||||
| 717 | + | other := errors.New("some other failure") | |||
| 718 | + | got := friendlySendErr("alpha-1", other) | |||
| 719 | + | if got != "send: some other failure" { | |||
| 720 | + | t.Errorf("friendlySendErr passthrough = %q", got) | |||
| 721 | + | } | |||
| 722 | + | } | |||
| 723 | + | ||||
| 724 | + | // TestRenderSwarmTranscriptBlocks_GroupsByRole asserts the flat | |||
| 725 | + | // transcript log is regrouped into user / assistant / stderr / | |||
| 726 | + | // error blocks before rendering. Each output row should be styled | |||
| 727 | + | // for its role rather than printed verbatim. | |||
| 728 | + | func TestRenderSwarmTranscriptBlocks_GroupsByRole(t *testing.T) { | |||
| 729 | + | lines := []string{ | |||
| 730 | + | "user: hello", | |||
| 731 | + | "hi there", | |||
| 732 | + | "second assistant line", | |||
| 733 | + | "stderr: warning thing", | |||
| 734 | + | "error: boom", | |||
| 735 | + | } | |||
| 736 | + | out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80) | |||
| 737 | + | joined := strings.Join(out, "\n") | |||
| 738 | + | ||||
| 739 | + | // The user echo should appear as a bubble row, which contains | |||
| 740 | + | // the "▌ " left-bar glyph. | |||
| 741 | + | if !strings.Contains(joined, "▌ ") { | |||
| 742 | + | t.Errorf("user echo did not render as a bubble row:\n%s", joined) | |||
| 743 | + | } | |||
| 744 | + | // The user prefix should be stripped from the rendered output. | |||
| 745 | + | if strings.Contains(joined, "user: hello") { | |||
| 746 | + | t.Errorf("user prefix leaked into rendered output") | |||
| 747 | + | } | |||
| 748 | + | // Assistant content survives. | |||
| 749 | + | if !strings.Contains(joined, "hi there") { | |||
| 750 | + | t.Errorf("assistant text missing from rendered output") | |||
| 751 | + | } | |||
| 752 | + | // Stderr line is prefixed with stderr marker (we keep "stderr" | |||
| 753 | + | // word in the rendered output for clarity). | |||
| 754 | + | if !strings.Contains(joined, "stderr") || !strings.Contains(joined, "warning thing") { | |||
| 755 | + | t.Errorf("stderr block missing:\n%s", joined) | |||
| 756 | + | } | |||
| 757 | + | // Error line gets the ✖ marker. | |||
| 758 | + | if !strings.Contains(joined, "✖ boom") { | |||
| 759 | + | t.Errorf("error block missing ✖ marker:\n%s", joined) | |||
| 760 | + | } | |||
| 761 | + | } | |||
| 762 | + | ||||
| 763 | + | // TestRenderSwarmTranscriptBlocks_MarkdownStyling proves the | |||
| 764 | + | // assistant text goes through tui.RenderMarkdown: a literal **bold** | |||
| 765 | + | // pair should become an ANSI bold sequence and bullet "-" should | |||
| 766 | + | // become "•". | |||
| 767 | + | func TestRenderSwarmTranscriptBlocks_MarkdownStyling(t *testing.T) { | |||
| 768 | + | lines := []string{ | |||
| 769 | + | "Here is a list:", | |||
| 770 | + | "- **bold** item", | |||
| 771 | + | "- plain item", | |||
| 772 | + | } | |||
| 773 | + | out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80) | |||
| 774 | + | joined := strings.Join(out, "\n") | |||
| 775 | + | ||||
| 776 | + | // ANSI bold on/off. Markdown rendering uses CSI 1 / 22. | |||
| 777 | + | if !strings.Contains(joined, "\x1b[1m") || !strings.Contains(joined, "\x1b[22m") { | |||
| 778 | + | t.Errorf("expected ANSI bold sequences:\n%q", joined) | |||
| 779 | + | } | |||
| 780 | + | // Bullet glyph upgrade. | |||
| 781 | + | if !strings.Contains(joined, "• ") { | |||
| 782 | + | t.Errorf("expected • bullet:\n%s", joined) | |||
| 783 | + | } | |||
| 784 | + | // Literal "**bold**" markdown source must NOT leak through. | |||
| 785 | + | if strings.Contains(joined, "**bold**") { | |||
| 786 | + | t.Errorf("unrendered markdown leaked through:\n%s", joined) | |||
| 787 | + | } | |||
| 788 | + | } | |||
| 789 | + | ||||
| 790 | + | // TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines | |||
| 791 | + | // guards the contract that consecutive assistant lines form one | |||
| 792 | + | // markdown block. If we render each line in isolation, fenced code | |||
| 793 | + | // (and other multi-line constructs) would never close. | |||
| 794 | + | func TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines(t *testing.T) { | |||
| 795 | + | lines := []string{ | |||
| 796 | + | "Some prose.", | |||
| 797 | + | "```go", | |||
| 798 | + | "package main", | |||
| 799 | + | "", | |||
| 800 | + | "func main() {}", | |||
| 801 | + | "```", | |||
| 802 | + | "Done.", | |||
| 803 | + | } | |||
| 804 | + | out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80) | |||
| 805 | + | joined := strings.Join(out, "\n") | |||
| 806 | + | ||||
| 807 | + | // The literal fence markers should be consumed by the markdown | |||
| 808 | + | // renderer; if we rendered each line on its own the ``` would | |||
| 809 | + | // leak as text. | |||
| 810 | + | if strings.Contains(joined, "```") { | |||
| 811 | + | t.Errorf("fence markers leaked — lines weren't grouped:\n%s", joined) | |||
| 812 | + | } | |||
| 813 | + | // The code content should still be in the output — strip ANSI | |||
| 814 | + | // before checking because syntax highlighting wraps each token. | |||
| 815 | + | plain := stripANSIBytes(joined) | |||
| 816 | + | if !strings.Contains(plain, "package main") || !strings.Contains(plain, "func main") { | |||
| 817 | + | t.Errorf("code block content missing:\nansi=%q\nplain=%q", joined, plain) | |||
| 818 | + | } | |||
| 819 | + | } | |||
| 820 | + | ||||
| 821 | + | // TestSwarmDialogKOnNonRunningAgentShowsHint mirrors the prompt- | |||
| 822 | + | // disabled test: pressing 'k' on an already-terminal or detached | |||
| 823 | + | // agent now surfaces a hint instead of routing to Swarm.Stop, which | |||
| 824 | + | // is a no-op for those states (and used to segfault for detached | |||
| 825 | + | // agents before the cancel-nil guard). | |||
| 826 | + | func TestSwarmDialogKOnNonRunningAgentShowsHint(t *testing.T) { | |||
| 827 | + | for _, st := range []swarm.Status{ | |||
| 828 | + | swarm.StatusDetached, swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled, | |||
| 829 | + | } { | |||
| 830 | + | t.Run(string(st), func(t *testing.T) { | |||
| 831 | + | rows := []swarm.AgentSnapshot{{ID: "a", Status: st, Started: time.Now()}} | |||
| 832 | + | stops := 0 | |||
| 833 | + | d := newSwarmDialog() | |||
| 834 | + | d.Open(staticSnapshots(rows...), | |||
| 835 | + | func(string) error { stops++; return nil }, | |||
| 836 | + | nil, nil, nil, nil, "") | |||
| 837 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 838 | + | _, _, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'k'}) | |||
| 839 | + | if stops != 0 { | |||
| 840 | + | t.Fatalf("stop callback fired on %s agent", st) | |||
| 841 | + | } | |||
| 842 | + | if !strings.Contains(errMsg, string(st)) || !strings.Contains(errMsg, "remove") { | |||
| 843 | + | t.Errorf("hint = %q; should mention %q and suggest remove", errMsg, st) | |||
| 844 | + | } | |||
| 845 | + | }) | |||
| 846 | + | } | |||
| 847 | + | } | |||
| 848 | + | ||||
| 849 | + | // TestSwarmDialogSpawnFlow_CurrentModelInherited proves the spawn | |||
| 850 | + | // callback receives whatever model the host pinned via | |||
| 851 | + | // SetCurrentModel before opening the dialog. This is how /model is | |||
| 852 | + | // surfaced to swarm agents: the user picks via /model first, then | |||
| 853 | + | // /swarm n, and the new agent inherits. | |||
| 854 | + | func TestSwarmDialogSpawnFlow_CurrentModelInherited(t *testing.T) { | |||
| 855 | + | var gotTask, gotModel, gotProv string | |||
| 856 | + | d := newSwarmDialog() | |||
| 857 | + | d.SetCurrentModel("claude-sonnet-4-5", "anthropic") | |||
| 858 | + | d.Open(staticSnapshots(), nil, nil, | |||
| 859 | + | func(task, model, prov string) error { | |||
| 860 | + | gotTask, gotModel, gotProv = task, model, prov | |||
| 861 | + | return nil | |||
| 862 | + | }, | |||
| 863 | + | nil, nil, "") | |||
| 864 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 865 | + | ||||
| 866 | + | // `n` goes straight to the spawn editor; no picker step. | |||
| 867 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | |||
| 868 | + | if !d.spawning { | |||
| 869 | + | t.Fatal("n did not enter spawning mode") | |||
| 870 | + | } | |||
| 871 | + | ||||
| 872 | + | // The spawn editor advertises the inherited model so the user | |||
| 873 | + | // knows what they're about to spawn against. | |||
| 874 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |||
| 875 | + | if !strings.Contains(out, "model: claude-sonnet-4-5") { | |||
| 876 | + | t.Errorf("spawn editor missing model banner:\n%s", out) | |||
| 877 | + | } | |||
| 878 | + | ||||
| 879 | + | for _, r := range "do thing" { | |||
| 880 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |||
| 881 | + | } | |||
| 882 | + | _, msg, _ := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 883 | + | if gotTask != "do thing" || gotModel != "claude-sonnet-4-5" || gotProv != "anthropic" { | |||
| 884 | + | t.Fatalf("spawn args = (%q,%q,%q); want (\"do thing\",\"claude-sonnet-4-5\",\"anthropic\")", | |||
| 885 | + | gotTask, gotModel, gotProv) | |||
| 886 | + | } | |||
| 887 | + | if !strings.Contains(msg, "claude-sonnet-4-5") { | |||
| 888 | + | t.Errorf("status msg = %q; should mention inherited model", msg) | |||
| 889 | + | } | |||
| 890 | + | } | |||
| 891 | + | ||||
| 892 | + | // TestSwarmDialogSpawnEditor_SlashModelOpensPicker proves the in- | |||
| 893 | + | // editor /model command pops the same model dialog the global | |||
| 894 | + | // /model uses, that picking a model captures it as the pending | |||
| 895 | + | // override, and that the spawn editor reopens with any previously- | |||
| 896 | + | // typed task text intact so the user doesn't lose work. | |||
| 897 | + | func TestSwarmDialogSpawnEditor_SlashModelOpensPicker(t *testing.T) { | |||
| 898 | + | var gotTask, gotModel, gotProv string | |||
| 899 | + | d := newSwarmDialog() | |||
| 900 | + | d.SetCurrentModel("old-model", "old-prov") | |||
| 901 | + | d.Open(staticSnapshots(), nil, nil, | |||
| 902 | + | func(task, model, prov string) error { | |||
| 903 | + | gotTask, gotModel, gotProv = task, model, prov | |||
| 904 | + | return nil | |||
| 905 | + | }, | |||
| 906 | + | nil, nil, "") | |||
| 907 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 908 | + | ||||
| 909 | + | // Open spawn editor and type some task text plus a /model line. | |||
| 910 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | |||
| 911 | + | if !d.spawning { | |||
| 912 | + | t.Fatal("n did not enter spawning mode") | |||
| 913 | + | } | |||
| 914 | + | for _, r := range "refactor X" { | |||
| 915 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |||
| 916 | + | } | |||
| 917 | + | // Newline so the editor has a two-line buffer when we add /model. | |||
| 918 | + | d.newTaskEd.SetValue("refactor X\n/model") | |||
| 919 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 920 | + | ||||
| 921 | + | if !d.pickingModel { | |||
| 922 | + | t.Fatalf("/model submit did not open the picker (spawning=%v picking=%v)", d.spawning, d.pickingModel) | |||
| 923 | + | } | |||
| 924 | + | if d.spawning { | |||
| 925 | + | t.Fatal("spawn editor still marked open while picker is up") | |||
| 926 | + | } | |||
| 927 | + | if d.spawnDraft != "refactor X" { | |||
| 928 | + | t.Fatalf("spawnDraft = %q; want %q (the /model line should be stripped, the task kept)", d.spawnDraft, "refactor X") | |||
| 929 | + | } | |||
| 930 | + | ||||
| 931 | + | // Picker render must take over the dialog frame. | |||
| 932 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |||
| 933 | + | if !strings.Contains(out, "select model for next spawn") { | |||
| 934 | + | t.Errorf("picker render hint missing:\n%s", out) | |||
| 935 | + | } | |||
| 936 | + | ||||
| 937 | + | // Inject a fake selection into the embedded modelDialog and confirm. | |||
| 938 | + | d.modelPicker.all = []provider.Model{{Provider: "acme", ID: "fake-1"}} | |||
| 939 | + | d.modelPicker.view = d.modelPicker.all | |||
| 940 | + | d.modelPicker.cursor = 0 | |||
| 941 | + | _, msg, _ := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 942 | + | ||||
| 943 | + | if d.pickingModel { | |||
| 944 | + | t.Fatal("picker did not close on select") | |||
| 945 | + | } | |||
| 946 | + | if !d.spawning { | |||
| 947 | + | t.Fatal("spawn editor did not reopen after picker select") | |||
| 948 | + | } | |||
| 949 | + | if d.pendingModel != "fake-1" || d.pendingProvider != "acme" { | |||
| 950 | + | t.Errorf("pending = (%q,%q); want (fake-1, acme)", d.pendingModel, d.pendingProvider) | |||
| 951 | + | } | |||
| 952 | + | if !strings.Contains(msg, "fake-1") { | |||
| 953 | + | t.Errorf("status msg = %q; should mention picked model", msg) | |||
| 954 | + | } | |||
| 955 | + | if got := d.newTaskEd.Value(); got != "refactor X" { | |||
| 956 | + | t.Errorf("reopened editor buffer = %q; want preserved task %q", got, "refactor X") | |||
| 957 | + | } | |||
| 958 | + | ||||
| 959 | + | // Now submit the task and confirm the spawn callback gets the | |||
| 960 | + | // new model. | |||
| 961 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 962 | + | if gotTask != "refactor X" || gotModel != "fake-1" || gotProv != "acme" { | |||
| 963 | + | t.Errorf("spawn args = (%q,%q,%q); want (refactor X, fake-1, acme)", gotTask, gotModel, gotProv) | |||
| 964 | + | } | |||
| 965 | + | } | |||
| 966 | + | ||||
| 967 | + | // TestSwarmDialogSpawnEditor_SlashModelEscRestoresDraft confirms | |||
| 968 | + | // pressing Esc inside the picker (no selection) reopens the spawn | |||
| 969 | + | // editor with the previously-typed text intact AND leaves the | |||
| 970 | + | // pending model untouched, so the user can bail out cleanly. | |||
| 971 | + | func TestSwarmDialogSpawnEditor_SlashModelEscRestoresDraft(t *testing.T) { | |||
| 972 | + | d := newSwarmDialog() | |||
| 973 | + | d.SetCurrentModel("keep-me", "keep-prov") | |||
| 974 | + | d.Open(staticSnapshots(), nil, nil, | |||
| 975 | + | func(string, string, string) error { return nil }, | |||
| 976 | + | nil, nil, "") | |||
| 977 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 978 | + | ||||
| 979 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | |||
| 980 | + | d.newTaskEd.SetValue("task here\n/model") | |||
| 981 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 982 | + | if !d.pickingModel { | |||
| 983 | + | t.Fatal("/model did not open the picker") | |||
| 984 | + | } | |||
| 985 | + | ||||
| 986 | + | d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |||
| 987 | + | if d.pickingModel { | |||
| 988 | + | t.Fatal("esc did not close the picker") | |||
| 989 | + | } | |||
| 990 | + | if !d.spawning { | |||
| 991 | + | t.Fatal("spawn editor did not reopen after picker cancel") | |||
| 992 | + | } | |||
| 993 | + | if d.pendingModel != "keep-me" || d.pendingProvider != "keep-prov" { | |||
| 994 | + | t.Errorf("pending changed on Esc: (%q,%q); want (keep-me, keep-prov)", d.pendingModel, d.pendingProvider) | |||
| 995 | + | } | |||
| 996 | + | if got := d.newTaskEd.Value(); got != "task here" { | |||
| 997 | + | t.Errorf("reopened editor buffer = %q; want %q", got, "task here") | |||
| 998 | + | } | |||
| 999 | + | } | |||
| 1000 | + | ||||
| 1001 | + | // TestStripSlashModelLine pins the helper's behaviour: drop any | |||
| 1002 | + | // line whose trimmed content is "/model" or starts with "/model ", | |||
| 1003 | + | // preserve everything else verbatim. | |||
| 1004 | + | func TestStripSlashModelLine(t *testing.T) { | |||
| 1005 | + | cases := []struct{ in, want string }{ | |||
| 1006 | + | {"/model", ""}, | |||
| 1007 | + | {" /model ", ""}, | |||
| 1008 | + | {"foo\n/model", "foo"}, | |||
| 1009 | + | {"/model\nfoo", "foo"}, | |||
| 1010 | + | {"foo\n/model\nbar", "foo\nbar"}, | |||
| 1011 | + | {"/model gpt-5", ""}, | |||
| 1012 | + | {"a /model in prose", "a /model in prose"}, // not first non-blank token → keep | |||
| 1013 | + | {"", ""}, | |||
| 1014 | + | {"plain task", "plain task"}, | |||
| 1015 | + | } | |||
| 1016 | + | for _, c := range cases { | |||
| 1017 | + | if got := stripSlashModelLine(c.in); got != c.want { | |||
| 1018 | + | t.Errorf("stripSlashModelLine(%q) = %q; want %q", c.in, got, c.want) | |||
| 1019 | + | } | |||
| 1020 | + | } | |||
| 1021 | + | } | |||
| 1022 | + | ||||
| 1023 | + | // TestSwarmDialogSpawnFlow_NoModelInheritsChildDefault confirms that | |||
| 1024 | + | // when the host doesn't pin a model (SetCurrentModel never called or | |||
| 1025 | + | // called with empty strings), the spawn callback receives empty | |||
| 1026 | + | // model/provider and the child subprocess resolves its own default. | |||
| 1027 | + | func TestSwarmDialogSpawnFlow_NoModelInheritsChildDefault(t *testing.T) { | |||
| 1028 | + | var gotModel, gotProv string | |||
| 1029 | + | d := newSwarmDialog() | |||
| 1030 | + | d.Open(staticSnapshots(), nil, nil, | |||
| 1031 | + | func(_, model, prov string) error { | |||
| 1032 | + | gotModel, gotProv = model, prov | |||
| 1033 | + | return nil | |||
| 1034 | + | }, | |||
| 1035 | + | nil, nil, "") | |||
| 1036 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 1037 | + | ||||
| 1038 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | |||
| 1039 | + | for _, r := range "do thing" { | |||
| 1040 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |||
| 1041 | + | } | |||
| 1042 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 1043 | + | if gotModel != "" || gotProv != "" { | |||
| 1044 | + | t.Fatalf("spawn args = (model=%q, prov=%q); want both empty", gotModel, gotProv) | |||
| 1045 | + | } | |||
| 1046 | + | } | |||
| 1047 | + | ||||
| 1048 | + | // TestSwarmTranscriptGrowsWithNewMessages confirms the /btw-style | |||
| 1049 | + | // "the transcript grows with the content" property: when the agent | |||
| 1050 | + | // reply lands in the snapshot, the next render includes it in full, | |||
| 1051 | + | // alongside everything that was already there. Replaces the old | |||
| 1052 | + | // followTail/scroll tests that pinned a viewport mechanic we no | |||
| 1053 | + | // longer have. | |||
| 1054 | + | func TestSwarmTranscriptGrowsWithNewMessages(t *testing.T) { | |||
| 1055 | + | cur := []swarm.AgentSnapshot{{ | |||
| 1056 | + | ID: "agent-1", Task: "x", Status: swarm.StatusRunning, | |||
| 1057 | + | Lines: []string{"old line 1", "old line 2"}, Started: time.Now(), | |||
| 1058 | + | }} | |||
| 1059 | + | snap := func() []swarm.AgentSnapshot { return cur } | |||
| 1060 | + | ||||
| 1061 | + | d := newSwarmDialog() | |||
| 1062 | + | d.Open(snap, nil, nil, nil, | |||
| 1063 | + | func(string, string) error { return nil }, | |||
| 1064 | + | nil, "") | |||
| 1065 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 1066 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 1067 | + | ||||
| 1068 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |||
| 1069 | + | if !strings.Contains(out, "old line 1") || !strings.Contains(out, "old line 2") { | |||
| 1070 | + | t.Fatalf("initial render missing existing lines:\n%s", out) | |||
| 1071 | + | } | |||
| 1072 | + | ||||
| 1073 | + | // Agent appends a fresh reply: the very next render must show | |||
| 1074 | + | // both the new content AND keep the old content (no clipping). | |||
| 1075 | + | cur = []swarm.AgentSnapshot{{ | |||
| 1076 | + | ID: "agent-1", Task: "x", Status: swarm.StatusRunning, | |||
| 1077 | + | Lines: []string{"old line 1", "old line 2", "new reply A", "new reply B"}, | |||
| 1078 | + | Started: cur[0].Started, | |||
| 1079 | + | }} | |||
| 1080 | + | out = strings.Join(d.Render(tui.Theme{}, 80), "\n") | |||
| 1081 | + | for _, want := range []string{"old line 1", "old line 2", "new reply A", "new reply B"} { | |||
| 1082 | + | if !strings.Contains(out, want) { | |||
| 1083 | + | t.Errorf("line %q missing after growth:\n%s", want, out) | |||
| 1084 | + | } | |||
| 1085 | + | } | |||
| 1086 | + | } | |||
| 1087 | + | ||||
| 1088 | + | // TestSwarmTranscriptBtwStyleMultipleSends pins the /btw-style flow | |||
| 1089 | + | // for the transcript view: after one Enter-submit the editor stays | |||
| 1090 | + | // mounted and clears, and a second message can be composed and sent | |||
| 1091 | + | // without any modal toggle. Regresses the previous design where | |||
| 1092 | + | // every send closed the prompt and forced the user to press `p` | |||
| 1093 | + | // again. | |||
| 1094 | + | func TestSwarmTranscriptBtwStyleMultipleSends(t *testing.T) { | |||
| 1095 | + | rows := []swarm.AgentSnapshot{{ | |||
| 1096 | + | ID: "agent-1", Task: "x", Status: swarm.StatusRunning, Started: time.Now(), | |||
| 1097 | + | Lines: []string{"warm-up"}, | |||
| 1098 | + | }} | |||
| 1099 | + | var sent []string | |||
| 1100 | + | d := newSwarmDialog() | |||
| 1101 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, | |||
| 1102 | + | func(_, text string) error { sent = append(sent, text); return nil }, | |||
| 1103 | + | nil, "") | |||
| 1104 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 1105 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open transcript + auto-editor | |||
| 1106 | + | ||||
| 1107 | + | // First message. | |||
| 1108 | + | for _, r := range "first" { | |||
| 1109 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |||
| 1110 | + | } | |||
| 1111 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 1112 | + | ||||
| 1113 | + | // Second message, no `p` toggle in between. | |||
| 1114 | + | for _, r := range "second" { | |||
| 1115 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |||
| 1116 | + | } | |||
| 1117 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 1118 | + | ||||
| 1119 | + | if len(sent) != 2 || sent[0] != "first" || sent[1] != "second" { | |||
| 1120 | + | t.Fatalf("sent = %v; want [first second]", sent) | |||
| 1121 | + | } | |||
| 1122 | + | if !d.viewing || !d.prompting { | |||
| 1123 | + | t.Errorf("transcript view/editor torn down between sends (viewing=%v prompting=%v)", d.viewing, d.prompting) | |||
| 1124 | + | } | |||
| 1125 | + | } | |||
| 1126 | + | ||||
| 1127 | + | // TestSwarmTranscriptEscClosesView pins the Esc semantics for the | |||
| 1128 | + | // /btw-style transcript view: a single Esc closes the whole view | |||
| 1129 | + | // (back to the dashboard list), regardless of whether the editor | |||
| 1130 | + | // is currently composing. Without this routing the editor's own | |||
| 1131 | + | // Esc handler would just clear the modal and leave the user stuck | |||
| 1132 | + | // inside an empty transcript view. | |||
| 1133 | + | func TestSwarmTranscriptEscClosesView(t *testing.T) { | |||
| 1134 | + | rows := []swarm.AgentSnapshot{{ | |||
| 1135 | + | ID: "agent-1", Task: "x", Status: swarm.StatusRunning, Started: time.Now(), | |||
| 1136 | + | Lines: []string{"hi"}, | |||
| 1137 | + | }} | |||
| 1138 | + | d := newSwarmDialog() | |||
| 1139 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, | |||
| 1140 | + | func(string, string) error { return nil }, | |||
| 1141 | + | nil, "") | |||
| 1142 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 1143 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 1144 | + | if !d.viewing || !d.prompting { | |||
| 1145 | + | t.Fatalf("setup: viewing=%v prompting=%v", d.viewing, d.prompting) | |||
| 1146 | + | } | |||
| 1147 | + | ||||
| 1148 | + | d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |||
| 1149 | + | if d.viewing { | |||
| 1150 | + | t.Error("Esc did not close the transcript view") | |||
| 1151 | + | } | |||
| 1152 | + | if d.prompting { | |||
| 1153 | + | t.Error("Esc did not tear down the inline editor") | |||
| 1154 | + | } | |||
| 1155 | + | } | |||
| 1156 | + | ||||
| 1157 | + | // TestSwarmTranscriptNonRunningAgentNoEditor pins the safety net: | |||
| 1158 | + | // transcripts for done / failed / killed agents must NOT auto-open | |||
| 1159 | + | // an editor (the agent's inbox isn't listening, so a send would | |||
| 1160 | + | // just bounce). The view shows the transcript plus a hint about | |||
| 1161 | + | // resuming instead. | |||
| 1162 | + | func TestSwarmTranscriptNonRunningAgentNoEditor(t *testing.T) { | |||
| 1163 | + | rows := []swarm.AgentSnapshot{{ | |||
| 1164 | + | ID: "agent-1", Task: "x", Status: swarm.StatusDone, Started: time.Now(), | |||
| 1165 | + | Lines: []string{"all done"}, | |||
| 1166 | + | }} | |||
| 1167 | + | d := newSwarmDialog() | |||
| 1168 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, | |||
| 1169 | + | func(string, string) error { return nil }, | |||
| 1170 | + | nil, "") | |||
| 1171 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 1172 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 1173 | + | if d.prompting { | |||
| 1174 | + | t.Fatal("editor auto-opened for a done agent; should only happen for running ones") | |||
| 1175 | + | } | |||
| 1176 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |||
| 1177 | + | if !strings.Contains(out, "prompt: agent is done") { | |||
| 1178 | + | t.Errorf("hint about non-running status missing:\n%s", out) | |||
| 1179 | + | } | |||
| 1180 | + | } | |||
| 1181 | + | ||||
| 1182 | + | // TestSwarmTranscriptTickRefreshWhileEditing pins the streaming | |||
| 1183 | + | // fix: with the always-on editor in transcript view, the host's | |||
| 1184 | + | // animation tick MUST still call us so streaming agent output | |||
| 1185 | + | // renders. Without this, sending a message and watching the reply | |||
| 1186 | + | // land silently failed because the prompting=true branch was | |||
| 1187 | + | // suppressing redraws (like the modal editors do). | |||
| 1188 | + | func TestSwarmTranscriptTickRefreshWhileEditing(t *testing.T) { | |||
| 1189 | + | rows := []swarm.AgentSnapshot{{ | |||
| 1190 | + | ID: "agent-1", Task: "x", Status: swarm.StatusRunning, | |||
| 1191 | + | Activity: "thinking", Started: time.Now(), | |||
| 1192 | + | Lines: []string{"hello"}, | |||
| 1193 | + | }} | |||
| 1194 | + | d := newSwarmDialog() | |||
| 1195 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, | |||
| 1196 | + | func(string, string) error { return nil }, | |||
| 1197 | + | nil, "") | |||
| 1198 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 1199 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 1200 | + | if !d.viewing || !d.prompting { | |||
| 1201 | + | t.Fatalf("setup: viewing=%v prompting=%v", d.viewing, d.prompting) | |||
| 1202 | + | } | |||
| 1203 | + | ||||
| 1204 | + | if !d.NeedsTickRefresh() { | |||
| 1205 | + | t.Fatal("transcript view with always-on editor refused tick refresh; streaming output would freeze") | |||
| 1206 | + | } | |||
| 1207 | + | } | |||
| 1208 | + | ||||
| 1209 | + | // TestSwarmTranscriptTickRefreshModalSuppressed pins the inverse: | |||
| 1210 | + | // the list-view `p` modal (prompting without viewing) still | |||
| 1211 | + | // suppresses tick like the other modal editors, so the cursor | |||
| 1212 | + | // blink works cleanly while composing a one-off prompt. | |||
| 1213 | + | func TestSwarmTranscriptTickRefreshModalSuppressed(t *testing.T) { | |||
| 1214 | + | rows := []swarm.AgentSnapshot{{ | |||
| 1215 | + | ID: "agent-1", Status: swarm.StatusRunning, Started: time.Now(), | |||
| 1216 | + | }} | |||
| 1217 | + | d := newSwarmDialog() | |||
| 1218 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, | |||
| 1219 | + | func(string, string) error { return nil }, | |||
| 1220 | + | nil, "") | |||
| 1221 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 1222 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) | |||
| 1223 | + | if d.viewing || !d.prompting { | |||
| 1224 | + | t.Fatalf("setup: viewing=%v prompting=%v; want list-view modal", d.viewing, d.prompting) | |||
| 1225 | + | } | |||
| 1226 | + | ||||
| 1227 | + | if d.NeedsTickRefresh() { | |||
| 1228 | + | t.Error("list-view prompt modal did not suppress tick refresh; cursor blink may misbehave") | |||
| 1229 | + | } | |||
| 1230 | + | } | |||
| 1231 | + | ||||
| 1232 | + | // TestSwarmTranscriptBusySpinnerRenders proves the inline spinner | |||
| 1233 | + | // appears above the always-on editor while the agent is busy and | |||
| 1234 | + | // disappears when it goes idle. This is the /btw-style "something | |||
| 1235 | + | // is happening" feedback the user was missing. | |||
| 1236 | + | func TestSwarmTranscriptBusySpinnerRenders(t *testing.T) { | |||
| 1237 | + | cur := []swarm.AgentSnapshot{{ | |||
| 1238 | + | ID: "agent-1", Task: "x", Status: swarm.StatusRunning, | |||
| 1239 | + | Activity: "thinking", Started: time.Now(), | |||
| 1240 | + | Lines: []string{"hello"}, | |||
| 1241 | + | }} | |||
| 1242 | + | snap := func() []swarm.AgentSnapshot { return cur } | |||
| 1243 | + | d := newSwarmDialog() | |||
| 1244 | + | d.Open(snap, nil, nil, nil, | |||
| 1245 | + | func(string, string) error { return nil }, | |||
| 1246 | + | nil, "") | |||
| 1247 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 1248 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 1249 | + | ||||
| 1250 | + | out := strings.Join(d.Render(tui.Theme{}, 80), "\n") | |||
| 1251 | + | if !strings.Contains(out, "thinking") { | |||
| 1252 | + | t.Fatalf("busy spinner did not surface activity \"thinking\" near the editor:\n%s", out) | |||
| 1253 | + | } | |||
| 1254 | + | if d.transcriptSpin == nil { | |||
| 1255 | + | t.Error("spinner not initialised while agent is busy") | |||
| 1256 | + | } | |||
| 1257 | + | ||||
| 1258 | + | // Flip the activity to idle: spinner should vanish. | |||
| 1259 | + | cur[0].Activity = "idle" | |||
| 1260 | + | out = strings.Join(d.Render(tui.Theme{}, 80), "\n") | |||
| 1261 | + | if d.transcriptSpin != nil { | |||
| 1262 | + | t.Error("spinner stayed live after agent reported idle") | |||
| 1263 | + | } | |||
| 1264 | + | // The bare word "thinking" must not appear anymore. | |||
| 1265 | + | if strings.Contains(out, "· thinking ·") { | |||
| 1266 | + | t.Errorf("busy line still rendered when agent is idle:\n%s", out) | |||
| 1267 | + | } | |||
| 1268 | + | } | |||
| 1269 | + | ||||
| 1270 | + | func TestStatusLabelCoversAll(t *testing.T) { | |||
| 1271 | + | for _, s := range []swarm.Status{ | |||
| 1272 | + | swarm.StatusPending, swarm.StatusRunning, | |||
| 1273 | + | swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled, | |||
| 1274 | + | } { | |||
| 1275 | + | if statusLabel(s) == "" { | |||
| 1276 | + | t.Fatalf("no label for %s", s) | |||
| 1277 | + | } | |||
| 1278 | + | } | |||
| 1279 | + | } | |||
A internal/agent/modes/swarm_slash.go +309 -0
| @@ -0,0 +1,309 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "github.com/patriceckhart/zot/internal/swarm" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // runSwarm dispatches /swarm subcommands. Layout: | |||
| 12 | + | // | |||
| 13 | + | // /swarm -> open the dashboard | |||
| 14 | + | // /swarm list -> open the dashboard | |||
| 15 | + | // /swarm new [--model M] [--provider P] <task...> | |||
| 16 | + | // -> spawn an agent (optionally pinned to a model) | |||
| 17 | + | // /swarm kill <id> -> stop a running agent | |||
| 18 | + | // /swarm remove <id> -> tear down a terminated agent | |||
| 19 | + | // /swarm logs <id> -> open the scrollable transcript view | |||
| 20 | + | // /swarm send <id> <text...> -> send a follow-up user turn to <id> | |||
| 21 | + | // /swarm resume [id] -> resume an agent (omit id to pick from a list) | |||
| 22 | + | // /swarm attach <id> -> (planned) drop into the agent's TUI | |||
| 23 | + | // | |||
| 24 | + | // When cfg.Swarm is nil the command tells the user the feature is | |||
| 25 | + | // disabled instead of pretending to work. | |||
| 26 | + | func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||
| 27 | + | if i.cfg.Swarm == nil { | |||
| 28 | + | i.mu.Lock() | |||
| 29 | + | i.statusErr = "swarm is disabled in this build" | |||
| 30 | + | i.statusOK = "" | |||
| 31 | + | i.mu.Unlock() | |||
| 32 | + | return | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | sub := "" | |||
| 36 | + | rest := "" | |||
| 37 | + | if len(args) > 0 { | |||
| 38 | + | sub = strings.ToLower(args[0]) | |||
| 39 | + | // Guard the args[1:] reslice: when only the subcommand was | |||
| 40 | + | // typed (e.g. bare "/swarm new"), args has length 1 and the | |||
| 41 | + | // naive args[1:] is fine, but when args is empty (bare | |||
| 42 | + | // "/swarm") the reslice is [1:0] and panics. The len>0 branch | |||
| 43 | + | // here keeps both cases safe. | |||
| 44 | + | if len(args) > 1 { | |||
| 45 | + | rest = strings.TrimSpace(strings.Join(args[1:], " ")) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // spawnAdapter, sendAdapter, resumeAdapter wrap the Swarm methods | |||
| 50 | + | // in the signatures the dialog expects. Defined once here so the | |||
| 51 | + | // three Open()-shaped entry points (list, logs/view-jump, resume) | |||
| 52 | + | // feed the dialog identical callbacks. | |||
| 53 | + | spawnAdapter := func(task, model, provider string) error { | |||
| 54 | + | _, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | |||
| 55 | + | Task: task, Model: model, Provider: provider, | |||
| 56 | + | }) | |||
| 57 | + | return err | |||
| 58 | + | } | |||
| 59 | + | sendAdapter := func(id, text string) error { | |||
| 60 | + | return i.cfg.Swarm.SendUserTurn(id, text) | |||
| 61 | + | } | |||
| 62 | + | resumeAdapter := func(id string) error { | |||
| 63 | + | _, err := i.cfg.Swarm.Resume(ctx, id) | |||
| 64 | + | return err | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Pin every fresh spawn to whatever the host's /model selection | |||
| 68 | + | // is right now. This is captured at /swarm time — if the user | |||
| 69 | + | // wants a different model for the next swarm agent, they pick it | |||
| 70 | + | // via /model first (globally), or, while inside the spawn | |||
| 71 | + | // editor, by typing /model on its own line to pop the picker. | |||
| 72 | + | i.swarmDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider) | |||
| 73 | + | if i.cfg.LoggedInProviders != nil { | |||
| 74 | + | i.swarmDialog.SetLoggedInProviders(i.cfg.LoggedInProviders()) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | switch sub { | |||
| 78 | + | case "", "list", "ls", "ps": | |||
| 79 | + | i.swarmDialog.Open( | |||
| 80 | + | i.cfg.Swarm.SnapshotAll, | |||
| 81 | + | i.cfg.Swarm.Stop, | |||
| 82 | + | i.cfg.Swarm.Remove, | |||
| 83 | + | spawnAdapter, | |||
| 84 | + | sendAdapter, | |||
| 85 | + | resumeAdapter, | |||
| 86 | + | i.cfg.CWD, | |||
| 87 | + | ) | |||
| 88 | + | case "new", "spawn": | |||
| 89 | + | if rest == "" { | |||
| 90 | + | i.swarmStatus("", "/swarm new <task>: missing task") | |||
| 91 | + | return | |||
| 92 | + | } | |||
| 93 | + | // Permit `--model X --provider Y` flags before the task so | |||
| 94 | + | // scripts can pin a model without going through the dialog. | |||
| 95 | + | // Anything that isn't a recognised flag terminates parsing | |||
| 96 | + | // and the rest becomes the task; this keeps `/swarm new | |||
| 97 | + | // --model foo do a thing` and `/swarm new do --model thing` | |||
| 98 | + | // (where --model is part of the task) unambiguous — only | |||
| 99 | + | // leading flags are consumed. | |||
| 100 | + | model, provider, task := parseSpawnFlags(rest) | |||
| 101 | + | if task == "" { | |||
| 102 | + | i.swarmStatus("", "/swarm new: missing task (after any --model/--provider flags)") | |||
| 103 | + | return | |||
| 104 | + | } | |||
| 105 | + | a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | |||
| 106 | + | Task: task, Model: model, Provider: provider, | |||
| 107 | + | }) | |||
| 108 | + | if err != nil { | |||
| 109 | + | i.swarmStatus("", "spawn: "+err.Error()) | |||
| 110 | + | return | |||
| 111 | + | } | |||
| 112 | + | if model != "" { | |||
| 113 | + | i.swarmStatus("spawned "+a.ID+" on "+a.Branch+" (model "+model+")", "") | |||
| 114 | + | } else { | |||
| 115 | + | i.swarmStatus("spawned "+a.ID+" on "+a.Branch, "") | |||
| 116 | + | } | |||
| 117 | + | case "kill", "stop": | |||
| 118 | + | if rest == "" { | |||
| 119 | + | i.swarmStatus("", "/swarm kill <id>: missing id") | |||
| 120 | + | return | |||
| 121 | + | } | |||
| 122 | + | if err := i.cfg.Swarm.Stop(rest); err != nil { | |||
| 123 | + | i.swarmStatus("", "kill: "+err.Error()) | |||
| 124 | + | return | |||
| 125 | + | } | |||
| 126 | + | i.swarmStatus("stopped "+rest, "") | |||
| 127 | + | case "remove", "rm": | |||
| 128 | + | if rest == "" { | |||
| 129 | + | i.swarmStatus("", "/swarm remove <id>: missing id") | |||
| 130 | + | return | |||
| 131 | + | } | |||
| 132 | + | if err := i.cfg.Swarm.Remove(rest); err != nil { | |||
| 133 | + | i.swarmStatus("", "remove: "+err.Error()) | |||
| 134 | + | return | |||
| 135 | + | } | |||
| 136 | + | i.swarmStatus("removed "+rest, "") | |||
| 137 | + | case "logs", "log", "view": | |||
| 138 | + | if rest == "" { | |||
| 139 | + | i.swarmStatus("", "/swarm logs <id>: missing id") | |||
| 140 | + | return | |||
| 141 | + | } | |||
| 142 | + | ok := i.swarmDialog.OpenViewing( | |||
| 143 | + | rest, | |||
| 144 | + | i.cfg.Swarm.SnapshotAll, | |||
| 145 | + | i.cfg.Swarm.Stop, | |||
| 146 | + | i.cfg.Swarm.Remove, | |||
| 147 | + | spawnAdapter, | |||
| 148 | + | sendAdapter, | |||
| 149 | + | resumeAdapter, | |||
| 150 | + | i.cfg.CWD, | |||
| 151 | + | ) | |||
| 152 | + | if !ok { | |||
| 153 | + | i.swarmStatus("", "/swarm logs: no agent matching "+rest) | |||
| 154 | + | } | |||
| 155 | + | case "resume", "reattach", "reopen": | |||
| 156 | + | if rest == "" { | |||
| 157 | + | // No id given: open the dashboard with the cursor | |||
| 158 | + | // pre-positioned on the first resumable agent, and | |||
| 159 | + | // tell the user how many there are so they know what | |||
| 160 | + | // to expect. Pressing R confirms; ↑/↓ to pick a | |||
| 161 | + | // different row first. | |||
| 162 | + | count := i.swarmDialog.OpenForResume( | |||
| 163 | + | i.cfg.Swarm.SnapshotAll, | |||
| 164 | + | i.cfg.Swarm.Stop, | |||
| 165 | + | i.cfg.Swarm.Remove, | |||
| 166 | + | spawnAdapter, | |||
| 167 | + | sendAdapter, | |||
| 168 | + | resumeAdapter, | |||
| 169 | + | i.cfg.CWD, | |||
| 170 | + | ) | |||
| 171 | + | switch count { | |||
| 172 | + | case 0: | |||
| 173 | + | i.swarmStatus("", "/swarm resume: no resumable agents (none detached or terminated)") | |||
| 174 | + | case 1: | |||
| 175 | + | i.swarmStatus("1 resumable agent · press R to resume", "") | |||
| 176 | + | default: | |||
| 177 | + | i.swarmStatus(fmt.Sprintf("%d resumable agents · ↑/↓ to pick · R to resume", count), "") | |||
| 178 | + | } | |||
| 179 | + | return | |||
| 180 | + | } | |||
| 181 | + | a, err := i.cfg.Swarm.Resume(ctx, rest) | |||
| 182 | + | if err != nil { | |||
| 183 | + | i.swarmStatus("", "resume: "+err.Error()) | |||
| 184 | + | return | |||
| 185 | + | } | |||
| 186 | + | i.swarmStatus("resumed "+a.ID, "") | |||
| 187 | + | case "send", "prompt", "msg": | |||
| 188 | + | // /swarm send <id> <text...> is the non-interactive | |||
| 189 | + | // counterpart of pressing 'p' in the dashboard. We split the | |||
| 190 | + | // joined `rest` ourselves rather than reusing the dispatcher's | |||
| 191 | + | // already-fielded args[] because the text may contain spaces | |||
| 192 | + | // the user expects to be preserved verbatim. | |||
| 193 | + | id, text := splitIDAndRest(rest) | |||
| 194 | + | if id == "" { | |||
| 195 | + | i.swarmStatus("", "/swarm send <id> <text>: missing id") | |||
| 196 | + | return | |||
| 197 | + | } | |||
| 198 | + | if text == "" { | |||
| 199 | + | i.swarmStatus("", "/swarm send <id> <text>: missing text") | |||
| 200 | + | return | |||
| 201 | + | } | |||
| 202 | + | if err := i.cfg.Swarm.SendUserTurn(id, text); err != nil { | |||
| 203 | + | i.swarmStatus("", friendlySendErr(id, err)) | |||
| 204 | + | return | |||
| 205 | + | } | |||
| 206 | + | i.swarmStatus("sent to "+id, "") | |||
| 207 | + | case "attach": | |||
| 208 | + | // PTY-reparenting is a significant chunk of work I haven't | |||
| 209 | + | // landed yet (see the design sketch). Recognise the name so | |||
| 210 | + | // /swarm attach doesn't fall through to the generic "unknown | |||
| 211 | + | // subcommand" path — that error message is misleading because | |||
| 212 | + | // it makes attach sound like a typo instead of a planned | |||
| 213 | + | // feature. Point the user at /swarm logs in the meantime. | |||
| 214 | + | i.swarmStatus("", "/swarm attach: not implemented yet (needs PTY reparenting). Use /swarm logs "+firstWord(rest)+" to watch its transcript.") | |||
| 215 | + | default: | |||
| 216 | + | i.swarmStatus("", "/swarm: unknown subcommand "+sub+" (try list / new / kill / remove / logs / send / resume)") | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | // parseSpawnFlags consumes any leading `--model X` / `--provider Y` | |||
| 221 | + | // flags from s and returns them along with the remaining task body. | |||
| 222 | + | // We deliberately only honour LEADING flags so a task like "check | |||
| 223 | + | // --model lookup" doesn't accidentally swallow part of its prose as | |||
| 224 | + | // the model name. | |||
| 225 | + | // | |||
| 226 | + | // Recognised forms: | |||
| 227 | + | // | |||
| 228 | + | // --model X two-token form | |||
| 229 | + | // --model=X single-token form | |||
| 230 | + | // --provider X two-token form | |||
| 231 | + | // --provider=X single-token form | |||
| 232 | + | func parseSpawnFlags(s string) (model, provider, task string) { | |||
| 233 | + | fields := strings.Fields(s) | |||
| 234 | + | i := 0 | |||
| 235 | + | for i < len(fields) { | |||
| 236 | + | f := fields[i] | |||
| 237 | + | switch { | |||
| 238 | + | case f == "--model": | |||
| 239 | + | // Consume the flag even when no value follows so a | |||
| 240 | + | // dangling "--model" doesn't leak into the task. The | |||
| 241 | + | // caller surfaces "missing task" instead. | |||
| 242 | + | if i+1 < len(fields) { | |||
| 243 | + | model = fields[i+1] | |||
| 244 | + | i += 2 | |||
| 245 | + | } else { | |||
| 246 | + | i++ | |||
| 247 | + | } | |||
| 248 | + | continue | |||
| 249 | + | case strings.HasPrefix(f, "--model="): | |||
| 250 | + | model = strings.TrimPrefix(f, "--model=") | |||
| 251 | + | i++ | |||
| 252 | + | continue | |||
| 253 | + | case f == "--provider": | |||
| 254 | + | if i+1 < len(fields) { | |||
| 255 | + | provider = fields[i+1] | |||
| 256 | + | i += 2 | |||
| 257 | + | } else { | |||
| 258 | + | i++ | |||
| 259 | + | } | |||
| 260 | + | continue | |||
| 261 | + | case strings.HasPrefix(f, "--provider="): | |||
| 262 | + | provider = strings.TrimPrefix(f, "--provider=") | |||
| 263 | + | i++ | |||
| 264 | + | continue | |||
| 265 | + | } | |||
| 266 | + | break | |||
| 267 | + | } | |||
| 268 | + | task = strings.TrimSpace(strings.Join(fields[i:], " ")) | |||
| 269 | + | return | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | // splitIDAndRest splits "<id> <text...>" into (id, text). The text | |||
| 273 | + | // half preserves all whitespace after the first token so the agent | |||
| 274 | + | // receives the user's prompt verbatim (modulo a single trim of the | |||
| 275 | + | // boundary space). Returns ("", "") when s is empty so the caller | |||
| 276 | + | // can surface a missing-id error. | |||
| 277 | + | func splitIDAndRest(s string) (id, text string) { | |||
| 278 | + | s = strings.TrimLeft(s, " \t") | |||
| 279 | + | if s == "" { | |||
| 280 | + | return "", "" | |||
| 281 | + | } | |||
| 282 | + | cut := strings.IndexAny(s, " \t") | |||
| 283 | + | if cut < 0 { | |||
| 284 | + | return s, "" | |||
| 285 | + | } | |||
| 286 | + | return s[:cut], strings.TrimLeft(s[cut+1:], " \t") | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | // firstWord returns the first whitespace-separated token of s, or | |||
| 290 | + | // "<id>" when s is empty. Used to keep the "/swarm attach" hint | |||
| 291 | + | // readable even when the user typed no argument. | |||
| 292 | + | func firstWord(s string) string { | |||
| 293 | + | s = strings.TrimSpace(s) | |||
| 294 | + | if s == "" { | |||
| 295 | + | return "<id>" | |||
| 296 | + | } | |||
| 297 | + | if i := strings.IndexAny(s, " \t"); i >= 0 { | |||
| 298 | + | return s[:i] | |||
| 299 | + | } | |||
| 300 | + | return s | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | func (i *Interactive) swarmStatus(ok, errMsg string) { | |||
| 304 | + | i.mu.Lock() | |||
| 305 | + | i.statusOK = ok | |||
| 306 | + | i.statusErr = errMsg | |||
| 307 | + | i.mu.Unlock() | |||
| 308 | + | i.invalidate() | |||
| 309 | + | } | |||
A internal/agent/modes/swarm_slash_test.go +228 -0
| @@ -0,0 +1,228 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/patriceckhart/zot/internal/swarm" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // newInteractiveForSwarmTest builds the minimal Interactive scaffolding | |||
| 13 | + | // runSwarm needs. It does NOT call NewInteractive (which would pull in | |||
| 14 | + | // the whole TUI); the runSwarm method only touches cfg.Swarm, the | |||
| 15 | + | // status mutex, and the swarm dialog, so we hand-build those. | |||
| 16 | + | func newInteractiveForSwarmTest(t *testing.T) (*Interactive, *swarm.Swarm) { | |||
| 17 | + | t.Helper() | |||
| 18 | + | root := t.TempDir() | |||
| 19 | + | f := swarm.New(swarm.Config{ | |||
| 20 | + | Root: root, | |||
| 21 | + | RepoRoot: root, | |||
| 22 | + | Worktree: swarm.MemWorktree(filepath.Join(root, "wt")), | |||
| 23 | + | NewRunner: func(a *swarm.Agent) swarm.Runner { | |||
| 24 | + | return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error { | |||
| 25 | + | <-ctx.Done() | |||
| 26 | + | return ctx.Err() | |||
| 27 | + | }) | |||
| 28 | + | }, | |||
| 29 | + | }) | |||
| 30 | + | iv := &Interactive{ | |||
| 31 | + | swarmDialog: newSwarmDialog(), | |||
| 32 | + | dirty: make(chan struct{}, 1), | |||
| 33 | + | } | |||
| 34 | + | iv.cfg.Swarm = f | |||
| 35 | + | return iv, f | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // TestRunSwarmBareDoesNotPanic regression-tests the slice-out-of-range | |||
| 39 | + | // panic that hit when /swarm was typed with no subcommand: runSwarm | |||
| 40 | + | // did args[1:] without checking len(args), which panics as [1:0]. | |||
| 41 | + | func TestRunSwarmBareDoesNotPanic(t *testing.T) { | |||
| 42 | + | iv, _ := newInteractiveForSwarmTest(t) | |||
| 43 | + | defer iv.cfg.Swarm.StopAll() | |||
| 44 | + | ||||
| 45 | + | // Bare /swarm: parts[1:] from the dispatcher is an empty slice. | |||
| 46 | + | iv.runSwarm(context.Background(), nil) | |||
| 47 | + | ||||
| 48 | + | if !iv.swarmDialog.Active() { | |||
| 49 | + | t.Fatal("bare /swarm should open the dashboard") | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestRunSwarmSubcommandsDoNotPanic(t *testing.T) { | |||
| 54 | + | iv, _ := newInteractiveForSwarmTest(t) | |||
| 55 | + | defer iv.cfg.Swarm.StopAll() | |||
| 56 | + | ||||
| 57 | + | // Each row is the slice that the dispatcher hands to runSwarm — | |||
| 58 | + | // i.e. parts[1:] where parts was strings.Fields of the slash | |||
| 59 | + | // command. Mixing zero-arg and arg'd forms exercises both | |||
| 60 | + | // branches of the reslice guard. | |||
| 61 | + | cases := [][]string{ | |||
| 62 | + | {"list"}, | |||
| 63 | + | {"new"}, | |||
| 64 | + | {"new", "fix", "the", "thing"}, | |||
| 65 | + | {"kill"}, | |||
| 66 | + | {"kill", "no-such-id"}, | |||
| 67 | + | {"remove"}, | |||
| 68 | + | {"remove", "no-such-id"}, | |||
| 69 | + | {"send"}, | |||
| 70 | + | {"send", "no-such-id"}, | |||
| 71 | + | {"send", "no-such-id", "hello", "world"}, | |||
| 72 | + | {"bogus"}, | |||
| 73 | + | } | |||
| 74 | + | for _, args := range cases { | |||
| 75 | + | func() { | |||
| 76 | + | defer func() { | |||
| 77 | + | if r := recover(); r != nil { | |||
| 78 | + | t.Fatalf("runSwarm(%v) panicked: %v", args, r) | |||
| 79 | + | } | |||
| 80 | + | }() | |||
| 81 | + | iv.runSwarm(context.Background(), args) | |||
| 82 | + | }() | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func TestRunSwarmNewSpawnsAgent(t *testing.T) { | |||
| 87 | + | iv, f := newInteractiveForSwarmTest(t) | |||
| 88 | + | defer f.StopAll() | |||
| 89 | + | ||||
| 90 | + | iv.runSwarm(context.Background(), []string{"new", "do", "stuff"}) | |||
| 91 | + | agents := f.List() | |||
| 92 | + | if len(agents) != 1 { | |||
| 93 | + | t.Fatalf("want 1 agent; got %d", len(agents)) | |||
| 94 | + | } | |||
| 95 | + | if agents[0].Task != "do stuff" { | |||
| 96 | + | t.Fatalf("task = %q; want %q", agents[0].Task, "do stuff") | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // TestRunSwarmSendDeliversToAgentInbox spins up a real agent with a | |||
| 101 | + | // fake Runner whose only job is to forward inbox lines to a channel, | |||
| 102 | + | // then asserts the /swarm send <id> <text...> path routes through | |||
| 103 | + | // Swarm.SendUserTurn and lands at the agent verbatim. | |||
| 104 | + | func TestRunSwarmSendDeliversToAgentInbox(t *testing.T) { | |||
| 105 | + | root := t.TempDir() | |||
| 106 | + | recv := make(chan string, 4) | |||
| 107 | + | f := swarm.New(swarm.Config{ | |||
| 108 | + | Root: root, | |||
| 109 | + | RepoRoot: root, | |||
| 110 | + | Worktree: swarm.MemWorktree(filepath.Join(root, "wt")), | |||
| 111 | + | NewRunner: func(a *swarm.Agent) swarm.Runner { | |||
| 112 | + | return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error { | |||
| 113 | + | // Stand up a real Listener on the agent's inbox path so | |||
| 114 | + | // SendUserTurn (which dials a unix socket) actually has | |||
| 115 | + | // something to talk to. The runner-test stubs do the | |||
| 116 | + | // same; this is the minimum to exercise the wire. | |||
| 117 | + | ln, err := swarm.Listen(a.InboxPath) | |||
| 118 | + | if err != nil { | |||
| 119 | + | return err | |||
| 120 | + | } | |||
| 121 | + | defer ln.Close() | |||
| 122 | + | for { | |||
| 123 | + | select { | |||
| 124 | + | case <-ctx.Done(): | |||
| 125 | + | return ctx.Err() | |||
| 126 | + | case line, ok := <-ln.Lines(): | |||
| 127 | + | if !ok { | |||
| 128 | + | return nil | |||
| 129 | + | } | |||
| 130 | + | recv <- line | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | }) | |||
| 134 | + | }, | |||
| 135 | + | }) | |||
| 136 | + | defer f.StopAll() | |||
| 137 | + | iv := &Interactive{swarmDialog: newSwarmDialog(), dirty: make(chan struct{}, 1)} | |||
| 138 | + | iv.cfg.Swarm = f | |||
| 139 | + | ||||
| 140 | + | a, err := f.Spawn(context.Background(), "do thing") | |||
| 141 | + | if err != nil { | |||
| 142 | + | t.Fatalf("spawn: %v", err) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // Run /swarm send <id> <text...>. The dispatcher would have | |||
| 146 | + | // already strings.Fields-ed the input; mirror that here. | |||
| 147 | + | iv.runSwarm(context.Background(), []string{"send", a.ID, "please", "continue"}) | |||
| 148 | + | ||||
| 149 | + | select { | |||
| 150 | + | case msg := <-recv: | |||
| 151 | + | want := "user please continue" | |||
| 152 | + | if msg != want { | |||
| 153 | + | t.Fatalf("agent received %q; want %q", msg, want) | |||
| 154 | + | } | |||
| 155 | + | case <-time.After(2 * time.Second): | |||
| 156 | + | t.Fatal("timed out waiting for agent to receive the prompt") | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | if iv.statusErr != "" { | |||
| 160 | + | t.Fatalf("status err set: %q", iv.statusErr) | |||
| 161 | + | } | |||
| 162 | + | if iv.statusOK == "" || iv.statusOK[:7] != "sent to" { | |||
| 163 | + | t.Fatalf("status ok = %q; want \"sent to ...\"", iv.statusOK) | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func TestParseSpawnFlags(t *testing.T) { | |||
| 168 | + | cases := []struct { | |||
| 169 | + | in string | |||
| 170 | + | wantModel, wantProv string | |||
| 171 | + | wantTask string | |||
| 172 | + | }{ | |||
| 173 | + | {"do x", "", "", "do x"}, | |||
| 174 | + | {"--model claude do x", "claude", "", "do x"}, | |||
| 175 | + | {"--model=claude do x", "claude", "", "do x"}, | |||
| 176 | + | {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "do x"}, | |||
| 177 | + | {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "do x"}, | |||
| 178 | + | // Only LEADING flags are consumed. | |||
| 179 | + | {"do --model x", "", "", "do --model x"}, | |||
| 180 | + | // Missing value: --model with no follow-up token leaves model empty | |||
| 181 | + | // and the next field starts the task. | |||
| 182 | + | {"--model", "", "", ""}, | |||
| 183 | + | } | |||
| 184 | + | for _, c := range cases { | |||
| 185 | + | m, p, task := parseSpawnFlags(c.in) | |||
| 186 | + | if m != c.wantModel || p != c.wantProv || task != c.wantTask { | |||
| 187 | + | t.Errorf("parseSpawnFlags(%q) = (%q,%q,%q); want (%q,%q,%q)", | |||
| 188 | + | c.in, m, p, task, c.wantModel, c.wantProv, c.wantTask) | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | func TestSplitIDAndRest(t *testing.T) { | |||
| 194 | + | cases := []struct { | |||
| 195 | + | in string | |||
| 196 | + | wantID string | |||
| 197 | + | wantText string | |||
| 198 | + | }{ | |||
| 199 | + | {"", "", ""}, | |||
| 200 | + | {" ", "", ""}, | |||
| 201 | + | {"alpha", "alpha", ""}, | |||
| 202 | + | {"alpha hello world", "alpha", "hello world"}, | |||
| 203 | + | {" alpha hello world ", "alpha", "hello world "}, | |||
| 204 | + | {"alpha\thi", "alpha", "hi"}, | |||
| 205 | + | } | |||
| 206 | + | for _, c := range cases { | |||
| 207 | + | gotID, gotText := splitIDAndRest(c.in) | |||
| 208 | + | if gotID != c.wantID || gotText != c.wantText { | |||
| 209 | + | t.Errorf("splitIDAndRest(%q) = (%q,%q); want (%q,%q)", c.in, gotID, gotText, c.wantID, c.wantText) | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func TestRunSwarmWithoutSwarmIsNoop(t *testing.T) { | |||
| 215 | + | iv := &Interactive{ | |||
| 216 | + | swarmDialog: newSwarmDialog(), | |||
| 217 | + | dirty: make(chan struct{}, 1), | |||
| 218 | + | } | |||
| 219 | + | // cfg.Swarm stays nil. The command should set a status err and | |||
| 220 | + | // otherwise be inert. | |||
| 221 | + | iv.runSwarm(context.Background(), nil) | |||
| 222 | + | if iv.swarmDialog.Active() { | |||
| 223 | + | t.Fatal("dialog opened despite no swarm") | |||
| 224 | + | } | |||
| 225 | + | if iv.statusErr == "" { | |||
| 226 | + | t.Fatal("expected a status error when swarm is nil") | |||
| 227 | + | } | |||
| 228 | + | } | |||
A internal/agent/swarm_agent.go +262 -0
| @@ -0,0 +1,262 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "strings" | |||
| 9 | + | "sync" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/patriceckhart/zot/internal/agent/modes" | |||
| 13 | + | "github.com/patriceckhart/zot/internal/core" | |||
| 14 | + | "github.com/patriceckhart/zot/internal/swarm" | |||
| 15 | + | "github.com/patriceckhart/zot/internal/provider" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // runSwarmAgentMode is the daemon-mode entry point used by every | |||
| 19 | + | // swarm-spawned zot subprocess. It's intentionally close in shape to | |||
| 20 | + | // runJSONMode but with two key differences: | |||
| 21 | + | // | |||
| 22 | + | // - Lifetime: the process stays alive across many user turns. The | |||
| 23 | + | // initial positional task (if any) is the first turn; subsequent | |||
| 24 | + | // turns arrive through the inbox unix socket at args.SwarmAgent. | |||
| 25 | + | // | |||
| 26 | + | // - Output: every emitted JSON line is also mirrored verbatim into | |||
| 27 | + | // events.jsonl (see ZOT_SWARM_EVENT_LOG) so a separate zot | |||
| 28 | + | // process can /swarm open this agent and replay its full history | |||
| 29 | + | // even after the parent that spawned us is long gone. | |||
| 30 | + | // | |||
| 31 | + | // The runner in internal/swarm/runner.go is the only caller in | |||
| 32 | + | // production; tests use the stubchild binary under | |||
| 33 | + | // internal/swarm/testdata/cmd/stubchild instead of the real model | |||
| 34 | + | // loop. | |||
| 35 | + | func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||
| 36 | + | if args.SwarmAgent == "" { | |||
| 37 | + | return fmt.Errorf("--swarm-agent requires a socket path") | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | r, err := Resolve(args, true) | |||
| 41 | + | if err != nil { | |||
| 42 | + | return err | |||
| 43 | + | } | |||
| 44 | + | extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version) | |||
| 45 | + | defer stopExt() | |||
| 46 | + | ||||
| 47 | + | ag := r.NewAgent() | |||
| 48 | + | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |||
| 49 | + | sess, _ := openOrCreateSession(args, r, ag, version) | |||
| 50 | + | defer sess.Close() | |||
| 51 | + | ||||
| 52 | + | // Open the inbox listener BEFORE emitting agent_ready so the | |||
| 53 | + | // supervisor can dial through on the very first send. The | |||
| 54 | + | // swarm supervisor's Inbox.SendInput retries dialing for a | |||
| 55 | + | // short window, but emitting ready first and then listening | |||
| 56 | + | // would still race in tight loops. | |||
| 57 | + | ln, err := swarm.Listen(args.SwarmAgent) | |||
| 58 | + | if err != nil { | |||
| 59 | + | return fmt.Errorf("swarm-agent listen: %w", err) | |||
| 60 | + | } | |||
| 61 | + | defer ln.Close() | |||
| 62 | + | ||||
| 63 | + | // Event log is owned by the supervisor's runner via stdout, but | |||
| 64 | + | // the daemon also writes a redundant copy here when the runner's | |||
| 65 | + | // pipe is closed (e.g. parent zot exited but the agent is still | |||
| 66 | + | // running headless). The env var is set by the runner; if it's | |||
| 67 | + | // empty we silently skip the second mirror. | |||
| 68 | + | var logMirror *swarm.EventLog | |||
| 69 | + | if path := os.Getenv("ZOT_SWARM_EVENT_LOG"); path != "" { | |||
| 70 | + | logMirror, _ = swarm.OpenEventLog(path) | |||
| 71 | + | } | |||
| 72 | + | if logMirror != nil { | |||
| 73 | + | defer logMirror.Close() | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | em := newSwarmEmitter(os.Stdout, logMirror) | |||
| 77 | + | ||||
| 78 | + | em.emit("agent_ready", map[string]any{ | |||
| 79 | + | "version": version, | |||
| 80 | + | "cwd": r.CWD, | |||
| 81 | + | "model": r.Model, | |||
| 82 | + | }) | |||
| 83 | + | ||||
| 84 | + | // turnCtx is the per-turn context. We keep a per-process | |||
| 85 | + | // cancel so the "cancel" inbox message can interrupt an | |||
| 86 | + | // in-flight turn without tearing down the whole daemon. | |||
| 87 | + | var ( | |||
| 88 | + | mu sync.Mutex | |||
| 89 | + | turnCtx context.Context = ctx | |||
| 90 | + | cancelFn context.CancelFunc | |||
| 91 | + | busyTurn bool | |||
| 92 | + | turnNo int | |||
| 93 | + | shutdown = make(chan struct{}) | |||
| 94 | + | ) | |||
| 95 | + | ||||
| 96 | + | runOne := func(prompt string) { | |||
| 97 | + | mu.Lock() | |||
| 98 | + | if busyTurn { | |||
| 99 | + | // Drop concurrent turns rather than queuing. The | |||
| 100 | + | // supervisor protocol assumes one outstanding turn per | |||
| 101 | + | // agent; if a user really wants to interrupt and start | |||
| 102 | + | // another, they should send "cancel" first. | |||
| 103 | + | mu.Unlock() | |||
| 104 | + | em.emit("error", map[string]any{"message": "agent busy; send 'cancel' first"}) | |||
| 105 | + | return | |||
| 106 | + | } | |||
| 107 | + | busyTurn = true | |||
| 108 | + | turnNo++ | |||
| 109 | + | step := turnNo | |||
| 110 | + | turnCtx, cancelFn = context.WithCancel(ctx) | |||
| 111 | + | c := turnCtx | |||
| 112 | + | mu.Unlock() | |||
| 113 | + | ||||
| 114 | + | em.emit("turn_start", map[string]any{"step": step}) | |||
| 115 | + | ||||
| 116 | + | sink := func(ev core.AgentEvent) { | |||
| 117 | + | em.emit(ev.Type(), modes.EventToJSON(ev)) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | start := len(ag.Messages()) | |||
| 121 | + | err := ag.Prompt(c, prompt, nil, sink) | |||
| 122 | + | WriteNewTranscript(ag, sess, start) | |||
| 123 | + | ||||
| 124 | + | em.emit("turn_end", map[string]any{ | |||
| 125 | + | "step": step, | |||
| 126 | + | "error": errString(err), | |||
| 127 | + | }) | |||
| 128 | + | ||||
| 129 | + | mu.Lock() | |||
| 130 | + | busyTurn = false | |||
| 131 | + | cancelFn = nil | |||
| 132 | + | mu.Unlock() | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // Initial task: run before processing the inbox so the agent | |||
| 136 | + | // "starts working" the moment it boots, matching what users | |||
| 137 | + | // expect from `/swarm new <task>`. | |||
| 138 | + | if args.Prompt != "" { | |||
| 139 | + | go runOne(args.Prompt) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // Inbox loop: one supervisor message at a time. We don't spawn | |||
| 143 | + | // a goroutine per turn because runOne already serialises them | |||
| 144 | + | // via the busyTurn flag; doing the dispatch on the main | |||
| 145 | + | // goroutine keeps the daemon's lifecycle easy to follow. | |||
| 146 | + | for { | |||
| 147 | + | select { | |||
| 148 | + | case <-ctx.Done(): | |||
| 149 | + | em.emit("agent_stopped", map[string]any{"reason": "cancelled"}) | |||
| 150 | + | return ctx.Err() | |||
| 151 | + | case <-shutdown: | |||
| 152 | + | em.emit("agent_stopped", map[string]any{"reason": "shutdown"}) | |||
| 153 | + | return nil | |||
| 154 | + | case msg, ok := <-ln.Lines(): | |||
| 155 | + | if !ok { | |||
| 156 | + | em.emit("agent_stopped", map[string]any{"reason": "inbox-closed"}) | |||
| 157 | + | return nil | |||
| 158 | + | } | |||
| 159 | + | switch { | |||
| 160 | + | case msg == "shutdown": | |||
| 161 | + | close(shutdown) | |||
| 162 | + | case msg == "cancel": | |||
| 163 | + | mu.Lock() | |||
| 164 | + | if cancelFn != nil { | |||
| 165 | + | cancelFn() | |||
| 166 | + | } | |||
| 167 | + | mu.Unlock() | |||
| 168 | + | case strings.HasPrefix(msg, "user "): | |||
| 169 | + | prompt := strings.TrimPrefix(msg, "user ") | |||
| 170 | + | go runOne(prompt) | |||
| 171 | + | default: | |||
| 172 | + | em.emit("error", map[string]any{ | |||
| 173 | + | "message": "unknown supervisor message: " + truncateForLog(msg, 200), | |||
| 174 | + | }) | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // swarmEmitter serialises events to stdout and (optionally) to a | |||
| 181 | + | // durable log file. Concurrent goroutines call emit so we have to | |||
| 182 | + | // hold a mutex around the encoder. | |||
| 183 | + | type swarmEmitter struct { | |||
| 184 | + | mu sync.Mutex | |||
| 185 | + | w *os.File | |||
| 186 | + | mirror *swarm.EventLog | |||
| 187 | + | ||||
| 188 | + | // orphan flips true the first time a stdout write fails (broken | |||
| 189 | + | // pipe — the supervisor died). Until then the mirror stays | |||
| 190 | + | // dormant: the supervisor is the canonical writer to events.jsonl | |||
| 191 | + | // (it parses our stdout and Append()s each event itself). Writing | |||
| 192 | + | // from both sides used to land every event in the log twice, | |||
| 193 | + | // which showed up as a fully-duplicated transcript the next time | |||
| 194 | + | // the agent was reloaded — the exact "why is everything doubled" | |||
| 195 | + | // bug. Once orphaned the mirror takes over so the events still | |||
| 196 | + | // land on disk for the next reload. | |||
| 197 | + | orphan bool | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func newSwarmEmitter(w *os.File, mirror *swarm.EventLog) *swarmEmitter { | |||
| 201 | + | return &swarmEmitter{w: w, mirror: mirror} | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func (e *swarmEmitter) emit(typ string, data map[string]any) { | |||
| 205 | + | if data == nil { | |||
| 206 | + | data = map[string]any{} | |||
| 207 | + | } | |||
| 208 | + | data["type"] = typ | |||
| 209 | + | data["time"] = time.Now().Format(time.RFC3339Nano) | |||
| 210 | + | ||||
| 211 | + | e.mu.Lock() | |||
| 212 | + | defer e.mu.Unlock() | |||
| 213 | + | ||||
| 214 | + | if !e.orphan { | |||
| 215 | + | // Encode to a buffer first so we can detect the stdout write | |||
| 216 | + | // error — json.Encoder swallows partial writes silently, but | |||
| 217 | + | // a direct Write on *os.File returns broken-pipe immediately. | |||
| 218 | + | line, err := json.Marshal(data) | |||
| 219 | + | if err == nil { | |||
| 220 | + | line = append(line, '\n') | |||
| 221 | + | if _, werr := e.w.Write(line); werr != nil { | |||
| 222 | + | // Supervisor's stdout pipe is gone (parent zot exited | |||
| 223 | + | // but we kept running). Switch to mirror-only mode so | |||
| 224 | + | // subsequent events still get persisted; also retro- | |||
| 225 | + | // actively log this very event to the mirror so it | |||
| 226 | + | // doesn't get lost in the handoff. | |||
| 227 | + | e.orphan = true | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | if e.orphan && e.mirror != nil { | |||
| 233 | + | // Drop "type" + "time" out of data into Event fields so the | |||
| 234 | + | // mirror file matches what the supervisor would have written. | |||
| 235 | + | flat := map[string]any{} | |||
| 236 | + | for k, v := range data { | |||
| 237 | + | if k == "type" || k == "time" { | |||
| 238 | + | continue | |||
| 239 | + | } | |||
| 240 | + | flat[k] = v | |||
| 241 | + | } | |||
| 242 | + | _ = e.mirror.Append(swarm.NewEvent(typ, flat)) | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | func errString(err error) string { | |||
| 247 | + | if err == nil { | |||
| 248 | + | return "" | |||
| 249 | + | } | |||
| 250 | + | return err.Error() | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | func truncateForLog(s string, n int) string { | |||
| 254 | + | if len(s) <= n { | |||
| 255 | + | return s | |||
| 256 | + | } | |||
| 257 | + | return s[:n-1] + "…" | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // _ keeps the provider import used; provider types may surface | |||
| 261 | + | // through ag.OnEvent / modes.EventToJSON in future iterations. | |||
| 262 | + | var _ provider.Content = provider.TextBlock{} | |||
A internal/agent/swarm_agent_test.go +119 -0
| @@ -0,0 +1,119 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "io" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "testing" | |||
| 10 | + | ||||
| 11 | + | "github.com/patriceckhart/zot/internal/swarm" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // TestSwarmEmitterMirrorDormantUntilStdoutBreaks regresses the | |||
| 15 | + | // "everything is doubled after reopening a swarm agent" bug. | |||
| 16 | + | // | |||
| 17 | + | // Symptom: events.jsonl held two copies of every event because the | |||
| 18 | + | // child mirrored each event to disk AND the supervisor parsed the | |||
| 19 | + | // child's stdout and appended each event to disk too. On next zot | |||
| 20 | + | // launch the replay produced two transcript lines per real one. | |||
| 21 | + | // | |||
| 22 | + | // Fix invariant: while stdout writes succeed (i.e. the supervisor is | |||
| 23 | + | // alive on the other end of the pipe), the child's mirror writes | |||
| 24 | + | // NOTHING. Only when a stdout write fails (broken pipe → orphan) | |||
| 25 | + | // does the mirror take over so events still get persisted. | |||
| 26 | + | func TestSwarmEmitterMirrorDormantUntilStdoutBreaks(t *testing.T) { | |||
| 27 | + | // Real *os.File for the emitter's stdout-equivalent so the | |||
| 28 | + | // emitter's write() path (which expects *os.File) actually runs. | |||
| 29 | + | stdoutPath := filepath.Join(t.TempDir(), "stdout.fifo") | |||
| 30 | + | stdoutFile, err := os.Create(stdoutPath) | |||
| 31 | + | if err != nil { | |||
| 32 | + | t.Fatalf("create stdout file: %v", err) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // Mirror writes go to a separate events.jsonl that we can read | |||
| 36 | + | // at the end to assert how many events the mirror emitted. | |||
| 37 | + | mirrorPath := filepath.Join(t.TempDir(), "events.jsonl") | |||
| 38 | + | mirror, err := swarm.OpenEventLog(mirrorPath) | |||
| 39 | + | if err != nil { | |||
| 40 | + | t.Fatalf("open mirror: %v", err) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | em := newSwarmEmitter(stdoutFile, mirror) | |||
| 44 | + | ||||
| 45 | + | // Healthy stdout: emit three events. Mirror must stay empty. | |||
| 46 | + | em.emit("turn_start", map[string]any{"step": 1}) | |||
| 47 | + | em.emit("assistant_message", map[string]any{"text": "hi"}) | |||
| 48 | + | em.emit("turn_end", map[string]any{"step": 1}) | |||
| 49 | + | ||||
| 50 | + | got, err := swarm.ReadEventLog(mirrorPath) | |||
| 51 | + | if err != nil { | |||
| 52 | + | t.Fatalf("read mirror: %v", err) | |||
| 53 | + | } | |||
| 54 | + | if len(got) != 0 { | |||
| 55 | + | t.Fatalf("mirror wrote %d events while supervisor was alive; want 0 (every event would otherwise double on the next reload)\n%+v", | |||
| 56 | + | len(got), got) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // Simulate supervisor death: close stdout so the next Write | |||
| 60 | + | // returns EBADF / broken pipe. The emitter must flip into | |||
| 61 | + | // orphan mode and start writing through the mirror. | |||
| 62 | + | if err := stdoutFile.Close(); err != nil { | |||
| 63 | + | t.Fatalf("close stdout: %v", err) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | em.emit("assistant_message", map[string]any{"text": "after orphan"}) | |||
| 67 | + | em.emit("turn_end", map[string]any{"step": 2}) | |||
| 68 | + | ||||
| 69 | + | got, err = swarm.ReadEventLog(mirrorPath) | |||
| 70 | + | if err != nil { | |||
| 71 | + | t.Fatalf("read mirror post-orphan: %v", err) | |||
| 72 | + | } | |||
| 73 | + | if len(got) < 2 { | |||
| 74 | + | t.Fatalf("mirror failed to take over after stdout died: got %d events", len(got)) | |||
| 75 | + | } | |||
| 76 | + | if got[len(got)-1].Type != "turn_end" { | |||
| 77 | + | t.Errorf("last mirrored event type = %q; want turn_end", got[len(got)-1].Type) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // TestSwarmEmitterStdoutShapeMatchesSupervisorParser pins the | |||
| 82 | + | // wire-format contract: each emitted event lands on stdout as one | |||
| 83 | + | // JSON object per line with type+time at top level alongside the | |||
| 84 | + | // data fields. The supervisor's runner parses this exact shape. | |||
| 85 | + | func TestSwarmEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) { | |||
| 86 | + | // Pipe so we can read what the emitter wrote. | |||
| 87 | + | r, w, err := os.Pipe() | |||
| 88 | + | if err != nil { | |||
| 89 | + | t.Fatalf("pipe: %v", err) | |||
| 90 | + | } | |||
| 91 | + | defer r.Close() | |||
| 92 | + | ||||
| 93 | + | em := newSwarmEmitter(w, nil) | |||
| 94 | + | em.emit("turn_start", map[string]any{"step": 1}) | |||
| 95 | + | _ = w.Close() | |||
| 96 | + | ||||
| 97 | + | body, err := io.ReadAll(r) | |||
| 98 | + | if err != nil { | |||
| 99 | + | t.Fatalf("read pipe: %v", err) | |||
| 100 | + | } | |||
| 101 | + | // One trailing newline => one event line. | |||
| 102 | + | lines := bytes.Split(bytes.TrimRight(body, "\n"), []byte("\n")) | |||
| 103 | + | if len(lines) != 1 { | |||
| 104 | + | t.Fatalf("expected 1 event line, got %d: %q", len(lines), body) | |||
| 105 | + | } | |||
| 106 | + | var flat map[string]any | |||
| 107 | + | if err := json.Unmarshal(lines[0], &flat); err != nil { | |||
| 108 | + | t.Fatalf("not valid json: %v\n%s", err, lines[0]) | |||
| 109 | + | } | |||
| 110 | + | if flat["type"] != "turn_start" { | |||
| 111 | + | t.Errorf("type field missing or wrong: %v", flat["type"]) | |||
| 112 | + | } | |||
| 113 | + | if _, ok := flat["time"].(string); !ok { | |||
| 114 | + | t.Errorf("time field missing: %v", flat["time"]) | |||
| 115 | + | } | |||
| 116 | + | if flat["step"] != float64(1) { | |||
| 117 | + | t.Errorf("step field missing or wrong: %v", flat["step"]) | |||
| 118 | + | } | |||
| 119 | + | } | |||
M internal/core/core_test.go +48 -0
| @@ -46,6 +46,54 @@ func TestSessionRoundTrip(t *testing.T) { | |||||
| 46 | } | 46 | } | ||
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 49 | + | // TestNewSessionAtPathCreatesAtExplicitPath proves the swarm child's | |||
| 50 | + | // session-persistence fallback works: NewSessionAtPath creates the | |||
| 51 | + | // file (and parent dirs) at the exact path the caller chose, | |||
| 52 | + | // independent of SessionsDir(root, cwd). Without this helper, the | |||
| 53 | + | // swarm child's --session <path> with a non-existent path would | |||
| 54 | + | // fall through to NewSession and write the session under a | |||
| 55 | + | // different (autogenerated) name, leaving the supervisor-chosen | |||
| 56 | + | // path empty. | |||
| 57 | + | func TestNewSessionAtPathCreatesAtExplicitPath(t *testing.T) { | |||
| 58 | + | dir := t.TempDir() | |||
| 59 | + | want := dir + "/nested/sub/session.json" | |||
| 60 | + | ||||
| 61 | + | s, err := NewSessionAtPath(want, "/tmp/proj", "anthropic", "claude", "test") | |||
| 62 | + | if err != nil { | |||
| 63 | + | t.Fatalf("NewSessionAtPath: %v", err) | |||
| 64 | + | } | |||
| 65 | + | if s.Path != want { | |||
| 66 | + | t.Errorf("Path = %q; want %q", s.Path, want) | |||
| 67 | + | } | |||
| 68 | + | if err := s.AppendMessage(provider.Message{ | |||
| 69 | + | Role: provider.RoleUser, | |||
| 70 | + | Content: []provider.Content{provider.TextBlock{Text: "hi"}}, | |||
| 71 | + | Time: time.Now().UTC(), | |||
| 72 | + | }); err != nil { | |||
| 73 | + | t.Fatal(err) | |||
| 74 | + | } | |||
| 75 | + | if err := s.Close(); err != nil { | |||
| 76 | + | t.Fatal(err) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // Reopen at the same path and the message must still be there. | |||
| 80 | + | reopened, msgs, err := OpenSession(want) | |||
| 81 | + | if err != nil { | |||
| 82 | + | t.Fatalf("OpenSession at fixed path: %v", err) | |||
| 83 | + | } | |||
| 84 | + | t.Cleanup(func() { _ = reopened.Close() }) | |||
| 85 | + | if len(msgs) != 1 { | |||
| 86 | + | t.Fatalf("reopen got %d msgs; want 1", len(msgs)) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // A second call to NewSessionAtPath at the same path must fail | |||
| 90 | + | // (O_EXCL): callers should use OpenSession to reattach to an | |||
| 91 | + | // existing file. | |||
| 92 | + | if _, err := NewSessionAtPath(want, "/tmp/proj", "anthropic", "claude", "test"); err == nil { | |||
| 93 | + | t.Error("NewSessionAtPath on existing path returned nil; want error") | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 49 | func TestCostAdd(t *testing.T) { | 97 | func TestCostAdd(t *testing.T) { | ||
| 50 | var c CostTracker | 98 | var c CostTracker | ||
| 51 | c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, CostUSD: 0.01}) | 99 | c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, CostUSD: 0.01}) | ||
M internal/core/session.go +22 -1
| @@ -85,7 +85,8 @@ func SessionsDir(root, cwd string) string { | |||||
| 85 | return filepath.Join(root, "sessions", short) | 85 | return filepath.Join(root, "sessions", short) | ||
| 86 | } | 86 | } | ||
| 87 | 87 | ||||
| 88 | - | // NewSession creates and opens a new session file. | 88 | + | // NewSession creates and opens a new session file under |
| 89 | + | // SessionsDir(root, cwd) with an autogenerated, time-stamped name. | |||
| 89 | func NewSession(root, cwd, providerName, model, version string) (*Session, error) { | 90 | func NewSession(root, cwd, providerName, model, version string) (*Session, error) { | ||
| 90 | dir := SessionsDir(root, cwd) | 91 | dir := SessionsDir(root, cwd) | ||
| 91 | if err := os.MkdirAll(dir, 0o755); err != nil { | 92 | if err := os.MkdirAll(dir, 0o755); err != nil { | ||
| @@ -94,6 +95,26 @@ func NewSession(root, cwd, providerName, model, version string) (*Session, error | |||||
| 94 | id := uuid.NewString() | 95 | id := uuid.NewString() | ||
| 95 | name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), id[:8]) | 96 | name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), id[:8]) | ||
| 96 | p := filepath.Join(dir, name) | 97 | p := filepath.Join(dir, name) | ||
| 98 | + | return newSessionAt(p, cwd, providerName, model, version) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // NewSessionAtPath creates a session at an explicit file path. Used | |||
| 102 | + | // by callers (notably the swarm-agent child) that need the session | |||
| 103 | + | // file to live at a path chosen by their parent rather than under | |||
| 104 | + | // SessionsDir. Returns an error if the file already exists — use | |||
| 105 | + | // OpenSession for that case. | |||
| 106 | + | func NewSessionAtPath(path, cwd, providerName, model, version string) (*Session, error) { | |||
| 107 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |||
| 108 | + | return nil, err | |||
| 109 | + | } | |||
| 110 | + | return newSessionAt(path, cwd, providerName, model, version) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // newSessionAt is the shared implementation. Both NewSession and | |||
| 114 | + | // NewSessionAtPath funnel through here so the meta-line layout, | |||
| 115 | + | // freshFile bookkeeping, and id format stay identical. | |||
| 116 | + | func newSessionAt(p, cwd, providerName, model, version string) (*Session, error) { | |||
| 117 | + | id := uuid.NewString() | |||
| 97 | f, err := os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) | 118 | f, err := os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) | ||
| 98 | if err != nil { | 119 | if err != nil { | ||
| 99 | return nil, err | 120 | return nil, err | ||
A internal/swarm/agent.go +188 -0
| @@ -0,0 +1,188 @@ | |||||
| 1 | + | package swarm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | "sync" | |||
| 8 | + | "time" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Agent is one supervised task. Public fields are immutable after | |||
| 12 | + | // Spawn; mutable state (status, activity, transcript) lives behind | |||
| 13 | + | // the embedded mutex. | |||
| 14 | + | type Agent struct { | |||
| 15 | + | ID string | |||
| 16 | + | Task string | |||
| 17 | + | Branch string | |||
| 18 | + | Dir string | |||
| 19 | + | Started time.Time | |||
| 20 | + | ||||
| 21 | + | // Model and Provider, when non-empty, override the child | |||
| 22 | + | // subprocess's model resolution. Empty means "inherit whatever | |||
| 23 | + | // the child resolves on its own from config / env / flags" — | |||
| 24 | + | // the historical behaviour. Persisted in meta.json so Resume | |||
| 25 | + | // keeps using the same model across zot restarts. | |||
| 26 | + | Model string | |||
| 27 | + | Provider string | |||
| 28 | + | ||||
| 29 | + | // SessionID, when non-empty, scopes the agent to a particular | |||
| 30 | + | // host zot session: the dashboard only surfaces agents whose | |||
| 31 | + | // SessionID matches the active session. Empty means "unscoped" | |||
| 32 | + | // (legacy meta files from before the field existed, or agents | |||
| 33 | + | // spawned without a session context such as in tests). Set at | |||
| 34 | + | // Spawn time from Swarm.activeSession and persisted in | |||
| 35 | + | // meta.json so the scope survives restarts. | |||
| 36 | + | SessionID string | |||
| 37 | + | ||||
| 38 | + | // Resuming is true when this Agent struct was built by Resume | |||
| 39 | + | // rather than Spawn. The runner consults it to decide whether | |||
| 40 | + | // to pass the original Task as a positional argv to the child: | |||
| 41 | + | // on first Spawn we want the child to run the task immediately; | |||
| 42 | + | // on Resume the task was already run last time and replaying it | |||
| 43 | + | // would produce a second turn that collides ("agent busy; send | |||
| 44 | + | // 'cancel' first") with whatever the user types next. Not | |||
| 45 | + | // persisted — every Resume sets it explicitly. | |||
| 46 | + | Resuming bool | |||
| 47 | + | ||||
| 48 | + | // InboxPath is the unix socket the child agent listens on for | |||
| 49 | + | // follow-up prompts and control messages. The supervisor opens | |||
| 50 | + | // an Inbox at this path; the child opens a Listener. | |||
| 51 | + | InboxPath string | |||
| 52 | + | ||||
| 53 | + | // EventLogPath is the durable JSONL event log for this agent. | |||
| 54 | + | // The runner appends every well-formed event from the child | |||
| 55 | + | // (plus lifecycle events of its own) here. /swarm open in any | |||
| 56 | + | // zot process reads from this file to replay the full history. | |||
| 57 | + | EventLogPath string | |||
| 58 | + | ||||
| 59 | + | // SessionPath is the child's persistent session file. Surfaced | |||
| 60 | + | // so the dashboard / /swarm open can resume the agent later. | |||
| 61 | + | SessionPath string | |||
| 62 | + | ||||
| 63 | + | // inbox is the supervisor-side socket handle. Populated by | |||
| 64 | + | // Swarm.Spawn so SendInput can route messages without each | |||
| 65 | + | // caller redialing. | |||
| 66 | + | inbox *Inbox | |||
| 67 | + | ||||
| 68 | + | mu sync.Mutex | |||
| 69 | + | status Status | |||
| 70 | + | activity string | |||
| 71 | + | transcript []string | |||
| 72 | + | finished time.Time | |||
| 73 | + | lastErr error | |||
| 74 | + | ||||
| 75 | + | ctx context.Context | |||
| 76 | + | cancel context.CancelFunc | |||
| 77 | + | runner Runner | |||
| 78 | + | ||||
| 79 | + | // done closes when the run goroutine finalises the agent's | |||
| 80 | + | // status (done / failed / killed). Wait blocks on this so | |||
| 81 | + | // callers don't have to poll. | |||
| 82 | + | done chan struct{} | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // Inbox exposes the supervisor-side socket handle. Returns nil for | |||
| 86 | + | // agents without inboxes (e.g. tests using a custom Runner that | |||
| 87 | + | // doesn't speak the daemon protocol). | |||
| 88 | + | func (a *Agent) Inbox() *Inbox { return a.inbox } | |||
| 89 | + | ||||
| 90 | + | // Status returns the current high-level status. Cheap; safe from any goroutine. | |||
| 91 | + | func (a *Agent) Status() Status { | |||
| 92 | + | a.mu.Lock() | |||
| 93 | + | defer a.mu.Unlock() | |||
| 94 | + | return a.status | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // Activity returns the current one-line activity string. | |||
| 98 | + | func (a *Agent) Activity() string { | |||
| 99 | + | a.mu.Lock() | |||
| 100 | + | defer a.mu.Unlock() | |||
| 101 | + | return a.activity | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // Transcript returns a copy of the running transcript. | |||
| 105 | + | func (a *Agent) Transcript() []string { | |||
| 106 | + | a.mu.Lock() | |||
| 107 | + | defer a.mu.Unlock() | |||
| 108 | + | out := make([]string, len(a.transcript)) | |||
| 109 | + | copy(out, a.transcript) | |||
| 110 | + | return out | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // Err returns the runner's terminal error, if any. | |||
| 114 | + | func (a *Agent) Err() error { | |||
| 115 | + | a.mu.Lock() | |||
| 116 | + | defer a.mu.Unlock() | |||
| 117 | + | return a.lastErr | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // Wait blocks until the agent reaches a terminal state. Used by tests | |||
| 121 | + | // and by /swarm wait <id>. | |||
| 122 | + | func (a *Agent) Wait() { <-a.done } | |||
| 123 | + | ||||
| 124 | + | func (a *Agent) setStatus(s Status) { | |||
| 125 | + | a.mu.Lock() | |||
| 126 | + | a.status = s | |||
| 127 | + | a.mu.Unlock() | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func (a *Agent) setActivity(msg string) { | |||
| 131 | + | a.mu.Lock() | |||
| 132 | + | a.activity = strings.TrimSpace(msg) | |||
| 133 | + | a.mu.Unlock() | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func (a *Agent) appendTranscript(chunk string) { | |||
| 137 | + | chunk = strings.TrimRight(chunk, "\n") | |||
| 138 | + | if chunk == "" { | |||
| 139 | + | return | |||
| 140 | + | } | |||
| 141 | + | a.mu.Lock() | |||
| 142 | + | for _, line := range strings.Split(chunk, "\n") { | |||
| 143 | + | a.transcript = append(a.transcript, line) | |||
| 144 | + | } | |||
| 145 | + | // Bound the transcript so dashboards don't hold gigabytes for | |||
| 146 | + | // long-running agents. 2000 lines is plenty for inspection; | |||
| 147 | + | // the durable record lives in the agent's session file. | |||
| 148 | + | const cap = 2000 | |||
| 149 | + | if len(a.transcript) > cap { | |||
| 150 | + | a.transcript = a.transcript[len(a.transcript)-cap:] | |||
| 151 | + | } | |||
| 152 | + | a.mu.Unlock() | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // newAgentID returns a short, mostly-collision-free identifier of the | |||
| 156 | + | // form "<slug>-<nano>". The slug is derived from the task text so | |||
| 157 | + | // dashboards stay readable; the nano suffix guarantees uniqueness | |||
| 158 | + | // even when two agents are spawned in the same millisecond. | |||
| 159 | + | func newAgentID(task string, now time.Time) string { | |||
| 160 | + | slug := taskSlug(task) | |||
| 161 | + | return fmt.Sprintf("%s-%d", slug, now.UnixNano()%1_000_000) | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func taskSlug(task string) string { | |||
| 165 | + | task = strings.ToLower(task) | |||
| 166 | + | var b strings.Builder | |||
| 167 | + | dash := false | |||
| 168 | + | for _, r := range task { | |||
| 169 | + | switch { | |||
| 170 | + | case r >= 'a' && r <= 'z', r >= '0' && r <= '9': | |||
| 171 | + | b.WriteRune(r) | |||
| 172 | + | dash = false | |||
| 173 | + | default: | |||
| 174 | + | if !dash && b.Len() > 0 { | |||
| 175 | + | b.WriteByte('-') | |||
| 176 | + | dash = true | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | if b.Len() >= 24 { | |||
| 180 | + | break | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | out := strings.Trim(b.String(), "-") | |||
| 184 | + | if out == "" { | |||
| 185 | + | out = "agent" | |||
| 186 | + | } | |||
| 187 | + | return out | |||
| 188 | + | } | |||
A internal/swarm/event.go +305 -0
| @@ -0,0 +1,305 @@ | |||||
| 1 | + | package swarm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "os" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "sync" | |||
| 12 | + | "time" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // Event is one structured datum in an agent's durable event log. | |||
| 16 | + | // | |||
| 17 | + | // The shape is intentionally a thin map+timestamp wrapper rather | |||
| 18 | + | // than a strict struct so the swarm package doesn't have to track | |||
| 19 | + | // every field added to core.AgentEvent. The child writes | |||
| 20 | + | // `modes.EventToJSON(ev)` plus a few swarm-internal types | |||
| 21 | + | // (lifecycle, user_input echo, error). The supervisor doesn't | |||
| 22 | + | // interpret most fields; it just keeps them for replay and lifts | |||
| 23 | + | // a few well-known ones into AgentSnapshot (Activity, Status, | |||
| 24 | + | // Tail) for the dashboard. | |||
| 25 | + | // | |||
| 26 | + | // The on-disk file (events.jsonl) is append-only, one event per | |||
| 27 | + | // line, newline-terminated JSON. Reading is forward-only; readers | |||
| 28 | + | // stat the file size and read from their last offset on every | |||
| 29 | + | // poll. | |||
| 30 | + | type Event struct { | |||
| 31 | + | Time time.Time `json:"time"` | |||
| 32 | + | Type string `json:"type"` | |||
| 33 | + | Data map[string]any `json:"-"` | |||
| 34 | + | Raw map[string]any `json:"-"` // includes type+time+data for replay | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // MarshalJSON flattens Data into the top-level object so consumers | |||
| 38 | + | // see {type, time, ...fields} rather than {type, time, data:{...}}. | |||
| 39 | + | func (e Event) MarshalJSON() ([]byte, error) { | |||
| 40 | + | out := make(map[string]any, len(e.Data)+2) | |||
| 41 | + | for k, v := range e.Data { | |||
| 42 | + | out[k] = v | |||
| 43 | + | } | |||
| 44 | + | out["type"] = e.Type | |||
| 45 | + | out["time"] = e.Time | |||
| 46 | + | return json.Marshal(out) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // UnmarshalJSON accepts a flat object with at least type+time. | |||
| 50 | + | // All other fields land in both Data and Raw. | |||
| 51 | + | func (e *Event) UnmarshalJSON(b []byte) error { | |||
| 52 | + | m := map[string]any{} | |||
| 53 | + | if err := json.Unmarshal(b, &m); err != nil { | |||
| 54 | + | return err | |||
| 55 | + | } | |||
| 56 | + | e.Raw = m | |||
| 57 | + | e.Data = map[string]any{} | |||
| 58 | + | for k, v := range m { | |||
| 59 | + | if k == "type" || k == "time" { | |||
| 60 | + | continue | |||
| 61 | + | } | |||
| 62 | + | e.Data[k] = v | |||
| 63 | + | } | |||
| 64 | + | if t, ok := m["type"].(string); ok { | |||
| 65 | + | e.Type = t | |||
| 66 | + | } else { | |||
| 67 | + | return errors.New("swarm event: missing type") | |||
| 68 | + | } | |||
| 69 | + | if ts, ok := m["time"].(string); ok { | |||
| 70 | + | parsed, err := time.Parse(time.RFC3339Nano, ts) | |||
| 71 | + | if err == nil { | |||
| 72 | + | e.Time = parsed | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | return nil | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // EventLog is an append-only writer for events.jsonl. Safe for | |||
| 79 | + | // concurrent writers (the child only has one writer, but tests | |||
| 80 | + | // occasionally fan out). | |||
| 81 | + | type EventLog struct { | |||
| 82 | + | path string | |||
| 83 | + | mu sync.Mutex | |||
| 84 | + | f *os.File | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // OpenEventLog opens (or creates) the events.jsonl file at path. | |||
| 88 | + | // Parent directories are created as needed. | |||
| 89 | + | func OpenEventLog(path string) (*EventLog, error) { | |||
| 90 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |||
| 91 | + | return nil, fmt.Errorf("event log dir: %w", err) | |||
| 92 | + | } | |||
| 93 | + | f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) | |||
| 94 | + | if err != nil { | |||
| 95 | + | return nil, fmt.Errorf("event log open: %w", err) | |||
| 96 | + | } | |||
| 97 | + | return &EventLog{path: path, f: f}, nil | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // Path returns the absolute log path. | |||
| 101 | + | func (l *EventLog) Path() string { return l.path } | |||
| 102 | + | ||||
| 103 | + | // Append writes one event. The encoding is `<json>\n`. Concurrent | |||
| 104 | + | // callers are serialised; small enough events never need partial | |||
| 105 | + | // writes since unix guarantees atomicity for writes ≤ PIPE_BUF on | |||
| 106 | + | // regular files, and we hold a per-process mutex on top. | |||
| 107 | + | func (l *EventLog) Append(ev Event) error { | |||
| 108 | + | l.mu.Lock() | |||
| 109 | + | defer l.mu.Unlock() | |||
| 110 | + | if ev.Time.IsZero() { | |||
| 111 | + | ev.Time = time.Now() | |||
| 112 | + | } | |||
| 113 | + | b, err := json.Marshal(ev) | |||
| 114 | + | if err != nil { | |||
| 115 | + | return err | |||
| 116 | + | } | |||
| 117 | + | b = append(b, '\n') | |||
| 118 | + | _, err = l.f.Write(b) | |||
| 119 | + | return err | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // Close flushes and closes the underlying file. | |||
| 123 | + | func (l *EventLog) Close() error { | |||
| 124 | + | l.mu.Lock() | |||
| 125 | + | defer l.mu.Unlock() | |||
| 126 | + | if l.f == nil { | |||
| 127 | + | return nil | |||
| 128 | + | } | |||
| 129 | + | err := l.f.Close() | |||
| 130 | + | l.f = nil | |||
| 131 | + | return err | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // NewEvent is a convenience constructor that fills the timestamp. | |||
| 135 | + | func NewEvent(typ string, data map[string]any) Event { | |||
| 136 | + | if data == nil { | |||
| 137 | + | data = map[string]any{} | |||
| 138 | + | } | |||
| 139 | + | return Event{Time: time.Now(), Type: typ, Data: data} | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // ReadEventLog parses every event currently in the file. Used by | |||
| 143 | + | // the dashboard on first open and by tests; live tailing uses | |||
| 144 | + | // FollowEventLog below. | |||
| 145 | + | func ReadEventLog(path string) ([]Event, error) { | |||
| 146 | + | f, err := os.Open(path) | |||
| 147 | + | if err != nil { | |||
| 148 | + | if os.IsNotExist(err) { | |||
| 149 | + | return nil, nil | |||
| 150 | + | } | |||
| 151 | + | return nil, err | |||
| 152 | + | } | |||
| 153 | + | defer f.Close() | |||
| 154 | + | return readEvents(f) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func readEvents(r io.Reader) ([]Event, error) { | |||
| 158 | + | br := bufio.NewReader(r) | |||
| 159 | + | var out []Event | |||
| 160 | + | for { | |||
| 161 | + | line, err := br.ReadBytes('\n') | |||
| 162 | + | if len(line) > 0 { | |||
| 163 | + | var ev Event | |||
| 164 | + | if jerr := json.Unmarshal(line, &ev); jerr == nil { | |||
| 165 | + | if !isLikelyDoubleWrite(ev, out) { | |||
| 166 | + | out = append(out, ev) | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | // Malformed lines are skipped silently; the dashboard | |||
| 170 | + | // renders only well-formed events and the child is the | |||
| 171 | + | // only writer. | |||
| 172 | + | } | |||
| 173 | + | if err != nil { | |||
| 174 | + | if errors.Is(err, io.EOF) { | |||
| 175 | + | return out, nil | |||
| 176 | + | } | |||
| 177 | + | return out, err | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | // isLikelyDoubleWrite reports whether ev is a back-to-back duplicate | |||
| 183 | + | // of the most recent event in `tail` — same type and identical Data | |||
| 184 | + | // payload, with timestamps within a small window (the supervisor and | |||
| 185 | + | // the child's mirror used to BOTH write each event to disk, landing | |||
| 186 | + | // the same content twice in quick succession in events.jsonl). The | |||
| 187 | + | // historical behaviour was fixed at write time (the child's mirror | |||
| 188 | + | // is now dormant unless the supervisor is gone), but on-disk files | |||
| 189 | + | // from before the fix are still polluted, so we dedupe defensively | |||
| 190 | + | // at read time too. | |||
| 191 | + | // | |||
| 192 | + | // We deliberately bound by time (250ms) so two genuinely identical | |||
| 193 | + | // adjacent events that happen seconds apart — e.g. an agent that | |||
| 194 | + | // runs the same tool twice in a row — still both render. | |||
| 195 | + | func isLikelyDoubleWrite(ev Event, tail []Event) bool { | |||
| 196 | + | if len(tail) == 0 { | |||
| 197 | + | return false | |||
| 198 | + | } | |||
| 199 | + | prev := tail[len(tail)-1] | |||
| 200 | + | if prev.Type != ev.Type { | |||
| 201 | + | return false | |||
| 202 | + | } | |||
| 203 | + | if !ev.Time.IsZero() && !prev.Time.IsZero() { | |||
| 204 | + | dt := ev.Time.Sub(prev.Time) | |||
| 205 | + | if dt < 0 { | |||
| 206 | + | dt = -dt | |||
| 207 | + | } | |||
| 208 | + | if dt > 250*time.Millisecond { | |||
| 209 | + | return false | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | return sameEventData(prev.Data, ev.Data) | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | // sameEventData deep-compares two event payloads. Cheap because the | |||
| 216 | + | // payloads are small map[string]any trees built from JSON, and only | |||
| 217 | + | // called for adjacent same-type pairs. | |||
| 218 | + | func sameEventData(a, b map[string]any) bool { | |||
| 219 | + | if len(a) != len(b) { | |||
| 220 | + | return false | |||
| 221 | + | } | |||
| 222 | + | ab, _ := json.Marshal(a) | |||
| 223 | + | bb, _ := json.Marshal(b) | |||
| 224 | + | return string(ab) == string(bb) | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | // EventFollower polls an events.jsonl file and emits new events as | |||
| 228 | + | // they're appended. The supervisor uses one per agent so dashboard | |||
| 229 | + | // snapshots stay current without reparsing the whole file on every | |||
| 230 | + | // frame. Close stops the goroutine; the channel returned by Events | |||
| 231 | + | // is closed when Close is called or the file is removed. | |||
| 232 | + | type EventFollower struct { | |||
| 233 | + | path string | |||
| 234 | + | interval time.Duration | |||
| 235 | + | out chan Event | |||
| 236 | + | done chan struct{} | |||
| 237 | + | once sync.Once | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | // FollowEventLog starts polling path every interval. interval ≤ 0 | |||
| 241 | + | // defaults to 50ms which is invisible to the user and cheap on | |||
| 242 | + | // disk (one stat + one short read per agent per tick). | |||
| 243 | + | func FollowEventLog(path string, interval time.Duration) *EventFollower { | |||
| 244 | + | if interval <= 0 { | |||
| 245 | + | interval = 50 * time.Millisecond | |||
| 246 | + | } | |||
| 247 | + | f := &EventFollower{ | |||
| 248 | + | path: path, | |||
| 249 | + | interval: interval, | |||
| 250 | + | out: make(chan Event, 256), | |||
| 251 | + | done: make(chan struct{}), | |||
| 252 | + | } | |||
| 253 | + | go f.loop() | |||
| 254 | + | return f | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | // Events returns the receive end of the event stream. | |||
| 258 | + | func (f *EventFollower) Events() <-chan Event { return f.out } | |||
| 259 | + | ||||
| 260 | + | // Close stops polling and closes the events channel. | |||
| 261 | + | func (f *EventFollower) Close() { | |||
| 262 | + | f.once.Do(func() { | |||
| 263 | + | close(f.done) | |||
| 264 | + | }) | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | func (f *EventFollower) loop() { | |||
| 268 | + | defer close(f.out) | |||
| 269 | + | var offset int64 | |||
| 270 | + | tick := time.NewTicker(f.interval) | |||
| 271 | + | defer tick.Stop() | |||
| 272 | + | for { | |||
| 273 | + | select { | |||
| 274 | + | case <-f.done: | |||
| 275 | + | return | |||
| 276 | + | case <-tick.C: | |||
| 277 | + | } | |||
| 278 | + | fi, err := os.Stat(f.path) | |||
| 279 | + | if err != nil { | |||
| 280 | + | continue | |||
| 281 | + | } | |||
| 282 | + | if fi.Size() <= offset { | |||
| 283 | + | continue | |||
| 284 | + | } | |||
| 285 | + | fh, err := os.Open(f.path) | |||
| 286 | + | if err != nil { | |||
| 287 | + | continue | |||
| 288 | + | } | |||
| 289 | + | if _, err := fh.Seek(offset, io.SeekStart); err != nil { | |||
| 290 | + | _ = fh.Close() | |||
| 291 | + | continue | |||
| 292 | + | } | |||
| 293 | + | evs, _ := readEvents(fh) | |||
| 294 | + | newOffset, _ := fh.Seek(0, io.SeekCurrent) | |||
| 295 | + | _ = fh.Close() | |||
| 296 | + | offset = newOffset | |||
| 297 | + | for _, ev := range evs { | |||
| 298 | + | select { | |||
| 299 | + | case f.out <- ev: | |||
| 300 | + | case <-f.done: | |||
| 301 | + | return | |||
| 302 | + | } | |||
| 303 | + | } | |||
| 304 | + | } | |||
| 305 | + | } | |||
A internal/swarm/event_test.go +227 -0
| @@ -0,0 +1,227 @@ | |||||
| 1 | + | package swarm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "path/filepath" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // TestEventLogAppendAndRead is the simplest possible invariant: | |||
| 10 | + | // what we write goes back through ReadEventLog exactly, including | |||
| 11 | + | // the flat field shape and the timestamp. | |||
| 12 | + | func TestEventLogAppendAndRead(t *testing.T) { | |||
| 13 | + | path := filepath.Join(t.TempDir(), "events.jsonl") | |||
| 14 | + | log, err := OpenEventLog(path) | |||
| 15 | + | if err != nil { | |||
| 16 | + | t.Fatalf("open: %v", err) | |||
| 17 | + | } | |||
| 18 | + | want := []Event{ | |||
| 19 | + | NewEvent("turn_start", map[string]any{"step": 0}), | |||
| 20 | + | NewEvent("assistant_message", map[string]any{ | |||
| 21 | + | "content": []any{map[string]any{"type": "text", "text": "hi"}}, | |||
| 22 | + | }), | |||
| 23 | + | NewEvent("turn_end", map[string]any{"stop": "end"}), | |||
| 24 | + | } | |||
| 25 | + | for _, ev := range want { | |||
| 26 | + | if err := log.Append(ev); err != nil { | |||
| 27 | + | t.Fatalf("append: %v", err) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | _ = log.Close() | |||
| 31 | + | ||||
| 32 | + | got, err := ReadEventLog(path) | |||
| 33 | + | if err != nil { | |||
| 34 | + | t.Fatalf("read: %v", err) | |||
| 35 | + | } | |||
| 36 | + | if len(got) != len(want) { | |||
| 37 | + | t.Fatalf("got %d events, want %d", len(got), len(want)) | |||
| 38 | + | } | |||
| 39 | + | for i := range want { | |||
| 40 | + | if got[i].Type != want[i].Type { | |||
| 41 | + | t.Errorf("event %d type = %q; want %q", i, got[i].Type, want[i].Type) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // TestReadEventLogIgnoresGarbage documents that one malformed line | |||
| 47 | + | // doesn't break the whole replay. The runner's responsibility is to | |||
| 48 | + | // only write well-formed events, but a partial crash could leave | |||
| 49 | + | // a half-written line; we don't want that to prevent the dashboard | |||
| 50 | + | // from showing the rest. | |||
| 51 | + | func TestReadEventLogIgnoresGarbage(t *testing.T) { | |||
| 52 | + | path := filepath.Join(t.TempDir(), "events.jsonl") | |||
| 53 | + | log, err := OpenEventLog(path) | |||
| 54 | + | if err != nil { | |||
| 55 | + | t.Fatalf("open: %v", err) | |||
| 56 | + | } | |||
| 57 | + | if err := log.Append(NewEvent("first", nil)); err != nil { | |||
| 58 | + | t.Fatalf("append: %v", err) | |||
| 59 | + | } | |||
| 60 | + | // Sneak a garbage line in by writing directly. | |||
| 61 | + | if _, err := log.f.WriteString("not json at all\n"); err != nil { | |||
| 62 | + | t.Fatalf("raw write: %v", err) | |||
| 63 | + | } | |||
| 64 | + | if err := log.Append(NewEvent("second", nil)); err != nil { | |||
| 65 | + | t.Fatalf("append: %v", err) | |||
| 66 | + | } | |||
| 67 | + | _ = log.Close() | |||
| 68 | + | ||||
| 69 | + | got, err := ReadEventLog(path) | |||
| 70 | + | if err != nil { | |||
| 71 | + | t.Fatalf("read: %v", err) | |||
| 72 | + | } | |||
| 73 | + | if len(got) != 2 || got[0].Type != "first" || got[1].Type != "second" { | |||
| 74 | + | t.Fatalf("garbage tripped parser: %+v", got) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // TestReadEventLogDeduplicatesDoubleWrites pins the read-time fix | |||
| 79 | + | // for already-polluted events.jsonl files. The historical bug: the | |||
| 80 | + | // supervisor parsed child stdout and called log.Append(ev), AND the | |||
| 81 | + | // child's mirror appended the same event. Two writes per event | |||
| 82 | + | // landed in the file in quick succession with near-identical | |||
| 83 | + | // timestamps, and the next zot launch replayed everything twice — | |||
| 84 | + | // the user saw every transcript line duplicated. | |||
| 85 | + | // | |||
| 86 | + | // The write-time fix makes the child's mirror dormant when the | |||
| 87 | + | // supervisor is alive, but old files are still polluted. ReadEventLog | |||
| 88 | + | // now folds back-to-back same-type identical-payload events whose | |||
| 89 | + | // timestamps are within ~250ms of each other. | |||
| 90 | + | func TestReadEventLogDeduplicatesDoubleWrites(t *testing.T) { | |||
| 91 | + | path := filepath.Join(t.TempDir(), "events.jsonl") | |||
| 92 | + | log, err := OpenEventLog(path) | |||
| 93 | + | if err != nil { | |||
| 94 | + | t.Fatalf("open: %v", err) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // Two writes per logical event, mimicking the supervisor + | |||
| 98 | + | // mirror double-write pattern. Timestamps differ by a few ms | |||
| 99 | + | // like they would in the wild. | |||
| 100 | + | now := time.Now() | |||
| 101 | + | writePair := func(typ string, data map[string]any) { | |||
| 102 | + | e1 := NewEvent(typ, data) | |||
| 103 | + | e1.Time = now | |||
| 104 | + | if err := log.Append(e1); err != nil { | |||
| 105 | + | t.Fatalf("append: %v", err) | |||
| 106 | + | } | |||
| 107 | + | e2 := NewEvent(typ, data) | |||
| 108 | + | e2.Time = now.Add(2 * time.Millisecond) | |||
| 109 | + | if err := log.Append(e2); err != nil { | |||
| 110 | + | t.Fatalf("append: %v", err) | |||
| 111 | + | } | |||
| 112 | + | now = now.Add(500 * time.Millisecond) // next event well outside the dedup window | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | writePair("assistant_message", map[string]any{ | |||
| 116 | + | "content": []any{map[string]any{"type": "text", "text": "hi"}}, | |||
| 117 | + | }) | |||
| 118 | + | writePair("turn_end", map[string]any{"step": float64(1)}) | |||
| 119 | + | _ = log.Close() | |||
| 120 | + | ||||
| 121 | + | got, err := ReadEventLog(path) | |||
| 122 | + | if err != nil { | |||
| 123 | + | t.Fatalf("read: %v", err) | |||
| 124 | + | } | |||
| 125 | + | if len(got) != 2 { | |||
| 126 | + | t.Fatalf("expected double-writes to collapse to 2 events; got %d", len(got)) | |||
| 127 | + | } | |||
| 128 | + | if got[0].Type != "assistant_message" || got[1].Type != "turn_end" { | |||
| 129 | + | t.Errorf("unexpected types after dedup: %q, %q", got[0].Type, got[1].Type) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // TestReadEventLogKeepsLegitimateAdjacentEvents pins the inverse: | |||
| 134 | + | // two events of the same type with identical payloads but separated | |||
| 135 | + | // by more than the dedup window (250ms) must both survive. The | |||
| 136 | + | // agent legitimately can emit identical adjacent events (think: a | |||
| 137 | + | // retry running the same tool seconds apart). | |||
| 138 | + | func TestReadEventLogKeepsLegitimateAdjacentEvents(t *testing.T) { | |||
| 139 | + | path := filepath.Join(t.TempDir(), "events.jsonl") | |||
| 140 | + | log, err := OpenEventLog(path) | |||
| 141 | + | if err != nil { | |||
| 142 | + | t.Fatalf("open: %v", err) | |||
| 143 | + | } | |||
| 144 | + | now := time.Now() | |||
| 145 | + | for i := 0; i < 3; i++ { | |||
| 146 | + | e := NewEvent("tool_call", map[string]any{"name": "read"}) | |||
| 147 | + | e.Time = now.Add(time.Duration(i) * time.Second) | |||
| 148 | + | if err := log.Append(e); err != nil { | |||
| 149 | + | t.Fatalf("append: %v", err) | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | _ = log.Close() | |||
| 153 | + | ||||
| 154 | + | got, err := ReadEventLog(path) | |||
| 155 | + | if err != nil { | |||
| 156 | + | t.Fatalf("read: %v", err) | |||
| 157 | + | } | |||
| 158 | + | if len(got) != 3 { | |||
| 159 | + | t.Errorf("dedup collapsed legitimately separated events; got %d, want 3", len(got)) | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | // TestFollowerEmitsNewEvents follows a live file and ensures events | |||
| 164 | + | // appended after the follower starts make it to the channel. | |||
| 165 | + | func TestFollowerEmitsNewEvents(t *testing.T) { | |||
| 166 | + | path := filepath.Join(t.TempDir(), "events.jsonl") | |||
| 167 | + | // Pre-seed one event so the file exists. The follower's | |||
| 168 | + | // contract is "emit events appended AFTER it starts", so this | |||
| 169 | + | // first event is not expected on the channel. | |||
| 170 | + | log, err := OpenEventLog(path) | |||
| 171 | + | if err != nil { | |||
| 172 | + | t.Fatalf("open: %v", err) | |||
| 173 | + | } | |||
| 174 | + | if err := log.Append(NewEvent("preexisting", nil)); err != nil { | |||
| 175 | + | t.Fatalf("append: %v", err) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | fol := FollowEventLog(path, 10*time.Millisecond) | |||
| 179 | + | defer fol.Close() | |||
| 180 | + | ||||
| 181 | + | // Give the follower one tick to read the pre-existing event so | |||
| 182 | + | // its offset advances past the first line. After that, any new | |||
| 183 | + | // append should show up. | |||
| 184 | + | time.Sleep(40 * time.Millisecond) | |||
| 185 | + | drain(fol.Events()) | |||
| 186 | + | ||||
| 187 | + | for i := 0; i < 3; i++ { | |||
| 188 | + | if err := log.Append(NewEvent("live", map[string]any{"i": i})); err != nil { | |||
| 189 | + | t.Fatalf("append %d: %v", i, err) | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | got := waitFor(t, fol.Events(), 3, time.Second) | |||
| 194 | + | if len(got) != 3 { | |||
| 195 | + | t.Fatalf("got %d live events, want 3", len(got)) | |||
| 196 | + | } | |||
| 197 | + | for i, ev := range got { | |||
| 198 | + | if ev.Type != "live" { | |||
| 199 | + | t.Errorf("event %d type = %q; want live", i, ev.Type) | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func drain(ch <-chan Event) { | |||
| 205 | + | for { | |||
| 206 | + | select { | |||
| 207 | + | case <-ch: | |||
| 208 | + | default: | |||
| 209 | + | return | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func waitFor(t *testing.T, ch <-chan Event, n int, timeout time.Duration) []Event { | |||
| 215 | + | t.Helper() | |||
| 216 | + | var got []Event | |||
| 217 | + | deadline := time.After(timeout) | |||
| 218 | + | for len(got) < n { | |||
| 219 | + | select { | |||
| 220 | + | case ev := <-ch: | |||
| 221 | + | got = append(got, ev) | |||
| 222 | + | case <-deadline: | |||
| 223 | + | return got | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | return got | |||
| 227 | + | } | |||
A internal/swarm/inbox.go +270 -0
| @@ -0,0 +1,270 @@ | |||||
| 1 | + | package swarm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "io" | |||
| 8 | + | "net" | |||
| 9 | + | "os" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "sync" | |||
| 12 | + | "syscall" | |||
| 13 | + | "time" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // Inbox is the supervisor-side handle on a per-agent unix socket | |||
| 17 | + | // that the parent zot uses to send follow-up prompts (and other | |||
| 18 | + | // control lines) to a running swarm agent. | |||
| 19 | + | // | |||
| 20 | + | // Protocol on the wire: one UTF-8 message per line, newline- | |||
| 21 | + | // terminated. Three message kinds: | |||
| 22 | + | // | |||
| 23 | + | // user <text>... — append <text> as the next user turn | |||
| 24 | + | // cancel — cancel the agent's in-flight turn | |||
| 25 | + | // shutdown — graceful exit; child will write a final | |||
| 26 | + | // EvTurnEnd-equivalent JSON event and quit | |||
| 27 | + | // | |||
| 28 | + | // The child listens on the same socket via Listener (below) and | |||
| 29 | + | // translates the messages into core.Agent.Prompt calls. The | |||
| 30 | + | // parent never reads from the socket — it's a one-way channel | |||
| 31 | + | // driven by the supervisor. | |||
| 32 | + | // | |||
| 33 | + | // Why a socket and not a FIFO: FIFOs are POSIX-only and have | |||
| 34 | + | // awkward blocking-open semantics. A unix socket gives us: | |||
| 35 | + | // | |||
| 36 | + | // - the ability to fail fast if no child is listening yet, | |||
| 37 | + | // so SendInput returns a useful error to the TUI; | |||
| 38 | + | // - clean shutdown semantics when the child closes the | |||
| 39 | + | // listener; | |||
| 40 | + | // - works under tests with a temp dir; and | |||
| 41 | + | // - portable enough; Windows support is a follow-up that can | |||
| 42 | + | // swap in a named pipe behind this same interface. | |||
| 43 | + | type Inbox struct { | |||
| 44 | + | path string | |||
| 45 | + | ||||
| 46 | + | mu sync.Mutex | |||
| 47 | + | conn net.Conn // lazily dialed; one persistent connection per agent | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // NewInbox returns a handle that will dial the socket at path on | |||
| 51 | + | // the first SendInput call. The socket file is expected to be | |||
| 52 | + | // created by the child (Listener.Open below). This split lets the | |||
| 53 | + | // parent build the Inbox before the child has booted; sends that | |||
| 54 | + | // happen before the child is ready get a clear "not yet" error | |||
| 55 | + | // the caller can retry or surface. | |||
| 56 | + | func NewInbox(path string) *Inbox { return &Inbox{path: path} } | |||
| 57 | + | ||||
| 58 | + | // Path returns the absolute socket path. Used by the runner to | |||
| 59 | + | // pass the path to the child as a flag. | |||
| 60 | + | func (b *Inbox) Path() string { return b.path } | |||
| 61 | + | ||||
| 62 | + | // SendInput writes msg to the inbox as one line. msg must already | |||
| 63 | + | // include the leading kind ("user ", "cancel", "shutdown") — the | |||
| 64 | + | // caller chooses the shape so we don't proliferate methods. The | |||
| 65 | + | // socket is reused across calls; transient dial failures yield | |||
| 66 | + | // ErrNotReady so the TUI can retry or report "agent not listening | |||
| 67 | + | // yet" rather than crashing. | |||
| 68 | + | func (b *Inbox) SendInput(msg string) error { | |||
| 69 | + | b.mu.Lock() | |||
| 70 | + | defer b.mu.Unlock() | |||
| 71 | + | if b.conn == nil { | |||
| 72 | + | c, err := dialUnix(b.path, 200*time.Millisecond) | |||
| 73 | + | if err != nil { | |||
| 74 | + | return err | |||
| 75 | + | } | |||
| 76 | + | b.conn = c | |||
| 77 | + | } | |||
| 78 | + | if _, err := io.WriteString(b.conn, msg+"\n"); err != nil { | |||
| 79 | + | // Drop the connection so the next call redials. The | |||
| 80 | + | // previous error is more informative than the redial's | |||
| 81 | + | // would be, so surface this one. | |||
| 82 | + | _ = b.conn.Close() | |||
| 83 | + | b.conn = nil | |||
| 84 | + | return err | |||
| 85 | + | } | |||
| 86 | + | return nil | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // Close drops any persistent connection. Safe to call repeatedly. | |||
| 90 | + | // Does not unlink the socket file — that's the child's job | |||
| 91 | + | // (Listener.Close). | |||
| 92 | + | func (b *Inbox) Close() error { | |||
| 93 | + | b.mu.Lock() | |||
| 94 | + | defer b.mu.Unlock() | |||
| 95 | + | if b.conn != nil { | |||
| 96 | + | err := b.conn.Close() | |||
| 97 | + | b.conn = nil | |||
| 98 | + | return err | |||
| 99 | + | } | |||
| 100 | + | return nil | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // ErrNotReady is returned by SendInput when the child hasn't yet | |||
| 104 | + | // opened its listener. Callers can retry after a short backoff; | |||
| 105 | + | // the TUI surfaces it as "agent <id> not listening yet". | |||
| 106 | + | var ErrNotReady = errors.New("swarm: agent inbox not ready") | |||
| 107 | + | ||||
| 108 | + | // dialUnix wraps net.DialTimeout with a small retry window so the | |||
| 109 | + | // happy path "child just started, supervisor sends right away" | |||
| 110 | + | // works without forcing the caller to poll. | |||
| 111 | + | // | |||
| 112 | + | // Errors collapse onto ErrNotReady whenever the failure means "no | |||
| 113 | + | // one is listening": either the socket file doesn't exist (child | |||
| 114 | + | // hasn't booted) or it does exist but the dial was refused (the | |||
| 115 | + | // previous child exited and left a stale inode). Both are | |||
| 116 | + | // recoverable by Swarm.Resume, and both should surface as the same | |||
| 117 | + | // user-facing hint rather than as a path-leaking error string. | |||
| 118 | + | func dialUnix(path string, timeout time.Duration) (net.Conn, error) { | |||
| 119 | + | deadline := time.Now().Add(timeout) | |||
| 120 | + | var lastErr error | |||
| 121 | + | for { | |||
| 122 | + | c, err := net.DialTimeout("unix", path, 50*time.Millisecond) | |||
| 123 | + | if err == nil { | |||
| 124 | + | return c, nil | |||
| 125 | + | } | |||
| 126 | + | lastErr = err | |||
| 127 | + | if time.Now().After(deadline) { | |||
| 128 | + | break | |||
| 129 | + | } | |||
| 130 | + | time.Sleep(20 * time.Millisecond) | |||
| 131 | + | } | |||
| 132 | + | if _, statErr := os.Stat(path); os.IsNotExist(statErr) { | |||
| 133 | + | return nil, ErrNotReady | |||
| 134 | + | } | |||
| 135 | + | if isNoListenerErr(lastErr) { | |||
| 136 | + | return nil, ErrNotReady | |||
| 137 | + | } | |||
| 138 | + | return nil, fmt.Errorf("swarm: dial %s: %w", path, lastErr) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // isNoListenerErr reports whether err means "the socket file exists | |||
| 142 | + | // but no process is listening on it" — i.e. ECONNREFUSED on unix | |||
| 143 | + | // domain sockets. We pattern-match on errno via errors.Is rather | |||
| 144 | + | // than the error string so platform variants (linux vs darwin) all | |||
| 145 | + | // fold to the same case. | |||
| 146 | + | func isNoListenerErr(err error) bool { | |||
| 147 | + | return errors.Is(err, syscall.ECONNREFUSED) | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // Listener is the child-side end of the inbox. The swarm-agent | |||
| 151 | + | // daemon mode constructs one with Listen, then iterates Lines | |||
| 152 | + | // to receive supervisor messages. Designed so a single goroutine | |||
| 153 | + | // in the child can drive prompting without juggling raw net code. | |||
| 154 | + | type Listener struct { | |||
| 155 | + | path string | |||
| 156 | + | ln net.Listener | |||
| 157 | + | // active is the most recent accepted connection. The protocol | |||
| 158 | + | // only expects one supervisor (the parent zot), so newer | |||
| 159 | + | // connections preempt older ones — the previous parent | |||
| 160 | + | // presumably crashed. | |||
| 161 | + | mu sync.Mutex | |||
| 162 | + | active net.Conn | |||
| 163 | + | out chan string | |||
| 164 | + | done chan struct{} | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // Listen creates the socket at path and starts accepting. The | |||
| 168 | + | // caller must call Close to remove the socket file on shutdown. | |||
| 169 | + | // Returns the listener even if a stale socket exists from a | |||
| 170 | + | // previous run: we unlink first, mirroring how most unix daemons | |||
| 171 | + | // behave. | |||
| 172 | + | func Listen(path string) (*Listener, error) { | |||
| 173 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |||
| 174 | + | return nil, fmt.Errorf("inbox dir: %w", err) | |||
| 175 | + | } | |||
| 176 | + | // Best-effort cleanup of a stale socket. If the parent | |||
| 177 | + | // process is still alive and using it, Listen will fail | |||
| 178 | + | // below and the caller surfaces that as a real conflict. | |||
| 179 | + | _ = os.Remove(path) | |||
| 180 | + | ln, err := net.Listen("unix", path) | |||
| 181 | + | if err != nil { | |||
| 182 | + | return nil, fmt.Errorf("inbox listen: %w", err) | |||
| 183 | + | } | |||
| 184 | + | l := &Listener{ | |||
| 185 | + | path: path, | |||
| 186 | + | ln: ln, | |||
| 187 | + | out: make(chan string, 16), | |||
| 188 | + | done: make(chan struct{}), | |||
| 189 | + | } | |||
| 190 | + | go l.acceptLoop() | |||
| 191 | + | return l, nil | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // Lines returns a channel of newline-stripped messages received | |||
| 195 | + | // from the supervisor. The channel closes when Close is called. | |||
| 196 | + | func (l *Listener) Lines() <-chan string { return l.out } | |||
| 197 | + | ||||
| 198 | + | // Close stops accepting, drops the active connection, removes | |||
| 199 | + | // the socket file, and closes Lines. Idempotent. | |||
| 200 | + | func (l *Listener) Close() error { | |||
| 201 | + | select { | |||
| 202 | + | case <-l.done: | |||
| 203 | + | return nil | |||
| 204 | + | default: | |||
| 205 | + | close(l.done) | |||
| 206 | + | } | |||
| 207 | + | l.mu.Lock() | |||
| 208 | + | if l.active != nil { | |||
| 209 | + | _ = l.active.Close() | |||
| 210 | + | } | |||
| 211 | + | l.mu.Unlock() | |||
| 212 | + | _ = l.ln.Close() | |||
| 213 | + | _ = os.Remove(l.path) | |||
| 214 | + | return nil | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func (l *Listener) acceptLoop() { | |||
| 218 | + | defer close(l.out) | |||
| 219 | + | for { | |||
| 220 | + | c, err := l.ln.Accept() | |||
| 221 | + | if err != nil { | |||
| 222 | + | select { | |||
| 223 | + | case <-l.done: | |||
| 224 | + | return | |||
| 225 | + | default: | |||
| 226 | + | // Accept can fail transiently (signal); back off briefly. | |||
| 227 | + | time.Sleep(20 * time.Millisecond) | |||
| 228 | + | continue | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | l.mu.Lock() | |||
| 232 | + | if l.active != nil { | |||
| 233 | + | _ = l.active.Close() | |||
| 234 | + | } | |||
| 235 | + | l.active = c | |||
| 236 | + | l.mu.Unlock() | |||
| 237 | + | go l.readLoop(c) | |||
| 238 | + | } | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | func (l *Listener) readLoop(c net.Conn) { | |||
| 242 | + | br := bufio.NewReader(c) | |||
| 243 | + | for { | |||
| 244 | + | line, err := br.ReadString('\n') | |||
| 245 | + | if line != "" { | |||
| 246 | + | select { | |||
| 247 | + | case l.out <- trimRightNL(line): | |||
| 248 | + | case <-l.done: | |||
| 249 | + | _ = c.Close() | |||
| 250 | + | return | |||
| 251 | + | } | |||
| 252 | + | } | |||
| 253 | + | if err != nil { | |||
| 254 | + | _ = c.Close() | |||
| 255 | + | l.mu.Lock() | |||
| 256 | + | if l.active == c { | |||
| 257 | + | l.active = nil | |||
| 258 | + | } | |||
| 259 | + | l.mu.Unlock() | |||
| 260 | + | return | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | func trimRightNL(s string) string { | |||
| 266 | + | for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') { | |||
| 267 | + | s = s[:len(s)-1] | |||
| 268 | + | } | |||
| 269 | + | return s | |||
| 270 | + | } | |||
A internal/swarm/inbox_test.go +232 -0
| @@ -0,0 +1,232 @@ | |||||
| 1 | + | package swarm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "sync" | |||
| 9 | + | "syscall" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // shortSocketDir returns a tempdir short enough that we can append | |||
| 15 | + | // "in.sock" and still fit under the unix-socket path cap. The Go | |||
| 16 | + | // runner's t.TempDir() lives under a long /var/folders/... path on | |||
| 17 | + | // macOS which blows the 104-byte limit by itself, so the inbox tests | |||
| 18 | + | // can't use it. | |||
| 19 | + | func shortSocketDir(t *testing.T) string { | |||
| 20 | + | t.Helper() | |||
| 21 | + | dir, err := os.MkdirTemp("/tmp", "zot-in-") | |||
| 22 | + | if err != nil { | |||
| 23 | + | t.Fatalf("mkdir: %v", err) | |||
| 24 | + | } | |||
| 25 | + | t.Cleanup(func() { os.RemoveAll(dir) }) | |||
| 26 | + | return dir | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // TestInboxRoundTrip is the happy path: child listens, supervisor | |||
| 30 | + | // dials and sends three lines, child receives them in order, and | |||
| 31 | + | // closing both sides is clean. Locks in the line-framed protocol. | |||
| 32 | + | func TestInboxRoundTrip(t *testing.T) { | |||
| 33 | + | path := filepath.Join(shortSocketDir(t), "in.sock") | |||
| 34 | + | ln, err := Listen(path) | |||
| 35 | + | if err != nil { | |||
| 36 | + | t.Fatalf("Listen: %v", err) | |||
| 37 | + | } | |||
| 38 | + | defer ln.Close() | |||
| 39 | + | ||||
| 40 | + | got := collectFor(t, ln.Lines(), 3, time.Second) | |||
| 41 | + | inbox := NewInbox(path) | |||
| 42 | + | defer inbox.Close() | |||
| 43 | + | ||||
| 44 | + | want := []string{"user hello", "user world", "cancel"} | |||
| 45 | + | for _, msg := range want { | |||
| 46 | + | if err := inbox.SendInput(msg); err != nil { | |||
| 47 | + | t.Fatalf("SendInput(%q): %v", msg, err) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | gotLines := <-got | |||
| 52 | + | if len(gotLines) != len(want) { | |||
| 53 | + | t.Fatalf("got %d lines, want %d: %v", len(gotLines), len(want), gotLines) | |||
| 54 | + | } | |||
| 55 | + | for i, w := range want { | |||
| 56 | + | if gotLines[i] != w { | |||
| 57 | + | t.Errorf("line %d = %q; want %q", i, gotLines[i], w) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // TestInboxNotReady regression-tests the explicit "child hasn't | |||
| 63 | + | // opened the socket yet" case. The TUI needs an error it can show | |||
| 64 | + | // as "agent not listening yet" rather than a generic dial failure. | |||
| 65 | + | func TestInboxNotReady(t *testing.T) { | |||
| 66 | + | path := filepath.Join(shortSocketDir(t), "in.sock") | |||
| 67 | + | inbox := NewInbox(path) | |||
| 68 | + | defer inbox.Close() | |||
| 69 | + | err := inbox.SendInput("user hi") | |||
| 70 | + | if err == nil { | |||
| 71 | + | t.Fatal("SendInput on missing socket: want error") | |||
| 72 | + | } | |||
| 73 | + | if !errors.Is(err, ErrNotReady) { | |||
| 74 | + | t.Fatalf("SendInput err = %v; want ErrNotReady", err) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // TestIsNoListenerErrMatchesECONNREFUSED documents the contract | |||
| 79 | + | // that the user's screenshot regression depends on: any dial error | |||
| 80 | + | // whose root cause is "the kernel refused the connect because nobody | |||
| 81 | + | // is listening" must fold into ErrNotReady at the SendInput layer, | |||
| 82 | + | // so the TUI shows "agent not accepting input (press R to resume)" | |||
| 83 | + | // instead of leaking the unix-socket path. We pattern-match via | |||
| 84 | + | // errors.Is on syscall.ECONNREFUSED rather than string-matching the | |||
| 85 | + | // error text so platform variants all hit the same branch. | |||
| 86 | + | func TestIsNoListenerErrMatchesECONNREFUSED(t *testing.T) { | |||
| 87 | + | if !isNoListenerErr(syscall.ECONNREFUSED) { | |||
| 88 | + | t.Error("isNoListenerErr(ECONNREFUSED) = false; want true") | |||
| 89 | + | } | |||
| 90 | + | // Wrapped via fmt.Errorf — common form of the dial error. | |||
| 91 | + | if !isNoListenerErr(fmt.Errorf("dial unix /tmp/x: %w", syscall.ECONNREFUSED)) { | |||
| 92 | + | t.Error("isNoListenerErr(wrapped) = false; want true") | |||
| 93 | + | } | |||
| 94 | + | if isNoListenerErr(syscall.ECONNRESET) { | |||
| 95 | + | t.Error("isNoListenerErr(ECONNRESET) = true; want false (unrelated errno)") | |||
| 96 | + | } | |||
| 97 | + | if isNoListenerErr(nil) { | |||
| 98 | + | t.Error("isNoListenerErr(nil) = true; want false") | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // TestInboxNewSupervisorPreemptsOld documents that only one | |||
| 103 | + | // supervisor connection at a time is honoured. The swarm design | |||
| 104 | + | // assumes a single parent zot owns each agent; if a second | |||
| 105 | + | // parent dials, the listener boots the first one. The first | |||
| 106 | + | // supervisor's already-delivered messages still land in the | |||
| 107 | + | // channel — what we're guarding against is silent dual ownership, | |||
| 108 | + | // not message loss. | |||
| 109 | + | func TestInboxNewSupervisorPreemptsOld(t *testing.T) { | |||
| 110 | + | path := filepath.Join(shortSocketDir(t), "in.sock") | |||
| 111 | + | ln, err := Listen(path) | |||
| 112 | + | if err != nil { | |||
| 113 | + | t.Fatalf("Listen: %v", err) | |||
| 114 | + | } | |||
| 115 | + | defer ln.Close() | |||
| 116 | + | ||||
| 117 | + | first := NewInbox(path) | |||
| 118 | + | if err := first.SendInput("user one"); err != nil { | |||
| 119 | + | t.Fatalf("first send: %v", err) | |||
| 120 | + | } | |||
| 121 | + | // Wait for the listener to absorb "user one" before dialing the | |||
| 122 | + | // second supervisor; otherwise we race against the connection | |||
| 123 | + | // preemption and "user one" can be dropped (the first conn is | |||
| 124 | + | // closed by Accept before its read loop processes the byte). | |||
| 125 | + | waitN(t, ln.Lines(), 1, time.Second) | |||
| 126 | + | ||||
| 127 | + | second := NewInbox(path) | |||
| 128 | + | defer second.Close() | |||
| 129 | + | if err := second.SendInput("user two"); err != nil { | |||
| 130 | + | t.Fatalf("second send: %v", err) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | secondLines := waitN(t, ln.Lines(), 1, time.Second) | |||
| 134 | + | if len(secondLines) != 1 || secondLines[0] != "user two" { | |||
| 135 | + | t.Fatalf("second supervisor not received: %v", secondLines) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // The original supervisor's connection was preempted by the | |||
| 139 | + | // listener accepting the second dial. The next SendInput on the | |||
| 140 | + | // first handle sees a broken pipe and drops the cached connection; | |||
| 141 | + | // the call after that redials successfully. Both behaviours | |||
| 142 | + | // matter: the broken-pipe error must surface (so a real | |||
| 143 | + | // supervisor knows it lost ownership) AND the next call must | |||
| 144 | + | // recover so a flaky reconnect doesn't permanently wedge the | |||
| 145 | + | // inbox. | |||
| 146 | + | if err := first.SendInput("user three"); err == nil { | |||
| 147 | + | t.Fatal("expected broken-pipe error on stale connection") | |||
| 148 | + | } | |||
| 149 | + | if err := first.SendInput("user four"); err != nil { | |||
| 150 | + | t.Fatalf("first redial after eviction: %v", err) | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func waitN(t *testing.T, ch <-chan string, n int, timeout time.Duration) []string { | |||
| 155 | + | t.Helper() | |||
| 156 | + | var got []string | |||
| 157 | + | deadline := time.After(timeout) | |||
| 158 | + | for len(got) < n { | |||
| 159 | + | select { | |||
| 160 | + | case msg := <-ch: | |||
| 161 | + | got = append(got, msg) | |||
| 162 | + | case <-deadline: | |||
| 163 | + | return got | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | return got | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // collectFor consumes up to n items from ch within timeout and | |||
| 170 | + | // pushes the slice onto the returned channel. Used by the tests | |||
| 171 | + | // above so they don't race against the listener's accept goroutine. | |||
| 172 | + | func collectFor(t *testing.T, ch <-chan string, n int, timeout time.Duration) <-chan []string { | |||
| 173 | + | t.Helper() | |||
| 174 | + | out := make(chan []string, 1) | |||
| 175 | + | go func() { | |||
| 176 | + | var got []string | |||
| 177 | + | deadline := time.After(timeout) | |||
| 178 | + | for len(got) < n { | |||
| 179 | + | select { | |||
| 180 | + | case msg, ok := <-ch: | |||
| 181 | + | if !ok { | |||
| 182 | + | out <- got | |||
| 183 | + | return | |||
| 184 | + | } | |||
| 185 | + | got = append(got, msg) | |||
| 186 | + | case <-deadline: | |||
| 187 | + | out <- got | |||
| 188 | + | return | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | out <- got | |||
| 192 | + | }() | |||
| 193 | + | return out | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // TestInboxConcurrentSends serialises the SendInput call so two | |||
| 197 | + | // goroutines writing at once still produce two whole lines, never | |||
| 198 | + | // a tearing of bytes between them. | |||
| 199 | + | func TestInboxConcurrentSends(t *testing.T) { | |||
| 200 | + | path := filepath.Join(shortSocketDir(t), "in.sock") | |||
| 201 | + | ln, err := Listen(path) | |||
| 202 | + | if err != nil { | |||
| 203 | + | t.Fatalf("Listen: %v", err) | |||
| 204 | + | } | |||
| 205 | + | defer ln.Close() | |||
| 206 | + | inbox := NewInbox(path) | |||
| 207 | + | defer inbox.Close() | |||
| 208 | + | ||||
| 209 | + | var wg sync.WaitGroup | |||
| 210 | + | const n = 50 | |||
| 211 | + | wg.Add(n) | |||
| 212 | + | for i := 0; i < n; i++ { | |||
| 213 | + | go func(i int) { | |||
| 214 | + | defer wg.Done() | |||
| 215 | + | if err := inbox.SendInput("user msg"); err != nil { | |||
| 216 | + | t.Errorf("send %d: %v", i, err) | |||
| 217 | + | } | |||
| 218 | + | }(i) | |||
| 219 | + | } | |||
| 220 | + | wg.Wait() | |||
| 221 | + | ||||
| 222 | + | collected := collectFor(t, ln.Lines(), n, 2*time.Second) | |||
| 223 | + | got := <-collected | |||
| 224 | + | if len(got) != n { | |||
| 225 | + | t.Fatalf("got %d lines, want %d", len(got), n) | |||
| 226 | + | } | |||
| 227 | + | for i, g := range got { | |||
| 228 | + | if g != "user msg" { | |||
| 229 | + | t.Fatalf("line %d garbled: %q", i, g) | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | } | |||
A internal/swarm/persist.go +394 -0
| @@ -0,0 +1,394 @@ | |||||
| 1 | + | package swarm | |||
| 2 | + | ||||
| 3 | + | // On-disk persistence for swarm agents. | |||
| 4 | + | // | |||
| 5 | + | // Every Spawn writes a meta.json next to the agent's events.jsonl and | |||
| 6 | + | // session.json. The file captures the immutable identity bits (id, | |||
| 7 | + | // task, branch, dir) plus the paths the runner needs to resume the | |||
| 8 | + | // agent later. On a fresh zot launch, Swarm.Reload() walks | |||
| 9 | + | // <root>/agents/*/meta.json and re-registers every agent it finds in | |||
| 10 | + | // StatusDetached so the user can see, view, resume, or remove them | |||
| 11 | + | // from the dashboard. | |||
| 12 | + | // | |||
| 13 | + | // We don't try to keep meta.json in sync with mutable state (status, | |||
| 14 | + | // activity, transcript). Those live in the events log (durable) and | |||
| 15 | + | // in-memory Agent fields (rebuilt by Reload from the log tail). | |||
| 16 | + | // Keeping meta.json immutable means we never have to worry about | |||
| 17 | + | // concurrent writers stomping on each other and the file matters | |||
| 18 | + | // only on the spawn/reload boundary. | |||
| 19 | + | ||||
| 20 | + | import ( | |||
| 21 | + | "context" | |||
| 22 | + | "encoding/json" | |||
| 23 | + | "errors" | |||
| 24 | + | "fmt" | |||
| 25 | + | "os" | |||
| 26 | + | "path/filepath" | |||
| 27 | + | "sort" | |||
| 28 | + | "strings" | |||
| 29 | + | "time" | |||
| 30 | + | ) | |||
| 31 | + | ||||
| 32 | + | // agentMeta is the durable identity record for one agent. Only fields | |||
| 33 | + | // the supervisor needs to rebuild an Agent after a restart live here. | |||
| 34 | + | // Adding a field is backwards-compatible (older meta.json files just | |||
| 35 | + | // leave it zero); removing or renaming one is not. | |||
| 36 | + | type agentMeta struct { | |||
| 37 | + | ID string `json:"id"` | |||
| 38 | + | Task string `json:"task"` | |||
| 39 | + | Branch string `json:"branch"` | |||
| 40 | + | Dir string `json:"dir"` | |||
| 41 | + | Started time.Time `json:"started"` | |||
| 42 | + | Model string `json:"model,omitempty"` | |||
| 43 | + | Provider string `json:"provider,omitempty"` | |||
| 44 | + | InboxPath string `json:"inbox_path"` | |||
| 45 | + | EventLogPath string `json:"event_log_path"` | |||
| 46 | + | SessionPath string `json:"session_path"` | |||
| 47 | + | ||||
| 48 | + | // SessionID, when non-empty, scopes the agent to a particular | |||
| 49 | + | // host zot session: the dashboard only shows agents whose | |||
| 50 | + | // SessionID matches the active session. Older meta.json files | |||
| 51 | + | // (and agents spawned outside of any session, e.g. by tests or | |||
| 52 | + | // scripted callers that didn't call SetActiveSession) have an | |||
| 53 | + | // empty SessionID and are visible from every session as a | |||
| 54 | + | // backward-compat fallback. Added in 2026 — a fresh field on a | |||
| 55 | + | // json struct is backwards-compatible by design. | |||
| 56 | + | SessionID string `json:"session_id,omitempty"` | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.json") } | |||
| 60 | + | ||||
| 61 | + | // writeAgentMeta serialises a's identity into stateDir/meta.json. The | |||
| 62 | + | // write is atomic (tmp + rename) so a crash mid-write can't leave a | |||
| 63 | + | // half-parsable file that fails Reload. | |||
| 64 | + | func writeAgentMeta(stateDir string, a *Agent) error { | |||
| 65 | + | m := agentMeta{ | |||
| 66 | + | ID: a.ID, | |||
| 67 | + | Task: a.Task, | |||
| 68 | + | Branch: a.Branch, | |||
| 69 | + | Dir: a.Dir, | |||
| 70 | + | Started: a.Started, | |||
| 71 | + | Model: a.Model, | |||
| 72 | + | Provider: a.Provider, | |||
| 73 | + | InboxPath: a.InboxPath, | |||
| 74 | + | EventLogPath: a.EventLogPath, | |||
| 75 | + | SessionPath: a.SessionPath, | |||
| 76 | + | SessionID: a.SessionID, | |||
| 77 | + | } | |||
| 78 | + | b, err := json.MarshalIndent(m, "", " ") | |||
| 79 | + | if err != nil { | |||
| 80 | + | return fmt.Errorf("swarm meta marshal: %w", err) | |||
| 81 | + | } | |||
| 82 | + | if err := os.MkdirAll(stateDir, 0o755); err != nil { | |||
| 83 | + | return fmt.Errorf("swarm meta dir: %w", err) | |||
| 84 | + | } | |||
| 85 | + | final := metaPath(stateDir) | |||
| 86 | + | tmp := final + ".tmp" | |||
| 87 | + | if err := os.WriteFile(tmp, append(b, '\n'), 0o644); err != nil { | |||
| 88 | + | return fmt.Errorf("swarm meta write: %w", err) | |||
| 89 | + | } | |||
| 90 | + | if err := os.Rename(tmp, final); err != nil { | |||
| 91 | + | _ = os.Remove(tmp) | |||
| 92 | + | return fmt.Errorf("swarm meta rename: %w", err) | |||
| 93 | + | } | |||
| 94 | + | return nil | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // readAgentMeta loads one meta.json. Returns os.ErrNotExist (wrapped) | |||
| 98 | + | // when the file is missing so callers can distinguish "no such agent" | |||
| 99 | + | // from "corrupt metadata". | |||
| 100 | + | func readAgentMeta(stateDir string) (agentMeta, error) { | |||
| 101 | + | var m agentMeta | |||
| 102 | + | b, err := os.ReadFile(metaPath(stateDir)) | |||
| 103 | + | if err != nil { | |||
| 104 | + | return m, err | |||
| 105 | + | } | |||
| 106 | + | if err := json.Unmarshal(b, &m); err != nil { | |||
| 107 | + | return m, fmt.Errorf("swarm meta parse %s: %w", stateDir, err) | |||
| 108 | + | } | |||
| 109 | + | if m.ID == "" { | |||
| 110 | + | return m, fmt.Errorf("swarm meta %s: missing id", stateDir) | |||
| 111 | + | } | |||
| 112 | + | return m, nil | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // Reload scans <root>/agents/*/meta.json and re-registers every | |||
| 116 | + | // previously-spawned agent as a StatusDetached entry. Agents already | |||
| 117 | + | // present in memory are left alone (Reload is idempotent and safe to | |||
| 118 | + | // call after Spawn, though in practice the cli invokes it exactly | |||
| 119 | + | // once just after New()). | |||
| 120 | + | // | |||
| 121 | + | // The reloaded agents have no live Runner; the user can: | |||
| 122 | + | // - view their transcript (the dashboard reads from EventLogPath), | |||
| 123 | + | // - resume them via Swarm.Resume (starts a fresh subprocess on the | |||
| 124 | + | // same worktree / session / inbox path), | |||
| 125 | + | // - remove them (worktree + meta + events log gone). | |||
| 126 | + | // | |||
| 127 | + | // Reload returns the number of agents loaded plus any per-directory | |||
| 128 | + | // error encountered. Malformed entries are skipped rather than | |||
| 129 | + | // failing the whole reload — one bad meta.json shouldn't hide the | |||
| 130 | + | // rest of the swarm. | |||
| 131 | + | func (f *Swarm) Reload() (loaded int, errs []error) { | |||
| 132 | + | agentsDir := filepath.Join(f.cfg.Root, "agents") | |||
| 133 | + | entries, err := os.ReadDir(agentsDir) | |||
| 134 | + | if err != nil { | |||
| 135 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 136 | + | return 0, nil | |||
| 137 | + | } | |||
| 138 | + | return 0, []error{fmt.Errorf("swarm reload: %w", err)} | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // Sort by directory name so the load order is stable across runs. | |||
| 142 | + | // agentStateDir uses the id verbatim so name-sort == id-sort, | |||
| 143 | + | // which mirrors the creation order well enough for the dashboard | |||
| 144 | + | // (we also sort by Started in SnapshotAll, but having a stable | |||
| 145 | + | // f.order helps tests). | |||
| 146 | + | names := make([]string, 0, len(entries)) | |||
| 147 | + | for _, e := range entries { | |||
| 148 | + | if e.IsDir() { | |||
| 149 | + | names = append(names, e.Name()) | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | sort.Strings(names) | |||
| 153 | + | ||||
| 154 | + | for _, name := range names { | |||
| 155 | + | stateDir := filepath.Join(agentsDir, name) | |||
| 156 | + | m, err := readAgentMeta(stateDir) | |||
| 157 | + | if err != nil { | |||
| 158 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 159 | + | // Bare directory with no meta.json — probably a | |||
| 160 | + | // leftover from a Spawn that failed before | |||
| 161 | + | // writeAgentMeta. Ignore silently. | |||
| 162 | + | continue | |||
| 163 | + | } | |||
| 164 | + | errs = append(errs, err) | |||
| 165 | + | continue | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | f.mu.Lock() | |||
| 169 | + | if _, exists := f.agents[m.ID]; exists { | |||
| 170 | + | f.mu.Unlock() | |||
| 171 | + | continue | |||
| 172 | + | } | |||
| 173 | + | a := f.buildDetachedAgent(m) | |||
| 174 | + | f.agents[m.ID] = a | |||
| 175 | + | f.order = append(f.order, m.ID) | |||
| 176 | + | f.mu.Unlock() | |||
| 177 | + | loaded++ | |||
| 178 | + | } | |||
| 179 | + | return loaded, errs | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | // buildDetachedAgent constructs an Agent from a meta.json with no | |||
| 183 | + | // running Runner. The agent's transcript is populated from the tail | |||
| 184 | + | // of its event log so the dashboard immediately shows recent output; | |||
| 185 | + | // activity is inferred from the last lifecycle event. | |||
| 186 | + | // | |||
| 187 | + | // The returned Agent has a closed `done` channel because Wait should | |||
| 188 | + | // return instantly: there is nothing to wait for. | |||
| 189 | + | func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent { | |||
| 190 | + | a := &Agent{ | |||
| 191 | + | ID: m.ID, | |||
| 192 | + | Task: m.Task, | |||
| 193 | + | Branch: m.Branch, | |||
| 194 | + | Dir: m.Dir, | |||
| 195 | + | Started: m.Started, | |||
| 196 | + | Model: m.Model, | |||
| 197 | + | Provider: m.Provider, | |||
| 198 | + | InboxPath: m.InboxPath, | |||
| 199 | + | EventLogPath: m.EventLogPath, | |||
| 200 | + | SessionPath: m.SessionPath, | |||
| 201 | + | SessionID: m.SessionID, | |||
| 202 | + | inbox: NewInbox(m.InboxPath), | |||
| 203 | + | status: StatusDetached, | |||
| 204 | + | activity: "detached", | |||
| 205 | + | done: make(chan struct{}), | |||
| 206 | + | } | |||
| 207 | + | // Wait() must not block for detached agents; close the channel | |||
| 208 | + | // immediately. Callers Resuming the agent will replace done with | |||
| 209 | + | // a fresh channel before starting the new runner. | |||
| 210 | + | close(a.done) | |||
| 211 | + | ||||
| 212 | + | // Recover transcript + activity hints from the event log. Best | |||
| 213 | + | // effort: a missing or unreadable log just leaves the agent | |||
| 214 | + | // detached with an empty transcript. | |||
| 215 | + | if a.EventLogPath != "" { | |||
| 216 | + | if evs, err := ReadEventLog(a.EventLogPath); err == nil { | |||
| 217 | + | replayEventsIntoAgent(a, evs) | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | return a | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // replayEventsIntoAgent re-derives an agent's transcript and last | |||
| 224 | + | // known status hint from its event log. Mirrors applyEventToSink in | |||
| 225 | + | // runner.go but writes directly to the Agent fields (no Sink because | |||
| 226 | + | // the agent isn't being driven by a runner yet). | |||
| 227 | + | // | |||
| 228 | + | // Status precedence: explicit lifecycle events (agent_stopped) win | |||
| 229 | + | // over inferred ones (assistant_message → idle). If the log contains | |||
| 230 | + | // no terminator we keep status=StatusDetached so the user can resume. | |||
| 231 | + | func replayEventsIntoAgent(a *Agent, evs []Event) { | |||
| 232 | + | terminal := false | |||
| 233 | + | for _, ev := range evs { | |||
| 234 | + | switch ev.Type { | |||
| 235 | + | case "assistant_message": | |||
| 236 | + | if c, ok := ev.Data["content"].([]any); ok { | |||
| 237 | + | for _, blk := range c { | |||
| 238 | + | m, _ := blk.(map[string]any) | |||
| 239 | + | if t, _ := m["type"].(string); t == "text" { | |||
| 240 | + | if txt, _ := m["text"].(string); txt != "" { | |||
| 241 | + | a.appendTranscript(txt) | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | } | |||
| 246 | + | case "user_message": | |||
| 247 | + | if c, ok := ev.Data["content"].([]any); ok { | |||
| 248 | + | for _, blk := range c { | |||
| 249 | + | m, _ := blk.(map[string]any) | |||
| 250 | + | if t, _ := m["type"].(string); t == "text" { | |||
| 251 | + | if txt, _ := m["text"].(string); txt != "" { | |||
| 252 | + | a.appendTranscript("user: " + txt) | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | } | |||
| 257 | + | case "stdout": | |||
| 258 | + | if txt, _ := ev.Data["text"].(string); txt != "" { | |||
| 259 | + | a.appendTranscript(txt) | |||
| 260 | + | } | |||
| 261 | + | case "stderr": | |||
| 262 | + | if txt, _ := ev.Data["text"].(string); txt != "" { | |||
| 263 | + | a.appendTranscript("stderr: " + txt) | |||
| 264 | + | } | |||
| 265 | + | case "error": | |||
| 266 | + | if msg, _ := ev.Data["message"].(string); msg != "" { | |||
| 267 | + | a.appendTranscript("error: " + msg) | |||
| 268 | + | } | |||
| 269 | + | case "agent_stopped": | |||
| 270 | + | terminal = true | |||
| 271 | + | reason, _ := ev.Data["reason"].(string) | |||
| 272 | + | a.mu.Lock() | |||
| 273 | + | switch reason { | |||
| 274 | + | case "cancelled": | |||
| 275 | + | a.status = StatusKilled | |||
| 276 | + | a.activity = "cancelled (offline)" | |||
| 277 | + | case "shutdown": | |||
| 278 | + | a.status = StatusDone | |||
| 279 | + | a.activity = "shutdown (offline)" | |||
| 280 | + | case "exit": | |||
| 281 | + | if code, ok := ev.Data["code"].(float64); ok && code != 0 { | |||
| 282 | + | a.status = StatusFailed | |||
| 283 | + | a.activity = fmt.Sprintf("exit %d (offline)", int(code)) | |||
| 284 | + | } else { | |||
| 285 | + | a.status = StatusDone | |||
| 286 | + | a.activity = "done (offline)" | |||
| 287 | + | } | |||
| 288 | + | default: | |||
| 289 | + | a.status = StatusDone | |||
| 290 | + | a.activity = "stopped (offline)" | |||
| 291 | + | } | |||
| 292 | + | a.mu.Unlock() | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | if !terminal { | |||
| 296 | + | // Non-terminal log means the previous parent died mid-run. | |||
| 297 | + | // Leave status=StatusDetached but record a hint so the | |||
| 298 | + | // dashboard shows something useful. | |||
| 299 | + | a.mu.Lock() | |||
| 300 | + | if a.activity == "detached" && len(a.transcript) > 0 { | |||
| 301 | + | a.activity = "detached (resume to continue)" | |||
| 302 | + | } | |||
| 303 | + | a.mu.Unlock() | |||
| 304 | + | } | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | // Resume re-attaches a Runner to a previously-spawned agent. The | |||
| 308 | + | // existing worktree, session file, branch, and inbox path are kept; | |||
| 309 | + | // only the in-memory Agent and its runner are replaced. Use this to | |||
| 310 | + | // continue a swarm session across zot restarts: | |||
| 311 | + | // | |||
| 312 | + | // swarmMgr.Reload() | |||
| 313 | + | // a, err := swarmMgr.Resume(ctx, "alpha-12345") | |||
| 314 | + | // swarmMgr.SendUserTurn(a.ID, "where were we?") | |||
| 315 | + | // | |||
| 316 | + | // The agent must be in a non-running state (Detached, Done, Failed, | |||
| 317 | + | // Killed). Resuming a still-running agent returns an error so two | |||
| 318 | + | // runners don't race for the same session. | |||
| 319 | + | func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||
| 320 | + | existing := f.Get(id) | |||
| 321 | + | if existing == nil { | |||
| 322 | + | return nil, fmt.Errorf("swarm: no such agent %q", id) | |||
| 323 | + | } | |||
| 324 | + | existing.mu.Lock() | |||
| 325 | + | st := existing.status | |||
| 326 | + | existing.mu.Unlock() | |||
| 327 | + | if st == StatusRunning || st == StatusPending { | |||
| 328 | + | return nil, fmt.Errorf("swarm: agent %s is still %s; stop it first", existing.ID, st) | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | // Rebuild from the meta record so we don't carry stale runner | |||
| 332 | + | // state from a previous incarnation. We re-read meta.json rather | |||
| 333 | + | // than reusing the live struct's fields so callers that mutated | |||
| 334 | + | // (e.g. tests that hand-built an Agent) don't accidentally route | |||
| 335 | + | // the new runner at the wrong paths. | |||
| 336 | + | m := agentMeta{ | |||
| 337 | + | ID: existing.ID, Task: existing.Task, Branch: existing.Branch, | |||
| 338 | + | Dir: existing.Dir, Started: existing.Started, | |||
| 339 | + | Model: existing.Model, Provider: existing.Provider, | |||
| 340 | + | InboxPath: existing.InboxPath, EventLogPath: existing.EventLogPath, | |||
| 341 | + | SessionPath: existing.SessionPath, | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | a := &Agent{ | |||
| 345 | + | ID: m.ID, | |||
| 346 | + | Task: m.Task, | |||
| 347 | + | Branch: m.Branch, | |||
| 348 | + | Dir: m.Dir, | |||
| 349 | + | Started: m.Started, | |||
| 350 | + | Model: m.Model, | |||
| 351 | + | Provider: m.Provider, | |||
| 352 | + | InboxPath: m.InboxPath, | |||
| 353 | + | EventLogPath: m.EventLogPath, | |||
| 354 | + | SessionPath: m.SessionPath, | |||
| 355 | + | Resuming: true, | |||
| 356 | + | inbox: NewInbox(m.InboxPath), | |||
| 357 | + | status: StatusPending, | |||
| 358 | + | activity: "resuming", | |||
| 359 | + | done: make(chan struct{}), | |||
| 360 | + | } | |||
| 361 | + | // Carry the previous transcript forward so the dashboard doesn't | |||
| 362 | + | // flash empty between resume and the first new event. | |||
| 363 | + | prev := existing.Transcript() | |||
| 364 | + | if len(prev) > 0 { | |||
| 365 | + | a.appendTranscript(strings.Join(prev, "\n")) | |||
| 366 | + | } | |||
| 367 | + | a.ctx, a.cancel = context.WithCancel(ctx) | |||
| 368 | + | a.runner = f.cfg.NewRunner(a) | |||
| 369 | + | ||||
| 370 | + | f.mu.Lock() | |||
| 371 | + | f.agents[a.ID] = a | |||
| 372 | + | // Keep the agent's slot in f.order; replacing in-place avoids | |||
| 373 | + | // reshuffling the dashboard's row ordering on resume. | |||
| 374 | + | found := false | |||
| 375 | + | for _, k := range f.order { | |||
| 376 | + | if k == a.ID { | |||
| 377 | + | found = true | |||
| 378 | + | break | |||
| 379 | + | } | |||
| 380 | + | } | |||
| 381 | + | if !found { | |||
| 382 | + | f.order = append(f.order, a.ID) | |||
| 383 | + | } | |||
| 384 | + | f.mu.Unlock() | |||
| 385 | + | ||||
| 386 | + | // Refresh the meta.json so any new path defaults (e.g. socket | |||
| 387 | + | // path moved into /tmp because the root got renamed) get | |||
| 388 | + | // persisted. Best-effort; resume still works if the disk is | |||
| 389 | + | // read-only because everything the runner needs is in-memory. | |||
| 390 | + | _ = writeAgentMeta(f.agentStateDir(a.ID), a) | |||
| 391 | + | ||||
| 392 | + | go f.run(a) | |||
| 393 | + | return a, nil | |||
| 394 | + | } | |||
A internal/swarm/persist_test.go +838 -0
| @@ -0,0 +1,838 @@ | |||||
| 1 | + | package swarm | |||
| 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 | + | ||||
| 15 | + | // TestSpawnWritesMetaJSON asserts the durability contract: every | |||
| 16 | + | // successful Spawn leaves a meta.json on disk with the agent's | |||
| 17 | + | // identity bits. Without this, Reload on the next zot launch can't | |||
| 18 | + | // find the agent and the user loses access to the worktree. | |||
| 19 | + | func TestSpawnWritesMetaJSON(t *testing.T) { | |||
| 20 | + | root := t.TempDir() | |||
| 21 | + | f := New(Config{ | |||
| 22 | + | Root: root, | |||
| 23 | + | RepoRoot: root, | |||
| 24 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 25 | + | NewRunner: func(a *Agent) Runner { | |||
| 26 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |||
| 27 | + | <-ctx.Done() | |||
| 28 | + | return ctx.Err() | |||
| 29 | + | }) | |||
| 30 | + | }, | |||
| 31 | + | }) | |||
| 32 | + | defer f.StopAll() | |||
| 33 | + | ||||
| 34 | + | a, err := f.Spawn(context.Background(), "investigate widget") | |||
| 35 | + | if err != nil { | |||
| 36 | + | t.Fatalf("spawn: %v", err) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // Read meta.json straight off disk. | |||
| 40 | + | metaBytes, err := os.ReadFile(filepath.Join(root, "agents", a.ID, "meta.json")) | |||
| 41 | + | if err != nil { | |||
| 42 | + | t.Fatalf("read meta.json: %v", err) | |||
| 43 | + | } | |||
| 44 | + | var got agentMeta | |||
| 45 | + | if err := json.Unmarshal(metaBytes, &got); err != nil { | |||
| 46 | + | t.Fatalf("parse meta.json: %v", err) | |||
| 47 | + | } | |||
| 48 | + | if got.ID != a.ID { | |||
| 49 | + | t.Errorf("meta.ID = %q; want %q", got.ID, a.ID) | |||
| 50 | + | } | |||
| 51 | + | if got.Task != "investigate widget" { | |||
| 52 | + | t.Errorf("meta.Task = %q", got.Task) | |||
| 53 | + | } | |||
| 54 | + | if got.Branch != a.Branch || got.Dir != a.Dir { | |||
| 55 | + | t.Errorf("meta paths drifted: %+v vs agent %+v", got, a) | |||
| 56 | + | } | |||
| 57 | + | if got.InboxPath == "" || got.EventLogPath == "" || got.SessionPath == "" { | |||
| 58 | + | t.Errorf("meta paths empty: %+v", got) | |||
| 59 | + | } | |||
| 60 | + | if got.Started.IsZero() { | |||
| 61 | + | t.Error("meta.Started is zero") | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // TestReloadRebuildsDetachedAgents simulates a zot restart by spawning | |||
| 66 | + | // in one Swarm, throwing it away, then opening a fresh Swarm against | |||
| 67 | + | // the same root and calling Reload. The user-visible state — id, | |||
| 68 | + | // task, branch, dir — must come back, and status must be Detached so | |||
| 69 | + | // the dashboard can offer resume. | |||
| 70 | + | func TestReloadRebuildsDetachedAgents(t *testing.T) { | |||
| 71 | + | root := t.TempDir() | |||
| 72 | + | ||||
| 73 | + | // First incarnation: spawn two agents, then drop the supervisor. | |||
| 74 | + | first := New(Config{ | |||
| 75 | + | Root: root, | |||
| 76 | + | RepoRoot: root, | |||
| 77 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 78 | + | NewRunner: func(a *Agent) Runner { | |||
| 79 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |||
| 80 | + | <-ctx.Done() | |||
| 81 | + | return ctx.Err() | |||
| 82 | + | }) | |||
| 83 | + | }, | |||
| 84 | + | }) | |||
| 85 | + | a1, err := first.Spawn(context.Background(), "alpha task") | |||
| 86 | + | if err != nil { | |||
| 87 | + | t.Fatalf("spawn a1: %v", err) | |||
| 88 | + | } | |||
| 89 | + | a2, err := first.Spawn(context.Background(), "beta task") | |||
| 90 | + | if err != nil { | |||
| 91 | + | t.Fatalf("spawn a2: %v", err) | |||
| 92 | + | } | |||
| 93 | + | first.StopAll() | |||
| 94 | + | // Wait briefly for the runner goroutines to settle so their | |||
| 95 | + | // stop event reaches the log before we move on. Reload itself | |||
| 96 | + | // doesn't need this; it makes the assertions below deterministic. | |||
| 97 | + | a1.Wait() | |||
| 98 | + | a2.Wait() | |||
| 99 | + | ||||
| 100 | + | // Second incarnation against the same root. | |||
| 101 | + | second := New(Config{ | |||
| 102 | + | Root: root, | |||
| 103 | + | RepoRoot: root, | |||
| 104 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 105 | + | }) | |||
| 106 | + | loaded, errs := second.Reload() | |||
| 107 | + | if len(errs) > 0 { | |||
| 108 | + | t.Fatalf("reload errs: %v", errs) | |||
| 109 | + | } | |||
| 110 | + | if loaded != 2 { | |||
| 111 | + | t.Fatalf("loaded = %d; want 2", loaded) | |||
| 112 | + | } | |||
| 113 | + | snap := second.SnapshotAll() | |||
| 114 | + | if len(snap) != 2 { | |||
| 115 | + | t.Fatalf("snapshot len = %d; want 2", len(snap)) | |||
| 116 | + | } | |||
| 117 | + | ids := map[string]AgentSnapshot{} | |||
| 118 | + | for _, s := range snap { | |||
| 119 | + | ids[s.ID] = s | |||
| 120 | + | } | |||
| 121 | + | if _, ok := ids[a1.ID]; !ok { | |||
| 122 | + | t.Errorf("reloaded set missing %s", a1.ID) | |||
| 123 | + | } | |||
| 124 | + | if _, ok := ids[a2.ID]; !ok { | |||
| 125 | + | t.Errorf("reloaded set missing %s", a2.ID) | |||
| 126 | + | } | |||
| 127 | + | for _, s := range snap { | |||
| 128 | + | if s.Status != StatusDetached { | |||
| 129 | + | t.Errorf("agent %s status = %q; want detached", s.ID, s.Status) | |||
| 130 | + | } | |||
| 131 | + | if s.Task == "" { | |||
| 132 | + | t.Errorf("agent %s lost its task", s.ID) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // TestReloadIsIdempotent calls Reload twice in a row and asserts the | |||
| 138 | + | // second call neither duplicates rows nor errors. | |||
| 139 | + | func TestReloadIsIdempotent(t *testing.T) { | |||
| 140 | + | root := t.TempDir() | |||
| 141 | + | first := New(Config{ | |||
| 142 | + | Root: root, RepoRoot: root, | |||
| 143 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 144 | + | NewRunner: func(a *Agent) Runner { | |||
| 145 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |||
| 146 | + | }, | |||
| 147 | + | }) | |||
| 148 | + | if _, err := first.Spawn(context.Background(), "only one"); err != nil { | |||
| 149 | + | t.Fatal(err) | |||
| 150 | + | } | |||
| 151 | + | first.StopAll() | |||
| 152 | + | ||||
| 153 | + | second := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | |||
| 154 | + | loaded1, _ := second.Reload() | |||
| 155 | + | loaded2, errs := second.Reload() | |||
| 156 | + | if len(errs) > 0 { | |||
| 157 | + | t.Fatalf("errs on second reload: %v", errs) | |||
| 158 | + | } | |||
| 159 | + | if loaded1 != 1 || loaded2 != 0 { | |||
| 160 | + | t.Fatalf("loaded counts = (%d, %d); want (1, 0)", loaded1, loaded2) | |||
| 161 | + | } | |||
| 162 | + | if got := len(second.SnapshotAll()); got != 1 { | |||
| 163 | + | t.Fatalf("snapshot len = %d; want 1", got) | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // TestReloadReplaysTranscriptFromEventLog drops a curated events.jsonl | |||
| 168 | + | // next to a meta.json and checks that the transcript surfaces in the | |||
| 169 | + | // reloaded agent. This is the user-facing payoff of Reload: opening | |||
| 170 | + | // /swarm logs <id> after a restart shows what the agent said before. | |||
| 171 | + | func TestReloadReplaysTranscriptFromEventLog(t *testing.T) { | |||
| 172 | + | root := t.TempDir() | |||
| 173 | + | id := "alpha-9" | |||
| 174 | + | stateDir := filepath.Join(root, "agents", id) | |||
| 175 | + | if err := os.MkdirAll(stateDir, 0o755); err != nil { | |||
| 176 | + | t.Fatal(err) | |||
| 177 | + | } | |||
| 178 | + | // meta.json | |||
| 179 | + | m := agentMeta{ | |||
| 180 | + | ID: id, Task: "do thing", Branch: "swarm/alpha-9", | |||
| 181 | + | Dir: filepath.Join(root, "wt", id), Started: time.Now().Add(-time.Hour), | |||
| 182 | + | InboxPath: filepath.Join(stateDir, "in.sock"), | |||
| 183 | + | EventLogPath: filepath.Join(stateDir, "events.jsonl"), | |||
| 184 | + | SessionPath: filepath.Join(stateDir, "session.json"), | |||
| 185 | + | } | |||
| 186 | + | mb, _ := json.MarshalIndent(m, "", " ") | |||
| 187 | + | if err := os.WriteFile(filepath.Join(stateDir, "meta.json"), mb, 0o644); err != nil { | |||
| 188 | + | t.Fatal(err) | |||
| 189 | + | } | |||
| 190 | + | // Event log with one assistant turn and a graceful stop. | |||
| 191 | + | log, err := OpenEventLog(m.EventLogPath) | |||
| 192 | + | if err != nil { | |||
| 193 | + | t.Fatal(err) | |||
| 194 | + | } | |||
| 195 | + | _ = log.Append(NewEvent("assistant_message", map[string]any{ | |||
| 196 | + | "content": []any{map[string]any{"type": "text", "text": "hello from the past"}}, | |||
| 197 | + | })) | |||
| 198 | + | _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "shutdown"})) | |||
| 199 | + | _ = log.Close() | |||
| 200 | + | ||||
| 201 | + | f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | |||
| 202 | + | loaded, errs := f.Reload() | |||
| 203 | + | if len(errs) > 0 || loaded != 1 { | |||
| 204 | + | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |||
| 205 | + | } | |||
| 206 | + | a := f.Get(id) | |||
| 207 | + | if a == nil { | |||
| 208 | + | t.Fatal("reloaded agent not found") | |||
| 209 | + | } | |||
| 210 | + | // Status should reflect the lifecycle terminator we wrote. | |||
| 211 | + | if a.Status() != StatusDone { | |||
| 212 | + | t.Errorf("status = %q; want done (offline)", a.Status()) | |||
| 213 | + | } | |||
| 214 | + | got := a.Transcript() | |||
| 215 | + | found := false | |||
| 216 | + | for _, line := range got { | |||
| 217 | + | if line == "hello from the past" { | |||
| 218 | + | found = true | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | if !found { | |||
| 222 | + | t.Errorf("transcript missing replayed line: %v", got) | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | // TestReloadSkipsBareDirsAndCorruptMeta ensures one bad meta.json | |||
| 227 | + | // doesn't blow up the whole reload. Directories with no meta.json | |||
| 228 | + | // at all are silently ignored (Spawn that failed mid-way leaves | |||
| 229 | + | // these); corrupt meta files are reported as errors but don't stop | |||
| 230 | + | // the rest of the load. | |||
| 231 | + | func TestReloadSkipsBareDirsAndCorruptMeta(t *testing.T) { | |||
| 232 | + | root := t.TempDir() | |||
| 233 | + | agentsDir := filepath.Join(root, "agents") | |||
| 234 | + | // Bare directory, no meta.json. | |||
| 235 | + | if err := os.MkdirAll(filepath.Join(agentsDir, "leftover"), 0o755); err != nil { | |||
| 236 | + | t.Fatal(err) | |||
| 237 | + | } | |||
| 238 | + | // Directory with garbage meta.json. | |||
| 239 | + | if err := os.MkdirAll(filepath.Join(agentsDir, "corrupt"), 0o755); err != nil { | |||
| 240 | + | t.Fatal(err) | |||
| 241 | + | } | |||
| 242 | + | if err := os.WriteFile(filepath.Join(agentsDir, "corrupt", "meta.json"), []byte("not json"), 0o644); err != nil { | |||
| 243 | + | t.Fatal(err) | |||
| 244 | + | } | |||
| 245 | + | // One good agent. | |||
| 246 | + | good := "good-1" | |||
| 247 | + | stateDir := filepath.Join(agentsDir, good) | |||
| 248 | + | _ = os.MkdirAll(stateDir, 0o755) | |||
| 249 | + | m := agentMeta{ID: good, Task: "x", Branch: "swarm/" + good, Dir: "/tmp/x", Started: time.Now()} | |||
| 250 | + | mb, _ := json.MarshalIndent(m, "", " ") | |||
| 251 | + | _ = os.WriteFile(filepath.Join(stateDir, "meta.json"), mb, 0o644) | |||
| 252 | + | ||||
| 253 | + | f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | |||
| 254 | + | loaded, errs := f.Reload() | |||
| 255 | + | if loaded != 1 { | |||
| 256 | + | t.Errorf("loaded = %d; want 1", loaded) | |||
| 257 | + | } | |||
| 258 | + | if len(errs) != 1 { | |||
| 259 | + | t.Errorf("errs len = %d; want 1 (the corrupt entry): %v", len(errs), errs) | |||
| 260 | + | } | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | // TestResumeRestartsRunnerOnSameSession is the headline test. We: | |||
| 264 | + | // | |||
| 265 | + | // 1. Spawn an agent with a counting runner (records each Run call). | |||
| 266 | + | // 2. Stop it. | |||
| 267 | + | // 3. Resume it. | |||
| 268 | + | // 4. Assert Run was called twice — once for spawn, once for resume — | |||
| 269 | + | // against the same SessionPath / InboxPath / Dir. | |||
| 270 | + | func TestResumeRestartsRunnerOnSameSession(t *testing.T) { | |||
| 271 | + | root := t.TempDir() | |||
| 272 | + | var ( | |||
| 273 | + | mu sync.Mutex | |||
| 274 | + | runs []*Agent | |||
| 275 | + | runsDone int32 | |||
| 276 | + | ) | |||
| 277 | + | f := New(Config{ | |||
| 278 | + | Root: root, RepoRoot: root, | |||
| 279 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 280 | + | NewRunner: func(a *Agent) Runner { | |||
| 281 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { | |||
| 282 | + | mu.Lock() | |||
| 283 | + | runs = append(runs, a) | |||
| 284 | + | mu.Unlock() | |||
| 285 | + | sink.Activity("ran") | |||
| 286 | + | atomic.AddInt32(&runsDone, 1) | |||
| 287 | + | <-ctx.Done() | |||
| 288 | + | return ctx.Err() | |||
| 289 | + | }) | |||
| 290 | + | }, | |||
| 291 | + | }) | |||
| 292 | + | defer f.StopAll() | |||
| 293 | + | ||||
| 294 | + | a, err := f.Spawn(context.Background(), "do thing") | |||
| 295 | + | if err != nil { | |||
| 296 | + | t.Fatalf("spawn: %v", err) | |||
| 297 | + | } | |||
| 298 | + | originalSession := a.SessionPath | |||
| 299 | + | originalDir := a.Dir | |||
| 300 | + | originalInbox := a.InboxPath | |||
| 301 | + | ||||
| 302 | + | // Wait for the runner to record its presence, then stop the agent. | |||
| 303 | + | deadline := time.Now().Add(time.Second) | |||
| 304 | + | for atomic.LoadInt32(&runsDone) < 1 && time.Now().Before(deadline) { | |||
| 305 | + | time.Sleep(2 * time.Millisecond) | |||
| 306 | + | } | |||
| 307 | + | if err := f.Stop(a.ID); err != nil { | |||
| 308 | + | t.Fatalf("stop: %v", err) | |||
| 309 | + | } | |||
| 310 | + | a.Wait() | |||
| 311 | + | if a.Status() != StatusKilled { | |||
| 312 | + | t.Fatalf("post-stop status = %q; want killed", a.Status()) | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | // Now resume. | |||
| 316 | + | a2, err := f.Resume(context.Background(), a.ID) | |||
| 317 | + | if err != nil { | |||
| 318 | + | t.Fatalf("resume: %v", err) | |||
| 319 | + | } | |||
| 320 | + | if a2.ID != a.ID { | |||
| 321 | + | t.Errorf("resume returned different id: %s vs %s", a2.ID, a.ID) | |||
| 322 | + | } | |||
| 323 | + | if a2.SessionPath != originalSession { | |||
| 324 | + | t.Errorf("resume changed session path: %s vs %s", a2.SessionPath, originalSession) | |||
| 325 | + | } | |||
| 326 | + | if a2.Dir != originalDir { | |||
| 327 | + | t.Errorf("resume changed dir: %s vs %s", a2.Dir, originalDir) | |||
| 328 | + | } | |||
| 329 | + | if a2.InboxPath != originalInbox { | |||
| 330 | + | t.Errorf("resume changed inbox path: %s vs %s", a2.InboxPath, originalInbox) | |||
| 331 | + | } | |||
| 332 | + | // Two runner invocations: spawn + resume. | |||
| 333 | + | deadline = time.Now().Add(time.Second) | |||
| 334 | + | for atomic.LoadInt32(&runsDone) < 2 && time.Now().Before(deadline) { | |||
| 335 | + | time.Sleep(2 * time.Millisecond) | |||
| 336 | + | } | |||
| 337 | + | mu.Lock() | |||
| 338 | + | defer mu.Unlock() | |||
| 339 | + | if len(runs) != 2 { | |||
| 340 | + | t.Fatalf("runner invocations = %d; want 2", len(runs)) | |||
| 341 | + | } | |||
| 342 | + | if runs[1].ID != a.ID { | |||
| 343 | + | t.Errorf("second run had wrong id: %s", runs[1].ID) | |||
| 344 | + | } | |||
| 345 | + | if got := len(f.SnapshotAll()); got != 1 { | |||
| 346 | + | t.Errorf("snapshot len = %d; want 1 (in-place replace)", got) | |||
| 347 | + | } | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | // TestResumeSetsResumingFlag pins the contract execRunner relies on: | |||
| 351 | + | // after Resume, the new Agent has Resuming == true; after a fresh | |||
| 352 | + | // Spawn it's false. The runner branches on this to decide whether | |||
| 353 | + | // to pass the original Task as a positional argv to the child, so | |||
| 354 | + | // getting it wrong silently re-runs the initial task on every | |||
| 355 | + | // resume and surfaces "agent busy; send 'cancel' first" between | |||
| 356 | + | // assistant messages. | |||
| 357 | + | func TestResumeSetsResumingFlag(t *testing.T) { | |||
| 358 | + | root := t.TempDir() | |||
| 359 | + | f := New(Config{ | |||
| 360 | + | Root: root, RepoRoot: root, | |||
| 361 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 362 | + | NewRunner: func(a *Agent) Runner { | |||
| 363 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |||
| 364 | + | }, | |||
| 365 | + | }) | |||
| 366 | + | defer f.StopAll() | |||
| 367 | + | ||||
| 368 | + | a, err := f.Spawn(context.Background(), "x") | |||
| 369 | + | if err != nil { | |||
| 370 | + | t.Fatal(err) | |||
| 371 | + | } | |||
| 372 | + | if a.Resuming { | |||
| 373 | + | t.Fatal("fresh Spawn produced Resuming==true; want false") | |||
| 374 | + | } | |||
| 375 | + | if err := f.Stop(a.ID); err != nil { | |||
| 376 | + | t.Fatal(err) | |||
| 377 | + | } | |||
| 378 | + | a.Wait() | |||
| 379 | + | ||||
| 380 | + | a2, err := f.Resume(context.Background(), a.ID) | |||
| 381 | + | if err != nil { | |||
| 382 | + | t.Fatalf("resume: %v", err) | |||
| 383 | + | } | |||
| 384 | + | if !a2.Resuming { | |||
| 385 | + | t.Error("Resume produced Resuming==false; want true so the runner skips the duplicate initial-task argv") | |||
| 386 | + | } | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | // TestResumeRejectsRunningAgent prevents the user from double-running | |||
| 390 | + | // an agent: two runners on the same session.json would race. | |||
| 391 | + | func TestResumeRejectsRunningAgent(t *testing.T) { | |||
| 392 | + | root := t.TempDir() | |||
| 393 | + | f := New(Config{ | |||
| 394 | + | Root: root, RepoRoot: root, | |||
| 395 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 396 | + | NewRunner: func(a *Agent) Runner { | |||
| 397 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |||
| 398 | + | }, | |||
| 399 | + | }) | |||
| 400 | + | defer f.StopAll() | |||
| 401 | + | a, err := f.Spawn(context.Background(), "x") | |||
| 402 | + | if err != nil { | |||
| 403 | + | t.Fatal(err) | |||
| 404 | + | } | |||
| 405 | + | // Wait for the runner to actually start. | |||
| 406 | + | deadline := time.Now().Add(time.Second) | |||
| 407 | + | for a.Status() != StatusRunning && time.Now().Before(deadline) { | |||
| 408 | + | time.Sleep(2 * time.Millisecond) | |||
| 409 | + | } | |||
| 410 | + | if _, err := f.Resume(context.Background(), a.ID); err == nil { | |||
| 411 | + | t.Fatal("resume on running agent did not error") | |||
| 412 | + | } | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | // TestResumeAfterReload exercises the full lifecycle the user cares | |||
| 416 | + | // about: spawn in process A, exit A, start process B, Reload, Resume. | |||
| 417 | + | // The resumed agent must keep its id and start producing new output | |||
| 418 | + | // against the same on-disk state. | |||
| 419 | + | // | |||
| 420 | + | // Note on transcript persistence: in production the runner is | |||
| 421 | + | // execRunner, which writes every event to events.jsonl, and Reload's | |||
| 422 | + | // replayEventsIntoAgent rebuilds the transcript from there. The fake | |||
| 423 | + | // RunnerFunc in this test calls sink.Transcript directly (which only | |||
| 424 | + | // touches in-memory state), so the "first run" line is *expected* to | |||
| 425 | + | // be lost across the restart — the in-memory Agent went away. We | |||
| 426 | + | // have TestReloadReplaysTranscriptFromEventLog covering the | |||
| 427 | + | // log-replay path with a curated events.jsonl. | |||
| 428 | + | func TestResumeAfterReload(t *testing.T) { | |||
| 429 | + | root := t.TempDir() | |||
| 430 | + | // Process A | |||
| 431 | + | a := New(Config{ | |||
| 432 | + | Root: root, RepoRoot: root, | |||
| 433 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 434 | + | NewRunner: func(ag *Agent) Runner { | |||
| 435 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { | |||
| 436 | + | sink.Transcript("first run for " + ag.ID) | |||
| 437 | + | <-ctx.Done() | |||
| 438 | + | return ctx.Err() | |||
| 439 | + | }) | |||
| 440 | + | }, | |||
| 441 | + | }) | |||
| 442 | + | ag, err := a.Spawn(context.Background(), "x") | |||
| 443 | + | if err != nil { | |||
| 444 | + | t.Fatal(err) | |||
| 445 | + | } | |||
| 446 | + | // Let the runner record its transcript line before we tear it down. | |||
| 447 | + | deadline := time.Now().Add(time.Second) | |||
| 448 | + | for len(ag.Transcript()) == 0 && time.Now().Before(deadline) { | |||
| 449 | + | time.Sleep(2 * time.Millisecond) | |||
| 450 | + | } | |||
| 451 | + | id := ag.ID | |||
| 452 | + | a.StopAll() | |||
| 453 | + | ag.Wait() | |||
| 454 | + | ||||
| 455 | + | // Process B: a new Swarm against the same root. | |||
| 456 | + | resumed := make(chan struct{}, 1) | |||
| 457 | + | b := New(Config{ | |||
| 458 | + | Root: root, RepoRoot: root, | |||
| 459 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 460 | + | NewRunner: func(ag *Agent) Runner { | |||
| 461 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { | |||
| 462 | + | sink.Transcript("second run for " + ag.ID) | |||
| 463 | + | select { | |||
| 464 | + | case resumed <- struct{}{}: | |||
| 465 | + | default: | |||
| 466 | + | } | |||
| 467 | + | <-ctx.Done() | |||
| 468 | + | return ctx.Err() | |||
| 469 | + | }) | |||
| 470 | + | }, | |||
| 471 | + | }) | |||
| 472 | + | if loaded, errs := b.Reload(); loaded != 1 || len(errs) > 0 { | |||
| 473 | + | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |||
| 474 | + | } | |||
| 475 | + | a2, err := b.Resume(context.Background(), id) | |||
| 476 | + | if err != nil { | |||
| 477 | + | t.Fatalf("resume: %v", err) | |||
| 478 | + | } | |||
| 479 | + | defer b.StopAll() | |||
| 480 | + | ||||
| 481 | + | select { | |||
| 482 | + | case <-resumed: | |||
| 483 | + | case <-time.After(time.Second): | |||
| 484 | + | t.Fatal("resume runner did not start") | |||
| 485 | + | } | |||
| 486 | + | if a2.ID != id { | |||
| 487 | + | t.Errorf("resume id = %s; want %s", a2.ID, id) | |||
| 488 | + | } | |||
| 489 | + | ||||
| 490 | + | // Wait for the new transcript line to land. | |||
| 491 | + | deadline = time.Now().Add(time.Second) | |||
| 492 | + | for time.Now().Before(deadline) { | |||
| 493 | + | for _, l := range a2.Transcript() { | |||
| 494 | + | if l == "second run for "+id { | |||
| 495 | + | return | |||
| 496 | + | } | |||
| 497 | + | } | |||
| 498 | + | time.Sleep(5 * time.Millisecond) | |||
| 499 | + | } | |||
| 500 | + | t.Fatalf("resumed transcript missing fresh line: %v", a2.Transcript()) | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | // TestSpawnReqPersistsModel confirms that the per-agent model | |||
| 504 | + | // override is captured at Spawn time, surfaced via Snapshot, and | |||
| 505 | + | // written to meta.json so a later Reload + Resume reuses it. | |||
| 506 | + | func TestSpawnReqPersistsModel(t *testing.T) { | |||
| 507 | + | root := t.TempDir() | |||
| 508 | + | f := New(Config{ | |||
| 509 | + | Root: root, RepoRoot: root, | |||
| 510 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 511 | + | NewRunner: func(a *Agent) Runner { | |||
| 512 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |||
| 513 | + | }, | |||
| 514 | + | }) | |||
| 515 | + | a, err := f.SpawnReq(context.Background(), SpawnRequest{ | |||
| 516 | + | Task: "x", Model: "claude-sonnet-4-5", Provider: "anthropic", | |||
| 517 | + | }) | |||
| 518 | + | if err != nil { | |||
| 519 | + | t.Fatalf("spawn: %v", err) | |||
| 520 | + | } | |||
| 521 | + | if a.Model != "claude-sonnet-4-5" || a.Provider != "anthropic" { | |||
| 522 | + | t.Fatalf("agent fields = (%q,%q); want (claude-sonnet-4-5, anthropic)", a.Model, a.Provider) | |||
| 523 | + | } | |||
| 524 | + | snap := a.Snapshot() | |||
| 525 | + | if snap.Model != "claude-sonnet-4-5" || snap.Provider != "anthropic" { | |||
| 526 | + | t.Fatalf("snapshot = (%q,%q); model fields not surfaced", snap.Model, snap.Provider) | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | // Stop so we can read meta.json without racing the run loop. | |||
| 530 | + | if err := f.Stop(a.ID); err != nil { | |||
| 531 | + | t.Fatal(err) | |||
| 532 | + | } | |||
| 533 | + | a.Wait() | |||
| 534 | + | ||||
| 535 | + | metaBytes, err := os.ReadFile(filepath.Join(root, "agents", a.ID, "meta.json")) | |||
| 536 | + | if err != nil { | |||
| 537 | + | t.Fatalf("read meta.json: %v", err) | |||
| 538 | + | } | |||
| 539 | + | var got agentMeta | |||
| 540 | + | if err := json.Unmarshal(metaBytes, &got); err != nil { | |||
| 541 | + | t.Fatal(err) | |||
| 542 | + | } | |||
| 543 | + | if got.Model != "claude-sonnet-4-5" || got.Provider != "anthropic" { | |||
| 544 | + | t.Errorf("meta = (%q,%q); want model + provider persisted", got.Model, got.Provider) | |||
| 545 | + | } | |||
| 546 | + | ||||
| 547 | + | // Reload in a fresh Swarm and confirm the detached agent still | |||
| 548 | + | // carries the model/provider so Resume can route the child | |||
| 549 | + | // subprocess back to the same model. | |||
| 550 | + | g := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | |||
| 551 | + | if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 { | |||
| 552 | + | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |||
| 553 | + | } | |||
| 554 | + | re := g.Get(a.ID) | |||
| 555 | + | if re == nil { | |||
| 556 | + | t.Fatal("reloaded agent missing") | |||
| 557 | + | } | |||
| 558 | + | if re.Model != "claude-sonnet-4-5" || re.Provider != "anthropic" { | |||
| 559 | + | t.Errorf("reloaded fields = (%q,%q); want preserved", re.Model, re.Provider) | |||
| 560 | + | } | |||
| 561 | + | } | |||
| 562 | + | ||||
| 563 | + | // TestSwarmAgentArgsIncludesModelFlags pins the argv contract that | |||
| 564 | + | // connects the supervisor's per-agent model override to the child's | |||
| 565 | + | // --model / --provider flag set. Adding a new path to argv without | |||
| 566 | + | // updating this assertion is the failure mode this catches. | |||
| 567 | + | func TestSwarmAgentArgsIncludesModelFlags(t *testing.T) { | |||
| 568 | + | args := swarmAgentArgs(swarmAgentArgsOpts{ | |||
| 569 | + | Exe: "/zot", Dir: "/wt", SessionPath: "/s.json", InboxPath: "/in.sock", | |||
| 570 | + | Task: "do x", Model: "gpt-5", Provider: "openai", | |||
| 571 | + | }) | |||
| 572 | + | want := []string{"--model", "gpt-5", "--provider", "openai"} | |||
| 573 | + | for i := 0; i+1 < len(want); i += 2 { | |||
| 574 | + | flag, value := want[i], want[i+1] | |||
| 575 | + | found := false | |||
| 576 | + | for j := 0; j+1 < len(args); j++ { | |||
| 577 | + | if args[j] == flag && args[j+1] == value { | |||
| 578 | + | found = true | |||
| 579 | + | break | |||
| 580 | + | } | |||
| 581 | + | } | |||
| 582 | + | if !found { | |||
| 583 | + | t.Errorf("argv missing %s %s: %v", flag, value, args) | |||
| 584 | + | } | |||
| 585 | + | } | |||
| 586 | + | // And the task still ends up positional last. | |||
| 587 | + | if args[len(args)-1] != "do x" { | |||
| 588 | + | t.Errorf("task should be last positional; argv=%v", args) | |||
| 589 | + | } | |||
| 590 | + | } | |||
| 591 | + | ||||
| 592 | + | // TestStopOnDetachedAgentIsNoopAndDoesNotPanic regression-tests the | |||
| 593 | + | // segfault that hit when the user pressed 'k' on a reloaded | |||
| 594 | + | // (detached) agent: Swarm.Stop unconditionally called a.cancel(), | |||
| 595 | + | // but buildDetachedAgent never assigns a cancel func because there's | |||
| 596 | + | // no in-process runner to cancel. The fix is to short-circuit Stop | |||
| 597 | + | // for StatusDetached and — belt-and-braces — nil-check a.cancel. | |||
| 598 | + | func TestStopOnDetachedAgentIsNoopAndDoesNotPanic(t *testing.T) { | |||
| 599 | + | root := t.TempDir() | |||
| 600 | + | // Build a detached agent the same way Reload does: drop a | |||
| 601 | + | // meta.json on disk, then ask a fresh Swarm to pick it up. | |||
| 602 | + | id := "alpha-1" | |||
| 603 | + | stateDir := filepath.Join(root, "agents", id) | |||
| 604 | + | if err := os.MkdirAll(stateDir, 0o755); err != nil { | |||
| 605 | + | t.Fatal(err) | |||
| 606 | + | } | |||
| 607 | + | m := agentMeta{ | |||
| 608 | + | ID: id, Task: "t", Branch: "swarm/" + id, | |||
| 609 | + | Dir: filepath.Join(root, "wt", id), | |||
| 610 | + | Started: time.Now().Add(-time.Hour), | |||
| 611 | + | InboxPath: filepath.Join(stateDir, "in.sock"), | |||
| 612 | + | EventLogPath: filepath.Join(stateDir, "events.jsonl"), | |||
| 613 | + | SessionPath: filepath.Join(stateDir, "session.json"), | |||
| 614 | + | } | |||
| 615 | + | mb, _ := json.MarshalIndent(m, "", " ") | |||
| 616 | + | if err := os.WriteFile(filepath.Join(stateDir, "meta.json"), mb, 0o644); err != nil { | |||
| 617 | + | t.Fatal(err) | |||
| 618 | + | } | |||
| 619 | + | ||||
| 620 | + | f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | |||
| 621 | + | if loaded, errs := f.Reload(); loaded != 1 || len(errs) > 0 { | |||
| 622 | + | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |||
| 623 | + | } | |||
| 624 | + | a := f.Get(id) | |||
| 625 | + | if a == nil { | |||
| 626 | + | t.Fatal("detached agent missing from swarm") | |||
| 627 | + | } | |||
| 628 | + | if a.Status() != StatusDetached { | |||
| 629 | + | t.Fatalf("setup: agent status = %q; want detached", a.Status()) | |||
| 630 | + | } | |||
| 631 | + | ||||
| 632 | + | // The real assertion: Stop returns cleanly without panicking. | |||
| 633 | + | defer func() { | |||
| 634 | + | if r := recover(); r != nil { | |||
| 635 | + | t.Fatalf("Stop on detached agent panicked: %v", r) | |||
| 636 | + | } | |||
| 637 | + | }() | |||
| 638 | + | if err := f.Stop(id); err != nil { | |||
| 639 | + | t.Fatalf("Stop on detached agent: %v", err) | |||
| 640 | + | } | |||
| 641 | + | // Status must remain Detached (Stop is a no-op). | |||
| 642 | + | if got := a.Status(); got != StatusDetached { | |||
| 643 | + | t.Errorf("after Stop status = %q; want detached (no-op)", got) | |||
| 644 | + | } | |||
| 645 | + | ||||
| 646 | + | // StopAll, which the cli defers on exit, must also be safe. | |||
| 647 | + | f.StopAll() | |||
| 648 | + | } | |||
| 649 | + | ||||
| 650 | + | // TestRemoveAlsoCleansStateDir ensures Remove deletes the on-disk | |||
| 651 | + | // state directory in addition to the worktree, so a removed agent | |||
| 652 | + | // doesn't reappear on the next Reload. | |||
| 653 | + | func TestRemoveAlsoCleansStateDir(t *testing.T) { | |||
| 654 | + | root := t.TempDir() | |||
| 655 | + | f := New(Config{ | |||
| 656 | + | Root: root, RepoRoot: root, | |||
| 657 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 658 | + | NewRunner: func(a *Agent) Runner { | |||
| 659 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |||
| 660 | + | }, | |||
| 661 | + | }) | |||
| 662 | + | a, err := f.Spawn(context.Background(), "x") | |||
| 663 | + | if err != nil { | |||
| 664 | + | t.Fatal(err) | |||
| 665 | + | } | |||
| 666 | + | stateDir := filepath.Join(root, "agents", a.ID) | |||
| 667 | + | if _, err := os.Stat(filepath.Join(stateDir, "meta.json")); err != nil { | |||
| 668 | + | t.Fatalf("meta.json missing pre-remove: %v", err) | |||
| 669 | + | } | |||
| 670 | + | if err := f.Stop(a.ID); err != nil { | |||
| 671 | + | t.Fatal(err) | |||
| 672 | + | } | |||
| 673 | + | a.Wait() | |||
| 674 | + | if err := f.Remove(a.ID); err != nil { | |||
| 675 | + | t.Fatal(err) | |||
| 676 | + | } | |||
| 677 | + | if _, err := os.Stat(stateDir); !errors.Is(err, os.ErrNotExist) { | |||
| 678 | + | t.Fatalf("state dir should be gone after remove; got %v", err) | |||
| 679 | + | } | |||
| 680 | + | ||||
| 681 | + | // A fresh Swarm + Reload should find nothing. | |||
| 682 | + | g := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) | |||
| 683 | + | if loaded, _ := g.Reload(); loaded != 0 { | |||
| 684 | + | t.Fatalf("reload after remove loaded=%d; want 0", loaded) | |||
| 685 | + | } | |||
| 686 | + | } | |||
| 687 | + | ||||
| 688 | + | // TestActiveSessionScopesSnapshotAll proves the session filter the | |||
| 689 | + | // user asked for: a single Swarm with agents spawned under two | |||
| 690 | + | // different active sessions only surfaces the agents matching the | |||
| 691 | + | // currently-active session via SnapshotAll. Switching the active | |||
| 692 | + | // session re-narrows the view without touching agent state. | |||
| 693 | + | func TestActiveSessionScopesSnapshotAll(t *testing.T) { | |||
| 694 | + | root := t.TempDir() | |||
| 695 | + | f := New(Config{ | |||
| 696 | + | Root: root, RepoRoot: root, | |||
| 697 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 698 | + | NewRunner: func(a *Agent) Runner { | |||
| 699 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |||
| 700 | + | }, | |||
| 701 | + | }) | |||
| 702 | + | ||||
| 703 | + | // Spawn one agent under session A and one under session B. | |||
| 704 | + | f.SetActiveSession("sess-A") | |||
| 705 | + | aA, err := f.Spawn(context.Background(), "task A") | |||
| 706 | + | if err != nil { | |||
| 707 | + | t.Fatal(err) | |||
| 708 | + | } | |||
| 709 | + | f.SetActiveSession("sess-B") | |||
| 710 | + | aB, err := f.Spawn(context.Background(), "task B") | |||
| 711 | + | if err != nil { | |||
| 712 | + | t.Fatal(err) | |||
| 713 | + | } | |||
| 714 | + | ||||
| 715 | + | // Both agents must carry the session id they were spawned under, | |||
| 716 | + | // regardless of any later SetActiveSession. | |||
| 717 | + | if aA.SessionID != "sess-A" { | |||
| 718 | + | t.Errorf("aA.SessionID = %q; want sess-A", aA.SessionID) | |||
| 719 | + | } | |||
| 720 | + | if aB.SessionID != "sess-B" { | |||
| 721 | + | t.Errorf("aB.SessionID = %q; want sess-B", aB.SessionID) | |||
| 722 | + | } | |||
| 723 | + | ||||
| 724 | + | // Active session B → only aB visible. | |||
| 725 | + | only := snapshotIDs(f.SnapshotAll()) | |||
| 726 | + | if len(only) != 1 || only[0] != aB.ID { | |||
| 727 | + | t.Errorf("scoped to sess-B, snapshot ids = %v; want [%s]", only, aB.ID) | |||
| 728 | + | } | |||
| 729 | + | ||||
| 730 | + | // Switch back to A → only aA visible. | |||
| 731 | + | f.SetActiveSession("sess-A") | |||
| 732 | + | only = snapshotIDs(f.SnapshotAll()) | |||
| 733 | + | if len(only) != 1 || only[0] != aA.ID { | |||
| 734 | + | t.Errorf("scoped to sess-A, snapshot ids = %v; want [%s]", only, aA.ID) | |||
| 735 | + | } | |||
| 736 | + | ||||
| 737 | + | // Clear the scope → both visible. | |||
| 738 | + | f.SetActiveSession("") | |||
| 739 | + | all := snapshotIDs(f.SnapshotAll()) | |||
| 740 | + | if len(all) != 2 { | |||
| 741 | + | t.Errorf("unscoped snapshot ids = %v; want both agents", all) | |||
| 742 | + | } | |||
| 743 | + | ||||
| 744 | + | // Cleanup. | |||
| 745 | + | _ = f.Stop(aA.ID) | |||
| 746 | + | _ = f.Stop(aB.ID) | |||
| 747 | + | aA.Wait() | |||
| 748 | + | aB.Wait() | |||
| 749 | + | } | |||
| 750 | + | ||||
| 751 | + | // TestSessionIDPersistsAcrossReload confirms the SessionID field | |||
| 752 | + | // survives a Swarm restart: an agent spawned under session A in one | |||
| 753 | + | // Swarm instance is still SessionID="sess-A" after a fresh New + | |||
| 754 | + | // Reload reads it back from meta.json. Without persistence, the | |||
| 755 | + | // scope filter would forget which session owned each agent after | |||
| 756 | + | // a zot restart and the dashboard would leak everything again. | |||
| 757 | + | func TestSessionIDPersistsAcrossReload(t *testing.T) { | |||
| 758 | + | root := t.TempDir() | |||
| 759 | + | mkSwarm := func() *Swarm { | |||
| 760 | + | return New(Config{ | |||
| 761 | + | Root: root, RepoRoot: root, | |||
| 762 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 763 | + | NewRunner: func(a *Agent) Runner { | |||
| 764 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |||
| 765 | + | }, | |||
| 766 | + | }) | |||
| 767 | + | } | |||
| 768 | + | ||||
| 769 | + | f := mkSwarm() | |||
| 770 | + | f.SetActiveSession("sess-keep") | |||
| 771 | + | a, err := f.Spawn(context.Background(), "persist me") | |||
| 772 | + | if err != nil { | |||
| 773 | + | t.Fatal(err) | |||
| 774 | + | } | |||
| 775 | + | _ = f.Stop(a.ID) | |||
| 776 | + | a.Wait() | |||
| 777 | + | ||||
| 778 | + | // Fresh Swarm, same root, reload from disk. | |||
| 779 | + | g := mkSwarm() | |||
| 780 | + | if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 { | |||
| 781 | + | t.Fatalf("reload loaded=%d errs=%v; want 1 / no errs", loaded, errs) | |||
| 782 | + | } | |||
| 783 | + | g.SetActiveSession("sess-keep") | |||
| 784 | + | got := snapshotIDs(g.SnapshotAll()) | |||
| 785 | + | if len(got) != 1 || got[0] != a.ID { | |||
| 786 | + | t.Errorf("after reload + scope to sess-keep, ids = %v; want [%s]", got, a.ID) | |||
| 787 | + | } | |||
| 788 | + | ||||
| 789 | + | // Scope to a different session: agent must be hidden. | |||
| 790 | + | g.SetActiveSession("sess-other") | |||
| 791 | + | if got := snapshotIDs(g.SnapshotAll()); len(got) != 0 { | |||
| 792 | + | t.Errorf("scoped to other session, ids = %v; want []", got) | |||
| 793 | + | } | |||
| 794 | + | } | |||
| 795 | + | ||||
| 796 | + | // TestEmptySessionIDIsVisibleFromAnyScope pins the backward-compat | |||
| 797 | + | // rule: agents whose meta.json predates the SessionID field (or who | |||
| 798 | + | // were spawned without an active session, e.g. via a test rig or | |||
| 799 | + | // scripted caller) carry SessionID == "" and remain visible from | |||
| 800 | + | // every scope. Otherwise the schema bump would orphan every | |||
| 801 | + | // pre-existing agent the moment a user upgraded zot. | |||
| 802 | + | func TestEmptySessionIDIsVisibleFromAnyScope(t *testing.T) { | |||
| 803 | + | root := t.TempDir() | |||
| 804 | + | f := New(Config{ | |||
| 805 | + | Root: root, RepoRoot: root, | |||
| 806 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 807 | + | NewRunner: func(a *Agent) Runner { | |||
| 808 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) | |||
| 809 | + | }, | |||
| 810 | + | }) | |||
| 811 | + | // No SetActiveSession call → agent spawned with empty SessionID. | |||
| 812 | + | a, err := f.Spawn(context.Background(), "legacy") | |||
| 813 | + | if err != nil { | |||
| 814 | + | t.Fatal(err) | |||
| 815 | + | } | |||
| 816 | + | if a.SessionID != "" { | |||
| 817 | + | t.Fatalf("unscoped spawn produced SessionID %q; want empty", a.SessionID) | |||
| 818 | + | } | |||
| 819 | + | ||||
| 820 | + | for _, scope := range []string{"", "any-session", "some-other"} { | |||
| 821 | + | f.SetActiveSession(scope) | |||
| 822 | + | got := snapshotIDs(f.SnapshotAll()) | |||
| 823 | + | if len(got) != 1 || got[0] != a.ID { | |||
| 824 | + | t.Errorf("scope=%q: ids = %v; want legacy agent visible", scope, got) | |||
| 825 | + | } | |||
| 826 | + | } | |||
| 827 | + | ||||
| 828 | + | _ = f.Stop(a.ID) | |||
| 829 | + | a.Wait() | |||
| 830 | + | } | |||
| 831 | + | ||||
| 832 | + | func snapshotIDs(ss []AgentSnapshot) []string { | |||
| 833 | + | out := make([]string, len(ss)) | |||
| 834 | + | for i, s := range ss { | |||
| 835 | + | out[i] = s.ID | |||
| 836 | + | } | |||
| 837 | + | return out | |||
| 838 | + | } | |||
A internal/swarm/runner.go +339 -0
| @@ -0,0 +1,339 @@ | |||||
| 1 | + | package swarm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "os" | |||
| 10 | + | "os/exec" | |||
| 11 | + | "path/filepath" | |||
| 12 | + | "strings" | |||
| 13 | + | "time" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // execRunner spawns `zot --swarm-agent <inbox> --session <path>` in | |||
| 17 | + | // the agent's worktree and consumes its JSONL event stream on stdout. | |||
| 18 | + | // | |||
| 19 | + | // Why a long-lived daemon and not `zot --print`: the supervisor and | |||
| 20 | + | // the user expect agents to keep accepting follow-up prompts. A | |||
| 21 | + | // one-shot subprocess can't do that; this design gives each swarm | |||
| 22 | + | // agent a persistent session file plus an inbox socket the parent | |||
| 23 | + | // writes to, mirroring Claude Code's "Agents view" model. | |||
| 24 | + | // | |||
| 25 | + | // Events flow: | |||
| 26 | + | // | |||
| 27 | + | // child stdout --> decoder --> EventLog (events.jsonl) | |||
| 28 | + | // --> Sink (Activity/Transcript) | |||
| 29 | + | // | |||
| 30 | + | // The on-disk log is the durable record. The Sink updates are an | |||
| 31 | + | // in-memory mirror so the dashboard doesn't have to tail the file | |||
| 32 | + | // for the parent's own agents. /swarm open in a separate zot would | |||
| 33 | + | // read the log directly. | |||
| 34 | + | type execRunner struct { | |||
| 35 | + | agent *Agent | |||
| 36 | + | ||||
| 37 | + | // Command overrides the default `zot --swarm-agent ...` | |||
| 38 | + | // invocation. Tests set this to a fake binary (or `go run` | |||
| 39 | + | // against a tiny stub program) so the supervisor logic can be | |||
| 40 | + | // tested without a real child. Production code leaves it nil. | |||
| 41 | + | Command []string | |||
| 42 | + | ||||
| 43 | + | // SessionPath is the agent's session file. When empty the | |||
| 44 | + | // runner derives it as <Dir>/.zot/session.json so each agent | |||
| 45 | + | // owns its own session inside its worktree. | |||
| 46 | + | SessionPath string | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // swarmAgentArgsOpts captures every dynamic input to swarmAgentArgs | |||
| 50 | + | // so future per-agent overrides (e.g. tools, reasoning) can be added | |||
| 51 | + | // without churning the signature. The fields map 1:1 onto child CLI | |||
| 52 | + | // flags; empty values omit the flag entirely and let the child | |||
| 53 | + | // resolve a default the same way a normal `zot` invocation does. | |||
| 54 | + | type swarmAgentArgsOpts struct { | |||
| 55 | + | Exe string | |||
| 56 | + | Dir string | |||
| 57 | + | SessionPath string | |||
| 58 | + | InboxPath string | |||
| 59 | + | Task string | |||
| 60 | + | Model string | |||
| 61 | + | Provider string | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // defaultChildArgs builds the argv execRunner uses when its Command | |||
| 65 | + | // override is empty. Centralised so the spawn-vs-resume decision | |||
| 66 | + | // (whether to pass the original Task as a positional) lives in one | |||
| 67 | + | // place that tests can hit directly without going through Run's | |||
| 68 | + | // side effects. | |||
| 69 | + | // | |||
| 70 | + | // On Spawn (Resuming==false) we pass the task so the child's first | |||
| 71 | + | // turn runs immediately. On Resume (Resuming==true) we omit it: the | |||
| 72 | + | // child reopens the existing session file, loads the prior | |||
| 73 | + | // conversation, and just waits on the inbox for the next prompt. | |||
| 74 | + | // Re-firing the task on every resume produces a duplicate turn that | |||
| 75 | + | // collides with whatever the user types next, surfacing the | |||
| 76 | + | // "agent busy; send 'cancel' first" error between two assistant | |||
| 77 | + | // messages — which is exactly the bug this helper fixes. | |||
| 78 | + | func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []string { | |||
| 79 | + | task := a.Task | |||
| 80 | + | if a.Resuming { | |||
| 81 | + | task = "" | |||
| 82 | + | } | |||
| 83 | + | return swarmAgentArgs(swarmAgentArgsOpts{ | |||
| 84 | + | Exe: exe, | |||
| 85 | + | Dir: a.Dir, | |||
| 86 | + | SessionPath: sessionPath, | |||
| 87 | + | InboxPath: inboxPath, | |||
| 88 | + | Task: task, | |||
| 89 | + | Model: a.Model, | |||
| 90 | + | Provider: a.Provider, | |||
| 91 | + | }) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // swarmAgentArgs builds the argv used when execRunner.Command is | |||
| 95 | + | // empty. Pulled out so tests can lock in the flag set without | |||
| 96 | + | // actually spawning a subprocess. | |||
| 97 | + | func swarmAgentArgs(opts swarmAgentArgsOpts) []string { | |||
| 98 | + | args := []string{ | |||
| 99 | + | opts.Exe, | |||
| 100 | + | "--swarm-agent", opts.InboxPath, | |||
| 101 | + | "--session", opts.SessionPath, | |||
| 102 | + | "--cwd", opts.Dir, | |||
| 103 | + | } | |||
| 104 | + | if opts.Model != "" { | |||
| 105 | + | args = append(args, "--model", opts.Model) | |||
| 106 | + | } | |||
| 107 | + | if opts.Provider != "" { | |||
| 108 | + | args = append(args, "--provider", opts.Provider) | |||
| 109 | + | } | |||
| 110 | + | if opts.Task != "" { | |||
| 111 | + | // First task is positional so the child treats it as the | |||
| 112 | + | // initial user turn; subsequent turns arrive via the inbox. | |||
| 113 | + | args = append(args, opts.Task) | |||
| 114 | + | } | |||
| 115 | + | return args | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||
| 119 | + | sessionPath := r.SessionPath | |||
| 120 | + | if sessionPath == "" { | |||
| 121 | + | sessionPath = filepath.Join(r.agent.Dir, ".zot", "session.json") | |||
| 122 | + | } | |||
| 123 | + | if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil { | |||
| 124 | + | return fmt.Errorf("session dir: %w", err) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | inboxPath := r.agent.InboxPath | |||
| 128 | + | logPath := r.agent.EventLogPath | |||
| 129 | + | if logPath == "" { | |||
| 130 | + | return fmt.Errorf("swarm: agent missing event log path") | |||
| 131 | + | } | |||
| 132 | + | log, err := OpenEventLog(logPath) | |||
| 133 | + | if err != nil { | |||
| 134 | + | return err | |||
| 135 | + | } | |||
| 136 | + | defer log.Close() | |||
| 137 | + | ||||
| 138 | + | args := r.Command | |||
| 139 | + | if len(args) == 0 { | |||
| 140 | + | exe, err := os.Executable() | |||
| 141 | + | if err != nil { | |||
| 142 | + | return fmt.Errorf("locate self: %w", err) | |||
| 143 | + | } | |||
| 144 | + | args = defaultChildArgs(exe, r.agent, sessionPath, inboxPath) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | cmd := exec.CommandContext(ctx, args[0], args[1:]...) | |||
| 148 | + | cmd.Dir = r.agent.Dir | |||
| 149 | + | cmd.Env = append(os.Environ(), | |||
| 150 | + | "ZOT_SWARM_AGENT_ID="+r.agent.ID, | |||
| 151 | + | "ZOT_SWARM_EVENT_LOG="+logPath, | |||
| 152 | + | ) | |||
| 153 | + | ||||
| 154 | + | stdout, err := cmd.StdoutPipe() | |||
| 155 | + | if err != nil { | |||
| 156 | + | return err | |||
| 157 | + | } | |||
| 158 | + | stderr, err := cmd.StderrPipe() | |||
| 159 | + | if err != nil { | |||
| 160 | + | return err | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | // "spawning" is briefly shown until the first event arrives; | |||
| 164 | + | // the child's "spawned" lifecycle event then overwrites it. | |||
| 165 | + | sink.Activity("starting") | |||
| 166 | + | if err := cmd.Start(); err != nil { | |||
| 167 | + | return err | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // stdout: parsed as JSONL. Every well-formed event is appended | |||
| 171 | + | // to the durable log AND forwarded to the in-memory sink so the | |||
| 172 | + | // dashboard updates without having to tail the file. Malformed | |||
| 173 | + | // lines are surfaced as plain transcript so they don't vanish. | |||
| 174 | + | done := make(chan struct{}, 2) | |||
| 175 | + | go func() { | |||
| 176 | + | defer func() { done <- struct{}{} }() | |||
| 177 | + | dec := bufio.NewReader(stdout) | |||
| 178 | + | for { | |||
| 179 | + | line, err := dec.ReadBytes('\n') | |||
| 180 | + | if len(line) > 0 { | |||
| 181 | + | trimmed := strings.TrimRight(string(line), "\r\n") | |||
| 182 | + | if trimmed == "" { | |||
| 183 | + | goto next | |||
| 184 | + | } | |||
| 185 | + | if ev, ok := parseEventLine(trimmed); ok { | |||
| 186 | + | _ = log.Append(ev) | |||
| 187 | + | applyEventToSink(ev, sink) | |||
| 188 | + | } else { | |||
| 189 | + | // Non-JSON output. Keep it as transcript so an | |||
| 190 | + | // accidental fmt.Println in the child still | |||
| 191 | + | // shows up in the dashboard, and log a | |||
| 192 | + | // lifecycle event so the durable record stays | |||
| 193 | + | // in sync. | |||
| 194 | + | sink.Transcript(trimmed) | |||
| 195 | + | _ = log.Append(NewEvent("stdout", map[string]any{"text": trimmed})) | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | next: | |||
| 199 | + | if err != nil { | |||
| 200 | + | return | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | }() | |||
| 204 | + | ||||
| 205 | + | // stderr: lifecycle/error chatter from the child. Every line | |||
| 206 | + | // is mirrored as a stderr event in the durable log AND surfaced | |||
| 207 | + | // in the transcript so users can diagnose a failing agent | |||
| 208 | + | // without leaving the dashboard. | |||
| 209 | + | go func() { | |||
| 210 | + | defer func() { done <- struct{}{} }() | |||
| 211 | + | br := bufio.NewReader(stderr) | |||
| 212 | + | for { | |||
| 213 | + | line, err := br.ReadString('\n') | |||
| 214 | + | if line != "" { | |||
| 215 | + | txt := strings.TrimRight(line, "\r\n") | |||
| 216 | + | sink.Transcript("stderr: " + txt) | |||
| 217 | + | _ = log.Append(NewEvent("stderr", map[string]any{"text": txt})) | |||
| 218 | + | } | |||
| 219 | + | if err != nil { | |||
| 220 | + | return | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | }() | |||
| 224 | + | ||||
| 225 | + | <-done | |||
| 226 | + | <-done | |||
| 227 | + | ||||
| 228 | + | err = cmd.Wait() | |||
| 229 | + | exit := 0 | |||
| 230 | + | if ee, ok := err.(*exec.ExitError); ok { | |||
| 231 | + | exit = ee.ExitCode() | |||
| 232 | + | } | |||
| 233 | + | if err != nil && ctx.Err() != nil { | |||
| 234 | + | _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "cancelled"})) | |||
| 235 | + | return ctx.Err() | |||
| 236 | + | } | |||
| 237 | + | if err != nil { | |||
| 238 | + | _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": exit, "error": err.Error()})) | |||
| 239 | + | return err | |||
| 240 | + | } | |||
| 241 | + | _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": 0})) | |||
| 242 | + | sink.Activity("done") | |||
| 243 | + | return nil | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | // parseEventLine attempts to decode one JSONL line as an Event. | |||
| 247 | + | // Returns ok=false for non-JSON or JSON without a "type" field. | |||
| 248 | + | func parseEventLine(line string) (Event, bool) { | |||
| 249 | + | if len(line) == 0 || line[0] != '{' { | |||
| 250 | + | return Event{}, false | |||
| 251 | + | } | |||
| 252 | + | var ev Event | |||
| 253 | + | if err := json.Unmarshal([]byte(line), &ev); err != nil { | |||
| 254 | + | return Event{}, false | |||
| 255 | + | } | |||
| 256 | + | if ev.Type == "" { | |||
| 257 | + | return Event{}, false | |||
| 258 | + | } | |||
| 259 | + | if ev.Time.IsZero() { | |||
| 260 | + | ev.Time = time.Now() | |||
| 261 | + | } | |||
| 262 | + | return ev, true | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | // applyEventToSink translates an Event into Sink updates. Only a | |||
| 266 | + | // few event types are interpreted; the rest still land in the | |||
| 267 | + | // durable log via the caller. | |||
| 268 | + | func applyEventToSink(ev Event, sink Sink) { | |||
| 269 | + | switch ev.Type { | |||
| 270 | + | case "assistant_message": | |||
| 271 | + | if c, ok := ev.Data["content"].([]any); ok { | |||
| 272 | + | for _, blk := range c { | |||
| 273 | + | m, _ := blk.(map[string]any) | |||
| 274 | + | if t, _ := m["type"].(string); t == "text" { | |||
| 275 | + | if txt, _ := m["text"].(string); txt != "" { | |||
| 276 | + | sink.Transcript(txt) | |||
| 277 | + | } | |||
| 278 | + | } | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | sink.Activity("idle") | |||
| 282 | + | case "user_message": | |||
| 283 | + | if c, ok := ev.Data["content"].([]any); ok { | |||
| 284 | + | for _, blk := range c { | |||
| 285 | + | m, _ := blk.(map[string]any) | |||
| 286 | + | if t, _ := m["type"].(string); t == "text" { | |||
| 287 | + | if txt, _ := m["text"].(string); txt != "" { | |||
| 288 | + | sink.Transcript("user: " + txt) | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | } | |||
| 292 | + | } | |||
| 293 | + | case "turn_start": | |||
| 294 | + | sink.Activity("thinking") | |||
| 295 | + | case "tool_call": | |||
| 296 | + | if name, _ := ev.Data["name"].(string); name != "" { | |||
| 297 | + | sink.Activity("tool: " + truncate(name, 60)) | |||
| 298 | + | } | |||
| 299 | + | case "tool_result": | |||
| 300 | + | sink.Activity("idle") | |||
| 301 | + | case "turn_end": | |||
| 302 | + | sink.Activity("idle") | |||
| 303 | + | case "agent_ready": | |||
| 304 | + | sink.Activity("idle") | |||
| 305 | + | case "agent_stopped": | |||
| 306 | + | // terminal status is decided by Swarm.run from the runner's | |||
| 307 | + | // return value, not from this event. Don't overwrite the | |||
| 308 | + | // activity here. | |||
| 309 | + | case "error": | |||
| 310 | + | if msg, _ := ev.Data["message"].(string); msg != "" { | |||
| 311 | + | sink.Transcript("error: " + msg) | |||
| 312 | + | } | |||
| 313 | + | } | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | // RunnerFunc adapts a plain function into a Runner. Useful for tests | |||
| 317 | + | // and for callers who don't need their own type. | |||
| 318 | + | type RunnerFunc func(ctx context.Context, sink Sink) error | |||
| 319 | + | ||||
| 320 | + | func (f RunnerFunc) Run(ctx context.Context, sink Sink) error { return f(ctx, sink) } | |||
| 321 | + | ||||
| 322 | + | // streamLines kept around for any caller still using it directly. | |||
| 323 | + | // | |||
| 324 | + | // Deprecated: the runner now parses JSONL from stdout via | |||
| 325 | + | // parseEventLine; this helper is unused inside the package but | |||
| 326 | + | // remains exported via internal use by tests in the runner_test | |||
| 327 | + | // suite that pre-date the daemon switch. | |||
| 328 | + | func streamLines(r io.Reader, fn func(string)) { | |||
| 329 | + | br := bufio.NewReader(r) | |||
| 330 | + | for { | |||
| 331 | + | line, err := br.ReadString('\n') | |||
| 332 | + | if line != "" { | |||
| 333 | + | fn(strings.TrimRight(line, "\r\n")) | |||
| 334 | + | } | |||
| 335 | + | if err != nil { | |||
| 336 | + | return | |||
| 337 | + | } | |||
| 338 | + | } | |||
| 339 | + | } | |||
A internal/swarm/runner_e2e_test.go +194 -0
| @@ -0,0 +1,194 @@ | |||||
| 1 | + | package swarm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "os" | |||
| 7 | + | "os/exec" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "runtime" | |||
| 10 | + | "strings" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // TestRunnerEndToEndWithStubChild is the integration test for the | |||
| 16 | + | // new daemon-mode runner. It compiles the stubchild binary in | |||
| 17 | + | // testdata/cmd/stubchild, points an execRunner at it, and drives | |||
| 18 | + | // a Swarm through one Spawn + one SendUserTurn + Stop cycle. | |||
| 19 | + | // | |||
| 20 | + | // What this proves: | |||
| 21 | + | // | |||
| 22 | + | // - The default argv shape (swarmAgentArgs) is one the child can | |||
| 23 | + | // actually parse — locks the shape against silent breakage. | |||
| 24 | + | // - The stdout JSONL parser ingests events and writes them to the | |||
| 25 | + | // durable log. | |||
| 26 | + | // - applyEventToSink turns events into Activity / Transcript | |||
| 27 | + | // updates the dashboard reads. | |||
| 28 | + | // - The supervisor inbox dials the child's socket and a follow-up | |||
| 29 | + | // line round-trips back as a user_message event. | |||
| 30 | + | // - Stop closes the inbox AND cancels the child's context so the | |||
| 31 | + | // stub exits cleanly. | |||
| 32 | + | // | |||
| 33 | + | // The test is skipped on platforms without unix sockets (Windows). | |||
| 34 | + | func TestRunnerEndToEndWithStubChild(t *testing.T) { | |||
| 35 | + | if runtime.GOOS == "windows" { | |||
| 36 | + | t.Skip("unix sockets not supported") | |||
| 37 | + | } | |||
| 38 | + | if testing.Short() { | |||
| 39 | + | t.Skip("skip end-to-end runner test in -short mode") | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | exe := buildStubChild(t) | |||
| 43 | + | ||||
| 44 | + | root := t.TempDir() | |||
| 45 | + | repo := t.TempDir() | |||
| 46 | + | f := New(Config{ | |||
| 47 | + | Root: root, | |||
| 48 | + | RepoRoot: repo, | |||
| 49 | + | Worktree: MemWorktree(filepath.Join(root, "wt")), | |||
| 50 | + | NewRunner: func(a *Agent) Runner { | |||
| 51 | + | return &execRunner{ | |||
| 52 | + | agent: a, | |||
| 53 | + | Command: swarmAgentArgs(swarmAgentArgsOpts{ | |||
| 54 | + | Exe: exe, | |||
| 55 | + | Dir: a.Dir, | |||
| 56 | + | SessionPath: a.SessionPath, | |||
| 57 | + | InboxPath: a.InboxPath, | |||
| 58 | + | Task: a.Task, | |||
| 59 | + | Model: a.Model, | |||
| 60 | + | Provider: a.Provider, | |||
| 61 | + | }), | |||
| 62 | + | } | |||
| 63 | + | }, | |||
| 64 | + | }) | |||
| 65 | + | defer f.StopAll() | |||
| 66 | + | ||||
| 67 | + | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |||
| 68 | + | defer cancel() | |||
| 69 | + | ||||
| 70 | + | a, err := f.Spawn(ctx, "first task") | |||
| 71 | + | if err != nil { | |||
| 72 | + | t.Fatalf("Spawn: %v", err) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // Wait until the durable log has at least one assistant_message | |||
| 76 | + | // from the initial task. That confirms stdout→log→follower. | |||
| 77 | + | waitFor := func(want string) { | |||
| 78 | + | t.Helper() | |||
| 79 | + | deadline := time.Now().Add(5 * time.Second) | |||
| 80 | + | for time.Now().Before(deadline) { | |||
| 81 | + | evs, _ := ReadEventLog(a.EventLogPath) | |||
| 82 | + | for _, ev := range evs { | |||
| 83 | + | if strings.Contains(eventText(ev), want) { | |||
| 84 | + | return | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | time.Sleep(20 * time.Millisecond) | |||
| 88 | + | } | |||
| 89 | + | evs, _ := ReadEventLog(a.EventLogPath) | |||
| 90 | + | t.Fatalf("timed out waiting for %q in event log; got %d events:\n%s\n%s", | |||
| 91 | + | want, len(evs), formatEvents(evs), dumpEventsVerbose(evs)) | |||
| 92 | + | } | |||
| 93 | + | waitFor("echo: first task") | |||
| 94 | + | ||||
| 95 | + | // Send a follow-up over the inbox. The stub echoes the text into | |||
| 96 | + | // an assistant_message we can poll for in the log. | |||
| 97 | + | if err := retrySend(f, a.ID, "user follow up", time.Second); err != nil { | |||
| 98 | + | t.Fatalf("SendInput: %v", err) | |||
| 99 | + | } | |||
| 100 | + | waitFor("echo: follow up") | |||
| 101 | + | ||||
| 102 | + | // Shut the agent down gracefully via the inbox. | |||
| 103 | + | if err := f.SendInput(a.ID, "shutdown"); err != nil && !errors.Is(err, ErrNotReady) { | |||
| 104 | + | t.Fatalf("shutdown send: %v", err) | |||
| 105 | + | } | |||
| 106 | + | a.Wait() | |||
| 107 | + | if got := a.Status(); got != StatusDone && got != StatusKilled { | |||
| 108 | + | t.Fatalf("final status = %s; want done/killed", got) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // retrySend exists because the inbox dial races against the child | |||
| 113 | + | // opening the socket. Production callers handle ErrNotReady with a | |||
| 114 | + | // status message; tests retry within a small window. | |||
| 115 | + | func retrySend(f *Swarm, id, msg string, timeout time.Duration) error { | |||
| 116 | + | deadline := time.Now().Add(timeout) | |||
| 117 | + | var lastErr error | |||
| 118 | + | for time.Now().Before(deadline) { | |||
| 119 | + | err := f.SendInput(id, msg) | |||
| 120 | + | if err == nil { | |||
| 121 | + | return nil | |||
| 122 | + | } | |||
| 123 | + | lastErr = err | |||
| 124 | + | if !errors.Is(err, ErrNotReady) { | |||
| 125 | + | return err | |||
| 126 | + | } | |||
| 127 | + | time.Sleep(30 * time.Millisecond) | |||
| 128 | + | } | |||
| 129 | + | return lastErr | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func eventText(ev Event) string { | |||
| 133 | + | if ev.Type != "assistant_message" && ev.Type != "user_message" { | |||
| 134 | + | return "" | |||
| 135 | + | } | |||
| 136 | + | content, _ := ev.Data["content"].([]any) | |||
| 137 | + | var sb strings.Builder | |||
| 138 | + | for _, c := range content { | |||
| 139 | + | m, _ := c.(map[string]any) | |||
| 140 | + | if t, _ := m["type"].(string); t == "text" { | |||
| 141 | + | if txt, _ := m["text"].(string); txt != "" { | |||
| 142 | + | sb.WriteString(txt) | |||
| 143 | + | sb.WriteByte('\n') | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | return sb.String() | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func dumpEventsVerbose(evs []Event) string { | |||
| 151 | + | var sb strings.Builder | |||
| 152 | + | for _, ev := range evs { | |||
| 153 | + | sb.WriteString(ev.Type) | |||
| 154 | + | sb.WriteString("\t") | |||
| 155 | + | for k, v := range ev.Data { | |||
| 156 | + | sb.WriteString(k) | |||
| 157 | + | sb.WriteString("=") | |||
| 158 | + | switch vv := v.(type) { | |||
| 159 | + | case string: | |||
| 160 | + | sb.WriteString(vv) | |||
| 161 | + | default: | |||
| 162 | + | sb.WriteString("<...>") | |||
| 163 | + | } | |||
| 164 | + | sb.WriteString(" ") | |||
| 165 | + | } | |||
| 166 | + | sb.WriteString("\n") | |||
| 167 | + | } | |||
| 168 | + | return sb.String() | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func formatEvents(evs []Event) string { | |||
| 172 | + | var sb strings.Builder | |||
| 173 | + | for _, ev := range evs { | |||
| 174 | + | sb.WriteString(ev.Type) | |||
| 175 | + | sb.WriteString(" ") | |||
| 176 | + | sb.WriteString(ev.Time.Format(time.RFC3339Nano)) | |||
| 177 | + | sb.WriteString("\n") | |||
| 178 | + | } | |||
| 179 | + | return sb.String() | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func buildStubChild(t *testing.T) string { | |||
| 183 | + | t.Helper() | |||
| 184 | + | out := filepath.Join(t.TempDir(), "stubchild") | |||
| 185 | + | cmd := exec.Command("go", "build", "-o", out, "./testdata/cmd/stubchild") | |||
| 186 | + | // Pass through the test runner's env so `go build` can find | |||
| 187 | + | // HOME, PATH, GOCACHE, etc. CGO is disabled to keep the build | |||
| 188 | + | // hermetic across machines. | |||
| 189 | + | cmd.Env = append(os.Environ(), "CGO_ENABLED=0") | |||
| 190 | + | if b, err := cmd.CombinedOutput(); err != nil { | |||
| 191 | + | t.Fatalf("build stubchild: %v\n%s", err, b) | |||
| 192 | + | } | |||
| 193 | + | return out | |||
| 194 | + | } | |||
A internal/swarm/runner_test.go +140 -0
| @@ -0,0 +1,140 @@ | |||||
| 1 | + | package swarm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // TestSwarmAgentArgs locks in the exact flag set the subprocess | |||
| 9 | + | // runner uses to start a swarm agent in daemon mode. Past | |||
| 10 | + | // regressions in this area: | |||
| 11 | + | // | |||
| 12 | + | // - "--no-sess" instead of "--no-session" (old print-mode runner): | |||
| 13 | + | // every spawned agent died with "unknown flag" before it could | |||
| 14 | + | // talk to the model. | |||
| 15 | + | // | |||
| 16 | + | // - Forgetting --cwd: the child resolved tools against the parent | |||
| 17 | + | // zot's working directory, defeating the whole point of the | |||
| 18 | + | // worktree isolation. | |||
| 19 | + | // | |||
| 20 | + | // - Forgetting --session: a daemon-mode agent without a session | |||
| 21 | + | // file would lose context between follow-up turns, making | |||
| 22 | + | // "send another message" mostly useless. | |||
| 23 | + | // | |||
| 24 | + | // The test asserts the load-bearing pieces are present in plausible | |||
| 25 | + | // positions. If a flag is renamed, update both the runner and this | |||
| 26 | + | // test so we notice immediately. | |||
| 27 | + | func TestSwarmAgentArgs(t *testing.T) { | |||
| 28 | + | args := swarmAgentArgs(swarmAgentArgsOpts{ | |||
| 29 | + | Exe: "/path/to/zot", | |||
| 30 | + | Dir: "/tmp/worktree", | |||
| 31 | + | SessionPath: "/tmp/state/session.json", | |||
| 32 | + | InboxPath: "/tmp/state/in.sock", | |||
| 33 | + | Task: "do the thing", | |||
| 34 | + | }) | |||
| 35 | + | if len(args) < 7 { | |||
| 36 | + | t.Fatalf("argv unexpectedly short: %v", args) | |||
| 37 | + | } | |||
| 38 | + | if args[0] != "/path/to/zot" { | |||
| 39 | + | t.Fatalf("argv[0] = %q; want the binary path", args[0]) | |||
| 40 | + | } | |||
| 41 | + | // The task must come last so anything that looks flag-like in | |||
| 42 | + | // the task body doesn't get interpreted as a flag. | |||
| 43 | + | if args[len(args)-1] != "do the thing" { | |||
| 44 | + | t.Fatalf("task should be last positional; got %v", args) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | mustHave := map[string]string{ | |||
| 48 | + | "--swarm-agent": "/tmp/state/in.sock", | |||
| 49 | + | "--session": "/tmp/state/session.json", | |||
| 50 | + | "--cwd": "/tmp/worktree", | |||
| 51 | + | } | |||
| 52 | + | for flag, value := range mustHave { | |||
| 53 | + | i := indexOf(args, flag) | |||
| 54 | + | if i < 0 { | |||
| 55 | + | t.Errorf("argv missing %q: %v", flag, args) | |||
| 56 | + | continue | |||
| 57 | + | } | |||
| 58 | + | if i+1 >= len(args) || args[i+1] != value { | |||
| 59 | + | t.Errorf("argv %q value = %q; want %q", flag, safeAt(args, i+1), value) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // Reject prior bad flags explicitly so a future revert is caught. | |||
| 64 | + | joined := strings.Join(args, " ") | |||
| 65 | + | for _, bad := range []string{"--print", "--no-sess ", "--no-session"} { | |||
| 66 | + | if strings.Contains(joined, bad) { | |||
| 67 | + | t.Fatalf("argv contains stale/wrong flag %q: %s", bad, joined) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // TestSwarmAgentArgsEmptyTaskOmitsPositional makes sure that when the | |||
| 73 | + | // agent is being adopted (no fresh task) we don't pass an empty | |||
| 74 | + | // positional which the arg parser would treat as a real prompt. | |||
| 75 | + | func TestSwarmAgentArgsEmptyTaskOmitsPositional(t *testing.T) { | |||
| 76 | + | args := swarmAgentArgs(swarmAgentArgsOpts{ | |||
| 77 | + | Exe: "/zot", Dir: "/wt", SessionPath: "/s.json", InboxPath: "/in.sock", | |||
| 78 | + | }) | |||
| 79 | + | for _, a := range args { | |||
| 80 | + | if a == "" { | |||
| 81 | + | t.Fatalf("argv contains an empty positional: %v", args) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | // last arg should be a real flag value, not a stray positional | |||
| 85 | + | if a := args[len(args)-1]; strings.HasPrefix(a, "--") { | |||
| 86 | + | t.Fatalf("argv ends on a flag with no value: %v", args) | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // TestDefaultChildArgsSpawnIncludesTask pins the spawn shape: a | |||
| 91 | + | // fresh (non-resuming) Agent produces argv that ends with the | |||
| 92 | + | // original task as a positional, so the child runs it as the | |||
| 93 | + | // initial user turn. | |||
| 94 | + | func TestDefaultChildArgsSpawnIncludesTask(t *testing.T) { | |||
| 95 | + | a := &Agent{Dir: "/wt", Task: "do thing"} | |||
| 96 | + | args := defaultChildArgs("/zot", a, "/s.json", "/in.sock") | |||
| 97 | + | if got := args[len(args)-1]; got != "do thing" { | |||
| 98 | + | t.Fatalf("spawn argv last = %q; want %q\n%v", got, "do thing", args) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // TestDefaultChildArgsResumeOmitsTask is the regression for the | |||
| 103 | + | // "agent busy; send 'cancel' first" error: when an Agent is being | |||
| 104 | + | // resumed (Resuming==true), the child argv MUST NOT include the | |||
| 105 | + | // original Task as a positional. Otherwise the child fires the task | |||
| 106 | + | // as a fresh user turn on every resume, racing with whatever the | |||
| 107 | + | // user types next via the inbox. | |||
| 108 | + | func TestDefaultChildArgsResumeOmitsTask(t *testing.T) { | |||
| 109 | + | a := &Agent{Dir: "/wt", Task: "do thing", Resuming: true} | |||
| 110 | + | args := defaultChildArgs("/zot", a, "/s.json", "/in.sock") | |||
| 111 | + | for _, v := range args { | |||
| 112 | + | if v == "do thing" { | |||
| 113 | + | t.Fatalf("resume argv contains the task; it would re-fire as a duplicate turn\n%v", args) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | // And no trailing positional at all: the last arg should be a | |||
| 117 | + | // flag value, not a stray empty string. | |||
| 118 | + | if got := args[len(args)-1]; got == "" { | |||
| 119 | + | t.Fatalf("resume argv ends with an empty positional: %v", args) | |||
| 120 | + | } | |||
| 121 | + | if strings.HasPrefix(args[len(args)-1], "--") { | |||
| 122 | + | t.Fatalf("resume argv ends on a bare flag (no value): %v", args) | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func indexOf(xs []string, want string) int { | |||
| 127 | + | for i, x := range xs { | |||
| 128 | + | if x == want { | |||
| 129 | + | return i | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | return -1 | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func safeAt(xs []string, i int) string { | |||
| 136 | + | if i < 0 || i >= len(xs) { | |||
| 137 | + | return "" | |||
| 138 | + | } | |||
| 139 | + | return xs[i] | |||
| 140 | + | } | |||
A internal/swarm/socketpath.go +69 -0
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | package swarm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/sha1" | |||
| 5 | + | "encoding/hex" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "runtime" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // maxUnixSocketPath is the conservative platform-portable path limit | |||
| 13 | + | // for unix sockets. macOS allows 104, linux 108 (including the NUL | |||
| 14 | + | // terminator). We pick 100 so the path itself plus a small filename | |||
| 15 | + | // tail stays under both caps with a safety margin. | |||
| 16 | + | const maxUnixSocketPath = 100 | |||
| 17 | + | ||||
| 18 | + | // inboxSocketPath returns a per-agent unix-socket path that's short | |||
| 19 | + | // enough to actually work (see maxUnixSocketPath) and unique per | |||
| 20 | + | // swarm root so two zot instances on the same machine don't collide. | |||
| 21 | + | // | |||
| 22 | + | // Strategy: | |||
| 23 | + | // | |||
| 24 | + | // 1. Try <root>/agents/<id>/in.sock. This is the obvious place and | |||
| 25 | + | // puts everything next to the durable state; on most setups it | |||
| 26 | + | // fits. | |||
| 27 | + | // 2. If that's too long, fall back to <tmp>/zot-swarm-<roothash>/<id>.sock. | |||
| 28 | + | // We hash root rather than embedding it so the tmp directory name | |||
| 29 | + | // stays short. SHA-1's first 8 hex chars is plenty: collisions | |||
| 30 | + | // only matter within a single user's tmp dir and we already | |||
| 31 | + | // create a dedicated subdir. | |||
| 32 | + | // 3. If even /tmp is somehow too long (chroots, containers), give | |||
| 33 | + | // up with a clear error so the caller surfaces it instead of | |||
| 34 | + | // leaving the user wondering why follow-ups don't work. | |||
| 35 | + | func inboxSocketPath(root, agentID string) (string, error) { | |||
| 36 | + | primary := filepath.Join(root, "agents", agentID, "in.sock") | |||
| 37 | + | if len(primary) <= maxUnixSocketPath { | |||
| 38 | + | return primary, nil | |||
| 39 | + | } | |||
| 40 | + | tmp := os.TempDir() | |||
| 41 | + | dir := filepath.Join(tmp, "zot-swarm-"+rootTag(root)) | |||
| 42 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |||
| 43 | + | return "", fmt.Errorf("socket tmp dir: %w", err) | |||
| 44 | + | } | |||
| 45 | + | candidate := filepath.Join(dir, agentID+".sock") | |||
| 46 | + | if len(candidate) <= maxUnixSocketPath { | |||
| 47 | + | return candidate, nil | |||
| 48 | + | } | |||
| 49 | + | // Last-resort: use just the short hash of the id so even very long | |||
| 50 | + | // task slugs fit. We surface the original id in the meta.json / | |||
| 51 | + | // events log; the socket path is purely transport. | |||
| 52 | + | short := shortHash(agentID) | |||
| 53 | + | candidate = filepath.Join(dir, short+".sock") | |||
| 54 | + | if len(candidate) <= maxUnixSocketPath { | |||
| 55 | + | return candidate, nil | |||
| 56 | + | } | |||
| 57 | + | return "", fmt.Errorf("unix socket path too long even after shortening (%s, %d > %d, GOOS=%s)", | |||
| 58 | + | candidate, len(candidate), maxUnixSocketPath, runtime.GOOS) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // rootTag returns a stable 8-hex-char tag for the swarm root. Used | |||
| 62 | + | // in the tmp-dir name so two parallel zot instances with different | |||
| 63 | + | // roots don't share sockets. | |||
| 64 | + | func rootTag(root string) string { return shortHash(root) } | |||
| 65 | + | ||||
| 66 | + | func shortHash(s string) string { | |||
| 67 | + | sum := sha1.Sum([]byte(s)) | |||
| 68 | + | return hex.EncodeToString(sum[:4]) | |||
| 69 | + | } | |||
A internal/swarm/swarm.go +527 -0
| @@ -0,0 +1,527 @@ | |||||
| 1 | + | // Package swarm implements zot's multi-agent supervisor. | |||
| 2 | + | // | |||
| 3 | + | // A Swarm manages a set of headless zot subprocesses ("agents") | |||
| 4 | + | // working in their own git worktrees. The interactive TUI exposes | |||
| 5 | + | // the supervisor through the /swarm slash command and a dashboard | |||
| 6 | + | // dialog; non-TUI code can drive it directly through this package. | |||
| 7 | + | // | |||
| 8 | + | // Each Agent has: | |||
| 9 | + | // - a unique id (short slug + nanoseconds) | |||
| 10 | + | // - a branch name on a fresh git worktree under <root>/swarm/<id> | |||
| 11 | + | // - a Runner (the thing that actually executes the task) | |||
| 12 | + | // - a Status string + Activity string that the dashboard reads | |||
| 13 | + | // | |||
| 14 | + | // The Runner abstraction means tests can swap a fake in instead of | |||
| 15 | + | // really spawning a subprocess; the production Runner shells out to | |||
| 16 | + | // `zot --print` so we reuse zot's own model resolution and tooling | |||
| 17 | + | // without re-implementing the agent loop. | |||
| 18 | + | package swarm | |||
| 19 | + | ||||
| 20 | + | import ( | |||
| 21 | + | "context" | |||
| 22 | + | "errors" | |||
| 23 | + | "fmt" | |||
| 24 | + | "os" | |||
| 25 | + | "path/filepath" | |||
| 26 | + | "sort" | |||
| 27 | + | "strings" | |||
| 28 | + | "sync" | |||
| 29 | + | "time" | |||
| 30 | + | ) | |||
| 31 | + | ||||
| 32 | + | // Status is the high-level lifecycle state of an Agent. | |||
| 33 | + | type Status string | |||
| 34 | + | ||||
| 35 | + | const ( | |||
| 36 | + | StatusPending Status = "pending" // created, not started yet | |||
| 37 | + | StatusRunning Status = "running" // Runner.Run is in flight | |||
| 38 | + | StatusDone Status = "done" // Runner.Run returned nil | |||
| 39 | + | StatusFailed Status = "failed" // Runner.Run returned an error | |||
| 40 | + | StatusKilled Status = "killed" // Stop() called before completion | |||
| 41 | + | StatusDetached Status = "detached" // reloaded from disk; no live runner | |||
| 42 | + | ) | |||
| 43 | + | ||||
| 44 | + | // Config configures a Swarm. | |||
| 45 | + | type Config struct { | |||
| 46 | + | // Root is the directory under which worktrees + state files live. | |||
| 47 | + | // Typically <ZotHome>/swarm, but tests pass a tempdir. | |||
| 48 | + | Root string | |||
| 49 | + | ||||
| 50 | + | // RepoRoot is the path to the user's git repo (CWD of the parent | |||
| 51 | + | // zot). New worktrees branch off this repo. | |||
| 52 | + | RepoRoot string | |||
| 53 | + | ||||
| 54 | + | // Worktree creates the per-agent working directory. If nil, the | |||
| 55 | + | // default git-worktree implementation is used. | |||
| 56 | + | Worktree WorktreeManager | |||
| 57 | + | ||||
| 58 | + | // NewRunner produces the Runner for an Agent. If nil, the default | |||
| 59 | + | // `zot --print` exec runner is used. Tests inject a fake here. | |||
| 60 | + | NewRunner func(a *Agent) Runner | |||
| 61 | + | ||||
| 62 | + | // Now is a clock seam for tests; defaults to time.Now. | |||
| 63 | + | Now func() time.Time | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // WorktreeManager creates and removes per-agent working directories. | |||
| 67 | + | type WorktreeManager interface { | |||
| 68 | + | Create(id, branch, base string) (dir string, err error) | |||
| 69 | + | Remove(id, dir string) error | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // Runner executes one agent task. Run blocks until the task finishes, | |||
| 73 | + | // is cancelled via ctx, or hits an unrecoverable error. | |||
| 74 | + | // | |||
| 75 | + | // Run should report progress by writing short human-readable strings | |||
| 76 | + | // to the activity channel and final transcript text to transcript. | |||
| 77 | + | // Both channels are non-blocking sinks owned by the Swarm; if the | |||
| 78 | + | // dashboard isn't reading, sends are dropped. | |||
| 79 | + | type Runner interface { | |||
| 80 | + | Run(ctx context.Context, sink Sink) error | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // Sink is how a Runner reports activity and transcript back to the | |||
| 84 | + | // supervisor. All methods are safe to call from any goroutine and | |||
| 85 | + | // never block. | |||
| 86 | + | type Sink interface { | |||
| 87 | + | // Activity sets the one-line "what is this agent doing right now" | |||
| 88 | + | // string shown in the dashboard. | |||
| 89 | + | Activity(msg string) | |||
| 90 | + | // Transcript appends a chunk of agent output (typically a final | |||
| 91 | + | // assistant message) to the agent's running transcript. | |||
| 92 | + | Transcript(chunk string) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // Swarm supervises a set of Agents. | |||
| 96 | + | type Swarm struct { | |||
| 97 | + | cfg Config | |||
| 98 | + | ||||
| 99 | + | mu sync.Mutex | |||
| 100 | + | agents map[string]*Agent | |||
| 101 | + | order []string // creation order for stable listing | |||
| 102 | + | ||||
| 103 | + | // activeSession is the host session id the dashboard is | |||
| 104 | + | // currently scoped to. When non-empty, SnapshotAll filters out | |||
| 105 | + | // agents whose SessionID doesn't match (legacy / unscoped | |||
| 106 | + | // agents with SessionID == "" are always shown). Spawn stamps | |||
| 107 | + | // new agents with this value so they appear only in the session | |||
| 108 | + | // that created them. When empty (the default), the historical | |||
| 109 | + | // "show everything" behaviour is preserved — important for | |||
| 110 | + | // tests and any scripted use of the Swarm that doesn't bother | |||
| 111 | + | // with sessions. | |||
| 112 | + | activeSession string | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // New constructs a Swarm from cfg. Missing config fields are filled | |||
| 116 | + | // with defaults. | |||
| 117 | + | func New(cfg Config) *Swarm { | |||
| 118 | + | if cfg.Now == nil { | |||
| 119 | + | cfg.Now = time.Now | |||
| 120 | + | } | |||
| 121 | + | if cfg.Worktree == nil { | |||
| 122 | + | cfg.Worktree = &gitWorktree{root: filepath.Join(cfg.Root, "worktrees"), repo: cfg.RepoRoot} | |||
| 123 | + | } | |||
| 124 | + | if cfg.NewRunner == nil { | |||
| 125 | + | cfg.NewRunner = func(a *Agent) Runner { return &execRunner{agent: a} } | |||
| 126 | + | } | |||
| 127 | + | return &Swarm{ | |||
| 128 | + | cfg: cfg, | |||
| 129 | + | agents: map[string]*Agent{}, | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // SetActiveSession scopes the dashboard view (and Spawn stamping) | |||
| 134 | + | // to a particular host zot session id. Pass empty to clear the | |||
| 135 | + | // scope and revert to "show every agent" (the original behaviour). | |||
| 136 | + | // | |||
| 137 | + | // Existing in-memory agents keep their SessionID; only the filter | |||
| 138 | + | // applied at snapshot time changes. So swapping the active session | |||
| 139 | + | // with /sessions instantly re-narrows the dashboard without | |||
| 140 | + | // touching any agent state. | |||
| 141 | + | func (f *Swarm) SetActiveSession(id string) { | |||
| 142 | + | f.mu.Lock() | |||
| 143 | + | defer f.mu.Unlock() | |||
| 144 | + | f.activeSession = id | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // ActiveSession returns the current scope, mostly for tests and | |||
| 148 | + | // diagnostics. Empty means "no scope; show everything". | |||
| 149 | + | func (f *Swarm) ActiveSession() string { | |||
| 150 | + | f.mu.Lock() | |||
| 151 | + | defer f.mu.Unlock() | |||
| 152 | + | return f.activeSession | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // agentStateDir is the per-agent state directory laid out as: | |||
| 156 | + | // | |||
| 157 | + | // <root>/agents/<id>/ | |||
| 158 | + | // events.jsonl durable event log (runner-owned) | |||
| 159 | + | // in.sock unix socket inbox (child-owned) | |||
| 160 | + | // session.json persistent agent session (child-owned) | |||
| 161 | + | // meta.json static metadata (id, task, branch, dir) | |||
| 162 | + | func (f *Swarm) agentStateDir(id string) string { | |||
| 163 | + | return filepath.Join(f.cfg.Root, "agents", id) | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // SpawnRequest configures a Spawn. Only Task is required; the rest | |||
| 167 | + | // are optional. Model + Provider, when set, get baked into the | |||
| 168 | + | // child argv as --model / --provider so the agent runs against the | |||
| 169 | + | // chosen model regardless of the parent's current selection. | |||
| 170 | + | type SpawnRequest struct { | |||
| 171 | + | Task string | |||
| 172 | + | Model string // optional override; child resolves default if empty | |||
| 173 | + | Provider string // optional override; usually paired with Model | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | // Spawn creates a new Agent for the given task, sets up its worktree, | |||
| 177 | + | // allocates the on-disk state directory (events log, inbox socket | |||
| 178 | + | // path, session file path), and starts the Runner on a background | |||
| 179 | + | // goroutine. The returned Agent is already in StatusRunning (or | |||
| 180 | + | // StatusFailed if worktree setup failed before the goroutine | |||
| 181 | + | // started). This is the historical signature; callers that want to | |||
| 182 | + | // override the child's model use SpawnReq instead. | |||
| 183 | + | func (f *Swarm) Spawn(ctx context.Context, task string) (*Agent, error) { | |||
| 184 | + | return f.SpawnReq(ctx, SpawnRequest{Task: task}) | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | // SpawnReq is the full-fat variant of Spawn that accepts a | |||
| 188 | + | // SpawnRequest. Existing callers can keep using Spawn; new code that | |||
| 189 | + | // wants to pin the child's model uses this. | |||
| 190 | + | func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) { | |||
| 191 | + | task := strings.TrimSpace(req.Task) | |||
| 192 | + | if task == "" { | |||
| 193 | + | return nil, errors.New("swarm: empty task") | |||
| 194 | + | } | |||
| 195 | + | id := newAgentID(task, f.cfg.Now()) | |||
| 196 | + | branch := "swarm/" + id | |||
| 197 | + | dir, err := f.cfg.Worktree.Create(id, branch, f.cfg.RepoRoot) | |||
| 198 | + | if err != nil { | |||
| 199 | + | return nil, fmt.Errorf("worktree create: %w", err) | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | stateDir := f.agentStateDir(id) | |||
| 203 | + | if err := os.MkdirAll(stateDir, 0o755); err != nil { | |||
| 204 | + | return nil, fmt.Errorf("swarm state dir: %w", err) | |||
| 205 | + | } | |||
| 206 | + | logPath := filepath.Join(stateDir, "events.jsonl") | |||
| 207 | + | sessionPath := filepath.Join(stateDir, "session.json") | |||
| 208 | + | // Unix sockets have a hard 104-byte path limit on darwin and 108 | |||
| 209 | + | // on linux. Long ZOT_HOME paths plus an agent slug blow that cap | |||
| 210 | + | // quickly. Pick the shortest path that still keeps sockets | |||
| 211 | + | // per-swarm-root so two zot instances on the same machine don't | |||
| 212 | + | // collide. inboxSocketPath falls back from $TMPDIR to /tmp if | |||
| 213 | + | // neither is short enough. | |||
| 214 | + | inboxPath, err := inboxSocketPath(f.cfg.Root, id) | |||
| 215 | + | if err != nil { | |||
| 216 | + | return nil, fmt.Errorf("swarm inbox path: %w", err) | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // Snapshot activeSession under the lock; we use it twice (struct | |||
| 220 | + | // init below + persistence). Reading it without the lock would | |||
| 221 | + | // race a concurrent SetActiveSession call. | |||
| 222 | + | f.mu.Lock() | |||
| 223 | + | sessionID := f.activeSession | |||
| 224 | + | f.mu.Unlock() | |||
| 225 | + | ||||
| 226 | + | a := &Agent{ | |||
| 227 | + | ID: id, | |||
| 228 | + | Task: task, | |||
| 229 | + | Branch: branch, | |||
| 230 | + | Dir: dir, | |||
| 231 | + | Started: f.cfg.Now(), | |||
| 232 | + | Model: strings.TrimSpace(req.Model), | |||
| 233 | + | Provider: strings.TrimSpace(req.Provider), | |||
| 234 | + | SessionID: sessionID, | |||
| 235 | + | InboxPath: inboxPath, | |||
| 236 | + | EventLogPath: logPath, | |||
| 237 | + | SessionPath: sessionPath, | |||
| 238 | + | inbox: NewInbox(inboxPath), | |||
| 239 | + | status: StatusPending, | |||
| 240 | + | activity: "queued", | |||
| 241 | + | done: make(chan struct{}), | |||
| 242 | + | } | |||
| 243 | + | a.ctx, a.cancel = context.WithCancel(ctx) | |||
| 244 | + | a.runner = f.cfg.NewRunner(a) | |||
| 245 | + | ||||
| 246 | + | f.mu.Lock() | |||
| 247 | + | f.agents[id] = a | |||
| 248 | + | f.order = append(f.order, id) | |||
| 249 | + | f.mu.Unlock() | |||
| 250 | + | ||||
| 251 | + | // Persist the agent's identity so a later `zot` invocation can | |||
| 252 | + | // reload it from disk via Swarm.Reload. Best-effort: if the disk | |||
| 253 | + | // is read-only we still let the runner start, the user just won't | |||
| 254 | + | // see this agent on the next launch. | |||
| 255 | + | _ = writeAgentMeta(stateDir, a) | |||
| 256 | + | ||||
| 257 | + | go f.run(a) | |||
| 258 | + | return a, nil | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | // SendInput delivers msg to the agent's inbox. msg should already | |||
| 262 | + | // include the leading kind ("user " + text, "cancel", "shutdown"). | |||
| 263 | + | // Returns an error wrapping ErrNotReady if the child hasn't opened | |||
| 264 | + | // its listener yet; the caller can retry or surface the wait. | |||
| 265 | + | func (f *Swarm) SendInput(id, msg string) error { | |||
| 266 | + | a := f.Get(id) | |||
| 267 | + | if a == nil { | |||
| 268 | + | return fmt.Errorf("no such agent %q", id) | |||
| 269 | + | } | |||
| 270 | + | if a.inbox == nil { | |||
| 271 | + | return fmt.Errorf("agent %s has no inbox", a.ID) | |||
| 272 | + | } | |||
| 273 | + | return a.inbox.SendInput(msg) | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | // SendUserTurn is sugar for the common "send the next user turn" | |||
| 277 | + | // case. It quotes nothing and forwards verbatim; callers are | |||
| 278 | + | // expected to have already trimmed and expanded the text. | |||
| 279 | + | func (f *Swarm) SendUserTurn(id, text string) error { | |||
| 280 | + | return f.SendInput(id, "user "+text) | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | func (f *Swarm) run(a *Agent) { | |||
| 284 | + | a.setStatus(StatusRunning) | |||
| 285 | + | a.setActivity("starting") | |||
| 286 | + | err := a.runner.Run(a.ctx, agentSink{a: a}) | |||
| 287 | + | a.mu.Lock() | |||
| 288 | + | a.finished = f.cfg.Now() | |||
| 289 | + | switch { | |||
| 290 | + | case a.status == StatusKilled: | |||
| 291 | + | // Already finalised by Stop. | |||
| 292 | + | case errors.Is(err, context.Canceled): | |||
| 293 | + | a.status = StatusKilled | |||
| 294 | + | a.activity = "cancelled" | |||
| 295 | + | case err != nil: | |||
| 296 | + | a.status = StatusFailed | |||
| 297 | + | a.activity = "error: " + truncate(err.Error(), 120) | |||
| 298 | + | a.lastErr = err | |||
| 299 | + | default: | |||
| 300 | + | a.status = StatusDone | |||
| 301 | + | a.activity = "done" | |||
| 302 | + | } | |||
| 303 | + | a.mu.Unlock() | |||
| 304 | + | close(a.done) | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | // List returns a snapshot of every agent in creation order. The | |||
| 308 | + | // returned slice is a copy; callers may iterate without holding the | |||
| 309 | + | // swarm lock. Agent fields are read under their own mutex during | |||
| 310 | + | // formatting in Snapshot. | |||
| 311 | + | func (f *Swarm) List() []*Agent { | |||
| 312 | + | f.mu.Lock() | |||
| 313 | + | defer f.mu.Unlock() | |||
| 314 | + | out := make([]*Agent, 0, len(f.order)) | |||
| 315 | + | for _, id := range f.order { | |||
| 316 | + | out = append(out, f.agents[id]) | |||
| 317 | + | } | |||
| 318 | + | return out | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | // Get returns the agent with the given (possibly truncated) id, or | |||
| 322 | + | // nil. Matching is prefix-based so the user can type the first few | |||
| 323 | + | // characters of a long id. | |||
| 324 | + | func (f *Swarm) Get(id string) *Agent { | |||
| 325 | + | f.mu.Lock() | |||
| 326 | + | defer f.mu.Unlock() | |||
| 327 | + | if a, ok := f.agents[id]; ok { | |||
| 328 | + | return a | |||
| 329 | + | } | |||
| 330 | + | // Prefix match. | |||
| 331 | + | var hits []*Agent | |||
| 332 | + | for k, a := range f.agents { | |||
| 333 | + | if strings.HasPrefix(k, id) { | |||
| 334 | + | hits = append(hits, a) | |||
| 335 | + | } | |||
| 336 | + | } | |||
| 337 | + | if len(hits) == 1 { | |||
| 338 | + | return hits[0] | |||
| 339 | + | } | |||
| 340 | + | return nil | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | // Stop cancels the agent's context. The Runner should observe the | |||
| 344 | + | // cancellation and return promptly; the goroutine then finalises the | |||
| 345 | + | // agent in StatusKilled. Also closes the supervisor-side inbox handle | |||
| 346 | + | // so any pending SendInput retries fail fast instead of dialing a | |||
| 347 | + | // socket about to be unlinked. | |||
| 348 | + | // | |||
| 349 | + | // Stop is a no-op for any agent that's not in a live runnable state | |||
| 350 | + | // — Done / Failed / Killed (already finalised) and Detached (no | |||
| 351 | + | // in-process runner; reloaded from disk). Calling Stop on a detached | |||
| 352 | + | // agent must not crash: buildDetachedAgent doesn't allocate a | |||
| 353 | + | // context/cancel pair because there's nothing to cancel. | |||
| 354 | + | func (f *Swarm) Stop(id string) error { | |||
| 355 | + | a := f.Get(id) | |||
| 356 | + | if a == nil { | |||
| 357 | + | return fmt.Errorf("no such agent %q", id) | |||
| 358 | + | } | |||
| 359 | + | a.mu.Lock() | |||
| 360 | + | switch a.status { | |||
| 361 | + | case StatusDone, StatusFailed, StatusKilled, StatusDetached: | |||
| 362 | + | a.mu.Unlock() | |||
| 363 | + | return nil | |||
| 364 | + | } | |||
| 365 | + | a.status = StatusKilled | |||
| 366 | + | a.activity = "stopped" | |||
| 367 | + | a.mu.Unlock() | |||
| 368 | + | // Belt-and-braces guard against the cancel-less case (detached | |||
| 369 | + | // agents skip this branch above, but a future code path that | |||
| 370 | + | // builds an Agent without a runner shouldn't crash the | |||
| 371 | + | // supervisor). | |||
| 372 | + | if a.cancel != nil { | |||
| 373 | + | a.cancel() | |||
| 374 | + | } | |||
| 375 | + | if a.inbox != nil { | |||
| 376 | + | _ = a.inbox.Close() | |||
| 377 | + | } | |||
| 378 | + | return nil | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | // StopAll cancels every running agent. Used on shutdown. | |||
| 382 | + | func (f *Swarm) StopAll() { | |||
| 383 | + | for _, a := range f.List() { | |||
| 384 | + | _ = f.Stop(a.ID) | |||
| 385 | + | } | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | // Remove tears down the worktree for a terminated agent. It is an | |||
| 389 | + | // error to remove an agent that's still running; call Stop first and | |||
| 390 | + | // wait for the status to settle. Detached agents (reloaded from | |||
| 391 | + | // disk) remove cleanly because they have no live runner racing for | |||
| 392 | + | // the same files. | |||
| 393 | + | func (f *Swarm) Remove(id string) error { | |||
| 394 | + | a := f.Get(id) | |||
| 395 | + | if a == nil { | |||
| 396 | + | return fmt.Errorf("no such agent %q", id) | |||
| 397 | + | } | |||
| 398 | + | a.mu.Lock() | |||
| 399 | + | st := a.status | |||
| 400 | + | dir := a.Dir | |||
| 401 | + | a.mu.Unlock() | |||
| 402 | + | if st == StatusRunning || st == StatusPending { | |||
| 403 | + | return fmt.Errorf("agent %s still %s", a.ID, st) | |||
| 404 | + | } | |||
| 405 | + | if err := f.cfg.Worktree.Remove(a.ID, dir); err != nil { | |||
| 406 | + | return err | |||
| 407 | + | } | |||
| 408 | + | // Best-effort cleanup of the per-agent state directory (meta.json, | |||
| 409 | + | // events.jsonl, session.json, in.sock if it's local). Worktree | |||
| 410 | + | // removal already succeeded; failing here would leave the user | |||
| 411 | + | // with no recourse, so swallow the error. | |||
| 412 | + | _ = os.RemoveAll(f.agentStateDir(a.ID)) | |||
| 413 | + | f.mu.Lock() | |||
| 414 | + | delete(f.agents, a.ID) | |||
| 415 | + | for i, k := range f.order { | |||
| 416 | + | if k == a.ID { | |||
| 417 | + | f.order = append(f.order[:i], f.order[i+1:]...) | |||
| 418 | + | break | |||
| 419 | + | } | |||
| 420 | + | } | |||
| 421 | + | f.mu.Unlock() | |||
| 422 | + | return nil | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | // Snapshot returns a read-only view of one agent. Safe for the TUI | |||
| 426 | + | // goroutine to call repeatedly; never blocks on the Runner. | |||
| 427 | + | type AgentSnapshot struct { | |||
| 428 | + | ID string | |||
| 429 | + | Task string | |||
| 430 | + | Branch string | |||
| 431 | + | Dir string | |||
| 432 | + | Status Status | |||
| 433 | + | Activity string | |||
| 434 | + | Started time.Time | |||
| 435 | + | Finished time.Time | |||
| 436 | + | Err string | |||
| 437 | + | Tail string // last few transcript lines, joined with "\n" | |||
| 438 | + | Lines []string // full transcript (already capped by Agent.appendTranscript) | |||
| 439 | + | ||||
| 440 | + | // Model and Provider expose the per-agent overrides set at | |||
| 441 | + | // Spawn time (empty when the agent inherits the child's default | |||
| 442 | + | // resolution). The dashboard surfaces these so the user can | |||
| 443 | + | // confirm which model an agent is running against. | |||
| 444 | + | Model string | |||
| 445 | + | Provider string | |||
| 446 | + | ||||
| 447 | + | // Paths to the agent's durable state. Surface them in the | |||
| 448 | + | // snapshot so the dashboard / /swarm open can read events.jsonl | |||
| 449 | + | // or resume the session without going back through the Agent. | |||
| 450 | + | InboxPath string | |||
| 451 | + | EventLogPath string | |||
| 452 | + | SessionPath string | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | // Snapshot copies the live agent state into a value the caller can | |||
| 456 | + | // inspect at leisure. | |||
| 457 | + | func (a *Agent) Snapshot() AgentSnapshot { | |||
| 458 | + | a.mu.Lock() | |||
| 459 | + | defer a.mu.Unlock() | |||
| 460 | + | tail := strings.Join(lastN(a.transcript, 6), "\n") | |||
| 461 | + | lines := make([]string, len(a.transcript)) | |||
| 462 | + | copy(lines, a.transcript) | |||
| 463 | + | errStr := "" | |||
| 464 | + | if a.lastErr != nil { | |||
| 465 | + | errStr = a.lastErr.Error() | |||
| 466 | + | } | |||
| 467 | + | return AgentSnapshot{ | |||
| 468 | + | ID: a.ID, Task: a.Task, Branch: a.Branch, Dir: a.Dir, | |||
| 469 | + | Status: a.status, Activity: a.activity, | |||
| 470 | + | Started: a.Started, Finished: a.finished, | |||
| 471 | + | Err: errStr, Tail: tail, Lines: lines, | |||
| 472 | + | Model: a.Model, | |||
| 473 | + | Provider: a.Provider, | |||
| 474 | + | InboxPath: a.InboxPath, | |||
| 475 | + | EventLogPath: a.EventLogPath, | |||
| 476 | + | SessionPath: a.SessionPath, | |||
| 477 | + | } | |||
| 478 | + | } | |||
| 479 | + | ||||
| 480 | + | // SnapshotAll returns snapshots of every agent in creation order, | |||
| 481 | + | // scoped to the active session when one is set. | |||
| 482 | + | // | |||
| 483 | + | // Scoping rules: | |||
| 484 | + | // - activeSession == "": no filter, every agent is returned | |||
| 485 | + | // (historical behaviour; used by tests and scripted callers). | |||
| 486 | + | // - activeSession != "": include only agents whose SessionID | |||
| 487 | + | // matches activeSession OR is empty. The empty-id pass-through | |||
| 488 | + | // keeps pre-upgrade agents (their meta.json was written before | |||
| 489 | + | // session_id existed) visible from any session so the user | |||
| 490 | + | // doesn't lose access after the schema bump. | |||
| 491 | + | func (f *Swarm) SnapshotAll() []AgentSnapshot { | |||
| 492 | + | agents := f.List() | |||
| 493 | + | f.mu.Lock() | |||
| 494 | + | active := f.activeSession | |||
| 495 | + | f.mu.Unlock() | |||
| 496 | + | ||||
| 497 | + | out := make([]AgentSnapshot, 0, len(agents)) | |||
| 498 | + | for _, a := range agents { | |||
| 499 | + | if active != "" && a.SessionID != "" && a.SessionID != active { | |||
| 500 | + | continue | |||
| 501 | + | } | |||
| 502 | + | out = append(out, a.Snapshot()) | |||
| 503 | + | } | |||
| 504 | + | // Sort by start time for a stable, deterministic listing. | |||
| 505 | + | sort.SliceStable(out, func(i, j int) bool { return out[i].Started.Before(out[j].Started) }) | |||
| 506 | + | return out | |||
| 507 | + | } | |||
| 508 | + | ||||
| 509 | + | // agentSink is the Sink the Swarm hands to each Runner. | |||
| 510 | + | type agentSink struct{ a *Agent } | |||
| 511 | + | ||||
| 512 | + | func (s agentSink) Activity(msg string) { s.a.setActivity(msg) } | |||
| 513 | + | func (s agentSink) Transcript(chunk string) { s.a.appendTranscript(chunk) } | |||
| 514 | + | ||||
| 515 | + | func truncate(s string, n int) string { | |||
| 516 | + | if len(s) <= n { | |||
| 517 | + | return s | |||
| 518 | + | } | |||
| 519 | + | return s[:n-1] + "…" | |||
| 520 | + | } | |||
| 521 | + | ||||
| 522 | + | func lastN(lines []string, n int) []string { | |||
| 523 | + | if len(lines) <= n { | |||
| 524 | + | return lines | |||
| 525 | + | } | |||
| 526 | + | return lines[len(lines)-n:] | |||
| 527 | + | } | |||
A internal/swarm/swarm_test.go +239 -0
| @@ -0,0 +1,239 @@ | |||||
| 1 | + | package swarm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "strings" | |||
| 8 | + | "sync" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // newTestSwarm builds a Swarm rooted in t.TempDir with the in-memory | |||
| 14 | + | // worktree and a Runner factory the test controls. Returns the swarm | |||
| 15 | + | // plus a slice of runners keyed by spawn order so tests can assert | |||
| 16 | + | // they were actually invoked. | |||
| 17 | + | func newTestSwarm(t *testing.T, mk func(a *Agent) Runner) *Swarm { | |||
| 18 | + | t.Helper() | |||
| 19 | + | root := t.TempDir() | |||
| 20 | + | return New(Config{ | |||
| 21 | + | Root: root, | |||
| 22 | + | RepoRoot: root, | |||
| 23 | + | Worktree: MemWorktree(filepath.Join(root, "worktrees")), | |||
| 24 | + | NewRunner: mk, | |||
| 25 | + | }) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestSpawnRunsAndCompletes(t *testing.T) { | |||
| 29 | + | ran := make(chan string, 1) | |||
| 30 | + | f := newTestSwarm(t, func(a *Agent) Runner { | |||
| 31 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { | |||
| 32 | + | ran <- a.Task | |||
| 33 | + | sink.Activity("hello") | |||
| 34 | + | sink.Transcript("line one") | |||
| 35 | + | sink.Transcript("line two") | |||
| 36 | + | return nil | |||
| 37 | + | }) | |||
| 38 | + | }) | |||
| 39 | + | a, err := f.Spawn(context.Background(), "do a thing") | |||
| 40 | + | if err != nil { | |||
| 41 | + | t.Fatal(err) | |||
| 42 | + | } | |||
| 43 | + | select { | |||
| 44 | + | case got := <-ran: | |||
| 45 | + | if got != "do a thing" { | |||
| 46 | + | t.Fatalf("runner got task %q; want %q", got, "do a thing") | |||
| 47 | + | } | |||
| 48 | + | case <-time.After(time.Second): | |||
| 49 | + | t.Fatal("runner did not start") | |||
| 50 | + | } | |||
| 51 | + | a.Wait() | |||
| 52 | + | if a.Status() != StatusDone { | |||
| 53 | + | t.Fatalf("status %s; want done", a.Status()) | |||
| 54 | + | } | |||
| 55 | + | if got := a.Transcript(); len(got) != 2 || got[0] != "line one" || got[1] != "line two" { | |||
| 56 | + | t.Fatalf("transcript = %q", got) | |||
| 57 | + | } | |||
| 58 | + | if !strings.Contains(a.ID, "do-a-thing") { | |||
| 59 | + | t.Fatalf("id %q missing slug", a.ID) | |||
| 60 | + | } | |||
| 61 | + | if a.Branch != "swarm/"+a.ID { | |||
| 62 | + | t.Fatalf("branch = %q", a.Branch) | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestSpawnEmptyTaskFails(t *testing.T) { | |||
| 67 | + | f := newTestSwarm(t, func(a *Agent) Runner { | |||
| 68 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | |||
| 69 | + | }) | |||
| 70 | + | if _, err := f.Spawn(context.Background(), " "); err == nil { | |||
| 71 | + | t.Fatal("expected error on empty task") | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestRunnerErrorMarksFailed(t *testing.T) { | |||
| 76 | + | wantErr := errors.New("boom") | |||
| 77 | + | f := newTestSwarm(t, func(a *Agent) Runner { | |||
| 78 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { return wantErr }) | |||
| 79 | + | }) | |||
| 80 | + | a, _ := f.Spawn(context.Background(), "explode") | |||
| 81 | + | a.Wait() | |||
| 82 | + | if a.Status() != StatusFailed { | |||
| 83 | + | t.Fatalf("status %s; want failed", a.Status()) | |||
| 84 | + | } | |||
| 85 | + | if !errors.Is(a.Err(), wantErr) { | |||
| 86 | + | t.Fatalf("err = %v", a.Err()) | |||
| 87 | + | } | |||
| 88 | + | if !strings.Contains(a.Activity(), "boom") { | |||
| 89 | + | t.Fatalf("activity = %q; want it to mention the error", a.Activity()) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestStopCancelsRunningAgent(t *testing.T) { | |||
| 94 | + | started := make(chan struct{}) | |||
| 95 | + | f := newTestSwarm(t, func(a *Agent) Runner { | |||
| 96 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { | |||
| 97 | + | close(started) | |||
| 98 | + | <-ctx.Done() | |||
| 99 | + | return ctx.Err() | |||
| 100 | + | }) | |||
| 101 | + | }) | |||
| 102 | + | a, _ := f.Spawn(context.Background(), "long") | |||
| 103 | + | <-started | |||
| 104 | + | if err := f.Stop(a.ID); err != nil { | |||
| 105 | + | t.Fatal(err) | |||
| 106 | + | } | |||
| 107 | + | a.Wait() | |||
| 108 | + | if a.Status() != StatusKilled { | |||
| 109 | + | t.Fatalf("status = %s; want killed", a.Status()) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestStopAfterDoneIsNoop(t *testing.T) { | |||
| 114 | + | f := newTestSwarm(t, func(a *Agent) Runner { | |||
| 115 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | |||
| 116 | + | }) | |||
| 117 | + | a, _ := f.Spawn(context.Background(), "quick") | |||
| 118 | + | a.Wait() | |||
| 119 | + | if err := f.Stop(a.ID); err != nil { | |||
| 120 | + | t.Fatalf("stop after done: %v", err) | |||
| 121 | + | } | |||
| 122 | + | if a.Status() != StatusDone { | |||
| 123 | + | t.Fatalf("status flipped to %s", a.Status()) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func TestGetPrefixMatch(t *testing.T) { | |||
| 128 | + | f := newTestSwarm(t, func(a *Agent) Runner { | |||
| 129 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | |||
| 130 | + | }) | |||
| 131 | + | a, _ := f.Spawn(context.Background(), "alpha task") | |||
| 132 | + | a.Wait() | |||
| 133 | + | // Full id works. | |||
| 134 | + | if got := f.Get(a.ID); got != a { | |||
| 135 | + | t.Fatal("get by full id failed") | |||
| 136 | + | } | |||
| 137 | + | // Slug prefix works as long as it's unique. | |||
| 138 | + | if got := f.Get("alpha"); got != a { | |||
| 139 | + | t.Fatal("get by prefix failed") | |||
| 140 | + | } | |||
| 141 | + | // Bogus id returns nil. | |||
| 142 | + | if got := f.Get("zzz-nope"); got != nil { | |||
| 143 | + | t.Fatalf("expected nil; got %#v", got) | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func TestRemoveRequiresTerminalState(t *testing.T) { | |||
| 148 | + | hold := make(chan struct{}) | |||
| 149 | + | f := newTestSwarm(t, func(a *Agent) Runner { | |||
| 150 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { | |||
| 151 | + | <-hold | |||
| 152 | + | return nil | |||
| 153 | + | }) | |||
| 154 | + | }) | |||
| 155 | + | a, _ := f.Spawn(context.Background(), "still going") | |||
| 156 | + | // Wait for run goroutine to flip to running. | |||
| 157 | + | for i := 0; i < 100 && a.Status() != StatusRunning; i++ { | |||
| 158 | + | time.Sleep(time.Millisecond) | |||
| 159 | + | } | |||
| 160 | + | if err := f.Remove(a.ID); err == nil { | |||
| 161 | + | t.Fatal("remove of running agent should fail") | |||
| 162 | + | } | |||
| 163 | + | close(hold) | |||
| 164 | + | a.Wait() | |||
| 165 | + | if err := f.Remove(a.ID); err != nil { | |||
| 166 | + | t.Fatalf("remove after done: %v", err) | |||
| 167 | + | } | |||
| 168 | + | if got := f.Get(a.ID); got != nil { | |||
| 169 | + | t.Fatal("agent still present after remove") | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func TestSnapshotIsStableAcrossAccess(t *testing.T) { | |||
| 174 | + | var wg sync.WaitGroup | |||
| 175 | + | wg.Add(1) | |||
| 176 | + | f := newTestSwarm(t, func(a *Agent) Runner { | |||
| 177 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { | |||
| 178 | + | defer wg.Done() | |||
| 179 | + | for i := 0; i < 200; i++ { | |||
| 180 | + | sink.Transcript("chunk") | |||
| 181 | + | sink.Activity("step") | |||
| 182 | + | } | |||
| 183 | + | return nil | |||
| 184 | + | }) | |||
| 185 | + | }) | |||
| 186 | + | a, _ := f.Spawn(context.Background(), "race") | |||
| 187 | + | // Hammer Snapshot while the runner is writing; the -race detector | |||
| 188 | + | // is the real assertion here. | |||
| 189 | + | stop := make(chan struct{}) | |||
| 190 | + | go func() { | |||
| 191 | + | for { | |||
| 192 | + | select { | |||
| 193 | + | case <-stop: | |||
| 194 | + | return | |||
| 195 | + | default: | |||
| 196 | + | _ = a.Snapshot() | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | }() | |||
| 200 | + | wg.Wait() | |||
| 201 | + | a.Wait() | |||
| 202 | + | close(stop) | |||
| 203 | + | if a.Status() != StatusDone { | |||
| 204 | + | t.Fatalf("status = %s", a.Status()) | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func TestTaskSlug(t *testing.T) { | |||
| 209 | + | cases := map[string]string{ | |||
| 210 | + | "fix the login form": "fix-the-login-form", | |||
| 211 | + | " weird --- spaces!! ": "weird-spaces", | |||
| 212 | + | "": "agent", | |||
| 213 | + | "a-very-long-task-name-that-overflows": "a-very-long-task-name-th", | |||
| 214 | + | } | |||
| 215 | + | for in, want := range cases { | |||
| 216 | + | if got := taskSlug(in); got != want { | |||
| 217 | + | t.Errorf("taskSlug(%q) = %q; want %q", in, got, want) | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func TestSnapshotAllSorted(t *testing.T) { | |||
| 223 | + | f := newTestSwarm(t, func(a *Agent) Runner { | |||
| 224 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | |||
| 225 | + | }) | |||
| 226 | + | a1, _ := f.Spawn(context.Background(), "first") | |||
| 227 | + | // Force second spawn into a later nanosecond bucket. | |||
| 228 | + | time.Sleep(2 * time.Millisecond) | |||
| 229 | + | a2, _ := f.Spawn(context.Background(), "second") | |||
| 230 | + | a1.Wait() | |||
| 231 | + | a2.Wait() | |||
| 232 | + | snaps := f.SnapshotAll() | |||
| 233 | + | if len(snaps) != 2 { | |||
| 234 | + | t.Fatalf("want 2 snapshots; got %d", len(snaps)) | |||
| 235 | + | } | |||
| 236 | + | if !snaps[0].Started.Before(snaps[1].Started) && !snaps[0].Started.Equal(snaps[1].Started) { | |||
| 237 | + | t.Fatal("snapshots not in spawn order") | |||
| 238 | + | } | |||
| 239 | + | } | |||
A internal/swarm/testdata/cmd/stubchild/main.go +145 -0
| @@ -0,0 +1,145 @@ | |||||
| 1 | + | // stubchild is a minimal swarm-agent stand-in used by the runner | |||
| 2 | + | // end-to-end test. It speaks the daemon protocol the real zot | |||
| 3 | + | // binary will implement next: | |||
| 4 | + | // | |||
| 5 | + | // - parses --swarm-agent <path>, --session <path>, --cwd <path>, | |||
| 6 | + | // and an optional positional task, | |||
| 7 | + | // - opens a unix-socket listener at the inbox path so the | |||
| 8 | + | // supervisor's Inbox can dial through, | |||
| 9 | + | // - emits well-formed JSONL events on stdout that the supervisor | |||
| 10 | + | // mirrors into the durable event log, | |||
| 11 | + | // - reads one line per supervisor message and echoes it back as | |||
| 12 | + | // a "user_message" event followed by a fake "assistant_message" | |||
| 13 | + | // so the runner sees the dialogue happen. | |||
| 14 | + | // | |||
| 15 | + | // The runner test compiles this binary into a tempdir, points | |||
| 16 | + | // swarm's execRunner at it via Command, and asserts the events | |||
| 17 | + | // flow through correctly without needing the full zot model | |||
| 18 | + | // machinery. | |||
| 19 | + | package main | |||
| 20 | + | ||||
| 21 | + | import ( | |||
| 22 | + | "bufio" | |||
| 23 | + | "encoding/json" | |||
| 24 | + | "flag" | |||
| 25 | + | "fmt" | |||
| 26 | + | "net" | |||
| 27 | + | "os" | |||
| 28 | + | "sync" | |||
| 29 | + | "time" | |||
| 30 | + | ) | |||
| 31 | + | ||||
| 32 | + | func main() { | |||
| 33 | + | var ( | |||
| 34 | + | inboxPath string | |||
| 35 | + | sessionPath string | |||
| 36 | + | cwd string | |||
| 37 | + | ) | |||
| 38 | + | flag.StringVar(&inboxPath, "swarm-agent", "", "inbox socket path") | |||
| 39 | + | flag.StringVar(&sessionPath, "session", "", "session file path") | |||
| 40 | + | flag.StringVar(&cwd, "cwd", "", "working directory") | |||
| 41 | + | flag.Parse() | |||
| 42 | + | ||||
| 43 | + | if inboxPath == "" { | |||
| 44 | + | fmt.Fprintln(os.Stderr, "stubchild: --swarm-agent required") | |||
| 45 | + | os.Exit(2) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | emit := newEmitter() | |||
| 49 | + | ||||
| 50 | + | // Open the inbox listener BEFORE doing any work so the supervisor | |||
| 51 | + | // can dial through as soon as it sees the agent_ready event. If we | |||
| 52 | + | // processed the initial task first, the parent's SendInput call | |||
| 53 | + | // would race the stub's net.Listen and trip ErrNotReady. | |||
| 54 | + | ln, err := net.Listen("unix", inboxPath) | |||
| 55 | + | if err != nil { | |||
| 56 | + | emit("error", map[string]any{"message": err.Error()}) | |||
| 57 | + | os.Exit(1) | |||
| 58 | + | } | |||
| 59 | + | defer os.Remove(inboxPath) | |||
| 60 | + | ||||
| 61 | + | emit("agent_ready", map[string]any{ | |||
| 62 | + | "inbox": inboxPath, | |||
| 63 | + | "session": sessionPath, | |||
| 64 | + | "cwd": cwd, | |||
| 65 | + | }) | |||
| 66 | + | ||||
| 67 | + | // Initial task lives in the positional. Process it as the first | |||
| 68 | + | // user turn so the supervisor's "initial task" path is exercised. | |||
| 69 | + | if task := flag.Arg(0); task != "" { | |||
| 70 | + | runTurn(emit, task, 0) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | turn := 1 | |||
| 74 | + | for { | |||
| 75 | + | c, err := ln.Accept() | |||
| 76 | + | if err != nil { | |||
| 77 | + | return | |||
| 78 | + | } | |||
| 79 | + | br := bufio.NewReader(c) | |||
| 80 | + | for { | |||
| 81 | + | line, err := br.ReadString('\n') | |||
| 82 | + | if line != "" { | |||
| 83 | + | msg := trimNL(line) | |||
| 84 | + | switch { | |||
| 85 | + | case msg == "shutdown": | |||
| 86 | + | emit("agent_stopped", map[string]any{"reason": "shutdown"}) | |||
| 87 | + | _ = c.Close() | |||
| 88 | + | return | |||
| 89 | + | case msg == "cancel": | |||
| 90 | + | emit("turn_end", map[string]any{"stop": "cancelled"}) | |||
| 91 | + | case len(msg) > 5 && msg[:5] == "user ": | |||
| 92 | + | runTurn(emit, msg[5:], turn) | |||
| 93 | + | turn++ | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | if err != nil { | |||
| 97 | + | _ = c.Close() | |||
| 98 | + | break | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // runTurn fakes one model round-trip: turn_start, an echoed | |||
| 105 | + | // user_message, an assistant_message that echoes back, and a | |||
| 106 | + | // turn_end. Enough event variety that applyEventToSink has | |||
| 107 | + | // something to interpret. | |||
| 108 | + | func runTurn(emit emitter, text string, step int) { | |||
| 109 | + | emit("turn_start", map[string]any{"step": step}) | |||
| 110 | + | emit("user_message", map[string]any{ | |||
| 111 | + | "content": []any{ | |||
| 112 | + | map[string]any{"type": "text", "text": text}, | |||
| 113 | + | }, | |||
| 114 | + | }) | |||
| 115 | + | emit("assistant_message", map[string]any{ | |||
| 116 | + | "content": []any{ | |||
| 117 | + | map[string]any{"type": "text", "text": "echo: " + text}, | |||
| 118 | + | }, | |||
| 119 | + | }) | |||
| 120 | + | emit("turn_end", map[string]any{"stop": "end"}) | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | type emitter = func(string, map[string]any) | |||
| 124 | + | ||||
| 125 | + | func newEmitter() emitter { | |||
| 126 | + | var mu sync.Mutex | |||
| 127 | + | enc := json.NewEncoder(os.Stdout) | |||
| 128 | + | return func(typ string, data map[string]any) { | |||
| 129 | + | mu.Lock() | |||
| 130 | + | defer mu.Unlock() | |||
| 131 | + | if data == nil { | |||
| 132 | + | data = map[string]any{} | |||
| 133 | + | } | |||
| 134 | + | data["type"] = typ | |||
| 135 | + | data["time"] = time.Now().Format(time.RFC3339Nano) | |||
| 136 | + | _ = enc.Encode(data) | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func trimNL(s string) string { | |||
| 141 | + | for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') { | |||
| 142 | + | s = s[:len(s)-1] | |||
| 143 | + | } | |||
| 144 | + | return s | |||
| 145 | + | } | |||
A internal/swarm/worktree.go +110 -0
| @@ -0,0 +1,110 @@ | |||||
| 1 | + | package swarm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "os/exec" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "strings" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // gitWorktree creates per-agent working directories via `git worktree | |||
| 13 | + | // add`. If the repo path isn't a git repository it falls back to a | |||
| 14 | + | // plain mkdir under root/<id>, which is still useful for tests and | |||
| 15 | + | // for running agents in non-git directories. | |||
| 16 | + | type gitWorktree struct { | |||
| 17 | + | root string // <swarm-root>/worktrees | |||
| 18 | + | repo string // user CWD; git operations resolve from here | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // Create makes a new worktree on branch off of HEAD. The branch is | |||
| 22 | + | // created if it doesn't already exist. Returns the absolute path. | |||
| 23 | + | func (g *gitWorktree) Create(id, branch, base string) (string, error) { | |||
| 24 | + | dir := filepath.Join(g.root, id) | |||
| 25 | + | if err := os.MkdirAll(g.root, 0o755); err != nil { | |||
| 26 | + | return "", err | |||
| 27 | + | } | |||
| 28 | + | if !isGitRepo(g.repo) { | |||
| 29 | + | // Non-git fallback: just a fresh directory. The agent works | |||
| 30 | + | // here; nothing is staged for merge. | |||
| 31 | + | if err := os.MkdirAll(dir, 0o755); err != nil { | |||
| 32 | + | return "", err | |||
| 33 | + | } | |||
| 34 | + | return dir, nil | |||
| 35 | + | } | |||
| 36 | + | cmd := exec.Command("git", "worktree", "add", "-b", branch, dir) | |||
| 37 | + | cmd.Dir = g.repo | |||
| 38 | + | out, err := cmd.CombinedOutput() | |||
| 39 | + | if err != nil { | |||
| 40 | + | // If the branch already exists (e.g. a leftover from a | |||
| 41 | + | // previous run), retry without -b so the user doesn't need | |||
| 42 | + | // to clean up by hand. | |||
| 43 | + | if strings.Contains(string(out), "already exists") { | |||
| 44 | + | cmd = exec.Command("git", "worktree", "add", dir, branch) | |||
| 45 | + | cmd.Dir = g.repo | |||
| 46 | + | out, err = cmd.CombinedOutput() | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | if err != nil { | |||
| 50 | + | return "", fmt.Errorf("git worktree add: %s: %w", strings.TrimSpace(string(out)), err) | |||
| 51 | + | } | |||
| 52 | + | return dir, nil | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // Remove deletes the worktree. Uses `git worktree remove --force` so | |||
| 56 | + | // dirty trees don't block cleanup; the user is the one running this | |||
| 57 | + | // command explicitly. | |||
| 58 | + | func (g *gitWorktree) Remove(id, dir string) error { | |||
| 59 | + | if dir == "" { | |||
| 60 | + | return errors.New("empty worktree dir") | |||
| 61 | + | } | |||
| 62 | + | if !isGitRepo(g.repo) { | |||
| 63 | + | return os.RemoveAll(dir) | |||
| 64 | + | } | |||
| 65 | + | cmd := exec.Command("git", "worktree", "remove", "--force", dir) | |||
| 66 | + | cmd.Dir = g.repo | |||
| 67 | + | out, err := cmd.CombinedOutput() | |||
| 68 | + | if err != nil { | |||
| 69 | + | // `git worktree remove` refuses to operate on an unknown path; | |||
| 70 | + | // in that case fall back to a plain rmdir so the user can | |||
| 71 | + | // always clean up. | |||
| 72 | + | if strings.Contains(string(out), "not a working tree") { | |||
| 73 | + | return os.RemoveAll(dir) | |||
| 74 | + | } | |||
| 75 | + | return fmt.Errorf("git worktree remove: %s: %w", strings.TrimSpace(string(out)), err) | |||
| 76 | + | } | |||
| 77 | + | return nil | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func isGitRepo(dir string) bool { | |||
| 81 | + | if dir == "" { | |||
| 82 | + | return false | |||
| 83 | + | } | |||
| 84 | + | cmd := exec.Command("git", "-C", dir, "rev-parse", "--git-dir") | |||
| 85 | + | return cmd.Run() == nil | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // memWorktree is a WorktreeManager used by tests. It does no git | |||
| 89 | + | // operations; it just makes a fresh subdirectory under root. | |||
| 90 | + | type memWorktree struct{ root string } | |||
| 91 | + | ||||
| 92 | + | // MemWorktree returns a WorktreeManager that creates plain | |||
| 93 | + | // subdirectories. Exposed for tests and for callers running zot | |||
| 94 | + | // outside a git repository. | |||
| 95 | + | func MemWorktree(root string) WorktreeManager { return &memWorktree{root: root} } | |||
| 96 | + | ||||
| 97 | + | func (m *memWorktree) Create(id, branch, base string) (string, error) { | |||
| 98 | + | dir := filepath.Join(m.root, id) | |||
| 99 | + | if err := os.MkdirAll(dir, 0o755); err != nil { | |||
| 100 | + | return "", err | |||
| 101 | + | } | |||
| 102 | + | return dir, nil | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func (m *memWorktree) Remove(id, dir string) error { | |||
| 106 | + | if dir == "" { | |||
| 107 | + | return errors.New("empty worktree dir") | |||
| 108 | + | } | |||
| 109 | + | return os.RemoveAll(dir) | |||
| 110 | + | } | |||
M internal/tui/render.go +30 -0
| @@ -382,6 +382,20 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 382 | cursorTargetRow = len(chatFrame) + cursorBottomRow | 382 | cursorTargetRow = len(chatFrame) + cursorBottomRow | ||
| 383 | } | 383 | } | ||
| 384 | 384 | ||||
| 385 | + | // Idle no-op fast path. When the buffer AND the cursor position | |||
| 386 | + | // haven't changed since the last DrawLog, emit nothing. The | |||
| 387 | + | // alternative — always writing SeqHideCursor + cursor-position + | |||
| 388 | + | // SeqShowCursor — resets the terminal's cursor blink timer on | |||
| 389 | + | // every tick. At our 120ms animation cadence that means the | |||
| 390 | + | // caret in an idle dialog editor (e.g. an open swarm transcript | |||
| 391 | + | // for an agent that's currently idle) appears as a solid block | |||
| 392 | + | // that never blinks, because we keep "showing" it before the | |||
| 393 | + | // terminal can blink it off. Bailing out here lets the OS run | |||
| 394 | + | // its blink cycle. | |||
| 395 | + | if r.logInit && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) { | |||
| 396 | + | return | |||
| 397 | + | } | |||
| 398 | + | ||||
| 385 | var w strings.Builder | 399 | var w strings.Builder | ||
| 386 | w.WriteString(SeqSynchronizedOn) | 400 | w.WriteString(SeqSynchronizedOn) | ||
| 387 | w.WriteString(SeqHideCursor) | 401 | w.WriteString(SeqHideCursor) | ||
| @@ -612,6 +626,22 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 612 | r.cursorCol = cursorCol | 626 | r.cursorCol = cursorCol | ||
| 613 | } | 627 | } | ||
| 614 | 628 | ||||
| 629 | + | // sameLines reports whether two []string have the exact same | |||
| 630 | + | // length and per-row contents. Used by DrawLog's idle no-op fast | |||
| 631 | + | // path; cheap enough at our frame rates and far simpler than | |||
| 632 | + | // hashing every byte. | |||
| 633 | + | func sameLines(a, b []string) bool { | |||
| 634 | + | if len(a) != len(b) { | |||
| 635 | + | return false | |||
| 636 | + | } | |||
| 637 | + | for i := range a { | |||
| 638 | + | if a[i] != b[i] { | |||
| 639 | + | return false | |||
| 640 | + | } | |||
| 641 | + | } | |||
| 642 | + | return true | |||
| 643 | + | } | |||
| 644 | + | ||||
| 615 | func writeBlock(w *strings.Builder, lines []string) { | 645 | func writeBlock(w *strings.Builder, lines []string) { | ||
| 616 | for i, line := range lines { | 646 | for i, line := range lines { | ||
| 617 | w.WriteString("\x1b[0m") | 647 | w.WriteString("\x1b[0m") | ||
A internal/tui/render_test.go +103 -0
| @@ -0,0 +1,103 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // TestDrawLogIdleNoOpEmitsNothing pins the cursor-blink fix: when | |||
| 10 | + | // DrawLog is called with the exact same buffer and cursor position | |||
| 11 | + | // as the previous call, it must emit ZERO bytes. | |||
| 12 | + | // | |||
| 13 | + | // The bug this regresses: at the 120ms animation tick the renderer | |||
| 14 | + | // used to always emit SeqHideCursor + cursor-position + | |||
| 15 | + | // SeqShowCursor, which resets the terminal's blink timer. Faster | |||
| 16 | + | // than the OS blink interval, so an idle dialog editor (e.g. a | |||
| 17 | + | // re-opened swarm transcript whose agent isn't producing output) | |||
| 18 | + | // rendered the caret as a solid non-blinking block. | |||
| 19 | + | // | |||
| 20 | + | // With the no-op fast path the renderer leaves the screen alone | |||
| 21 | + | // on idle frames, letting the terminal run its own blink cycle. | |||
| 22 | + | func TestDrawLogIdleNoOpEmitsNothing(t *testing.T) { | |||
| 23 | + | var buf bytes.Buffer | |||
| 24 | + | r := NewRenderer(&buf) | |||
| 25 | + | r.Resize(80, 24) | |||
| 26 | + | ||||
| 27 | + | chat := []string{"hello", "world"} | |||
| 28 | + | bottom := []string{"▌ "} | |||
| 29 | + | // First draw populates the renderer's cached buffer. | |||
| 30 | + | r.DrawLog(chat, bottom, 0, 2) | |||
| 31 | + | first := buf.Len() | |||
| 32 | + | if first == 0 { | |||
| 33 | + | t.Fatal("first DrawLog wrote nothing; setup is broken") | |||
| 34 | + | } | |||
| 35 | + | buf.Reset() | |||
| 36 | + | ||||
| 37 | + | // Identical second draw: same content, same cursor placement. | |||
| 38 | + | r.DrawLog(chat, bottom, 0, 2) | |||
| 39 | + | if buf.Len() != 0 { | |||
| 40 | + | t.Fatalf("idle re-draw emitted %d bytes; expected 0 so terminal blink keeps ticking\n%q", | |||
| 41 | + | buf.Len(), buf.String()) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // TestDrawLogContentChangeBreaksFastPath proves the no-op fast path | |||
| 46 | + | // only fires when nothing changed. A buffer mutation must still | |||
| 47 | + | // produce output, otherwise streaming agent replies would freeze on | |||
| 48 | + | // screen. | |||
| 49 | + | func TestDrawLogContentChangeBreaksFastPath(t *testing.T) { | |||
| 50 | + | var buf bytes.Buffer | |||
| 51 | + | r := NewRenderer(&buf) | |||
| 52 | + | r.Resize(80, 24) | |||
| 53 | + | ||||
| 54 | + | r.DrawLog([]string{"hello"}, []string{"▌ "}, 0, 2) | |||
| 55 | + | buf.Reset() | |||
| 56 | + | ||||
| 57 | + | // New chat row lands. | |||
| 58 | + | r.DrawLog([]string{"hello", "world"}, []string{"▌ "}, 0, 2) | |||
| 59 | + | if buf.Len() == 0 { | |||
| 60 | + | t.Fatal("content change suppressed by fast path; streaming output would freeze") | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // TestDrawLogCursorMoveBreaksFastPath proves a cursor-only change | |||
| 65 | + | // (no buffer change) still produces output. Without this, typing in | |||
| 66 | + | // the editor would visually move the caret but the terminal would | |||
| 67 | + | // keep drawing it at the old column. | |||
| 68 | + | func TestDrawLogCursorMoveBreaksFastPath(t *testing.T) { | |||
| 69 | + | var buf bytes.Buffer | |||
| 70 | + | r := NewRenderer(&buf) | |||
| 71 | + | r.Resize(80, 24) | |||
| 72 | + | ||||
| 73 | + | r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 2) | |||
| 74 | + | buf.Reset() | |||
| 75 | + | ||||
| 76 | + | // Same buffer, different cursor column. | |||
| 77 | + | r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 3) | |||
| 78 | + | if buf.Len() == 0 { | |||
| 79 | + | t.Fatal("cursor-only change suppressed by fast path; caret would lag behind typing") | |||
| 80 | + | } | |||
| 81 | + | // And the emitted bytes must at least reposition the cursor. | |||
| 82 | + | if !strings.Contains(buf.String(), "\x1b[") { | |||
| 83 | + | t.Errorf("cursor move emission missing CSI escapes: %q", buf.String()) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // TestDrawLogResizeForcesFullRedraw confirms a resize invalidates | |||
| 88 | + | // the cache so the next DrawLog with identical inputs still emits. | |||
| 89 | + | // Resize sets logInit=false; without that, a resize followed by an | |||
| 90 | + | // identical buffer would falsely no-op and leave a stale frame. | |||
| 91 | + | func TestDrawLogResizeForcesFullRedraw(t *testing.T) { | |||
| 92 | + | var buf bytes.Buffer | |||
| 93 | + | r := NewRenderer(&buf) | |||
| 94 | + | r.Resize(80, 24) | |||
| 95 | + | r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 2) | |||
| 96 | + | buf.Reset() | |||
| 97 | + | ||||
| 98 | + | r.Resize(100, 30) | |||
| 99 | + | r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 2) | |||
| 100 | + | if buf.Len() == 0 { | |||
| 101 | + | t.Fatal("post-resize redraw skipped; the new frame would never reach the terminal") | |||
| 102 | + | } | |||
| 103 | + | } | |||