merge(main): integrate latest main
37 files · +2560 -178
M .github/workflows/ci.yml +4 -12
| @@ -47,22 +47,14 @@ jobs: | |||
| 47 | 47 | - name: go test | |
| 48 | 48 | run: go test -race ./... | |
| 49 | 49 | ||
| 50 | - | lint: | |
| 51 | - | name: lint | |
| 52 | - | runs-on: ubuntu-latest | |
| 53 | - | steps: | |
| 54 | - | - uses: actions/checkout@v4 | |
| 55 | - | ||
| 50 | + | # Keep Linux-only quality checks in the Ubuntu test job so CI needs | |
| 51 | + | # only the three platform runners. | |
| 56 | 52 | - name: release-version policy | |
| 53 | + | if: matrix.os == 'ubuntu-latest' | |
| 57 | 54 | run: bash .github/scripts/next-release-tag_test.sh | |
| 58 | 55 | ||
| 59 | - | - name: set up go | |
| 60 | - | uses: actions/setup-go@v7 | |
| 61 | - | with: | |
| 62 | - | go-version: "1.26" | |
| 63 | - | cache: true | |
| 64 | - | ||
| 65 | 56 | - name: golangci-lint | |
| 57 | + | if: matrix.os == 'ubuntu-latest' | |
| 66 | 58 | uses: golangci/golangci-lint-action@v9.3.0 | |
| 67 | 59 | with: | |
| 68 | 60 | version: v2.12.2 | |
| @@ -47,22 +47,14 @@ jobs: | |||||
| 47 | - name: go test | 47 | - name: go test | ||
| 48 | run: go test -race ./... | 48 | run: go test -race ./... | ||
| 49 | 49 | ||||
| 50 | - | lint: | 50 | + | # Keep Linux-only quality checks in the Ubuntu test job so CI needs |
| 51 | - | name: lint | 51 | + | # only the three platform runners. |
| 52 | - | runs-on: ubuntu-latest | |||
| 53 | - | steps: | |||
| 54 | - | - uses: actions/checkout@v4 | |||
| 55 | - | ||||
| 56 | - name: release-version policy | 52 | - name: release-version policy | ||
| 53 | + | if: matrix.os == 'ubuntu-latest' | |||
| 57 | run: bash .github/scripts/next-release-tag_test.sh | 54 | run: bash .github/scripts/next-release-tag_test.sh | ||
| 58 | 55 | ||||
| 59 | - | - name: set up go | |||
| 60 | - | uses: actions/setup-go@v7 | |||
| 61 | - | with: | |||
| 62 | - | go-version: "1.26" | |||
| 63 | - | cache: true | |||
| 64 | - | ||||
| 65 | - name: golangci-lint | 56 | - name: golangci-lint | ||
| 57 | + | if: matrix.os == 'ubuntu-latest' | |||
| 66 | uses: golangci/golangci-lint-action@v9.3.0 | 58 | uses: golangci/golangci-lint-action@v9.3.0 | ||
| 67 | with: | 59 | with: | ||
| 68 | version: v2.12.2 | 60 | version: v2.12.2 | ||
M .gitignore +1 -0
| @@ -76,3 +76,4 @@ tmp/ | |||
| 76 | 76 | .codereview/ | |
| 77 | 77 | docs/superpowers/ | |
| 78 | 78 | npm/ | |
| 79 | + | /.worktrees/ | |
| @@ -76,3 +76,4 @@ tmp/ | |||||
| 76 | .codereview/ | 76 | .codereview/ | ||
| 77 | docs/superpowers/ | 77 | docs/superpowers/ | ||
| 78 | npm/ | 78 | npm/ | ||
| 79 | + | /.worktrees/ | |||
M README.md +13 -11
| @@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |||
| 9 | 9 | ||
| 10 | 10 | - one static binary. | |
| 11 | 11 | - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models. | |
| 12 | - | - six core built-in tools (read, write, edit, bash, lsp, and web_search); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries. | |
| 12 | + | - seven core built-in tools (read, write, edit, bash, create_worktree, lsp, and web_search); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries. | |
| 13 | 13 | - three run modes (interactive tui, print, json). | |
| 14 | 14 | - built-in telegram bot. | |
| 15 | 15 | - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md). | |
| @@ -233,10 +233,11 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||
| 233 | 233 | - `write`: create or overwrite files, making parent directories as needed. | |
| 234 | 234 | - `edit`: one or more exact-match replacements in an existing file. | |
| 235 | 235 | - `bash`: run a command in the session cwd with merged stdout/stderr and a timeout. On Unix, zut uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable. | |
| 236 | + | - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path. | |
| 236 | 237 | - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md). | |
| 237 | 238 | - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md). | |
| 238 | 239 | ||
| 239 | - | When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits refuse paths outside the session cwd. Jail does not sandbox web-search network egress. | |
| 240 | + | When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress. | |
| 240 | 241 | ||
| 241 | 242 | ## Modes | |
| 242 | 243 | ||
| @@ -279,6 +280,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||
| 279 | 280 | | `/logout [provider]` | Clear credentials for any logged-in provider, or all when omitted. `/logout openai-codex` clears ChatGPT/Codex subscription auth while preserving a public OpenAI API key; `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zut again. | | |
| 280 | 281 | | `/model` | Pick a model from a list (or `/model <id>` to set directly). | | |
| 281 | 282 | | `/reasoning` | Set the reasoning level for subsequent model calls. | | |
| 283 | + | | `/fast` | Toggle fast mode for subsequent model calls. | | |
| 282 | 284 | | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. | | |
| 283 | 285 | | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. | | |
| 284 | 286 | | `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. | | |
| @@ -293,7 +295,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||
| 293 | 295 | | `/unjail` | Allow tools to touch paths outside again. | | |
| 294 | 296 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | |
| 295 | 297 | | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. | | |
| 296 | - | | `/settings` | Change persistent settings, including web search, inline images, terminal alerts, AI terminal titles, auto-subagents, Ponytail coding mode, fast mode, main/sub-agent LSP access, and the auto-compact threshold. Saved to `$ZUT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. | | |
| 298 | + | | `/settings` | Change persistent settings, including web search, inline images, terminal alerts, AI terminal titles, auto-subagents, Ponytail coding mode, fast mode, main/sub-agent LSP access, and the auto-compact threshold. `/fast` is a shortcut for the fast-mode toggle. Saved to `$ZUT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. | | |
| 297 | 299 | | `/clear` | Clear the chat transcript. | | |
| 298 | 300 | | `/exit` | Exit zut. | | |
| 299 | 301 | ||
| @@ -380,7 +382,7 @@ Background subagents that run alongside your main session. Each one is a separat | |||
| 380 | 382 | ||
| 381 | 383 | **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back. | |
| 382 | 384 | ||
| 383 | - | **Persistence across zut restarts** — every spawn writes a durable manifest, append-only event log, session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. On the next `zut` launch they show up as **detached**; use `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task. | |
| 385 | + | **Persistence across zut restarts** — every spawn writes a durable manifest, lifecycle summary, append-only event log, session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. On the next `zut` launch the compact summary restores each agent as **detached** without replaying its full history. Open its transcript to hydrate the durable event log, then use `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task. | |
| 384 | 386 | ||
| 385 | 387 | **Where state lives** — per-agent manifests, session files, events, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup. | |
| 386 | 388 | ||
| @@ -402,10 +404,10 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 402 | 404 | - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zut: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`. | |
| 403 | 405 | - **auto-subagents** — let the main agent spawn background sub-agents in parallel via `subagent_spawn` and query live state via `subagent_status`. Off by default. The tools accept named profiles, reasoning, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion updates still arrive through the auto-subagents watcher. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details. | |
| 404 | 406 | - **Ponytail coding mode** — include compact engineering guidance in each resolved system prompt; the guidance tells the model to apply it to coding, debugging, and review work rather than ordinary conversation. It favors understanding the real flow, small validated changes, reuse, and preserving safety checks. Enabled by default; changes apply to the next model call and persist as `ponytail_enabled` in `$ZUT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zutfile agents. | |
| 405 | - | - **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account. | |
| 407 | + | - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account. | |
| 406 | 408 | - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`. | |
| 407 | 409 | - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts. | |
| 408 | - | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. After a successful threshold compaction, zut automatically resumes an unfinished most-recent intent; a prompt queued during compaction takes priority. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. | |
| 410 | + | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. After a successful threshold compaction, zut automatically resumes an unfinished most-recent intent, including truncated output; a prompt queued during compaction takes priority. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. | |
| 409 | 411 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | |
| 410 | 412 | - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`. | |
| 411 | 413 | - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`. | |
| @@ -422,11 +424,11 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro | |||
| 422 | 424 | ||
| 423 | 425 | Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%. | |
| 424 | 426 | ||
| 425 | - | zut also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own and, once it succeeds, resumes an unfinished most-recent intent automatically. Completed text answers settle normally, while a prompt queued during compaction runs instead of the automatic continuation. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response. | |
| 427 | + | zut also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own and, once it succeeds, resumes an unfinished most-recent intent automatically, including output that stopped at the model's token limit. Completed text answers settle normally, while a prompt queued during compaction runs instead of the automatic continuation. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response. | |
| 426 | 428 | ||
| 427 | 429 | ### `/jail` | |
| 428 | 430 | ||
| 429 | - | Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, and `edit` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `bash` refuses obvious escape patterns (`sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar) and rejects shell arguments or redirections that point outside the sandbox. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session. | |
| 431 | + | Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, and `edit` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `create_worktree` also requires its repository root, Git metadata, optional `.gitignore`, and configured worktree destination to be inside the sandbox. `bash` refuses obvious escape patterns (`sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar) and rejects shell arguments or redirections that point outside the sandbox. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session. | |
| 430 | 432 | ||
| 431 | 433 | This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zut under docker or a proper sandbox. | |
| 432 | 434 | ||
| @@ -794,7 +796,7 @@ Frames containing images are full-repainted (no differential diff) to prevent st | |||
| 794 | 796 | ||
| 795 | 797 | ## Tool rendering | |
| 796 | 798 | ||
| 797 | - | By default each tool call (bash, read, write, edit) renders inside a bordered panel — a `┌─ header ─┐`, `│`-prefixed body rows, and a `└─┘` footer. On a screen with many calls the borders can read as busy, so zut also offers a **flat** mode: a single quiet header line per call (`▌ bash …`) with indented, border-free output. Same information — tool name, arg summary, streamed output, the `... (N more lines, ctrl+o to expand)` truncation — just no frame. | |
| 799 | + | By default each tool call (bash, read, write, edit, create_worktree, lsp) renders inside a bordered panel — a `┌─ header ─┐`, `│`-prefixed body rows, and a `└─┘` footer. On a screen with many calls the borders can read as busy, so zut also offers a **flat** mode: a single quiet header line per call (`▌ bash …`) with indented, border-free output. Same information — tool name, arg summary, streamed output, the `... (N more lines, ctrl+o to expand)` truncation — just no frame. | |
| 798 | 800 | ||
| 799 | 801 | Set the `tool_render` key in `$ZUT_HOME/config.json`: | |
| 800 | 802 | ||
| @@ -883,7 +885,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`, | |||
| 883 | 885 | | `ctrl+b` | Toggle the right sidebar; hidden or narrow widgets use a bounded above-input fallback. | | |
| 884 | 886 | | `ctrl+l` | Redraw the screen. | | |
| 885 | 887 | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. | | |
| 886 | - | | `ctrl+o` | Expand or collapse long tool results (outputs over ~12 lines, including `web_search`). | | |
| 888 | + | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, lsp, and web_search outputs over ~12 lines). | | |
| 887 | 889 | | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. | | |
| 888 | 890 | | `@` | Open the file picker. Browse files and directories in the working directory. | | |
| 889 | 891 | ||
| @@ -1066,7 +1068,7 @@ packages/agent/extproto/ extension wire-format types | |||
| 1066 | 1068 | packages/agent/modes/ interactive tui, print, json, dialogs | |
| 1067 | 1069 | packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface) | |
| 1068 | 1070 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | |
| 1069 | - | packages/agent/tools/ read, write, edit, bash, lsp, web search, sandbox | |
| 1071 | + | packages/agent/tools/ read, write, edit, bash, create_worktree, lsp, web search, sandbox | |
| 1070 | 1072 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | |
| 1071 | 1073 | packages/agent/subagents/ named profiles, supervisor, and background runtime | |
| 1072 | 1074 | packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk) | |
| @@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |||||
| 9 | 9 | ||||
| 10 | - one static binary. | 10 | - one static binary. | ||
| 11 | - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models. | 11 | - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models. | ||
| 12 | - | - six core built-in tools (read, write, edit, bash, lsp, and web_search); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries. | 12 | + | - seven core built-in tools (read, write, edit, bash, create_worktree, lsp, and web_search); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries. |
| 13 | - three run modes (interactive tui, print, json). | 13 | - three run modes (interactive tui, print, json). | ||
| 14 | - built-in telegram bot. | 14 | - built-in telegram bot. | ||
| 15 | - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md). | 15 | - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md). | ||
| @@ -233,10 +233,11 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||||
| 233 | - `write`: create or overwrite files, making parent directories as needed. | 233 | - `write`: create or overwrite files, making parent directories as needed. | ||
| 234 | - `edit`: one or more exact-match replacements in an existing file. | 234 | - `edit`: one or more exact-match replacements in an existing file. | ||
| 235 | - `bash`: run a command in the session cwd with merged stdout/stderr and a timeout. On Unix, zut uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable. | 235 | - `bash`: run a command in the session cwd with merged stdout/stderr and a timeout. On Unix, zut uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable. | ||
| 236 | + | - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path. | |||
| 236 | - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md). | 237 | - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md). | ||
| 237 | - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md). | 238 | - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md). | ||
| 238 | 239 | ||||
| 239 | - | When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits refuse paths outside the session cwd. Jail does not sandbox web-search network egress. | 240 | + | When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress. |
| 240 | 241 | ||||
| 241 | ## Modes | 242 | ## Modes | ||
| 242 | 243 | ||||
| @@ -279,6 +280,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||||
| 279 | | `/logout [provider]` | Clear credentials for any logged-in provider, or all when omitted. `/logout openai-codex` clears ChatGPT/Codex subscription auth while preserving a public OpenAI API key; `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zut again. | | 280 | | `/logout [provider]` | Clear credentials for any logged-in provider, or all when omitted. `/logout openai-codex` clears ChatGPT/Codex subscription auth while preserving a public OpenAI API key; `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zut again. | | ||
| 280 | | `/model` | Pick a model from a list (or `/model <id>` to set directly). | | 281 | | `/model` | Pick a model from a list (or `/model <id>` to set directly). | | ||
| 281 | | `/reasoning` | Set the reasoning level for subsequent model calls. | | 282 | | `/reasoning` | Set the reasoning level for subsequent model calls. | | ||
| 283 | + | | `/fast` | Toggle fast mode for subsequent model calls. | | |||
| 282 | | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. | | 284 | | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. | | ||
| 283 | | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. | | 285 | | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. | | ||
| 284 | | `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. | | 286 | | `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. | | ||
| @@ -293,7 +295,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||||
| 293 | | `/unjail` | Allow tools to touch paths outside again. | | 295 | | `/unjail` | Allow tools to touch paths outside again. | | ||
| 294 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | 296 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | ||
| 295 | | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. | | 297 | | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. | | ||
| 296 | - | | `/settings` | Change persistent settings, including web search, inline images, terminal alerts, AI terminal titles, auto-subagents, Ponytail coding mode, fast mode, main/sub-agent LSP access, and the auto-compact threshold. Saved to `$ZUT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. | | 298 | + | | `/settings` | Change persistent settings, including web search, inline images, terminal alerts, AI terminal titles, auto-subagents, Ponytail coding mode, fast mode, main/sub-agent LSP access, and the auto-compact threshold. `/fast` is a shortcut for the fast-mode toggle. Saved to `$ZUT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. | |
| 297 | | `/clear` | Clear the chat transcript. | | 299 | | `/clear` | Clear the chat transcript. | | ||
| 298 | | `/exit` | Exit zut. | | 300 | | `/exit` | Exit zut. | | ||
| 299 | 301 | ||||
| @@ -380,7 +382,7 @@ Background subagents that run alongside your main session. Each one is a separat | |||||
| 380 | 382 | ||||
| 381 | **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back. | 383 | **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back. | ||
| 382 | 384 | ||||
| 383 | - | **Persistence across zut restarts** — every spawn writes a durable manifest, append-only event log, session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. On the next `zut` launch they show up as **detached**; use `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task. | 385 | + | **Persistence across zut restarts** — every spawn writes a durable manifest, lifecycle summary, append-only event log, session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. On the next `zut` launch the compact summary restores each agent as **detached** without replaying its full history. Open its transcript to hydrate the durable event log, then use `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task. |
| 384 | 386 | ||||
| 385 | **Where state lives** — per-agent manifests, session files, events, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup. | 387 | **Where state lives** — per-agent manifests, session files, events, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup. | ||
| 386 | 388 | ||||
| @@ -402,10 +404,10 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||||
| 402 | - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zut: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`. | 404 | - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zut: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`. | ||
| 403 | - **auto-subagents** — let the main agent spawn background sub-agents in parallel via `subagent_spawn` and query live state via `subagent_status`. Off by default. The tools accept named profiles, reasoning, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion updates still arrive through the auto-subagents watcher. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details. | 405 | - **auto-subagents** — let the main agent spawn background sub-agents in parallel via `subagent_spawn` and query live state via `subagent_status`. Off by default. The tools accept named profiles, reasoning, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion updates still arrive through the auto-subagents watcher. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details. | ||
| 404 | - **Ponytail coding mode** — include compact engineering guidance in each resolved system prompt; the guidance tells the model to apply it to coding, debugging, and review work rather than ordinary conversation. It favors understanding the real flow, small validated changes, reuse, and preserving safety checks. Enabled by default; changes apply to the next model call and persist as `ponytail_enabled` in `$ZUT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zutfile agents. | 406 | - **Ponytail coding mode** — include compact engineering guidance in each resolved system prompt; the guidance tells the model to apply it to coding, debugging, and review work rather than ordinary conversation. It favors understanding the real flow, small validated changes, reuse, and preserving safety checks. Enabled by default; changes apply to the next model call and persist as `ponytail_enabled` in `$ZUT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zutfile agents. | ||
| 405 | - | - **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account. | 407 | + | - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account. |
| 406 | - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`. | 408 | - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`. | ||
| 407 | - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts. | 409 | - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts. | ||
| 408 | - | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. After a successful threshold compaction, zut automatically resumes an unfinished most-recent intent; a prompt queued during compaction takes priority. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. | 410 | + | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. After a successful threshold compaction, zut automatically resumes an unfinished most-recent intent, including truncated output; a prompt queued during compaction takes priority. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. |
| 409 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | 411 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | ||
| 410 | - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`. | 412 | - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`. | ||
| 411 | - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`. | 413 | - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`. | ||
| @@ -422,11 +424,11 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro | |||||
| 422 | 424 | ||||
| 423 | Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%. | 425 | Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%. | ||
| 424 | 426 | ||||
| 425 | - | zut also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own and, once it succeeds, resumes an unfinished most-recent intent automatically. Completed text answers settle normally, while a prompt queued during compaction runs instead of the automatic continuation. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response. | 427 | + | zut also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own and, once it succeeds, resumes an unfinished most-recent intent automatically, including output that stopped at the model's token limit. Completed text answers settle normally, while a prompt queued during compaction runs instead of the automatic continuation. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response. |
| 426 | 428 | ||||
| 427 | ### `/jail` | 429 | ### `/jail` | ||
| 428 | 430 | ||||
| 429 | - | Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, and `edit` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `bash` refuses obvious escape patterns (`sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar) and rejects shell arguments or redirections that point outside the sandbox. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session. | 431 | + | Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, and `edit` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `create_worktree` also requires its repository root, Git metadata, optional `.gitignore`, and configured worktree destination to be inside the sandbox. `bash` refuses obvious escape patterns (`sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar) and rejects shell arguments or redirections that point outside the sandbox. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session. |
| 430 | 432 | ||||
| 431 | This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zut under docker or a proper sandbox. | 433 | This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zut under docker or a proper sandbox. | ||
| 432 | 434 | ||||
| @@ -794,7 +796,7 @@ Frames containing images are full-repainted (no differential diff) to prevent st | |||||
| 794 | 796 | ||||
| 795 | ## Tool rendering | 797 | ## Tool rendering | ||
| 796 | 798 | ||||
| 797 | - | By default each tool call (bash, read, write, edit) renders inside a bordered panel — a `┌─ header ─┐`, `│`-prefixed body rows, and a `└─┘` footer. On a screen with many calls the borders can read as busy, so zut also offers a **flat** mode: a single quiet header line per call (`▌ bash …`) with indented, border-free output. Same information — tool name, arg summary, streamed output, the `... (N more lines, ctrl+o to expand)` truncation — just no frame. | 799 | + | By default each tool call (bash, read, write, edit, create_worktree, lsp) renders inside a bordered panel — a `┌─ header ─┐`, `│`-prefixed body rows, and a `└─┘` footer. On a screen with many calls the borders can read as busy, so zut also offers a **flat** mode: a single quiet header line per call (`▌ bash …`) with indented, border-free output. Same information — tool name, arg summary, streamed output, the `... (N more lines, ctrl+o to expand)` truncation — just no frame. |
| 798 | 800 | ||||
| 799 | Set the `tool_render` key in `$ZUT_HOME/config.json`: | 801 | Set the `tool_render` key in `$ZUT_HOME/config.json`: | ||
| 800 | 802 | ||||
| @@ -883,7 +885,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`, | |||||
| 883 | | `ctrl+b` | Toggle the right sidebar; hidden or narrow widgets use a bounded above-input fallback. | | 885 | | `ctrl+b` | Toggle the right sidebar; hidden or narrow widgets use a bounded above-input fallback. | | ||
| 884 | | `ctrl+l` | Redraw the screen. | | 886 | | `ctrl+l` | Redraw the screen. | | ||
| 885 | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. | | 887 | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. | | ||
| 886 | - | | `ctrl+o` | Expand or collapse long tool results (outputs over ~12 lines, including `web_search`). | | 888 | + | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, lsp, and web_search outputs over ~12 lines). | |
| 887 | | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. | | 889 | | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. | | ||
| 888 | | `@` | Open the file picker. Browse files and directories in the working directory. | | 890 | | `@` | Open the file picker. Browse files and directories in the working directory. | | ||
| 889 | 891 | ||||
| @@ -1066,7 +1068,7 @@ packages/agent/extproto/ extension wire-format types | |||||
| 1066 | packages/agent/modes/ interactive tui, print, json, dialogs | 1068 | packages/agent/modes/ interactive tui, print, json, dialogs | ||
| 1067 | packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface) | 1069 | packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface) | ||
| 1068 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | 1070 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | ||
| 1069 | - | packages/agent/tools/ read, write, edit, bash, lsp, web search, sandbox | 1071 | + | packages/agent/tools/ read, write, edit, bash, create_worktree, lsp, web search, sandbox |
| 1070 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | 1072 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | ||
| 1071 | packages/agent/subagents/ named profiles, supervisor, and background runtime | 1073 | packages/agent/subagents/ named profiles, supervisor, and background runtime | ||
| 1072 | packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk) | 1074 | packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk) | ||
M docs/extensions.md +4 -3
| @@ -225,9 +225,10 @@ describing the tool's args (the same shape Anthropic and OpenAI accept). | |||
| 225 | 225 | ``` | |
| 226 | 226 | ||
| 227 | 227 | Tool names live in the same namespace as built-in tools (`read`, | |
| 228 | - | `write`, `edit`, `bash`, `lsp`, `web_search`, `skill`). Conflicts with active | |
| 229 | - | built-ins are ignored. The native `web_search` name is reserved even when the | |
| 230 | - | setting disables that built-in, so an extension cannot claim or replace it. | |
| 228 | + | `write`, `edit`, `bash`, `create_worktree`, `lsp`, `web_search`, `skill`). | |
| 229 | + | Conflicts with active built-ins are silently shadowed by the built-in. The | |
| 230 | + | native `web_search` name is reserved even when the setting disables that | |
| 231 | + | built-in, so an extension cannot claim or replace it. | |
| 231 | 232 | ||
| 232 | 233 | Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`: | |
| 233 | 234 | ||
| @@ -225,9 +225,10 @@ describing the tool's args (the same shape Anthropic and OpenAI accept). | |||||
| 225 | ``` | 225 | ``` | ||
| 226 | 226 | ||||
| 227 | Tool names live in the same namespace as built-in tools (`read`, | 227 | Tool names live in the same namespace as built-in tools (`read`, | ||
| 228 | - | `write`, `edit`, `bash`, `lsp`, `web_search`, `skill`). Conflicts with active | 228 | + | `write`, `edit`, `bash`, `create_worktree`, `lsp`, `web_search`, `skill`). |
| 229 | - | built-ins are ignored. The native `web_search` name is reserved even when the | 229 | + | Conflicts with active built-ins are silently shadowed by the built-in. The |
| 230 | - | setting disables that built-in, so an extension cannot claim or replace it. | 230 | + | native `web_search` name is reserved even when the setting disables that |
| 231 | + | built-in, so an extension cannot claim or replace it. | |||
| 231 | 232 | ||||
| 232 | Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`: | 233 | Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`: | ||
| 233 | 234 | ||||
M docs/providers.md +12 -10
| @@ -115,16 +115,6 @@ show instructions and should be configured with environment variables. | |||
| 115 | 115 | | Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` | `cloudflare-ai-gateway` | | |
| 116 | 116 | | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` | | |
| 117 | 117 | ||
| 118 | - | ### OpenAI Fast mode | |
| 119 | - | ||
| 120 | - | OpenAI, OpenAI Responses, and OpenAI Codex requests can opt into OpenAI's | |
| 121 | - | Fast service tier from `/settings` by enabling **fast mode**. The setting is | |
| 122 | - | off by default and is stored as `fast_mode` in `$ZUT_HOME/config.json`. | |
| 123 | - | ||
| 124 | - | Fast mode is currently limited to those OpenAI provider IDs. Enabling it while | |
| 125 | - | using another provider returns an error instead of silently changing that | |
| 126 | - | provider's request. Subagent children inherit the setting from their parent. | |
| 127 | - | ||
| 128 | 118 | Example: | |
| 129 | 119 | ||
| 130 | 120 | ```bash | |
| @@ -132,6 +122,18 @@ export OPENROUTER_API_KEY=... | |||
| 132 | 122 | zut --provider openrouter | |
| 133 | 123 | ``` | |
| 134 | 124 | ||
| 125 | + | ## Fast mode | |
| 126 | + | ||
| 127 | + | Use `/fast` or `/settings` to enable **fast mode** for subsequent model calls. | |
| 128 | + | The setting is off by default and is stored as `fast_mode` in | |
| 129 | + | `$ZUT_HOME/config.json`. | |
| 130 | + | ||
| 131 | + | Fast mode currently requests OpenAI's Fast service tier for OpenAI, OpenAI | |
| 132 | + | Responses, and OpenAI Codex requests. Enabling it while using another provider | |
| 133 | + | currently returns an error instead of silently changing that provider's request. | |
| 134 | + | Subagent children inherit the setting from their parent unless their profile | |
| 135 | + | sets `fastMode` explicitly. | |
| 136 | + | ||
| 135 | 137 | ## Local llama.cpp router | |
| 136 | 138 | ||
| 137 | 139 | The `llama.cpp` provider connects to a multi-model router and is separate from the `ollama` provider. Ollama normally uses `http://localhost:11434`; entering that URL for llama.cpp management produces a 404 because Ollama does not implement the router endpoints. | |
| @@ -115,16 +115,6 @@ show instructions and should be configured with environment variables. | |||||
| 115 | | Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` | `cloudflare-ai-gateway` | | 115 | | Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` | `cloudflare-ai-gateway` | | ||
| 116 | | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` | | 116 | | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` | | ||
| 117 | 117 | ||||
| 118 | - | ### OpenAI Fast mode | |||
| 119 | - | ||||
| 120 | - | OpenAI, OpenAI Responses, and OpenAI Codex requests can opt into OpenAI's | |||
| 121 | - | Fast service tier from `/settings` by enabling **fast mode**. The setting is | |||
| 122 | - | off by default and is stored as `fast_mode` in `$ZUT_HOME/config.json`. | |||
| 123 | - | ||||
| 124 | - | Fast mode is currently limited to those OpenAI provider IDs. Enabling it while | |||
| 125 | - | using another provider returns an error instead of silently changing that | |||
| 126 | - | provider's request. Subagent children inherit the setting from their parent. | |||
| 127 | - | ||||
| 128 | Example: | 118 | Example: | ||
| 129 | 119 | ||||
| 130 | ```bash | 120 | ```bash | ||
| @@ -132,6 +122,18 @@ export OPENROUTER_API_KEY=... | |||||
| 132 | zut --provider openrouter | 122 | zut --provider openrouter | ||
| 133 | ``` | 123 | ``` | ||
| 134 | 124 | ||||
| 125 | + | ## Fast mode | |||
| 126 | + | ||||
| 127 | + | Use `/fast` or `/settings` to enable **fast mode** for subsequent model calls. | |||
| 128 | + | The setting is off by default and is stored as `fast_mode` in | |||
| 129 | + | `$ZUT_HOME/config.json`. | |||
| 130 | + | ||||
| 131 | + | Fast mode currently requests OpenAI's Fast service tier for OpenAI, OpenAI | |||
| 132 | + | Responses, and OpenAI Codex requests. Enabling it while using another provider | |||
| 133 | + | currently returns an error instead of silently changing that provider's request. | |||
| 134 | + | Subagent children inherit the setting from their parent unless their profile | |||
| 135 | + | sets `fastMode` explicitly. | |||
| 136 | + | ||||
| 135 | ## Local llama.cpp router | 137 | ## Local llama.cpp router | ||
| 136 | 138 | ||||
| 137 | The `llama.cpp` provider connects to a multi-model router and is separate from the `ollama` provider. Ollama normally uses `http://localhost:11434`; entering that URL for llama.cpp management produces a 404 because Ollama does not implement the router endpoints. | 139 | The `llama.cpp` provider connects to a multi-model router and is separate from the `ollama` provider. Ollama normally uses `http://localhost:11434`; entering that URL for llama.cpp management produces a 404 because Ollama does not implement the router endpoints. | ||
M docs/subagents.md +3 -3
| @@ -41,14 +41,14 @@ Supported metadata: | |||
| 41 | 41 | |---|---| | |
| 42 | 42 | | `name` | Name passed to `subagent_spawn`'s `agent` field. Falls back to the filename. | | |
| 43 | 43 | | `description` | Short description shown to the main agent. | | |
| 44 | - | | `tools` | Comma-separated or list-form tool names. zut enforces its built-in `read`, `write`, `edit`, `bash`, `lsp`, and `web_search` registry; the conditional `skill` tool is available when skills are enabled. Unknown names do not grant capabilities. | | |
| 44 | + | | `tools` | Comma-separated or list-form tool names. zut enforces its built-in `read`, `write`, `edit`, `bash`, `create_worktree`, `lsp`, and `web_search` registry; the conditional `skill` tool is available when skills are enabled. Unknown names do not grant capabilities. | | |
| 45 | 45 | | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. | | |
| 46 | 46 | | `provider` | Optional separate provider ID for a model without a provider prefix. | | |
| 47 | 47 | | `thinking` / `reasoning` | Optional reasoning level: `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, or `max`. | | |
| 48 | 48 | | `systemPromptMode` | `append` (default) or `replace`. | | |
| 49 | 49 | | `inheritProjectContext` | Set to `false` to omit `AGENTS.md` context from the child. | | |
| 50 | 50 | | `inheritSkills` | Set to `false` to omit skill discovery and the conditional `skill` loader from the child; `--no-skill` has the same effect for a run. | | |
| 51 | - | | `fastMode` | Optional fast-mode restriction. Omit it to inherit the host setting; `false` disables fast mode for this profile; `true` only permits fast mode when the host setting is enabled. | | |
| 51 | + | | `fastMode` | Optional fast-mode override. Omit it to inherit the host setting; `false` disables fast mode for this profile; `true` enables it for this profile even when the host setting is off. | | |
| 52 | 52 | ||
| 53 | 53 | Other frontmatter from another agent host is ignored when zut does not have an equivalent setting. Recursive child spawning is disabled in v1; a worker cannot invoke `subagent_spawn` to create descendants. | |
| 54 | 54 | ||
| @@ -98,7 +98,7 @@ The interactive command also supports the same selection explicitly: | |||
| 98 | 98 | /subagents new --agent implementer --reasoning high Implement the parser change | |
| 99 | 99 | ``` | |
| 100 | 100 | ||
| 101 | - | Shared mode preserves the historical host working directory. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zut creates a detached Git worktree, captures changed files and a patch, and never merges automatically. Named profiles change the child's instructions and configuration; they are not a security sandbox. A profile's `systemPromptMode` controls its own body relative to the built-in identity; global append addenda, including enabled Ponytail coding guidance, remain present. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` cannot enable fast mode when the host setting is off. Non-OpenAI child providers return an unsupported-provider error instead of silently ignoring an enabled setting. | |
| 101 | + | Shared mode preserves the historical host working directory. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zut creates a detached Git worktree, captures changed files and a patch, and never merges automatically. Named profiles change the child's instructions and configuration; they are not a security sandbox. A profile's `systemPromptMode` controls its own body relative to the built-in identity; globally appended instructions, including enabled Ponytail coding guidance, remain present. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` enables fast mode even when the host setting is off; the `subagent_spawn` result warns the parent session when this override occurs. Child providers that do not yet support fast mode return an unsupported-provider error instead of silently ignoring an enabled setting. | |
| 102 | 102 | ||
| 103 | 103 | ## Lifecycle, results, and recovery | |
| 104 | 104 | ||
| @@ -41,14 +41,14 @@ Supported metadata: | |||||
| 41 | |---|---| | 41 | |---|---| | ||
| 42 | | `name` | Name passed to `subagent_spawn`'s `agent` field. Falls back to the filename. | | 42 | | `name` | Name passed to `subagent_spawn`'s `agent` field. Falls back to the filename. | | ||
| 43 | | `description` | Short description shown to the main agent. | | 43 | | `description` | Short description shown to the main agent. | | ||
| 44 | - | | `tools` | Comma-separated or list-form tool names. zut enforces its built-in `read`, `write`, `edit`, `bash`, `lsp`, and `web_search` registry; the conditional `skill` tool is available when skills are enabled. Unknown names do not grant capabilities. | | 44 | + | | `tools` | Comma-separated or list-form tool names. zut enforces its built-in `read`, `write`, `edit`, `bash`, `create_worktree`, `lsp`, and `web_search` registry; the conditional `skill` tool is available when skills are enabled. Unknown names do not grant capabilities. | |
| 45 | | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. | | 45 | | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. | | ||
| 46 | | `provider` | Optional separate provider ID for a model without a provider prefix. | | 46 | | `provider` | Optional separate provider ID for a model without a provider prefix. | | ||
| 47 | | `thinking` / `reasoning` | Optional reasoning level: `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, or `max`. | | 47 | | `thinking` / `reasoning` | Optional reasoning level: `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, or `max`. | | ||
| 48 | | `systemPromptMode` | `append` (default) or `replace`. | | 48 | | `systemPromptMode` | `append` (default) or `replace`. | | ||
| 49 | | `inheritProjectContext` | Set to `false` to omit `AGENTS.md` context from the child. | | 49 | | `inheritProjectContext` | Set to `false` to omit `AGENTS.md` context from the child. | | ||
| 50 | | `inheritSkills` | Set to `false` to omit skill discovery and the conditional `skill` loader from the child; `--no-skill` has the same effect for a run. | | 50 | | `inheritSkills` | Set to `false` to omit skill discovery and the conditional `skill` loader from the child; `--no-skill` has the same effect for a run. | | ||
| 51 | - | | `fastMode` | Optional fast-mode restriction. Omit it to inherit the host setting; `false` disables fast mode for this profile; `true` only permits fast mode when the host setting is enabled. | | 51 | + | | `fastMode` | Optional fast-mode override. Omit it to inherit the host setting; `false` disables fast mode for this profile; `true` enables it for this profile even when the host setting is off. | |
| 52 | 52 | ||||
| 53 | Other frontmatter from another agent host is ignored when zut does not have an equivalent setting. Recursive child spawning is disabled in v1; a worker cannot invoke `subagent_spawn` to create descendants. | 53 | Other frontmatter from another agent host is ignored when zut does not have an equivalent setting. Recursive child spawning is disabled in v1; a worker cannot invoke `subagent_spawn` to create descendants. | ||
| 54 | 54 | ||||
| @@ -98,7 +98,7 @@ The interactive command also supports the same selection explicitly: | |||||
| 98 | /subagents new --agent implementer --reasoning high Implement the parser change | 98 | /subagents new --agent implementer --reasoning high Implement the parser change | ||
| 99 | ``` | 99 | ``` | ||
| 100 | 100 | ||||
| 101 | - | Shared mode preserves the historical host working directory. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zut creates a detached Git worktree, captures changed files and a patch, and never merges automatically. Named profiles change the child's instructions and configuration; they are not a security sandbox. A profile's `systemPromptMode` controls its own body relative to the built-in identity; global append addenda, including enabled Ponytail coding guidance, remain present. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` cannot enable fast mode when the host setting is off. Non-OpenAI child providers return an unsupported-provider error instead of silently ignoring an enabled setting. | 101 | + | Shared mode preserves the historical host working directory. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zut creates a detached Git worktree, captures changed files and a patch, and never merges automatically. Named profiles change the child's instructions and configuration; they are not a security sandbox. A profile's `systemPromptMode` controls its own body relative to the built-in identity; globally appended instructions, including enabled Ponytail coding guidance, remain present. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` enables fast mode even when the host setting is off; the `subagent_spawn` result warns the parent session when this override occurs. Child providers that do not yet support fast mode return an unsupported-provider error instead of silently ignoring an enabled setting. |
| 102 | 102 | ||||
| 103 | ## Lifecycle, results, and recovery | 103 | ## Lifecycle, results, and recovery | ||
| 104 | 104 | ||||
M docs/zutfiles.md +19 -1
| @@ -303,7 +303,7 @@ In `--print` / `--stream` / `--json` modes, `pre` still runs first: shell escape | |||
| 303 | 303 | ||
| 304 | 304 | ## Filesystem permissions | |
| 305 | 305 | ||
| 306 | - | Filesystem permissions are enforced by the built-in `read`, `write`, and `edit` tools. Empty or omitted scopes deny the operation. | |
| 306 | + | Filesystem permissions are enforced by the built-in `read`, `write`, `edit`, and `create_worktree` tools. Empty or omitted scopes deny the operation. | |
| 307 | 307 | ||
| 308 | 308 | Two variables are available in filesystem scopes: | |
| 309 | 309 | ||
| @@ -373,6 +373,24 @@ Data-only agent: | |||
| 373 | 373 | ||
| 374 | 374 | Filesystem checks canonicalize existing paths, or the nearest existing parent for a new path, so symlinks cannot be used to escape a declared scope. | |
| 375 | 375 | ||
| 376 | + | `create_worktree` bootstraps a repository without an existing worktree root in two calls. Its first branch-only call returns no-write guidance for the agent to ask the user whether to use `.worktrees` or an external directory. The reply is sent as `bootstrap_root`: `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the repository root's `.gitignore`; an absolute external path creates `<external-root>/<branch>` without changing repository files. The selected root is stored privately as `zut.worktrees.path` in local Git config, so later calls only need `branch`. | |
| 377 | + | ||
| 378 | + | The tool needs read and write scopes covering the repository root, Git metadata, and the selected worktree root. It also requires Bash permission for `git`, even though it invokes Git directly without a shell: | |
| 379 | + | ||
| 380 | + | ```json | |
| 381 | + | { | |
| 382 | + | "permissions": { | |
| 383 | + | "fs": { | |
| 384 | + | "read": ["${workspace}"], | |
| 385 | + | "write": ["${workspace}"] | |
| 386 | + | }, | |
| 387 | + | "bash": { "mode": "allowlist", "allow": ["git"] } | |
| 388 | + | } | |
| 389 | + | } | |
| 390 | + | ``` | |
| 391 | + | ||
| 392 | + | The tool disables Git hooks, but a repository checkout can still use configured Git filters. Treat `git` permission for `create_worktree` as permission to run repository-controlled checkout behavior; jail mode remains an accident-prevention guardrail, not a security sandbox. | |
| 393 | + | ||
| 376 | 394 | If the working directory changes during a run, `${workspace}` permissions are expanded again for the new directory. | |
| 377 | 395 | ||
| 378 | 396 | ## Bash permissions | |
| @@ -303,7 +303,7 @@ In `--print` / `--stream` / `--json` modes, `pre` still runs first: shell escape | |||||
| 303 | 303 | ||||
| 304 | ## Filesystem permissions | 304 | ## Filesystem permissions | ||
| 305 | 305 | ||||
| 306 | - | Filesystem permissions are enforced by the built-in `read`, `write`, and `edit` tools. Empty or omitted scopes deny the operation. | 306 | + | Filesystem permissions are enforced by the built-in `read`, `write`, `edit`, and `create_worktree` tools. Empty or omitted scopes deny the operation. |
| 307 | 307 | ||||
| 308 | Two variables are available in filesystem scopes: | 308 | Two variables are available in filesystem scopes: | ||
| 309 | 309 | ||||
| @@ -373,6 +373,24 @@ Data-only agent: | |||||
| 373 | 373 | ||||
| 374 | Filesystem checks canonicalize existing paths, or the nearest existing parent for a new path, so symlinks cannot be used to escape a declared scope. | 374 | Filesystem checks canonicalize existing paths, or the nearest existing parent for a new path, so symlinks cannot be used to escape a declared scope. | ||
| 375 | 375 | ||||
| 376 | + | `create_worktree` bootstraps a repository without an existing worktree root in two calls. Its first branch-only call returns no-write guidance for the agent to ask the user whether to use `.worktrees` or an external directory. The reply is sent as `bootstrap_root`: `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the repository root's `.gitignore`; an absolute external path creates `<external-root>/<branch>` without changing repository files. The selected root is stored privately as `zut.worktrees.path` in local Git config, so later calls only need `branch`. | |||
| 377 | + | ||||
| 378 | + | The tool needs read and write scopes covering the repository root, Git metadata, and the selected worktree root. It also requires Bash permission for `git`, even though it invokes Git directly without a shell: | |||
| 379 | + | ||||
| 380 | + | ```json | |||
| 381 | + | { | |||
| 382 | + | "permissions": { | |||
| 383 | + | "fs": { | |||
| 384 | + | "read": ["${workspace}"], | |||
| 385 | + | "write": ["${workspace}"] | |||
| 386 | + | }, | |||
| 387 | + | "bash": { "mode": "allowlist", "allow": ["git"] } | |||
| 388 | + | } | |||
| 389 | + | } | |||
| 390 | + | ``` | |||
| 391 | + | ||||
| 392 | + | The tool disables Git hooks, but a repository checkout can still use configured Git filters. Treat `git` permission for `create_worktree` as permission to run repository-controlled checkout behavior; jail mode remains an accident-prevention guardrail, not a security sandbox. | |||
| 393 | + | ||||
| 376 | If the working directory changes during a run, `${workspace}` permissions are expanded again for the new directory. | 394 | If the working directory changes during a run, `${workspace}` permissions are expanded again for the new directory. | ||
| 377 | 395 | ||||
| 378 | ## Bash permissions | 396 | ## Bash permissions | ||
M packages/agent/build.go +12 -10
| @@ -890,12 +890,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 890 | 890 | if args.FastModeSet { | |
| 891 | 891 | fastMode = args.FastMode | |
| 892 | 892 | } | |
| 893 | - | // A profile can opt out of the effective host/child fast-mode setting, | |
| 894 | - | // but it does not enable fast mode by itself. Keep this as a | |
| 895 | - | // restriction on the effective value rather than an override so an | |
| 896 | - | // explicit child --fast-mode flag cannot bypass fastMode: false. | |
| 893 | + | // A profile's explicit fastMode setting overrides the global setting | |
| 894 | + | // for this child. This preserves fastMode: false as an opt-out while | |
| 895 | + | // allowing fastMode: true to request the fast tier for a focused worker. | |
| 897 | 896 | if selectedProfile != nil && selectedProfile.FastMode != nil { | |
| 898 | - | fastMode = fastMode && *selectedProfile.FastMode | |
| 897 | + | fastMode = *selectedProfile.FastMode | |
| 899 | 898 | } | |
| 900 | 899 | ||
| 901 | 900 | max := args.MaxSteps // 0 = unlimited | |
| @@ -1225,6 +1224,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) { | |||
| 1225 | 1224 | v.Sandbox = s | |
| 1226 | 1225 | case *tools.BashTool: | |
| 1227 | 1226 | v.Sandbox = s | |
| 1227 | + | case *tools.CreateWorktreeTool: | |
| 1228 | + | v.Sandbox = s | |
| 1228 | 1229 | case *tools.LSPTool: | |
| 1229 | 1230 | v.Sandbox = s | |
| 1230 | 1231 | } | |
| @@ -1256,10 +1257,11 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||
| 1256 | 1257 | manager = lsp.NewManagerWithOptions(options) | |
| 1257 | 1258 | } | |
| 1258 | 1259 | all := map[string]core.Tool{ | |
| 1259 | - | "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox}, | |
| 1260 | - | "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite}, | |
| 1261 | - | "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, | |
| 1262 | - | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, | |
| 1260 | + | "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox}, | |
| 1261 | + | "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite}, | |
| 1262 | + | "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, | |
| 1263 | + | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, | |
| 1264 | + | "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox}, | |
| 1263 | 1265 | } | |
| 1264 | 1266 | if webSearchAllowedForRegistry(args) { | |
| 1265 | 1267 | all["web_search"] = tools.NewWebSearchTool() | |
| @@ -1332,7 +1334,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool { | |||
| 1332 | 1334 | return false | |
| 1333 | 1335 | } | |
| 1334 | 1336 | ||
| 1335 | - | var nativeToolSummaryOrder = []string{"read", "write", "edit", "bash", "lsp", "web_search"} | |
| 1337 | + | var nativeToolSummaryOrder = []string{"read", "write", "edit", "bash", "create_worktree", "lsp", "web_search"} | |
| 1336 | 1338 | ||
| 1337 | 1339 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | |
| 1338 | 1340 | var out []ToolSummary | |
| @@ -890,12 +890,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 890 | if args.FastModeSet { | 890 | if args.FastModeSet { | ||
| 891 | fastMode = args.FastMode | 891 | fastMode = args.FastMode | ||
| 892 | } | 892 | } | ||
| 893 | - | // A profile can opt out of the effective host/child fast-mode setting, | 893 | + | // A profile's explicit fastMode setting overrides the global setting |
| 894 | - | // but it does not enable fast mode by itself. Keep this as a | 894 | + | // for this child. This preserves fastMode: false as an opt-out while |
| 895 | - | // restriction on the effective value rather than an override so an | 895 | + | // allowing fastMode: true to request the fast tier for a focused worker. |
| 896 | - | // explicit child --fast-mode flag cannot bypass fastMode: false. | |||
| 897 | if selectedProfile != nil && selectedProfile.FastMode != nil { | 896 | if selectedProfile != nil && selectedProfile.FastMode != nil { | ||
| 898 | - | fastMode = fastMode && *selectedProfile.FastMode | 897 | + | fastMode = *selectedProfile.FastMode |
| 899 | } | 898 | } | ||
| 900 | 899 | ||||
| 901 | max := args.MaxSteps // 0 = unlimited | 900 | max := args.MaxSteps // 0 = unlimited | ||
| @@ -1225,6 +1224,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) { | |||||
| 1225 | v.Sandbox = s | 1224 | v.Sandbox = s | ||
| 1226 | case *tools.BashTool: | 1225 | case *tools.BashTool: | ||
| 1227 | v.Sandbox = s | 1226 | v.Sandbox = s | ||
| 1227 | + | case *tools.CreateWorktreeTool: | |||
| 1228 | + | v.Sandbox = s | |||
| 1228 | case *tools.LSPTool: | 1229 | case *tools.LSPTool: | ||
| 1229 | v.Sandbox = s | 1230 | v.Sandbox = s | ||
| 1230 | } | 1231 | } | ||
| @@ -1256,10 +1257,11 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||||
| 1256 | manager = lsp.NewManagerWithOptions(options) | 1257 | manager = lsp.NewManagerWithOptions(options) | ||
| 1257 | } | 1258 | } | ||
| 1258 | all := map[string]core.Tool{ | 1259 | all := map[string]core.Tool{ | ||
| 1259 | - | "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox}, | 1260 | + | "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox}, |
| 1260 | - | "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite}, | 1261 | + | "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite}, |
| 1261 | - | "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, | 1262 | + | "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, |
| 1262 | - | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, | 1263 | + | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, |
| 1264 | + | "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox}, | |||
| 1263 | } | 1265 | } | ||
| 1264 | if webSearchAllowedForRegistry(args) { | 1266 | if webSearchAllowedForRegistry(args) { | ||
| 1265 | all["web_search"] = tools.NewWebSearchTool() | 1267 | all["web_search"] = tools.NewWebSearchTool() | ||
| @@ -1332,7 +1334,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool { | |||||
| 1332 | return false | 1334 | return false | ||
| 1333 | } | 1335 | } | ||
| 1334 | 1336 | ||||
| 1335 | - | var nativeToolSummaryOrder = []string{"read", "write", "edit", "bash", "lsp", "web_search"} | 1337 | + | var nativeToolSummaryOrder = []string{"read", "write", "edit", "bash", "create_worktree", "lsp", "web_search"} |
| 1336 | 1338 | ||||
| 1337 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | 1339 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | ||
| 1338 | var out []ToolSummary | 1340 | var out []ToolSummary | ||
M packages/agent/build_test.go +51 -6
| @@ -103,7 +103,7 @@ func TestMergeExtensionToolsKeepsDeterministicNativeSummaryOrder(t *testing.T) { | |||
| 103 | 103 | for _, summary := range r.ToolSummary { | |
| 104 | 104 | names = append(names, summary.Name) | |
| 105 | 105 | } | |
| 106 | - | want := "read,write,edit,bash,web_search,alpha,zeta" | |
| 106 | + | want := "read,write,edit,bash,create_worktree,web_search,alpha,zeta" | |
| 107 | 107 | if got := strings.Join(names, ","); got != want { | |
| 108 | 108 | t.Fatalf("merged tool summary order = %q, want %q", got, want) | |
| 109 | 109 | } | |
| @@ -136,7 +136,8 @@ func TestMergeExtensionToolsRetainsPonytailPromptAddendum(t *testing.T) { | |||
| 136 | 136 | ||
| 137 | 137 | func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | |
| 138 | 138 | root := t.TempDir() | |
| 139 | - | registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), true, true, false) | |
| 139 | + | sandbox := tools.NewSandbox(root) | |
| 140 | + | registry := buildToolRegistry(Args{}, root, sandbox, true, true, false) | |
| 140 | 141 | lspTool, ok := registry["lsp"].(*tools.LSPTool) | |
| 141 | 142 | if !ok || lspTool.Manager == nil { | |
| 142 | 143 | t.Fatalf("lsp tool = %#v", registry["lsp"]) | |
| @@ -145,6 +146,10 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | |||
| 145 | 146 | if _, ok := registry["lsp"]; !ok { | |
| 146 | 147 | t.Fatal("default registry does not include lsp") | |
| 147 | 148 | } | |
| 149 | + | worktree, ok := registry["create_worktree"].(*tools.CreateWorktreeTool) | |
| 150 | + | if !ok || worktree.CWD != root || worktree.Sandbox != sandbox { | |
| 151 | + | t.Fatalf("default registry worktree tool = %#v", registry["create_worktree"]) | |
| 152 | + | } | |
| 148 | 153 | if _, ok := registry["web_search"].(*tools.WebSearchTool); !ok { | |
| 149 | 154 | t.Fatalf("default registry web_search = %#v", registry["web_search"]) | |
| 150 | 155 | } | |
| @@ -156,8 +161,8 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | |||
| 156 | 161 | if !ok || edit.LSP == nil || edit.LSPDiagnostics { | |
| 157 | 162 | t.Fatalf("edit tool LSP wiring = %#v", edit) | |
| 158 | 163 | } | |
| 159 | - | if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 5 { | |
| 160 | - | t.Fatalf("LSP-disabled registry has %d tools, want 5", len(disabled)) | |
| 164 | + | if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 6 { | |
| 165 | + | t.Fatalf("LSP-disabled registry has %d tools, want 6", len(disabled)) | |
| 161 | 166 | } | |
| 162 | 167 | readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true) | |
| 163 | 168 | if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil { | |
| @@ -182,6 +187,46 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | |||
| 182 | 187 | if _, ok := workerCapped["web_search"]; ok { | |
| 183 | 188 | t.Fatalf("worker tool list ceiling was bypassed: %#v", workerCapped) | |
| 184 | 189 | } | |
| 190 | + | worktreeOnly := buildToolRegistry(Args{Tools: []string{"create_worktree"}}, root, tools.NewSandbox(root), true, true, true) | |
| 191 | + | if worktree, ok := worktreeOnly["create_worktree"].(*tools.CreateWorktreeTool); !ok || worktree == nil || len(worktreeOnly) != 1 { | |
| 192 | + | t.Fatalf("worktree-only registry = %#v", worktreeOnly) | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func TestToolSummariesIncludeCreateWorktreeAndWebSearch(t *testing.T) { | |
| 197 | + | root := t.TempDir() | |
| 198 | + | registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, false, false) | |
| 199 | + | ||
| 200 | + | summaries := toolSummaries(registry, Args{}) | |
| 201 | + | want := []string{"read", "write", "edit", "bash", "create_worktree", "web_search"} | |
| 202 | + | if len(summaries) != len(want) { | |
| 203 | + | t.Fatalf("tool summaries = %#v, want %v", summaries, want) | |
| 204 | + | } | |
| 205 | + | for index, summary := range summaries { | |
| 206 | + | if summary.Name != want[index] { | |
| 207 | + | t.Fatalf("tool summaries[%d] = %q, want %q", index, summary.Name, want[index]) | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func TestResolvedUseSandboxUpdatesCreateWorktreeTool(t *testing.T) { | |
| 213 | + | root := t.TempDir() | |
| 214 | + | previous := tools.NewSandbox(root) | |
| 215 | + | shared := tools.NewSandbox(root) | |
| 216 | + | worktree := &tools.CreateWorktreeTool{CWD: root, Sandbox: previous} | |
| 217 | + | resolved := &Resolved{ | |
| 218 | + | Sandbox: previous, | |
| 219 | + | ToolRegistry: core.NewRegistry(worktree), | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | resolved.UseSandbox(shared) | |
| 223 | + | ||
| 224 | + | if resolved.Sandbox != shared { | |
| 225 | + | t.Fatal("resolved sandbox was not replaced") | |
| 226 | + | } | |
| 227 | + | if worktree.Sandbox != shared { | |
| 228 | + | t.Fatal("worktree tool sandbox was not replaced") | |
| 229 | + | } | |
| 185 | 230 | } | |
| 186 | 231 | ||
| 187 | 232 | func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) { | |
| @@ -378,7 +423,7 @@ You are a read-only reviewer. | |||
| 378 | 423 | } | |
| 379 | 424 | } | |
| 380 | 425 | ||
| 381 | - | func TestResolveSubagentFastModeUsesHostSettingAsAnUpperBound(t *testing.T) { | |
| 426 | + | func TestResolveSubagentFastModeProfileOverridesHostSetting(t *testing.T) { | |
| 382 | 427 | cases := []struct { | |
| 383 | 428 | name string | |
| 384 | 429 | hostFastMode bool | |
| @@ -390,7 +435,7 @@ func TestResolveSubagentFastModeUsesHostSettingAsAnUpperBound(t *testing.T) { | |||
| 390 | 435 | {name: "unset inherits enabled host", hostFastMode: true, wantFastMode: true}, | |
| 391 | 436 | {name: "unset inherits disabled host", hostFastMode: false, wantFastMode: false}, | |
| 392 | 437 | {name: "false disables enabled host", hostFastMode: true, profileFast: boolPtr(false), wantFastMode: false}, | |
| 393 | - | {name: "true cannot enable disabled host", hostFastMode: false, profileFast: boolPtr(true), wantFastMode: false}, | |
| 438 | + | {name: "true enables disabled host", hostFastMode: false, profileFast: boolPtr(true), wantFastMode: true}, | |
| 394 | 439 | {name: "false stays disabled with disabled host", hostFastMode: false, profileFast: boolPtr(false), wantFastMode: false}, | |
| 395 | 440 | {name: "profile false cannot be bypassed", hostFastMode: true, profileFast: boolPtr(false), argFastMode: true, argFastSet: true, wantFastMode: false}, | |
| 396 | 441 | } | |
| @@ -103,7 +103,7 @@ func TestMergeExtensionToolsKeepsDeterministicNativeSummaryOrder(t *testing.T) { | |||||
| 103 | for _, summary := range r.ToolSummary { | 103 | for _, summary := range r.ToolSummary { | ||
| 104 | names = append(names, summary.Name) | 104 | names = append(names, summary.Name) | ||
| 105 | } | 105 | } | ||
| 106 | - | want := "read,write,edit,bash,web_search,alpha,zeta" | 106 | + | want := "read,write,edit,bash,create_worktree,web_search,alpha,zeta" |
| 107 | if got := strings.Join(names, ","); got != want { | 107 | if got := strings.Join(names, ","); got != want { | ||
| 108 | t.Fatalf("merged tool summary order = %q, want %q", got, want) | 108 | t.Fatalf("merged tool summary order = %q, want %q", got, want) | ||
| 109 | } | 109 | } | ||
| @@ -136,7 +136,8 @@ func TestMergeExtensionToolsRetainsPonytailPromptAddendum(t *testing.T) { | |||||
| 136 | 136 | ||||
| 137 | func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | 137 | func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | ||
| 138 | root := t.TempDir() | 138 | root := t.TempDir() | ||
| 139 | - | registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), true, true, false) | 139 | + | sandbox := tools.NewSandbox(root) |
| 140 | + | registry := buildToolRegistry(Args{}, root, sandbox, true, true, false) | |||
| 140 | lspTool, ok := registry["lsp"].(*tools.LSPTool) | 141 | lspTool, ok := registry["lsp"].(*tools.LSPTool) | ||
| 141 | if !ok || lspTool.Manager == nil { | 142 | if !ok || lspTool.Manager == nil { | ||
| 142 | t.Fatalf("lsp tool = %#v", registry["lsp"]) | 143 | t.Fatalf("lsp tool = %#v", registry["lsp"]) | ||
| @@ -145,6 +146,10 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | |||||
| 145 | if _, ok := registry["lsp"]; !ok { | 146 | if _, ok := registry["lsp"]; !ok { | ||
| 146 | t.Fatal("default registry does not include lsp") | 147 | t.Fatal("default registry does not include lsp") | ||
| 147 | } | 148 | } | ||
| 149 | + | worktree, ok := registry["create_worktree"].(*tools.CreateWorktreeTool) | |||
| 150 | + | if !ok || worktree.CWD != root || worktree.Sandbox != sandbox { | |||
| 151 | + | t.Fatalf("default registry worktree tool = %#v", registry["create_worktree"]) | |||
| 152 | + | } | |||
| 148 | if _, ok := registry["web_search"].(*tools.WebSearchTool); !ok { | 153 | if _, ok := registry["web_search"].(*tools.WebSearchTool); !ok { | ||
| 149 | t.Fatalf("default registry web_search = %#v", registry["web_search"]) | 154 | t.Fatalf("default registry web_search = %#v", registry["web_search"]) | ||
| 150 | } | 155 | } | ||
| @@ -156,8 +161,8 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | |||||
| 156 | if !ok || edit.LSP == nil || edit.LSPDiagnostics { | 161 | if !ok || edit.LSP == nil || edit.LSPDiagnostics { | ||
| 157 | t.Fatalf("edit tool LSP wiring = %#v", edit) | 162 | t.Fatalf("edit tool LSP wiring = %#v", edit) | ||
| 158 | } | 163 | } | ||
| 159 | - | if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 5 { | 164 | + | if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 6 { |
| 160 | - | t.Fatalf("LSP-disabled registry has %d tools, want 5", len(disabled)) | 165 | + | t.Fatalf("LSP-disabled registry has %d tools, want 6", len(disabled)) |
| 161 | } | 166 | } | ||
| 162 | readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true) | 167 | readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true) | ||
| 163 | if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil { | 168 | if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil { | ||
| @@ -182,6 +187,46 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | |||||
| 182 | if _, ok := workerCapped["web_search"]; ok { | 187 | if _, ok := workerCapped["web_search"]; ok { | ||
| 183 | t.Fatalf("worker tool list ceiling was bypassed: %#v", workerCapped) | 188 | t.Fatalf("worker tool list ceiling was bypassed: %#v", workerCapped) | ||
| 184 | } | 189 | } | ||
| 190 | + | worktreeOnly := buildToolRegistry(Args{Tools: []string{"create_worktree"}}, root, tools.NewSandbox(root), true, true, true) | |||
| 191 | + | if worktree, ok := worktreeOnly["create_worktree"].(*tools.CreateWorktreeTool); !ok || worktree == nil || len(worktreeOnly) != 1 { | |||
| 192 | + | t.Fatalf("worktree-only registry = %#v", worktreeOnly) | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func TestToolSummariesIncludeCreateWorktreeAndWebSearch(t *testing.T) { | |||
| 197 | + | root := t.TempDir() | |||
| 198 | + | registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, false, false) | |||
| 199 | + | ||||
| 200 | + | summaries := toolSummaries(registry, Args{}) | |||
| 201 | + | want := []string{"read", "write", "edit", "bash", "create_worktree", "web_search"} | |||
| 202 | + | if len(summaries) != len(want) { | |||
| 203 | + | t.Fatalf("tool summaries = %#v, want %v", summaries, want) | |||
| 204 | + | } | |||
| 205 | + | for index, summary := range summaries { | |||
| 206 | + | if summary.Name != want[index] { | |||
| 207 | + | t.Fatalf("tool summaries[%d] = %q, want %q", index, summary.Name, want[index]) | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func TestResolvedUseSandboxUpdatesCreateWorktreeTool(t *testing.T) { | |||
| 213 | + | root := t.TempDir() | |||
| 214 | + | previous := tools.NewSandbox(root) | |||
| 215 | + | shared := tools.NewSandbox(root) | |||
| 216 | + | worktree := &tools.CreateWorktreeTool{CWD: root, Sandbox: previous} | |||
| 217 | + | resolved := &Resolved{ | |||
| 218 | + | Sandbox: previous, | |||
| 219 | + | ToolRegistry: core.NewRegistry(worktree), | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | resolved.UseSandbox(shared) | |||
| 223 | + | ||||
| 224 | + | if resolved.Sandbox != shared { | |||
| 225 | + | t.Fatal("resolved sandbox was not replaced") | |||
| 226 | + | } | |||
| 227 | + | if worktree.Sandbox != shared { | |||
| 228 | + | t.Fatal("worktree tool sandbox was not replaced") | |||
| 229 | + | } | |||
| 185 | } | 230 | } | ||
| 186 | 231 | ||||
| 187 | func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) { | 232 | func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) { | ||
| @@ -378,7 +423,7 @@ You are a read-only reviewer. | |||||
| 378 | } | 423 | } | ||
| 379 | } | 424 | } | ||
| 380 | 425 | ||||
| 381 | - | func TestResolveSubagentFastModeUsesHostSettingAsAnUpperBound(t *testing.T) { | 426 | + | func TestResolveSubagentFastModeProfileOverridesHostSetting(t *testing.T) { |
| 382 | cases := []struct { | 427 | cases := []struct { | ||
| 383 | name string | 428 | name string | ||
| 384 | hostFastMode bool | 429 | hostFastMode bool | ||
| @@ -390,7 +435,7 @@ func TestResolveSubagentFastModeUsesHostSettingAsAnUpperBound(t *testing.T) { | |||||
| 390 | {name: "unset inherits enabled host", hostFastMode: true, wantFastMode: true}, | 435 | {name: "unset inherits enabled host", hostFastMode: true, wantFastMode: true}, | ||
| 391 | {name: "unset inherits disabled host", hostFastMode: false, wantFastMode: false}, | 436 | {name: "unset inherits disabled host", hostFastMode: false, wantFastMode: false}, | ||
| 392 | {name: "false disables enabled host", hostFastMode: true, profileFast: boolPtr(false), wantFastMode: false}, | 437 | {name: "false disables enabled host", hostFastMode: true, profileFast: boolPtr(false), wantFastMode: false}, | ||
| 393 | - | {name: "true cannot enable disabled host", hostFastMode: false, profileFast: boolPtr(true), wantFastMode: false}, | 438 | + | {name: "true enables disabled host", hostFastMode: false, profileFast: boolPtr(true), wantFastMode: true}, |
| 394 | {name: "false stays disabled with disabled host", hostFastMode: false, profileFast: boolPtr(false), wantFastMode: false}, | 439 | {name: "false stays disabled with disabled host", hostFastMode: false, profileFast: boolPtr(false), wantFastMode: false}, | ||
| 395 | {name: "profile false cannot be bypassed", hostFastMode: true, profileFast: boolPtr(false), argFastMode: true, argFastSet: true, wantFastMode: false}, | 440 | {name: "profile false cannot be bypassed", hostFastMode: true, profileFast: boolPtr(false), argFastMode: true, argFastSet: true, wantFastMode: false}, | ||
| 396 | } | 441 | } | ||
M packages/agent/cli.go +6 -3
| @@ -1407,15 +1407,18 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1407 | 1407 | return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil | |
| 1408 | 1408 | }, | |
| 1409 | 1409 | }) | |
| 1410 | - | // Pull any previously-spawned agents off disk so the dashboard | |
| 1411 | - | // shows them as detached and the user can resume / remove them. | |
| 1412 | - | _, _ = subagentsMgr.Reload() | |
| 1413 | 1410 | if args.PermissionSet != nil { | |
| 1414 | 1411 | // Packaged zutfile agents have an explicit capability ceiling. A | |
| 1415 | 1412 | // child process would otherwise rebuild a fresh unrestricted sandbox, | |
| 1416 | 1413 | // so do not expose either slash or tool-based delegation from this | |
| 1417 | 1414 | // restricted host until worker capability propagation exists. | |
| 1418 | 1415 | subagentsMgr = nil | |
| 1416 | + | } else { | |
| 1417 | + | // Replaying every historical event log can take seconds for users with | |
| 1418 | + | // many completed subagents. The manager is safe for concurrent snapshots, | |
| 1419 | + | // so populate the detached-agent dashboard in the background instead of | |
| 1420 | + | // blocking the first interactive paint. | |
| 1421 | + | go func() { _, _ = subagentsMgr.Reload() }() | |
| 1419 | 1422 | } | |
| 1420 | 1423 | ||
| 1421 | 1424 | // onSpawnedSupervisor is the OnSpawned callback the subagent_spawn tool | |
| @@ -1407,15 +1407,18 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1407 | return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil | 1407 | return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil | ||
| 1408 | }, | 1408 | }, | ||
| 1409 | }) | 1409 | }) | ||
| 1410 | - | // Pull any previously-spawned agents off disk so the dashboard | |||
| 1411 | - | // shows them as detached and the user can resume / remove them. | |||
| 1412 | - | _, _ = subagentsMgr.Reload() | |||
| 1413 | if args.PermissionSet != nil { | 1410 | if args.PermissionSet != nil { | ||
| 1414 | // Packaged zutfile agents have an explicit capability ceiling. A | 1411 | // Packaged zutfile agents have an explicit capability ceiling. A | ||
| 1415 | // child process would otherwise rebuild a fresh unrestricted sandbox, | 1412 | // child process would otherwise rebuild a fresh unrestricted sandbox, | ||
| 1416 | // so do not expose either slash or tool-based delegation from this | 1413 | // so do not expose either slash or tool-based delegation from this | ||
| 1417 | // restricted host until worker capability propagation exists. | 1414 | // restricted host until worker capability propagation exists. | ||
| 1418 | subagentsMgr = nil | 1415 | subagentsMgr = nil | ||
| 1416 | + | } else { | |||
| 1417 | + | // Replaying every historical event log can take seconds for users with | |||
| 1418 | + | // many completed subagents. The manager is safe for concurrent snapshots, | |||
| 1419 | + | // so populate the detached-agent dashboard in the background instead of | |||
| 1420 | + | // blocking the first interactive paint. | |||
| 1421 | + | go func() { _, _ = subagentsMgr.Reload() }() | |||
| 1419 | } | 1422 | } | ||
| 1420 | 1423 | ||||
| 1421 | // onSpawnedSupervisor is the OnSpawned callback the subagent_spawn tool | 1424 | // onSpawnedSupervisor is the OnSpawned callback the subagent_spawn tool | ||
M packages/agent/ext/ext.go +4 -3
| @@ -420,9 +420,10 @@ func (e *Extension) Command(name, description string, fn CommandHandler) { | |||
| 420 | 420 | // OpenAI accept). Call this BEFORE Run(); zut folds extension tools | |
| 421 | 421 | // into the agent's registry once the extension's ready frame fires. | |
| 422 | 422 | // | |
| 423 | - | // Conflicts with active built-in tool names are ignored. The native | |
| 424 | - | // web_search name is reserved even when that built-in is disabled, so an | |
| 425 | - | // extension cannot claim or replace it. | |
| 423 | + | // Conflicts with active built-in tool names (read, write, edit, bash, | |
| 424 | + | // create_worktree, lsp, web_search, and skill) are silently shadowed by the | |
| 425 | + | // built-in. The native web_search name is reserved even when that built-in is | |
| 426 | + | // disabled, so an extension cannot claim or replace it. | |
| 426 | 427 | func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) { | |
| 427 | 428 | e.registerTool(name, description, schema, false, fn) | |
| 428 | 429 | } | |
| @@ -420,9 +420,10 @@ func (e *Extension) Command(name, description string, fn CommandHandler) { | |||||
| 420 | // OpenAI accept). Call this BEFORE Run(); zut folds extension tools | 420 | // OpenAI accept). Call this BEFORE Run(); zut folds extension tools | ||
| 421 | // into the agent's registry once the extension's ready frame fires. | 421 | // into the agent's registry once the extension's ready frame fires. | ||
| 422 | // | 422 | // | ||
| 423 | - | // Conflicts with active built-in tool names are ignored. The native | 423 | + | // Conflicts with active built-in tool names (read, write, edit, bash, |
| 424 | - | // web_search name is reserved even when that built-in is disabled, so an | 424 | + | // create_worktree, lsp, web_search, and skill) are silently shadowed by the |
| 425 | - | // extension cannot claim or replace it. | 425 | + | // built-in. The native web_search name is reserved even when that built-in is |
| 426 | + | // disabled, so an extension cannot claim or replace it. | |||
| 426 | func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) { | 427 | func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) { | ||
| 427 | e.registerTool(name, description, schema, false, fn) | 428 | e.registerTool(name, description, schema, false, fn) | ||
| 428 | } | 429 | } | ||
M packages/agent/modes/compact_queue_test.go +133 -5
| @@ -153,7 +153,7 @@ func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) { | |||
| 153 | 153 | interactive := NewInteractive(InteractiveConfig{Agent: agent}) | |
| 154 | 154 | interactive.runCtx = context.Background() | |
| 155 | 155 | ||
| 156 | - | interactive.runCompact(context.Background(), false) | |
| 156 | + | interactive.runCompact(context.Background(), false, false) | |
| 157 | 157 | select { | |
| 158 | 158 | case <-client.compactionStarted: | |
| 159 | 159 | case <-time.After(2 * time.Second): | |
| @@ -178,9 +178,12 @@ type thresholdAutoCompactClient struct { | |||
| 178 | 178 | compactionStarted chan struct{} | |
| 179 | 179 | releaseCompaction chan struct{} | |
| 180 | 180 | followUpRequest chan provider.Request | |
| 181 | + | firstStop provider.StopReason | |
| 182 | + | firstContent []provider.Content | |
| 181 | 183 | ||
| 182 | - | mu sync.Mutex | |
| 183 | - | calls int | |
| 184 | + | mu sync.Mutex | |
| 185 | + | calls int | |
| 186 | + | followUps int | |
| 184 | 187 | } | |
| 185 | 188 | ||
| 186 | 189 | func (c *thresholdAutoCompactClient) Name() string { return "threshold-auto-compact-test" } | |
| @@ -196,12 +199,20 @@ func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Re | |||
| 196 | 199 | defer close(out) | |
| 197 | 200 | switch call { | |
| 198 | 201 | case 1: | |
| 202 | + | stop := c.firstStop | |
| 203 | + | if stop == "" { | |
| 204 | + | stop = provider.StopEnd | |
| 205 | + | } | |
| 206 | + | content := c.firstContent | |
| 207 | + | if len(content) == 0 { | |
| 208 | + | content = []provider.Content{provider.ReasoningBlock{Summary: "still working"}} | |
| 209 | + | } | |
| 199 | 210 | out <- provider.EventUsage{Usage: provider.Usage{InputTokens: 150000}} | |
| 200 | 211 | out <- provider.EventDone{ | |
| 201 | - | Stop: provider.StopEnd, | |
| 212 | + | Stop: stop, | |
| 202 | 213 | Message: provider.Message{ | |
| 203 | 214 | Role: provider.RoleAssistant, | |
| 204 | - | Content: []provider.Content{provider.ReasoningBlock{Summary: "still working"}}, | |
| 215 | + | Content: content, | |
| 205 | 216 | }, | |
| 206 | 217 | } | |
| 207 | 218 | case 2: | |
| @@ -214,6 +225,9 @@ func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Re | |||
| 214 | 225 | out <- provider.EventDone{Stop: provider.StopEnd} | |
| 215 | 226 | } | |
| 216 | 227 | default: | |
| 228 | + | c.mu.Lock() | |
| 229 | + | c.followUps++ | |
| 230 | + | c.mu.Unlock() | |
| 217 | 231 | c.followUpRequest <- req | |
| 218 | 232 | out <- provider.EventDone{ | |
| 219 | 233 | Stop: provider.StopEnd, | |
| @@ -261,6 +275,120 @@ func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) { | |||
| 261 | 275 | } | |
| 262 | 276 | } | |
| 263 | 277 | ||
| 278 | + | func TestThresholdAutoCompactionContinuesTruncatedText(t *testing.T) { | |
| 279 | + | client := &thresholdAutoCompactClient{ | |
| 280 | + | compactionStarted: make(chan struct{}), | |
| 281 | + | releaseCompaction: make(chan struct{}), | |
| 282 | + | followUpRequest: make(chan provider.Request, 1), | |
| 283 | + | firstStop: provider.StopLength, | |
| 284 | + | firstContent: []provider.Content{provider.TextBlock{Text: "partial answer"}}, | |
| 285 | + | } | |
| 286 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 287 | + | threshold := 70 | |
| 288 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 289 | + | Agent: agent, | |
| 290 | + | Provider: "anthropic", | |
| 291 | + | Model: "claude-sonnet-4-5-20250929", | |
| 292 | + | AutoCompactThreshold: &threshold, | |
| 293 | + | }) | |
| 294 | + | interactive.runCtx = context.Background() | |
| 295 | + | ||
| 296 | + | interactive.startTurn(context.Background(), "initial request") | |
| 297 | + | select { | |
| 298 | + | case <-client.compactionStarted: | |
| 299 | + | case <-time.After(2 * time.Second): | |
| 300 | + | t.Fatal("threshold auto-compaction did not start after truncated text") | |
| 301 | + | } | |
| 302 | + | close(client.releaseCompaction) | |
| 303 | + | ||
| 304 | + | select { | |
| 305 | + | case req := <-client.followUpRequest: | |
| 306 | + | if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 { | |
| 307 | + | t.Fatalf("truncated continuation request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages) | |
| 308 | + | } | |
| 309 | + | case <-time.After(2 * time.Second): | |
| 310 | + | t.Fatal("threshold auto-compaction did not continue truncated text") | |
| 311 | + | } | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | func TestThresholdAutoCompactionForcedContinuationPrecedesQueuedPrompt(t *testing.T) { | |
| 315 | + | client := &thresholdAutoCompactClient{ | |
| 316 | + | compactionStarted: make(chan struct{}), | |
| 317 | + | releaseCompaction: make(chan struct{}), | |
| 318 | + | followUpRequest: make(chan provider.Request, 2), | |
| 319 | + | firstStop: provider.StopLength, | |
| 320 | + | firstContent: []provider.Content{provider.TextBlock{Text: "partial answer"}}, | |
| 321 | + | } | |
| 322 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 323 | + | threshold := 70 | |
| 324 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 325 | + | Agent: agent, | |
| 326 | + | Provider: "anthropic", | |
| 327 | + | Model: "claude-sonnet-4-5-20250929", | |
| 328 | + | AutoCompactThreshold: &threshold, | |
| 329 | + | }) | |
| 330 | + | interactive.runCtx = context.Background() | |
| 331 | + | ||
| 332 | + | interactive.startTurn(context.Background(), "initial request") | |
| 333 | + | select { | |
| 334 | + | case <-client.compactionStarted: | |
| 335 | + | case <-time.After(2 * time.Second): | |
| 336 | + | t.Fatal("threshold auto-compaction did not start after truncated text") | |
| 337 | + | } | |
| 338 | + | interactive.SubmitOrQueue("queued follow-up", nil) | |
| 339 | + | close(client.releaseCompaction) | |
| 340 | + | ||
| 341 | + | select { | |
| 342 | + | case req := <-client.followUpRequest: | |
| 343 | + | if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 { | |
| 344 | + | t.Fatalf("first request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages) | |
| 345 | + | } | |
| 346 | + | if got := requestUserTextCount(req, "queued follow-up"); got != 0 { | |
| 347 | + | t.Fatalf("forced continuation request included queued prompt %d times, want 0: %#v", got, req.Messages) | |
| 348 | + | } | |
| 349 | + | case <-time.After(2 * time.Second): | |
| 350 | + | t.Fatal("threshold auto-compaction did not continue truncated text") | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | select { | |
| 354 | + | case req := <-client.followUpRequest: | |
| 355 | + | if got := requestUserTextCount(req, "queued follow-up"); got != 1 { | |
| 356 | + | t.Fatalf("second request contains queued prompt %d times, want 1: %#v", got, req.Messages) | |
| 357 | + | } | |
| 358 | + | case <-time.After(2 * time.Second): | |
| 359 | + | t.Fatal("queued prompt did not run after forced continuation") | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | deadline := time.NewTimer(2 * time.Second) | |
| 363 | + | defer deadline.Stop() | |
| 364 | + | poll := time.NewTicker(time.Millisecond) | |
| 365 | + | defer poll.Stop() | |
| 366 | + | for { | |
| 367 | + | interactive.mu.Lock() | |
| 368 | + | idle := !interactive.busy | |
| 369 | + | interactive.mu.Unlock() | |
| 370 | + | if idle { | |
| 371 | + | break | |
| 372 | + | } | |
| 373 | + | select { | |
| 374 | + | case <-deadline.C: | |
| 375 | + | t.Fatal("interactive did not settle after forced continuation and queued prompt") | |
| 376 | + | case <-poll.C: | |
| 377 | + | } | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | client.mu.Lock() | |
| 381 | + | calls := client.calls | |
| 382 | + | followUps := client.followUps | |
| 383 | + | client.mu.Unlock() | |
| 384 | + | if calls != 4 { | |
| 385 | + | t.Fatalf("provider called %d times, want initial request, compaction, forced continuation, and queued prompt", calls) | |
| 386 | + | } | |
| 387 | + | if followUps != 2 { | |
| 388 | + | t.Fatalf("follow-up requests = %d, want forced continuation and queued prompt", followUps) | |
| 389 | + | } | |
| 390 | + | } | |
| 391 | + | ||
| 264 | 392 | func TestPreTurnCompactionPreservesPromptImages(t *testing.T) { | |
| 265 | 393 | client := &compactQueueClient{ | |
| 266 | 394 | compactionStarted: make(chan struct{}), | |
| @@ -153,7 +153,7 @@ func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) { | |||||
| 153 | interactive := NewInteractive(InteractiveConfig{Agent: agent}) | 153 | interactive := NewInteractive(InteractiveConfig{Agent: agent}) | ||
| 154 | interactive.runCtx = context.Background() | 154 | interactive.runCtx = context.Background() | ||
| 155 | 155 | ||||
| 156 | - | interactive.runCompact(context.Background(), false) | 156 | + | interactive.runCompact(context.Background(), false, false) |
| 157 | select { | 157 | select { | ||
| 158 | case <-client.compactionStarted: | 158 | case <-client.compactionStarted: | ||
| 159 | case <-time.After(2 * time.Second): | 159 | case <-time.After(2 * time.Second): | ||
| @@ -178,9 +178,12 @@ type thresholdAutoCompactClient struct { | |||||
| 178 | compactionStarted chan struct{} | 178 | compactionStarted chan struct{} | ||
| 179 | releaseCompaction chan struct{} | 179 | releaseCompaction chan struct{} | ||
| 180 | followUpRequest chan provider.Request | 180 | followUpRequest chan provider.Request | ||
| 181 | + | firstStop provider.StopReason | |||
| 182 | + | firstContent []provider.Content | |||
| 181 | 183 | ||||
| 182 | - | mu sync.Mutex | 184 | + | mu sync.Mutex |
| 183 | - | calls int | 185 | + | calls int |
| 186 | + | followUps int | |||
| 184 | } | 187 | } | ||
| 185 | 188 | ||||
| 186 | func (c *thresholdAutoCompactClient) Name() string { return "threshold-auto-compact-test" } | 189 | func (c *thresholdAutoCompactClient) Name() string { return "threshold-auto-compact-test" } | ||
| @@ -196,12 +199,20 @@ func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Re | |||||
| 196 | defer close(out) | 199 | defer close(out) | ||
| 197 | switch call { | 200 | switch call { | ||
| 198 | case 1: | 201 | case 1: | ||
| 202 | + | stop := c.firstStop | |||
| 203 | + | if stop == "" { | |||
| 204 | + | stop = provider.StopEnd | |||
| 205 | + | } | |||
| 206 | + | content := c.firstContent | |||
| 207 | + | if len(content) == 0 { | |||
| 208 | + | content = []provider.Content{provider.ReasoningBlock{Summary: "still working"}} | |||
| 209 | + | } | |||
| 199 | out <- provider.EventUsage{Usage: provider.Usage{InputTokens: 150000}} | 210 | out <- provider.EventUsage{Usage: provider.Usage{InputTokens: 150000}} | ||
| 200 | out <- provider.EventDone{ | 211 | out <- provider.EventDone{ | ||
| 201 | - | Stop: provider.StopEnd, | 212 | + | Stop: stop, |
| 202 | Message: provider.Message{ | 213 | Message: provider.Message{ | ||
| 203 | Role: provider.RoleAssistant, | 214 | Role: provider.RoleAssistant, | ||
| 204 | - | Content: []provider.Content{provider.ReasoningBlock{Summary: "still working"}}, | 215 | + | Content: content, |
| 205 | }, | 216 | }, | ||
| 206 | } | 217 | } | ||
| 207 | case 2: | 218 | case 2: | ||
| @@ -214,6 +225,9 @@ func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Re | |||||
| 214 | out <- provider.EventDone{Stop: provider.StopEnd} | 225 | out <- provider.EventDone{Stop: provider.StopEnd} | ||
| 215 | } | 226 | } | ||
| 216 | default: | 227 | default: | ||
| 228 | + | c.mu.Lock() | |||
| 229 | + | c.followUps++ | |||
| 230 | + | c.mu.Unlock() | |||
| 217 | c.followUpRequest <- req | 231 | c.followUpRequest <- req | ||
| 218 | out <- provider.EventDone{ | 232 | out <- provider.EventDone{ | ||
| 219 | Stop: provider.StopEnd, | 233 | Stop: provider.StopEnd, | ||
| @@ -261,6 +275,120 @@ func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) { | |||||
| 261 | } | 275 | } | ||
| 262 | } | 276 | } | ||
| 263 | 277 | ||||
| 278 | + | func TestThresholdAutoCompactionContinuesTruncatedText(t *testing.T) { | |||
| 279 | + | client := &thresholdAutoCompactClient{ | |||
| 280 | + | compactionStarted: make(chan struct{}), | |||
| 281 | + | releaseCompaction: make(chan struct{}), | |||
| 282 | + | followUpRequest: make(chan provider.Request, 1), | |||
| 283 | + | firstStop: provider.StopLength, | |||
| 284 | + | firstContent: []provider.Content{provider.TextBlock{Text: "partial answer"}}, | |||
| 285 | + | } | |||
| 286 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 287 | + | threshold := 70 | |||
| 288 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 289 | + | Agent: agent, | |||
| 290 | + | Provider: "anthropic", | |||
| 291 | + | Model: "claude-sonnet-4-5-20250929", | |||
| 292 | + | AutoCompactThreshold: &threshold, | |||
| 293 | + | }) | |||
| 294 | + | interactive.runCtx = context.Background() | |||
| 295 | + | ||||
| 296 | + | interactive.startTurn(context.Background(), "initial request") | |||
| 297 | + | select { | |||
| 298 | + | case <-client.compactionStarted: | |||
| 299 | + | case <-time.After(2 * time.Second): | |||
| 300 | + | t.Fatal("threshold auto-compaction did not start after truncated text") | |||
| 301 | + | } | |||
| 302 | + | close(client.releaseCompaction) | |||
| 303 | + | ||||
| 304 | + | select { | |||
| 305 | + | case req := <-client.followUpRequest: | |||
| 306 | + | if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 { | |||
| 307 | + | t.Fatalf("truncated continuation request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages) | |||
| 308 | + | } | |||
| 309 | + | case <-time.After(2 * time.Second): | |||
| 310 | + | t.Fatal("threshold auto-compaction did not continue truncated text") | |||
| 311 | + | } | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | func TestThresholdAutoCompactionForcedContinuationPrecedesQueuedPrompt(t *testing.T) { | |||
| 315 | + | client := &thresholdAutoCompactClient{ | |||
| 316 | + | compactionStarted: make(chan struct{}), | |||
| 317 | + | releaseCompaction: make(chan struct{}), | |||
| 318 | + | followUpRequest: make(chan provider.Request, 2), | |||
| 319 | + | firstStop: provider.StopLength, | |||
| 320 | + | firstContent: []provider.Content{provider.TextBlock{Text: "partial answer"}}, | |||
| 321 | + | } | |||
| 322 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 323 | + | threshold := 70 | |||
| 324 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 325 | + | Agent: agent, | |||
| 326 | + | Provider: "anthropic", | |||
| 327 | + | Model: "claude-sonnet-4-5-20250929", | |||
| 328 | + | AutoCompactThreshold: &threshold, | |||
| 329 | + | }) | |||
| 330 | + | interactive.runCtx = context.Background() | |||
| 331 | + | ||||
| 332 | + | interactive.startTurn(context.Background(), "initial request") | |||
| 333 | + | select { | |||
| 334 | + | case <-client.compactionStarted: | |||
| 335 | + | case <-time.After(2 * time.Second): | |||
| 336 | + | t.Fatal("threshold auto-compaction did not start after truncated text") | |||
| 337 | + | } | |||
| 338 | + | interactive.SubmitOrQueue("queued follow-up", nil) | |||
| 339 | + | close(client.releaseCompaction) | |||
| 340 | + | ||||
| 341 | + | select { | |||
| 342 | + | case req := <-client.followUpRequest: | |||
| 343 | + | if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 { | |||
| 344 | + | t.Fatalf("first request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages) | |||
| 345 | + | } | |||
| 346 | + | if got := requestUserTextCount(req, "queued follow-up"); got != 0 { | |||
| 347 | + | t.Fatalf("forced continuation request included queued prompt %d times, want 0: %#v", got, req.Messages) | |||
| 348 | + | } | |||
| 349 | + | case <-time.After(2 * time.Second): | |||
| 350 | + | t.Fatal("threshold auto-compaction did not continue truncated text") | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | select { | |||
| 354 | + | case req := <-client.followUpRequest: | |||
| 355 | + | if got := requestUserTextCount(req, "queued follow-up"); got != 1 { | |||
| 356 | + | t.Fatalf("second request contains queued prompt %d times, want 1: %#v", got, req.Messages) | |||
| 357 | + | } | |||
| 358 | + | case <-time.After(2 * time.Second): | |||
| 359 | + | t.Fatal("queued prompt did not run after forced continuation") | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | deadline := time.NewTimer(2 * time.Second) | |||
| 363 | + | defer deadline.Stop() | |||
| 364 | + | poll := time.NewTicker(time.Millisecond) | |||
| 365 | + | defer poll.Stop() | |||
| 366 | + | for { | |||
| 367 | + | interactive.mu.Lock() | |||
| 368 | + | idle := !interactive.busy | |||
| 369 | + | interactive.mu.Unlock() | |||
| 370 | + | if idle { | |||
| 371 | + | break | |||
| 372 | + | } | |||
| 373 | + | select { | |||
| 374 | + | case <-deadline.C: | |||
| 375 | + | t.Fatal("interactive did not settle after forced continuation and queued prompt") | |||
| 376 | + | case <-poll.C: | |||
| 377 | + | } | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | client.mu.Lock() | |||
| 381 | + | calls := client.calls | |||
| 382 | + | followUps := client.followUps | |||
| 383 | + | client.mu.Unlock() | |||
| 384 | + | if calls != 4 { | |||
| 385 | + | t.Fatalf("provider called %d times, want initial request, compaction, forced continuation, and queued prompt", calls) | |||
| 386 | + | } | |||
| 387 | + | if followUps != 2 { | |||
| 388 | + | t.Fatalf("follow-up requests = %d, want forced continuation and queued prompt", followUps) | |||
| 389 | + | } | |||
| 390 | + | } | |||
| 391 | + | ||||
| 264 | func TestPreTurnCompactionPreservesPromptImages(t *testing.T) { | 392 | func TestPreTurnCompactionPreservesPromptImages(t *testing.T) { | ||
| 265 | client := &compactQueueClient{ | 393 | client := &compactQueueClient{ | ||
| 266 | compactionStarted: make(chan struct{}), | 394 | compactionStarted: make(chan struct{}), | ||
M packages/agent/modes/fast_mode_test.go +33 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 9 | 10 | "github.com/bnema/zut/packages/core" | |
| 10 | 11 | "github.com/bnema/zut/packages/provider" | |
| 11 | 12 | ) | |
| @@ -76,6 +77,38 @@ func TestApplyFastModeUpdatesAgentAndStore(t *testing.T) { | |||
| 76 | 77 | } | |
| 77 | 78 | } | |
| 78 | 79 | ||
| 80 | + | func TestFastSlashTogglesFastMode(t *testing.T) { | |
| 81 | + | store := &recordingFastModeSettingsStore{} | |
| 82 | + | agent := core.NewAgent(fastModeProviderClient{}, "gpt-5", "", nil) | |
| 83 | + | supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()}) | |
| 84 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 85 | + | Provider: "openai", | |
| 86 | + | Model: "gpt-5", | |
| 87 | + | SettingsStore: store, | |
| 88 | + | Agent: agent, | |
| 89 | + | Supervisor: supervisor, | |
| 90 | + | }) | |
| 91 | + | interactive.rend = nil | |
| 92 | + | ||
| 93 | + | if interactive.runSlash(context.Background(), "/FAST") { | |
| 94 | + | t.Fatal("/fast requested exit") | |
| 95 | + | } | |
| 96 | + | if interactive.cfg.FastMode == nil || !*interactive.cfg.FastMode || !agent.FastMode || !supervisor.FastMode() { | |
| 97 | + | t.Fatalf("/fast did not enable every live fast-mode target: cfg=%v agent=%v supervisor=%v", interactive.cfg.FastMode, agent.FastMode, supervisor.FastMode()) | |
| 98 | + | } | |
| 99 | + | if store.setCalls != 1 || !store.last || interactive.statusOK != "fast mode enabled" { | |
| 100 | + | t.Fatalf("enable persistence/status = calls=%d last=%v status=%q", store.setCalls, store.last, interactive.statusOK) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | interactive.runSlash(context.Background(), "/fast") | |
| 104 | + | if interactive.cfg.FastMode == nil || *interactive.cfg.FastMode || agent.FastMode || supervisor.FastMode() { | |
| 105 | + | t.Fatalf("/fast did not disable every live fast-mode target: cfg=%v agent=%v supervisor=%v", interactive.cfg.FastMode, agent.FastMode, supervisor.FastMode()) | |
| 106 | + | } | |
| 107 | + | if store.setCalls != 2 || store.last || interactive.statusOK != "fast mode disabled" { | |
| 108 | + | t.Fatalf("disable persistence/status = calls=%d last=%v status=%q", store.setCalls, store.last, interactive.statusOK) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 79 | 112 | func TestApplyFastModeRejectsUnsupportedProvider(t *testing.T) { | |
| 80 | 113 | store := &recordingFastModeSettingsStore{} | |
| 81 | 114 | interactive := NewInteractive(InteractiveConfig{ | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "strings" | 6 | "strings" | ||
| 7 | "testing" | 7 | "testing" | ||
| 8 | 8 | ||||
| 9 | + | "github.com/bnema/zut/packages/agent/subagents" | |||
| 9 | "github.com/bnema/zut/packages/core" | 10 | "github.com/bnema/zut/packages/core" | ||
| 10 | "github.com/bnema/zut/packages/provider" | 11 | "github.com/bnema/zut/packages/provider" | ||
| 11 | ) | 12 | ) | ||
| @@ -76,6 +77,38 @@ func TestApplyFastModeUpdatesAgentAndStore(t *testing.T) { | |||||
| 76 | } | 77 | } | ||
| 77 | } | 78 | } | ||
| 78 | 79 | ||||
| 80 | + | func TestFastSlashTogglesFastMode(t *testing.T) { | |||
| 81 | + | store := &recordingFastModeSettingsStore{} | |||
| 82 | + | agent := core.NewAgent(fastModeProviderClient{}, "gpt-5", "", nil) | |||
| 83 | + | supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()}) | |||
| 84 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 85 | + | Provider: "openai", | |||
| 86 | + | Model: "gpt-5", | |||
| 87 | + | SettingsStore: store, | |||
| 88 | + | Agent: agent, | |||
| 89 | + | Supervisor: supervisor, | |||
| 90 | + | }) | |||
| 91 | + | interactive.rend = nil | |||
| 92 | + | ||||
| 93 | + | if interactive.runSlash(context.Background(), "/FAST") { | |||
| 94 | + | t.Fatal("/fast requested exit") | |||
| 95 | + | } | |||
| 96 | + | if interactive.cfg.FastMode == nil || !*interactive.cfg.FastMode || !agent.FastMode || !supervisor.FastMode() { | |||
| 97 | + | t.Fatalf("/fast did not enable every live fast-mode target: cfg=%v agent=%v supervisor=%v", interactive.cfg.FastMode, agent.FastMode, supervisor.FastMode()) | |||
| 98 | + | } | |||
| 99 | + | if store.setCalls != 1 || !store.last || interactive.statusOK != "fast mode enabled" { | |||
| 100 | + | t.Fatalf("enable persistence/status = calls=%d last=%v status=%q", store.setCalls, store.last, interactive.statusOK) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | interactive.runSlash(context.Background(), "/fast") | |||
| 104 | + | if interactive.cfg.FastMode == nil || *interactive.cfg.FastMode || agent.FastMode || supervisor.FastMode() { | |||
| 105 | + | t.Fatalf("/fast did not disable every live fast-mode target: cfg=%v agent=%v supervisor=%v", interactive.cfg.FastMode, agent.FastMode, supervisor.FastMode()) | |||
| 106 | + | } | |||
| 107 | + | if store.setCalls != 2 || store.last || interactive.statusOK != "fast mode disabled" { | |||
| 108 | + | t.Fatalf("disable persistence/status = calls=%d last=%v status=%q", store.setCalls, store.last, interactive.statusOK) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 79 | func TestApplyFastModeRejectsUnsupportedProvider(t *testing.T) { | 112 | func TestApplyFastModeRejectsUnsupportedProvider(t *testing.T) { | ||
| 80 | store := &recordingFastModeSettingsStore{} | 113 | store := &recordingFastModeSettingsStore{} | ||
| 81 | interactive := NewInteractive(InteractiveConfig{ | 114 | interactive := NewInteractive(InteractiveConfig{ | ||
M packages/agent/modes/interactive.go +18 -11
| @@ -4362,7 +4362,7 @@ func (i *Interactive) openSettingsDialog() { | |||
| 4362 | 4362 | { | |
| 4363 | 4363 | key: "fast_mode", | |
| 4364 | 4364 | label: "fast mode", | |
| 4365 | - | desc: "request OpenAI's fast service tier; unsupported providers return an error", | |
| 4365 | + | desc: "request the provider's fast tier where supported; unsupported providers return an error", | |
| 4366 | 4366 | value: fastMode, | |
| 4367 | 4367 | hint: fastModeHint, | |
| 4368 | 4368 | }, | |
| @@ -5713,6 +5713,9 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 5713 | 5713 | } | |
| 5714 | 5714 | case "/reasoning": | |
| 5715 | 5715 | i.openReasoningDialog() | |
| 5716 | + | case "/fast": | |
| 5717 | + | enabled := i.cfg.FastMode == nil || !*i.cfg.FastMode | |
| 5718 | + | i.applySettingToggle("fast_mode", enabled) | |
| 5716 | 5719 | case "/settings": | |
| 5717 | 5720 | i.openSettingsDialog() | |
| 5718 | 5721 | case "/sessions": | |
| @@ -5728,7 +5731,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 5728 | 5731 | case "/skills": | |
| 5729 | 5732 | i.openSkillsDialog() | |
| 5730 | 5733 | case "/compact": | |
| 5731 | - | i.runCompact(ctx, false) | |
| 5734 | + | i.runCompact(ctx, false, false) | |
| 5732 | 5735 | case "/study": | |
| 5733 | 5736 | // Canned prompt that tells the agent to read every file | |
| 5734 | 5737 | // in some target so its later turns have the whole thing | |
| @@ -6674,7 +6677,7 @@ func (i *Interactive) clearPendingCompactTurnLocked() { | |||
| 6674 | 6677 | i.pendingPostCompactNote = "" | |
| 6675 | 6678 | } | |
| 6676 | 6679 | ||
| 6677 | - | const autoCompactContinuationPrompt = `Resume work on the user's most recent intent. Re-read the kept recent messages after the summary to confirm what the user asked for last. If their latest request supersedes earlier plans recorded in the summary, follow the latest request. If there is nothing left to do, say so briefly instead of inventing further work.` | |
| 6680 | + | const autoCompactContinuationPrompt = `Context was compacted while work was in progress. Continue the user's most recent active request now; do not wait for them to type "continue". Re-read the context summary and kept recent messages, treating active constraints and preferences there as still in force, including delegation/subagent instructions. If a newer request supersedes earlier plans in the summary, follow the newer request. If there is truly nothing left to do, say so briefly instead of inventing work.` | |
| 6678 | 6681 | ||
| 6679 | 6682 | // startAutoCompactContinuation adds an internal user turn that tells the | |
| 6680 | 6683 | // model to resume the task after threshold compaction. Provider requests | |
| @@ -6709,7 +6712,9 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) { | |||
| 6709 | 6712 | // When auto is true the spinner message is pinned to "condensing | |
| 6710 | 6713 | // history" and the status bar surfaces "(auto)" next to the context | |
| 6711 | 6714 | // percentage so it's obvious the system triggered this, not the user. | |
| 6712 | - | func (i *Interactive) runCompact(parent context.Context, auto bool) { | |
| 6715 | + | // forceAutoContinue tells the post-compact hand-off to resume even if the | |
| 6716 | + | // transcript tail has visible assistant text, for example after StopLength. | |
| 6717 | + | func (i *Interactive) runCompact(parent context.Context, auto bool, forceAutoContinue bool) { | |
| 6713 | 6718 | if i.agent == nil { | |
| 6714 | 6719 | i.mu.Lock() | |
| 6715 | 6720 | i.statusErr = "not logged in. type /login first." | |
| @@ -6743,7 +6748,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||
| 6743 | 6748 | // Sink discards deltas — we don't stream the summary to the UI. | |
| 6744 | 6749 | sink := func(delta string) {} | |
| 6745 | 6750 | msgsBefore := i.agent.Messages() | |
| 6746 | - | autoContinue := auto && shouldAutoContinueAfterCompaction(msgsBefore) | |
| 6751 | + | autoContinue := auto && (forceAutoContinue || shouldAutoContinueAfterCompaction(msgsBefore)) | |
| 6747 | 6752 | // Keep the usual recent tail when possible, but never let it cover | |
| 6748 | 6753 | // the whole transcript. A short session can still be at 70–90% of | |
| 6749 | 6754 | // a model's window because one prompt or tool result is large; the | |
| @@ -6829,14 +6834,16 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||
| 6829 | 6834 | i.pendingCompactImages = nil | |
| 6830 | 6835 | i.hasPendingCompactPrompt = false | |
| 6831 | 6836 | hasNext = true | |
| 6837 | + | case forceAutoContinue && autoContinue: | |
| 6838 | + | continueAutomatically = true | |
| 6832 | 6839 | case len(i.queued) > 0: | |
| 6833 | 6840 | next, i.queued = i.queued[0], i.queued[1:] | |
| 6834 | 6841 | hasNext = true | |
| 6835 | 6842 | case autoContinue: | |
| 6836 | 6843 | // A successful threshold compaction must hand control back to | |
| 6837 | - | // an unfinished model turn. Completed text answers should settle | |
| 6838 | - | // without an invented extra request; the user can still prompt | |
| 6839 | - | // again normally. | |
| 6844 | + | // unfinished work. Completed text answers still settle normally | |
| 6845 | + | // unless the caller knows the text was truncated and forces this | |
| 6846 | + | // continuation. | |
| 6840 | 6847 | continueAutomatically = true | |
| 6841 | 6848 | } | |
| 6842 | 6849 | } | |
| @@ -7047,7 +7054,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 7047 | 7054 | i.pendingPostCompactNote = "context auto-compacted; sending your last message" | |
| 7048 | 7055 | i.mu.Unlock() | |
| 7049 | 7056 | i.invalidate() | |
| 7050 | - | i.runCompact(parent, true) | |
| 7057 | + | i.runCompact(parent, true, false) | |
| 7051 | 7058 | return | |
| 7052 | 7059 | } | |
| 7053 | 7060 | i.mu.Unlock() | |
| @@ -7242,9 +7249,9 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 7242 | 7249 | case offer: | |
| 7243 | 7250 | i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs) | |
| 7244 | 7251 | case recoverContextOverflow: | |
| 7245 | - | i.runCompact(parent, true) | |
| 7252 | + | i.runCompact(parent, true, false) | |
| 7246 | 7253 | case shouldAutoCompact: | |
| 7247 | - | i.runCompact(parent, true) | |
| 7254 | + | i.runCompact(parent, true, lastStop == provider.StopLength) | |
| 7248 | 7255 | } | |
| 7249 | 7256 | }() | |
| 7250 | 7257 | } | |
| @@ -4362,7 +4362,7 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 4362 | { | 4362 | { | ||
| 4363 | key: "fast_mode", | 4363 | key: "fast_mode", | ||
| 4364 | label: "fast mode", | 4364 | label: "fast mode", | ||
| 4365 | - | desc: "request OpenAI's fast service tier; unsupported providers return an error", | 4365 | + | desc: "request the provider's fast tier where supported; unsupported providers return an error", |
| 4366 | value: fastMode, | 4366 | value: fastMode, | ||
| 4367 | hint: fastModeHint, | 4367 | hint: fastModeHint, | ||
| 4368 | }, | 4368 | }, | ||
| @@ -5713,6 +5713,9 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 5713 | } | 5713 | } | ||
| 5714 | case "/reasoning": | 5714 | case "/reasoning": | ||
| 5715 | i.openReasoningDialog() | 5715 | i.openReasoningDialog() | ||
| 5716 | + | case "/fast": | |||
| 5717 | + | enabled := i.cfg.FastMode == nil || !*i.cfg.FastMode | |||
| 5718 | + | i.applySettingToggle("fast_mode", enabled) | |||
| 5716 | case "/settings": | 5719 | case "/settings": | ||
| 5717 | i.openSettingsDialog() | 5720 | i.openSettingsDialog() | ||
| 5718 | case "/sessions": | 5721 | case "/sessions": | ||
| @@ -5728,7 +5731,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 5728 | case "/skills": | 5731 | case "/skills": | ||
| 5729 | i.openSkillsDialog() | 5732 | i.openSkillsDialog() | ||
| 5730 | case "/compact": | 5733 | case "/compact": | ||
| 5731 | - | i.runCompact(ctx, false) | 5734 | + | i.runCompact(ctx, false, false) |
| 5732 | case "/study": | 5735 | case "/study": | ||
| 5733 | // Canned prompt that tells the agent to read every file | 5736 | // Canned prompt that tells the agent to read every file | ||
| 5734 | // in some target so its later turns have the whole thing | 5737 | // in some target so its later turns have the whole thing | ||
| @@ -6674,7 +6677,7 @@ func (i *Interactive) clearPendingCompactTurnLocked() { | |||||
| 6674 | i.pendingPostCompactNote = "" | 6677 | i.pendingPostCompactNote = "" | ||
| 6675 | } | 6678 | } | ||
| 6676 | 6679 | ||||
| 6677 | - | const autoCompactContinuationPrompt = `Resume work on the user's most recent intent. Re-read the kept recent messages after the summary to confirm what the user asked for last. If their latest request supersedes earlier plans recorded in the summary, follow the latest request. If there is nothing left to do, say so briefly instead of inventing further work.` | 6680 | + | const autoCompactContinuationPrompt = `Context was compacted while work was in progress. Continue the user's most recent active request now; do not wait for them to type "continue". Re-read the context summary and kept recent messages, treating active constraints and preferences there as still in force, including delegation/subagent instructions. If a newer request supersedes earlier plans in the summary, follow the newer request. If there is truly nothing left to do, say so briefly instead of inventing work.` |
| 6678 | 6681 | ||||
| 6679 | // startAutoCompactContinuation adds an internal user turn that tells the | 6682 | // startAutoCompactContinuation adds an internal user turn that tells the | ||
| 6680 | // model to resume the task after threshold compaction. Provider requests | 6683 | // model to resume the task after threshold compaction. Provider requests | ||
| @@ -6709,7 +6712,9 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) { | |||||
| 6709 | // When auto is true the spinner message is pinned to "condensing | 6712 | // When auto is true the spinner message is pinned to "condensing | ||
| 6710 | // history" and the status bar surfaces "(auto)" next to the context | 6713 | // history" and the status bar surfaces "(auto)" next to the context | ||
| 6711 | // percentage so it's obvious the system triggered this, not the user. | 6714 | // percentage so it's obvious the system triggered this, not the user. | ||
| 6712 | - | func (i *Interactive) runCompact(parent context.Context, auto bool) { | 6715 | + | // forceAutoContinue tells the post-compact hand-off to resume even if the |
| 6716 | + | // transcript tail has visible assistant text, for example after StopLength. | |||
| 6717 | + | func (i *Interactive) runCompact(parent context.Context, auto bool, forceAutoContinue bool) { | |||
| 6713 | if i.agent == nil { | 6718 | if i.agent == nil { | ||
| 6714 | i.mu.Lock() | 6719 | i.mu.Lock() | ||
| 6715 | i.statusErr = "not logged in. type /login first." | 6720 | i.statusErr = "not logged in. type /login first." | ||
| @@ -6743,7 +6748,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||||
| 6743 | // Sink discards deltas — we don't stream the summary to the UI. | 6748 | // Sink discards deltas — we don't stream the summary to the UI. | ||
| 6744 | sink := func(delta string) {} | 6749 | sink := func(delta string) {} | ||
| 6745 | msgsBefore := i.agent.Messages() | 6750 | msgsBefore := i.agent.Messages() | ||
| 6746 | - | autoContinue := auto && shouldAutoContinueAfterCompaction(msgsBefore) | 6751 | + | autoContinue := auto && (forceAutoContinue || shouldAutoContinueAfterCompaction(msgsBefore)) |
| 6747 | // Keep the usual recent tail when possible, but never let it cover | 6752 | // Keep the usual recent tail when possible, but never let it cover | ||
| 6748 | // the whole transcript. A short session can still be at 70–90% of | 6753 | // the whole transcript. A short session can still be at 70–90% of | ||
| 6749 | // a model's window because one prompt or tool result is large; the | 6754 | // a model's window because one prompt or tool result is large; the | ||
| @@ -6829,14 +6834,16 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||||
| 6829 | i.pendingCompactImages = nil | 6834 | i.pendingCompactImages = nil | ||
| 6830 | i.hasPendingCompactPrompt = false | 6835 | i.hasPendingCompactPrompt = false | ||
| 6831 | hasNext = true | 6836 | hasNext = true | ||
| 6837 | + | case forceAutoContinue && autoContinue: | |||
| 6838 | + | continueAutomatically = true | |||
| 6832 | case len(i.queued) > 0: | 6839 | case len(i.queued) > 0: | ||
| 6833 | next, i.queued = i.queued[0], i.queued[1:] | 6840 | next, i.queued = i.queued[0], i.queued[1:] | ||
| 6834 | hasNext = true | 6841 | hasNext = true | ||
| 6835 | case autoContinue: | 6842 | case autoContinue: | ||
| 6836 | // A successful threshold compaction must hand control back to | 6843 | // A successful threshold compaction must hand control back to | ||
| 6837 | - | // an unfinished model turn. Completed text answers should settle | 6844 | + | // unfinished work. Completed text answers still settle normally |
| 6838 | - | // without an invented extra request; the user can still prompt | 6845 | + | // unless the caller knows the text was truncated and forces this |
| 6839 | - | // again normally. | 6846 | + | // continuation. |
| 6840 | continueAutomatically = true | 6847 | continueAutomatically = true | ||
| 6841 | } | 6848 | } | ||
| 6842 | } | 6849 | } | ||
| @@ -7047,7 +7054,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 7047 | i.pendingPostCompactNote = "context auto-compacted; sending your last message" | 7054 | i.pendingPostCompactNote = "context auto-compacted; sending your last message" | ||
| 7048 | i.mu.Unlock() | 7055 | i.mu.Unlock() | ||
| 7049 | i.invalidate() | 7056 | i.invalidate() | ||
| 7050 | - | i.runCompact(parent, true) | 7057 | + | i.runCompact(parent, true, false) |
| 7051 | return | 7058 | return | ||
| 7052 | } | 7059 | } | ||
| 7053 | i.mu.Unlock() | 7060 | i.mu.Unlock() | ||
| @@ -7242,9 +7249,9 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 7242 | case offer: | 7249 | case offer: | ||
| 7243 | i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs) | 7250 | i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs) | ||
| 7244 | case recoverContextOverflow: | 7251 | case recoverContextOverflow: | ||
| 7245 | - | i.runCompact(parent, true) | 7252 | + | i.runCompact(parent, true, false) |
| 7246 | case shouldAutoCompact: | 7253 | case shouldAutoCompact: | ||
| 7247 | - | i.runCompact(parent, true) | 7254 | + | i.runCompact(parent, true, lastStop == provider.StopLength) |
| 7248 | } | 7255 | } | ||
| 7249 | }() | 7256 | }() | ||
| 7250 | } | 7257 | } | ||
M packages/agent/modes/slash_suggest.go +1 -0
| @@ -51,6 +51,7 @@ var slashCatalog = []slashCommand{ | |||
| 51 | 51 | {Name: "/logout", Desc: "clear a provider's credentials"}, | |
| 52 | 52 | {Name: "/model", Desc: "pick a model (or /model <id>)"}, | |
| 53 | 53 | {Name: "/reasoning", Desc: "set the reasoning level"}, | |
| 54 | + | {Name: "/fast", Desc: "toggle fast mode"}, | |
| 54 | 55 | {Name: "/llama", Desc: "manage llama.cpp router models"}, | |
| 55 | 56 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | |
| 56 | 57 | {Name: "/fork", Desc: "pick a user message and fork the current session"}, | |
| @@ -51,6 +51,7 @@ var slashCatalog = []slashCommand{ | |||||
| 51 | {Name: "/logout", Desc: "clear a provider's credentials"}, | 51 | {Name: "/logout", Desc: "clear a provider's credentials"}, | ||
| 52 | {Name: "/model", Desc: "pick a model (or /model <id>)"}, | 52 | {Name: "/model", Desc: "pick a model (or /model <id>)"}, | ||
| 53 | {Name: "/reasoning", Desc: "set the reasoning level"}, | 53 | {Name: "/reasoning", Desc: "set the reasoning level"}, | ||
| 54 | + | {Name: "/fast", Desc: "toggle fast mode"}, | |||
| 54 | {Name: "/llama", Desc: "manage llama.cpp router models"}, | 55 | {Name: "/llama", Desc: "manage llama.cpp router models"}, | ||
| 55 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | 56 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | ||
| 56 | {Name: "/fork", Desc: "pick a user message and fork the current session"}, | 57 | {Name: "/fork", Desc: "pick a user message and fork the current session"}, | ||
M packages/agent/modes/slash_suggest_test.go +10 -0
| @@ -44,6 +44,16 @@ func TestSlashSuggesterHasSupervisor(t *testing.T) { | |||
| 44 | 44 | } | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | + | func TestSlashSuggesterHasFastModeToggle(t *testing.T) { | |
| 48 | + | s := newSlashSuggester() | |
| 49 | + | if got := commandNames(s.matches("/fa")); !contains(got, "/fast") { | |
| 50 | + | t.Fatalf("/fast missing from suggestions, got %v", got) | |
| 51 | + | } | |
| 52 | + | if !isKnownSlashCommand("/FAST") { | |
| 53 | + | t.Fatal("/FAST was not recognized as a built-in command") | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 47 | 57 | func TestSlashCommandsAreCaseInsensitive(t *testing.T) { | |
| 48 | 58 | s := newSlashSuggester() | |
| 49 | 59 | if got := commandNames(s.matches("/EX")); !contains(got, "/exit") { | |
| @@ -44,6 +44,16 @@ func TestSlashSuggesterHasSupervisor(t *testing.T) { | |||||
| 44 | } | 44 | } | ||
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
| 47 | + | func TestSlashSuggesterHasFastModeToggle(t *testing.T) { | |||
| 48 | + | s := newSlashSuggester() | |||
| 49 | + | if got := commandNames(s.matches("/fa")); !contains(got, "/fast") { | |||
| 50 | + | t.Fatalf("/fast missing from suggestions, got %v", got) | |||
| 51 | + | } | |||
| 52 | + | if !isKnownSlashCommand("/FAST") { | |||
| 53 | + | t.Fatal("/FAST was not recognized as a built-in command") | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 47 | func TestSlashCommandsAreCaseInsensitive(t *testing.T) { | 57 | func TestSlashCommandsAreCaseInsensitive(t *testing.T) { | ||
| 48 | s := newSlashSuggester() | 58 | s := newSlashSuggester() | ||
| 49 | if got := commandNames(s.matches("/EX")); !contains(got, "/exit") { | 59 | if got := commandNames(s.matches("/EX")); !contains(got, "/exit") { | ||
M packages/agent/modes/subagents_dialog.go +16 -0
| @@ -49,6 +49,9 @@ type subagentsDialog struct { | |||
| 49 | 49 | // session. Wired by Open(); when nil the inline 'R' shortcut is | |
| 50 | 50 | // disabled. | |
| 51 | 51 | resume func(id string) error | |
| 52 | + | // loadTranscript hydrates a detached agent's durable event history when | |
| 53 | + | // its transcript view is opened. Nil preserves snapshot-only behavior. | |
| 54 | + | loadTranscript func(id string) error | |
| 52 | 55 | ||
| 53 | 56 | rows []subagents.AgentSnapshot | |
| 54 | 57 | cursor int | |
| @@ -123,6 +126,11 @@ func (d *subagentsDialog) SetLineInput(enabled bool) { | |||
| 123 | 126 | d.lineInput = enabled | |
| 124 | 127 | } | |
| 125 | 128 | ||
| 129 | + | // SetLoadTranscript supplies the optional on-demand durable transcript loader. | |
| 130 | + | func (d *subagentsDialog) SetLoadTranscript(fn func(id string) error) { | |
| 131 | + | d.loadTranscript = fn | |
| 132 | + | } | |
| 133 | + | ||
| 126 | 134 | func (d *subagentsDialog) editorPrompt(th tui.Theme) string { | |
| 127 | 135 | if d.lineInput { | |
| 128 | 136 | return "" | |
| @@ -362,6 +370,9 @@ func (d *subagentsDialog) OpenViewing( | |||
| 362 | 370 | rows := d.snapshot() | |
| 363 | 371 | for i, r := range rows { | |
| 364 | 372 | if r.ID == agentID || strings.HasPrefix(r.ID, agentID) { | |
| 373 | + | if d.loadTranscript != nil { | |
| 374 | + | _ = d.loadTranscript(r.ID) | |
| 375 | + | } | |
| 365 | 376 | d.cursor = i | |
| 366 | 377 | d.viewing = true | |
| 367 | 378 | // Auto-open the inline editor for running agents so | |
| @@ -514,6 +525,11 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) | |||
| 514 | 525 | } | |
| 515 | 526 | case tui.KeyEnter: | |
| 516 | 527 | if len(d.rows) > 0 { | |
| 528 | + | if a := d.selected(); a != nil && d.loadTranscript != nil { | |
| 529 | + | if err := d.loadTranscript(a.ID); err != nil { | |
| 530 | + | return false, "", "load transcript: " + err.Error() | |
| 531 | + | } | |
| 532 | + | } | |
| 517 | 533 | d.viewing = true | |
| 518 | 534 | // Auto-open the inline editor (à la /btw) so the user | |
| 519 | 535 | // can start typing immediately. Skipped for non-running | |
| @@ -49,6 +49,9 @@ type subagentsDialog struct { | |||||
| 49 | // session. Wired by Open(); when nil the inline 'R' shortcut is | 49 | // session. Wired by Open(); when nil the inline 'R' shortcut is | ||
| 50 | // disabled. | 50 | // disabled. | ||
| 51 | resume func(id string) error | 51 | resume func(id string) error | ||
| 52 | + | // loadTranscript hydrates a detached agent's durable event history when | |||
| 53 | + | // its transcript view is opened. Nil preserves snapshot-only behavior. | |||
| 54 | + | loadTranscript func(id string) error | |||
| 52 | 55 | ||||
| 53 | rows []subagents.AgentSnapshot | 56 | rows []subagents.AgentSnapshot | ||
| 54 | cursor int | 57 | cursor int | ||
| @@ -123,6 +126,11 @@ func (d *subagentsDialog) SetLineInput(enabled bool) { | |||||
| 123 | d.lineInput = enabled | 126 | d.lineInput = enabled | ||
| 124 | } | 127 | } | ||
| 125 | 128 | ||||
| 129 | + | // SetLoadTranscript supplies the optional on-demand durable transcript loader. | |||
| 130 | + | func (d *subagentsDialog) SetLoadTranscript(fn func(id string) error) { | |||
| 131 | + | d.loadTranscript = fn | |||
| 132 | + | } | |||
| 133 | + | ||||
| 126 | func (d *subagentsDialog) editorPrompt(th tui.Theme) string { | 134 | func (d *subagentsDialog) editorPrompt(th tui.Theme) string { | ||
| 127 | if d.lineInput { | 135 | if d.lineInput { | ||
| 128 | return "" | 136 | return "" | ||
| @@ -362,6 +370,9 @@ func (d *subagentsDialog) OpenViewing( | |||||
| 362 | rows := d.snapshot() | 370 | rows := d.snapshot() | ||
| 363 | for i, r := range rows { | 371 | for i, r := range rows { | ||
| 364 | if r.ID == agentID || strings.HasPrefix(r.ID, agentID) { | 372 | if r.ID == agentID || strings.HasPrefix(r.ID, agentID) { | ||
| 373 | + | if d.loadTranscript != nil { | |||
| 374 | + | _ = d.loadTranscript(r.ID) | |||
| 375 | + | } | |||
| 365 | d.cursor = i | 376 | d.cursor = i | ||
| 366 | d.viewing = true | 377 | d.viewing = true | ||
| 367 | // Auto-open the inline editor for running agents so | 378 | // Auto-open the inline editor for running agents so | ||
| @@ -514,6 +525,11 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) | |||||
| 514 | } | 525 | } | ||
| 515 | case tui.KeyEnter: | 526 | case tui.KeyEnter: | ||
| 516 | if len(d.rows) > 0 { | 527 | if len(d.rows) > 0 { | ||
| 528 | + | if a := d.selected(); a != nil && d.loadTranscript != nil { | |||
| 529 | + | if err := d.loadTranscript(a.ID); err != nil { | |||
| 530 | + | return false, "", "load transcript: " + err.Error() | |||
| 531 | + | } | |||
| 532 | + | } | |||
| 517 | d.viewing = true | 533 | d.viewing = true | ||
| 518 | // Auto-open the inline editor (à la /btw) so the user | 534 | // Auto-open the inline editor (à la /btw) so the user | ||
| 519 | // can start typing immediately. Skipped for non-running | 535 | // can start typing immediately. Skipped for non-running | ||
M packages/agent/modes/subagents_dialog_test.go +31 -0
| @@ -113,6 +113,37 @@ func TestSubagentsDialogEnterShowsTranscript(t *testing.T) { | |||
| 113 | 113 | } | |
| 114 | 114 | } | |
| 115 | 115 | ||
| 116 | + | func TestSubagentsDialogEnterHydratesTranscript(t *testing.T) { | |
| 117 | + | now := time.Now() | |
| 118 | + | hydrated := false | |
| 119 | + | loads := 0 | |
| 120 | + | snapshot := func() []subagents.AgentSnapshot { | |
| 121 | + | row := subagents.AgentSnapshot{ID: "alpha-1", Task: "do stuff", Status: subagents.StatusDone, Started: now} | |
| 122 | + | if hydrated { | |
| 123 | + | row.Lines = []string{"loaded history"} | |
| 124 | + | } | |
| 125 | + | return []subagents.AgentSnapshot{row} | |
| 126 | + | } | |
| 127 | + | d := newSubagentsDialog() | |
| 128 | + | d.SetLoadTranscript(func(id string) error { | |
| 129 | + | if id != "alpha-1" { | |
| 130 | + | t.Fatalf("load id = %q", id) | |
| 131 | + | } | |
| 132 | + | loads++ | |
| 133 | + | hydrated = true | |
| 134 | + | return nil | |
| 135 | + | }) | |
| 136 | + | d.Open(snapshot, nil, nil, nil, nil, nil, "") | |
| 137 | + | _ = d.Render(tui.Theme{}, 80) | |
| 138 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 139 | + | if loads != 1 { | |
| 140 | + | t.Fatalf("transcript loads = %d, want 1", loads) | |
| 141 | + | } | |
| 142 | + | if out := strings.Join(d.Render(tui.Theme{}, 80), "\n"); !strings.Contains(out, "loaded history") { | |
| 143 | + | t.Fatalf("hydrated transcript missing from view:\n%s", out) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 116 | 147 | func TestSubagentsDialogEscClosesList(t *testing.T) { | |
| 117 | 148 | d := newSubagentsDialog() | |
| 118 | 149 | d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") | |
| @@ -113,6 +113,37 @@ func TestSubagentsDialogEnterShowsTranscript(t *testing.T) { | |||||
| 113 | } | 113 | } | ||
| 114 | } | 114 | } | ||
| 115 | 115 | ||||
| 116 | + | func TestSubagentsDialogEnterHydratesTranscript(t *testing.T) { | |||
| 117 | + | now := time.Now() | |||
| 118 | + | hydrated := false | |||
| 119 | + | loads := 0 | |||
| 120 | + | snapshot := func() []subagents.AgentSnapshot { | |||
| 121 | + | row := subagents.AgentSnapshot{ID: "alpha-1", Task: "do stuff", Status: subagents.StatusDone, Started: now} | |||
| 122 | + | if hydrated { | |||
| 123 | + | row.Lines = []string{"loaded history"} | |||
| 124 | + | } | |||
| 125 | + | return []subagents.AgentSnapshot{row} | |||
| 126 | + | } | |||
| 127 | + | d := newSubagentsDialog() | |||
| 128 | + | d.SetLoadTranscript(func(id string) error { | |||
| 129 | + | if id != "alpha-1" { | |||
| 130 | + | t.Fatalf("load id = %q", id) | |||
| 131 | + | } | |||
| 132 | + | loads++ | |||
| 133 | + | hydrated = true | |||
| 134 | + | return nil | |||
| 135 | + | }) | |||
| 136 | + | d.Open(snapshot, nil, nil, nil, nil, nil, "") | |||
| 137 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 138 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 139 | + | if loads != 1 { | |||
| 140 | + | t.Fatalf("transcript loads = %d, want 1", loads) | |||
| 141 | + | } | |||
| 142 | + | if out := strings.Join(d.Render(tui.Theme{}, 80), "\n"); !strings.Contains(out, "loaded history") { | |||
| 143 | + | t.Fatalf("hydrated transcript missing from view:\n%s", out) | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | ||||
| 116 | func TestSubagentsDialogEscClosesList(t *testing.T) { | 147 | func TestSubagentsDialogEscClosesList(t *testing.T) { | ||
| 117 | d := newSubagentsDialog() | 148 | d := newSubagentsDialog() | ||
| 118 | d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") | 149 | d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") | ||
M packages/agent/modes/subagents_slash.go +5 -0
| @@ -77,6 +77,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) { | |||
| 77 | 77 | // editor, by typing /model on its own line to pop the picker. | |
| 78 | 78 | i.subagentsDialog.SetCompactMode(i.compactModeEnabled()) | |
| 79 | 79 | i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines) | |
| 80 | + | i.subagentsDialog.SetLoadTranscript(i.cfg.Supervisor.LoadTranscript) | |
| 80 | 81 | i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider) | |
| 81 | 82 | if i.cfg.LoggedInProviders != nil { | |
| 82 | 83 | i.subagentsDialog.SetLoggedInProviders(i.cfg.LoggedInProviders()) | |
| @@ -205,6 +206,10 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) { | |||
| 205 | 206 | i.subagentsStatus("", "/subagents logs <id>: missing id") | |
| 206 | 207 | return | |
| 207 | 208 | } | |
| 209 | + | if err := i.cfg.Supervisor.LoadTranscript(rest); err != nil { | |
| 210 | + | i.subagentsStatus("", "logs: "+err.Error()) | |
| 211 | + | return | |
| 212 | + | } | |
| 208 | 213 | ok := i.subagentsDialog.OpenViewing( | |
| 209 | 214 | rest, | |
| 210 | 215 | i.cfg.Supervisor.SnapshotAll, | |
| @@ -77,6 +77,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) { | |||||
| 77 | // editor, by typing /model on its own line to pop the picker. | 77 | // editor, by typing /model on its own line to pop the picker. | ||
| 78 | i.subagentsDialog.SetCompactMode(i.compactModeEnabled()) | 78 | i.subagentsDialog.SetCompactMode(i.compactModeEnabled()) | ||
| 79 | i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines) | 79 | i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines) | ||
| 80 | + | i.subagentsDialog.SetLoadTranscript(i.cfg.Supervisor.LoadTranscript) | |||
| 80 | i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider) | 81 | i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider) | ||
| 81 | if i.cfg.LoggedInProviders != nil { | 82 | if i.cfg.LoggedInProviders != nil { | ||
| 82 | i.subagentsDialog.SetLoggedInProviders(i.cfg.LoggedInProviders()) | 83 | i.subagentsDialog.SetLoggedInProviders(i.cfg.LoggedInProviders()) | ||
| @@ -205,6 +206,10 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) { | |||||
| 205 | i.subagentsStatus("", "/subagents logs <id>: missing id") | 206 | i.subagentsStatus("", "/subagents logs <id>: missing id") | ||
| 206 | return | 207 | return | ||
| 207 | } | 208 | } | ||
| 209 | + | if err := i.cfg.Supervisor.LoadTranscript(rest); err != nil { | |||
| 210 | + | i.subagentsStatus("", "logs: "+err.Error()) | |||
| 211 | + | return | |||
| 212 | + | } | |||
| 208 | ok := i.subagentsDialog.OpenViewing( | 213 | ok := i.subagentsDialog.OpenViewing( | ||
| 209 | rest, | 214 | rest, | ||
| 210 | i.cfg.Supervisor.SnapshotAll, | 215 | i.cfg.Supervisor.SnapshotAll, | ||
M packages/agent/sdk/sdk.go +3 -2
| @@ -77,8 +77,9 @@ type Config struct { | |||
| 77 | 77 | BaseURL string | |
| 78 | 78 | ||
| 79 | 79 | // Tools is the list of tools to enable. Nil/empty = all ordinary built-in | |
| 80 | - | // tools (read, write, edit, bash, lsp). web_search is excluded unless this | |
| 81 | - | // list explicitly contains "web_search". Pass an empty-but-non-nil slice | |
| 80 | + | // tools (read, write, edit, bash, create_worktree, lsp). web_search is | |
| 81 | + | // excluded unless this list explicitly contains "web_search". Pass an | |
| 82 | + | // empty-but-non-nil slice | |
| 82 | 83 | // (e.g. []string{}) plus NoTools=true to disable everything. | |
| 83 | 84 | Tools []string | |
| 84 | 85 | ||
| @@ -77,8 +77,9 @@ type Config struct { | |||||
| 77 | BaseURL string | 77 | BaseURL string | ||
| 78 | 78 | ||||
| 79 | // Tools is the list of tools to enable. Nil/empty = all ordinary built-in | 79 | // Tools is the list of tools to enable. Nil/empty = all ordinary built-in | ||
| 80 | - | // tools (read, write, edit, bash, lsp). web_search is excluded unless this | 80 | + | // tools (read, write, edit, bash, create_worktree, lsp). web_search is |
| 81 | - | // list explicitly contains "web_search". Pass an empty-but-non-nil slice | 81 | + | // excluded unless this list explicitly contains "web_search". Pass an |
| 82 | + | // empty-but-non-nil slice | |||
| 82 | // (e.g. []string{}) plus NoTools=true to disable everything. | 83 | // (e.g. []string{}) plus NoTools=true to disable everything. | ||
| 83 | Tools []string | 84 | Tools []string | ||
| 84 | 85 | ||||
M packages/agent/skills/builtin/write-zut-extension/SKILL.md +1 -1
| @@ -29,7 +29,7 @@ Three things an extension can do (any combination): | |||
| 29 | 29 | 2. **Tools** — register tools the LLM itself calls. Schema is | |
| 30 | 30 | JSON Schema; zut routes the model's `tool_call` to the | |
| 31 | 31 | extension's `tool_result`. Same lifecycle as built-in tools | |
| 32 | - | (read/write/edit/bash/lsp/web_search/skill). | |
| 32 | + | (read/write/edit/bash/create_worktree/lsp/web_search/skill). | |
| 33 | 33 | ||
| 34 | 34 | 3. **Lifecycle hooks** — subscribe to events | |
| 35 | 35 | (session_start, turn_start, tool_call, turn_end, | |
| @@ -29,7 +29,7 @@ Three things an extension can do (any combination): | |||||
| 29 | 2. **Tools** — register tools the LLM itself calls. Schema is | 29 | 2. **Tools** — register tools the LLM itself calls. Schema is | ||
| 30 | JSON Schema; zut routes the model's `tool_call` to the | 30 | JSON Schema; zut routes the model's `tool_call` to the | ||
| 31 | extension's `tool_result`. Same lifecycle as built-in tools | 31 | extension's `tool_result`. Same lifecycle as built-in tools | ||
| 32 | - | (read/write/edit/bash/lsp/web_search/skill). | 32 | + | (read/write/edit/bash/create_worktree/lsp/web_search/skill). |
| 33 | 33 | ||||
| 34 | 3. **Lifecycle hooks** — subscribe to events | 34 | 3. **Lifecycle hooks** — subscribe to events | ||
| 35 | (session_start, turn_start, tool_call, turn_end, | 35 | (session_start, turn_start, tool_call, turn_end, | ||
M packages/agent/subagents/agent.go +25 -8
| @@ -58,6 +58,12 @@ type Agent struct { | |||
| 58 | 58 | FastMode bool | |
| 59 | 59 | Subagent string | |
| 60 | 60 | ||
| 61 | + | // fastModeOverridesHost records the spawn-time case where an explicit | |
| 62 | + | // profile fastMode: true enabled the child while the host was off. It is | |
| 63 | + | // intentionally transient: the parent only needs it to report the initial | |
| 64 | + | // spawn warning, while the effective FastMode remains durable state. | |
| 65 | + | fastModeOverridesHost bool | |
| 66 | + | ||
| 61 | 67 | // SessionID, when non-empty, scopes the agent to a particular | |
| 62 | 68 | // host zut session: the dashboard only surfaces agents whose | |
| 63 | 69 | // SessionID matches the active session. Empty means "unscoped" and | |
| @@ -105,13 +111,16 @@ type Agent struct { | |||
| 105 | 111 | // Supervisor.Spawn so callers do not need to manage the socket directly. | |
| 106 | 112 | inbox *Inbox | |
| 107 | 113 | ||
| 108 | - | mu sync.Mutex | |
| 109 | - | status Status | |
| 110 | - | activity string | |
| 111 | - | transcript []string | |
| 112 | - | lastAssistant string | |
| 113 | - | finished time.Time | |
| 114 | - | lastErr error | |
| 114 | + | mu sync.Mutex | |
| 115 | + | status Status | |
| 116 | + | activity string | |
| 117 | + | transcript []string | |
| 118 | + | lastAssistant string | |
| 119 | + | finished time.Time | |
| 120 | + | lastErr error | |
| 121 | + | transcriptLoaded bool | |
| 122 | + | legacyEventState bool | |
| 123 | + | transcriptMu sync.Mutex | |
| 115 | 124 | ||
| 116 | 125 | lifecycleMu sync.Mutex | |
| 117 | 126 | processState ProcessState | |
| @@ -168,6 +177,12 @@ type Agent struct { | |||
| 168 | 177 | // doesn't speak the daemon protocol). | |
| 169 | 178 | func (a *Agent) Inbox() *Inbox { return a.inbox } | |
| 170 | 179 | ||
| 180 | + | // FastModeOverridesHost reports whether this child was created with an | |
| 181 | + | // explicit fast-mode override while the host setting was disabled. | |
| 182 | + | func (a *Agent) FastModeOverridesHost() bool { | |
| 183 | + | return a != nil && a.fastModeOverridesHost | |
| 184 | + | } | |
| 185 | + | ||
| 171 | 186 | // Status returns the current high-level status. Cheap; safe from any goroutine. | |
| 172 | 187 | func (a *Agent) Status() Status { | |
| 173 | 188 | a.mu.Lock() | |
| @@ -299,8 +314,10 @@ func (a *Agent) setResult(result *TurnResult) { | |||
| 299 | 314 | a.lifecycleMu.Unlock() | |
| 300 | 315 | } | |
| 301 | 316 | ||
| 302 | - | // Transcript returns a copy of the running transcript. | |
| 317 | + | // Transcript returns a copy of the running transcript. Detached agents | |
| 318 | + | // hydrate their durable event history the first time it is requested. | |
| 303 | 319 | func (a *Agent) Transcript() []string { | |
| 320 | + | _ = a.loadTranscript() | |
| 304 | 321 | a.mu.Lock() | |
| 305 | 322 | defer a.mu.Unlock() | |
| 306 | 323 | out := make([]string, len(a.transcript)) | |
| @@ -58,6 +58,12 @@ type Agent struct { | |||||
| 58 | FastMode bool | 58 | FastMode bool | ||
| 59 | Subagent string | 59 | Subagent string | ||
| 60 | 60 | ||||
| 61 | + | // fastModeOverridesHost records the spawn-time case where an explicit | |||
| 62 | + | // profile fastMode: true enabled the child while the host was off. It is | |||
| 63 | + | // intentionally transient: the parent only needs it to report the initial | |||
| 64 | + | // spawn warning, while the effective FastMode remains durable state. | |||
| 65 | + | fastModeOverridesHost bool | |||
| 66 | + | ||||
| 61 | // SessionID, when non-empty, scopes the agent to a particular | 67 | // SessionID, when non-empty, scopes the agent to a particular | ||
| 62 | // host zut session: the dashboard only surfaces agents whose | 68 | // host zut session: the dashboard only surfaces agents whose | ||
| 63 | // SessionID matches the active session. Empty means "unscoped" and | 69 | // SessionID matches the active session. Empty means "unscoped" and | ||
| @@ -105,13 +111,16 @@ type Agent struct { | |||||
| 105 | // Supervisor.Spawn so callers do not need to manage the socket directly. | 111 | // Supervisor.Spawn so callers do not need to manage the socket directly. | ||
| 106 | inbox *Inbox | 112 | inbox *Inbox | ||
| 107 | 113 | ||||
| 108 | - | mu sync.Mutex | 114 | + | mu sync.Mutex |
| 109 | - | status Status | 115 | + | status Status |
| 110 | - | activity string | 116 | + | activity string |
| 111 | - | transcript []string | 117 | + | transcript []string |
| 112 | - | lastAssistant string | 118 | + | lastAssistant string |
| 113 | - | finished time.Time | 119 | + | finished time.Time |
| 114 | - | lastErr error | 120 | + | lastErr error |
| 121 | + | transcriptLoaded bool | |||
| 122 | + | legacyEventState bool | |||
| 123 | + | transcriptMu sync.Mutex | |||
| 115 | 124 | ||||
| 116 | lifecycleMu sync.Mutex | 125 | lifecycleMu sync.Mutex | ||
| 117 | processState ProcessState | 126 | processState ProcessState | ||
| @@ -168,6 +177,12 @@ type Agent struct { | |||||
| 168 | // doesn't speak the daemon protocol). | 177 | // doesn't speak the daemon protocol). | ||
| 169 | func (a *Agent) Inbox() *Inbox { return a.inbox } | 178 | func (a *Agent) Inbox() *Inbox { return a.inbox } | ||
| 170 | 179 | ||||
| 180 | + | // FastModeOverridesHost reports whether this child was created with an | |||
| 181 | + | // explicit fast-mode override while the host setting was disabled. | |||
| 182 | + | func (a *Agent) FastModeOverridesHost() bool { | |||
| 183 | + | return a != nil && a.fastModeOverridesHost | |||
| 184 | + | } | |||
| 185 | + | ||||
| 171 | // Status returns the current high-level status. Cheap; safe from any goroutine. | 186 | // Status returns the current high-level status. Cheap; safe from any goroutine. | ||
| 172 | func (a *Agent) Status() Status { | 187 | func (a *Agent) Status() Status { | ||
| 173 | a.mu.Lock() | 188 | a.mu.Lock() | ||
| @@ -299,8 +314,10 @@ func (a *Agent) setResult(result *TurnResult) { | |||||
| 299 | a.lifecycleMu.Unlock() | 314 | a.lifecycleMu.Unlock() | ||
| 300 | } | 315 | } | ||
| 301 | 316 | ||||
| 302 | - | // Transcript returns a copy of the running transcript. | 317 | + | // Transcript returns a copy of the running transcript. Detached agents |
| 318 | + | // hydrate their durable event history the first time it is requested. | |||
| 303 | func (a *Agent) Transcript() []string { | 319 | func (a *Agent) Transcript() []string { | ||
| 320 | + | _ = a.loadTranscript() | |||
| 304 | a.mu.Lock() | 321 | a.mu.Lock() | ||
| 305 | defer a.mu.Unlock() | 322 | defer a.mu.Unlock() | ||
| 306 | out := make([]string, len(a.transcript)) | 323 | out := make([]string, len(a.transcript)) | ||
M packages/agent/subagents/event.go +5 -9
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "io" | |
| 9 | 9 | "os" | |
| 10 | 10 | "path/filepath" | |
| 11 | + | "reflect" | |
| 11 | 12 | "sync" | |
| 12 | 13 | "time" | |
| 13 | 14 | ) | |
| @@ -343,16 +344,11 @@ func isLikelyDoubleWrite(ev, previous Event) bool { | |||
| 343 | 344 | return sameEventData(previous.Data, ev.Data) | |
| 344 | 345 | } | |
| 345 | 346 | ||
| 346 | - | // sameEventData deep-compares two event payloads. Cheap because the | |
| 347 | - | // payloads are small map[string]any trees built from JSON, and only | |
| 348 | - | // called for adjacent same-type pairs. | |
| 347 | + | // sameEventData deep-compares two event payloads without re-encoding them. | |
| 348 | + | // Reload invokes this for adjacent same-type events across every persisted | |
| 349 | + | // subagent log, so serializing large streamed payloads here delays startup. | |
| 349 | 350 | func sameEventData(a, b map[string]any) bool { | |
| 350 | - | if len(a) != len(b) { | |
| 351 | - | return false | |
| 352 | - | } | |
| 353 | - | ab, _ := json.Marshal(a) | |
| 354 | - | bb, _ := json.Marshal(b) | |
| 355 | - | return string(ab) == string(bb) | |
| 351 | + | return len(a) == len(b) && reflect.DeepEqual(a, b) | |
| 356 | 352 | } | |
| 357 | 353 | ||
| 358 | 354 | // EventFollower polls an events.jsonl file and emits new events as | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "io" | 8 | "io" | ||
| 9 | "os" | 9 | "os" | ||
| 10 | "path/filepath" | 10 | "path/filepath" | ||
| 11 | + | "reflect" | |||
| 11 | "sync" | 12 | "sync" | ||
| 12 | "time" | 13 | "time" | ||
| 13 | ) | 14 | ) | ||
| @@ -343,16 +344,11 @@ func isLikelyDoubleWrite(ev, previous Event) bool { | |||||
| 343 | return sameEventData(previous.Data, ev.Data) | 344 | return sameEventData(previous.Data, ev.Data) | ||
| 344 | } | 345 | } | ||
| 345 | 346 | ||||
| 346 | - | // sameEventData deep-compares two event payloads. Cheap because the | 347 | + | // sameEventData deep-compares two event payloads without re-encoding them. |
| 347 | - | // payloads are small map[string]any trees built from JSON, and only | 348 | + | // Reload invokes this for adjacent same-type events across every persisted |
| 348 | - | // called for adjacent same-type pairs. | 349 | + | // subagent log, so serializing large streamed payloads here delays startup. |
| 349 | func sameEventData(a, b map[string]any) bool { | 350 | func sameEventData(a, b map[string]any) bool { | ||
| 350 | - | if len(a) != len(b) { | 351 | + | return len(a) == len(b) && reflect.DeepEqual(a, b) |
| 351 | - | return false | |||
| 352 | - | } | |||
| 353 | - | ab, _ := json.Marshal(a) | |||
| 354 | - | bb, _ := json.Marshal(b) | |||
| 355 | - | return string(ab) == string(bb) | |||
| 356 | } | 352 | } | ||
| 357 | 353 | ||||
| 358 | // EventFollower polls an events.jsonl file and emits new events as | 354 | // EventFollower polls an events.jsonl file and emits new events as | ||
M packages/agent/subagents/event_test.go +8 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "fmt" | |
| 7 | + | "math" | |
| 7 | 8 | "os" | |
| 8 | 9 | "path/filepath" | |
| 9 | 10 | "strings" | |
| @@ -247,6 +248,13 @@ func TestReadEventLogDeduplicatesDoubleWrites(t *testing.T) { | |||
| 247 | 248 | // by more than the dedup window (250ms) must both survive. The | |
| 248 | 249 | // agent legitimately can emit identical adjacent events (think: a | |
| 249 | 250 | // retry running the same tool seconds apart). | |
| 251 | + | func TestSameEventDataDoesNotConflateFailedEncodings(t *testing.T) { | |
| 252 | + | payload := math.NaN() | |
| 253 | + | if sameEventData(map[string]any{"value": payload}, map[string]any{"value": payload}) { | |
| 254 | + | t.Fatal("payloads with non-equal values must not be treated as equal when encoding fails") | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | ||
| 250 | 258 | func TestReadEventLogKeepsLegitimateAdjacentEvents(t *testing.T) { | |
| 251 | 259 | path := filepath.Join(t.TempDir(), "events.jsonl") | |
| 252 | 260 | log, err := OpenEventLog(path) | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "math" | |||
| 7 | "os" | 8 | "os" | ||
| 8 | "path/filepath" | 9 | "path/filepath" | ||
| 9 | "strings" | 10 | "strings" | ||
| @@ -247,6 +248,13 @@ func TestReadEventLogDeduplicatesDoubleWrites(t *testing.T) { | |||||
| 247 | // by more than the dedup window (250ms) must both survive. The | 248 | // by more than the dedup window (250ms) must both survive. The | ||
| 248 | // agent legitimately can emit identical adjacent events (think: a | 249 | // agent legitimately can emit identical adjacent events (think: a | ||
| 249 | // retry running the same tool seconds apart). | 250 | // retry running the same tool seconds apart). | ||
| 251 | + | func TestSameEventDataDoesNotConflateFailedEncodings(t *testing.T) { | |||
| 252 | + | payload := math.NaN() | |||
| 253 | + | if sameEventData(map[string]any{"value": payload}, map[string]any{"value": payload}) { | |||
| 254 | + | t.Fatal("payloads with non-equal values must not be treated as equal when encoding fails") | |||
| 255 | + | } | |||
| 256 | + | } | |||
| 257 | + | ||||
| 250 | func TestReadEventLogKeepsLegitimateAdjacentEvents(t *testing.T) { | 258 | func TestReadEventLogKeepsLegitimateAdjacentEvents(t *testing.T) { | ||
| 251 | path := filepath.Join(t.TempDir(), "events.jsonl") | 259 | path := filepath.Join(t.TempDir(), "events.jsonl") | ||
| 252 | log, err := OpenEventLog(path) | 260 | log, err := OpenEventLog(path) | ||
M packages/agent/subagents/fast_mode_test.go +10 -6
| @@ -58,18 +58,19 @@ func TestSupervisorFastModePropagatesToChild(t *testing.T) { | |||
| 58 | 58 | } | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 61 | - | func TestSpawnRequestFastModeIsBoundByHostSetting(t *testing.T) { | |
| 61 | + | func TestSpawnRequestFastModeProfileOverridesHostSetting(t *testing.T) { | |
| 62 | 62 | falseValue, trueValue := false, true | |
| 63 | 63 | cases := []struct { | |
| 64 | - | name string | |
| 65 | - | hostFast bool | |
| 66 | - | profileFast *bool | |
| 67 | - | want bool | |
| 64 | + | name string | |
| 65 | + | hostFast bool | |
| 66 | + | profileFast *bool | |
| 67 | + | want bool | |
| 68 | + | wantHostOverride bool | |
| 68 | 69 | }{ | |
| 69 | 70 | {name: "unset inherits enabled host", hostFast: true, want: true}, | |
| 70 | 71 | {name: "unset inherits disabled host", hostFast: false, want: false}, | |
| 71 | 72 | {name: "false disables enabled host", hostFast: true, profileFast: &falseValue, want: false}, | |
| 72 | - | {name: "true cannot enable disabled host", hostFast: false, profileFast: &trueValue, want: false}, | |
| 73 | + | {name: "true enables disabled host", hostFast: false, profileFast: &trueValue, want: true, wantHostOverride: true}, | |
| 73 | 74 | {name: "true preserves enabled host", hostFast: true, profileFast: &trueValue, want: true}, | |
| 74 | 75 | } | |
| 75 | 76 | for _, tc := range cases { | |
| @@ -91,6 +92,9 @@ func TestSpawnRequestFastModeIsBoundByHostSetting(t *testing.T) { | |||
| 91 | 92 | if a.FastMode != tc.want { | |
| 92 | 93 | t.Fatalf("FastMode = %v, want %v", a.FastMode, tc.want) | |
| 93 | 94 | } | |
| 95 | + | if a.FastModeOverridesHost() != tc.wantHostOverride { | |
| 96 | + | t.Fatalf("FastModeOverridesHost() = %v, want %v", a.FastModeOverridesHost(), tc.wantHostOverride) | |
| 97 | + | } | |
| 94 | 98 | args := defaultChildArgs("/zut", a, "/session", "/inbox") | |
| 95 | 99 | wantFlag := "--no-fast-mode" | |
| 96 | 100 | if tc.want { | |
| @@ -58,18 +58,19 @@ func TestSupervisorFastModePropagatesToChild(t *testing.T) { | |||||
| 58 | } | 58 | } | ||
| 59 | } | 59 | } | ||
| 60 | 60 | ||||
| 61 | - | func TestSpawnRequestFastModeIsBoundByHostSetting(t *testing.T) { | 61 | + | func TestSpawnRequestFastModeProfileOverridesHostSetting(t *testing.T) { |
| 62 | falseValue, trueValue := false, true | 62 | falseValue, trueValue := false, true | ||
| 63 | cases := []struct { | 63 | cases := []struct { | ||
| 64 | - | name string | 64 | + | name string |
| 65 | - | hostFast bool | 65 | + | hostFast bool |
| 66 | - | profileFast *bool | 66 | + | profileFast *bool |
| 67 | - | want bool | 67 | + | want bool |
| 68 | + | wantHostOverride bool | |||
| 68 | }{ | 69 | }{ | ||
| 69 | {name: "unset inherits enabled host", hostFast: true, want: true}, | 70 | {name: "unset inherits enabled host", hostFast: true, want: true}, | ||
| 70 | {name: "unset inherits disabled host", hostFast: false, want: false}, | 71 | {name: "unset inherits disabled host", hostFast: false, want: false}, | ||
| 71 | {name: "false disables enabled host", hostFast: true, profileFast: &falseValue, want: false}, | 72 | {name: "false disables enabled host", hostFast: true, profileFast: &falseValue, want: false}, | ||
| 72 | - | {name: "true cannot enable disabled host", hostFast: false, profileFast: &trueValue, want: false}, | 73 | + | {name: "true enables disabled host", hostFast: false, profileFast: &trueValue, want: true, wantHostOverride: true}, |
| 73 | {name: "true preserves enabled host", hostFast: true, profileFast: &trueValue, want: true}, | 74 | {name: "true preserves enabled host", hostFast: true, profileFast: &trueValue, want: true}, | ||
| 74 | } | 75 | } | ||
| 75 | for _, tc := range cases { | 76 | for _, tc := range cases { | ||
| @@ -91,6 +92,9 @@ func TestSpawnRequestFastModeIsBoundByHostSetting(t *testing.T) { | |||||
| 91 | if a.FastMode != tc.want { | 92 | if a.FastMode != tc.want { | ||
| 92 | t.Fatalf("FastMode = %v, want %v", a.FastMode, tc.want) | 93 | t.Fatalf("FastMode = %v, want %v", a.FastMode, tc.want) | ||
| 93 | } | 94 | } | ||
| 95 | + | if a.FastModeOverridesHost() != tc.wantHostOverride { | |||
| 96 | + | t.Fatalf("FastModeOverridesHost() = %v, want %v", a.FastModeOverridesHost(), tc.wantHostOverride) | |||
| 97 | + | } | |||
| 94 | args := defaultChildArgs("/zut", a, "/session", "/inbox") | 98 | args := defaultChildArgs("/zut", a, "/session", "/inbox") | ||
| 95 | wantFlag := "--no-fast-mode" | 99 | wantFlag := "--no-fast-mode" | ||
| 96 | if tc.want { | 100 | if tc.want { | ||
M packages/agent/subagents/persist.go +186 -59
| @@ -3,19 +3,14 @@ package subagents | |||
| 3 | 3 | // On-disk persistence for subagent agents. | |
| 4 | 4 | // | |
| 5 | 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 zut launch, Supervisor.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. | |
| 6 | + | // session.json. The file captures identity, resumable lifecycle state, and | |
| 7 | + | // compact dashboard state. On a fresh zut launch, Supervisor.Reload() walks | |
| 8 | + | // <root>/agents/*/meta.json and re-registers every agent without replaying | |
| 9 | + | // its full history. The event log remains the durable transcript source and | |
| 10 | + | // is hydrated only when a caller requests that agent's transcript. | |
| 12 | 11 | // | |
| 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. | |
| 12 | + | // Metadata writes are atomic (tmp + rename). The supervisor is the sole | |
| 13 | + | // writer, so its lifecycle snapshots cannot race with another host process. | |
| 19 | 14 | ||
| 20 | 15 | import ( | |
| 21 | 16 | "context" | |
| @@ -62,6 +57,10 @@ type agentMeta struct { | |||
| 62 | 57 | Timeout time.Duration `json:"timeout,omitempty"` | |
| 63 | 58 | Tools []string `json:"tools,omitempty"` | |
| 64 | 59 | WebSearchPolicy WebSearchPolicy `json:"web_search_policy,omitempty"` | |
| 60 | + | Status Status `json:"status,omitempty"` | |
| 61 | + | Activity string `json:"activity,omitempty"` | |
| 62 | + | Finished time.Time `json:"finished,omitempty"` | |
| 63 | + | Error string `json:"error,omitempty"` | |
| 65 | 64 | ProcessState ProcessState `json:"process_state,omitempty"` | |
| 66 | 65 | TurnState TurnState `json:"turn_state,omitempty"` | |
| 67 | 66 | CurrentTurnID string `json:"current_turn_id,omitempty"` | |
| @@ -94,6 +93,15 @@ func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.jso | |||
| 94 | 93 | func writeAgentMeta(stateDir string, a *Agent) error { | |
| 95 | 94 | fastMode := a.FastMode | |
| 96 | 95 | webSearchPolicy := childWebSearchPolicy(a.WebSearchPolicy, a.Subagent, a.Tools) | |
| 96 | + | a.mu.Lock() | |
| 97 | + | status := a.status | |
| 98 | + | activity := a.activity | |
| 99 | + | finished := a.finished | |
| 100 | + | errText := "" | |
| 101 | + | if a.lastErr != nil { | |
| 102 | + | errText = a.lastErr.Error() | |
| 103 | + | } | |
| 104 | + | a.mu.Unlock() | |
| 97 | 105 | a.lifecycleMu.Lock() | |
| 98 | 106 | processState := a.processState | |
| 99 | 107 | turnState := a.turnState | |
| @@ -131,6 +139,10 @@ func writeAgentMeta(stateDir string, a *Agent) error { | |||
| 131 | 139 | Timeout: a.Timeout, | |
| 132 | 140 | Tools: append([]string(nil), a.Tools...), | |
| 133 | 141 | WebSearchPolicy: webSearchPolicy, | |
| 142 | + | Status: status, | |
| 143 | + | Activity: activity, | |
| 144 | + | Finished: finished, | |
| 145 | + | Error: errText, | |
| 134 | 146 | ProcessState: processState, | |
| 135 | 147 | TurnState: turnState, | |
| 136 | 148 | CurrentTurnID: currentTurnID, | |
| @@ -323,13 +335,12 @@ func (f *Supervisor) reloadRoot(root string) (loaded int, errs []error) { | |||
| 323 | 335 | return loaded, errs | |
| 324 | 336 | } | |
| 325 | 337 | ||
| 326 | - | // buildDetachedAgent constructs an Agent from a meta.json with no | |
| 327 | - | // running Runner. The agent's transcript is populated from the tail | |
| 328 | - | // of its event log so the dashboard immediately shows recent output; | |
| 329 | - | // activity is inferred from the last lifecycle event. | |
| 338 | + | // buildDetachedAgent constructs an Agent from compact metadata with no running | |
| 339 | + | // Runner. Its transcript stays unloaded until a caller requests it, while the | |
| 340 | + | // persisted lifecycle summary keeps the dashboard immediately useful. | |
| 330 | 341 | // | |
| 331 | - | // The returned Agent has a closed `done` channel because Wait should | |
| 332 | - | // return instantly: there is nothing to wait for. | |
| 342 | + | // The returned Agent has a closed `done` channel because Wait should return | |
| 343 | + | // instantly: there is nothing to wait for. | |
| 333 | 344 | func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent { | |
| 334 | 345 | // Metadata without a repository identity uses the current RepoRoot; | |
| 335 | 346 | // records with one retain their persisted repository so a restart from | |
| @@ -355,12 +366,42 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent { | |||
| 355 | 366 | if lastActivity.IsZero() { | |
| 356 | 367 | lastActivity = m.Started | |
| 357 | 368 | } | |
| 358 | - | processState := ProcessDetached | |
| 359 | - | activity := "detached" | |
| 369 | + | persistedStatus := m.Status | |
| 370 | + | status := StatusDetached | |
| 371 | + | activity := strings.TrimSpace(m.Activity) | |
| 372 | + | processState := m.ProcessState | |
| 373 | + | legacyEventState := persistedStatus == "" | |
| 374 | + | if legacyEventState { | |
| 375 | + | activity = "detached" | |
| 376 | + | processState = ProcessDetached | |
| 377 | + | } | |
| 378 | + | if processState == "" { | |
| 379 | + | processState = ProcessDetached | |
| 380 | + | } | |
| 381 | + | if activity == "" { | |
| 382 | + | switch persistedStatus { | |
| 383 | + | case StatusDone: | |
| 384 | + | activity = "done" | |
| 385 | + | case StatusFailed: | |
| 386 | + | activity = "failed" | |
| 387 | + | case StatusKilled: | |
| 388 | + | activity = "cancelled" | |
| 389 | + | default: | |
| 390 | + | activity = "detached" | |
| 391 | + | } | |
| 392 | + | } | |
| 393 | + | if persistedStatus == StatusRunning || persistedStatus == StatusPending { | |
| 394 | + | activity = "detached (resume to continue)" | |
| 395 | + | processState = ProcessDetached | |
| 396 | + | } | |
| 360 | 397 | if inboxLive(m.InboxPath) { | |
| 361 | 398 | processState = ProcessAlive | |
| 362 | 399 | activity = "live (detached)" | |
| 363 | 400 | } | |
| 401 | + | var lastErr error | |
| 402 | + | if m.Error != "" { | |
| 403 | + | lastErr = errors.New(m.Error) | |
| 404 | + | } | |
| 364 | 405 | agentStateDir := f.agentStateDir(m.ID) | |
| 365 | 406 | if m.EventLogPath != "" { | |
| 366 | 407 | // EventLogPath is <state-dir>/events.jsonl; keep the reloaded | |
| @@ -401,8 +442,11 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent { | |||
| 401 | 442 | SessionPath: m.SessionPath, | |
| 402 | 443 | SessionID: m.SessionID, | |
| 403 | 444 | inbox: NewInbox(m.InboxPath), | |
| 404 | - | status: StatusDetached, | |
| 445 | + | status: status, | |
| 405 | 446 | activity: activity, | |
| 447 | + | finished: m.Finished, | |
| 448 | + | lastErr: lastErr, | |
| 449 | + | legacyEventState: legacyEventState, | |
| 406 | 450 | processState: processState, | |
| 407 | 451 | turnState: turnState, | |
| 408 | 452 | currentTurnID: m.CurrentTurnID, | |
| @@ -422,14 +466,6 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent { | |||
| 422 | 466 | } | |
| 423 | 467 | a.closeDone() | |
| 424 | 468 | ||
| 425 | - | // Recover transcript + activity hints from the event log. Best | |
| 426 | - | // effort: a missing or unreadable log just leaves the agent | |
| 427 | - | // detached with an empty transcript. | |
| 428 | - | if a.EventLogPath != "" { | |
| 429 | - | if evs, err := ReadEventLog(a.EventLogPath); err == nil { | |
| 430 | - | replayEventsIntoAgent(a, evs) | |
| 431 | - | } | |
| 432 | - | } | |
| 433 | 469 | // The append-only log may contain a terminal event from an earlier | |
| 434 | 470 | // attempt. A live socket is stronger evidence for the current process; | |
| 435 | 471 | // keep the reloaded entry detached/live so Stop and Remove cannot treat a | |
| @@ -451,6 +487,80 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent { | |||
| 451 | 487 | return a | |
| 452 | 488 | } | |
| 453 | 489 | ||
| 490 | + | // LoadTranscript hydrates one reloaded agent's durable event history. Reload | |
| 491 | + | // deliberately skips this work so startup stays bounded by metadata size; | |
| 492 | + | // callers request it only when opening the transcript or otherwise reading it. | |
| 493 | + | func (f *Supervisor) LoadTranscript(id string) error { | |
| 494 | + | a := f.Get(id) | |
| 495 | + | if a == nil { | |
| 496 | + | return fmt.Errorf("subagent: no such agent %q", id) | |
| 497 | + | } | |
| 498 | + | return a.loadTranscript() | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | func (a *Agent) loadTranscript() error { | |
| 502 | + | if a == nil { | |
| 503 | + | return nil | |
| 504 | + | } | |
| 505 | + | a.transcriptMu.Lock() | |
| 506 | + | defer a.transcriptMu.Unlock() | |
| 507 | + | ||
| 508 | + | a.mu.Lock() | |
| 509 | + | if a.transcriptLoaded { | |
| 510 | + | a.mu.Unlock() | |
| 511 | + | return nil | |
| 512 | + | } | |
| 513 | + | path := a.EventLogPath | |
| 514 | + | legacyEventState := a.legacyEventState | |
| 515 | + | a.mu.Unlock() | |
| 516 | + | var summaryUpdatedAt, summaryLastActivity time.Time | |
| 517 | + | if !legacyEventState { | |
| 518 | + | a.lifecycleMu.Lock() | |
| 519 | + | summaryUpdatedAt = a.updatedAt | |
| 520 | + | summaryLastActivity = a.lastActivity | |
| 521 | + | a.lifecycleMu.Unlock() | |
| 522 | + | } | |
| 523 | + | if path == "" { | |
| 524 | + | a.mu.Lock() | |
| 525 | + | a.transcriptLoaded = true | |
| 526 | + | a.mu.Unlock() | |
| 527 | + | return nil | |
| 528 | + | } | |
| 529 | + | ||
| 530 | + | events, err := ReadEventLog(path) | |
| 531 | + | if err != nil { | |
| 532 | + | return fmt.Errorf("read subagent transcript: %w", err) | |
| 533 | + | } | |
| 534 | + | a.mu.Lock() | |
| 535 | + | if a.transcriptLoaded { | |
| 536 | + | a.mu.Unlock() | |
| 537 | + | return nil | |
| 538 | + | } | |
| 539 | + | a.transcript = nil | |
| 540 | + | a.lastAssistant = "" | |
| 541 | + | a.outputBytes = 0 | |
| 542 | + | a.outputLines = 0 | |
| 543 | + | a.outputTruncated = false | |
| 544 | + | a.mu.Unlock() | |
| 545 | + | ||
| 546 | + | if legacyEventState { | |
| 547 | + | // Metadata written before the dashboard summary existed has no | |
| 548 | + | // terminal status to trust. Preserve its historical event-derived | |
| 549 | + | // behavior once the user explicitly opens its transcript. | |
| 550 | + | replayEventsIntoAgent(a, events) | |
| 551 | + | } else { | |
| 552 | + | replayTranscriptIntoAgent(a, events) | |
| 553 | + | a.lifecycleMu.Lock() | |
| 554 | + | a.updatedAt = summaryUpdatedAt | |
| 555 | + | a.lastActivity = summaryLastActivity | |
| 556 | + | a.lifecycleMu.Unlock() | |
| 557 | + | } | |
| 558 | + | a.mu.Lock() | |
| 559 | + | a.transcriptLoaded = true | |
| 560 | + | a.mu.Unlock() | |
| 561 | + | return nil | |
| 562 | + | } | |
| 563 | + | ||
| 454 | 564 | // replayEventsIntoAgent re-derives an agent's transcript and last | |
| 455 | 565 | // known status hint from its event log. Mirrors applyEventToSink in | |
| 456 | 566 | // runner.go but writes directly to the Agent fields (no Sink because | |
| @@ -491,36 +601,8 @@ func replayEventsIntoAgent(a *Agent, evs []Event) { | |||
| 491 | 601 | a.status = StatusDone | |
| 492 | 602 | a.activity = "done (offline)" | |
| 493 | 603 | a.mu.Unlock() | |
| 494 | - | case "assistant_message", "user_message": | |
| 495 | - | var text []string | |
| 496 | - | if c, ok := ev.Data["content"].([]any); ok { | |
| 497 | - | for _, blk := range c { | |
| 498 | - | m, _ := blk.(map[string]any) | |
| 499 | - | if t, _ := m["type"].(string); t == "text" { | |
| 500 | - | if txt, _ := m["text"].(string); txt != "" { | |
| 501 | - | text = append(text, txt) | |
| 502 | - | } | |
| 503 | - | } | |
| 504 | - | } | |
| 505 | - | } | |
| 506 | - | message := strings.Join(text, "\n") | |
| 507 | - | if ev.Type == "assistant_message" { | |
| 508 | - | a.appendAssistantMessage(message) | |
| 509 | - | } else { | |
| 510 | - | a.appendUserMessage(message) | |
| 511 | - | } | |
| 512 | - | case "stdout": | |
| 513 | - | if txt, _ := ev.Data["text"].(string); txt != "" { | |
| 514 | - | a.appendTranscript(txt) | |
| 515 | - | } | |
| 516 | - | case "stderr": | |
| 517 | - | if txt, _ := ev.Data["text"].(string); txt != "" { | |
| 518 | - | a.appendTranscript("stderr: " + txt) | |
| 519 | - | } | |
| 520 | - | case "error": | |
| 521 | - | if msg, _ := ev.Data["message"].(string); msg != "" { | |
| 522 | - | a.appendTranscript("error: " + msg) | |
| 523 | - | } | |
| 604 | + | case "assistant_message", "user_message", "stdout", "stderr", "error": | |
| 605 | + | replayEventTranscript(a, ev) | |
| 524 | 606 | case "agent_stopped": | |
| 525 | 607 | terminal = true | |
| 526 | 608 | reason, _ := ev.Data["reason"].(string) | |
| @@ -559,6 +641,50 @@ func replayEventsIntoAgent(a *Agent, evs []Event) { | |||
| 559 | 641 | } | |
| 560 | 642 | } | |
| 561 | 643 | ||
| 644 | + | // replayTranscriptIntoAgent rebuilds only the visible transcript. Newer | |
| 645 | + | // metadata already contains the authoritative dashboard lifecycle summary, | |
| 646 | + | // so opening history must not overwrite it with an earlier event attempt. | |
| 647 | + | func replayTranscriptIntoAgent(a *Agent, evs []Event) { | |
| 648 | + | for _, ev := range evs { | |
| 649 | + | replayEventTranscript(a, ev) | |
| 650 | + | } | |
| 651 | + | } | |
| 652 | + | ||
| 653 | + | func replayEventTranscript(a *Agent, ev Event) { | |
| 654 | + | switch ev.Type { | |
| 655 | + | case "assistant_message", "user_message": | |
| 656 | + | var text []string | |
| 657 | + | if c, ok := ev.Data["content"].([]any); ok { | |
| 658 | + | for _, blk := range c { | |
| 659 | + | m, _ := blk.(map[string]any) | |
| 660 | + | if t, _ := m["type"].(string); t == "text" { | |
| 661 | + | if txt, _ := m["text"].(string); txt != "" { | |
| 662 | + | text = append(text, txt) | |
| 663 | + | } | |
| 664 | + | } | |
| 665 | + | } | |
| 666 | + | } | |
| 667 | + | message := strings.Join(text, "\n") | |
| 668 | + | if ev.Type == "assistant_message" { | |
| 669 | + | a.appendAssistantMessage(message) | |
| 670 | + | } else { | |
| 671 | + | a.appendUserMessage(message) | |
| 672 | + | } | |
| 673 | + | case "stdout": | |
| 674 | + | if txt, _ := ev.Data["text"].(string); txt != "" { | |
| 675 | + | a.appendTranscript(txt) | |
| 676 | + | } | |
| 677 | + | case "stderr": | |
| 678 | + | if txt, _ := ev.Data["text"].(string); txt != "" { | |
| 679 | + | a.appendTranscript("stderr: " + txt) | |
| 680 | + | } | |
| 681 | + | case "error": | |
| 682 | + | if msg, _ := ev.Data["message"].(string); msg != "" { | |
| 683 | + | a.appendTranscript("error: " + msg) | |
| 684 | + | } | |
| 685 | + | } | |
| 686 | + | } | |
| 687 | + | ||
| 562 | 688 | // Resume re-attaches a Runner to a previously-spawned agent. Durable | |
| 563 | 689 | // session and event files are kept, while the transient inbox path is | |
| 564 | 690 | // recalculated for the current runtime environment. Use this to | |
| @@ -745,6 +871,7 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age | |||
| 745 | 871 | inbox: NewInbox(m.InboxPath), | |
| 746 | 872 | status: StatusPending, | |
| 747 | 873 | activity: "resuming", | |
| 874 | + | transcriptLoaded: true, | |
| 748 | 875 | processState: ProcessPending, | |
| 749 | 876 | turnState: TurnQueued, | |
| 750 | 877 | updatedAt: now, | |
| @@ -3,19 +3,14 @@ package subagents | |||||
| 3 | // On-disk persistence for subagent agents. | 3 | // On-disk persistence for subagent agents. | ||
| 4 | // | 4 | // | ||
| 5 | // Every Spawn writes a meta.json next to the agent's events.jsonl and | 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, | 6 | + | // session.json. The file captures identity, resumable lifecycle state, and |
| 7 | - | // task, branch, dir) plus the paths the runner needs to resume the | 7 | + | // compact dashboard state. On a fresh zut launch, Supervisor.Reload() walks |
| 8 | - | // agent later. On a fresh zut launch, Supervisor.Reload() walks | 8 | + | // <root>/agents/*/meta.json and re-registers every agent without replaying |
| 9 | - | // <root>/agents/*/meta.json and re-registers every agent it finds in | 9 | + | // its full history. The event log remains the durable transcript source and |
| 10 | - | // StatusDetached so the user can see, view, resume, or remove them | 10 | + | // is hydrated only when a caller requests that agent's transcript. |
| 11 | - | // from the dashboard. | |||
| 12 | // | 11 | // | ||
| 13 | - | // We don't try to keep meta.json in sync with mutable state (status, | 12 | + | // Metadata writes are atomic (tmp + rename). The supervisor is the sole |
| 14 | - | // activity, transcript). Those live in the events log (durable) and | 13 | + | // writer, so its lifecycle snapshots cannot race with another host process. |
| 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 | 14 | ||||
| 20 | import ( | 15 | import ( | ||
| 21 | "context" | 16 | "context" | ||
| @@ -62,6 +57,10 @@ type agentMeta struct { | |||||
| 62 | Timeout time.Duration `json:"timeout,omitempty"` | 57 | Timeout time.Duration `json:"timeout,omitempty"` | ||
| 63 | Tools []string `json:"tools,omitempty"` | 58 | Tools []string `json:"tools,omitempty"` | ||
| 64 | WebSearchPolicy WebSearchPolicy `json:"web_search_policy,omitempty"` | 59 | WebSearchPolicy WebSearchPolicy `json:"web_search_policy,omitempty"` | ||
| 60 | + | Status Status `json:"status,omitempty"` | |||
| 61 | + | Activity string `json:"activity,omitempty"` | |||
| 62 | + | Finished time.Time `json:"finished,omitempty"` | |||
| 63 | + | Error string `json:"error,omitempty"` | |||
| 65 | ProcessState ProcessState `json:"process_state,omitempty"` | 64 | ProcessState ProcessState `json:"process_state,omitempty"` | ||
| 66 | TurnState TurnState `json:"turn_state,omitempty"` | 65 | TurnState TurnState `json:"turn_state,omitempty"` | ||
| 67 | CurrentTurnID string `json:"current_turn_id,omitempty"` | 66 | CurrentTurnID string `json:"current_turn_id,omitempty"` | ||
| @@ -94,6 +93,15 @@ func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.jso | |||||
| 94 | func writeAgentMeta(stateDir string, a *Agent) error { | 93 | func writeAgentMeta(stateDir string, a *Agent) error { | ||
| 95 | fastMode := a.FastMode | 94 | fastMode := a.FastMode | ||
| 96 | webSearchPolicy := childWebSearchPolicy(a.WebSearchPolicy, a.Subagent, a.Tools) | 95 | webSearchPolicy := childWebSearchPolicy(a.WebSearchPolicy, a.Subagent, a.Tools) | ||
| 96 | + | a.mu.Lock() | |||
| 97 | + | status := a.status | |||
| 98 | + | activity := a.activity | |||
| 99 | + | finished := a.finished | |||
| 100 | + | errText := "" | |||
| 101 | + | if a.lastErr != nil { | |||
| 102 | + | errText = a.lastErr.Error() | |||
| 103 | + | } | |||
| 104 | + | a.mu.Unlock() | |||
| 97 | a.lifecycleMu.Lock() | 105 | a.lifecycleMu.Lock() | ||
| 98 | processState := a.processState | 106 | processState := a.processState | ||
| 99 | turnState := a.turnState | 107 | turnState := a.turnState | ||
| @@ -131,6 +139,10 @@ func writeAgentMeta(stateDir string, a *Agent) error { | |||||
| 131 | Timeout: a.Timeout, | 139 | Timeout: a.Timeout, | ||
| 132 | Tools: append([]string(nil), a.Tools...), | 140 | Tools: append([]string(nil), a.Tools...), | ||
| 133 | WebSearchPolicy: webSearchPolicy, | 141 | WebSearchPolicy: webSearchPolicy, | ||
| 142 | + | Status: status, | |||
| 143 | + | Activity: activity, | |||
| 144 | + | Finished: finished, | |||
| 145 | + | Error: errText, | |||
| 134 | ProcessState: processState, | 146 | ProcessState: processState, | ||
| 135 | TurnState: turnState, | 147 | TurnState: turnState, | ||
| 136 | CurrentTurnID: currentTurnID, | 148 | CurrentTurnID: currentTurnID, | ||
| @@ -323,13 +335,12 @@ func (f *Supervisor) reloadRoot(root string) (loaded int, errs []error) { | |||||
| 323 | return loaded, errs | 335 | return loaded, errs | ||
| 324 | } | 336 | } | ||
| 325 | 337 | ||||
| 326 | - | // buildDetachedAgent constructs an Agent from a meta.json with no | 338 | + | // buildDetachedAgent constructs an Agent from compact metadata with no running |
| 327 | - | // running Runner. The agent's transcript is populated from the tail | 339 | + | // Runner. Its transcript stays unloaded until a caller requests it, while the |
| 328 | - | // of its event log so the dashboard immediately shows recent output; | 340 | + | // persisted lifecycle summary keeps the dashboard immediately useful. |
| 329 | - | // activity is inferred from the last lifecycle event. | |||
| 330 | // | 341 | // | ||
| 331 | - | // The returned Agent has a closed `done` channel because Wait should | 342 | + | // The returned Agent has a closed `done` channel because Wait should return |
| 332 | - | // return instantly: there is nothing to wait for. | 343 | + | // instantly: there is nothing to wait for. |
| 333 | func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent { | 344 | func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent { | ||
| 334 | // Metadata without a repository identity uses the current RepoRoot; | 345 | // Metadata without a repository identity uses the current RepoRoot; | ||
| 335 | // records with one retain their persisted repository so a restart from | 346 | // records with one retain their persisted repository so a restart from | ||
| @@ -355,12 +366,42 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent { | |||||
| 355 | if lastActivity.IsZero() { | 366 | if lastActivity.IsZero() { | ||
| 356 | lastActivity = m.Started | 367 | lastActivity = m.Started | ||
| 357 | } | 368 | } | ||
| 358 | - | processState := ProcessDetached | 369 | + | persistedStatus := m.Status |
| 359 | - | activity := "detached" | 370 | + | status := StatusDetached |
| 371 | + | activity := strings.TrimSpace(m.Activity) | |||
| 372 | + | processState := m.ProcessState | |||
| 373 | + | legacyEventState := persistedStatus == "" | |||
| 374 | + | if legacyEventState { | |||
| 375 | + | activity = "detached" | |||
| 376 | + | processState = ProcessDetached | |||
| 377 | + | } | |||
| 378 | + | if processState == "" { | |||
| 379 | + | processState = ProcessDetached | |||
| 380 | + | } | |||
| 381 | + | if activity == "" { | |||
| 382 | + | switch persistedStatus { | |||
| 383 | + | case StatusDone: | |||
| 384 | + | activity = "done" | |||
| 385 | + | case StatusFailed: | |||
| 386 | + | activity = "failed" | |||
| 387 | + | case StatusKilled: | |||
| 388 | + | activity = "cancelled" | |||
| 389 | + | default: | |||
| 390 | + | activity = "detached" | |||
| 391 | + | } | |||
| 392 | + | } | |||
| 393 | + | if persistedStatus == StatusRunning || persistedStatus == StatusPending { | |||
| 394 | + | activity = "detached (resume to continue)" | |||
| 395 | + | processState = ProcessDetached | |||
| 396 | + | } | |||
| 360 | if inboxLive(m.InboxPath) { | 397 | if inboxLive(m.InboxPath) { | ||
| 361 | processState = ProcessAlive | 398 | processState = ProcessAlive | ||
| 362 | activity = "live (detached)" | 399 | activity = "live (detached)" | ||
| 363 | } | 400 | } | ||
| 401 | + | var lastErr error | |||
| 402 | + | if m.Error != "" { | |||
| 403 | + | lastErr = errors.New(m.Error) | |||
| 404 | + | } | |||
| 364 | agentStateDir := f.agentStateDir(m.ID) | 405 | agentStateDir := f.agentStateDir(m.ID) | ||
| 365 | if m.EventLogPath != "" { | 406 | if m.EventLogPath != "" { | ||
| 366 | // EventLogPath is <state-dir>/events.jsonl; keep the reloaded | 407 | // EventLogPath is <state-dir>/events.jsonl; keep the reloaded | ||
| @@ -401,8 +442,11 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent { | |||||
| 401 | SessionPath: m.SessionPath, | 442 | SessionPath: m.SessionPath, | ||
| 402 | SessionID: m.SessionID, | 443 | SessionID: m.SessionID, | ||
| 403 | inbox: NewInbox(m.InboxPath), | 444 | inbox: NewInbox(m.InboxPath), | ||
| 404 | - | status: StatusDetached, | 445 | + | status: status, |
| 405 | activity: activity, | 446 | activity: activity, | ||
| 447 | + | finished: m.Finished, | |||
| 448 | + | lastErr: lastErr, | |||
| 449 | + | legacyEventState: legacyEventState, | |||
| 406 | processState: processState, | 450 | processState: processState, | ||
| 407 | turnState: turnState, | 451 | turnState: turnState, | ||
| 408 | currentTurnID: m.CurrentTurnID, | 452 | currentTurnID: m.CurrentTurnID, | ||
| @@ -422,14 +466,6 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent { | |||||
| 422 | } | 466 | } | ||
| 423 | a.closeDone() | 467 | a.closeDone() | ||
| 424 | 468 | ||||
| 425 | - | // Recover transcript + activity hints from the event log. Best | |||
| 426 | - | // effort: a missing or unreadable log just leaves the agent | |||
| 427 | - | // detached with an empty transcript. | |||
| 428 | - | if a.EventLogPath != "" { | |||
| 429 | - | if evs, err := ReadEventLog(a.EventLogPath); err == nil { | |||
| 430 | - | replayEventsIntoAgent(a, evs) | |||
| 431 | - | } | |||
| 432 | - | } | |||
| 433 | // The append-only log may contain a terminal event from an earlier | 469 | // The append-only log may contain a terminal event from an earlier | ||
| 434 | // attempt. A live socket is stronger evidence for the current process; | 470 | // attempt. A live socket is stronger evidence for the current process; | ||
| 435 | // keep the reloaded entry detached/live so Stop and Remove cannot treat a | 471 | // keep the reloaded entry detached/live so Stop and Remove cannot treat a | ||
| @@ -451,6 +487,80 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent { | |||||
| 451 | return a | 487 | return a | ||
| 452 | } | 488 | } | ||
| 453 | 489 | ||||
| 490 | + | // LoadTranscript hydrates one reloaded agent's durable event history. Reload | |||
| 491 | + | // deliberately skips this work so startup stays bounded by metadata size; | |||
| 492 | + | // callers request it only when opening the transcript or otherwise reading it. | |||
| 493 | + | func (f *Supervisor) LoadTranscript(id string) error { | |||
| 494 | + | a := f.Get(id) | |||
| 495 | + | if a == nil { | |||
| 496 | + | return fmt.Errorf("subagent: no such agent %q", id) | |||
| 497 | + | } | |||
| 498 | + | return a.loadTranscript() | |||
| 499 | + | } | |||
| 500 | + | ||||
| 501 | + | func (a *Agent) loadTranscript() error { | |||
| 502 | + | if a == nil { | |||
| 503 | + | return nil | |||
| 504 | + | } | |||
| 505 | + | a.transcriptMu.Lock() | |||
| 506 | + | defer a.transcriptMu.Unlock() | |||
| 507 | + | ||||
| 508 | + | a.mu.Lock() | |||
| 509 | + | if a.transcriptLoaded { | |||
| 510 | + | a.mu.Unlock() | |||
| 511 | + | return nil | |||
| 512 | + | } | |||
| 513 | + | path := a.EventLogPath | |||
| 514 | + | legacyEventState := a.legacyEventState | |||
| 515 | + | a.mu.Unlock() | |||
| 516 | + | var summaryUpdatedAt, summaryLastActivity time.Time | |||
| 517 | + | if !legacyEventState { | |||
| 518 | + | a.lifecycleMu.Lock() | |||
| 519 | + | summaryUpdatedAt = a.updatedAt | |||
| 520 | + | summaryLastActivity = a.lastActivity | |||
| 521 | + | a.lifecycleMu.Unlock() | |||
| 522 | + | } | |||
| 523 | + | if path == "" { | |||
| 524 | + | a.mu.Lock() | |||
| 525 | + | a.transcriptLoaded = true | |||
| 526 | + | a.mu.Unlock() | |||
| 527 | + | return nil | |||
| 528 | + | } | |||
| 529 | + | ||||
| 530 | + | events, err := ReadEventLog(path) | |||
| 531 | + | if err != nil { | |||
| 532 | + | return fmt.Errorf("read subagent transcript: %w", err) | |||
| 533 | + | } | |||
| 534 | + | a.mu.Lock() | |||
| 535 | + | if a.transcriptLoaded { | |||
| 536 | + | a.mu.Unlock() | |||
| 537 | + | return nil | |||
| 538 | + | } | |||
| 539 | + | a.transcript = nil | |||
| 540 | + | a.lastAssistant = "" | |||
| 541 | + | a.outputBytes = 0 | |||
| 542 | + | a.outputLines = 0 | |||
| 543 | + | a.outputTruncated = false | |||
| 544 | + | a.mu.Unlock() | |||
| 545 | + | ||||
| 546 | + | if legacyEventState { | |||
| 547 | + | // Metadata written before the dashboard summary existed has no | |||
| 548 | + | // terminal status to trust. Preserve its historical event-derived | |||
| 549 | + | // behavior once the user explicitly opens its transcript. | |||
| 550 | + | replayEventsIntoAgent(a, events) | |||
| 551 | + | } else { | |||
| 552 | + | replayTranscriptIntoAgent(a, events) | |||
| 553 | + | a.lifecycleMu.Lock() | |||
| 554 | + | a.updatedAt = summaryUpdatedAt | |||
| 555 | + | a.lastActivity = summaryLastActivity | |||
| 556 | + | a.lifecycleMu.Unlock() | |||
| 557 | + | } | |||
| 558 | + | a.mu.Lock() | |||
| 559 | + | a.transcriptLoaded = true | |||
| 560 | + | a.mu.Unlock() | |||
| 561 | + | return nil | |||
| 562 | + | } | |||
| 563 | + | ||||
| 454 | // replayEventsIntoAgent re-derives an agent's transcript and last | 564 | // replayEventsIntoAgent re-derives an agent's transcript and last | ||
| 455 | // known status hint from its event log. Mirrors applyEventToSink in | 565 | // known status hint from its event log. Mirrors applyEventToSink in | ||
| 456 | // runner.go but writes directly to the Agent fields (no Sink because | 566 | // runner.go but writes directly to the Agent fields (no Sink because | ||
| @@ -491,36 +601,8 @@ func replayEventsIntoAgent(a *Agent, evs []Event) { | |||||
| 491 | a.status = StatusDone | 601 | a.status = StatusDone | ||
| 492 | a.activity = "done (offline)" | 602 | a.activity = "done (offline)" | ||
| 493 | a.mu.Unlock() | 603 | a.mu.Unlock() | ||
| 494 | - | case "assistant_message", "user_message": | 604 | + | case "assistant_message", "user_message", "stdout", "stderr", "error": |
| 495 | - | var text []string | 605 | + | replayEventTranscript(a, ev) |
| 496 | - | if c, ok := ev.Data["content"].([]any); ok { | |||
| 497 | - | for _, blk := range c { | |||
| 498 | - | m, _ := blk.(map[string]any) | |||
| 499 | - | if t, _ := m["type"].(string); t == "text" { | |||
| 500 | - | if txt, _ := m["text"].(string); txt != "" { | |||
| 501 | - | text = append(text, txt) | |||
| 502 | - | } | |||
| 503 | - | } | |||
| 504 | - | } | |||
| 505 | - | } | |||
| 506 | - | message := strings.Join(text, "\n") | |||
| 507 | - | if ev.Type == "assistant_message" { | |||
| 508 | - | a.appendAssistantMessage(message) | |||
| 509 | - | } else { | |||
| 510 | - | a.appendUserMessage(message) | |||
| 511 | - | } | |||
| 512 | - | case "stdout": | |||
| 513 | - | if txt, _ := ev.Data["text"].(string); txt != "" { | |||
| 514 | - | a.appendTranscript(txt) | |||
| 515 | - | } | |||
| 516 | - | case "stderr": | |||
| 517 | - | if txt, _ := ev.Data["text"].(string); txt != "" { | |||
| 518 | - | a.appendTranscript("stderr: " + txt) | |||
| 519 | - | } | |||
| 520 | - | case "error": | |||
| 521 | - | if msg, _ := ev.Data["message"].(string); msg != "" { | |||
| 522 | - | a.appendTranscript("error: " + msg) | |||
| 523 | - | } | |||
| 524 | case "agent_stopped": | 606 | case "agent_stopped": | ||
| 525 | terminal = true | 607 | terminal = true | ||
| 526 | reason, _ := ev.Data["reason"].(string) | 608 | reason, _ := ev.Data["reason"].(string) | ||
| @@ -559,6 +641,50 @@ func replayEventsIntoAgent(a *Agent, evs []Event) { | |||||
| 559 | } | 641 | } | ||
| 560 | } | 642 | } | ||
| 561 | 643 | ||||
| 644 | + | // replayTranscriptIntoAgent rebuilds only the visible transcript. Newer | |||
| 645 | + | // metadata already contains the authoritative dashboard lifecycle summary, | |||
| 646 | + | // so opening history must not overwrite it with an earlier event attempt. | |||
| 647 | + | func replayTranscriptIntoAgent(a *Agent, evs []Event) { | |||
| 648 | + | for _, ev := range evs { | |||
| 649 | + | replayEventTranscript(a, ev) | |||
| 650 | + | } | |||
| 651 | + | } | |||
| 652 | + | ||||
| 653 | + | func replayEventTranscript(a *Agent, ev Event) { | |||
| 654 | + | switch ev.Type { | |||
| 655 | + | case "assistant_message", "user_message": | |||
| 656 | + | var text []string | |||
| 657 | + | if c, ok := ev.Data["content"].([]any); ok { | |||
| 658 | + | for _, blk := range c { | |||
| 659 | + | m, _ := blk.(map[string]any) | |||
| 660 | + | if t, _ := m["type"].(string); t == "text" { | |||
| 661 | + | if txt, _ := m["text"].(string); txt != "" { | |||
| 662 | + | text = append(text, txt) | |||
| 663 | + | } | |||
| 664 | + | } | |||
| 665 | + | } | |||
| 666 | + | } | |||
| 667 | + | message := strings.Join(text, "\n") | |||
| 668 | + | if ev.Type == "assistant_message" { | |||
| 669 | + | a.appendAssistantMessage(message) | |||
| 670 | + | } else { | |||
| 671 | + | a.appendUserMessage(message) | |||
| 672 | + | } | |||
| 673 | + | case "stdout": | |||
| 674 | + | if txt, _ := ev.Data["text"].(string); txt != "" { | |||
| 675 | + | a.appendTranscript(txt) | |||
| 676 | + | } | |||
| 677 | + | case "stderr": | |||
| 678 | + | if txt, _ := ev.Data["text"].(string); txt != "" { | |||
| 679 | + | a.appendTranscript("stderr: " + txt) | |||
| 680 | + | } | |||
| 681 | + | case "error": | |||
| 682 | + | if msg, _ := ev.Data["message"].(string); msg != "" { | |||
| 683 | + | a.appendTranscript("error: " + msg) | |||
| 684 | + | } | |||
| 685 | + | } | |||
| 686 | + | } | |||
| 687 | + | ||||
| 562 | // Resume re-attaches a Runner to a previously-spawned agent. Durable | 688 | // Resume re-attaches a Runner to a previously-spawned agent. Durable | ||
| 563 | // session and event files are kept, while the transient inbox path is | 689 | // session and event files are kept, while the transient inbox path is | ||
| 564 | // recalculated for the current runtime environment. Use this to | 690 | // recalculated for the current runtime environment. Use this to | ||
| @@ -745,6 +871,7 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age | |||||
| 745 | inbox: NewInbox(m.InboxPath), | 871 | inbox: NewInbox(m.InboxPath), | ||
| 746 | status: StatusPending, | 872 | status: StatusPending, | ||
| 747 | activity: "resuming", | 873 | activity: "resuming", | ||
| 874 | + | transcriptLoaded: true, | |||
| 748 | processState: ProcessPending, | 875 | processState: ProcessPending, | ||
| 749 | turnState: TurnQueued, | 876 | turnState: TurnQueued, | ||
| 750 | updatedAt: now, | 877 | updatedAt: now, | ||
M packages/agent/subagents/persist_test.go +81 -3
| @@ -292,11 +292,15 @@ func TestReloadReplaysTranscriptFromEventLog(t *testing.T) { | |||
| 292 | 292 | if a == nil { | |
| 293 | 293 | t.Fatal("reloaded agent not found") | |
| 294 | 294 | } | |
| 295 | - | // Status should reflect the lifecycle terminator we wrote. | |
| 296 | - | if a.Status() != StatusDone { | |
| 297 | - | t.Errorf("status = %q; want done (offline)", a.Status()) | |
| 295 | + | // Legacy metadata has no persisted dashboard status, so startup keeps the | |
| 296 | + | // agent detached until the user asks to inspect its durable history. | |
| 297 | + | if a.Status() != StatusDetached { | |
| 298 | + | t.Errorf("startup status = %q; want detached", a.Status()) | |
| 298 | 299 | } | |
| 299 | 300 | got := a.Transcript() | |
| 301 | + | if a.Status() != StatusDone { | |
| 302 | + | t.Errorf("hydrated status = %q; want done (offline)", a.Status()) | |
| 303 | + | } | |
| 300 | 304 | found := false | |
| 301 | 305 | for _, line := range got { | |
| 302 | 306 | if line == "hello from the past" { | |
| @@ -308,6 +312,80 @@ func TestReloadReplaysTranscriptFromEventLog(t *testing.T) { | |||
| 308 | 312 | } | |
| 309 | 313 | } | |
| 310 | 314 | ||
| 315 | + | // TestReloadUsesPersistedSummaryBeforeTranscriptHydration ensures startup | |
| 316 | + | // rebuilds the dashboard from compact metadata rather than replaying the | |
| 317 | + | // event log. Opening the transcript remains the explicit point that loads | |
| 318 | + | // durable history. | |
| 319 | + | func TestReloadUsesPersistedSummaryBeforeTranscriptHydration(t *testing.T) { | |
| 320 | + | root := t.TempDir() | |
| 321 | + | id := "summary-1" | |
| 322 | + | stateDir := filepath.Join(root, "agents", id) | |
| 323 | + | agent := &Agent{ | |
| 324 | + | ID: id, | |
| 325 | + | Task: "persist summary", | |
| 326 | + | OriginalTask: "persist summary", | |
| 327 | + | Dir: root, | |
| 328 | + | Started: time.Now().Add(-time.Hour), | |
| 329 | + | EventLogPath: filepath.Join(stateDir, "events.jsonl"), | |
| 330 | + | SessionPath: filepath.Join(stateDir, "session.json"), | |
| 331 | + | status: StatusDone, | |
| 332 | + | activity: "done", | |
| 333 | + | finished: time.Now().Add(-time.Minute), | |
| 334 | + | processState: ProcessExited, | |
| 335 | + | turnState: TurnSucceeded, | |
| 336 | + | currentTurnID: "turn-1", | |
| 337 | + | done: make(chan struct{}), | |
| 338 | + | } | |
| 339 | + | if err := writeAgentMeta(stateDir, agent); err != nil { | |
| 340 | + | t.Fatal(err) | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | log, err := OpenEventLog(agent.EventLogPath) | |
| 344 | + | if err != nil { | |
| 345 | + | t.Fatal(err) | |
| 346 | + | } | |
| 347 | + | if err := log.Append(NewEvent("assistant_message", map[string]any{ | |
| 348 | + | "content": []any{map[string]any{"type": "text", "text": "historical reply"}}, | |
| 349 | + | })); err != nil { | |
| 350 | + | t.Fatal(err) | |
| 351 | + | } | |
| 352 | + | if err := log.Append(NewEvent("agent_stopped", map[string]any{"reason": "cancelled"})); err != nil { | |
| 353 | + | t.Fatal(err) | |
| 354 | + | } | |
| 355 | + | if err := log.Close(); err != nil { | |
| 356 | + | t.Fatal(err) | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | supervisor := New(Config{Root: root, RepoRoot: root}) | |
| 360 | + | if loaded, errs := supervisor.Reload(); loaded != 1 || len(errs) != 0 { | |
| 361 | + | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |
| 362 | + | } | |
| 363 | + | reloaded := supervisor.Get(id) | |
| 364 | + | if reloaded == nil { | |
| 365 | + | t.Fatal("reloaded agent missing") | |
| 366 | + | } | |
| 367 | + | before := reloaded.Snapshot() | |
| 368 | + | if before.Status != StatusDetached || before.Activity != "done" || before.ProcessState != ProcessExited || before.TurnState != TurnSucceeded { | |
| 369 | + | t.Fatalf("startup summary = (%q, %q, %q, %q), want (detached, done, exited, succeeded)", before.Status, before.Activity, before.ProcessState, before.TurnState) | |
| 370 | + | } | |
| 371 | + | if len(before.Lines) != 0 { | |
| 372 | + | t.Fatalf("startup loaded transcript lines: %v", before.Lines) | |
| 373 | + | } | |
| 374 | + | lastActivity := before.LastActivity | |
| 375 | + | ||
| 376 | + | lines := reloaded.Transcript() | |
| 377 | + | if len(lines) != 1 || lines[0] != "historical reply" { | |
| 378 | + | t.Fatalf("hydrated transcript = %v", lines) | |
| 379 | + | } | |
| 380 | + | after := reloaded.Snapshot() | |
| 381 | + | if after.Status != StatusDetached || after.Activity != "done" { | |
| 382 | + | t.Fatalf("hydration overwrote persisted summary: (%q, %q)", after.Status, after.Activity) | |
| 383 | + | } | |
| 384 | + | if !after.LastActivity.Equal(lastActivity) { | |
| 385 | + | t.Fatalf("hydration changed last activity from %s to %s", lastActivity, after.LastActivity) | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | ||
| 311 | 389 | // TestReloadSkipsBareDirsAndCorruptMeta ensures one bad meta.json | |
| 312 | 390 | // doesn't blow up the whole reload. Directories with no meta.json | |
| 313 | 391 | // at all are silently ignored (Spawn that failed mid-way leaves | |
| @@ -292,11 +292,15 @@ func TestReloadReplaysTranscriptFromEventLog(t *testing.T) { | |||||
| 292 | if a == nil { | 292 | if a == nil { | ||
| 293 | t.Fatal("reloaded agent not found") | 293 | t.Fatal("reloaded agent not found") | ||
| 294 | } | 294 | } | ||
| 295 | - | // Status should reflect the lifecycle terminator we wrote. | 295 | + | // Legacy metadata has no persisted dashboard status, so startup keeps the |
| 296 | - | if a.Status() != StatusDone { | 296 | + | // agent detached until the user asks to inspect its durable history. |
| 297 | - | t.Errorf("status = %q; want done (offline)", a.Status()) | 297 | + | if a.Status() != StatusDetached { |
| 298 | + | t.Errorf("startup status = %q; want detached", a.Status()) | |||
| 298 | } | 299 | } | ||
| 299 | got := a.Transcript() | 300 | got := a.Transcript() | ||
| 301 | + | if a.Status() != StatusDone { | |||
| 302 | + | t.Errorf("hydrated status = %q; want done (offline)", a.Status()) | |||
| 303 | + | } | |||
| 300 | found := false | 304 | found := false | ||
| 301 | for _, line := range got { | 305 | for _, line := range got { | ||
| 302 | if line == "hello from the past" { | 306 | if line == "hello from the past" { | ||
| @@ -308,6 +312,80 @@ func TestReloadReplaysTranscriptFromEventLog(t *testing.T) { | |||||
| 308 | } | 312 | } | ||
| 309 | } | 313 | } | ||
| 310 | 314 | ||||
| 315 | + | // TestReloadUsesPersistedSummaryBeforeTranscriptHydration ensures startup | |||
| 316 | + | // rebuilds the dashboard from compact metadata rather than replaying the | |||
| 317 | + | // event log. Opening the transcript remains the explicit point that loads | |||
| 318 | + | // durable history. | |||
| 319 | + | func TestReloadUsesPersistedSummaryBeforeTranscriptHydration(t *testing.T) { | |||
| 320 | + | root := t.TempDir() | |||
| 321 | + | id := "summary-1" | |||
| 322 | + | stateDir := filepath.Join(root, "agents", id) | |||
| 323 | + | agent := &Agent{ | |||
| 324 | + | ID: id, | |||
| 325 | + | Task: "persist summary", | |||
| 326 | + | OriginalTask: "persist summary", | |||
| 327 | + | Dir: root, | |||
| 328 | + | Started: time.Now().Add(-time.Hour), | |||
| 329 | + | EventLogPath: filepath.Join(stateDir, "events.jsonl"), | |||
| 330 | + | SessionPath: filepath.Join(stateDir, "session.json"), | |||
| 331 | + | status: StatusDone, | |||
| 332 | + | activity: "done", | |||
| 333 | + | finished: time.Now().Add(-time.Minute), | |||
| 334 | + | processState: ProcessExited, | |||
| 335 | + | turnState: TurnSucceeded, | |||
| 336 | + | currentTurnID: "turn-1", | |||
| 337 | + | done: make(chan struct{}), | |||
| 338 | + | } | |||
| 339 | + | if err := writeAgentMeta(stateDir, agent); err != nil { | |||
| 340 | + | t.Fatal(err) | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | log, err := OpenEventLog(agent.EventLogPath) | |||
| 344 | + | if err != nil { | |||
| 345 | + | t.Fatal(err) | |||
| 346 | + | } | |||
| 347 | + | if err := log.Append(NewEvent("assistant_message", map[string]any{ | |||
| 348 | + | "content": []any{map[string]any{"type": "text", "text": "historical reply"}}, | |||
| 349 | + | })); err != nil { | |||
| 350 | + | t.Fatal(err) | |||
| 351 | + | } | |||
| 352 | + | if err := log.Append(NewEvent("agent_stopped", map[string]any{"reason": "cancelled"})); err != nil { | |||
| 353 | + | t.Fatal(err) | |||
| 354 | + | } | |||
| 355 | + | if err := log.Close(); err != nil { | |||
| 356 | + | t.Fatal(err) | |||
| 357 | + | } | |||
| 358 | + | ||||
| 359 | + | supervisor := New(Config{Root: root, RepoRoot: root}) | |||
| 360 | + | if loaded, errs := supervisor.Reload(); loaded != 1 || len(errs) != 0 { | |||
| 361 | + | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |||
| 362 | + | } | |||
| 363 | + | reloaded := supervisor.Get(id) | |||
| 364 | + | if reloaded == nil { | |||
| 365 | + | t.Fatal("reloaded agent missing") | |||
| 366 | + | } | |||
| 367 | + | before := reloaded.Snapshot() | |||
| 368 | + | if before.Status != StatusDetached || before.Activity != "done" || before.ProcessState != ProcessExited || before.TurnState != TurnSucceeded { | |||
| 369 | + | t.Fatalf("startup summary = (%q, %q, %q, %q), want (detached, done, exited, succeeded)", before.Status, before.Activity, before.ProcessState, before.TurnState) | |||
| 370 | + | } | |||
| 371 | + | if len(before.Lines) != 0 { | |||
| 372 | + | t.Fatalf("startup loaded transcript lines: %v", before.Lines) | |||
| 373 | + | } | |||
| 374 | + | lastActivity := before.LastActivity | |||
| 375 | + | ||||
| 376 | + | lines := reloaded.Transcript() | |||
| 377 | + | if len(lines) != 1 || lines[0] != "historical reply" { | |||
| 378 | + | t.Fatalf("hydrated transcript = %v", lines) | |||
| 379 | + | } | |||
| 380 | + | after := reloaded.Snapshot() | |||
| 381 | + | if after.Status != StatusDetached || after.Activity != "done" { | |||
| 382 | + | t.Fatalf("hydration overwrote persisted summary: (%q, %q)", after.Status, after.Activity) | |||
| 383 | + | } | |||
| 384 | + | if !after.LastActivity.Equal(lastActivity) { | |||
| 385 | + | t.Fatalf("hydration changed last activity from %s to %s", lastActivity, after.LastActivity) | |||
| 386 | + | } | |||
| 387 | + | } | |||
| 388 | + | ||||
| 311 | // TestReloadSkipsBareDirsAndCorruptMeta ensures one bad meta.json | 389 | // TestReloadSkipsBareDirsAndCorruptMeta ensures one bad meta.json | ||
| 312 | // doesn't blow up the whole reload. Directories with no meta.json | 390 | // doesn't blow up the whole reload. Directories with no meta.json | ||
| 313 | // at all are silently ignored (Spawn that failed mid-way leaves | 391 | // at all are silently ignored (Spawn that failed mid-way leaves | ||
M packages/agent/subagents/supervisor.go +8 -5
| @@ -328,7 +328,7 @@ func (f *Supervisor) uniqueAgentIDLocked(base string) string { | |||
| 328 | 328 | // chosen model regardless of the parent's current selection. A named | |
| 329 | 329 | // Subagent is passed to the child as --subagent, where it resolves the | |
| 330 | 330 | // markdown profile and applies its instructions. Reasoning is passed | |
| 331 | - | // as --reasoning when set. FastMode restricts the host setting when a | |
| 331 | + | // as --reasoning when set. FastMode overrides the host setting when a | |
| 332 | 332 | // selected profile declares a fastMode preference. | |
| 333 | 333 | type SpawnRequest struct { | |
| 334 | 334 | Task string | |
| @@ -339,9 +339,8 @@ type SpawnRequest struct { | |||
| 339 | 339 | // effective custom provider endpoint. | |
| 340 | 340 | InsecureTLS bool | |
| 341 | 341 | Reasoning string // optional reasoning/thinking level override | |
| 342 | - | // FastMode is an optional profile restriction. A non-nil false value | |
| 343 | - | // disables fast mode for this child; true still cannot enable fast mode | |
| 344 | - | // when Config.FastMode is false. Nil inherits Config.FastMode. | |
| 342 | + | // FastMode is an optional profile override. A non-nil value enables or | |
| 343 | + | // disables fast mode for this child; nil inherits Config.FastMode. | |
| 345 | 344 | FastMode *bool | |
| 346 | 345 | Subagent string // optional named markdown profile | |
| 347 | 346 | ||
| @@ -413,8 +412,10 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||
| 413 | 412 | configInsecureTLS := f.cfg.InsecureTLS | |
| 414 | 413 | sessionID := f.activeSession | |
| 415 | 414 | f.mu.Unlock() | |
| 415 | + | fastModeOverridesHost := false | |
| 416 | 416 | if req.FastMode != nil { | |
| 417 | - | fastMode = fastMode && *req.FastMode | |
| 417 | + | fastModeOverridesHost = !fastMode && *req.FastMode | |
| 418 | + | fastMode = *req.FastMode | |
| 418 | 419 | } | |
| 419 | 420 | childProvider := strings.TrimSpace(req.Provider) | |
| 420 | 421 | inheritProviderSettings := childProvider == "" || configProvider == "" || strings.EqualFold(childProvider, strings.TrimSpace(configProvider)) | |
| @@ -527,6 +528,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||
| 527 | 528 | inbox: NewInbox(inboxPath), | |
| 528 | 529 | status: StatusPending, | |
| 529 | 530 | activity: "queued", | |
| 531 | + | transcriptLoaded: true, | |
| 530 | 532 | processState: ProcessPending, | |
| 531 | 533 | turnState: TurnQueued, | |
| 532 | 534 | updatedAt: now, | |
| @@ -538,6 +540,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||
| 538 | 540 | done: make(chan struct{}), | |
| 539 | 541 | turnResults: make(chan *TurnResult, 16), | |
| 540 | 542 | } | |
| 543 | + | a.fastModeOverridesHost = fastModeOverridesHost | |
| 541 | 544 | if a.RootSessionID == "" { | |
| 542 | 545 | a.RootSessionID = sessionID | |
| 543 | 546 | } | |
| @@ -328,7 +328,7 @@ func (f *Supervisor) uniqueAgentIDLocked(base string) string { | |||||
| 328 | // chosen model regardless of the parent's current selection. A named | 328 | // chosen model regardless of the parent's current selection. A named | ||
| 329 | // Subagent is passed to the child as --subagent, where it resolves the | 329 | // Subagent is passed to the child as --subagent, where it resolves the | ||
| 330 | // markdown profile and applies its instructions. Reasoning is passed | 330 | // markdown profile and applies its instructions. Reasoning is passed | ||
| 331 | - | // as --reasoning when set. FastMode restricts the host setting when a | 331 | + | // as --reasoning when set. FastMode overrides the host setting when a |
| 332 | // selected profile declares a fastMode preference. | 332 | // selected profile declares a fastMode preference. | ||
| 333 | type SpawnRequest struct { | 333 | type SpawnRequest struct { | ||
| 334 | Task string | 334 | Task string | ||
| @@ -339,9 +339,8 @@ type SpawnRequest struct { | |||||
| 339 | // effective custom provider endpoint. | 339 | // effective custom provider endpoint. | ||
| 340 | InsecureTLS bool | 340 | InsecureTLS bool | ||
| 341 | Reasoning string // optional reasoning/thinking level override | 341 | Reasoning string // optional reasoning/thinking level override | ||
| 342 | - | // FastMode is an optional profile restriction. A non-nil false value | 342 | + | // FastMode is an optional profile override. A non-nil value enables or |
| 343 | - | // disables fast mode for this child; true still cannot enable fast mode | 343 | + | // disables fast mode for this child; nil inherits Config.FastMode. |
| 344 | - | // when Config.FastMode is false. Nil inherits Config.FastMode. | |||
| 345 | FastMode *bool | 344 | FastMode *bool | ||
| 346 | Subagent string // optional named markdown profile | 345 | Subagent string // optional named markdown profile | ||
| 347 | 346 | ||||
| @@ -413,8 +412,10 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||||
| 413 | configInsecureTLS := f.cfg.InsecureTLS | 412 | configInsecureTLS := f.cfg.InsecureTLS | ||
| 414 | sessionID := f.activeSession | 413 | sessionID := f.activeSession | ||
| 415 | f.mu.Unlock() | 414 | f.mu.Unlock() | ||
| 415 | + | fastModeOverridesHost := false | |||
| 416 | if req.FastMode != nil { | 416 | if req.FastMode != nil { | ||
| 417 | - | fastMode = fastMode && *req.FastMode | 417 | + | fastModeOverridesHost = !fastMode && *req.FastMode |
| 418 | + | fastMode = *req.FastMode | |||
| 418 | } | 419 | } | ||
| 419 | childProvider := strings.TrimSpace(req.Provider) | 420 | childProvider := strings.TrimSpace(req.Provider) | ||
| 420 | inheritProviderSettings := childProvider == "" || configProvider == "" || strings.EqualFold(childProvider, strings.TrimSpace(configProvider)) | 421 | inheritProviderSettings := childProvider == "" || configProvider == "" || strings.EqualFold(childProvider, strings.TrimSpace(configProvider)) | ||
| @@ -527,6 +528,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||||
| 527 | inbox: NewInbox(inboxPath), | 528 | inbox: NewInbox(inboxPath), | ||
| 528 | status: StatusPending, | 529 | status: StatusPending, | ||
| 529 | activity: "queued", | 530 | activity: "queued", | ||
| 531 | + | transcriptLoaded: true, | |||
| 530 | processState: ProcessPending, | 532 | processState: ProcessPending, | ||
| 531 | turnState: TurnQueued, | 533 | turnState: TurnQueued, | ||
| 532 | updatedAt: now, | 534 | updatedAt: now, | ||
| @@ -538,6 +540,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||||
| 538 | done: make(chan struct{}), | 540 | done: make(chan struct{}), | ||
| 539 | turnResults: make(chan *TurnResult, 16), | 541 | turnResults: make(chan *TurnResult, 16), | ||
| 540 | } | 542 | } | ||
| 543 | + | a.fastModeOverridesHost = fastModeOverridesHost | |||
| 541 | if a.RootSessionID == "" { | 544 | if a.RootSessionID == "" { | ||
| 542 | a.RootSessionID = sessionID | 545 | a.RootSessionID = sessionID | ||
| 543 | } | 546 | } | ||
M packages/agent/systemprompt.go +5 -0
| @@ -64,6 +64,9 @@ func BuildSystemPrompt(o SystemPromptOpts) string { | |||
| 64 | 64 | sb.WriteString(defaultIdentity) | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | + | sb.WriteString("\n\n") | |
| 68 | + | sb.WriteString(compactedSummaryHandoffInstruction) | |
| 69 | + | ||
| 67 | 70 | if strings.TrimSpace(o.ZutDocsDir) != "" { | |
| 68 | 71 | sb.WriteString("\n\nZut's own docs are installed under ") | |
| 69 | 72 | sb.WriteString(o.ZutDocsDir) | |
| @@ -87,3 +90,5 @@ const defaultIdentity = `You are an expert coding assistant operating inside zut | |||
| 87 | 90 | Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did. | |
| 88 | 91 | ||
| 89 | 92 | For focused changes to an existing file, inspect its current contents and use edit with verbatim oldText taken from that same file. Include only enough context to make each match unambiguous. Use write when creating a file or replacing it wholesale. Do not mutate files through bash redirections or commands such as cat, echo, sed, or tee, because those changes appear as opaque shell output instead of a readable edit diff.` | |
| 93 | + | ||
| 94 | + | const compactedSummaryHandoffInstruction = `When you see a "## Context Summary (compacted)" message, treat it as a handoff from earlier work. Keep its active constraints and preferences in force, continue the most recent unresolved user request without waiting for the user to type "continue", and follow a newer user request when it supersedes the summary.` | |
| @@ -64,6 +64,9 @@ func BuildSystemPrompt(o SystemPromptOpts) string { | |||||
| 64 | sb.WriteString(defaultIdentity) | 64 | sb.WriteString(defaultIdentity) | ||
| 65 | } | 65 | } | ||
| 66 | 66 | ||||
| 67 | + | sb.WriteString("\n\n") | |||
| 68 | + | sb.WriteString(compactedSummaryHandoffInstruction) | |||
| 69 | + | ||||
| 67 | if strings.TrimSpace(o.ZutDocsDir) != "" { | 70 | if strings.TrimSpace(o.ZutDocsDir) != "" { | ||
| 68 | sb.WriteString("\n\nZut's own docs are installed under ") | 71 | sb.WriteString("\n\nZut's own docs are installed under ") | ||
| 69 | sb.WriteString(o.ZutDocsDir) | 72 | sb.WriteString(o.ZutDocsDir) | ||
| @@ -87,3 +90,5 @@ const defaultIdentity = `You are an expert coding assistant operating inside zut | |||||
| 87 | Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did. | 90 | Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did. | ||
| 88 | 91 | ||||
| 89 | For focused changes to an existing file, inspect its current contents and use edit with verbatim oldText taken from that same file. Include only enough context to make each match unambiguous. Use write when creating a file or replacing it wholesale. Do not mutate files through bash redirections or commands such as cat, echo, sed, or tee, because those changes appear as opaque shell output instead of a readable edit diff.` | 92 | For focused changes to an existing file, inspect its current contents and use edit with verbatim oldText taken from that same file. Include only enough context to make each match unambiguous. Use write when creating a file or replacing it wholesale. Do not mutate files through bash redirections or commands such as cat, echo, sed, or tee, because those changes appear as opaque shell output instead of a readable edit diff.` | ||
| 93 | + | ||||
| 94 | + | const compactedSummaryHandoffInstruction = `When you see a "## Context Summary (compacted)" message, treat it as a handoff from earlier work. Keep its active constraints and preferences in force, continue the most recent unresolved user request without waiting for the user to type "continue", and follow a newer user request when it supersedes the summary.` | |||
A packages/agent/systemprompt_test.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestBuildSystemPromptAddsCompactionHandoffToCustomPrompt(t *testing.T) { | |
| 9 | + | prompt := BuildSystemPrompt(SystemPromptOpts{Custom: "custom identity"}) | |
| 10 | + | if !strings.Contains(prompt, "custom identity") { | |
| 11 | + | t.Fatalf("custom prompt missing:\n%s", prompt) | |
| 12 | + | } | |
| 13 | + | if !strings.Contains(prompt, compactedSummaryHandoffInstruction) { | |
| 14 | + | t.Fatalf("custom prompt missing compaction handoff:\n%s", prompt) | |
| 15 | + | } | |
| 16 | + | if count := strings.Count(prompt, compactedSummaryHandoffInstruction); count != 1 { | |
| 17 | + | t.Fatalf("compaction handoff count = %d, want 1:\n%s", count, prompt) | |
| 18 | + | } | |
| 19 | + | } | |
| @@ -0,0 +1,19 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestBuildSystemPromptAddsCompactionHandoffToCustomPrompt(t *testing.T) { | |||
| 9 | + | prompt := BuildSystemPrompt(SystemPromptOpts{Custom: "custom identity"}) | |||
| 10 | + | if !strings.Contains(prompt, "custom identity") { | |||
| 11 | + | t.Fatalf("custom prompt missing:\n%s", prompt) | |||
| 12 | + | } | |||
| 13 | + | if !strings.Contains(prompt, compactedSummaryHandoffInstruction) { | |||
| 14 | + | t.Fatalf("custom prompt missing compaction handoff:\n%s", prompt) | |||
| 15 | + | } | |||
| 16 | + | if count := strings.Count(prompt, compactedSummaryHandoffInstruction); count != 1 { | |||
| 17 | + | t.Fatalf("compaction handoff count = %d, want 1:\n%s", count, prompt) | |||
| 18 | + | } | |||
| 19 | + | } | |||
A packages/agent/tools/create_worktree.go +908 -0
| @@ -0,0 +1,908 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "os" | |
| 11 | + | "os/exec" | |
| 12 | + | "path/filepath" | |
| 13 | + | "strings" | |
| 14 | + | "sync" | |
| 15 | + | "time" | |
| 16 | + | ||
| 17 | + | "github.com/bnema/zut/packages/core" | |
| 18 | + | "github.com/bnema/zut/packages/provider" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | const ( | |
| 22 | + | worktreesIgnoreEntry = "/.worktrees/" | |
| 23 | + | defaultWorktreeRoot = ".worktrees" | |
| 24 | + | worktreeConfigKey = "zut.worktrees.path" | |
| 25 | + | maxCreateWorktreeGitOutput = 64 * 1024 | |
| 26 | + | maxCreateWorktreeIgnoreBytes = 1 << 20 | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | var createWorktreeOperationMu sync.Mutex | |
| 30 | + | ||
| 31 | + | // CreateWorktreeTool creates a persistent branch checkout. A repository that | |
| 32 | + | // has neither a local worktree configuration nor an existing .worktrees | |
| 33 | + | // directory returns a bootstrap request without changing any files. | |
| 34 | + | type CreateWorktreeTool struct { | |
| 35 | + | CWD string | |
| 36 | + | Sandbox *Sandbox | |
| 37 | + | ||
| 38 | + | previewMu sync.Mutex | |
| 39 | + | preview *createWorktreePreview | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type createWorktreeArgs struct { | |
| 43 | + | Branch string `json:"branch"` | |
| 44 | + | BootstrapRoot string `json:"bootstrap_root,omitempty"` | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | const createWorktreeSchema = `{ | |
| 48 | + | "type":"object", | |
| 49 | + | "properties":{ | |
| 50 | + | "branch":{ | |
| 51 | + | "type":"string", | |
| 52 | + | "description":"Name of the new Git branch." | |
| 53 | + | }, | |
| 54 | + | "bootstrap_root":{ | |
| 55 | + | "type":"string", | |
| 56 | + | "description":"Only for an unconfigured repository: use .worktrees to initialize the repository-root default, or provide an absolute external worktree root. The choice is stored in local Git config. Omit it on the first call to request user guidance instead of changing files." | |
| 57 | + | } | |
| 58 | + | }, | |
| 59 | + | "required":["branch"] | |
| 60 | + | }` | |
| 61 | + | ||
| 62 | + | func (t *CreateWorktreeTool) Name() string { return "create_worktree" } | |
| 63 | + | func (t *CreateWorktreeTool) Description() string { | |
| 64 | + | return "Create a persistent Git branch worktree. An unconfigured repository returns bootstrap guidance; ask the user for .worktrees or an absolute external root, then call again with bootstrap_root." | |
| 65 | + | } | |
| 66 | + | func (t *CreateWorktreeTool) Schema() json.RawMessage { return json.RawMessage(createWorktreeSchema) } | |
| 67 | + | ||
| 68 | + | // Preview reports the exact bootstrap request or checkout that Execute will | |
| 69 | + | // produce without modifying the repository. | |
| 70 | + | func (t *CreateWorktreeTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) { | |
| 71 | + | plan, err := t.plan(ctx, raw) | |
| 72 | + | if err != nil { | |
| 73 | + | return core.ToolResult{}, err | |
| 74 | + | } | |
| 75 | + | t.previewMu.Lock() | |
| 76 | + | t.preview = &createWorktreePreview{raw: string(raw), plan: plan} | |
| 77 | + | t.previewMu.Unlock() | |
| 78 | + | return plan.preview, nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (t *CreateWorktreeTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |
| 82 | + | createWorktreeOperationMu.Lock() | |
| 83 | + | defer createWorktreeOperationMu.Unlock() | |
| 84 | + | ||
| 85 | + | plan, err := t.planForExecute(ctx, raw) | |
| 86 | + | if err != nil { | |
| 87 | + | return core.ToolResult{}, err | |
| 88 | + | } | |
| 89 | + | if plan.bootstrapRequired { | |
| 90 | + | return plan.preview, nil | |
| 91 | + | } | |
| 92 | + | return executeCreateWorktreePlan(ctx, plan, progress) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func executeCreateWorktreePlan(ctx context.Context, plan createWorktreePlan, progress func(string)) (core.ToolResult, error) { | |
| 96 | + | configSaved := false | |
| 97 | + | var createdWorktreeParents []string | |
| 98 | + | if plan.setConfig { | |
| 99 | + | if _, err := createWorktreeGitOutput(ctx, plan.repoRoot, "config", "--local", "--replace-all", worktreeConfigKey, plan.configValue); err != nil { | |
| 100 | + | return core.ToolResult{}, fmt.Errorf("create_worktree: save worktree root: %w", err) | |
| 101 | + | } | |
| 102 | + | configSaved = true | |
| 103 | + | } | |
| 104 | + | rollback := func(cause error) error { | |
| 105 | + | cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 106 | + | defer cancel() | |
| 107 | + | if err := plan.cleanupFailedCheckout(cleanupCtx); err != nil { | |
| 108 | + | cause = fmt.Errorf("%w; cleanup worktree: %v", cause, err) | |
| 109 | + | } | |
| 110 | + | if err := removeCreatedWorktreeParents(createdWorktreeParents); err != nil { | |
| 111 | + | cause = fmt.Errorf("%w; remove created worktree directories: %v", cause, err) | |
| 112 | + | } | |
| 113 | + | if err := plan.ignore.restore(); err != nil { | |
| 114 | + | cause = fmt.Errorf("%w; restore .gitignore: %v", cause, err) | |
| 115 | + | } | |
| 116 | + | if configSaved { | |
| 117 | + | if err := plan.removeBootstrapConfig(cleanupCtx); err != nil { | |
| 118 | + | cause = fmt.Errorf("%w; remove local worktree configuration: %v", cause, err) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | return cause | |
| 122 | + | } | |
| 123 | + | if err := plan.ignore.apply(); err != nil { | |
| 124 | + | return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: update .gitignore: %w", err)) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | createdWorktreeParents, err := missingWorktreeParents(plan.worktreePath) | |
| 128 | + | if err != nil { | |
| 129 | + | return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: inspect worktree directories: %w", err)) | |
| 130 | + | } | |
| 131 | + | if err := os.MkdirAll(filepath.Dir(plan.worktreePath), 0o755); err != nil { | |
| 132 | + | return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: make worktree directory: %w", err)) | |
| 133 | + | } | |
| 134 | + | if progress != nil { | |
| 135 | + | progress("Creating Git worktree...\n") | |
| 136 | + | } | |
| 137 | + | if _, err := createWorktreeGitOutput(ctx, plan.repoRoot, "worktree", "add", "-b", plan.branch, plan.worktreePath, plan.base); err != nil { | |
| 138 | + | return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: create worktree: %w", err)) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | result := plan.preview | |
| 142 | + | previewDetails, _ := plan.preview.Details.(map[string]any) | |
| 143 | + | details := make(map[string]any, len(previewDetails)+1) | |
| 144 | + | for key, value := range previewDetails { | |
| 145 | + | details[key] = value | |
| 146 | + | } | |
| 147 | + | details["state"] = "created" | |
| 148 | + | result.Details = details | |
| 149 | + | result.Content = []provider.Content{provider.TextBlock{Text: strings.Replace(plan.previewText, "create worktree", "created worktree", 1)}} | |
| 150 | + | return result, nil | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | type createWorktreePreview struct { | |
| 154 | + | raw string | |
| 155 | + | plan createWorktreePlan | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func (t *CreateWorktreeTool) planForExecute(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) { | |
| 159 | + | t.previewMu.Lock() | |
| 160 | + | cached := t.preview | |
| 161 | + | t.preview = nil | |
| 162 | + | t.previewMu.Unlock() | |
| 163 | + | ||
| 164 | + | plan, err := t.plan(ctx, raw) | |
| 165 | + | if err != nil { | |
| 166 | + | return createWorktreePlan{}, err | |
| 167 | + | } | |
| 168 | + | if cached != nil && cached.raw == string(raw) && !sameCreateWorktreePlan(cached.plan, plan) { | |
| 169 | + | return createWorktreePlan{}, errors.New("create_worktree: repository state changed since preview; inspect and retry") | |
| 170 | + | } | |
| 171 | + | return plan, nil | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | type createWorktreePlan struct { | |
| 175 | + | repoRoot string | |
| 176 | + | branch string | |
| 177 | + | base string | |
| 178 | + | worktreeRoot string | |
| 179 | + | worktreePath string | |
| 180 | + | rootSource string | |
| 181 | + | configValue string | |
| 182 | + | setConfig bool | |
| 183 | + | bootstrapRequired bool | |
| 184 | + | previewText string | |
| 185 | + | preview core.ToolResult | |
| 186 | + | ignore worktreesIgnoreUpdate | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) { | |
| 190 | + | var args createWorktreeArgs | |
| 191 | + | if err := json.Unmarshal(raw, &args); err != nil { | |
| 192 | + | return createWorktreePlan{}, fmt.Errorf("invalid args: %w", err) | |
| 193 | + | } | |
| 194 | + | branchInput := strings.TrimSpace(args.Branch) | |
| 195 | + | if branchInput == "" { | |
| 196 | + | return createWorktreePlan{}, errors.New("branch is required") | |
| 197 | + | } | |
| 198 | + | if strings.HasPrefix(branchInput, "-") || strings.Contains(branchInput, "@{") { | |
| 199 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q", branchInput) | |
| 200 | + | } | |
| 201 | + | if t.Sandbox == nil { | |
| 202 | + | return createWorktreePlan{}, errors.New("create_worktree: sandbox is required") | |
| 203 | + | } | |
| 204 | + | if err := t.Sandbox.CheckBashPermission("git"); err != nil { | |
| 205 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: %w", err) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | cwd, err := t.accessibleCWD() | |
| 209 | + | if err != nil { | |
| 210 | + | return createWorktreePlan{}, err | |
| 211 | + | } | |
| 212 | + | candidateRoot, err := t.findGitRootCandidate(cwd) | |
| 213 | + | if err != nil { | |
| 214 | + | return createWorktreePlan{}, err | |
| 215 | + | } | |
| 216 | + | repoRoot, err := createWorktreeGitOutput(ctx, candidateRoot, "rev-parse", "--show-toplevel") | |
| 217 | + | if err != nil { | |
| 218 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: verify repository: %w", err) | |
| 219 | + | } | |
| 220 | + | repoRoot, err = canonical(repoRoot) | |
| 221 | + | if err != nil { | |
| 222 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve repository root: %w", err) | |
| 223 | + | } | |
| 224 | + | if err := t.checkReadAccess(repoRoot); err != nil { | |
| 225 | + | return createWorktreePlan{}, err | |
| 226 | + | } | |
| 227 | + | branch, err := createWorktreeGitOutput(ctx, repoRoot, "check-ref-format", "--branch", branchInput) | |
| 228 | + | if err != nil { | |
| 229 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q: %w", branchInput, err) | |
| 230 | + | } | |
| 231 | + | if branch != branchInput { | |
| 232 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q", branchInput) | |
| 233 | + | } | |
| 234 | + | base, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", "HEAD^{commit}") | |
| 235 | + | if err != nil { | |
| 236 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve current commit: %w", err) | |
| 237 | + | } | |
| 238 | + | commonDir, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--git-common-dir") | |
| 239 | + | if err != nil { | |
| 240 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve Git metadata: %w", err) | |
| 241 | + | } | |
| 242 | + | if !filepath.IsAbs(commonDir) { | |
| 243 | + | commonDir = filepath.Join(repoRoot, commonDir) | |
| 244 | + | } | |
| 245 | + | commonDir, err = canonical(commonDir) | |
| 246 | + | if err != nil { | |
| 247 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve Git metadata: %w", err) | |
| 248 | + | } | |
| 249 | + | if err := t.checkReadAccess(commonDir); err != nil { | |
| 250 | + | return createWorktreePlan{}, err | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | configuredRoot, configured, err := createWorktreeGitConfigValue(ctx, repoRoot, worktreeConfigKey) | |
| 254 | + | if err != nil { | |
| 255 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: read local worktree configuration: %w", err) | |
| 256 | + | } | |
| 257 | + | bootstrapRoot := strings.TrimSpace(args.BootstrapRoot) | |
| 258 | + | if configured && bootstrapRoot != "" { | |
| 259 | + | return createWorktreePlan{}, errors.New("create_worktree: worktree root is already configured; change the local Git configuration explicitly before bootstrapping again") | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | rootSource := "existing .worktrees directory" | |
| 263 | + | configValue := "" | |
| 264 | + | setConfig := false | |
| 265 | + | defaultRoot := false | |
| 266 | + | var worktreeRoot string | |
| 267 | + | if configured { | |
| 268 | + | worktreeRoot, defaultRoot, err = resolveConfiguredWorktreeRoot(repoRoot, configuredRoot) | |
| 269 | + | if err != nil { | |
| 270 | + | return createWorktreePlan{}, err | |
| 271 | + | } | |
| 272 | + | rootSource = "local Git config" | |
| 273 | + | } else { | |
| 274 | + | defaultPath := filepath.Join(repoRoot, defaultWorktreeRoot) | |
| 275 | + | exists, err := inspectWorktreeRoot(defaultPath) | |
| 276 | + | if err != nil { | |
| 277 | + | return createWorktreePlan{}, err | |
| 278 | + | } | |
| 279 | + | if !exists && bootstrapRoot == "" { | |
| 280 | + | return bootstrapRequiredPlan(branch, repoRoot), nil | |
| 281 | + | } | |
| 282 | + | if exists { | |
| 283 | + | if bootstrapRoot != "" { | |
| 284 | + | return createWorktreePlan{}, errors.New("create_worktree: worktree root is already established; omit bootstrap_root") | |
| 285 | + | } | |
| 286 | + | worktreeRoot = defaultPath | |
| 287 | + | defaultRoot = true | |
| 288 | + | } else { | |
| 289 | + | worktreeRoot, defaultRoot, configValue, err = resolveBootstrapWorktreeRoot(repoRoot, bootstrapRoot) | |
| 290 | + | if err != nil { | |
| 291 | + | return createWorktreePlan{}, err | |
| 292 | + | } | |
| 293 | + | setConfig = true | |
| 294 | + | rootSource = "bootstrap selection" | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | worktreeRoot, err = validateWorktreeRoot(worktreeRoot, defaultRoot) | |
| 299 | + | if err != nil { | |
| 300 | + | return createWorktreePlan{}, err | |
| 301 | + | } | |
| 302 | + | worktreePath := filepath.Join(worktreeRoot, filepath.FromSlash(branch)) | |
| 303 | + | rootCanonical, err := canonicalOrParent(worktreeRoot) | |
| 304 | + | if err != nil { | |
| 305 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree root: %w", err) | |
| 306 | + | } | |
| 307 | + | if !defaultRoot && isUnder(repoRoot, rootCanonical) { | |
| 308 | + | return createWorktreePlan{}, errors.New("create_worktree: external worktree root must be outside the repository") | |
| 309 | + | } | |
| 310 | + | pathCanonical, err := canonicalOrParent(worktreePath) | |
| 311 | + | if err != nil { | |
| 312 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree path: %w", err) | |
| 313 | + | } | |
| 314 | + | if !isUnder(rootCanonical, pathCanonical) { | |
| 315 | + | return createWorktreePlan{}, errors.New("create_worktree: branch path escapes configured worktree root") | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | ignorePath := "" | |
| 319 | + | accessPaths := []string{repoRoot, commonDir, worktreeRoot, worktreePath} | |
| 320 | + | if defaultRoot { | |
| 321 | + | ignorePath = filepath.Join(repoRoot, ".gitignore") | |
| 322 | + | accessPaths = append(accessPaths, ignorePath) | |
| 323 | + | } | |
| 324 | + | if err := t.checkReadWriteAccess(accessPaths...); err != nil { | |
| 325 | + | return createWorktreePlan{}, err | |
| 326 | + | } | |
| 327 | + | if _, err := os.Lstat(worktreePath); err == nil { | |
| 328 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: worktree path %q already exists", filepath.ToSlash(worktreePath)) | |
| 329 | + | } else if !os.IsNotExist(err) { | |
| 330 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: inspect worktree path: %w", err) | |
| 331 | + | } | |
| 332 | + | exists, err := createWorktreeBranchExists(ctx, repoRoot, branch) | |
| 333 | + | if err != nil { | |
| 334 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: check branch: %w", err) | |
| 335 | + | } | |
| 336 | + | if exists { | |
| 337 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: branch %q already exists", branch) | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | ignore := worktreesIgnoreUpdate{} | |
| 341 | + | ignoreAction := "not modified (external worktree root)" | |
| 342 | + | if defaultRoot { | |
| 343 | + | ignore, err = newWorktreesIgnoreUpdate(ignorePath) | |
| 344 | + | if err != nil { | |
| 345 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: read .gitignore: %w", err) | |
| 346 | + | } | |
| 347 | + | ignoreAction = "already contains " + worktreesIgnoreEntry | |
| 348 | + | if ignore.changed { | |
| 349 | + | ignoreAction = "add " + worktreesIgnoreEntry | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | displayRoot := worktreeRoot | |
| 354 | + | if defaultRoot { | |
| 355 | + | displayRoot = defaultWorktreeRoot | |
| 356 | + | } | |
| 357 | + | displayPath := displayWorktreePath(repoRoot, worktreePath, defaultRoot) | |
| 358 | + | configAction := "" | |
| 359 | + | if setConfig { | |
| 360 | + | configAction = fmt.Sprintf("local config: set %s=%s\n", worktreeConfigKey, configValue) | |
| 361 | + | } | |
| 362 | + | previewText := fmt.Sprintf("create worktree\nbranch: %s\nbase: %s\nworktree root: %s\nroot source: %s\npath: %s\n%s.gitignore: %s\n", branch, base, filepath.ToSlash(displayRoot), rootSource, displayPath, configAction, ignoreAction) | |
| 363 | + | return createWorktreePlan{ | |
| 364 | + | repoRoot: repoRoot, | |
| 365 | + | branch: branch, | |
| 366 | + | base: base, | |
| 367 | + | worktreeRoot: worktreeRoot, | |
| 368 | + | worktreePath: worktreePath, | |
| 369 | + | rootSource: rootSource, | |
| 370 | + | configValue: configValue, | |
| 371 | + | setConfig: setConfig, | |
| 372 | + | previewText: previewText, | |
| 373 | + | preview: core.ToolResult{ | |
| 374 | + | Content: []provider.Content{provider.TextBlock{Text: previewText}}, | |
| 375 | + | Details: map[string]any{ | |
| 376 | + | "state": "ready", | |
| 377 | + | "branch": branch, | |
| 378 | + | "base": base, | |
| 379 | + | "worktree_root": displayRoot, | |
| 380 | + | "root_source": rootSource, | |
| 381 | + | "path": displayPath, | |
| 382 | + | "bootstrap_saved": setConfig, | |
| 383 | + | }, | |
| 384 | + | }, | |
| 385 | + | ignore: ignore, | |
| 386 | + | }, nil | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | func bootstrapRequiredPlan(branch, repoRoot string) createWorktreePlan { | |
| 390 | + | text := fmt.Sprintf("Worktree bootstrap is required.\nbranch: %s\nrepository: %s\nChoose a location with the user, then call create_worktree again with bootstrap_root set to .worktrees for the repository default or to an absolute external directory. This call made no changes.\n", branch, filepath.ToSlash(repoRoot)) | |
| 391 | + | return createWorktreePlan{ | |
| 392 | + | branch: branch, | |
| 393 | + | bootstrapRequired: true, | |
| 394 | + | previewText: text, | |
| 395 | + | preview: core.ToolResult{ | |
| 396 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |
| 397 | + | Details: map[string]any{ | |
| 398 | + | "state": "bootstrap_required", | |
| 399 | + | "branch": branch, | |
| 400 | + | "default_bootstrap_root": defaultWorktreeRoot, | |
| 401 | + | }, | |
| 402 | + | }, | |
| 403 | + | } | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | func (t *CreateWorktreeTool) accessibleCWD() (string, error) { | |
| 407 | + | cwd := t.CWD | |
| 408 | + | if cwd == "" { | |
| 409 | + | var err error | |
| 410 | + | cwd, err = os.Getwd() | |
| 411 | + | if err != nil { | |
| 412 | + | return "", fmt.Errorf("create_worktree: working directory: %w", err) | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | cwd, err := canonical(cwd) | |
| 416 | + | if err != nil { | |
| 417 | + | return "", fmt.Errorf("create_worktree: resolve working directory: %w", err) | |
| 418 | + | } | |
| 419 | + | if err := t.checkReadAccess(cwd); err != nil { | |
| 420 | + | return "", err | |
| 421 | + | } | |
| 422 | + | return cwd, nil | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | func (t *CreateWorktreeTool) findGitRootCandidate(cwd string) (string, error) { | |
| 426 | + | for dir := cwd; ; dir = filepath.Dir(dir) { | |
| 427 | + | if err := t.checkReadAccess(dir); err != nil { | |
| 428 | + | return "", err | |
| 429 | + | } | |
| 430 | + | if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil { | |
| 431 | + | return dir, nil | |
| 432 | + | } else if !os.IsNotExist(err) { | |
| 433 | + | return "", fmt.Errorf("create_worktree: inspect Git metadata: %w", err) | |
| 434 | + | } | |
| 435 | + | parent := filepath.Dir(dir) | |
| 436 | + | if parent == dir { | |
| 437 | + | break | |
| 438 | + | } | |
| 439 | + | } | |
| 440 | + | return "", errors.New("create_worktree: working directory is not a Git repository") | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | func (t *CreateWorktreeTool) checkReadAccess(paths ...string) error { | |
| 444 | + | for _, path := range paths { | |
| 445 | + | if err := t.Sandbox.CheckReadPath(path); err != nil { | |
| 446 | + | return fmt.Errorf("create_worktree: %w", err) | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | return nil | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | func (t *CreateWorktreeTool) checkReadWriteAccess(paths ...string) error { | |
| 453 | + | for _, path := range paths { | |
| 454 | + | if err := t.Sandbox.CheckReadPath(path); err != nil { | |
| 455 | + | return fmt.Errorf("create_worktree: %w", err) | |
| 456 | + | } | |
| 457 | + | if err := t.Sandbox.CheckWritePath(path); err != nil { | |
| 458 | + | return fmt.Errorf("create_worktree: %w", err) | |
| 459 | + | } | |
| 460 | + | } | |
| 461 | + | return nil | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | func resolveConfiguredWorktreeRoot(repoRoot, value string) (string, bool, error) { | |
| 465 | + | value = strings.TrimSpace(value) | |
| 466 | + | if value == defaultWorktreeRoot { | |
| 467 | + | return filepath.Join(repoRoot, defaultWorktreeRoot), true, nil | |
| 468 | + | } | |
| 469 | + | if !filepath.IsAbs(value) { | |
| 470 | + | return "", false, errors.New("create_worktree: local worktree configuration must be .worktrees or an absolute external path") | |
| 471 | + | } | |
| 472 | + | return value, false, nil | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | func resolveBootstrapWorktreeRoot(repoRoot, value string) (string, bool, string, error) { | |
| 476 | + | if value == defaultWorktreeRoot { | |
| 477 | + | return filepath.Join(repoRoot, defaultWorktreeRoot), true, defaultWorktreeRoot, nil | |
| 478 | + | } | |
| 479 | + | if !filepath.IsAbs(value) { | |
| 480 | + | return "", false, "", errors.New("create_worktree: bootstrap_root must be .worktrees or an absolute external path") | |
| 481 | + | } | |
| 482 | + | root, err := canonicalOrParent(value) | |
| 483 | + | if err != nil { | |
| 484 | + | return "", false, "", fmt.Errorf("create_worktree: resolve bootstrap_root: %w", err) | |
| 485 | + | } | |
| 486 | + | if isUnder(repoRoot, root) { | |
| 487 | + | return "", false, "", errors.New("create_worktree: external bootstrap_root must be outside the repository") | |
| 488 | + | } | |
| 489 | + | return root, false, root, nil | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | func validateWorktreeRoot(path string, defaultRoot bool) (string, error) { | |
| 493 | + | info, err := os.Lstat(path) | |
| 494 | + | if os.IsNotExist(err) { | |
| 495 | + | return path, nil | |
| 496 | + | } | |
| 497 | + | if err != nil { | |
| 498 | + | return "", fmt.Errorf("create_worktree: inspect worktree root: %w", err) | |
| 499 | + | } | |
| 500 | + | if info.Mode()&os.ModeSymlink != 0 { | |
| 501 | + | return "", errors.New("create_worktree: worktree root must not be a symbolic link") | |
| 502 | + | } | |
| 503 | + | if !info.IsDir() { | |
| 504 | + | return "", errors.New("create_worktree: worktree root exists but is not a directory") | |
| 505 | + | } | |
| 506 | + | if defaultRoot && filepath.Base(path) != defaultWorktreeRoot { | |
| 507 | + | return "", errors.New("create_worktree: invalid default worktree root") | |
| 508 | + | } | |
| 509 | + | return path, nil | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | func inspectWorktreeRoot(path string) (bool, error) { | |
| 513 | + | info, err := os.Lstat(path) | |
| 514 | + | if os.IsNotExist(err) { | |
| 515 | + | return false, nil | |
| 516 | + | } | |
| 517 | + | if err != nil { | |
| 518 | + | return false, fmt.Errorf("create_worktree: inspect .worktrees: %w", err) | |
| 519 | + | } | |
| 520 | + | if info.Mode()&os.ModeSymlink != 0 { | |
| 521 | + | return false, errors.New("create_worktree: .worktrees must not be a symbolic link") | |
| 522 | + | } | |
| 523 | + | if !info.IsDir() { | |
| 524 | + | return false, errors.New("create_worktree: .worktrees exists but is not a directory") | |
| 525 | + | } | |
| 526 | + | return true, nil | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | func displayWorktreePath(repoRoot, path string, defaultRoot bool) string { | |
| 530 | + | if defaultRoot { | |
| 531 | + | relative, err := filepath.Rel(repoRoot, path) | |
| 532 | + | if err == nil { | |
| 533 | + | return filepath.ToSlash(relative) | |
| 534 | + | } | |
| 535 | + | } | |
| 536 | + | return filepath.ToSlash(path) | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | func sameCreateWorktreePlan(a, b createWorktreePlan) bool { | |
| 540 | + | return a.bootstrapRequired == b.bootstrapRequired && | |
| 541 | + | a.repoRoot == b.repoRoot && | |
| 542 | + | a.branch == b.branch && | |
| 543 | + | a.base == b.base && | |
| 544 | + | a.worktreeRoot == b.worktreeRoot && | |
| 545 | + | a.worktreePath == b.worktreePath && | |
| 546 | + | a.rootSource == b.rootSource && | |
| 547 | + | a.configValue == b.configValue && | |
| 548 | + | a.setConfig == b.setConfig && | |
| 549 | + | a.ignore.path == b.ignore.path && | |
| 550 | + | a.ignore.existed == b.ignore.existed && | |
| 551 | + | a.ignore.changed == b.ignore.changed && | |
| 552 | + | bytes.Equal(a.ignore.before, b.ignore.before) | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | func missingWorktreeParents(worktreePath string) ([]string, error) { | |
| 556 | + | var missing []string | |
| 557 | + | for directory := filepath.Dir(worktreePath); ; directory = filepath.Dir(directory) { | |
| 558 | + | info, err := os.Lstat(directory) | |
| 559 | + | if err == nil { | |
| 560 | + | if !info.IsDir() { | |
| 561 | + | return nil, fmt.Errorf("%q is not a directory", directory) | |
| 562 | + | } | |
| 563 | + | break | |
| 564 | + | } | |
| 565 | + | if !os.IsNotExist(err) { | |
| 566 | + | return nil, err | |
| 567 | + | } | |
| 568 | + | missing = append(missing, directory) | |
| 569 | + | parent := filepath.Dir(directory) | |
| 570 | + | if parent == directory { | |
| 571 | + | return nil, errors.New("no existing parent directory") | |
| 572 | + | } | |
| 573 | + | } | |
| 574 | + | for left, right := 0, len(missing)-1; left < right; left, right = left+1, right-1 { | |
| 575 | + | missing[left], missing[right] = missing[right], missing[left] | |
| 576 | + | } | |
| 577 | + | return missing, nil | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | func removeCreatedWorktreeParents(parents []string) error { | |
| 581 | + | for index := len(parents) - 1; index >= 0; index-- { | |
| 582 | + | if err := os.Remove(parents[index]); err != nil && !os.IsNotExist(err) { | |
| 583 | + | return err | |
| 584 | + | } | |
| 585 | + | } | |
| 586 | + | return nil | |
| 587 | + | } | |
| 588 | + | ||
| 589 | + | func (p createWorktreePlan) removeBootstrapConfig(ctx context.Context) error { | |
| 590 | + | value, configured, err := createWorktreeGitConfigValue(ctx, p.repoRoot, worktreeConfigKey) | |
| 591 | + | if err != nil { | |
| 592 | + | return err | |
| 593 | + | } | |
| 594 | + | if !configured { | |
| 595 | + | return nil | |
| 596 | + | } | |
| 597 | + | if value != p.configValue { | |
| 598 | + | return errors.New("local worktree configuration changed during creation") | |
| 599 | + | } | |
| 600 | + | _, err = createWorktreeGitOutput(ctx, p.repoRoot, "config", "--local", "--unset-all", worktreeConfigKey) | |
| 601 | + | return err | |
| 602 | + | } | |
| 603 | + | ||
| 604 | + | func (p createWorktreePlan) cleanupFailedCheckout(ctx context.Context) error { | |
| 605 | + | registered, err := createWorktreeRegistered(ctx, p.repoRoot, p.worktreePath) | |
| 606 | + | if err != nil { | |
| 607 | + | return err | |
| 608 | + | } | |
| 609 | + | var cleanupErr error | |
| 610 | + | if registered { | |
| 611 | + | if _, err := createWorktreeGitOutput(ctx, p.repoRoot, "worktree", "remove", "--force", p.worktreePath); err != nil { | |
| 612 | + | cleanupErr = fmt.Errorf("remove registered worktree: %w", err) | |
| 613 | + | } | |
| 614 | + | } | |
| 615 | + | branchHead, exists, err := createWorktreeGitRef(ctx, p.repoRoot, "refs/heads/"+p.branch) | |
| 616 | + | if err == nil && exists && branchHead == p.base { | |
| 617 | + | if _, err := createWorktreeGitOutput(ctx, p.repoRoot, "branch", "-D", p.branch); err != nil && cleanupErr == nil { | |
| 618 | + | cleanupErr = fmt.Errorf("remove branch: %w", err) | |
| 619 | + | } | |
| 620 | + | } | |
| 621 | + | if err != nil && cleanupErr == nil { | |
| 622 | + | cleanupErr = err | |
| 623 | + | } | |
| 624 | + | return cleanupErr | |
| 625 | + | } | |
| 626 | + | ||
| 627 | + | func createWorktreeRegistered(ctx context.Context, repoRoot, worktreePath string) (bool, error) { | |
| 628 | + | output, err := createWorktreeGitOutput(ctx, repoRoot, "worktree", "list", "--porcelain") | |
| 629 | + | if err != nil { | |
| 630 | + | return false, err | |
| 631 | + | } | |
| 632 | + | want, err := canonicalOrParent(worktreePath) | |
| 633 | + | if err != nil { | |
| 634 | + | return false, err | |
| 635 | + | } | |
| 636 | + | for _, line := range strings.Split(output, "\n") { | |
| 637 | + | if !strings.HasPrefix(line, "worktree ") { | |
| 638 | + | continue | |
| 639 | + | } | |
| 640 | + | path, err := canonicalOrParent(strings.TrimPrefix(line, "worktree ")) | |
| 641 | + | if err == nil && path == want { | |
| 642 | + | return true, nil | |
| 643 | + | } | |
| 644 | + | } | |
| 645 | + | return false, nil | |
| 646 | + | } | |
| 647 | + | ||
| 648 | + | func createWorktreeBranchExists(ctx context.Context, repoRoot, branch string) (bool, error) { | |
| 649 | + | _, err := createWorktreeGitOutput(ctx, repoRoot, "show-ref", "--verify", "--quiet", "refs/heads/"+branch) | |
| 650 | + | if err == nil { | |
| 651 | + | return true, nil | |
| 652 | + | } | |
| 653 | + | if ctx.Err() != nil { | |
| 654 | + | return false, ctx.Err() | |
| 655 | + | } | |
| 656 | + | if createWorktreeGitExitCode(err) == 1 { | |
| 657 | + | return false, nil | |
| 658 | + | } | |
| 659 | + | return false, err | |
| 660 | + | } | |
| 661 | + | ||
| 662 | + | func createWorktreeGitRef(ctx context.Context, repoRoot, ref string) (string, bool, error) { | |
| 663 | + | output, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", "--quiet", ref+"^{commit}") | |
| 664 | + | if err == nil { | |
| 665 | + | return output, true, nil | |
| 666 | + | } | |
| 667 | + | if createWorktreeGitExitCode(err) == 1 { | |
| 668 | + | return "", false, nil | |
| 669 | + | } | |
| 670 | + | return "", false, err | |
| 671 | + | } | |
| 672 | + | ||
| 673 | + | func createWorktreeGitConfigValue(ctx context.Context, repoRoot, key string) (string, bool, error) { | |
| 674 | + | output, err := createWorktreeGitOutput(ctx, repoRoot, "config", "--local", "--get", key) | |
| 675 | + | if err == nil { | |
| 676 | + | return output, true, nil | |
| 677 | + | } | |
| 678 | + | if createWorktreeGitExitCode(err) == 1 { | |
| 679 | + | return "", false, nil | |
| 680 | + | } | |
| 681 | + | return "", false, err | |
| 682 | + | } | |
| 683 | + | ||
| 684 | + | type createWorktreeGitError struct { | |
| 685 | + | args []string | |
| 686 | + | output string | |
| 687 | + | err error | |
| 688 | + | } | |
| 689 | + | ||
| 690 | + | func (e *createWorktreeGitError) Error() string { | |
| 691 | + | message := strings.TrimSpace(e.output) | |
| 692 | + | if message == "" { | |
| 693 | + | message = e.err.Error() | |
| 694 | + | } | |
| 695 | + | return fmt.Sprintf("git %s: %s", strings.Join(e.args, " "), message) | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | func (e *createWorktreeGitError) Unwrap() error { return e.err } | |
| 699 | + | ||
| 700 | + | func createWorktreeGitExitCode(err error) int { | |
| 701 | + | var exitErr *exec.ExitError | |
| 702 | + | if errors.As(err, &exitErr) { | |
| 703 | + | return exitErr.ExitCode() | |
| 704 | + | } | |
| 705 | + | return -1 | |
| 706 | + | } | |
| 707 | + | ||
| 708 | + | func createWorktreeGitOutput(ctx context.Context, dir string, args ...string) (string, error) { | |
| 709 | + | cmdArgs := append([]string{"-c", "core.hooksPath=" + os.DevNull, "-C", dir}, args...) | |
| 710 | + | cmd := exec.CommandContext(ctx, "git", cmdArgs...) | |
| 711 | + | cmd.Env = createWorktreeGitEnv() | |
| 712 | + | setProcessGroup(cmd) | |
| 713 | + | ||
| 714 | + | var stdout, output cappedGitOutput | |
| 715 | + | cmd.Stdout = io.MultiWriter(&stdout, &output) | |
| 716 | + | cmd.Stderr = &output | |
| 717 | + | if err := cmd.Start(); err != nil { | |
| 718 | + | return "", fmt.Errorf("start git %s: %w", strings.Join(args, " "), err) | |
| 719 | + | } | |
| 720 | + | watchDone := make(chan struct{}) | |
| 721 | + | watchStopped := make(chan struct{}) | |
| 722 | + | go func() { | |
| 723 | + | defer close(watchStopped) | |
| 724 | + | select { | |
| 725 | + | case <-ctx.Done(): | |
| 726 | + | killProcessGroup(cmd) | |
| 727 | + | case <-watchDone: | |
| 728 | + | } | |
| 729 | + | }() | |
| 730 | + | waitErr := cmd.Wait() | |
| 731 | + | close(watchDone) | |
| 732 | + | <-watchStopped | |
| 733 | + | if waitErr != nil { | |
| 734 | + | if ctx.Err() != nil { | |
| 735 | + | return "", ctx.Err() | |
| 736 | + | } | |
| 737 | + | return "", &createWorktreeGitError{args: args, output: output.String(), err: waitErr} | |
| 738 | + | } | |
| 739 | + | return strings.TrimSpace(stdout.String()), nil | |
| 740 | + | } | |
| 741 | + | ||
| 742 | + | type cappedGitOutput struct { | |
| 743 | + | mu sync.Mutex | |
| 744 | + | buffer bytes.Buffer | |
| 745 | + | truncated bool | |
| 746 | + | } | |
| 747 | + | ||
| 748 | + | func (b *cappedGitOutput) Write(p []byte) (int, error) { | |
| 749 | + | b.mu.Lock() | |
| 750 | + | defer b.mu.Unlock() | |
| 751 | + | if room := maxCreateWorktreeGitOutput - b.buffer.Len(); room > 0 { | |
| 752 | + | if len(p) > room { | |
| 753 | + | _, _ = b.buffer.Write(p[:room]) | |
| 754 | + | b.truncated = true | |
| 755 | + | } else { | |
| 756 | + | _, _ = b.buffer.Write(p) | |
| 757 | + | } | |
| 758 | + | } else { | |
| 759 | + | b.truncated = true | |
| 760 | + | } | |
| 761 | + | return len(p), nil | |
| 762 | + | } | |
| 763 | + | ||
| 764 | + | func (b *cappedGitOutput) String() string { | |
| 765 | + | b.mu.Lock() | |
| 766 | + | defer b.mu.Unlock() | |
| 767 | + | output := b.buffer.String() | |
| 768 | + | if b.truncated { | |
| 769 | + | output += "\n... [git output truncated]" | |
| 770 | + | } | |
| 771 | + | return output | |
| 772 | + | } | |
| 773 | + | ||
| 774 | + | func createWorktreeGitEnv() []string { | |
| 775 | + | env := make([]string, 0, len(os.Environ())+5) | |
| 776 | + | for _, entry := range os.Environ() { | |
| 777 | + | key, _, _ := strings.Cut(entry, "=") | |
| 778 | + | if strings.HasPrefix(key, "GIT_") || key == "SSH_ASKPASS" { | |
| 779 | + | continue | |
| 780 | + | } | |
| 781 | + | env = append(env, entry) | |
| 782 | + | } | |
| 783 | + | return append(env, | |
| 784 | + | "GIT_CONFIG_NOSYSTEM=1", | |
| 785 | + | "GIT_CONFIG_GLOBAL="+os.DevNull, | |
| 786 | + | "GIT_TERMINAL_PROMPT=0", | |
| 787 | + | "GIT_ASKPASS=", | |
| 788 | + | "SSH_ASKPASS=", | |
| 789 | + | ) | |
| 790 | + | } | |
| 791 | + | ||
| 792 | + | type worktreesIgnoreUpdate struct { | |
| 793 | + | path string | |
| 794 | + | before []byte | |
| 795 | + | after []byte | |
| 796 | + | existed bool | |
| 797 | + | mode os.FileMode | |
| 798 | + | changed bool | |
| 799 | + | } | |
| 800 | + | ||
| 801 | + | func newWorktreesIgnoreUpdate(path string) (worktreesIgnoreUpdate, error) { | |
| 802 | + | contents, mode, existed, err := readWorktreesIgnore(path) | |
| 803 | + | if err != nil { | |
| 804 | + | return worktreesIgnoreUpdate{}, err | |
| 805 | + | } | |
| 806 | + | for _, line := range strings.Split(string(contents), "\n") { | |
| 807 | + | if strings.TrimSuffix(line, "\r") == worktreesIgnoreEntry { | |
| 808 | + | return worktreesIgnoreUpdate{path: path, before: contents, existed: existed, mode: mode}, nil | |
| 809 | + | } | |
| 810 | + | } | |
| 811 | + | lineEnding := "\n" | |
| 812 | + | if bytes.Contains(contents, []byte("\r\n")) { | |
| 813 | + | lineEnding = "\r\n" | |
| 814 | + | } | |
| 815 | + | after := append([]byte(nil), contents...) | |
| 816 | + | if len(after) > 0 && !strings.HasSuffix(string(after), "\n") { | |
| 817 | + | after = append(after, lineEnding...) | |
| 818 | + | } | |
| 819 | + | after = append(after, []byte(worktreesIgnoreEntry+lineEnding)...) | |
| 820 | + | return worktreesIgnoreUpdate{path: path, before: contents, after: after, existed: existed, mode: mode, changed: true}, nil | |
| 821 | + | } | |
| 822 | + | ||
| 823 | + | func (u worktreesIgnoreUpdate) apply() error { | |
| 824 | + | if !u.changed { | |
| 825 | + | return nil | |
| 826 | + | } | |
| 827 | + | current, _, existed, err := readWorktreesIgnore(u.path) | |
| 828 | + | if err != nil { | |
| 829 | + | return err | |
| 830 | + | } | |
| 831 | + | if existed != u.existed || !bytes.Equal(current, u.before) { | |
| 832 | + | return errors.New(".gitignore changed since preview; inspect it and retry") | |
| 833 | + | } | |
| 834 | + | return writeWorktreesIgnore(u.path, u.after, u.mode) | |
| 835 | + | } | |
| 836 | + | ||
| 837 | + | func (u worktreesIgnoreUpdate) restore() error { | |
| 838 | + | if !u.changed { | |
| 839 | + | return nil | |
| 840 | + | } | |
| 841 | + | current, _, existed, err := readWorktreesIgnore(u.path) | |
| 842 | + | if err != nil { | |
| 843 | + | return err | |
| 844 | + | } | |
| 845 | + | if !existed || !bytes.Equal(current, u.after) { | |
| 846 | + | return nil | |
| 847 | + | } | |
| 848 | + | if !u.existed { | |
| 849 | + | if err := os.Remove(u.path); err != nil && !os.IsNotExist(err) { | |
| 850 | + | return err | |
| 851 | + | } | |
| 852 | + | return nil | |
| 853 | + | } | |
| 854 | + | return writeWorktreesIgnore(u.path, u.before, u.mode) | |
| 855 | + | } | |
| 856 | + | ||
| 857 | + | func readWorktreesIgnore(path string) ([]byte, os.FileMode, bool, error) { | |
| 858 | + | info, err := os.Lstat(path) | |
| 859 | + | if os.IsNotExist(err) { | |
| 860 | + | return nil, 0o644, false, nil | |
| 861 | + | } | |
| 862 | + | if err != nil { | |
| 863 | + | return nil, 0, false, err | |
| 864 | + | } | |
| 865 | + | if info.Mode()&os.ModeSymlink != 0 { | |
| 866 | + | return nil, 0, false, errors.New(".gitignore must not be a symbolic link") | |
| 867 | + | } | |
| 868 | + | if !info.Mode().IsRegular() { | |
| 869 | + | return nil, 0, false, errors.New(".gitignore must be a regular file") | |
| 870 | + | } | |
| 871 | + | if info.Size() > maxCreateWorktreeIgnoreBytes { | |
| 872 | + | return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes) | |
| 873 | + | } | |
| 874 | + | file, err := os.Open(path) | |
| 875 | + | if err != nil { | |
| 876 | + | return nil, 0, false, err | |
| 877 | + | } | |
| 878 | + | defer file.Close() | |
| 879 | + | contents, err := io.ReadAll(io.LimitReader(file, maxCreateWorktreeIgnoreBytes+1)) | |
| 880 | + | if err != nil { | |
| 881 | + | return nil, 0, false, err | |
| 882 | + | } | |
| 883 | + | if len(contents) > maxCreateWorktreeIgnoreBytes { | |
| 884 | + | return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes) | |
| 885 | + | } | |
| 886 | + | return contents, info.Mode().Perm(), true, nil | |
| 887 | + | } | |
| 888 | + | ||
| 889 | + | func writeWorktreesIgnore(path string, contents []byte, mode os.FileMode) error { | |
| 890 | + | file, err := os.CreateTemp(filepath.Dir(path), ".zut-gitignore-*") | |
| 891 | + | if err != nil { | |
| 892 | + | return err | |
| 893 | + | } | |
| 894 | + | tempPath := file.Name() | |
| 895 | + | defer os.Remove(tempPath) | |
| 896 | + | if err := file.Chmod(mode); err != nil { | |
| 897 | + | _ = file.Close() | |
| 898 | + | return err | |
| 899 | + | } | |
| 900 | + | if _, err := file.Write(contents); err != nil { | |
| 901 | + | _ = file.Close() | |
| 902 | + | return err | |
| 903 | + | } | |
| 904 | + | if err := file.Close(); err != nil { | |
| 905 | + | return err | |
| 906 | + | } | |
| 907 | + | return os.Rename(tempPath, path) | |
| 908 | + | } | |
| @@ -0,0 +1,908 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "io" | |||
| 10 | + | "os" | |||
| 11 | + | "os/exec" | |||
| 12 | + | "path/filepath" | |||
| 13 | + | "strings" | |||
| 14 | + | "sync" | |||
| 15 | + | "time" | |||
| 16 | + | ||||
| 17 | + | "github.com/bnema/zut/packages/core" | |||
| 18 | + | "github.com/bnema/zut/packages/provider" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | const ( | |||
| 22 | + | worktreesIgnoreEntry = "/.worktrees/" | |||
| 23 | + | defaultWorktreeRoot = ".worktrees" | |||
| 24 | + | worktreeConfigKey = "zut.worktrees.path" | |||
| 25 | + | maxCreateWorktreeGitOutput = 64 * 1024 | |||
| 26 | + | maxCreateWorktreeIgnoreBytes = 1 << 20 | |||
| 27 | + | ) | |||
| 28 | + | ||||
| 29 | + | var createWorktreeOperationMu sync.Mutex | |||
| 30 | + | ||||
| 31 | + | // CreateWorktreeTool creates a persistent branch checkout. A repository that | |||
| 32 | + | // has neither a local worktree configuration nor an existing .worktrees | |||
| 33 | + | // directory returns a bootstrap request without changing any files. | |||
| 34 | + | type CreateWorktreeTool struct { | |||
| 35 | + | CWD string | |||
| 36 | + | Sandbox *Sandbox | |||
| 37 | + | ||||
| 38 | + | previewMu sync.Mutex | |||
| 39 | + | preview *createWorktreePreview | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type createWorktreeArgs struct { | |||
| 43 | + | Branch string `json:"branch"` | |||
| 44 | + | BootstrapRoot string `json:"bootstrap_root,omitempty"` | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | const createWorktreeSchema = `{ | |||
| 48 | + | "type":"object", | |||
| 49 | + | "properties":{ | |||
| 50 | + | "branch":{ | |||
| 51 | + | "type":"string", | |||
| 52 | + | "description":"Name of the new Git branch." | |||
| 53 | + | }, | |||
| 54 | + | "bootstrap_root":{ | |||
| 55 | + | "type":"string", | |||
| 56 | + | "description":"Only for an unconfigured repository: use .worktrees to initialize the repository-root default, or provide an absolute external worktree root. The choice is stored in local Git config. Omit it on the first call to request user guidance instead of changing files." | |||
| 57 | + | } | |||
| 58 | + | }, | |||
| 59 | + | "required":["branch"] | |||
| 60 | + | }` | |||
| 61 | + | ||||
| 62 | + | func (t *CreateWorktreeTool) Name() string { return "create_worktree" } | |||
| 63 | + | func (t *CreateWorktreeTool) Description() string { | |||
| 64 | + | return "Create a persistent Git branch worktree. An unconfigured repository returns bootstrap guidance; ask the user for .worktrees or an absolute external root, then call again with bootstrap_root." | |||
| 65 | + | } | |||
| 66 | + | func (t *CreateWorktreeTool) Schema() json.RawMessage { return json.RawMessage(createWorktreeSchema) } | |||
| 67 | + | ||||
| 68 | + | // Preview reports the exact bootstrap request or checkout that Execute will | |||
| 69 | + | // produce without modifying the repository. | |||
| 70 | + | func (t *CreateWorktreeTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) { | |||
| 71 | + | plan, err := t.plan(ctx, raw) | |||
| 72 | + | if err != nil { | |||
| 73 | + | return core.ToolResult{}, err | |||
| 74 | + | } | |||
| 75 | + | t.previewMu.Lock() | |||
| 76 | + | t.preview = &createWorktreePreview{raw: string(raw), plan: plan} | |||
| 77 | + | t.previewMu.Unlock() | |||
| 78 | + | return plan.preview, nil | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func (t *CreateWorktreeTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |||
| 82 | + | createWorktreeOperationMu.Lock() | |||
| 83 | + | defer createWorktreeOperationMu.Unlock() | |||
| 84 | + | ||||
| 85 | + | plan, err := t.planForExecute(ctx, raw) | |||
| 86 | + | if err != nil { | |||
| 87 | + | return core.ToolResult{}, err | |||
| 88 | + | } | |||
| 89 | + | if plan.bootstrapRequired { | |||
| 90 | + | return plan.preview, nil | |||
| 91 | + | } | |||
| 92 | + | return executeCreateWorktreePlan(ctx, plan, progress) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func executeCreateWorktreePlan(ctx context.Context, plan createWorktreePlan, progress func(string)) (core.ToolResult, error) { | |||
| 96 | + | configSaved := false | |||
| 97 | + | var createdWorktreeParents []string | |||
| 98 | + | if plan.setConfig { | |||
| 99 | + | if _, err := createWorktreeGitOutput(ctx, plan.repoRoot, "config", "--local", "--replace-all", worktreeConfigKey, plan.configValue); err != nil { | |||
| 100 | + | return core.ToolResult{}, fmt.Errorf("create_worktree: save worktree root: %w", err) | |||
| 101 | + | } | |||
| 102 | + | configSaved = true | |||
| 103 | + | } | |||
| 104 | + | rollback := func(cause error) error { | |||
| 105 | + | cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 106 | + | defer cancel() | |||
| 107 | + | if err := plan.cleanupFailedCheckout(cleanupCtx); err != nil { | |||
| 108 | + | cause = fmt.Errorf("%w; cleanup worktree: %v", cause, err) | |||
| 109 | + | } | |||
| 110 | + | if err := removeCreatedWorktreeParents(createdWorktreeParents); err != nil { | |||
| 111 | + | cause = fmt.Errorf("%w; remove created worktree directories: %v", cause, err) | |||
| 112 | + | } | |||
| 113 | + | if err := plan.ignore.restore(); err != nil { | |||
| 114 | + | cause = fmt.Errorf("%w; restore .gitignore: %v", cause, err) | |||
| 115 | + | } | |||
| 116 | + | if configSaved { | |||
| 117 | + | if err := plan.removeBootstrapConfig(cleanupCtx); err != nil { | |||
| 118 | + | cause = fmt.Errorf("%w; remove local worktree configuration: %v", cause, err) | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | return cause | |||
| 122 | + | } | |||
| 123 | + | if err := plan.ignore.apply(); err != nil { | |||
| 124 | + | return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: update .gitignore: %w", err)) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | createdWorktreeParents, err := missingWorktreeParents(plan.worktreePath) | |||
| 128 | + | if err != nil { | |||
| 129 | + | return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: inspect worktree directories: %w", err)) | |||
| 130 | + | } | |||
| 131 | + | if err := os.MkdirAll(filepath.Dir(plan.worktreePath), 0o755); err != nil { | |||
| 132 | + | return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: make worktree directory: %w", err)) | |||
| 133 | + | } | |||
| 134 | + | if progress != nil { | |||
| 135 | + | progress("Creating Git worktree...\n") | |||
| 136 | + | } | |||
| 137 | + | if _, err := createWorktreeGitOutput(ctx, plan.repoRoot, "worktree", "add", "-b", plan.branch, plan.worktreePath, plan.base); err != nil { | |||
| 138 | + | return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: create worktree: %w", err)) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | result := plan.preview | |||
| 142 | + | previewDetails, _ := plan.preview.Details.(map[string]any) | |||
| 143 | + | details := make(map[string]any, len(previewDetails)+1) | |||
| 144 | + | for key, value := range previewDetails { | |||
| 145 | + | details[key] = value | |||
| 146 | + | } | |||
| 147 | + | details["state"] = "created" | |||
| 148 | + | result.Details = details | |||
| 149 | + | result.Content = []provider.Content{provider.TextBlock{Text: strings.Replace(plan.previewText, "create worktree", "created worktree", 1)}} | |||
| 150 | + | return result, nil | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | type createWorktreePreview struct { | |||
| 154 | + | raw string | |||
| 155 | + | plan createWorktreePlan | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func (t *CreateWorktreeTool) planForExecute(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) { | |||
| 159 | + | t.previewMu.Lock() | |||
| 160 | + | cached := t.preview | |||
| 161 | + | t.preview = nil | |||
| 162 | + | t.previewMu.Unlock() | |||
| 163 | + | ||||
| 164 | + | plan, err := t.plan(ctx, raw) | |||
| 165 | + | if err != nil { | |||
| 166 | + | return createWorktreePlan{}, err | |||
| 167 | + | } | |||
| 168 | + | if cached != nil && cached.raw == string(raw) && !sameCreateWorktreePlan(cached.plan, plan) { | |||
| 169 | + | return createWorktreePlan{}, errors.New("create_worktree: repository state changed since preview; inspect and retry") | |||
| 170 | + | } | |||
| 171 | + | return plan, nil | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | type createWorktreePlan struct { | |||
| 175 | + | repoRoot string | |||
| 176 | + | branch string | |||
| 177 | + | base string | |||
| 178 | + | worktreeRoot string | |||
| 179 | + | worktreePath string | |||
| 180 | + | rootSource string | |||
| 181 | + | configValue string | |||
| 182 | + | setConfig bool | |||
| 183 | + | bootstrapRequired bool | |||
| 184 | + | previewText string | |||
| 185 | + | preview core.ToolResult | |||
| 186 | + | ignore worktreesIgnoreUpdate | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) { | |||
| 190 | + | var args createWorktreeArgs | |||
| 191 | + | if err := json.Unmarshal(raw, &args); err != nil { | |||
| 192 | + | return createWorktreePlan{}, fmt.Errorf("invalid args: %w", err) | |||
| 193 | + | } | |||
| 194 | + | branchInput := strings.TrimSpace(args.Branch) | |||
| 195 | + | if branchInput == "" { | |||
| 196 | + | return createWorktreePlan{}, errors.New("branch is required") | |||
| 197 | + | } | |||
| 198 | + | if strings.HasPrefix(branchInput, "-") || strings.Contains(branchInput, "@{") { | |||
| 199 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q", branchInput) | |||
| 200 | + | } | |||
| 201 | + | if t.Sandbox == nil { | |||
| 202 | + | return createWorktreePlan{}, errors.New("create_worktree: sandbox is required") | |||
| 203 | + | } | |||
| 204 | + | if err := t.Sandbox.CheckBashPermission("git"); err != nil { | |||
| 205 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: %w", err) | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | cwd, err := t.accessibleCWD() | |||
| 209 | + | if err != nil { | |||
| 210 | + | return createWorktreePlan{}, err | |||
| 211 | + | } | |||
| 212 | + | candidateRoot, err := t.findGitRootCandidate(cwd) | |||
| 213 | + | if err != nil { | |||
| 214 | + | return createWorktreePlan{}, err | |||
| 215 | + | } | |||
| 216 | + | repoRoot, err := createWorktreeGitOutput(ctx, candidateRoot, "rev-parse", "--show-toplevel") | |||
| 217 | + | if err != nil { | |||
| 218 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: verify repository: %w", err) | |||
| 219 | + | } | |||
| 220 | + | repoRoot, err = canonical(repoRoot) | |||
| 221 | + | if err != nil { | |||
| 222 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve repository root: %w", err) | |||
| 223 | + | } | |||
| 224 | + | if err := t.checkReadAccess(repoRoot); err != nil { | |||
| 225 | + | return createWorktreePlan{}, err | |||
| 226 | + | } | |||
| 227 | + | branch, err := createWorktreeGitOutput(ctx, repoRoot, "check-ref-format", "--branch", branchInput) | |||
| 228 | + | if err != nil { | |||
| 229 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q: %w", branchInput, err) | |||
| 230 | + | } | |||
| 231 | + | if branch != branchInput { | |||
| 232 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q", branchInput) | |||
| 233 | + | } | |||
| 234 | + | base, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", "HEAD^{commit}") | |||
| 235 | + | if err != nil { | |||
| 236 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve current commit: %w", err) | |||
| 237 | + | } | |||
| 238 | + | commonDir, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--git-common-dir") | |||
| 239 | + | if err != nil { | |||
| 240 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve Git metadata: %w", err) | |||
| 241 | + | } | |||
| 242 | + | if !filepath.IsAbs(commonDir) { | |||
| 243 | + | commonDir = filepath.Join(repoRoot, commonDir) | |||
| 244 | + | } | |||
| 245 | + | commonDir, err = canonical(commonDir) | |||
| 246 | + | if err != nil { | |||
| 247 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve Git metadata: %w", err) | |||
| 248 | + | } | |||
| 249 | + | if err := t.checkReadAccess(commonDir); err != nil { | |||
| 250 | + | return createWorktreePlan{}, err | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | configuredRoot, configured, err := createWorktreeGitConfigValue(ctx, repoRoot, worktreeConfigKey) | |||
| 254 | + | if err != nil { | |||
| 255 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: read local worktree configuration: %w", err) | |||
| 256 | + | } | |||
| 257 | + | bootstrapRoot := strings.TrimSpace(args.BootstrapRoot) | |||
| 258 | + | if configured && bootstrapRoot != "" { | |||
| 259 | + | return createWorktreePlan{}, errors.New("create_worktree: worktree root is already configured; change the local Git configuration explicitly before bootstrapping again") | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | rootSource := "existing .worktrees directory" | |||
| 263 | + | configValue := "" | |||
| 264 | + | setConfig := false | |||
| 265 | + | defaultRoot := false | |||
| 266 | + | var worktreeRoot string | |||
| 267 | + | if configured { | |||
| 268 | + | worktreeRoot, defaultRoot, err = resolveConfiguredWorktreeRoot(repoRoot, configuredRoot) | |||
| 269 | + | if err != nil { | |||
| 270 | + | return createWorktreePlan{}, err | |||
| 271 | + | } | |||
| 272 | + | rootSource = "local Git config" | |||
| 273 | + | } else { | |||
| 274 | + | defaultPath := filepath.Join(repoRoot, defaultWorktreeRoot) | |||
| 275 | + | exists, err := inspectWorktreeRoot(defaultPath) | |||
| 276 | + | if err != nil { | |||
| 277 | + | return createWorktreePlan{}, err | |||
| 278 | + | } | |||
| 279 | + | if !exists && bootstrapRoot == "" { | |||
| 280 | + | return bootstrapRequiredPlan(branch, repoRoot), nil | |||
| 281 | + | } | |||
| 282 | + | if exists { | |||
| 283 | + | if bootstrapRoot != "" { | |||
| 284 | + | return createWorktreePlan{}, errors.New("create_worktree: worktree root is already established; omit bootstrap_root") | |||
| 285 | + | } | |||
| 286 | + | worktreeRoot = defaultPath | |||
| 287 | + | defaultRoot = true | |||
| 288 | + | } else { | |||
| 289 | + | worktreeRoot, defaultRoot, configValue, err = resolveBootstrapWorktreeRoot(repoRoot, bootstrapRoot) | |||
| 290 | + | if err != nil { | |||
| 291 | + | return createWorktreePlan{}, err | |||
| 292 | + | } | |||
| 293 | + | setConfig = true | |||
| 294 | + | rootSource = "bootstrap selection" | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | worktreeRoot, err = validateWorktreeRoot(worktreeRoot, defaultRoot) | |||
| 299 | + | if err != nil { | |||
| 300 | + | return createWorktreePlan{}, err | |||
| 301 | + | } | |||
| 302 | + | worktreePath := filepath.Join(worktreeRoot, filepath.FromSlash(branch)) | |||
| 303 | + | rootCanonical, err := canonicalOrParent(worktreeRoot) | |||
| 304 | + | if err != nil { | |||
| 305 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree root: %w", err) | |||
| 306 | + | } | |||
| 307 | + | if !defaultRoot && isUnder(repoRoot, rootCanonical) { | |||
| 308 | + | return createWorktreePlan{}, errors.New("create_worktree: external worktree root must be outside the repository") | |||
| 309 | + | } | |||
| 310 | + | pathCanonical, err := canonicalOrParent(worktreePath) | |||
| 311 | + | if err != nil { | |||
| 312 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree path: %w", err) | |||
| 313 | + | } | |||
| 314 | + | if !isUnder(rootCanonical, pathCanonical) { | |||
| 315 | + | return createWorktreePlan{}, errors.New("create_worktree: branch path escapes configured worktree root") | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | ignorePath := "" | |||
| 319 | + | accessPaths := []string{repoRoot, commonDir, worktreeRoot, worktreePath} | |||
| 320 | + | if defaultRoot { | |||
| 321 | + | ignorePath = filepath.Join(repoRoot, ".gitignore") | |||
| 322 | + | accessPaths = append(accessPaths, ignorePath) | |||
| 323 | + | } | |||
| 324 | + | if err := t.checkReadWriteAccess(accessPaths...); err != nil { | |||
| 325 | + | return createWorktreePlan{}, err | |||
| 326 | + | } | |||
| 327 | + | if _, err := os.Lstat(worktreePath); err == nil { | |||
| 328 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: worktree path %q already exists", filepath.ToSlash(worktreePath)) | |||
| 329 | + | } else if !os.IsNotExist(err) { | |||
| 330 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: inspect worktree path: %w", err) | |||
| 331 | + | } | |||
| 332 | + | exists, err := createWorktreeBranchExists(ctx, repoRoot, branch) | |||
| 333 | + | if err != nil { | |||
| 334 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: check branch: %w", err) | |||
| 335 | + | } | |||
| 336 | + | if exists { | |||
| 337 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: branch %q already exists", branch) | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | ignore := worktreesIgnoreUpdate{} | |||
| 341 | + | ignoreAction := "not modified (external worktree root)" | |||
| 342 | + | if defaultRoot { | |||
| 343 | + | ignore, err = newWorktreesIgnoreUpdate(ignorePath) | |||
| 344 | + | if err != nil { | |||
| 345 | + | return createWorktreePlan{}, fmt.Errorf("create_worktree: read .gitignore: %w", err) | |||
| 346 | + | } | |||
| 347 | + | ignoreAction = "already contains " + worktreesIgnoreEntry | |||
| 348 | + | if ignore.changed { | |||
| 349 | + | ignoreAction = "add " + worktreesIgnoreEntry | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | displayRoot := worktreeRoot | |||
| 354 | + | if defaultRoot { | |||
| 355 | + | displayRoot = defaultWorktreeRoot | |||
| 356 | + | } | |||
| 357 | + | displayPath := displayWorktreePath(repoRoot, worktreePath, defaultRoot) | |||
| 358 | + | configAction := "" | |||
| 359 | + | if setConfig { | |||
| 360 | + | configAction = fmt.Sprintf("local config: set %s=%s\n", worktreeConfigKey, configValue) | |||
| 361 | + | } | |||
| 362 | + | previewText := fmt.Sprintf("create worktree\nbranch: %s\nbase: %s\nworktree root: %s\nroot source: %s\npath: %s\n%s.gitignore: %s\n", branch, base, filepath.ToSlash(displayRoot), rootSource, displayPath, configAction, ignoreAction) | |||
| 363 | + | return createWorktreePlan{ | |||
| 364 | + | repoRoot: repoRoot, | |||
| 365 | + | branch: branch, | |||
| 366 | + | base: base, | |||
| 367 | + | worktreeRoot: worktreeRoot, | |||
| 368 | + | worktreePath: worktreePath, | |||
| 369 | + | rootSource: rootSource, | |||
| 370 | + | configValue: configValue, | |||
| 371 | + | setConfig: setConfig, | |||
| 372 | + | previewText: previewText, | |||
| 373 | + | preview: core.ToolResult{ | |||
| 374 | + | Content: []provider.Content{provider.TextBlock{Text: previewText}}, | |||
| 375 | + | Details: map[string]any{ | |||
| 376 | + | "state": "ready", | |||
| 377 | + | "branch": branch, | |||
| 378 | + | "base": base, | |||
| 379 | + | "worktree_root": displayRoot, | |||
| 380 | + | "root_source": rootSource, | |||
| 381 | + | "path": displayPath, | |||
| 382 | + | "bootstrap_saved": setConfig, | |||
| 383 | + | }, | |||
| 384 | + | }, | |||
| 385 | + | ignore: ignore, | |||
| 386 | + | }, nil | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | func bootstrapRequiredPlan(branch, repoRoot string) createWorktreePlan { | |||
| 390 | + | text := fmt.Sprintf("Worktree bootstrap is required.\nbranch: %s\nrepository: %s\nChoose a location with the user, then call create_worktree again with bootstrap_root set to .worktrees for the repository default or to an absolute external directory. This call made no changes.\n", branch, filepath.ToSlash(repoRoot)) | |||
| 391 | + | return createWorktreePlan{ | |||
| 392 | + | branch: branch, | |||
| 393 | + | bootstrapRequired: true, | |||
| 394 | + | previewText: text, | |||
| 395 | + | preview: core.ToolResult{ | |||
| 396 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |||
| 397 | + | Details: map[string]any{ | |||
| 398 | + | "state": "bootstrap_required", | |||
| 399 | + | "branch": branch, | |||
| 400 | + | "default_bootstrap_root": defaultWorktreeRoot, | |||
| 401 | + | }, | |||
| 402 | + | }, | |||
| 403 | + | } | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | func (t *CreateWorktreeTool) accessibleCWD() (string, error) { | |||
| 407 | + | cwd := t.CWD | |||
| 408 | + | if cwd == "" { | |||
| 409 | + | var err error | |||
| 410 | + | cwd, err = os.Getwd() | |||
| 411 | + | if err != nil { | |||
| 412 | + | return "", fmt.Errorf("create_worktree: working directory: %w", err) | |||
| 413 | + | } | |||
| 414 | + | } | |||
| 415 | + | cwd, err := canonical(cwd) | |||
| 416 | + | if err != nil { | |||
| 417 | + | return "", fmt.Errorf("create_worktree: resolve working directory: %w", err) | |||
| 418 | + | } | |||
| 419 | + | if err := t.checkReadAccess(cwd); err != nil { | |||
| 420 | + | return "", err | |||
| 421 | + | } | |||
| 422 | + | return cwd, nil | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | func (t *CreateWorktreeTool) findGitRootCandidate(cwd string) (string, error) { | |||
| 426 | + | for dir := cwd; ; dir = filepath.Dir(dir) { | |||
| 427 | + | if err := t.checkReadAccess(dir); err != nil { | |||
| 428 | + | return "", err | |||
| 429 | + | } | |||
| 430 | + | if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil { | |||
| 431 | + | return dir, nil | |||
| 432 | + | } else if !os.IsNotExist(err) { | |||
| 433 | + | return "", fmt.Errorf("create_worktree: inspect Git metadata: %w", err) | |||
| 434 | + | } | |||
| 435 | + | parent := filepath.Dir(dir) | |||
| 436 | + | if parent == dir { | |||
| 437 | + | break | |||
| 438 | + | } | |||
| 439 | + | } | |||
| 440 | + | return "", errors.New("create_worktree: working directory is not a Git repository") | |||
| 441 | + | } | |||
| 442 | + | ||||
| 443 | + | func (t *CreateWorktreeTool) checkReadAccess(paths ...string) error { | |||
| 444 | + | for _, path := range paths { | |||
| 445 | + | if err := t.Sandbox.CheckReadPath(path); err != nil { | |||
| 446 | + | return fmt.Errorf("create_worktree: %w", err) | |||
| 447 | + | } | |||
| 448 | + | } | |||
| 449 | + | return nil | |||
| 450 | + | } | |||
| 451 | + | ||||
| 452 | + | func (t *CreateWorktreeTool) checkReadWriteAccess(paths ...string) error { | |||
| 453 | + | for _, path := range paths { | |||
| 454 | + | if err := t.Sandbox.CheckReadPath(path); err != nil { | |||
| 455 | + | return fmt.Errorf("create_worktree: %w", err) | |||
| 456 | + | } | |||
| 457 | + | if err := t.Sandbox.CheckWritePath(path); err != nil { | |||
| 458 | + | return fmt.Errorf("create_worktree: %w", err) | |||
| 459 | + | } | |||
| 460 | + | } | |||
| 461 | + | return nil | |||
| 462 | + | } | |||
| 463 | + | ||||
| 464 | + | func resolveConfiguredWorktreeRoot(repoRoot, value string) (string, bool, error) { | |||
| 465 | + | value = strings.TrimSpace(value) | |||
| 466 | + | if value == defaultWorktreeRoot { | |||
| 467 | + | return filepath.Join(repoRoot, defaultWorktreeRoot), true, nil | |||
| 468 | + | } | |||
| 469 | + | if !filepath.IsAbs(value) { | |||
| 470 | + | return "", false, errors.New("create_worktree: local worktree configuration must be .worktrees or an absolute external path") | |||
| 471 | + | } | |||
| 472 | + | return value, false, nil | |||
| 473 | + | } | |||
| 474 | + | ||||
| 475 | + | func resolveBootstrapWorktreeRoot(repoRoot, value string) (string, bool, string, error) { | |||
| 476 | + | if value == defaultWorktreeRoot { | |||
| 477 | + | return filepath.Join(repoRoot, defaultWorktreeRoot), true, defaultWorktreeRoot, nil | |||
| 478 | + | } | |||
| 479 | + | if !filepath.IsAbs(value) { | |||
| 480 | + | return "", false, "", errors.New("create_worktree: bootstrap_root must be .worktrees or an absolute external path") | |||
| 481 | + | } | |||
| 482 | + | root, err := canonicalOrParent(value) | |||
| 483 | + | if err != nil { | |||
| 484 | + | return "", false, "", fmt.Errorf("create_worktree: resolve bootstrap_root: %w", err) | |||
| 485 | + | } | |||
| 486 | + | if isUnder(repoRoot, root) { | |||
| 487 | + | return "", false, "", errors.New("create_worktree: external bootstrap_root must be outside the repository") | |||
| 488 | + | } | |||
| 489 | + | return root, false, root, nil | |||
| 490 | + | } | |||
| 491 | + | ||||
| 492 | + | func validateWorktreeRoot(path string, defaultRoot bool) (string, error) { | |||
| 493 | + | info, err := os.Lstat(path) | |||
| 494 | + | if os.IsNotExist(err) { | |||
| 495 | + | return path, nil | |||
| 496 | + | } | |||
| 497 | + | if err != nil { | |||
| 498 | + | return "", fmt.Errorf("create_worktree: inspect worktree root: %w", err) | |||
| 499 | + | } | |||
| 500 | + | if info.Mode()&os.ModeSymlink != 0 { | |||
| 501 | + | return "", errors.New("create_worktree: worktree root must not be a symbolic link") | |||
| 502 | + | } | |||
| 503 | + | if !info.IsDir() { | |||
| 504 | + | return "", errors.New("create_worktree: worktree root exists but is not a directory") | |||
| 505 | + | } | |||
| 506 | + | if defaultRoot && filepath.Base(path) != defaultWorktreeRoot { | |||
| 507 | + | return "", errors.New("create_worktree: invalid default worktree root") | |||
| 508 | + | } | |||
| 509 | + | return path, nil | |||
| 510 | + | } | |||
| 511 | + | ||||
| 512 | + | func inspectWorktreeRoot(path string) (bool, error) { | |||
| 513 | + | info, err := os.Lstat(path) | |||
| 514 | + | if os.IsNotExist(err) { | |||
| 515 | + | return false, nil | |||
| 516 | + | } | |||
| 517 | + | if err != nil { | |||
| 518 | + | return false, fmt.Errorf("create_worktree: inspect .worktrees: %w", err) | |||
| 519 | + | } | |||
| 520 | + | if info.Mode()&os.ModeSymlink != 0 { | |||
| 521 | + | return false, errors.New("create_worktree: .worktrees must not be a symbolic link") | |||
| 522 | + | } | |||
| 523 | + | if !info.IsDir() { | |||
| 524 | + | return false, errors.New("create_worktree: .worktrees exists but is not a directory") | |||
| 525 | + | } | |||
| 526 | + | return true, nil | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | func displayWorktreePath(repoRoot, path string, defaultRoot bool) string { | |||
| 530 | + | if defaultRoot { | |||
| 531 | + | relative, err := filepath.Rel(repoRoot, path) | |||
| 532 | + | if err == nil { | |||
| 533 | + | return filepath.ToSlash(relative) | |||
| 534 | + | } | |||
| 535 | + | } | |||
| 536 | + | return filepath.ToSlash(path) | |||
| 537 | + | } | |||
| 538 | + | ||||
| 539 | + | func sameCreateWorktreePlan(a, b createWorktreePlan) bool { | |||
| 540 | + | return a.bootstrapRequired == b.bootstrapRequired && | |||
| 541 | + | a.repoRoot == b.repoRoot && | |||
| 542 | + | a.branch == b.branch && | |||
| 543 | + | a.base == b.base && | |||
| 544 | + | a.worktreeRoot == b.worktreeRoot && | |||
| 545 | + | a.worktreePath == b.worktreePath && | |||
| 546 | + | a.rootSource == b.rootSource && | |||
| 547 | + | a.configValue == b.configValue && | |||
| 548 | + | a.setConfig == b.setConfig && | |||
| 549 | + | a.ignore.path == b.ignore.path && | |||
| 550 | + | a.ignore.existed == b.ignore.existed && | |||
| 551 | + | a.ignore.changed == b.ignore.changed && | |||
| 552 | + | bytes.Equal(a.ignore.before, b.ignore.before) | |||
| 553 | + | } | |||
| 554 | + | ||||
| 555 | + | func missingWorktreeParents(worktreePath string) ([]string, error) { | |||
| 556 | + | var missing []string | |||
| 557 | + | for directory := filepath.Dir(worktreePath); ; directory = filepath.Dir(directory) { | |||
| 558 | + | info, err := os.Lstat(directory) | |||
| 559 | + | if err == nil { | |||
| 560 | + | if !info.IsDir() { | |||
| 561 | + | return nil, fmt.Errorf("%q is not a directory", directory) | |||
| 562 | + | } | |||
| 563 | + | break | |||
| 564 | + | } | |||
| 565 | + | if !os.IsNotExist(err) { | |||
| 566 | + | return nil, err | |||
| 567 | + | } | |||
| 568 | + | missing = append(missing, directory) | |||
| 569 | + | parent := filepath.Dir(directory) | |||
| 570 | + | if parent == directory { | |||
| 571 | + | return nil, errors.New("no existing parent directory") | |||
| 572 | + | } | |||
| 573 | + | } | |||
| 574 | + | for left, right := 0, len(missing)-1; left < right; left, right = left+1, right-1 { | |||
| 575 | + | missing[left], missing[right] = missing[right], missing[left] | |||
| 576 | + | } | |||
| 577 | + | return missing, nil | |||
| 578 | + | } | |||
| 579 | + | ||||
| 580 | + | func removeCreatedWorktreeParents(parents []string) error { | |||
| 581 | + | for index := len(parents) - 1; index >= 0; index-- { | |||
| 582 | + | if err := os.Remove(parents[index]); err != nil && !os.IsNotExist(err) { | |||
| 583 | + | return err | |||
| 584 | + | } | |||
| 585 | + | } | |||
| 586 | + | return nil | |||
| 587 | + | } | |||
| 588 | + | ||||
| 589 | + | func (p createWorktreePlan) removeBootstrapConfig(ctx context.Context) error { | |||
| 590 | + | value, configured, err := createWorktreeGitConfigValue(ctx, p.repoRoot, worktreeConfigKey) | |||
| 591 | + | if err != nil { | |||
| 592 | + | return err | |||
| 593 | + | } | |||
| 594 | + | if !configured { | |||
| 595 | + | return nil | |||
| 596 | + | } | |||
| 597 | + | if value != p.configValue { | |||
| 598 | + | return errors.New("local worktree configuration changed during creation") | |||
| 599 | + | } | |||
| 600 | + | _, err = createWorktreeGitOutput(ctx, p.repoRoot, "config", "--local", "--unset-all", worktreeConfigKey) | |||
| 601 | + | return err | |||
| 602 | + | } | |||
| 603 | + | ||||
| 604 | + | func (p createWorktreePlan) cleanupFailedCheckout(ctx context.Context) error { | |||
| 605 | + | registered, err := createWorktreeRegistered(ctx, p.repoRoot, p.worktreePath) | |||
| 606 | + | if err != nil { | |||
| 607 | + | return err | |||
| 608 | + | } | |||
| 609 | + | var cleanupErr error | |||
| 610 | + | if registered { | |||
| 611 | + | if _, err := createWorktreeGitOutput(ctx, p.repoRoot, "worktree", "remove", "--force", p.worktreePath); err != nil { | |||
| 612 | + | cleanupErr = fmt.Errorf("remove registered worktree: %w", err) | |||
| 613 | + | } | |||
| 614 | + | } | |||
| 615 | + | branchHead, exists, err := createWorktreeGitRef(ctx, p.repoRoot, "refs/heads/"+p.branch) | |||
| 616 | + | if err == nil && exists && branchHead == p.base { | |||
| 617 | + | if _, err := createWorktreeGitOutput(ctx, p.repoRoot, "branch", "-D", p.branch); err != nil && cleanupErr == nil { | |||
| 618 | + | cleanupErr = fmt.Errorf("remove branch: %w", err) | |||
| 619 | + | } | |||
| 620 | + | } | |||
| 621 | + | if err != nil && cleanupErr == nil { | |||
| 622 | + | cleanupErr = err | |||
| 623 | + | } | |||
| 624 | + | return cleanupErr | |||
| 625 | + | } | |||
| 626 | + | ||||
| 627 | + | func createWorktreeRegistered(ctx context.Context, repoRoot, worktreePath string) (bool, error) { | |||
| 628 | + | output, err := createWorktreeGitOutput(ctx, repoRoot, "worktree", "list", "--porcelain") | |||
| 629 | + | if err != nil { | |||
| 630 | + | return false, err | |||
| 631 | + | } | |||
| 632 | + | want, err := canonicalOrParent(worktreePath) | |||
| 633 | + | if err != nil { | |||
| 634 | + | return false, err | |||
| 635 | + | } | |||
| 636 | + | for _, line := range strings.Split(output, "\n") { | |||
| 637 | + | if !strings.HasPrefix(line, "worktree ") { | |||
| 638 | + | continue | |||
| 639 | + | } | |||
| 640 | + | path, err := canonicalOrParent(strings.TrimPrefix(line, "worktree ")) | |||
| 641 | + | if err == nil && path == want { | |||
| 642 | + | return true, nil | |||
| 643 | + | } | |||
| 644 | + | } | |||
| 645 | + | return false, nil | |||
| 646 | + | } | |||
| 647 | + | ||||
| 648 | + | func createWorktreeBranchExists(ctx context.Context, repoRoot, branch string) (bool, error) { | |||
| 649 | + | _, err := createWorktreeGitOutput(ctx, repoRoot, "show-ref", "--verify", "--quiet", "refs/heads/"+branch) | |||
| 650 | + | if err == nil { | |||
| 651 | + | return true, nil | |||
| 652 | + | } | |||
| 653 | + | if ctx.Err() != nil { | |||
| 654 | + | return false, ctx.Err() | |||
| 655 | + | } | |||
| 656 | + | if createWorktreeGitExitCode(err) == 1 { | |||
| 657 | + | return false, nil | |||
| 658 | + | } | |||
| 659 | + | return false, err | |||
| 660 | + | } | |||
| 661 | + | ||||
| 662 | + | func createWorktreeGitRef(ctx context.Context, repoRoot, ref string) (string, bool, error) { | |||
| 663 | + | output, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", "--quiet", ref+"^{commit}") | |||
| 664 | + | if err == nil { | |||
| 665 | + | return output, true, nil | |||
| 666 | + | } | |||
| 667 | + | if createWorktreeGitExitCode(err) == 1 { | |||
| 668 | + | return "", false, nil | |||
| 669 | + | } | |||
| 670 | + | return "", false, err | |||
| 671 | + | } | |||
| 672 | + | ||||
| 673 | + | func createWorktreeGitConfigValue(ctx context.Context, repoRoot, key string) (string, bool, error) { | |||
| 674 | + | output, err := createWorktreeGitOutput(ctx, repoRoot, "config", "--local", "--get", key) | |||
| 675 | + | if err == nil { | |||
| 676 | + | return output, true, nil | |||
| 677 | + | } | |||
| 678 | + | if createWorktreeGitExitCode(err) == 1 { | |||
| 679 | + | return "", false, nil | |||
| 680 | + | } | |||
| 681 | + | return "", false, err | |||
| 682 | + | } | |||
| 683 | + | ||||
| 684 | + | type createWorktreeGitError struct { | |||
| 685 | + | args []string | |||
| 686 | + | output string | |||
| 687 | + | err error | |||
| 688 | + | } | |||
| 689 | + | ||||
| 690 | + | func (e *createWorktreeGitError) Error() string { | |||
| 691 | + | message := strings.TrimSpace(e.output) | |||
| 692 | + | if message == "" { | |||
| 693 | + | message = e.err.Error() | |||
| 694 | + | } | |||
| 695 | + | return fmt.Sprintf("git %s: %s", strings.Join(e.args, " "), message) | |||
| 696 | + | } | |||
| 697 | + | ||||
| 698 | + | func (e *createWorktreeGitError) Unwrap() error { return e.err } | |||
| 699 | + | ||||
| 700 | + | func createWorktreeGitExitCode(err error) int { | |||
| 701 | + | var exitErr *exec.ExitError | |||
| 702 | + | if errors.As(err, &exitErr) { | |||
| 703 | + | return exitErr.ExitCode() | |||
| 704 | + | } | |||
| 705 | + | return -1 | |||
| 706 | + | } | |||
| 707 | + | ||||
| 708 | + | func createWorktreeGitOutput(ctx context.Context, dir string, args ...string) (string, error) { | |||
| 709 | + | cmdArgs := append([]string{"-c", "core.hooksPath=" + os.DevNull, "-C", dir}, args...) | |||
| 710 | + | cmd := exec.CommandContext(ctx, "git", cmdArgs...) | |||
| 711 | + | cmd.Env = createWorktreeGitEnv() | |||
| 712 | + | setProcessGroup(cmd) | |||
| 713 | + | ||||
| 714 | + | var stdout, output cappedGitOutput | |||
| 715 | + | cmd.Stdout = io.MultiWriter(&stdout, &output) | |||
| 716 | + | cmd.Stderr = &output | |||
| 717 | + | if err := cmd.Start(); err != nil { | |||
| 718 | + | return "", fmt.Errorf("start git %s: %w", strings.Join(args, " "), err) | |||
| 719 | + | } | |||
| 720 | + | watchDone := make(chan struct{}) | |||
| 721 | + | watchStopped := make(chan struct{}) | |||
| 722 | + | go func() { | |||
| 723 | + | defer close(watchStopped) | |||
| 724 | + | select { | |||
| 725 | + | case <-ctx.Done(): | |||
| 726 | + | killProcessGroup(cmd) | |||
| 727 | + | case <-watchDone: | |||
| 728 | + | } | |||
| 729 | + | }() | |||
| 730 | + | waitErr := cmd.Wait() | |||
| 731 | + | close(watchDone) | |||
| 732 | + | <-watchStopped | |||
| 733 | + | if waitErr != nil { | |||
| 734 | + | if ctx.Err() != nil { | |||
| 735 | + | return "", ctx.Err() | |||
| 736 | + | } | |||
| 737 | + | return "", &createWorktreeGitError{args: args, output: output.String(), err: waitErr} | |||
| 738 | + | } | |||
| 739 | + | return strings.TrimSpace(stdout.String()), nil | |||
| 740 | + | } | |||
| 741 | + | ||||
| 742 | + | type cappedGitOutput struct { | |||
| 743 | + | mu sync.Mutex | |||
| 744 | + | buffer bytes.Buffer | |||
| 745 | + | truncated bool | |||
| 746 | + | } | |||
| 747 | + | ||||
| 748 | + | func (b *cappedGitOutput) Write(p []byte) (int, error) { | |||
| 749 | + | b.mu.Lock() | |||
| 750 | + | defer b.mu.Unlock() | |||
| 751 | + | if room := maxCreateWorktreeGitOutput - b.buffer.Len(); room > 0 { | |||
| 752 | + | if len(p) > room { | |||
| 753 | + | _, _ = b.buffer.Write(p[:room]) | |||
| 754 | + | b.truncated = true | |||
| 755 | + | } else { | |||
| 756 | + | _, _ = b.buffer.Write(p) | |||
| 757 | + | } | |||
| 758 | + | } else { | |||
| 759 | + | b.truncated = true | |||
| 760 | + | } | |||
| 761 | + | return len(p), nil | |||
| 762 | + | } | |||
| 763 | + | ||||
| 764 | + | func (b *cappedGitOutput) String() string { | |||
| 765 | + | b.mu.Lock() | |||
| 766 | + | defer b.mu.Unlock() | |||
| 767 | + | output := b.buffer.String() | |||
| 768 | + | if b.truncated { | |||
| 769 | + | output += "\n... [git output truncated]" | |||
| 770 | + | } | |||
| 771 | + | return output | |||
| 772 | + | } | |||
| 773 | + | ||||
| 774 | + | func createWorktreeGitEnv() []string { | |||
| 775 | + | env := make([]string, 0, len(os.Environ())+5) | |||
| 776 | + | for _, entry := range os.Environ() { | |||
| 777 | + | key, _, _ := strings.Cut(entry, "=") | |||
| 778 | + | if strings.HasPrefix(key, "GIT_") || key == "SSH_ASKPASS" { | |||
| 779 | + | continue | |||
| 780 | + | } | |||
| 781 | + | env = append(env, entry) | |||
| 782 | + | } | |||
| 783 | + | return append(env, | |||
| 784 | + | "GIT_CONFIG_NOSYSTEM=1", | |||
| 785 | + | "GIT_CONFIG_GLOBAL="+os.DevNull, | |||
| 786 | + | "GIT_TERMINAL_PROMPT=0", | |||
| 787 | + | "GIT_ASKPASS=", | |||
| 788 | + | "SSH_ASKPASS=", | |||
| 789 | + | ) | |||
| 790 | + | } | |||
| 791 | + | ||||
| 792 | + | type worktreesIgnoreUpdate struct { | |||
| 793 | + | path string | |||
| 794 | + | before []byte | |||
| 795 | + | after []byte | |||
| 796 | + | existed bool | |||
| 797 | + | mode os.FileMode | |||
| 798 | + | changed bool | |||
| 799 | + | } | |||
| 800 | + | ||||
| 801 | + | func newWorktreesIgnoreUpdate(path string) (worktreesIgnoreUpdate, error) { | |||
| 802 | + | contents, mode, existed, err := readWorktreesIgnore(path) | |||
| 803 | + | if err != nil { | |||
| 804 | + | return worktreesIgnoreUpdate{}, err | |||
| 805 | + | } | |||
| 806 | + | for _, line := range strings.Split(string(contents), "\n") { | |||
| 807 | + | if strings.TrimSuffix(line, "\r") == worktreesIgnoreEntry { | |||
| 808 | + | return worktreesIgnoreUpdate{path: path, before: contents, existed: existed, mode: mode}, nil | |||
| 809 | + | } | |||
| 810 | + | } | |||
| 811 | + | lineEnding := "\n" | |||
| 812 | + | if bytes.Contains(contents, []byte("\r\n")) { | |||
| 813 | + | lineEnding = "\r\n" | |||
| 814 | + | } | |||
| 815 | + | after := append([]byte(nil), contents...) | |||
| 816 | + | if len(after) > 0 && !strings.HasSuffix(string(after), "\n") { | |||
| 817 | + | after = append(after, lineEnding...) | |||
| 818 | + | } | |||
| 819 | + | after = append(after, []byte(worktreesIgnoreEntry+lineEnding)...) | |||
| 820 | + | return worktreesIgnoreUpdate{path: path, before: contents, after: after, existed: existed, mode: mode, changed: true}, nil | |||
| 821 | + | } | |||
| 822 | + | ||||
| 823 | + | func (u worktreesIgnoreUpdate) apply() error { | |||
| 824 | + | if !u.changed { | |||
| 825 | + | return nil | |||
| 826 | + | } | |||
| 827 | + | current, _, existed, err := readWorktreesIgnore(u.path) | |||
| 828 | + | if err != nil { | |||
| 829 | + | return err | |||
| 830 | + | } | |||
| 831 | + | if existed != u.existed || !bytes.Equal(current, u.before) { | |||
| 832 | + | return errors.New(".gitignore changed since preview; inspect it and retry") | |||
| 833 | + | } | |||
| 834 | + | return writeWorktreesIgnore(u.path, u.after, u.mode) | |||
| 835 | + | } | |||
| 836 | + | ||||
| 837 | + | func (u worktreesIgnoreUpdate) restore() error { | |||
| 838 | + | if !u.changed { | |||
| 839 | + | return nil | |||
| 840 | + | } | |||
| 841 | + | current, _, existed, err := readWorktreesIgnore(u.path) | |||
| 842 | + | if err != nil { | |||
| 843 | + | return err | |||
| 844 | + | } | |||
| 845 | + | if !existed || !bytes.Equal(current, u.after) { | |||
| 846 | + | return nil | |||
| 847 | + | } | |||
| 848 | + | if !u.existed { | |||
| 849 | + | if err := os.Remove(u.path); err != nil && !os.IsNotExist(err) { | |||
| 850 | + | return err | |||
| 851 | + | } | |||
| 852 | + | return nil | |||
| 853 | + | } | |||
| 854 | + | return writeWorktreesIgnore(u.path, u.before, u.mode) | |||
| 855 | + | } | |||
| 856 | + | ||||
| 857 | + | func readWorktreesIgnore(path string) ([]byte, os.FileMode, bool, error) { | |||
| 858 | + | info, err := os.Lstat(path) | |||
| 859 | + | if os.IsNotExist(err) { | |||
| 860 | + | return nil, 0o644, false, nil | |||
| 861 | + | } | |||
| 862 | + | if err != nil { | |||
| 863 | + | return nil, 0, false, err | |||
| 864 | + | } | |||
| 865 | + | if info.Mode()&os.ModeSymlink != 0 { | |||
| 866 | + | return nil, 0, false, errors.New(".gitignore must not be a symbolic link") | |||
| 867 | + | } | |||
| 868 | + | if !info.Mode().IsRegular() { | |||
| 869 | + | return nil, 0, false, errors.New(".gitignore must be a regular file") | |||
| 870 | + | } | |||
| 871 | + | if info.Size() > maxCreateWorktreeIgnoreBytes { | |||
| 872 | + | return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes) | |||
| 873 | + | } | |||
| 874 | + | file, err := os.Open(path) | |||
| 875 | + | if err != nil { | |||
| 876 | + | return nil, 0, false, err | |||
| 877 | + | } | |||
| 878 | + | defer file.Close() | |||
| 879 | + | contents, err := io.ReadAll(io.LimitReader(file, maxCreateWorktreeIgnoreBytes+1)) | |||
| 880 | + | if err != nil { | |||
| 881 | + | return nil, 0, false, err | |||
| 882 | + | } | |||
| 883 | + | if len(contents) > maxCreateWorktreeIgnoreBytes { | |||
| 884 | + | return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes) | |||
| 885 | + | } | |||
| 886 | + | return contents, info.Mode().Perm(), true, nil | |||
| 887 | + | } | |||
| 888 | + | ||||
| 889 | + | func writeWorktreesIgnore(path string, contents []byte, mode os.FileMode) error { | |||
| 890 | + | file, err := os.CreateTemp(filepath.Dir(path), ".zut-gitignore-*") | |||
| 891 | + | if err != nil { | |||
| 892 | + | return err | |||
| 893 | + | } | |||
| 894 | + | tempPath := file.Name() | |||
| 895 | + | defer os.Remove(tempPath) | |||
| 896 | + | if err := file.Chmod(mode); err != nil { | |||
| 897 | + | _ = file.Close() | |||
| 898 | + | return err | |||
| 899 | + | } | |||
| 900 | + | if _, err := file.Write(contents); err != nil { | |||
| 901 | + | _ = file.Close() | |||
| 902 | + | return err | |||
| 903 | + | } | |||
| 904 | + | if err := file.Close(); err != nil { | |||
| 905 | + | return err | |||
| 906 | + | } | |||
| 907 | + | return os.Rename(tempPath, path) | |||
| 908 | + | } | |||
A packages/agent/tools/create_worktree_test.go +812 -0
| @@ -0,0 +1,812 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "os" | |
| 8 | + | "os/exec" | |
| 9 | + | "path/filepath" | |
| 10 | + | "runtime" | |
| 11 | + | "strings" | |
| 12 | + | "testing" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/zut/packages/provider" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func TestCreateWorktreeBootstrapsDefaultAtRepositoryRoot(t *testing.T) { | |
| 19 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 20 | + | t.Skip("git is required") | |
| 21 | + | } | |
| 22 | + | repo := initWorktreeTestRepo(t) | |
| 23 | + | nested := filepath.Join(repo, "nested", "dir") | |
| 24 | + | if err := os.MkdirAll(nested, 0o755); err != nil { | |
| 25 | + | t.Fatal(err) | |
| 26 | + | } | |
| 27 | + | if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("*.local\n"), 0o644); err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | branchBefore := gitTestOutput(t, repo, "branch", "--show-current") | |
| 31 | + | sandbox := NewSandbox(repo) | |
| 32 | + | sandbox.Lock() | |
| 33 | + | ||
| 34 | + | tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox} | |
| 35 | + | args, err := json.Marshal(map[string]string{"branch": "feature/new-checkout", "bootstrap_root": ".worktrees"}) | |
| 36 | + | if err != nil { | |
| 37 | + | t.Fatal(err) | |
| 38 | + | } | |
| 39 | + | if _, err := tool.Execute(context.Background(), args, nil); err != nil { | |
| 40 | + | t.Fatal(err) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | worktree := filepath.Join(repo, ".worktrees", "feature", "new-checkout") | |
| 44 | + | if info, err := os.Stat(worktree); err != nil || !info.IsDir() { | |
| 45 | + | t.Fatalf("worktree %q was not created: %v", worktree, err) | |
| 46 | + | } | |
| 47 | + | if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/new-checkout" { | |
| 48 | + | t.Fatalf("worktree branch = %q, want %q", got, "feature/new-checkout") | |
| 49 | + | } | |
| 50 | + | if got := gitTestOutput(t, repo, "branch", "--show-current"); got != branchBefore { | |
| 51 | + | t.Fatalf("source branch changed from %q to %q", branchBefore, got) | |
| 52 | + | } | |
| 53 | + | ignore, err := os.ReadFile(filepath.Join(repo, ".gitignore")) | |
| 54 | + | if err != nil { | |
| 55 | + | t.Fatal(err) | |
| 56 | + | } | |
| 57 | + | if count := strings.Count(string(ignore), "/.worktrees/\n"); count != 1 { | |
| 58 | + | t.Fatalf("/.worktrees/ ignore entries = %d, want 1:\n%s", count, ignore) | |
| 59 | + | } | |
| 60 | + | if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != defaultWorktreeRoot { | |
| 61 | + | t.Fatalf("saved worktree root = %q, want %q", got, defaultWorktreeRoot) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestCreateWorktreeRequiresBootstrapForUnconfiguredRepository(t *testing.T) { | |
| 66 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 67 | + | t.Skip("git is required") | |
| 68 | + | } | |
| 69 | + | repo := initWorktreeTestRepo(t) | |
| 70 | + | tool := newCreateWorktreeTestTool(repo) | |
| 71 | + | ||
| 72 | + | result, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/needs-bootstrap"}`), nil) | |
| 73 | + | if err != nil { | |
| 74 | + | t.Fatal(err) | |
| 75 | + | } | |
| 76 | + | text := result.Content[0].(provider.TextBlock).Text | |
| 77 | + | if !strings.Contains(text, "Worktree bootstrap is required") { | |
| 78 | + | t.Fatalf("bootstrap result = %q", text) | |
| 79 | + | } | |
| 80 | + | if state, _ := result.Details.(map[string]any)["state"].(string); state != "bootstrap_required" { | |
| 81 | + | t.Fatalf("bootstrap state = %q", state) | |
| 82 | + | } | |
| 83 | + | for _, path := range []string{".worktrees", ".gitignore"} { | |
| 84 | + | if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) { | |
| 85 | + | t.Fatalf("bootstrap created %s: %v", path, statErr) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |
| 89 | + | t.Fatalf("local worktree config exit = %d, want 1", got.exitCode) | |
| 90 | + | } | |
| 91 | + | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/needs-bootstrap"); got.exitCode != 1 { | |
| 92 | + | t.Fatalf("bootstrap created branch: %#v", got) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestCreateWorktreeRejectsBootstrapRootWhenDefaultRootExists(t *testing.T) { | |
| 97 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 98 | + | t.Skip("git is required") | |
| 99 | + | } | |
| 100 | + | repo := initWorktreeTestRepo(t) | |
| 101 | + | if err := os.Mkdir(filepath.Join(repo, defaultWorktreeRoot), 0o755); err != nil { | |
| 102 | + | t.Fatal(err) | |
| 103 | + | } | |
| 104 | + | tool := newCreateWorktreeTestTool(repo) | |
| 105 | + | ||
| 106 | + | _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{ | |
| 107 | + | "branch": "feature/conflicting-bootstrap", | |
| 108 | + | "bootstrap_root": t.TempDir(), | |
| 109 | + | }), nil) | |
| 110 | + | if err == nil || !strings.Contains(err.Error(), "already established") { | |
| 111 | + | t.Fatalf("error = %v, want established-root error", err) | |
| 112 | + | } | |
| 113 | + | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |
| 114 | + | t.Fatalf("conflicting bootstrap saved config: %#v", got) | |
| 115 | + | } | |
| 116 | + | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/conflicting-bootstrap"); got.exitCode != 1 { | |
| 117 | + | t.Fatalf("conflicting bootstrap created branch: %#v", got) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing.T) { | |
| 122 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 123 | + | t.Skip("git is required") | |
| 124 | + | } | |
| 125 | + | repo := initWorktreeTestRepo(t) | |
| 126 | + | externalRoot, err := canonicalOrParent(filepath.Join(t.TempDir(), "company-worktrees")) | |
| 127 | + | if err != nil { | |
| 128 | + | t.Fatal(err) | |
| 129 | + | } | |
| 130 | + | tool := newCreateWorktreeTestTool(repo) | |
| 131 | + | args := mustJSON(t, map[string]string{"branch": "feature/external", "bootstrap_root": externalRoot}) | |
| 132 | + | ||
| 133 | + | result, err := tool.Execute(context.Background(), args, nil) | |
| 134 | + | if err != nil { | |
| 135 | + | t.Fatal(err) | |
| 136 | + | } | |
| 137 | + | worktree := filepath.Join(externalRoot, "feature", "external") | |
| 138 | + | if info, err := os.Stat(worktree); err != nil || !info.IsDir() { | |
| 139 | + | t.Fatalf("external worktree %q was not created: %v", worktree, err) | |
| 140 | + | } | |
| 141 | + | if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/external" { | |
| 142 | + | t.Fatalf("external branch = %q", got) | |
| 143 | + | } | |
| 144 | + | if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != externalRoot { | |
| 145 | + | t.Fatalf("saved external root = %q, want %q", got, externalRoot) | |
| 146 | + | } | |
| 147 | + | if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) { | |
| 148 | + | t.Fatalf("external bootstrap changed .gitignore: %v", err) | |
| 149 | + | } | |
| 150 | + | if source, _ := result.Details.(map[string]any)["root_source"].(string); source != "bootstrap selection" { | |
| 151 | + | t.Fatalf("root source = %q", source) | |
| 152 | + | } | |
| 153 | + | if state, _ := result.Details.(map[string]any)["state"].(string); state != "created" { | |
| 154 | + | t.Fatalf("state = %q, want created", state) | |
| 155 | + | } | |
| 156 | + | followup, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/external-followup"}`), nil) | |
| 157 | + | if err != nil { | |
| 158 | + | t.Fatal(err) | |
| 159 | + | } | |
| 160 | + | if _, err := os.Stat(filepath.Join(externalRoot, "feature", "external-followup")); err != nil { | |
| 161 | + | t.Fatalf("configured external root was not reused: %v", err) | |
| 162 | + | } | |
| 163 | + | if source, _ := followup.Details.(map[string]any)["root_source"].(string); source != "local Git config" { | |
| 164 | + | t.Fatalf("follow-up root source = %q", source) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func TestCreateWorktreeRejectsInvalidBootstrapRoot(t *testing.T) { | |
| 169 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 170 | + | t.Skip("git is required") | |
| 171 | + | } | |
| 172 | + | repo := initWorktreeTestRepo(t) | |
| 173 | + | tool := newCreateWorktreeTestTool(repo) | |
| 174 | + | for _, root := range []string{"relative-worktrees", filepath.Join(repo, "company-worktrees")} { | |
| 175 | + | _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": "feature/invalid", "bootstrap_root": root}), nil) | |
| 176 | + | if err == nil || !strings.Contains(err.Error(), "bootstrap_root") { | |
| 177 | + | t.Fatalf("bootstrap_root %q error = %v", root, err) | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |
| 181 | + | t.Fatalf("invalid bootstrap saved config: %#v", got) | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func TestCreateWorktreeRejectsBranchSyntaxThatGitCanInterpret(t *testing.T) { | |
| 186 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 187 | + | t.Skip("git is required") | |
| 188 | + | } | |
| 189 | + | repo := initWorktreeTestRepo(t) | |
| 190 | + | tool := newCreateWorktreeTestTool(repo) | |
| 191 | + | for _, branch := range []string{"-option", "topic@{1}"} { | |
| 192 | + | _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": branch}), nil) | |
| 193 | + | if err == nil || !strings.Contains(err.Error(), "invalid branch") { | |
| 194 | + | t.Fatalf("branch %q error = %v", branch, err) | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func TestCreateWorktreeRejectsConfiguredExternalRootResolvingInsideRepository(t *testing.T) { | |
| 200 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 201 | + | t.Skip("git is required") | |
| 202 | + | } | |
| 203 | + | repo := initWorktreeTestRepo(t) | |
| 204 | + | link := filepath.Join(t.TempDir(), "repository-link") | |
| 205 | + | if err := os.Symlink(repo, link); err != nil { | |
| 206 | + | t.Skipf("symbolic links are unavailable: %v", err) | |
| 207 | + | } | |
| 208 | + | root := filepath.Join(link, "external-worktrees") | |
| 209 | + | gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, root) | |
| 210 | + | tool := newCreateWorktreeTestTool(repo) | |
| 211 | + | ||
| 212 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/inside-repository"}`), nil) | |
| 213 | + | if err == nil || !strings.Contains(err.Error(), "external worktree root must be outside the repository") { | |
| 214 | + | t.Fatalf("error = %v, want external-root error", err) | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func TestCreateWorktreeDisablesPostCheckoutHook(t *testing.T) { | |
| 219 | + | if runtime.GOOS == "windows" { | |
| 220 | + | t.Skip("Git hook fixture uses a POSIX shell") | |
| 221 | + | } | |
| 222 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 223 | + | t.Skip("git is required") | |
| 224 | + | } | |
| 225 | + | repo := initWorktreeTestRepo(t) | |
| 226 | + | if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil { | |
| 227 | + | t.Fatal(err) | |
| 228 | + | } | |
| 229 | + | sentinel := filepath.Join(repo, "hook-ran") | |
| 230 | + | hook := filepath.Join(repo, ".git", "hooks", "post-checkout") | |
| 231 | + | script := "#!/bin/sh\ntouch " + shellQuoteForTest(sentinel) + "\n" | |
| 232 | + | if err := os.WriteFile(hook, []byte(script), 0o755); err != nil { | |
| 233 | + | t.Fatal(err) | |
| 234 | + | } | |
| 235 | + | tool := newCreateWorktreeTestTool(repo) | |
| 236 | + | ||
| 237 | + | if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-hook"}`), nil); err != nil { | |
| 238 | + | t.Fatal(err) | |
| 239 | + | } | |
| 240 | + | worktree := filepath.Join(repo, ".worktrees", "feature", "no-hook") | |
| 241 | + | if info, err := os.Stat(worktree); err != nil || !info.IsDir() { | |
| 242 | + | t.Fatalf("worktree %q was not created: %v", worktree, err) | |
| 243 | + | } | |
| 244 | + | if _, err := os.Stat(sentinel); !os.IsNotExist(err) { | |
| 245 | + | t.Fatalf("post-checkout hook ran: %v", err) | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | func TestCreateWorktreeCleanupRemovesPartialCheckoutAndBranch(t *testing.T) { | |
| 250 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 251 | + | t.Skip("git is required") | |
| 252 | + | } | |
| 253 | + | repo := initWorktreeTestRepo(t) | |
| 254 | + | worktreePath := filepath.Join(repo, ".worktrees", "partial") | |
| 255 | + | if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil { | |
| 256 | + | t.Fatal(err) | |
| 257 | + | } | |
| 258 | + | base := gitTestOutput(t, repo, "rev-parse", "HEAD") | |
| 259 | + | gitTestOutput(t, repo, "worktree", "add", "-b", "feature/partial", worktreePath, base) | |
| 260 | + | plan := createWorktreePlan{ | |
| 261 | + | repoRoot: repo, | |
| 262 | + | branch: "feature/partial", | |
| 263 | + | base: base, | |
| 264 | + | worktreePath: worktreePath, | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | if err := plan.cleanupFailedCheckout(context.Background()); err != nil { | |
| 268 | + | t.Fatal(err) | |
| 269 | + | } | |
| 270 | + | if _, err := os.Stat(worktreePath); !os.IsNotExist(err) { | |
| 271 | + | t.Fatalf("partial worktree remains: %v", err) | |
| 272 | + | } | |
| 273 | + | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/partial"); got.exitCode != 1 { | |
| 274 | + | t.Fatalf("partial branch remains: %#v", got) | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | func TestExecuteCreateWorktreePlanRemovesCreatedParentsOnCheckoutFailure(t *testing.T) { | |
| 279 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 280 | + | t.Skip("git is required") | |
| 281 | + | } | |
| 282 | + | repo := initWorktreeTestRepo(t) | |
| 283 | + | branch := "feature/checkout-failure" | |
| 284 | + | plan := createWorktreePlan{ | |
| 285 | + | repoRoot: repo, | |
| 286 | + | branch: branch, | |
| 287 | + | base: "not-a-commit", | |
| 288 | + | worktreePath: filepath.Join(repo, defaultWorktreeRoot, "feature", "checkout-failure"), | |
| 289 | + | configValue: defaultWorktreeRoot, | |
| 290 | + | setConfig: true, | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | _, err := executeCreateWorktreePlan(context.Background(), plan, nil) | |
| 294 | + | if err == nil || !strings.Contains(err.Error(), "create worktree") { | |
| 295 | + | t.Fatalf("error = %v, want worktree creation error", err) | |
| 296 | + | } | |
| 297 | + | if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot)); !os.IsNotExist(err) { | |
| 298 | + | t.Fatalf("failed checkout left worktree directories: %v", err) | |
| 299 | + | } | |
| 300 | + | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |
| 301 | + | t.Fatalf("failed checkout kept local config: %#v", got) | |
| 302 | + | } | |
| 303 | + | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/"+branch); got.exitCode != 1 { | |
| 304 | + | t.Fatalf("failed checkout created branch: %#v", got) | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | func TestExecuteCreateWorktreePlanRollsBackBootstrapConfigOnIgnoreFailure(t *testing.T) { | |
| 309 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 310 | + | t.Skip("git is required") | |
| 311 | + | } | |
| 312 | + | repo := initWorktreeTestRepo(t) | |
| 313 | + | ignorePath := filepath.Join(repo, ".gitignore") | |
| 314 | + | before := []byte("generated/\n") | |
| 315 | + | if err := os.WriteFile(ignorePath, before, 0o644); err != nil { | |
| 316 | + | t.Fatal(err) | |
| 317 | + | } | |
| 318 | + | if err := os.WriteFile(ignorePath, []byte("changed by another process\n"), 0o644); err != nil { | |
| 319 | + | t.Fatal(err) | |
| 320 | + | } | |
| 321 | + | base := gitTestOutput(t, repo, "rev-parse", "HEAD") | |
| 322 | + | plan := createWorktreePlan{ | |
| 323 | + | repoRoot: repo, | |
| 324 | + | branch: "feature/ignore-failure", | |
| 325 | + | base: base, | |
| 326 | + | worktreePath: filepath.Join(repo, ".worktrees", "feature", "ignore-failure"), | |
| 327 | + | configValue: defaultWorktreeRoot, | |
| 328 | + | setConfig: true, | |
| 329 | + | ignore: worktreesIgnoreUpdate{ | |
| 330 | + | path: ignorePath, | |
| 331 | + | before: before, | |
| 332 | + | after: append(append([]byte(nil), before...), []byte(worktreesIgnoreEntry+"\n")...), | |
| 333 | + | existed: true, | |
| 334 | + | mode: 0o644, | |
| 335 | + | changed: true, | |
| 336 | + | }, | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | _, err := executeCreateWorktreePlan(context.Background(), plan, nil) | |
| 340 | + | if err == nil || !strings.Contains(err.Error(), ".gitignore changed since preview") { | |
| 341 | + | t.Fatalf("error = %v, want stale .gitignore error", err) | |
| 342 | + | } | |
| 343 | + | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |
| 344 | + | t.Fatalf("failed bootstrap kept local config: %#v", got) | |
| 345 | + | } | |
| 346 | + | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/ignore-failure"); got.exitCode != 1 { | |
| 347 | + | t.Fatalf("failed bootstrap created branch: %#v", got) | |
| 348 | + | } | |
| 349 | + | contents, err := os.ReadFile(ignorePath) | |
| 350 | + | if err != nil { | |
| 351 | + | t.Fatal(err) | |
| 352 | + | } | |
| 353 | + | if got, want := string(contents), "changed by another process\n"; got != want { | |
| 354 | + | t.Fatalf(".gitignore = %q, want %q", got, want) | |
| 355 | + | } | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | func TestCreateWorktreePreservesGitignoreLineEndings(t *testing.T) { | |
| 359 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 360 | + | t.Skip("git is required") | |
| 361 | + | } | |
| 362 | + | repo := initWorktreeTestRepo(t) | |
| 363 | + | ignorePath := filepath.Join(repo, ".gitignore") | |
| 364 | + | if err := os.WriteFile(ignorePath, []byte("generated/\r\n"), 0o644); err != nil { | |
| 365 | + | t.Fatal(err) | |
| 366 | + | } | |
| 367 | + | tool := newCreateWorktreeTestTool(repo) | |
| 368 | + | ||
| 369 | + | if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/crlf","bootstrap_root":".worktrees"}`), nil); err != nil { | |
| 370 | + | t.Fatal(err) | |
| 371 | + | } | |
| 372 | + | ignore, err := os.ReadFile(ignorePath) | |
| 373 | + | if err != nil { | |
| 374 | + | t.Fatal(err) | |
| 375 | + | } | |
| 376 | + | if got, want := string(ignore), "generated/\r\n/.worktrees/\r\n"; got != want { | |
| 377 | + | t.Fatalf(".gitignore = %q, want %q", got, want) | |
| 378 | + | } | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | func TestCreateWorktreeRejectsGitignoreSymbolicLink(t *testing.T) { | |
| 382 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 383 | + | t.Skip("git is required") | |
| 384 | + | } | |
| 385 | + | repo := initWorktreeTestRepo(t) | |
| 386 | + | target := filepath.Join(repo, "ignore-target") | |
| 387 | + | if err := os.WriteFile(target, []byte("generated/\n"), 0o644); err != nil { | |
| 388 | + | t.Fatal(err) | |
| 389 | + | } | |
| 390 | + | ignorePath := filepath.Join(repo, ".gitignore") | |
| 391 | + | if err := os.Symlink(target, ignorePath); err != nil { | |
| 392 | + | t.Skipf("symbolic links are unavailable: %v", err) | |
| 393 | + | } | |
| 394 | + | tool := newCreateWorktreeTestTool(repo) | |
| 395 | + | ||
| 396 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/ignore-symlink","bootstrap_root":".worktrees"}`), nil) | |
| 397 | + | if err == nil || !strings.Contains(err.Error(), ".gitignore must not be a symbolic link") { | |
| 398 | + | t.Fatalf("error = %v, want symbolic-link error", err) | |
| 399 | + | } | |
| 400 | + | info, err := os.Lstat(ignorePath) | |
| 401 | + | if err != nil { | |
| 402 | + | t.Fatal(err) | |
| 403 | + | } | |
| 404 | + | if info.Mode()&os.ModeSymlink == 0 { | |
| 405 | + | t.Fatal("tool replaced .gitignore symbolic link") | |
| 406 | + | } | |
| 407 | + | if _, statErr := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(statErr) { | |
| 408 | + | t.Fatalf("symbolic-link rejection created .worktrees: %v", statErr) | |
| 409 | + | } | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | func TestCreateWorktreeRejectsNonRegularGitignore(t *testing.T) { | |
| 413 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 414 | + | t.Skip("git is required") | |
| 415 | + | } | |
| 416 | + | repo := initWorktreeTestRepo(t) | |
| 417 | + | if err := os.Mkdir(filepath.Join(repo, ".gitignore"), 0o755); err != nil { | |
| 418 | + | t.Fatal(err) | |
| 419 | + | } | |
| 420 | + | tool := newCreateWorktreeTestTool(repo) | |
| 421 | + | ||
| 422 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/nonregular","bootstrap_root":".worktrees"}`), nil) | |
| 423 | + | if err == nil || !strings.Contains(err.Error(), ".gitignore must be a regular file") { | |
| 424 | + | t.Fatalf("error = %v, want regular-file error", err) | |
| 425 | + | } | |
| 426 | + | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |
| 427 | + | t.Fatalf("non-regular ignore saved config: %#v", got) | |
| 428 | + | } | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | func TestCreateWorktreeGitOutputKeepsStderrOutOfSuccessfulOutput(t *testing.T) { | |
| 432 | + | if runtime.GOOS == "windows" { | |
| 433 | + | t.Skip("Git fixture uses a POSIX shell") | |
| 434 | + | } | |
| 435 | + | binDir := t.TempDir() | |
| 436 | + | gitPath := filepath.Join(binDir, "git") | |
| 437 | + | if err := os.WriteFile(gitPath, []byte("#!/bin/sh\nprintf 'value\\n'\nprintf 'warning\\n' >&2\n"), 0o755); err != nil { | |
| 438 | + | t.Fatal(err) | |
| 439 | + | } | |
| 440 | + | t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) | |
| 441 | + | ||
| 442 | + | output, err := createWorktreeGitOutput(context.Background(), t.TempDir(), "status") | |
| 443 | + | if err != nil { | |
| 444 | + | t.Fatal(err) | |
| 445 | + | } | |
| 446 | + | if output != "value" { | |
| 447 | + | t.Fatalf("output = %q, want value", output) | |
| 448 | + | } | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | func TestCreateWorktreeGitCancelsDescendants(t *testing.T) { | |
| 452 | + | if runtime.GOOS == "windows" { | |
| 453 | + | t.Skip("process-group fixture uses a POSIX shell") | |
| 454 | + | } | |
| 455 | + | binDir := t.TempDir() | |
| 456 | + | started := filepath.Join(t.TempDir(), "git-started") | |
| 457 | + | childPID := filepath.Join(t.TempDir(), "child-pid") | |
| 458 | + | gitPath := filepath.Join(binDir, "git") | |
| 459 | + | script := "#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\" > " + shellQuoteForTest(childPID) + "\nprintf started > " + shellQuoteForTest(started) + "\nwait\n" | |
| 460 | + | if err := os.WriteFile(gitPath, []byte(script), 0o755); err != nil { | |
| 461 | + | t.Fatal(err) | |
| 462 | + | } | |
| 463 | + | t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) | |
| 464 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 465 | + | defer cancel() | |
| 466 | + | cwd := t.TempDir() | |
| 467 | + | done := make(chan error, 1) | |
| 468 | + | go func() { | |
| 469 | + | _, err := createWorktreeGitOutput(ctx, cwd, "status") | |
| 470 | + | done <- err | |
| 471 | + | }() | |
| 472 | + | ||
| 473 | + | deadline := time.NewTimer(5 * time.Second) | |
| 474 | + | defer deadline.Stop() | |
| 475 | + | poll := time.NewTicker(10 * time.Millisecond) | |
| 476 | + | defer poll.Stop() | |
| 477 | + | for { | |
| 478 | + | if _, err := os.Stat(started); err == nil { | |
| 479 | + | break | |
| 480 | + | } else if !os.IsNotExist(err) { | |
| 481 | + | t.Fatal(err) | |
| 482 | + | } | |
| 483 | + | select { | |
| 484 | + | case <-deadline.C: | |
| 485 | + | t.Fatal("fake git did not start") | |
| 486 | + | case <-poll.C: | |
| 487 | + | } | |
| 488 | + | } | |
| 489 | + | cancel() | |
| 490 | + | select { | |
| 491 | + | case err := <-done: | |
| 492 | + | if !errors.Is(err, context.Canceled) { | |
| 493 | + | t.Fatalf("error = %v, want context canceled", err) | |
| 494 | + | } | |
| 495 | + | case <-time.After(5 * time.Second): | |
| 496 | + | t.Fatal("Git process group did not exit after cancellation") | |
| 497 | + | } | |
| 498 | + | pid, err := os.ReadFile(childPID) | |
| 499 | + | if err != nil { | |
| 500 | + | t.Fatal(err) | |
| 501 | + | } | |
| 502 | + | exitDeadline := time.NewTimer(5 * time.Second) | |
| 503 | + | defer exitDeadline.Stop() | |
| 504 | + | for { | |
| 505 | + | err := exec.Command("kill", "-0", strings.TrimSpace(string(pid))).Run() | |
| 506 | + | if err != nil { | |
| 507 | + | var exitErr *exec.ExitError | |
| 508 | + | if errors.As(err, &exitErr) { | |
| 509 | + | break | |
| 510 | + | } | |
| 511 | + | t.Fatalf("check child process: %v", err) | |
| 512 | + | } | |
| 513 | + | select { | |
| 514 | + | case <-exitDeadline.C: | |
| 515 | + | t.Fatalf("Git descendant %q remained after cancellation", pid) | |
| 516 | + | case <-poll.C: | |
| 517 | + | } | |
| 518 | + | } | |
| 519 | + | } | |
| 520 | + | ||
| 521 | + | func TestCreateWorktreePreviewDoesNotModifyRepository(t *testing.T) { | |
| 522 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 523 | + | t.Skip("git is required") | |
| 524 | + | } | |
| 525 | + | repo := initWorktreeTestRepo(t) | |
| 526 | + | tool := newCreateWorktreeTestTool(repo) | |
| 527 | + | args := json.RawMessage(`{"branch":"feature/preview"}`) | |
| 528 | + | ||
| 529 | + | preview, err := tool.Preview(context.Background(), args) | |
| 530 | + | if err != nil { | |
| 531 | + | t.Fatal(err) | |
| 532 | + | } | |
| 533 | + | text := preview.Content[0].(provider.TextBlock).Text | |
| 534 | + | for _, want := range []string{"Worktree bootstrap is required", "feature/preview", "bootstrap_root"} { | |
| 535 | + | if !strings.Contains(text, want) { | |
| 536 | + | t.Fatalf("preview missing %q:\n%s", want, text) | |
| 537 | + | } | |
| 538 | + | } | |
| 539 | + | if _, err := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(err) { | |
| 540 | + | t.Fatalf("preview created .worktrees: %v", err) | |
| 541 | + | } | |
| 542 | + | if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) { | |
| 543 | + | t.Fatalf("preview created .gitignore: %v", err) | |
| 544 | + | } | |
| 545 | + | cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/preview") | |
| 546 | + | if err := cmd.Run(); err == nil { | |
| 547 | + | t.Fatal("preview created the branch") | |
| 548 | + | } | |
| 549 | + | } | |
| 550 | + | ||
| 551 | + | func TestCreateWorktreeRejectsStateChangedAfterPreview(t *testing.T) { | |
| 552 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 553 | + | t.Skip("git is required") | |
| 554 | + | } | |
| 555 | + | repo := initWorktreeTestRepo(t) | |
| 556 | + | if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil { | |
| 557 | + | t.Fatal(err) | |
| 558 | + | } | |
| 559 | + | tool := newCreateWorktreeTestTool(repo) | |
| 560 | + | args := json.RawMessage(`{"branch":"feature/stale-preview"}`) | |
| 561 | + | if _, err := tool.Preview(context.Background(), args); err != nil { | |
| 562 | + | t.Fatal(err) | |
| 563 | + | } | |
| 564 | + | if err := os.WriteFile(filepath.Join(repo, "after-preview.txt"), []byte("changed\n"), 0o644); err != nil { | |
| 565 | + | t.Fatal(err) | |
| 566 | + | } | |
| 567 | + | gitTestOutput(t, repo, "add", "after-preview.txt") | |
| 568 | + | gitTestOutput(t, repo, "commit", "-qm", "change after preview") | |
| 569 | + | ||
| 570 | + | _, err := tool.Execute(context.Background(), args, nil) | |
| 571 | + | if err == nil || !strings.Contains(err.Error(), "state changed since preview") { | |
| 572 | + | t.Fatalf("error = %v, want stale-preview error", err) | |
| 573 | + | } | |
| 574 | + | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/stale-preview"); got.exitCode != 1 { | |
| 575 | + | t.Fatalf("stale preview created branch: %#v", got) | |
| 576 | + | } | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | func TestCreateWorktreeRejectsNonRepository(t *testing.T) { | |
| 580 | + | dir := t.TempDir() | |
| 581 | + | tool := newCreateWorktreeTestTool(dir) | |
| 582 | + | ||
| 583 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/new"}`), nil) | |
| 584 | + | if err == nil || !strings.Contains(err.Error(), "not a Git repository") { | |
| 585 | + | t.Fatalf("error = %v, want repository error", err) | |
| 586 | + | } | |
| 587 | + | for _, path := range []string{".worktrees", ".gitignore"} { | |
| 588 | + | if _, statErr := os.Stat(filepath.Join(dir, path)); !os.IsNotExist(statErr) { | |
| 589 | + | t.Fatalf("non-repository call created %s: %v", path, statErr) | |
| 590 | + | } | |
| 591 | + | } | |
| 592 | + | } | |
| 593 | + | ||
| 594 | + | func TestCreateWorktreeRequiresSandbox(t *testing.T) { | |
| 595 | + | tool := &CreateWorktreeTool{CWD: t.TempDir()} | |
| 596 | + | ||
| 597 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-sandbox"}`), nil) | |
| 598 | + | if err == nil || !strings.Contains(err.Error(), "sandbox is required") { | |
| 599 | + | t.Fatalf("error = %v, want sandbox error", err) | |
| 600 | + | } | |
| 601 | + | } | |
| 602 | + | ||
| 603 | + | func TestCreateWorktreeRejectsExistingBranchWithoutChangingGitignore(t *testing.T) { | |
| 604 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 605 | + | t.Skip("git is required") | |
| 606 | + | } | |
| 607 | + | repo := initWorktreeTestRepo(t) | |
| 608 | + | tool := newCreateWorktreeTestTool(repo) | |
| 609 | + | bootstrapArgs := json.RawMessage(`{"branch":"feature/existing","bootstrap_root":".worktrees"}`) | |
| 610 | + | if _, err := tool.Execute(context.Background(), bootstrapArgs, nil); err != nil { | |
| 611 | + | t.Fatal(err) | |
| 612 | + | } | |
| 613 | + | ignoreBefore, err := os.ReadFile(filepath.Join(repo, ".gitignore")) | |
| 614 | + | if err != nil { | |
| 615 | + | t.Fatal(err) | |
| 616 | + | } | |
| 617 | + | ||
| 618 | + | _, err = tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/existing"}`), nil) | |
| 619 | + | if err == nil || !strings.Contains(err.Error(), "already exists") { | |
| 620 | + | t.Fatalf("error = %v, want collision error", err) | |
| 621 | + | } | |
| 622 | + | ignoreAfter, err := os.ReadFile(filepath.Join(repo, ".gitignore")) | |
| 623 | + | if err != nil { | |
| 624 | + | t.Fatal(err) | |
| 625 | + | } | |
| 626 | + | if string(ignoreAfter) != string(ignoreBefore) { | |
| 627 | + | t.Fatalf("collision changed .gitignore:\nbefore: %q\nafter: %q", ignoreBefore, ignoreAfter) | |
| 628 | + | } | |
| 629 | + | } | |
| 630 | + | ||
| 631 | + | func TestCreateWorktreeRespectsJail(t *testing.T) { | |
| 632 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 633 | + | t.Skip("git is required") | |
| 634 | + | } | |
| 635 | + | repo := initWorktreeTestRepo(t) | |
| 636 | + | nested := filepath.Join(repo, "nested") | |
| 637 | + | if err := os.Mkdir(nested, 0o755); err != nil { | |
| 638 | + | t.Fatal(err) | |
| 639 | + | } | |
| 640 | + | sandbox := NewSandbox(nested) | |
| 641 | + | sandbox.Lock() | |
| 642 | + | tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox} | |
| 643 | + | ||
| 644 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed"}`), nil) | |
| 645 | + | if err == nil || !strings.Contains(err.Error(), "jailed:") { | |
| 646 | + | t.Fatalf("error = %v, want jailed error", err) | |
| 647 | + | } | |
| 648 | + | for _, path := range []string{".worktrees", ".gitignore"} { | |
| 649 | + | if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) { | |
| 650 | + | t.Fatalf("jailed call created %s: %v", path, statErr) | |
| 651 | + | } | |
| 652 | + | } | |
| 653 | + | } | |
| 654 | + | ||
| 655 | + | func TestCreateWorktreeRejectsJailedAncestorBeforeGitDiscovery(t *testing.T) { | |
| 656 | + | if runtime.GOOS == "windows" { | |
| 657 | + | t.Skip("Git invocation fixture uses a POSIX shell") | |
| 658 | + | } | |
| 659 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 660 | + | t.Skip("git is required") | |
| 661 | + | } | |
| 662 | + | repo := initWorktreeTestRepo(t) | |
| 663 | + | nested := filepath.Join(repo, "nested") | |
| 664 | + | if err := os.Mkdir(nested, 0o755); err != nil { | |
| 665 | + | t.Fatal(err) | |
| 666 | + | } | |
| 667 | + | sentinel := filepath.Join(repo, "git-was-invoked") | |
| 668 | + | binDir := t.TempDir() | |
| 669 | + | fakeGit := filepath.Join(binDir, "git") | |
| 670 | + | if err := os.WriteFile(fakeGit, []byte("#!/bin/sh\ntouch "+shellQuoteForTest(sentinel)+"\nexit 1\n"), 0o755); err != nil { | |
| 671 | + | t.Fatal(err) | |
| 672 | + | } | |
| 673 | + | t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) | |
| 674 | + | sandbox := NewSandbox(nested) | |
| 675 | + | sandbox.Lock() | |
| 676 | + | tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox} | |
| 677 | + | ||
| 678 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed-discovery"}`), nil) | |
| 679 | + | if err == nil || !strings.Contains(err.Error(), "jailed:") { | |
| 680 | + | t.Fatalf("error = %v, want jailed error", err) | |
| 681 | + | } | |
| 682 | + | if _, err := os.Stat(sentinel); !os.IsNotExist(err) { | |
| 683 | + | t.Fatalf("Git ran before jailed ancestor was rejected: %v", err) | |
| 684 | + | } | |
| 685 | + | } | |
| 686 | + | ||
| 687 | + | func TestCreateWorktreeRequiresFilesystemPermissions(t *testing.T) { | |
| 688 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 689 | + | t.Skip("git is required") | |
| 690 | + | } | |
| 691 | + | repo := initWorktreeTestRepo(t) | |
| 692 | + | sandbox := NewSandbox(repo) | |
| 693 | + | permissions := &PermissionSet{} | |
| 694 | + | permissions.Bash.Mode = "allowlist" | |
| 695 | + | permissions.Bash.Allow = []string{"git"} | |
| 696 | + | sandbox.SetPermissions(permissions) | |
| 697 | + | tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox} | |
| 698 | + | ||
| 699 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/restricted"}`), nil) | |
| 700 | + | if err == nil || !strings.Contains(err.Error(), "no filesystem read permission") { | |
| 701 | + | t.Fatalf("error = %v, want filesystem permission error", err) | |
| 702 | + | } | |
| 703 | + | for _, path := range []string{".worktrees", ".gitignore"} { | |
| 704 | + | if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) { | |
| 705 | + | t.Fatalf("restricted call created %s: %v", path, statErr) | |
| 706 | + | } | |
| 707 | + | } | |
| 708 | + | } | |
| 709 | + | ||
| 710 | + | func TestCreateWorktreeRequiresGitBashPermission(t *testing.T) { | |
| 711 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 712 | + | t.Skip("git is required") | |
| 713 | + | } | |
| 714 | + | repo := initWorktreeTestRepo(t) | |
| 715 | + | sandbox := NewSandbox(repo) | |
| 716 | + | permissions := &PermissionSet{} | |
| 717 | + | permissions.FS.Read = []string{repo} | |
| 718 | + | permissions.FS.Write = []string{repo} | |
| 719 | + | sandbox.SetPermissions(permissions) | |
| 720 | + | tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox} | |
| 721 | + | ||
| 722 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-git"}`), nil) | |
| 723 | + | if err == nil || !strings.Contains(err.Error(), "no bash permission") { | |
| 724 | + | t.Fatalf("error = %v, want git permission error", err) | |
| 725 | + | } | |
| 726 | + | for _, path := range []string{".worktrees", ".gitignore"} { | |
| 727 | + | if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) { | |
| 728 | + | t.Fatalf("restricted call created %s: %v", path, statErr) | |
| 729 | + | } | |
| 730 | + | } | |
| 731 | + | } | |
| 732 | + | ||
| 733 | + | func TestCreateWorktreeRejectsInvalidWorktreePathBeforeGitignoreUpdate(t *testing.T) { | |
| 734 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 735 | + | t.Skip("git is required") | |
| 736 | + | } | |
| 737 | + | repo := initWorktreeTestRepo(t) | |
| 738 | + | worktrees := filepath.Join(repo, ".worktrees") | |
| 739 | + | if err := os.Mkdir(worktrees, 0o755); err != nil { | |
| 740 | + | t.Fatal(err) | |
| 741 | + | } | |
| 742 | + | if err := os.WriteFile(filepath.Join(worktrees, "blocked"), []byte("not a directory"), 0o644); err != nil { | |
| 743 | + | t.Fatal(err) | |
| 744 | + | } | |
| 745 | + | tool := newCreateWorktreeTestTool(repo) | |
| 746 | + | ||
| 747 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"blocked/new"}`), nil) | |
| 748 | + | if err == nil || !strings.Contains(err.Error(), "not a directory") { | |
| 749 | + | t.Fatalf("error = %v, want non-directory path error", err) | |
| 750 | + | } | |
| 751 | + | if _, statErr := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(statErr) { | |
| 752 | + | t.Fatalf("invalid path left .gitignore behind: %v", statErr) | |
| 753 | + | } | |
| 754 | + | cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/blocked/new") | |
| 755 | + | if err := cmd.Run(); err == nil { | |
| 756 | + | t.Fatal("invalid path created the branch") | |
| 757 | + | } | |
| 758 | + | } | |
| 759 | + | ||
| 760 | + | func newCreateWorktreeTestTool(cwd string) *CreateWorktreeTool { | |
| 761 | + | return &CreateWorktreeTool{CWD: cwd, Sandbox: NewSandbox(cwd)} | |
| 762 | + | } | |
| 763 | + | ||
| 764 | + | func initWorktreeTestRepo(t *testing.T) string { | |
| 765 | + | t.Helper() | |
| 766 | + | t.Setenv("GIT_CONFIG_NOSYSTEM", "1") | |
| 767 | + | t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull) | |
| 768 | + | t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) | |
| 769 | + | repo := t.TempDir() | |
| 770 | + | gitTestOutput(t, repo, "init", "-q") | |
| 771 | + | gitTestOutput(t, repo, "config", "user.email", "test@example.invalid") | |
| 772 | + | gitTestOutput(t, repo, "config", "user.name", "Test User") | |
| 773 | + | if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("before\n"), 0o644); err != nil { | |
| 774 | + | t.Fatal(err) | |
| 775 | + | } | |
| 776 | + | gitTestOutput(t, repo, "add", "README.md") | |
| 777 | + | gitTestOutput(t, repo, "commit", "-qm", "initial") | |
| 778 | + | return repo | |
| 779 | + | } | |
| 780 | + | ||
| 781 | + | func shellQuoteForTest(value string) string { | |
| 782 | + | return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" | |
| 783 | + | } | |
| 784 | + | ||
| 785 | + | type gitTestResult struct { | |
| 786 | + | output string | |
| 787 | + | exitCode int | |
| 788 | + | } | |
| 789 | + | ||
| 790 | + | func gitTestOutputAllowExit(t *testing.T, dir string, args ...string) gitTestResult { | |
| 791 | + | t.Helper() | |
| 792 | + | cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) | |
| 793 | + | output, err := cmd.CombinedOutput() | |
| 794 | + | if err == nil { | |
| 795 | + | return gitTestResult{output: strings.TrimSpace(string(output))} | |
| 796 | + | } | |
| 797 | + | if exitErr, ok := err.(*exec.ExitError); ok { | |
| 798 | + | return gitTestResult{output: strings.TrimSpace(string(output)), exitCode: exitErr.ExitCode()} | |
| 799 | + | } | |
| 800 | + | t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output) | |
| 801 | + | return gitTestResult{} | |
| 802 | + | } | |
| 803 | + | ||
| 804 | + | func gitTestOutput(t *testing.T, dir string, args ...string) string { | |
| 805 | + | t.Helper() | |
| 806 | + | cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) | |
| 807 | + | output, err := cmd.CombinedOutput() | |
| 808 | + | if err != nil { | |
| 809 | + | t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output) | |
| 810 | + | } | |
| 811 | + | return strings.TrimSpace(string(output)) | |
| 812 | + | } | |
| @@ -0,0 +1,812 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "os" | |||
| 8 | + | "os/exec" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "runtime" | |||
| 11 | + | "strings" | |||
| 12 | + | "testing" | |||
| 13 | + | "time" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/zut/packages/provider" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func TestCreateWorktreeBootstrapsDefaultAtRepositoryRoot(t *testing.T) { | |||
| 19 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 20 | + | t.Skip("git is required") | |||
| 21 | + | } | |||
| 22 | + | repo := initWorktreeTestRepo(t) | |||
| 23 | + | nested := filepath.Join(repo, "nested", "dir") | |||
| 24 | + | if err := os.MkdirAll(nested, 0o755); err != nil { | |||
| 25 | + | t.Fatal(err) | |||
| 26 | + | } | |||
| 27 | + | if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("*.local\n"), 0o644); err != nil { | |||
| 28 | + | t.Fatal(err) | |||
| 29 | + | } | |||
| 30 | + | branchBefore := gitTestOutput(t, repo, "branch", "--show-current") | |||
| 31 | + | sandbox := NewSandbox(repo) | |||
| 32 | + | sandbox.Lock() | |||
| 33 | + | ||||
| 34 | + | tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox} | |||
| 35 | + | args, err := json.Marshal(map[string]string{"branch": "feature/new-checkout", "bootstrap_root": ".worktrees"}) | |||
| 36 | + | if err != nil { | |||
| 37 | + | t.Fatal(err) | |||
| 38 | + | } | |||
| 39 | + | if _, err := tool.Execute(context.Background(), args, nil); err != nil { | |||
| 40 | + | t.Fatal(err) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | worktree := filepath.Join(repo, ".worktrees", "feature", "new-checkout") | |||
| 44 | + | if info, err := os.Stat(worktree); err != nil || !info.IsDir() { | |||
| 45 | + | t.Fatalf("worktree %q was not created: %v", worktree, err) | |||
| 46 | + | } | |||
| 47 | + | if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/new-checkout" { | |||
| 48 | + | t.Fatalf("worktree branch = %q, want %q", got, "feature/new-checkout") | |||
| 49 | + | } | |||
| 50 | + | if got := gitTestOutput(t, repo, "branch", "--show-current"); got != branchBefore { | |||
| 51 | + | t.Fatalf("source branch changed from %q to %q", branchBefore, got) | |||
| 52 | + | } | |||
| 53 | + | ignore, err := os.ReadFile(filepath.Join(repo, ".gitignore")) | |||
| 54 | + | if err != nil { | |||
| 55 | + | t.Fatal(err) | |||
| 56 | + | } | |||
| 57 | + | if count := strings.Count(string(ignore), "/.worktrees/\n"); count != 1 { | |||
| 58 | + | t.Fatalf("/.worktrees/ ignore entries = %d, want 1:\n%s", count, ignore) | |||
| 59 | + | } | |||
| 60 | + | if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != defaultWorktreeRoot { | |||
| 61 | + | t.Fatalf("saved worktree root = %q, want %q", got, defaultWorktreeRoot) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestCreateWorktreeRequiresBootstrapForUnconfiguredRepository(t *testing.T) { | |||
| 66 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 67 | + | t.Skip("git is required") | |||
| 68 | + | } | |||
| 69 | + | repo := initWorktreeTestRepo(t) | |||
| 70 | + | tool := newCreateWorktreeTestTool(repo) | |||
| 71 | + | ||||
| 72 | + | result, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/needs-bootstrap"}`), nil) | |||
| 73 | + | if err != nil { | |||
| 74 | + | t.Fatal(err) | |||
| 75 | + | } | |||
| 76 | + | text := result.Content[0].(provider.TextBlock).Text | |||
| 77 | + | if !strings.Contains(text, "Worktree bootstrap is required") { | |||
| 78 | + | t.Fatalf("bootstrap result = %q", text) | |||
| 79 | + | } | |||
| 80 | + | if state, _ := result.Details.(map[string]any)["state"].(string); state != "bootstrap_required" { | |||
| 81 | + | t.Fatalf("bootstrap state = %q", state) | |||
| 82 | + | } | |||
| 83 | + | for _, path := range []string{".worktrees", ".gitignore"} { | |||
| 84 | + | if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) { | |||
| 85 | + | t.Fatalf("bootstrap created %s: %v", path, statErr) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |||
| 89 | + | t.Fatalf("local worktree config exit = %d, want 1", got.exitCode) | |||
| 90 | + | } | |||
| 91 | + | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/needs-bootstrap"); got.exitCode != 1 { | |||
| 92 | + | t.Fatalf("bootstrap created branch: %#v", got) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestCreateWorktreeRejectsBootstrapRootWhenDefaultRootExists(t *testing.T) { | |||
| 97 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 98 | + | t.Skip("git is required") | |||
| 99 | + | } | |||
| 100 | + | repo := initWorktreeTestRepo(t) | |||
| 101 | + | if err := os.Mkdir(filepath.Join(repo, defaultWorktreeRoot), 0o755); err != nil { | |||
| 102 | + | t.Fatal(err) | |||
| 103 | + | } | |||
| 104 | + | tool := newCreateWorktreeTestTool(repo) | |||
| 105 | + | ||||
| 106 | + | _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{ | |||
| 107 | + | "branch": "feature/conflicting-bootstrap", | |||
| 108 | + | "bootstrap_root": t.TempDir(), | |||
| 109 | + | }), nil) | |||
| 110 | + | if err == nil || !strings.Contains(err.Error(), "already established") { | |||
| 111 | + | t.Fatalf("error = %v, want established-root error", err) | |||
| 112 | + | } | |||
| 113 | + | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |||
| 114 | + | t.Fatalf("conflicting bootstrap saved config: %#v", got) | |||
| 115 | + | } | |||
| 116 | + | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/conflicting-bootstrap"); got.exitCode != 1 { | |||
| 117 | + | t.Fatalf("conflicting bootstrap created branch: %#v", got) | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing.T) { | |||
| 122 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 123 | + | t.Skip("git is required") | |||
| 124 | + | } | |||
| 125 | + | repo := initWorktreeTestRepo(t) | |||
| 126 | + | externalRoot, err := canonicalOrParent(filepath.Join(t.TempDir(), "company-worktrees")) | |||
| 127 | + | if err != nil { | |||
| 128 | + | t.Fatal(err) | |||
| 129 | + | } | |||
| 130 | + | tool := newCreateWorktreeTestTool(repo) | |||
| 131 | + | args := mustJSON(t, map[string]string{"branch": "feature/external", "bootstrap_root": externalRoot}) | |||
| 132 | + | ||||
| 133 | + | result, err := tool.Execute(context.Background(), args, nil) | |||
| 134 | + | if err != nil { | |||
| 135 | + | t.Fatal(err) | |||
| 136 | + | } | |||
| 137 | + | worktree := filepath.Join(externalRoot, "feature", "external") | |||
| 138 | + | if info, err := os.Stat(worktree); err != nil || !info.IsDir() { | |||
| 139 | + | t.Fatalf("external worktree %q was not created: %v", worktree, err) | |||
| 140 | + | } | |||
| 141 | + | if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/external" { | |||
| 142 | + | t.Fatalf("external branch = %q", got) | |||
| 143 | + | } | |||
| 144 | + | if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != externalRoot { | |||
| 145 | + | t.Fatalf("saved external root = %q, want %q", got, externalRoot) | |||
| 146 | + | } | |||
| 147 | + | if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) { | |||
| 148 | + | t.Fatalf("external bootstrap changed .gitignore: %v", err) | |||
| 149 | + | } | |||
| 150 | + | if source, _ := result.Details.(map[string]any)["root_source"].(string); source != "bootstrap selection" { | |||
| 151 | + | t.Fatalf("root source = %q", source) | |||
| 152 | + | } | |||
| 153 | + | if state, _ := result.Details.(map[string]any)["state"].(string); state != "created" { | |||
| 154 | + | t.Fatalf("state = %q, want created", state) | |||
| 155 | + | } | |||
| 156 | + | followup, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/external-followup"}`), nil) | |||
| 157 | + | if err != nil { | |||
| 158 | + | t.Fatal(err) | |||
| 159 | + | } | |||
| 160 | + | if _, err := os.Stat(filepath.Join(externalRoot, "feature", "external-followup")); err != nil { | |||
| 161 | + | t.Fatalf("configured external root was not reused: %v", err) | |||
| 162 | + | } | |||
| 163 | + | if source, _ := followup.Details.(map[string]any)["root_source"].(string); source != "local Git config" { | |||
| 164 | + | t.Fatalf("follow-up root source = %q", source) | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func TestCreateWorktreeRejectsInvalidBootstrapRoot(t *testing.T) { | |||
| 169 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 170 | + | t.Skip("git is required") | |||
| 171 | + | } | |||
| 172 | + | repo := initWorktreeTestRepo(t) | |||
| 173 | + | tool := newCreateWorktreeTestTool(repo) | |||
| 174 | + | for _, root := range []string{"relative-worktrees", filepath.Join(repo, "company-worktrees")} { | |||
| 175 | + | _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": "feature/invalid", "bootstrap_root": root}), nil) | |||
| 176 | + | if err == nil || !strings.Contains(err.Error(), "bootstrap_root") { | |||
| 177 | + | t.Fatalf("bootstrap_root %q error = %v", root, err) | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |||
| 181 | + | t.Fatalf("invalid bootstrap saved config: %#v", got) | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func TestCreateWorktreeRejectsBranchSyntaxThatGitCanInterpret(t *testing.T) { | |||
| 186 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 187 | + | t.Skip("git is required") | |||
| 188 | + | } | |||
| 189 | + | repo := initWorktreeTestRepo(t) | |||
| 190 | + | tool := newCreateWorktreeTestTool(repo) | |||
| 191 | + | for _, branch := range []string{"-option", "topic@{1}"} { | |||
| 192 | + | _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": branch}), nil) | |||
| 193 | + | if err == nil || !strings.Contains(err.Error(), "invalid branch") { | |||
| 194 | + | t.Fatalf("branch %q error = %v", branch, err) | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func TestCreateWorktreeRejectsConfiguredExternalRootResolvingInsideRepository(t *testing.T) { | |||
| 200 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 201 | + | t.Skip("git is required") | |||
| 202 | + | } | |||
| 203 | + | repo := initWorktreeTestRepo(t) | |||
| 204 | + | link := filepath.Join(t.TempDir(), "repository-link") | |||
| 205 | + | if err := os.Symlink(repo, link); err != nil { | |||
| 206 | + | t.Skipf("symbolic links are unavailable: %v", err) | |||
| 207 | + | } | |||
| 208 | + | root := filepath.Join(link, "external-worktrees") | |||
| 209 | + | gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, root) | |||
| 210 | + | tool := newCreateWorktreeTestTool(repo) | |||
| 211 | + | ||||
| 212 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/inside-repository"}`), nil) | |||
| 213 | + | if err == nil || !strings.Contains(err.Error(), "external worktree root must be outside the repository") { | |||
| 214 | + | t.Fatalf("error = %v, want external-root error", err) | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | func TestCreateWorktreeDisablesPostCheckoutHook(t *testing.T) { | |||
| 219 | + | if runtime.GOOS == "windows" { | |||
| 220 | + | t.Skip("Git hook fixture uses a POSIX shell") | |||
| 221 | + | } | |||
| 222 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 223 | + | t.Skip("git is required") | |||
| 224 | + | } | |||
| 225 | + | repo := initWorktreeTestRepo(t) | |||
| 226 | + | if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil { | |||
| 227 | + | t.Fatal(err) | |||
| 228 | + | } | |||
| 229 | + | sentinel := filepath.Join(repo, "hook-ran") | |||
| 230 | + | hook := filepath.Join(repo, ".git", "hooks", "post-checkout") | |||
| 231 | + | script := "#!/bin/sh\ntouch " + shellQuoteForTest(sentinel) + "\n" | |||
| 232 | + | if err := os.WriteFile(hook, []byte(script), 0o755); err != nil { | |||
| 233 | + | t.Fatal(err) | |||
| 234 | + | } | |||
| 235 | + | tool := newCreateWorktreeTestTool(repo) | |||
| 236 | + | ||||
| 237 | + | if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-hook"}`), nil); err != nil { | |||
| 238 | + | t.Fatal(err) | |||
| 239 | + | } | |||
| 240 | + | worktree := filepath.Join(repo, ".worktrees", "feature", "no-hook") | |||
| 241 | + | if info, err := os.Stat(worktree); err != nil || !info.IsDir() { | |||
| 242 | + | t.Fatalf("worktree %q was not created: %v", worktree, err) | |||
| 243 | + | } | |||
| 244 | + | if _, err := os.Stat(sentinel); !os.IsNotExist(err) { | |||
| 245 | + | t.Fatalf("post-checkout hook ran: %v", err) | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | func TestCreateWorktreeCleanupRemovesPartialCheckoutAndBranch(t *testing.T) { | |||
| 250 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 251 | + | t.Skip("git is required") | |||
| 252 | + | } | |||
| 253 | + | repo := initWorktreeTestRepo(t) | |||
| 254 | + | worktreePath := filepath.Join(repo, ".worktrees", "partial") | |||
| 255 | + | if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil { | |||
| 256 | + | t.Fatal(err) | |||
| 257 | + | } | |||
| 258 | + | base := gitTestOutput(t, repo, "rev-parse", "HEAD") | |||
| 259 | + | gitTestOutput(t, repo, "worktree", "add", "-b", "feature/partial", worktreePath, base) | |||
| 260 | + | plan := createWorktreePlan{ | |||
| 261 | + | repoRoot: repo, | |||
| 262 | + | branch: "feature/partial", | |||
| 263 | + | base: base, | |||
| 264 | + | worktreePath: worktreePath, | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | if err := plan.cleanupFailedCheckout(context.Background()); err != nil { | |||
| 268 | + | t.Fatal(err) | |||
| 269 | + | } | |||
| 270 | + | if _, err := os.Stat(worktreePath); !os.IsNotExist(err) { | |||
| 271 | + | t.Fatalf("partial worktree remains: %v", err) | |||
| 272 | + | } | |||
| 273 | + | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/partial"); got.exitCode != 1 { | |||
| 274 | + | t.Fatalf("partial branch remains: %#v", got) | |||
| 275 | + | } | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | func TestExecuteCreateWorktreePlanRemovesCreatedParentsOnCheckoutFailure(t *testing.T) { | |||
| 279 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 280 | + | t.Skip("git is required") | |||
| 281 | + | } | |||
| 282 | + | repo := initWorktreeTestRepo(t) | |||
| 283 | + | branch := "feature/checkout-failure" | |||
| 284 | + | plan := createWorktreePlan{ | |||
| 285 | + | repoRoot: repo, | |||
| 286 | + | branch: branch, | |||
| 287 | + | base: "not-a-commit", | |||
| 288 | + | worktreePath: filepath.Join(repo, defaultWorktreeRoot, "feature", "checkout-failure"), | |||
| 289 | + | configValue: defaultWorktreeRoot, | |||
| 290 | + | setConfig: true, | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | _, err := executeCreateWorktreePlan(context.Background(), plan, nil) | |||
| 294 | + | if err == nil || !strings.Contains(err.Error(), "create worktree") { | |||
| 295 | + | t.Fatalf("error = %v, want worktree creation error", err) | |||
| 296 | + | } | |||
| 297 | + | if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot)); !os.IsNotExist(err) { | |||
| 298 | + | t.Fatalf("failed checkout left worktree directories: %v", err) | |||
| 299 | + | } | |||
| 300 | + | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |||
| 301 | + | t.Fatalf("failed checkout kept local config: %#v", got) | |||
| 302 | + | } | |||
| 303 | + | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/"+branch); got.exitCode != 1 { | |||
| 304 | + | t.Fatalf("failed checkout created branch: %#v", got) | |||
| 305 | + | } | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | func TestExecuteCreateWorktreePlanRollsBackBootstrapConfigOnIgnoreFailure(t *testing.T) { | |||
| 309 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 310 | + | t.Skip("git is required") | |||
| 311 | + | } | |||
| 312 | + | repo := initWorktreeTestRepo(t) | |||
| 313 | + | ignorePath := filepath.Join(repo, ".gitignore") | |||
| 314 | + | before := []byte("generated/\n") | |||
| 315 | + | if err := os.WriteFile(ignorePath, before, 0o644); err != nil { | |||
| 316 | + | t.Fatal(err) | |||
| 317 | + | } | |||
| 318 | + | if err := os.WriteFile(ignorePath, []byte("changed by another process\n"), 0o644); err != nil { | |||
| 319 | + | t.Fatal(err) | |||
| 320 | + | } | |||
| 321 | + | base := gitTestOutput(t, repo, "rev-parse", "HEAD") | |||
| 322 | + | plan := createWorktreePlan{ | |||
| 323 | + | repoRoot: repo, | |||
| 324 | + | branch: "feature/ignore-failure", | |||
| 325 | + | base: base, | |||
| 326 | + | worktreePath: filepath.Join(repo, ".worktrees", "feature", "ignore-failure"), | |||
| 327 | + | configValue: defaultWorktreeRoot, | |||
| 328 | + | setConfig: true, | |||
| 329 | + | ignore: worktreesIgnoreUpdate{ | |||
| 330 | + | path: ignorePath, | |||
| 331 | + | before: before, | |||
| 332 | + | after: append(append([]byte(nil), before...), []byte(worktreesIgnoreEntry+"\n")...), | |||
| 333 | + | existed: true, | |||
| 334 | + | mode: 0o644, | |||
| 335 | + | changed: true, | |||
| 336 | + | }, | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | _, err := executeCreateWorktreePlan(context.Background(), plan, nil) | |||
| 340 | + | if err == nil || !strings.Contains(err.Error(), ".gitignore changed since preview") { | |||
| 341 | + | t.Fatalf("error = %v, want stale .gitignore error", err) | |||
| 342 | + | } | |||
| 343 | + | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |||
| 344 | + | t.Fatalf("failed bootstrap kept local config: %#v", got) | |||
| 345 | + | } | |||
| 346 | + | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/ignore-failure"); got.exitCode != 1 { | |||
| 347 | + | t.Fatalf("failed bootstrap created branch: %#v", got) | |||
| 348 | + | } | |||
| 349 | + | contents, err := os.ReadFile(ignorePath) | |||
| 350 | + | if err != nil { | |||
| 351 | + | t.Fatal(err) | |||
| 352 | + | } | |||
| 353 | + | if got, want := string(contents), "changed by another process\n"; got != want { | |||
| 354 | + | t.Fatalf(".gitignore = %q, want %q", got, want) | |||
| 355 | + | } | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | func TestCreateWorktreePreservesGitignoreLineEndings(t *testing.T) { | |||
| 359 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 360 | + | t.Skip("git is required") | |||
| 361 | + | } | |||
| 362 | + | repo := initWorktreeTestRepo(t) | |||
| 363 | + | ignorePath := filepath.Join(repo, ".gitignore") | |||
| 364 | + | if err := os.WriteFile(ignorePath, []byte("generated/\r\n"), 0o644); err != nil { | |||
| 365 | + | t.Fatal(err) | |||
| 366 | + | } | |||
| 367 | + | tool := newCreateWorktreeTestTool(repo) | |||
| 368 | + | ||||
| 369 | + | if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/crlf","bootstrap_root":".worktrees"}`), nil); err != nil { | |||
| 370 | + | t.Fatal(err) | |||
| 371 | + | } | |||
| 372 | + | ignore, err := os.ReadFile(ignorePath) | |||
| 373 | + | if err != nil { | |||
| 374 | + | t.Fatal(err) | |||
| 375 | + | } | |||
| 376 | + | if got, want := string(ignore), "generated/\r\n/.worktrees/\r\n"; got != want { | |||
| 377 | + | t.Fatalf(".gitignore = %q, want %q", got, want) | |||
| 378 | + | } | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | func TestCreateWorktreeRejectsGitignoreSymbolicLink(t *testing.T) { | |||
| 382 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 383 | + | t.Skip("git is required") | |||
| 384 | + | } | |||
| 385 | + | repo := initWorktreeTestRepo(t) | |||
| 386 | + | target := filepath.Join(repo, "ignore-target") | |||
| 387 | + | if err := os.WriteFile(target, []byte("generated/\n"), 0o644); err != nil { | |||
| 388 | + | t.Fatal(err) | |||
| 389 | + | } | |||
| 390 | + | ignorePath := filepath.Join(repo, ".gitignore") | |||
| 391 | + | if err := os.Symlink(target, ignorePath); err != nil { | |||
| 392 | + | t.Skipf("symbolic links are unavailable: %v", err) | |||
| 393 | + | } | |||
| 394 | + | tool := newCreateWorktreeTestTool(repo) | |||
| 395 | + | ||||
| 396 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/ignore-symlink","bootstrap_root":".worktrees"}`), nil) | |||
| 397 | + | if err == nil || !strings.Contains(err.Error(), ".gitignore must not be a symbolic link") { | |||
| 398 | + | t.Fatalf("error = %v, want symbolic-link error", err) | |||
| 399 | + | } | |||
| 400 | + | info, err := os.Lstat(ignorePath) | |||
| 401 | + | if err != nil { | |||
| 402 | + | t.Fatal(err) | |||
| 403 | + | } | |||
| 404 | + | if info.Mode()&os.ModeSymlink == 0 { | |||
| 405 | + | t.Fatal("tool replaced .gitignore symbolic link") | |||
| 406 | + | } | |||
| 407 | + | if _, statErr := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(statErr) { | |||
| 408 | + | t.Fatalf("symbolic-link rejection created .worktrees: %v", statErr) | |||
| 409 | + | } | |||
| 410 | + | } | |||
| 411 | + | ||||
| 412 | + | func TestCreateWorktreeRejectsNonRegularGitignore(t *testing.T) { | |||
| 413 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 414 | + | t.Skip("git is required") | |||
| 415 | + | } | |||
| 416 | + | repo := initWorktreeTestRepo(t) | |||
| 417 | + | if err := os.Mkdir(filepath.Join(repo, ".gitignore"), 0o755); err != nil { | |||
| 418 | + | t.Fatal(err) | |||
| 419 | + | } | |||
| 420 | + | tool := newCreateWorktreeTestTool(repo) | |||
| 421 | + | ||||
| 422 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/nonregular","bootstrap_root":".worktrees"}`), nil) | |||
| 423 | + | if err == nil || !strings.Contains(err.Error(), ".gitignore must be a regular file") { | |||
| 424 | + | t.Fatalf("error = %v, want regular-file error", err) | |||
| 425 | + | } | |||
| 426 | + | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |||
| 427 | + | t.Fatalf("non-regular ignore saved config: %#v", got) | |||
| 428 | + | } | |||
| 429 | + | } | |||
| 430 | + | ||||
| 431 | + | func TestCreateWorktreeGitOutputKeepsStderrOutOfSuccessfulOutput(t *testing.T) { | |||
| 432 | + | if runtime.GOOS == "windows" { | |||
| 433 | + | t.Skip("Git fixture uses a POSIX shell") | |||
| 434 | + | } | |||
| 435 | + | binDir := t.TempDir() | |||
| 436 | + | gitPath := filepath.Join(binDir, "git") | |||
| 437 | + | if err := os.WriteFile(gitPath, []byte("#!/bin/sh\nprintf 'value\\n'\nprintf 'warning\\n' >&2\n"), 0o755); err != nil { | |||
| 438 | + | t.Fatal(err) | |||
| 439 | + | } | |||
| 440 | + | t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) | |||
| 441 | + | ||||
| 442 | + | output, err := createWorktreeGitOutput(context.Background(), t.TempDir(), "status") | |||
| 443 | + | if err != nil { | |||
| 444 | + | t.Fatal(err) | |||
| 445 | + | } | |||
| 446 | + | if output != "value" { | |||
| 447 | + | t.Fatalf("output = %q, want value", output) | |||
| 448 | + | } | |||
| 449 | + | } | |||
| 450 | + | ||||
| 451 | + | func TestCreateWorktreeGitCancelsDescendants(t *testing.T) { | |||
| 452 | + | if runtime.GOOS == "windows" { | |||
| 453 | + | t.Skip("process-group fixture uses a POSIX shell") | |||
| 454 | + | } | |||
| 455 | + | binDir := t.TempDir() | |||
| 456 | + | started := filepath.Join(t.TempDir(), "git-started") | |||
| 457 | + | childPID := filepath.Join(t.TempDir(), "child-pid") | |||
| 458 | + | gitPath := filepath.Join(binDir, "git") | |||
| 459 | + | script := "#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\" > " + shellQuoteForTest(childPID) + "\nprintf started > " + shellQuoteForTest(started) + "\nwait\n" | |||
| 460 | + | if err := os.WriteFile(gitPath, []byte(script), 0o755); err != nil { | |||
| 461 | + | t.Fatal(err) | |||
| 462 | + | } | |||
| 463 | + | t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) | |||
| 464 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 465 | + | defer cancel() | |||
| 466 | + | cwd := t.TempDir() | |||
| 467 | + | done := make(chan error, 1) | |||
| 468 | + | go func() { | |||
| 469 | + | _, err := createWorktreeGitOutput(ctx, cwd, "status") | |||
| 470 | + | done <- err | |||
| 471 | + | }() | |||
| 472 | + | ||||
| 473 | + | deadline := time.NewTimer(5 * time.Second) | |||
| 474 | + | defer deadline.Stop() | |||
| 475 | + | poll := time.NewTicker(10 * time.Millisecond) | |||
| 476 | + | defer poll.Stop() | |||
| 477 | + | for { | |||
| 478 | + | if _, err := os.Stat(started); err == nil { | |||
| 479 | + | break | |||
| 480 | + | } else if !os.IsNotExist(err) { | |||
| 481 | + | t.Fatal(err) | |||
| 482 | + | } | |||
| 483 | + | select { | |||
| 484 | + | case <-deadline.C: | |||
| 485 | + | t.Fatal("fake git did not start") | |||
| 486 | + | case <-poll.C: | |||
| 487 | + | } | |||
| 488 | + | } | |||
| 489 | + | cancel() | |||
| 490 | + | select { | |||
| 491 | + | case err := <-done: | |||
| 492 | + | if !errors.Is(err, context.Canceled) { | |||
| 493 | + | t.Fatalf("error = %v, want context canceled", err) | |||
| 494 | + | } | |||
| 495 | + | case <-time.After(5 * time.Second): | |||
| 496 | + | t.Fatal("Git process group did not exit after cancellation") | |||
| 497 | + | } | |||
| 498 | + | pid, err := os.ReadFile(childPID) | |||
| 499 | + | if err != nil { | |||
| 500 | + | t.Fatal(err) | |||
| 501 | + | } | |||
| 502 | + | exitDeadline := time.NewTimer(5 * time.Second) | |||
| 503 | + | defer exitDeadline.Stop() | |||
| 504 | + | for { | |||
| 505 | + | err := exec.Command("kill", "-0", strings.TrimSpace(string(pid))).Run() | |||
| 506 | + | if err != nil { | |||
| 507 | + | var exitErr *exec.ExitError | |||
| 508 | + | if errors.As(err, &exitErr) { | |||
| 509 | + | break | |||
| 510 | + | } | |||
| 511 | + | t.Fatalf("check child process: %v", err) | |||
| 512 | + | } | |||
| 513 | + | select { | |||
| 514 | + | case <-exitDeadline.C: | |||
| 515 | + | t.Fatalf("Git descendant %q remained after cancellation", pid) | |||
| 516 | + | case <-poll.C: | |||
| 517 | + | } | |||
| 518 | + | } | |||
| 519 | + | } | |||
| 520 | + | ||||
| 521 | + | func TestCreateWorktreePreviewDoesNotModifyRepository(t *testing.T) { | |||
| 522 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 523 | + | t.Skip("git is required") | |||
| 524 | + | } | |||
| 525 | + | repo := initWorktreeTestRepo(t) | |||
| 526 | + | tool := newCreateWorktreeTestTool(repo) | |||
| 527 | + | args := json.RawMessage(`{"branch":"feature/preview"}`) | |||
| 528 | + | ||||
| 529 | + | preview, err := tool.Preview(context.Background(), args) | |||
| 530 | + | if err != nil { | |||
| 531 | + | t.Fatal(err) | |||
| 532 | + | } | |||
| 533 | + | text := preview.Content[0].(provider.TextBlock).Text | |||
| 534 | + | for _, want := range []string{"Worktree bootstrap is required", "feature/preview", "bootstrap_root"} { | |||
| 535 | + | if !strings.Contains(text, want) { | |||
| 536 | + | t.Fatalf("preview missing %q:\n%s", want, text) | |||
| 537 | + | } | |||
| 538 | + | } | |||
| 539 | + | if _, err := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(err) { | |||
| 540 | + | t.Fatalf("preview created .worktrees: %v", err) | |||
| 541 | + | } | |||
| 542 | + | if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) { | |||
| 543 | + | t.Fatalf("preview created .gitignore: %v", err) | |||
| 544 | + | } | |||
| 545 | + | cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/preview") | |||
| 546 | + | if err := cmd.Run(); err == nil { | |||
| 547 | + | t.Fatal("preview created the branch") | |||
| 548 | + | } | |||
| 549 | + | } | |||
| 550 | + | ||||
| 551 | + | func TestCreateWorktreeRejectsStateChangedAfterPreview(t *testing.T) { | |||
| 552 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 553 | + | t.Skip("git is required") | |||
| 554 | + | } | |||
| 555 | + | repo := initWorktreeTestRepo(t) | |||
| 556 | + | if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil { | |||
| 557 | + | t.Fatal(err) | |||
| 558 | + | } | |||
| 559 | + | tool := newCreateWorktreeTestTool(repo) | |||
| 560 | + | args := json.RawMessage(`{"branch":"feature/stale-preview"}`) | |||
| 561 | + | if _, err := tool.Preview(context.Background(), args); err != nil { | |||
| 562 | + | t.Fatal(err) | |||
| 563 | + | } | |||
| 564 | + | if err := os.WriteFile(filepath.Join(repo, "after-preview.txt"), []byte("changed\n"), 0o644); err != nil { | |||
| 565 | + | t.Fatal(err) | |||
| 566 | + | } | |||
| 567 | + | gitTestOutput(t, repo, "add", "after-preview.txt") | |||
| 568 | + | gitTestOutput(t, repo, "commit", "-qm", "change after preview") | |||
| 569 | + | ||||
| 570 | + | _, err := tool.Execute(context.Background(), args, nil) | |||
| 571 | + | if err == nil || !strings.Contains(err.Error(), "state changed since preview") { | |||
| 572 | + | t.Fatalf("error = %v, want stale-preview error", err) | |||
| 573 | + | } | |||
| 574 | + | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/stale-preview"); got.exitCode != 1 { | |||
| 575 | + | t.Fatalf("stale preview created branch: %#v", got) | |||
| 576 | + | } | |||
| 577 | + | } | |||
| 578 | + | ||||
| 579 | + | func TestCreateWorktreeRejectsNonRepository(t *testing.T) { | |||
| 580 | + | dir := t.TempDir() | |||
| 581 | + | tool := newCreateWorktreeTestTool(dir) | |||
| 582 | + | ||||
| 583 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/new"}`), nil) | |||
| 584 | + | if err == nil || !strings.Contains(err.Error(), "not a Git repository") { | |||
| 585 | + | t.Fatalf("error = %v, want repository error", err) | |||
| 586 | + | } | |||
| 587 | + | for _, path := range []string{".worktrees", ".gitignore"} { | |||
| 588 | + | if _, statErr := os.Stat(filepath.Join(dir, path)); !os.IsNotExist(statErr) { | |||
| 589 | + | t.Fatalf("non-repository call created %s: %v", path, statErr) | |||
| 590 | + | } | |||
| 591 | + | } | |||
| 592 | + | } | |||
| 593 | + | ||||
| 594 | + | func TestCreateWorktreeRequiresSandbox(t *testing.T) { | |||
| 595 | + | tool := &CreateWorktreeTool{CWD: t.TempDir()} | |||
| 596 | + | ||||
| 597 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-sandbox"}`), nil) | |||
| 598 | + | if err == nil || !strings.Contains(err.Error(), "sandbox is required") { | |||
| 599 | + | t.Fatalf("error = %v, want sandbox error", err) | |||
| 600 | + | } | |||
| 601 | + | } | |||
| 602 | + | ||||
| 603 | + | func TestCreateWorktreeRejectsExistingBranchWithoutChangingGitignore(t *testing.T) { | |||
| 604 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 605 | + | t.Skip("git is required") | |||
| 606 | + | } | |||
| 607 | + | repo := initWorktreeTestRepo(t) | |||
| 608 | + | tool := newCreateWorktreeTestTool(repo) | |||
| 609 | + | bootstrapArgs := json.RawMessage(`{"branch":"feature/existing","bootstrap_root":".worktrees"}`) | |||
| 610 | + | if _, err := tool.Execute(context.Background(), bootstrapArgs, nil); err != nil { | |||
| 611 | + | t.Fatal(err) | |||
| 612 | + | } | |||
| 613 | + | ignoreBefore, err := os.ReadFile(filepath.Join(repo, ".gitignore")) | |||
| 614 | + | if err != nil { | |||
| 615 | + | t.Fatal(err) | |||
| 616 | + | } | |||
| 617 | + | ||||
| 618 | + | _, err = tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/existing"}`), nil) | |||
| 619 | + | if err == nil || !strings.Contains(err.Error(), "already exists") { | |||
| 620 | + | t.Fatalf("error = %v, want collision error", err) | |||
| 621 | + | } | |||
| 622 | + | ignoreAfter, err := os.ReadFile(filepath.Join(repo, ".gitignore")) | |||
| 623 | + | if err != nil { | |||
| 624 | + | t.Fatal(err) | |||
| 625 | + | } | |||
| 626 | + | if string(ignoreAfter) != string(ignoreBefore) { | |||
| 627 | + | t.Fatalf("collision changed .gitignore:\nbefore: %q\nafter: %q", ignoreBefore, ignoreAfter) | |||
| 628 | + | } | |||
| 629 | + | } | |||
| 630 | + | ||||
| 631 | + | func TestCreateWorktreeRespectsJail(t *testing.T) { | |||
| 632 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 633 | + | t.Skip("git is required") | |||
| 634 | + | } | |||
| 635 | + | repo := initWorktreeTestRepo(t) | |||
| 636 | + | nested := filepath.Join(repo, "nested") | |||
| 637 | + | if err := os.Mkdir(nested, 0o755); err != nil { | |||
| 638 | + | t.Fatal(err) | |||
| 639 | + | } | |||
| 640 | + | sandbox := NewSandbox(nested) | |||
| 641 | + | sandbox.Lock() | |||
| 642 | + | tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox} | |||
| 643 | + | ||||
| 644 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed"}`), nil) | |||
| 645 | + | if err == nil || !strings.Contains(err.Error(), "jailed:") { | |||
| 646 | + | t.Fatalf("error = %v, want jailed error", err) | |||
| 647 | + | } | |||
| 648 | + | for _, path := range []string{".worktrees", ".gitignore"} { | |||
| 649 | + | if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) { | |||
| 650 | + | t.Fatalf("jailed call created %s: %v", path, statErr) | |||
| 651 | + | } | |||
| 652 | + | } | |||
| 653 | + | } | |||
| 654 | + | ||||
| 655 | + | func TestCreateWorktreeRejectsJailedAncestorBeforeGitDiscovery(t *testing.T) { | |||
| 656 | + | if runtime.GOOS == "windows" { | |||
| 657 | + | t.Skip("Git invocation fixture uses a POSIX shell") | |||
| 658 | + | } | |||
| 659 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 660 | + | t.Skip("git is required") | |||
| 661 | + | } | |||
| 662 | + | repo := initWorktreeTestRepo(t) | |||
| 663 | + | nested := filepath.Join(repo, "nested") | |||
| 664 | + | if err := os.Mkdir(nested, 0o755); err != nil { | |||
| 665 | + | t.Fatal(err) | |||
| 666 | + | } | |||
| 667 | + | sentinel := filepath.Join(repo, "git-was-invoked") | |||
| 668 | + | binDir := t.TempDir() | |||
| 669 | + | fakeGit := filepath.Join(binDir, "git") | |||
| 670 | + | if err := os.WriteFile(fakeGit, []byte("#!/bin/sh\ntouch "+shellQuoteForTest(sentinel)+"\nexit 1\n"), 0o755); err != nil { | |||
| 671 | + | t.Fatal(err) | |||
| 672 | + | } | |||
| 673 | + | t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) | |||
| 674 | + | sandbox := NewSandbox(nested) | |||
| 675 | + | sandbox.Lock() | |||
| 676 | + | tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox} | |||
| 677 | + | ||||
| 678 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed-discovery"}`), nil) | |||
| 679 | + | if err == nil || !strings.Contains(err.Error(), "jailed:") { | |||
| 680 | + | t.Fatalf("error = %v, want jailed error", err) | |||
| 681 | + | } | |||
| 682 | + | if _, err := os.Stat(sentinel); !os.IsNotExist(err) { | |||
| 683 | + | t.Fatalf("Git ran before jailed ancestor was rejected: %v", err) | |||
| 684 | + | } | |||
| 685 | + | } | |||
| 686 | + | ||||
| 687 | + | func TestCreateWorktreeRequiresFilesystemPermissions(t *testing.T) { | |||
| 688 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 689 | + | t.Skip("git is required") | |||
| 690 | + | } | |||
| 691 | + | repo := initWorktreeTestRepo(t) | |||
| 692 | + | sandbox := NewSandbox(repo) | |||
| 693 | + | permissions := &PermissionSet{} | |||
| 694 | + | permissions.Bash.Mode = "allowlist" | |||
| 695 | + | permissions.Bash.Allow = []string{"git"} | |||
| 696 | + | sandbox.SetPermissions(permissions) | |||
| 697 | + | tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox} | |||
| 698 | + | ||||
| 699 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/restricted"}`), nil) | |||
| 700 | + | if err == nil || !strings.Contains(err.Error(), "no filesystem read permission") { | |||
| 701 | + | t.Fatalf("error = %v, want filesystem permission error", err) | |||
| 702 | + | } | |||
| 703 | + | for _, path := range []string{".worktrees", ".gitignore"} { | |||
| 704 | + | if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) { | |||
| 705 | + | t.Fatalf("restricted call created %s: %v", path, statErr) | |||
| 706 | + | } | |||
| 707 | + | } | |||
| 708 | + | } | |||
| 709 | + | ||||
| 710 | + | func TestCreateWorktreeRequiresGitBashPermission(t *testing.T) { | |||
| 711 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 712 | + | t.Skip("git is required") | |||
| 713 | + | } | |||
| 714 | + | repo := initWorktreeTestRepo(t) | |||
| 715 | + | sandbox := NewSandbox(repo) | |||
| 716 | + | permissions := &PermissionSet{} | |||
| 717 | + | permissions.FS.Read = []string{repo} | |||
| 718 | + | permissions.FS.Write = []string{repo} | |||
| 719 | + | sandbox.SetPermissions(permissions) | |||
| 720 | + | tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox} | |||
| 721 | + | ||||
| 722 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-git"}`), nil) | |||
| 723 | + | if err == nil || !strings.Contains(err.Error(), "no bash permission") { | |||
| 724 | + | t.Fatalf("error = %v, want git permission error", err) | |||
| 725 | + | } | |||
| 726 | + | for _, path := range []string{".worktrees", ".gitignore"} { | |||
| 727 | + | if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) { | |||
| 728 | + | t.Fatalf("restricted call created %s: %v", path, statErr) | |||
| 729 | + | } | |||
| 730 | + | } | |||
| 731 | + | } | |||
| 732 | + | ||||
| 733 | + | func TestCreateWorktreeRejectsInvalidWorktreePathBeforeGitignoreUpdate(t *testing.T) { | |||
| 734 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 735 | + | t.Skip("git is required") | |||
| 736 | + | } | |||
| 737 | + | repo := initWorktreeTestRepo(t) | |||
| 738 | + | worktrees := filepath.Join(repo, ".worktrees") | |||
| 739 | + | if err := os.Mkdir(worktrees, 0o755); err != nil { | |||
| 740 | + | t.Fatal(err) | |||
| 741 | + | } | |||
| 742 | + | if err := os.WriteFile(filepath.Join(worktrees, "blocked"), []byte("not a directory"), 0o644); err != nil { | |||
| 743 | + | t.Fatal(err) | |||
| 744 | + | } | |||
| 745 | + | tool := newCreateWorktreeTestTool(repo) | |||
| 746 | + | ||||
| 747 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"blocked/new"}`), nil) | |||
| 748 | + | if err == nil || !strings.Contains(err.Error(), "not a directory") { | |||
| 749 | + | t.Fatalf("error = %v, want non-directory path error", err) | |||
| 750 | + | } | |||
| 751 | + | if _, statErr := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(statErr) { | |||
| 752 | + | t.Fatalf("invalid path left .gitignore behind: %v", statErr) | |||
| 753 | + | } | |||
| 754 | + | cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/blocked/new") | |||
| 755 | + | if err := cmd.Run(); err == nil { | |||
| 756 | + | t.Fatal("invalid path created the branch") | |||
| 757 | + | } | |||
| 758 | + | } | |||
| 759 | + | ||||
| 760 | + | func newCreateWorktreeTestTool(cwd string) *CreateWorktreeTool { | |||
| 761 | + | return &CreateWorktreeTool{CWD: cwd, Sandbox: NewSandbox(cwd)} | |||
| 762 | + | } | |||
| 763 | + | ||||
| 764 | + | func initWorktreeTestRepo(t *testing.T) string { | |||
| 765 | + | t.Helper() | |||
| 766 | + | t.Setenv("GIT_CONFIG_NOSYSTEM", "1") | |||
| 767 | + | t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull) | |||
| 768 | + | t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) | |||
| 769 | + | repo := t.TempDir() | |||
| 770 | + | gitTestOutput(t, repo, "init", "-q") | |||
| 771 | + | gitTestOutput(t, repo, "config", "user.email", "test@example.invalid") | |||
| 772 | + | gitTestOutput(t, repo, "config", "user.name", "Test User") | |||
| 773 | + | if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("before\n"), 0o644); err != nil { | |||
| 774 | + | t.Fatal(err) | |||
| 775 | + | } | |||
| 776 | + | gitTestOutput(t, repo, "add", "README.md") | |||
| 777 | + | gitTestOutput(t, repo, "commit", "-qm", "initial") | |||
| 778 | + | return repo | |||
| 779 | + | } | |||
| 780 | + | ||||
| 781 | + | func shellQuoteForTest(value string) string { | |||
| 782 | + | return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" | |||
| 783 | + | } | |||
| 784 | + | ||||
| 785 | + | type gitTestResult struct { | |||
| 786 | + | output string | |||
| 787 | + | exitCode int | |||
| 788 | + | } | |||
| 789 | + | ||||
| 790 | + | func gitTestOutputAllowExit(t *testing.T, dir string, args ...string) gitTestResult { | |||
| 791 | + | t.Helper() | |||
| 792 | + | cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) | |||
| 793 | + | output, err := cmd.CombinedOutput() | |||
| 794 | + | if err == nil { | |||
| 795 | + | return gitTestResult{output: strings.TrimSpace(string(output))} | |||
| 796 | + | } | |||
| 797 | + | if exitErr, ok := err.(*exec.ExitError); ok { | |||
| 798 | + | return gitTestResult{output: strings.TrimSpace(string(output)), exitCode: exitErr.ExitCode()} | |||
| 799 | + | } | |||
| 800 | + | t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output) | |||
| 801 | + | return gitTestResult{} | |||
| 802 | + | } | |||
| 803 | + | ||||
| 804 | + | func gitTestOutput(t *testing.T, dir string, args ...string) string { | |||
| 805 | + | t.Helper() | |||
| 806 | + | cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) | |||
| 807 | + | output, err := cmd.CombinedOutput() | |||
| 808 | + | if err != nil { | |||
| 809 | + | t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output) | |||
| 810 | + | } | |||
| 811 | + | return strings.TrimSpace(string(output)) | |||
| 812 | + | } | |||
M packages/agent/tools/read.go +1 -1
| @@ -1,4 +1,4 @@ | |||
| 1 | - | // Package tools implements zut's built-in tools: read, write, edit, bash, lsp, and web search. | |
| 1 | + | // Package tools implements zut's built-in tools: read, write, edit, bash, create_worktree, lsp, and web search. | |
| 2 | 2 | package tools | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | // Package tools implements zut's built-in tools: read, write, edit, bash, lsp, and web search. | 1 | + | // Package tools implements zut's built-in tools: read, write, edit, bash, create_worktree, lsp, and web search. |
| 2 | package tools | 2 | package tools | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
M packages/agent/tools/subagent_spawn.go +3 -0
| @@ -274,6 +274,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||
| 274 | 274 | if agent.FastMode { | |
| 275 | 275 | sb.WriteString("fast mode: enabled\n") | |
| 276 | 276 | } | |
| 277 | + | if fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() { | |
| 278 | + | sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n") | |
| 279 | + | } | |
| 277 | 280 | sb.WriteString("\nThe sub-agent is running in the background. Use /subagents in the TUI to monitor it. ") | |
| 278 | 281 | sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.") | |
| 279 | 282 | return core.ToolResult{ | |
| @@ -274,6 +274,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||||
| 274 | if agent.FastMode { | 274 | if agent.FastMode { | ||
| 275 | sb.WriteString("fast mode: enabled\n") | 275 | sb.WriteString("fast mode: enabled\n") | ||
| 276 | } | 276 | } | ||
| 277 | + | if fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() { | |||
| 278 | + | sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n") | |||
| 279 | + | } | |||
| 277 | sb.WriteString("\nThe sub-agent is running in the background. Use /subagents in the TUI to monitor it. ") | 280 | sb.WriteString("\nThe sub-agent is running in the background. Use /subagents in the TUI to monitor it. ") | ||
| 278 | sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.") | 281 | sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.") | ||
| 279 | return core.ToolResult{ | 282 | return core.ToolResult{ | ||
M packages/agent/tools/subagent_spawn_test.go +41 -0
| @@ -299,6 +299,47 @@ func TestSubagentSpawnAppliesProfileFastModeRestriction(t *testing.T) { | |||
| 299 | 299 | } | |
| 300 | 300 | } | |
| 301 | 301 | ||
| 302 | + | func TestSubagentSpawnProfileFastModeOverridesGlobalOffWithWarning(t *testing.T) { | |
| 303 | + | root := t.TempDir() | |
| 304 | + | profileFastMode := true | |
| 305 | + | f := subagents.New(subagents.Config{ | |
| 306 | + | Root: filepath.Join(root, "subagents"), | |
| 307 | + | RepoRoot: root, | |
| 308 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |
| 309 | + | return noopSupervisorRunner{} | |
| 310 | + | }, | |
| 311 | + | }) | |
| 312 | + | t.Cleanup(f.StopAll) | |
| 313 | + | tool := &SubagentSpawnTool{ | |
| 314 | + | Supervisor: f, | |
| 315 | + | Enabled: func() bool { return true }, | |
| 316 | + | ResolveSubagent: func(name string) (*subagents.Profile, error) { | |
| 317 | + | if name != "fast-worker" { | |
| 318 | + | return nil, nil | |
| 319 | + | } | |
| 320 | + | return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil | |
| 321 | + | }, | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"fast-worker"}`), nil) | |
| 325 | + | if err != nil { | |
| 326 | + | t.Fatal(err) | |
| 327 | + | } | |
| 328 | + | if res.IsError { | |
| 329 | + | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |
| 330 | + | } | |
| 331 | + | agents := f.List() | |
| 332 | + | if len(agents) != 1 || !agents[0].FastMode { | |
| 333 | + | t.Fatalf("agent fast mode = %#v, want enabled by profile", agents) | |
| 334 | + | } | |
| 335 | + | if got := res.Details.(map[string]any)["fast_mode"]; got != true { | |
| 336 | + | t.Fatalf("fast_mode detail = %v, want true", got) | |
| 337 | + | } | |
| 338 | + | if text := textResult(res.Content); !strings.Contains(text, "warning: subagent profile has fast mode enabled, overriding global fast mode off") { | |
| 339 | + | t.Fatalf("spawn result missing profile override warning:\n%s", text) | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | ||
| 302 | 343 | func TestSubagentSpawnUsesProfileReasoningWhenOmitted(t *testing.T) { | |
| 303 | 344 | tool := &SubagentSpawnTool{ | |
| 304 | 345 | Supervisor: newTestSupervisor(t), | |
| @@ -299,6 +299,47 @@ func TestSubagentSpawnAppliesProfileFastModeRestriction(t *testing.T) { | |||||
| 299 | } | 299 | } | ||
| 300 | } | 300 | } | ||
| 301 | 301 | ||||
| 302 | + | func TestSubagentSpawnProfileFastModeOverridesGlobalOffWithWarning(t *testing.T) { | |||
| 303 | + | root := t.TempDir() | |||
| 304 | + | profileFastMode := true | |||
| 305 | + | f := subagents.New(subagents.Config{ | |||
| 306 | + | Root: filepath.Join(root, "subagents"), | |||
| 307 | + | RepoRoot: root, | |||
| 308 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |||
| 309 | + | return noopSupervisorRunner{} | |||
| 310 | + | }, | |||
| 311 | + | }) | |||
| 312 | + | t.Cleanup(f.StopAll) | |||
| 313 | + | tool := &SubagentSpawnTool{ | |||
| 314 | + | Supervisor: f, | |||
| 315 | + | Enabled: func() bool { return true }, | |||
| 316 | + | ResolveSubagent: func(name string) (*subagents.Profile, error) { | |||
| 317 | + | if name != "fast-worker" { | |||
| 318 | + | return nil, nil | |||
| 319 | + | } | |||
| 320 | + | return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil | |||
| 321 | + | }, | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"fast-worker"}`), nil) | |||
| 325 | + | if err != nil { | |||
| 326 | + | t.Fatal(err) | |||
| 327 | + | } | |||
| 328 | + | if res.IsError { | |||
| 329 | + | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |||
| 330 | + | } | |||
| 331 | + | agents := f.List() | |||
| 332 | + | if len(agents) != 1 || !agents[0].FastMode { | |||
| 333 | + | t.Fatalf("agent fast mode = %#v, want enabled by profile", agents) | |||
| 334 | + | } | |||
| 335 | + | if got := res.Details.(map[string]any)["fast_mode"]; got != true { | |||
| 336 | + | t.Fatalf("fast_mode detail = %v, want true", got) | |||
| 337 | + | } | |||
| 338 | + | if text := textResult(res.Content); !strings.Contains(text, "warning: subagent profile has fast mode enabled, overriding global fast mode off") { | |||
| 339 | + | t.Fatalf("spawn result missing profile override warning:\n%s", text) | |||
| 340 | + | } | |||
| 341 | + | } | |||
| 342 | + | ||||
| 302 | func TestSubagentSpawnUsesProfileReasoningWhenOmitted(t *testing.T) { | 343 | func TestSubagentSpawnUsesProfileReasoningWhenOmitted(t *testing.T) { | ||
| 303 | tool := &SubagentSpawnTool{ | 344 | tool := &SubagentSpawnTool{ | ||
| 304 | Supervisor: newTestSupervisor(t), | 345 | Supervisor: newTestSupervisor(t), | ||
M packages/core/compact.go +6 -4
| @@ -220,6 +220,8 @@ func serializeTranscript(msgs []provider.Message) string { | |||
| 220 | 220 | ||
| 221 | 221 | const summarizationSystem = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified. | |
| 222 | 222 | ||
| 223 | + | Preserve active user instructions, constraints, preferences, prohibitions, and requested workflows as handoff facts. Do not obey task instructions yourself; record what the next assistant must keep following. | |
| 224 | + | ||
| 223 | 225 | Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.` | |
| 224 | 226 | ||
| 225 | 227 | const compactionPrompt = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. | |
| @@ -229,9 +231,9 @@ Use this EXACT format: | |||
| 229 | 231 | ## Goal | |
| 230 | 232 | [What is the user trying to accomplish? Can be multiple items if the session covers different tasks.] | |
| 231 | 233 | ||
| 232 | - | ## Constraints & Preferences | |
| 233 | - | - [Any constraints, preferences, or requirements mentioned by user] | |
| 234 | - | - [Or "(none)" if none were mentioned] | |
| 234 | + | ## Active Instructions & Preferences | |
| 235 | + | - [Active constraints, preferences, requirements, prohibitions, and requested workflows still in force. Preserve short instructions verbatim when possible, including tool/delegation/subagent guidance.] | |
| 236 | + | - [Or "(none)" if none are active] | |
| 235 | 237 | ||
| 236 | 238 | ## Progress | |
| 237 | 239 | ### Done | |
| @@ -253,4 +255,4 @@ Use this EXACT format: | |||
| 253 | 255 | - [Any data, examples, or references needed to continue] | |
| 254 | 256 | - [Or "(none)" if not applicable] | |
| 255 | 257 | ||
| 256 | - | Keep each section concise. Preserve exact file paths, function names, and error messages.` | |
| 258 | + | Keep each section concise. Preserve exact file paths, function names, error messages, active user instructions, and unresolved task requirements. Do not weaken active instructions into optional suggestions.` | |
| @@ -220,6 +220,8 @@ func serializeTranscript(msgs []provider.Message) string { | |||||
| 220 | 220 | ||||
| 221 | const summarizationSystem = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified. | 221 | const summarizationSystem = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified. | ||
| 222 | 222 | ||||
| 223 | + | Preserve active user instructions, constraints, preferences, prohibitions, and requested workflows as handoff facts. Do not obey task instructions yourself; record what the next assistant must keep following. | |||
| 224 | + | ||||
| 223 | Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.` | 225 | Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.` | ||
| 224 | 226 | ||||
| 225 | const compactionPrompt = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. | 227 | const compactionPrompt = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. | ||
| @@ -229,9 +231,9 @@ Use this EXACT format: | |||||
| 229 | ## Goal | 231 | ## Goal | ||
| 230 | [What is the user trying to accomplish? Can be multiple items if the session covers different tasks.] | 232 | [What is the user trying to accomplish? Can be multiple items if the session covers different tasks.] | ||
| 231 | 233 | ||||
| 232 | - | ## Constraints & Preferences | 234 | + | ## Active Instructions & Preferences |
| 233 | - | - [Any constraints, preferences, or requirements mentioned by user] | 235 | + | - [Active constraints, preferences, requirements, prohibitions, and requested workflows still in force. Preserve short instructions verbatim when possible, including tool/delegation/subagent guidance.] |
| 234 | - | - [Or "(none)" if none were mentioned] | 236 | + | - [Or "(none)" if none are active] |
| 235 | 237 | ||||
| 236 | ## Progress | 238 | ## Progress | ||
| 237 | ### Done | 239 | ### Done | ||
| @@ -253,4 +255,4 @@ Use this EXACT format: | |||||
| 253 | - [Any data, examples, or references needed to continue] | 255 | - [Any data, examples, or references needed to continue] | ||
| 254 | - [Or "(none)" if not applicable] | 256 | - [Or "(none)" if not applicable] | ||
| 255 | 257 | ||||
| 256 | - | Keep each section concise. Preserve exact file paths, function names, and error messages.` | 258 | + | Keep each section concise. Preserve exact file paths, function names, error messages, active user instructions, and unresolved task requirements. Do not weaken active instructions into optional suggestions.` |
M packages/core/compact_lifecycle_test.go +62 -2
| @@ -2,6 +2,7 @@ package core | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "strings" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/zut/packages/provider" | |
| @@ -9,20 +10,27 @@ import ( | |||
| 9 | 10 | ||
| 10 | 11 | type compactLifecycleClient struct { | |
| 11 | 12 | hadLifecycle bool | |
| 13 | + | req provider.Request | |
| 14 | + | summary string | |
| 12 | 15 | } | |
| 13 | 16 | ||
| 14 | 17 | func (c *compactLifecycleClient) Name() string { return "compact-lifecycle" } | |
| 15 | 18 | ||
| 16 | 19 | func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |
| 20 | + | c.req = req | |
| 17 | 21 | c.hadLifecycle = req.Lifecycle != nil | |
| 18 | 22 | if req.Lifecycle != nil { | |
| 19 | 23 | req.Lifecycle.RequestAttempt(1, 1) | |
| 20 | 24 | } | |
| 25 | + | summary := c.summary | |
| 26 | + | if summary == "" { | |
| 27 | + | summary = "summary" | |
| 28 | + | } | |
| 21 | 29 | out := make(chan provider.Event, 2) | |
| 22 | - | out <- provider.EventTextDelta{Delta: "summary"} | |
| 30 | + | out <- provider.EventTextDelta{Delta: summary} | |
| 23 | 31 | out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{ | |
| 24 | 32 | Role: provider.RoleAssistant, | |
| 25 | - | Content: []provider.Content{provider.TextBlock{Text: "summary"}}, | |
| 33 | + | Content: []provider.Content{provider.TextBlock{Text: summary}}, | |
| 26 | 34 | }} | |
| 27 | 35 | close(out) | |
| 28 | 36 | return out, nil | |
| @@ -68,3 +76,55 @@ func TestCompactWithEventsEmitsRequestLifecycle(t *testing.T) { | |||
| 68 | 76 | t.Fatalf("fourth event = %#v, want summary text delta", events[3]) | |
| 69 | 77 | } | |
| 70 | 78 | } | |
| 79 | + | ||
| 80 | + | func TestCompactPromptPreservesActiveInstructions(t *testing.T) { | |
| 81 | + | const activeInstruction = "delegate independent work to subagents" | |
| 82 | + | client := &compactLifecycleClient{summary: "Active instruction still in force: " + activeInstruction} | |
| 83 | + | agent := NewAgent(client, "compact-model", "system", Registry{}) | |
| 84 | + | agent.SetMessages([]provider.Message{ | |
| 85 | + | { | |
| 86 | + | Role: provider.RoleUser, | |
| 87 | + | Content: []provider.Content{provider.TextBlock{Text: "When coding, delegate independent work to subagents."}}, | |
| 88 | + | }, | |
| 89 | + | { | |
| 90 | + | Role: provider.RoleAssistant, | |
| 91 | + | Content: []provider.Content{provider.TextBlock{Text: "I will do that."}}, | |
| 92 | + | }, | |
| 93 | + | }) | |
| 94 | + | ||
| 95 | + | if _, err := agent.Compact(context.Background(), 0, nil); err != nil { | |
| 96 | + | t.Fatalf("Compact: %v", err) | |
| 97 | + | } | |
| 98 | + | messages := agent.Messages() | |
| 99 | + | if len(messages) != 1 { | |
| 100 | + | t.Fatalf("compacted messages = %d, want 1", len(messages)) | |
| 101 | + | } | |
| 102 | + | if messages[0].Role != provider.RoleUser { | |
| 103 | + | t.Fatalf("compacted message role = %q, want user", messages[0].Role) | |
| 104 | + | } | |
| 105 | + | if messages[0].Meta["compaction"] != "true" { | |
| 106 | + | t.Fatalf("compacted message meta = %#v, want compaction=true", messages[0].Meta) | |
| 107 | + | } | |
| 108 | + | compacted, ok := messages[0].Content[0].(provider.TextBlock) | |
| 109 | + | if !ok { | |
| 110 | + | t.Fatalf("compacted message content = %#v, want TextBlock", messages[0].Content[0]) | |
| 111 | + | } | |
| 112 | + | if !strings.Contains(compacted.Text, "## Context Summary (compacted)") || !strings.Contains(compacted.Text, activeInstruction) { | |
| 113 | + | t.Fatalf("compacted transcript did not preserve active instruction:\n%s", compacted.Text) | |
| 114 | + | } | |
| 115 | + | if !strings.Contains(client.req.System, "Preserve active user instructions") { | |
| 116 | + | t.Fatalf("compaction system prompt does not preserve active instructions: %q", client.req.System) | |
| 117 | + | } | |
| 118 | + | if len(client.req.Messages) != 1 { | |
| 119 | + | t.Fatalf("compaction request messages = %d, want 1", len(client.req.Messages)) | |
| 120 | + | } | |
| 121 | + | text, ok := client.req.Messages[0].Content[0].(provider.TextBlock) | |
| 122 | + | if !ok { | |
| 123 | + | t.Fatalf("compaction request content = %#v, want TextBlock", client.req.Messages[0].Content[0]) | |
| 124 | + | } | |
| 125 | + | for _, want := range []string{"## Active Instructions & Preferences", "delegation/subagent", activeInstruction} { | |
| 126 | + | if !strings.Contains(text.Text, want) { | |
| 127 | + | t.Fatalf("compaction prompt missing %q:\n%s", want, text.Text) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | } | |
| @@ -2,6 +2,7 @@ package core | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "strings" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| 7 | "github.com/bnema/zut/packages/provider" | 8 | "github.com/bnema/zut/packages/provider" | ||
| @@ -9,20 +10,27 @@ import ( | |||||
| 9 | 10 | ||||
| 10 | type compactLifecycleClient struct { | 11 | type compactLifecycleClient struct { | ||
| 11 | hadLifecycle bool | 12 | hadLifecycle bool | ||
| 13 | + | req provider.Request | |||
| 14 | + | summary string | |||
| 12 | } | 15 | } | ||
| 13 | 16 | ||||
| 14 | func (c *compactLifecycleClient) Name() string { return "compact-lifecycle" } | 17 | func (c *compactLifecycleClient) Name() string { return "compact-lifecycle" } | ||
| 15 | 18 | ||||
| 16 | func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | 19 | func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | ||
| 20 | + | c.req = req | |||
| 17 | c.hadLifecycle = req.Lifecycle != nil | 21 | c.hadLifecycle = req.Lifecycle != nil | ||
| 18 | if req.Lifecycle != nil { | 22 | if req.Lifecycle != nil { | ||
| 19 | req.Lifecycle.RequestAttempt(1, 1) | 23 | req.Lifecycle.RequestAttempt(1, 1) | ||
| 20 | } | 24 | } | ||
| 25 | + | summary := c.summary | |||
| 26 | + | if summary == "" { | |||
| 27 | + | summary = "summary" | |||
| 28 | + | } | |||
| 21 | out := make(chan provider.Event, 2) | 29 | out := make(chan provider.Event, 2) | ||
| 22 | - | out <- provider.EventTextDelta{Delta: "summary"} | 30 | + | out <- provider.EventTextDelta{Delta: summary} |
| 23 | out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{ | 31 | out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{ | ||
| 24 | Role: provider.RoleAssistant, | 32 | Role: provider.RoleAssistant, | ||
| 25 | - | Content: []provider.Content{provider.TextBlock{Text: "summary"}}, | 33 | + | Content: []provider.Content{provider.TextBlock{Text: summary}}, |
| 26 | }} | 34 | }} | ||
| 27 | close(out) | 35 | close(out) | ||
| 28 | return out, nil | 36 | return out, nil | ||
| @@ -68,3 +76,55 @@ func TestCompactWithEventsEmitsRequestLifecycle(t *testing.T) { | |||||
| 68 | t.Fatalf("fourth event = %#v, want summary text delta", events[3]) | 76 | t.Fatalf("fourth event = %#v, want summary text delta", events[3]) | ||
| 69 | } | 77 | } | ||
| 70 | } | 78 | } | ||
| 79 | + | ||||
| 80 | + | func TestCompactPromptPreservesActiveInstructions(t *testing.T) { | |||
| 81 | + | const activeInstruction = "delegate independent work to subagents" | |||
| 82 | + | client := &compactLifecycleClient{summary: "Active instruction still in force: " + activeInstruction} | |||
| 83 | + | agent := NewAgent(client, "compact-model", "system", Registry{}) | |||
| 84 | + | agent.SetMessages([]provider.Message{ | |||
| 85 | + | { | |||
| 86 | + | Role: provider.RoleUser, | |||
| 87 | + | Content: []provider.Content{provider.TextBlock{Text: "When coding, delegate independent work to subagents."}}, | |||
| 88 | + | }, | |||
| 89 | + | { | |||
| 90 | + | Role: provider.RoleAssistant, | |||
| 91 | + | Content: []provider.Content{provider.TextBlock{Text: "I will do that."}}, | |||
| 92 | + | }, | |||
| 93 | + | }) | |||
| 94 | + | ||||
| 95 | + | if _, err := agent.Compact(context.Background(), 0, nil); err != nil { | |||
| 96 | + | t.Fatalf("Compact: %v", err) | |||
| 97 | + | } | |||
| 98 | + | messages := agent.Messages() | |||
| 99 | + | if len(messages) != 1 { | |||
| 100 | + | t.Fatalf("compacted messages = %d, want 1", len(messages)) | |||
| 101 | + | } | |||
| 102 | + | if messages[0].Role != provider.RoleUser { | |||
| 103 | + | t.Fatalf("compacted message role = %q, want user", messages[0].Role) | |||
| 104 | + | } | |||
| 105 | + | if messages[0].Meta["compaction"] != "true" { | |||
| 106 | + | t.Fatalf("compacted message meta = %#v, want compaction=true", messages[0].Meta) | |||
| 107 | + | } | |||
| 108 | + | compacted, ok := messages[0].Content[0].(provider.TextBlock) | |||
| 109 | + | if !ok { | |||
| 110 | + | t.Fatalf("compacted message content = %#v, want TextBlock", messages[0].Content[0]) | |||
| 111 | + | } | |||
| 112 | + | if !strings.Contains(compacted.Text, "## Context Summary (compacted)") || !strings.Contains(compacted.Text, activeInstruction) { | |||
| 113 | + | t.Fatalf("compacted transcript did not preserve active instruction:\n%s", compacted.Text) | |||
| 114 | + | } | |||
| 115 | + | if !strings.Contains(client.req.System, "Preserve active user instructions") { | |||
| 116 | + | t.Fatalf("compaction system prompt does not preserve active instructions: %q", client.req.System) | |||
| 117 | + | } | |||
| 118 | + | if len(client.req.Messages) != 1 { | |||
| 119 | + | t.Fatalf("compaction request messages = %d, want 1", len(client.req.Messages)) | |||
| 120 | + | } | |||
| 121 | + | text, ok := client.req.Messages[0].Content[0].(provider.TextBlock) | |||
| 122 | + | if !ok { | |||
| 123 | + | t.Fatalf("compaction request content = %#v, want TextBlock", client.req.Messages[0].Content[0]) | |||
| 124 | + | } | |||
| 125 | + | for _, want := range []string{"## Active Instructions & Preferences", "delegation/subagent", activeInstruction} { | |||
| 126 | + | if !strings.Contains(text.Text, want) { | |||
| 127 | + | t.Fatalf("compaction prompt missing %q:\n%s", want, text.Text) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | } | |||