Resync upstream runtime, rendering, session, and glob changes (#206)
* fix(provider): dedupe tool results, project tool images, gate deferred tools, retry codex cache fallback RepairOrphanedToolResults keeps the first result per call ID and drops duplicates and orphans. OpenAI-compatible tool messages are text-only; images ride once in a following user message (DeepSeek drops them, the persisted mirror is reused). The Moonshot deferred-tools handshake only applies to Moonshot providers; others use top-level activated tools. A 400 with param=prompt_cache_retention retries once with the native identity on the same session/request correlation. * fix(extensions): preserve startup error context and render multiline diagnostics Manifest and spawn failures identify the extension directory once, with name, quoted executable, declared language, cause, and stderr log path. Error wrapping keeps errors.Is(err, exec.ErrNotFound) working. renderErr and renderReloadErrors split explicit newlines before wrapping so each row is one bounded terminal line. * fix(tui): force full repaint on inaccessible bottom-band shrink A shorter bottom frame starting above the addressable viewport forces a full repaint so stale dialog rows cannot remain. Removed-row counts at or above the terminal height repaint fully without overwriting the recomputed viewport state with stale incremental values. * feat(compact): retain post-compaction context estimate Successful compaction exposes a deterministic timeline-based estimate for the status display. Immediate continuations bypass the pre-turn compaction guard until fresh provider usage arrives. * feat(tools): add bounded, confined glob file matching GlobTool supports *, ?, **, and character classes with slash-normalized matching, hidden opt-in, inherited ignore rules, jail/symlink checks, and depth (24) and result (500) bounds with sorted output. Registered beside grep in the default, research, orchestrator, and summary surfaces. * feat(sessions): add picker deletion and standalone prune command The session picker confirms deletion with y, refuses the active session, and removes rows from every projection without racing async load/search generations. zut sessions prune scans stored sessions by recorded cwd, supports dry-run, age and cwd filters, rechecks before deleting, and preserves symlinks and malformed entries. * docs: describe glob, session deletion, and prune command Present-state documentation for the glob tool, picker deletion keys, and the sessions prune command with its filters and safety behavior. * fix(review): close picker-delete race, unify DeepSeek text-only routing Move sessionDialog.Remove under i.mu since the render scheduler renders the dialog concurrently. Apply the DeepSeek text-only wire to DeepSeek models routed through opencode-go, matching the developer-role branch. * fix(review): harden session scanner against non-regular entries Report FIFOs, devices, and other non-regular files instead of opening them; report a symlinked sessions root; skip tree-navigation branches hidden from the flat picker. * fix(review): walk explicit ignored glob roots, polish diagnostics and docs An explicitly requested search root is always walked with nested ignore files still applying. Support [!...] negation, skip symlinked directory targets, and keep the requested root name in results. Fix dry-run wording without --all, indent ext doctor continuations, normalize CRLF in error rows, and document prune/session limits.
- commit
- e363e1080a40d678c0fcea316f7ee6b94092e496
- author
- bnema · · 2026-09-12 16:51 UTC
- committer
- GitHub · · 2026-09-12 16:51 UTC
- parent
- f7eb314
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v0.37.0
38 files · +3690 -113
M README.md +20 -5
| @@ -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 | - | - fifteen core built-in tools (read, write, edit, bash, python, create_worktree, grep, lsp, web_search, web_open, web_find, web_click, update_goal, update_plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries. | |
| 12 | + | - seventeen core built-in tools (read, write, edit, bash, python, create_worktree, grep, glob, ast, lsp, web_search, web_open, web_find, web_click, update_goal, update_plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web 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). | |
| @@ -264,6 +264,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||
| 264 | 264 | - `write`: create or overwrite files, making parent directories as needed. | |
| 265 | 265 | - `edit`: one or more exact-match replacements in an existing file. | |
| 266 | 266 | - `grep`: search a file or directory for a regular expression using ripgrep when available, with a portable grep fallback. Search output is bounded and follows the active filesystem read scope; it never invokes a shell. The fallback's regular-expression syntax and ignore/hidden-file behavior can differ from ripgrep. | |
| 267 | + | - `glob`: find files matching a glob pattern (`**/*.go`, `*.json`, `src/**/*.ts`) within a directory tree. Supports `*`, `?`, `**`, and character classes; patterns with a slash match relative paths, others match basenames. Honors `.gitignore` rules including nested files, skips hidden entries unless asked, never follows nested directory symlinks, and bounds depth (24) and matches (500, sorted). | |
| 267 | 268 | - `ast`: search or rewrite source code structurally with an [ast-grep](https://ast-grep.github.io/) pattern. Prefer it when the operation depends on code syntax; use `grep`/`edit` for plain text. Zut starts an installed `ast-grep` or `sg` executable directly without a shell. Searches return compact `path:line` matches. Rewrites are planned in memory, shown as a diff for confirmation, checked against the active filesystem scope, applied with file permissions preserved, and followed by bounded diagnostics. Install ast-grep separately; the tool returns a clear error when neither executable is available. | |
| 268 | 269 | - `bash`: run a command in the session cwd with merged stdout/stderr. Every call must include a positive integer `timeout` in seconds; calls without it or with `timeout <= 0` are rejected, and the deadline is always enforced. 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. | |
| 269 | 270 | - `python`: execute inline Python 3 code with an existing interpreter, never installing one. Every call must include nonblank `code` and a positive integer `timeout` in seconds (shared by interpreter discovery and execution). Each call runs a fresh stateless subprocess directly (`-u -B -`, code on stdin) in the session cwd with merged stdout/stderr; there is no persistent interpreter state, no script file, and no bytecode cache. Interactive input is unsupported (stdin observes EOF). Output is bounded (50 KiB, 2,000 lines) with explicit truncation marks; truncated results persist the byte-capped buffer to a temp file referenced as `full output` with its path in `full_output_path`, mirroring `bash`. Discovery order: `python_interpreter` in `$ZUT_HOME/config.json` (one executable path; relative paths resolve against the session cwd), then the active `VIRTUAL_ENV`, then `./.venv`, then `python3`/`python` on `PATH`. On Windows, Store aliases, `py` launchers, and install-manager entrypoints are rejected; only positively identified installed interpreters run. Missing packages fail with the ordinary import traceback. Tool arguments and output are recorded in the session like other tools; files the code deliberately writes persist. Save reusable scripts with `write`/`edit` instead of using `python` as an opaque file-edit path. Unavailable to packaged agents, and rejected while jailed (see below). | |
| @@ -276,7 +277,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||
| 276 | 277 | ||
| 277 | 278 | Tool results carry resource provenance in the durable session. Before each provider request, Zut replaces an older read only when an equivalent newer view supersedes it, and replaces a multi-file search only after every discovered file has been consumed or mutated. This provider-only projection removes known-stale context without rewriting session history. Because an older projected result changes, prompt caching restarts from that point; compact deterministic markers limit the cost. | |
| 278 | 279 | ||
| 279 | - | When the sandbox is on (see `/jail`), filesystem tools, `grep`, `ast`, 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. `python` is rejected outright while jailed: arbitrary Python cannot be confined by the current jail (admission-time check; use `/unjail`). When admitted, `python` has unrestricted host filesystem/network/process access like `bash`. Jail does not sandbox web-search network egress. | |
| 280 | + | When the sandbox is on (see `/jail`), filesystem tools, `grep`, `glob`, `ast`, 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. `python` is rejected outright while jailed: arbitrary Python cannot be confined by the current jail (admission-time check; use `/unjail`). When admitted, `python` has unrestricted host filesystem/network/process access like `bash`. Jail does not sandbox web-search network egress. | |
| 280 | 281 | ||
| 281 | 282 | ## Modes | |
| 282 | 283 | ||
| @@ -381,7 +382,7 @@ Type `!` followed by a command to run it directly without going through the mode | |||
| 381 | 382 | ||
| 382 | 383 | ### `/sessions` | |
| 383 | 384 | ||
| 384 | - | Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the session title. Press `Tab` to rotate between **this directory** and **all sessions** in the active normal or Zutfile-agent namespace; other agent namespaces never appear. Press `/` and type at least two characters to search contiguous text in an ephemeral in-memory corpus of persisted user and assistant messages. Tool calls, tool results, images, developer context, and compaction payloads are excluded. The picker displays each matching session's count and a highlighted first matching excerpt; changing a query reuses the corpus rather than reopening transcripts. Session entries and search corpus load in the background. Once an entry appears, `up`/`down` and `enter` can select it immediately; `esc` clears a search or cancels and closes the picker. Selecting a session from another working directory switches the live workspace to that session's recorded directory before the transcript resumes, so tools, instructions, sandbox state, and file suggestions do not retain the old directory. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if a staged switch fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. `/sessions` is unavailable with `--no-session`. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed. | |
| 385 | + | Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the session title. Press `Tab` to rotate between **this directory** and **all sessions** in the active normal or Zutfile-agent namespace; other agent namespaces never appear. Press `/` and type at least two characters to search contiguous text in an ephemeral in-memory corpus of persisted user and assistant messages. Tool calls, tool results, images, developer context, and compaction payloads are excluded. The picker displays each matching session's count and a highlighted first matching excerpt; changing a query reuses the corpus rather than reopening transcripts. Session entries and search corpus load in the background. Once an entry appears, `up`/`down` and `enter` can select it immediately; `r` renames, `d` deletes after confirmation (the active session is protected, but sessions open in other zut processes cannot be detected and are deleted like any other file), and `esc` clears a search or cancels and closes the picker. Selecting a session from another working directory switches the live workspace to that session's recorded directory before the transcript resumes, so tools, instructions, sandbox state, and file suggestions do not retain the old directory. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if a staged switch fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. `/sessions` is unavailable with `--no-session`. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed. | |
| 385 | 386 | ||
| 386 | 387 | ### `/session` | |
| 387 | 388 | ||
| @@ -526,6 +527,20 @@ This is a guardrail against accidents, not a hard security boundary. If you need | |||
| 526 | 527 | ||
| 527 | 528 | Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZUT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. Session metadata stores the provider and model used by that session; resume honors that pair, rebuilding the active agent when it differs and refusing the switch without discarding the current session if rebuilding fails. Usage checkpoints may include sanitized `retry_lifecycle` metadata—attempts, backoff delays, terminal state, and an allowlisted failure category—but never raw provider errors or response bodies. This metadata is ignored when rebuilding provider context. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy. | |
| 528 | 529 | ||
| 530 | + | Use `zut sessions prune` outside the TUI to find sessions whose recorded working directories no longer exist. The command groups sessions by directory, shows the stored session count and size, lets you select groups, and requires confirmation before permanently deleting files. It preserves sessions when a directory check fails with an error other than "not found" and rechecks each selected directory immediately before deletion. A deleted directory and a path hidden by some unmounted filesystems are indistinguishable, and sessions open in another running zut process cannot be detected, so review the selection, ensure remote filesystems are mounted, and prune only what no live session is using before deleting missing-directory groups. | |
| 531 | + | ||
| 532 | + | Use `--older-than` to prune sessions by last activity instead, including sessions for directories that still exist. Supported age units are `m` (minutes), `h` (hours), `d` (24-hour days), `w` (7-day weeks), `mo` (calendar months), and `y` (calendar years). Add `--cwd PATH` to limit age pruning to one working directory. Each session file is checked again immediately before deletion and preserved if it has become active recently. | |
| 533 | + | ||
| 534 | + | ```sh | |
| 535 | + | zut sessions prune --dry-run # list missing-directory groups | |
| 536 | + | zut sessions prune --older-than 4h --dry-run # inspect sessions inactive for four hours | |
| 537 | + | zut sessions prune --older-than 1mo --cwd ~/project # select old sessions for one directory | |
| 538 | + | zut sessions prune --older-than 1y --all # select every match, then confirm | |
| 539 | + | zut sessions prune --older-than 30d --all --yes # delete every match without prompting | |
| 540 | + | ``` | |
| 541 | + | ||
| 542 | + | `--all` selects every match and still asks for confirmation. Adding `--yes` makes deletion non-interactive and requires `--all`. Malformed, unreadable, and symlinked session entries are reported and preserved. The scan covers `$ZUT_HOME/sessions/` including named-agent namespaces; explicit session files stored elsewhere are not included. | |
| 543 | + | ||
| 529 | 544 | ## Providers | |
| 530 | 545 | ||
| 531 | 546 | zut's built-in provider catalog includes: | |
| @@ -974,7 +989,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`, | |||
| 974 | 989 | | `ctrl+d` | Exit on empty input. | | |
| 975 | 990 | | `ctrl+l` | Redraw the screen. | | |
| 976 | 991 | | `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. | | |
| 977 | - | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, grep, lsp, and web_search outputs over ~12 lines). | | |
| 992 | + | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, grep, glob, lsp, and web_search outputs over ~12 lines). | | |
| 978 | 993 | | `ctrl+r` | Cycle through the reasoning levels supported by the active model. The selected level applies to subsequent model calls and persists to `config.json`. | | |
| 979 | 994 | | `ctrl+1` ... `ctrl+9` | Activate a persistent model + reasoning + fast-mode profile; empty slots open the model picker. French AZERTY Ctrl + top-row symbols also work when forwarded by the terminal. Rejected while a turn is running. See [Model profiles](#model-profiles); `/profile N` is the terminal-independent fallback. | | |
| 980 | 995 | | `@` | Open the file picker. Browse files and directories in the working directory. | | |
| @@ -1158,7 +1173,7 @@ packages/agent/extproto/ extension wire-format types | |||
| 1158 | 1173 | packages/agent/modes/ interactive tui, print, json, dialogs | |
| 1159 | 1174 | packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface) | |
| 1160 | 1175 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | |
| 1161 | - | packages/agent/tools/ read, write, edit, bash, create_worktree, grep, lsp, web search, goals, sandbox | |
| 1176 | + | packages/agent/tools/ read, write, edit, bash, create_worktree, grep, glob, lsp, web search, goals, sandbox | |
| 1162 | 1177 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | |
| 1163 | 1178 | packages/agent/subagents/ named profiles and resident background runtime | |
| 1164 | 1179 | packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk) | |
M docs/extensions.md +5 -1
| @@ -776,7 +776,11 @@ error in red, including its message, and dismisses itself after five | |||
| 776 | 776 | seconds. Extension failures from startup and `/reload-ext` remain in the | |
| 777 | 777 | scrolling chat until a successful reload or `/clear`, without being sent to | |
| 778 | 778 | the model or saved in the session transcript. Subprocess startup and | |
| 779 | - | handshake errors include the exact stderr log path for the failed extension. | |
| 779 | + | handshake errors identify the extension and its directory, configured executable, | |
| 780 | + | and declared language (if present), along with the original error and stderr log | |
| 781 | + | path when the log was opened. Manifest errors also identify the extension | |
| 782 | + | directory. Multiline diagnostics preserve their line breaks in the `/reload-ext` | |
| 783 | + | status and scrolling chat, and in `zut ext doctor` output. | |
| 780 | 784 | If a subprocess exits before sending `hello`, the error also reports its exit | |
| 781 | 785 | status or terminating signal. | |
| 782 | 786 | ||
M packages/agent/args.go +5 -0
| @@ -427,6 +427,11 @@ func PrintHelp(version string) { | |||
| 427 | 427 | row{"zut --ext ./path/to/ext", "load an extension for this run only"}, | |
| 428 | 428 | row{"zut ext help", "show all extension subcommands"}, | |
| 429 | 429 | ) | |
| 430 | + | section("session management", | |
| 431 | + | row{"zut sessions prune", "select and delete sessions for directories that no longer exist"}, | |
| 432 | + | row{"zut sessions prune --dry-run", "list stale session groups without deleting them"}, | |
| 433 | + | row{"zut sessions prune --older-than 30d", "select sessions inactive for 30 days"}, | |
| 434 | + | ) | |
| 430 | 435 | section("self-update", | |
| 431 | 436 | row{"zut update", "download and install the latest release"}, | |
| 432 | 437 | row{"zut update --check", "show whether a new release is available"}, | |
M packages/agent/build.go +6 -2
| @@ -123,7 +123,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) { | |||
| 123 | 123 | // Web capability and other native names remain reserved even when their | |
| 124 | 124 | // policy excludes the current session. An extension must not turn a | |
| 125 | 125 | // normal CLI opt-out into a differently implemented capability. | |
| 126 | - | if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "ast" || info.Name == "python" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.UpdatePlanToolName { | |
| 126 | + | if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "glob" || info.Name == "ast" || info.Name == "python" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.UpdatePlanToolName { | |
| 127 | 127 | continue | |
| 128 | 128 | } | |
| 129 | 129 | if _, exists := r.ToolRegistry[info.Name]; exists { | |
| @@ -1318,6 +1318,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) { | |||
| 1318 | 1318 | v.Sandbox = s | |
| 1319 | 1319 | case *tools.GrepTool: | |
| 1320 | 1320 | v.Sandbox = s | |
| 1321 | + | case *tools.GlobTool: | |
| 1322 | + | v.Sandbox = s | |
| 1321 | 1323 | case *tools.ASTTool: | |
| 1322 | 1324 | v.Sandbox = s | |
| 1323 | 1325 | } | |
| @@ -1356,6 +1358,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||
| 1356 | 1358 | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, | |
| 1357 | 1359 | "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox}, | |
| 1358 | 1360 | "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox}, | |
| 1361 | + | "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox}, | |
| 1359 | 1362 | "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, | |
| 1360 | 1363 | "update_goal": &tools.UpdateGoalTool{}, | |
| 1361 | 1364 | "update_plan": &tools.UpdatePlanTool{}, | |
| @@ -1394,6 +1397,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||
| 1394 | 1397 | if orchestratorExplorationToolsAllowed(args) { | |
| 1395 | 1398 | reg["read"] = all["read"] | |
| 1396 | 1399 | reg["grep"] = all["grep"] | |
| 1400 | + | reg["glob"] = all["glob"] | |
| 1397 | 1401 | if astTool, ok := all["ast"].(*tools.ASTTool); ok { | |
| 1398 | 1402 | astTool.ReadOnly = true | |
| 1399 | 1403 | reg["ast"] = astTool | |
| @@ -1504,7 +1508,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool { | |||
| 1504 | 1508 | return false | |
| 1505 | 1509 | } | |
| 1506 | 1510 | ||
| 1507 | - | var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "ast", "bash", "python", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"} | |
| 1511 | + | var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"} | |
| 1508 | 1512 | ||
| 1509 | 1513 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | |
| 1510 | 1514 | var out []ToolSummary | |
M packages/agent/build_grep_test.go +34 -0
| @@ -46,6 +46,40 @@ func TestBuildToolRegistryIncludesUpdatePlanByDefaultAndExplicitSelection(t *tes | |||
| 46 | 46 | } | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | + | func TestBuildToolRegistryGlobSelectionAndDefaults(t *testing.T) { | |
| 50 | + | cwd := t.TempDir() | |
| 51 | + | ||
| 52 | + | defaultRegistry := buildToolRegistry(Args{}, cwd, nil, false, false, false) | |
| 53 | + | if _, ok := defaultRegistry["glob"].(*tools.GlobTool); !ok { | |
| 54 | + | t.Fatalf("default glob has type %T", defaultRegistry["glob"]) | |
| 55 | + | } | |
| 56 | + | defaultSummaries := toolSummaries(defaultRegistry, Args{}) | |
| 57 | + | globIndex := -1 | |
| 58 | + | for i, summary := range defaultSummaries { | |
| 59 | + | if summary.Name == "glob" { | |
| 60 | + | globIndex = i | |
| 61 | + | break | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | if globIndex < 0 { | |
| 65 | + | t.Fatal("default tool summaries do not contain glob") | |
| 66 | + | } | |
| 67 | + | if globIndex == 0 || defaultSummaries[globIndex-1].Name != "grep" { | |
| 68 | + | t.Fatalf("glob summary order has predecessor %q, want grep", defaultSummaries[globIndex-1].Name) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | selected := buildToolRegistry(Args{Tools: []string{"glob"}}, cwd, nil, false, false, false) | |
| 72 | + | if len(selected) != 1 { | |
| 73 | + | t.Fatalf("selected registry has %d tools, want one", len(selected)) | |
| 74 | + | } | |
| 75 | + | if _, ok := selected["glob"].(*tools.GlobTool); !ok { | |
| 76 | + | t.Fatalf("selected glob has type %T", selected["glob"]) | |
| 77 | + | } | |
| 78 | + | if summaries := toolSummaries(selected, Args{Tools: []string{"glob"}}); len(summaries) != 1 || summaries[0].Name != "glob" { | |
| 79 | + | t.Fatalf("selected summaries = %#v, want only glob", summaries) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 49 | 83 | func TestBuildToolRegistryGrepSelectionAndDefaults(t *testing.T) { | |
| 50 | 84 | cwd := t.TempDir() | |
| 51 | 85 | ||
M packages/agent/build_orchestrator_test.go +3 -0
| @@ -126,6 +126,9 @@ func TestOrchestratorAlwaysReceivesResearchTools(t *testing.T) { | |||
| 126 | 126 | if _, ok := registry["grep"].(*tools.GrepTool); ok != tc.wantResearch { | |
| 127 | 127 | t.Fatalf("grep = %T, want research tool present = %v", registry["grep"], tc.wantResearch) | |
| 128 | 128 | } | |
| 129 | + | if _, ok := registry["glob"].(*tools.GlobTool); ok != tc.wantResearch { | |
| 130 | + | t.Fatalf("glob = %T, want research tool present = %v", registry["glob"], tc.wantResearch) | |
| 131 | + | } | |
| 129 | 132 | for _, name := range tools.WebCapabilityNames { | |
| 130 | 133 | _, gotWebTool := registry[name] | |
| 131 | 134 | if gotWebTool != tc.wantWeb { | |
M packages/agent/cli.go +3 -0
| @@ -867,6 +867,9 @@ func Run(rawArgs []string, version string) error { | |||
| 867 | 867 | if handled, err := runUpdateCommand(rawArgs, version); handled { | |
| 868 | 868 | return err | |
| 869 | 869 | } | |
| 870 | + | if handled, err := runSessionsCommand(rawArgs); handled { | |
| 871 | + | return err | |
| 872 | + | } | |
| 870 | 873 | if handled, err := runZutfileCommand(rawArgs, version); handled { | |
| 871 | 874 | return err | |
| 872 | 875 | } | |
M packages/agent/ext/ext.go +4 -3
| @@ -421,9 +421,10 @@ func (e *Extension) Command(name, description string, fn CommandHandler) { | |||
| 421 | 421 | // into the agent's registry once the extension's ready frame fires. | |
| 422 | 422 | // | |
| 423 | 423 | // Conflicts with active built-in tool names (read, write, edit, bash, | |
| 424 | - | // python, create_worktree, grep, ast, lsp, web_search, web_open, web_find, | |
| 425 | - | // web_click, schedule, update_goal, update_plan, and skill) are silently | |
| 426 | - | // shadowed by the built-in. Every native public-web name, plus grep, ast, python, schedule, | |
| 424 | + | // python, create_worktree, grep, glob, ast, lsp, web_search, web_open, | |
| 425 | + | // web_find, web_click, schedule, update_goal, update_plan, and skill) are | |
| 426 | + | // silently shadowed by the built-in. Every native public-web name, plus | |
| 427 | + | // grep, glob, ast, python, schedule, | |
| 427 | 428 | // update_goal, and update_plan, is reserved even when unavailable, so an | |
| 428 | 429 | // extension cannot claim or replace it. | |
| 429 | 430 | func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) { | |
M packages/agent/extcmd.go +4 -1
| @@ -195,7 +195,10 @@ func extDoctor(version string) error { | |||
| 195 | 195 | if len(errs) > 0 { | |
| 196 | 196 | fmt.Fprintln(os.Stdout, "load errors:") | |
| 197 | 197 | for _, err := range errs { | |
| 198 | - | fmt.Fprintf(os.Stdout, " ! %v\n", err) | |
| 198 | + | // Multiline diagnostics keep their line breaks; indent | |
| 199 | + | // continuations so they read as one entry. | |
| 200 | + | indented := strings.ReplaceAll(err.Error(), "\n", "\n ") | |
| 201 | + | fmt.Fprintf(os.Stdout, " ! %s\n", indented) | |
| 199 | 202 | } | |
| 200 | 203 | } | |
| 201 | 204 | return nil | |
M packages/agent/extensions/manager.go +16 -8
| @@ -308,7 +308,7 @@ func (m *Manager) Discover(ctx context.Context) []error { | |||
| 308 | 308 | go func(extDir string) { | |
| 309 | 309 | defer wg.Done() | |
| 310 | 310 | if err := m.loadOne(ctx, extDir); err != nil { | |
| 311 | - | errCh <- fmt.Errorf("%s: %w", extDir, err) | |
| 311 | + | errCh <- err | |
| 312 | 312 | } | |
| 313 | 313 | }(j.dir) | |
| 314 | 314 | } | |
| @@ -342,18 +342,18 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error { | |||
| 342 | 342 | manifestPath := filepath.Join(dir, "extension.json") | |
| 343 | 343 | raw, err := os.ReadFile(manifestPath) | |
| 344 | 344 | if err != nil { | |
| 345 | - | return fmt.Errorf("read manifest: %w", err) | |
| 345 | + | return fmt.Errorf("%s: read manifest: %w", dir, err) | |
| 346 | 346 | } | |
| 347 | 347 | var mf Manifest | |
| 348 | 348 | if err := json.Unmarshal(raw, &mf); err != nil { | |
| 349 | - | return fmt.Errorf("parse manifest: %w", err) | |
| 349 | + | return fmt.Errorf("%s: parse manifest: %w", dir, err) | |
| 350 | 350 | } | |
| 351 | 351 | if mf.Name == "" { | |
| 352 | - | return errors.New("manifest: name is required") | |
| 352 | + | return fmt.Errorf("%s: manifest: name is required", dir) | |
| 353 | 353 | } | |
| 354 | 354 | hasTheme := HasExtensionTheme(dir) | |
| 355 | 355 | if mf.Exec == "" && !hasTheme { | |
| 356 | - | return errors.New("manifest: exec is required") | |
| 356 | + | return fmt.Errorf("%s: manifest: exec is required", dir) | |
| 357 | 357 | } | |
| 358 | 358 | if !mf.IsEnabled() { | |
| 359 | 359 | // Quietly skip disabled extensions; zut ext list will show them. | |
| @@ -387,7 +387,15 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error { | |||
| 387 | 387 | } | |
| 388 | 388 | if mf.Exec != "" { | |
| 389 | 389 | if err := m.spawn(ctx, ext); err != nil { | |
| 390 | - | return err | |
| 390 | + | language := "" | |
| 391 | + | if mf.Language != "" { | |
| 392 | + | language = fmt.Sprintf(" (declared language: %q)", mf.Language) | |
| 393 | + | } | |
| 394 | + | logDetail := "" | |
| 395 | + | if ext.LogPath != "" { | |
| 396 | + | logDetail = "\n stderr log: " + ext.LogPath | |
| 397 | + | } | |
| 398 | + | return fmt.Errorf("Extension %s failed to start.\n\n exec: %q%s\n error: %w\n extension directory: %s%s", mf.Name, mf.Exec, language, err, dir, logDetail) | |
| 391 | 399 | } | |
| 392 | 400 | } else { | |
| 393 | 401 | ext.readyOnce.Do(func() { close(ext.readyCh) }) | |
| @@ -462,7 +470,7 @@ func (m *Manager) LoadExplicit(ctx context.Context, paths []string) []error { | |||
| 462 | 470 | go func(extDir string) { | |
| 463 | 471 | defer wg.Done() | |
| 464 | 472 | if err := m.loadOne(ctx, extDir); err != nil { | |
| 465 | - | errCh <- fmt.Errorf("%s: %w", extDir, err) | |
| 473 | + | errCh <- err | |
| 466 | 474 | } | |
| 467 | 475 | }(abs) | |
| 468 | 476 | } | |
| @@ -681,7 +689,7 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error { | |||
| 681 | 689 | return fmt.Errorf("stdout pipe (stderr log: %s): %w", logPath, err) | |
| 682 | 690 | } | |
| 683 | 691 | if err := cmd.Start(); err != nil { | |
| 684 | - | return fmt.Errorf("spawn (stderr log: %s): %w", logPath, err) | |
| 692 | + | return err | |
| 685 | 693 | } | |
| 686 | 694 | started = true | |
| 687 | 695 | ext.cmd = cmd | |
A packages/agent/extensions/manager_diagnostics_test.go +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | package extensions | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 7 | + | "os/exec" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestLoadErrorsIdentifyExtensionDirectory(t *testing.T) { | |
| 14 | + | for _, explicit := range []bool{false, true} { | |
| 15 | + | mode := "discover" | |
| 16 | + | if explicit { | |
| 17 | + | mode = "explicit" | |
| 18 | + | } | |
| 19 | + | for _, tc := range []struct { | |
| 20 | + | name string | |
| 21 | + | manifest string | |
| 22 | + | want string | |
| 23 | + | }{ | |
| 24 | + | {"invalid-json", `{`, "parse manifest"}, | |
| 25 | + | {"missing-name", `{"exec":"missing"}`, "name is required"}, | |
| 26 | + | {"missing-exec", `{"name":"broken"}`, "exec is required"}, | |
| 27 | + | {"open-log", `{"name":"broken","exec":"missing"}`, "open log"}, | |
| 28 | + | {"missing-runtime", `{"name":"broken","exec":"zut-test-missing-runtime"}`, "failed to start"}, | |
| 29 | + | } { | |
| 30 | + | t.Run(mode+"/"+tc.name, func(t *testing.T) { | |
| 31 | + | home := t.TempDir() | |
| 32 | + | dir := filepath.Join(home, "extensions", tc.name) | |
| 33 | + | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | if err := os.WriteFile(filepath.Join(dir, "extension.json"), []byte(tc.manifest), 0o644); err != nil { | |
| 37 | + | t.Fatal(err) | |
| 38 | + | } | |
| 39 | + | if tc.name == "open-log" { | |
| 40 | + | if err := os.WriteFile(filepath.Join(home, "logs"), nil, 0o644); err != nil { | |
| 41 | + | t.Fatal(err) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | if tc.name == "missing-runtime" { | |
| 45 | + | t.Setenv("PATH", t.TempDir()) | |
| 46 | + | } | |
| 47 | + | mgr := New(home, "", "test", "", "", nil) | |
| 48 | + | var errs []error | |
| 49 | + | if explicit { | |
| 50 | + | errs = mgr.LoadExplicit(context.Background(), []string{dir}) | |
| 51 | + | } else { | |
| 52 | + | errs = mgr.Discover(context.Background()) | |
| 53 | + | } | |
| 54 | + | if len(errs) != 1 { | |
| 55 | + | t.Fatalf("errors = %v, want one", errs) | |
| 56 | + | } | |
| 57 | + | if got := errs[0].Error(); !strings.Contains(got, dir) || !strings.Contains(got, tc.want) { | |
| 58 | + | t.Fatalf("error = %q, want directory %q and %q", got, dir, tc.want) | |
| 59 | + | } | |
| 60 | + | if tc.name == "missing-runtime" { | |
| 61 | + | if !errors.Is(errs[0], exec.ErrNotFound) { | |
| 62 | + | t.Fatalf("error does not wrap exec.ErrNotFound: %v", errs[0]) | |
| 63 | + | } | |
| 64 | + | if strings.Count(errs[0].Error(), dir) != 1 { | |
| 65 | + | t.Fatalf("directory should appear once: %v", errs[0]) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | }) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestDiscoverReportsMissingRuntimeWhenExtensionCannotStart(t *testing.T) { | |
| 74 | + | tmp := t.TempDir() | |
| 75 | + | extDir := filepath.Join(tmp, "extensions", "missing-runtime") | |
| 76 | + | if err := os.MkdirAll(extDir, 0o755); err != nil { | |
| 77 | + | t.Fatal(err) | |
| 78 | + | } | |
| 79 | + | if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"missing-runtime","exec":"python3","language":"python"}`), 0o644); err != nil { | |
| 80 | + | t.Fatal(err) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // Isolate PATH so this tests a missing language runtime rather than | |
| 84 | + | // relying on a made-up executable name that could theoretically exist. | |
| 85 | + | t.Setenv("PATH", filepath.Join(tmp, "empty-bin")) | |
| 86 | + | mgr := New(tmp, "", "0.0.0-test", "", "", nil) | |
| 87 | + | errs := mgr.Discover(context.Background()) | |
| 88 | + | if len(errs) != 1 { | |
| 89 | + | t.Fatalf("discover errors = %v, want one", errs) | |
| 90 | + | } | |
| 91 | + | got := errs[0].Error() | |
| 92 | + | for _, want := range []string{ | |
| 93 | + | `Extension missing-runtime failed to start.`, | |
| 94 | + | `exec: "python3"`, | |
| 95 | + | `declared language: "python"`, | |
| 96 | + | "executable file not found", | |
| 97 | + | extDir, | |
| 98 | + | } { | |
| 99 | + | if !strings.Contains(got, want) { | |
| 100 | + | t.Fatalf("error = %q, want it to contain %q", got, want) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | if strings.Count(got, extDir) != 1 { | |
| 104 | + | t.Fatalf("directory should appear once: %q", got) | |
| 105 | + | } | |
| 106 | + | } | |
M packages/agent/modes/compact_queue_test.go +141 -0
| @@ -1575,3 +1575,144 @@ func requestImageCount(req provider.Request, want provider.ImageBlock) int { | |||
| 1575 | 1575 | } | |
| 1576 | 1576 | return count | |
| 1577 | 1577 | } | |
| 1578 | + | ||
| 1579 | + | func TestCompactionRetainsContextEstimate(t *testing.T) { | |
| 1580 | + | client := &compactQueueClient{ | |
| 1581 | + | compactionStarted: make(chan struct{}), | |
| 1582 | + | releaseCompaction: make(chan struct{}), | |
| 1583 | + | followUpRequest: make(chan provider.Request, 1), | |
| 1584 | + | } | |
| 1585 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 1586 | + | agent.SetMessages([]provider.Message{ | |
| 1587 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}, | |
| 1588 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}}, | |
| 1589 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}}, | |
| 1590 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}}, | |
| 1591 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}}, | |
| 1592 | + | }) | |
| 1593 | + | interactive := NewInteractive(InteractiveConfig{Agent: agent}) | |
| 1594 | + | ||
| 1595 | + | interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginManual}) | |
| 1596 | + | select { | |
| 1597 | + | case <-client.compactionStarted: | |
| 1598 | + | case <-time.After(2 * time.Second): | |
| 1599 | + | t.Fatal("compaction did not start") | |
| 1600 | + | } | |
| 1601 | + | close(client.releaseCompaction) | |
| 1602 | + | ||
| 1603 | + | deadline := time.NewTimer(5 * time.Second) | |
| 1604 | + | defer deadline.Stop() | |
| 1605 | + | poll := time.NewTicker(time.Millisecond) | |
| 1606 | + | defer poll.Stop() | |
| 1607 | + | for { | |
| 1608 | + | interactive.mu.Lock() | |
| 1609 | + | busy := interactive.busy | |
| 1610 | + | got := interactive.lastCtxInput | |
| 1611 | + | interactive.mu.Unlock() | |
| 1612 | + | if !busy { | |
| 1613 | + | want := estimateCompactedContextInput(agent.Messages()) | |
| 1614 | + | if got != want || got == 0 { | |
| 1615 | + | t.Fatalf("context estimate after compaction = %d, want %d", got, want) | |
| 1616 | + | } | |
| 1617 | + | return | |
| 1618 | + | } | |
| 1619 | + | select { | |
| 1620 | + | case <-deadline.C: | |
| 1621 | + | t.Fatal("compaction did not finish") | |
| 1622 | + | case <-poll.C: | |
| 1623 | + | } | |
| 1624 | + | } | |
| 1625 | + | } | |
| 1626 | + | ||
| 1627 | + | func TestCompactionEstimateBypassesImmediateContinuation(t *testing.T) { | |
| 1628 | + | client := &compactQueueClient{ | |
| 1629 | + | compactionStarted: make(chan struct{}), | |
| 1630 | + | releaseCompaction: make(chan struct{}), | |
| 1631 | + | followUpRequest: make(chan provider.Request, 1), | |
| 1632 | + | } | |
| 1633 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 1634 | + | agent.SetMessages([]provider.Message{ | |
| 1635 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}, | |
| 1636 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}}, | |
| 1637 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}}, | |
| 1638 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}}, | |
| 1639 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}}, | |
| 1640 | + | }) | |
| 1641 | + | interactive := NewInteractive(InteractiveConfig{Agent: agent}) | |
| 1642 | + | interactive.runCtx = context.Background() | |
| 1643 | + | ||
| 1644 | + | interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginManual}) | |
| 1645 | + | select { | |
| 1646 | + | case <-client.compactionStarted: | |
| 1647 | + | case <-time.After(2 * time.Second): | |
| 1648 | + | t.Fatal("compaction did not start") | |
| 1649 | + | } | |
| 1650 | + | ||
| 1651 | + | interactive.ed.SetValue("follow up") | |
| 1652 | + | interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter}) | |
| 1653 | + | close(client.releaseCompaction) | |
| 1654 | + | ||
| 1655 | + | select { | |
| 1656 | + | case req := <-client.followUpRequest: | |
| 1657 | + | if !requestContainsUserText(req, "follow up") { | |
| 1658 | + | t.Fatalf("follow-up request does not contain queued prompt: %#v", req.Messages) | |
| 1659 | + | } | |
| 1660 | + | case <-time.After(5 * time.Second): | |
| 1661 | + | t.Fatal("queued prompt did not start a turn after compaction") | |
| 1662 | + | } | |
| 1663 | + | ||
| 1664 | + | // The immediate continuation runs against the just-compacted transcript, | |
| 1665 | + | // so the pre-turn guard must see a zero estimate until fresh provider | |
| 1666 | + | // usage arrives. The follow-up sends no usage, so the bypass must hold | |
| 1667 | + | // through the turn with no second compaction (exactly two provider calls). | |
| 1668 | + | deadline := time.NewTimer(5 * time.Second) | |
| 1669 | + | defer deadline.Stop() | |
| 1670 | + | poll := time.NewTicker(time.Millisecond) | |
| 1671 | + | defer poll.Stop() | |
| 1672 | + | for { | |
| 1673 | + | interactive.mu.Lock() | |
| 1674 | + | busy := interactive.busy | |
| 1675 | + | estimate := interactive.lastCtxInput | |
| 1676 | + | interactive.mu.Unlock() | |
| 1677 | + | client.mu.Lock() | |
| 1678 | + | calls := client.calls | |
| 1679 | + | client.mu.Unlock() | |
| 1680 | + | if !busy { | |
| 1681 | + | if estimate != 0 { | |
| 1682 | + | t.Fatalf("context estimate during immediate continuation = %d, want bypass 0", estimate) | |
| 1683 | + | } | |
| 1684 | + | if calls != 2 { | |
| 1685 | + | t.Fatalf("provider called %d times, want compaction plus one follow-up (no second compaction)", calls) | |
| 1686 | + | } | |
| 1687 | + | return | |
| 1688 | + | } | |
| 1689 | + | select { | |
| 1690 | + | case <-deadline.C: | |
| 1691 | + | t.Fatal("follow-up turn did not finish") | |
| 1692 | + | case <-poll.C: | |
| 1693 | + | } | |
| 1694 | + | } | |
| 1695 | + | } | |
| 1696 | + | ||
| 1697 | + | func TestEstimateCompactedContextInput(t *testing.T) { | |
| 1698 | + | if got := estimateCompactedContextInput(nil); got != 0 { | |
| 1699 | + | t.Fatalf("empty transcript estimate = %d, want 0", got) | |
| 1700 | + | } | |
| 1701 | + | msgs := []provider.Message{ | |
| 1702 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "1234"}}}, | |
| 1703 | + | {Role: provider.RoleAssistant, Content: []provider.Content{ | |
| 1704 | + | provider.TextBlock{Text: "5678"}, | |
| 1705 | + | provider.ToolCallBlock{ID: "call-1", Name: "read", Arguments: json.RawMessage(`{}`)}, | |
| 1706 | + | }}, | |
| 1707 | + | {Role: provider.RoleTool, Content: []provider.Content{ | |
| 1708 | + | provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{ | |
| 1709 | + | provider.TextBlock{Text: "01234567"}, | |
| 1710 | + | provider.ImageBlock{MimeType: "image/png", Data: []byte("ignored-bytes")}, | |
| 1711 | + | }}, | |
| 1712 | + | }}, | |
| 1713 | + | } | |
| 1714 | + | // 4 + 4 + len("read")+2 + 8 = 22 chars → 5 tokens; image bytes excluded. | |
| 1715 | + | if got, want := estimateCompactedContextInput(msgs), 5; got != want { | |
| 1716 | + | t.Fatalf("estimate = %d, want %d", got, want) | |
| 1717 | + | } | |
| 1718 | + | } | |
M packages/agent/modes/interactive_commands.go +36 -0
| @@ -1007,6 +1007,42 @@ func (i *Interactive) canResumeSessionSelection() bool { | |||
| 1007 | 1007 | return !busy && !queued && ag != nil && ag.QueuedMessageCount() == 0 | |
| 1008 | 1008 | } | |
| 1009 | 1009 | ||
| 1010 | + | // applySessionDeletion permanently removes a session selected in the | |
| 1011 | + | // picker. The active session is protected because unlinking its open file | |
| 1012 | + | // would lose subsequent transcript writes on Unix and behaves differently | |
| 1013 | + | // on Windows. | |
| 1014 | + | func (i *Interactive) applySessionDeletion(path string) { | |
| 1015 | + | if i.cfg.CurrentSessionPath == nil { | |
| 1016 | + | i.mu.Lock() | |
| 1017 | + | i.statusErr = "session deletion is not wired in this build" | |
| 1018 | + | i.statusOK = "" | |
| 1019 | + | i.mu.Unlock() | |
| 1020 | + | return | |
| 1021 | + | } | |
| 1022 | + | current := i.cfg.CurrentSessionPath() | |
| 1023 | + | if current != "" && filepath.Clean(current) == filepath.Clean(path) { | |
| 1024 | + | i.mu.Lock() | |
| 1025 | + | i.statusErr = "cannot delete the active session; resume another session first" | |
| 1026 | + | i.statusOK = "" | |
| 1027 | + | i.mu.Unlock() | |
| 1028 | + | return | |
| 1029 | + | } | |
| 1030 | + | if err := core.DeleteSession(path); err != nil { | |
| 1031 | + | i.mu.Lock() | |
| 1032 | + | i.statusErr = "delete session: " + err.Error() | |
| 1033 | + | i.statusOK = "" | |
| 1034 | + | i.mu.Unlock() | |
| 1035 | + | return | |
| 1036 | + | } | |
| 1037 | + | // Remove runs under the mutex: the render-scheduler goroutine assembles | |
| 1038 | + | // state and renders the dialog concurrently while it stays active. | |
| 1039 | + | i.mu.Lock() | |
| 1040 | + | i.sessionDialog.Remove(path) | |
| 1041 | + | i.statusOK = "deleted session: " + friendlyPath(path) | |
| 1042 | + | i.statusErr = "" | |
| 1043 | + | i.mu.Unlock() | |
| 1044 | + | } | |
| 1045 | + | ||
| 1010 | 1046 | func (i *Interactive) applySessionSelection(path string) { | |
| 1011 | 1047 | if i.cfg.LoadSession == nil { | |
| 1012 | 1048 | i.mu.Lock() | |
M packages/agent/modes/interactive_compaction.go +37 -1
| @@ -17,6 +17,35 @@ func (i *Interactive) clearPendingCompactTurnLocked() { | |||
| 17 | 17 | i.continueAfterCompact = false | |
| 18 | 18 | i.pendingPostCompactNote = "" | |
| 19 | 19 | } | |
| 20 | + | ||
| 21 | + | // estimateCompactedContextInput approximates the input tokens of the | |
| 22 | + | // just-compacted transcript for the status display. It uses the same rough | |
| 23 | + | // 1-token-per-4-chars rule as compaction's own estimate, summed over the | |
| 24 | + | // textual timeline content. Empty transcripts yield zero; image bytes are | |
| 25 | + | // not counted. This is display-only: the next provider usage replaces it, | |
| 26 | + | // and immediate continuations bypass the pre-turn guard until then. | |
| 27 | + | func estimateCompactedContextInput(msgs []provider.Message) int { | |
| 28 | + | total := 0 | |
| 29 | + | for _, msg := range msgs { | |
| 30 | + | for _, c := range msg.Content { | |
| 31 | + | switch v := c.(type) { | |
| 32 | + | case provider.TextBlock: | |
| 33 | + | total += len(v.Text) | |
| 34 | + | case provider.ToolCallBlock: | |
| 35 | + | total += len(v.Name) + len(v.Arguments) | |
| 36 | + | case provider.ToolResultBlock: | |
| 37 | + | for _, inner := range v.Content { | |
| 38 | + | if text, ok := inner.(provider.TextBlock); ok { | |
| 39 | + | total += len(text.Text) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | case provider.ReasoningBlock: | |
| 43 | + | total += len(v.Summary) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | return total / 4 | |
| 48 | + | } | |
| 20 | 49 | func (i *Interactive) compactHandoffLocked() json.RawMessage { | |
| 21 | 50 | return encodeCompactHandoff(i.compactContinuation) | |
| 22 | 51 | } | |
| @@ -322,7 +351,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 322 | 351 | } | |
| 323 | 352 | i.pendingPostCompactNote = "" | |
| 324 | 353 | i.extNotes = stripAutoCompactNotes(i.extNotes) | |
| 325 | - | i.lastCtxInput = 0 | |
| 354 | + | i.lastCtxInput = estimateCompactedContextInput(msgs) | |
| 326 | 355 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 327 | 356 | i.toolOrder = nil | |
| 328 | 357 | i.toolGate = map[string]int{} | |
| @@ -380,6 +409,13 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 380 | 409 | // the mutex. A completion update arriving after the compaction result | |
| 381 | 410 | // but before this assignment is then queued for the selected follow-up | |
| 382 | 411 | // instead of starting a competing turn. | |
| 412 | + | if hasNext || continueExisting || continueAutomatically || continueGoal { | |
| 413 | + | // The estimate above keeps the status display honest, but the | |
| 414 | + | // follow-up turn must not trip the pre-turn compaction guard | |
| 415 | + | // before fresh provider usage arrives: it runs against the | |
| 416 | + | // just-compacted transcript by construction. | |
| 417 | + | i.lastCtxInput = 0 | |
| 418 | + | } | |
| 383 | 419 | i.busy = hasNext || continueExisting || continueAutomatically || continueGoal | |
| 384 | 420 | i.compacting = false | |
| 385 | 421 | i.autoCompacting = false | |
M packages/agent/modes/interactive_input_routing.go +3 -0
| @@ -250,6 +250,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 250 | 250 | if act.Select { | |
| 251 | 251 | i.applySessionSelection(act.Path) | |
| 252 | 252 | } | |
| 253 | + | if act.Delete { | |
| 254 | + | i.applySessionDeletion(act.Path) | |
| 255 | + | } | |
| 253 | 256 | if act.Err != nil { | |
| 254 | 257 | if manualRenameCurrent { | |
| 255 | 258 | i.restoreFailedSessionTitle() | |
M packages/agent/modes/reload_ext.go +15 -9
| @@ -55,16 +55,22 @@ func renderReloadErrors(theme tui.Theme, errors []string, width int) []string { | |||
| 55 | 55 | } | |
| 56 | 56 | var out []string | |
| 57 | 57 | for _, msg := range errors { | |
| 58 | - | wrapped := tui.WrapANSILine(msg, wrapWidth) | |
| 59 | - | if len(wrapped) == 0 { | |
| 60 | - | wrapped = []string{""} | |
| 61 | - | } | |
| 62 | - | for idx, line := range wrapped { | |
| 63 | - | prefix := marker | |
| 64 | - | if idx > 0 { | |
| 65 | - | prefix = indent | |
| 58 | + | // Split explicit newlines before wrapping. Passing a multiline string | |
| 59 | + | // to WrapANSILine would leave the terminal to interpret the embedded | |
| 60 | + | // newline, which can misalign the continuation text. Normalize CRLF | |
| 61 | + | // first so no raw CR survives inside a row. | |
| 62 | + | for lineIndex, paragraph := range strings.Split(strings.ReplaceAll(msg, "\r\n", "\n"), "\n") { | |
| 63 | + | wrapped := tui.WrapANSILine(paragraph, wrapWidth) | |
| 64 | + | if len(wrapped) == 0 { | |
| 65 | + | wrapped = []string{""} | |
| 66 | + | } | |
| 67 | + | for wrapIndex, line := range wrapped { | |
| 68 | + | prefix := indent | |
| 69 | + | if lineIndex == 0 && wrapIndex == 0 { | |
| 70 | + | prefix = marker | |
| 71 | + | } | |
| 72 | + | out = append(out, theme.FGColor(theme.Error, prefix+line)) | |
| 66 | 73 | } | |
| 67 | - | out = append(out, theme.FGColor(theme.Error, prefix+line)) | |
| 68 | 74 | } | |
| 69 | 75 | } | |
| 70 | 76 | return out | |
M packages/agent/modes/reload_ext_test.go +15 -0
| @@ -35,6 +35,21 @@ func TestFormatReloadStatusIncludesErrorDetails(t *testing.T) { | |||
| 35 | 35 | } | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | + | func TestRenderReloadErrorsPreservesMultilineAlignment(t *testing.T) { | |
| 39 | + | lines := renderReloadErrors(tui.Theme{Error: tui.Color256(1)}, []string{"failed to start\nextension directory: /tmp/ext\nstderr log: /tmp/ext.log"}, 80) | |
| 40 | + | if len(lines) != 3 { | |
| 41 | + | t.Fatalf("rendered %d lines, want 3: %q", len(lines), lines) | |
| 42 | + | } | |
| 43 | + | if !strings.Contains(lines[0], "✖ failed to start") { | |
| 44 | + | t.Fatalf("first line = %q, want error marker", lines[0]) | |
| 45 | + | } | |
| 46 | + | for _, line := range lines[1:] { | |
| 47 | + | if !strings.Contains(line, " ") { | |
| 48 | + | t.Fatalf("continuation line = %q, want indentation", line) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 38 | 53 | func TestFormatReloadStatusSuccess(t *testing.T) { | |
| 39 | 54 | msg, failed := formatReloadStatus(extensions.ReloadStats{Stopped: 2, Loaded: 3, Ready: 3}) | |
| 40 | 55 | if failed { | |
M packages/agent/modes/session_dialog.go +83 -4
| @@ -75,6 +75,11 @@ type sessionDialog struct { | |||
| 75 | 75 | cursor int | |
| 76 | 76 | renaming bool | |
| 77 | 77 | rename string | |
| 78 | + | deleting bool | |
| 79 | + | // removedPaths remembers picker deletions for the dialog lifetime so | |
| 80 | + | // in-flight load/search generations cannot resurrect a deleted row | |
| 81 | + | // when they rebuild the visible list. | |
| 82 | + | removedPaths map[string]bool | |
| 78 | 83 | ||
| 79 | 84 | allScope bool | |
| 80 | 85 | root string | |
| @@ -132,6 +137,7 @@ type sessionDialog struct { | |||
| 132 | 137 | // sessionDialogAction is returned by HandleKey. | |
| 133 | 138 | type sessionDialogAction struct { | |
| 134 | 139 | Select bool | |
| 140 | + | Delete bool | |
| 135 | 141 | Path string | |
| 136 | 142 | Close bool | |
| 137 | 143 | Renamed bool | |
| @@ -174,6 +180,8 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string, allScope | |||
| 174 | 180 | d.viewTop = 0 | |
| 175 | 181 | d.renaming = false | |
| 176 | 182 | d.rename = "" | |
| 183 | + | d.deleting = false | |
| 184 | + | d.removedPaths = nil | |
| 177 | 185 | d.root = root | |
| 178 | 186 | d.cwd = cwd | |
| 179 | 187 | d.allScope = len(allScope) > 0 && allScope[0] | |
| @@ -377,7 +385,7 @@ func (d *sessionDialog) appendLoadedSessions(start, end int) { | |||
| 377 | 385 | return | |
| 378 | 386 | } | |
| 379 | 387 | for _, slot := range d.loadSlots[start:end] { | |
| 380 | - | if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 { | |
| 388 | + | if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 || d.removedPaths[slot.summary.Path] { | |
| 381 | 389 | continue | |
| 382 | 390 | } | |
| 383 | 391 | d.baseSessions = append(d.baseSessions, slot.summary) | |
| @@ -393,7 +401,7 @@ func (d *sessionDialog) rebuildLoadedSessions(limit int) { | |||
| 393 | 401 | } | |
| 394 | 402 | filtered := make([]core.SessionSummary, 0, limit) | |
| 395 | 403 | for _, slot := range d.loadSlots[:limit] { | |
| 396 | - | if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 { | |
| 404 | + | if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 || d.removedPaths[slot.summary.Path] { | |
| 397 | 405 | continue | |
| 398 | 406 | } | |
| 399 | 407 | filtered = append(filtered, slot.summary) | |
| @@ -423,7 +431,8 @@ func (d *sessionDialog) CursorPos() (row, col int) { | |||
| 423 | 431 | return 3, 4 + len([]rune(d.rename)) | |
| 424 | 432 | } | |
| 425 | 433 | ||
| 426 | - | // Close hides the dialog and cancels any in-flight session reads. | |
| 434 | + | // Close hides the dialog, cancels any in-flight session reads, and abandons | |
| 435 | + | // any pending edit or deletion. | |
| 427 | 436 | func (d *sessionDialog) Close() { | |
| 428 | 437 | if d.loadCancel != nil { | |
| 429 | 438 | d.loadCancel() | |
| @@ -447,6 +456,7 @@ func (d *sessionDialog) Close() { | |||
| 447 | 456 | d.searchEvents = nil | |
| 448 | 457 | d.searchSegments = nil | |
| 449 | 458 | d.searchMatches = nil | |
| 459 | + | d.deleting = false | |
| 450 | 460 | d.active = false | |
| 451 | 461 | } | |
| 452 | 462 | ||
| @@ -632,6 +642,17 @@ func (d *sessionDialog) applySearchFilter() { | |||
| 632 | 642 | } | |
| 633 | 643 | d.sessions = filtered | |
| 634 | 644 | } | |
| 645 | + | // Belt and suspenders: a deletion confirmed while a search match or | |
| 646 | + | // load event is being applied must not reappear in the visible rows. | |
| 647 | + | if len(d.removedPaths) > 0 && len(d.sessions) > 0 { | |
| 648 | + | kept := d.sessions[:0] | |
| 649 | + | for _, summary := range d.sessions { | |
| 650 | + | if !d.removedPaths[summary.Path] { | |
| 651 | + | kept = append(kept, summary) | |
| 652 | + | } | |
| 653 | + | } | |
| 654 | + | d.sessions = kept | |
| 655 | + | } | |
| 635 | 656 | if d.cursor >= len(d.sessions) { | |
| 636 | 657 | d.cursor = max(0, len(d.sessions)-1) | |
| 637 | 658 | } | |
| @@ -682,6 +703,14 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 682 | 703 | lines = append(lines, frameRule(th, width)) | |
| 683 | 704 | return lines | |
| 684 | 705 | } | |
| 706 | + | if d.deleting && d.cursor >= 0 && d.cursor < len(d.sessions) { | |
| 707 | + | s := d.sessions[d.cursor] | |
| 708 | + | lines = append(lines, th.FGColor(th.Muted, "permanently delete this session?")) | |
| 709 | + | lines = append(lines, " "+formatSessionRowPlain(s, width-2)) | |
| 710 | + | lines = append(lines, th.FGColor(th.Muted, "press y to delete, enter/esc to cancel")) | |
| 711 | + | lines = append(lines, frameRule(th, width)) | |
| 712 | + | return lines | |
| 713 | + | } | |
| 685 | 714 | if d.renaming { | |
| 686 | 715 | lines = append(lines, th.FGColor(th.Muted, "rename session (enter save, esc cancel):")) | |
| 687 | 716 | text := d.rename | |
| @@ -694,7 +723,7 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 694 | 723 | lines = append(lines, frameRule(th, width)) | |
| 695 | 724 | return lines | |
| 696 | 725 | } | |
| 697 | - | hint := "↑/↓ pick · enter resume · / search · tab scope · r rename · esc cancel" | |
| 726 | + | hint := "↑/↓ pick · enter resume · / search · tab scope · r rename · d delete · esc cancel" | |
| 698 | 727 | if d.searchRequested { | |
| 699 | 728 | hint = "search: " + d.query + " (esc clear · tab scope)" | |
| 700 | 729 | if !sessionSearchQuery(d.query) { | |
| @@ -979,6 +1008,23 @@ func formatRelative(t time.Time) string { | |||
| 979 | 1008 | ||
| 980 | 1009 | // HandleKey advances the dialog and returns an action to apply, if any. | |
| 981 | 1010 | func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |
| 1011 | + | // Deletion confirmation: y deletes, anything else (including enter and | |
| 1012 | + | // esc) cancels without closing the picker. | |
| 1013 | + | if d.deleting { | |
| 1014 | + | switch k.Kind { | |
| 1015 | + | case tui.KeyRune: | |
| 1016 | + | if (k.Rune == 'y' || k.Rune == 'Y') && d.cursor >= 0 && d.cursor < len(d.sessions) { | |
| 1017 | + | path := d.sessions[d.cursor].Path | |
| 1018 | + | d.deleting = false | |
| 1019 | + | return sessionDialogAction{Delete: true, Path: path} | |
| 1020 | + | } | |
| 1021 | + | d.deleting = false | |
| 1022 | + | case tui.KeyEnter, tui.KeyEsc: | |
| 1023 | + | d.deleting = false | |
| 1024 | + | } | |
| 1025 | + | return sessionDialogAction{} | |
| 1026 | + | } | |
| 1027 | + | ||
| 982 | 1028 | // Rename mode: type the new name. | |
| 983 | 1029 | if d.renaming { | |
| 984 | 1030 | switch k.Kind { | |
| @@ -1151,6 +1197,39 @@ navigate: | |||
| 1151 | 1197 | } | |
| 1152 | 1198 | return sessionDialogAction{} | |
| 1153 | 1199 | } | |
| 1200 | + | if k.Rune == 'd' && !d.loading && !d.searchRequested && len(d.sessions) > 0 { | |
| 1201 | + | d.deleting = true | |
| 1202 | + | } | |
| 1154 | 1203 | } | |
| 1155 | 1204 | return sessionDialogAction{} | |
| 1156 | 1205 | } | |
| 1206 | + | ||
| 1207 | + | // Remove deletes path from every picker projection after the host has | |
| 1208 | + | // removed the corresponding file. The next row stays selected when | |
| 1209 | + | // possible and the dialog stays open. The path is remembered so an | |
| 1210 | + | // in-flight load or search generation cannot resurrect the row. | |
| 1211 | + | func (d *sessionDialog) Remove(path string) { | |
| 1212 | + | if d.removedPaths == nil { | |
| 1213 | + | d.removedPaths = map[string]bool{} | |
| 1214 | + | } | |
| 1215 | + | d.removedPaths[path] = true | |
| 1216 | + | d.sessions = removeSessionSummary(d.sessions, path) | |
| 1217 | + | d.baseSessions = removeSessionSummary(d.baseSessions, path) | |
| 1218 | + | if d.cursor >= len(d.sessions) { | |
| 1219 | + | d.cursor = len(d.sessions) - 1 | |
| 1220 | + | } | |
| 1221 | + | if d.cursor < 0 { | |
| 1222 | + | d.cursor = 0 | |
| 1223 | + | } | |
| 1224 | + | d.viewTop = clampViewTop(d.viewTop, d.cursor, d.MaxRows, len(d.sessions)) | |
| 1225 | + | } | |
| 1226 | + | ||
| 1227 | + | func removeSessionSummary(summaries []core.SessionSummary, path string) []core.SessionSummary { | |
| 1228 | + | for idx := range summaries { | |
| 1229 | + | if summaries[idx].Path != path { | |
| 1230 | + | continue | |
| 1231 | + | } | |
| 1232 | + | return append(summaries[:idx], summaries[idx+1:]...) | |
| 1233 | + | } | |
| 1234 | + | return summaries | |
| 1235 | + | } | |
M packages/agent/modes/session_dialog_test.go +211 -0
| @@ -2,6 +2,8 @@ package modes | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 5 | 7 | "strings" | |
| 6 | 8 | "sync" | |
| 7 | 9 | "testing" | |
| @@ -612,3 +614,212 @@ func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) { | |||
| 612 | 614 | for range secondEvents { | |
| 613 | 615 | } | |
| 614 | 616 | } | |
| 617 | + | ||
| 618 | + | func TestSessionDialogDeleteRequiresConfirmation(t *testing.T) { | |
| 619 | + | d := newSessionDialog() | |
| 620 | + | d.active = true | |
| 621 | + | d.sessions = []core.SessionSummary{{Path: "/sessions/one.jsonl"}} | |
| 622 | + | ||
| 623 | + | if act := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'}); act.Delete { | |
| 624 | + | t.Fatal("delete hotkey immediately returned a delete action") | |
| 625 | + | } | |
| 626 | + | if !d.deleting { | |
| 627 | + | t.Fatal("delete hotkey did not open confirmation") | |
| 628 | + | } | |
| 629 | + | if act := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); act.Delete { | |
| 630 | + | t.Fatal("enter confirmed deletion; want safe default cancellation") | |
| 631 | + | } | |
| 632 | + | if d.deleting { | |
| 633 | + | t.Fatal("enter did not close deletion confirmation") | |
| 634 | + | } | |
| 635 | + | if !d.Active() { | |
| 636 | + | t.Fatal("enter closed the picker; want the dialog to stay open") | |
| 637 | + | } | |
| 638 | + | ||
| 639 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'}) | |
| 640 | + | if act := d.HandleKey(tui.Key{Kind: tui.KeyEsc}); act.Delete || act.Close { | |
| 641 | + | t.Fatalf("esc action = %+v, want silent cancellation", act) | |
| 642 | + | } | |
| 643 | + | if d.deleting { | |
| 644 | + | t.Fatal("esc did not close deletion confirmation") | |
| 645 | + | } | |
| 646 | + | if !d.Active() { | |
| 647 | + | t.Fatal("esc closed the picker; want the dialog to stay open") | |
| 648 | + | } | |
| 649 | + | ||
| 650 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'}) | |
| 651 | + | act := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'y'}) | |
| 652 | + | if !act.Delete || act.Path != "/sessions/one.jsonl" { | |
| 653 | + | t.Fatalf("confirmed action = %+v, want delete for selected path", act) | |
| 654 | + | } | |
| 655 | + | if len(d.sessions) != 1 { | |
| 656 | + | t.Fatal("dialog removed row before the host confirmed filesystem deletion") | |
| 657 | + | } | |
| 658 | + | } | |
| 659 | + | ||
| 660 | + | func TestSessionDialogDeleteConfirmationRender(t *testing.T) { | |
| 661 | + | d := newSessionDialog() | |
| 662 | + | d.active = true | |
| 663 | + | d.sessions = []core.SessionSummary{{Path: "/sessions/one.jsonl", Title: "keep or delete"}} | |
| 664 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'}) | |
| 665 | + | ||
| 666 | + | plain := strings.Join(d.Render(tui.Theme{}, 100), "\n") | |
| 667 | + | for _, want := range []string{"permanently delete", "keep or delete", "press y to delete"} { | |
| 668 | + | if !strings.Contains(plain, want) { | |
| 669 | + | t.Fatalf("confirmation render missing %q:\n%s", want, plain) | |
| 670 | + | } | |
| 671 | + | } | |
| 672 | + | normal := &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: "/sessions/one.jsonl"}}} | |
| 673 | + | if got := strings.Join(normal.Render(tui.Theme{}, 100), "\n"); !strings.Contains(got, "d delete") { | |
| 674 | + | t.Fatalf("picker hint missing delete key:\n%s", got) | |
| 675 | + | } | |
| 676 | + | } | |
| 677 | + | ||
| 678 | + | func TestSessionDialogRemoveKeepsCursorValid(t *testing.T) { | |
| 679 | + | tests := []struct { | |
| 680 | + | name string | |
| 681 | + | cursor int | |
| 682 | + | removePath string | |
| 683 | + | wantCursor int | |
| 684 | + | wantPath string | |
| 685 | + | }{ | |
| 686 | + | {name: "first", cursor: 0, removePath: "a", wantCursor: 0, wantPath: "b"}, | |
| 687 | + | {name: "middle", cursor: 1, removePath: "b", wantCursor: 1, wantPath: "c"}, | |
| 688 | + | {name: "last", cursor: 2, removePath: "c", wantCursor: 1, wantPath: "b"}, | |
| 689 | + | } | |
| 690 | + | for _, tt := range tests { | |
| 691 | + | t.Run(tt.name, func(t *testing.T) { | |
| 692 | + | d := &sessionDialog{ | |
| 693 | + | sessions: []core.SessionSummary{{Path: "a"}, {Path: "b"}, {Path: "c"}}, | |
| 694 | + | baseSessions: []core.SessionSummary{{Path: "a"}, {Path: "b"}, {Path: "c"}}, | |
| 695 | + | cursor: tt.cursor, | |
| 696 | + | } | |
| 697 | + | d.Remove(tt.removePath) | |
| 698 | + | if d.cursor != tt.wantCursor { | |
| 699 | + | t.Fatalf("cursor = %d, want %d", d.cursor, tt.wantCursor) | |
| 700 | + | } | |
| 701 | + | if got := d.sessions[d.cursor].Path; got != tt.wantPath { | |
| 702 | + | t.Fatalf("selected path = %q, want %q", got, tt.wantPath) | |
| 703 | + | } | |
| 704 | + | for _, summary := range d.baseSessions { | |
| 705 | + | if summary.Path == tt.removePath { | |
| 706 | + | t.Fatalf("removed path %q still in base sessions", tt.removePath) | |
| 707 | + | } | |
| 708 | + | } | |
| 709 | + | }) | |
| 710 | + | } | |
| 711 | + | ||
| 712 | + | d := &sessionDialog{sessions: []core.SessionSummary{{Path: "only"}}} | |
| 713 | + | d.Remove("only") | |
| 714 | + | if len(d.sessions) != 0 || d.cursor != 0 { | |
| 715 | + | t.Fatalf("empty dialog has len %d and cursor %d, want 0 and 0", len(d.sessions), d.cursor) | |
| 716 | + | } | |
| 717 | + | } | |
| 718 | + | ||
| 719 | + | func TestSessionDialogRemoveSurvivesLoadRebuild(t *testing.T) { | |
| 720 | + | d := &sessionDialog{ | |
| 721 | + | active: true, | |
| 722 | + | sessions: []core.SessionSummary{{Path: "b"}}, | |
| 723 | + | baseSessions: []core.SessionSummary{{Path: "a"}, {Path: "b"}}, | |
| 724 | + | cursor: 0, | |
| 725 | + | loadSlots: []sessionLoadSlot{ | |
| 726 | + | {loaded: true, summary: core.SessionSummary{Path: "a", MessageCount: 1}}, | |
| 727 | + | {loaded: true, summary: core.SessionSummary{Path: "b", MessageCount: 1}}, | |
| 728 | + | }, | |
| 729 | + | } | |
| 730 | + | d.Remove("a") | |
| 731 | + | d.rebuildLoadedSessions(2) | |
| 732 | + | for _, summary := range d.sessions { | |
| 733 | + | if summary.Path == "a" { | |
| 734 | + | t.Fatalf("deleted path resurrected by load rebuild: %+v", d.sessions) | |
| 735 | + | } | |
| 736 | + | } | |
| 737 | + | d.applySearchFilter() | |
| 738 | + | for _, summary := range d.sessions { | |
| 739 | + | if summary.Path == "a" { | |
| 740 | + | t.Fatalf("deleted path resurrected by search filter: %+v", d.sessions) | |
| 741 | + | } | |
| 742 | + | } | |
| 743 | + | } | |
| 744 | + | ||
| 745 | + | func TestApplySessionDeletionRemovesFileAndRow(t *testing.T) { | |
| 746 | + | path := filepath.Join(t.TempDir(), "session.jsonl") | |
| 747 | + | if err := os.WriteFile(path, []byte("session"), 0o600); err != nil { | |
| 748 | + | t.Fatal(err) | |
| 749 | + | } | |
| 750 | + | i := &Interactive{ | |
| 751 | + | cfg: InteractiveConfig{CurrentSessionPath: func() string { return "" }}, | |
| 752 | + | sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}}, | |
| 753 | + | } | |
| 754 | + | ||
| 755 | + | i.applySessionDeletion(path) | |
| 756 | + | ||
| 757 | + | if _, err := os.Stat(path); !os.IsNotExist(err) { | |
| 758 | + | t.Fatalf("deleted session still exists: %v", err) | |
| 759 | + | } | |
| 760 | + | if len(i.sessionDialog.sessions) != 0 { | |
| 761 | + | t.Fatal("deleted session remains in picker") | |
| 762 | + | } | |
| 763 | + | if !strings.Contains(i.statusOK, "deleted session") || i.statusErr != "" { | |
| 764 | + | t.Fatalf("statusOK = %q, statusErr = %q", i.statusOK, i.statusErr) | |
| 765 | + | } | |
| 766 | + | } | |
| 767 | + | ||
| 768 | + | func TestApplySessionDeletionProtectsActiveSession(t *testing.T) { | |
| 769 | + | path := filepath.Join(t.TempDir(), "session.jsonl") | |
| 770 | + | if err := os.WriteFile(path, []byte("session"), 0o600); err != nil { | |
| 771 | + | t.Fatal(err) | |
| 772 | + | } | |
| 773 | + | i := &Interactive{ | |
| 774 | + | cfg: InteractiveConfig{CurrentSessionPath: func() string { return path }}, | |
| 775 | + | sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}}, | |
| 776 | + | } | |
| 777 | + | ||
| 778 | + | i.applySessionDeletion(path) | |
| 779 | + | ||
| 780 | + | if _, err := os.Stat(path); err != nil { | |
| 781 | + | t.Fatalf("active session was deleted: %v", err) | |
| 782 | + | } | |
| 783 | + | if len(i.sessionDialog.sessions) != 1 { | |
| 784 | + | t.Fatal("active session was removed from picker") | |
| 785 | + | } | |
| 786 | + | if !strings.Contains(i.statusErr, "active session") { | |
| 787 | + | t.Fatalf("statusErr = %q, want active-session explanation", i.statusErr) | |
| 788 | + | } | |
| 789 | + | } | |
| 790 | + | ||
| 791 | + | func TestApplySessionDeletionRequiresCurrentSessionWiring(t *testing.T) { | |
| 792 | + | path := filepath.Join(t.TempDir(), "session.jsonl") | |
| 793 | + | if err := os.WriteFile(path, []byte("session"), 0o600); err != nil { | |
| 794 | + | t.Fatal(err) | |
| 795 | + | } | |
| 796 | + | i := &Interactive{ | |
| 797 | + | sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}}, | |
| 798 | + | } | |
| 799 | + | ||
| 800 | + | i.applySessionDeletion(path) | |
| 801 | + | ||
| 802 | + | if _, err := os.Stat(path); err != nil { | |
| 803 | + | t.Fatalf("session was deleted without active-session wiring: %v", err) | |
| 804 | + | } | |
| 805 | + | if !strings.Contains(i.statusErr, "not wired") { | |
| 806 | + | t.Fatalf("statusErr = %q, want wiring error", i.statusErr) | |
| 807 | + | } | |
| 808 | + | } | |
| 809 | + | ||
| 810 | + | func TestApplySessionDeletionPreservesRowOnError(t *testing.T) { | |
| 811 | + | path := filepath.Join(t.TempDir(), "missing.jsonl") | |
| 812 | + | i := &Interactive{ | |
| 813 | + | cfg: InteractiveConfig{CurrentSessionPath: func() string { return "" }}, | |
| 814 | + | sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}}, | |
| 815 | + | } | |
| 816 | + | ||
| 817 | + | i.applySessionDeletion(path) | |
| 818 | + | ||
| 819 | + | if len(i.sessionDialog.sessions) != 1 { | |
| 820 | + | t.Fatal("failed deletion removed session from picker") | |
| 821 | + | } | |
| 822 | + | if !strings.Contains(i.statusErr, "delete session") { | |
| 823 | + | t.Fatalf("statusErr = %q, want deletion error", i.statusErr) | |
| 824 | + | } | |
| 825 | + | } | |
M packages/agent/sdk/sdk.go +1 -1
| @@ -78,7 +78,7 @@ type Config struct { | |||
| 78 | 78 | BaseURL string | |
| 79 | 79 | ||
| 80 | 80 | // Tools is the list of tools to enable. Nil/empty enables the ordinary | |
| 81 | - | // built-ins: read, write, edit, bash, python, create_worktree, grep, lsp, | |
| 81 | + | // built-ins: read, write, edit, bash, python, create_worktree, grep, glob, ast, lsp, | |
| 82 | 82 | // update_goal, and update_plan. The public-web capability is excluded unless this list | |
| 83 | 83 | // explicitly contains "web_search"; that selector enables web_search, | |
| 84 | 84 | // web_open, web_find, and web_click. Pass NoTools=true to disable everything. | |
A packages/agent/sessionscmd.go +433 -0
| @@ -0,0 +1,433 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "io/fs" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "sort" | |
| 12 | + | "strconv" | |
| 13 | + | "strings" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/zut/packages/core" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | type sessionsPruneOptions struct { | |
| 20 | + | dryRun bool | |
| 21 | + | all bool | |
| 22 | + | yes bool | |
| 23 | + | olderThan *sessionAge | |
| 24 | + | cwd string | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | type sessionAge struct { | |
| 28 | + | value int | |
| 29 | + | unit string | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | type statPathFunc func(string) (fs.FileInfo, error) | |
| 33 | + | ||
| 34 | + | // runSessionsCommand dispatches standalone session management commands | |
| 35 | + | // before normal agent startup, so they do not require a provider or | |
| 36 | + | // credentials. | |
| 37 | + | func runSessionsCommand(rawArgs []string) (handled bool, err error) { | |
| 38 | + | if len(rawArgs) == 0 || rawArgs[0] != "sessions" { | |
| 39 | + | return false, nil | |
| 40 | + | } | |
| 41 | + | if len(rawArgs) == 1 { | |
| 42 | + | printSessionsHelp(os.Stdout) | |
| 43 | + | return true, nil | |
| 44 | + | } | |
| 45 | + | switch rawArgs[1] { | |
| 46 | + | case "help", "-h", "--help": | |
| 47 | + | printSessionsHelp(os.Stdout) | |
| 48 | + | return true, nil | |
| 49 | + | case "prune": | |
| 50 | + | opts, err := parseSessionsPruneOptions(rawArgs[2:]) | |
| 51 | + | if errors.Is(err, errSessionsPruneHelp) { | |
| 52 | + | printSessionsHelp(os.Stdout) | |
| 53 | + | return true, nil | |
| 54 | + | } | |
| 55 | + | if err != nil { | |
| 56 | + | printSessionsHelp(os.Stderr) | |
| 57 | + | return true, err | |
| 58 | + | } | |
| 59 | + | return true, runSessionsPrune(SessionsPath(), opts, os.Stdin, os.Stdout, os.Stderr, os.Stat) | |
| 60 | + | default: | |
| 61 | + | printSessionsHelp(os.Stderr) | |
| 62 | + | return true, fmt.Errorf("unknown sessions subcommand: %s", rawArgs[1]) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func parseSessionsPruneOptions(args []string) (sessionsPruneOptions, error) { | |
| 67 | + | var opts sessionsPruneOptions | |
| 68 | + | for idx := 0; idx < len(args); idx++ { | |
| 69 | + | arg := args[idx] | |
| 70 | + | switch arg { | |
| 71 | + | case "--dry-run": | |
| 72 | + | opts.dryRun = true | |
| 73 | + | case "--all": | |
| 74 | + | opts.all = true | |
| 75 | + | case "-y", "--yes": | |
| 76 | + | opts.yes = true | |
| 77 | + | case "--older-than": | |
| 78 | + | if idx+1 >= len(args) { | |
| 79 | + | return opts, fmt.Errorf("--older-than requires an age") | |
| 80 | + | } | |
| 81 | + | idx++ | |
| 82 | + | age, err := parseSessionAge(args[idx]) | |
| 83 | + | if err != nil { | |
| 84 | + | return opts, err | |
| 85 | + | } | |
| 86 | + | opts.olderThan = &age | |
| 87 | + | case "--cwd": | |
| 88 | + | if idx+1 >= len(args) { | |
| 89 | + | return opts, fmt.Errorf("--cwd requires a path") | |
| 90 | + | } | |
| 91 | + | idx++ | |
| 92 | + | if strings.TrimSpace(args[idx]) == "" { | |
| 93 | + | return opts, fmt.Errorf("--cwd requires a non-empty path") | |
| 94 | + | } | |
| 95 | + | opts.cwd = args[idx] | |
| 96 | + | case "-h", "--help": | |
| 97 | + | return opts, errSessionsPruneHelp | |
| 98 | + | default: | |
| 99 | + | return opts, fmt.Errorf("unknown sessions prune flag: %s", arg) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | if opts.yes && !opts.all { | |
| 103 | + | return opts, fmt.Errorf("--yes requires --all") | |
| 104 | + | } | |
| 105 | + | if opts.yes && opts.dryRun { | |
| 106 | + | return opts, fmt.Errorf("--yes cannot be used with --dry-run") | |
| 107 | + | } | |
| 108 | + | if opts.cwd != "" && opts.olderThan == nil { | |
| 109 | + | return opts, fmt.Errorf("--cwd requires --older-than") | |
| 110 | + | } | |
| 111 | + | return opts, nil | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func parseSessionAge(value string) (sessionAge, error) { | |
| 115 | + | value = strings.TrimSpace(value) | |
| 116 | + | digitEnd := 0 | |
| 117 | + | for digitEnd < len(value) && value[digitEnd] >= '0' && value[digitEnd] <= '9' { | |
| 118 | + | digitEnd++ | |
| 119 | + | } | |
| 120 | + | if digitEnd == 0 || digitEnd == len(value) { | |
| 121 | + | return sessionAge{}, fmt.Errorf("invalid age %q (use m, h, d, w, mo, or y)", value) | |
| 122 | + | } | |
| 123 | + | amount, err := strconv.ParseInt(value[:digitEnd], 10, 32) | |
| 124 | + | if err != nil || amount <= 0 { | |
| 125 | + | return sessionAge{}, fmt.Errorf("invalid age %q (value must be a positive whole number)", value) | |
| 126 | + | } | |
| 127 | + | unit := value[digitEnd:] | |
| 128 | + | switch unit { | |
| 129 | + | case "m", "h", "d", "w", "mo", "y": | |
| 130 | + | default: | |
| 131 | + | return sessionAge{}, fmt.Errorf("invalid age unit %q (use m, h, d, w, mo, or y)", unit) | |
| 132 | + | } | |
| 133 | + | age := sessionAge{value: int(amount), unit: unit} | |
| 134 | + | if _, err := age.cutoff(time.Unix(0, 0)); err != nil { | |
| 135 | + | return sessionAge{}, fmt.Errorf("invalid age %q: %w", value, err) | |
| 136 | + | } | |
| 137 | + | return age, nil | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func (age sessionAge) cutoff(now time.Time) (time.Time, error) { | |
| 141 | + | var unit time.Duration | |
| 142 | + | switch age.unit { | |
| 143 | + | case "m": | |
| 144 | + | unit = time.Minute | |
| 145 | + | case "h": | |
| 146 | + | unit = time.Hour | |
| 147 | + | case "d": | |
| 148 | + | unit = 24 * time.Hour | |
| 149 | + | case "w": | |
| 150 | + | unit = 7 * 24 * time.Hour | |
| 151 | + | case "mo": | |
| 152 | + | return subtractCalendarAge(now, 0, age.value), nil | |
| 153 | + | case "y": | |
| 154 | + | return subtractCalendarAge(now, age.value, 0), nil | |
| 155 | + | default: | |
| 156 | + | return time.Time{}, fmt.Errorf("unsupported unit %q", age.unit) | |
| 157 | + | } | |
| 158 | + | if int64(age.value) > int64((time.Duration(1<<63-1))/unit) { | |
| 159 | + | return time.Time{}, fmt.Errorf("duration is too large") | |
| 160 | + | } | |
| 161 | + | return now.Add(-time.Duration(age.value) * unit), nil | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // subtractCalendarAge clamps the day to the target month's final day. This | |
| 165 | + | // makes one month before March 31 the end of February rather than early March. | |
| 166 | + | func subtractCalendarAge(now time.Time, years, months int) time.Time { | |
| 167 | + | first := time.Date(now.Year(), now.Month(), 1, now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), now.Location()) | |
| 168 | + | target := first.AddDate(-years, -months, 0) | |
| 169 | + | lastDay := target.AddDate(0, 1, -1).Day() | |
| 170 | + | day := now.Day() | |
| 171 | + | if day > lastDay { | |
| 172 | + | day = lastDay | |
| 173 | + | } | |
| 174 | + | return time.Date(target.Year(), target.Month(), day, now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), now.Location()) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | var errSessionsPruneHelp = errors.New("sessions prune help requested") | |
| 178 | + | ||
| 179 | + | func printSessionsHelp(out io.Writer) { | |
| 180 | + | fmt.Fprintln(out, `zut sessions - manage stored sessions | |
| 181 | + | ||
| 182 | + | usage: | |
| 183 | + | zut sessions prune select missing directory groups | |
| 184 | + | zut sessions prune --older-than 30d select sessions inactive for 30 days | |
| 185 | + | zut sessions prune --older-than 1mo --cwd PATH | |
| 186 | + | zut sessions prune --dry-run list matches without deleting | |
| 187 | + | zut sessions prune --all [--yes] select every match, optionally without prompting | |
| 188 | + | ||
| 189 | + | Age units are m (minutes), h (hours), d (24-hour days), w (7-day weeks), | |
| 190 | + | mo (calendar months), and y (calendar years). Age uses last session activity. | |
| 191 | + | Without --older-than, only sessions whose recorded directory is missing match. | |
| 192 | + | A missing directory cannot be distinguished from some unmounted filesystems. | |
| 193 | + | Unreadable and malformed entries are preserved.`) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func runSessionsPrune(sessionsRoot string, opts sessionsPruneOptions, in io.Reader, out, errOut io.Writer, stat statPathFunc) error { | |
| 197 | + | groups, scanIssues := core.ScanStoredSessionGroups(sessionsRoot) | |
| 198 | + | for _, issue := range scanIssues { | |
| 199 | + | fmt.Fprintf(errOut, "warning: preserving %s: %v\n", issue.Path, issue.Err) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | candidates := make([]core.StoredSessionGroup, 0, len(groups)) | |
| 203 | + | var cutoff time.Time | |
| 204 | + | if opts.olderThan != nil { | |
| 205 | + | var err error | |
| 206 | + | cutoff, err = opts.olderThan.cutoff(time.Now()) | |
| 207 | + | if err != nil { | |
| 208 | + | return err | |
| 209 | + | } | |
| 210 | + | cwd := opts.cwd | |
| 211 | + | if cwd != "" { | |
| 212 | + | cwd, err = filepath.Abs(cwd) | |
| 213 | + | if err != nil { | |
| 214 | + | return fmt.Errorf("resolve --cwd: %w", err) | |
| 215 | + | } | |
| 216 | + | cwd = filepath.Clean(cwd) | |
| 217 | + | } | |
| 218 | + | for _, group := range groups { | |
| 219 | + | if cwd != "" && filepath.Clean(group.CWD) != cwd { | |
| 220 | + | continue | |
| 221 | + | } | |
| 222 | + | matching := core.StoredSessionGroup{CWD: group.CWD} | |
| 223 | + | for _, path := range group.Paths { | |
| 224 | + | info, err := stat(path) | |
| 225 | + | if err != nil { | |
| 226 | + | fmt.Fprintf(errOut, "warning: preserving %s: %v\n", path, err) | |
| 227 | + | continue | |
| 228 | + | } | |
| 229 | + | if info.ModTime().Before(cutoff) { | |
| 230 | + | matching.Paths = append(matching.Paths, path) | |
| 231 | + | matching.SizeBytes += info.Size() | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | if len(matching.Paths) > 0 { | |
| 235 | + | candidates = append(candidates, matching) | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | } else { | |
| 239 | + | for _, group := range groups { | |
| 240 | + | if !filepath.IsAbs(group.CWD) { | |
| 241 | + | fmt.Fprintf(errOut, "warning: preserving sessions with non-absolute cwd %q\n", group.CWD) | |
| 242 | + | continue | |
| 243 | + | } | |
| 244 | + | _, err := stat(group.CWD) | |
| 245 | + | switch { | |
| 246 | + | case err == nil: | |
| 247 | + | continue | |
| 248 | + | case errors.Is(err, fs.ErrNotExist): | |
| 249 | + | candidates = append(candidates, group) | |
| 250 | + | default: | |
| 251 | + | fmt.Fprintf(errOut, "warning: preserving sessions for %s: %v\n", group.CWD, err) | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | if len(candidates) == 0 { | |
| 256 | + | if opts.olderThan != nil { | |
| 257 | + | fmt.Fprintln(out, "no sessions older than the requested age found") | |
| 258 | + | } else { | |
| 259 | + | fmt.Fprintln(out, "no stale session directories found") | |
| 260 | + | } | |
| 261 | + | return nil | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | if opts.olderThan != nil { | |
| 265 | + | fmt.Fprintln(out, "session directories with inactive sessions:") | |
| 266 | + | } else { | |
| 267 | + | fmt.Fprintln(out, "missing session directories:") | |
| 268 | + | } | |
| 269 | + | for idx, group := range candidates { | |
| 270 | + | fmt.Fprintf(out, " %d. %s (%s, %d bytes)\n", idx+1, group.CWD, sessionCount(len(group.Paths)), group.SizeBytes) | |
| 271 | + | } | |
| 272 | + | if opts.dryRun { | |
| 273 | + | if opts.all { | |
| 274 | + | fmt.Fprintf(out, "dry run: %s in %s would be deleted\n", sessionCount(totalSessions(candidates)), directoryCount(len(candidates))) | |
| 275 | + | } else { | |
| 276 | + | fmt.Fprintln(out, "dry run: nothing selected; add --all to preview deletion of every match") | |
| 277 | + | } | |
| 278 | + | return nil | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | reader := bufio.NewReader(in) | |
| 282 | + | selected := make([]int, 0, len(candidates)) | |
| 283 | + | if opts.all { | |
| 284 | + | for idx := range candidates { | |
| 285 | + | selected = append(selected, idx) | |
| 286 | + | } | |
| 287 | + | } else { | |
| 288 | + | fmt.Fprint(out, "select groups to delete (for example 1,3-5; all; none): ") | |
| 289 | + | line, err := reader.ReadString('\n') | |
| 290 | + | if err != nil && !errors.Is(err, io.EOF) { | |
| 291 | + | return fmt.Errorf("read selection: %w", err) | |
| 292 | + | } | |
| 293 | + | selected, err = parseSessionSelection(line, len(candidates)) | |
| 294 | + | if err != nil { | |
| 295 | + | return err | |
| 296 | + | } | |
| 297 | + | if len(selected) == 0 { | |
| 298 | + | fmt.Fprintln(out, "no sessions deleted") | |
| 299 | + | return nil | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | selectedSessions := 0 | |
| 304 | + | for _, idx := range selected { | |
| 305 | + | selectedSessions += len(candidates[idx].Paths) | |
| 306 | + | } | |
| 307 | + | if !opts.yes { | |
| 308 | + | fmt.Fprintf(out, "permanently delete %s in %s? [y/N]: ", sessionCount(selectedSessions), directoryCount(len(selected))) | |
| 309 | + | line, err := reader.ReadString('\n') | |
| 310 | + | if err != nil && !errors.Is(err, io.EOF) { | |
| 311 | + | return fmt.Errorf("read confirmation: %w", err) | |
| 312 | + | } | |
| 313 | + | answer := strings.ToLower(strings.TrimSpace(line)) | |
| 314 | + | if answer != "y" && answer != "yes" { | |
| 315 | + | fmt.Fprintln(out, "no sessions deleted") | |
| 316 | + | return nil | |
| 317 | + | } | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | deletedSessions := 0 | |
| 321 | + | deletedGroups := 0 | |
| 322 | + | var deleteErrors []error | |
| 323 | + | for _, idx := range selected { | |
| 324 | + | group := candidates[idx] | |
| 325 | + | if opts.olderThan == nil { | |
| 326 | + | // Recheck just before deleting: a directory that reappeared | |
| 327 | + | // (remount, restore) must not lose its sessions. | |
| 328 | + | _, err := stat(group.CWD) | |
| 329 | + | if err == nil { | |
| 330 | + | fmt.Fprintf(errOut, "warning: preserving sessions for %s because the directory now exists\n", group.CWD) | |
| 331 | + | continue | |
| 332 | + | } | |
| 333 | + | if !errors.Is(err, fs.ErrNotExist) { | |
| 334 | + | fmt.Fprintf(errOut, "warning: preserving sessions for %s after recheck: %v\n", group.CWD, err) | |
| 335 | + | continue | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | groupDeleted := 0 | |
| 340 | + | for _, path := range group.Paths { | |
| 341 | + | if opts.olderThan != nil { | |
| 342 | + | info, err := stat(path) | |
| 343 | + | if err != nil { | |
| 344 | + | fmt.Fprintf(errOut, "warning: preserving %s after recheck: %v\n", path, err) | |
| 345 | + | continue | |
| 346 | + | } | |
| 347 | + | if !info.ModTime().Before(cutoff) { | |
| 348 | + | fmt.Fprintf(errOut, "warning: preserving %s because it now has recent activity\n", path) | |
| 349 | + | continue | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | if err := core.DeleteSession(path); err != nil { | |
| 353 | + | deleteErrors = append(deleteErrors, fmt.Errorf("delete %s: %w", path, err)) | |
| 354 | + | continue | |
| 355 | + | } | |
| 356 | + | groupDeleted++ | |
| 357 | + | } | |
| 358 | + | deletedSessions += groupDeleted | |
| 359 | + | if groupDeleted == len(group.Paths) { | |
| 360 | + | deletedGroups++ | |
| 361 | + | } | |
| 362 | + | } | |
| 363 | + | fmt.Fprintf(out, "deleted %s from %s\n", sessionCount(deletedSessions), directoryCount(deletedGroups)) | |
| 364 | + | return errors.Join(deleteErrors...) | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func parseSessionSelection(input string, count int) ([]int, error) { | |
| 368 | + | input = strings.ToLower(strings.TrimSpace(input)) | |
| 369 | + | switch input { | |
| 370 | + | case "", "none", "n": | |
| 371 | + | return nil, nil | |
| 372 | + | case "all", "a": | |
| 373 | + | selected := make([]int, count) | |
| 374 | + | for idx := range selected { | |
| 375 | + | selected[idx] = idx | |
| 376 | + | } | |
| 377 | + | return selected, nil | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | seen := make(map[int]bool) | |
| 381 | + | for _, field := range strings.Split(input, ",") { | |
| 382 | + | field = strings.TrimSpace(field) | |
| 383 | + | if field == "" { | |
| 384 | + | return nil, fmt.Errorf("invalid empty selection") | |
| 385 | + | } | |
| 386 | + | startText, endText, isRange := strings.Cut(field, "-") | |
| 387 | + | start, err := strconv.Atoi(strings.TrimSpace(startText)) | |
| 388 | + | if err != nil { | |
| 389 | + | return nil, fmt.Errorf("invalid selection %q", field) | |
| 390 | + | } | |
| 391 | + | end := start | |
| 392 | + | if isRange { | |
| 393 | + | end, err = strconv.Atoi(strings.TrimSpace(endText)) | |
| 394 | + | if err != nil || end < start { | |
| 395 | + | return nil, fmt.Errorf("invalid selection range %q", field) | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | if start < 1 || end > count { | |
| 399 | + | return nil, fmt.Errorf("selection %q is outside 1-%d", field, count) | |
| 400 | + | } | |
| 401 | + | for value := start; value <= end; value++ { | |
| 402 | + | seen[value-1] = true | |
| 403 | + | } | |
| 404 | + | } | |
| 405 | + | selected := make([]int, 0, len(seen)) | |
| 406 | + | for idx := range seen { | |
| 407 | + | selected = append(selected, idx) | |
| 408 | + | } | |
| 409 | + | sort.Ints(selected) | |
| 410 | + | return selected, nil | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | func totalSessions(groups []core.StoredSessionGroup) int { | |
| 414 | + | total := 0 | |
| 415 | + | for _, group := range groups { | |
| 416 | + | total += len(group.Paths) | |
| 417 | + | } | |
| 418 | + | return total | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | func sessionCount(count int) string { | |
| 422 | + | if count == 1 { | |
| 423 | + | return "1 session" | |
| 424 | + | } | |
| 425 | + | return fmt.Sprintf("%d sessions", count) | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | func directoryCount(count int) string { | |
| 429 | + | if count == 1 { | |
| 430 | + | return "1 directory" | |
| 431 | + | } | |
| 432 | + | return fmt.Sprintf("%d directories", count) | |
| 433 | + | } | |
A packages/agent/sessionscmd_test.go +372 -0
| @@ -0,0 +1,372 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "io/fs" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "reflect" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/zut/packages/core" | |
| 14 | + | "github.com/bnema/zut/packages/provider" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestParseSessionSelection(t *testing.T) { | |
| 18 | + | tests := []struct { | |
| 19 | + | input string | |
| 20 | + | count int | |
| 21 | + | want []int | |
| 22 | + | wantErr bool | |
| 23 | + | }{ | |
| 24 | + | {input: "", count: 3}, | |
| 25 | + | {input: "none", count: 3}, | |
| 26 | + | {input: "all", count: 3, want: []int{0, 1, 2}}, | |
| 27 | + | {input: "1,3-4,3", count: 4, want: []int{0, 2, 3}}, | |
| 28 | + | {input: "0", count: 3, wantErr: true}, | |
| 29 | + | {input: "4", count: 3, wantErr: true}, | |
| 30 | + | {input: "3-2", count: 3, wantErr: true}, | |
| 31 | + | {input: "1,,2", count: 3, wantErr: true}, | |
| 32 | + | } | |
| 33 | + | for _, tt := range tests { | |
| 34 | + | t.Run("select/"+tt.input, func(t *testing.T) { | |
| 35 | + | got, err := parseSessionSelection(tt.input, tt.count) | |
| 36 | + | if (err != nil) != tt.wantErr { | |
| 37 | + | t.Fatalf("error = %v, wantErr %v", err, tt.wantErr) | |
| 38 | + | } | |
| 39 | + | if !reflect.DeepEqual(got, tt.want) { | |
| 40 | + | t.Fatalf("selection = %v, want %v", got, tt.want) | |
| 41 | + | } | |
| 42 | + | }) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestParseSessionAge(t *testing.T) { | |
| 47 | + | now := time.Date(2026, time.August, 22, 12, 0, 0, 0, time.UTC) | |
| 48 | + | tests := []struct { | |
| 49 | + | input string | |
| 50 | + | want time.Time | |
| 51 | + | wantErr bool | |
| 52 | + | }{ | |
| 53 | + | {input: "30m", want: now.Add(-30 * time.Minute)}, | |
| 54 | + | {input: "4h", want: now.Add(-4 * time.Hour)}, | |
| 55 | + | {input: "30d", want: now.Add(-30 * 24 * time.Hour)}, | |
| 56 | + | {input: "2w", want: now.Add(-14 * 24 * time.Hour)}, | |
| 57 | + | {input: "1mo", want: now.AddDate(0, -1, 0)}, | |
| 58 | + | {input: "1y", want: now.AddDate(-1, 0, 0)}, | |
| 59 | + | {input: "", wantErr: true}, | |
| 60 | + | {input: "0d", wantErr: true}, | |
| 61 | + | {input: "-1d", wantErr: true}, | |
| 62 | + | {input: "1.5h", wantErr: true}, | |
| 63 | + | {input: "1s", wantErr: true}, | |
| 64 | + | {input: "99999999999999999999d", wantErr: true}, | |
| 65 | + | } | |
| 66 | + | for _, tt := range tests { | |
| 67 | + | t.Run("age/"+tt.input, func(t *testing.T) { | |
| 68 | + | age, err := parseSessionAge(tt.input) | |
| 69 | + | if (err != nil) != tt.wantErr { | |
| 70 | + | t.Fatalf("parseSessionAge(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) | |
| 71 | + | } | |
| 72 | + | if tt.wantErr { | |
| 73 | + | return | |
| 74 | + | } | |
| 75 | + | got, err := age.cutoff(now) | |
| 76 | + | if err != nil { | |
| 77 | + | t.Fatal(err) | |
| 78 | + | } | |
| 79 | + | if !got.Equal(tt.want) { | |
| 80 | + | t.Fatalf("cutoff = %v, want %v", got, tt.want) | |
| 81 | + | } | |
| 82 | + | }) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestSessionAgeClampsCalendarBoundaries(t *testing.T) { | |
| 87 | + | month, err := parseSessionAge("1mo") | |
| 88 | + | if err != nil { | |
| 89 | + | t.Fatal(err) | |
| 90 | + | } | |
| 91 | + | got, err := month.cutoff(time.Date(2025, time.March, 31, 12, 0, 0, 0, time.UTC)) | |
| 92 | + | if err != nil { | |
| 93 | + | t.Fatal(err) | |
| 94 | + | } | |
| 95 | + | want := time.Date(2025, time.February, 28, 12, 0, 0, 0, time.UTC) | |
| 96 | + | if !got.Equal(want) { | |
| 97 | + | t.Fatalf("one month cutoff = %v, want %v", got, want) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | year, err := parseSessionAge("1y") | |
| 101 | + | if err != nil { | |
| 102 | + | t.Fatal(err) | |
| 103 | + | } | |
| 104 | + | got, err = year.cutoff(time.Date(2024, time.February, 29, 12, 0, 0, 0, time.UTC)) | |
| 105 | + | if err != nil { | |
| 106 | + | t.Fatal(err) | |
| 107 | + | } | |
| 108 | + | want = time.Date(2023, time.February, 28, 12, 0, 0, 0, time.UTC) | |
| 109 | + | if !got.Equal(want) { | |
| 110 | + | t.Fatalf("one year cutoff = %v, want %v", got, want) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestParseSessionsPruneOptions(t *testing.T) { | |
| 115 | + | if _, err := parseSessionsPruneOptions([]string{"--yes"}); err == nil { | |
| 116 | + | t.Fatal("--yes without --all was accepted") | |
| 117 | + | } | |
| 118 | + | if _, err := parseSessionsPruneOptions([]string{"--cwd", "/tmp/project"}); err == nil { | |
| 119 | + | t.Fatal("--cwd without --older-than was accepted") | |
| 120 | + | } | |
| 121 | + | if _, err := parseSessionsPruneOptions([]string{"--all", "--yes", "--dry-run"}); err == nil { | |
| 122 | + | t.Fatal("--yes with --dry-run was accepted") | |
| 123 | + | } | |
| 124 | + | if _, err := parseSessionsPruneOptions([]string{"--bogus"}); err == nil { | |
| 125 | + | t.Fatal("unknown flag was accepted") | |
| 126 | + | } | |
| 127 | + | if _, err := parseSessionsPruneOptions([]string{"--older-than"}); err == nil { | |
| 128 | + | t.Fatal("missing --older-than value was accepted") | |
| 129 | + | } | |
| 130 | + | opts, err := parseSessionsPruneOptions([]string{"--older-than", "1mo", "--cwd", "/tmp/project", "--all", "--yes"}) | |
| 131 | + | if err != nil { | |
| 132 | + | t.Fatal(err) | |
| 133 | + | } | |
| 134 | + | if !opts.all || !opts.yes || opts.olderThan == nil || opts.olderThan.unit != "mo" || opts.cwd != "/tmp/project" { | |
| 135 | + | t.Fatalf("options = %#v", opts) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestSessionsPruneDryRunPreservesSessions(t *testing.T) { | |
| 140 | + | root := t.TempDir() | |
| 141 | + | missing := filepath.Join(root, "missing-project") | |
| 142 | + | existing := t.TempDir() | |
| 143 | + | missingPath := createPruneTestSession(t, root, missing) | |
| 144 | + | existingPath := createPruneTestSession(t, root, existing) | |
| 145 | + | ||
| 146 | + | var out, errOut bytes.Buffer | |
| 147 | + | if err := runSessionsPrune(root, sessionsPruneOptions{dryRun: true, all: true}, strings.NewReader(""), &out, &errOut, os.Stat); err != nil { | |
| 148 | + | t.Fatal(err) | |
| 149 | + | } | |
| 150 | + | if !strings.Contains(out.String(), missing) || strings.Contains(out.String(), existing) { | |
| 151 | + | t.Fatalf("output = %q, want only missing cwd", out.String()) | |
| 152 | + | } | |
| 153 | + | if !strings.Contains(out.String(), "dry run: 1 session in 1 directory would be deleted") { | |
| 154 | + | t.Fatalf("output = %q, want dry-run summary", out.String()) | |
| 155 | + | } | |
| 156 | + | for _, path := range []string{missingPath, existingPath} { | |
| 157 | + | if _, err := os.Stat(path); err != nil { | |
| 158 | + | t.Fatalf("dry run removed %s: %v", path, err) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func TestSessionsPruneInteractiveDeletesSelectedGroup(t *testing.T) { | |
| 164 | + | root := t.TempDir() | |
| 165 | + | firstCWD := filepath.Join(root, "gone-a") | |
| 166 | + | secondCWD := filepath.Join(root, "gone-b") | |
| 167 | + | first := createPruneTestSession(t, root, firstCWD) | |
| 168 | + | second := createPruneTestSession(t, root, secondCWD) | |
| 169 | + | ||
| 170 | + | var out, errOut bytes.Buffer | |
| 171 | + | if err := runSessionsPrune(root, sessionsPruneOptions{}, strings.NewReader("2\ny\n"), &out, &errOut, os.Stat); err != nil { | |
| 172 | + | t.Fatal(err) | |
| 173 | + | } | |
| 174 | + | if _, err := os.Stat(first); err != nil { | |
| 175 | + | t.Fatalf("unselected session was removed: %v", err) | |
| 176 | + | } | |
| 177 | + | if _, err := os.Stat(second); !os.IsNotExist(err) { | |
| 178 | + | t.Fatalf("selected session still exists or stat failed unexpectedly: %v", err) | |
| 179 | + | } | |
| 180 | + | if !strings.Contains(out.String(), "deleted 1 session from 1 directory") { | |
| 181 | + | t.Fatalf("output = %q", out.String()) | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func TestSessionsPruneDeclinedConfirmationDeletesNothing(t *testing.T) { | |
| 186 | + | root := t.TempDir() | |
| 187 | + | path := createPruneTestSession(t, root, filepath.Join(root, "gone")) | |
| 188 | + | ||
| 189 | + | var out, errOut bytes.Buffer | |
| 190 | + | if err := runSessionsPrune(root, sessionsPruneOptions{all: true}, strings.NewReader("n\n"), &out, &errOut, os.Stat); err != nil { | |
| 191 | + | t.Fatal(err) | |
| 192 | + | } | |
| 193 | + | if _, err := os.Stat(path); err != nil { | |
| 194 | + | t.Fatalf("declined confirmation removed the session: %v", err) | |
| 195 | + | } | |
| 196 | + | if !strings.Contains(out.String(), "no sessions deleted") { | |
| 197 | + | t.Fatalf("output = %q, want declined message", out.String()) | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func TestSessionsPruneAllYesDeletesWithoutPrompting(t *testing.T) { | |
| 202 | + | root := t.TempDir() | |
| 203 | + | first := createPruneTestSession(t, root, filepath.Join(root, "gone-a")) | |
| 204 | + | second := createPruneTestSession(t, root, filepath.Join(root, "gone-b")) | |
| 205 | + | ||
| 206 | + | var out, errOut bytes.Buffer | |
| 207 | + | if err := runSessionsPrune(root, sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, os.Stat); err != nil { | |
| 208 | + | t.Fatal(err) | |
| 209 | + | } | |
| 210 | + | for _, path := range []string{first, second} { | |
| 211 | + | if _, err := os.Stat(path); !os.IsNotExist(err) { | |
| 212 | + | t.Fatalf("session %s still exists: %v", path, err) | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | if !strings.Contains(out.String(), "deleted 2 sessions from 2 directories") { | |
| 216 | + | t.Fatalf("output = %q", out.String()) | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func TestSessionsPrunePreservesGroupWhenDirectoryCheckIsInconclusive(t *testing.T) { | |
| 221 | + | root := t.TempDir() | |
| 222 | + | cwd := filepath.Join(root, "unavailable-mount") | |
| 223 | + | path := createPruneTestSession(t, root, cwd) | |
| 224 | + | stat := func(string) (fs.FileInfo, error) { return nil, fs.ErrPermission } | |
| 225 | + | ||
| 226 | + | var out, errOut bytes.Buffer | |
| 227 | + | if err := runSessionsPrune(root, sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, stat); err != nil { | |
| 228 | + | t.Fatal(err) | |
| 229 | + | } | |
| 230 | + | if _, err := os.Stat(path); err != nil { | |
| 231 | + | t.Fatalf("session was removed after inconclusive stat: %v", err) | |
| 232 | + | } | |
| 233 | + | if !strings.Contains(errOut.String(), "permission denied") { | |
| 234 | + | t.Fatalf("stderr = %q, want stat warning", errOut.String()) | |
| 235 | + | } | |
| 236 | + | if !strings.Contains(out.String(), "no stale session directories found") { | |
| 237 | + | t.Fatalf("output = %q", out.String()) | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func TestSessionsPruneRechecksDirectoryBeforeDeleting(t *testing.T) { | |
| 242 | + | root := t.TempDir() | |
| 243 | + | cwd := filepath.Join(root, "temporarily-missing") | |
| 244 | + | path := createPruneTestSession(t, root, cwd) | |
| 245 | + | calls := 0 | |
| 246 | + | stat := func(string) (fs.FileInfo, error) { | |
| 247 | + | calls++ | |
| 248 | + | if calls == 1 { | |
| 249 | + | return nil, fs.ErrNotExist | |
| 250 | + | } | |
| 251 | + | return nil, nil | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | var out, errOut bytes.Buffer | |
| 255 | + | if err := runSessionsPrune(root, sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, stat); err != nil { | |
| 256 | + | t.Fatal(err) | |
| 257 | + | } | |
| 258 | + | if _, err := os.Stat(path); err != nil { | |
| 259 | + | t.Fatalf("session was removed after cwd reappeared: %v", err) | |
| 260 | + | } | |
| 261 | + | if !strings.Contains(errOut.String(), "directory now exists") { | |
| 262 | + | t.Fatalf("stderr = %q, want recheck warning", errOut.String()) | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func TestSessionsPruneOlderThanFiltersByLastActivity(t *testing.T) { | |
| 267 | + | root := t.TempDir() | |
| 268 | + | cwd := t.TempDir() | |
| 269 | + | oldPath := createPruneTestSession(t, root, cwd) | |
| 270 | + | newPath := createPruneTestSession(t, root, cwd) | |
| 271 | + | oldTime := time.Now().Add(-48 * time.Hour) | |
| 272 | + | if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil { | |
| 273 | + | t.Fatal(err) | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | age, err := parseSessionAge("24h") | |
| 277 | + | if err != nil { | |
| 278 | + | t.Fatal(err) | |
| 279 | + | } | |
| 280 | + | var out, errOut bytes.Buffer | |
| 281 | + | opts := sessionsPruneOptions{dryRun: true, all: true, olderThan: &age} | |
| 282 | + | if err := runSessionsPrune(root, opts, strings.NewReader(""), &out, &errOut, os.Stat); err != nil { | |
| 283 | + | t.Fatal(err) | |
| 284 | + | } | |
| 285 | + | if !strings.Contains(out.String(), "dry run: 1 session in 1 directory would be deleted") { | |
| 286 | + | t.Fatalf("output = %q", out.String()) | |
| 287 | + | } | |
| 288 | + | for _, path := range []string{oldPath, newPath} { | |
| 289 | + | if _, err := os.Stat(path); err != nil { | |
| 290 | + | t.Fatalf("dry run removed %s: %v", path, err) | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | func TestSessionsPruneOlderThanLimitsDeletionToCWD(t *testing.T) { | |
| 296 | + | root := t.TempDir() | |
| 297 | + | firstCWD := t.TempDir() | |
| 298 | + | secondCWD := t.TempDir() | |
| 299 | + | first := createPruneTestSession(t, root, firstCWD) | |
| 300 | + | second := createPruneTestSession(t, root, secondCWD) | |
| 301 | + | oldTime := time.Now().Add(-48 * time.Hour) | |
| 302 | + | for _, path := range []string{first, second} { | |
| 303 | + | if err := os.Chtimes(path, oldTime, oldTime); err != nil { | |
| 304 | + | t.Fatal(err) | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | age, err := parseSessionAge("24h") | |
| 309 | + | if err != nil { | |
| 310 | + | t.Fatal(err) | |
| 311 | + | } | |
| 312 | + | var out, errOut bytes.Buffer | |
| 313 | + | opts := sessionsPruneOptions{all: true, yes: true, olderThan: &age, cwd: firstCWD} | |
| 314 | + | if err := runSessionsPrune(root, opts, strings.NewReader(""), &out, &errOut, os.Stat); err != nil { | |
| 315 | + | t.Fatal(err) | |
| 316 | + | } | |
| 317 | + | if _, err := os.Stat(first); !os.IsNotExist(err) { | |
| 318 | + | t.Fatalf("matching session still exists or stat failed unexpectedly: %v", err) | |
| 319 | + | } | |
| 320 | + | if _, err := os.Stat(second); err != nil { | |
| 321 | + | t.Fatalf("session from another cwd was removed: %v", err) | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | func TestSessionsPruneWarnsOnScanIssues(t *testing.T) { | |
| 326 | + | root := t.TempDir() | |
| 327 | + | kept := createPruneTestSession(t, root, filepath.Join(root, "gone")) | |
| 328 | + | if err := os.WriteFile(filepath.Join(root, "broken.jsonl"), []byte("{not json\n"), 0o600); err != nil { | |
| 329 | + | t.Fatal(err) | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | var out, errOut bytes.Buffer | |
| 333 | + | if err := runSessionsPrune(root, sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, os.Stat); err != nil { | |
| 334 | + | t.Fatal(err) | |
| 335 | + | } | |
| 336 | + | if !strings.Contains(errOut.String(), "warning: preserving") { | |
| 337 | + | t.Fatalf("stderr = %q, want scan-issue warning", errOut.String()) | |
| 338 | + | } | |
| 339 | + | if _, err := os.Stat(filepath.Join(root, "broken.jsonl")); err != nil { | |
| 340 | + | t.Fatalf("malformed entry was removed: %v", err) | |
| 341 | + | } | |
| 342 | + | if _, err := os.Stat(kept); !os.IsNotExist(err) { | |
| 343 | + | t.Fatalf("stale session still exists: %v", err) | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | func TestSessionsCommandDispatch(t *testing.T) { | |
| 348 | + | if handled, err := runSessionsCommand(nil); handled || err != nil { | |
| 349 | + | t.Fatalf("handled=%v err=%v, want passthrough", handled, err) | |
| 350 | + | } | |
| 351 | + | if handled, err := runSessionsCommand([]string{"other"}); handled || err != nil { | |
| 352 | + | t.Fatalf("handled=%v err=%v, want passthrough", handled, err) | |
| 353 | + | } | |
| 354 | + | if handled, err := runSessionsCommand([]string{"sessions", "bogus"}); !handled || err == nil { | |
| 355 | + | t.Fatalf("handled=%v err=%v, want handled error", handled, err) | |
| 356 | + | } | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | func createPruneTestSession(t *testing.T, root, cwd string) string { | |
| 360 | + | t.Helper() | |
| 361 | + | session, err := core.NewSession(root, cwd, "test", "test-model", "test-version") | |
| 362 | + | if err != nil { | |
| 363 | + | t.Fatal(err) | |
| 364 | + | } | |
| 365 | + | if err := session.AppendMessage(provider.Message{ | |
| 366 | + | Role: provider.RoleUser, | |
| 367 | + | Content: []provider.Content{provider.TextBlock{Text: "hello"}}, | |
| 368 | + | }); err != nil { | |
| 369 | + | t.Fatal(err) | |
| 370 | + | } | |
| 371 | + | return session.Path | |
| 372 | + | } | |
A packages/agent/tools/glob.go +402 -0
| @@ -0,0 +1,402 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "regexp" | |
| 10 | + | "sort" | |
| 11 | + | "strings" | |
| 12 | + | "unicode/utf8" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/zut/packages/core" | |
| 15 | + | "github.com/bnema/zut/packages/ignore" | |
| 16 | + | "github.com/bnema/zut/packages/provider" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | const ( | |
| 20 | + | maxGlobInputBytes = 4 * 1024 | |
| 21 | + | maxGlobMatches = 500 | |
| 22 | + | maxGlobDepth = 24 | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // GlobTool finds files matching a pattern within a directory tree. | |
| 26 | + | type GlobTool struct { | |
| 27 | + | CWD string | |
| 28 | + | Sandbox *Sandbox | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | var _ core.Tool = (*GlobTool)(nil) | |
| 32 | + | ||
| 33 | + | type globArgs struct { | |
| 34 | + | Pattern string `json:"pattern"` | |
| 35 | + | Path string `json:"path,omitempty"` | |
| 36 | + | Hidden bool `json:"hidden,omitempty"` | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | const globSchema = `{"type":"object","properties":{"pattern":{"type":"string","description":"Glob pattern to match files against (e.g. \"**/*.go\", \"*.json\", \"src/**/*.ts\")"},"path":{"type":"string","description":"Directory to search within, relative to CWD (defaults to \".\")"},"hidden":{"type":"boolean","description":"Whether to include hidden files and directories (default false)"}},"required":["pattern"],"additionalProperties":false}` | |
| 40 | + | ||
| 41 | + | func (t *GlobTool) Name() string { return "glob" } | |
| 42 | + | ||
| 43 | + | func (t *GlobTool) Description() string { | |
| 44 | + | return "Find files matching a glob pattern (e.g. \"**/*.go\", \"*.json\", \"src/**/*.ts\"). Honors .gitignore rules." | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (t *GlobTool) Schema() json.RawMessage { return json.RawMessage(globSchema) } | |
| 48 | + | ||
| 49 | + | // Execute walks the requested directory and returns the files matching the | |
| 50 | + | // pattern. The search root is resolved and checked before traversal so a | |
| 51 | + | // denied path cannot be used to probe the filesystem. | |
| 52 | + | func (t *GlobTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |
| 53 | + | args, err := parseGlobArgs(raw) | |
| 54 | + | if err != nil { | |
| 55 | + | return core.ToolResult{}, err | |
| 56 | + | } | |
| 57 | + | if err := ctx.Err(); err != nil { | |
| 58 | + | return core.ToolResult{}, err | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | re, hasSlash, err := compileGlob(args.Pattern) | |
| 62 | + | if err != nil { | |
| 63 | + | return core.ToolResult{}, err | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | root := filepath.Clean(resolvePath(t.CWD, args.Path)) | |
| 67 | + | if !filepath.IsAbs(root) { | |
| 68 | + | root, err = filepath.Abs(root) | |
| 69 | + | if err != nil { | |
| 70 | + | return core.ToolResult{}, err | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | if err := t.Sandbox.CheckReadPath(root); err != nil { | |
| 74 | + | return core.ToolResult{}, err | |
| 75 | + | } | |
| 76 | + | info, err := os.Stat(root) | |
| 77 | + | if err != nil { | |
| 78 | + | return core.ToolResult{}, err | |
| 79 | + | } | |
| 80 | + | if !info.IsDir() { | |
| 81 | + | shown := t.Sandbox.DisplayPath(root, args.Path) | |
| 82 | + | return core.ToolResult{}, fmt.Errorf("glob: %s is not a directory", shown) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // WalkDir does not follow a symlink used as its root. Resolve the | |
| 86 | + | // explicit search directory so a path that Stat identified as a | |
| 87 | + | // directory is actually traversed, then re-check the resolved target | |
| 88 | + | // before reading it. | |
| 89 | + | searchDir, err := filepath.EvalSymlinks(root) | |
| 90 | + | if err != nil { | |
| 91 | + | return core.ToolResult{}, err | |
| 92 | + | } | |
| 93 | + | if err := t.Sandbox.CheckReadPath(searchDir); err != nil { | |
| 94 | + | return core.ToolResult{}, err | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | stack, ignoreRoot, searchIgnored := t.globIgnoreStack(searchDir) | |
| 98 | + | if searchIgnored { | |
| 99 | + | // An explicitly requested root is always walked, even when an | |
| 100 | + | // ignore rule matches it: like ripgrep with an explicit path, the | |
| 101 | + | // user's narrowing wins over inherited rules. Nested ignore files | |
| 102 | + | // still apply inside the walk. | |
| 103 | + | stack, ignoreRoot = ignore.NewStack(searchDir), searchDir | |
| 104 | + | } | |
| 105 | + | rootSep := strings.Count(searchDir, string(os.PathSeparator)) | |
| 106 | + | var pushed []string | |
| 107 | + | var matches []string | |
| 108 | + | var discovered []string | |
| 109 | + | truncated := false | |
| 110 | + | ||
| 111 | + | var walkErr error | |
| 112 | + | walkErr = filepath.WalkDir(searchDir, func(path string, d os.DirEntry, err error) error { | |
| 113 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 114 | + | return ctxErr | |
| 115 | + | } | |
| 116 | + | if err != nil { | |
| 117 | + | if d != nil && d.IsDir() { | |
| 118 | + | return filepath.SkipDir | |
| 119 | + | } | |
| 120 | + | return nil | |
| 121 | + | } | |
| 122 | + | if path == searchDir { | |
| 123 | + | return nil | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | rel, relErr := filepath.Rel(searchDir, path) | |
| 127 | + | if relErr != nil { | |
| 128 | + | return nil | |
| 129 | + | } | |
| 130 | + | relSlash := filepath.ToSlash(rel) | |
| 131 | + | ||
| 132 | + | // Hidden entries are skipped unless explicitly requested. | |
| 133 | + | // .git is always skipped. Nested symlinks are never followed: a | |
| 134 | + | // symlink to a directory is reported by WalkDir as a non-directory, | |
| 135 | + | // so resolve it explicitly and skip directory targets instead of | |
| 136 | + | // listing them as file matches. | |
| 137 | + | if d.Type()&os.ModeSymlink != 0 { | |
| 138 | + | if target, evalErr := filepath.EvalSymlinks(path); evalErr == nil { | |
| 139 | + | if targetInfo, statErr := os.Stat(target); statErr == nil && targetInfo.IsDir() { | |
| 140 | + | return nil | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | if !args.Hidden { | |
| 145 | + | if strings.HasPrefix(d.Name(), ".") { | |
| 146 | + | if d.IsDir() { | |
| 147 | + | return filepath.SkipDir | |
| 148 | + | } | |
| 149 | + | return nil | |
| 150 | + | } | |
| 151 | + | } else if d.IsDir() && d.Name() == ".git" { | |
| 152 | + | return filepath.SkipDir | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | ignoreRel, ignoreRelErr := filepath.Rel(ignoreRoot, path) | |
| 156 | + | if ignoreRelErr != nil { | |
| 157 | + | return nil | |
| 158 | + | } | |
| 159 | + | ignoreRelSlash := filepath.ToSlash(ignoreRel) | |
| 160 | + | ||
| 161 | + | // Ignore filtering: pop stack frames for directories no | |
| 162 | + | // longer in scope. | |
| 163 | + | dirSlash := relSlash | |
| 164 | + | if !d.IsDir() { | |
| 165 | + | if idx := strings.LastIndex(relSlash, "/"); idx >= 0 { | |
| 166 | + | dirSlash = relSlash[:idx] | |
| 167 | + | } else { | |
| 168 | + | dirSlash = "" | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | for len(pushed) > 0 { | |
| 172 | + | top := pushed[len(pushed)-1] | |
| 173 | + | if top == dirSlash || strings.HasPrefix(dirSlash, top+"/") { | |
| 174 | + | break | |
| 175 | + | } | |
| 176 | + | pushed = pushed[:len(pushed)-1] | |
| 177 | + | stack.Pop() | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | if stack.Match(ignoreRelSlash, d.IsDir()) { | |
| 181 | + | if d.IsDir() { | |
| 182 | + | return filepath.SkipDir | |
| 183 | + | } | |
| 184 | + | return nil | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | if d.IsDir() { | |
| 188 | + | if strings.Count(path, string(os.PathSeparator))-rootSep >= maxGlobDepth { | |
| 189 | + | return filepath.SkipDir | |
| 190 | + | } | |
| 191 | + | stack.Push(path, ignoreRel) | |
| 192 | + | pushed = append(pushed, relSlash) | |
| 193 | + | return nil | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | var matched bool | |
| 197 | + | if hasSlash { | |
| 198 | + | matched = re.MatchString(relSlash) | |
| 199 | + | } else { | |
| 200 | + | matched = re.MatchString(d.Name()) | |
| 201 | + | } | |
| 202 | + | if matched { | |
| 203 | + | matches = append(matches, t.globDisplayPath(root, args.Path, relSlash)) | |
| 204 | + | discovered = append(discovered, canonicalResourcePath(filepath.Join(searchDir, filepath.FromSlash(relSlash)))) | |
| 205 | + | if len(matches) >= maxGlobMatches { | |
| 206 | + | truncated = true | |
| 207 | + | return filepath.SkipAll | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | return nil | |
| 211 | + | }) | |
| 212 | + | ||
| 213 | + | if walkErr != nil && walkErr != filepath.SkipAll { | |
| 214 | + | return core.ToolResult{}, walkErr | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | sort.Strings(matches) | |
| 218 | + | ||
| 219 | + | var text string | |
| 220 | + | if len(matches) == 0 { | |
| 221 | + | text = "No files matched the pattern." | |
| 222 | + | } else { | |
| 223 | + | text = strings.Join(matches, "\n") | |
| 224 | + | if truncated { | |
| 225 | + | text += fmt.Sprintf("\n\n(Truncated: showing first %d matches)", maxGlobMatches) | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | return core.ToolResult{ | |
| 230 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |
| 231 | + | Context: provider.ToolContext{Discovers: discovered}, | |
| 232 | + | Details: map[string]any{ | |
| 233 | + | "matches": len(matches), | |
| 234 | + | "truncated": truncated, | |
| 235 | + | "pattern": args.Pattern, | |
| 236 | + | }, | |
| 237 | + | }, nil | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func parseGlobArgs(raw json.RawMessage) (globArgs, error) { | |
| 241 | + | if len(raw) == 0 || len(raw) > maxGlobInputBytes { | |
| 242 | + | return globArgs{}, fmt.Errorf("glob: invalid arguments") | |
| 243 | + | } | |
| 244 | + | var fields map[string]json.RawMessage | |
| 245 | + | if err := json.Unmarshal(raw, &fields); err != nil || fields == nil { | |
| 246 | + | return globArgs{}, fmt.Errorf("glob: invalid arguments") | |
| 247 | + | } | |
| 248 | + | for key := range fields { | |
| 249 | + | if key != "pattern" && key != "path" && key != "hidden" { | |
| 250 | + | return globArgs{}, fmt.Errorf("glob: unknown argument %q", key) | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | var args globArgs | |
| 254 | + | if err := json.Unmarshal(raw, &args); err != nil { | |
| 255 | + | return globArgs{}, fmt.Errorf("glob: invalid arguments: %w", err) | |
| 256 | + | } | |
| 257 | + | if _, ok := fields["pattern"]; !ok || strings.TrimSpace(args.Pattern) == "" { | |
| 258 | + | return globArgs{}, fmt.Errorf("glob: pattern is required") | |
| 259 | + | } | |
| 260 | + | if !utf8.ValidString(args.Pattern) || !utf8.ValidString(args.Path) || | |
| 261 | + | strings.IndexByte(args.Pattern, 0) >= 0 || strings.IndexByte(args.Path, 0) >= 0 { | |
| 262 | + | return globArgs{}, fmt.Errorf("glob: arguments contain invalid characters") | |
| 263 | + | } | |
| 264 | + | return args, nil | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // globIgnoreStack builds an ignore stack rooted at CWD when the search path | |
| 268 | + | // is below it, preloading the ignore files between CWD and the search root. | |
| 269 | + | // This preserves inherited rules when the caller narrows a search with path. | |
| 270 | + | func (t *GlobTool) globIgnoreStack(searchDir string) (*ignore.Stack, string, bool) { | |
| 271 | + | cwd := resolvePath(t.CWD, ".") | |
| 272 | + | cwd, err := filepath.EvalSymlinks(cwd) | |
| 273 | + | if err != nil || t.Sandbox.CheckReadPath(cwd) != nil { | |
| 274 | + | return ignore.NewStack(searchDir), searchDir, false | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | rel, err := filepath.Rel(cwd, searchDir) | |
| 278 | + | if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) { | |
| 279 | + | return ignore.NewStack(searchDir), searchDir, false | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | stack := ignore.NewStack(cwd) | |
| 283 | + | if rel == "." { | |
| 284 | + | return stack, cwd, false | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | current := cwd | |
| 288 | + | var relParts []string | |
| 289 | + | for _, part := range strings.Split(rel, string(os.PathSeparator)) { | |
| 290 | + | current = filepath.Join(current, part) | |
| 291 | + | relParts = append(relParts, part) | |
| 292 | + | currentRel := filepath.Join(relParts...) | |
| 293 | + | if stack.Match(filepath.ToSlash(currentRel), true) { | |
| 294 | + | return stack, cwd, true | |
| 295 | + | } | |
| 296 | + | stack.Push(current, currentRel) | |
| 297 | + | } | |
| 298 | + | return stack, cwd, false | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | func (t *GlobTool) globDisplayPath(searchDir, given, relSlash string) string { | |
| 302 | + | if given == "" || given == "." { | |
| 303 | + | return relSlash | |
| 304 | + | } | |
| 305 | + | // Prefer the path exactly as requested so a symlinked search root | |
| 306 | + | // keeps its name in results instead of the resolved target. | |
| 307 | + | if !filepath.IsAbs(given) { | |
| 308 | + | cleaned := filepath.ToSlash(filepath.Clean(given)) | |
| 309 | + | if cleaned != "" && cleaned != "." { | |
| 310 | + | return strings.TrimSuffix(cleaned, "/") + "/" + relSlash | |
| 311 | + | } | |
| 312 | + | return relSlash | |
| 313 | + | } | |
| 314 | + | prefix := filepath.ToSlash(t.Sandbox.DisplayPath(searchDir, given)) | |
| 315 | + | if prefix == "" || prefix == "." { | |
| 316 | + | return relSlash | |
| 317 | + | } | |
| 318 | + | return strings.TrimSuffix(prefix, "/") + "/" + relSlash | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | // compileGlob converts a slash-normalized glob pattern into a regular | |
| 322 | + | // expression. It supports '*', '**', '?', and character classes '[...]'. | |
| 323 | + | // Patterns containing a slash match against the path relative to the search | |
| 324 | + | // root; patterns without one match against the file basename. | |
| 325 | + | func compileGlob(pattern string) (*regexp.Regexp, bool, error) { | |
| 326 | + | pattern = filepath.ToSlash(strings.TrimSpace(pattern)) | |
| 327 | + | pattern = strings.TrimPrefix(pattern, "./") | |
| 328 | + | if pattern == "" { | |
| 329 | + | return nil, false, fmt.Errorf("glob: empty pattern") | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | hasSlash := strings.Contains(pattern, "/") | |
| 333 | + | ||
| 334 | + | var sb strings.Builder | |
| 335 | + | sb.WriteString("^") | |
| 336 | + | i := 0 | |
| 337 | + | for i < len(pattern) { | |
| 338 | + | switch c := pattern[i]; c { | |
| 339 | + | case '*': | |
| 340 | + | if i+1 < len(pattern) && pattern[i+1] == '*' { | |
| 341 | + | i += 2 | |
| 342 | + | if i < len(pattern) && pattern[i] == '/' { | |
| 343 | + | i++ | |
| 344 | + | sb.WriteString("(?:.*/)?") | |
| 345 | + | } else { | |
| 346 | + | sb.WriteString(".*") | |
| 347 | + | } | |
| 348 | + | } else { | |
| 349 | + | i++ | |
| 350 | + | if hasSlash { | |
| 351 | + | sb.WriteString("[^/]*") | |
| 352 | + | } else { | |
| 353 | + | sb.WriteString(".*") | |
| 354 | + | } | |
| 355 | + | } | |
| 356 | + | case '?': | |
| 357 | + | i++ | |
| 358 | + | if hasSlash { | |
| 359 | + | sb.WriteString("[^/]") | |
| 360 | + | } else { | |
| 361 | + | sb.WriteString(".") | |
| 362 | + | } | |
| 363 | + | case '[': | |
| 364 | + | j := i + 1 | |
| 365 | + | negated := false | |
| 366 | + | if j < len(pattern) && (pattern[j] == '^' || pattern[j] == '!') { | |
| 367 | + | negated = pattern[j] == '!' | |
| 368 | + | j++ | |
| 369 | + | } | |
| 370 | + | if j < len(pattern) && pattern[j] == ']' { | |
| 371 | + | j++ | |
| 372 | + | } | |
| 373 | + | for j < len(pattern) && pattern[j] != ']' { | |
| 374 | + | j++ | |
| 375 | + | } | |
| 376 | + | if j < len(pattern) { | |
| 377 | + | class := pattern[i+1 : j+1] | |
| 378 | + | if negated { | |
| 379 | + | class = "^" + class[1:] | |
| 380 | + | } | |
| 381 | + | sb.WriteString("[" + class) | |
| 382 | + | i = j + 1 | |
| 383 | + | } else { | |
| 384 | + | return nil, false, fmt.Errorf("glob: invalid pattern %q: unterminated character class", pattern) | |
| 385 | + | } | |
| 386 | + | case '.', '+', '(', ')', '^', '$', '|', '\\', '{', '}', ',': | |
| 387 | + | sb.WriteByte('\\') | |
| 388 | + | sb.WriteByte(c) | |
| 389 | + | i++ | |
| 390 | + | default: | |
| 391 | + | sb.WriteByte(c) | |
| 392 | + | i++ | |
| 393 | + | } | |
| 394 | + | } | |
| 395 | + | sb.WriteString("$") | |
| 396 | + | ||
| 397 | + | re, err := regexp.Compile(sb.String()) | |
| 398 | + | if err != nil { | |
| 399 | + | return nil, false, fmt.Errorf("glob: invalid pattern %q: %w", pattern, err) | |
| 400 | + | } | |
| 401 | + | return re, hasSlash, nil | |
| 402 | + | } | |
A packages/agent/tools/glob_test.go +541 -0
| @@ -0,0 +1,541 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "runtime" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/zut/packages/core" | |
| 13 | + | "github.com/bnema/zut/packages/provider" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func globArgsJSON(pattern, path string, hidden bool) json.RawMessage { | |
| 17 | + | args := map[string]any{"pattern": pattern} | |
| 18 | + | if path != "" { | |
| 19 | + | args["path"] = path | |
| 20 | + | } | |
| 21 | + | if hidden { | |
| 22 | + | args["hidden"] = true | |
| 23 | + | } | |
| 24 | + | raw, _ := json.Marshal(args) | |
| 25 | + | return raw | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func writeGlobFile(t *testing.T, path, content string) { | |
| 29 | + | t.Helper() | |
| 30 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 31 | + | t.Fatal(err) | |
| 32 | + | } | |
| 33 | + | if err := os.WriteFile(path, []byte(content), 0o644); err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func globDetails(result core.ToolResult) map[string]any { | |
| 39 | + | return result.Details.(map[string]any) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func globResultText(t *testing.T, result core.ToolResult) string { | |
| 43 | + | t.Helper() | |
| 44 | + | if len(result.Content) != 1 { | |
| 45 | + | t.Fatalf("content blocks = %d, want 1", len(result.Content)) | |
| 46 | + | } | |
| 47 | + | text, ok := result.Content[0].(provider.TextBlock) | |
| 48 | + | if !ok { | |
| 49 | + | t.Fatalf("content type = %T, want TextBlock", result.Content[0]) | |
| 50 | + | } | |
| 51 | + | return text.Text | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestGlobStarMatchesBasenames(t *testing.T) { | |
| 55 | + | root := t.TempDir() | |
| 56 | + | writeGlobFile(t, filepath.Join(root, "a.go"), "") | |
| 57 | + | writeGlobFile(t, filepath.Join(root, "b.txt"), "") | |
| 58 | + | writeGlobFile(t, filepath.Join(root, "sub", "c.go"), "") | |
| 59 | + | ||
| 60 | + | tool := &GlobTool{CWD: root} | |
| 61 | + | result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "", false), nil) | |
| 62 | + | if err != nil { | |
| 63 | + | t.Fatal(err) | |
| 64 | + | } | |
| 65 | + | text := globResultText(t, result) | |
| 66 | + | if !strings.Contains(text, "a.go") || !strings.Contains(text, "sub/c.go") { | |
| 67 | + | t.Fatalf("output missing matches: %q", text) | |
| 68 | + | } | |
| 69 | + | if strings.Contains(text, "b.txt") { | |
| 70 | + | t.Fatalf("output contains non-match: %q", text) | |
| 71 | + | } | |
| 72 | + | if globDetails(result)["matches"] != 2 { | |
| 73 | + | t.Fatalf("matches = %v, want 2", globDetails(result)["matches"]) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestGlobGlobstarMatchesNestedPaths(t *testing.T) { | |
| 78 | + | root := t.TempDir() | |
| 79 | + | writeGlobFile(t, filepath.Join(root, "a.go"), "") | |
| 80 | + | writeGlobFile(t, filepath.Join(root, "sub", "deep", "b.go"), "") | |
| 81 | + | ||
| 82 | + | tool := &GlobTool{CWD: root} | |
| 83 | + | result, err := tool.Execute(context.Background(), globArgsJSON("**/*.go", "", false), nil) | |
| 84 | + | if err != nil { | |
| 85 | + | t.Fatal(err) | |
| 86 | + | } | |
| 87 | + | text := globResultText(t, result) | |
| 88 | + | if !strings.Contains(text, "a.go") || !strings.Contains(text, "sub/deep/b.go") { | |
| 89 | + | t.Fatalf("globstar missed nested matches: %q", text) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestGlobQuestionMarkAndCharacterClass(t *testing.T) { | |
| 94 | + | root := t.TempDir() | |
| 95 | + | writeGlobFile(t, filepath.Join(root, "a1.go"), "") | |
| 96 | + | writeGlobFile(t, filepath.Join(root, "a2.go"), "") | |
| 97 | + | writeGlobFile(t, filepath.Join(root, "abc.go"), "") | |
| 98 | + | ||
| 99 | + | tool := &GlobTool{CWD: root} | |
| 100 | + | result, err := tool.Execute(context.Background(), globArgsJSON("a?.go", "", false), nil) | |
| 101 | + | if err != nil { | |
| 102 | + | t.Fatal(err) | |
| 103 | + | } | |
| 104 | + | if got := globDetails(result)["matches"]; got != 2 { | |
| 105 | + | t.Fatalf("? matches = %v, want 2", got) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | result, err = tool.Execute(context.Background(), globArgsJSON("a[12].go", "", false), nil) | |
| 109 | + | if err != nil { | |
| 110 | + | t.Fatal(err) | |
| 111 | + | } | |
| 112 | + | if got := globDetails(result)["matches"]; got != 2 { | |
| 113 | + | t.Fatalf("class matches = %v, want 2", got) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func TestGlobSlashPatternMatchesRelativePath(t *testing.T) { | |
| 118 | + | root := t.TempDir() | |
| 119 | + | writeGlobFile(t, filepath.Join(root, "src", "a.go"), "") | |
| 120 | + | writeGlobFile(t, filepath.Join(root, "other", "a.go"), "") | |
| 121 | + | ||
| 122 | + | tool := &GlobTool{CWD: root} | |
| 123 | + | result, err := tool.Execute(context.Background(), globArgsJSON("src/*.go", "", false), nil) | |
| 124 | + | if err != nil { | |
| 125 | + | t.Fatal(err) | |
| 126 | + | } | |
| 127 | + | text := globResultText(t, result) | |
| 128 | + | if !strings.Contains(text, "src/a.go") || strings.Contains(text, "other/a.go") { | |
| 129 | + | t.Fatalf("path pattern matched wrong set: %q", text) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestGlobInvalidPatternIsRejected(t *testing.T) { | |
| 134 | + | tool := &GlobTool{CWD: t.TempDir()} | |
| 135 | + | if _, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":""}`), nil); err == nil { | |
| 136 | + | t.Fatal("empty pattern accepted") | |
| 137 | + | } | |
| 138 | + | if _, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":"a["}`), nil); err == nil { | |
| 139 | + | t.Fatal("unterminated class accepted") | |
| 140 | + | } | |
| 141 | + | if _, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":1}`), nil); err == nil { | |
| 142 | + | t.Fatal("non-string pattern accepted") | |
| 143 | + | } | |
| 144 | + | if _, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":"*.go","bogus":1}`), nil); err == nil { | |
| 145 | + | t.Fatal("unknown argument accepted") | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestGlobHiddenToggle(t *testing.T) { | |
| 150 | + | root := t.TempDir() | |
| 151 | + | writeGlobFile(t, filepath.Join(root, ".hidden.go"), "") | |
| 152 | + | writeGlobFile(t, filepath.Join(root, ".hdir", "inside.go"), "") | |
| 153 | + | writeGlobFile(t, filepath.Join(root, "visible.go"), "") | |
| 154 | + | writeGlobFile(t, filepath.Join(root, ".git", "objects", "x"), "") | |
| 155 | + | ||
| 156 | + | tool := &GlobTool{CWD: root} | |
| 157 | + | result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "", false), nil) | |
| 158 | + | if err != nil { | |
| 159 | + | t.Fatal(err) | |
| 160 | + | } | |
| 161 | + | text := globResultText(t, result) | |
| 162 | + | if strings.Contains(text, ".hidden") || strings.Contains(text, ".hdir") { | |
| 163 | + | t.Fatalf("hidden entries leaked: %q", text) | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | result, err = tool.Execute(context.Background(), globArgsJSON("*.go", "", true), nil) | |
| 167 | + | if err != nil { | |
| 168 | + | t.Fatal(err) | |
| 169 | + | } | |
| 170 | + | text = globResultText(t, result) | |
| 171 | + | if !strings.Contains(text, ".hidden.go") || !strings.Contains(text, ".hdir/inside.go") { | |
| 172 | + | t.Fatalf("hidden opt-in missed entries: %q", text) | |
| 173 | + | } | |
| 174 | + | if strings.Contains(text, ".git") { | |
| 175 | + | t.Fatalf(".git leaked with hidden opt-in: %q", text) | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func TestGlobHonorsRootAndNestedIgnores(t *testing.T) { | |
| 180 | + | root := t.TempDir() | |
| 181 | + | writeGlobFile(t, filepath.Join(root, ".gitignore"), "ignored.txt\nbuild/\n") | |
| 182 | + | writeGlobFile(t, filepath.Join(root, "ignored.txt"), "") | |
| 183 | + | writeGlobFile(t, filepath.Join(root, "kept.txt"), "") | |
| 184 | + | writeGlobFile(t, filepath.Join(root, "build", "out.txt"), "") | |
| 185 | + | writeGlobFile(t, filepath.Join(root, "sub", ".gitignore"), "local.txt\n") | |
| 186 | + | writeGlobFile(t, filepath.Join(root, "sub", "local.txt"), "") | |
| 187 | + | writeGlobFile(t, filepath.Join(root, "sub", "keep.txt"), "") | |
| 188 | + | ||
| 189 | + | tool := &GlobTool{CWD: root} | |
| 190 | + | result, err := tool.Execute(context.Background(), globArgsJSON("*.txt", "", false), nil) | |
| 191 | + | if err != nil { | |
| 192 | + | t.Fatal(err) | |
| 193 | + | } | |
| 194 | + | text := globResultText(t, result) | |
| 195 | + | for _, leaked := range []string{"ignored.txt", "build/out.txt", "local.txt"} { | |
| 196 | + | if strings.Contains(text, leaked) { | |
| 197 | + | t.Fatalf("ignored file leaked: %q in %q", leaked, text) | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | if !strings.Contains(text, "kept.txt") || !strings.Contains(text, "sub/keep.txt") { | |
| 201 | + | t.Fatalf("kept files missing: %q", text) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // Narrowing with path preserves the inherited root rules. | |
| 205 | + | result, err = tool.Execute(context.Background(), globArgsJSON("*.txt", "sub", false), nil) | |
| 206 | + | if err != nil { | |
| 207 | + | t.Fatal(err) | |
| 208 | + | } | |
| 209 | + | text = globResultText(t, result) | |
| 210 | + | if strings.Contains(text, "local.txt") { | |
| 211 | + | t.Fatalf("nested ignore lost with path narrowing: %q", text) | |
| 212 | + | } | |
| 213 | + | if !strings.Contains(text, "keep.txt") { | |
| 214 | + | t.Fatalf("narrowed search missed keep.txt: %q", text) | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func TestGlobNegationUnignores(t *testing.T) { | |
| 219 | + | root := t.TempDir() | |
| 220 | + | writeGlobFile(t, filepath.Join(root, ".gitignore"), "*.log\n!important.log\n") | |
| 221 | + | writeGlobFile(t, filepath.Join(root, "a.log"), "") | |
| 222 | + | writeGlobFile(t, filepath.Join(root, "important.log"), "") | |
| 223 | + | ||
| 224 | + | tool := &GlobTool{CWD: root} | |
| 225 | + | result, err := tool.Execute(context.Background(), globArgsJSON("*.log", "", false), nil) | |
| 226 | + | if err != nil { | |
| 227 | + | t.Fatal(err) | |
| 228 | + | } | |
| 229 | + | text := globResultText(t, result) | |
| 230 | + | if strings.Contains(text, "a.log") || !strings.Contains(text, "important.log") { | |
| 231 | + | t.Fatalf("negation mishandled: %q", text) | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func TestGlobWalksExplicitlyRequestedIgnoredRoot(t *testing.T) { | |
| 236 | + | root := t.TempDir() | |
| 237 | + | writeGlobFile(t, filepath.Join(root, ".gitignore"), "build/\n") | |
| 238 | + | writeGlobFile(t, filepath.Join(root, "build", "out.txt"), "") | |
| 239 | + | ||
| 240 | + | tool := &GlobTool{CWD: root} | |
| 241 | + | result, err := tool.Execute(context.Background(), globArgsJSON("*.txt", "build", false), nil) | |
| 242 | + | if err != nil { | |
| 243 | + | t.Fatal(err) | |
| 244 | + | } | |
| 245 | + | if !strings.Contains(globResultText(t, result), "build/out.txt") { | |
| 246 | + | t.Fatalf("explicit ignored root not walked: %q", globResultText(t, result)) | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func TestGlobFileRootIsRejected(t *testing.T) { | |
| 251 | + | root := t.TempDir() | |
| 252 | + | writeGlobFile(t, filepath.Join(root, "a.go"), "") | |
| 253 | + | ||
| 254 | + | tool := &GlobTool{CWD: root} | |
| 255 | + | if _, err := tool.Execute(context.Background(), globArgsJSON("*.go", "a.go", false), nil); err == nil { | |
| 256 | + | t.Fatal("file search root accepted") | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func TestGlobEmptyResults(t *testing.T) { | |
| 261 | + | tool := &GlobTool{CWD: t.TempDir()} | |
| 262 | + | result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "", false), nil) | |
| 263 | + | if err != nil { | |
| 264 | + | t.Fatal(err) | |
| 265 | + | } | |
| 266 | + | if got := globResultText(t, result); got != "No files matched the pattern." { | |
| 267 | + | t.Fatalf("empty output = %q", got) | |
| 268 | + | } | |
| 269 | + | if globDetails(result)["matches"] != 0 || globDetails(result)["truncated"] != false { | |
| 270 | + | t.Fatalf("empty details = %v", result.Details) | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func TestGlobSortsDeterministically(t *testing.T) { | |
| 275 | + | root := t.TempDir() | |
| 276 | + | for _, name := range []string{"z.go", "m.go", "a.go", "sub/z.go", "sub/a.go"} { | |
| 277 | + | writeGlobFile(t, filepath.Join(root, name), "") | |
| 278 | + | } | |
| 279 | + | tool := &GlobTool{CWD: root} | |
| 280 | + | first, err := tool.Execute(context.Background(), globArgsJSON("**/*.go", "", false), nil) | |
| 281 | + | if err != nil { | |
| 282 | + | t.Fatal(err) | |
| 283 | + | } | |
| 284 | + | second, err := tool.Execute(context.Background(), globArgsJSON("**/*.go", "", false), nil) | |
| 285 | + | if err != nil { | |
| 286 | + | t.Fatal(err) | |
| 287 | + | } | |
| 288 | + | a, b := globResultText(t, first), globResultText(t, second) | |
| 289 | + | if a != b { | |
| 290 | + | t.Fatalf("nondeterministic order:\n%s\n%s", a, b) | |
| 291 | + | } | |
| 292 | + | lines := strings.Split(a, "\n") | |
| 293 | + | for i := 1; i < len(lines); i++ { | |
| 294 | + | if lines[i-1] > lines[i] { | |
| 295 | + | t.Fatalf("unsorted output: %q", a) | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | func TestGlobRespectsSandbox(t *testing.T) { | |
| 301 | + | base := t.TempDir() | |
| 302 | + | root := filepath.Join(base, "workspace") | |
| 303 | + | if err := os.Mkdir(root, 0o755); err != nil { | |
| 304 | + | t.Fatal(err) | |
| 305 | + | } | |
| 306 | + | outside := filepath.Join(base, "outside") | |
| 307 | + | if err := os.Mkdir(outside, 0o755); err != nil { | |
| 308 | + | t.Fatal(err) | |
| 309 | + | } | |
| 310 | + | writeGlobFile(t, filepath.Join(outside, "secret.go"), "") | |
| 311 | + | ||
| 312 | + | sandbox := NewSandbox(root) | |
| 313 | + | sandbox.Lock() | |
| 314 | + | tool := &GlobTool{CWD: root, Sandbox: sandbox} | |
| 315 | + | if _, err := tool.Execute(context.Background(), globArgsJSON("*.go", outside, false), nil); err == nil { | |
| 316 | + | t.Fatal("search outside jail accepted") | |
| 317 | + | } else if !strings.Contains(err.Error(), "jailed") { | |
| 318 | + | t.Fatalf("error = %v, want jail denial", err) | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | func TestGlobRootSymlinkInsideAndOutsideJail(t *testing.T) { | |
| 323 | + | if os.Getenv("OS") == "Windows_NT" { | |
| 324 | + | t.Skip("symlink tests need privileges on windows") | |
| 325 | + | } | |
| 326 | + | base := t.TempDir() | |
| 327 | + | root := filepath.Join(base, "workspace") | |
| 328 | + | inner := filepath.Join(root, "inner") | |
| 329 | + | if err := os.MkdirAll(inner, 0o755); err != nil { | |
| 330 | + | t.Fatal(err) | |
| 331 | + | } | |
| 332 | + | writeGlobFile(t, filepath.Join(inner, "a.go"), "") | |
| 333 | + | outside := filepath.Join(base, "outside") | |
| 334 | + | if err := os.MkdirAll(outside, 0o755); err != nil { | |
| 335 | + | t.Fatal(err) | |
| 336 | + | } | |
| 337 | + | writeGlobFile(t, filepath.Join(outside, "secret.go"), "") | |
| 338 | + | ||
| 339 | + | sandbox := NewSandbox(root) | |
| 340 | + | sandbox.Lock() | |
| 341 | + | ||
| 342 | + | inside := filepath.Join(root, "link-inside") | |
| 343 | + | if err := os.Symlink(inner, inside); err != nil { | |
| 344 | + | t.Skipf("symlinks unsupported: %v", err) | |
| 345 | + | } | |
| 346 | + | tool := &GlobTool{CWD: root, Sandbox: sandbox} | |
| 347 | + | result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "link-inside", false), nil) | |
| 348 | + | if err != nil { | |
| 349 | + | t.Fatal(err) | |
| 350 | + | } | |
| 351 | + | if !strings.Contains(globResultText(t, result), "a.go") { | |
| 352 | + | t.Fatalf("symlinked root not traversed: %v", result) | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | escape := filepath.Join(root, "link-outside") | |
| 356 | + | if err := os.Symlink(outside, escape); err != nil { | |
| 357 | + | t.Skipf("symlinks unsupported: %v", err) | |
| 358 | + | } | |
| 359 | + | if _, err := tool.Execute(context.Background(), globArgsJSON("*.go", "link-outside", false), nil); err == nil { | |
| 360 | + | t.Fatal("symlinked root escaping the jail accepted") | |
| 361 | + | } else if !strings.Contains(err.Error(), "jailed") { | |
| 362 | + | t.Fatalf("error = %v, want jail denial", err) | |
| 363 | + | } | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | func TestGlobCancellationPropagates(t *testing.T) { | |
| 367 | + | root := t.TempDir() | |
| 368 | + | writeGlobFile(t, filepath.Join(root, "a.go"), "") | |
| 369 | + | tool := &GlobTool{CWD: root} | |
| 370 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 371 | + | cancel() | |
| 372 | + | if _, err := tool.Execute(ctx, globArgsJSON("*.go", "", false), nil); err == nil { | |
| 373 | + | t.Fatal("cancelled context accepted") | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | func TestGlobReportsMetadata(t *testing.T) { | |
| 378 | + | root := t.TempDir() | |
| 379 | + | writeGlobFile(t, filepath.Join(root, "a.go"), "") | |
| 380 | + | tool := &GlobTool{CWD: root} | |
| 381 | + | result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "", false), nil) | |
| 382 | + | if err != nil { | |
| 383 | + | t.Fatal(err) | |
| 384 | + | } | |
| 385 | + | if globDetails(result)["pattern"] != "*.go" { | |
| 386 | + | t.Fatalf("pattern detail = %v", result.Details) | |
| 387 | + | } | |
| 388 | + | if globDetails(result)["truncated"] != false || globDetails(result)["matches"] != 1 { | |
| 389 | + | t.Fatalf("details = %v", result.Details) | |
| 390 | + | } | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | func TestGlobBoundsDepth(t *testing.T) { | |
| 394 | + | root := t.TempDir() | |
| 395 | + | deep := root | |
| 396 | + | for i := 0; i < maxGlobDepth+5; i++ { | |
| 397 | + | deep = filepath.Join(deep, "d") | |
| 398 | + | } | |
| 399 | + | writeGlobFile(t, filepath.Join(deep, "deep.go"), "") | |
| 400 | + | writeGlobFile(t, filepath.Join(root, "top.go"), "") | |
| 401 | + | ||
| 402 | + | tool := &GlobTool{CWD: root} | |
| 403 | + | result, err := tool.Execute(context.Background(), globArgsJSON("**/*.go", "", false), nil) | |
| 404 | + | if err != nil { | |
| 405 | + | t.Fatal(err) | |
| 406 | + | } | |
| 407 | + | text := globResultText(t, result) | |
| 408 | + | if !strings.Contains(text, "top.go") { | |
| 409 | + | t.Fatalf("shallow match missing: %q", text) | |
| 410 | + | } | |
| 411 | + | if strings.Contains(text, "deep.go") { | |
| 412 | + | t.Fatalf("depth bound not enforced: %q", text) | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | func TestGlobBoundsResults(t *testing.T) { | |
| 417 | + | root := t.TempDir() | |
| 418 | + | for i := 0; i < maxGlobMatches+50; i++ { | |
| 419 | + | writeGlobFile(t, filepath.Join(root, strings.Repeat("f", 4)+string(rune('a'+i/26))+string(rune('a'+i%26))+".go"), "") | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | tool := &GlobTool{CWD: root} | |
| 423 | + | result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "", false), nil) | |
| 424 | + | if err != nil { | |
| 425 | + | t.Fatal(err) | |
| 426 | + | } | |
| 427 | + | if got := globDetails(result)["matches"]; got != maxGlobMatches { | |
| 428 | + | t.Fatalf("matches = %v, want %d", got, maxGlobMatches) | |
| 429 | + | } | |
| 430 | + | if globDetails(result)["truncated"] != true { | |
| 431 | + | t.Fatalf("truncated = %v, want true", globDetails(result)) | |
| 432 | + | } | |
| 433 | + | if !strings.Contains(globResultText(t, result), "Truncated") { | |
| 434 | + | t.Fatalf("truncation note missing: %q", globResultText(t, result)) | |
| 435 | + | } | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | func TestGlobUnreadableEntriesAreSkipped(t *testing.T) { | |
| 439 | + | root := t.TempDir() | |
| 440 | + | writeGlobFile(t, filepath.Join(root, "a.go"), "") | |
| 441 | + | denied := filepath.Join(root, "denied") | |
| 442 | + | if err := os.MkdirAll(denied, 0o755); err != nil { | |
| 443 | + | t.Fatal(err) | |
| 444 | + | } | |
| 445 | + | writeGlobFile(t, filepath.Join(denied, "b.go"), "") | |
| 446 | + | if err := os.Chmod(denied, 0o000); err != nil { | |
| 447 | + | t.Skipf("chmod unsupported: %v", err) | |
| 448 | + | } | |
| 449 | + | defer func() { _ = os.Chmod(denied, 0o755) }() | |
| 450 | + | ||
| 451 | + | tool := &GlobTool{CWD: root} | |
| 452 | + | result, err := tool.Execute(context.Background(), globArgsJSON("**/*.go", "", false), nil) | |
| 453 | + | if err != nil { | |
| 454 | + | t.Fatal(err) | |
| 455 | + | } | |
| 456 | + | if !strings.Contains(globResultText(t, result), "a.go") { | |
| 457 | + | t.Fatalf("readable match missing: %q", globResultText(t, result)) | |
| 458 | + | } | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | func TestGlobNegatedCharacterClass(t *testing.T) { | |
| 462 | + | root := t.TempDir() | |
| 463 | + | writeGlobFile(t, filepath.Join(root, "a.go"), "") | |
| 464 | + | writeGlobFile(t, filepath.Join(root, "b.go"), "") | |
| 465 | + | writeGlobFile(t, filepath.Join(root, "c.go"), "") | |
| 466 | + | ||
| 467 | + | tool := &GlobTool{CWD: root} | |
| 468 | + | result, err := tool.Execute(context.Background(), globArgsJSON("[!ab].go", "", false), nil) | |
| 469 | + | if err != nil { | |
| 470 | + | t.Fatal(err) | |
| 471 | + | } | |
| 472 | + | text := globResultText(t, result) | |
| 473 | + | if !strings.Contains(text, "c.go") || strings.Contains(text, "a.go") || strings.Contains(text, "b.go") { | |
| 474 | + | t.Fatalf("negated class matched wrong set: %q", text) | |
| 475 | + | } | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | func TestGlobSkipsSymlinkedDirectoryTargets(t *testing.T) { | |
| 479 | + | if runtime.GOOS == "windows" { | |
| 480 | + | t.Skip("symlink tests need privileges on windows") | |
| 481 | + | } | |
| 482 | + | root := t.TempDir() | |
| 483 | + | inner := filepath.Join(root, "inner") | |
| 484 | + | if err := os.MkdirAll(inner, 0o755); err != nil { | |
| 485 | + | t.Fatal(err) | |
| 486 | + | } | |
| 487 | + | writeGlobFile(t, filepath.Join(inner, "a.go"), "") | |
| 488 | + | link := filepath.Join(root, "dirlink") | |
| 489 | + | if err := os.Symlink(inner, link); err != nil { | |
| 490 | + | t.Skipf("symlinks unsupported: %v", err) | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | tool := &GlobTool{CWD: root} | |
| 494 | + | result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "", false), nil) | |
| 495 | + | if err != nil { | |
| 496 | + | t.Fatal(err) | |
| 497 | + | } | |
| 498 | + | if strings.Contains(globResultText(t, result), "dirlink") { | |
| 499 | + | t.Fatalf("symlinked directory listed as a file: %q", globResultText(t, result)) | |
| 500 | + | } | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | func TestGlobSymlinkedRootKeepsRequestedName(t *testing.T) { | |
| 504 | + | if runtime.GOOS == "windows" { | |
| 505 | + | t.Skip("symlink tests need privileges on windows") | |
| 506 | + | } | |
| 507 | + | base := t.TempDir() | |
| 508 | + | inner := filepath.Join(base, "inner") | |
| 509 | + | if err := os.MkdirAll(inner, 0o755); err != nil { | |
| 510 | + | t.Fatal(err) | |
| 511 | + | } | |
| 512 | + | writeGlobFile(t, filepath.Join(inner, "a.go"), "") | |
| 513 | + | link := filepath.Join(base, "link-inside") | |
| 514 | + | if err := os.Symlink(inner, link); err != nil { | |
| 515 | + | t.Skipf("symlinks unsupported: %v", err) | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | tool := &GlobTool{CWD: base} | |
| 519 | + | result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "link-inside", false), nil) | |
| 520 | + | if err != nil { | |
| 521 | + | t.Fatal(err) | |
| 522 | + | } | |
| 523 | + | if got := globResultText(t, result); got != "link-inside/a.go" { | |
| 524 | + | t.Fatalf("output = %q, want link-inside/a.go", got) | |
| 525 | + | } | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | func TestGlobReportsDiscoveredPaths(t *testing.T) { | |
| 529 | + | root := t.TempDir() | |
| 530 | + | writeGlobFile(t, filepath.Join(root, "sub", "a.go"), "") | |
| 531 | + | tool := &GlobTool{CWD: root} | |
| 532 | + | result, err := tool.Execute(context.Background(), globArgsJSON("**/*.go", "", false), nil) | |
| 533 | + | if err != nil { | |
| 534 | + | t.Fatal(err) | |
| 535 | + | } | |
| 536 | + | discovers := result.Context | |
| 537 | + | want := canonicalResourcePath(filepath.Join(root, "sub", "a.go")) | |
| 538 | + | if len(discovers.Discovers) != 1 || discovers.Discovers[0] != want { | |
| 539 | + | t.Fatalf("discovers = %v, want [%s]", discovers.Discovers, want) | |
| 540 | + | } | |
| 541 | + | } | |
A packages/core/session_prune.go +108 -0
| @@ -0,0 +1,108 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | "io/fs" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "sort" | |
| 10 | + | "strings" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // StoredSessionGroup describes sessions whose metadata names the same working | |
| 14 | + | // directory. Paths can span the default and named-agent session namespaces. | |
| 15 | + | type StoredSessionGroup struct { | |
| 16 | + | CWD string | |
| 17 | + | Paths []string | |
| 18 | + | SizeBytes int64 | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // SessionScanIssue describes a stored entry that could not be safely | |
| 22 | + | // classified. Callers should report these entries and leave them untouched. | |
| 23 | + | type SessionScanIssue struct { | |
| 24 | + | Path string | |
| 25 | + | Err error | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // ScanStoredSessionGroups finds JSONL sessions below sessionsRoot and groups | |
| 29 | + | // valid entries by the cwd recorded in their metadata. Symlinks and malformed | |
| 30 | + | // sessions are reported as issues rather than followed or deleted. | |
| 31 | + | func ScanStoredSessionGroups(sessionsRoot string) ([]StoredSessionGroup, []SessionScanIssue) { | |
| 32 | + | byCWD := make(map[string]*StoredSessionGroup) | |
| 33 | + | var issues []SessionScanIssue | |
| 34 | + | ||
| 35 | + | // A symlinked sessions root would otherwise scan as an empty store | |
| 36 | + | // with no hint; report it instead of silently finding nothing. | |
| 37 | + | if info, err := os.Lstat(sessionsRoot); err == nil && info.Mode()&os.ModeSymlink != 0 { | |
| 38 | + | return nil, []SessionScanIssue{{Path: sessionsRoot, Err: fmt.Errorf("sessions root is a symbolic link")}} | |
| 39 | + | } | |
| 40 | + | err := filepath.WalkDir(sessionsRoot, func(path string, entry fs.DirEntry, walkErr error) error { | |
| 41 | + | if walkErr != nil { | |
| 42 | + | if path == sessionsRoot && errors.Is(walkErr, fs.ErrNotExist) { | |
| 43 | + | return nil | |
| 44 | + | } | |
| 45 | + | issues = append(issues, SessionScanIssue{Path: path, Err: walkErr}) | |
| 46 | + | if entry != nil && entry.IsDir() { | |
| 47 | + | return fs.SkipDir | |
| 48 | + | } | |
| 49 | + | return nil | |
| 50 | + | } | |
| 51 | + | if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { | |
| 52 | + | return nil | |
| 53 | + | } | |
| 54 | + | if entry.Type()&os.ModeSymlink != 0 { | |
| 55 | + | issues = append(issues, SessionScanIssue{Path: path, Err: fmt.Errorf("symbolic link is not a stored session")}) | |
| 56 | + | return nil | |
| 57 | + | } | |
| 58 | + | // Never block on or stream non-regular entries: a FIFO named | |
| 59 | + | // *.jsonl would hang open(2) forever and a device node would | |
| 60 | + | // return data without end. Report and preserve them instead. | |
| 61 | + | if !entry.Type().IsRegular() { | |
| 62 | + | issues = append(issues, SessionScanIssue{Path: path, Err: fmt.Errorf("not a regular file")}) | |
| 63 | + | return nil | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | meta, err := readSessionMeta(path) | |
| 67 | + | if err != nil { | |
| 68 | + | issues = append(issues, SessionScanIssue{Path: path, Err: err}) | |
| 69 | + | return nil | |
| 70 | + | } | |
| 71 | + | cwd := strings.TrimSpace(meta.CWD) | |
| 72 | + | if cwd == "" { | |
| 73 | + | issues = append(issues, SessionScanIssue{Path: path, Err: fmt.Errorf("session metadata has an empty cwd")}) | |
| 74 | + | return nil | |
| 75 | + | } | |
| 76 | + | // Tree-navigation branches stay visible through /session tree; | |
| 77 | + | // pruning them here would silently remove sessions the picker | |
| 78 | + | // deliberately hides from the flat list. | |
| 79 | + | if meta.HideFromSessions { | |
| 80 | + | return nil | |
| 81 | + | } | |
| 82 | + | info, err := entry.Info() | |
| 83 | + | if err != nil { | |
| 84 | + | issues = append(issues, SessionScanIssue{Path: path, Err: err}) | |
| 85 | + | return nil | |
| 86 | + | } | |
| 87 | + | group := byCWD[cwd] | |
| 88 | + | if group == nil { | |
| 89 | + | group = &StoredSessionGroup{CWD: cwd} | |
| 90 | + | byCWD[cwd] = group | |
| 91 | + | } | |
| 92 | + | group.Paths = append(group.Paths, path) | |
| 93 | + | group.SizeBytes += info.Size() | |
| 94 | + | return nil | |
| 95 | + | }) | |
| 96 | + | if err != nil && !os.IsNotExist(err) { | |
| 97 | + | issues = append(issues, SessionScanIssue{Path: sessionsRoot, Err: err}) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | groups := make([]StoredSessionGroup, 0, len(byCWD)) | |
| 101 | + | for _, group := range byCWD { | |
| 102 | + | sort.Strings(group.Paths) | |
| 103 | + | groups = append(groups, *group) | |
| 104 | + | } | |
| 105 | + | sort.Slice(groups, func(i, j int) bool { return groups[i].CWD < groups[j].CWD }) | |
| 106 | + | sort.Slice(issues, func(i, j int) bool { return issues[i].Path < issues[j].Path }) | |
| 107 | + | return groups, issues | |
| 108 | + | } | |
A packages/core/session_prune_test.go +177 -0
| @@ -0,0 +1,177 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "runtime" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "golang.org/x/sys/unix" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func writePruneTestSession(t *testing.T, path, cwd string) { | |
| 15 | + | t.Helper() | |
| 16 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 17 | + | t.Fatal(err) | |
| 18 | + | } | |
| 19 | + | line := `{"type":"meta","meta":{"id":"test-id","cwd":` + strconvQuote(cwd) + `,"model":"m","provider":"p"}}` + "\n" | |
| 20 | + | if err := os.WriteFile(path, []byte(line), 0o600); err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func markHiddenFromSessions(t *testing.T, path string) { | |
| 26 | + | t.Helper() | |
| 27 | + | raw, err := os.ReadFile(path) | |
| 28 | + | if err != nil { | |
| 29 | + | t.Fatal(err) | |
| 30 | + | } | |
| 31 | + | // The flag lives inside the meta object, not on the row. | |
| 32 | + | line := strings.TrimSuffix(strings.TrimSuffix(string(raw), "\n"), "}") | |
| 33 | + | line = strings.TrimSuffix(line, "}") + `,"hide_from_sessions":true}}` + "\n" | |
| 34 | + | if err := os.WriteFile(path, []byte(line), 0o600); err != nil { | |
| 35 | + | t.Fatal(err) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func makeFifo(path string) error { | |
| 40 | + | return unix.Mkfifo(path, 0o600) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func strconvQuote(s string) string { | |
| 44 | + | quoted := strings.ReplaceAll(s, `\`, `\\`) | |
| 45 | + | quoted = strings.ReplaceAll(quoted, `"`, `\"`) | |
| 46 | + | return `"` + quoted + `"` | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestScanStoredSessionGroupsAbsentRoot(t *testing.T) { | |
| 50 | + | groups, issues := ScanStoredSessionGroups(filepath.Join(t.TempDir(), "no-such-root")) | |
| 51 | + | if len(groups) != 0 || len(issues) != 0 { | |
| 52 | + | t.Fatalf("groups=%v issues=%v, want empty", groups, issues) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestScanStoredSessionGroupsGroupsByCWD(t *testing.T) { | |
| 57 | + | root := t.TempDir() | |
| 58 | + | writePruneTestSession(t, filepath.Join(root, "one.jsonl"), "/work/a") | |
| 59 | + | writePruneTestSession(t, filepath.Join(root, "agents", "named", "two.jsonl"), "/work/a") | |
| 60 | + | writePruneTestSession(t, filepath.Join(root, "three.jsonl"), "/work/b") | |
| 61 | + | ||
| 62 | + | groups, issues := ScanStoredSessionGroups(root) | |
| 63 | + | if len(issues) != 0 { | |
| 64 | + | t.Fatalf("issues=%v, want none", issues) | |
| 65 | + | } | |
| 66 | + | if len(groups) != 2 { | |
| 67 | + | t.Fatalf("groups=%v, want 2", groups) | |
| 68 | + | } | |
| 69 | + | if groups[0].CWD != "/work/a" || len(groups[0].Paths) != 2 { | |
| 70 | + | t.Fatalf("first group=%+v, want /work/a with 2 paths", groups[0]) | |
| 71 | + | } | |
| 72 | + | if groups[1].CWD != "/work/b" || len(groups[1].Paths) != 1 { | |
| 73 | + | t.Fatalf("second group=%+v, want /work/b with 1 path", groups[1]) | |
| 74 | + | } | |
| 75 | + | if groups[0].SizeBytes <= 0 { | |
| 76 | + | t.Fatalf("group size=%d, want positive", groups[0].SizeBytes) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestScanStoredSessionGroupsPreservesProblemEntries(t *testing.T) { | |
| 81 | + | root := t.TempDir() | |
| 82 | + | writePruneTestSession(t, filepath.Join(root, "ok.jsonl"), "/work/a") | |
| 83 | + | writePruneTestSession(t, filepath.Join(root, "bad.jsonl"), "") | |
| 84 | + | if err := os.WriteFile(filepath.Join(root, "broken.jsonl"), []byte("{not json\n"), 0o600); err != nil { | |
| 85 | + | t.Fatal(err) | |
| 86 | + | } | |
| 87 | + | link := filepath.Join(root, "link.jsonl") | |
| 88 | + | if err := os.Symlink(filepath.Join(root, "ok.jsonl"), link); err != nil { | |
| 89 | + | t.Skipf("symlinks unsupported: %v", err) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | groups, issues := ScanStoredSessionGroups(root) | |
| 93 | + | if len(groups) != 1 || len(groups[0].Paths) != 1 { | |
| 94 | + | t.Fatalf("groups=%v, want only the valid session", groups) | |
| 95 | + | } | |
| 96 | + | if len(issues) != 3 { | |
| 97 | + | t.Fatalf("issues=%v, want empty-cwd, malformed, and symlink", issues) | |
| 98 | + | } | |
| 99 | + | for i := 1; i < len(issues); i++ { | |
| 100 | + | if issues[i-1].Path > issues[i].Path { | |
| 101 | + | t.Fatalf("issues unsorted: %v", issues) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestScanStoredSessionGroupsSkipsNonRegularEntries(t *testing.T) { | |
| 107 | + | if runtime.GOOS == "windows" { | |
| 108 | + | t.Skip("fifo tests need unix privileges") | |
| 109 | + | } | |
| 110 | + | root := t.TempDir() | |
| 111 | + | writePruneTestSession(t, filepath.Join(root, "ok.jsonl"), "/work/a") | |
| 112 | + | fifo := filepath.Join(root, "pipe.jsonl") | |
| 113 | + | if err := makeFifo(fifo); err != nil { | |
| 114 | + | t.Skipf("fifos unsupported: %v", err) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | done := make(chan struct{}) | |
| 118 | + | go func() { | |
| 119 | + | defer close(done) | |
| 120 | + | groups, issues := ScanStoredSessionGroups(root) | |
| 121 | + | if len(groups) != 1 || len(groups[0].Paths) != 1 { | |
| 122 | + | t.Errorf("groups=%v, want only the valid session", groups) | |
| 123 | + | return | |
| 124 | + | } | |
| 125 | + | found := false | |
| 126 | + | for _, issue := range issues { | |
| 127 | + | if issue.Path == fifo { | |
| 128 | + | found = true | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | if !found { | |
| 132 | + | t.Errorf("issues=%v, want the fifo reported", issues) | |
| 133 | + | } | |
| 134 | + | }() | |
| 135 | + | select { | |
| 136 | + | case <-done: | |
| 137 | + | case <-time.After(10 * time.Second): | |
| 138 | + | t.Fatal("scanner blocked on a fifo entry") | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func TestScanStoredSessionGroupsSkipsHiddenBranches(t *testing.T) { | |
| 143 | + | root := t.TempDir() | |
| 144 | + | writePruneTestSession(t, filepath.Join(root, "visible.jsonl"), "/work/a") | |
| 145 | + | hidden := filepath.Join(root, "branch.jsonl") | |
| 146 | + | writePruneTestSession(t, hidden, "/work/a") | |
| 147 | + | markHiddenFromSessions(t, hidden) | |
| 148 | + | ||
| 149 | + | groups, _ := ScanStoredSessionGroups(root) | |
| 150 | + | if len(groups) != 1 || len(groups[0].Paths) != 1 { | |
| 151 | + | t.Fatalf("groups=%v, want only the visible session", groups) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func TestScanStoredSessionGroupsReportsSymlinkedRoot(t *testing.T) { | |
| 156 | + | if runtime.GOOS == "windows" { | |
| 157 | + | t.Skip("symlink tests need privileges on windows") | |
| 158 | + | } | |
| 159 | + | base := t.TempDir() | |
| 160 | + | real := filepath.Join(base, "real") | |
| 161 | + | if err := os.MkdirAll(real, 0o755); err != nil { | |
| 162 | + | t.Fatal(err) | |
| 163 | + | } | |
| 164 | + | writePruneTestSession(t, filepath.Join(real, "one.jsonl"), "/work/a") | |
| 165 | + | link := filepath.Join(base, "link") | |
| 166 | + | if err := os.Symlink(real, link); err != nil { | |
| 167 | + | t.Skipf("symlinks unsupported: %v", err) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | groups, issues := ScanStoredSessionGroups(link) | |
| 171 | + | if len(groups) != 0 { | |
| 172 | + | t.Fatalf("groups=%v, want none for a symlinked root", groups) | |
| 173 | + | } | |
| 174 | + | if len(issues) != 1 { | |
| 175 | + | t.Fatalf("issues=%v, want the symlinked root reported", issues) | |
| 176 | + | } | |
| 177 | + | } | |
M packages/provider/deferred_tools_test.go +104 -0
| @@ -37,6 +37,78 @@ func TestKimiK3DeferredToolsLoadAtToolResult(t *testing.T) { | |||
| 37 | 37 | } | |
| 38 | 38 | } | |
| 39 | 39 | ||
| 40 | + | // The deferred-tools handshake is a Moonshot API extension. Another | |
| 41 | + | // provider serving the same model id must get the standard wire instead: | |
| 42 | + | // no tools-bearing system message (it carries no content, which strict | |
| 43 | + | // schemas reject), and the activated tool in the top-level definitions | |
| 44 | + | // where every provider reads it. | |
| 45 | + | func TestKimiK3DeferredToolsSkippedForNonMoonshotProvider(t *testing.T) { | |
| 46 | + | client := NewOpenAICompat("custom", "token", "https://example.test/v1", "").(*openaiClient) | |
| 47 | + | tools := []Tool{ | |
| 48 | + | {Name: "search_tools", Schema: json.RawMessage(`{"type":"object"}`)}, | |
| 49 | + | {Name: "lookup_weather", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true}, | |
| 50 | + | } | |
| 51 | + | wire, err := client.buildRequest(Request{ | |
| 52 | + | Model: "kimi-k3", | |
| 53 | + | Tools: tools, | |
| 54 | + | Messages: []Message{ | |
| 55 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "weather"}}}, | |
| 56 | + | {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "call-1", Name: "search_tools", Arguments: json.RawMessage(`{}`)}}}, | |
| 57 | + | {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "call-1", Content: []Content{TextBlock{Text: "found"}}}}, AddedToolNames: []string{"lookup_weather"}}, | |
| 58 | + | }, | |
| 59 | + | }) | |
| 60 | + | if err != nil { | |
| 61 | + | t.Fatal(err) | |
| 62 | + | } | |
| 63 | + | for _, message := range wire.Messages { | |
| 64 | + | if len(message.Tools) > 0 { | |
| 65 | + | t.Fatalf("message %q carries tools; deferred injection must not reach a non-Moonshot provider", message.Role) | |
| 66 | + | } | |
| 67 | + | if message.Content == nil && len(message.ToolCalls) == 0 { | |
| 68 | + | t.Fatalf("message %q has neither content nor tool calls", message.Role) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | var names []string | |
| 72 | + | for _, tool := range wire.Tools { | |
| 73 | + | names = append(names, tool.Function.Name) | |
| 74 | + | } | |
| 75 | + | if len(names) != 2 || names[0] != "search_tools" || names[1] != "lookup_weather" { | |
| 76 | + | t.Fatalf("top-level tools = %v, want [search_tools lookup_weather]", names) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestMoonshotCNKimiK3DeferredToolsLoadAtToolResult(t *testing.T) { | |
| 81 | + | client := NewMoonshotCN("token", "").(*openaiClient) | |
| 82 | + | tools := []Tool{ | |
| 83 | + | {Name: "search_tools", Schema: json.RawMessage(`{"type":"object"}`)}, | |
| 84 | + | {Name: "lookup_weather", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true}, | |
| 85 | + | } | |
| 86 | + | wire, err := client.buildRequest(Request{ | |
| 87 | + | Model: "kimi-k3", | |
| 88 | + | Tools: tools, | |
| 89 | + | Messages: []Message{ | |
| 90 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "weather"}}}, | |
| 91 | + | {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "call-1", Name: "search_tools", Arguments: json.RawMessage(`{}`)}}}, | |
| 92 | + | {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "call-1", Content: []Content{TextBlock{Text: "found"}}}}, AddedToolNames: []string{"lookup_weather"}}, | |
| 93 | + | }, | |
| 94 | + | }) | |
| 95 | + | if err != nil { | |
| 96 | + | t.Fatal(err) | |
| 97 | + | } | |
| 98 | + | if len(wire.Tools) != 1 || wire.Tools[0].Function.Name != "search_tools" { | |
| 99 | + | t.Fatalf("top-level tools = %+v, want only search_tools", wire.Tools) | |
| 100 | + | } | |
| 101 | + | var loaded []oaiTool | |
| 102 | + | for _, message := range wire.Messages { | |
| 103 | + | if message.Role == "system" && len(message.Tools) > 0 { | |
| 104 | + | loaded = message.Tools | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | if len(loaded) != 1 || loaded[0].Function.Name != "lookup_weather" { | |
| 108 | + | t.Fatalf("loaded tools = %+v, want lookup_weather", loaded) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 40 | 112 | func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) { | |
| 41 | 113 | tools := []Tool{ | |
| 42 | 114 | {Name: "base", Schema: json.RawMessage(`{"type":"object"}`)}, | |
| @@ -161,3 +233,35 @@ func TestKimiK3AndGrok45CatalogMetadata(t *testing.T) { | |||
| 161 | 233 | t.Fatalf("xai/grok-4.5 metadata = %+v", m) | |
| 162 | 234 | } | |
| 163 | 235 | } | |
| 236 | + | ||
| 237 | + | func TestMoonshotDeferredMarkerSurvivesDedupOnRetainedMessage(t *testing.T) { | |
| 238 | + | client := NewMoonshot("token", "").(*openaiClient) | |
| 239 | + | tools := []Tool{ | |
| 240 | + | {Name: "search_tools", Schema: json.RawMessage(`{"type":"object"}`)}, | |
| 241 | + | {Name: "lookup_weather", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true}, | |
| 242 | + | } | |
| 243 | + | wire, err := client.buildRequest(Request{ | |
| 244 | + | Model: "kimi-k3", | |
| 245 | + | Tools: tools, | |
| 246 | + | Messages: []Message{ | |
| 247 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "weather"}}}, | |
| 248 | + | {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "call-1", Name: "search_tools", Arguments: json.RawMessage(`{}`)}}}, | |
| 249 | + | // Duplicate results: repair keeps the first and its marker, so | |
| 250 | + | // the deferred tool still loads on the handshake message. | |
| 251 | + | {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "call-1", Content: []Content{TextBlock{Text: "found"}}}}, AddedToolNames: []string{"lookup_weather"}}, | |
| 252 | + | {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "call-1", Content: []Content{TextBlock{Text: "stale duplicate"}}}}, AddedToolNames: []string{"lookup_weather"}}, | |
| 253 | + | }, | |
| 254 | + | }) | |
| 255 | + | if err != nil { | |
| 256 | + | t.Fatal(err) | |
| 257 | + | } | |
| 258 | + | var loaded []oaiTool | |
| 259 | + | for _, message := range wire.Messages { | |
| 260 | + | if message.Role == "system" && len(message.Tools) > 0 { | |
| 261 | + | loaded = message.Tools | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | if len(loaded) != 1 || loaded[0].Function.Name != "lookup_weather" { | |
| 265 | + | t.Fatalf("loaded tools = %+v, want lookup_weather from the retained marker", loaded) | |
| 266 | + | } | |
| 267 | + | } | |
M packages/provider/openai.go +93 -32
| @@ -310,7 +310,7 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||
| 310 | 310 | out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: system}) | |
| 311 | 311 | } | |
| 312 | 312 | ||
| 313 | - | deferredMode := isKimiDeferredModel(req.Model) | |
| 313 | + | deferredMode := supportsDeferredTools(c.Name()) && isKimiDeferredModel(req.Model) | |
| 314 | 314 | toolByName := make(map[string]Tool, len(req.Tools)) | |
| 315 | 315 | for _, t := range req.Tools { | |
| 316 | 316 | toolByName[t.Name] = t | |
| @@ -321,10 +321,13 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||
| 321 | 321 | // schema (parts arrays containing image_url). Force every user/tool | |
| 322 | 322 | // message to a plain string and silently drop image blocks for | |
| 323 | 323 | // this provider so historical sessions with screenshots still replay. | |
| 324 | - | textOnly := c.name == "deepseek" | |
| 324 | + | // The same quirk applies to DeepSeek models routed through opencode-go. | |
| 325 | + | textOnly := c.name == ProviderDeepSeek || (c.name == ProviderOpenCodeGo && isDeepSeekModel(req.Model)) | |
| 325 | 326 | ||
| 326 | 327 | req.Messages = RepairOrphanedToolResults(req.Messages) | |
| 327 | - | for _, msg := range req.Messages { | |
| 328 | + | for msgIndex := 0; msgIndex < len(req.Messages); msgIndex++ { | |
| 329 | + | msg := req.Messages[msgIndex] | |
| 330 | + | addedToolNames := append([]string(nil), msg.AddedToolNames...) | |
| 328 | 331 | switch msg.Role { | |
| 329 | 332 | case RoleDeveloper: | |
| 330 | 333 | content := buildOAIUserContent(msg.Content, textOnly) | |
| @@ -388,23 +391,53 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||
| 388 | 391 | } | |
| 389 | 392 | out.Messages = append(out.Messages, am) | |
| 390 | 393 | case RoleTool: | |
| 391 | - | // Each ToolResultBlock becomes its own tool message. Preserve | |
| 392 | - | // image blocks for vision-capable OpenAI models instead of | |
| 393 | - | // flattening the tool output to plain text. | |
| 394 | - | for _, b := range msg.Content { | |
| 395 | - | if tr, ok := b.(ToolResultBlock); ok { | |
| 396 | - | content := buildOAIToolContent(tr.Content, tr.IsError, textOnly) | |
| 397 | - | out.Messages = append(out.Messages, oaiMessage{ | |
| 398 | - | Role: "tool", | |
| 399 | - | ToolCallID: tr.CallID, | |
| 400 | - | Content: content, | |
| 401 | - | }) | |
| 394 | + | // Chat Completions tool messages only carry text. Keep every | |
| 395 | + | // tool result paired with its call, then deliver any images in | |
| 396 | + | // one user message after the complete batch of tool results. | |
| 397 | + | // Strict OpenAI-compatible APIs reject image_url blocks on a | |
| 398 | + | // role=tool message. | |
| 399 | + | var images []Content | |
| 400 | + | groupEnd := msgIndex | |
| 401 | + | for groupEnd < len(req.Messages) && req.Messages[groupEnd].Role == RoleTool { | |
| 402 | + | toolMessage := req.Messages[groupEnd] | |
| 403 | + | if groupEnd > msgIndex { | |
| 404 | + | addedToolNames = append(addedToolNames, toolMessage.AddedToolNames...) | |
| 405 | + | } | |
| 406 | + | for _, b := range toolMessage.Content { | |
| 407 | + | if tr, ok := b.(ToolResultBlock); ok { | |
| 408 | + | content := buildOAIToolContent(tr.Content, tr.IsError, textOnly) | |
| 409 | + | out.Messages = append(out.Messages, oaiMessage{ | |
| 410 | + | Role: "tool", | |
| 411 | + | ToolCallID: tr.CallID, | |
| 412 | + | Content: content, | |
| 413 | + | }) | |
| 414 | + | if !textOnly { | |
| 415 | + | for _, inner := range tr.Content { | |
| 416 | + | if image, ok := inner.(ImageBlock); ok { | |
| 417 | + | images = append(images, image) | |
| 418 | + | } | |
| 419 | + | } | |
| 420 | + | } | |
| 421 | + | } | |
| 402 | 422 | } | |
| 423 | + | groupEnd++ | |
| 424 | + | } | |
| 425 | + | // The agent loop persists an image mirror as the next user | |
| 426 | + | // message so Responses-routed models can consume it. Reuse | |
| 427 | + | // that message instead of sending the image twice. | |
| 428 | + | hasPersistedMirror := groupEnd < len(req.Messages) && isOpenAIToolImageMirror(req.Messages[groupEnd]) | |
| 429 | + | if len(images) > 0 && !hasPersistedMirror { | |
| 430 | + | content := append([]Content{TextBlock{Text: "Tool output included the following image content:"}}, images...) | |
| 431 | + | out.Messages = append(out.Messages, oaiMessage{ | |
| 432 | + | Role: "user", | |
| 433 | + | Content: buildOAIContentBlocks(content, false), | |
| 434 | + | }) | |
| 403 | 435 | } | |
| 436 | + | msgIndex = groupEnd - 1 | |
| 404 | 437 | } | |
| 405 | 438 | if deferredMode { | |
| 406 | 439 | var loaded []oaiTool | |
| 407 | - | for _, name := range msg.AddedToolNames { | |
| 440 | + | for _, name := range addedToolNames { | |
| 408 | 441 | if t, ok := toolByName[name]; ok && t.Deferred { | |
| 409 | 442 | loaded = append(loaded, makeOAITool(t)) | |
| 410 | 443 | } | |
| @@ -433,6 +466,17 @@ func isKimiDeferredModel(model string) bool { | |||
| 433 | 466 | return model == "kimi-k3" || strings.HasSuffix(model, "/kimi-k3") | |
| 434 | 467 | } | |
| 435 | 468 | ||
| 469 | + | // supportsDeferredTools reports whether a provider speaks Moonshot's | |
| 470 | + | // deferred-tools handshake, where a tool that becomes available | |
| 471 | + | // mid-conversation is delivered on a system message carrying a `tools` | |
| 472 | + | // array. That message is a Moonshot API extension, not OpenAI wire format: | |
| 473 | + | // it has no content, which a strict chat-completions schema rejects | |
| 474 | + | // outright. Other providers take the standard path instead, where an | |
| 475 | + | // activated deferred tool joins the top-level tools array. | |
| 476 | + | func supportsDeferredTools(provider string) bool { | |
| 477 | + | return provider == "moonshotai" || provider == "moonshotai-cn" | |
| 478 | + | } | |
| 479 | + | ||
| 436 | 480 | func isDeepSeekModel(model string) bool { | |
| 437 | 481 | model = strings.ToLower(strings.TrimSpace(model)) | |
| 438 | 482 | return strings.HasPrefix(model, "deepseek-") || strings.Contains(model, "/deepseek-") | |
| @@ -477,30 +521,47 @@ func buildOAIUserContent(blocks []Content, textOnly bool) interface{} { | |||
| 477 | 521 | return buildOAIContentBlocks(blocks, false) | |
| 478 | 522 | } | |
| 479 | 523 | ||
| 480 | - | func buildOAIToolContent(blocks []Content, isError, textOnly bool) interface{} { | |
| 524 | + | func buildOAIToolContent(blocks []Content, isError, textOnly bool) string { | |
| 525 | + | var sb strings.Builder | |
| 481 | 526 | hasImage := false | |
| 482 | 527 | for _, b := range blocks { | |
| 483 | - | if _, ok := b.(ImageBlock); ok { | |
| 528 | + | switch v := b.(type) { | |
| 529 | + | case TextBlock: | |
| 530 | + | if sb.Len() > 0 { | |
| 531 | + | sb.WriteString("\n") | |
| 532 | + | } | |
| 533 | + | sb.WriteString(v.Text) | |
| 534 | + | case ImageBlock: | |
| 484 | 535 | hasImage = true | |
| 485 | - | break | |
| 486 | 536 | } | |
| 487 | 537 | } | |
| 488 | - | if textOnly || !hasImage { | |
| 489 | - | var sb strings.Builder | |
| 490 | - | for _, b := range blocks { | |
| 491 | - | if tb, ok := b.(TextBlock); ok { | |
| 492 | - | if sb.Len() > 0 { | |
| 493 | - | sb.WriteString("\n") | |
| 494 | - | } | |
| 495 | - | sb.WriteString(tb.Text) | |
| 496 | - | } | |
| 497 | - | } | |
| 498 | - | if isError && sb.Len() > 0 { | |
| 499 | - | sb.WriteString(" [error]") | |
| 538 | + | if sb.Len() == 0 && hasImage && !textOnly { | |
| 539 | + | sb.WriteString("(see attached image)") | |
| 540 | + | } | |
| 541 | + | if isError && sb.Len() > 0 { | |
| 542 | + | sb.WriteString(" [error]") | |
| 543 | + | } | |
| 544 | + | return sb.String() | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | // isOpenAIToolImageMirror reports whether msg is the synthetic user message | |
| 548 | + | // the agent loop persists after a tool result carrying images (see | |
| 549 | + | // mirrorToolImagesAsUser). The image bytes are already available once | |
| 550 | + | // through that message, so buildRequest must not emit a second copy. | |
| 551 | + | func isOpenAIToolImageMirror(msg Message) bool { | |
| 552 | + | if msg.Role != RoleUser || len(msg.Content) < 2 { | |
| 553 | + | return false | |
| 554 | + | } | |
| 555 | + | text, ok := msg.Content[0].(TextBlock) | |
| 556 | + | if !ok || text.Text != "Tool output included the following image content:" { | |
| 557 | + | return false | |
| 558 | + | } | |
| 559 | + | for _, content := range msg.Content[1:] { | |
| 560 | + | if _, ok := content.(ImageBlock); ok { | |
| 561 | + | return true | |
| 500 | 562 | } | |
| 501 | - | return sb.String() | |
| 502 | 563 | } | |
| 503 | - | return buildOAIContentBlocks(blocks, isError) | |
| 564 | + | return false | |
| 504 | 565 | } | |
| 505 | 566 | ||
| 506 | 567 | func buildOAIContentBlocks(blocks []Content, isError bool) []interface{} { | |
M packages/provider/openai_codex.go +74 -14
| @@ -565,6 +565,28 @@ func estimatedOpenAIPromptTokens(wire *codexRequest) int { | |||
| 565 | 565 | return len(encoded) / 4 | |
| 566 | 566 | } | |
| 567 | 567 | ||
| 568 | + | // unsupportedPromptCacheRetention reports whether a 400 response is the | |
| 569 | + | // backend rejecting the prompt_cache_key retention the Codex CLI identity | |
| 570 | + | // selects. Only that structured error retries with the native identity; | |
| 571 | + | // every other status, param, or code is returned as-is. | |
| 572 | + | func unsupportedPromptCacheRetention(status int, body []byte) bool { | |
| 573 | + | if status != http.StatusBadRequest { | |
| 574 | + | return false | |
| 575 | + | } | |
| 576 | + | var payload struct { | |
| 577 | + | Error struct { | |
| 578 | + | Message string `json:"message"` | |
| 579 | + | Param string `json:"param"` | |
| 580 | + | Code string `json:"code"` | |
| 581 | + | } `json:"error"` | |
| 582 | + | } | |
| 583 | + | if json.Unmarshal(body, &payload) != nil || payload.Error.Param != "prompt_cache_retention" { | |
| 584 | + | return false | |
| 585 | + | } | |
| 586 | + | message := strings.ToLower(payload.Error.Message) | |
| 587 | + | return payload.Error.Code == "invalid_parameter" || strings.Contains(message, "not supported") | |
| 588 | + | } | |
| 589 | + | ||
| 568 | 590 | func usesCodexCLIRouting(model string) bool { | |
| 569 | 591 | switch model { | |
| 570 | 592 | case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro": | |
| @@ -596,18 +618,32 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er | |||
| 596 | 618 | return nil, err | |
| 597 | 619 | } | |
| 598 | 620 | ||
| 599 | - | newReq := func() (*http.Request, error) { | |
| 600 | - | httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body)) | |
| 601 | - | if err != nil { | |
| 602 | - | return nil, err | |
| 621 | + | newReq := func(useCodexIdentity bool) func() (*http.Request, error) { | |
| 622 | + | return func() (*http.Request, error) { | |
| 623 | + | httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body)) | |
| 624 | + | if err != nil { | |
| 625 | + | return nil, err | |
| 626 | + | } | |
| 627 | + | httpReq.Header = c.responsesHeaders(req, useCodexIdentity, false) | |
| 628 | + | if !useCodexIdentity { | |
| 629 | + | // The native identity shape carries no session/request | |
| 630 | + | // correlation headers; keep the values selected under the | |
| 631 | + | // CLI identity so the retry stays on the same session and | |
| 632 | + | // request correlation. | |
| 633 | + | cliHeaders := c.responsesHeaders(req, true, false) | |
| 634 | + | for _, key := range []string{"session-id", "thread-id", "x-client-request-id"} { | |
| 635 | + | if value := cliHeaders.Get(key); value != "" { | |
| 636 | + | httpReq.Header.Set(key, value) | |
| 637 | + | } | |
| 638 | + | } | |
| 639 | + | } | |
| 640 | + | httpReq.Header.Set("content-type", "application/json") | |
| 641 | + | httpReq.Header.Set("accept", "text/event-stream") | |
| 642 | + | return httpReq, nil | |
| 603 | 643 | } | |
| 604 | - | httpReq.Header = c.responsesHeaders(req, useCodexCLIRouting, false) | |
| 605 | - | httpReq.Header.Set("content-type", "application/json") | |
| 606 | - | httpReq.Header.Set("accept", "text/event-stream") | |
| 607 | - | return httpReq, nil | |
| 608 | 644 | } | |
| 609 | 645 | ||
| 610 | - | resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle) | |
| 646 | + | resp, err := doStreamWithRetry(ctx, c.http, newReq(useCodexCLIRouting), req.Lifecycle) | |
| 611 | 647 | if err != nil { | |
| 612 | 648 | label := c.providerName | |
| 613 | 649 | if label == "" { | |
| @@ -616,13 +652,37 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er | |||
| 616 | 652 | return nil, fmt.Errorf("%s: %w", label, err) | |
| 617 | 653 | } | |
| 618 | 654 | if resp.StatusCode != http.StatusOK { | |
| 619 | - | b, _ := io.ReadAll(resp.Body) | |
| 655 | + | status := resp.StatusCode | |
| 656 | + | responseBody, _ := io.ReadAll(resp.Body) | |
| 620 | 657 | resp.Body.Close() | |
| 621 | - | label := c.providerName | |
| 622 | - | if label == "" { | |
| 623 | - | label = ProviderOpenAICodex | |
| 658 | + | if !useCodexCLIRouting || !unsupportedPromptCacheRetention(status, responseBody) { | |
| 659 | + | label := c.providerName | |
| 660 | + | if label == "" { | |
| 661 | + | label = ProviderOpenAICodex | |
| 662 | + | } | |
| 663 | + | return nil, fmt.Errorf("%s: http %d: %s", label, status, strings.TrimSpace(string(responseBody))) | |
| 664 | + | } | |
| 665 | + | // The Codex CLI identity selects cached-prompt retention the model | |
| 666 | + | // does not support. Retry once with the native identity on the same | |
| 667 | + | // session/request correlation and body; anything else is returned. | |
| 668 | + | resp, err = doStreamWithRetry(ctx, c.http, newReq(false), req.Lifecycle) | |
| 669 | + | if err != nil { | |
| 670 | + | label := c.providerName | |
| 671 | + | if label == "" { | |
| 672 | + | label = ProviderOpenAICodex | |
| 673 | + | } | |
| 674 | + | return nil, fmt.Errorf("%s: cache compatibility retry: %w", label, err) | |
| 675 | + | } | |
| 676 | + | if resp.StatusCode != http.StatusOK { | |
| 677 | + | status = resp.StatusCode | |
| 678 | + | responseBody, _ = io.ReadAll(resp.Body) | |
| 679 | + | resp.Body.Close() | |
| 680 | + | label := c.providerName | |
| 681 | + | if label == "" { | |
| 682 | + | label = ProviderOpenAICodex | |
| 683 | + | } | |
| 684 | + | return nil, fmt.Errorf("%s: http %d: %s", label, status, strings.TrimSpace(string(responseBody))) | |
| 624 | 685 | } | |
| 625 | - | return nil, fmt.Errorf("%s: http %d: %s", label, resp.StatusCode, strings.TrimSpace(string(b))) | |
| 626 | 686 | } | |
| 627 | 687 | ||
| 628 | 688 | out := make(chan Event, 16) | |
M packages/provider/openai_codex_test.go +154 -0
| @@ -393,3 +393,157 @@ func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) { | |||
| 393 | 393 | t.Fatalf("request missing prompt_cache_key: %s", body.String()) | |
| 394 | 394 | } | |
| 395 | 395 | } | |
| 396 | + | ||
| 397 | + | func TestCodexRetriesUnsupportedPromptCacheRetentionWithNativeIdentity(t *testing.T) { | |
| 398 | + | c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses") | |
| 399 | + | var headers []http.Header | |
| 400 | + | var bodies []string | |
| 401 | + | c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { | |
| 402 | + | headers = append(headers, r.Header.Clone()) | |
| 403 | + | body, err := io.ReadAll(r.Body) | |
| 404 | + | if err != nil { | |
| 405 | + | t.Fatal(err) | |
| 406 | + | } | |
| 407 | + | bodies = append(bodies, string(body)) | |
| 408 | + | if len(headers) == 1 { | |
| 409 | + | return &http.Response{ | |
| 410 | + | StatusCode: http.StatusBadRequest, | |
| 411 | + | Header: make(http.Header), | |
| 412 | + | Body: io.NopCloser(strings.NewReader(`{ | |
| 413 | + | "error": { | |
| 414 | + | "message": "prompt_cache_retention is not supported on this model", | |
| 415 | + | "type": "invalid_request_error", | |
| 416 | + | "param": "prompt_cache_retention", | |
| 417 | + | "code": "invalid_parameter" | |
| 418 | + | } | |
| 419 | + | }`)), | |
| 420 | + | }, nil | |
| 421 | + | } | |
| 422 | + | return &http.Response{ | |
| 423 | + | StatusCode: http.StatusOK, | |
| 424 | + | Header: http.Header{"Content-Type": []string{"text/event-stream"}}, | |
| 425 | + | Body: io.NopCloser(strings.NewReader("")), | |
| 426 | + | }, nil | |
| 427 | + | }) | |
| 428 | + | ||
| 429 | + | events, err := c.Stream(context.Background(), Request{ | |
| 430 | + | Model: "gpt-5.6-sol", | |
| 431 | + | Context: RequestContext{CacheSessionID: "cache-root-3", ThreadID: "thread-3", TurnID: "turn-3"}, | |
| 432 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | |
| 433 | + | }) | |
| 434 | + | if err != nil { | |
| 435 | + | t.Fatal(err) | |
| 436 | + | } | |
| 437 | + | for event := range events { | |
| 438 | + | if done, ok := event.(EventDone); ok && done.Err != nil { | |
| 439 | + | t.Fatalf("retry stream failed: %v", done.Err) | |
| 440 | + | } | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | if len(headers) != 2 { | |
| 444 | + | t.Fatalf("requests = %d, want exactly one retry", len(headers)) | |
| 445 | + | } | |
| 446 | + | if got := headers[0].Get("originator"); got != "codex_cli_rs" { | |
| 447 | + | t.Fatalf("initial originator = %q, want codex_cli_rs", got) | |
| 448 | + | } | |
| 449 | + | // The retry drops the Codex CLI identity and uses the native one on the | |
| 450 | + | // same session/request correlation and body. | |
| 451 | + | if got := headers[1].Get("originator"); got != "zot" { | |
| 452 | + | t.Fatalf("retry originator = %q, want zot", got) | |
| 453 | + | } | |
| 454 | + | if headers[1].Get("session-id") != "cache-root-3" || headers[1].Get("thread-id") != "thread-3" || headers[1].Get("x-client-request-id") != "thread-3" { | |
| 455 | + | t.Fatalf("retry correlation headers = session=%q thread=%q request=%q", | |
| 456 | + | headers[1].Get("session-id"), headers[1].Get("thread-id"), headers[1].Get("x-client-request-id")) | |
| 457 | + | } | |
| 458 | + | if len(bodies) != 2 || !strings.Contains(bodies[1], `"prompt_cache_key":"cache-root-3"`) { | |
| 459 | + | t.Fatalf("retry body did not preserve prompt cache key: %v", bodies) | |
| 460 | + | } | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | func TestCodexDoesNotRetryUnrelatedBadRequest(t *testing.T) { | |
| 464 | + | c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses") | |
| 465 | + | requests := 0 | |
| 466 | + | c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { | |
| 467 | + | requests++ | |
| 468 | + | return &http.Response{ | |
| 469 | + | StatusCode: http.StatusBadRequest, | |
| 470 | + | Header: make(http.Header), | |
| 471 | + | Body: io.NopCloser(strings.NewReader(`{ | |
| 472 | + | "error": {"message": "bad input", "param": "input", "code": "invalid_parameter"} | |
| 473 | + | }`)), | |
| 474 | + | }, nil | |
| 475 | + | }) | |
| 476 | + | ||
| 477 | + | _, err := c.Stream(context.Background(), Request{ | |
| 478 | + | Model: "gpt-5.6-sol", | |
| 479 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | |
| 480 | + | }) | |
| 481 | + | if err == nil { | |
| 482 | + | t.Fatal("expected error, got nil") | |
| 483 | + | } | |
| 484 | + | if requests != 1 { | |
| 485 | + | t.Fatalf("requests = %d, want 1 (no retry)", requests) | |
| 486 | + | } | |
| 487 | + | if !strings.Contains(err.Error(), "http 400") { | |
| 488 | + | t.Fatalf("error = %v, want http 400", err) | |
| 489 | + | } | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | func TestCodexUnsupportedRetentionSecondFailureReturns(t *testing.T) { | |
| 493 | + | c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses") | |
| 494 | + | requests := 0 | |
| 495 | + | c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { | |
| 496 | + | requests++ | |
| 497 | + | return &http.Response{ | |
| 498 | + | StatusCode: http.StatusBadRequest, | |
| 499 | + | Header: make(http.Header), | |
| 500 | + | Body: io.NopCloser(strings.NewReader(`{ | |
| 501 | + | "error": { | |
| 502 | + | "message": "prompt_cache_retention is not supported", | |
| 503 | + | "param": "prompt_cache_retention", | |
| 504 | + | "code": "unsupported_parameter" | |
| 505 | + | } | |
| 506 | + | }`)), | |
| 507 | + | }, nil | |
| 508 | + | }) | |
| 509 | + | ||
| 510 | + | _, err := c.Stream(context.Background(), Request{ | |
| 511 | + | Model: "gpt-5.6-sol", | |
| 512 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | |
| 513 | + | }) | |
| 514 | + | if err == nil { | |
| 515 | + | t.Fatal("expected error, got nil") | |
| 516 | + | } | |
| 517 | + | if requests != 2 { | |
| 518 | + | t.Fatalf("requests = %d, want exactly one retry", requests) | |
| 519 | + | } | |
| 520 | + | if !strings.Contains(err.Error(), "http 400") { | |
| 521 | + | t.Fatalf("error = %v, want second http 400", err) | |
| 522 | + | } | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | func TestUnsupportedPromptCacheRetentionPredicate(t *testing.T) { | |
| 526 | + | retention := func(code string) string { | |
| 527 | + | return `{"error":{"message":"` + code + `","param":"prompt_cache_retention","code":"invalid_parameter"}}` | |
| 528 | + | } | |
| 529 | + | cases := []struct { | |
| 530 | + | name string | |
| 531 | + | status int | |
| 532 | + | body string | |
| 533 | + | want bool | |
| 534 | + | }{ | |
| 535 | + | {"code match", http.StatusBadRequest, retention("anything"), true}, | |
| 536 | + | {"message match", http.StatusBadRequest, `{"error":{"message":"retention not supported here","param":"prompt_cache_retention","code":"other"}}`, true}, | |
| 537 | + | {"wrong status", http.StatusForbidden, retention("anything"), false}, | |
| 538 | + | {"wrong param", http.StatusBadRequest, `{"error":{"message":"x","param":"input","code":"invalid_parameter"}}`, false}, | |
| 539 | + | {"no signal", http.StatusBadRequest, `{"error":{"message":"x","param":"prompt_cache_retention","code":"other"}}`, false}, | |
| 540 | + | {"malformed", http.StatusBadRequest, "not json", false}, | |
| 541 | + | } | |
| 542 | + | for _, tc := range cases { | |
| 543 | + | t.Run(tc.name, func(t *testing.T) { | |
| 544 | + | if got := unsupportedPromptCacheRetention(tc.status, []byte(tc.body)); got != tc.want { | |
| 545 | + | t.Fatalf("predicate = %v, want %v", got, tc.want) | |
| 546 | + | } | |
| 547 | + | }) | |
| 548 | + | } | |
| 549 | + | } | |
A packages/provider/openai_tool_images_test.go +210 -0
| @@ -0,0 +1,210 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestOpenAICompatToolResultImagesFollowTextToolMessages(t *testing.T) { | |
| 6 | + | c := NewOpenAICompat("commandcode", "token", "https://example.test/v1", "").(*openaiClient) | |
| 7 | + | ||
| 8 | + | wire, err := c.buildRequest(Request{ | |
| 9 | + | Model: "vision-model", | |
| 10 | + | Messages: []Message{ | |
| 11 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "read both images"}}}, | |
| 12 | + | {Role: RoleAssistant, Content: []Content{ | |
| 13 | + | ToolCallBlock{ID: "call-1", Name: "read"}, | |
| 14 | + | ToolCallBlock{ID: "call-2", Name: "read"}, | |
| 15 | + | }}, | |
| 16 | + | {Role: RoleTool, Content: []Content{ | |
| 17 | + | ToolResultBlock{CallID: "call-1", Content: []Content{ | |
| 18 | + | ImageBlock{MimeType: "image/png", Data: []byte("first")}, | |
| 19 | + | }}, | |
| 20 | + | }}, | |
| 21 | + | {Role: RoleTool, Content: []Content{ | |
| 22 | + | ToolResultBlock{CallID: "call-2", Content: []Content{ | |
| 23 | + | TextBlock{Text: "second image"}, | |
| 24 | + | ImageBlock{MimeType: "image/jpeg", Data: []byte("second")}, | |
| 25 | + | }}, | |
| 26 | + | }}, | |
| 27 | + | }, | |
| 28 | + | }) | |
| 29 | + | if err != nil { | |
| 30 | + | t.Fatal(err) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | wantRoles := []string{"user", "assistant", "tool", "tool", "user"} | |
| 34 | + | if len(wire.Messages) != len(wantRoles) { | |
| 35 | + | t.Fatalf("message count = %d, want %d: %+v", len(wire.Messages), len(wantRoles), wire.Messages) | |
| 36 | + | } | |
| 37 | + | for i, want := range wantRoles { | |
| 38 | + | if got := wire.Messages[i].Role; got != want { | |
| 39 | + | t.Fatalf("message %d role = %q, want %q", i, got, want) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | if got, ok := wire.Messages[2].Content.(string); !ok || got != "(see attached image)" { | |
| 43 | + | t.Fatalf("first tool content = %#v, want text placeholder", wire.Messages[2].Content) | |
| 44 | + | } | |
| 45 | + | if got, ok := wire.Messages[3].Content.(string); !ok || got != "second image" { | |
| 46 | + | t.Fatalf("second tool content = %#v, want text", wire.Messages[3].Content) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | parts, ok := wire.Messages[4].Content.([]interface{}) | |
| 50 | + | if !ok { | |
| 51 | + | t.Fatalf("image message content type = %T, want []interface{}", wire.Messages[4].Content) | |
| 52 | + | } | |
| 53 | + | if len(parts) != 3 { | |
| 54 | + | t.Fatalf("image message parts = %d, want prefix and two images", len(parts)) | |
| 55 | + | } | |
| 56 | + | if text, ok := parts[0].(oaiContentText); !ok || text.Text != "Tool output included the following image content:" { | |
| 57 | + | t.Fatalf("image message prefix = %#v", parts[0]) | |
| 58 | + | } | |
| 59 | + | for i, part := range parts[1:] { | |
| 60 | + | image, ok := part.(oaiContentImage) | |
| 61 | + | if !ok || image.Type != "image_url" { | |
| 62 | + | t.Fatalf("image part %d = %#v", i, part) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestOpenAICompatDoesNotDuplicatePersistedToolImageMirror(t *testing.T) { | |
| 68 | + | c := NewOpenAICompat("custom", "token", "https://example.test/v1", "").(*openaiClient) | |
| 69 | + | image := ImageBlock{MimeType: "image/png", Data: []byte("image")} | |
| 70 | + | ||
| 71 | + | wire, err := c.buildRequest(Request{ | |
| 72 | + | Model: "vision-model", | |
| 73 | + | Messages: []Message{ | |
| 74 | + | {Role: RoleAssistant, Content: []Content{ | |
| 75 | + | ToolCallBlock{ID: "call-1", Name: "read"}, | |
| 76 | + | }}, | |
| 77 | + | {Role: RoleTool, Content: []Content{ | |
| 78 | + | ToolResultBlock{CallID: "call-1", Content: []Content{image}}, | |
| 79 | + | }}, | |
| 80 | + | {Role: RoleUser, Content: []Content{ | |
| 81 | + | TextBlock{Text: "Tool output included the following image content:"}, | |
| 82 | + | image, | |
| 83 | + | }}, | |
| 84 | + | }, | |
| 85 | + | }) | |
| 86 | + | if err != nil { | |
| 87 | + | t.Fatal(err) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | wantRoles := []string{"assistant", "tool", "user"} | |
| 91 | + | if len(wire.Messages) != len(wantRoles) { | |
| 92 | + | t.Fatalf("message count = %d, want %d: %+v", len(wire.Messages), len(wantRoles), wire.Messages) | |
| 93 | + | } | |
| 94 | + | for i, want := range wantRoles { | |
| 95 | + | if got := wire.Messages[i].Role; got != want { | |
| 96 | + | t.Fatalf("message %d role = %q, want %q", i, got, want) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestOpenAICompatToolResultErrorText(t *testing.T) { | |
| 102 | + | c := NewOpenAICompat("custom", "token", "https://example.test/v1", "").(*openaiClient) | |
| 103 | + | ||
| 104 | + | wire, err := c.buildRequest(Request{ | |
| 105 | + | Model: "vision-model", | |
| 106 | + | Messages: []Message{ | |
| 107 | + | {Role: RoleAssistant, Content: []Content{ | |
| 108 | + | ToolCallBlock{ID: "call-1", Name: "read"}, | |
| 109 | + | }}, | |
| 110 | + | {Role: RoleTool, Content: []Content{ | |
| 111 | + | ToolResultBlock{CallID: "call-1", IsError: true, Content: []Content{ | |
| 112 | + | TextBlock{Text: "boom"}, | |
| 113 | + | }}, | |
| 114 | + | }}, | |
| 115 | + | }, | |
| 116 | + | }) | |
| 117 | + | if err != nil { | |
| 118 | + | t.Fatal(err) | |
| 119 | + | } | |
| 120 | + | if len(wire.Messages) != 2 { | |
| 121 | + | t.Fatalf("message count = %d, want 2: %+v", len(wire.Messages), len(wire.Messages)) | |
| 122 | + | } | |
| 123 | + | if got, ok := wire.Messages[1].Content.(string); !ok || got != "boom [error]" { | |
| 124 | + | t.Fatalf("tool content = %#v, want error text", wire.Messages[1].Content) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestOpenAICompatDeepSeekDropsToolImages(t *testing.T) { | |
| 129 | + | c := NewOpenAICompat("deepseek", "token", "https://example.test/v1", "").(*openaiClient) | |
| 130 | + | ||
| 131 | + | wire, err := c.buildRequest(Request{ | |
| 132 | + | Model: "deepseek-chat", | |
| 133 | + | Messages: []Message{ | |
| 134 | + | {Role: RoleAssistant, Content: []Content{ | |
| 135 | + | ToolCallBlock{ID: "call-1", Name: "read"}, | |
| 136 | + | }}, | |
| 137 | + | {Role: RoleTool, Content: []Content{ | |
| 138 | + | ToolResultBlock{CallID: "call-1", Content: []Content{ | |
| 139 | + | TextBlock{Text: "caption"}, | |
| 140 | + | ImageBlock{MimeType: "image/png", Data: []byte("image")}, | |
| 141 | + | }}, | |
| 142 | + | }}, | |
| 143 | + | }, | |
| 144 | + | }) | |
| 145 | + | if err != nil { | |
| 146 | + | t.Fatal(err) | |
| 147 | + | } | |
| 148 | + | if len(wire.Messages) != 2 { | |
| 149 | + | t.Fatalf("message count = %d, want 2 (no image mirror): %+v", len(wire.Messages), len(wire.Messages)) | |
| 150 | + | } | |
| 151 | + | if got, ok := wire.Messages[1].Content.(string); !ok || got != "caption" { | |
| 152 | + | t.Fatalf("tool content = %#v, want text only", wire.Messages[1].Content) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func TestOpenAICompatOpenCodeGoDeepSeekDropsToolImages(t *testing.T) { | |
| 157 | + | c := NewOpenAICompat(ProviderOpenCodeGo, "token", "https://example.test/v1", "").(*openaiClient) | |
| 158 | + | ||
| 159 | + | wire, err := c.buildRequest(Request{ | |
| 160 | + | Model: "deepseek/deepseek-chat", | |
| 161 | + | Messages: []Message{ | |
| 162 | + | {Role: RoleAssistant, Content: []Content{ | |
| 163 | + | ToolCallBlock{ID: "call-1", Name: "read"}, | |
| 164 | + | }}, | |
| 165 | + | {Role: RoleTool, Content: []Content{ | |
| 166 | + | ToolResultBlock{CallID: "call-1", Content: []Content{ | |
| 167 | + | TextBlock{Text: "caption"}, | |
| 168 | + | ImageBlock{MimeType: "image/png", Data: []byte("image")}, | |
| 169 | + | }}, | |
| 170 | + | }}, | |
| 171 | + | }, | |
| 172 | + | }) | |
| 173 | + | if err != nil { | |
| 174 | + | t.Fatal(err) | |
| 175 | + | } | |
| 176 | + | if len(wire.Messages) != 2 { | |
| 177 | + | t.Fatalf("message count = %d, want 2 (no image mirror): %+v", len(wire.Messages), len(wire.Messages)) | |
| 178 | + | } | |
| 179 | + | if got, ok := wire.Messages[1].Content.(string); !ok || got != "caption" { | |
| 180 | + | t.Fatalf("tool content = %#v, want text only", wire.Messages[1].Content) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func TestOpenAICompatUserImagesPassThrough(t *testing.T) { | |
| 185 | + | c := NewOpenAICompat("custom", "token", "https://example.test/v1", "").(*openaiClient) | |
| 186 | + | image := ImageBlock{MimeType: "image/png", Data: []byte("image")} | |
| 187 | + | ||
| 188 | + | wire, err := c.buildRequest(Request{ | |
| 189 | + | Model: "vision-model", | |
| 190 | + | Messages: []Message{ | |
| 191 | + | {Role: RoleUser, Content: []Content{ | |
| 192 | + | TextBlock{Text: "look"}, | |
| 193 | + | image, | |
| 194 | + | }}, | |
| 195 | + | }, | |
| 196 | + | }) | |
| 197 | + | if err != nil { | |
| 198 | + | t.Fatal(err) | |
| 199 | + | } | |
| 200 | + | if len(wire.Messages) != 1 { | |
| 201 | + | t.Fatalf("message count = %d, want 1: %+v", len(wire.Messages), len(wire.Messages)) | |
| 202 | + | } | |
| 203 | + | parts, ok := wire.Messages[0].Content.([]interface{}) | |
| 204 | + | if !ok || len(parts) != 2 { | |
| 205 | + | t.Fatalf("user content = %#v, want text+image", wire.Messages[0].Content) | |
| 206 | + | } | |
| 207 | + | if _, ok := parts[1].(oaiContentImage); !ok { | |
| 208 | + | t.Fatalf("second part = %#v, want image", parts[1]) | |
| 209 | + | } | |
| 210 | + | } | |
M packages/provider/provider.go +9 -7
| @@ -131,12 +131,12 @@ type ReasoningBlock struct { | |||
| 131 | 131 | ||
| 132 | 132 | func (ReasoningBlock) isContent() {} | |
| 133 | 133 | ||
| 134 | - | // RepairOrphanedToolResults removes tool_result content blocks (and | |
| 135 | - | // entire messages that become empty) when the matching tool_use ID | |
| 136 | - | // does not appear anywhere in the given messages. Resume tails, | |
| 137 | - | // compaction repair, and provider request builders all need this so | |
| 138 | - | // the upstream API never sees a tool_call_id with no corresponding | |
| 139 | - | // assistant tool_call earlier in the same request. | |
| 134 | + | // RepairOrphanedToolResults removes invalid tool_result content blocks | |
| 135 | + | // and entire messages that become empty. A result is invalid when its | |
| 136 | + | // matching tool_use ID does not appear anywhere in the messages or when | |
| 137 | + | // an earlier result already covers the same ID. Resume tails, compaction | |
| 138 | + | // repair, and provider request builders all need this so the upstream API | |
| 139 | + | // sees exactly one result for each referenced tool call. | |
| 140 | 140 | func RepairOrphanedToolResults(msgs []Message) []Message { | |
| 141 | 141 | useIDs := map[string]bool{} | |
| 142 | 142 | for _, m := range msgs { | |
| @@ -146,14 +146,16 @@ func RepairOrphanedToolResults(msgs []Message) []Message { | |||
| 146 | 146 | } | |
| 147 | 147 | } | |
| 148 | 148 | } | |
| 149 | + | resultIDs := map[string]bool{} | |
| 149 | 150 | out := make([]Message, 0, len(msgs)) | |
| 150 | 151 | for _, m := range msgs { | |
| 151 | 152 | var filtered []Content | |
| 152 | 153 | for _, c := range m.Content { | |
| 153 | 154 | if tr, ok := c.(ToolResultBlock); ok { | |
| 154 | - | if !useIDs[tr.CallID] { | |
| 155 | + | if !useIDs[tr.CallID] || resultIDs[tr.CallID] { | |
| 155 | 156 | continue | |
| 156 | 157 | } | |
| 158 | + | resultIDs[tr.CallID] = true | |
| 157 | 159 | } | |
| 158 | 160 | filtered = append(filtered, c) | |
| 159 | 161 | } | |
M packages/provider/provider_test.go +39 -0
| @@ -472,6 +472,45 @@ func TestAnthropicBuildRequestStripsAssistantImages(t *testing.T) { | |||
| 472 | 472 | } | |
| 473 | 473 | } | |
| 474 | 474 | ||
| 475 | + | func TestRepairOrphanedToolResultsDeduplicates(t *testing.T) { | |
| 476 | + | text := func(s string) TextBlock { return TextBlock{Text: s} } | |
| 477 | + | result := func(id, s string) ToolResultBlock { | |
| 478 | + | return ToolResultBlock{CallID: id, Content: []Content{text(s)}} | |
| 479 | + | } | |
| 480 | + | msgs := []Message{ | |
| 481 | + | {Role: RoleAssistant, Content: []Content{ | |
| 482 | + | ToolCallBlock{ID: "tool-1", Name: "read"}, | |
| 483 | + | ToolCallBlock{ID: "tool-2", Name: "read"}, | |
| 484 | + | }}, | |
| 485 | + | {Role: RoleTool, Content: []Content{result("tool-1", "first"), text("note"), result("tool-1", "dup-same-msg")}}, | |
| 486 | + | {Role: RoleTool, Content: []Content{result("tool-1", "dup-across-msg")}}, | |
| 487 | + | {Role: RoleTool, Content: []Content{result("tool-2", "second")}}, | |
| 488 | + | {Role: RoleTool, Content: []Content{result("orphan", "orphan")}}, | |
| 489 | + | } | |
| 490 | + | got := RepairOrphanedToolResults(msgs) | |
| 491 | + | if len(got) != 3 { | |
| 492 | + | t.Fatalf("messages=%d, want 3 after dropping duplicates and orphans", len(got)) | |
| 493 | + | } | |
| 494 | + | first := got[1].Content | |
| 495 | + | if len(first) != 2 { | |
| 496 | + | t.Fatalf("first result message blocks=%d, want result+note", len(first)) | |
| 497 | + | } | |
| 498 | + | tr, ok := first[0].(ToolResultBlock) | |
| 499 | + | if !ok || tr.CallID != "tool-1" { | |
| 500 | + | t.Fatalf("first block=%T %+v, want tool-1 result", first[0], first[0]) | |
| 501 | + | } | |
| 502 | + | if txt, ok := tr.Content[0].(TextBlock); !ok || txt.Text != "first" { | |
| 503 | + | t.Fatalf("first result content=%+v, want first", tr.Content) | |
| 504 | + | } | |
| 505 | + | if txt, ok := first[1].(TextBlock); !ok || txt.Text != "note" { | |
| 506 | + | t.Fatalf("mixed content=%+v, want preserved note", first[1]) | |
| 507 | + | } | |
| 508 | + | last, ok := got[2].Content[0].(ToolResultBlock) | |
| 509 | + | if !ok || last.CallID != "tool-2" { | |
| 510 | + | t.Fatalf("last block=%T %+v, want tool-2 result", got[2].Content[0], got[2].Content[0]) | |
| 511 | + | } | |
| 512 | + | } | |
| 513 | + | ||
| 475 | 514 | func TestAnthropicStreamHappyPath(t *testing.T) { | |
| 476 | 515 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 477 | 516 | w.Header().Set("content-type", "text/event-stream") | |
M packages/tui/render.go +32 -9
| @@ -763,7 +763,23 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 763 | 763 | // real previous bg-colored row. | |
| 764 | 764 | ||
| 765 | 765 | wasInitialized := r.logInit | |
| 766 | - | full := !wasInitialized || len(r.logLines) == 0 | |
| 766 | + | bottomShrunkAboveViewport := false | |
| 767 | + | if len(bottomFrame) < len(r.logBottom) { | |
| 768 | + | newViewportTop := len(lines) - r.rows | |
| 769 | + | if newViewportTop < 0 { | |
| 770 | + | newViewportTop = 0 | |
| 771 | + | } | |
| 772 | + | // If the shorter bottom frame starts above the currently addressable | |
| 773 | + | // viewport, relative cursor movement cannot repaint its prefix. This | |
| 774 | + | // happens when returning from a moderately long dialog to a short | |
| 775 | + | // one, and more generally on any bottom-band shrink while the | |
| 776 | + | // transcript is taller than the viewport (queue chips draining, | |
| 777 | + | // extension chrome lines, activity pages). The full repaint rewrites | |
| 778 | + | // the whole frame without purging scrollback, matching the | |
| 779 | + | // recovery path below. | |
| 780 | + | bottomShrunkAboveViewport = newViewportTop < r.logViewportTop | |
| 781 | + | } | |
| 782 | + | full := !wasInitialized || len(r.logLines) == 0 || bottomShrunkAboveViewport | |
| 767 | 783 | if full { | |
| 768 | 784 | // A cache invalidation may require a full viewport repaint after flow | |
| 769 | 785 | // has started, but only the initial paint may discard old scrollback. | |
| @@ -989,10 +1005,12 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 989 | 1005 | w.WriteString(lines[idx]) | |
| 990 | 1006 | } | |
| 991 | 1007 | finalRow := renderEnd | |
| 1008 | + | fullRepaint := false | |
| 992 | 1009 | if len(r.logLines) > len(lines) { | |
| 993 | 1010 | extra := len(r.logLines) - len(lines) | |
| 994 | - | if extra > r.rows { | |
| 1011 | + | if extra >= r.rows { | |
| 995 | 1012 | writeFull(true, false) | |
| 1013 | + | fullRepaint = true | |
| 996 | 1014 | } else { | |
| 997 | 1015 | for e := 0; e < extra; e++ { | |
| 998 | 1016 | w.WriteString("\x1b[1B") | |
| @@ -1007,13 +1025,18 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 1007 | 1025 | } | |
| 1008 | 1026 | } | |
| 1009 | 1027 | } | |
| 1010 | - | r.logHardwareRow = finalRow | |
| 1011 | - | r.logViewportTop = viewportTop | |
| 1012 | - | if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop { | |
| 1013 | - | r.logViewportTop = minTop | |
| 1014 | - | } | |
| 1015 | - | if r.logViewportTop < 0 { | |
| 1016 | - | r.logViewportTop = 0 | |
| 1028 | + | // When writeFull ran above it already recomputed logHardwareRow | |
| 1029 | + | // and logViewportTop; overwriting them with stale incremental | |
| 1030 | + | // values here would desync the next relative move. | |
| 1031 | + | if !fullRepaint { | |
| 1032 | + | r.logHardwareRow = finalRow | |
| 1033 | + | r.logViewportTop = viewportTop | |
| 1034 | + | if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop { | |
| 1035 | + | r.logViewportTop = minTop | |
| 1036 | + | } | |
| 1037 | + | if r.logViewportTop < 0 { | |
| 1038 | + | r.logViewportTop = 0 | |
| 1039 | + | } | |
| 1017 | 1040 | } | |
| 1018 | 1041 | } | |
| 1019 | 1042 | } | |
M packages/tui/render_test.go +127 -0
| @@ -292,3 +292,130 @@ func TestDrawLogInvalidationPreservesScrollbackSelection(t *testing.T) { | |||
| 292 | 292 | t.Fatalf("invalidation repaint erased scrollback and native selection: %q", got) | |
| 293 | 293 | } | |
| 294 | 294 | } | |
| 295 | + | ||
| 296 | + | // TestDrawLogPartialBottomShrinkRepaintsInaccessibleRows covers returning | |
| 297 | + | // from a moderately long dialog to a short one: the shorter bottom frame | |
| 298 | + | // starts above the currently addressable viewport, so relative cursor | |
| 299 | + | // movement cannot repaint its prefix and a full repaint is required. | |
| 300 | + | // Without the forced repaint, stale dialog rows stay on screen. | |
| 301 | + | func TestDrawLogPartialBottomShrinkRepaintsInaccessibleRows(t *testing.T) { | |
| 302 | + | t.Setenv("TERM_PROGRAM", "ghostty") | |
| 303 | + | var buf bytes.Buffer | |
| 304 | + | r := NewRenderer(&buf) | |
| 305 | + | r.Resize(80, 4) | |
| 306 | + | ||
| 307 | + | r.DrawLog(nil, []string{"transcript 1", "transcript 2", "transcript 3", "transcript 4", "transcript 5"}, -1, 0) | |
| 308 | + | buf.Reset() | |
| 309 | + | ||
| 310 | + | r.DrawLog(nil, []string{"dashboard", "selected agent 1"}, -1, 0) | |
| 311 | + | got := buf.String() | |
| 312 | + | if !strings.Contains(got, SeqClearScreenNoHome) { | |
| 313 | + | t.Fatalf("partial bottom shrink did not repaint the screen: %q", got) | |
| 314 | + | } | |
| 315 | + | for _, want := range []string{"dashboard", "selected agent 1"} { | |
| 316 | + | if !strings.Contains(got, want) { | |
| 317 | + | t.Fatalf("partial bottom repaint missing %q: %q", want, got) | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | // TestDrawLogChatShrinkDoesNotForceBottomRepaint pins the scope of the | |
| 323 | + | // forced repaint: chat reflow has its own coordinate-rebasing path which | |
| 324 | + | // preserves native terminal selections. Only a shrinking bottom frame | |
| 325 | + | // forces the full repaint used when returning to a short dialog. | |
| 326 | + | func TestDrawLogChatShrinkDoesNotForceBottomRepaint(t *testing.T) { | |
| 327 | + | t.Setenv("TERM_PROGRAM", "ghostty") | |
| 328 | + | var buf bytes.Buffer | |
| 329 | + | r := NewRenderer(&buf) | |
| 330 | + | r.Resize(80, 4) | |
| 331 | + | ||
| 332 | + | r.DrawLog([]string{"chat 1", "chat 2", "chat 3", "chat 4", "chat 5", "chat 6"}, []string{"input"}, 0, 0) | |
| 333 | + | buf.Reset() | |
| 334 | + | ||
| 335 | + | r.DrawLog([]string{"chat 1", "chat 2", "chat 3", "chat 4", "chat 5"}, []string{"input"}, 0, 0) | |
| 336 | + | if got := buf.String(); strings.Contains(got, SeqClearScreenNoHome) { | |
| 337 | + | t.Fatalf("chat shrink unnecessarily repainted the screen: %q", got) | |
| 338 | + | } | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | // TestDrawLogFullHeightShrinkRepaintsAndTracksViewport covers a dialog | |
| 342 | + | // shrink that removes a full viewport of logical rows: the incremental | |
| 343 | + | // clear-below path cannot address them, so the draw must repaint fully | |
| 344 | + | // and the recomputed viewport state must survive for the next draw. | |
| 345 | + | // A cursor move right after must update the visible selection instead of | |
| 346 | + | // being discarded as inaccessible. | |
| 347 | + | func TestDrawLogFullHeightShrinkRepaintsAndTracksViewport(t *testing.T) { | |
| 348 | + | t.Setenv("TERM_PROGRAM", "ghostty") | |
| 349 | + | var buf bytes.Buffer | |
| 350 | + | r := NewRenderer(&buf) | |
| 351 | + | r.Resize(80, 4) | |
| 352 | + | ||
| 353 | + | r.DrawLog(nil, []string{"transcript 1", "transcript 2", "transcript 3", "transcript 4", "transcript 5", "transcript 6"}, -1, 0) | |
| 354 | + | buf.Reset() | |
| 355 | + | ||
| 356 | + | r.DrawLog(nil, []string{"dashboard", "selected agent 1"}, -1, 0) | |
| 357 | + | if got := buf.String(); !strings.Contains(got, SeqClearScreenNoHome) { | |
| 358 | + | t.Fatalf("full-height shrink did not repaint the screen: %q", got) | |
| 359 | + | } | |
| 360 | + | buf.Reset() | |
| 361 | + | ||
| 362 | + | r.DrawLog(nil, []string{"dashboard", "selected agent 2"}, -1, 0) | |
| 363 | + | if got := buf.String(); !strings.Contains(got, "selected agent 2") { | |
| 364 | + | t.Fatalf("visible update after full-height shrink was suppressed: %q", got) | |
| 365 | + | } | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | // TestDrawLogShrinkRecoveryMatrix pins the Zut-specific renderer state | |
| 369 | + | // across a forced full repaint: themed backgrounds still tint the frame, | |
| 370 | + | // keepScrollback terminals never purge scrollback, Kitty images are | |
| 371 | + | // cleaned, and a narrow follow-up draw stays coherent with no stale rows. | |
| 372 | + | func TestDrawLogShrinkRecoveryMatrix(t *testing.T) { | |
| 373 | + | t.Setenv("TERM_PROGRAM", "vscode") | |
| 374 | + | var buf bytes.Buffer | |
| 375 | + | r := NewRenderer(&buf) | |
| 376 | + | background := Color256(237) | |
| 377 | + | th := Dark | |
| 378 | + | th.Background = &background | |
| 379 | + | r.SetTheme(th) | |
| 380 | + | r.Resize(80, 4) | |
| 381 | + | if !r.keepScrollback { | |
| 382 | + | t.Fatal("vscode TERM_PROGRAM did not enable keepScrollback") | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | image := "\x1b_Ga=T;payload\x1b\\" | |
| 386 | + | r.DrawLog(nil, []string{image, "transcript 1", "transcript 2", "transcript 3", "transcript 4", "transcript 5"}, -1, 0) | |
| 387 | + | buf.Reset() | |
| 388 | + | ||
| 389 | + | r.DrawLog(nil, []string{"dashboard", "selected agent 1"}, -1, 0) | |
| 390 | + | got := buf.String() | |
| 391 | + | if !strings.Contains(got, SeqClearToEnd) { | |
| 392 | + | t.Fatalf("themed shrink recovery did not repaint in place: %q", got) | |
| 393 | + | } | |
| 394 | + | if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) { | |
| 395 | + | t.Fatalf("keepScrollback repaint purged scrollback: %q", got) | |
| 396 | + | } | |
| 397 | + | if !strings.Contains(got, SeqDeleteKittyImages) { | |
| 398 | + | t.Fatalf("shrink recovery did not clean Kitty images: %q", got) | |
| 399 | + | } | |
| 400 | + | if !strings.Contains(got, th.BackgroundStyle()) { | |
| 401 | + | t.Fatalf("themed repaint lost the background tint: %q", got) | |
| 402 | + | } | |
| 403 | + | buf.Reset() | |
| 404 | + | ||
| 405 | + | // A narrow follow-up draw must stay coherent: no stale rows, no extra | |
| 406 | + | // full repaint, and the hardware cursor tracks the new viewport. | |
| 407 | + | r.Resize(20, 4) | |
| 408 | + | r.DrawLog(nil, []string{"dashboard", "selected agent 2"}, -1, 0) | |
| 409 | + | got = buf.String() | |
| 410 | + | if !strings.Contains(got, "selected agent 2") { | |
| 411 | + | t.Fatalf("narrow follow-up draw lost the selection: %q", got) | |
| 412 | + | } | |
| 413 | + | if strings.Contains(got, "transcript") { | |
| 414 | + | t.Fatalf("narrow follow-up draw left stale rows: %q", got) | |
| 415 | + | } | |
| 416 | + | for _, row := range strings.Split(got, "\r\n") { | |
| 417 | + | if strings.ContainsAny(row, "\r\n") { | |
| 418 | + | t.Fatalf("row embeds a newline: %q", row) | |
| 419 | + | } | |
| 420 | + | } | |
| 421 | + | } | |
M packages/tui/view.go +26 -16
| @@ -465,17 +465,17 @@ func (v *View) renderErr(width int) []string { | |||
| 465 | 465 | if wrapWidth < 8 { | |
| 466 | 466 | wrapWidth = 8 | |
| 467 | 467 | } | |
| 468 | - | wrapped := wrapLine(v.Err, wrapWidth, "") | |
| 469 | - | if len(wrapped) == 0 { | |
| 470 | - | wrapped = []string{""} | |
| 471 | - | } | |
| 472 | - | out := make([]string, 0, len(wrapped)) | |
| 473 | - | for idx, line := range wrapped { | |
| 474 | - | prefix := marker | |
| 475 | - | if idx > 0 { | |
| 476 | - | prefix = indent | |
| 468 | + | var out []string | |
| 469 | + | // Each returned row must represent one terminal line, including when | |
| 470 | + | // the error contains explicit newlines or blank separator lines. | |
| 471 | + | for _, paragraph := range strings.Split(v.Err, "\n") { | |
| 472 | + | for _, line := range wrapLine(paragraph, wrapWidth, "") { | |
| 473 | + | prefix := indent | |
| 474 | + | if len(out) == 0 { | |
| 475 | + | prefix = marker | |
| 476 | + | } | |
| 477 | + | out = append(out, v.Theme.FGColor(v.Theme.Error, prefix+line)) | |
| 477 | 478 | } | |
| 478 | - | out = append(out, v.Theme.FGColor(v.Theme.Error, prefix+line)) | |
| 479 | 479 | } | |
| 480 | 480 | return out | |
| 481 | 481 | } | |
| @@ -2859,9 +2859,9 @@ func toolArgWidth() int { | |||
| 2859 | 2859 | // suffix for the "tool name <args>" header. tool is the tool | |
| 2860 | 2860 | // name so we can add shape-specific decorations: for read we | |
| 2861 | 2861 | // append the requested line range (e.g. "path:1-200") pulled | |
| 2862 | - | // from the offset/limit args, which is useful context at a | |
| 2863 | - | // glance without expanding the result body. Other tools keep | |
| 2864 | - | // the legacy "path or command, truncated" shape. | |
| 2862 | + | // from the offset/limit args, and for glob we prefer the pattern | |
| 2863 | + | // with an " in <dir>" suffix. Other tools keep the legacy | |
| 2864 | + | // "path, file_path, or command, truncated" shape. | |
| 2865 | 2865 | // | |
| 2866 | 2866 | // The truncation width defaults to 60 cells but can be tuned via | |
| 2867 | 2867 | // the ZUT_TOOL_ARG_WIDTH environment variable (see toolArgWidth). | |
| @@ -2886,7 +2886,14 @@ func ShortArgs(tool string, raw json.RawMessage) string { | |||
| 2886 | 2886 | return s | |
| 2887 | 2887 | } | |
| 2888 | 2888 | var primary string | |
| 2889 | - | for _, k := range []string{"path", "file_path", "command"} { | |
| 2889 | + | // Glob leads with its pattern; every other tool keeps the legacy | |
| 2890 | + | // path-first shape so tools carrying both (e.g. ast's pattern+path) | |
| 2891 | + | // still label with the file path. | |
| 2892 | + | keys := []string{"path", "file_path", "command"} | |
| 2893 | + | if strings.EqualFold(tool, "glob") { | |
| 2894 | + | keys = []string{"pattern", "path", "file_path", "command"} | |
| 2895 | + | } | |
| 2896 | + | for _, k := range keys { | |
| 2890 | 2897 | if s, ok := x[k].(string); ok { | |
| 2891 | 2898 | primary = s | |
| 2892 | 2899 | break | |
| @@ -2902,8 +2909,7 @@ func ShortArgs(tool string, raw json.RawMessage) string { | |||
| 2902 | 2909 | } | |
| 2903 | 2910 | primary = oneLineToolLabel(primary) | |
| 2904 | 2911 | ||
| 2905 | - | // Tool-specific decoration. Only the read tool gets a range | |
| 2906 | - | // suffix for now; other tools just truncate the primary arg. | |
| 2912 | + | // Tool-specific decoration. | |
| 2907 | 2913 | suffix := "" | |
| 2908 | 2914 | switch strings.ToLower(tool) { | |
| 2909 | 2915 | case "read": | |
| @@ -2917,6 +2923,10 @@ func ShortArgs(tool string, raw json.RawMessage) string { | |||
| 2917 | 2923 | } else if start > 1 { | |
| 2918 | 2924 | suffix = fmt.Sprintf(":%d-", start) | |
| 2919 | 2925 | } | |
| 2926 | + | case "glob": | |
| 2927 | + | if dir, ok := x["path"].(string); ok && dir != "" && dir != "." { | |
| 2928 | + | suffix = " in " + dir | |
| 2929 | + | } | |
| 2920 | 2930 | } | |
| 2921 | 2931 | ||
| 2922 | 2932 | // Truncate the primary arg leaving room for the suffix so the | |
A packages/tui/view_error_test.go +28 -0
| @@ -0,0 +1,28 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestRenderErrSplitsExplicitNewlines(t *testing.T) { | |
| 9 | + | v := &View{Theme: Theme{Error: Color256(1)}, Err: "failed to start\n\n exec: python3\nstderr log: /tmp/ext.log"} | |
| 10 | + | want := []string{"✖ failed to start", " ", " exec: python3", " stderr log: /tmp/ext.log"} | |
| 11 | + | rows := v.renderErr(80) | |
| 12 | + | if len(rows) != len(want) { | |
| 13 | + | t.Fatalf("rows = %q, want %d rows", rows, len(want)) | |
| 14 | + | } | |
| 15 | + | for n, row := range rows { | |
| 16 | + | if expected := v.Theme.FGColor(v.Theme.Error, want[n]); row != expected { | |
| 17 | + | t.Errorf("row %d = %q, want %q", n, row, expected) | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | for _, row := range v.renderErr(20) { | |
| 21 | + | if strings.ContainsAny(row, "\r\n") { | |
| 22 | + | t.Errorf("row contains a newline: %q", row) | |
| 23 | + | } | |
| 24 | + | if width := visibleWidth(row); width > 20 { | |
| 25 | + | t.Errorf("row width = %d, want <= 20: %q", width, row) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | } | |
M packages/tui/view_tool_arg_width_test.go +13 -0
| @@ -61,3 +61,16 @@ func TestShortArgsNoTruncationWhenShort(t *testing.T) { | |||
| 61 | 61 | t.Fatalf("ShortArgs = %q, want %q", got, "short query") | |
| 62 | 62 | } | |
| 63 | 63 | } | |
| 64 | + | ||
| 65 | + | func TestShortArgsGlobShowsPatternAndDir(t *testing.T) { | |
| 66 | + | t.Setenv("ZUT_TOOL_ARG_WIDTH", "") | |
| 67 | + | if got := ShortArgs("glob", json.RawMessage(`{"pattern":"**/*.go"}`)); got != "**/*.go" { | |
| 68 | + | t.Fatalf("ShortArgs glob = %q, want pattern", got) | |
| 69 | + | } | |
| 70 | + | if got := ShortArgs("glob", json.RawMessage(`{"pattern":"*.json","path":"configs"}`)); got != "*.json in configs" { | |
| 71 | + | t.Fatalf("ShortArgs glob with dir = %q, want pattern plus dir", got) | |
| 72 | + | } | |
| 73 | + | if got := ShortArgs("glob", json.RawMessage(`{"pattern":"*.json","path":"."}`)); got != "*.json" { | |
| 74 | + | t.Fatalf("ShortArgs glob with dot dir = %q, want bare pattern", got) | |
| 75 | + | } | |
| 76 | + | } | |