Compare commits

...
Author SHA1 Message Date
Vasanthdev2004 330b10fe8e docs(readme): clarify /loop runs a prompt or custom command, not built-ins 2026-07-06 08:59:06 +05:30
Vasanthdev2004 ac25352e12 fix(tui): address /loop review round 2 — ticker re-arm, self-paced command, resume guard
- stopLoop re-arms the poll ticker after removing one loop, so stopping one of
  several no longer strands the rest with no tick scheduled (jatmn).
- a bare self-paced /command is rejected with a pointer to the interval form:
  self-paced drives a free-form goal via the playbook + LOOP: control line, which
  a custom command's expanded template never carries, so it would silently
  degrade to adaptive polling (jatmn).
- /resume only tears loops down on an actual session-id change; /resume latest or
  /resume <currentID> can resolve to the already-active session, whose loops are
  its own, not a "previous" session's (CodeRabbit).
- tests: skipped-behind-modal loop stays scheduled, footer carries the next-wake
  time, the confirmed /clear actually wipes the transcript, plus multi-loop stop
  keeping the ticker alive and self-paced-/command rejection.
2026-07-06 08:53:04 +05:30
Vasanthdev2004 de7f0b6a5b fix(tui): finish /loop v1 — real self-paced completion, session scope, leave gate
Response to the review. The self-paced path was the real gap: it advertised
model-chosen completion but nothing ever wrote the done/next-wake fields, so it
was just adaptive polling dressed up. Replaced that with an actual control
channel — each self-paced iteration runs a short maintenance playbook and ends
its reply with a control line (LOOP: DONE / LOOP: CONTINUE [interval]) that
advanceLoop parses to stop the loop or pick the next wake. The two dead model
fields are gone; done and next-wake now come from the model's output.

Rest of the review:
- loops stop on a session switch (/new, /resume) instead of firing the previous
  conversation's prompt into the new one
- validateLoopTarget resolves custom /commands up front and rejects unknowns, so
  a mistyped `/loop 5m /nope` isn't scheduled and then re-sent as literal text
- a one-shot confirm gates /clear and /quit while loops are active
- age-based auto-expire (24h) next to the iteration cap
- the two CodeRabbit test nits (HasPrefix footer check, bare `/loop stop` on a
  single loop)

Hardening from a self-review pass over the above:
- normalize CRLF and allow a trailing note on the control line, so
  `LOOP: DONE — all clean` and \r\n replies still stop the loop
- disarm the leave-confirm before the queue/exit early returns, so an interposed
  prompt can't leave it falsely armed
- a loop no longer fires behind an open picker/modal; a run launched behind the
  /resume picker would otherwise complete into whatever session you switch to
2026-07-06 08:53:04 +05:30
Vasanthdev2004 3eafaf584d fix(tui): stop looping on repeated failure + clear loop tag on cancel
Addresses CodeRabbit review on #502:
- advanceLoop now counts consecutive failed iterations (loopMaxFailures=3) and
  stops the loop, so a loop whose turns keep erroring no longer churns to the
  iteration cap (the doom guard only sees identical non-empty answers).
- cancelRun clears activeLoopID and re-arms the interrupted loop for its next
  cadence, so a cancelled iteration is not left running forever and the next
  unrelated turn is not misattributed as that loop's completion.
2026-07-06 08:53:04 +05:30
Vasanthdev2004 9cfdb2fecf docs(readme): add /loop to the slash-command table 2026-07-06 08:53:04 +05:30
Vasanthdev2004 4ffbf4e0fe test(tui): drive /loop end-to-end through Update (submit + stale tick) 2026-07-06 08:53:04 +05:30
Vasanthdev2004 440d4df367 feat(tui): /loop self-paced adaptive cadence + footer summary + /clear note
Third slice of #501.

- Self-paced loops now use an adaptive cadence (2m early -> 30m heartbeat as
  iterations accrue), with the loopNextWake channel kept as an override for a
  future explicit model signal.
- Persistent footer segment "↻ N loops · next 3:05pm" while loops are active.
- /clear notes that loops keep running (it wipes the transcript, not the session)
  so a loop never silently fires into a cleared screen.
- Tests for adaptive cadence + footer summary.
2026-07-06 08:53:04 +05:30
Vasanthdev2004 4bc9a97176 feat(tui): wire /loop into the turn lifecycle — dispatch, idle tick, continuation
Second slice of #501. Adds the controller and wires it in:

- /loop command registered (commands.go) and dispatched to handleLoopCommand.
- Loop state on the model (loops, activeLoopID, loopSeq/loopTicking poll guards,
  loopNextWake/loopDone self-pace channel).
- An idle poll tick (loopTickMsg, ~1s) fires the earliest due loop only when the
  session is idle — mirrors the launchQueuedMessageIfReady guard so loops and a
  user's queued prompt never collide; a self-perpetuating tea.Tick that stops when
  no loops remain.
- The agentResponseMsg completion seam advances a loop iteration (schedule next
  wake for fixed, model delay for self-paced) with doom-loop, iteration-cap, and
  done stop conditions, restarting the ticker if other loops remain.
- /loop list|stop|stop all, footer summary + list rendering, and a guard that only
  a prompt or custom /command may loop (not a built-in).

Controller tests cover start/advance/self-paced-delay/done/doom/cap/stop/busy.
2026-07-06 08:53:04 +05:30
Vasanthdev2004 6d7d3946c8 feat(tui): /loop foundation — command parsing, loop state, safety bounds
First slice of the /loop feature (#501): the self-contained core with no TUI
wiring yet. parseLoopCommand handles the leading-interval, trailing-"every",
bare-self-paced, and list/stop forms; loopState models one session-scoped loop
with doom-loop + iteration-cap fields; interval/self-pace clamps and duration
formatting round it out, with unit tests. Because Zero drives loops off a Go
timer rather than a cron backend, arbitrary intervals (90s, 7m) are supported
with no whitelist limitation.
2026-07-06 08:52:13 +05:30
Gautam ManchandaniandGautam Manchandani d185f3ef0d Fix action summary for structured output (#485)
* Fix action summary for structured output

* Address action summary review comments

* Keep action summary extraction best effort

---------

Co-authored-by: Gautam Manchandani <[email protected]>
2026-07-06 08:52:43 +08:00
euxaristia 8c6c49ad52 chore: fix golangci-lint findings (dead code, ineffassign, staticcheck) (#527)
- Remove unused safeRequestedPrefix, safeShellCommandTokens
  (internal/agent/command_prefix.go), and truncateHeadTail
  (internal/tools/bash.go): each was a thin wrapper with no remaining
  callers.
- Drop an ineffectual assignment in stringListArgWebSearch: every
  return path after it already sets the named return explicitly.
- Simplify a negated-and condition in
  partition_cache_stable_test.go via De Morgan's law.
- Use strings.Contains instead of strings.Index(...) < 0 in
  edit_replacers.go.
- Fix error string style (ST1005): lowercase "unsafe redirect URL"
  and drop the trailing period from webFetchPublicOnlyHint.

Skipped the errcheck findings on this pass (unchecked defer
Close()/Write() errors): those are stylistic, not correctness bugs,
and this repo's CI only runs go vet, not golangci-lint.
2026-07-06 08:09:32 +08:00
Gautam ManchandaniandGautam Manchandani 809489a6e3 Batch session event persistence (#526)
Co-authored-by: Gautam Manchandani <[email protected]>
2026-07-06 08:08:58 +08:00
euxaristia 56147a708e ci: add golangci-lint as an advisory check (#528)
go vet, gofmt, govulncheck, and deadcode don't catch unused private
functions/assignments reachable within a package but never called, or
staticcheck-style correctness/readability issues. deadcode's whole-program
reachability analysis specifically missed several of these; golangci-lint's
truncateHeadTail nitpick on #476 assumed it would fail CI, but nothing in
this repo actually ran it.

Scoped to unused, ineffassign, and staticcheck rather than the full default
battery, and continue-on-error like deadcode, so this surfaces the existing
findings across the repo (being cleaned up incrementally per #527) without
blocking in-flight PRs on a big-bang adoption.
2026-07-06 08:07:29 +08:00
euxaristiaandClaude Fable 5 81aad58d97 fix(tools): Block MSYS coreutils under Windows sandbox (#476)
* fix(tools): block MSYS coreutils under Windows sandbox

Git for Windows usr\bin coreutils (head, grep, cat, ...) are MSYS PE binaries that fail with ACCESS_DENIED and cygheap/signal-pipe errors when Zero runs them in the write-restricted Windows sandbox. Detect and block these commands before execution, surface MSYS runtime failures from stderr, steer agents toward native Zero tools or require_escalated, and exclude MSYS-prone names from known-safe command tails.

Refs #458

* fix(tools): anchor the last MSYS failure heuristic to MSYS markers

CodeRabbit review feedback: the win32-error-5 plus terminating check
had no MSYS-specific anchor, so unrelated access-denied failures could
be mislabeled as windows_msys_sandbox. Require an MSYS marker like the
neighboring checks do.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(tools): let escalation bypass the MSYS guard and make detection quote-aware

Addresses jatmn's review on PR #476:
- bash and exec_command called detectShellCommandIssue before resolving
  sandbox_permissions into a command engine, so a require_escalated retry of
  a blocked command was still hard-blocked by the same preflight check.
  Resolve the command engine first and only bypass the windows_msys_sandbox
  issue kind when that resolves to true unsandboxed execution; other issue
  kinds (real cmd.exe syntax problems) still block regardless of escalation.
- Unify all MSYS-prone command name checks (MsysProneCommandName, the
  preflight scan, and transitively command_prefix.go's known-safe guard)
  behind one windowsMsysProneNames set. This fixes expr (in the name set but
  missing from the old regex alternations, so never proactively flagged) and
  ls (hit the older windows_shell_syntax branch first, so it got different
  metadata than every other MSYS-prone name).
- Make the preflight scan quote-aware: split the command into cmd.exe-
  operator-separated segments respecting double-quote grouping, and only
  check the first word of each segment instead of scanning raw text anywhere.
  Fixes false positives on quoted text like commit messages or PR comment
  bodies that merely mention a coreutil name.

* test(tools): decouple MSYS-guard bypass tests from real command output

TestBashToolRequireEscalatedMsysGuard and
TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval asserted
shell_issue == "" to prove the require_escalated bypass worked. Once
bypassed, "cat somefile.txt" actually runs unsandboxed, and its real,
PATH-dependent output could trip the unrelated post-execution
detectShellOutputIssue heuristic, failing the assertion for reasons
unrelated to the preflight guard under test.

Assert on the exit_code "-1" sentinel (set only by shellIssueBlockResult)
instead, which directly reflects whether the preflight guard blocked the
command.

* fix(tools): anchor MSYS path detection to command position, fix prefix approval gap

Address jatmn's latest review on PR #476:

- shell_runtime.go: the explicit MSYS binary-path check (usr\bin\,
  mingw64\bin\, msys-2.0.dll, cygwin1.dll) ran against the raw command
  string, so a quoted mention (e.g. a commit message describing a
  usr\bin\head.exe failure) was still hard-blocked. It now runs against
  the first word of each operator-separated segment, same as the
  coreutil-name check. windowsCommandSegments also stopped treating ;
  as a command separator (cmd.exe does not split on ;, unlike bash) and
  now respects cmd.exe's ^ escape character, so escaped operators like
  echo ^| head are no longer misread as a real head invocation.

- command_prefix.go: proposedCommandPrefix could offer to approve a
  single segment (e.g. "ps aux" in "ps aux | head -5") without checking
  that every other segment was independently safe. Since approving a
  prefix escalates the whole command to bypass the sandbox, an
  MSYS-prone or otherwise unsafe segment could ride along uncovered.
  It now only proposes a segment as the prefix when every other
  segment in the command is known-safe on its own.

Added regression tests for quoted MSYS path mentions, caret-escaped
operators, semicolon text, and the prefix-approval gap.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(tools): treat redirection as a command boundary, stop using command text as output evidence

Addresses jatmn's latest review on PR #476:
- firstCommandWord only split on whitespace, so cmd.exe redirection
  attached directly to a command name (head>out.txt, cat<in.txt,
  grep>matches.txt) was read as one word and never matched against the
  MSYS-prone name set. It now also stops at < and >.
- detectShellOutputIssue scanned command+output together, so a command
  whose own argument text merely quoted an MSYS failure string (e.g. a
  gh pr comment --body describing the bug) could be mislabeled after
  running even though the real stdout/stderr was unrelated. Removed the
  command parameter entirely from detectShellOutputIssue and both call
  sites (bash.go, exec_command.go), so the command line can never be
  used as post-execution evidence again.

* fix(tools): remove unused truncateHeadTail wrapper

CodeRabbit nitpick: truncateHeadTailWithTotal is called directly
everywhere, so this wrapper is dead code that trips golangci-lint's
unused check and fails CI.

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-06 08:03:32 +08:00
Kevin Codex 95ccd5bc32 perf: universal tool-output ceiling with spill + async post-edit diagnostics (#518)
* feat(tools): universal token-based output ceiling with spill-to-disk

Cap every tool result at the registry boundary (16k estimated tokens,
ZERO_TOOL_OUTPUT_CEILING_TOKENS-tunable, 0 disables) unless the tool
manages its own deliberate budget. Head+tail truncation, full output
spilled to the existing redacted spill dir and re-readable via
grep/read_file, so nothing is lost — only deferred.

This closes the three unbounded paths a single call could flood the
context window through (and then re-bill every following turn):
- web_fetch: up to 256KiB default / 2MiB max of non-HTML body
- skill: full SKILL.md returned uncapped
- MCP-served tools: no budget at all

Self-budgeting tools opt out via an unexported marker interface (an MCP
server can never exempt itself): bash, exec_command (model-raisable),
read_file, read_minified_file, grep, glob, list_directory. Browser
snapshots deliberately stay under the net.

bash itself is tightened to match the unified token model: emit budget
96KiB -> 32KiB per stream (~24k -> ~8k tokens worst case per stream),
while capture retention stays at 96KiB head+tail so the new spill file
covers 6x more of the output than the transcript shows.

* perf(agent): collect post-edit LSP diagnostics off the tool-call path

Inline post-edit diagnostics blocked every edit_file/write_file result on
the language server: a >=300ms quiet-period debounce that reset on each
publish, seconds on the first edit per language, capped at 10s — the
largest recurring latency tax in an edit-heavy session.

Diagnostics are now collected in the background: a file is enqueued the
moment a mutating tool finishes, a single worker checks it while the rest
of the batch executes, and the loop drains completed results just before
building the NEXT provider request, appending errors as a user nudge.
The model always issues another request after an edit turn (to read its
tool results), so it still sees introduced errors at the same decision
point — no tool call ever stalls on the language server.

A file re-edited before its check runs is checked once in its final
state (the checker re-reads the file); re-edited after, it is re-queued
and the newer result wins. A drain that finds the worker still busy
gives up after 3s and delivers on a later turn — results stay accurate
because any further edit re-enqueues the file.

The tools-side inline mechanism (tools.RunOptions.Diagnostics) is kept
for direct API callers; the loop just no longer wires it.

* fix: address review — max-turns diagnostics drain, capture-gap marker, test hardening

- Drain background diagnostics before the max-turns final-answer request
  so an error introduced by the LAST turn's edit is reported in the
  summary instead of silently dropped (+ regression test).
- spillBashStreams marks the head/tail junction with a capture-gap
  marker when boundedBuffer dropped the middle of a stream, so a spilled
  log never reads as contiguous when it is not; junction position is
  asserted in the test.
- Tests override TMP/TEMP alongside TMPDIR (os.TempDir reads TMP/TEMP on
  Windows) via a shared setTestTempDir helper.
- TestBudgetBashCaptureReportsTrueTotal now asserts spill_path meta and
  spill file content; exemption-list test gains read_minified_file.

* fix(test): use a real workspace root in async diagnostics tests

filepath.IsAbs("/ws") is false on Windows (no drive letter), so the
collector test's workspace-absolute assertion failed on the windows
smoke job. Root the fake workspace at t.TempDir() — absolute on every
platform — and assert against it.

* fix: pattern-scrub spill files and gate finalization on pending diagnostics

Review follow-ups (Vasanthdev2004):

- Spill files now pass the pattern-based secret scanner (AWS keys,
  GitHub/Slack/OpenAI tokens, PEM blocks, JWTs) in addition to the
  configured-key scrub. A bash spill runs before formatBashOutput's
  scan, so without this a spilled file held in cleartext exactly the
  credentials the transcript redacts. Applied centrally in
  spillTruncatedOutput, covering bash, exec_command, and ceiling spills.

- Finalization diagnostics gate: a no-tool-call final answer now drains
  pending post-edit diagnostics with the full inline-era budget (10s,
  vs the 3s per-turn wait) before the run is accepted; introduced
  errors append the nudge and give the model one more turn instead of
  being silently dropped when no later turn exists. The max-turns
  summary path uses the same finalization budget. Free for runs that
  never edited — an idle collector returns immediately.
2026-07-05 23:50:31 +08:00
KRATOS 284a6c86cb Skills: deterministic invocation — /<skill-name>, searchable /skills picker, discovery-budget fix (#520)
* feat(skills): make skill invocation deterministic and discoverable

Skills were model-pulled only: the model had to notice a skill in a
640-byte system-prompt list (~6 skills visible; the rest collapsed into
'…and N more') and call the skill tool on its own. Users could not run a
skill at all — typing /<skill-name> reported 'unknown command'. The two
halves of the fix:

User invocation
- /<skill-name> [args] now runs an installed skill directly: resolved
  after builtins and user commands, the skill body is inlined as the
  prompt with the typed args appended, and submitted through the same
  launch path user commands use.
- /skills lists installed skills with their slash form, live from disk.
  Labels are honest: a skill shadowed by a builtin, alias, user command,
  or a case-colliding sibling is listed bare with a '(via skill tool)'
  note instead of advertising a dead /name.
- The slash palette suggests skills (labeled '(skill)') read through the
  same loader dispatch uses, so mid-session installs appear and shadowed
  or case-colliding names are never advertised.
- Skill and user-command invocations now apply the plain-prompt run-state
  guards: mid-run submissions queue the EXPANDED prompt (the queue flush
  submits literal text), compaction-in-flight warns, exiting drops.
- Skills resolve through a lazy plugin-aware loader (default dir merged
  with plugin skill roots — the same set the skill tool and the system
  prompt see), TTL-cached so palette keystrokes don't re-read SKILL.md
  bodies.

Model triggering
- Raise the skills-list budget 640 -> 4096 bytes: the list is the model's
  only discovery surface, and the old cap made skills past ~6 invisible —
  invocation reliability collapsed exactly for users invested in skills.
- Raise description truncation 100 -> 200 runes: the description carries
  the trigger conditions ('use when…'); cutting them is what stopped the
  model from firing the skill.
- Strengthen the instruction line: scan the list before acting, call the
  matching skill FIRST, don't substitute your own approach.

* feat(tui): /skills opens a searchable skill picker

Replace the static /skills card with the same picker UX /model uses:
type-to-filter (ranked search), arrow-key selection, Enter runs the
chosen skill immediately. Selection is by the skill's exact name, so
skills the slash path cannot reach — names shadowed by a builtin or
user command, or names that don't fit a slash token — are still
runnable from the picker, where the choice is unambiguous. The text
card remains only as the no-skills install hint, and the picker path
applies the same run-state guards as slash invocation.

* feat(tui): ask-first guard for bare skill invocations

A skill invoked with no request submits only its body — instructions
with no target ("review the PR" — which PR?) — and the model improvises
a target instead of asking, which is especially wrong in a fresh
session. Bare invocations (slash and picker) now append a conditional
note: if the instructions need a target or details not already clear
from the conversation, ask first; self-contained skills still just run.
Invocations that carry a request are unchanged.

* fix(tui): review follow-ups — restore typed invocation on compaction guard, cap merged suggestions

- The compaction-in-flight guard for skill/user-command invocations fired
  after the dispatch path had already cleared the composer, so the typed
  "/name args" was lost and had to be retyped; thread the raw invocation
  through and restore it into the composer (the plain-prompt guard keeps
  the composer intact by firing before the clear). The picker path has no
  typed form and restores nothing.
- Cap the merged autocomplete list at the shared bound: each source
  self-caps, but three sources could stack to ~3x the palette limit.
- Tighten the shadowed-skill palette test to assert on the advertised
  Name rather than the fallback Desc wording.

* chore: ignore zero-skills local build artifact

A locally built test binary under this name was accidentally committed
(and has been scrubbed from the branch history); ignore the name so it
cannot recur.
2026-07-05 21:14:53 +05:30
8f3b8f0d13 docs: document AGENTS.md/ZERO.md and plugin manifest format (#522)
* docs: document AGENTS.md/ZERO.md and plugin manifest format

Add an "Extending Zero" section to the README covering the
AGENTS.md/ZERO.md project-instructions lookup chain and the
plugin.json manifest fields, neither of which were previously
documented outside the source.

Closes #521

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* docs: fix plugin path scope and add per-user ZERO.md guidelines

- Correct project-scope plugin discovery to <cwd>/.zero/plugins, not
  the repo root.
- Clarify that user-scope plugin discovery always resolves under
  ~/.config regardless of OS, separate from config.UserConfigDir()
  (which is OS-aware and used for the per-user ZERO.md file).
- Document the per-user ZERO.md guidelines file added in #475.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-07-05 22:51:20 +08:00
PatrickNoFilter 0f69d995e9 fix: Termux/Android support — PRoot scroll, SIGSYS sandbox, build docs (#509)
* fix(tui): TracerPid PRoot detection, Ctrl+U/D direction, composer guard

- Replace Termux env var detection with isRunningUnderPRoot()
  via /proc/self/status TracerPid, cached with sync.OnceValue
- Mouse mode falls back to CellMotion (not AllMotion) under PRoot
  to fix broken touch scrolling
- Add Shift+Up/Shift+Down before plain KeyDown/KeyUp for transcript
  scroll with permission/askuser cursor + composer guard
- Add Ctrl+U (scroll up) and Ctrl+D (scroll down) with proper
  direction: Ctrl+U=move up, Ctrl+D=move down
- Add guards for active modals (wizard, picker, spec review, etc.)
  to avoid intercepting their Ctrl+U/D keybindings
- Tests: TestPermissionCursorCtrlU/D (direction regression),
  TestShiftUp/DownComposerGuard (composer guard)
- References issue #507

* fix(sandbox): avoid faccessat2 for Termux/Android portability

Replace exec.LookPath with lookupExecutable that uses os.Stat
instead of the faccessat2 syscall. The faccessat2 syscall (439)
is blocked by the Samsung seccomp filter on Android, causing
SIGSYS termination. os.Stat uses the newfstatat/statx syscalls
which are universally permitted.

This is part of broader Termux/Android support for Zero.

* docs(install): add Termux/Android build and configuration guide

Document how to build Zero for native Android/Termux: GOOS=android
to avoid the SIGSYS crash, proot bind-mount for DNS resolution,
PRoot scroll fix, and free-tier provider setup.

* fix(sandbox): skip empty PATH segments, use platform-aware executable check
2026-07-05 21:48:34 +08:00
Kevin Codex 87e7e69afd feat(openai): forward prompt_cache_key for server-side prefix cache routing (#515)
* feat(openai): forward prompt_cache_key for server-side prefix cache routing

Send the session ID as OpenAI's prompt_cache_key on every completion
request so consecutive turns route to a backend replica that already
holds the conversation prefix in its prompt cache, instead of re-billing
the full ~11k-token system+tools prefix each turn on OpenAI-compatible
endpoints (which get no cache_control breakpoints, unlike Anthropic).

Keyless requests serialize byte-identically to before, and
ZERO_DISABLE_PROMPT_CACHE_KEY=1 suppresses the field for strict
endpoints that reject unknown parameters. The Codex (Responses API)
flavor is deliberately untouched: the ChatGPT backend is a separate,
stricter surface.

* fix(openai): parse prompt-cache-key kill switch as a boolean flag

ZERO_DISABLE_PROMPT_CACHE_KEY=0 or =false previously disabled forwarding
because any non-empty value tripped the check. Parse it the same way
ZERO_FORMAT_ON_WRITE parses boolean flags, so explicitly-falsy values
are a no-op and only truthy values flip the toggle.
2026-07-05 21:41:03 +08:00
Vasanth T f4c998ac30 feat(agent): curb over-engineering the solution in the editing discipline (#517)
The editing discipline already caps the diff size and scope, but a small diff can
still over-build the solution — speculative abstraction, unrequested
configurability, handling for cases that cannot occur. Add a guardrail: solve the
problem as posed, nothing the user did not ask for; if a 200-line solution could
be 50, rewrite it.

Validated with a before/after eval on five over-engineering-prone tasks: 3/5
solutions came out simpler with the guardrail, none more complex, none broke
correctness (avg 9 vs 11 non-blank lines); the baseline gold-plated on two of them
(an unrequested cache-introspection attribute and header-merge config). No effect
on trivial tasks, where the baseline is already minimal.

Closes #516
2026-07-05 21:38:42 +08:00
Alaa Elsamouly 30bff28efc Fix/detailed view scroll select (#498)
* fix(tui): strip all ANSI escapes from copied transcript text

renderAssistantMarkdownText embeds bold markers for prose and ANSI color sequences from chroma for fenced code blocks. stripMarkdownRenderControls only stripped the bold markers, leaking ANSI codes into clipboard text. Switch to ansi.Strip() to remove all escape sequences.

* refactor(tui): extract rendering dispatch and add detailed bool to transcriptBodyItems

Extract rowRenderFn and transcriptRowDispatchFn types and refactor rendering methods into *Fn variants parameterized by the render function.

Add detailed bool parameter to transcriptBodyItems to prepare for the detailed transcript viewport. All callers pass false so there is no behavior change yet.

Extract transcriptRowBodyHeightCacheKeyOpts from transcriptRowBodyHeightCacheKey so the body cap can be configured per caller.

* feat(tui): scrollable detailed transcript viewport with sidebar suppression

Enable keyboard scrolling (PgUp/PgDown/Up/Down) in detailed transcript mode by routing through scrollChat instead of swallowing the keys.

Rewrite detailedTranscriptView to use the transcriptBodyItems pipeline so the body renders through the same viewport/scroll engine as the live view.

Implement detailedTranscriptFooter with copy-status and jump-to-bottom hint integration.

Suppress the sidebar in detailed mode so the full chat width is available for reading uncapped tool output.

* test(tui): detailed transcript scroll with PgUp/Down and Up/Down arrows

TestPgUpDownScrollsDetailedTranscript verifies that PgUp scrolls toward older content and PgDown returns to the bottom in detailed mode.

TestUpDownArrowsScrollDetailedTranscript verifies that arrow keys scroll one line at a time and bottom is clamped at offset 0.

* test(tui): ANSI strip verification for markdown rendering controls and selectable text

TestStripMarkdownRenderControlsStripsAllANSI verifies bold markers, color sequences, and combined sequences are stripped.

TestSelectedTranscriptTextStripsANSIFromHighlightedCode verifies that chroma-highlighted code blocks produce clean plain text in the selectable layer.

* fix for coderabit, and better fewer code.

* fix(tui): address PR review feedback dead code, hover clear, and
rebindable key label

* format

* addressing the review issue

1. the ctrl + b in detailed mode, added the guard to not be able to do
it in detailed mode so it affect its state in the normal mode.

2. added a test coverage for keyDown after `keDown then keyUp` so it
   covers that.

* fix(tui): close file drill-in when entering detailed transcript

toggleDetailedTranscript now calls exitFileView before returning, so an active file drill-in is automatically closed when the user enters detailed transcript mode.

* test(tui): verify file drill-in closes on detailed toggle

Adds TestDetailedTranscriptClosesFileView and TestDetailedTranscriptStaysClosedOnSecondToggle to guard against the file-view bypass bug.

* fix(tui): block composer hit-test while detailed transcript is active

composerMouseSelectionBlocked and mouseOverComposer now check m.transcriptDetailed so mouse clicks and wheel events in the composer area reach the transcript body instead of being silently consumed.

* test(tui): verify composer mouse is ignored in detailed mode

Adds TestComposerMouseSelectionBlockedInDetailedMode to guard against composer hit-test swallowing mouse events during detailed transcript viewing.
2026-07-05 19:36:17 +08:00
euxaristiaandClaude Sonnet 5 7b10aab74b feat(agent): inject per-user config.UserConfigDir()/zero/ZERO.md guidelines into system prompt (#475)
* Add-user-ZERO-guidelines-to-system-prompt

* fix(agent): resolve user ZERO.md under config.UserConfigDir()/zero

Review feedback: every other user-level config (config.json, commands,
specialists) lives under config.UserConfigDir()/zero, so the per-user
guidelines file now resolves there instead of ~/.zero. The basename match
is case-insensitive to mirror the project guideline loader.

Co-Authored-By: Claude Fable 5 <[email protected]>

* refactor(agent): extract shared guideline truncation and lookup helpers

CodeRabbit review feedback: the rune-safe truncation block and the
case-insensitive basename scan were duplicated across the project and
user guideline loaders. Extract truncateGuidelineContent and
findCaseInsensitiveFile and use them in both paths.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(agent): keep truncated guideline content within the byte limit

CodeRabbit review feedback: truncateGuidelineContent appended the
truncation marker after slicing to the limit, so the result could
exceed the requested budget by the marker length. Reserve space for
the marker before choosing the cut point.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(agent): fix small-limit truncation, precedence, and test isolation for user guidelines

Address remaining review feedback on PR #475:

- truncateGuidelineContent still exceeded the limit when limit was
  smaller than the truncation marker itself. Fall back to a hard,
  rune-safe cut with no marker in that case so the result never
  exceeds the caller's budget.
- Reorder buildSystemPrompt so user guidelines are injected before
  workspace/project guidelines, and add an explicit precedence note
  in the user guidelines section text, so a repo's AGENTS.md/ZERO.md
  is the later, more specific instruction and wins on conflict.
- Add a TestMain in the agent package that stubs userConfigDirForPrompt
  to an empty temp directory by default, so buildSystemPrompt(Options{})
  calls in unrelated tests don't pick up a contributor's real
  ~/.config/zero/ZERO.md.
- Document the per-user ZERO.md location and its precedence relative
  to project guidelines in AGENTS.md.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-05 18:53:17 +08:00
Kevin Codex 3c81fea228 feat: agent quality, caching, retry, and tooling upgrades (#506)
* feat: agent quality, caching, retry, and tooling upgrades

Ten self-contained improvements to the core agent engine and tools:

- edit_file: fuzzy fallback matching (7-strategy cascade with uniqueness
  protection and a disproportion guard) when old_string does not match
  byte-for-byte; exact match remains the fast path
- edit_file/write_file: inline error-severity LSP diagnostics in tool
  output so the model sees a break it introduced in the same turn
- lsp: expand server registry from 4 to 28 languages; doctor grades only
  tier-1 servers and lists the long tail informationally
- agent: consecutive read-only tool calls execute concurrently (bounded
  at 8), consumed in original order, never spanning a mutating call
- anthropic: cache_control breakpoints on the last two conversation
  messages so the transcript prefix stays a prompt-cache hit
- providerio: retry schedule raised to 6 attempts with exponential
  2s-30s backoff when the server sends no Retry-After
- tools: truncated exec/bash output spills to a redacted temp file
  referenced in the truncation notice; bash output (previously
  unbounded) now shares exec_command's token budget
- web_fetch: dependency-free HTML-to-markdown conversion (format:
  auto|raw|markdown) and larger limits (256KiB default, 2MiB max, 30s)
- modelregistry: live models.dev overlay refreshes context limits and
  base pricing from a TTL-cached fetch; opt-in at the CLI entrypoint
- tools: opt-in format-on-write (ZERO_FORMAT_ON_WRITE=1) runs standard
  formatters across 27 extensions, ordered before the file-tracker
  re-baseline so conflict detection is preserved

* fix: address PR review feedback on fuzzy edits, diagnostics, spill

- edit_file: adapt the replacement to the fuzzy-resolved span — when the
  span is a uniformly deeper-indented copy of old_string (line-trimmed /
  indentation-flexible matches), the same indentation delta is applied to
  new_string, and a span ending in a bare CR keeps its CR so CRLF files
  never gain a mixed line ending; block-anchor matches with drifted
  interiors are deliberately left untouched
- diagnostics: render workspace-relative paths in inline post-edit
  diagnostics (base name outside the workspace) so absolute paths never
  leak the local username or directory layout into the model prompt or
  session transcript
- spill: harden the spill directory for shared temp dirs — per-uid name,
  reject symlinked or non-directory paths (MkdirAll follows symlinks),
  and on Unix require current-user ownership
- bash: the branch's output cap and truncation-flag threading are
  superseded by main's capture-time bounding (boundedBuffer +
  budgetBashCapture), which already sets Result.Truncated; this rebase
  adopts that design and keeps spill-to-disk on exec_command, where the
  full output is still buffered and a spill is genuinely complete

* fix: address review round 2 — permission-event race, fuzzy ambiguity, spill reachability

- agent: serialize OnPermission behind the same mutex as
  OnPermissionRequest in the parallel read batch. buildPermissionEvent
  fires for batched reads with a non-trivial sandbox decision (e.g. a
  grant-matched extra root), and front-end handlers append to shared
  session-recording state without their own locking — two batched reads
  emitting concurrently was a data race the pre-batch serial loop never
  allowed.
- edit_file: reject two or more DISTINCT candidate spans from one fuzzy
  strategy as ambiguous instead of silently editing the first. Duplicate
  blocks at different indentation each resolve to a unique literal, so
  the previous occurrence-uniqueness check passed and wrote the wrong
  span. A single candidate occurring at multiple positions still
  cascades to stricter strategies as before.
- tools: make spill files actually reachable by the recovery path the
  truncation notice advertises — resolveScopedReadPath now recognizes
  files inside the per-uid spill dir (containment verified after symlink
  resolution, so a planted link cannot smuggle an out-of-scope file
  through the gate); read_file/grep can follow the notice instead of
  re-running the command.

* fix: surface competing block-anchor spans; make write-barrier test observe cross-probe order

- edit_file: blockAnchorReplacer now returns every span clearing the
  similarity threshold (best first) instead of silently picking the
  single most-similar block. Hiding competitors from the cascade's
  distinct-candidate check could edit the wrong block when two blocks
  share anchors and both interiors are plausible; one clear winner still
  resolves, two plausible blocks now report ambiguity (covered by a
  two-block test that asserts the file is untouched).
- agent tests: the parallel write-barrier test now records both probes
  into one mutex-guarded shared log and asserts the cross-probe ordering
  it always claimed to (write starts after the first read batch ends;
  second read batch starts after the write ends). The previous version
  only compared indices within the read probe's own log, which a
  regression letting the write overlap a batch could still pass; the
  stray no-op mutex assignment is gone.
2026-07-05 17:25:26 +08:00
Ashwinhegde19andAshwin f6c060631e fix(openai): handle Ollama reasoning stream deltas (#486)
* fix(openai): handle reasoning alias in stream deltas

* test(cli): assert reasoning event order

---------

Co-authored-by: Ashwin <[email protected]>
2026-07-05 17:05:54 +08:00
euxaristiaandClaude Fable 5 563a6dbe91 fix(sandbox): fix nested pipe creation under the Windows restricted token (#456)
* fix(sandbox): fix nested pipe creation under the Windows restricted token

Any subprocess spawned FROM WITHIN a process already running under Zero's
write-restricted token, that captures the child's output via a pipe, failed
with ERROR_ACCESS_DENIED. Reproduces with `gh pr list` (gh shells out to git
and captures its output), with a bare Go program doing exec.Command(...).
Output(), and with cmd.exe's own FOR /F ('...') construct, which uses the
identical CreatePipe+CreateProcess pattern internally with no external tools
involved.

Root cause: a WRITE_RESTRICTED token requires a WRITE-type access check to
match BOTH the normal enabled-SID grant and a separate grant to one of the
token's restricted SIDs. CreateRestrictedToken does not modify the default
DACL it inherits from the base token, so a pipe created with a default
security descriptor (the common case; every language runtime's "spawn a
subprocess and capture output" primitive does this) only carries ACEs for
the base identity, none of which are in the restricted-SID list, so the
second check fails. This is the same failure family already documented for
Schannel/SEC_E_NO_CREDENTIALS, but broader in scope: it affects any pipe,
event, mutex, or other kernel object created with default security by a
process running under the token.

Fix: add the token's own logon SID (already unconditionally present in its
restricted-SID list) to the token's default DACL, so objects it creates
without an explicit security descriptor automatically satisfy the extra
check. This does not touch filesystem access: NTFS write-jailing is
enforced by the explicit ACL grants applied to workspace paths, created
WITH an explicit security descriptor, and the token's default DACL is only
consulted when none is supplied. Exposure is bounded to the same logon
session (only other processes running as the same signed-in user could, in
principle, open a NAMED object the sandboxed process creates with default
security; anonymous pipes have no name and are reachable only via an
inherited handle, so this is a no-op for the actual objects this fixes).

This is not specific to the unelevated tier added in #427 - the affected
token-construction code is shared by both the fully elevated and unelevated
restricted-token paths, so this predates that work.

Verified on Windows 11 (before/after, using an unfixed build for
comparison):
- Before: FOR /F fails with Win32 error 5 (ERROR_ACCESS_DENIED), masked
  behind cmd.exe's generic "not recognized" message; `gh pr list` fails
  with "failed to run git: pipe: Access is denied."; a bare Go nested
  exec.Command(...).Output() fails with "pipe: Access is denied."
- After: all three succeed; `gh pr list` returns real PR data.
- Write-jail regression check: a write outside every granted root is still
  denied by the OS after the fix (unchanged from before).

New TestWindowsRestrictedTokenNestedPipeCapture (ZERO_SANDBOX_REAL_SMOKE=1)
pins the FOR /F case using only cmd.exe, no external dependency. go vet and
the full internal/sandbox suite are clean (excluding pre-existing failures
already present identically on unmodified main, unrelated to this change).

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(sandbox): fix dangling-buffer use-after-scope in default DACL merge

Addresses the blocker from review on this PR (Vasanthdev2004, gnanam1990,
coderabbitai): windowsTokenDefaultDacl returned a *windows.ACL that points
INTO the local buf byte slice backing TOKEN_DEFAULT_DACL - Windows embeds
the ACL data in the same allocation GetTokenInformation fills. The
function's own runtime.KeepAlive(buf) only covered buf up to that
function's own return; nothing kept it alive across the caller's later use.
In broadenWindowsRestrictedTokenDefaultDacl, the returned ACL pointer was
then dereferenced by native code in setEntriesInACL after intervening
allocations (the EXPLICIT_ACCESS literal, TrusteeValueFromSID) that are
GC-safepoint-eligible - a real use-after-free window in code that
constructs part of the sandbox's own restricted token, even though Go's
current non-moving GC meant it didn't manifest in testing.

windowsTokenDefaultDacl now returns the backing buffer alongside the ACL;
the caller holds runtime.KeepAlive(oldDaclBuf) until after
SetEntriesInAclW (the last point that dereferences it) returns.

Also from review: SetEntriesInAclW reports its Win32 error directly in the
return value, not via GetLastError, so setEntriesInACL now uses that
return value directly instead of the (potentially stale/unrelated) syscall
error. And narrowed the default-DACL grant from GENERIC_ALL to GENERIC_READ
| GENERIC_WRITE - the second WRITE_RESTRICTED access check only needs a
read/write match; GENERIC_ALL also implied DELETE/WRITE_DAC/WRITE_OWNER
that the pipe/event/mutex use case never needed.

Re-verified on Windows 11 with the fixed runner: nested pipe capture still
succeeds (pure Go exec.Command(...).Output(), and TestWindowsRestrictedTokenNestedPipeCapture
via go test itself), and the write-jail regression check (write denied
outside every granted root, allowed inside) is unchanged. Full
internal/sandbox suite green.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-05 17:04:07 +08:00
Kunj SHAH 2312abe5dd feat: add zero changes push and pr subcommands, and extra repo-info metrics (#391)
* feat: add zero changes push and pr subcommands, and extra repo-info metrics

* fix: address review feedback on changes command and repoinfo

* style: format repoinfo.go with gofmt

* fix: address remaining review feedback — JSON tags, -- separator, PR validation, tests

* fix: address CodeRabbit + review feedback — default-branch guard, --yes flag, branch parsing, typed JSON, missing tests

- Add default-branch guard: Push rejects main/master unless --yes is passed
- Add --yes flag to changes push/pr to allow pushing to protected branches
- Fix BranchCount parsing: skip detached HEAD lines and strip worktree + prefix
- Replace ad-hoc map with typed struct for PR --json output
- Add push failure short-circuit test for pr workflow
- Add --dry-run rejection test for pr workflow
- Add CreatePR dir assertion in zerogit_test
- Add --yes validation test for commit
- Add RejectsDefaultBranch and AllowDefaultBranchWithYes tests

* test: add positive-path test for --yes flag in changes push

* fix: resolve remote HEAD dynamically instead of hardcoded main/master

* style: run gofmt on changed files

* retrigger CI

* fix: resolve syntax error in parseChangesArgs
2026-07-05 17:03:29 +08:00
118 changed files with 9777 additions and 369 deletions

No files matched your search

+14 -3
View File
@@ -100,9 +100,10 @@ jobs:
go-version-file: go.mod
cache: true
# govulncheck and deadcode resolve a toolchain from their own modules, which
# can drop below the version go.mod requires and then fail to load our
# packages. Pin GOTOOLCHAIN to the go.mod toolchain so both run under it.
# govulncheck, deadcode, and golangci-lint each resolve a toolchain from
# their own modules, which can drop below the version go.mod requires and
# then fail to load our packages. Pin GOTOOLCHAIN to the go.mod toolchain
# so all three run under it.
- name: Pin toolchain from go.mod
run: |
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
@@ -120,3 +121,13 @@ jobs:
- name: deadcode (advisory)
continue-on-error: true
run: go run golang.org/x/tools/cmd/[email protected] -test=false ./...
# Advisory: catches what deadcode's whole-program reachability analysis
# doesn't, unused private functions/assignments reachable within a
# package but never actually called, plus staticcheck-style correctness
# and readability issues. Scoped to a few linters rather than the full
# default battery, and non-blocking, while the existing findings across
# the repo are cleaned up incrementally (see #527).
- name: golangci-lint (advisory)
continue-on-error: true
run: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...
+5
View File
@@ -37,8 +37,13 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
/zero
/zero.exe
/zero-skills
/snake-game.html
# Superpowers skill artifacts (brainstorm mockups, specs, plans) — local only
.superpowers/
docs/superpowers/
# Termux/Android build artifacts
zero-termux-arm64
zero-linux-sandbox
+7
View File
@@ -47,6 +47,12 @@ Tips:
- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). Zero picks those up automatically when you launch from inside the sub-tree.
- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained.
### Personal guidelines, across every project
For preferences that follow *you*, not a specific repo (tone, tooling habits, workflow), drop a `ZERO.md` in your user config directory: `~/.config/zero/ZERO.md` on Linux/macOS, `%AppData%\Roaming\zero\ZERO.md` on Windows — the same directory as `config.json` and your personal specialists. Same format and 8 KiB cap as the project files above, and the same case-insensitive basename match.
This file is injected as its own `## User guidelines` section, before the project's `AGENTS.md`/`ZERO.md`, and is labeled as personal preference in the prompt: project guidelines are the later, more specific instruction and take precedence over it when the two conflict.
## 2. Custom specialists
Specialists are Zero's sub-agents. Three scopes, in priority order:
@@ -307,6 +313,7 @@ A team that wants every contributor's Zero to behave the same way commits:
Each contributor adds only:
- `~/.config/zero/config.json` — their personal API keys, theme, default mode.
- `~/.config/zero/ZERO.md` — personal preferences that follow them across every project (see section 1).
- `~/.local/share/zero/skills/` — personal skills they keep across projects.
That's it. Run `zero` from the repo root and the agent has the team's full instruction set, every contributor's personal setup, and nothing else.
+34
View File
@@ -186,6 +186,7 @@ Common slash commands:
| `/spec`, `/plan` | draft and review a plan before building |
| `/image` | attach an image for vision-capable models |
| `/resume`, `/rewind` | continue or roll back local sessions |
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
| `/compact`, `/context` | manage context usage |
| `/permissions`, `/tools` | inspect available tools and policy |
| `/add-dir` | allow an extra write directory for this session |
@@ -283,6 +284,39 @@ zero cron scheduled agent jobs
zero update check for newer releases
```
## Extending Zero
### Project and personal instructions
Zero appends project-specific guidance to the system prompt from the first
`AGENTS.md`, `ZERO.md`, or `.zero/AGENTS.md` file found in each directory from
the git root down to your current working directory (checked in that order
per directory). Files are injected general-to-specific, capped at 8 KiB per
file and 32 KiB total.
A personal `ZERO.md` under `config.UserConfigDir()/zero/ZERO.md`
(`$XDG_CONFIG_HOME/zero/ZERO.md` or `~/.config/zero/ZERO.md` on Linux/macOS,
`%AppData%\Roaming\zero\ZERO.md` on Windows) applies across every workspace, ahead of any project guidelines.
### Plugins
Plugins are discovered from `~/.config/zero/plugins/<name>/plugin.json` (user
scope — `$XDG_CONFIG_HOME` or `~/.config` on every OS, independent of the
`config.UserConfigDir()` path used above) and `<cwd>/.zero/plugins/<name>/plugin.json`
(project scope — resolved from the current working directory, not the repo
root), and managed with `zero plugins`. A manifest can declare:
- `tools` — custom tools (`command`, `args`, `inputSchema`, and a
`permission` of `prompt` or `deny`; `allow` is honored only when manifest tool
auto-approval is enabled)
- `hooks` — commands run on `beforeTool`, `afterTool`, `sessionStart`, or
`sessionEnd`
- `prompts` and `skills` — additional prompt/skill files
MCP servers (`zero mcp`) and standalone markdown skills (`zero skills`) use
the same extension points and can also be wired up outside of a plugin
manifest.
## Appearance And Accessibility
| Control | Effect |
+4 -4
View File
@@ -180,6 +180,7 @@ runs:
ZERO_INPUT_ADD_DIR: ${{ inputs.add-dir }}
ZERO_INPUT_WORKTREE: ${{ inputs.worktree }}
ZERO_INPUT_OUTPUT_FORMAT: ${{ inputs.output-format }}
ACTION_PATH: ${{ github.action_path }}
RUNNER_TEMP: ${{ runner.temp }}
run: |
set -euo pipefail
@@ -247,10 +248,9 @@ runs:
echo "exit-code=${code}" >> "${GITHUB_OUTPUT}"
echo "output-file=${output_file}" >> "${GITHUB_OUTPUT}"
# Best-effort one-line summary: last non-empty line of the captured output.
summary="$(grep -v '^[[:space:]]*$' "${output_file}" | tail -n 1 || true)"
# Keep the summary single-line and bounded for the step output.
summary="$(printf '%s' "${summary}" | cut -c1-280)"
# Best-effort one-line summary. Structured output ends with lifecycle
# metadata, so parse final/error events instead of tailing stdout.
summary="$(node "${ACTION_PATH}/scripts/action-summary.mjs" "${ZERO_INPUT_OUTPUT_FORMAT}" "${output_file}" || true)"
{
echo "summary<<ZERO_EOF"
printf '%s\n' "${summary}"
+55
View File
@@ -173,6 +173,61 @@ Linux native sandboxing also requires Bubblewrap to be installed.
macOS uses the system sandbox and does not need an extra helper binary.
### Termux (Android)
Zero can run natively on Android via [Termux](https://termux.dev/). Build with
`GOOS=android` to avoid the `faccessat2` syscall that is blocked by Samsung's
seccomp filter on Android:
```bash
# Install Go in Termux
pkg install golang
# Build Zero for Android
git clone https://github.com/Gitlawb/zero.git
cd zero
CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -ldflags="-s -w" -o zero ./cmd/zero
# Move into PATH
mv zero ~/.local/bin/
```
> **Why `GOOS=android`?** Go 1.26+ detects `runtime.GOOS == "android"` and skips
> the `faccessat2` syscall inside `os/exec.findExecutable`, falling back to
> permission-bit checks. Without this flag, Android's seccomp sends SIGSYS and
> kills the process whenever Zero looks up a binary on `PATH` (git, sh, etc.).
**DNS.** Android does not expose `/etc/resolv.conf`. Go's pure-Go DNS resolver
needs one. Use `proot` to bind-mount Termux's resolver config:
```bash
pkg install proot
proot -b "$PREFIX/etc/resolv.conf:/etc/resolv.conf" zero
```
Create a wrapper at `~/.local/bin/zero` to avoid typing proot every time:
```bash
#!/data/data/com.termux/files/usr/bin/bash
exec proot -b "$PREFIX/etc/resolv.conf:/etc/resolv.conf" ~/.local/bin/zero.bin "$@"
```
**Scroll.** On native Termux (not under PRoot), mouse scrolling works out of the
box. The TUI uses Bubble Tea's `AllMotion` mouse mode by default. If you run Zero
inside PRoot (e.g. through proot-distro), the scroll fix activates `CellMotion`
to avoid PRoot's ptrace interference with the 1003 escape sequence.
**Providers.** Zero works with any OpenAI-compatible provider on Termux. For
example, to use OpenCode Zen's free tier:
```bash
zero providers add opencode \
--name opencode \
--model deepseek-v4-flash-free \
--base-url https://opencode.ai/zen/v1 \
--set-active
```
Windows source builds can use the main `zero.exe` as the command runner and setup
helper through Zero's built-in self-dispatch path. If you want a release-style
layout anyway, build the standalone helper executables next to `zero.exe`:
+5
View File
@@ -41,6 +41,7 @@ events when present.
```json
{ "schemaVersion": 2, "type": "run_start", "runId": "run_20260603_abc123", "sessionId": "zero_20260603100000_abc123", "cwd": "/repo", "provider": "openai", "model": "gpt-4.1", "apiModel": "gpt-4.1" }
{ "schemaVersion": 2, "type": "reasoning", "runId": "run_20260603_abc123", "delta": "Thinking..." }
{ "schemaVersion": 2, "type": "text", "runId": "run_20260603_abc123", "delta": "..." }
{ "schemaVersion": 2, "type": "tool_call", "runId": "run_20260603_abc123", "id": "call_1", "name": "read_file", "args": { "path": "README.md" }, "sideEffect": "read" }
{ "schemaVersion": 2, "type": "permission_request", "runId": "run_20260603_abc123", "id": "call_2", "name": "write_file", "action": "prompt", "permission": "prompt", "permissionMode": "ask", "sideEffect": "write", "reason": "Creates or overwrites files." }
@@ -51,6 +52,10 @@ events when present.
{ "schemaVersion": 2, "type": "run_end", "runId": "run_20260603_abc123", "status": "success", "exitCode": 0 }
```
`reasoning` events carry live model reasoning/status deltas for providers that
stream them separately from answer text. They are liveness/progress events only:
they are not folded into `text` or the final answer.
Permission events may include structured sandbox metadata:
```json
+185
View File
@@ -0,0 +1,185 @@
package agent
import (
"context"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// Post-edit LSP diagnostics used to run synchronously inside edit_file /
// write_file: every edit blocked its tool result on a fresh-publish wait with
// a quiet-period debounce (≥300ms floor that reset on each publish, 10s cap) —
// the largest recurring latency tax in an edit-heavy session. Collection is
// now asynchronous: a file is enqueued the moment a mutating tool finishes, a
// single worker checks it while the rest of the tool batch (and any
// self-correct verification) runs, and the loop drains completed results just
// before building the NEXT provider request, appending errors as a user-role
// nudge. The model always issues another request after an edit turn — it has
// to read its tool results — so it still sees introduced errors at the same
// decision point as before, without any tool call stalling on the language
// server.
//
// A file re-edited before its check runs is checked once in its final state
// (the checker re-reads the file); a file re-edited after its check completed
// is re-enqueued and the newer result wins. When the worker is still busy at
// drain time, the drain gives up after a short wait and the results are
// delivered on the following turn instead — they stay accurate because any
// further edit re-enqueues the file.
// asyncDiagnosticsDrainTimeout bounds how long a turn waits on the in-flight
// check before deferring delivery to the next turn. Most checks finish well
// under it (debounce floor + analysis); a cold language server on a large
// repo does not get to stall the loop the way the old inline 10s cap could.
// A var so tests can shorten the wait.
var asyncDiagnosticsDrainTimeout = 3 * time.Second
// asyncDiagnosticsFinalDrainTimeout is the wait used when the run is about to
// FINALIZE (natural completion or the max-turns summary): there is no later
// turn to defer to, so the gate waits out the old inline-era budget rather
// than silently dropping an error the last edit introduced. A var so tests
// can shorten the wait.
var asyncDiagnosticsFinalDrainTimeout = 10 * time.Second
// asyncDiagnosticsNudge prefixes the drained diagnostics blocks; phrased like
// the old inline block so the model reacts the same way.
const asyncDiagnosticsNudge = "Diagnostics after your recent edits (fix any errors you introduced):\n"
type asyncDiagnostics struct {
check func(context.Context, string) string
workspaceRoot string
mu sync.Mutex
queue []string
queued map[string]bool
results map[string]string
// working is non-nil while the worker goroutine runs and is closed when it
// exits, so drain can wait for quiescence without polling.
working chan struct{}
}
// newAsyncDiagnostics returns nil when check is nil, and every method on a nil
// receiver is a no-op — callers wire it unconditionally.
func newAsyncDiagnostics(check func(context.Context, string) string, workspaceRoot string) *asyncDiagnostics {
if check == nil {
return nil
}
return &asyncDiagnostics{
check: check,
workspaceRoot: workspaceRoot,
queued: map[string]bool{},
results: map[string]string{},
}
}
// enqueue schedules changed files for a background check. Paths are
// workspace-relative from Result.ChangedFiles (absolute when under a granted
// extra write root), mirroring SelfCorrector's resolution.
func (diagnostics *asyncDiagnostics) enqueue(ctx context.Context, files []string) {
if diagnostics == nil || len(files) == 0 {
return
}
diagnostics.mu.Lock()
defer diagnostics.mu.Unlock()
for _, file := range files {
path := diagnostics.absPath(file)
if diagnostics.queued[path] {
continue
}
diagnostics.queued[path] = true
// A completed result for this file describes a pre-edit state now.
delete(diagnostics.results, path)
diagnostics.queue = append(diagnostics.queue, path)
}
if diagnostics.working == nil && len(diagnostics.queue) > 0 {
done := make(chan struct{})
diagnostics.working = done
go diagnostics.work(ctx, done)
}
}
func (diagnostics *asyncDiagnostics) work(ctx context.Context, done chan struct{}) {
defer close(done)
for {
diagnostics.mu.Lock()
if len(diagnostics.queue) == 0 || ctx.Err() != nil {
diagnostics.working = nil
diagnostics.mu.Unlock()
return
}
path := diagnostics.queue[0]
diagnostics.queue = diagnostics.queue[1:]
// Un-mark before checking so an edit that lands DURING the check
// re-enqueues the file and it is checked again in its final state.
delete(diagnostics.queued, path)
diagnostics.mu.Unlock()
block := diagnostics.check(ctx, path)
diagnostics.mu.Lock()
if block != "" {
diagnostics.results[path] = block
} else {
delete(diagnostics.results, path)
}
diagnostics.mu.Unlock()
}
}
// drain waits briefly for the worker to go quiet, then formats and consumes
// all completed error blocks as one nudge. Returns "" when there is nothing
// to report or the worker is still busy (results then surface next turn).
func (diagnostics *asyncDiagnostics) drain(ctx context.Context) string {
return diagnostics.drainWithin(ctx, asyncDiagnosticsDrainTimeout)
}
// drainFinal is drain with the finalization budget: called before the run's
// last model request, where an undelivered result would otherwise be lost.
func (diagnostics *asyncDiagnostics) drainFinal(ctx context.Context) string {
return diagnostics.drainWithin(ctx, asyncDiagnosticsFinalDrainTimeout)
}
func (diagnostics *asyncDiagnostics) drainWithin(ctx context.Context, timeout time.Duration) string {
if diagnostics == nil {
return ""
}
diagnostics.mu.Lock()
busy := diagnostics.working
diagnostics.mu.Unlock()
if busy != nil {
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-busy:
case <-timer.C:
return ""
case <-ctx.Done():
return ""
}
}
diagnostics.mu.Lock()
defer diagnostics.mu.Unlock()
if len(diagnostics.results) == 0 {
return ""
}
paths := make([]string, 0, len(diagnostics.results))
for path := range diagnostics.results {
paths = append(paths, path)
}
sort.Strings(paths)
blocks := make([]string, 0, len(paths))
for _, path := range paths {
blocks = append(blocks, diagnostics.results[path])
}
diagnostics.results = map[string]string{}
return asyncDiagnosticsNudge + strings.Join(blocks, "\n")
}
func (diagnostics *asyncDiagnostics) absPath(path string) string {
if filepath.IsAbs(path) {
return path
}
return filepath.Join(diagnostics.workspaceRoot, path)
}
+338
View File
@@ -0,0 +1,338 @@
package agent
import (
"context"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestAsyncDiagnosticsNilCollectorNoOps(t *testing.T) {
var diagnostics *asyncDiagnostics
if diagnostics != newAsyncDiagnostics(nil, "/ws") {
t.Fatal("nil check must yield a nil collector")
}
diagnostics.enqueue(context.Background(), []string{"a.go"})
if nudge := diagnostics.drain(context.Background()); nudge != "" {
t.Fatalf("nil collector drain = %q, want empty", nudge)
}
}
func TestAsyncDiagnosticsCollectsAndDrainsOnce(t *testing.T) {
// A real absolute root: a literal "/ws" is not filepath.IsAbs on Windows
// (no drive letter), which is exactly what the assertions below check.
root := t.TempDir()
var mu sync.Mutex
var checked []string
check := func(_ context.Context, absPath string) string {
mu.Lock()
checked = append(checked, absPath)
mu.Unlock()
if filepath.Base(absPath) == "clean.go" {
return ""
}
return "ERR " + filepath.Base(absPath)
}
diagnostics := newAsyncDiagnostics(check, root)
diagnostics.enqueue(context.Background(), []string{"broken.go", "clean.go"})
nudge := diagnostics.drain(context.Background())
if !strings.HasPrefix(nudge, asyncDiagnosticsNudge) {
t.Fatalf("nudge missing prefix: %q", nudge)
}
if !strings.Contains(nudge, "ERR broken.go") || strings.Contains(nudge, "clean.go") {
t.Fatalf("nudge = %q, want broken.go errors only", nudge)
}
mu.Lock()
for _, path := range checked {
if !filepath.IsAbs(path) || !strings.HasPrefix(path, root+string(filepath.Separator)) {
t.Fatalf("check received %q, want path under workspace root %q", path, root)
}
}
mu.Unlock()
if second := diagnostics.drain(context.Background()); second != "" {
t.Fatalf("second drain = %q, want empty (results are consumed)", second)
}
}
// A check still running at drain time defers delivery to the next drain
// instead of blocking the turn.
func TestAsyncDiagnosticsSlowCheckDefersToNextDrain(t *testing.T) {
previous := asyncDiagnosticsDrainTimeout
asyncDiagnosticsDrainTimeout = 20 * time.Millisecond
defer func() { asyncDiagnosticsDrainTimeout = previous }()
release := make(chan struct{})
check := func(_ context.Context, absPath string) string {
<-release
return "ERR " + filepath.Base(absPath)
}
diagnostics := newAsyncDiagnostics(check, "/ws")
diagnostics.enqueue(context.Background(), []string{"slow.go"})
start := time.Now()
if nudge := diagnostics.drain(context.Background()); nudge != "" {
t.Fatalf("drain during in-flight check = %q, want empty", nudge)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("drain blocked %v, want bounded by the (shortened) timeout", elapsed)
}
close(release)
deadline := time.Now().Add(2 * time.Second)
for {
if nudge := diagnostics.drain(context.Background()); nudge != "" {
if !strings.Contains(nudge, "ERR slow.go") {
t.Fatalf("deferred nudge = %q", nudge)
}
return
}
if time.Now().After(deadline) {
t.Fatal("deferred diagnostics never delivered")
}
time.Sleep(5 * time.Millisecond)
}
}
// Re-enqueueing a file after its check completed replaces the stale result.
func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
var mu sync.Mutex
response := "ERR old"
check := func(context.Context, string) string {
mu.Lock()
defer mu.Unlock()
return response
}
diagnostics := newAsyncDiagnostics(check, "/ws")
diagnostics.enqueue(context.Background(), []string{"a.go"})
waitForIdle(t, diagnostics)
mu.Lock()
response = "ERR new"
mu.Unlock()
diagnostics.enqueue(context.Background(), []string{"a.go"})
nudge := diagnostics.drain(context.Background())
if !strings.Contains(nudge, "ERR new") || strings.Contains(nudge, "ERR old") {
t.Fatalf("nudge = %q, want only the re-check result", nudge)
}
}
func waitForIdle(t *testing.T, diagnostics *asyncDiagnostics) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
diagnostics.mu.Lock()
busy := diagnostics.working
diagnostics.mu.Unlock()
if busy == nil {
return
}
if time.Now().After(deadline) {
t.Fatal("worker never went idle")
}
time.Sleep(2 * time.Millisecond)
}
}
// changedFilesTool is a fake mutating tool reporting a changed file without
// touching the filesystem.
type changedFilesTool struct{}
func (changedFilesTool) Name() string { return "fake_edit" }
func (changedFilesTool) Description() string { return "test mutating tool" }
func (changedFilesTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
}
func (changedFilesTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionAllow, Reason: "test"}
}
func (changedFilesTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "edited", ChangedFiles: []string{"main.go"}}
}
// End-to-end through Run: an edit turn does NOT carry diagnostics in the tool
// result (the old inline path), but the next request contains the background
// diagnostics nudge, before the model finalizes.
func TestRunDeliversAsyncDiagnosticsNudgeNextTurn(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(changedFilesTool{})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
root := t.TempDir()
var checkedPath string
var mu sync.Mutex
result, err := Run(context.Background(), "fix main.go", provider, Options{
Registry: registry,
Cwd: root,
FileDiagnostics: func(_ context.Context, absPath string) string {
mu.Lock()
checkedPath = absPath
mu.Unlock()
return "main.go:1:1 error: boom"
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected 2 provider turns, got %d", len(provider.requests))
}
// The tool result itself must NOT block on / embed diagnostics.
for _, message := range provider.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleTool && strings.Contains(message.Content, "boom") {
t.Fatalf("diagnostics leaked into the tool result: %q", message.Content)
}
}
// The nudge must be present in the second request as a user message.
found := false
for _, message := range provider.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
if !strings.Contains(message.Content, "boom") {
t.Fatalf("nudge missing diagnostics: %q", message.Content)
}
found = true
}
}
if !found {
t.Fatal("no async diagnostics nudge in the follow-up request")
}
mu.Lock()
defer mu.Unlock()
if want := filepath.Join(root, "main.go"); checkedPath != want {
t.Fatalf("checked path = %q, want %q", checkedPath, want)
}
}
// When the run hits the maxTurns ceiling right after an edit turn, the
// final-answer request must still carry the diagnostics nudge — otherwise an
// error introduced by the last edit would go unreported in the summary.
func TestRunDrainsAsyncDiagnosticsBeforeMaxTurnsFinalAnswer(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(changedFilesTool{})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "summary"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "fix main.go", provider, Options{
Registry: registry,
Cwd: t.TempDir(),
MaxTurns: 1,
FileDiagnostics: func(context.Context, string) string {
return "main.go:1:1 error: boom"
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "summary" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected the max-turns final-answer request, got %d requests", len(provider.requests))
}
found := false
for _, message := range provider.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
found = true
}
}
if !found {
t.Fatal("max-turns final-answer request missing the diagnostics nudge")
}
}
// When the model finalizes while a slow check is still in flight (the
// per-turn drain missed it), the finalization gate must wait it out and give
// the model one more turn with the nudge instead of dropping the errors.
func TestRunFinalizationGateDeliversLateDiagnostics(t *testing.T) {
previous := asyncDiagnosticsDrainTimeout
asyncDiagnosticsDrainTimeout = time.Millisecond
defer func() { asyncDiagnosticsDrainTimeout = previous }()
registry := tools.NewRegistry()
registry.Register(changedFilesTool{})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done after fix"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "fix main.go", provider, Options{
Registry: registry,
Cwd: t.TempDir(),
FileDiagnostics: func(context.Context, string) string {
// Slower than the (shortened) per-turn drain, well under the
// finalization budget.
time.Sleep(100 * time.Millisecond)
return "main.go:1:1 error: boom"
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done after fix" {
t.Fatalf("final answer = %q, want the post-nudge answer", result.FinalAnswer)
}
if len(provider.requests) != 3 {
t.Fatalf("expected a third request carrying the late nudge, got %d requests", len(provider.requests))
}
found := false
for _, message := range provider.requests[2].Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
if !strings.Contains(message.Content, "boom") {
t.Fatalf("nudge missing diagnostics: %q", message.Content)
}
found = true
}
}
if !found {
t.Fatal("finalization gate did not deliver the late diagnostics nudge")
}
}
+33 -16
View File
@@ -2,6 +2,7 @@ package agent
import (
"fmt"
"runtime"
"strings"
"github.com/Gitlawb/zero/internal/sandbox"
@@ -27,10 +28,20 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
}
return nil
}
for _, tokens := range segments {
if !knownSafeCommandSegment(tokens) && sandbox.ValidCommandPrefix(tokens) {
return append([]string(nil), tokens...)
// Only propose approving a prefix of one segment when every other segment
// in the command is independently known-safe. Once a prefix is approved,
// shellExecutionArgsForApproval escalates the whole command (every
// segment, not just the approved one) to bypass the sandbox, so offering a
// prefix that leaves an MSYS-prone (or otherwise unsafe) segment uncovered
// would let that segment run unsandboxed without ever being reviewed.
for index, tokens := range segments {
if knownSafeCommandSegment(tokens) {
continue
}
if !sandbox.ValidCommandPrefix(tokens) || !otherSegmentsKnownSafe(segments, index) {
return nil
}
return append([]string(nil), tokens...)
}
if len(segments) == 0 || !sandbox.ValidCommandPrefix(segments[0]) {
return nil
@@ -38,6 +49,20 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
return append([]string(nil), segments[0]...)
}
// otherSegmentsKnownSafe reports whether every segment other than the one at
// skip is known-safe on its own.
func otherSegmentsKnownSafe(segments [][]string, skip int) bool {
for index, tokens := range segments {
if index == skip {
continue
}
if !knownSafeCommandSegment(tokens) {
return false
}
}
return true
}
func matchCommandPrefix(toolName string, args map[string]any, options Options) (sandbox.CommandPrefixGrant, bool, bool) {
if !isShellCommandTool(toolName) || options.Sandbox == nil {
return sandbox.CommandPrefixGrant{}, false, false
@@ -168,10 +193,6 @@ func cleanPrefixRule(prefix []string) []string {
return cleaned
}
func safeRequestedPrefix(prefix []string, command []string) bool {
return safeRequestedPrefixForSegments(prefix, [][]string{command})
}
func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) {
return false
@@ -196,14 +217,6 @@ func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
return matched
}
func safeShellCommandTokens(command string) ([]string, bool) {
segments, ok := safeShellCommandSegments(command)
if !ok || len(segments) != 1 {
return nil, false
}
return segments[0], true
}
func safeShellCommandSegments(command string) ([][]string, bool) {
command = strings.TrimSpace(command)
if command == "" {
@@ -275,7 +288,11 @@ func knownSafeCommandSegment(command []string) bool {
if len(command) == 0 {
return false
}
switch commandName(command[0]) {
name := commandName(command[0])
if runtime.GOOS == "windows" && tools.MsysProneCommandName(name) {
return false
}
switch name {
case "cat", "cd", "cut", "echo", "expr", "false", "grep", "head", "id",
"ls", "nl", "paste", "pwd", "rev", "seq", "stat", "tail", "tr",
"true", "uname", "uniq", "wc", "which", "whoami":
+50 -1
View File
@@ -1,9 +1,11 @@
package agent
import (
"runtime"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
@@ -35,12 +37,37 @@ func TestProposedCommandPrefixHonorsValidatedRequestedPrefix(t *testing.T) {
func TestProposedCommandPrefixSupportsSegmentedCommands(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"})
if runtime.GOOS == "windows" {
// head is MSYS-prone on Windows (#458), so proposedCommandPrefix must
// not offer "ps aux" as a reusable prefix here: approving it would
// escalate the whole command, including the uncovered head segment,
// to bypass the sandbox unreviewed. See
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered for
// the platform-independent regression coverage of this behavior.
if got != nil {
t.Fatalf("expected no prefix on Windows because head is MSYS-prone, got %#v", got)
}
return
}
want := []string{"ps", "aux"}
if !equalStringSlices(got, want) {
t.Fatalf("prefix = %#v, want %#v", got, want)
}
}
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered guards
// against proposedCommandPrefix offering to approve one segment of a
// multi-segment command (e.g. "ps aux") while a different segment (e.g. "npm
// install") is not known-safe. shellExecutionArgsForApproval escalates the
// entire command once any prefix is approved, so an uncovered unsafe segment
// would bypass the sandbox unreviewed. Uses npm, which is never known-safe on
// any platform, so the assertion does not depend on runtime.GOOS.
func TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered(t *testing.T) {
if got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux && npm install"}); got != nil {
t.Fatalf("expected no prefix because npm segment is not known-safe, got %#v", got)
}
}
func TestProposedCommandPrefixHonorsRequestedPrefixAcrossSegments(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{
"command": "git status --short && git status --branch",
@@ -120,13 +147,35 @@ func TestProposedCommandPrefixRejectsUnsafeLaunchers(t *testing.T) {
func TestMatchCommandPrefixCoversSegmentedCommandWithSafeTail(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
// head is MSYS-prone on Windows (#458) and must not count as a known-safe tail.
command := "ps aux | head -5"
if runtime.GOOS == "windows" {
command = "ps aux | echo ok"
}
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"}, Options{Sandbox: engine})
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": command}, Options{Sandbox: engine})
if !ok || !session || !equalStringSlices(grant.Prefix, []string{"ps", "aux"}) {
t.Fatalf("match = %#v ok=%v session=%v, want session ps aux prefix", grant, ok, session)
}
}
func TestKnownSafeCommandSegmentRejectsMsysProneOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("windows-only known-safe MSYS guard")
}
for _, command := range [][]string{{"head", "-5"}, {"cat", "file.txt"}, {"grep", "pat"}} {
if knownSafeCommandSegment(command) {
t.Fatalf("expected %q to be unsafe on Windows, got known-safe", command)
}
}
if !knownSafeCommandSegment([]string{"echo", "ok"}) {
t.Fatal("expected echo to remain known-safe on Windows")
}
if !tools.MsysProneCommandName("head") {
t.Fatal("expected head to be MSYS-prone")
}
}
func TestMatchCommandPrefixRejectsUncoveredSegment(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
+62
View File
@@ -0,0 +1,62 @@
package agent
import (
"context"
"os"
"path/filepath"
"strings"
"time"
"github.com/Gitlawb/zero/internal/lsp"
)
// fileDiagnosticsTimeout bounds one inline post-edit diagnostics check so a
// slow or wedged language server can never hang a tool call; on timeout the
// edit simply reports without a diagnostics block.
const fileDiagnosticsTimeout = 10 * time.Second
// NewFileDiagnostics adapts an *lsp.Manager to the per-edit inline diagnostics
// callback (tools.RunOptions.Diagnostics): it reads the just-written file,
// checks it against the file's language server, and formats error-severity
// diagnostics for the model. Warnings and hints are excluded — nagging about
// style on every edit is noise, while a type error the edit just introduced is
// exactly what the model should see before its next step. Diagnostics are
// rendered with workspace-relative paths: the absolute path would put the
// local username/home directory into the model prompt and session transcript
// on every edit. Returns nil when manager is nil, disabling inline diagnostics
// entirely.
func NewFileDiagnostics(manager *lsp.Manager, workspaceRoot string) func(context.Context, string) string {
if manager == nil {
return nil
}
return func(ctx context.Context, absPath string) string {
text, err := os.ReadFile(absPath)
if err != nil {
return ""
}
checkCtx, cancel := context.WithTimeout(ctx, fileDiagnosticsTimeout)
defer cancel()
diagnostics, err := manager.Check(checkCtx, absPath, string(text))
if err != nil {
return ""
}
errors := lsp.FilterBySeverity(diagnostics, lsp.SeverityError)
if len(errors) == 0 {
return ""
}
return lsp.FormatDiagnostics(diagnosticsDisplayPath(workspaceRoot, absPath), errors)
}
}
// diagnosticsDisplayPath renders absPath relative to the workspace root for
// model-facing output, falling back to the file's base name when the path is
// outside the workspace (a bare name still identifies the file without
// exposing the directory layout).
func diagnosticsDisplayPath(workspaceRoot, absPath string) string {
if workspaceRoot != "" {
if rel, err := filepath.Rel(workspaceRoot, absPath); err == nil && !strings.HasPrefix(rel, "..") {
return rel
}
}
return filepath.Base(absPath)
}
+24
View File
@@ -0,0 +1,24 @@
package agent
import (
"path/filepath"
"testing"
)
// Diagnostics are model-facing: absolute paths would leak the local username
// and directory layout into the prompt and session transcript on every edit.
func TestDiagnosticsDisplayPath(t *testing.T) {
root := filepath.Join("/Users", "someone", "project")
cases := []struct {
root, abs, want string
}{
{root, filepath.Join(root, "internal", "a.go"), filepath.Join("internal", "a.go")},
{root, filepath.Join("/etc", "other.go"), "other.go"}, // outside root -> base name only
{"", filepath.Join("/home", "user", "x.go"), "x.go"}, // no root -> base name only
}
for _, c := range cases {
if got := diagnosticsDisplayPath(c.root, c.abs); got != c.want {
t.Errorf("diagnosticsDisplayPath(%q, %q) = %q, want %q", c.root, c.abs, got, c.want)
}
}
}
+2 -1
View File
@@ -429,8 +429,9 @@ func (state *guardState) observeToolResult(name string, failed bool, output stri
func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (stop bool) {
hasToolCalls := len(collected.ToolCalls) > 0
hasVisibleText := strings.TrimSpace(collected.Text) != ""
hasReasoning := collected.HasReasoning || len(collected.ReasoningBlocks) > 0
if hasToolCalls || hasVisibleText {
if hasToolCalls || hasVisibleText || hasReasoning {
state.emptyTurns = 0
} else {
state.emptyTurns++
+33
View File
@@ -22,6 +22,14 @@ func textTurn(content string) []zeroruntime.StreamEvent {
}
}
// reasoningTurn produces live reasoning without visible assistant text.
func reasoningTurn(content string) []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventReasoning, Content: content},
{Type: zeroruntime.StreamEventDone},
}
}
// toolTurn produces a turn that calls a named tool with the given args JSON.
func toolTurn(callID string, toolName string, args string) []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
@@ -101,6 +109,31 @@ func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
}
}
func TestRunResetsEmptyTurnCounterOnReasoning(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
reasoningTurn("thinking 1"),
reasoningTurn("thinking 2"),
reasoningTurn("thinking 3"),
textTurn("done"),
},
}
result, err := Run(context.Background(), "go", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 12,
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected reasoning-only turns to keep the run live until final answer, got %q", result.FinalAnswer)
}
if len(provider.requests) != 4 {
t.Fatalf("expected 4 turns, got %d", len(provider.requests))
}
}
func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
+84 -5
View File
@@ -137,6 +137,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
guards := newGuardState()
compactor := newCompactionState(options)
// Background post-edit diagnostics: files changed by mutating tools are
// checked off the tool-call critical path and any errors are appended as a
// nudge before the NEXT request (see async_diagnostics.go). nil when no
// FileDiagnostics callback is wired; every method no-ops on nil.
postEditDiagnostics := newAsyncDiagnostics(options.FileDiagnostics, options.Cwd)
// loaded tracks deferred-eligible tools the model has pulled via tool_search
// during THIS run. It is consulted by partitionTools each turn to expose a
// loaded tool's full schema; it lives only for the run (v1 within-run scope).
@@ -159,6 +165,17 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
for turn := 0; turn < maxTurns; turn++ {
result.Turns = turn + 1
// Deliver background post-edit diagnostics from the previous turn's edits
// BEFORE compaction so the nudge is part of the request being budgeted.
// A brief wait at most (asyncDiagnosticsDrainTimeout); an unfinished check
// simply delivers on a later turn.
if nudge := postEditDiagnostics.drain(ctx); nudge != "" {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: nudge,
})
}
// Build the per-turn tool list first so proactive compaction can include
// the tool-definition tokens (they ride on every request) in its estimate.
// partitionTools depends only on registry/permissions/options/loaded, not on
@@ -173,6 +190,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}
// Report the per-category context budget for this turn so a surface can
@@ -208,6 +226,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}
// Pre-content connect after a context-limit compaction: route through the
// reconnect helper so a transient upstream hiccup here doesn't fail the
@@ -268,6 +287,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}
retryStream, retryStreamErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
if retryStreamErr != nil {
@@ -326,6 +346,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
if retryErr != nil {
@@ -470,6 +491,19 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
continue
}
}
// Finalization diagnostics gate: edits from this run may still have
// checks in flight — the per-turn drain waits only briefly and defers
// to "a later turn", but a final answer means there is no later turn.
// Wait out the full inline-era budget once; an introduced error gives
// the model one more turn to see (and fix) it instead of being lost.
// Free for runs that never edited: an idle collector returns "".
if nudge := postEditDiagnostics.drainFinal(ctx); nudge != "" {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: nudge,
})
continue
}
result.FinalAnswer = collected.Text
result.Messages = copyMessages(messages)
return result, nil
@@ -495,11 +529,35 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// between tool_results breaks strict provider replay) — same after-batch
// rationale as turnRequestedModel above.
var changedFilesThisBatch []string
// Parallel read-ahead state: results for calls[precomputedStart:precomputedEnd]
// executed concurrently, consumed strictly in order below.
var precomputed []precomputedToolResult
precomputedStart, precomputedEnd := 0, 0
for index, call := range collected.ToolCalls {
// When this call starts a consecutive run of >= 2 auto-allowed read-only
// calls, execute the whole run concurrently now (see parallel_tools.go).
// The scan happens lazily at the run's first index — never ahead of a
// pending mutating call — so read-after-write ordering is preserved.
if index >= precomputedEnd {
runEnd := index
for runEnd < len(collected.ToolCalls) && parallelSafeToolCall(registry, collected.ToolCalls[runEnd], options) {
runEnd++
}
if runEnd-index >= 2 {
precomputed = executeParallelReadBatch(ctx, registry, collected.ToolCalls, index, runEnd, permissionMode, options)
precomputedStart, precomputedEnd = index, runEnd
}
}
if options.OnToolCall != nil {
options.OnToolCall(call)
}
toolResult, abortErr := executeToolCall(ctx, registry, call, permissionMode, options)
var toolResult ToolResult
var abortErr error
if index >= precomputedStart && index < precomputedEnd {
toolResult, abortErr = precomputed[index-precomputedStart].result, precomputed[index-precomputedStart].abortErr
} else {
toolResult, abortErr = executeToolCall(ctx, registry, call, permissionMode, options)
}
if options.OnToolResult != nil {
options.OnToolResult(toolResult)
}
@@ -562,11 +620,14 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.Output)
}
// Post-edit self-correction: collect the files this successful mutating
// tool changed; verification runs once over the union after the batch.
// A read-only tool (no ChangedFiles) never contributes.
if options.SelfCorrect != nil && toolResult.Status == tools.StatusOK && len(toolResult.ChangedFiles) > 0 {
// Collect the files this successful mutating tool changed. Self-correct
// verification runs once over the union after the batch; background
// diagnostics start NOW so the language server analyzes while the rest
// of the batch executes. A read-only tool (no ChangedFiles) never
// contributes.
if toolResult.Status == tools.StatusOK && len(toolResult.ChangedFiles) > 0 {
changedFilesThisBatch = append(changedFilesThisBatch, toolResult.ChangedFiles...)
postEditDiagnostics.enqueue(ctx, toolResult.ChangedFiles)
}
}
@@ -649,6 +710,17 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// mid-run, not a natural completion (a finished run returns via the no-tool-call
// success path before this). Under the headless completion gate that is INCOMPLETE,
// not success, so a run that loops to the turn limit isn't reported as done.
//
// The final-answer call is one more model request, so diagnostics from the
// LAST turn's edits get a drain too — with the finalization budget, since
// there is no later turn to defer to — otherwise an error introduced by
// the final edit would go unreported in the summary.
if nudge := postEditDiagnostics.drainFinal(ctx); nudge != "" {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: nudge,
})
}
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, options); strings.TrimSpace(answer) != "" {
result.FinalAnswer = answer
result.FinishReason = finishReason
@@ -681,6 +753,7 @@ func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages [
stream, err := streamWithReconnect(ctx, provider, zeroruntime.CompletionRequest{
Messages: copyMessages(finalMessages),
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}, reconnectNoticeFor(options))
if err != nil {
return "", messages, ""
@@ -1071,6 +1144,12 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
// Per-session file version tracker so write_file/edit_file refuse to clobber
// a file that changed on disk outside Zero since it was last read.
FileTracker: options.FileTracker,
// Post-edit diagnostics are NOT forwarded to the tools: they used to run
// synchronously inside edit_file/write_file and block every mutating tool
// call on the language server (≥300ms debounce floor, 10s cap). The loop
// now checks changed files in the background and nudges the model before
// its next request instead (see async_diagnostics.go). The tools' inline
// mechanism (tools.RunOptions.Diagnostics) stays for direct API callers.
// Forward the run's operator tool filters so a filter-aware tool
// (tool_search) never discloses or loads an operator-hidden deferred tool.
EnabledTools: options.EnabledTools,
+11 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
@@ -1945,6 +1946,11 @@ func TestRunCommandPrefixApprovalBypassesSandboxForMatchingShellCalls(t *testing
func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T) {
root := t.TempDir()
segmentedCommand := `ps aux | head -5`
if runtime.GOOS == "windows" {
// head is MSYS-prone on Windows (#458) and no longer counts as a known-safe tail.
segmentedCommand = `ps aux | echo ok`
}
retryTool := &sandboxDeniedRetryTool{}
registry := tools.NewRegistry()
registry.Register(retryTool)
@@ -1958,7 +1964,7 @@ func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T)
},
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"ps aux | head -5"}`},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: fmt.Sprintf(`{"command":%q}`, segmentedCommand)},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
{Type: zeroruntime.StreamEventDone},
},
@@ -2907,8 +2913,10 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
t.Fatalf("expected operating system in environment block, got %q", prompt)
}
if runtime.GOOS == "windows" {
if !strings.Contains(prompt, "Windows cmd.exe syntax") || !strings.Contains(prompt, "cwd argument") {
t.Fatalf("expected Windows cmd.exe shell guidance in prompt, got %q", prompt)
for _, want := range []string{"Windows cmd.exe syntax", "cwd argument", "MSYS binaries", "grep", "require_escalated"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected Windows shell guidance to mention %q, got %q", want, prompt)
}
}
} else if !strings.Contains(prompt, "/bin/sh syntax") {
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
+97
View File
@@ -0,0 +1,97 @@
package agent
import (
"context"
"sync"
"github.com/Gitlawb/zero/internal/tools"
)
// Parallel read-ahead for tool batches. When a turn requests several
// independent lookups (read_file + grep + glob is the common shape), executing
// them one after another serializes pure I/O waits. A consecutive run of
// auto-allowed read-only calls is executed concurrently instead; results are
// then consumed in the original call order, so guard counters, message
// ordering, abort semantics, and the surface's call/result event pairing are
// byte-identical to sequential execution. Runs never span a mutating call: a
// read that follows a write must observe the write, so eligibility is decided
// per consecutive run, not per batch.
// maxParallelReadTools bounds concurrent read-only tool executions in a turn.
const maxParallelReadTools = 8
// precomputedToolResult is one parallel read-ahead execution, keyed back to
// its batch index by the caller.
type precomputedToolResult struct {
result ToolResult
abortErr error
}
// parallelSafeToolCall reports whether call may run concurrently with its
// neighbors: the tool must exist, be side-effect-free (SideEffectRead), and be
// auto-allowed for these args, so no interactive prompt or workspace mutation
// is on the hot path. Loop-intercepted tools (ask_user, request_permissions)
// and tool_search (mutates the deferred-tool set) stay sequential.
func parallelSafeToolCall(registry *tools.Registry, call ToolCall, options Options) bool {
switch call.Name {
case "ask_user", tools.RequestPermissionsToolName, tools.ToolSearchToolName:
return false
}
tool, found := registry.Get(call.Name)
if !found || tool.Safety().SideEffect != tools.SideEffectRead {
return false
}
args := map[string]any{}
if call.Arguments != "" {
if err := decodeToolArguments(call.Arguments, &args); err != nil {
return false
}
}
return effectivePermission(tool, args) == tools.PermissionAllow
}
// executeParallelReadBatch runs calls[start:end] concurrently (bounded by
// maxParallelReadTools) and returns results indexed relative to start. All
// execution-side callbacks that can fire inside executeToolCall are serialized
// behind one mutex: a permission prompt (a sandbox preflight can demand one
// even for an auto-allowed read) must never appear twice at once on an
// interactive front-end, and OnPermission event handlers append to shared
// session-recording state without their own locking — two batched reads under
// a granted extra root would otherwise race (the pre-batch serial loop never
// had two callbacks in flight at once).
func executeParallelReadBatch(ctx context.Context, registry *tools.Registry, calls []ToolCall, start, end int, permissionMode PermissionMode, options Options) []precomputedToolResult {
batchOptions := options
var callbackMutex sync.Mutex
if options.OnPermissionRequest != nil {
inner := options.OnPermissionRequest
batchOptions.OnPermissionRequest = func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
callbackMutex.Lock()
defer callbackMutex.Unlock()
return inner(ctx, request)
}
}
if options.OnPermission != nil {
inner := options.OnPermission
batchOptions.OnPermission = func(event PermissionEvent) {
callbackMutex.Lock()
defer callbackMutex.Unlock()
inner(event)
}
}
results := make([]precomputedToolResult, end-start)
semaphore := make(chan struct{}, maxParallelReadTools)
var waitGroup sync.WaitGroup
for index := start; index < end; index++ {
waitGroup.Add(1)
go func(index int) {
defer waitGroup.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
result, abortErr := executeToolCall(ctx, registry, calls[index], permissionMode, batchOptions)
results[index-start] = precomputedToolResult{result: result, abortErr: abortErr}
}(index)
}
waitGroup.Wait()
return results
}
+204
View File
@@ -0,0 +1,204 @@
package agent
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// probeTool records execution overlap and ordering so tests can assert what
// actually ran concurrently.
type probeTool struct {
name string
sideEffect tools.SideEffect
delay time.Duration
// shared, when set, receives this probe's start/end entries too, so a test
// can observe ordering ACROSS probes (e.g. reads vs a write barrier).
shared *probeLog
mu sync.Mutex
active int
maxActive int
log []string
}
// probeLog is a mutex-guarded event log shared between probes.
type probeLog struct {
mu sync.Mutex
entries []string
}
func (log *probeLog) append(entry string) {
log.mu.Lock()
defer log.mu.Unlock()
log.entries = append(log.entries, entry)
}
func (log *probeLog) snapshot() []string {
log.mu.Lock()
defer log.mu.Unlock()
return append([]string(nil), log.entries...)
}
func (tool *probeTool) Name() string { return tool.name }
func (tool *probeTool) Description() string { return "test probe tool" }
func (tool *probeTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{"id": {Type: "string"}},
AdditionalProperties: false,
}
}
func (tool *probeTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tool.sideEffect, Permission: tools.PermissionAllow, Reason: "test"}
}
func (tool *probeTool) Run(_ context.Context, args map[string]any) tools.Result {
id, _ := args["id"].(string)
tool.mu.Lock()
tool.active++
if tool.active > tool.maxActive {
tool.maxActive = tool.active
}
tool.log = append(tool.log, "start:"+id)
tool.mu.Unlock()
if tool.shared != nil {
tool.shared.append("start:" + id)
}
time.Sleep(tool.delay)
if tool.shared != nil {
tool.shared.append("end:" + id)
}
tool.mu.Lock()
tool.active--
tool.log = append(tool.log, "end:"+id)
tool.mu.Unlock()
return tools.Result{Status: tools.StatusOK, Output: "probe " + id}
}
func probeCallEvents(callID, toolName, id string) []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: callID, ToolName: toolName},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: callID, ArgumentsFragment: fmt.Sprintf(`{"id":%q}`, id)},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: callID},
}
}
func TestParallelSafeToolCall(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(&probeTool{name: "probe_read", sideEffect: tools.SideEffectRead})
registry.Register(&probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite})
call := func(name, args string) ToolCall { return ToolCall{ID: "c", Name: name, Arguments: args} }
if !parallelSafeToolCall(registry, call("probe_read", `{"id":"a"}`), Options{}) {
t.Fatal("auto-allowed read tool must be parallel-safe")
}
if parallelSafeToolCall(registry, call("probe_write", `{"id":"a"}`), Options{}) {
t.Fatal("mutating tool must not be parallel-safe")
}
if parallelSafeToolCall(registry, call("unknown_tool", `{}`), Options{}) {
t.Fatal("unknown tool must not be parallel-safe")
}
if parallelSafeToolCall(registry, call("probe_read", `{"id":`), Options{}) {
t.Fatal("undecodable arguments must not be parallel-safe")
}
if parallelSafeToolCall(registry, call("ask_user", `{}`), Options{}) {
t.Fatal("loop-intercepted tools must stay sequential")
}
}
func TestRunExecutesConsecutiveReadsConcurrently(t *testing.T) {
probe := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 60 * time.Millisecond}
registry := tools.NewRegistry()
registry.Register(probe)
turnOne := append(probeCallEvents("call-1", "probe_read", "a"), probeCallEvents("call-2", "probe_read", "b")...)
turnOne = append(turnOne, probeCallEvents("call-3", "probe_read", "c")...)
turnOne = append(turnOne, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
turnOne,
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
},
}
var results []ToolResult
_, err := Run(context.Background(), "probe", provider, Options{
Registry: registry,
OnToolResult: func(result ToolResult) { results = append(results, result) },
})
if err != nil {
t.Fatal(err)
}
if probe.maxActive < 2 {
t.Fatalf("consecutive read-only calls must overlap, max concurrency was %d", probe.maxActive)
}
// Results must still be recorded in original call order.
if len(results) != 3 || results[0].ToolCallID != "call-1" || results[1].ToolCallID != "call-2" || results[2].ToolCallID != "call-3" {
t.Fatalf("tool results out of order: %#v", results)
}
messages := provider.requests[1].Messages
var toolOrder []string
for _, message := range messages {
if message.Role == zeroruntime.MessageRoleTool {
toolOrder = append(toolOrder, message.ToolCallID)
}
}
if len(toolOrder) != 3 || toolOrder[0] != "call-1" || toolOrder[1] != "call-2" || toolOrder[2] != "call-3" {
t.Fatalf("recorded tool messages out of order: %v", toolOrder)
}
}
func TestRunParallelReadsNeverSpanMutatingCall(t *testing.T) {
shared := &probeLog{}
read := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 30 * time.Millisecond, shared: shared}
write := &probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite, shared: shared}
registry := tools.NewRegistry()
registry.Register(read)
registry.Register(write)
turnOne := append(probeCallEvents("call-1", "probe_read", "r1"), probeCallEvents("call-2", "probe_read", "r2")...)
turnOne = append(turnOne, probeCallEvents("call-3", "probe_write", "w")...)
turnOne = append(turnOne, probeCallEvents("call-4", "probe_read", "r3")...)
turnOne = append(turnOne, probeCallEvents("call-5", "probe_read", "r4")...)
turnOne = append(turnOne, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
turnOne,
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
},
}
_, err := Run(context.Background(), "probe", provider, Options{Registry: registry})
if err != nil {
t.Fatal(err)
}
// Cross-probe ordering on the SHARED log: the write must start only after
// both first-batch reads finished, and both second-batch reads must start
// only after the write finished — batches never cross a mutating call.
log := shared.snapshot()
index := func(entry string) int {
for i, e := range log {
if e == entry {
return i
}
}
t.Fatalf("entry %q missing from shared log: %v", entry, log)
return -1
}
writeStart, writeEnd := index("start:w"), index("end:w")
if firstBatchMaxEnd := max(index("end:r1"), index("end:r2")); writeStart < firstBatchMaxEnd {
t.Fatalf("write started before the first read batch finished: %v", log)
}
if secondBatchMinStart := min(index("start:r3"), index("start:r4")); secondBatchMinStart < writeEnd {
t.Fatalf("second read batch started before the write finished: %v", log)
}
if read.maxActive < 2 {
t.Fatalf("reads within a batch must overlap, max concurrency was %d", read.maxActive)
}
}
@@ -34,7 +34,7 @@ func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
t.Fatalf("expected read_file, tool_search and loaded alpha all exposed, got %#v", exposed)
}
// The eager tools must precede the loaded deferred tool (appended, not inserted).
if !(readPos < alphaPos && searchPos < alphaPos) {
if readPos >= alphaPos || searchPos >= alphaPos {
t.Fatalf("loaded deferred tool must be appended after the eager block; got read=%d search=%d alpha=%d", readPos, searchPos, alphaPos)
}
// beta was never loaded — it stays hidden.
+30 -5
View File
@@ -7,14 +7,18 @@ import (
)
func TestSkillsContextCapsLongList(t *testing.T) {
skills := make([]SkillInfo, 0, 40)
for i := 0; i < 40; i++ {
// 60 skills with realistic trigger-rich descriptions (~180 chars each, under
// the 200-rune truncation) total well past the 4096-byte list budget, so the
// overflow summary must kick in while the block stays bounded.
longDesc := strings.Repeat("use when the request touches deployments, release notes, or verification; ", 2) + "number "
skills := make([]SkillInfo, 0, 60)
for i := 0; i < 60; i++ {
n := strconv.Itoa(i)
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: "does something useful, number " + n})
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: longDesc + n})
}
got := skillsContext(Options{Skills: skills})
if len(got) > 1200 {
t.Fatalf("skills block should stay bounded, got %d bytes:\n%s", len(got), got)
if len(got) > skillsContextListBudget+1000 {
t.Fatalf("skills block should stay bounded near the %d-byte budget, got %d bytes:\n%s", skillsContextListBudget, len(got), got)
}
if !strings.Contains(got, "more (call skill") {
t.Fatalf("expected an overflow summary line, got:\n%s", got)
@@ -25,6 +29,27 @@ func TestSkillsContextCapsLongList(t *testing.T) {
}
}
// A realistic mid-size skill set (20 skills, trigger-rich descriptions) must be
// listed in FULL — no overflow summary. This pins the fix for the old 640-byte
// budget, under which skills past ~#6 were invisible to the model and therefore
// never triggered.
func TestSkillsContextListsRealisticSetInFull(t *testing.T) {
desc := "Use when the user asks about deployments, release notes, or pre-merge verification runs."
skills := make([]SkillInfo, 0, 20)
for i := 0; i < 20; i++ {
skills = append(skills, SkillInfo{Name: "skill-" + strconv.Itoa(i), Description: desc})
}
got := skillsContext(Options{Skills: skills})
if strings.Contains(got, "more (call skill") {
t.Fatalf("20 described skills must all be listed without overflow, got:\n%s", got)
}
for i := 0; i < 20; i++ {
if !strings.Contains(got, "- skill-"+strconv.Itoa(i)+":") {
t.Fatalf("skill-%d missing from the list:\n%s", i, got)
}
}
}
func TestSkillsContext(t *testing.T) {
if got := skillsContext(Options{}); got != "" {
t.Fatalf("no skills should yield an empty section, got %q", got)
+127 -27
View File
@@ -9,6 +9,7 @@ import (
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/repomap"
"github.com/Gitlawb/zero/internal/workspaceseed"
)
@@ -38,6 +39,14 @@ const fallbackSystemPrompt = "You are Zero, a terminal coding agent. Help with t
// general-to-specific order.
var projectContextFiles = []string{"AGENTS.md", "ZERO.md", ".zero/AGENTS.md"}
// userContextFile is the per-user instruction file, resolved under
// config.UserConfigDir()/zero/ alongside the rest of Zero's per-user config
// (config.json, commands, specialists) so users can keep personal guidance out
// of individual repositories.
const userContextFile = "ZERO.md"
var userConfigDirForPrompt = config.UserConfigDir
// maxProjectContextBytes caps how much of a single project doc is injected so
// a large guidelines file can't blow the context budget.
const maxProjectContextBytes = 8 << 10 // 8 KiB per file
@@ -81,6 +90,13 @@ func buildSystemPrompt(options Options) string {
if seed := workspaceSeedContext(options.Cwd); seed != "" {
sections = append(sections, seed)
}
// User guidelines are injected before workspace/project guidelines so the
// project's AGENTS.md/ZERO.md is the later, more specific instruction
// block. See userGuidelines for the explicit precedence note carried in
// the section text itself.
if user := userGuidelines(); user != "" {
sections = append(sections, user)
}
if ws := workspaceContext(options.Cwd); ws != "" {
sections = append(sections, ws)
}
@@ -175,10 +191,19 @@ func specialistDelegationContext(options Options) string {
// when no skills are installed, so a skill-less run reproduces the previous prompt
// byte-for-byte.
// skillsContextListBudget bounds the bytes spent listing individual skills so a
// workspace with dozens of them can't bloat every turn's prompt. Skills past the
// budget are summarized as a count rather than dropped — the model can still load
// any of them by name (an unknown name returns the full list from the skill tool).
const skillsContextListBudget = 640
// workspace with an extreme number of them can't bloat every turn's prompt.
// Skills past the budget are summarized as a count rather than dropped — the
// model can still load any of them by name (an unknown name returns the full
// list from the skill tool).
//
// The budget is deliberately generous — roughly 18 skills with maximally long
// (200-rune, ASCII) descriptions, or 40+ with typical terser ones; non-ASCII
// descriptions consume it faster since the budget counts bytes. This list is
// the model's ONLY discovery surface, so a skill squeezed out of it effectively
// never triggers. The previous 640-byte cap silently broke invocation for
// anyone with more than ~6 skills — the model cannot match a request against a
// skill it cannot see.
const skillsContextListBudget = 4096
func skillsContext(options Options) string {
if len(options.Skills) == 0 {
@@ -186,7 +211,7 @@ func skillsContext(options Options) string {
}
var b strings.Builder
b.WriteString("<available_skills>\n")
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. When a request matches one, call skill with its exact name to pull in its guidance before acting — do not guess names or skip a relevant skill.\n")
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. Before acting on a request, scan this list; when the request matches a skill's name or description, call skill with its exact name FIRST and follow the loaded guidance — do not guess names, do not skip a matching skill, and do not substitute your own approach for its instructions.\n")
listed, spent, omitted := 0, 0, 0
for _, info := range options.Skills {
name := strings.TrimSpace(info.Name)
@@ -216,9 +241,11 @@ func skillsContext(options Options) string {
}
// truncateForSkillLine keeps a skill's one-line description short so a single
// verbose description can't dominate the skills-list budget.
// verbose description can't dominate the skills-list budget. The cap is roomy on
// purpose: the description carries the skill's trigger conditions ("use when…"),
// and truncating those is what stops the model from ever invoking the skill.
func truncateForSkillLine(desc string) string {
const maxDescRunes = 100
const maxDescRunes = 200
runes := []rune(desc)
if len(runes) <= maxDescRunes {
return desc
@@ -259,7 +286,7 @@ func workspaceContext(cwd string) string {
b.WriteString("Working directory: " + cwd + "\n")
b.WriteString("Operating system: " + runtime.GOOS + "\n")
if runtime.GOOS == "windows" {
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories. Do not pipe to or invoke POSIX coreutils from Git for Windows (usr\\bin head/grep/tail/cat/...): they are MSYS binaries and fail under the write-restricted sandbox; use native Zero tools (grep, read_file, list_directory, glob) or cmd.exe findstr/more instead, or sandbox_permissions require_escalated only when host-level execution is truly required.\n")
} else {
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
}
@@ -312,13 +339,7 @@ func projectGuidelines(cwd, gitRoot string) string {
if remaining := maxProjectContextTotalBytes - totalUsed; remaining < limit {
limit = remaining
}
if len(content) > limit {
cut := limit
for cut > 0 && !utf8.RuneStart(content[cut]) {
cut--
}
content = content[:cut] + "\n… (truncated)"
}
content = truncateGuidelineContent(content, limit)
label := projectGuidelineLabel(match, gitRoot)
b.WriteString("\n\n## Project guidelines (" + label + ")\n\n" + content)
totalUsed += len(content)
@@ -326,6 +347,92 @@ func projectGuidelines(cwd, gitRoot string) string {
return b.String()
}
// userGuidelines returns the per-user ZERO.md instructions block, if present.
// The file lives in config.UserConfigDir()/zero/ next to Zero's other
// per-user config; the basename match is case-insensitive so a file saved as
// zero.md still resolves on case-sensitive filesystems, mirroring the project
// guideline loader. The section carries an explicit precedence note because
// this is a global, personal preferences file: it is injected earlier in the
// prompt than the project's AGENTS.md/ZERO.md (see buildSystemPrompt), and
// the note keeps that precedence unambiguous even if a model weighs later
// context more heavily than section order alone implies.
func userGuidelines() string {
configDir, err := userConfigDirForPrompt()
if err != nil {
return ""
}
configDir = strings.TrimSpace(configDir)
if configDir == "" {
return ""
}
match := findCaseInsensitiveFile(filepath.Join(configDir, "zero"), userContextFile)
if match == "" {
return ""
}
data, err := os.ReadFile(match)
if err != nil {
return ""
}
content := strings.TrimSpace(string(data))
if content == "" {
return ""
}
content = truncateGuidelineContent(content, maxProjectContextBytes)
return "## User guidelines (" + filepath.Base(match) + ")\n\n" +
"These are the operator's personal preferences, not project policy. " +
"Where they conflict with a repository's project guidelines below (AGENTS.md/ZERO.md), the project guidelines take precedence.\n\n" +
content
}
// truncationMarker is appended to guideline content that was cut short.
const truncationMarker = "\n… (truncated)"
// truncateGuidelineContent caps content at limit bytes without splitting a
// UTF-8 rune, appending a truncation marker when anything was cut. Space for
// the marker is reserved before choosing the cut point. When limit is too
// small to fit the marker itself, the marker is dropped instead, so the
// returned string never exceeds limit for any non-negative limit.
func truncateGuidelineContent(content string, limit int) string {
if len(content) <= limit {
return content
}
if limit <= 0 {
return ""
}
cut := limit - len(truncationMarker)
if cut < 0 {
cut = 0
}
for cut > 0 && !utf8.RuneStart(content[cut]) {
cut--
}
if truncated := content[:cut] + truncationMarker; len(truncated) <= limit {
return truncated
}
// limit is smaller than the marker itself: fall back to a hard,
// rune-safe cut at limit bytes with no marker rather than exceeding it.
end := limit
for end > 0 && !utf8.RuneStart(content[end]) {
end--
}
return content[:end]
}
// findCaseInsensitiveFile returns the on-disk path of the regular file in dir
// whose name matches basename case-insensitively, or "" when absent.
func findCaseInsensitiveFile(dir, basename string) string {
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
for _, e := range entries {
if !e.IsDir() && strings.EqualFold(e.Name(), basename) {
return filepath.Join(dir, e.Name())
}
}
return ""
}
// projectGuidelineDirs returns the directory chain from gitRoot to cwd
// (inclusive), in root-to-leaf order. If gitRoot is empty or unreachable
// from cwd, the chain collapses to [cwd].
@@ -378,23 +485,16 @@ func projectGuidelineLabel(match, gitRoot string) string {
// Returns "" when nothing matches.
func findProjectContextFile(dir string) string {
for _, name := range projectContextFiles {
baseLower := strings.ToLower(filepath.Base(name))
// Walk to the file's parent through dir with case-insensitive segment
// matching, then find a regular file with the same lowercased
// basename. This works on both case-sensitive and case-insensitive
// filesystems and always returns the on-disk filename.
// matching, then find a regular file with the same basename. This works
// on both case-sensitive and case-insensitive filesystems and always
// returns the on-disk filename.
parent, ok := resolveDirCaseInsensitive(filepath.Dir(filepath.Join(dir, name)), dir)
if !ok {
continue
}
entries, err := os.ReadDir(parent)
if err != nil {
continue
}
for _, e := range entries {
if !e.IsDir() && strings.ToLower(e.Name()) == baseLower {
return filepath.Join(parent, e.Name())
}
if match := findCaseInsensitiveFile(parent, filepath.Base(name)); match != "" {
return match
}
}
return ""
+4
View File
@@ -63,6 +63,10 @@ work.
targeted diffs. Match the existing indentation, imports, and idioms. Match the
file's comment density: do not add explanatory comments unless the user asks or
the code is already comment-dense.
- Solve the problem as posed, not a more general version of it. Add no
speculative abstraction, configurability, or handling for cases that cannot
occur, and nothing the user did not ask for. A small diff can still be
over-built; if a 200-line solution could be 50, rewrite it.
- Preserve behavior you were not asked to change. Do not delete or rewrite code
you did not author unless the task requires it; if you must, say so.
+125
View File
@@ -9,6 +9,25 @@ import (
"github.com/Gitlawb/zero/internal/sandbox"
)
// TestMain points userConfigDirForPrompt at an empty, package-wide temp
// directory for every test in this package. Without this, buildSystemPrompt
// falls back to the real config.UserConfigDir, so any developer with a
// personal ~/.config/zero/ZERO.md would get its content folded into
// otherwise-unrelated prompt assertions, making test runs non-deterministic
// across contributor machines. Tests that specifically exercise user
// guidelines stub userConfigDirForPrompt themselves via
// withSystemPromptTestUserConfigDir(Func) and restore this default on cleanup.
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "zero-agent-tests-*")
if err != nil {
panic(err)
}
userConfigDirForPrompt = func() (string, error) { return dir, nil }
code := m.Run()
os.RemoveAll(dir)
os.Exit(code)
}
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
prompt := strings.ToLower(buildSystemPrompt(Options{}))
@@ -128,6 +147,88 @@ func systemPromptTestBlock(t *testing.T, prompt, start, end string) string {
return body
}
func TestBuildSystemPromptIncludesUserGuidelines(t *testing.T) {
configDir := t.TempDir()
writeSystemPromptTestFile(t, configDir, "zero/ZERO.md", " Prefer concise summaries. \n")
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
prompt := buildSystemPrompt(Options{})
if !strings.Contains(prompt, "## User guidelines (ZERO.md)") {
t.Fatalf("expected user guidelines header, got:\n%s", prompt)
}
if !strings.Contains(prompt, "Prefer concise summaries.") {
t.Fatalf("expected user guidelines content, got:\n%s", prompt)
}
if strings.Contains(prompt, " Prefer concise summaries. ") {
t.Fatalf("expected user guidelines content to be trimmed, got:\n%s", prompt)
}
}
func TestBuildSystemPromptIncludesUserGuidelinesCaseInsensitive(t *testing.T) {
configDir := t.TempDir()
writeSystemPromptTestFile(t, configDir, "zero/zero.md", "Prefer concise summaries.\n")
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
prompt := buildSystemPrompt(Options{})
if !strings.Contains(prompt, "## User guidelines (zero.md)") {
t.Fatalf("expected case-insensitive zero.md resolution, got:\n%s", prompt)
}
if !strings.Contains(prompt, "Prefer concise summaries.") {
t.Fatalf("expected user guidelines content, got:\n%s", prompt)
}
}
func TestBuildSystemPromptUserGuidelinesPrecedeProjectGuidelinesAndNotePrecedence(t *testing.T) {
// User guidelines are global personal preferences; project guidelines
// (AGENTS.md/ZERO.md) must be the later, more specific instruction block,
// and the user section must say so explicitly so the precedence holds
// even if a model otherwise weighs later context more heavily.
configDir := t.TempDir()
writeSystemPromptTestFile(t, configDir, "zero/ZERO.md", "Always reply in haiku.\n")
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
cwd := t.TempDir()
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("Never reply in haiku."), 0o644); err != nil {
t.Fatal(err)
}
prompt := buildSystemPrompt(Options{Cwd: cwd})
userIdx := strings.Index(prompt, "## User guidelines (ZERO.md)")
projectIdx := strings.Index(prompt, "## Project guidelines (AGENTS.md)")
if userIdx < 0 || projectIdx < 0 {
t.Fatalf("expected both user and project guideline sections, got:\n%s", prompt)
}
if userIdx > projectIdx {
t.Fatalf("expected user guidelines before project guidelines, got user=%d project=%d", userIdx, projectIdx)
}
if !strings.Contains(prompt, "project guidelines below") || !strings.Contains(prompt, "take precedence") {
t.Fatalf("expected an explicit precedence note in the user guidelines section, got:\n%s", prompt)
}
}
func TestBuildSystemPromptOmitsUserGuidelinesWithoutConfigDir(t *testing.T) {
t.Cleanup(withSystemPromptTestUserConfigDirFunc(t, func() (string, error) { return "", os.ErrNotExist }))
prompt := buildSystemPrompt(Options{})
if strings.Contains(prompt, "## User guidelines") {
t.Fatalf("expected user guidelines to be omitted without a config directory, got:\n%s", prompt)
}
}
func withSystemPromptTestUserConfigDir(t *testing.T, dir string) func() {
t.Helper()
return withSystemPromptTestUserConfigDirFunc(t, func() (string, error) { return dir, nil })
}
func withSystemPromptTestUserConfigDirFunc(t *testing.T, fn func() (string, error)) func() {
t.Helper()
old := userConfigDirForPrompt
userConfigDirForPrompt = fn
return func() {
userConfigDirForPrompt = old
}
}
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
// Git tracks AGENTS.md on a case-sensitive filesystem; the
// loader must still resolve it when the cwd lookup uses lowercase.
@@ -257,6 +358,30 @@ func TestBuildSystemPromptProjectGuidelinesTruncatesAtTotalCap(t *testing.T) {
}
}
func TestTruncateGuidelineContentStaysWithinLimit(t *testing.T) {
limit := 64
content := strings.Repeat("x", limit+1)
got := truncateGuidelineContent(content, limit)
if len(got) > limit {
t.Fatalf("truncated result is %d bytes, want at most %d", len(got), limit)
}
if !strings.Contains(got, "truncated") {
t.Fatalf("expected truncation marker, got %q", got)
}
}
func TestTruncateGuidelineContentStaysWithinLimitForSmallBudgets(t *testing.T) {
// Limits at or below the truncation marker's own length can't fit the
// marker at all; the helper must still never exceed the requested limit.
content := strings.Repeat("x", 100)
for _, limit := range []int{0, 1, len(truncationMarker) - 1, len(truncationMarker), len(truncationMarker) + 1} {
got := truncateGuidelineContent(content, limit)
if len(got) > limit {
t.Fatalf("truncateGuidelineContent(_, %d) = %q (%d bytes), want at most %d bytes", limit, got, len(got), limit)
}
}
}
func TestProjectGuidelineDirsOrdersRootToLeaf(t *testing.T) {
root := filepath.Join("r")
leaf := filepath.Join(root, "a", "b")
+8
View File
@@ -285,6 +285,14 @@ type Options struct {
// ceiling and the autonomy gate. nil disables it entirely (the loop is
// byte-identical to before). One instance per run — it holds attempt state.
SelfCorrect *SelfCorrector
// FileDiagnostics, when set, checks files changed by mutating tools for
// error-severity language diagnostics IN THE BACKGROUND and appends any
// errors as a nudge before the model's next request — the model still sees
// an error it introduced at its next decision point, but no tool call ever
// blocks on the language server (the old inline path stalled every edit on
// a ≥300ms debounce, 10s cap). Build one with NewFileDiagnostics. nil
// disables post-edit diagnostics.
FileDiagnostics func(ctx context.Context, absPath string) string
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
// any assistant turn that produces text but no tool call is accepted as the
+54
View File
@@ -12,6 +12,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/Gitlawb/zero/internal/agent"
@@ -19,6 +20,7 @@ import (
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/localcontrol"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/observability"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/providerhealth"
@@ -77,6 +79,8 @@ type appDeps struct {
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
pushChanges func(context.Context, zerogit.PushOptions) (zerogit.PushResult, error)
createPR func(context.Context, zerogit.PROptions) (zerogit.PRResult, error)
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
@@ -178,6 +182,8 @@ func defaultAppDeps() appDeps {
runAgentEval: defaultRunAgentEval,
inspectChanges: zerogit.Inspect,
commitChanges: zerogit.Commit,
pushChanges: zerogit.Push,
createPR: zerogit.CreatePR,
runTUI: tui.Run,
runEditor: openEditor,
checkUpdate: update.Check,
@@ -229,6 +235,12 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
defer observability.Recover(observability.DefaultCrashDir(), "cli", stderr, &exitCode)
deps = fillAppDeps(deps)
// CLI runs opt into the models.dev overlay (cached live context limits and
// pricing on top of the curated catalog). Explicitly enabled here — and only
// here — so library consumers and hermetic tests are never perturbed by a
// cache file on the machine. The refresh itself is fired in exec/TUI startup.
modelregistry.EnableModelsDevOverlay()
addDirs, args, err := splitLeadingAddDirFlags(args)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
@@ -510,6 +522,12 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.commitChanges == nil {
deps.commitChanges = defaults.commitChanges
}
if deps.pushChanges == nil {
deps.pushChanges = defaults.pushChanges
}
if deps.createPR == nil {
deps.createPR = defaults.createPR
}
if deps.runTUI == nil {
deps.runTUI = defaults.runTUI
}
@@ -544,6 +562,11 @@ func runInteractiveTUI(stderr io.Writer, deps appDeps, permissionMode agent.Perm
}
func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string, forceSetup bool) int {
// Refresh the models.dev pricing/limits cache in the background when stale;
// the overlay is read at registry construction from the cache file, so this
// benefits the next run and never blocks or fails this one.
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
workspaceRoot, err := deps.getwd()
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), 1)
@@ -760,6 +783,14 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
Specialists: specialistRuntime.specialists,
Skills: pluginActivation.skillInfos(deps.skillsDir()),
},
// LoadSkills backs /skills and direct /<skill-name> invocation in the TUI.
// It resolves against the same merged set (default dir + plugin skill
// roots) as the skill tool and the system-prompt list, re-read per use so
// newly installed skills work without a restart.
LoadSkills: cachedSkillsLoader(func() []skills.Skill {
merged, _ := plugins.MergedSkillsLoaded(deps.skillsDir(), pluginActivation.skillRoots)
return merged
}),
PermissionMode: permissionMode,
Notify: resolved.Notify,
KeyBindings: resolved.KeyBindings,
@@ -1255,3 +1286,26 @@ Flags:
`)
return err
}
// cachedSkillsLoader memoizes a skills loader for a short interval. The TUI
// calls the loader from autocomplete on every "/x" keystroke; without a cache
// each keystroke would re-read every SKILL.md body. Two seconds is fresh enough
// that a newly installed skill still shows up "immediately" while typing.
func cachedSkillsLoader(load func() []skills.Skill) func() []skills.Skill {
var (
mu sync.Mutex
at time.Time
cached []skills.Skill
)
const ttl = 2 * time.Second
return func() []skills.Skill {
mu.Lock()
defer mu.Unlock()
if cached != nil && time.Since(at) < ttl {
return cached
}
cached = load()
at = time.Now()
return cached
}
}
+27 -13
View File
@@ -136,6 +136,11 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
return exitSuccess
}
// Refresh the models.dev pricing/limits cache in the background when stale;
// the overlay is read at registry construction from the cache file, so this
// benefits the next run and never blocks or fails this one.
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
// A mode seeds model/effort/max-turns/tool filters as a preset. Expand it up
// front — before tool-filter validation and the --list-tools branch — so a
// mode-injected tool filter is validated and reflected in --list-tools, and a
@@ -500,9 +505,10 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// by default the corrector is nil, leaving the agent loop byte-identical. When
// on we verify with both the workspace test plan and LSP diagnostics over the
// changed files; the autonomy gate inside the corrector still decides whether
// failures auto-fix or just report. lspShutdown tears down any language-server
// sessions the LSP half spawned (no-op when self-correct is off).
selfCorrector, lspShutdown := newExecSelfCorrector(options.selfCorrect, workspaceRoot, options.autonomy)
// failures auto-fix or just report. fileDiagnostics (always on, lazy) gives
// edit_file/write_file inline error diagnostics for the file they just wrote.
// lspShutdown tears down any language-server sessions either half spawned.
selfCorrector, fileDiagnostics, lspShutdown := newExecSelfCorrector(options.selfCorrect, workspaceRoot, options.autonomy)
defer lspShutdown()
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
MaxTurns: resolved.MaxTurns,
@@ -526,6 +532,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
PermissionMode: permissionMode,
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
FileDiagnostics: fileDiagnostics,
// Headless exec: don't accept a no-tool-call turn as "done" while work
// clearly remains (pending plan items / a mid-step continuation cue) —
// nudge to continue, and finalize as INCOMPLETE rather than false success
@@ -537,6 +544,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
EnabledTools: options.enabledTools,
DisabledTools: options.disabledTools,
OnText: writer.text,
OnReasoning: writer.reasoning,
OnToolCall: func(call agent.ToolCall) {
writer.toolCall(call, registry)
sessionRecorder.append(sessions.EventToolCall, map[string]any{
@@ -701,23 +709,29 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// unless a changed file's language actually has one installed on PATH. The
// returned cleanup shuts that manager down (terminating any spawned server
// sessions); it is a no-op when self-correct is off.
func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (*agent.SelfCorrector, func()) {
if !enabled {
return nil, func() {}
}
func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (*agent.SelfCorrector, func(context.Context, string) string, func()) {
// The manager is created regardless of --self-correct: it also backs the
// always-on background post-edit diagnostics (agent.NewFileDiagnostics,
// collected off the tool-call path by the loop), and it stays lazy — no
// language server is spawned unless an edited file's language actually has
// one installed on PATH.
manager := lsp.NewManager(workspaceRoot)
cleanup := func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = manager.Shutdown(ctx)
}
fileDiagnostics := agent.NewFileDiagnostics(manager, workspaceRoot)
if !enabled {
return nil, fileDiagnostics, cleanup
}
corrector := agent.NewSelfCorrector(workspaceRoot, agent.NewLSPDiagnosticsChecker(manager), agent.NewProjectVerifier(workspaceRoot), agent.SelfCorrectConfig{
Enabled: true,
IncludeTests: true,
IncludeLSP: true,
Autonomy: autonomy,
})
cleanup := func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = manager.Shutdown(ctx)
}
return corrector, cleanup
return corrector, fileDiagnostics, cleanup
}
func deferredEligibleCount(registry *tools.Registry, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) int {
+59 -3
View File
@@ -529,6 +529,45 @@ func TestRunExecStreamJSONRunStartUsesResolvedAPIModel(t *testing.T) {
}
}
func TestRunExecStreamJSONEmitsReasoningEvents(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
cwd := t.TempDir()
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--output-format", "stream-json", "think then answer"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return reasoningExecProvider{}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
events := decodeJSONLines(t, stdout.String())
reasoningIdx, reasoning := findJSONEventIndex(t, events, "reasoning")
if reasoning["delta"] != "Thinking. " {
t.Fatalf("unexpected reasoning event: %#v", reasoning)
}
textIdx, text := findJSONEventIndex(t, events, "text")
if text["delta"] != "done" {
t.Fatalf("unexpected text event: %#v", text)
}
if reasoningIdx >= textIdx {
t.Fatalf("expected reasoning event before text event, got indices %d and %d", reasoningIdx, textIdx)
}
final := findJSONEvent(t, events, "final")
if final["text"] != "done" {
t.Fatalf("reasoning must not be folded into final answer: %#v", final)
}
}
func TestExecEventWriterTruncatesStreamJSONToolResults(t *testing.T) {
var stdout bytes.Buffer
writer := execEventWriter{
@@ -751,6 +790,17 @@ type toolCallingExecProvider struct {
answer string
}
type reasoningExecProvider struct{}
func (reasoningExecProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
ch := make(chan zeroruntime.StreamEvent, 3)
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventReasoning, Content: "Thinking. "}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
func (provider toolCallingExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
for _, message := range request.Messages {
if message.Role == zeroruntime.MessageRoleTool {
@@ -777,13 +827,19 @@ func (provider toolCallingExecProvider) StreamCompletion(ctx context.Context, re
func findJSONEvent(t *testing.T, events []map[string]any, eventType string) map[string]any {
t.Helper()
for _, event := range events {
_, event := findJSONEventIndex(t, events, eventType)
return event
}
func findJSONEventIndex(t *testing.T, events []map[string]any, eventType string) (int, map[string]any) {
t.Helper()
for idx, event := range events {
if event["type"] == eventType {
return event
return idx, event
}
}
t.Fatalf("event %q not found in %#v", eventType, events)
return nil
return -1, nil
}
func findSessionEvent(t *testing.T, events []sessions.Event, eventType sessions.EventType) sessions.Event {
+10
View File
@@ -80,6 +80,16 @@ func (writer *execEventWriter) text(delta string) {
writer.writeStdout(delta)
}
func (writer *execEventWriter) reasoning(delta string) {
if writer.format == execOutputJSON {
writer.writeJSON(map[string]any{"type": "reasoning", "delta": delta})
return
}
if writer.format == execOutputStreamJSON {
writer.writeStreamJSON(streamjson.Event{Type: streamjson.EventReasoning, RunID: writer.runID, Delta: delta})
}
}
func (writer *execEventWriter) toolCall(call agent.ToolCall, registry *tools.Registry) {
if writer.format == execOutputJSON {
writer.writeJSON(map[string]any{
+9
View File
@@ -88,6 +88,15 @@ func formatRepoInfo(info repoinfo.Info) string {
fmt.Fprintf(&b, "Files: %d\n", info.FileCount)
fmt.Fprintf(&b, "Directories: %d (max depth %d)\n", info.DirectoryCount, info.MaxDepth)
fmt.Fprintf(&b, "Est. LOC: %d\n", info.LOCEstimate)
if info.CommitCount != nil {
fmt.Fprintf(&b, "Commits: %d\n", *info.CommitCount)
}
if info.BranchCount != nil {
fmt.Fprintf(&b, "Branches: %d\n", *info.BranchCount)
}
if info.TagCount != nil {
fmt.Fprintf(&b, "Tags: %d\n", *info.TagCount)
}
if info.PrimaryLanguage != "" {
fmt.Fprintf(&b, "Primary lang: %s\n", info.PrimaryLanguage)
}
+5 -1
View File
@@ -33,7 +33,7 @@ func TestFormatRepoInfoText(t *testing.T) {
contrib := 3
info := repoInfoTestData(age, contrib)
out := formatRepoInfo(info)
for _, want := range []string{"Files", "TypeScript", "main", "GitHub Actions", "42", "3"} {
for _, want := range []string{"Files", "TypeScript", "main", "GitHub Actions", "42", "3", "Commits: 100", "Branches: 5", "Tags: 10"} {
if !strings.Contains(out, want) {
t.Fatalf("text output missing %q:\n%s", want, out)
}
@@ -113,12 +113,16 @@ func initTempGitRepo(t *testing.T) string {
}
func repoInfoTestData(age, contrib int) repoinfo.Info {
commits := 100
branches := 5
tags := 10
return repoinfo.Info{
FileCount: 10, DirectoryCount: 3, MaxDepth: 2, LOCEstimate: 500,
Languages: []repoinfo.LangStat{{Name: "TypeScript", LOCEstimate: 400, FileCount: 4}},
PrimaryLanguage: "TypeScript", LanguageCount: 1,
WorkspaceType: "none", CICD: []string{"GitHub Actions"},
HasGit: true, Branch: "main", AgeDays: &age, Contributors90d: &contrib,
CommitCount: &commits, BranchCount: &branches, TagCount: &tags,
}
}
+195 -2
View File
@@ -41,6 +41,13 @@ type changesCommandOptions struct {
hasMessage bool
dryRun bool
maxDiffBytes int
remote string
force bool
title string
body string
fill bool
draft bool
yes bool
auto bool
}
@@ -266,6 +273,10 @@ func runChanges(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
return exitCrash
}
return exitSuccess
case "push":
return runChangesPush(args, stdout, stderr, deps)
case "pr", "pull-request":
return runChangesPR(args, stdout, stderr, deps)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown changes command %q", command))
}
@@ -465,6 +476,41 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
return options, false, err
}
options.maxDiffBytes = maxDiffBytes
case arg == "--remote":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.remote = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--remote="):
options.remote = strings.TrimSpace(strings.TrimPrefix(arg, "--remote="))
case arg == "--force":
options.force = true
case arg == "--title":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.title = value
index = next
case strings.HasPrefix(arg, "--title="):
options.title = strings.TrimPrefix(arg, "--title=")
case arg == "--body":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.body = value
index = next
case strings.HasPrefix(arg, "--body="):
options.body = strings.TrimPrefix(arg, "--body=")
case arg == "--fill":
options.fill = true
case arg == "--draft":
options.draft = true
case arg == "--yes":
options.yes = true
case arg == "-a" || arg == "--auto":
options.auto = true
case strings.HasPrefix(arg, "-"):
@@ -473,15 +519,30 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
return options, false, execUsageError{fmt.Sprintf("unexpected changes argument %q", arg)}
}
}
if command != "commit" && options.message != "" {
return options, false, execUsageError{"--message is only valid with `zero changes commit`"}
}
if command != "commit" && (options.hasMessage || options.dryRun || options.auto) {
return options, false, execUsageError{"--message, --dry-run, and --auto are only valid with `zero changes commit`"}
}
if command == "commit" && options.hasMessage && options.auto {
return options, false, execUsageError{"cannot specify both --message and --auto"}
}
if command == "commit" && options.baseRef != "" {
if command != "commit" && command != "push" && options.dryRun {
return options, false, execUsageError{"--dry-run is only valid with commit or push"}
}
if command != "inspect" && options.baseRef != "" {
return options, false, execUsageError{"--base is only valid with `zero changes inspect`"}
}
if command != "push" && command != "pr" && (options.remote != "" || options.force) {
return options, false, execUsageError{"--remote and --force are only valid with push or pr"}
}
if command != "pr" && (options.title != "" || options.body != "" || options.fill || options.draft) {
return options, false, execUsageError{"--title, --body, --fill, and --draft are only valid with `zero changes pr`"}
}
if command != "push" && command != "pr" && options.yes {
return options, false, execUsageError{"--yes is only valid with push or pr"}
}
return options, false, nil
}
@@ -759,14 +820,24 @@ func writeChangesHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero changes inspect [flags]
zero changes commit [flags]
zero changes push [flags]
zero changes pr [flags]
Inspects local git changes and optionally creates a commit.
Inspects, commits, pushes, and creates pull requests for local git changes.
Flags:
-C, --cwd <path> Workspace directory
--base <ref> Diff against <ref>...HEAD instead of the working tree
--diff-bytes <n> Maximum diff bytes to include
-m, --message <text> Commit message for `+"`zero changes commit`"+`
--dry-run Preview commit metadata / push without mutating git state
--remote <name> Remote to push to (defaults to upstream tracked branch or origin)
--force Use force-with-lease when pushing
--title <text> PR title
--body <text> PR body
--fill Automatically populate PR title and body from commits
--draft Create PR as a draft
--yes Confirm pushing to a default/protected branch
-a, --auto Auto-generate commit message using LLM (use --dry-run to preview)
--dry-run Preview commit metadata without mutating git state
--json Print JSON output
@@ -775,6 +846,128 @@ Flags:
return err
}
func runChangesPush(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseChangesArgs(args, "push")
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeChangesHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
result, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
Cwd: workspaceRoot,
Remote: options.remote,
Force: options.force,
DryRun: options.dryRun,
AllowPushDefaultBranch: options.yes,
})
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if options.json {
if err := writePrettyJSON(stdout, result); err != nil {
return exitCrash
}
return exitSuccess
}
dryRunStr := ""
if options.dryRun {
dryRunStr = " (dry run)"
}
if _, err := fmt.Fprintf(stdout, "Pushed branch %s to remote %s%s\n", result.Branch, result.Remote, dryRunStr); err != nil {
return exitCrash
}
if result.Output != "" {
if _, err := fmt.Fprintln(stdout, result.Output); err != nil {
return exitCrash
}
}
return exitSuccess
}
func runChangesPR(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseChangesArgs(args, "pr")
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeChangesHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
if !options.fill && options.title == "" {
return writeExecUsageError(stderr, "must provide either --fill or --title to run non-interactively")
}
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if !options.json {
if _, err := fmt.Fprintln(stdout, "Pushing current branch to set upstream..."); err != nil {
return exitCrash
}
}
pushResult, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
Cwd: workspaceRoot,
Remote: options.remote,
Force: options.force,
AllowPushDefaultBranch: options.yes,
})
if err != nil {
return writeExecUsageError(stderr, fmt.Sprintf("auto-push failed: %v", err))
}
if !options.json {
if _, err := fmt.Fprintf(stdout, "Pushed branch %s to remote %s\n", pushResult.Branch, pushResult.Remote); err != nil {
return exitCrash
}
}
prResult, err := deps.createPR(context.Background(), zerogit.PROptions{
Cwd: workspaceRoot,
Fill: options.fill,
Draft: options.draft,
Title: options.title,
Body: options.body,
})
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if options.json {
type prJSONResult struct {
Branch string `json:"branch"`
Remote string `json:"remote"`
Output string `json:"output"`
}
res := prJSONResult{
Branch: pushResult.Branch,
Remote: pushResult.Remote,
Output: strings.TrimSpace(prResult.Output),
}
if err := writePrettyJSON(stdout, res); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, strings.TrimSpace(prResult.Output)); err != nil {
return exitCrash
}
return exitSuccess
}
func generateAutoCommitMessage(ctx context.Context, provider zeroruntime.Provider, model string, summary zerogit.ChangeSummary) (string, error) {
var promptBuilder strings.Builder
promptBuilder.WriteString("Analyze the following git diff and generate a concise, conventional commit message.\n")
+25 -9
View File
@@ -131,29 +131,45 @@ func doctorSandboxPolicy(cfg config.SandboxConfig) sandbox.Policy {
// lspServersCheck reports which language servers ZERO would use are present on
// PATH. Missing servers are not a failure — ZERO degrades to text-only edits for
// those languages — so the worst status is WARN, and each missing server gets an
// actionable install command keyed by its binary name.
// actionable install command keyed by its binary name. Only the tier-1 servers
// (lsp.CoreServerBinaries) drive the status: the long-tail servers configured
// for breadth are listed informationally under missingOptional, because warning
// about a missing zls on a machine with no Zig code would be permanent noise.
func lspServersCheck(lookup func(string) (string, error)) Check {
if lookup == nil {
lookup = exec.LookPath
}
core := map[string]bool{}
for _, binary := range lsp.CoreServerBinaries() {
core[binary] = true
}
present := map[string]any{}
missing := map[string]any{}
missingOptional := map[string]any{}
for _, binary := range lsp.ServerBinaries() {
if _, err := lookup(binary); err == nil {
present[binary] = "on PATH"
continue
}
missing[binary] = lspRemedy(binary)
if core[binary] {
missing[binary] = lspRemedy(binary)
} else {
missingOptional[binary] = lspRemedy(binary)
}
}
details := map[string]any{"present": present}
if len(missingOptional) > 0 {
details["missingOptional"] = missingOptional
}
if len(missing) == 0 {
return check("lsp.servers", "LSP servers", StatusPass, "All known language servers are available on PATH.", map[string]any{
"present": present,
})
message := "All core language servers are available on PATH."
if len(missingOptional) > 0 {
message = fmt.Sprintf("All core language servers are available on PATH (%d optional server(s) not installed).", len(missingOptional))
}
return check("lsp.servers", "LSP servers", StatusPass, message, details)
}
return check("lsp.servers", "LSP servers", StatusWarn, fmt.Sprintf("%d language server(s) missing from PATH; affected files degrade to text-only edits.", len(missing)), map[string]any{
"present": present,
"missing": missing,
})
details["missing"] = missing
return check("lsp.servers", "LSP servers", StatusWarn, fmt.Sprintf("%d language server(s) missing from PATH; affected files degrade to text-only edits.", len(missing)), details)
}
// lspRemedy returns an actionable install command for a missing language-server
+123 -15
View File
@@ -9,26 +9,134 @@ import (
// serverCommands maps a file extension to the language-server command (argv) ZERO
// will spawn. The first element is the binary looked up on PATH; missing binaries
// are not an error, the agent just degrades to text-only for that file.
// are not an error, the agent just degrades to text-only for that file. Every
// command is the language's community-standard server invoked in stdio mode, so
// a wrong guess can never spawn something surprising — only fail the PATH check.
var serverCommands = map[string][]string{
".go": {"gopls", "serve"},
".ts": {"typescript-language-server", "--stdio"},
".tsx": {"typescript-language-server", "--stdio"},
".js": {"typescript-language-server", "--stdio"},
".jsx": {"typescript-language-server", "--stdio"},
".py": {"pyright-langserver", "--stdio"},
".rs": {"rust-analyzer"},
".go": {"gopls", "serve"},
".ts": {"typescript-language-server", "--stdio"},
".tsx": {"typescript-language-server", "--stdio"},
".mts": {"typescript-language-server", "--stdio"},
".cts": {"typescript-language-server", "--stdio"},
".js": {"typescript-language-server", "--stdio"},
".jsx": {"typescript-language-server", "--stdio"},
".mjs": {"typescript-language-server", "--stdio"},
".cjs": {"typescript-language-server", "--stdio"},
".py": {"pyright-langserver", "--stdio"},
".pyi": {"pyright-langserver", "--stdio"},
".rs": {"rust-analyzer"},
".c": {"clangd"},
".h": {"clangd"},
".cpp": {"clangd"},
".cc": {"clangd"},
".cxx": {"clangd"},
".hpp": {"clangd"},
".java": {"jdtls"},
".kt": {"kotlin-language-server"},
".kts": {"kotlin-language-server"},
".rb": {"ruby-lsp"},
".php": {"intelephense", "--stdio"},
".zig": {"zls"},
".lua": {"lua-language-server"},
".ex": {"elixir-ls"},
".exs": {"elixir-ls"},
".hs": {"haskell-language-server-wrapper", "--lsp"},
".swift": {"sourcekit-lsp"},
".ml": {"ocamllsp"},
".mli": {"ocamllsp"},
".scala": {"metals"},
".clj": {"clojure-lsp"},
".cljs": {"clojure-lsp"},
".cljc": {"clojure-lsp"},
".dart": {"dart", "language-server"},
".gleam": {"gleam", "lsp"},
".nix": {"nixd"},
".tf": {"terraform-ls", "serve"},
".sh": {"bash-language-server", "start"},
".bash": {"bash-language-server", "start"},
".yaml": {"yaml-language-server", "--stdio"},
".yml": {"yaml-language-server", "--stdio"},
".json": {"vscode-json-language-server", "--stdio"},
".css": {"vscode-css-language-server", "--stdio"},
".scss": {"vscode-css-language-server", "--stdio"},
".less": {"vscode-css-language-server", "--stdio"},
".html": {"vscode-html-language-server", "--stdio"},
".svelte": {"svelteserver", "--stdio"},
".vue": {"vue-language-server", "--stdio"},
".astro": {"astro-ls", "--stdio"},
}
// languageIDs maps a file extension to the LSP languageId used in didOpen.
var languageIDs = map[string]string{
".go": "go",
".ts": "typescript",
".tsx": "typescriptreact",
".js": "javascript",
".jsx": "javascriptreact",
".py": "python",
".rs": "rust",
".go": "go",
".ts": "typescript",
".tsx": "typescriptreact",
".mts": "typescript",
".cts": "typescript",
".js": "javascript",
".jsx": "javascriptreact",
".mjs": "javascript",
".cjs": "javascript",
".py": "python",
".pyi": "python",
".rs": "rust",
".c": "c",
".h": "c",
".cpp": "cpp",
".cc": "cpp",
".cxx": "cpp",
".hpp": "cpp",
".java": "java",
".kt": "kotlin",
".kts": "kotlin",
".rb": "ruby",
".php": "php",
".zig": "zig",
".lua": "lua",
".ex": "elixir",
".exs": "elixir",
".hs": "haskell",
".swift": "swift",
".ml": "ocaml",
".mli": "ocaml",
".scala": "scala",
".clj": "clojure",
".cljs": "clojure",
".cljc": "clojure",
".dart": "dart",
".gleam": "gleam",
".nix": "nix",
".tf": "terraform",
".sh": "shellscript",
".bash": "shellscript",
".yaml": "yaml",
".yml": "yaml",
".json": "json",
".css": "css",
".scss": "scss",
".less": "less",
".html": "html",
".svelte": "svelte",
".vue": "vue",
".astro": "astro",
}
// coreServerBinaries are the tier-1 servers for the languages agents hit most;
// `zero doctor` treats their absence as warn-worthy. The long tail of servers
// configured above for breadth is reported informationally only — warning about
// a missing zls on a machine with no Zig code would be permanent noise.
var coreServerBinaries = []string{
"gopls",
"typescript-language-server",
"pyright-langserver",
"rust-analyzer",
}
// CoreServerBinaries returns the tier-1 server binaries (sorted copy).
func CoreServerBinaries() []string {
binaries := append([]string(nil), coreServerBinaries...)
sort.Strings(binaries)
return binaries
}
// ServerBinaries returns the unique set of language-server binaries ZERO may
+4
View File
@@ -63,6 +63,10 @@ func DefaultModelEntries() []ModelEntry {
googleModel("gemini-2.5-flash-lite", "Gemini 2.5 Flash-Lite", "gemini-2.5-flash-lite", ModelStatusActive, []string{"google:gemini-2.5-flash-lite", "gemini-flash-lite"}, ContextLimits{ContextWindow: 1_048_576, MaxOutputTokens: 65_536}, ModelCost{InputPerMillion: 0.1, CachedInputPerMillion: 0.01, OutputPerMillion: 0.4}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityReasoning, ModelCapabilityLongContext}, standardReasoningEfforts(), "Google low-cost Flash model for background routing and summaries."),
}
decorateModelDepth(entries)
// Overlay volatile facts (context limits, base pricing) from a cached
// models.dev snapshot when one is present and fresh — see modelsdev.go.
// Identity fields (ids, aliases, patterns, deprecations) stay curated.
entries = applyModelsDevOverrides(entries, cachedModelsDevProviders())
return cloneModelEntries(entries)
}
+269
View File
@@ -0,0 +1,269 @@
package modelregistry
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
)
// Live models.dev overlay for the curated catalog. The hand-maintained
// DefaultModelEntries list is the source of truth for identity — ids, aliases,
// match patterns, deprecations, escalation targets — but its VOLATILE facts
// (context window, max output tokens, per-million pricing) go stale between
// releases. When a cached snapshot of https://models.dev/api.json is present,
// those fields are refreshed from it at registry construction; everything else
// stays curated. The overlay never adds models (an auto-added entry would lack
// aliases, match patterns, and provider wiring) and never touches the network
// on the registry hot path — fetching happens only in the explicit background
// refresh, cached to disk with a TTL.
const (
modelsDevDefaultURL = "https://models.dev/api.json"
// modelsDevRefreshAfter is how old the cache may get before a background
// refresh re-fetches it.
modelsDevRefreshAfter = 24 * time.Hour
// modelsDevMaxAge is the oldest cache still applied as an overlay. Beyond
// this the curated catalog (updated with the binary) is likely fresher than
// the snapshot, so a stale file is ignored rather than trusted.
modelsDevMaxAge = 7 * 24 * time.Hour
modelsDevFetchLimit = 32 << 20 // 32MiB guard on the response body
modelsDevFetchWindow = 15 * time.Second
)
// modelsDevModel is the subset of a models.dev model record the overlay uses.
type modelsDevModel struct {
Limit struct {
Context int `json:"context"`
Output int `json:"output"`
} `json:"limit"`
Cost struct {
Input float64 `json:"input"`
Output float64 `json:"output"`
CacheRead float64 `json:"cache_read"`
CacheWrite float64 `json:"cache_write"`
} `json:"cost"`
}
// modelsDevProvider matches one provider object in api.json.
type modelsDevProvider struct {
Models map[string]modelsDevModel `json:"models"`
}
// parseModelsDev decodes an api.json document into provider-slug -> api-model
// -> record. api.json is a top-level object keyed by provider id.
func parseModelsDev(data []byte) (map[string]map[string]modelsDevModel, error) {
var doc map[string]modelsDevProvider
if err := json.Unmarshal(data, &doc); err != nil {
return nil, fmt.Errorf("modelregistry: parse models.dev: %w", err)
}
providers := make(map[string]map[string]modelsDevModel, len(doc))
for slug, provider := range doc {
if len(provider.Models) == 0 {
continue
}
providers[slug] = provider.Models
}
if len(providers) == 0 {
return nil, fmt.Errorf("modelregistry: models.dev document has no providers")
}
return providers, nil
}
// modelsDevSlugs maps an entry's provider kind to the models.dev provider ids
// worth checking. Only first-party slugs are used: router entries on models.dev
// carry generic or stale numbers.
func modelsDevSlugs(kind ProviderKind) []string {
switch kind {
case ProviderAnthropic:
return []string{"anthropic"}
case ProviderOpenAI:
return []string{"openai"}
case ProviderGoogle:
return []string{"google", "google-vertex"}
default:
return nil
}
}
// applyModelsDevOverrides refreshes each curated entry's context limits and
// base pricing from the snapshot, when the snapshot knows the model. Tiered
// pricing is never touched: models.dev has no tier data, and mixing a live
// base rate with curated tiers would misprice the tier boundaries.
func applyModelsDevOverrides(entries []ModelEntry, providers map[string]map[string]modelsDevModel) []ModelEntry {
if len(providers) == 0 {
return entries
}
for i := range entries {
entry := &entries[i]
var record modelsDevModel
found := false
for _, slug := range modelsDevSlugs(entry.Provider) {
if models, ok := providers[slug]; ok {
if candidate, ok := models[strings.TrimSpace(entry.APIModel)]; ok {
record = candidate
found = true
break
}
}
}
if !found {
continue
}
if record.Limit.Context > 0 {
entry.ContextLimits.ContextWindow = record.Limit.Context
}
if record.Limit.Output > 0 {
entry.ContextLimits.MaxOutputTokens = record.Limit.Output
}
if len(entry.Cost.Tiers) == 0 && record.Cost.Input > 0 && record.Cost.Output > 0 {
entry.Cost.InputPerMillion = record.Cost.Input
entry.Cost.OutputPerMillion = record.Cost.Output
if record.Cost.CacheRead > 0 {
entry.Cost.CachedInputPerMillion = record.Cost.CacheRead
}
if record.Cost.CacheWrite > 0 {
entry.Cost.CacheWritePerMillion = record.Cost.CacheWrite
}
entry.Cost.Source = "models.dev/api.json (cached)"
}
}
return entries
}
// modelsDevCachePath returns the on-disk cache location. ZERO_MODELS_CACHE_PATH
// overrides it (used by tests and unusual setups).
func modelsDevCachePath() (string, error) {
if override := strings.TrimSpace(os.Getenv("ZERO_MODELS_CACHE_PATH")); override != "" {
return override, nil
}
base, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(base, "zero", "modelsdev.json"), nil
}
var (
modelsDevOnce sync.Once
modelsDevCached map[string]map[string]modelsDevModel
modelsDevEnabled atomic.Bool
)
// EnableModelsDevOverlay opts the process into applying the cached models.dev
// snapshot on top of the curated catalog. The CLI entrypoint calls it; library
// consumers and tests that never do get the curated catalog byte-identical to
// before, so hermetic tests can't be perturbed by a cache file on the machine.
// ZERO_DISABLE_MODELS_FETCH disables both the overlay and the fetch.
func EnableModelsDevOverlay() {
modelsDevEnabled.Store(true)
}
// cachedModelsDevProviders loads the cached snapshot once per process. Not
// enabled, missing, stale (> modelsDevMaxAge), or malformed all yield nil and
// the curated catalog is used untouched. Read once deliberately:
// DefaultRegistry is called on hot paths (pickers, cost views) and must not
// re-stat the file every time; a background refresh benefits the NEXT process.
func cachedModelsDevProviders() map[string]map[string]modelsDevModel {
if !modelsDevEnabled.Load() || strings.TrimSpace(os.Getenv("ZERO_DISABLE_MODELS_FETCH")) != "" {
return nil
}
modelsDevOnce.Do(func() {
path, err := modelsDevCachePath()
if err != nil {
return
}
info, err := os.Stat(path)
if err != nil || time.Since(info.ModTime()) > modelsDevMaxAge {
return
}
data, err := os.ReadFile(path)
if err != nil {
return
}
providers, err := parseModelsDev(data)
if err != nil {
return
}
modelsDevCached = providers
})
return modelsDevCached
}
// resetModelsDevCacheForTest clears the process-level cache memoization and
// disables the overlay.
func resetModelsDevCacheForTest() {
modelsDevOnce = sync.Once{}
modelsDevCached = nil
modelsDevEnabled.Store(false)
}
// RefreshModelsDevCache fetches models.dev/api.json into the on-disk cache
// when the cache is missing or older than modelsDevRefreshAfter. It is safe to
// call fire-and-forget from startup (use a goroutine); it never affects the
// current process's registry (see cachedModelsDevProviders). Disabled entirely
// by ZERO_DISABLE_MODELS_FETCH. The URL can be overridden with ZERO_MODELS_URL.
func RefreshModelsDevCache(ctx context.Context) error {
if strings.TrimSpace(os.Getenv("ZERO_DISABLE_MODELS_FETCH")) != "" {
return nil
}
path, err := modelsDevCachePath()
if err != nil {
return err
}
if info, err := os.Stat(path); err == nil && time.Since(info.ModTime()) < modelsDevRefreshAfter {
return nil
}
url := strings.TrimSpace(os.Getenv("ZERO_MODELS_URL"))
if url == "" {
url = modelsDevDefaultURL
}
fetchCtx, cancel := context.WithTimeout(ctx, modelsDevFetchWindow)
defer cancel()
request, err := http.NewRequestWithContext(fetchCtx, http.MethodGet, url, nil)
if err != nil {
return err
}
request.Header.Set("User-Agent", "zero-models-refresh/0.1")
response, err := http.DefaultClient.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("modelregistry: models.dev fetch: HTTP %d", response.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(response.Body, modelsDevFetchLimit))
if err != nil {
return err
}
// Validate before persisting: a bad body must never clobber a good cache.
if _, err := parseModelsDev(data); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
temp, err := os.CreateTemp(filepath.Dir(path), "modelsdev-*.json")
if err != nil {
return err
}
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
_ = os.Remove(temp.Name())
return err
}
if err := temp.Close(); err != nil {
_ = os.Remove(temp.Name())
return err
}
return os.Rename(temp.Name(), path)
}
+228
View File
@@ -0,0 +1,228 @@
package modelregistry
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
const sampleModelsDev = `{
"anthropic": {
"id": "anthropic",
"models": {
"claude-sonnet-4-5-20250929": {
"limit": {"context": 1000000, "output": 64000},
"cost": {"input": 3.5, "output": 17.5, "cache_read": 0.35, "cache_write": 4.4}
}
}
},
"google": {
"id": "google",
"models": {
"gemini-2.5-pro": {
"limit": {"context": 2097152, "output": 65536},
"cost": {"input": 9.99, "output": 9.99}
}
}
}
}`
func TestParseModelsDev(t *testing.T) {
providers, err := parseModelsDev([]byte(sampleModelsDev))
if err != nil {
t.Fatal(err)
}
record, ok := providers["anthropic"]["claude-sonnet-4-5-20250929"]
if !ok {
t.Fatal("expected anthropic sonnet record")
}
if record.Limit.Context != 1_000_000 || record.Cost.Input != 3.5 {
t.Fatalf("unexpected record: %+v", record)
}
if _, err := parseModelsDev([]byte(`{}`)); err == nil {
t.Fatal("empty document must be rejected")
}
if _, err := parseModelsDev([]byte(`not json`)); err == nil {
t.Fatal("malformed document must be rejected")
}
}
func TestApplyModelsDevOverrides(t *testing.T) {
// Point the cache at a non-existent file so DefaultModelEntries returns the
// pure curated catalog, then apply the sample snapshot explicitly.
t.Setenv("ZERO_MODELS_CACHE_PATH", filepath.Join(t.TempDir(), "absent.json"))
resetModelsDevCacheForTest()
t.Cleanup(resetModelsDevCacheForTest)
providers, err := parseModelsDev([]byte(sampleModelsDev))
if err != nil {
t.Fatal(err)
}
entries := applyModelsDevOverrides(DefaultModelEntries(), providers)
var sonnet, geminiPro, opus ModelEntry
for _, entry := range entries {
switch entry.ID {
case "claude-sonnet-4.5":
sonnet = entry
case "gemini-2.5-pro":
geminiPro = entry
case "claude-opus-4.1":
opus = entry
}
}
// Known model: limits and base pricing refreshed from the snapshot.
if sonnet.ContextLimits.ContextWindow != 1_000_000 || sonnet.ContextLimits.MaxOutputTokens != 64_000 {
t.Fatalf("sonnet limits not overridden: %+v", sonnet.ContextLimits)
}
if sonnet.Cost.InputPerMillion != 3.5 || sonnet.Cost.OutputPerMillion != 17.5 || sonnet.Cost.CacheWritePerMillion != 4.4 {
t.Fatalf("sonnet cost not overridden: %+v", sonnet.Cost)
}
if sonnet.Cost.Source != "models.dev/api.json (cached)" {
t.Fatalf("sonnet cost source not marked: %q", sonnet.Cost.Source)
}
// Tiered pricing is curated: limits refresh, cost must NOT (gemini-2.5-pro
// has curated tiers and the snapshot's flat 9.99 would misprice them).
if geminiPro.ContextLimits.ContextWindow != 2_097_152 {
t.Fatalf("gemini limits not overridden: %+v", geminiPro.ContextLimits)
}
if geminiPro.Cost.InputPerMillion == 9.99 || len(geminiPro.Cost.Tiers) == 0 {
t.Fatalf("tiered cost must stay curated: %+v", geminiPro.Cost)
}
// Model absent from the snapshot: untouched.
if opus.ContextLimits.ContextWindow != 200_000 || opus.Cost.InputPerMillion != 15 {
t.Fatalf("opus must be untouched: %+v %+v", opus.ContextLimits, opus.Cost)
}
}
func TestRefreshModelsDevCacheFetchesAndCaches(t *testing.T) {
hits := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
_, _ = w.Write([]byte(sampleModelsDev))
}))
defer server.Close()
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
t.Setenv("ZERO_MODELS_URL", server.URL)
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "")
if err := RefreshModelsDevCache(t.Context()); err != nil {
t.Fatal(err)
}
if hits != 1 {
t.Fatalf("expected 1 fetch, got %d", hits)
}
if _, err := os.Stat(cachePath); err != nil {
t.Fatalf("cache file missing: %v", err)
}
// Fresh cache: second call must not re-fetch.
if err := RefreshModelsDevCache(t.Context()); err != nil {
t.Fatal(err)
}
if hits != 1 {
t.Fatalf("fresh cache must skip the fetch, got %d hits", hits)
}
}
func TestRefreshModelsDevCacheRejectsBadBodyWithoutClobbering(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("not json"))
}))
defer server.Close()
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
t.Fatal(err)
}
stale := time.Now().Add(-48 * time.Hour)
if err := os.Chtimes(cachePath, stale, stale); err != nil {
t.Fatal(err)
}
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
t.Setenv("ZERO_MODELS_URL", server.URL)
if err := RefreshModelsDevCache(t.Context()); err == nil {
t.Fatal("bad body must return an error")
}
content, err := os.ReadFile(cachePath)
if err != nil || string(content) != sampleModelsDev {
t.Fatal("bad fetch must not clobber the existing cache")
}
}
func TestRefreshModelsDevCacheDisabledByEnv(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("fetch must not happen when disabled")
}))
defer server.Close()
t.Setenv("ZERO_MODELS_CACHE_PATH", filepath.Join(t.TempDir(), "modelsdev.json"))
t.Setenv("ZERO_MODELS_URL", server.URL)
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "1")
if err := RefreshModelsDevCache(t.Context()); err != nil {
t.Fatal(err)
}
}
func TestCachedModelsDevProvidersIgnoresStaleCache(t *testing.T) {
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
t.Fatal(err)
}
stale := time.Now().Add(-modelsDevMaxAge - time.Hour)
if err := os.Chtimes(cachePath, stale, stale); err != nil {
t.Fatal(err)
}
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
resetModelsDevCacheForTest()
t.Cleanup(resetModelsDevCacheForTest)
EnableModelsDevOverlay()
if providers := cachedModelsDevProviders(); providers != nil {
t.Fatal("stale cache must be ignored")
}
}
func TestCachedModelsDevProvidersRequiresOptIn(t *testing.T) {
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
resetModelsDevCacheForTest()
t.Cleanup(resetModelsDevCacheForTest)
// Without EnableModelsDevOverlay a fresh, valid cache must still be ignored:
// library consumers and hermetic tests get the pure curated catalog.
if providers := cachedModelsDevProviders(); providers != nil {
t.Fatal("overlay must be opt-in")
}
}
func TestDefaultModelEntriesAppliesFreshCache(t *testing.T) {
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
resetModelsDevCacheForTest()
t.Cleanup(resetModelsDevCacheForTest)
EnableModelsDevOverlay()
for _, entry := range DefaultModelEntries() {
if entry.ID == "claude-sonnet-4.5" {
if entry.ContextLimits.ContextWindow != 1_000_000 {
t.Fatalf("fresh cache must overlay the registry: %+v", entry.ContextLimits)
}
return
}
}
t.Fatal("claude-sonnet-4.5 not found")
}
@@ -0,0 +1,96 @@
package anthropic
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// The final two messages must carry a cache_control breakpoint on their last
// cacheable block so the conversation transcript — not just system prompt and
// tools — is a prompt-cache hit on the next turn. Earlier messages must stay
// unmarked (Anthropic caps breakpoints at 4 per request: system, tools, and
// these two).
func TestAnthropicRequestMarksLastTwoMessagesForCaching(t *testing.T) {
var gotBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSEEvent(w, "message_stop", `{"type":"message_stop"}`)
}))
defer server.Close()
provider, err := New(Options{BaseURL: server.URL + "/", Model: "claude-test"})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: "first"},
{
Role: zeroruntime.MessageRoleAssistant,
Content: "calling a tool",
ToolCalls: []zeroruntime.ToolCall{{ID: "toolu_1", Name: "grep", Arguments: `{}`}},
},
{Role: zeroruntime.MessageRoleTool, Content: "result", ToolCallID: "toolu_1"},
},
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
messages := gotBody["messages"].([]any)
if len(messages) != 3 {
t.Fatalf("expected 3 wire messages, got %d: %#v", len(messages), messages)
}
lastBlockHasCache := func(message any) bool {
blocks, ok := message.(map[string]any)["content"].([]any)
if !ok || len(blocks) == 0 {
return false
}
last := blocks[len(blocks)-1].(map[string]any)
control, ok := last["cache_control"].(map[string]any)
return ok && control["type"] == "ephemeral"
}
if lastBlockHasCache(messages[0]) {
t.Fatalf("first message must not carry a breakpoint: %#v", messages[0])
}
if !lastBlockHasCache(messages[1]) {
t.Fatalf("second-to-last message must carry a breakpoint: %#v", messages[1])
}
if !lastBlockHasCache(messages[2]) {
t.Fatalf("last message must carry a breakpoint: %#v", messages[2])
}
}
// A string-content message must be converted to a block array so it can carry
// the breakpoint, and thinking blocks must never carry cache_control (the API
// rejects them) — the marker goes on the last cacheable block instead.
func TestApplyMessageCacheBreakpointsSkipsThinkingBlocks(t *testing.T) {
messages := []anthropicMessage{
{Role: "user", Content: "plain string"},
{Role: "assistant", Content: []map[string]any{
{"type": "text", "text": "answer"},
{"type": "thinking", "thinking": "hmm", "signature": "sig"},
}},
}
applyMessageCacheBreakpoints(messages)
userBlocks := contentBlocks(messages[0].Content)
if len(userBlocks) != 1 || userBlocks[0]["cache_control"] == nil {
t.Fatalf("string content must become a marked block: %#v", messages[0].Content)
}
assistantBlocks := contentBlocks(messages[1].Content)
if assistantBlocks[1]["cache_control"] != nil {
t.Fatalf("thinking block must not be marked: %#v", assistantBlocks[1])
}
if assistantBlocks[0]["cache_control"] == nil {
t.Fatalf("text block must carry the breakpoint instead: %#v", assistantBlocks[0])
}
}
+26
View File
@@ -405,9 +405,35 @@ func (provider *Provider) anthropicRequest(request zeroruntime.CompletionRequest
}
mapped.Tools[len(mapped.Tools)-1].CacheControl = &cacheControl{Type: cacheEphemeral}
}
applyMessageCacheBreakpoints(mapped.Messages)
return mapped, nil
}
// applyMessageCacheBreakpoints marks the last content block of the final two
// messages with cache_control so the conversation transcript is cached
// turn-over-turn, not just the system prompt and tool definitions. Two message
// breakpoints (not one) keep the previous turn's prefix a cache hit while the
// newest suffix is being written. Anthropic allows at most 4 breakpoints per
// request: system + tools use two above, these use the remaining two. Thinking
// blocks cannot carry cache_control, so the marker goes on the last block that
// can.
func applyMessageCacheBreakpoints(messages []anthropicMessage) {
marked := 0
for i := len(messages) - 1; i >= 0 && marked < 2; i-- {
blocks := contentBlocks(messages[i].Content)
for j := len(blocks) - 1; j >= 0; j-- {
blockType, _ := blocks[j]["type"].(string)
if blockType == "thinking" || blockType == "redacted_thinking" {
continue
}
blocks[j]["cache_control"] = map[string]any{"type": cacheEphemeral}
messages[i].Content = blocks
marked++
break
}
}
}
func mapMessages(messages []zeroruntime.Message) (string, []anthropicMessage, error) {
systemParts := []string{}
mapped := []anthropicMessage{}
+25 -2
View File
@@ -9,6 +9,7 @@ import (
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
@@ -283,10 +284,10 @@ func (provider *Provider) emitChunk(
events chan<- zeroruntime.StreamEvent,
) {
for _, choice := range chunk.Choices {
if choice.Delta.ReasoningContent != "" {
if reasoning := choice.Delta.reasoningText(); reasoning != "" {
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventReasoning,
Content: choice.Delta.ReasoningContent,
Content: reasoning,
})
}
if choice.Delta.Content != "" {
@@ -317,6 +318,13 @@ func (provider *Provider) emitChunk(
}
}
func (delta streamDelta) reasoningText() string {
if delta.ReasoningContent != "" {
return delta.ReasoningContent
}
return delta.Reasoning
}
// mapFinishReason maps OpenAI's finish_reason onto the runtime's normalized
// terminal reasons. A normal finish ("stop"/"tool_calls"/"") returns "".
func mapFinishReason(reason string) string {
@@ -414,6 +422,12 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
if effort := openAIReasoningEffort(request.ReasoningEffort); effort != "" {
mapped.ReasoningEffort = effort
}
// prompt_cache_key is a documented OpenAI parameter; compatible servers
// ignore unknown fields, but a strict endpoint that rejects it can be
// accommodated with ZERO_DISABLE_PROMPT_CACHE_KEY=1.
if key := strings.TrimSpace(request.PromptCacheKey); key != "" && !promptCacheKeyDisabled() {
mapped.PromptCacheKey = key
}
if len(request.Tools) > 0 {
mapped.Tools = make([]toolDefinition, 0, len(request.Tools))
for _, tool := range request.Tools {
@@ -430,6 +444,15 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
return mapped
}
// promptCacheKeyDisabled reports whether the ZERO_DISABLE_PROMPT_CACHE_KEY
// kill switch is set to a truthy value. "0" and "false" (any case) are
// no-ops, matching how ZERO_FORMAT_ON_WRITE parses boolean flags, so an
// explicitly-disabled toggle never flips the behavior it names.
func promptCacheKeyDisabled() bool {
value := strings.TrimSpace(os.Getenv("ZERO_DISABLE_PROMPT_CACHE_KEY"))
return value != "" && value != "0" && !strings.EqualFold(value, "false")
}
// openAIReasoningEffort normalizes a requested effort to a value the OpenAI chat
// completions API accepts, or "" to omit the field. "none" (and anything else)
// is dropped rather than risking a 400 on an unrecognized enum.
@@ -227,6 +227,39 @@ func TestStreamCompletionEmitsReasoningContentDeltas(t *testing.T) {
}
}
func TestStreamCompletionEmitsReasoningAliasDeltas(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"delta":{"reasoning":"Thinking. "}}]}`)
writeSSE(w, `{"choices":[{"delta":{"reasoning":"Answering now."}}]}`)
writeSSE(w, `[DONE]`)
})
events := collectProviderEvents(t, provider)
reasoning := eventsOfType(events, zeroruntime.StreamEventReasoning)
if len(reasoning) != 2 {
t.Fatalf("reasoning events = %#v, want two reasoning deltas", reasoning)
}
if reasoning[0].Content != "Thinking. " || reasoning[1].Content != "Answering now." {
t.Fatalf("unexpected reasoning events: %#v", reasoning)
}
if text := eventsOfType(events, zeroruntime.StreamEventText); len(text) != 0 {
t.Fatalf("reasoning must not emit text events, got %#v", text)
}
}
func TestStreamCompletionPrefersReasoningContentOverAlias(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"delta":{"reasoning_content":"standard","reasoning":"alias"}}]}`)
writeSSE(w, `[DONE]`)
})
events := collectProviderEvents(t, provider)
reasoning := eventsOfType(events, zeroruntime.StreamEventReasoning)
if len(reasoning) != 1 || reasoning[0].Content != "standard" {
t.Fatalf("reasoning events = %#v, want standard reasoning_content", reasoning)
}
}
func TestStreamCompletionEmitsReasoningBeforeRegularContent(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"delta":{"reasoning_content":"Thinking. ","content":"Answer."}}]}`)
@@ -1226,3 +1259,61 @@ func TestOpenAIRequestEmptyContentHandling(t *testing.T) {
t.Fatalf("assistant tool calls dropped: %#v", req.Messages[1])
}
}
// TestOpenAIRequestPromptCacheKey locks in prompt_cache_key forwarding: a
// session-carrying request serializes the key so the backend can route to a
// replica holding the cached prefix, a keyless request omits the field
// entirely (strict servers see byte-identical requests to before), and
// ZERO_DISABLE_PROMPT_CACHE_KEY suppresses it for endpoints that reject it.
func TestOpenAIRequestPromptCacheKey(t *testing.T) {
provider, err := New(Options{Model: "gpt-test"})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}}
req := provider.openAIRequest(zeroruntime.CompletionRequest{
Messages: messages,
PromptCacheKey: "sess_123",
})
if req.PromptCacheKey != "sess_123" {
t.Fatalf("PromptCacheKey = %q, want sess_123", req.PromptCacheKey)
}
data, err := json.Marshal(req)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(data), `"prompt_cache_key":"sess_123"`) {
t.Fatalf("prompt_cache_key not serialized: %s", data)
}
req = provider.openAIRequest(zeroruntime.CompletionRequest{Messages: messages})
if data, err = json.Marshal(req); err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(data), "prompt_cache_key") {
t.Fatalf("keyless request must omit prompt_cache_key: %s", data)
}
t.Setenv("ZERO_DISABLE_PROMPT_CACHE_KEY", "1")
req = provider.openAIRequest(zeroruntime.CompletionRequest{
Messages: messages,
PromptCacheKey: "sess_123",
})
if req.PromptCacheKey != "" {
t.Fatalf("kill switch ignored; PromptCacheKey = %q", req.PromptCacheKey)
}
// Explicitly-falsy kill switch values must NOT disable forwarding — only
// truthy values flip the toggle (same parsing as ZERO_FORMAT_ON_WRITE).
for _, value := range []string{"0", "false", "FALSE"} {
t.Setenv("ZERO_DISABLE_PROMPT_CACHE_KEY", value)
req = provider.openAIRequest(zeroruntime.CompletionRequest{
Messages: messages,
PromptCacheKey: "sess_123",
})
if req.PromptCacheKey != "sess_123" {
t.Fatalf("ZERO_DISABLE_PROMPT_CACHE_KEY=%q must be a no-op; PromptCacheKey = %q", value, req.PromptCacheKey)
}
}
}
+6
View File
@@ -8,6 +8,11 @@ type chatCompletionRequest struct {
ReasoningEffort string `json:"reasoning_effort,omitempty"`
Stream bool `json:"stream"`
StreamOptions *streamOptions `json:"stream_options,omitempty"`
// PromptCacheKey asks the backend to route the request to a replica that
// already holds this conversation's prefix in its prompt cache (the OpenAI
// `prompt_cache_key` parameter). Omitted when the caller carries no session
// identity or when ZERO_DISABLE_PROMPT_CACHE_KEY is set.
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
}
// streamOptions requests the final usage chunk on a streaming response. Without
@@ -78,6 +83,7 @@ type streamChoice struct {
type streamDelta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ToolCalls []streamToolCallDelta `json:"tool_calls"`
}
+32 -11
View File
@@ -23,12 +23,18 @@ import (
// (billable) work. Only the INITIAL request is ever in scope; once the response
// body starts streaming it is never re-issued.
const defaultMaxRetryAttempts = 3
const defaultMaxRetryAttempts = 6
// maxBackoff caps a single backoff wait so a hostile or buggy Retry-After can't
// stall the agent for minutes.
const maxBackoff = 30 * time.Second
// retryBackoffBase is the first wait when the server supplied no Retry-After.
// Rate-limit windows are measured in seconds, not milliseconds: retrying a 429
// after 400ms almost always burns the attempt while still limited, so the
// schedule is 2s, 4s, 8s, 16s, then maxBackoff. A var so tests can shrink it.
var retryBackoffBase = 2 * time.Second
// SendWithRetry issues an HTTP request, retrying ONLY the safe-to-replay server
// responses (429 and 503, see ShouldRetryStatus) up to maxAttempts — backing off
// between tries and honoring a server Retry-After header and context
@@ -110,17 +116,11 @@ func ShouldRetryStatus(code int) bool {
}
// Backoff waits before retry attempt N (1-based), returning false if the context
// is cancelled during the wait. The wait is attempt*400ms unless the server
// supplied a (positive) Retry-After, and is capped at maxBackoff.
// is cancelled during the wait. A server-supplied (positive) Retry-After wins;
// otherwise the wait doubles from retryBackoffBase per attempt. Either way the
// wait is capped at maxBackoff.
func Backoff(ctx context.Context, attempt int, retryAfter time.Duration) bool {
wait := time.Duration(attempt) * 400 * time.Millisecond
if retryAfter > 0 {
wait = retryAfter
}
if wait > maxBackoff {
wait = maxBackoff
}
timer := time.NewTimer(wait)
timer := time.NewTimer(backoffWait(attempt, retryAfter))
defer timer.Stop()
select {
case <-ctx.Done():
@@ -130,6 +130,27 @@ func Backoff(ctx context.Context, attempt int, retryAfter time.Duration) bool {
}
}
// backoffWait computes the wait before retry attempt N (1-based): Retry-After
// when supplied, else exponential from retryBackoffBase, both capped at
// maxBackoff. The exponent is clamped so a large attempt count cannot overflow.
func backoffWait(attempt int, retryAfter time.Duration) time.Duration {
wait := retryAfter
if wait <= 0 {
exponent := attempt - 1
if exponent > 5 {
exponent = 5
}
if exponent < 0 {
exponent = 0
}
wait = retryBackoffBase * time.Duration(1<<exponent)
}
if wait > maxBackoff {
wait = maxBackoff
}
return wait
}
// RetryAfter parses a response's Retry-After header (delay-seconds or an HTTP
// date) into a positive duration, or 0 when absent/unparseable. The result is
// capped at maxBackoff by Backoff.
@@ -94,7 +94,40 @@ func TestBackoffWaitsThenReturnsTrue(t *testing.T) {
}
}
// shrinkBackoff makes retry waits negligible for the duration of a test.
func shrinkBackoff(t *testing.T) {
t.Helper()
saved := retryBackoffBase
retryBackoffBase = time.Millisecond
t.Cleanup(func() { retryBackoffBase = saved })
}
func TestBackoffWaitSchedule(t *testing.T) {
// Without Retry-After the wait doubles per attempt from 2s and caps at 30s;
// a supplied Retry-After wins but is capped too.
cases := []struct {
attempt int
retryAfter time.Duration
want time.Duration
}{
{1, 0, 2 * time.Second},
{2, 0, 4 * time.Second},
{3, 0, 8 * time.Second},
{4, 0, 16 * time.Second},
{5, 0, 30 * time.Second}, // 32s capped
{50, 0, 30 * time.Second}, // clamped exponent, no overflow
{1, 7 * time.Second, 7 * time.Second},
{1, 5 * time.Minute, 30 * time.Second}, // hostile Retry-After capped
}
for _, c := range cases {
if got := backoffWait(c.attempt, c.retryAfter); got != c.want {
t.Errorf("backoffWait(%d, %v) = %v, want %v", c.attempt, c.retryAfter, got, c.want)
}
}
}
func TestSendWithRetryRetriesThenSucceeds(t *testing.T) {
shrinkBackoff(t)
var hits int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if atomic.AddInt32(&hits, 1) == 1 {
@@ -140,6 +173,7 @@ func TestSendWithRetryReturnsNonRetryableImmediately(t *testing.T) {
}
func TestSendWithRetryReturnsLastResponseAfterMaxAttempts(t *testing.T) {
shrinkBackoff(t)
var hits int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
+50
View File
@@ -54,6 +54,9 @@ type Info struct {
AgeDays *int `json:"ageDays,omitempty"`
Contributors90d *int `json:"contributors90d,omitempty"`
CommitVelocity30d *int `json:"commitVelocity30d,omitempty"`
BranchCount *int `json:"branchCount,omitempty"`
CommitCount *int `json:"commitCount,omitempty"`
TagCount *int `json:"tagCount,omitempty"`
}
// RunGit runs a git subcommand in dir and returns raw stdout. A non-zero exit
@@ -229,6 +232,43 @@ func Collect(ctx context.Context, opts Options) (Info, error) {
info.CommitVelocity30d = &n
}
}
if count, err := run(ctx, dir, "rev-list", "--count", "HEAD"); err == nil {
if n, perr := strconv.Atoi(strings.TrimSpace(count)); perr == nil {
info.CommitCount = &n
}
}
if branches, err := run(ctx, dir, "branch", "-a"); err == nil {
set := map[string]bool{}
for _, line := range strings.Split(branches, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.Contains(line, "->") {
continue
}
// Strip leading '* ' (current branch) or '+ ' (checked out in another worktree).
line = strings.TrimPrefix(line, "* ")
line = strings.TrimPrefix(line, "+ ")
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "(") {
continue
}
// If it's a remote branch, extract the branch name.
// E.g., remotes/origin/main -> main
// E.g., remotes/origin/feature/xyz -> feature/xyz
if strings.HasPrefix(line, "remotes/") {
line = strings.TrimPrefix(line, "remotes/")
if slashIdx := strings.Index(line, "/"); slashIdx != -1 {
line = line[slashIdx+1:]
}
}
set[line] = true
}
n := len(set)
info.BranchCount = &n
}
if tags, err := run(ctx, dir, "tag"); err == nil {
n := countNonEmptyLines(tags)
info.TagCount = &n
}
return info, nil
}
@@ -328,3 +368,13 @@ func defaultRunGit(ctx context.Context, dir string, args ...string) (string, err
}
return stdout.String(), nil
}
func countNonEmptyLines(s string) int {
count := 0
for _, line := range strings.Split(s, "\n") {
if strings.TrimSpace(line) != "" {
count++
}
}
return count
}
+13 -1
View File
@@ -43,6 +43,8 @@ func TestCollectCoreMetrics(t *testing.T) {
"remote": "https://github.com/x/y.git\n",
"log": "0\n",
"rev-list": "12\n",
"branch": " main\n remotes/origin/HEAD -> origin/main\n remotes/origin/main\n remotes/origin/feature\n",
"tag": "v0.1.0\nv0.2.0\n",
}, nil),
})
if err != nil {
@@ -85,6 +87,15 @@ func TestCollectCoreMetrics(t *testing.T) {
if info.CommitVelocity30d == nil || *info.CommitVelocity30d != 12 {
t.Fatalf("CommitVelocity30d=%v want 12", info.CommitVelocity30d)
}
if info.CommitCount == nil || *info.CommitCount != 12 {
t.Fatalf("CommitCount=%v want 12", info.CommitCount)
}
if info.BranchCount == nil || *info.BranchCount != 2 {
t.Fatalf("BranchCount=%v want 2", info.BranchCount)
}
if info.TagCount == nil || *info.TagCount != 2 {
t.Fatalf("TagCount=%v want 2", info.TagCount)
}
}
func TestCollectContributorsUnique(t *testing.T) {
@@ -252,12 +263,13 @@ func TestCollectNotGitRepo(t *testing.T) {
}
func TestCollectOnlyReadOnlyLocalSubcommands(t *testing.T) {
allowed := map[string]bool{"ls-tree": true, "rev-parse": true, "remote": true, "log": true, "rev-list": true}
allowed := map[string]bool{"ls-tree": true, "rev-parse": true, "remote": true, "log": true, "rev-list": true, "branch": true, "tag": true}
var used []string
_, err := Collect(context.Background(), Options{
Now: time.Unix(0, 0),
RunGit: fakeGit(t, map[string]string{
"ls-tree": lsTree, "rev-parse": "main\n", "remote": "u\n", "log": "0\n", "rev-list": "1\n",
"branch": "main\n", "tag": "v0.1.0\n",
}, &used),
})
if err != nil {
+49 -2
View File
@@ -3,8 +3,9 @@ package sandbox
import (
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
// errWindowsSandboxNotInitialized is returned only to a caller that explicitly
@@ -121,9 +122,55 @@ func (manager SandboxManager) Backend() Backend {
return manager.backend
}
// isExecutable checks whether a file is executable. On Unix, this checks the
// execute permission bits. On Windows, Go's os.FileMode does not set execute
// bits, so we check for common executable extensions instead.
func isExecutable(fi os.FileInfo) bool {
if !fi.Mode().IsRegular() {
return false
}
if runtime.GOOS == "windows" {
name := strings.ToLower(fi.Name())
return strings.HasSuffix(name, ".exe") ||
strings.HasSuffix(name, ".com") ||
strings.HasSuffix(name, ".bat") ||
strings.HasSuffix(name, ".cmd")
}
return fi.Mode()&0o111 != 0
}
// lookupExecutable checks whether a named binary exists and is executable,
// using os.Stat instead of exec.LookPath. exec.LookPath internally uses the
// faccessat2 syscall, which is blocked by Android's seccomp filter (SIGSYS).
func lookupExecutable(name string) (string, error) {
if !strings.Contains(name, string(os.PathSeparator)) {
path := os.Getenv("PATH")
for _, dir := range strings.Split(path, string(os.PathListSeparator)) {
if dir == "" {
continue // skip empty entries (e.g. trailing colon)
}
candidate := filepath.Join(dir, name)
if fi, err := os.Stat(candidate); err == nil {
if isExecutable(fi) {
return candidate, nil
}
}
}
return "", errors.New("executable file not found in $PATH")
}
fi, err := os.Stat(name)
if err != nil {
return "", err
}
if isExecutable(fi) {
return name, nil
}
return "", errors.New("executable file not found")
}
func selectPlatformBackend(goos string, lookup func(string) (string, error)) Backend {
if lookup == nil {
lookup = exec.LookPath
lookup = lookupExecutable
}
switch goos {
case "linux":
@@ -190,6 +190,67 @@ func TestWindowsUnelevatedRealSandboxSmoke(t *testing.T) {
}
}
// TestWindowsRestrictedTokenNestedPipeCapture pins the fix in
// broadenWindowsRestrictedTokenDefaultDacl (windows_token_windows.go): a
// nested subprocess spawned FROM WITHIN the sandboxed process, with its
// output captured via a pipe, must succeed. Before the fix, the sandboxed
// process's own attempt to create a pipe for such a subprocess failed with
// ERROR_ACCESS_DENIED (Win32 error 5) — the WRITE_RESTRICTED token's extra
// access check has no restricted-SID match against a freshly created pipe's
// default security descriptor. This is exactly the pattern any tool that
// shells out internally and captures output hits (`gh` invoking `git`, for
// one; cmd.exe's own FOR /F does the identical CreatePipe+CreateProcess
// dance internally, so it reproduces the bug with no external dependency
// beyond cmd.exe itself).
func TestWindowsRestrictedTokenNestedPipeCapture(t *testing.T) {
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real Windows sandbox smoke tests")
}
runnerExe := realSmokeExecutable(t, "ZERO_WINDOWS_COMMAND_RUNNER_EXE", WindowsSandboxCommandRunnerName)
root := t.TempDir()
sandboxHome := filepath.Join(root, ".zero-sandbox")
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{root},
WriteRoots: []WritableRoot{{Root: root, ProtectedMetadataNames: []string{".git", ".zero", ".agents"}}},
IncludePlatformRoots: true,
AllowTemp: true,
},
Network: NetworkPolicy{Mode: NetworkDeny},
}
config := WindowsSandboxCommandArgsOptions{
SandboxHome: sandboxHome,
CommandCWD: root,
WorkspaceRoots: []string{root},
PermissionProfile: profile,
SandboxLevel: WindowsSandboxLevelUnelevated,
}
marker := filepath.Join(root, "nested-pipe-marker.txt")
script := filepath.Join(root, "nested-pipe.cmd")
// FOR /F drives cmd.exe's own internal CreatePipe+CreateProcess capture
// of the quoted command's output — the exact mechanism this fix targets.
// The full path to whoami.exe sidesteps PATH resolution inside the
// sandboxed process's minimal environment.
scriptBody := "for /F %%i in ('C:\\Windows\\System32\\whoami.exe') do echo %%i> " + marker + "\r\n"
if err := os.WriteFile(script, []byte(scriptBody), 0o644); err != nil {
t.Fatalf("write nested-pipe script: %v", err)
}
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
"cmd.exe", "/d", "/s", "/c", script,
}, 0)
captured, err := os.ReadFile(marker)
if err != nil {
t.Fatalf("read nested-pipe marker: %v", err)
}
if strings.TrimSpace(string(captured)) == "" {
t.Fatalf("nested-pipe marker is empty; FOR /F failed to capture the subprocess's output")
}
}
func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) string {
t.Helper()
if path := os.Getenv(envKey); path != "" {
+135
View File
@@ -21,6 +21,11 @@ const (
var procCreateRestrictedToken = windows.NewLazySystemDLL("advapi32.dll").NewProc("CreateRestrictedToken")
// procSetEntriesInAclW merges new ACEs into an existing ACL. Not wrapped by
// x/sys/windows (its internal setEntriesInAcl is unexported), so it is
// declared directly the same way procCreateRestrictedToken is.
var procSetEntriesInAclW = windows.NewLazySystemDLL("advapi32.dll").NewProc("SetEntriesInAclW")
type windowsLocalSID struct {
sid *windows.SID
}
@@ -122,9 +127,139 @@ func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []w
_ = restricted.Close()
return 0, err
}
if err := broadenWindowsRestrictedTokenDefaultDacl(restricted, sidFromBytes(logonSID)); err != nil {
_ = restricted.Close()
return 0, err
}
runtime.KeepAlive(logonSID)
return restricted, nil
}
// broadenWindowsRestrictedTokenDefaultDacl adds the token's own logon SID to
// its default DACL (the DACL applied to any kernel object the token's process
// creates WITHOUT an explicit security descriptor, e.g. anonymous pipes,
// events, mutexes).
//
// A WRITE_RESTRICTED token requires a WRITE-type access check to match BOTH
// the normal enabled-SID grant AND a SEPARATE grant to one of the token's
// restricted SIDs. CreateRestrictedToken does not touch the default DACL it
// inherits from the base token, so newly created objects only carry ACEs for
// the base token's normal identity (owner, SYSTEM, Administrators) — none of
// which are in the restricted-SID list — so the second check fails. This
// breaks anything that creates a pipe/handle for itself with default
// security, which is exactly what any tool using a language runtime's
// "spawn a subprocess and capture its output" primitive does (Go's os/exec,
// and by extension `gh`, and many other CLI tools that shell out
// internally): CreatePipe() succeeds, but the creating process's own token
// then fails to actually read/write the handle it just made.
//
// The logon SID is already unconditionally present in this token's
// restricted-SID list (see createWindowsRestrictedTokenFromBase), so adding
// it here — and ONLY it — to the default DACL is the minimal change that
// satisfies the WRITE_RESTRICTED extra check for self-created objects. It
// does not touch file-system access: NTFS write-jailing is enforced by the
// explicit ACL grants applied to workspace paths (windows_acl.go), which are
// created WITH an explicit security descriptor and never consult the token's
// default DACL. The exposure is bounded to the same logon session (other
// processes running as the same signed-in user could, in principle, open a
// NAMED kernel object the sandboxed process creates with default security;
// anonymous pipes — the actual object type this fixes — have no name and are
// only reachable via an inherited handle, so this is a no-op for them).
func broadenWindowsRestrictedTokenDefaultDacl(token windows.Token, logonSID *windows.SID) error {
oldDacl, oldDaclBuf, err := windowsTokenDefaultDacl(token)
if err != nil {
return fmt.Errorf("read token default DACL: %w", err)
}
// The second (WRITE-type) access check only needs a read/write match; a
// full GENERIC_ALL grant (which also implies DELETE, WRITE_DAC, and
// WRITE_OWNER) is broader than the pipe/event/mutex use case this exists
// for actually requires.
access := []windows.EXPLICIT_ACCESS{{
AccessPermissions: windows.GENERIC_READ | windows.GENERIC_WRITE,
AccessMode: windows.GRANT_ACCESS,
Inheritance: windows.NO_INHERITANCE,
Trustee: windows.TRUSTEE{
MultipleTrusteeOperation: windows.NO_MULTIPLE_TRUSTEE,
TrusteeForm: windows.TRUSTEE_IS_SID,
TrusteeType: windows.TRUSTEE_IS_USER,
TrusteeValue: windows.TrusteeValueFromSID(logonSID),
},
}}
newDacl, err := setEntriesInACL(access, oldDacl)
// oldDacl points INTO oldDaclBuf (see windowsTokenDefaultDacl) and is
// dereferenced by native code inside setEntriesInACL, above. Nothing in Go
// references oldDaclBuf once windowsTokenDefaultDacl returned, so without
// this KeepAlive it is eligible for GC the moment an allocation in this
// function (the EXPLICIT_ACCESS literal, TrusteeValueFromSID) hits a
// safepoint, before the native call ever reads it - a real, if narrow,
// use-after-free window in security-boundary code.
runtime.KeepAlive(oldDaclBuf)
if err != nil {
return fmt.Errorf("merge token default DACL: %w", err)
}
defer func() { _, _ = windows.LocalFree(windows.Handle(unsafe.Pointer(newDacl))) }()
if err := windowsSetTokenDefaultDacl(token, newDacl); err != nil {
return fmt.Errorf("set token default DACL: %w", err)
}
return nil
}
type windowsTokenDefaultDaclInfo struct {
DefaultDacl *windows.ACL
}
// windowsTokenDefaultDacl returns the token's current default DACL along with
// the buffer backing it. TOKEN_DEFAULT_DACL embeds the ACL data inside the
// same allocation GetTokenInformation fills, so the returned *windows.ACL is
// only valid for as long as the returned buffer stays reachable: the caller
// must runtime.KeepAlive(buf) until every native call that dereferences the
// ACL has completed. A KeepAlive inside this function, ending at its own
// return, would not protect any use by the caller after that.
func windowsTokenDefaultDacl(token windows.Token) (*windows.ACL, []byte, error) {
var size uint32
err := windows.GetTokenInformation(token, windows.TokenDefaultDacl, nil, 0, &size)
if err == nil || err != windows.ERROR_INSUFFICIENT_BUFFER {
return nil, nil, fmt.Errorf("size TokenDefaultDacl: %w", err)
}
buf := make([]byte, size)
if err := windows.GetTokenInformation(token, windows.TokenDefaultDacl, &buf[0], size, &size); err != nil {
return nil, nil, fmt.Errorf("get TokenDefaultDacl: %w", err)
}
info := (*windowsTokenDefaultDaclInfo)(unsafe.Pointer(&buf[0]))
return info.DefaultDacl, buf, nil
}
func windowsSetTokenDefaultDacl(token windows.Token, dacl *windows.ACL) error {
info := windowsTokenDefaultDaclInfo{DefaultDacl: dacl}
err := windows.SetTokenInformation(token, windows.TokenDefaultDacl, (*byte)(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)))
runtime.KeepAlive(info)
return err
}
// setEntriesInACL merges entries into oldACL via Win32 SetEntriesInAclW,
// returning a newly allocated ACL the caller must free with windows.LocalFree.
func setEntriesInACL(entries []windows.EXPLICIT_ACCESS, oldACL *windows.ACL) (*windows.ACL, error) {
if len(entries) == 0 {
return nil, errors.New("setEntriesInACL requires at least one entry")
}
var newACL *windows.ACL
// SetEntriesInAclW reports failure directly in its return value (ERROR_SUCCESS
// on success, a Win32 error code otherwise); unlike many Win32 APIs it does not
// set the thread's last-error, so the syscall trio's second/third return
// values (which surface GetLastError) are not meaningful here.
ret, _, _ := procSetEntriesInAclW.Call(
uintptr(len(entries)),
uintptr(unsafe.Pointer(&entries[0])),
uintptr(unsafe.Pointer(oldACL)),
uintptr(unsafe.Pointer(&newACL)),
)
runtime.KeepAlive(entries)
if ret != 0 {
return nil, fmt.Errorf("SetEntriesInAclW: %w", syscall.Errno(ret))
}
return newACL, nil
}
func copyWindowsLogonSID(token windows.Token) ([]byte, error) {
groups, err := token.GetTokenGroups()
if err == nil {
+233
View File
@@ -0,0 +1,233 @@
package sessions
import (
"encoding/json"
"fmt"
"reflect"
"strings"
"sync"
"testing"
"time"
)
func TestStoreAppendEventsBatchesSequencesAndMetadata(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: sequenceClock([]time.Time{
time.Date(2026, 6, 4, 15, 0, 0, 0, time.UTC),
time.Date(2026, 6, 4, 15, 0, 1, 0, time.UTC),
time.Date(2026, 6, 4, 15, 0, 2, 0, time.UTC),
time.Date(2026, 6, 4, 15, 0, 3, 0, time.UTC),
})})
session, err := store.Create(CreateInput{SessionID: "batch"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
appended, err := store.AppendEvents(session.SessionID, []AppendEventInput{
{Type: EventMessage, Payload: map[string]any{"role": "user", "content": "one"}},
{Type: EventToolCall, Payload: map[string]any{"id": "call_1", "name": "read_file"}},
{Type: EventToolResult, Payload: map[string]any{"toolCallId": "call_1", "status": "ok"}},
})
if err != nil {
t.Fatalf("AppendEvents returned error: %v", err)
}
if len(appended) != 3 {
t.Fatalf("expected 3 appended events, got %#v", appended)
}
for index, event := range appended {
wantSequence := index + 1
if event.Sequence != wantSequence || event.ID != fmt.Sprintf("batch:%d", wantSequence) {
t.Fatalf("event identity mismatch at %d: %#v", index, event)
}
}
if appended[0].CreatedAt != "2026-06-04T15:00:01Z" || appended[2].CreatedAt != "2026-06-04T15:00:03Z" {
t.Fatalf("unexpected event timestamps: %#v", appended)
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if loaded == nil || loaded.EventCount != 3 || loaded.LastEventType != EventToolResult || loaded.UpdatedAt != appended[2].CreatedAt {
t.Fatalf("metadata not updated from final batch event: %#v", loaded)
}
events, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if !reflect.DeepEqual(eventTypesForTest(events), []EventType{EventMessage, EventToolCall, EventToolResult}) {
t.Fatalf("unexpected event types: %#v", events)
}
}
func TestStoreAppendEventsEmptyBatchDoesNotRewriteMetadata(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T15:10:00Z")})
session, err := store.Create(CreateInput{SessionID: "empty_batch", Title: "unchanged"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
appended, err := store.AppendEvents(session.SessionID, nil)
if err != nil {
t.Fatalf("AppendEvents empty returned error: %v", err)
}
if len(appended) != 0 {
t.Fatalf("empty AppendEvents returned %#v", appended)
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if loaded == nil || !reflect.DeepEqual(*loaded, session) {
t.Fatalf("empty AppendEvents rewrote metadata: before=%#v after=%#v", session, loaded)
}
}
func TestStoreAppendEventsInvalidPayloadDoesNotPartiallyAppend(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T15:20:00Z")})
session, err := store.Create(CreateInput{SessionID: "invalid_batch"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
_, err = store.AppendEvents(session.SessionID, []AppendEventInput{
{Type: EventMessage, Payload: map[string]any{"content": "valid first"}},
{Type: EventMessage, Payload: json.RawMessage(`{"broken"`)},
})
if err == nil || !strings.Contains(err.Error(), "invalid raw JSON payload") {
t.Fatalf("expected invalid raw payload error, got %v", err)
}
events, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if len(events) != 0 {
t.Fatalf("invalid batch should not append partial events: %#v", events)
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if loaded == nil || loaded.EventCount != 0 || loaded.LastEventType != "" {
t.Fatalf("invalid batch should not update metadata: %#v", loaded)
}
}
func TestStoreAppendEventsSequencesAfterMetadataLag(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T15:30:00Z")})
session, err := store.Create(CreateInput{SessionID: "lagging"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
if _, err := store.AppendEvent(session.SessionID, AppendEventInput{Type: EventMessage, Payload: map[string]any{"content": "already durable"}}); err != nil {
t.Fatalf("AppendEvent returned error: %v", err)
}
lagging, err := store.Get(session.SessionID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
lagging.EventCount = 0
lagging.LastEventType = ""
if err := store.writeMetadata(*lagging); err != nil {
t.Fatalf("writeMetadata returned error: %v", err)
}
appended, err := store.AppendEvents(session.SessionID, []AppendEventInput{
{Type: EventToolCall, Payload: map[string]any{"id": "call"}},
{Type: EventToolResult, Payload: map[string]any{"toolCallId": "call"}},
})
if err != nil {
t.Fatalf("AppendEvents returned error: %v", err)
}
if len(appended) != 2 || appended[0].Sequence != 2 || appended[1].Sequence != 3 {
t.Fatalf("batch did not sequence after durable log tail: %#v", appended)
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if loaded == nil || loaded.EventCount != 3 || loaded.LastEventType != EventToolResult {
t.Fatalf("metadata not repaired by batch append: %#v", loaded)
}
}
func TestStoreAppendEventsSerializesConcurrentBatches(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T15:40:00Z")})
session, err := store.Create(CreateInput{SessionID: "concurrent_batches"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
const writers = 8
const perBatch = 3
var wg sync.WaitGroup
errs := make(chan error, writers)
for writer := 0; writer < writers; writer++ {
wg.Add(1)
go func(writer int) {
defer wg.Done()
appended, err := store.AppendEvents(session.SessionID, []AppendEventInput{
{Type: EventMessage, Payload: map[string]int{"writer": writer, "index": 0}},
{Type: EventMessage, Payload: map[string]int{"writer": writer, "index": 1}},
{Type: EventMessage, Payload: map[string]int{"writer": writer, "index": 2}},
})
if err != nil {
errs <- err
return
}
if len(appended) != perBatch {
errs <- fmt.Errorf("writer %d appended %d events", writer, len(appended))
return
}
for index := 1; index < len(appended); index++ {
if appended[index].Sequence != appended[index-1].Sequence+1 {
errs <- fmt.Errorf("writer %d batch was interleaved: %#v", writer, appended)
return
}
}
errs <- nil
}(writer)
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("AppendEvents returned error: %v", err)
}
}
events, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
total := writers * perBatch
if len(events) != total {
t.Fatalf("expected %d events, got %d", total, len(events))
}
seen := map[int]bool{}
for _, event := range events {
if seen[event.Sequence] {
t.Fatalf("duplicate sequence %d in %#v", event.Sequence, events)
}
seen[event.Sequence] = true
}
for sequence := 1; sequence <= total; sequence++ {
if !seen[sequence] {
t.Fatalf("missing sequence %d in %#v", sequence, events)
}
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if loaded == nil || loaded.EventCount != total || loaded.LastEventType != EventMessage {
t.Fatalf("metadata not updated after concurrent batches: %#v", loaded)
}
}
func eventTypesForTest(events []Event) []EventType {
types := make([]EventType, 0, len(events))
for _, event := range events {
types = append(types, event.Type)
}
return types
}
+99 -39
View File
@@ -153,6 +153,11 @@ type AppendEventInput struct {
Payload any
}
type preparedAppendEvent struct {
Type EventType
Payload json.RawMessage
}
type RecordSpecInput struct {
SpecID string
SpecFilePath string
@@ -426,7 +431,7 @@ func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, err
if err != nil {
return Metadata{}, err
}
copied := 0
copyInputs := []AppendEventInput{}
for _, event := range events {
// Do NOT copy usage accounting into the fork. It already counted against the
// parent, and a usage report that aggregates the parent and the fork would
@@ -435,11 +440,12 @@ func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, err
if event.Type == EventUsage {
continue
}
if _, err := store.AppendEvent(fork.SessionID, AppendEventInput{Type: event.Type, Payload: event.Payload}); err != nil {
return Metadata{}, err
}
copied++
copyInputs = append(copyInputs, AppendEventInput{Type: event.Type, Payload: event.Payload})
}
if _, err := store.AppendEvents(fork.SessionID, copyInputs); err != nil {
return Metadata{}, err
}
copied := len(copyInputs)
// Copy the parent's content-addressed checkpoint blobs into the fork so the
// copied EventSessionCheckpoint events resolve to real blobs and a rewind on
// the fork can restore file content (otherwise rewind reads missing blobs
@@ -513,19 +519,34 @@ func (store *Store) RecordSpec(sessionID string, input RecordSpecInput) (Metadat
}
func (store *Store) AppendEvent(sessionID string, input AppendEventInput) (Event, error) {
if !ValidSessionID(sessionID) {
return Event{}, fmt.Errorf("invalid zero session id %q", sessionID)
}
if strings.TrimSpace(string(input.Type)) == "" {
return Event{}, fmt.Errorf("zero session event type is required")
}
unlock, err := store.lockSession(sessionID)
events, err := store.AppendEvents(sessionID, []AppendEventInput{input})
if err != nil {
return Event{}, err
}
return events[0], nil
}
// AppendEvents appends a batch of events atomically under one session lock and
// durability pass. The returned events have contiguous sequences in input order.
// An empty batch is a no-op and does not rewrite metadata.
func (store *Store) AppendEvents(sessionID string, inputs []AppendEventInput) ([]Event, error) {
if !ValidSessionID(sessionID) {
return nil, fmt.Errorf("invalid zero session id %q", sessionID)
}
prepared, err := prepareAppendEventInputs(inputs)
if err != nil {
return nil, err
}
if len(prepared) == 0 {
return []Event{}, nil
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return nil, err
}
defer unlock()
return store.appendEventLocked(sessionID, input)
return store.appendPreparedEventsLocked(sessionID, prepared)
}
// AppendEventUnlessExists appends input only when exists(currentEvents) is false,
@@ -572,14 +593,27 @@ func (store *Store) AppendEventUnlessExists(sessionID string, input AppendEventI
// the single lock they already hold, instead of re-locking (which would deadlock
// on the non-reentrant in-process mutex).
func (store *Store) appendEventLocked(sessionID string, input AppendEventInput) (Event, error) {
session, err := store.readMetadata(sessionID)
prepared, err := prepareAppendEventInputs([]AppendEventInput{input})
if err != nil {
return Event{}, err
}
payload, err := rawPayload(input.Payload)
events, err := store.appendPreparedEventsLocked(sessionID, prepared)
if err != nil {
return Event{}, err
}
return events[0], nil
}
// appendPreparedEventsLocked appends pre-validated events WITHOUT acquiring the
// session lock. The caller MUST already hold store.lockSession(sessionID).
func (store *Store) appendPreparedEventsLocked(sessionID string, inputs []preparedAppendEvent) ([]Event, error) {
if len(inputs) == 0 {
return []Event{}, nil
}
session, err := store.readMetadata(sessionID)
if err != nil {
return nil, err
}
sequence := session.EventCount + 1
// The events log is the source of truth for sequencing. metadata.EventCount is
// persisted separately (after the event line below), so a crash between the two
@@ -590,46 +624,54 @@ func (store *Store) appendEventLocked(sessionID string, input AppendEventInput)
if logSeq, err := store.lastEventSequence(sessionID); err == nil && logSeq >= sequence {
sequence = logSeq + 1
}
timestamp := store.timestamp()
event := Event{
ID: fmt.Sprintf("%s:%d", sessionID, sequence),
SessionID: sessionID,
Sequence: sequence,
Type: input.Type,
CreatedAt: timestamp,
Payload: payload,
}
data, err := json.Marshal(event)
if err != nil {
return Event{}, fmt.Errorf("encode zero session event: %w", err)
events := make([]Event, 0, len(inputs))
var batch bytes.Buffer
for index, input := range inputs {
timestamp := store.timestamp()
event := Event{
ID: fmt.Sprintf("%s:%d", sessionID, sequence+index),
SessionID: sessionID,
Sequence: sequence + index,
Type: input.Type,
CreatedAt: timestamp,
Payload: input.Payload,
}
data, err := json.Marshal(event)
if err != nil {
return nil, fmt.Errorf("encode zero session event: %w", err)
}
batch.Write(data)
batch.WriteByte('\n')
events = append(events, event)
}
file, err := os.OpenFile(store.eventsPath(sessionID), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600)
if err != nil {
return Event{}, fmt.Errorf("append zero session event: %w", err)
return nil, fmt.Errorf("append zero session event: %w", err)
}
if _, err := file.Write(append(data, '\n')); err != nil {
if _, err := file.Write(batch.Bytes()); err != nil {
_ = file.Close()
return Event{}, fmt.Errorf("append zero session event: %w", err)
return nil, fmt.Errorf("append zero session event: %w", err)
}
// fsync the event before reporting success: the derived metadata.json IS
// fsync the events before reporting success: the derived metadata.json IS
// fsync'd (writeMetadata), so without this a crash after the metadata flush
// but before the events.jsonl page reaches disk leaves EventCount ahead of the
// durable log — silently losing the just-appended event (incl. the checkpoint
// durable log — silently losing just-appended events (incl. checkpoints
// that /rewind targets). Make the log at least as durable as its metadata. (AUDIT-M12)
if err := file.Sync(); err != nil {
_ = file.Close()
return Event{}, fmt.Errorf("sync zero session event: %w", err)
return nil, fmt.Errorf("sync zero session event: %w", err)
}
if err := file.Close(); err != nil {
return Event{}, fmt.Errorf("close zero session event file: %w", err)
return nil, fmt.Errorf("close zero session event file: %w", err)
}
session.UpdatedAt = timestamp
session.EventCount = sequence
session.LastEventType = input.Type
last := events[len(events)-1]
session.UpdatedAt = last.CreatedAt
session.EventCount = last.Sequence
session.LastEventType = last.Type
if err := store.writeMetadata(session); err != nil {
return Event{}, err
return nil, err
}
return event, nil
return events, nil
}
// UpdateTitle replaces a session's Title and returns the updated metadata. It is
@@ -964,6 +1006,24 @@ func rawPayload(payload any) (json.RawMessage, error) {
return data, nil
}
func prepareAppendEventInputs(inputs []AppendEventInput) ([]preparedAppendEvent, error) {
prepared := make([]preparedAppendEvent, 0, len(inputs))
for _, input := range inputs {
if strings.TrimSpace(string(input.Type)) == "" {
return nil, fmt.Errorf("zero session event type is required")
}
payload, err := rawPayload(input.Payload)
if err != nil {
return nil, err
}
prepared = append(prepared, preparedAppendEvent{
Type: input.Type,
Payload: payload,
})
}
return prepared, nil
}
func envValue(env map[string]string, key string) string {
if env != nil {
return env[key]
+1
View File
@@ -21,6 +21,7 @@ type EventType string
const (
EventRunStart EventType = "run_start"
EventText EventType = "text"
EventReasoning EventType = "reasoning"
EventToolCall EventType = "tool_call"
EventPermission EventType = "permission"
EventPermissionRequest EventType = "permission_request"
+89 -22
View File
@@ -83,7 +83,12 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
if err != nil {
return errorResult("Error: Invalid arguments for bash: " + err.Error())
}
if issue := detectShellCommandIssue(commandText, runtime.GOOS); issue != nil {
// Resolve the command engine before the MSYS preflight check so an
// approved require_escalated call (commandEngine == nil, truly
// unsandboxed) can actually bypass the MSYS guard instead of being
// hard-blocked by the same check it was meant to escalate past.
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
if issue := detectShellCommandIssue(commandText, runtime.GOOS); issue != nil && !msysGuardBypassed(issue, commandEngine) {
return shellIssueBlockResult(*issue)
}
@@ -106,7 +111,6 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
"cwd": relativeCwd,
"timeout_ms": strconv.Itoa(timeoutMS),
}
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
if commandEngine == nil && sandboxPermissions == SandboxPermissionsRequireEscalated {
meta["sandbox_permissions"] = string(SandboxPermissionsRequireEscalated)
}
@@ -126,8 +130,8 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
// can't grow Zero's memory before truncation: only the head+tail each stream
// will ever surface to the model are retained, the middle is discarded as it
// streams, and the true size is counted for the truncation marker.
stdout := newBoundedBuffer(bashOutputBudgetBytes, bashOutputBudgetBytes)
stderr := newBoundedBuffer(bashOutputBudgetBytes, bashOutputBudgetBytes)
stdout := newBoundedBuffer(bashCaptureBudgetBytes, bashCaptureBudgetBytes)
stderr := newBoundedBuffer(bashCaptureBudgetBytes, bashCaptureBudgetBytes)
command.Stdout = stdout
command.Stderr = stderr
@@ -167,7 +171,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
outText, errText, truncated := budgetBashCapture(stdoutText, stdout.total, stderrText, stderrTotal, meta)
return Result{
Status: StatusError,
Output: formatBashOutputWithShellHint(commandText, outText, errText, exitCode, meta),
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
Truncated: truncated,
Meta: meta,
}
@@ -178,7 +182,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
if meta[SandboxLikelyDeniedMeta] == "true" {
return Result{
Status: StatusError,
Output: formatBashOutputWithShellHint(commandText, outText, errText, exitCode, meta),
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
Truncated: truncated,
Meta: meta,
}
@@ -198,6 +202,18 @@ func commandEngineForSandboxPermissions(engine *zeroSandbox.Engine, sandboxPermi
return engine
}
// msysGuardBypassed reports whether a windows_msys_sandbox preflight issue no
// longer applies because sandbox_permissions: require_escalated actually
// resolved to unsandboxed, host-level execution (commandEngine is nil only
// then, per commandEngineForSandboxPermissions). MSYS coreutils fail because
// of the sandbox's restricted token/handles, so running outside it removes
// the failure mode this guard exists for. Any other issue kind (e.g. a real
// cmd.exe syntax problem) still blocks regardless of escalation, since
// running outside the sandbox does not fix a syntax error.
func msysGuardBypassed(issue *shellIssue, commandEngine *zeroSandbox.Engine) bool {
return issue.Kind == windowsMsysSandboxKind && commandEngine == nil
}
// appendSandboxBlocks appends a <sandbox_blocks> block listing the denials
// the sandbox log monitor captured, so the model can see what was blocked. With no
// blocks the stderr is returned unchanged.
@@ -370,12 +386,21 @@ func formatBashOutput(stdout string, stderr string, exitCode int) string {
return strings.Join(parts, "\n")
}
// bashOutputBudgetBytes caps each of stdout/stderr shown to the model. bash is the
// one tool that can emit unbounded output (`cat large.log`, `find /`, verbose test
// runs); every other read/search tool already budgets its output. Head+tail
// truncation keeps both the start and the end of an oversized stream, since
// build/test failures usually surface at the tail.
const bashOutputBudgetBytes = 96 * 1024
// bashOutputBudgetBytes caps each of stdout/stderr shown to the model (32 KiB
// ≈ 8k tokens per stream). bash is the one tool that can emit unbounded output
// (`cat large.log`, `find /`, verbose test runs); a chatty build log at the old
// 96 KiB budget injected ~24k tokens per call and was then re-billed on every
// following turn. Head+tail truncation keeps both the start and the end of an
// oversized stream, since build/test failures usually surface at the tail, and
// the full captured text is spilled to disk so nothing is lost — the model
// greps/reads the spill instead of re-running the command.
const bashOutputBudgetBytes = 32 * 1024
// bashCaptureBudgetBytes bounds the head and tail retained IN MEMORY per
// stream during capture. Deliberately larger than the emit budget: the extra
// retained bytes never reach the transcript, but they make the spill file —
// the model's recovery path — cover 6× more of the output.
const bashCaptureBudgetBytes = 96 * 1024
// budgetBashOutput truncates stdout and stderr to bashOutputBudgetBytes each,
// keeping the head and tail of anything larger, and records raw/emitted byte
@@ -395,6 +420,19 @@ func budgetBashCapture(out string, outTotal int, errStr string, errTotal int, me
outText, outRaw, outTrunc := truncateHeadTailWithTotal(out, outTotal, bashOutputBudgetBytes)
errText, errRaw, errTrunc := truncateHeadTailWithTotal(errStr, errTotal, bashOutputBudgetBytes)
truncated := outTrunc || errTrunc
if truncated {
if spillPath := spillBashStreams(out, outTotal, errStr, errTotal); spillPath != "" {
hint := "\n[zero] captured output saved to " + spillPath + " (grep or read_file it instead of re-running)"
if errTrunc {
errText += hint
} else {
outText += hint
}
if meta != nil {
meta["spill_path"] = spillPath
}
}
}
if meta != nil {
emitted := len(outText) + len(errText)
meta["raw_bytes"] = strconv.Itoa(outRaw + errRaw)
@@ -407,6 +445,43 @@ func budgetBashCapture(out string, outTotal int, errStr string, errTotal int, me
return outText, errText, truncated
}
// spillBashStreams writes the retained (capture-bounded) stdout and stderr to
// the spill directory as one sectioned file and returns its path, or "" when
// spilling fails. The spill holds up to bashCaptureBudgetBytes of head and
// tail per stream — everything zero kept in memory, which is more than the
// emit budget shows the model but not necessarily the whole output. When the
// capture itself dropped the middle of a stream (total exceeds the retained
// bytes), a gap marker is inserted at the head/tail junction so the spilled
// log never reads as contiguous when it is not.
func spillBashStreams(stdout string, stdoutTotal int, stderr string, stderrTotal int) string {
stdout = sectionWithCaptureGap(stdout, stdoutTotal)
stderr = sectionWithCaptureGap(stderr, stderrTotal)
var combined strings.Builder
combined.Grow(len(stdout) + len(stderr) + 64)
combined.WriteString("### stdout\n")
combined.WriteString(stdout)
if !strings.HasSuffix(stdout, "\n") {
combined.WriteString("\n")
}
combined.WriteString("### stderr\n")
combined.WriteString(stderr)
return spillTruncatedOutput("bash", combined.String())
}
// sectionWithCaptureGap marks the point where boundedBuffer dropped the middle
// of a stream. When total exceeds the retained bytes, the retained text is the
// frozen head (bashCaptureBudgetBytes, always full once overflow happened)
// followed immediately by the rolling tail — the junction sits at the head cap,
// snapped back to a rune boundary.
func sectionWithCaptureGap(text string, total int) string {
if total <= len(text) || len(text) <= bashCaptureBudgetBytes {
return text
}
head := utf8Prefix(text, bashCaptureBudgetBytes)
marker := fmt.Sprintf("\n[zero] capture gap: %d bytes omitted from the middle of this stream\n", total-len(text))
return head + marker + text[len(head):]
}
// boundedBuffer is an io.Writer that retains at most headCap bytes from the start
// and tailCap bytes from the end of a stream while counting the total written, so
// a command emitting unbounded output (`cat huge.log`, `yes`) cannot grow Zero's
@@ -461,14 +536,6 @@ func (b *boundedBuffer) retained() string {
return string(b.head) + string(b.tail)
}
// truncateHeadTail keeps the first and last halves of value when it exceeds
// maxBytes, dropping the middle behind a marker. Returns the possibly-truncated
// text, the raw byte length, and whether truncation happened.
func truncateHeadTail(value string, maxBytes int) (string, int, bool) {
// value holds the whole stream, so its length is the true raw size.
return truncateHeadTailWithTotal(value, len(value), maxBytes)
}
// truncateHeadTailWithTotal head+tail-truncates value to maxBytes, using total —
// the full original byte count — for the "N bytes omitted" marker and the raw
// count. total may exceed len(value) when the middle was already discarded during
@@ -488,9 +555,9 @@ func truncateHeadTailWithTotal(value string, total, maxBytes int) (string, int,
return utf8Prefix(value, head) + marker + utf8Suffix(value, tail), total, true
}
func formatBashOutputWithShellHint(command string, stdout string, stderr string, exitCode int, meta map[string]string) string {
func formatBashOutputWithShellHint(stdout string, stderr string, exitCode int, meta map[string]string) string {
output := formatBashOutput(stdout, stderr, exitCode)
if issue := detectShellOutputIssue(command, stdout+"\n"+stderr, runtime.GOOS); issue != nil {
if issue := detectShellOutputIssue(stdout+"\n"+stderr, runtime.GOOS); issue != nil {
meta["shell_issue"] = issue.Kind
output = appendShellIssueHint(output, *issue)
}
+63 -7
View File
@@ -2,11 +2,23 @@ package tools
import (
"fmt"
"os"
"strconv"
"strings"
"testing"
)
// setTestTempDir points every platform's temp-dir lookup at a per-test
// directory so spill files never land in the real temp dir: os.TempDir reads
// TMPDIR on Unix but TMP/TEMP on Windows.
func setTestTempDir(t *testing.T) {
t.Helper()
dir := t.TempDir()
t.Setenv("TMPDIR", dir)
t.Setenv("TMP", dir)
t.Setenv("TEMP", dir)
}
// Small output passes through untouched and records raw==emitted, no truncated flag.
func TestBudgetBashOutputSmallPassesThrough(t *testing.T) {
meta := map[string]string{}
@@ -26,8 +38,10 @@ func TestBudgetBashOutputSmallPassesThrough(t *testing.T) {
}
// Oversized stdout is truncated head+tail: both the first and last lines survive,
// the middle is dropped behind a marker, and meta is flagged.
// the middle is dropped behind a marker, meta is flagged, and the captured text
// is spilled to a re-readable file.
func TestBudgetBashOutputTruncatesHeadAndTail(t *testing.T) {
setTestTempDir(t)
head := "FIRST_LINE_MARKER\n"
tail := "\nLAST_LINE_MARKER"
big := head + strings.Repeat("x", bashOutputBudgetBytes) + tail
@@ -47,9 +61,23 @@ func TestBudgetBashOutputTruncatesHeadAndTail(t *testing.T) {
if !strings.Contains(out, "output truncated") {
t.Fatalf("expected a truncation marker, got:\n%s", out[:200])
}
if len(out) > bashOutputBudgetBytes {
// The spill hint (path length varies) is the only allowed overage past the budget.
if len(out) > bashOutputBudgetBytes+512 {
t.Fatalf("emitted %d bytes exceeds budget %d", len(out), bashOutputBudgetBytes)
}
if !strings.Contains(out, "captured output saved to ") {
t.Fatalf("truncated bash output must carry a spill hint:\n%s", out[len(out)-300:])
}
if meta["spill_path"] == "" {
t.Fatal("spill path missing from meta")
}
content, err := os.ReadFile(meta["spill_path"])
if err != nil {
t.Fatalf("spill file unreadable: %v", err)
}
if !strings.Contains(string(content), "### stdout") || !strings.Contains(string(content), strings.Repeat("x", 1024)) {
t.Fatalf("spill must hold the sectioned captured streams (got %d bytes)", len(content))
}
if meta["truncated"] != "true" {
t.Fatalf("expected truncated=true, got %v", meta)
}
@@ -100,10 +128,12 @@ func TestBoundedBufferKeepsHeadAndTailBounded(t *testing.T) {
// budgetBashCapture reports the TRUE total (not the retained size) in the marker
// and raw_bytes, even though only a bounded head+tail was ever held in memory.
func TestBudgetBashCaptureReportsTrueTotal(t *testing.T) {
// Retained head+tail as boundedBuffer would hand over; the real command produced
// far more than was kept.
retained := "HEAD_START" + strings.Repeat("y", bashOutputBudgetBytes) + "TAIL_END"
total := 10 * bashOutputBudgetBytes
setTestTempDir(t)
// Retained head+tail as boundedBuffer would hand over after overflow: the
// frozen head is full at bashCaptureBudgetBytes, then the rolling tail; the
// real command produced far more than was kept.
retained := "HEAD_START" + strings.Repeat("y", bashCaptureBudgetBytes) + "TAIL_END"
total := 10 * bashCaptureBudgetBytes
meta := map[string]string{}
out, _, truncated := budgetBashCapture(retained, total, "", 0, meta)
@@ -114,7 +144,8 @@ func TestBudgetBashCaptureReportsTrueTotal(t *testing.T) {
if !strings.Contains(out, "HEAD_START") || !strings.Contains(out, "TAIL_END") {
t.Fatalf("head/tail lost after budgeting:\n%s", out[:min(120, len(out))])
}
if len(out) > bashOutputBudgetBytes {
// Spill hint is the only allowed overage past the budget.
if len(out) > bashOutputBudgetBytes+512 {
t.Fatalf("emitted %d bytes exceeds budget %d", len(out), bashOutputBudgetBytes)
}
if meta["raw_bytes"] != strconv.Itoa(total) {
@@ -124,4 +155,29 @@ func TestBudgetBashCaptureReportsTrueTotal(t *testing.T) {
if !strings.Contains(out, strconv.Itoa(total-bashOutputBudgetBytes)) {
t.Fatalf("marker should cite the true omitted byte count:\n%s", out[:min(200, len(out))])
}
// The spill must be recorded, sectioned, and carry a capture-gap marker at
// the head/tail junction — the capture dropped the middle, so the spilled
// log must never read as contiguous.
if meta["spill_path"] == "" {
t.Fatal("spill path missing from meta")
}
content, err := os.ReadFile(meta["spill_path"])
if err != nil {
t.Fatalf("spill file unreadable: %v", err)
}
if !strings.Contains(string(content), "### stdout") {
t.Fatal("spill must be sectioned by stream")
}
gapMarker := fmt.Sprintf("capture gap: %d bytes omitted", total-len(retained))
gapIndex := strings.Index(string(content), gapMarker)
if gapIndex < 0 {
t.Fatalf("spill missing capture-gap marker %q", gapMarker)
}
// The junction sits right after the frozen head (plus the section header),
// not appended at the end of the stream.
headerOffset := len("### stdout\n")
if gapIndex < headerOffset+bashCaptureBudgetBytes-64 || gapIndex > headerOffset+bashCaptureBudgetBytes+64 {
t.Fatalf("capture-gap marker at offset %d, want ~%d (the head cap)", gapIndex, headerOffset+bashCaptureBudgetBytes)
}
}
+106 -3
View File
@@ -181,9 +181,13 @@ func TestDetectShellCommandIssueFlagsPipedPosixUtilities(t *testing.T) {
`cat file.txt | wc -l`,
`some_command | tail -n 20`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue == nil {
issue := detectShellCommandIssue(command, "windows")
if issue == nil {
t.Fatalf("expected POSIX utility pipeline to be flagged for %q", command)
}
if issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected windows_msys_sandbox for %q, got %q", command, issue.Kind)
}
}
}
@@ -195,7 +199,7 @@ func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
`echo header text`,
`tail-log.sh`,
`grep-cli --version`,
`sed.exe --help`,
`mytool.exe --help`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
t.Fatalf("expected unrelated command to pass for %q, got %#v", command, issue)
@@ -204,7 +208,7 @@ func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
}
func TestDetectShellOutputIssueAddsWindowsSyntaxHint(t *testing.T) {
issue := detectShellOutputIssue(`cd /d/tmp/zero-pr-158 && ls -la`, "The syntax of the command is incorrect.", "windows")
issue := detectShellOutputIssue("The syntax of the command is incorrect.", "windows")
if issue == nil {
t.Fatal("expected Windows syntax error to get shell guidance")
}
@@ -434,6 +438,105 @@ func TestRegistryRunsWithDegradedUnavailableNativeSandbox(t *testing.T) {
}
}
// TestBashToolRequireEscalatedMsysGuard covers the boundary of the fix for an
// escalation-vs-preflight ordering bug: the MSYS sandbox guard exists only
// because MSYS/Cygwin coreutils fail under the write-restricted sandbox, so
// once sandbox_permissions: require_escalated is actually approved (running
// the command unsandboxed), the guard must get out of the way, but an
// unrelated windows_shell_syntax issue (a real cmd.exe syntax problem, not a
// sandbox interaction) must still block regardless of escalation.
func TestBashToolRequireEscalatedMsysGuard(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("windows-only MSYS sandbox guard")
}
root := t.TempDir()
newEngine := func() *sandbox.Engine {
return sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
})
}
registry := NewRegistry()
registry.Register(NewBashTool(root))
t.Run("default sandboxing still blocks an MSYS-prone command", func(t *testing.T) {
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
"command": "cat somefile.txt",
}, RunOptions{
PermissionGranted: true,
Sandbox: newEngine(),
PermissionMode: string(sandbox.PermissionModeAsk),
})
if result.Meta["shell_issue"] != "windows_msys_sandbox" {
t.Fatalf("expected windows_msys_sandbox block without escalation, got %#v", result)
}
})
t.Run("approved require_escalated bypasses the MSYS guard", func(t *testing.T) {
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
"command": "cat somefile.txt",
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
}, RunOptions{
PermissionGranted: true,
Sandbox: newEngine(),
PermissionMode: string(sandbox.PermissionModeAsk),
})
// Assert on the preflight block sentinel (exit_code "-1", set only by
// shellIssueBlockResult) rather than shell_issue: once the guard is
// bypassed, "cat somefile.txt" actually runs, and its real,
// PATH-dependent output could otherwise trip the unrelated
// post-execution detectShellOutputIssue heuristic and make this
// assertion flaky for reasons unrelated to the guard under test.
if result.Meta["exit_code"] == "-1" {
t.Fatalf("expected approved require_escalated to bypass the MSYS guard, got blocked: %#v", result)
}
})
t.Run("approved require_escalated still blocks an unrelated syntax issue", func(t *testing.T) {
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
"command": `cd /d/tmp/zero-pr-158 && dir`,
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
}, RunOptions{
PermissionGranted: true,
Sandbox: newEngine(),
PermissionMode: string(sandbox.PermissionModeAsk),
})
if result.Meta["shell_issue"] != "windows_shell_syntax" {
t.Fatalf("expected windows_shell_syntax block to still apply under require_escalated, got %#v", result)
}
})
}
// TestBashToolIgnoresMsysMarkersInCommandArgumentsAfterFailure guards the
// post-execution MSYS heuristic against treating the command line itself as
// evidence. The helper command below fails for a reason unrelated to MSYS
// (a plain non-zero exit), but its own argument text quotes an MSYS crash
// marker the way a PR-comment or commit-message argument might. Before the
// fix, detectShellOutputIssue scanned command+output together and would have
// misdiagnosed this as an MSYS sandbox failure even though the real
// stdout/stderr never mentions MSYS at all.
func TestBashToolIgnoresMsysMarkersInCommandArgumentsAfterFailure(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("windows-only MSYS output guard")
}
root := t.TempDir()
// The helper only reads argv[2] ("fail"); this trailing quoted argument is
// otherwise unused by the helper but still part of the command line text.
command := helperCommand("fail") + ` "fatal error - CreateFileMapping S-1-5-21, Win32 error 5. Terminating. cygheap_user::init"`
result := NewBashTool(root).Run(context.Background(), map[string]any{
"command": command,
})
if result.Status != StatusError || result.Meta["exit_code"] != "7" {
t.Fatalf("expected the helper's real exit 7 failure, got %s: %#v", result.Status, result)
}
if result.Meta["shell_issue"] == "windows_msys_sandbox" {
t.Fatalf("expected the MSYS marker in the command's own argument text to be ignored, got %#v", result)
}
}
func TestBashToolRunsWithDegradedUnavailableNativeSandbox(t *testing.T) {
root := t.TempDir()
policy := sandbox.DefaultPolicy()
+36 -1
View File
@@ -2,6 +2,7 @@ package tools
import (
"context"
"errors"
"fmt"
"os"
"strings"
@@ -44,7 +45,7 @@ func (tool editFileTool) Run(ctx context.Context, args map[string]any) Result {
return tool.RunWithOptions(ctx, args, RunOptions{})
}
func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any, options RunOptions) Result {
func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result {
requestedPath, err := aliasedStringArg(args, []string{"path", "file", "file_path", "filename"}, "", true, false)
if err != nil {
return errorResult("Error: Invalid arguments for edit_file: " + err.Error())
@@ -98,6 +99,35 @@ func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any,
}
}
// Fuzzy fallback: when the exact string (and its CRLF translation) is absent,
// run a cascade of tolerant matchers (trimmed lines, block anchors, collapsed
// whitespace, indentation drift, escape normalization) to locate the span the
// model intended. Only a span that occurs literally in the file is accepted,
// so the replacement applied below is still exact.
if occurrences == 0 {
findOld, findNew := oldString, newString
if strings.Contains(content, "\r\n") && !strings.Contains(findOld, "\r\n") {
findOld = strings.ReplaceAll(findOld, "\n", "\r\n")
findNew = strings.ReplaceAll(findNew, "\n", "\r\n")
}
search, ferr := fuzzyEditMatch(content, findOld, replaceAll)
switch {
case ferr == nil:
oldString = search
// The model's new_string was written at old_string's (mismatched)
// indentation; re-shape it to the span actually being replaced so a
// tolerant match never strips indentation or a trailing CR.
newString = adaptReplacementToSpan(search, findOld, findNew)
occurrences = strings.Count(content, search)
case errors.Is(ferr, errEditFuzzyAmbiguous):
return errorResult("Error: old_string matches multiple locations in " + relativePath + " even after fuzzy matching. Provide more surrounding context to make the match unique, or pass replace_all: true.")
case errors.Is(ferr, errEditFuzzyNotFound):
// Fall through to the exact-match error below.
default:
return errorResult("Error editing " + relativePath + ": " + ferr.Error())
}
}
if occurrences == 0 {
return errorResult("Error: Could not find the exact string to replace in " + relativePath + ". The old_string must match the file byte-for-byte.")
}
@@ -120,6 +150,10 @@ func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any,
if err := os.WriteFile(absolutePath, []byte(updated), 0o644); err != nil {
return errorResult("Error writing " + relativePath + ": " + err.Error())
}
// Optional format-on-write (ZERO_FORMAT_ON_WRITE). Must run BEFORE the
// FileTracker re-baseline: recording pre-format content would make the very
// next edit look like an external modification and trip the conflict guard.
updated = maybeFormatWrittenFile(ctx, absolutePath, updated)
// Re-baseline to the content we just wrote so subsequent edits in this session
// compare against the current on-disk state, not the pre-edit version.
newInfo, _ := os.Stat(absolutePath)
@@ -130,6 +164,7 @@ func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any,
suffix = "s"
}
summary := fmt.Sprintf("Successfully edited %s (replaced %d occurrence%s).", relativePath, replacedCount, suffix)
summary += inlineDiagnostics(ctx, options, absolutePath, relativePath)
result := okResult(summary)
result.ChangedFiles = []string{relativePath}
// Card-only preview (Display.Preview): the model's Output stays the one-line
+554
View File
@@ -0,0 +1,554 @@
package tools
// Fuzzy fallback matching for edit_file. When the model's old_string fails to
// match byte-for-byte (drifted indentation, collapsed whitespace, escaped
// characters, a slightly-misremembered middle line), these replacers propose
// candidate spans that plausibly correspond to what the model intended. Only a
// candidate that occurs literally in the file is accepted, so the replacement
// itself is always exact even when the match was tolerant.
//
// Strategy cascade ported from opencode's edit tool, whose replacers were in
// turn distilled from Cline's diff-apply evals and gemini-cli's edit corrector.
import (
"errors"
"regexp"
"sort"
"strings"
)
// editReplacer proposes candidate spans for find inside content. Candidates
// are re-validated by fuzzyEditMatch before use.
type editReplacer func(content, find string) []string
var (
errEditFuzzyNotFound = errors.New("no fuzzy match for old_string")
errEditFuzzyAmbiguous = errors.New("fuzzy match for old_string is ambiguous")
)
// Minimum average middle-line similarity for block-anchor matches.
const editAnchorSimilarityThreshold = 0.65
// fuzzyEditMatch runs the replacer cascade and returns the exact span of
// content to replace. When replaceAll is false the span must be unique in
// content; an ambiguous candidate is skipped in favor of later candidates and
// only reported if nothing unique is found. A span wildly larger than
// old_string is refused outright rather than risking a destructive edit.
func fuzzyEditMatch(content, find string, replaceAll bool) (string, error) {
replacers := []editReplacer{
lineTrimmedReplacer,
blockAnchorReplacer,
whitespaceNormalizedReplacer,
indentationFlexibleReplacer,
escapeNormalizedReplacer,
trimmedBoundaryReplacer,
contextAwareReplacer,
}
found := false
for _, replacer := range replacers {
// Collect the replacer's DISTINCT candidate spans that literally occur in
// content. Two or more distinct spans from one strategy (e.g. duplicate
// blocks at different indentation, each occurring once) mean the model's
// intent is genuinely ambiguous — silently editing the first would be a
// wrong-span write, so it is rejected instead.
var candidates []string
seen := map[string]bool{}
for _, search := range replacer(content, find) {
if search == "" || seen[search] {
continue
}
seen[search] = true
if !strings.Contains(content, search) {
continue
}
candidates = append(candidates, search)
}
if len(candidates) == 0 {
continue
}
found = true
if !replaceAll && len(candidates) > 1 {
return "", errEditFuzzyAmbiguous
}
search := candidates[0]
if isDisproportionateEditMatch(search, find) {
return "", errors.New("refusing replacement because the matched span is much larger than old_string; re-read the file and provide the full exact old_string for the intended replacement")
}
if replaceAll {
return search, nil
}
if strings.Index(content, search) == strings.LastIndex(content, search) {
return search, nil
}
// The single candidate occurs at multiple positions; a later, stricter
// strategy may still resolve a unique span, so keep cascading.
}
if !found {
return "", errEditFuzzyNotFound
}
return "", errEditFuzzyAmbiguous
}
// isDisproportionateEditMatch guards against anchor-style replacers matching a
// span far larger than the text the model asked to replace (e.g. first/last
// line anchors bridging hundreds of unrelated lines).
func isDisproportionateEditMatch(search, find string) bool {
findLines := strings.Count(find, "\n") + 1
searchLines := strings.Count(search, "\n") + 1
limit := findLines + 3
if findLines*2 > limit {
limit = findLines * 2
}
if searchLines >= limit {
return true
}
if findLines == 1 {
return false
}
searchTrimmed := len(strings.TrimSpace(search))
findTrimmed := len(strings.TrimSpace(find))
byteLimit := findTrimmed + 500
if findTrimmed*4 > byteLimit {
byteLimit = findTrimmed * 4
}
return searchTrimmed > byteLimit
}
// splitFindLines splits find into lines, dropping the trailing empty line a
// trailing newline produces so windows align with real content lines.
func splitFindLines(find string) []string {
lines := strings.Split(find, "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
return lines
}
// lineTrimmedReplacer matches when every line equals the corresponding
// old_string line after trimming surrounding whitespace.
func lineTrimmedReplacer(content, find string) []string {
contentLines := strings.Split(content, "\n")
findLines := splitFindLines(find)
if len(findLines) == 0 {
return nil
}
var candidates []string
for i := 0; i+len(findLines) <= len(contentLines); i++ {
matches := true
for j := range findLines {
if strings.TrimSpace(contentLines[i+j]) != strings.TrimSpace(findLines[j]) {
matches = false
break
}
}
if matches {
candidates = append(candidates, strings.Join(contentLines[i:i+len(findLines)], "\n"))
}
}
return candidates
}
// blockAnchorReplacer anchors on the first and last lines (trimmed) and
// accepts the block when the middle lines average >= the similarity threshold
// (Levenshtein-based), tolerating a slightly misremembered interior.
func blockAnchorReplacer(content, find string) []string {
findLines := splitFindLines(find)
if len(findLines) < 3 {
return nil
}
contentLines := strings.Split(content, "\n")
firstAnchor := strings.TrimSpace(findLines[0])
lastAnchor := strings.TrimSpace(findLines[len(findLines)-1])
searchBlockSize := len(findLines)
maxLineDelta := searchBlockSize / 4
if maxLineDelta < 1 {
maxLineDelta = 1
}
type span struct{ start, end int }
var candidates []span
for i := 0; i < len(contentLines); i++ {
if strings.TrimSpace(contentLines[i]) != firstAnchor {
continue
}
// Only the first occurrence of the last anchor after this start counts,
// mirroring the reference implementation: a farther-away closing line is
// assumed to close a different block.
for j := i + 2; j < len(contentLines); j++ {
if strings.TrimSpace(contentLines[j]) != lastAnchor {
continue
}
actualBlockSize := j - i + 1
delta := actualBlockSize - searchBlockSize
if delta < 0 {
delta = -delta
}
if delta <= maxLineDelta {
candidates = append(candidates, span{start: i, end: j})
}
break
}
}
if len(candidates) == 0 {
return nil
}
middleSimilarity := func(candidate span) float64 {
actualBlockSize := candidate.end - candidate.start + 1
linesToCheck := searchBlockSize - 2
if actualBlockSize-2 < linesToCheck {
linesToCheck = actualBlockSize - 2
}
if linesToCheck <= 0 {
return 1.0
}
similarity := 0.0
for j := 1; j < searchBlockSize-1 && j < actualBlockSize-1; j++ {
contentLine := strings.TrimSpace(contentLines[candidate.start+j])
findLine := strings.TrimSpace(findLines[j])
maxLen := len(contentLine)
if len(findLine) > maxLen {
maxLen = len(findLine)
}
if maxLen == 0 {
continue
}
distance := levenshtein(contentLine, findLine)
similarity += 1 - float64(distance)/float64(maxLen)
}
return similarity / float64(linesToCheck)
}
// Return EVERY candidate that clears the similarity threshold, best first.
// Picking only the best would hide competing blocks from fuzzyEditMatch's
// distinct-candidate ambiguity check and could silently edit the wrong
// block; with all qualifying spans surfaced, one clear winner still
// resolves (single candidate) while two plausible blocks are rejected as
// ambiguous. replaceAll consumers take the first (most similar) span.
type scoredSpan struct {
span span
similarity float64
}
var qualifying []scoredSpan
for _, candidate := range candidates {
if s := middleSimilarity(candidate); s >= editAnchorSimilarityThreshold {
qualifying = append(qualifying, scoredSpan{span: candidate, similarity: s})
}
}
if len(qualifying) == 0 {
return nil
}
sort.SliceStable(qualifying, func(i, j int) bool {
return qualifying[i].similarity > qualifying[j].similarity
})
spans := make([]string, 0, len(qualifying))
for _, scored := range qualifying {
spans = append(spans, strings.Join(contentLines[scored.span.start:scored.span.end+1], "\n"))
}
return spans
}
var editWhitespaceRun = regexp.MustCompile(`\s+`)
func normalizeEditWhitespace(text string) string {
return strings.TrimSpace(editWhitespaceRun.ReplaceAllString(text, " "))
}
// whitespaceNormalizedReplacer matches after collapsing all whitespace runs to
// a single space: full lines, sub-line spans (via a word-boundary regex), and
// multi-line windows.
func whitespaceNormalizedReplacer(content, find string) []string {
normalizedFind := normalizeEditWhitespace(find)
if normalizedFind == "" {
return nil
}
contentLines := strings.Split(content, "\n")
var candidates []string
var subLinePattern *regexp.Regexp
for _, line := range contentLines {
normalizedLine := normalizeEditWhitespace(line)
if normalizedLine == normalizedFind {
candidates = append(candidates, line)
continue
}
if !strings.Contains(normalizedLine, normalizedFind) {
continue
}
if subLinePattern == nil {
words := strings.Fields(find)
quoted := make([]string, len(words))
for i, word := range words {
quoted[i] = regexp.QuoteMeta(word)
}
pattern, err := regexp.Compile(strings.Join(quoted, `\s+`))
if err != nil {
continue
}
subLinePattern = pattern
}
if match := subLinePattern.FindString(line); match != "" {
candidates = append(candidates, match)
}
}
findLines := strings.Split(find, "\n")
if len(findLines) > 1 {
for i := 0; i+len(findLines) <= len(contentLines); i++ {
block := strings.Join(contentLines[i:i+len(findLines)], "\n")
if normalizeEditWhitespace(block) == normalizedFind {
candidates = append(candidates, block)
}
}
}
return candidates
}
// stripCommonIndentation removes the minimum leading-whitespace width shared
// by all non-empty lines, so blocks match regardless of their nesting depth.
func stripCommonIndentation(text string) string {
lines := strings.Split(text, "\n")
minIndent := -1
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
indent := len(line) - len(strings.TrimLeft(line, " \t"))
if minIndent < 0 || indent < minIndent {
minIndent = indent
}
}
if minIndent <= 0 {
return text
}
stripped := make([]string, len(lines))
for i, line := range lines {
if strings.TrimSpace(line) == "" {
stripped[i] = line
continue
}
stripped[i] = line[minIndent:]
}
return strings.Join(stripped, "\n")
}
func indentationFlexibleReplacer(content, find string) []string {
normalizedFind := stripCommonIndentation(find)
contentLines := strings.Split(content, "\n")
findLines := strings.Split(find, "\n")
var candidates []string
for i := 0; i+len(findLines) <= len(contentLines); i++ {
block := strings.Join(contentLines[i:i+len(findLines)], "\n")
if stripCommonIndentation(block) == normalizedFind {
candidates = append(candidates, block)
}
}
return candidates
}
var editEscapeSequence = regexp.MustCompile("\\\\(n|t|r|'|\"|`|\\\\|\\n|\\$)")
// unescapeEditString undoes one level of string escaping (\n, \t, \", \\, a
// backslash-newline continuation, \$) — the model sometimes reproduces file
// content as it appeared inside a quoted string literal.
func unescapeEditString(text string) string {
return editEscapeSequence.ReplaceAllStringFunc(text, func(match string) string {
switch match[1:] {
case "n":
return "\n"
case "t":
return "\t"
case "r":
return "\r"
case "\n":
return "\n"
default:
// ', ", `, \, $ all unescape to themselves.
return match[1:]
}
})
}
func escapeNormalizedReplacer(content, find string) []string {
unescapedFind := unescapeEditString(find)
var candidates []string
if strings.Contains(content, unescapedFind) {
candidates = append(candidates, unescapedFind)
}
contentLines := strings.Split(content, "\n")
findLines := strings.Split(unescapedFind, "\n")
for i := 0; i+len(findLines) <= len(contentLines); i++ {
block := strings.Join(contentLines[i:i+len(findLines)], "\n")
if unescapeEditString(block) == unescapedFind {
candidates = append(candidates, block)
}
}
return candidates
}
// trimmedBoundaryReplacer tolerates stray leading/trailing whitespace (often
// blank lines) around an otherwise-exact old_string.
func trimmedBoundaryReplacer(content, find string) []string {
trimmedFind := strings.TrimSpace(find)
if trimmedFind == find || trimmedFind == "" {
return nil
}
var candidates []string
if strings.Contains(content, trimmedFind) {
candidates = append(candidates, trimmedFind)
}
contentLines := strings.Split(content, "\n")
findLines := strings.Split(find, "\n")
for i := 0; i+len(findLines) <= len(contentLines); i++ {
block := strings.Join(contentLines[i:i+len(findLines)], "\n")
if strings.TrimSpace(block) == trimmedFind {
candidates = append(candidates, block)
}
}
return candidates
}
// contextAwareReplacer anchors on the first and last lines and accepts an
// equal-length block when at least half of its non-empty middle lines match
// after trimming — a cheaper, stricter cousin of blockAnchorReplacer.
func contextAwareReplacer(content, find string) []string {
findLines := splitFindLines(find)
if len(findLines) < 3 {
return nil
}
contentLines := strings.Split(content, "\n")
firstAnchor := strings.TrimSpace(findLines[0])
lastAnchor := strings.TrimSpace(findLines[len(findLines)-1])
var candidates []string
for i := 0; i < len(contentLines); i++ {
if strings.TrimSpace(contentLines[i]) != firstAnchor {
continue
}
for j := i + 2; j < len(contentLines); j++ {
if strings.TrimSpace(contentLines[j]) != lastAnchor {
continue
}
if j-i+1 == len(findLines) {
matching, nonEmpty := 0, 0
for k := 1; k < len(findLines)-1; k++ {
blockLine := strings.TrimSpace(contentLines[i+k])
findLine := strings.TrimSpace(findLines[k])
if blockLine == "" && findLine == "" {
continue
}
nonEmpty++
if blockLine == findLine {
matching++
}
}
if nonEmpty == 0 || float64(matching)/float64(nonEmpty) >= 0.5 {
candidates = append(candidates, strings.Join(contentLines[i:j+1], "\n"))
}
}
break
}
}
return candidates
}
// adaptReplacementToSpan re-shapes the model's replacement to the span a
// tolerant matcher resolved. When old_string only matched after normalization,
// new_string was written at old_string's (wrong) shape, so applying it raw
// would strip the file's indentation or drop a trailing CR:
//
// 1. Uniform re-indent: when every span line equals delta + the corresponding
// find line's indentation (the line-trimmed / indentation-flexible shapes),
// the same delta is prepended to every non-blank replacement line. Any
// line that breaks the uniform-delta relationship disables the shift —
// block-anchor matches with a drifted interior are left untouched.
// 2. Trailing CR: a span from a CRLF file ends mid-line at "\r" (candidates
// are built by joining lines split on "\n"); the replacement gets the same
// trailing "\r" so the file's CRLF pairs stay intact.
func adaptReplacementToSpan(span, find, replacement string) string {
if delta, ok := uniformIndentDelta(span, find); ok && delta != "" {
lines := strings.Split(replacement, "\n")
for i, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
lines[i] = delta + line
}
replacement = strings.Join(lines, "\n")
}
if strings.HasSuffix(span, "\r") && !strings.HasSuffix(replacement, "\r") {
replacement += "\r"
}
return replacement
}
// uniformIndentDelta returns the indentation prefix that, prepended to every
// non-blank find line, yields the corresponding span line's indentation. ok is
// false when line counts differ, any line pair disagrees on the delta, or the
// span is not simply a uniformly deeper-indented copy of find.
func uniformIndentDelta(span, find string) (string, bool) {
spanLines := strings.Split(span, "\n")
findLines := splitFindLines(find)
if len(spanLines) != len(findLines) {
return "", false
}
leadingWhitespace := func(line string) string {
return line[:len(line)-len(strings.TrimLeft(line, " \t"))]
}
delta := ""
haveDelta := false
for i := range findLines {
spanLine := strings.TrimSuffix(spanLines[i], "\r")
findLine := strings.TrimSuffix(findLines[i], "\r")
if strings.TrimSpace(spanLine) == "" && strings.TrimSpace(findLine) == "" {
continue
}
spanIndent := leadingWhitespace(spanLine)
findIndent := leadingWhitespace(findLine)
if !strings.HasSuffix(spanIndent, findIndent) {
return "", false
}
lineDelta := spanIndent[:len(spanIndent)-len(findIndent)]
if !haveDelta {
delta = lineDelta
haveDelta = true
continue
}
if lineDelta != delta {
return "", false
}
}
return delta, haveDelta
}
// levenshtein computes edit distance with a two-row rolling matrix.
func levenshtein(a, b string) int {
if a == "" {
return len(b)
}
if b == "" {
return len(a)
}
previous := make([]int, len(b)+1)
current := make([]int, len(b)+1)
for j := range previous {
previous[j] = j
}
for i := 1; i <= len(a); i++ {
current[0] = i
for j := 1; j <= len(b); j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
minimum := previous[j] + 1
if current[j-1]+1 < minimum {
minimum = current[j-1] + 1
}
if previous[j-1]+cost < minimum {
minimum = previous[j-1] + cost
}
current[j] = minimum
}
previous, current = current, previous
}
return previous[len(b)]
}
+369
View File
@@ -0,0 +1,369 @@
package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func runEdit(t *testing.T, dir, initial string, args map[string]any) (Result, string) {
t.Helper()
path := filepath.Join(dir, "target.txt")
if err := os.WriteFile(path, []byte(initial), 0o644); err != nil {
t.Fatal(err)
}
if args["path"] == nil {
args["path"] = "target.txt"
}
result := NewEditFileTool(dir).Run(context.Background(), args)
after, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return result, string(after)
}
func TestEditExactMatchStillPreferred(t *testing.T) {
// The fast path must keep byte-exact semantics: an exact match wins even
// when fuzzy strategies would also find candidates elsewhere.
result, after := runEdit(t, t.TempDir(), "alpha\nbeta\ngamma\n", map[string]any{
"old_string": "beta",
"new_string": "delta",
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %q", result.Output)
}
if after != "alpha\ndelta\ngamma\n" {
t.Fatalf("unexpected content: %q", after)
}
}
func TestEditFuzzyLineTrimmed(t *testing.T) {
// Model reproduced the lines without the file's leading indentation; the
// line-trimmed strategy must find the real indented span and preserve the
// file's own indentation on the replaced region boundary.
initial := "func a() {\n\tx := 1\n\treturn x\n}\n"
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
"old_string": "x := 1\nreturn x",
"new_string": "y := 2\nreturn y",
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %q", result.Output)
}
// new_string was written at old_string's outdented shape; the span's
// indentation must be re-applied so the file keeps its tabs.
if !strings.Contains(after, "\ty := 2\n\treturn y\n}") {
t.Fatalf("unexpected content: %q", after)
}
if strings.Contains(after, "x := 1") {
t.Fatalf("old span survived: %q", after)
}
}
func TestEditFuzzyWhitespaceNormalizedSingleLine(t *testing.T) {
// Whitespace runs collapsed: "x := 1" in the file, single spaces from
// the model.
initial := "start\nx := 1\nend\n"
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
"old_string": "x := 1",
"new_string": "x := 2",
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %q", result.Output)
}
if !strings.Contains(after, "x := 2") || strings.Contains(after, ":= 1") {
t.Fatalf("unexpected content: %q", after)
}
}
func TestEditFuzzyBlockAnchorToleratesMiddleDrift(t *testing.T) {
// First and last lines anchor the block; one interior line differs slightly
// (comment text drifted). Levenshtein similarity keeps it above 0.65.
initial := strings.Join([]string{
"func handler(w http.ResponseWriter, r *http.Request) {",
"\t// write the response body to the client",
"\tw.WriteHeader(http.StatusOK)",
"\tfmt.Fprint(w, \"done\")",
"}",
"",
}, "\n")
find := strings.Join([]string{
"func handler(w http.ResponseWriter, r *http.Request) {",
"\t// write the response body to client",
"\tw.WriteHeader(http.StatusOK)",
"\tfmt.Fprint(w, \"done\")",
"}",
}, "\n")
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
"old_string": find,
"new_string": "func handler(w http.ResponseWriter, r *http.Request) {\n\tw.WriteHeader(http.StatusNoContent)\n}",
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %q", result.Output)
}
if !strings.Contains(after, "StatusNoContent") || strings.Contains(after, "StatusOK") {
t.Fatalf("unexpected content: %q", after)
}
}
func TestEditFuzzyIndentationFlexible(t *testing.T) {
// The whole block sits one nesting level deeper in the file than in the
// model's old_string, with interior relative indentation preserved.
initial := "if ok {\n\t\tfor i := range xs {\n\t\t\tsum += xs[i]\n\t\t}\n}\n"
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
"old_string": "for i := range xs {\n\tsum += xs[i]\n}",
"new_string": "sum += total(xs)",
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %q", result.Output)
}
// The replacement lands at the span's real depth (two tabs), not at
// old_string's outdented depth.
if !strings.Contains(after, "\t\tsum += total(xs)") || strings.Contains(after, "range xs") {
t.Fatalf("unexpected content: %q", after)
}
}
func TestEditFuzzyCRLFLineTrimmedPreservesCarriageReturns(t *testing.T) {
// CRLF file + a multi-line outdented old_string: the resolved span carries
// tabs and trailing CRs; the replacement must inherit both so the file's
// CRLF pairs and indentation survive the edit.
initial := "func a() {\r\n\tx := 1\r\n\treturn x\r\n}\r\n"
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
"old_string": "x := 1\nreturn x",
"new_string": "y := 2\nreturn y",
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %q", result.Output)
}
if !strings.Contains(after, "\ty := 2\r\n\treturn y\r\n}") {
t.Fatalf("unexpected content: %q", after)
}
if strings.Contains(after, "\n\treturn y\n") {
t.Fatalf("bare LF introduced into CRLF file: %q", after)
}
}
func TestUniformIndentDelta(t *testing.T) {
cases := []struct {
span, find string
want string
ok bool
}{
{"\tx := 1\n\treturn x", "x := 1\nreturn x", "\t", true},
{"\t\tfor {\n\t\t\tgo()\n\t\t}", "for {\n\tgo()\n}", "\t\t", true},
{"x := 1", "x := 1", "", true}, // no shift needed
{"\tx := 1\n return x", "x := 1\nreturn x", "", false}, // mixed delta
{"\ta\n\tb\n\tc", "a\nb", "", false}, // line-count mismatch
{" a", "\ta", "", false}, // find indent not a suffix
}
for _, c := range cases {
got, ok := uniformIndentDelta(c.span, c.find)
if ok != c.ok || (ok && got != c.want) {
t.Errorf("uniformIndentDelta(%q, %q) = %q,%v want %q,%v", c.span, c.find, got, ok, c.want, c.ok)
}
}
}
func TestAdaptReplacementLeavesNonUniformSpansAlone(t *testing.T) {
// Block-anchor style match with a drifted interior: no uniform delta, so
// the replacement must pass through untouched.
span := "\tfunc h() {\n\t\t// drifted comment\n\t}"
find := "func h() {\n// different comment\n}"
if got := adaptReplacementToSpan(span, find, "replacement()"); got != "replacement()" {
t.Fatalf("non-uniform span must not re-indent: %q", got)
}
}
func TestEditFuzzyEscapeNormalized(t *testing.T) {
// Model reproduced the line as it would appear inside a quoted string
// literal, with escaped quotes.
initial := "console.log(\"hello world\")\n"
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
"old_string": `console.log(\"hello world\")`,
"new_string": `console.log("goodbye")`,
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %q", result.Output)
}
if !strings.Contains(after, `console.log("goodbye")`) {
t.Fatalf("unexpected content: %q", after)
}
}
func TestEditFuzzyTrimmedBoundary(t *testing.T) {
// Stray whitespace around an otherwise-exact old_string; " two " is not a
// substring of the file, so only the fuzzy cascade can resolve it.
initial := "one\ntwo\nthree\n"
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
"old_string": " two ",
"new_string": "2",
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %q", result.Output)
}
if after != "one\n2\nthree\n" {
t.Fatalf("unexpected content: %q", after)
}
}
func TestTrimmedBoundaryReplacerYieldsTrimmedSpan(t *testing.T) {
candidates := trimmedBoundaryReplacer("one\ntwo\nthree\n", "\ntwo\n")
if len(candidates) == 0 || candidates[0] != "two" {
t.Fatalf("expected trimmed candidate \"two\", got %v", candidates)
}
if trimmedBoundaryReplacer("anything", "already-trimmed") != nil {
t.Fatal("already-trimmed find must yield no candidates")
}
}
// Two identical blocks that only match old_string after indentation-tolerant
// matching: exact search finds nothing, fuzzy finds both.
const ambiguousBlocks = "\tif x {\n\t\tgo()\n\t}\nmid\n\tif x {\n\t\tgo()\n\t}\n"
func TestEditFuzzyAmbiguousReportsError(t *testing.T) {
// No strategy can disambiguate identical blocks, so the tool must refuse
// rather than guess, and the file must be untouched.
result, after := runEdit(t, t.TempDir(), ambiguousBlocks, map[string]any{
"old_string": "if x {\n\tgo()\n}",
"new_string": "stop()",
})
if result.Status != StatusError || !strings.Contains(result.Output, "multiple locations") {
t.Fatalf("expected ambiguity error, got %q", result.Output)
}
if after != ambiguousBlocks {
t.Fatalf("file must be unchanged, got %q", after)
}
}
func TestEditFuzzyReplaceAll(t *testing.T) {
// replace_all applies the fuzzy-resolved span at every occurrence.
result, after := runEdit(t, t.TempDir(), ambiguousBlocks, map[string]any{
"old_string": "if x {\n\tgo()\n}",
"new_string": "stop()",
"replace_all": true,
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %q", result.Output)
}
if strings.Count(after, "stop()") != 2 || strings.Contains(after, "go()") {
t.Fatalf("unexpected content: %q", after)
}
}
func TestEditFuzzyNotFoundKeepsExactError(t *testing.T) {
result, after := runEdit(t, t.TempDir(), "alpha\n", map[string]any{
"old_string": "omega",
"new_string": "x",
})
if result.Status != StatusError || !strings.Contains(result.Output, "Could not find the exact string") {
t.Fatalf("expected not-found error, got %q", result.Output)
}
if after != "alpha\n" {
t.Fatalf("file must be unchanged, got %q", after)
}
}
func TestEditFuzzyCRLFFile(t *testing.T) {
// CRLF file + LF old_string with indentation drift: the CRLF translation
// feeds the cascade and the replacement preserves CRLF endings.
initial := "func a() {\r\n\tx := 1\r\n}\r\n"
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
"old_string": "x := 1",
"new_string": "x := 2",
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %q", result.Output)
}
if !strings.Contains(after, "\tx := 2\r\n") {
t.Fatalf("unexpected content: %q", after)
}
}
func TestIsDisproportionateEditMatch(t *testing.T) {
// A candidate span that dwarfs old_string must be refused: anchors bridging
// unrelated code would otherwise delete it all.
big := strings.Repeat("line\n", 10)
if !isDisproportionateEditMatch(big, "a\nb\nc") {
t.Fatal("10-line span for 3-line find must be disproportionate")
}
if isDisproportionateEditMatch("a\nb\nc\nd", "a\nb\nc") {
t.Fatal("one extra line within tolerance must be allowed")
}
if isDisproportionateEditMatch("single line", "single") {
t.Fatal("single-line finds are exempt from the byte-length guard")
}
if !isDisproportionateEditMatch("xx\n"+strings.Repeat("y", 900)+"\nzz", "xx\nab\nzz") {
t.Fatal("byte-length blowup must be disproportionate")
}
}
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"abc", "", 3},
{"", "abc", 3},
{"kitten", "sitting", 3},
{"same", "same", 0},
}
for _, c := range cases {
if got := levenshtein(c.a, c.b); got != c.want {
t.Fatalf("levenshtein(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
}
}
}
func TestEditFuzzyDistinctCandidatesAreAmbiguous(t *testing.T) {
// Two same-content blocks at DIFFERENT indentation: each resolved span is
// distinct and occurs exactly once, so the old literal-uniqueness check
// passed and silently edited the first block. Distinct-shaped candidates
// from one strategy must instead report ambiguity and leave the file alone.
initial := "\tif x {\n\t\tgo()\n\t}\nmid\n\t\tif x {\n\t\t\tgo()\n\t\t}\n"
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
"old_string": "if x {\n\tgo()\n}",
"new_string": "stop()",
})
if result.Status != StatusError || !strings.Contains(result.Output, "multiple locations") {
t.Fatalf("distinct fuzzy candidates must be ambiguous, got %q", result.Output)
}
if after != initial {
t.Fatalf("file must be unchanged, got %q", after)
}
}
func TestEditFuzzyBlockAnchorTwoPlausibleBlocksAmbiguous(t *testing.T) {
// Two blocks share the same first/last anchor lines and BOTH interiors sit
// above the similarity threshold. Picking the "best" one would silently
// edit a block the model may not have meant; both must surface so the
// cascade reports ambiguity and the file stays untouched.
initial := strings.Join([]string{
"func setup(cfg Config) {",
"\tvalue := compute(alpha, beta, gamma)",
"}",
"",
"func setup(cfg Config) {",
"\tvalue := compute(alpha, delta, gamma)",
"}",
"",
}, "\n")
find := strings.Join([]string{
"func setup(cfg Config) {",
"\tvalue := compute(alpha, omega, gamma)",
"}",
}, "\n")
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
"old_string": find,
"new_string": "func setup(cfg Config) {}",
})
if result.Status != StatusError || !strings.Contains(result.Output, "multiple locations") {
t.Fatalf("two plausible anchor blocks must be ambiguous, got %q", result.Output)
}
if after != initial {
t.Fatalf("file must be unchanged, got %q", after)
}
}
+26 -2
View File
@@ -517,7 +517,12 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
if err != nil {
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
}
if issue := detectShellCommandIssue(commandText, runtimeGOOS()); issue != nil {
// Resolve the command engine before the MSYS preflight check so an
// approved require_escalated call (commandEngine == nil, truly
// unsandboxed) can actually bypass the MSYS guard instead of being
// hard-blocked by the same check it was meant to escalate past.
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
if issue := detectShellCommandIssue(commandText, runtimeGOOS()); issue != nil && !msysGuardBypassed(issue, commandEngine) {
return shellIssueBlockResult(*issue)
}
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
@@ -860,6 +865,12 @@ func execToolResult(input execToolResultInput) Result {
status = StatusError
}
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
if status == StatusError && input.exited && !input.interrupted {
if issue := detectShellOutputIssue(output, runtimeGOOS()); issue != nil {
meta["shell_issue"] = issue.Kind
body = appendShellIssueHint(body, *issue)
}
}
if input.outputBufferTruncated {
// Appended after truncateExecOutput's own head/tail slicing, not
// embedded in the text that goes through it — a marker inside that
@@ -908,6 +919,15 @@ func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode
}
func truncateExecOutput(output string, maxOutputTokens int) (string, bool) {
return truncateExecOutputSpill(output, maxOutputTokens, "exec_command")
}
// truncateExecOutputSpill keeps a head/tail window of the output within the
// token budget and, on truncation, spills the full output to disk so the model
// can grep/read the elided middle instead of re-running the command with a
// bigger budget. The spill is best-effort: when it fails the notice simply
// omits the file hint.
func truncateExecOutputSpill(output string, maxOutputTokens int, toolName string) (string, bool) {
if maxOutputTokens <= 0 {
maxOutputTokens = defaultMaxOutputTokens
}
@@ -915,9 +935,13 @@ func truncateExecOutput(output string, maxOutputTokens int) (string, bool) {
if len(output) <= maxBytes {
return output, false
}
notice := "\n[zero] output truncated\n"
if spillPath := spillTruncatedOutput(toolName, output); spillPath != "" {
notice = "\n[zero] output truncated — full output saved to " + spillPath + " (grep or read_file it instead of re-running)\n"
}
head := maxBytes / 2
tail := maxBytes - head
return utf8Prefix(output, head) + "\n[zero] output truncated\n" + utf8Suffix(output, tail), true
return utf8Prefix(output, head) + notice + utf8Suffix(output, tail), true
}
func utf8Prefix(value string, maxBytes int) string {
+40
View File
@@ -141,6 +141,46 @@ func TestExecCommandRequireEscalatedBypassesNativeSandboxAfterApproval(t *testin
}
}
// TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval mirrors
// TestBashToolRequireEscalatedMsysGuard for exec_command: the MSYS sandbox
// guard exists only because MSYS/Cygwin coreutils fail under the
// write-restricted sandbox, so once require_escalated is actually approved
// (unsandboxed execution), the guard must not block the same command it was
// meant to let escalate past.
func TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("windows-only MSYS sandbox guard")
}
root := t.TempDir()
manager := newExecSessionManager()
registry := NewRegistry()
registry.Register(NewScopedExecCommandTool(root, nil, manager))
engine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
})
result := registry.RunWithOptions(context.Background(), ExecCommandToolName, map[string]any{
"cmd": "cat somefile.txt",
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
}, RunOptions{
PermissionGranted: true,
Sandbox: engine,
PermissionMode: string(sandbox.PermissionModeAsk),
})
// Assert on the preflight block sentinel (exit_code "-1", set only by
// shellIssueBlockResult) rather than shell_issue: once the guard is
// bypassed, "cat somefile.txt" actually runs, and its real,
// PATH-dependent output could otherwise trip the unrelated
// post-execution detectShellOutputIssue heuristic and make this
// assertion flaky for reasons unrelated to the guard under test.
if result.Meta["exit_code"] == "-1" {
t.Fatalf("expected approved require_escalated to bypass the MSYS guard, got blocked: %#v", result)
}
}
func TestExecCommandReturnsExitCodeWhenCommandCompletesDuringInitialYield(t *testing.T) {
root := t.TempDir()
manager := newExecSessionManager()
+97
View File
@@ -0,0 +1,97 @@
package tools
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// Format-on-write for the mutating file tools. When enabled, a successful
// edit_file/write_file runs the language's standard formatter on the file it
// just wrote, so the model's output always lands in project-canonical style
// and never fails a CI format check it cannot see. Off by default (set
// ZERO_FORMAT_ON_WRITE=1): auto-reformatting changes bytes the model did not
// write, which strict workflows may not want.
//
// Ordering matters: formatting runs BEFORE the FileTracker re-baseline, and
// the caller records the POST-format content. Formatting after the baseline
// would make the very next edit look like an external modification and trip
// the conflict guard.
// formatOnWriteTimeout bounds one formatter run; a wedged formatter must never
// hang a tool call. On timeout the unformatted write stands.
const formatOnWriteTimeout = 10 * time.Second
// formatterCommands maps a file extension to the formatter argv; the file path
// is appended as the final argument. Only in-place, config-respecting,
// community-standard formatters — a missing binary silently skips formatting.
var formatterCommands = map[string][]string{
".go": {"gofmt", "-w"},
".rs": {"rustfmt"},
".py": {"ruff", "format", "--quiet"},
".ts": {"prettier", "--log-level", "silent", "--write"},
".tsx": {"prettier", "--log-level", "silent", "--write"},
".js": {"prettier", "--log-level", "silent", "--write"},
".jsx": {"prettier", "--log-level", "silent", "--write"},
".json": {"prettier", "--log-level", "silent", "--write"},
".css": {"prettier", "--log-level", "silent", "--write"},
".scss": {"prettier", "--log-level", "silent", "--write"},
".html": {"prettier", "--log-level", "silent", "--write"},
".md": {"prettier", "--log-level", "silent", "--write"},
".yaml": {"prettier", "--log-level", "silent", "--write"},
".yml": {"prettier", "--log-level", "silent", "--write"},
".zig": {"zig", "fmt"},
".dart": {"dart", "format"},
".tf": {"terraform", "fmt"},
".gleam": {"gleam", "format"},
".sh": {"shfmt", "-w"},
".bash": {"shfmt", "-w"},
".c": {"clang-format", "-i"},
".h": {"clang-format", "-i"},
".cpp": {"clang-format", "-i"},
".hpp": {"clang-format", "-i"},
".cc": {"clang-format", "-i"},
".kt": {"ktlint", "-F"},
".swift": {"swiftformat"},
".lua": {"stylua"},
}
// formatOnWriteEnabled reports whether the opt-in env toggle is set.
func formatOnWriteEnabled() bool {
value := strings.TrimSpace(os.Getenv("ZERO_FORMAT_ON_WRITE"))
return value != "" && value != "0" && !strings.EqualFold(value, "false")
}
// maybeFormatWrittenFile runs the configured formatter for absolutePath (when
// enabled and on PATH) and returns the file's content afterwards. Best-effort
// throughout: any failure — no formatter, formatter error, timeout, unreadable
// result — returns writtenContent so the caller's state matches the last write
// it performed itself.
func maybeFormatWrittenFile(ctx context.Context, absolutePath string, writtenContent string) string {
if !formatOnWriteEnabled() {
return writtenContent
}
command, ok := formatterCommands[strings.ToLower(filepath.Ext(absolutePath))]
if !ok {
return writtenContent
}
if _, err := exec.LookPath(command[0]); err != nil {
return writtenContent
}
formatCtx, cancel := context.WithTimeout(ctx, formatOnWriteTimeout)
defer cancel()
arguments := append(append([]string(nil), command[1:]...), absolutePath)
formatter := exec.CommandContext(formatCtx, command[0], arguments...)
formatter.Dir = filepath.Dir(absolutePath)
if err := formatter.Run(); err != nil {
return writtenContent
}
formatted, err := os.ReadFile(absolutePath)
if err != nil {
return writtenContent
}
return string(formatted)
}
+82
View File
@@ -0,0 +1,82 @@
package tools
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// gofmt ships with the Go toolchain, so it is the one formatter guaranteed to
// exist wherever these tests run.
func requireGofmt(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("gofmt"); err != nil {
t.Skip("gofmt not on PATH")
}
}
func TestFormatOnWriteDisabledByDefault(t *testing.T) {
requireGofmt(t)
t.Setenv("ZERO_FORMAT_ON_WRITE", "")
dir := t.TempDir()
ugly := "package a\n\nfunc A( ) { }\n"
result := NewWriteFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": "a.go",
"content": ugly,
}, RunOptions{})
if result.Status != StatusOK {
t.Fatalf("write failed: %q", result.Output)
}
onDisk, err := os.ReadFile(filepath.Join(dir, "a.go"))
if err != nil {
t.Fatal(err)
}
if string(onDisk) != ugly {
t.Fatalf("formatting must be off by default, got %q", onDisk)
}
}
func TestFormatOnWriteFormatsAndKeepsTrackerConsistent(t *testing.T) {
requireGofmt(t)
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
dir := t.TempDir()
tracker := NewFileTracker()
write := NewWriteFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": "a.go",
"content": "package a\n\nfunc A( ) { }\n",
}, RunOptions{FileTracker: tracker})
if write.Status != StatusOK {
t.Fatalf("write failed: %q", write.Output)
}
onDisk, err := os.ReadFile(filepath.Join(dir, "a.go"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(onDisk), "func A() {") {
t.Fatalf("expected gofmt-formatted content, got %q", onDisk)
}
// The tracker must have been re-baselined to the POST-format content: a
// follow-up edit must not trip the external-modification conflict guard.
edit := NewEditFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": "a.go",
"old_string": "func A() {",
"new_string": "func B() {",
}, RunOptions{FileTracker: tracker})
if edit.Status != StatusOK {
t.Fatalf("follow-up edit must not conflict after formatting: %q", edit.Output)
}
}
func TestFormatOnWriteSkipsUnknownExtensions(t *testing.T) {
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
content := maybeFormatWrittenFile(context.Background(), filepath.Join(t.TempDir(), "notes.xyz"), "raw text")
if content != "raw text" {
t.Fatalf("unknown extension must pass through: %q", content)
}
}
+18
View File
@@ -0,0 +1,18 @@
package tools
import "context"
// inlineDiagnostics renders the post-write diagnostics block a mutating tool
// appends to its output. Empty when no Diagnostics callback is wired, the file
// is clean, or no language server is available — the tool output is then
// byte-identical to the pre-diagnostics behavior.
func inlineDiagnostics(ctx context.Context, options RunOptions, absolutePath, relativePath string) string {
if options.Diagnostics == nil {
return ""
}
block := options.Diagnostics(ctx, absolutePath)
if block == "" {
return ""
}
return "\n\nDiagnostics in " + relativePath + " after this change (fix any errors you introduced):\n" + block
}
+53
View File
@@ -0,0 +1,53 @@
package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// A wired Diagnostics callback must surface its block in the tool output for
// both mutating tools, and a clean file (empty block) must leave the output
// byte-identical to the pre-diagnostics behavior.
func TestMutatingToolsAppendInlineDiagnostics(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.go"), []byte("package a\n"), 0o644); err != nil {
t.Fatal(err)
}
diagnostics := func(_ context.Context, absPath string) string {
return absPath + ":1:1: error: undefined: x"
}
editResult := NewEditFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": "a.go",
"old_string": "package a",
"new_string": "package b",
}, RunOptions{Diagnostics: diagnostics})
if editResult.Status != StatusOK {
t.Fatalf("edit failed: %q", editResult.Output)
}
if !strings.Contains(editResult.Output, "Diagnostics in a.go after this change") || !strings.Contains(editResult.Output, "undefined: x") {
t.Fatalf("edit output missing diagnostics block: %q", editResult.Output)
}
writeResult := NewWriteFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": "b.go",
"content": "package b\n",
}, RunOptions{Diagnostics: diagnostics})
if writeResult.Status != StatusOK {
t.Fatalf("write failed: %q", writeResult.Output)
}
if !strings.Contains(writeResult.Output, "Diagnostics in b.go after this change") {
t.Fatalf("write output missing diagnostics block: %q", writeResult.Output)
}
clean := NewWriteFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": "c.go",
"content": "package c\n",
}, RunOptions{Diagnostics: func(context.Context, string) string { return "" }})
if strings.Contains(clean.Output, "Diagnostics") {
t.Fatalf("clean file must not gain a diagnostics block: %q", clean.Output)
}
}
+81
View File
@@ -0,0 +1,81 @@
package tools
import (
"os"
"strconv"
"strings"
)
// Universal output ceiling. Every tool result leaving the registry boundary is
// capped at a single token-denominated budget unless the tool manages its own
// deliberate budget (selfBudgeting below). This is the safety net for tools
// with no cap of their own — web_fetch bodies, skill files, MCP server tools,
// browser snapshots, anything added later — so no single call can flood the
// context window with output that is then re-billed on every following turn.
// Truncation keeps head+tail and spills the full text to disk (re-readable via
// grep/read_file), so nothing is lost, only deferred.
// defaultOutputCeilingTokens is the per-call ceiling in estimated tokens
// (bytes/4). 16k tokens = 64 KiB — deliberately equal to the search-tool
// budget: generous enough for a large fetched document, small enough that one
// call cannot eat a third of a small context window.
const defaultOutputCeilingTokens = 16_000
// outputCeilingEnv overrides the ceiling (in tokens). Zero or negative
// disables the ceiling entirely; unset or unparsable keeps the default.
const outputCeilingEnv = "ZERO_TOOL_OUTPUT_CEILING_TOKENS"
// selfBudgeting marks a tool that enforces its own deliberate output budget —
// possibly model-raisable (exec_command) — which the registry ceiling must not
// second-guess. The method is unexported on purpose: only tools in this
// package can opt out, so an MCP-served tool can never exempt itself.
type selfBudgeting interface{ managesOutputBudget() }
// The exemption list, kept in one place. Each of these applies its own budget
// before returning: bash (bashOutputBudgetBytes per stream + spill),
// exec_command (model-raisable token budget + spill), read tools (128 KiB),
// search tools (64 KiB).
func (bashTool) managesOutputBudget() {}
func (execCommandTool) managesOutputBudget() {}
func (readFileTool) managesOutputBudget() {}
func (readMinifiedFileTool) managesOutputBudget() {}
func (grepTool) managesOutputBudget() {}
func (globTool) managesOutputBudget() {}
func (listDirectoryTool) managesOutputBudget() {}
func resolveOutputCeilingTokens() int {
raw := strings.TrimSpace(os.Getenv(outputCeilingEnv))
if raw == "" {
return defaultOutputCeilingTokens
}
parsed, err := strconv.Atoi(raw)
if err != nil {
return defaultOutputCeilingTokens
}
return parsed
}
// enforceOutputCeiling truncates an over-ceiling result to head+tail within
// the token budget, spilling the full (already redaction-scrubbed) output to
// disk with a recovery hint. Runs after scrubResultSecrets so the transcript
// and the spill file agree on what was hidden.
func enforceOutputCeiling(toolName string, result Result) Result {
ceiling := resolveOutputCeilingTokens()
if ceiling <= 0 {
return result
}
if len(result.Output) <= ceiling*4 {
return result
}
rawBytes := len(result.Output)
truncated, _ := truncateExecOutputSpill(result.Output, ceiling, toolName)
result.Output = truncated
result.Truncated = true
if result.Meta == nil {
result.Meta = map[string]string{}
}
result.Meta["raw_bytes"] = strconv.Itoa(rawBytes)
result.Meta["emitted_bytes"] = strconv.Itoa(len(result.Output))
result.Meta["estimated_tokens"] = strconv.Itoa(estimatedTokensFromBytes(len(result.Output)))
return result
}
+140
View File
@@ -0,0 +1,140 @@
package tools
import (
"context"
"os"
"strconv"
"strings"
"testing"
)
// ceilingFakeTool is a minimal tool with no output budget of its own — the
// stand-in for an MCP-served tool or any future builtin that forgets to cap
// its output.
type ceilingFakeTool struct {
baseTool
output string
}
func (tool ceilingFakeTool) Run(context.Context, map[string]any) Result {
return okResult(tool.output)
}
// ceilingExemptTool is the same fake but marked self-budgeting.
type ceilingExemptTool struct{ ceilingFakeTool }
func (ceilingExemptTool) managesOutputBudget() {}
func newCeilingFakeTool(name, output string) ceilingFakeTool {
return ceilingFakeTool{
baseTool: baseTool{name: name, safety: readOnlySafety("test tool")},
output: output,
}
}
// An unbudgeted tool's oversized output is capped at the universal ceiling,
// head and tail survive, and the full output is spilled to a re-readable file.
func TestOutputCeilingCapsUnbudgetedTool(t *testing.T) {
setTestTempDir(t)
big := "HEAD_MARK\n" + strings.Repeat("z", defaultOutputCeilingTokens*4*3) + "\nTAIL_MARK"
registry := NewRegistry()
registry.Register(newCeilingFakeTool("fake_big", big))
result := registry.Run(context.Background(), "fake_big", map[string]any{})
if !result.Truncated {
t.Fatal("over-ceiling output must report truncated=true")
}
maxBytes := defaultOutputCeilingTokens * 4
if len(result.Output) > maxBytes+512 {
t.Fatalf("emitted %d bytes, ceiling is %d", len(result.Output), maxBytes)
}
if !strings.Contains(result.Output, "HEAD_MARK") || !strings.Contains(result.Output, "TAIL_MARK") {
t.Fatal("head/tail lost at the ceiling")
}
if result.Meta["raw_bytes"] != strconv.Itoa(len(big)) {
t.Fatalf("raw_bytes = %s, want %d", result.Meta["raw_bytes"], len(big))
}
if !strings.Contains(result.Output, "full output saved to ") {
t.Fatalf("ceiling truncation must include a spill hint: %q", result.Output[:200])
}
start := strings.Index(result.Output, "full output saved to ") + len("full output saved to ")
end := strings.Index(result.Output[start:], " (grep")
content, err := os.ReadFile(result.Output[start : start+end])
if err != nil {
t.Fatalf("spill file unreadable: %v", err)
}
if string(content) != big {
t.Fatalf("spill must hold the full output: got %d bytes, want %d", len(content), len(big))
}
}
// A self-budgeting tool's output passes the boundary untouched even when it
// exceeds the universal ceiling — its own (possibly model-raised) budget wins.
func TestOutputCeilingSkipsSelfBudgetingTool(t *testing.T) {
big := strings.Repeat("z", defaultOutputCeilingTokens*4*2)
registry := NewRegistry()
registry.Register(ceilingExemptTool{newCeilingFakeTool("fake_exempt", big)})
result := registry.Run(context.Background(), "fake_exempt", map[string]any{})
if result.Truncated || result.Output != big {
t.Fatalf("self-budgeting tool must bypass the ceiling (truncated=%v, %d bytes)", result.Truncated, len(result.Output))
}
}
// Under-ceiling output is untouched and gains no budget metadata.
func TestOutputCeilingSmallOutputUntouched(t *testing.T) {
registry := NewRegistry()
registry.Register(newCeilingFakeTool("fake_small", "hello"))
result := registry.Run(context.Background(), "fake_small", map[string]any{})
if result.Truncated || result.Output != "hello" {
t.Fatalf("small output altered: %+v", result)
}
if _, ok := result.Meta["raw_bytes"]; ok {
t.Fatal("small output must not gain budget metadata")
}
}
// ZERO_TOOL_OUTPUT_CEILING_TOKENS tightens, loosens, or disables the ceiling.
func TestOutputCeilingEnvOverride(t *testing.T) {
setTestTempDir(t)
big := strings.Repeat("z", 100*1024)
t.Setenv(outputCeilingEnv, "1000")
registry := NewRegistry()
registry.Register(newCeilingFakeTool("fake_env", big))
result := registry.Run(context.Background(), "fake_env", map[string]any{})
if !result.Truncated || len(result.Output) > 1000*4+512 {
t.Fatalf("ceiling override ignored: truncated=%v, %d bytes", result.Truncated, len(result.Output))
}
t.Setenv(outputCeilingEnv, "0")
result = registry.Run(context.Background(), "fake_env", map[string]any{})
if result.Truncated || len(result.Output) != len(big) {
t.Fatalf("ceiling=0 must disable the net: truncated=%v, %d bytes", result.Truncated, len(result.Output))
}
}
// The exemption list matches the tools that really do budget themselves, and
// the unbudgeted ones (web_fetch, skill, browser) stay under the net.
func TestSelfBudgetingExemptionList(t *testing.T) {
dir := t.TempDir()
exempt := []Tool{
NewBashTool(dir),
NewExecCommandTool(dir, newExecSessionManager()),
NewReadFileTool(dir),
NewReadMinifiedFileTool(dir),
NewGrepTool(dir),
NewGlobTool(dir),
NewListDirectoryTool(dir),
}
for _, tool := range exempt {
if _, ok := tool.(selfBudgeting); !ok {
t.Errorf("%s must be exempt from the output ceiling", tool.Name())
}
}
if _, ok := NewWebFetchTool().(selfBudgeting); ok {
t.Error("web_fetch must NOT be exempt — the ceiling is its only budget")
}
}
+19 -2
View File
@@ -40,6 +40,12 @@ type RunOptions struct {
// tool-call progress in the specialist card. nil is a no-op (the default
// for every non-Task tool).
Progress func(streamjson.Event)
// Diagnostics, when set, returns a formatted language-diagnostics block for
// a file a mutating tool just wrote ("" when clean or no server available).
// edit_file/write_file append it to their output so the model sees an error
// it introduced in the same turn instead of waiting for a later verification
// pass. nil disables inline diagnostics.
Diagnostics func(ctx context.Context, absPath string) string
}
type sandboxAwareTool interface {
@@ -117,13 +123,24 @@ func (registry *Registry) Run(ctx context.Context, name string, args map[string]
func (registry *Registry) RunWithOptions(ctx context.Context, name string, args map[string]any, options RunOptions) (result Result) {
// Every return path passes through scrubResultSecrets exactly once, so denial,
// permission, and unknown-tool error messages (which can echo secret-bearing
// args/paths) are redacted at the boundary just like tool output.
defer func() { result = scrubResultSecrets(result) }()
// args/paths) are redacted at the boundary just like tool output. The output
// ceiling runs after the scrub so the transcript and the spill file agree on
// what was hidden.
ceilingExempt := false
defer func() {
result = scrubResultSecrets(result)
if !ceilingExempt {
result = enforceOutputCeiling(name, result)
}
}()
tool, ok := registry.Get(name)
if !ok {
return errorResult(`Error: Unknown tool "` + name + `".`)
}
if _, ok := tool.(selfBudgeting); ok {
ceilingExempt = true
}
if rejecter, ok := tool.(PrePermissionRejecter); ok {
if res, rejected := rejecter.RejectBeforePermission(args); rejected {
return res
+179 -21
View File
@@ -3,6 +3,7 @@ package tools
import (
"regexp"
"strings"
"unicode"
)
type shellRuntime struct {
@@ -17,14 +18,31 @@ type shellIssue struct {
Suggestion string
}
const windowsMsysSandboxKind = "windows_msys_sandbox"
const windowsMsysSandboxSuggestion = "MSYS/Cygwin coreutils from Git for Windows cannot run under Zero's write-restricted Windows sandbox. Prefer Zero native tools (grep, read_file with offset/limit, list_directory, glob), cmd.exe findstr/more, or PowerShell Select-Object -First/-Last. If host-level execution is truly required, rerun with sandbox_permissions: \"require_escalated\" and a narrow justification."
// windowsMsysProneNames is the single source of truth for POSIX coreutil names
// that commonly resolve to a Git-for-Windows MSYS/Cygwin binary rather than a
// cmd.exe-native command, and so fail under the write-restricted Windows
// sandbox (#458). Every Windows MSYS-detection path (the preflight command
// scan below, the exported MsysProneCommandName, and the known-safe-segment
// guard in internal/agent/command_prefix.go) derives from this one set, so
// they cannot drift out of sync with each other.
var windowsMsysProneNames = map[string]bool{
"cat": true, "cut": true, "expr": true, "grep": true, "head": true,
"id": true, "ls": true, "nl": true, "paste": true, "rev": true,
"seq": true, "stat": true, "tail": true, "tr": true, "uname": true,
"uniq": true, "wc": true, "which": true, "awk": true, "sed": true,
"xargs": true,
}
var (
windowsBashStyleCDPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)cd\s+/(?:[a-ce-z0-9_./~-]|d[a-z0-9_./~-])[a-z0-9_./~-]*`)
windowsLSCommandPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)ls\b(?:\s+|$)`)
// windowsPosixUtilityPattern catches the other common POSIX coreutils/grep
// family commands cmd.exe has no equivalent for (unlike `dir`/`findstr`,
// which sound similar enough that a model might reach for the POSIX name
// instead) — most often seen piped in, e.g. `git log ... | head`.
windowsPosixUtilityPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)(head|tail|grep|wc|awk|sed|cut|xargs|tr)(?:\s+|$)`)
// windowsMsysBinaryPathPattern catches explicit Git-for-Windows / MSYS usr\bin
// paths. These executables are valid Windows PE files but fail under the
// write-restricted sandbox with CreateFileMapping ACCESS_DENIED (#458).
windowsMsysBinaryPathPattern = regexp.MustCompile(`(?i)(?:\\usr\\bin\\|\\mingw64\\bin\\|msys-2\.0\.dll|cygwin1\.dll)`)
)
func detectShellRuntime(goos string) shellRuntime {
@@ -37,48 +55,162 @@ func detectShellRuntime(goos string) shellRuntime {
func shellGuidanceForGOOS(goos string) string {
runtime := detectShellRuntime(goos)
if goos == "windows" {
return "Uses " + runtime.Syntax + " syntax on Windows; prefer cwd over cd when changing directories."
return "Uses " + runtime.Syntax + " syntax on Windows; prefer cwd over cd when changing directories. MSYS/Cygwin coreutils on PATH (Git for Windows usr\\bin) are not sandbox-compatible; prefer native Zero file tools."
}
guidance := "Uses " + runtime.Syntax + " syntax."
if goos == "darwin" {
// `ps` is setuid root and cannot run under the macOS sandbox; `pgrep` needs a
// blocked system service. Point the model at the tools that DO work so it
// doesn't waste turns: lsof to find a process, kill to stop it.
guidance += " To find or stop a process, use `lsof -i :PORT` (or `lsof -nP -iTCP -sTCP:LISTEN`) for the PID then `kill <pid>`; `ps` and `pgrep` do not work under the sandbox."
}
return guidance
}
// MsysProneCommandName reports whether a bare command name commonly resolves to
// a Git-for-Windows MSYS binary that fails under the Windows restricted sandbox.
func MsysProneCommandName(name string) bool {
return windowsMsysProneNames[strings.ToLower(strings.TrimSpace(name))]
}
func windowsMsysSandboxIssue(message string) *shellIssue {
return &shellIssue{
Kind: windowsMsysSandboxKind,
Message: message,
Suggestion: windowsMsysSandboxSuggestion,
}
}
// windowsCommandSegments splits a command into cmd.exe-operator-separated
// segments (&, |, and their doubled forms &&/||), respecting double quotes
// (cmd.exe's grouping construct) and the caret (^) escape character, so an
// operator or command name mentioned inside a quoted argument (e.g. a commit
// message or PR comment body), or an operator escaped with ^ (cmd.exe prints
// `echo ^| head` as literal text instead of piping to head), is not mistaken
// for a real segment boundary or invocation. Unlike bash, cmd.exe does not
// treat ; as a statement separator, so it is left as ordinary argument text
// (e.g. `echo foo; head` is a single `echo` invocation with literal args).
func windowsCommandSegments(command string) []string {
var segments []string
var current strings.Builder
inQuotes := false
runes := []rune(command)
for i := 0; i < len(runes); i++ {
c := runes[i]
if !inQuotes && c == '^' && i+1 < len(runes) {
current.WriteRune(c)
i++
current.WriteRune(runes[i])
continue
}
if c == '"' {
inQuotes = !inQuotes
current.WriteRune(c)
continue
}
if !inQuotes && (c == '&' || c == '|') {
if seg := strings.TrimSpace(current.String()); seg != "" {
segments = append(segments, seg)
}
current.Reset()
continue
}
current.WriteRune(c)
}
if seg := strings.TrimSpace(current.String()); seg != "" {
segments = append(segments, seg)
}
return segments
}
// firstCommandWord returns the first token of a command segment. A leading
// double-quoted span counts as one token with its quotes stripped, since
// cmd.exe treats a quoted path as a single argument: the command invoked by
// `"C:\Program Files\Git\usr\bin\head.exe" file.txt` is the quoted path, not
// "C:\Program. For an unquoted command, the token ends at whitespace or a
// redirection operator (<, >): cmd.exe accepts redirection attached directly
// to the command name with no separating space (head>out.txt, cat<in.txt), so
// stopping only at whitespace would return "head>out.txt" as one word and
// miss the invoked command.
func firstCommandWord(segment string) string {
trimmed := strings.TrimSpace(segment)
if trimmed == "" {
return ""
}
if trimmed[0] == '"' {
if end := strings.IndexByte(trimmed[1:], '"'); end >= 0 {
return trimmed[1 : end+1]
}
return trimmed[1:]
}
if end := strings.IndexFunc(trimmed, isCommandWordBoundary); end >= 0 {
return trimmed[:end]
}
return trimmed
}
func isCommandWordBoundary(r rune) bool {
return unicode.IsSpace(r) || r == '<' || r == '>'
}
// msysProneCommandWord reports whether word (the first token of a command
// segment, as returned by firstCommandWord) names an MSYS-prone coreutil,
// bare or with a directory prefix and/or .exe suffix (head, head.exe,
// C:\...\usr\bin\head.exe, ...).
func msysProneCommandWord(word string) bool {
word = strings.Trim(word, `"`)
if i := strings.LastIndexAny(word, `\/`); i >= 0 {
word = word[i+1:]
}
word = strings.TrimSuffix(strings.ToLower(word), ".exe")
return MsysProneCommandName(word)
}
func detectShellCommandIssue(command string, goos string) *shellIssue {
if goos != "windows" {
return nil
}
trimmed := strings.TrimSpace(command)
if windowsBashStyleCDPattern.MatchString(trimmed) ||
windowsLSCommandPattern.MatchString(trimmed) {
if windowsBashStyleCDPattern.MatchString(trimmed) {
return &shellIssue{
Kind: "windows_shell_syntax",
Message: "Command looks like POSIX/Bash syntax, but Zero runs bash tool commands through Windows cmd.exe on this host.",
Suggestion: "Use the cwd argument instead of cd, use Windows cmd.exe syntax, or use native tools such as list_directory, read_file, grep, and glob.",
}
}
if windowsPosixUtilityPattern.MatchString(trimmed) {
return &shellIssue{
Kind: "windows_shell_syntax",
Message: "Command uses a POSIX utility (head/tail/grep/wc/awk/sed/cut/xargs/tr) that Windows cmd.exe does not have.",
Suggestion: "Use cmd.exe equivalents (e.g. `findstr` for grep, `more` to page output) or Zero's native tools (grep, read_file with offset/limit) instead of piping to a POSIX utility.",
// Check the first word of each operator-separated segment (not the raw
// text anywhere in the command) against the MSYS binary-path pattern and
// the single MSYS-prone name set, covering bare names (head), .exe names
// (head.exe), and directory-prefixed forms (C:\...\head.exe) uniformly.
// Being segment/word anchored rather than a whole-string regex or scan,
// neither check matches text that only appears inside a quoted argument
// (e.g. a commit message or PR comment body discussing head.exe).
for _, segment := range windowsCommandSegments(trimmed) {
word := firstCommandWord(segment)
if windowsMsysBinaryPathPattern.MatchString(word) {
return windowsMsysSandboxIssue("Command invokes an MSYS/Cygwin binary path that cannot run under Zero's Windows sandbox.")
}
if msysProneCommandWord(word) {
return windowsMsysSandboxIssue("Command uses a POSIX coreutil (head/tail/grep/cat/...) that commonly resolves to Git-for-Windows MSYS binaries incompatible with the Windows sandbox.")
}
}
return nil
}
func detectShellOutputIssue(command string, output string, goos string) *shellIssue {
// detectShellOutputIssue looks for MSYS runtime crash markers and cmd.exe
// syntax-error text in output only, never in the command that was run. The
// command line is attacker/user-controlled argument text (e.g. a `gh pr
// comment --body` quoting a sample failure), not something the shell
// produced, so treating it as evidence would reintroduce the same
// quoted-text false positives the preflight command-position check exists to
// avoid, just after execution instead of before it.
func detectShellOutputIssue(output string, goos string) *shellIssue {
if goos != "windows" {
return nil
}
haystack := strings.ToLower(command + "\n" + output)
if strings.Contains(haystack, "the syntax of the command is incorrect") ||
strings.Contains(haystack, "is not recognized as an internal or external command") {
lower := strings.ToLower(output)
if msysRuntimeFailedInOutput(lower) {
return windowsMsysSandboxIssue("An MSYS/Cygwin runtime failed under Zero's Windows sandbox (ACCESS_DENIED during MSYS startup).")
}
if strings.Contains(lower, "the syntax of the command is incorrect") ||
strings.Contains(lower, "is not recognized as an internal or external command") {
return &shellIssue{
Kind: "windows_shell_syntax",
Message: "Windows cmd.exe rejected the command syntax.",
@@ -88,6 +220,32 @@ func detectShellOutputIssue(command string, output string, goos string) *shellIs
return nil
}
func msysRuntimeFailedInOutput(lower string) bool {
if strings.Contains(lower, "fatal error - createfilemapping") {
return true
}
if strings.Contains(lower, "couldn't create signal pipe") && strings.Contains(lower, "win32 error 5") {
return true
}
if strings.Contains(lower, "cygheap_user::init") && strings.Contains(lower, "fatal error") {
return true
}
if strings.Contains(lower, "usr\\bin\\") && strings.Contains(lower, "fatal error") {
return true
}
if !strings.Contains(lower, "win32 error 5") || !strings.Contains(lower, "terminating") {
return false
}
// Anchor the broad win32-error-5 fallback to an MSYS-specific marker so
// unrelated access-denied failures are not mislabeled as MSYS sandbox
// incompatibilities.
return strings.Contains(lower, `usr\bin\`) ||
strings.Contains(lower, "cygheap") ||
strings.Contains(lower, "msys-2.0.dll") ||
strings.Contains(lower, "cygwin1.dll") ||
strings.Contains(lower, "[main]")
}
func appendShellIssueHint(output string, issue shellIssue) string {
output = strings.TrimRight(output, "\r\n")
hint := "[zero] shell issue: " + issue.Message
+208
View File
@@ -0,0 +1,208 @@
package tools
import (
"strings"
"testing"
)
func TestDetectShellCommandIssueFlagsMsysBinaryPaths(t *testing.T) {
for _, command := range []string{
`for /F %i in ('whoami') do echo %i | "C:\Program Files\Git\usr\bin\head.exe" -1`,
`C:\Git\usr\bin\grep.exe pattern file.txt`,
} {
issue := detectShellCommandIssue(command, "windows")
if issue == nil {
t.Fatalf("expected MSYS path issue for %q", command)
}
if issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected windows_msys_sandbox kind, got %q", issue.Kind)
}
if !strings.Contains(issue.Suggestion, "require_escalated") {
t.Fatalf("expected escalation guidance, got %#v", issue)
}
}
}
func TestDetectShellCommandIssueFlagsStandaloneCat(t *testing.T) {
issue := detectShellCommandIssue(`cat README.md`, "windows")
if issue == nil || issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected MSYS sandbox issue for cat, got %#v", issue)
}
}
func TestDetectShellOutputIssueFlagsMsysCreateFileMappingError(t *testing.T) {
output := `0 [main] head (3568) C:\Program Files\Git\usr\bin\head.exe: *** fatal error - CreateFileMapping S-1-5-21-3149109338-1484423945-518236903-1001.1, Win32 error 5. Terminating.`
issue := detectShellOutputIssue(output, "windows")
if issue == nil || issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected MSYS output issue, got %#v", issue)
}
if !strings.Contains(issue.Suggestion, "require_escalated") {
t.Fatalf("expected escalation guidance, got %#v", issue)
}
}
func TestDetectShellOutputIssueFlagsMsysSignalPipeError(t *testing.T) {
output := `0 [main] head (39684) cygheap_user::init: NtSetInformationToken (TokenDefaultDacl), 0xC0000022
648 [main] head (39684) C:\Program Files\Git\usr\bin\head.exe: *** fatal error - couldn't create signal pipe, Win32 error 5`
issue := detectShellOutputIssue(output, "windows")
if issue == nil || issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected MSYS output issue, got %#v", issue)
}
}
func TestDetectShellOutputIssueFlagsMsysTerminatingWithMsysMarker(t *testing.T) {
output := `1 [main] tail (4321) tail: *** MapViewOfFileEx failed, Win32 error 5. Terminating.`
issue := detectShellOutputIssue(output, "windows")
if issue == nil || issue.Kind != "windows_msys_sandbox" {
t.Fatalf("expected MSYS output issue, got %#v", issue)
}
}
func TestDetectShellOutputIssueIgnoresNonMsysWin32Error5(t *testing.T) {
output := `myapp.exe: unable to open service handle, Win32 error 5 (access denied). Terminating worker.`
issue := detectShellOutputIssue(output, "windows")
if issue != nil {
t.Fatalf("expected no issue for non-MSYS access-denied output, got %#v", issue)
}
}
func TestShellIssueBlockResultMsysCommand(t *testing.T) {
result := shellIssueBlockResult(*detectShellCommandIssue(`cat README.md`, "windows"))
if result.Status != StatusError {
t.Fatalf("status = %q, want error", result.Status)
}
for _, want := range []string{"[zero] shell issue:", "MSYS/Cygwin", "grep", "read_file", "require_escalated"} {
if !strings.Contains(result.Output, want) {
t.Fatalf("expected %q in blocked output, got %q", want, result.Output)
}
}
if result.Meta["shell_issue"] != "windows_msys_sandbox" {
t.Fatalf("meta shell_issue = %q", result.Meta["shell_issue"])
}
}
func TestMsysProneCommandName(t *testing.T) {
if !MsysProneCommandName("HEAD") || MsysProneCommandName("echo") {
t.Fatalf("unexpected MsysProneCommandName results")
}
}
// TestDetectShellCommandIssueFlagsExprAndLsConsistently guards against the
// preflight regex list drifting from the canonical windowsMsysProneNames set
// (both listed expr and ls as MSYS-prone, but the old regex alternations
// omitted expr entirely and let ls hit the older windows_shell_syntax branch
// first, so it never got MSYS-kind guidance).
func TestDetectShellCommandIssueFlagsExprAndLsConsistently(t *testing.T) {
for _, command := range []string{
`expr 1 + 1`,
`expr.exe 1 + 1`,
`ls -la`,
`ls`,
} {
issue := detectShellCommandIssue(command, "windows")
if issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected windows_msys_sandbox for %q, got %#v", command, issue)
}
}
}
// TestDetectShellCommandIssueIgnoresQuotedMsysMentions guards against
// treating an MSYS-prone name that only appears inside a quoted argument
// (e.g. a commit message, a PR comment body, or a doc string discussing the
// command) as an actual invocation. The preflight check must anchor on the
// first word of each command segment, not scan the raw text anywhere.
func TestDetectShellCommandIssueIgnoresQuotedMsysMentions(t *testing.T) {
for _, command := range []string{
`git commit -m "fix head.exe crash"`,
`gh pr comment --body "grep.exe fails under MSYS"`,
`echo "log | head is broken on windows"`,
`git commit -m "note: | head does not work here"`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
t.Fatalf("expected quoted MSYS mention to pass for %q, got %#v", command, issue)
}
}
// A real invocation alongside quoted text must still be caught.
if issue := detectShellCommandIssue(`echo "not a real command" && head file.txt`, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected real head invocation to still be flagged, got %#v", issue)
}
}
// TestDetectShellCommandIssueIgnoresQuotedMsysPathMentions guards the
// explicit MSYS-binary-path check the same way as the coreutil-name check:
// a full usr\bin\ path that only appears inside a quoted argument (e.g. a
// commit message describing the failure) must not be treated as an
// invocation, since the path check is now anchored to the first word of each
// command segment rather than scanned across the raw command text.
func TestDetectShellCommandIssueIgnoresQuotedMsysPathMentions(t *testing.T) {
for _, command := range []string{
`git commit -m "C:\Program Files\Git\usr\bin\head.exe fails"`,
`gh pr comment --body "C:\Git\usr\bin\grep.exe is blocked"`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
t.Fatalf("expected quoted MSYS path mention to pass for %q, got %#v", command, issue)
}
}
// A real invocation by full path must still be caught.
if issue := detectShellCommandIssue(`C:\Git\usr\bin\grep.exe pattern file.txt`, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected real MSYS path invocation to still be flagged, got %#v", issue)
}
}
// TestDetectShellCommandIssueRespectsCaretEscapedOperators guards against
// misreading cmd.exe's ^ escape character: `echo ^| head` prints the pipe and
// "head" as literal text (the caret escapes the pipe so it never splits into
// a separate `head` invocation), and `echo foo; head` is a single `echo`
// command with literal arguments since cmd.exe (unlike bash) does not treat
// ; as a statement separator.
func TestDetectShellCommandIssueRespectsCaretEscapedOperators(t *testing.T) {
for _, command := range []string{
`echo ^| head`,
`echo ^& head`,
`echo foo; head`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
t.Fatalf("expected no issue for %q, got %#v", command, issue)
}
}
// An unescaped pipe must still split into a real head invocation.
if issue := detectShellCommandIssue(`echo foo | head`, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected real head invocation to still be flagged, got %#v", issue)
}
}
// TestDetectShellCommandIssueFlagsRedirectionAttachedToCommand guards against
// firstCommandWord treating cmd.exe redirection operators as part of the
// command name. cmd.exe accepts redirection with no separating space
// (head>out.txt, cat<in.txt), so splitting only on whitespace would return
// "head>out.txt" as one word and miss the invoked command entirely.
func TestDetectShellCommandIssueFlagsRedirectionAttachedToCommand(t *testing.T) {
for _, command := range []string{
`some-command | head>out.txt`,
`cat<in.txt`,
`grep>matches.txt pattern`,
} {
issue := detectShellCommandIssue(command, "windows")
if issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected windows_msys_sandbox for %q, got %#v", command, issue)
}
}
}
// TestDetectShellOutputIssueSignatureOmitsCommandText documents, at the type
// level, that detectShellOutputIssue can no longer take the command line as
// evidence: it only accepts the real output. Harmless output must not be
// flagged, and output that genuinely carries the MSYS failure markers must
// still be flagged.
func TestDetectShellOutputIssueSignatureOmitsCommandText(t *testing.T) {
if issue := detectShellOutputIssue("hello from bash", "windows"); issue != nil {
t.Fatalf("expected no issue for harmless output, got %#v", issue)
}
output := `0 [main] head (3568) C:\Program Files\Git\usr\bin\head.exe: *** fatal error - CreateFileMapping ..., Win32 error 5. Terminating.`
if issue := detectShellOutputIssue(output, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("expected real MSYS output to still be flagged, got %#v", issue)
}
}
+145
View File
@@ -0,0 +1,145 @@
package tools
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/secrets"
)
// Spill-to-disk for truncated tool output. When a command produces more than
// its context budget, the tail was previously simply gone — the model's only
// recourse was re-running the (possibly expensive or non-idempotent) command
// with a bigger budget. Instead, the full output is written to a cache file
// whose path is included in the truncation notice, so the model can grep or
// read_file the remainder. Spilling is best-effort: on any error truncation
// behaves exactly as before, just without the file hint.
// spillRetention is how long spilled outputs are kept; files older than this
// are opportunistically removed whenever a new spill happens.
const spillRetention = 7 * 24 * time.Hour
// spillRootPath returns the spill directory path without creating or checking
// it. Used by the read-path resolver to recognize spill files as readable.
func spillRootPath() string {
name := "zero-tool-output"
if uid := os.Getuid(); uid >= 0 {
name = fmt.Sprintf("zero-tool-output-%d", uid)
}
return filepath.Join(os.TempDir(), name)
}
// resolveSpillReadPath reports whether requestedPath is a file inside the
// spill directory and, if so, returns its verified absolute path. Spill files
// are zero-created, per-uid owned, and already secret-redacted, so letting the
// scoped read tools open them is what makes the truncation notice's
// "read_file it" recovery actually work. Symlinks are resolved and the result
// must still be inside the spill dir, so a planted link cannot smuggle an
// out-of-scope file through this gate.
func resolveSpillReadPath(requestedPath string) (string, bool) {
if requestedPath == "" || !filepath.IsAbs(requestedPath) {
return "", false
}
root := spillRootPath()
cleaned := filepath.Clean(requestedPath)
if cleaned == root || !strings.HasPrefix(cleaned, root+string(filepath.Separator)) {
return "", false
}
resolved, err := filepath.EvalSymlinks(cleaned)
if err != nil {
return "", false
}
resolvedRoot, err := filepath.EvalSymlinks(root)
if err != nil {
return "", false
}
if !strings.HasPrefix(resolved, resolvedRoot+string(filepath.Separator)) {
return "", false
}
return resolved, true
}
// spillDir returns the per-user spill directory, creating it on first use.
// Hardened for shared temp dirs (Linux /tmp): the name carries the uid so
// users cannot collide, and a pre-existing path is only accepted when it is a
// real directory (not a symlink that would redirect spills) owned by the
// current user. Any doubt fails the spill — it is best-effort anyway.
func spillDir() (string, error) {
dir := spillRootPath()
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
// MkdirAll follows symlinks and leaves an existing directory untouched, so
// verify what is actually at the path.
info, err := os.Lstat(dir)
if err != nil {
return "", err
}
if !info.IsDir() {
return "", fmt.Errorf("spill path %s is not a directory", dir)
}
if err := checkSpillDirOwner(info); err != nil {
return "", err
}
return dir, nil
}
// spillTruncatedOutput writes the full pre-truncation output to the spill
// directory and returns the file path, or "" when spilling fails. Output is
// scrubbed with the same configured-key redaction the registry applies at the
// tool boundary PLUS the pattern-based secret scanner (AWS keys, tokens, PEM
// blocks, JWTs) that bash applies to its model-visible output — a spill runs
// before that formatter, so without the scan here a spilled file would hold
// pattern-matched credentials in cleartext that the transcript hides.
func spillTruncatedOutput(toolName, output string) string {
dir, err := spillDir()
if err != nil {
return ""
}
sweepSpillDir(dir)
prefix := strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-' {
return r
}
return '-'
}, toolName)
file, err := os.CreateTemp(dir, prefix+"-*.txt")
if err != nil {
return ""
}
defer file.Close()
scrubbed := redaction.RedactString(output, redaction.Options{})
scrubbed, _ = secrets.Redact(scrubbed)
if _, err := file.WriteString(scrubbed); err != nil {
_ = os.Remove(file.Name())
return ""
}
return file.Name()
}
// sweepSpillDir removes spill files older than spillRetention. Best-effort:
// errors are ignored, the directory is small, and a sweep runs only when a new
// spill is about to happen anyway.
func sweepSpillDir(dir string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
cutoff := time.Now().Add(-spillRetention)
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
_ = os.Remove(filepath.Join(dir, entry.Name()))
}
}
}
+91
View File
@@ -0,0 +1,91 @@
package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func spillDirName() string {
if uid := os.Getuid(); uid >= 0 {
return fmt.Sprintf("zero-tool-output-%d", uid)
}
return "zero-tool-output"
}
func TestSpillDirRejectsSymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation needs privileges on Windows")
}
tmp := t.TempDir()
t.Setenv("TMPDIR", tmp)
elsewhere := filepath.Join(tmp, "elsewhere")
if err := os.Mkdir(elsewhere, 0o700); err != nil {
t.Fatal(err)
}
// An attacker pre-creates the spill path as a symlink to redirect spills.
if err := os.Symlink(elsewhere, filepath.Join(tmp, spillDirName())); err != nil {
t.Fatal(err)
}
if path := spillTruncatedOutput("bash", "sensitive output"); path != "" {
t.Fatalf("spill must refuse a symlinked directory, wrote %s", path)
}
entries, _ := os.ReadDir(elsewhere)
if len(entries) != 0 {
t.Fatalf("nothing may land behind the symlink: %v", entries)
}
}
func TestSpillDirAcceptsOwnedDirectory(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
if path := spillTruncatedOutput("bash", "ok"); path == "" {
t.Fatal("spill must work in a clean per-user temp dir")
}
}
func TestResolveSpillReadPathContainment(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
spillPath := spillTruncatedOutput("exec_command", "spilled body")
if spillPath == "" {
t.Fatal("spill must succeed in a clean temp dir")
}
resolved, ok := resolveSpillReadPath(spillPath)
if !ok || resolved == "" {
t.Fatalf("a real spill file must resolve, got ok=%v", ok)
}
if _, ok := resolveSpillReadPath(filepath.Join(spillRootPath(), "..", "escape.txt")); ok {
t.Fatal("path traversal out of the spill dir must be rejected")
}
if _, ok := resolveSpillReadPath("/etc/hosts"); ok {
t.Fatal("paths outside the spill dir must be rejected")
}
if _, ok := resolveSpillReadPath(spillRootPath()); ok {
t.Fatal("the spill dir itself is not a readable file target")
}
if runtime.GOOS != "windows" {
// A symlink planted inside the spill dir pointing outside must not leak.
link := filepath.Join(spillRootPath(), "sneaky-link")
if err := os.Symlink("/etc/hosts", link); err == nil {
if _, ok := resolveSpillReadPath(link); ok {
t.Fatal("symlink escaping the spill dir must be rejected")
}
}
}
}
func TestReadFileCanReadSpillFile(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
spillPath := spillTruncatedOutput("exec_command", "line one\nline two\n")
if spillPath == "" {
t.Fatal("spill must succeed")
}
workspace := t.TempDir()
result := NewReadFileTool(workspace).Run(context.Background(), map[string]any{"path": spillPath})
if result.Status != StatusOK || !strings.Contains(result.Output, "line two") {
t.Fatalf("read_file must be able to follow the truncation notice: %s %q", result.Status, result.Output)
}
}
+24
View File
@@ -0,0 +1,24 @@
//go:build !windows
package tools
import (
"fmt"
"os"
"syscall"
)
// checkSpillDirOwner rejects a spill directory not owned by the current user:
// on a shared /tmp another user could have pre-created the path and would then
// control its lifetime (deletion/renaming) even though the 0600 spill files
// keep their contents private.
func checkSpillDirOwner(info os.FileInfo) error {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return nil
}
if int(stat.Uid) != os.Geteuid() {
return fmt.Errorf("spill directory is owned by uid %d, not the current user", stat.Uid)
}
return nil
}
+12
View File
@@ -0,0 +1,12 @@
//go:build windows
package tools
import "os"
// checkSpillDirOwner is a no-op on Windows: %TEMP% is per-user by default and
// there is no portable uid to compare. The Lstat symlink/dir check in spillDir
// still applies.
func checkSpillDirOwner(os.FileInfo) error {
return nil
}
+109
View File
@@ -0,0 +1,109 @@
package tools
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestTruncateExecOutputSpillWritesFullOutput(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
long := strings.Repeat("line of build output\n", 5000) // ~100KB > 40KB budget
truncated, wasTruncated := truncateExecOutputSpill(long, defaultMaxOutputTokens, "bash")
if !wasTruncated {
t.Fatal("output over budget must truncate")
}
if !strings.Contains(truncated, "full output saved to ") {
t.Fatalf("truncation notice missing spill path: %q", truncated[:200])
}
// Extract the path and verify the file holds the complete output.
start := strings.Index(truncated, "full output saved to ") + len("full output saved to ")
end := strings.Index(truncated[start:], " (grep")
spillPath := truncated[start : start+end]
content, err := os.ReadFile(spillPath)
if err != nil {
t.Fatalf("spill file unreadable: %v", err)
}
if string(content) != long {
t.Fatalf("spill file must hold the full output: got %d bytes, want %d", len(content), len(long))
}
}
func TestTruncateExecOutputUnderBudgetUnchanged(t *testing.T) {
output, wasTruncated := truncateExecOutputSpill("short output", defaultMaxOutputTokens, "bash")
if wasTruncated || output != "short output" {
t.Fatalf("under-budget output must pass through untouched: %q", output)
}
}
func TestSweepSpillDirRemovesOnlyOldFiles(t *testing.T) {
dir := t.TempDir()
oldFile := filepath.Join(dir, "bash-old.txt")
newFile := filepath.Join(dir, "bash-new.txt")
for _, path := range []string{oldFile, newFile} {
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
stale := time.Now().Add(-spillRetention - time.Hour)
if err := os.Chtimes(oldFile, stale, stale); err != nil {
t.Fatal(err)
}
sweepSpillDir(dir)
if _, err := os.Stat(oldFile); !os.IsNotExist(err) {
t.Fatal("stale spill file must be removed")
}
if _, err := os.Stat(newFile); err != nil {
t.Fatal("fresh spill file must survive the sweep")
}
}
// A spill happens BEFORE bash's model-facing formatter runs its pattern-based
// secret scan, so the spill itself must scrub pattern-matched credentials —
// otherwise the file would hold in cleartext exactly what the transcript hides.
func TestSpillTruncatedOutputScrubsPatternSecrets(t *testing.T) {
setTestTempDir(t)
githubToken := "ghp_" + strings.Repeat("a", 36)
body := "before\nAKIAIOSFODNN7EXAMPLE\n" + githubToken + "\nafter"
path := spillTruncatedOutput("bash", body)
if path == "" {
t.Fatal("spill must return a file path")
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(content), "AKIAIOSFODNN7EXAMPLE") {
t.Fatal("AWS access key reached the spill file in cleartext")
}
if strings.Contains(string(content), githubToken) {
t.Fatal("GitHub token reached the spill file in cleartext")
}
if !strings.Contains(string(content), "before") || !strings.Contains(string(content), "after") {
t.Fatalf("non-secret content must survive the scrub: %q", content)
}
}
func TestSpillTruncatedOutputWritesFile(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
path := spillTruncatedOutput("exec_command", "some output body")
if path == "" {
t.Fatal("spill must return a file path")
}
if base := filepath.Base(path); !strings.HasPrefix(base, "exec_command-") {
t.Fatalf("spill file name must carry the tool prefix: %s", base)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(content) != "some output body" {
t.Fatalf("unexpected spill content: %q", content)
}
}
+48 -10
View File
@@ -18,12 +18,17 @@ import (
)
const (
defaultWebFetchMaxBytes = 64 * 1024
maxWebFetchMaxBytes = 512 * 1024
webFetchTimeout = 10 * time.Second
// Raw-body budgets. HTML responses are converted to markdown before they
// reach the model (see web_fetch_markdown.go), so a generous raw budget
// does not translate into a generous context cost — conversion typically
// shrinks a page by an order of magnitude. 64KiB of raw HTML often held
// nothing but a page's <head>, which starved research tasks.
defaultWebFetchMaxBytes = 256 * 1024
maxWebFetchMaxBytes = 2 * 1024 * 1024
webFetchTimeout = 30 * time.Second
webFetchRedirectLimit = 5
webFetchErrorBodyLimit = 4 * 1024
webFetchPublicOnlyHint = "web_fetch only supports public remote HTTP/HTTPS URLs. For localhost or private network URLs, use bash with curl so sandbox network permission can apply."
webFetchPublicOnlyHint = "web_fetch only supports public remote HTTP/HTTPS URLs. For localhost or private network URLs, use bash with curl so sandbox network permission can apply"
)
type webFetchTool struct {
@@ -115,11 +120,17 @@ func newWebFetchToolWithClientAndResolver(client *http.Client, resolver webFetch
},
"max_bytes": {
Type: "integer",
Description: "Maximum response body bytes to return.",
Description: "Maximum raw response body bytes to download before conversion.",
Default: defaultWebFetchMaxBytes,
Minimum: intPtr(1),
Maximum: intPtr(maxWebFetchMaxBytes),
},
"format": {
Type: "string",
Description: "auto (default): HTML responses are converted to compact markdown, everything else is returned as-is. raw: never convert. markdown: force conversion.",
Enum: []string{"auto", "raw", "markdown"},
Default: "auto",
},
},
Required: []string{"url"},
AdditionalProperties: false,
@@ -170,6 +181,18 @@ func (tool webFetchTool) run(ctx context.Context, args map[string]any) Result {
if err != nil {
return errorResult("Error: Invalid arguments for web_fetch: " + err.Error())
}
format, err := stringArg(args, "format", "auto", false)
if err != nil {
return errorResult("Error: Invalid arguments for web_fetch: " + err.Error())
}
format = strings.ToLower(strings.TrimSpace(format))
switch format {
case "", "auto":
format = "auto"
case "raw", "markdown":
default:
return errorResult(`Error: Invalid arguments for web_fetch: format must be "auto", "raw", or "markdown".`)
}
if err := validateWebFetchURLBeforePermission(rawURL); err != nil {
return errorResult("Error: Unsafe URL for web_fetch: " + err.Error())
}
@@ -224,14 +247,28 @@ func (tool webFetchTool) run(ctx context.Context, args map[string]any) Result {
}
body = redactWebFetchText(body)
output := strings.Join([]string{
// HTML responses are converted to compact markdown by default: raw HTML is
// mostly markup, so conversion typically shrinks the page by an order of
// magnitude and the model reads content instead of boilerplate. format=raw
// is the escape hatch for pages the converter mangles.
converted := false
if format == "markdown" || (format == "auto" && looksLikeHTML(contentType, body)) {
if markdown := htmlToMarkdown(body); markdown != "" {
body = markdown
converted = true
}
}
headers := []string{
"URL: " + finalURL,
"Status: " + webFetchStatusLine(response),
"Content-Type: " + firstNonEmptyString(contentType, "unknown"),
"Bytes: " + strconv.Itoa(len(body)),
"",
body,
}, "\n")
}
if converted {
headers = append(headers, "Converted: html -> markdown (pass format: \"raw\" for the original HTML)")
}
output := strings.Join(append(headers, "", body), "\n")
return Result{
Status: StatusOK,
@@ -243,6 +280,7 @@ func (tool webFetchTool) run(ctx context.Context, args map[string]any) Result {
"content_type": contentType,
"bytes": strconv.Itoa(len(body)),
"truncated": strconv.FormatBool(truncated),
"converted": strconv.FormatBool(converted),
},
}
}
@@ -310,7 +348,7 @@ func webFetchRedirectPolicy(previous func(*http.Request, []*http.Request) error,
return fmt.Errorf("too many redirects: maximum is %d", webFetchRedirectLimit)
}
if err := validateParsedWebFetchURL(request.Context(), request.URL, resolver); err != nil {
return fmt.Errorf("Unsafe redirect URL: %w", err)
return fmt.Errorf("unsafe redirect URL: %w", err)
}
if previous != nil {
return previous(request, via)
+92
View File
@@ -0,0 +1,92 @@
package tools
import (
"fmt"
"html"
"regexp"
"strings"
)
// HTML-to-markdown conversion for web_fetch. Raw HTML is mostly boilerplate —
// on a typical page well under a tenth of the bytes are readable content — so
// returning it raw wastes nearly the whole response budget on markup. The
// converter is deliberately dependency-free (regex passes + stdlib entity
// unescaping, no HTML parser): the output feeds a language model, not a
// browser, so best-effort structure (headings, links, list markers) is enough,
// and the format=raw escape hatch covers pages it mangles.
var (
webFetchDropBlockRe = regexp.MustCompile(`(?is)<(script|style|head|noscript|svg|template|iframe)\b[^>]*>.*?</\s*(?:script|style|head|noscript|svg|template|iframe)\s*>`)
webFetchCommentRe = regexp.MustCompile(`(?s)<!--.*?-->`)
webFetchAnchorRe = regexp.MustCompile(`(?is)<a\b[^>]*?href\s*=\s*["']?([^"'\s>]+)["']?[^>]*>(.*?)</\s*a\s*>`)
webFetchHeadingRe = regexp.MustCompile(`(?is)<h([1-6])[^>]*>(.*?)</\s*h[1-6]\s*>`)
webFetchListItemRe = regexp.MustCompile(`(?i)<li\b[^>]*>`)
webFetchBreakRe = regexp.MustCompile(`(?i)<(?:br|hr)\s*/?>`)
webFetchBlockTagRe = regexp.MustCompile(`(?i)</?(?:p|div|section|article|main|header|footer|nav|aside|table|tr|ul|ol|blockquote|pre|figure|form)\b[^>]*>`)
webFetchAnyTagRe = regexp.MustCompile(`(?s)<[^>]*>`)
webFetchSpaceRunRe = regexp.MustCompile(`[ \t]+`)
webFetchBlankRunRe = regexp.MustCompile(`\n{3,}`)
)
// htmlToMarkdown converts an HTML document to compact markdown-flavored text:
// headings become #-prefixed lines, anchors become [text](href), list items
// become "- " bullets, block boundaries become blank lines, all other markup
// is stripped, and entities are unescaped.
func htmlToMarkdown(body string) string {
text := webFetchCommentRe.ReplaceAllString(body, " ")
text = webFetchDropBlockRe.ReplaceAllString(text, " ")
text = webFetchHeadingRe.ReplaceAllStringFunc(text, func(match string) string {
groups := webFetchHeadingRe.FindStringSubmatch(match)
level := int(groups[1][0] - '0')
if level < 1 || level > 6 {
level = 1
}
title := strings.TrimSpace(webFetchAnyTagRe.ReplaceAllString(groups[2], " "))
return "\n\n" + strings.Repeat("#", level) + " " + title + "\n\n"
})
text = webFetchAnchorRe.ReplaceAllStringFunc(text, func(match string) string {
groups := webFetchAnchorRe.FindStringSubmatch(match)
href := groups[1]
label := strings.TrimSpace(webFetchAnyTagRe.ReplaceAllString(groups[2], " "))
if label == "" {
return " "
}
if strings.HasPrefix(href, "#") || strings.HasPrefix(strings.ToLower(href), "javascript:") {
return label
}
return fmt.Sprintf("[%s](%s)", label, href)
})
text = webFetchListItemRe.ReplaceAllString(text, "\n- ")
text = webFetchBreakRe.ReplaceAllString(text, "\n")
text = webFetchBlockTagRe.ReplaceAllString(text, "\n\n")
text = webFetchAnyTagRe.ReplaceAllString(text, " ")
text = html.UnescapeString(text)
// Whitespace normalization: collapse space runs, trim line edges, cap blank
// runs at one empty line.
lines := strings.Split(text, "\n")
for i, line := range lines {
lines[i] = strings.TrimSpace(webFetchSpaceRunRe.ReplaceAllString(line, " "))
}
text = strings.Join(lines, "\n")
text = webFetchBlankRunRe.ReplaceAllString(text, "\n\n")
return strings.TrimSpace(text)
}
// looksLikeHTML reports whether a response should be treated as an HTML
// document, by Content-Type first and a body sniff as fallback (servers
// mislabel HTML as text/plain often enough to matter).
func looksLikeHTML(contentType, body string) bool {
lowered := strings.ToLower(contentType)
if strings.Contains(lowered, "text/html") || strings.Contains(lowered, "application/xhtml") {
return true
}
if lowered != "" && !strings.Contains(lowered, "text/plain") {
return false
}
head := strings.ToLower(strings.TrimSpace(body))
if len(head) > 512 {
head = head[:512]
}
return strings.Contains(head, "<!doctype html") || strings.Contains(head, "<html")
}
+127
View File
@@ -0,0 +1,127 @@
package tools
import (
"context"
"net/http"
"strings"
"testing"
)
const sampleHTML = `<!DOCTYPE html>
<html><head><title>ignored</title><style>body{color:red}</style></head>
<body>
<script>var tracking = "noise";</script>
<h1>Main Title</h1>
<p>First paragraph with a <a href="https://example.com/docs">docs link</a> and &amp; entity.</p>
<ul><li>alpha</li><li>beta</li></ul>
<!-- hidden comment -->
<h2>Section</h2>
<p>Body with extra spaces.</p>
</body></html>`
func TestHTMLToMarkdown(t *testing.T) {
markdown := htmlToMarkdown(sampleHTML)
for _, want := range []string{
"# Main Title",
"## Section",
"[docs link](https://example.com/docs)",
"- alpha",
"- beta",
"with a", // paragraph text survives
"& entity",
} {
if !strings.Contains(markdown, want) {
t.Fatalf("markdown missing %q:\n%s", want, markdown)
}
}
for _, banned := range []string{"tracking", "color:red", "hidden comment", "<p>", "</html>", "ignored"} {
if strings.Contains(markdown, banned) {
t.Fatalf("markdown must not contain %q:\n%s", banned, markdown)
}
}
if strings.Contains(markdown, "extra spaces") {
t.Fatalf("space runs must collapse:\n%s", markdown)
}
if strings.Contains(markdown, "\n\n\n") {
t.Fatalf("blank-line runs must be capped:\n%s", markdown)
}
}
func TestLooksLikeHTML(t *testing.T) {
cases := []struct {
contentType string
body string
want bool
}{
{"text/html; charset=utf-8", "anything", true},
{"application/xhtml+xml", "anything", true},
{"application/json", `{"html":"<html>"}`, false},
{"text/plain", "<!doctype html><html>", true}, // mislabeled HTML sniffed
{"text/plain", "just text", false},
{"", "<html><body>x</body></html>", true},
}
for _, c := range cases {
if got := looksLikeHTML(c.contentType, c.body); got != c.want {
t.Errorf("looksLikeHTML(%q, %.20q) = %v, want %v", c.contentType, c.body, got, c.want)
}
}
}
func TestWebFetchConvertsHTMLByDefault(t *testing.T) {
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
return webFetchTestResponse(request, http.StatusOK, "text/html; charset=utf-8", sampleHTML), nil
}))
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com/page"})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %s: %s", result.Status, result.Output)
}
if !strings.Contains(result.Output, "# Main Title") || strings.Contains(result.Output, "<h1>") {
t.Fatalf("HTML must convert to markdown by default:\n%s", result.Output)
}
if result.Meta["converted"] != "true" {
t.Fatalf("converted meta must be true: %#v", result.Meta)
}
}
func TestWebFetchRawFormatSkipsConversion(t *testing.T) {
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
return webFetchTestResponse(request, http.StatusOK, "text/html", sampleHTML), nil
}))
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com/page", "format": "raw"})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %s: %s", result.Status, result.Output)
}
if !strings.Contains(result.Output, "<h1>Main Title</h1>") {
t.Fatalf("format=raw must keep original HTML:\n%s", result.Output)
}
if result.Meta["converted"] != "false" {
t.Fatalf("converted meta must be false: %#v", result.Meta)
}
}
func TestWebFetchLeavesNonHTMLUntouched(t *testing.T) {
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
return webFetchTestResponse(request, http.StatusOK, "application/json", `{"key":"<b>value</b>"}`), nil
}))
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com/api"})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %s: %s", result.Status, result.Output)
}
if !strings.Contains(result.Output, `{"key":"<b>value</b>"}`) {
t.Fatalf("non-HTML must pass through untouched:\n%s", result.Output)
}
}
func TestWebFetchRejectsInvalidFormat(t *testing.T) {
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
return webFetchTestResponse(request, http.StatusOK, "text/plain", "x"), nil
}))
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com", "format": "xml"})
if result.Status != StatusError || !strings.Contains(result.Output, "format must be") {
t.Fatalf("invalid format must be rejected: %s %s", result.Status, result.Output)
}
}
+1 -1
View File
@@ -405,7 +405,7 @@ func TestWebFetchToolRejectsUnsafeRedirects(t *testing.T) {
if result.Status != StatusError {
t.Fatalf("expected redirect safety error, got %s: %s", result.Status, result.Output)
}
if !strings.Contains(result.Output, "Unsafe redirect URL") {
if !strings.Contains(result.Output, "unsafe redirect URL") {
t.Fatalf("expected unsafe redirect message, got %q", result.Output)
}
})
-1
View File
@@ -426,7 +426,6 @@ func stringListArgWebSearch(args map[string]any, key string) (domains []string,
if !ok || value == nil {
return nil, false, nil
}
provided = true
raw, ok := value.([]any)
if !ok {
if asString, ok2 := value.([]string); ok2 {
+8
View File
@@ -254,6 +254,14 @@ func scopedReadRoots(workspaceRoot string, scope PathScope) ([]string, error) {
}
func resolveScopedReadPath(workspaceRoot string, scope PathScope, requestedPath string) (string, string, error) {
// Spill files (truncated tool output saved under the per-uid temp dir) are
// readable regardless of scope: the truncation notice tells the model to
// read_file/grep them, which must actually work. resolveSpillReadPath
// verifies containment after symlink resolution, so this cannot be used to
// reach anything outside the spill dir.
if spillPath, ok := resolveSpillReadPath(requestedPath); ok {
return spillPath, spillPath, nil
}
if requestedPath == "" || !filepath.IsAbs(requestedPath) || scope == nil {
return resolveWorkspacePath(workspaceRoot, requestedPath)
}
+6 -1
View File
@@ -44,7 +44,7 @@ func (tool writeFileTool) Run(ctx context.Context, args map[string]any) Result {
return tool.RunWithOptions(ctx, args, RunOptions{})
}
func (tool writeFileTool) RunWithOptions(_ context.Context, args map[string]any, options RunOptions) Result {
func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result {
requestedPath, err := aliasedStringArg(args, []string{"path", "file", "file_path", "filename"}, "", true, false)
if err != nil {
return errorResult("Error: Invalid arguments for write_file: " + err.Error())
@@ -110,6 +110,10 @@ func (tool writeFileTool) RunWithOptions(_ context.Context, args map[string]any,
if err := os.WriteFile(absolutePath, []byte(content), 0o644); err != nil {
return errorResult("Error writing file " + relativePath + ": " + err.Error())
}
// Optional format-on-write (ZERO_FORMAT_ON_WRITE). Must run BEFORE the
// FileTracker baseline: recording pre-format content would make the very
// next edit look like an external modification and trip the conflict guard.
content = maybeFormatWrittenFile(ctx, absolutePath, content)
// Baseline the freshly written content so a later edit/overwrite in this
// session compares against what is now on disk.
newInfo, _ := os.Stat(absolutePath)
@@ -126,6 +130,7 @@ func (tool writeFileTool) RunWithOptions(_ context.Context, args map[string]any,
lines++
}
summary := fmt.Sprintf("%s %s (%d lines).", verb, relativePath, lines)
summary += inlineDiagnostics(ctx, options, absolutePath, relativePath)
result := okResult(summary)
result.ChangedFiles = []string{relativePath}
// Card-only preview: a real unified diff (all-green for a create, red/green for
+5 -2
View File
@@ -8,6 +8,7 @@ import (
"unicode/utf8"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
const (
@@ -237,8 +238,10 @@ func renderAssistantMarkdownPlainText(text string, proseMeasure int, tableMeasur
}
func stripMarkdownRenderControls(text string) string {
text = strings.ReplaceAll(text, markdownBoldStart, "")
return strings.ReplaceAll(text, markdownBoldEnd, "")
// renderAssistantMarkdownText embeds markdownBoldStart/markdownBoldEnd
// markers for prose text, and highlightCodeAuto embeds ANSI color sequences
// for fenced code blocks. Strip everything so clipboard text is pure plain text.
return ansi.Strip(text)
}
func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
+48 -1
View File
@@ -188,7 +188,54 @@ func (m model) matchCommandSuggestions(token string) []commandSuggestion {
out := matchCommandSuggestionsWithFilter(token, func(command commandDefinition) bool {
return command.kind != commandSandboxSetup || m.sandboxSetupCommand != nil
})
return append(out, m.matchUserCommandSuggestions(token)...)
out = append(out, m.matchUserCommandSuggestions(token)...)
out = append(out, m.matchSkillSuggestions(token)...)
// Each source caps itself, but the merged list must honor the shared cap too
// (three sources could otherwise stack up to ~3x the palette bound).
if len(out) > maxCommandSuggestions {
out = out[:maxCommandSuggestions]
}
return out
}
// matchSkillSuggestions returns installed skills whose slash name has the typed
// prefix, so skills are discoverable and invocable from the palette like user
// commands. Precedence (builtin > user command > skill) is enforced here by
// skipping any skill whose name is claimed by a builtin (or alias) or a user
// command — unlike the builtin/user pair, a shadowed skill row would be dead at
// dispatch time, so it must not be advertised at all.
func (m model) matchSkillSuggestions(token string) []commandSuggestion {
prefix := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(token, "/")))
if prefix == "" || m.loadSkills == nil {
return nil
}
taken := m.takenSlashNames()
var out []commandSuggestion
// Read through the loader (not the startup snapshot in agentOptions) so the
// palette matches what dispatch will actually resolve — a skill installed or
// removed mid-session appears/disappears without a restart.
for _, skill := range m.installedSkills() {
name := skillSlashName(skill.Name)
if name == "" || taken[name] || !strings.HasPrefix(name, prefix) {
continue
}
// Two skills whose names collide after lowercasing (e.g. "Deploy" and
// "deploy") map to one slash name; only the first — the one dispatch will
// run — may be advertised, or the row's description and the executed
// instructions silently diverge.
taken[name] = true
desc := strings.TrimSpace(skill.Description)
if desc == "" {
desc = "Skill: /" + name
} else {
desc += " (skill)"
}
out = append(out, commandSuggestion{Name: "/" + name, Desc: desc})
if len(out) >= maxCommandSuggestions {
break
}
}
return out
}
// matchUserCommandSuggestions returns file-sourced /commands whose name has the
+16
View File
@@ -698,3 +698,19 @@ func (m model) debugText() string {
}},
})
}
// skillsText is the /skills fallback when NO skills are installed — an install
// hint. With skills present /skills opens the searchable skill picker instead
// (see newSkillPicker), matching how /model works.
func (m model) skillsText() string {
return renderCommandOutput(commandOutput{
Title: "Skills",
Status: commandStatusInfo,
Sections: []commandSection{{
Lines: []string{"No skills installed."},
}},
Hints: []string{
"install one: create <skills-dir>/<name>/SKILL.md (see `zero skills`)",
},
})
}
+16
View File
@@ -46,6 +46,8 @@ const (
commandCopy
commandExport
commandNew
commandSkills
commandLoop
commandUnknown
)
@@ -131,6 +133,13 @@ var commandDefinitions = []commandDefinition{
description: "List registered tools.",
kind: commandTools,
},
{
name: "/skills",
usage: "/skills",
group: commandGroupTools,
description: "List installed skills; run one directly with /<skill-name> [args].",
kind: commandSkills,
},
{
name: "/context",
usage: "/context",
@@ -289,6 +298,13 @@ var commandDefinitions = []commandDefinition{
description: "Write the conversation transcript to a file.",
kind: commandExport,
},
{
name: "/loop",
usage: "/loop [interval] <prompt|/command> | /loop list | /loop stop [id|all]",
group: commandGroupSession,
description: "Repeat a prompt or command on an interval (e.g. /loop 5m /babysit-prs), or self-paced when no interval is given.",
kind: commandLoop,
},
{
name: "/help",
usage: "/help",
+1 -1
View File
@@ -280,7 +280,7 @@ func (m model) composerPositionAtMouse(msg tea.MouseMsg) (int, bool) {
}
func (m model) composerMouseSelectionBlocked() bool {
return m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
return m.transcriptDetailed || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive()
}
+50 -1
View File
@@ -143,7 +143,7 @@ func TestFileViewSwapsTranscriptBody(t *testing.T) {
m := filesPanelTestModel()
m = m.openFileView("internal/tui/sidebar.go")
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
if len(items) != 1 {
t.Fatalf("file view should swap the body to a single block item, got %d items", len(items))
}
@@ -264,6 +264,55 @@ func TestFileViewFullBodyTruncatesLongFile(t *testing.T) {
}
}
// TestDetailedTranscriptClosesFileView: entering detailed transcript mode
// closes an active file drill-in so the full transcript body replaces the file
// content.
func TestDetailedTranscriptClosesFileView(t *testing.T) {
m := filesPanelTestModel()
m.altScreen = true
m = m.openFileView("web/app.js")
if !m.fileView.active {
t.Fatal("sanity check: openFileView should activate the file view")
}
updated, _ := m.Update(testKeyCtrl('o'))
m = updated.(model)
if m.fileView.active {
t.Fatal("detailed transcript should close the file drill-in")
}
if !m.transcriptDetailed {
t.Fatal("Ctrl+O should enter detailed mode")
}
items := m.transcriptBodyItems(m.chatColumnWidth(), "", true)
if len(items) <= 1 {
t.Fatalf("detailed transcript should show multiple body items, got %d", len(items))
}
}
// TestDetailedTranscriptStaysClosedOnSecondToggle: toggling out and back into
// detailed mode does not re-open the closed file view.
func TestDetailedTranscriptStaysClosedOnSecondToggle(t *testing.T) {
m := filesPanelTestModel()
m.altScreen = true
m = m.openFileView("web/app.js")
updated, _ := m.Update(testKeyCtrl('o'))
m = updated.(model)
updated, _ = m.Update(testKeyCtrl('o'))
m = updated.(model)
if m.fileView.active {
t.Fatal("exiting detailed mode must not re-open the file drill-in")
}
if m.transcriptDetailed {
t.Fatal("second Ctrl+O should exit detailed mode")
}
}
// TestFileViewKeysDeferToBlockingModal: with a permission prompt up, Esc and
// the d/f shortcuts belong to the prompt — the drill-in must not swallow them
// (Esc exiting the view instead of reaching the prompt's deny handling).
+1 -1
View File
@@ -372,7 +372,7 @@ func (m model) scrollOffsetForTranscriptRow(rowIndex int) (int, bool) {
return 0, false
}
width := m.chatColumnWidth()
items := m.transcriptBodyItems(width, "")
items := m.transcriptBodyItems(width, "", false)
metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
startY := -1
for _, span := range metrics.spans {
+693
View File
@@ -0,0 +1,693 @@
package tui
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
tea "charm.land/bubbletea/v2"
)
// The /loop command repeats a prompt or slash command in one of two modes:
//
// - Fixed interval — `/loop 5m <prompt>` re-fires on a wall-clock cadence.
// - Self-paced — bare `/loop <prompt>` runs a maintenance playbook: the model
// does one increment of work each iteration and ends its reply with a control
// line (`LOOP: DONE` / `LOOP: CONTINUE [interval]`) that stops the loop or picks
// the next wake. With no control line it falls back to an adaptive cadence.
//
// Loops are foreground and session-scoped: a tick only fires while the session is
// idle between turns (it never interrupts a streaming turn), the loop lives with the
// session that created it, and switching sessions (/new, /resume) stops it. Safety
// rails cap iterations, age, consecutive failures, and no-progress repetition.
// Everything here is pure/loop-local; the turn-lifecycle wiring (ticker, launch,
// continuation) lives in model.go.
type loopMode int
const (
loopModeFixed loopMode = iota // re-fire every fixed interval
loopModeSelfPaced // model chooses the next wake each iteration
)
// Safety bounds. A loop is foreground + session-scoped, but these keep a runaway
// or forgotten loop from burning tokens forever.
const (
loopMinInterval = 30 * time.Second
loopMaxInterval = 24 * time.Hour
loopMaxIterations = 500 // hard ceiling; the loop auto-stops after this many
// loopMaxAge auto-expires a loop this long after it was created, so a forgotten
// loop stops on its own even if it never reaches the iteration cap.
loopMaxAge = 24 * time.Hour
loopSelfPaceMin = 1 * time.Minute
loopSelfPaceMax = 1 * time.Hour
loopSelfPaceDefault = 15 * time.Minute
// loopDoomThreshold ends a loop after this many consecutive identical final
// answers — a self-paced or interval loop that gets stuck repeating the same
// no-progress result should stop rather than churn.
loopDoomThreshold = 5
// loopMaxFailures ends a loop after this many consecutive failed iterations, so
// a loop whose iterations keep erroring stops instead of churning to the
// iteration cap (a failed turn has no final answer, so the doom guard can't see
// the lack of progress).
loopMaxFailures = 3
// loopPollInterval is how often the controller checks whether a loop is due.
// A loop only fires while idle, so this is a cheap between-turn poll.
loopPollInterval = 1 * time.Second
)
// loopState is one active loop owned by the current session.
type loopState struct {
id string
mode loopMode
prompt string // the prompt or "/command" re-run each iteration
interval time.Duration
iteration int
maxIter int // 0 => loopMaxIterations
createdAt time.Time
nextRunAt time.Time
// lastResult / repeatRun drive doom-loop detection: consecutive identical
// final answers past loopDoomThreshold end the loop.
lastResult string
repeatRun int
// failRun counts consecutive failed iterations; past loopMaxFailures the loop
// stops (a failed turn has no final answer for the doom guard to compare).
failRun int
paused bool
}
func (l *loopState) iterationCap() int {
if l.maxIter > 0 {
return l.maxIter
}
return loopMaxIterations
}
// due reports whether the loop should fire at now (active, not paused, past its
// next-run mark).
func (l *loopState) due(now time.Time) bool {
return !l.paused && !l.nextRunAt.IsZero() && !now.Before(l.nextRunAt)
}
// cadenceText renders the loop's cadence for status lines.
func (l *loopState) cadenceText() string {
if l.mode == loopModeSelfPaced {
return "self-paced"
}
return "every " + formatLoopDuration(l.interval)
}
type loopAction int
const (
loopActionUsage loopAction = iota // show usage / list when empty
loopActionStart // start a new loop
loopActionStop // stop one by id
loopActionStopAll // stop every loop
loopActionList // list active loops
)
// loopCommand is the parsed form of a `/loop ...` invocation (the text after the
// command name).
type loopCommand struct {
action loopAction
targetID string
mode loopMode
prompt string
interval time.Duration
// note carries a non-fatal advisory (e.g. an interval that was clamped) to
// surface back to the user.
note string
}
var loopIntervalRe = regexp.MustCompile(`^(\d+)(s|m|h|d)$`)
// loopControlRe matches a self-paced loop's control line at the start of a line,
// tolerant of a leading markdown bullet/quote marker and any trailing note after
// the keyword: "LOOP: DONE", "- loop: continue", "LOOP: CONTINUE 10m — still tidying".
// The leading ^ anchor keeps a mid-sentence mention ("I'll emit LOOP: DONE when…")
// from matching; parseLoopControl normalizes CRLF first so `$` can reach line end.
var loopControlRe = regexp.MustCompile(`(?im)^[^\S\r\n]*(?:[-*>•][^\S\r\n]*)?loop:[^\S\r\n]*(done|continue)\b(?:[^\S\r\n]+(\d+[smhd])\b)?.*$`)
// parseLoopCommand interprets the arguments to `/loop`. Forms:
//
// (empty) -> usage/list
// list | status -> list active loops
// stop | stop all | stop <id>
// <interval> <prompt> -> fixed-interval loop (e.g. "5m /babysit")
// <prompt> every <interval>
// <prompt> -> self-paced loop (no interval)
func parseLoopCommand(args string) loopCommand {
trimmed := strings.TrimSpace(args)
if trimmed == "" {
return loopCommand{action: loopActionUsage}
}
first := strings.ToLower(strings.Fields(trimmed)[0])
switch first {
case "list", "status", "ls":
return loopCommand{action: loopActionList}
case "stop", "cancel", "kill":
rest := strings.TrimSpace(trimmed[len(strings.Fields(trimmed)[0]):])
if rest == "" || strings.EqualFold(rest, "all") {
if strings.EqualFold(rest, "all") {
return loopCommand{action: loopActionStopAll}
}
// bare "stop" stops all when ambiguous; the caller may special-case a
// single active loop.
return loopCommand{action: loopActionStop, targetID: ""}
}
return loopCommand{action: loopActionStop, targetID: strings.TrimSpace(rest)}
}
// Leading interval token: "5m <prompt>".
if fields := strings.Fields(trimmed); len(fields) >= 2 {
if d, ok := parseLoopInterval(fields[0]); ok {
prompt := strings.TrimSpace(trimmed[len(fields[0]):])
cmd := loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt}
cmd.interval, cmd.note = clampLoopInterval(d)
return cmd
}
}
// Trailing "every <interval>".
if prompt, d, ok := splitTrailingEvery(trimmed); ok {
cmd := loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt}
cmd.interval, cmd.note = clampLoopInterval(d)
return cmd
}
// No interval anywhere -> self-paced.
return loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: trimmed}
}
// splitTrailingEvery matches "<prompt> every <interval>" (the interval must be a
// real duration token so "check every PR" is not mis-parsed as a cadence).
func splitTrailingEvery(input string) (string, time.Duration, bool) {
fields := strings.Fields(input)
if len(fields) < 3 {
return "", 0, false
}
last := fields[len(fields)-1]
prev := fields[len(fields)-2]
if !strings.EqualFold(prev, "every") {
return "", 0, false
}
d, ok := parseLoopInterval(last)
if !ok {
return "", 0, false
}
prompt := strings.TrimSpace(strings.Join(fields[:len(fields)-2], " "))
if prompt == "" {
return "", 0, false
}
return prompt, d, true
}
// parseLoopInterval parses a bare "<N><unit>" token (s/m/h/d) into a duration.
func parseLoopInterval(token string) (time.Duration, bool) {
m := loopIntervalRe.FindStringSubmatch(strings.ToLower(strings.TrimSpace(token)))
if m == nil {
return 0, false
}
n, err := strconv.Atoi(m[1])
if err != nil || n <= 0 {
return 0, false
}
unit := map[string]time.Duration{
"s": time.Second,
"m": time.Minute,
"h": time.Hour,
"d": 24 * time.Hour,
}[m[2]]
return time.Duration(n) * unit, true
}
// clampLoopInterval bounds an interval to [loopMinInterval, loopMaxInterval] and
// returns an advisory note when it was adjusted.
func clampLoopInterval(d time.Duration) (time.Duration, string) {
switch {
case d < loopMinInterval:
return loopMinInterval, fmt.Sprintf("interval raised to the %s minimum", formatLoopDuration(loopMinInterval))
case d > loopMaxInterval:
return loopMaxInterval, fmt.Sprintf("interval lowered to the %s maximum", formatLoopDuration(loopMaxInterval))
default:
return d, ""
}
}
// loopTickMsg is the between-turn poll that fires a due loop while the session is
// idle. seq invalidates a tick left over from before the loop set changed.
type loopTickMsg struct{ seq int }
// handleLoopCommand dispatches a parsed `/loop ...` invocation.
func (m model) handleLoopCommand(args string) (model, tea.Cmd) {
cmd := parseLoopCommand(args)
switch cmd.action {
case loopActionList:
return m.appendLoopSystem(m.loopListText()), nil
case loopActionUsage:
if len(m.loops) > 0 {
return m.appendLoopSystem(m.loopListText()), nil
}
return m.appendLoopSystem(loopUsageText()), nil
case loopActionStop:
return m.stopLoop(cmd.targetID)
case loopActionStopAll:
return m.stopAllLoops()
case loopActionStart:
return m.startLoop(cmd)
}
return m, nil
}
// startLoop registers a new loop and (re)starts the idle poll ticker. The first
// iteration fires on the next idle tick (nextRunAt = now).
func (m model) startLoop(cmd loopCommand) (model, tea.Cmd) {
if reason, ok := m.validateLoopTarget(cmd.prompt); !ok {
return m.appendLoopSystem(reason), nil
}
// A /command loop must be fixed-interval. Self-paced mode drives a free-form
// goal through the playbook + LOOP: control line (see firePrompt), which a
// custom command's expanded template never carries — so a bare self-paced
// /command would silently degrade to adaptive polling with no way to report
// done. Require an explicit interval for command loops instead.
if cmd.mode == loopModeSelfPaced && strings.HasPrefix(strings.TrimSpace(cmd.prompt), "/") {
return m.appendLoopSystem("A /command loop needs an interval — e.g. /loop 5m " + strings.Fields(cmd.prompt)[0] + ". Self-paced mode (no interval) is for a free-form goal."), nil
}
m.loopCounter++
id := "L" + strconv.Itoa(m.loopCounter)
loop := &loopState{
id: id,
mode: cmd.mode,
prompt: cmd.prompt,
interval: cmd.interval,
createdAt: m.now(),
nextRunAt: m.now(), // fire the first iteration on the next idle tick
}
m.loops = append(m.loops, loop)
note := ""
if cmd.note != "" {
note = " (" + cmd.note + ")"
}
m = m.appendLoopSystem(fmt.Sprintf(
"Loop %s started — %s%s. Runs while this session is idle; stop with /loop stop %s.",
id, loop.cadenceText(), note, id))
return m.ensureLoopTick()
}
// stopLoop stops one loop by id, or the single active loop when the id is omitted.
func (m model) stopLoop(id string) (model, tea.Cmd) {
if len(m.loops) == 0 {
return m.appendLoopSystem("No active loops."), nil
}
if id == "" {
if len(m.loops) == 1 {
return m.removeLoop(m.loops[0].id, "Loop "+m.loops[0].id+" stopped.").ensureLoopTick()
}
return m.appendLoopSystem("Multiple loops active — use /loop stop <id> or /loop stop all.\n" + m.loopListText()), nil
}
if m.findLoop(id) == nil {
return m.appendLoopSystem("No loop " + id + ". " + m.loopListText()), nil
}
// removeLoop stops the poll ticker; re-arm it so any OTHER loops that remain
// keep firing (ensureLoopTick no-ops when this was the last loop).
return m.removeLoop(id, "Loop "+id+" stopped.").ensureLoopTick()
}
func (m model) stopAllLoops() (model, tea.Cmd) {
if len(m.loops) == 0 {
return m.appendLoopSystem("No active loops."), nil
}
n := len(m.loops)
m.loops = nil
m.loopSeq++ // invalidate the pending poll tick
m.loopTicking = false
return m.appendLoopSystem(fmt.Sprintf("Stopped all %d loop(s).", n)), nil
}
// fireDueLoopIfIdle fires the earliest due loop when the session is idle. Called
// from the poll tick; a no-op while a turn, modal, or queued user message is
// pending (the loop simply waits for the next idle tick).
func (m model) fireDueLoopIfIdle() (model, tea.Cmd) {
if m.loopBusy() || len(m.loops) == 0 {
return m, nil
}
now := m.now()
var due *loopState
for _, l := range m.loops {
if l.due(now) && (due == nil || l.nextRunAt.Before(due.nextRunAt)) {
due = l
}
}
if due == nil {
return m, nil
}
m.activeLoopID = due.id
due.nextRunAt = time.Time{} // mark running
return m.fireLoopPrompt(due.firePrompt())
}
// firePrompt is the text actually sent for this iteration. A self-paced plain
// prompt is wrapped in the maintenance playbook so the model knows to do one
// increment of work and end with a LOOP: control line; a /command or a
// fixed-interval prompt is sent verbatim.
func (l *loopState) firePrompt() string {
if l.mode == loopModeSelfPaced && !strings.HasPrefix(strings.TrimSpace(l.prompt), "/") {
return loopSelfPacePlaybook(l)
}
return l.prompt
}
// fireLoopPrompt runs a loop's prompt as a turn — a custom /command if it resolves
// to one, otherwise a plain prompt.
func (m model) fireLoopPrompt(prompt string) (model, tea.Cmd) {
if strings.HasPrefix(strings.TrimSpace(prompt), "/") {
if next, teaCmd, ok := m.handleUserCommand(prompt); ok {
return next, teaCmd
}
}
return m.launchPrompt(prompt)
}
// advanceLoop updates a loop after one of its iterations completes: it folds the
// result into the failure/no-progress guards, honors a self-paced DONE/next-wake
// control line, enforces the iteration and age caps, then schedules the next wake
// (or stops the loop).
func (m model) advanceLoop(id, finalAnswer string, runErr error) model {
l := m.findLoop(id)
if l == nil {
return m // stopped mid-run
}
l.iteration++
if runErr != nil {
l.failRun++
} else {
l.failRun = 0
}
res := strings.TrimSpace(finalAnswer)
if res != "" && res == l.lastResult {
l.repeatRun++
} else {
l.repeatRun = 0
}
l.lastResult = res
// Only self-paced iterations run the playbook, so only they can carry a control
// line; a failed turn has no trustworthy final answer to read one from.
done, wake := false, time.Duration(0)
if l.mode == loopModeSelfPaced && runErr == nil {
done, wake = parseLoopControl(finalAnswer)
}
switch {
case done:
return m.removeLoop(id, fmt.Sprintf("Loop %s finished — the task reported done after %d iteration(s).", id, l.iteration))
case l.failRun >= loopMaxFailures:
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped after %d consecutive failed iterations.", id, l.failRun))
case l.iteration >= l.iterationCap():
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped at its %d-iteration cap.", id, l.iteration))
case !l.createdAt.IsZero() && m.now().Sub(l.createdAt) >= loopMaxAge:
return m.removeLoop(id, fmt.Sprintf("Loop %s expired after %s.", id, formatLoopDuration(loopMaxAge)))
case l.repeatRun >= loopDoomThreshold:
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped — %d identical results in a row (no progress).", id, l.repeatRun))
}
if l.mode == loopModeSelfPaced {
// A model-chosen next wake (from the control line) wins; otherwise fall back to
// an adaptive cadence that checks back often while work is fresh and settles to
// a slow heartbeat as iterations accrue.
delay := wake
if delay <= 0 {
delay = adaptiveSelfPaceDelay(l.iteration)
}
l.nextRunAt = m.now().Add(clampSelfPaceDelay(delay))
} else {
l.nextRunAt = m.now().Add(l.interval)
}
return m
}
// parseLoopControl reads a self-paced loop's control signal from the model's final
// answer. The last control line wins (the model may reference the protocol before
// emitting it). Returns done=true to stop the loop, or a next-wake hint (0 = none).
func parseLoopControl(answer string) (done bool, wake time.Duration) {
// Normalize CRLF/CR so the line-end anchor can reach `$` (Go's (?m)$ sits right
// before `\n`, and the horizontal-whitespace class can't consume a stray `\r`).
norm := strings.ReplaceAll(answer, "\r\n", "\n")
norm = strings.ReplaceAll(norm, "\r", "\n")
matches := loopControlRe.FindAllStringSubmatch(norm, -1)
if len(matches) == 0 {
return false, 0
}
last := matches[len(matches)-1]
if strings.EqualFold(last[1], "done") {
return true, 0
}
if last[2] != "" {
if d, ok := parseLoopInterval(last[2]); ok {
return false, d
}
}
return false, 0
}
// loopSelfPacePlaybook wraps a self-paced loop's goal in the maintenance-loop
// instructions the model follows each iteration: do one real increment of work,
// then emit a single control line so the harness knows whether to stop or when to
// wake next — the self-paced maintenance playbook the feature ships with.
func loopSelfPacePlaybook(l *loopState) string {
var b strings.Builder
b.WriteString("You are running in a self-paced maintenance loop (iteration ")
b.WriteString(strconv.Itoa(l.iteration + 1))
b.WriteString("). Do one concrete increment of work toward the goal below — actual work, not just a plan — then stop and report.\n\n")
b.WriteString("Goal: ")
b.WriteString(strings.TrimSpace(l.prompt))
b.WriteString("\n\nEnd your reply with a control line — on its own line, starting with \"LOOP:\":\n")
b.WriteString(" LOOP: DONE — the goal is fully met; the loop stops.\n")
b.WriteString(" LOOP: CONTINUE — more to do; the loop wakes again automatically.\n")
b.WriteString(" LOOP: CONTINUE 10m — more to do; wake after the delay you name (e.g. 5m, 1h).\n")
b.WriteString("A short note after the keyword is fine. Report DONE only when the goal is genuinely complete — the loop trusts that signal to stop.")
return b.String()
}
// adaptiveSelfPaceDelay picks a self-paced loop's next-wake by iteration: frequent
// early (work is actively progressing), slowing to a heartbeat as it matures.
func adaptiveSelfPaceDelay(iteration int) time.Duration {
switch {
case iteration < 2:
return 2 * time.Minute
case iteration < 5:
return 5 * time.Minute
case iteration < 10:
return 15 * time.Minute
default:
return 30 * time.Minute
}
}
func (m model) findLoop(id string) *loopState {
for _, l := range m.loops {
if l.id == id {
return l
}
}
return nil
}
func (m model) removeLoop(id, note string) model {
kept := make([]*loopState, 0, len(m.loops))
for _, l := range m.loops {
if l.id != id {
kept = append(kept, l)
}
}
m.loops = kept
m.loopSeq++ // invalidate the pending poll tick; a fresh one is scheduled if loops remain
m.loopTicking = false
if note != "" {
m = m.appendLoopSystem(note)
}
return m
}
// ensureLoopTick starts the idle poll ticker if it is not already running.
func (m model) ensureLoopTick() (model, tea.Cmd) {
if m.loopTicking || len(m.loops) == 0 {
return m, nil
}
m.loopTicking = true
return m, m.scheduleLoopTick()
}
func (m model) scheduleLoopTick() tea.Cmd {
seq := m.loopSeq
return tea.Tick(loopPollInterval, func(time.Time) tea.Msg {
return loopTickMsg{seq: seq}
})
}
// loopBusy reports whether the session is too busy for a loop to fire — a turn,
// queued user message, compaction, or ANY blocking modal (permission/askUser/spec
// review, provider or MCP wizard, MCP manager, or an open picker) is in flight.
// The modal check is load-bearing: a loop must not launch a run behind an open
// /resume picker, or that run would complete into whatever session the user then
// switches to. Mirrors launchQueuedMessageIfReady so loops and queued prompts never
// collide.
func (m model) loopBusy() bool {
return m.pending || m.exiting || m.compactInFlight || m.hasQueuedMessage() || !m.noBlockingModal()
}
func (m model) appendLoopSystem(text string) model {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m
}
// loopActive reports whether any loop is running (for the footer + leave warning).
func (m model) loopActive() bool { return len(m.loops) > 0 }
// loopFooterSummary renders the persistent "N loops · next 3:05pm" footer segment,
// or "" when no loops are active.
func (m model) loopFooterSummary() string {
if len(m.loops) == 0 {
return ""
}
var next time.Time
for _, l := range m.loops {
if l.nextRunAt.IsZero() || l.paused {
continue
}
if next.IsZero() || l.nextRunAt.Before(next) {
next = l.nextRunAt
}
}
label := fmt.Sprintf("%d loop", len(m.loops))
if len(m.loops) != 1 {
label += "s"
}
if next.IsZero() {
return label
}
return label + " · next " + next.Format("3:04pm")
}
func (m model) loopListText() string {
if len(m.loops) == 0 {
return "No active loops."
}
var b strings.Builder
b.WriteString("Active loops:\n")
now := m.now()
for _, l := range m.loops {
when := "running"
if !l.nextRunAt.IsZero() {
if d := l.nextRunAt.Sub(now); d > 0 {
when = "next in " + formatLoopDuration(d.Round(time.Second))
} else {
when = "due"
}
}
b.WriteString(fmt.Sprintf(" %s · %s · iter %d · %s · %s\n",
l.id, l.cadenceText(), l.iteration, when, truncateLoopPrompt(l.prompt)))
}
b.WriteString("Stop with /loop stop <id> or /loop stop all.")
return b.String()
}
func loopUsageText() string {
return "Repeat a prompt or command:\n" +
" /loop 5m /babysit-prs — run a command every 5 minutes\n" +
" /loop watch CI every 2m — trailing interval form\n" +
" /loop keep tidying docs — self-paced (the model picks its own cadence and stops when done)\n" +
" /loop list — show active loops\n" +
" /loop stop [id|all] — stop a loop\n" +
"Loops run while this session is idle and stop when you close it."
}
// validateLoopTarget rejects looping a built-in command or an unresolved custom
// command — only a prompt or an existing custom /command (.zero/commands/<name>.md)
// may loop. Resolving custom commands up front stops a typo'd `/loop 5m /nope` from
// being scheduled and then re-sent as literal text on every tick.
func (m model) validateLoopTarget(prompt string) (string, bool) {
p := strings.TrimSpace(prompt)
if p == "" {
return "Nothing to loop — give a prompt or a /command.", false
}
if strings.HasPrefix(p, "/") {
switch parseCommand(p).kind {
case commandPrompt:
// A bare "/" or similar that isn't a command — treat as a plain prompt.
case commandUnknown:
name, _ := splitUserCommand(p)
if _, ok := m.lookupUserCommand(name); !ok {
return "No command /" + name + " — a loop can only run a prompt or an existing custom /command.", false
}
default:
return "A loop can only run a prompt or a custom /command, not a built-in like " + strings.Fields(p)[0] + ".", false
}
}
return "", true
}
// clearLoopsForSessionSwitch drops every active loop and its ticker. Loops belong to
// the session that created them; carrying them across /new or /resume would fire the
// old session's prompt into an unrelated conversation. Returns the count cleared so
// the caller can note it. Pure state reset — no transcript writes.
func (m model) clearLoopsForSessionSwitch() (model, int) {
n := len(m.loops)
if n == 0 {
return m, 0
}
m.loops = nil
m.activeLoopID = ""
m.loopTicking = false
m.loopSeq++ // invalidate any pending poll tick
return m, n
}
func truncateLoopPrompt(prompt string) string {
const max = 48
p := strings.TrimSpace(strings.ReplaceAll(prompt, "\n", " "))
runes := []rune(p)
if len(runes) <= max {
return p
}
return strings.TrimSpace(string(runes[:max])) + "…"
}
// clampSelfPaceDelay bounds a model-chosen self-paced delay to the self-pace
// band, defaulting an unset/invalid value.
func clampSelfPaceDelay(d time.Duration) time.Duration {
switch {
case d <= 0:
return loopSelfPaceDefault
case d < loopSelfPaceMin:
return loopSelfPaceMin
case d > loopSelfPaceMax:
return loopSelfPaceMax
default:
return d
}
}
// formatLoopDuration renders a duration compactly (90s, 5m, 2h, 1d) for status
// text, preferring the largest whole unit.
func formatLoopDuration(d time.Duration) string {
switch {
case d%(24*time.Hour) == 0 && d >= 24*time.Hour:
return strconv.Itoa(int(d/(24*time.Hour))) + "d"
case d%time.Hour == 0 && d >= time.Hour:
return strconv.Itoa(int(d/time.Hour)) + "h"
case d%time.Minute == 0 && d >= time.Minute:
return strconv.Itoa(int(d/time.Minute)) + "m"
default:
return strconv.Itoa(int(d/time.Second)) + "s"
}
}
+460
View File
@@ -0,0 +1,460 @@
package tui
import (
"context"
"errors"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/usercommands"
)
func loopTestModel(t *testing.T, now time.Time) model {
t.Helper()
m := newModel(context.Background(), Options{})
m.now = func() time.Time { return now }
return m
}
func startFixedLoop(m model, prompt string, interval time.Duration) model {
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt, interval: interval})
return m
}
func TestStartLoopRegistersAndSchedules(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "check the build", 5*time.Minute)
if len(m.loops) != 1 {
t.Fatalf("want 1 loop, got %d", len(m.loops))
}
l := m.loops[0]
if l.mode != loopModeFixed || l.interval != 5*time.Minute || l.prompt != "check the build" {
t.Fatalf("unexpected loop %+v", l)
}
// First iteration is due immediately (nextRunAt == now).
if !l.due(now) {
t.Errorf("first iteration should be due at creation")
}
if !m.loopTicking {
t.Errorf("starting a loop should start the poll ticker")
}
}
func TestAdvanceLoopSchedulesNextWake(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "poll", 5*time.Minute)
id := m.loops[0].id
m = m.advanceLoop(id, "did some work", nil)
l := m.loops[0]
if l.iteration != 1 {
t.Fatalf("iteration = %d, want 1", l.iteration)
}
if !l.nextRunAt.Equal(now.Add(5 * time.Minute)) {
t.Fatalf("nextRunAt = %v, want +5m", l.nextRunAt)
}
}
func TestSelfPacedUsesModelDelay(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "keep tidying"})
id := m.loops[0].id
// The model carries its chosen next wake in the reply's control line.
m = m.advanceLoop(id, "made progress\nLOOP: CONTINUE 10m", nil)
if len(m.loops) == 0 {
t.Fatal("a CONTINUE control line should keep the loop running")
}
if !m.loops[0].nextRunAt.Equal(now.Add(10 * time.Minute)) {
t.Fatalf("self-paced nextRunAt = %v, want +10m", m.loops[0].nextRunAt)
}
}
func TestLoopDoneStops(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "finish the task"})
id := m.loops[0].id
m = m.advanceLoop(id, "all done\nLOOP: DONE", nil)
if len(m.loops) != 0 {
t.Fatalf("loop should stop when the task reports done, %d remain", len(m.loops))
}
}
func TestFixedLoopIgnoresControlLine(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "watch CI", time.Minute)
id := m.loops[0].id
// A fixed-interval loop never runs the playbook, so a stray control line in the
// output is not a completion signal — it keeps its wall-clock cadence.
m = m.advanceLoop(id, "LOOP: DONE", nil)
if len(m.loops) != 1 {
t.Fatal("a fixed-interval loop should ignore a control line and keep running")
}
if !m.loops[0].nextRunAt.Equal(now.Add(time.Minute)) {
t.Fatalf("fixed nextRunAt = %v, want +1m", m.loops[0].nextRunAt)
}
}
func TestLoopExpiresByAge(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "forgotten", time.Minute)
id := m.loops[0].id
m.loops[0].createdAt = now.Add(-loopMaxAge - time.Hour) // created past the age cap
m = m.advanceLoop(id, "still going", nil)
if len(m.loops) != 0 {
t.Fatal("a loop past its age cap should auto-expire")
}
}
func TestLoopDoomGuardStops(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "spin", time.Minute)
id := m.loops[0].id
for i := 0; i < loopDoomThreshold+1 && len(m.loops) > 0; i++ {
m = m.advanceLoop(id, "identical answer", nil)
}
if len(m.loops) != 0 {
t.Fatalf("doom guard should stop a loop repeating identical results")
}
}
func TestLoopIterationCapStops(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "bounded", time.Minute)
m.loops[0].maxIter = 3
id := m.loops[0].id
for i := 0; i < 3 && len(m.loops) > 0; i++ {
m = m.advanceLoop(id, "answer "+string(rune('a'+i)), nil) // distinct answers so doom doesn't fire
}
if len(m.loops) != 0 {
t.Fatalf("loop should stop at its iteration cap")
}
}
func TestStopLoopByID(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "a", time.Minute)
m = startFixedLoop(m, "b", time.Minute)
id := m.loops[0].id
m, cmd := m.stopLoop(id)
if len(m.loops) != 1 || m.findLoop(id) != nil {
t.Fatalf("stopLoop(%s) should remove exactly that loop", id)
}
// Stopping one of several loops must keep the poll ticker alive for the rest:
// removeLoop kills the ticker, so stopLoop has to re-arm it (jatmn).
if cmd == nil || !m.loopTicking {
t.Fatal("stopping one of several loops must leave the remaining loop scheduled (ticker re-armed)")
}
// Bare stop with a single active loop stops that sole loop (parseLoopCommand
// leaves the target empty and stopLoop special-cases the one-loop case).
soleID := m.loops[0].id
m, _ = m.stopLoop("")
if len(m.loops) != 0 || m.findLoop(soleID) != nil {
t.Fatalf("bare stop should remove the sole active loop")
}
m = startFixedLoop(m, "c", time.Minute)
m = startFixedLoop(m, "d", time.Minute)
m, _ = m.stopAllLoops()
if len(m.loops) != 0 {
t.Fatalf("stopAllLoops should clear all loops")
}
}
func TestFireDueLoopSkipsWhenBusy(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "x", time.Minute)
m.pending = true // a turn is in flight
got, cmd := m.fireDueLoopIfIdle()
if got.activeLoopID != "" || cmd != nil {
t.Fatalf("a due loop must not fire while a turn is pending")
}
}
func TestFireDueLoopSkipsBehindModal(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "poll", time.Minute) // due immediately
m.picker = &commandPicker{} // e.g. an open /resume session picker
got, cmd := m.fireDueLoopIfIdle()
if got.activeLoopID != "" || cmd != nil {
t.Fatal("a due loop must not launch a run behind an open picker/modal — it would complete into whatever session the user switches to")
}
if got.loops[0].nextRunAt.IsZero() {
t.Fatal("a loop skipped because a modal is open must stay scheduled, not have its next run cleared")
}
}
func TestAdaptiveSelfPaceDelayWidensOverTime(t *testing.T) {
if adaptiveSelfPaceDelay(0) >= adaptiveSelfPaceDelay(5) {
t.Error("early iterations should check back sooner than later ones")
}
if adaptiveSelfPaceDelay(20) != 30*time.Minute {
t.Errorf("a mature loop should settle to a 30m heartbeat, got %v", adaptiveSelfPaceDelay(20))
}
}
func TestLoopFooterSummary(t *testing.T) {
now := time.Date(2026, 7, 5, 15, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
if m.loopFooterSummary() != "" {
t.Error("no loops -> empty footer summary")
}
m = startFixedLoop(m, "a", 5*time.Minute)
m.loops[0].nextRunAt = now.Add(5 * time.Minute)
got := m.loopFooterSummary()
wantWake := "next " + now.Add(5*time.Minute).Format("3:04pm")
if !strings.HasPrefix(got, "1 loop") || !strings.Contains(got, wantWake) {
t.Fatalf("footer summary = %q, want it to start with a loop count and contain %q", got, wantWake)
}
}
func TestLoopCommandThroughSubmit(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m.input.SetValue("/loop 5m tidy the imports")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if len(next.loops) != 1 {
t.Fatalf("submitting /loop should create a loop, got %d", len(next.loops))
}
if next.loops[0].mode != loopModeFixed || next.loops[0].interval != 5*time.Minute {
t.Fatalf("unexpected loop from submit: %+v", next.loops[0])
}
if !next.loopTicking {
t.Fatalf("submitting /loop should start the poll ticker")
}
// /loop stop all through the same path clears it.
next.input.SetValue("/loop stop all")
updated2, _ := next.Update(testKey(tea.KeyEnter))
final := updated2.(model)
if len(final.loops) != 0 {
t.Fatalf("/loop stop all should clear loops, got %d", len(final.loops))
}
}
func TestStaleLoopTickIgnored(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "x", time.Minute)
m.loopSeq = 7
// A tick from before the seq changed must be dropped and stop the ticker.
updated, cmd := m.Update(loopTickMsg{seq: 3})
next := updated.(model)
if next.loopTicking {
t.Error("a stale loop tick should stop the ticker")
}
if cmd != nil {
t.Error("a stale loop tick should not reschedule")
}
}
func TestLoopStopsAfterConsecutiveFailures(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "flaky", time.Minute)
id := m.loops[0].id
boom := errors.New("boom")
for i := 0; i < loopMaxFailures && len(m.loops) > 0; i++ {
m = m.advanceLoop(id, "", boom)
}
if len(m.loops) != 0 {
t.Fatalf("loop should stop after %d consecutive failed iterations", loopMaxFailures)
}
}
func TestLoopFailureCounterResetsOnSuccess(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "recovers", time.Minute)
id := m.loops[0].id
m = m.advanceLoop(id, "", errors.New("x"))
m = m.advanceLoop(id, "", errors.New("x"))
m = m.advanceLoop(id, "back to healthy", nil) // a success resets the streak
if len(m.loops) != 1 {
t.Fatalf("a recovered failure streak should not stop the loop")
}
if m.loops[0].failRun != 0 {
t.Fatalf("a successful iteration should reset failRun, got %d", m.loops[0].failRun)
}
}
func TestCancelRunClearsActiveLoopAndRearms(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "x", 5*time.Minute)
m.activeLoopID = m.loops[0].id
m.loops[0].nextRunAt = time.Time{} // as if the iteration is running
m.pending = true
m.activeRunID = 7
m.runCancel = func() {}
m.cancelRun()
if m.activeLoopID != "" {
t.Fatal("cancel should clear the active-loop tag so the next turn isn't misattributed")
}
if m.loops[0].nextRunAt.IsZero() {
t.Fatal("cancel should re-arm the interrupted loop, not leave it running forever")
}
if !m.loops[0].nextRunAt.Equal(now.Add(5 * time.Minute)) {
t.Fatalf("re-armed nextRunAt = %v, want +5m", m.loops[0].nextRunAt)
}
}
func TestValidateLoopTargetRejectsBuiltin(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
if _, ok := m.validateLoopTarget("/model list"); ok {
t.Error("looping a built-in command should be rejected")
}
if _, ok := m.validateLoopTarget("keep improving docs"); !ok {
t.Error("a plain prompt should be a valid loop target")
}
if _, ok := m.validateLoopTarget(""); ok {
t.Error("an empty target should be rejected")
}
// A custom command that does not exist is rejected up front, so it is never
// scheduled and then re-sent as literal "/…" text on every tick.
if _, ok := m.validateLoopTarget("/babysit-prs"); ok {
t.Error("an unresolved custom command should be rejected")
}
// A registered custom command is accepted.
m.userCommands = []usercommands.Command{{Name: "babysit-prs", Template: "check the PRs"}}
if _, ok := m.validateLoopTarget("/babysit-prs"); !ok {
t.Error("an existing custom command should be a valid loop target")
}
}
func TestSelfPacedCommandLoopRejected(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m.userCommands = []usercommands.Command{{Name: "babysit-prs", Template: "check the PRs"}}
// A /command with no interval parses as self-paced, but self-paced mode drives
// a free-form goal via the playbook + LOOP: protocol, which a command's
// expanded template never carries — so a bare self-paced /command is rejected.
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "/babysit-prs"})
if len(m.loops) != 0 {
t.Fatal("a bare self-paced /command loop should be rejected (a command loop needs an interval)")
}
// The same command with an interval (fixed) is accepted.
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: "/babysit-prs", interval: 5 * time.Minute})
if len(m.loops) != 1 {
t.Fatal("a fixed-interval /command loop should be accepted")
}
}
func TestClearLoopsForSessionSwitch(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "a", time.Minute)
m = startFixedLoop(m, "b", time.Minute)
m.activeLoopID = m.loops[0].id
m, n := m.clearLoopsForSessionSwitch()
if n != 2 || len(m.loops) != 0 || m.activeLoopID != "" || m.loopTicking {
t.Fatalf("session switch should clear all loop state; got n=%d loops=%d active=%q ticking=%v",
n, len(m.loops), m.activeLoopID, m.loopTicking)
}
}
func TestStartNewSessionStopsLoops(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "x", time.Minute)
m = m.startNewSession()
if len(m.loops) != 0 || m.loopTicking {
t.Fatal("/new should stop loops tied to the previous session")
}
}
func TestLoopClearRequiresConfirm(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "x", time.Minute)
// First /clear with a loop active arms the confirm and does not clear yet.
m.input.SetValue("/clear")
u1, _ := m.Update(testKey(tea.KeyEnter))
n1 := u1.(model)
if n1.loopLeavePrompt != commandClear {
t.Fatal("first /clear with active loops should arm the confirm, not clear")
}
if len(n1.loops) != 1 {
t.Fatal("the loop should still be active after the warning")
}
// A different command disarms the confirm.
n1.input.SetValue("/loop list")
u2, _ := n1.Update(testKey(tea.KeyEnter))
n2 := u2.(model)
if n2.loopLeavePrompt != commandEmpty {
t.Fatal("an intervening command should disarm the leave confirm")
}
// Re-arm, then a second consecutive /clear confirms (disarms + clears).
n2.input.SetValue("/clear")
u3, _ := n2.Update(testKey(tea.KeyEnter))
n3 := u3.(model)
n3.input.SetValue("/clear")
u4, _ := n3.Update(testKey(tea.KeyEnter))
n4 := u4.(model)
if n4.loopLeavePrompt != commandEmpty {
t.Fatal("a second consecutive /clear should confirm and disarm")
}
if len(n4.loops) != 1 {
t.Fatal("/clear keeps loops running (it wipes the screen, not the session)")
}
// The confirmed clear must actually wipe the transcript, not silently no-op:
// only the clear path appends the "Transcript cleared" note, and the pre-clear
// loop-start row must be gone.
clearedNote, staleRow := false, false
for _, r := range n4.transcript {
if strings.Contains(r.text, "Transcript cleared") {
clearedNote = true
}
if strings.Contains(r.text, "Loop L1 started") {
staleRow = true
}
}
if !clearedNote || staleRow {
t.Fatalf("the second /clear must wipe the transcript (clearedNote=%v staleRow=%v)", clearedNote, staleRow)
}
}
func TestLeaveConfirmDisarmedByQueuedPrompt(t *testing.T) {
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
m := loopTestModel(t, now)
m = startFixedLoop(m, "x", time.Minute)
// Arm the /clear confirm.
m.input.SetValue("/clear")
u1, _ := m.Update(testKey(tea.KeyEnter))
n1 := u1.(model)
if n1.loopLeavePrompt != commandClear {
t.Fatal("first /clear should arm the confirm")
}
// A prompt submitted while a run streams is queued via an early return in
// handleSubmit; it must still disarm the confirm (the disarm runs before those
// returns). Otherwise a later /clear would be treated as the second press.
n1.pending = true
n1.input.SetValue("a follow-up question")
u2, _ := n1.Update(testKey(tea.KeyEnter))
n2 := u2.(model)
if n2.loopLeavePrompt != commandEmpty {
t.Fatal("a queued prompt should disarm the leave confirm; the early return must not skip the disarm")
}
}
+156
View File
@@ -0,0 +1,156 @@
package tui
import (
"strings"
"testing"
"time"
)
func TestParseLoopCommandModes(t *testing.T) {
cases := []struct {
in string
action loopAction
mode loopMode
prompt string
interval time.Duration
}{
{"", loopActionUsage, 0, "", 0},
{"list", loopActionList, 0, "", 0},
{"status", loopActionList, 0, "", 0},
{"stop all", loopActionStopAll, 0, "", 0},
{"stop ab12", loopActionStop, 0, "", 0},
{"5m /babysit-prs", loopActionStart, loopModeFixed, "/babysit-prs", 5 * time.Minute},
{"90s check the build", loopActionStart, loopModeFixed, "check the build", 90 * time.Second},
{"watch CI every 2m", loopActionStart, loopModeFixed, "watch CI", 2 * time.Minute},
{"keep improving the docs", loopActionStart, loopModeSelfPaced, "keep improving the docs", 0},
// "every PR" is not a cadence — the token after "every" isn't a duration.
{"review every PR", loopActionStart, loopModeSelfPaced, "review every PR", 0},
}
for _, c := range cases {
got := parseLoopCommand(c.in)
if got.action != c.action {
t.Errorf("%q action = %v, want %v", c.in, got.action, c.action)
}
if c.action == loopActionStart {
if got.mode != c.mode {
t.Errorf("%q mode = %v, want %v", c.in, got.mode, c.mode)
}
if got.prompt != c.prompt {
t.Errorf("%q prompt = %q, want %q", c.in, got.prompt, c.prompt)
}
if got.interval != c.interval {
t.Errorf("%q interval = %v, want %v", c.in, got.interval, c.interval)
}
}
}
}
func TestParseLoopCommandStopTarget(t *testing.T) {
if got := parseLoopCommand("stop"); got.action != loopActionStop || got.targetID != "" {
t.Fatalf("bare stop = %+v, want stop with empty target", got)
}
if got := parseLoopCommand("stop XyZ9"); got.targetID != "XyZ9" {
t.Fatalf("stop target = %q, want XyZ9", got.targetID)
}
}
func TestClampLoopInterval(t *testing.T) {
if d, note := clampLoopInterval(5 * time.Second); d != loopMinInterval || note == "" {
t.Fatalf("below-min = (%v,%q), want raised to min with note", d, note)
}
if d, note := clampLoopInterval(48 * time.Hour); d != loopMaxInterval || note == "" {
t.Fatalf("above-max = (%v,%q), want lowered to max with note", d, note)
}
if d, note := clampLoopInterval(5 * time.Minute); d != 5*time.Minute || note != "" {
t.Fatalf("in-range = (%v,%q), want unchanged, no note", d, note)
}
}
func TestClampSelfPaceDelay(t *testing.T) {
if got := clampSelfPaceDelay(0); got != loopSelfPaceDefault {
t.Errorf("zero delay = %v, want default %v", got, loopSelfPaceDefault)
}
if got := clampSelfPaceDelay(10 * time.Second); got != loopSelfPaceMin {
t.Errorf("below-min = %v, want %v", got, loopSelfPaceMin)
}
if got := clampSelfPaceDelay(3 * time.Hour); got != loopSelfPaceMax {
t.Errorf("above-max = %v, want %v", got, loopSelfPaceMax)
}
if got := clampSelfPaceDelay(10 * time.Minute); got != 10*time.Minute {
t.Errorf("in-band = %v, want unchanged", got)
}
}
func TestFormatLoopDuration(t *testing.T) {
cases := map[time.Duration]string{
30 * time.Second: "30s",
90 * time.Second: "90s",
5 * time.Minute: "5m",
2 * time.Hour: "2h",
24 * time.Hour: "1d",
}
for d, want := range cases {
if got := formatLoopDuration(d); got != want {
t.Errorf("formatLoopDuration(%v) = %q, want %q", d, got, want)
}
}
}
func TestParseLoopControl(t *testing.T) {
cases := []struct {
in string
done bool
wake time.Duration
}{
{"did the work\nLOOP: DONE", true, 0},
{"progress\nLOOP: CONTINUE", false, 0},
{"progress\nLOOP: CONTINUE 10m", false, 10 * time.Minute},
{"progress\n- loop: continue 1h", false, time.Hour}, // markdown bullet, lowercase
{"LOOP: CONTINUE 5m\n...more...\nLOOP: DONE", true, 0}, // last control line wins
{"no control line here", false, 0}, // absent -> adaptive
{"LOOP: DONE — all tests pass", true, 0}, // a trailing note is fine
{"progress\nLOOP: CONTINUE 5m — still tidying", false, 5 * time.Minute}, // note after the interval
{"work done.\r\nLOOP: DONE\r\n", true, 0}, // CRLF endings still parse
{"work\r\nLOOP: CONTINUE 10m\r\n", false, 10 * time.Minute}, // CRLF + interval
{"I will emit LOOP: DONE when finished", false, 0}, // mid-sentence mention doesn't match
{"LOOP: CONTINUE 99z", false, 0}, // bad unit -> continue, no wake hint
}
for _, c := range cases {
done, wake := parseLoopControl(c.in)
if done != c.done || wake != c.wake {
t.Errorf("parseLoopControl(%q) = (%v,%v), want (%v,%v)", c.in, done, wake, c.done, c.wake)
}
}
}
func TestFirePromptWrapsSelfPacedOnly(t *testing.T) {
self := &loopState{mode: loopModeSelfPaced, prompt: "tidy the docs"}
got := self.firePrompt()
if !strings.Contains(got, "LOOP: DONE") || !strings.Contains(got, "tidy the docs") {
t.Errorf("self-paced firePrompt should embed the playbook and goal, got %q", got)
}
fixed := &loopState{mode: loopModeFixed, prompt: "check the build"}
if fixed.firePrompt() != "check the build" {
t.Errorf("fixed-interval firePrompt should be verbatim, got %q", fixed.firePrompt())
}
// A self-paced /command is a real command, not a goal — send it verbatim, no playbook.
cmd := &loopState{mode: loopModeSelfPaced, prompt: "/babysit"}
if cmd.firePrompt() != "/babysit" {
t.Errorf("self-paced /command firePrompt should be verbatim, got %q", cmd.firePrompt())
}
}
func TestLoopStateDue(t *testing.T) {
base := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
l := &loopState{nextRunAt: base}
if l.due(base.Add(-time.Second)) {
t.Error("loop should not be due before nextRunAt")
}
if !l.due(base) {
t.Error("loop should be due at nextRunAt")
}
l.paused = true
if l.due(base.Add(time.Hour)) {
t.Error("paused loop should never be due")
}
}
+260 -27
View File
@@ -29,6 +29,7 @@ import (
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/skills"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/usage"
@@ -62,6 +63,7 @@ type model struct {
ctx context.Context
cwd string
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
loadSkills func() []skills.Skill // lazy installed-skills loader for /skills + /<skill-name>
userConfigPath string
doctorUserConfigPath string
projectConfigPath string
@@ -188,10 +190,22 @@ type model struct {
// like a frozen terminal (for ANY provider, not just slow ones). Zero = idle.
turnStartedAt time.Time
queuedMessage string
exiting bool
runCancel context.CancelFunc
runID int
activeRunID int
// loops holds the session's active /loop definitions (see loop.go). activeLoopID
// tags the in-flight run when it is a loop iteration (empty = a user turn), so the
// completion seam knows whether to advance a loop. loopSeq invalidates a stale
// pending poll tick when loops are stopped; loopTicking guards against scheduling
// a second poll ticker. loopLeavePrompt arms a one-shot confirm before /clear or
// /quit while loops are active (see handleSubmit).
loops []*loopState
activeLoopID string
loopSeq int
loopCounter int
loopTicking bool
loopLeavePrompt commandKind
exiting bool
runCancel context.CancelFunc
runID int
activeRunID int
// flushRunIDs holds the ids of runs cancelled while still in flight, mapped
// to the session they were recording into AT CANCEL TIME. Each cancelled
// agent goroutine keeps running to completion and returns its accumulated
@@ -735,6 +749,7 @@ func newModel(ctx context.Context, options Options) model {
cwd: cwd,
swarmDoneAt: map[string]time.Time{},
userCommands: loadedUserCommands,
loadSkills: options.LoadSkills,
composerCursorVisible: true,
userConfigPath: options.UserConfigPath,
doctorUserConfigPath: doctorUserConfigPath,
@@ -1354,7 +1369,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// home screen or a narrow terminal. Hiding reflows the chat to full
// width, so mirror the width-change bookkeeping (re-wrap the streaming
// fade, resize the composer) the WindowSizeMsg path does.
if m.noBlockingModal() && m.sidebarToggleAllowed() {
if !m.transcriptDetailed && m.noBlockingModal() && m.sidebarToggleAllowed() {
// Just show/hide — no transcript notice. The reflow IS the feedback,
// and emitting a line every toggle piled up noise in the chat.
m.sidebarHidden = !m.sidebarHidden
@@ -1421,22 +1436,50 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
case keyIs(msg, tea.KeyPgUp):
if m.transcriptDetailed {
return m, nil
}
// A stationary mouse over a bodyY-keyed transcript hover target would
// otherwise stay lit at the scrolled-away bodyY until the next real
// motion event (see clearHover) — same reasoning as the wheel-scroll
// cases in mouse.go.
m = m.clearHover()
return m.scrollChat(m.chatPageScrollLines()), nil
case keyIs(msg, tea.KeyPgDown):
m = m.clearHover()
return m.scrollChat(-m.chatPageScrollLines()), nil
case keyShift(msg) && keyIs(msg, tea.KeyUp):
// Shift+Up scrolls the transcript up one line. Must be checked before
// plain KeyUp so shifted arrows aren't consumed by the composer-path.
if m.transcriptDetailed {
return m, nil
}
if m.pendingPermission != nil {
return m.movePermissionCursor(-1), nil
}
if m.pendingAskUser != nil {
return m.moveAskUserCursor(-1), nil
}
if m.providerWizard != nil {
return m.handleProviderWizardKey(msg)
}
if m.mcpAddWizard != nil {
return m.handleMCPAddWizardKey(msg)
}
if m.mcpManager != nil {
return m.handleMCPManagerKey(msg)
}
if m.picker != nil {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(-1)
return m, nil
}
if m.suggestionsActive() {
break
}
if m.composerValue() != "" {
break // let the input handle multiline navigation
}
m = m.clearHover()
return m.scrollChat(-m.chatPageScrollLines()), nil
case keyIs(msg, tea.KeyDown):
return m.scrollChat(1), nil
case keyShift(msg) && keyIs(msg, tea.KeyDown):
// Shift+Down scrolls the transcript down one line. Must be checked
// before plain KeyDown so shifted arrows aren't consumed.
if m.transcriptDetailed {
return m, nil
}
@@ -1462,6 +1505,41 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.pickerMoved(1)
return m, nil
}
if m.suggestionsActive() {
break
}
if m.composerValue() != "" {
break // let the input handle multiline navigation
}
m = m.clearHover()
return m.scrollChat(-1), nil
case keyIs(msg, tea.KeyDown):
if m.transcriptDetailed {
m = m.clearHover()
return m.scrollChat(-1), nil
}
if m.pendingPermission != nil {
return m.movePermissionCursor(1), nil
}
if m.pendingAskUser != nil {
return m.moveAskUserCursor(1), nil
}
if m.providerWizard != nil {
return m.handleProviderWizardKey(msg)
}
if m.mcpAddWizard != nil {
return m.handleMCPAddWizardKey(msg)
}
if m.mcpManager != nil {
return m.handleMCPManagerKey(msg)
}
if m.picker != nil {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(1)
return m, nil
}
if m.suggestionsActive() {
m.moveSuggestion(1)
return m, nil
@@ -1480,7 +1558,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
if m.transcriptDetailed {
return m, nil
m = m.clearHover()
return m.scrollChat(1), nil
}
if m.pendingPermission != nil {
return m.movePermissionCursor(-1), nil
@@ -1514,6 +1593,48 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.historyRecallActive() {
return m.recallHistory(-1), nil
}
case keyCtrl(msg, 'u'):
// Ctrl+U scrolls up half a page, or moves the cursor up in
// permission/ask-user prompts. Falls through to the active modal
// (wizard, etc.) when none of the above are in focus.
if m.transcriptDetailed {
return m, nil
}
if m.pendingPermission != nil {
return m.movePermissionCursor(-1), nil
}
if m.pendingAskUser != nil {
return m.moveAskUserCursor(-1), nil
}
if m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.pendingSpecReview != nil {
break
}
if m.composerValue() != "" {
break // let the input handle its own Ctrl+U (delete-to-bol)
}
m = m.clearHover()
return m.scrollChat(m.chatPageScrollLines()), nil
case keyCtrl(msg, 'd'):
// Ctrl+D scrolls down half a page, or moves the cursor down in
// permission/ask-user prompts. Falls through to the active modal
// when none of the above are in focus.
if m.transcriptDetailed {
return m, nil
}
if m.pendingPermission != nil {
return m.movePermissionCursor(1), nil
}
if m.pendingAskUser != nil {
return m.moveAskUserCursor(1), nil
}
if m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.pendingSpecReview != nil {
break
}
if m.composerValue() != "" {
break // let the input handle its own Ctrl+D (delete-next-char)
}
m = m.clearHover()
return m.scrollChat(-m.chatPageScrollLines()), nil
}
if m.transcriptDetailed {
return m, nil
@@ -1787,6 +1908,16 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.clearComposer()
m.clearSuggestions()
return m, nil
case loopTickMsg:
// Idle poll for due loops. A stale tick (loops changed) or an empty loop set
// ends the ticker; otherwise fire the earliest due loop if idle and reschedule.
if msg.seq != m.loopSeq || len(m.loops) == 0 {
m.loopTicking = false
return m, nil
}
var fireCmd tea.Cmd
m, fireCmd = m.fireDueLoopIfIdle()
return m, tea.Batch(fireCmd, m.scheduleLoopTick())
case agentResponseMsg:
if msg.runID != m.activeRunID {
// A run cancelled while in flight still finishes in its goroutine and
@@ -1956,8 +2087,25 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// complete once the turn settles.
var sweepCmd tea.Cmd
m, sweepCmd = m.maybeGitSweep()
// If this run was a loop iteration, advance that loop (schedule its next wake
// or stop it). Done before launchQueuedMessageIfReady so a user's queued prompt
// still wins the immediate re-launch; the loop fires on the next idle tick.
var loopTickCmd tea.Cmd
if loopID := m.activeLoopID; loopID != "" {
m.activeLoopID = ""
loopFinalAnswer := ""
for _, row := range msg.rows {
if row.kind == rowAssistant && row.final {
loopFinalAnswer = row.text
}
}
m = m.advanceLoop(loopID, loopFinalAnswer, msg.err)
// advanceLoop -> removeLoop may have stopped the ticker; restart it if
// other loops remain.
m, loopTickCmd = m.ensureLoopTick()
}
next, queuedCmd := m.launchQueuedMessageIfReady()
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd)
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd)
case sessionTitleGeneratedMsg:
return m.handleSessionTitleGenerated(msg)
case recapGeneratedMsg:
@@ -2215,14 +2363,23 @@ func (m model) View() tea.View {
}
view.ReportFocus = m.notifier != nil
if m.wantsMouseCapture() {
// AllMotion (not CellMotion) is required for hover highlighting: it
// reports cursor movement even with no button pressed. CellMotion only
// reports motion while a button is held (drag) — see bubbletea's
// MouseMode docs. AllMotion has marginally worse terminal compatibility
// but is well supported by the terminals this app targets; the existing
// 15ms mouse-event throttle (mouseEventThrottleInterval) already bounds
// the redraw rate from the extra motion events.
view.MouseMode = tea.MouseModeAllMotion
if isRunningUnderPRoot() {
// Under PRoot the AllMotion (1003) sequence doesn't work
// reliably, breaking touch-gesture scrolling. Fall back to
// CellMotion which still delivers wheel events, clicks, and
// drag — the only thing lost is hover-highlighting.
view.MouseMode = tea.MouseModeCellMotion
} else {
// AllMotion (not CellMotion) is required for hover highlighting:
// it reports cursor movement even with no button pressed.
// CellMotion only reports motion while a button is held (drag) —
// see bubbletea's MouseMode docs. AllMotion has marginally worse
// terminal compatibility but is well supported by the terminals
// this app targets; the existing 15ms mouse-event throttle
// (mouseEventThrottleInterval) already bounds the redraw rate
// from the extra motion events.
view.MouseMode = tea.MouseModeAllMotion
}
}
return view
}
@@ -2297,7 +2454,7 @@ func (m model) transcriptView() string {
if m.transcriptEmpty() && !m.pending && viewportOverlay != "" {
emptyOverlay = viewportOverlay
}
bodyItems := m.transcriptBodyItems(width, emptyOverlay)
bodyItems := m.transcriptBodyItems(width, emptyOverlay, false)
footer := m.footerView(width)
@@ -2337,7 +2494,7 @@ func (m model) twoColumnTranscriptView() string {
width := chatW
suggestionOverlay := m.suggestionOverlay(width)
bodyItems := m.transcriptBodyItems(width, "")
bodyItems := m.transcriptBodyItems(width, "", false)
footer := m.footerView(width)
overlayForViewport := suggestionOverlay
if m.transcriptEmpty() && !m.pending {
@@ -2767,7 +2924,15 @@ func (m model) chatTranscriptViewport() (transcriptViewport, bool) {
return transcriptViewport{}, false
}
width := m.chatColumnWidth()
items := m.transcriptBodyItems(width, "")
if m.transcriptDetailed {
items := m.transcriptBodyItems(width, "", true)
body := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
header := detailedTranscriptHeader(width) + "\n" + zeroTheme.line.Render(strings.Repeat("-", width))
footer := m.detailedTranscriptFooter(width)
frame := m.scrollableTranscriptFrame(header, footer)
return transcriptViewportForLayout(body, frame, m.chatScrollOffset), true
}
items := m.transcriptBodyItems(width, "", false)
body := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
return transcriptViewportForLayout(body, frame, m.chatScrollOffset), true
@@ -3607,6 +3772,11 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
if text != "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
}
case pickerSkill:
// Fill the composer with "/name " so the user adds their request before
// submitting (a bare second Enter runs it without one); names the slash
// path cannot reach run immediately instead.
m, cmd = m.chooseSkillFromPicker(item)
case pickerTheme:
// The hovered palette is already live from the preview; handleThemeCommand
// records the choice (m.themeMode) and re-applies it, and reports the switch.
@@ -3658,6 +3828,13 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
return m, nil
}
command := parseCommand(input)
// A pending /clear or /quit leave-confirmation is armed for the immediately-next
// repeat of that same command only; any other submission — including one queued
// or deferred by the early returns below — disarms it. Runs before those returns
// so an interposed prompt can't be skipped over and leave the gate falsely armed.
if m.loopLeavePrompt != commandEmpty && command.kind != m.loopLeavePrompt {
m.loopLeavePrompt = commandEmpty
}
// While exiting (Ctrl+C waiting on the cancelled run's checkpoint flush) a
// new run must not start: the deferred tea.Quit would abort it mid-flight
// and orphan its checkpoint blobs — the exact loss flushRunIDs prevents.
@@ -3691,6 +3868,14 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: helpText()})
return m, nil
case commandClear:
// A foreground loop keeps firing after /clear (it wipes the screen, not the
// session), so warn once before clearing the context it will run into.
if m.loopActive() && m.loopLeavePrompt != commandClear {
m.loopLeavePrompt = commandClear
m = m.appendLoopSystem(m.loopFooterSummary() + " still running — /clear keeps them firing behind a cleared screen. Run /clear again to confirm, or /loop stop all first.")
return m, nil
}
m.loopLeavePrompt = commandEmpty
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionClear})
// Clearing wipes the visible transcript only — the session's context is
// intact, so the next prompt still replays the full history. Say so, and
@@ -3699,6 +3884,11 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
// Scrollback above can't be un-printed; a faint divider marks where the
// cleared surface ended and the frontier restarts for the fresh transcript.
m.resetFlushFrontier("· cleared ·")
// /clear wipes the transcript but not the session, so loops keep running;
// say so rather than let them silently fire into a "cleared" screen.
if m.loopActive() {
m = m.appendLoopSystem(m.loopFooterSummary() + " still running — /loop stop all to end them.")
}
return m, nil
case commandNew:
// A fresh session mid-run would strand the in-flight turn's events; make the
@@ -3709,7 +3899,17 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
return m, nil
}
return m.startNewSession(), nil
case commandLoop:
return m.handleLoopCommand(command.text)
case commandExit:
// Closing the session stops its foreground loops mid-task; warn once so a
// token-spending loop isn't ended by reflex.
if m.loopActive() && m.loopLeavePrompt != commandExit {
m.loopLeavePrompt = commandExit
m = m.appendLoopSystem(m.loopFooterSummary() + " active — closing the session stops them. Run /exit again to confirm, or /loop stop all first.")
return m, nil
}
m.loopLeavePrompt = commandEmpty
// /exit gets the same protection as Ctrl+C: cancel any in-flight run and
// defer the quit until its checkpoint session events flush — quitting
// immediately would orphan the blobs and break /rewind.
@@ -3722,6 +3922,15 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
case commandTools:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.toolsText()})
return m, nil
case commandSkills:
// With skills installed, /skills opens a searchable picker (like /model);
// the text card remains only as the no-skills install hint.
if picker := m.newSkillPicker(); picker != nil {
m.picker = picker
return m, nil
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.skillsText()})
return m, nil
case commandMCP:
if strings.TrimSpace(command.text) == "" {
return m.openMCPManager(), nil
@@ -3917,6 +4126,11 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
if next, cmd, handled := m.handleUserCommand(command.text); handled {
return next, cmd
}
// Then an installed skill: "/<skill-name> [args]" runs the skill directly
// (deterministic invocation, vs waiting for the model to pull it in).
if next, cmd, handled := m.handleSkillCommand(command.text); handled {
return next, cmd
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "unknown command: " + command.text,
@@ -4188,6 +4402,21 @@ func (m *model) cancelRun() {
m.runCancel()
}
m.clearStreamingToolCall() // a cancelled file-write must not linger into the next run
// A cancelled loop iteration bypasses the agentResponseMsg completion seam (its
// late message is drained through flushRunIDs, not advanceLoop), so clear the
// loop tag here and re-arm the interrupted loop for its next cadence. Otherwise
// the loop is left "running" forever (nextRunAt stays zero) and the NEXT
// unrelated turn would be misattributed as this loop's completion.
if m.activeLoopID != "" {
if l := m.findLoop(m.activeLoopID); l != nil {
if l.mode == loopModeSelfPaced {
l.nextRunAt = m.now().Add(clampSelfPaceDelay(adaptiveSelfPaceDelay(l.iteration)))
} else {
l.nextRunAt = m.now().Add(l.interval)
}
}
m.activeLoopID = ""
}
// Remember the in-flight run — and the session it was recording into — so
// its final agentResponseMsg is still drained for session-event persistence
// after activeRunID is cleared. Otherwise the checkpoint blobs it captured
@@ -4323,6 +4552,10 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
IncludeLSP: true,
Autonomy: selfCorrectAutonomyForMode(options.PermissionMode),
})
// Background post-edit diagnostics: the loop checks files changed by
// edit_file/write_file off the tool-call path and nudges the model with
// any errors before its next request. Shares the run's lazy manager.
options.FileDiagnostics = agent.NewFileDiagnostics(lspManager, options.Cwd)
}
// Some providers synthesize tool-call ids that repeat within a run (e.g.
+7 -7
View File
@@ -2142,7 +2142,7 @@ func TestTranscriptSelectionPaintsHighlightOnceNotTwice(t *testing.T) {
width := m.chatColumnWidth()
findRow := func(mm model) (transcriptBodyItem, bool) {
for _, it := range mm.transcriptBodyItems(width, "") {
for _, it := range mm.transcriptBodyItems(width, "", false) {
if it.kind == transcriptBodyItemRow && it.rowIndex >= 0 {
return it, true
}
@@ -2198,7 +2198,7 @@ func TestReasoningAfterToolCardGetsBlankSeparator(t *testing.T) {
transcriptRow{kind: rowReasoning, text: "Considering the next step in detail before acting"},
transcriptRow{kind: rowToolResult, id: "t2", tool: "read_file", status: tools.StatusOK, detail: "x\ny"},
)
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
reasoningIdx := -1
for i := range items {
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
@@ -2223,7 +2223,7 @@ func TestAssistantNarrationBeforeToolCardGetsBlankSeparator(t *testing.T) {
transcriptRow{kind: rowAssistant, text: "I'll inspect the existing file, then run it."},
transcriptRow{kind: rowToolResult, id: "t1", tool: "read_file", status: tools.StatusOK, detail: "File: time_test.py\n\n 1 | print('x')"},
)
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
toolIdx := -1
for i := range items {
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
@@ -2247,7 +2247,7 @@ func TestUserPromptBeforeToolCardGetsBlankSeparator(t *testing.T) {
transcriptRow{kind: rowUser, text: "create the landing page"},
transcriptRow{kind: rowToolResult, id: "t1", tool: "write_file", status: tools.StatusOK, detail: "--- /dev/null\n+++ b/index.html\n@@ -0,0 +1,1 @@\n+<!DOCTYPE html>"},
)
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
toolIdx := -1
for i := range items {
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
@@ -2273,7 +2273,7 @@ func TestAssistantAfterToolCardGetsRuleSeparator(t *testing.T) {
transcriptRow{kind: rowToolResult, id: "t1", tool: "bash", status: tools.StatusOK, detail: "stdout:\nok\nexit_code: 0"},
transcriptRow{kind: rowAssistant, text: "Done.", final: true},
)
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
finalIdx := -1
for i := range items {
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
@@ -2307,7 +2307,7 @@ func TestStreamingAssistantAfterToolCardGetsRuleSeparator(t *testing.T) {
transcriptRow{kind: rowAssistant, text: "I'll run it first."},
transcriptRow{kind: rowToolResult, id: "t1", tool: "bash", status: tools.StatusOK, detail: "stdout:\nok\nexit_code: 0"},
)
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
pendingIdx := -1
for i := range items {
if items[i].kind == transcriptBodyItemPendingInterim {
@@ -2357,7 +2357,7 @@ func TestTranscriptBodyRowsUseFullWidthAndAlignSelection(t *testing.T) {
t.Fatalf("expected no body gutter at width %d, got %d", width, gutter)
}
items := m.transcriptBodyItems(width, "")
items := m.transcriptBodyItems(width, "", false)
var row *transcriptBodyItem
for i := range items {
if items[i].kind == transcriptBodyItemRow && items[i].rowIndex >= 0 {
+32 -2
View File
@@ -1,6 +1,36 @@
package tui
import tea "charm.land/bubbletea/v2"
import (
"os"
"strings"
"sync"
tea "charm.land/bubbletea/v2"
)
// parseTracerPid returns true when /proc/self/status contains a non-zero
// TracerPid. This detects any ptrace tracer (PRoot, gdb, strace, dlv, etc.),
// not PRoot specifically. That's intentional: under any ptrace tracer the
// AllMotion (1003) sequence is unreliable, so CellMotion is the safer
// fallback. The only loss is hover-highlighting, which has no functional
// impact.
func parseTracerPid(data []byte) bool {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "TracerPid:") {
pid := strings.TrimSpace(line[10:])
return pid != "0"
}
}
return false
}
var isRunningUnderPRoot = sync.OnceValue(func() bool {
data, err := os.ReadFile("/proc/self/status")
if err != nil {
return false
}
return parseTracerPid(data)
})
type mouseOverlayHit struct {
x int
@@ -257,7 +287,7 @@ func (m model) syncMouseCapture() (model, tea.Cmd) {
}
func (m model) mouseOverComposer(msg tea.MouseMsg) bool {
if !m.altScreen || m.height <= 0 {
if !m.altScreen || m.height <= 0 || m.transcriptDetailed {
return false
}
width := m.chatColumnWidth()
+56
View File
@@ -920,6 +920,30 @@ func TestMouseCaptureOnlyDuringInteractiveSetupStages(t *testing.T) {
}
}
// TestComposerMouseSelectionBlockedInDetailedMode: composer mouse hit-testing
// is disabled while the detailed transcript view is active, so wheel and click
// events in the composer area reach the transcript body instead.
func TestComposerMouseSelectionBlockedInDetailedMode(t *testing.T) {
m := mouseTestModel()
m.input.SetValue("some text")
updated, _ := m.Update(testKeyCtrl('o'))
m = updated.(model)
if !m.transcriptDetailed {
t.Fatal("sanity check: Ctrl+O should enter detailed mode")
}
if !m.composerMouseSelectionBlocked() {
t.Fatal("composerMouseSelectionBlocked should be true in detailed mode")
}
width := m.chatColumnWidth()
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
if m.mouseOverComposer(testMouseWheel(tea.MouseWheelUp, 0, frame.composerRect.y)) {
t.Fatal("mouseOverComposer should return false in detailed mode")
}
}
func firstTranscriptTextMouseY(t *testing.T, m model) int {
t.Helper()
_, y := firstTranscriptTextMousePoint(t, m)
@@ -986,6 +1010,38 @@ func withSidebarContent(m model) model {
return m
}
func TestParseTracerPidMissing(t *testing.T) {
if got := parseTracerPid([]byte("Pid: 123\nName: foo\n")); got {
t.Fatal("missing TracerPid should return false")
}
}
func TestParseTracerPidZero(t *testing.T) {
if got := parseTracerPid([]byte("TracerPid: 0\nName: foo\n")); got {
t.Fatal("TracerPid: 0 should return false")
}
}
func TestParseTracerPidNonZero(t *testing.T) {
if got := parseTracerPid([]byte("Name: foo\nTracerPid: 42\n")); !got {
t.Fatal("TracerPid: 42 should return true")
}
}
func TestParseTracerPidMalformed(t *testing.T) {
// Non-numeric values are treated as non-zero since any non-"0" string
// indicates a tracer is attached, matching the function's semantics.
if got := parseTracerPid([]byte("TracerPid: abc\n")); !got {
t.Fatal("malformed TracerPid should return true (non-zero string)")
}
}
func TestParseTracerPidEmpty(t *testing.T) {
if got := parseTracerPid([]byte{}); got {
t.Fatal("empty input should return false")
}
}
func setupMouseTestModel() model {
m := newModel(context.Background(), Options{
Setup: SetupOptions{
+8 -1
View File
@@ -13,6 +13,7 @@ import (
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/skills"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/usage"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -48,7 +49,13 @@ type Options struct {
SessionCompactor SessionCompactor
PrService *PrService
AgentOptions agent.Options
AgentOptions agent.Options
// LoadSkills returns the installed skills (default skills dir merged with any
// plugin skill roots), bodies included, for /skills and direct /<skill-name>
// invocation. Called lazily per use so newly installed skills are picked up
// without a restart. Nil means the session has no skills wiring (skills stay
// model-pulled via the skill tool only).
LoadSkills func() []skills.Skill
PermissionMode agent.PermissionMode
ReasoningEffort modelregistry.ReasoningEffort
ResponseStyle string
+67
View File
@@ -309,3 +309,70 @@ func TestPermissionRenderShowsNetworkTargetAndHostScopedAlways(t *testing.T) {
t.Fatalf("network prompt should render target label, got %q", got)
}
}
// TestPermissionCursorCtrlU ensures Ctrl+U moves the permission cursor UP
// (toward the first option). Regression: the original implementation moved
// the cursor DOWN on Ctrl+U and UP on Ctrl+D.
func TestPermissionCursorCtrlU(t *testing.T) {
m := pendingPermissionModel(t, func(agent.PermissionDecision) {})
m.pendingPermission.cursor = 2 // middle option
updated, _ := m.Update(testKeyCtrl('u'))
next := updated.(model)
if next.pendingPermission.cursor != 1 {
t.Fatalf("Ctrl+U moved cursor to %d, want 1 (one step up)", next.pendingPermission.cursor)
}
}
// TestPermissionCursorCtrlD ensures Ctrl+D moves the permission cursor DOWN
// (toward the last option). Regression: the original implementation moved
// the cursor UP on Ctrl+D and DOWN on Ctrl+U.
func TestPermissionCursorCtrlD(t *testing.T) {
m := pendingPermissionModel(t, func(agent.PermissionDecision) {})
m.pendingPermission.cursor = 0 // first option
updated, _ := m.Update(testKeyCtrl('d'))
next := updated.(model)
if next.pendingPermission.cursor != 1 {
t.Fatalf("Ctrl+D moved cursor to %d, want 1 (one step down)", next.pendingPermission.cursor)
}
}
// TestShiftUpComposerGuard ensures Shift+Up does NOT scroll the transcript
// when the composer has text, so it falls through to the input's own line
// navigation.
func TestShiftUpComposerGuard(t *testing.T) {
m := mouseTestModel()
// Add enough transcript rows so scrolling is valid.
for i := 0; i < 20; i++ {
m.transcript = appendRow(m.transcript, rowAssistant, "line")
}
m.input.SetValue("some text")
m.chatScrollOffset = 5
updated, cmd := m.Update(testKeyShift(tea.KeyUp))
next := updated.(model)
_ = cmd
if got := next.chatScrollOffset; got != 5 {
t.Fatalf("Shift+Up with non-empty composer scrolled offset to %d, want 5 (unchanged)", got)
}
}
// TestShiftDownComposerGuard ensures Shift+Down does NOT scroll the transcript
// when the composer has text, falling through to the input's navigation.
func TestShiftDownComposerGuard(t *testing.T) {
m := mouseTestModel()
// Add enough transcript rows so scrolling is valid.
for i := 0; i < 20; i++ {
m.transcript = appendRow(m.transcript, rowAssistant, "line")
}
m.input.SetValue("some text")
m.chatScrollOffset = 3
updated, cmd := m.Update(testKeyShift(tea.KeyDown))
next := updated.(model)
_ = cmd
if got := next.chatScrollOffset; got != 3 {
t.Fatalf("Shift+Down with non-empty composer scrolled offset to %d, want 3 (unchanged)", got)
}
}
+30
View File
@@ -24,6 +24,7 @@ const (
pickerEffort
pickerSession
pickerTheme
pickerSkill
)
// pickerItem is one selectable row: Label is shown, Value is passed to the
@@ -225,6 +226,35 @@ func (m model) newModelPicker() *commandPicker {
return &commandPicker{kind: pickerModel, title: "Choose a model", items: items, allItems: append([]pickerItem{}, items...), selected: 0}
}
// newSkillPicker lists the installed skills for browse-and-run: type to filter,
// arrow keys to select, Enter runs the chosen skill immediately. Selection is by
// the skill's EXACT name (item.Value), so even skills the slash path cannot
// reach — names shadowed by a builtin or user command, or names that don't fit
// a slash token — are runnable here: picking from the Skills picker is
// unambiguous. Returns nil when no skills are installed (the caller falls back
// to the install-hint card).
func (m model) newSkillPicker() *commandPicker {
installed := m.installedSkills()
items := make([]pickerItem, 0, len(installed))
for _, skill := range installed {
name := strings.TrimSpace(skill.Name)
if name == "" {
continue
}
label := name
if slash := skillSlashName(name); slash != "" {
// Show the slash form where one exists — it doubles as documentation
// of the direct "/name [args]" invocation.
label = "/" + slash
}
items = append(items, pickerItem{Label: label, Value: name, Meta: strings.TrimSpace(skill.Description)})
}
if len(items) == 0 {
return nil
}
return &commandPicker{kind: pickerSkill, title: "pick a skill · ⏎ fills /name — add your request", items: items, allItems: append([]pickerItem{}, items...), selected: 0}
}
// modelPickerProviders returns the providers to list in /model: all saved
// providers, falling back to the active profile when none were threaded in.
func (m model) modelPickerProviders() []config.ProviderProfile {
+44
View File
@@ -511,6 +511,50 @@ func TestSelectableAssistantRowKeepsMarkdownSemanticsPlain(t *testing.T) {
}
}
func TestStripMarkdownRenderControlsStripsAllANSI(t *testing.T) {
// Bold markers that renderAssistantMarkdownText embeds for prose formatting.
withBold := "\x1b[1mhello\x1b[22m world"
if got := stripMarkdownRenderControls(withBold); got != "hello world" {
t.Fatalf("stripMarkdownRenderControls(%q) = %q, want %q", withBold, got, "hello world")
}
// Color sequences from highlightCodeAuto for fenced code blocks.
withColor := "\x1b[38;2;236;236;238mdef\x1b[0m hello():"
if got := stripMarkdownRenderControls(withColor); got != "def hello():" {
t.Fatalf("stripMarkdownRenderControls(%q) = %q, want %q", withColor, got, "def hello():")
}
// Sequences that combine both (bold + underline + color).
withBoldColor := "\x1b[1;4;38;2;236;236;238mW\x1b[m"
if got := stripMarkdownRenderControls(withBoldColor); got != "W" {
t.Fatalf("stripMarkdownRenderControls(%q) = %q, want %q", withBoldColor, got, "W")
}
}
func TestSelectedTranscriptTextStripsANSIFromHighlightedCode(t *testing.T) {
m := limeTestModel()
m.width, m.height = 120, 40
m.altScreen = true
m.headerPrinted = true
code := `package main
import "fmt"
func main() {
fmt.Println("hello")
}`
row := transcriptRow{kind: rowAssistant, text: "First, here's the code:\n\n```go\n" + code + "\n```\n\nThat's it.", final: true}
m.transcript = append(m.transcript, row)
m.flushed = len(m.transcript)
// The highlighted code block contains ANSI color sequences from chroma;
// verify none survive in the selectable text used for clipboard copy.
_, selectable := m.renderSelectableAssistantRow(0, row, 72, 0)
for _, line := range selectable {
if ansiPattern.MatchString(line.text) {
t.Fatalf("selectable text leaked ANSI escapes: %q", line.text)
}
}
}
func TestFinalAnswerRendersPlainTextAndCompletionLine(t *testing.T) {
m := limeTestModel()
row := transcriptRow{
+39 -12
View File
@@ -99,6 +99,12 @@ func (m model) startNewSession() model {
// Scrollback above can't be un-printed; a faint divider marks the boundary and
// the flush frontier restarts for the fresh transcript (mirrors /clear, /resume).
m.resetFlushFrontier("· new session ·")
// Loops belong to the previous session; stop them so they don't fire the old
// conversation's prompt into the fresh one.
if updated, cleared := m.clearLoopsForSessionSwitch(); cleared > 0 {
m = updated
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: fmt.Sprintf("Stopped %d loop(s) tied to the previous session.", cleared)})
}
return m
}
@@ -123,13 +129,24 @@ func (m model) appendSessionEvent(eventType sessions.EventType, payload any) (mo
func (m model) appendSessionEvents(events []pendingSessionEvent) (model, []transcriptRow) {
rows := []transcriptRow{}
if m.activeSession.SessionID == "" || len(events) == 0 {
return m, rows
}
inputs := make([]sessions.AppendEventInput, 0, len(events))
for _, event := range events {
next, err := m.appendSessionEvent(event.Type, event.Payload)
if err != nil {
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
continue
}
m = next
inputs = append(inputs, sessions.AppendEventInput{Type: event.Type, Payload: event.Payload})
}
appended, err := m.sessionStore.AppendEvents(m.activeSession.SessionID, inputs)
if err != nil {
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
return m, rows
}
if len(appended) > 0 {
last := appended[len(appended)-1]
m.activeSession.UpdatedAt = last.CreatedAt
m.activeSession.EventCount = last.Sequence
m.activeSession.LastEventType = last.Type
m.sessionEvents = append(m.sessionEvents, appended...)
}
return m, rows
}
@@ -142,13 +159,12 @@ func (m model) appendSessionEventsTo(sessionID string, events []pendingSessionEv
if m.sessionStore == nil || sessionID == "" {
return rows
}
inputs := make([]sessions.AppendEventInput, 0, len(events))
for _, event := range events {
if _, err := m.sessionStore.AppendEvent(sessionID, sessions.AppendEventInput{
Type: event.Type,
Payload: event.Payload,
}); err != nil {
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
}
inputs = append(inputs, sessions.AppendEventInput{Type: event.Type, Payload: event.Payload})
}
if _, err := m.sessionStore.AppendEvents(sessionID, inputs); err != nil {
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
}
return rows
}
@@ -195,6 +211,10 @@ func (m model) handleResumeCommand(args string) (model, string) {
return m, "Sessions\nerror: " + err.Error()
}
// Capture the current session id before switching so loops are only torn down
// on a real change — `/resume latest` or `/resume <currentID>` can resolve to
// the already-active session, whose loops belong to it, not a "previous" one.
previousID := m.activeSession.SessionID
m.activeSession = *session
m.sessionEvents = append([]sessions.Event{}, events...)
if m.providerName == "" {
@@ -203,9 +223,16 @@ func (m model) handleResumeCommand(args string) (model, string) {
if m.modelName == "" {
m.modelName = session.ModelID
}
loopsCleared := 0
if session.SessionID != previousID {
m, loopsCleared = m.clearLoopsForSessionSwitch()
}
rows := initialTranscript()
rows = appendRow(rows, rowSystem, m.formatResumeSummary(*session, len(events)))
if loopsCleared > 0 {
rows = appendRow(rows, rowSystem, fmt.Sprintf("Stopped %d loop(s) tied to the previous session.", loopsCleared))
}
rows = appendTranscriptRowsDedup(rows, transcriptRowsFromSessionEvents(events))
m.transcript = rows
// Every rehydrated row is settled by construction, so resetting the flush
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more