mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cc6730452 | ||
|
|
efd483f978 | ||
|
|
8b10c43cc6 | ||
|
|
4cd6466f43 | ||
|
|
0d1df5c0b4 | ||
|
|
82e6fafab4 | ||
|
|
22455afd6b | ||
|
|
8ba6ed42b5 | ||
|
|
9a1a1ee240 | ||
|
|
565e571509 |
No files matched your search
@@ -100,10 +100,9 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
- name: Pin toolchain from go.mod
|
||||
run: |
|
||||
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
|
||||
@@ -121,13 +120,3 @@ 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 ./...
|
||||
@@ -37,13 +37,8 @@ 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
|
||||
@@ -1 +1 @@
|
||||
{".":"0.2.0"}
|
||||
{".":"0.1.0"}
|
||||
@@ -47,12 +47,6 @@ 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:
|
||||
@@ -313,7 +307,6 @@ 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.
|
||||
|
||||
@@ -6,71 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
|
||||
tagged. Until then, source builds report the version `dev`.
|
||||
|
||||
## [0.2.0](https://github.com/Gitlawb/zero/compare/v0.1.0...v0.2.0) (2026-07-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add --auto flag for LLM-generated commit messages ([#423](https://github.com/Gitlawb/zero/issues/423)) ([b0abde7](https://github.com/Gitlawb/zero/commit/b0abde7d0697e808480cd59d69a6f4d0c6320475))
|
||||
* add zero changes push and pr subcommands, and extra repo-info metrics ([#391](https://github.com/Gitlawb/zero/issues/391)) ([2312abe](https://github.com/Gitlawb/zero/commit/2312abe5ddd95f4c6ef373cfb61cc03092f48cdd))
|
||||
* agent quality, caching, retry, and tooling upgrades ([#506](https://github.com/Gitlawb/zero/issues/506)) ([3c81fea](https://github.com/Gitlawb/zero/commit/3c81fea22873ee3df7fc97b10cb4f77792706c4b))
|
||||
* **agent:** curb over-engineering the solution in the editing discipline ([#517](https://github.com/Gitlawb/zero/issues/517)) ([f4c998a](https://github.com/Gitlawb/zero/commit/f4c998ac30c4f07ff313a2d706791e857293be49)), closes [#516](https://github.com/Gitlawb/zero/issues/516)
|
||||
* **agent:** inject per-user config.UserConfigDir()/zero/ZERO.md guidelines into system prompt ([#475](https://github.com/Gitlawb/zero/issues/475)) ([7b10aab](https://github.com/Gitlawb/zero/commit/7b10aab74bf14a01166b2cea22deab79bba9850b))
|
||||
* **openai:** forward prompt_cache_key for server-side prefix cache routing ([#515](https://github.com/Gitlawb/zero/issues/515)) ([87e7e69](https://github.com/Gitlawb/zero/commit/87e7e69afd18b5539579856f3a61c6a95bc445ae))
|
||||
* **providers:** add `zero providers models` to discover a provider's models ([#386](https://github.com/Gitlawb/zero/issues/386)) ([0bc8074](https://github.com/Gitlawb/zero/commit/0bc8074c97b0310e4a9d70c3f967003ee5e8a59f))
|
||||
* **providers:** add KiloCode and OpenCode provider support ([#388](https://github.com/Gitlawb/zero/issues/388)) ([b1ccb6d](https://github.com/Gitlawb/zero/commit/b1ccb6d9c1875377f5e5ea81a1304edd1e41ab4f))
|
||||
* **providers:** add Meituan LongCat catalog preset ([#424](https://github.com/Gitlawb/zero/issues/424)) ([b4275e3](https://github.com/Gitlawb/zero/commit/b4275e350472b2490212bf814709819d354c1216))
|
||||
* **providers:** split minimax zai into intl cn ([#398](https://github.com/Gitlawb/zero/issues/398)) ([aaad4d2](https://github.com/Gitlawb/zero/commit/aaad4d271270f41af837b6f3b60ae80beba0c645))
|
||||
* require manual approval before npm publish + drop release-as pin ([#369](https://github.com/Gitlawb/zero/issues/369)) ([bd89a1f](https://github.com/Gitlawb/zero/commit/bd89a1f451643c1b65ec803070abc7b116631ebe))
|
||||
* **sandbox:** unelevated Windows fallback tier instead of prompts-only degrade ([#427](https://github.com/Gitlawb/zero/issues/427)) ([b9ddd6f](https://github.com/Gitlawb/zero/commit/b9ddd6f42138312a1fee8d8bb67c46c8eb1dea2f))
|
||||
* support shift enter for composer newlines ([#462](https://github.com/Gitlawb/zero/issues/462)) ([daf65e0](https://github.com/Gitlawb/zero/commit/daf65e0af9a040314d4ab337b0ad59c55416b7bc))
|
||||
* **tui:** /loop — repeat a prompt or command on an interval or self-paced ([#502](https://github.com/Gitlawb/zero/issues/502)) ([387fe67](https://github.com/Gitlawb/zero/commit/387fe67ee7cd81317c9c969f5906a4437080fea3))
|
||||
* **tui:** add search/filter to provider picker in setup wizard ([#400](https://github.com/Gitlawb/zero/issues/400)) ([2fcea71](https://github.com/Gitlawb/zero/commit/2fcea71778d23e050c93409c471aef45b68c1621))
|
||||
* **update:** add zero upgrade command to apply self-updates ([#461](https://github.com/Gitlawb/zero/issues/461)) ([5f36349](https://github.com/Gitlawb/zero/commit/5f36349c1884e81fa9bc66bb5fe813b627e897b7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **action:** keep provider key scoped to zero step ([#448](https://github.com/Gitlawb/zero/issues/448)) ([407a927](https://github.com/Gitlawb/zero/commit/407a92739ff508cba32d2c12b3f36f0efcdd54c3))
|
||||
* add android platform support for Termux npm install ([#455](https://github.com/Gitlawb/zero/issues/455)) ([9bd93c6](https://github.com/Gitlawb/zero/commit/9bd93c62f8d57fb74057284aa66a1b6e1429dcdd)), closes [#449](https://github.com/Gitlawb/zero/issues/449)
|
||||
* **agent:** reject a malformed additional_permissions payload before prompting ([#453](https://github.com/Gitlawb/zero/issues/453)) ([e4f760e](https://github.com/Gitlawb/zero/commit/e4f760ee8bd57299cd2fcb37e8e23130037c2607))
|
||||
* allow non-TLS connections to private-network provider endpoints ([#444](https://github.com/Gitlawb/zero/issues/444)) ([1d86384](https://github.com/Gitlawb/zero/commit/1d8638466ca31517eb9db2b9353d3dce1cbeeabc))
|
||||
* **auth:** route zero auth login chatgpt to the dedicated ChatGPT flow ([#443](https://github.com/Gitlawb/zero/issues/443)) ([305a62c](https://github.com/Gitlawb/zero/commit/305a62c954ca6cec00bc58d5398f933415156aff))
|
||||
* **config:** fall back to a usable saved provider instead of forcing full re-onboarding ([#410](https://github.com/Gitlawb/zero/issues/410)) ([c60ad87](https://github.com/Gitlawb/zero/commit/c60ad8729f79bb841114d352ee2d2fe29d5d0e41))
|
||||
* **config:** let a gateway ANTHROPIC_BASE_URL resolve as anthropic-compatible ([#497](https://github.com/Gitlawb/zero/issues/497)) ([30dd7c3](https://github.com/Gitlawb/zero/commit/30dd7c3112ad22d42fa12b5addd4e38f4beda42a)), closes [#479](https://github.com/Gitlawb/zero/issues/479)
|
||||
* **config:** unbrick first-run setup — default google/anthropic models, enter setup on fixable config errors ([#385](https://github.com/Gitlawb/zero/issues/385)) ([72eed06](https://github.com/Gitlawb/zero/commit/72eed06b4f94c43d75d31fe54a58d2f566de059e))
|
||||
* **config:** use ~/.config on macOS and enter setup when no provider ([#371](https://github.com/Gitlawb/zero/issues/371)) ([#372](https://github.com/Gitlawb/zero/issues/372)) ([027a8f2](https://github.com/Gitlawb/zero/commit/027a8f2768b17b89f5c8270887f156e2ccda69ea))
|
||||
* **docs:** rename AGENTS.MD > AGENTS.md ([#438](https://github.com/Gitlawb/zero/issues/438)) ([4266baf](https://github.com/Gitlawb/zero/commit/4266baf222df583ed2078b776687f12d496475b5))
|
||||
* **gemini:** strip unsupported JSON Schema fields from tool declarations ([#374](https://github.com/Gitlawb/zero/issues/374)) ([39e7100](https://github.com/Gitlawb/zero/commit/39e7100674150144a1152e3110c64c7cf0321d64)), closes [#373](https://github.com/Gitlawb/zero/issues/373)
|
||||
* **install:** persist install dir to user PATH on Windows ([#407](https://github.com/Gitlawb/zero/issues/407)) ([bdb1b0e](https://github.com/Gitlawb/zero/commit/bdb1b0ecd15859b1712a6037d296dace7f9c3c3f))
|
||||
* **mcp:** block cross-origin credential redirects ([#396](https://github.com/Gitlawb/zero/issues/396)) ([f915f70](https://github.com/Gitlawb/zero/commit/f915f70e5a3096e2419fa8d961a0f84a626fa4a9))
|
||||
* **oauth:** treat Windows ERROR_ACCESS_DENIED as lock contention in createSecretFile ([#445](https://github.com/Gitlawb/zero/issues/445)) ([d05e914](https://github.com/Gitlawb/zero/commit/d05e9148a7f79f67d1d3c31fca2775f21fbd331e))
|
||||
* **openai:** handle Ollama reasoning stream deltas ([#486](https://github.com/Gitlawb/zero/issues/486)) ([f6c0606](https://github.com/Gitlawb/zero/commit/f6c060631e18e082dda24cc4dc0903c31c2120d6))
|
||||
* preserve conversation context in exec prompts ([#460](https://github.com/Gitlawb/zero/issues/460)) ([949ee43](https://github.com/Gitlawb/zero/commit/949ee43f71e5cb7fab4695c5cb7b442fe4ecfbf7))
|
||||
* **provider-wizard:** allow multiple custom OpenAI-compatible providers ([#403](https://github.com/Gitlawb/zero/issues/403)) ([3fbbd28](https://github.com/Gitlawb/zero/commit/3fbbd28e4c586822cc4312c86232d94befe56e87))
|
||||
* **sandbox:** fix nested pipe creation under the Windows restricted token ([#456](https://github.com/Gitlawb/zero/issues/456)) ([563a6db](https://github.com/Gitlawb/zero/commit/563a6dbe91e65d5daeefd7626e8a77e30a6d8fb2))
|
||||
* **sandbox:** gate /tmp test assertions on GOOS, not path existence ([#426](https://github.com/Gitlawb/zero/issues/426)) ([f653dca](https://github.com/Gitlawb/zero/commit/f653dcac363fb69ad7be5b35e6e0fa6d2bce476d))
|
||||
* **sandbox:** self-heal a corrupt unelevated setup marker ([#437](https://github.com/Gitlawb/zero/issues/437)) ([8d0c5fe](https://github.com/Gitlawb/zero/commit/8d0c5feccb8bdbfb015df0508aa6e3bcbd1fd0e8))
|
||||
* **specialist:** cap max specialist nesting depth ([#491](https://github.com/Gitlawb/zero/issues/491)) ([177442c](https://github.com/Gitlawb/zero/commit/177442cfe4015bd8df04cc9894f98b468ee796d4))
|
||||
* Termux/Android support — PRoot scroll, SIGSYS sandbox, build docs ([#509](https://github.com/Gitlawb/zero/issues/509)) ([0f69d99](https://github.com/Gitlawb/zero/commit/0f69d995e9b586b774f66c066b21abab5e03024a))
|
||||
* **tools:** Block MSYS coreutils under Windows sandbox ([#476](https://github.com/Gitlawb/zero/issues/476)) ([81aad58](https://github.com/Gitlawb/zero/commit/81aad58d97839e51c068e2f08907618991fdc3fb))
|
||||
* **tools:** CRLF line ending mismatch in edit_file tool on Windows ([#378](https://github.com/Gitlawb/zero/issues/378)) ([33dc7ae](https://github.com/Gitlawb/zero/commit/33dc7ae2cc82c5389675531e1416856dae7151ce))
|
||||
* **tools:** fix cmd.exe /S/C corrupting commands with embedded quotes ([#465](https://github.com/Gitlawb/zero/issues/465)) ([190241b](https://github.com/Gitlawb/zero/commit/190241bd593f43211b766e0b13c8e89802d4bb37))
|
||||
* **tools:** flag piped POSIX utilities before running on Windows ([#412](https://github.com/Gitlawb/zero/issues/412)) ([5658a36](https://github.com/Gitlawb/zero/commit/5658a366274fc59a9d5336b06a21019c9c25cbf1))
|
||||
* **tools:** make grep and glob respect run cancellation ([#464](https://github.com/Gitlawb/zero/issues/464)) ([ba6c026](https://github.com/Gitlawb/zero/commit/ba6c0264697b7d7ed479f6e782fba9700a481e3d))
|
||||
* **tools:** require permission before web_search requests ([#382](https://github.com/Gitlawb/zero/issues/382)) ([960db96](https://github.com/Gitlawb/zero/commit/960db9660e4e31dc588fe8f7d6f116ff5e225566))
|
||||
* **tui:** compose help overlay through the viewport overlay pipeline ([#421](https://github.com/Gitlawb/zero/issues/421)) ([5b2b4de](https://github.com/Gitlawb/zero/commit/5b2b4dea1aaf9e0f68baa25e97e83296fb17b1a2))
|
||||
* **tui:** keep the profile name on /model switch so the stored key resolves ([#441](https://github.com/Gitlawb/zero/issues/441)) ([9134148](https://github.com/Gitlawb/zero/commit/9134148f4df3e4e556fba6c2f8babfdf6fcfeee1)), closes [#440](https://github.com/Gitlawb/zero/issues/440)
|
||||
* **tui:** resolve every permission request so the agent can't deadlock ([#397](https://github.com/Gitlawb/zero/issues/397)) ([952788f](https://github.com/Gitlawb/zero/commit/952788f72d32957659fe004521fcc8372b9ba9b4))
|
||||
* **tui:** show an M suffix for million-scale token counts ([#457](https://github.com/Gitlawb/zero/issues/457)) ([0562e3b](https://github.com/Gitlawb/zero/commit/0562e3bef7df2328610a48a1e81632a8da4aec64))
|
||||
* **tui:** title /model rows by model name, not the catalog description ([#395](https://github.com/Gitlawb/zero/issues/395)) ([cdf9d83](https://github.com/Gitlawb/zero/commit/cdf9d839ae57a729f292f36f7c5b0c67b41b288d))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* cache TUI model registry ([#496](https://github.com/Gitlawb/zero/issues/496)) ([e7d88b4](https://github.com/Gitlawb/zero/commit/e7d88b4b518049733da25a8447c00144bd1da518))
|
||||
* universal tool-output ceiling with spill + async post-edit diagnostics ([#518](https://github.com/Gitlawb/zero/issues/518)) ([95ccd5b](https://github.com/Gitlawb/zero/commit/95ccd5bc327f6fb464ff0239f7229de789f578dc))
|
||||
|
||||
## 0.1.0 (2026-07-02)
|
||||
|
||||
|
||||
|
||||
@@ -186,7 +186,6 @@ 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 |
|
||||
@@ -284,39 +283,6 @@ 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
@@ -180,7 +180,6 @@ 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
|
||||
@@ -248,9 +247,10 @@ runs:
|
||||
echo "exit-code=${code}" >> "${GITHUB_OUTPUT}"
|
||||
echo "output-file=${output_file}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# 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)"
|
||||
# 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)"
|
||||
{
|
||||
echo "summary<<ZERO_EOF"
|
||||
printf '%s\n' "${summary}"
|
||||
|
||||
@@ -173,61 +173,6 @@ 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`:
|
||||
|
||||
@@ -41,7 +41,6 @@ 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." }
|
||||
@@ -52,10 +51,6 @@ 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
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
@@ -28,20 +27,10 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 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
|
||||
for _, tokens := range segments {
|
||||
if !knownSafeCommandSegment(tokens) && sandbox.ValidCommandPrefix(tokens) {
|
||||
return append([]string(nil), tokens...)
|
||||
}
|
||||
if !sandbox.ValidCommandPrefix(tokens) || !otherSegmentsKnownSafe(segments, index) {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), tokens...)
|
||||
}
|
||||
if len(segments) == 0 || !sandbox.ValidCommandPrefix(segments[0]) {
|
||||
return nil
|
||||
@@ -49,20 +38,6 @@ 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
|
||||
@@ -193,6 +168,10 @@ 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
|
||||
@@ -217,6 +196,14 @@ 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 == "" {
|
||||
@@ -288,11 +275,7 @@ func knownSafeCommandSegment(command []string) bool {
|
||||
if len(command) == 0 {
|
||||
return false
|
||||
}
|
||||
name := commandName(command[0])
|
||||
if runtime.GOOS == "windows" && tools.MsysProneCommandName(name) {
|
||||
return false
|
||||
}
|
||||
switch name {
|
||||
switch commandName(command[0]) {
|
||||
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":
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
|
||||
@@ -37,37 +35,12 @@ 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",
|
||||
@@ -147,35 +120,13 @@ 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": command}, Options{Sandbox: engine})
|
||||
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"}, 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"})
|
||||
|
||||
@@ -325,43 +325,6 @@ type compactionState struct {
|
||||
// OnText is deliberately NOT forwarded (compaction stays invisible to the user),
|
||||
// but its token COST must still be counted so usage reports and budgets include it.
|
||||
onUsage func(Usage)
|
||||
|
||||
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
|
||||
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
|
||||
// ~15-20%, which would trip compaction early (at ~60% of true capacity). It
|
||||
// starts at 1.0 and converges via an EMA as each turn reports actual usage, so
|
||||
// later turns compact nearer to real capacity. Zero is treated as 1.0.
|
||||
calibrationRatio float64
|
||||
}
|
||||
|
||||
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
|
||||
// running calibration ratio. A single sample is clamped to a sane band so an
|
||||
// outlier (a huge cache-read turn, a provider-overhead spike) can't skew the
|
||||
// estimate enough to disable or thrash compaction.
|
||||
func (state *compactionState) calibrate(rawEstimate int, actualPromptTokens int) {
|
||||
if !state.enabled || rawEstimate <= 0 || actualPromptTokens <= 0 {
|
||||
return
|
||||
}
|
||||
sample := float64(actualPromptTokens) / float64(rawEstimate)
|
||||
if sample < 0.5 {
|
||||
sample = 0.5
|
||||
} else if sample > 2.0 {
|
||||
sample = 2.0
|
||||
}
|
||||
if state.calibrationRatio <= 0 {
|
||||
state.calibrationRatio = 1.0
|
||||
}
|
||||
const alpha = 0.3 // weight on the newest sample; smooths jitter across turns
|
||||
state.calibrationRatio = state.calibrationRatio*(1-alpha) + sample*alpha
|
||||
}
|
||||
|
||||
// calibratedTokens applies the learned ratio to a raw estimate. Before any sample
|
||||
// arrives (ratio unset) it returns the raw estimate unchanged.
|
||||
func (state *compactionState) calibratedTokens(raw int) int {
|
||||
if state.calibrationRatio <= 0 {
|
||||
return raw
|
||||
}
|
||||
return int(float64(raw) * state.calibrationRatio)
|
||||
}
|
||||
|
||||
func newCompactionState(options Options) *compactionState {
|
||||
@@ -390,7 +353,7 @@ func (state *compactionState) maybeCompact(
|
||||
// the messages; both the threshold check and the shrink check below use the
|
||||
// same term so they stay consistent.
|
||||
toolTokens := estimateToolDefTokens(tools)
|
||||
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||
size := estimateTokens(messages) + toolTokens
|
||||
if size <= state.threshold {
|
||||
return messages
|
||||
}
|
||||
@@ -407,7 +370,7 @@ func (state *compactionState) maybeCompact(
|
||||
// entirely and preserve recent turns verbatim.
|
||||
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
||||
messages = pruned
|
||||
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||
size = estimateTokens(messages) + toolTokens
|
||||
if size <= state.threshold {
|
||||
state.lowWaterMark = size
|
||||
return messages
|
||||
@@ -423,7 +386,7 @@ func (state *compactionState) maybeCompact(
|
||||
// later turn) can try again; we never drop messages on failure here.
|
||||
return messages
|
||||
}
|
||||
newSize := state.calibratedTokens(estimateTokens(compacted) + toolTokens)
|
||||
newSize := estimateTokens(compacted) + toolTokens
|
||||
if newSize >= size {
|
||||
// Compaction did not actually shrink anything (e.g. nothing to
|
||||
// summarize). Leave the history untouched and don't churn next turn.
|
||||
@@ -481,7 +444,7 @@ func (state *compactionState) recover(
|
||||
// the SAME combined (messages + tool-defs) domain maybeCompact uses, so the
|
||||
// proactive shrink-guard compares like with like.
|
||||
state.reactiveAttempted = true
|
||||
state.lowWaterMark = state.calibratedTokens(estimateTokens(result) + estimateToolDefTokens(tools))
|
||||
state.lowWaterMark = estimateTokens(result) + estimateToolDefTokens(tools)
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCalibrationConvergesTowardActual(t *testing.T) {
|
||||
state := &compactionState{enabled: true}
|
||||
|
||||
// Before any sample, the estimate is unchanged.
|
||||
if got := state.calibratedTokens(1000); got != 1000 {
|
||||
t.Fatalf("uncalibrated tokens = %d, want 1000", got)
|
||||
}
|
||||
|
||||
// The estimator over-counts: our raw estimate is 1000 but the provider reports
|
||||
// 850 (a 0.85 ratio). Feeding that sample repeatedly should pull the ratio
|
||||
// toward 0.85, so a 1000-token raw estimate calibrates downward.
|
||||
for range 20 {
|
||||
state.calibrate(1000, 850)
|
||||
}
|
||||
got := state.calibratedTokens(1000)
|
||||
if got >= 1000 || got < 820 || got > 880 {
|
||||
t.Fatalf("calibrated tokens = %d, want ~850 after convergence", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalibrateIgnoresDegenerateSamples(t *testing.T) {
|
||||
state := &compactionState{enabled: true}
|
||||
state.calibrate(0, 500) // zero estimate
|
||||
state.calibrate(500, 0) // zero actual
|
||||
if state.calibrationRatio != 0 {
|
||||
t.Fatalf("degenerate samples must not move the ratio, got %v", state.calibrationRatio)
|
||||
}
|
||||
// Disabled state never calibrates.
|
||||
disabled := &compactionState{enabled: false}
|
||||
disabled.calibrate(1000, 850)
|
||||
if disabled.calibrationRatio != 0 {
|
||||
t.Fatalf("disabled compaction must not calibrate, got %v", disabled.calibrationRatio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalibrateClampsOutliers(t *testing.T) {
|
||||
state := &compactionState{enabled: true}
|
||||
// A wild outlier (10x) is clamped to 2.0, so one bad sample can't blow up the
|
||||
// ratio and disable compaction.
|
||||
for range 50 {
|
||||
state.calibrate(1000, 10000)
|
||||
}
|
||||
if state.calibrationRatio > 2.0 {
|
||||
t.Fatalf("ratio should be clamped at 2.0, got %v", state.calibrationRatio)
|
||||
}
|
||||
}
|
||||
@@ -20,16 +20,6 @@ const (
|
||||
toolNameUpdatePlan = "update_plan"
|
||||
toolNameToolSearch = "tool_search"
|
||||
toolNameSkill = "skill"
|
||||
toolNameWriteFile = "write_file"
|
||||
toolNameEditFile = "edit_file"
|
||||
)
|
||||
|
||||
// maxRecentEdits caps how many edited files are carried across a compaction, and
|
||||
// maxEditNoteBytes caps each edit's one-line note, so a long editing run can't
|
||||
// bloat the preserved-state block.
|
||||
const (
|
||||
maxRecentEdits = 20
|
||||
maxEditNoteBytes = 160
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -110,123 +100,12 @@ func formatPlanArguments(arguments string) string {
|
||||
}
|
||||
|
||||
// skillEntry is a named preserved body. It began as loaded-skill state and is
|
||||
// reused for loaded tools, project instruction blocks, and recent file edits.
|
||||
// reused for loaded tools and project instruction blocks.
|
||||
type skillEntry struct {
|
||||
name string
|
||||
body string
|
||||
}
|
||||
|
||||
// recentEdits returns the files mutated by write_file/edit_file calls in messages
|
||||
// — latest note per path, in last-seen order — as skillEntry{name: path, body:
|
||||
// note}. After compaction elides the editing turns, this tells the model WHAT it
|
||||
// changed in each file (from the tool's result) so it need not re-read to
|
||||
// rediscover its own footprint. Capped at maxRecentEdits paths.
|
||||
//
|
||||
// Ordering is by LAST edit, not first: re-editing a file moves it to the newest
|
||||
// position so the tail cap below keeps the file the model most recently touched
|
||||
// rather than an earlier, now-stale entry.
|
||||
func recentEdits(messages []zeroruntime.Message) []skillEntry {
|
||||
pathByID := map[string]string{}
|
||||
sequence := make([]string, 0)
|
||||
for _, message := range messages {
|
||||
for _, call := range message.ToolCalls {
|
||||
if call.Name != toolNameWriteFile && call.Name != toolNameEditFile {
|
||||
continue
|
||||
}
|
||||
path := editPathFromArguments(call.Arguments)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
if call.ID != "" {
|
||||
pathByID[call.ID] = path
|
||||
}
|
||||
sequence = append(sequence, path)
|
||||
}
|
||||
}
|
||||
order := lastSeenOrder(sequence)
|
||||
if len(order) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
noteByPath := map[string]string{}
|
||||
for _, message := range messages {
|
||||
if message.Role != zeroruntime.MessageRoleTool || message.ToolCallID == "" {
|
||||
continue
|
||||
}
|
||||
if path, ok := pathByID[message.ToolCallID]; ok {
|
||||
noteByPath[path] = editNote(message.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the most recent maxRecentEdits paths (the tail of last-seen order).
|
||||
if len(order) > maxRecentEdits {
|
||||
order = order[len(order)-maxRecentEdits:]
|
||||
}
|
||||
entries := make([]skillEntry, 0, len(order))
|
||||
for _, path := range order {
|
||||
entries = append(entries, skillEntry{name: path, body: noteByPath[path]})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// lastSeenOrder dedupes paths keeping each at its LAST occurrence, preserving
|
||||
// chronological order otherwise. A re-edited path therefore lands at the newest
|
||||
// position rather than staying pinned to where it first appeared.
|
||||
func lastSeenOrder(paths []string) []string {
|
||||
lastIdx := make(map[string]int, len(paths))
|
||||
for i, p := range paths {
|
||||
lastIdx[p] = i
|
||||
}
|
||||
order := make([]string, 0, len(lastIdx))
|
||||
for i, p := range paths {
|
||||
if lastIdx[p] == i {
|
||||
order = append(order, p)
|
||||
}
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// editPathFromArguments pulls the target file path from a write_file/edit_file
|
||||
// call's JSON arguments (path/file/file_path/filename aliases). Returns "" on
|
||||
// malformed arguments or no path.
|
||||
func editPathFromArguments(arguments string) string {
|
||||
var parsed struct {
|
||||
Path string `json:"path"`
|
||||
File string `json:"file"`
|
||||
FilePath string `json:"file_path"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(arguments)), &parsed); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, candidate := range []string{parsed.Path, parsed.File, parsed.FilePath, parsed.Filename} {
|
||||
if s := strings.TrimSpace(candidate); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// editNote reduces an edit tool's result to a single short line (its first
|
||||
// non-empty line, byte-capped on a rune boundary) for the preserved summary.
|
||||
func editNote(content string) string {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
s := strings.TrimSpace(line)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if len(s) > maxEditNoteBytes {
|
||||
limit := maxEditNoteBytes
|
||||
for limit > 0 && !utf8.RuneStart(s[limit]) {
|
||||
limit--
|
||||
}
|
||||
s = s[:limit] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// loadedSkills returns the skills loaded via the skill tool in messages — the
|
||||
// latest body per name, in first-seen order — matching each skill tool call to
|
||||
// its tool result by id.
|
||||
@@ -431,17 +310,11 @@ func capBody(body string) string {
|
||||
// preservedState is the JSON shape of the carried-across-compaction block.
|
||||
type preservedState struct {
|
||||
Plan string `json:"plan,omitempty"`
|
||||
RecentEdits []preservedEdit `json:"recent_edits,omitempty"`
|
||||
Tools []preservedTool `json:"tools,omitempty"`
|
||||
Skills []preservedSkill `json:"skills,omitempty"`
|
||||
ProjectInstructions []preservedInstruction `json:"project_instructions,omitempty"`
|
||||
}
|
||||
|
||||
type preservedEdit struct {
|
||||
Path string `json:"path"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
type preservedTool struct {
|
||||
Name string `json:"name"`
|
||||
Body string `json:"body"`
|
||||
@@ -474,12 +347,6 @@ func appendPreservedState(summary string, middle []zeroruntime.Message) string {
|
||||
plan = priorState.Plan
|
||||
}
|
||||
|
||||
// Recent edits: merge edits preserved earlier with fresh write_file/edit_file
|
||||
// results in middle (newer note per path wins AND moves to the newest
|
||||
// position), so the tail cap keeps the files the model most recently touched
|
||||
// across repeated compactions rather than dropping a just-re-edited file.
|
||||
edits := capRecentEdits(mergeRecentEdits(preservedEditsToEntries(priorState.RecentEdits), recentEdits(middle)))
|
||||
|
||||
// Tools: preserve deferred tool_search schemas from the transcript. Fresh
|
||||
// loads override older carried copies by name.
|
||||
tools := mergeSkillEntries(preservedToolsToEntries(priorState.Tools), loadedToolSchemas(middle))
|
||||
@@ -493,76 +360,19 @@ func appendPreservedState(summary string, middle []zeroruntime.Message) string {
|
||||
projectInstructionEntries(middle),
|
||||
)
|
||||
|
||||
if block := formatPreservedState(plan, edits, tools, skills, instructions); block != "" {
|
||||
if block := formatPreservedState(plan, tools, skills, instructions); block != "" {
|
||||
summary += "\n\n" + block
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
// mergeRecentEdits overlays fresh edits onto edits preserved by an earlier
|
||||
// compaction. Unlike mergeSkillEntries (which keeps refreshed entries in their
|
||||
// original slot), a path touched again by a fresh edit MOVES to the newest
|
||||
// position and takes the fresh note, so capRecentEdits — which keeps only the
|
||||
// most-recent tail — never drops a file the model just re-edited. Paths present
|
||||
// only in the older set keep their relative order, ahead of the fresh ones.
|
||||
func mergeRecentEdits(older, newer []skillEntry) []skillEntry {
|
||||
if len(newer) == 0 {
|
||||
return older
|
||||
}
|
||||
freshBody := make(map[string]string, len(newer))
|
||||
freshOrder := make([]string, 0, len(newer))
|
||||
for _, e := range newer {
|
||||
if _, ok := freshBody[e.name]; !ok {
|
||||
freshOrder = append(freshOrder, e.name)
|
||||
}
|
||||
freshBody[e.name] = e.body
|
||||
}
|
||||
merged := make([]skillEntry, 0, len(older)+len(freshOrder))
|
||||
// Older entries that were NOT re-edited keep their position, oldest first.
|
||||
for _, e := range older {
|
||||
if _, refreshed := freshBody[e.name]; refreshed {
|
||||
continue // re-appended at its newest position below
|
||||
}
|
||||
merged = append(merged, e)
|
||||
}
|
||||
// Fresh edits follow, in last-seen order, each carrying the fresh note — so
|
||||
// the most recently touched files sit at the tail the cap preserves.
|
||||
for _, name := range freshOrder {
|
||||
merged = append(merged, skillEntry{name: name, body: freshBody[name]})
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// capRecentEdits bounds the preserved edit list to the most recent maxRecentEdits
|
||||
// entries after a merge, so repeated compactions can't grow it without limit.
|
||||
func capRecentEdits(entries []skillEntry) []skillEntry {
|
||||
if len(entries) > maxRecentEdits {
|
||||
return entries[len(entries)-maxRecentEdits:]
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func preservedEditsToEntries(edits []preservedEdit) []skillEntry {
|
||||
entries := make([]skillEntry, 0, len(edits))
|
||||
for _, e := range edits {
|
||||
if e.Path == "" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, skillEntry{name: e.Path, body: e.Note})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// formatPreservedState renders state as the labelled, single-line
|
||||
// JSON block. Returns "" when there is nothing to preserve.
|
||||
func formatPreservedState(plan string, edits, tools, skills, instructions []skillEntry) string {
|
||||
if plan == "" && len(edits) == 0 && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
|
||||
func formatPreservedState(plan string, tools, skills, instructions []skillEntry) string {
|
||||
if plan == "" && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
|
||||
return ""
|
||||
}
|
||||
state := preservedState{Plan: plan}
|
||||
for _, e := range edits {
|
||||
state.RecentEdits = append(state.RecentEdits, preservedEdit{Path: e.name, Note: e.body})
|
||||
}
|
||||
for _, t := range tools {
|
||||
state.Tools = append(state.Tools, preservedTool{Name: t.name, Body: t.body})
|
||||
}
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// recentEdits extracts each mutated file's path and a one-line note from the
|
||||
// matching tool result, latest note per path in last-seen order.
|
||||
func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "e1", Name: "write_file", Arguments: `{"path":"internal/foo.go","content":"package foo"}`},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "Wrote internal/foo.go (12 lines)"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "e2", Name: "edit_file", Arguments: `{"path":"internal/bar.go","old_string":"a","new_string":"b"}`},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "Applied edit to internal/bar.go"},
|
||||
}
|
||||
|
||||
edits := recentEdits(messages)
|
||||
if len(edits) != 2 {
|
||||
t.Fatalf("expected 2 edited files, got %d: %#v", len(edits), edits)
|
||||
}
|
||||
if edits[0].name != "internal/foo.go" || !strings.Contains(edits[0].body, "12 lines") {
|
||||
t.Fatalf("first edit = %#v, want foo.go with its note", edits[0])
|
||||
}
|
||||
if edits[1].name != "internal/bar.go" || !strings.Contains(edits[1].body, "Applied edit") {
|
||||
t.Fatalf("second edit = %#v, want bar.go with its note", edits[1])
|
||||
}
|
||||
}
|
||||
|
||||
// After compaction elides the editing turns, the preserved-state block still
|
||||
// names the edited files and what changed, so the model needn't re-read them.
|
||||
func TestCompactionPreservesRecentEdits(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "add a flag"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "editing", ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "e1", Name: "write_file", Arguments: `{"path":"cmd/main.go","content":"..."}`},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "Wrote cmd/main.go (adds --version flag)"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "continue"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "continuing"},
|
||||
}
|
||||
summary := compactStateConversation(t, messages)
|
||||
|
||||
state := parsePreservedStateBlock(summary)
|
||||
if len(state.RecentEdits) != 1 {
|
||||
t.Fatalf("expected 1 preserved edit, got %#v", state.RecentEdits)
|
||||
}
|
||||
if state.RecentEdits[0].Path != "cmd/main.go" || !strings.Contains(state.RecentEdits[0].Note, "--version") {
|
||||
t.Fatalf("preserved edit = %#v, want cmd/main.go + its note", state.RecentEdits[0])
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh edit note for a path overrides the one carried from an earlier
|
||||
// compaction (newer wins) and moves the path to the newest position rather than
|
||||
// duplicating it or leaving it pinned to its old slot.
|
||||
func TestRecentEditsMergeNewerWins(t *testing.T) {
|
||||
prior := preservedState{RecentEdits: []preservedEdit{{Path: "a.go", Note: "old note"}, {Path: "z.go", Note: "z"}}}
|
||||
older := preservedEditsToEntries(prior.RecentEdits)
|
||||
newer := []skillEntry{{name: "a.go", body: "new note"}, {name: "b.go", body: "added"}}
|
||||
|
||||
merged := mergeRecentEdits(older, newer)
|
||||
// z.go (untouched) keeps its lead; a.go is re-edited so it takes the new note
|
||||
// and moves behind z.go, ahead of the brand-new b.go: [z.go, a.go, b.go].
|
||||
if len(merged) != 3 {
|
||||
t.Fatalf("expected z.go + refreshed a.go + b.go, got %#v", merged)
|
||||
}
|
||||
if merged[0].name != "z.go" {
|
||||
t.Fatalf("untouched z.go should stay first, got %#v", merged)
|
||||
}
|
||||
if merged[1].name != "a.go" || merged[1].body != "new note" {
|
||||
t.Fatalf("re-edited a.go should move to the newest edits with the new note, got %#v", merged[1])
|
||||
}
|
||||
if merged[2].name != "b.go" {
|
||||
t.Fatalf("brand-new b.go should be last, got %#v", merged[2])
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: once more than maxRecentEdits distinct files are tracked, re-editing
|
||||
// an early file must move it into the capped tail rather than leaving it to be
|
||||
// dropped — otherwise the next compaction omits exactly the file the model most
|
||||
// recently touched.
|
||||
func TestRecentEditsCapKeepsReeditedFile(t *testing.T) {
|
||||
// Earlier compaction preserved exactly maxRecentEdits files, f0 the oldest.
|
||||
older := make([]skillEntry, 0, maxRecentEdits)
|
||||
for i := 0; i < maxRecentEdits; i++ {
|
||||
older = append(older, skillEntry{name: fmt.Sprintf("f%d.go", i), body: "old"})
|
||||
}
|
||||
// Fresh window re-edits the OLDEST file (f0) and adds a brand-new file, so the
|
||||
// merged list exceeds the cap and something must be dropped.
|
||||
newer := []skillEntry{{name: "f0.go", body: "re-edited"}, {name: "fresh.go", body: "new"}}
|
||||
|
||||
merged := capRecentEdits(mergeRecentEdits(older, newer))
|
||||
if len(merged) != maxRecentEdits {
|
||||
t.Fatalf("cap should keep %d entries, got %d", maxRecentEdits, len(merged))
|
||||
}
|
||||
|
||||
var f0 *skillEntry
|
||||
for i := range merged {
|
||||
if merged[i].name == "f0.go" {
|
||||
f0 = &merged[i]
|
||||
}
|
||||
if merged[i].name == "f1.go" {
|
||||
t.Fatalf("f1.go (now the least-recently touched) should have been dropped, got %#v", merged)
|
||||
}
|
||||
}
|
||||
if f0 == nil {
|
||||
t.Fatalf("re-edited f0.go must survive the cap, got %#v", merged)
|
||||
}
|
||||
if f0.body != "re-edited" {
|
||||
t.Fatalf("surviving f0.go should carry the fresh note, got %q", f0.body)
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,6 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/lsp"
|
||||
@@ -20,12 +18,9 @@ const fileDiagnosticsTimeout = 10 * time.Second
|
||||
// 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 {
|
||||
// exactly what the model should see before its next step. Returns nil when
|
||||
// manager is nil, disabling inline diagnostics entirely.
|
||||
func NewFileDiagnostics(manager *lsp.Manager) func(context.Context, string) string {
|
||||
if manager == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -44,19 +39,6 @@ func NewFileDiagnostics(manager *lsp.Manager, workspaceRoot string) func(context
|
||||
if len(errors) == 0 {
|
||||
return ""
|
||||
}
|
||||
return lsp.FormatDiagnostics(diagnosticsDisplayPath(workspaceRoot, absPath), errors)
|
||||
return lsp.FormatDiagnostics(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)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,12 +22,6 @@ const (
|
||||
// calls have executed since the last update_plan call.
|
||||
staleToolCallThreshold = 10
|
||||
|
||||
// stalePlanTurnThreshold injects the same one-shot reminder once this many
|
||||
// turns have passed since the last update_plan while plan items are still
|
||||
// pending — the turn-based complement to staleToolCallThreshold, catching a
|
||||
// plan that drifts stale across many turns that each make few tool calls.
|
||||
stalePlanTurnThreshold = 8
|
||||
|
||||
// toolOnlyProgressReminderAt injects a one-shot progress nudge after this
|
||||
// many consecutive turns contain tool calls but no visible assistant text.
|
||||
// It does not stop the run; it tells the model to synthesize what it already
|
||||
@@ -49,12 +43,8 @@ const (
|
||||
toolFailureHintAt = 2
|
||||
// toolFailureStopAt halts the run after a tool fails this many times in a row
|
||||
// with the same error, so NO model (weak or strong) burns turns looping on a
|
||||
// bad call. Set to 6 (not 4): a corrective hint fires at toolFailureHintAt (2),
|
||||
// and a model iterating on a genuinely tricky edit can legitimately fail a few
|
||||
// times after the hint while converging — stopping at 4 cut those runs short.
|
||||
// The streak still resets the moment the tool succeeds or hits a different
|
||||
// error, so this only affects true same-error loops.
|
||||
toolFailureStopAt = 6
|
||||
// bad call.
|
||||
toolFailureStopAt = 4
|
||||
|
||||
// maxContinueNudges bounds how many times the headless completion gate
|
||||
// (Options.RequireCompletionSignal) re-prompts a model that stopped without a
|
||||
@@ -364,13 +354,8 @@ type guardState struct {
|
||||
emptyTurns int
|
||||
totalToolCalls int
|
||||
toolCallsSincePlanUpdate int
|
||||
// turnsSincePlanUpdate counts turns (not individual tool calls) since the last
|
||||
// update_plan, so a plan that goes stale across many low-tool-call turns is
|
||||
// still caught — the tool-call counter alone can take many turns to trip when
|
||||
// the model makes only one call per turn.
|
||||
turnsSincePlanUpdate int
|
||||
planEverCalled bool
|
||||
notCalledReminderSent bool
|
||||
planEverCalled bool
|
||||
notCalledReminderSent bool
|
||||
// staleReminderSent records whether the stale reminder has already fired for
|
||||
// the current stale interval. It is cleared when a plan update opens a new
|
||||
// interval, making the reminder one-shot per interval rather than per turn.
|
||||
@@ -429,9 +414,8 @@ 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 || hasReasoning {
|
||||
if hasToolCalls || hasVisibleText {
|
||||
state.emptyTurns = 0
|
||||
} else {
|
||||
state.emptyTurns++
|
||||
@@ -443,16 +427,11 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
|
||||
state.toolOnlyReminderSent = false
|
||||
}
|
||||
|
||||
// One turn has passed; the plan-update below resets this to 0 when the model
|
||||
// refreshes the plan this turn.
|
||||
state.turnsSincePlanUpdate++
|
||||
|
||||
for _, call := range collected.ToolCalls {
|
||||
state.totalToolCalls++
|
||||
if call.Name == planToolName {
|
||||
state.planEverCalled = true
|
||||
state.toolCallsSincePlanUpdate = 0
|
||||
state.turnsSincePlanUpdate = 0
|
||||
// A fresh plan update opens a new stale interval.
|
||||
state.staleReminderSent = false
|
||||
// Record how many items remain so the completion gate knows whether
|
||||
@@ -506,12 +485,10 @@ func (state *guardState) progressReminder() string {
|
||||
// number of turns completed so far).
|
||||
func (state *guardState) planReminder(turn int) string {
|
||||
// STALE reminder takes priority: a long run without a plan update is the
|
||||
// stronger signal. Fires on either the tool-call streak OR a turn streak with
|
||||
// pending items (so a plan drifting stale across many low-call turns is caught,
|
||||
// while a fully-completed plan is left alone). One-shot per stale interval.
|
||||
if state.planEverCalled && !state.staleReminderSent &&
|
||||
(state.toolCallsSincePlanUpdate >= staleToolCallThreshold ||
|
||||
(state.turnsSincePlanUpdate >= stalePlanTurnThreshold && state.planItemsPending > 0)) {
|
||||
// stronger signal. One-shot per stale interval.
|
||||
if state.planEverCalled &&
|
||||
!state.staleReminderSent &&
|
||||
state.toolCallsSincePlanUpdate >= staleToolCallThreshold {
|
||||
state.staleReminderSent = true
|
||||
return planStaleReminder(state.toolCallsSincePlanUpdate)
|
||||
}
|
||||
|
||||
@@ -22,14 +22,6 @@ 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{
|
||||
@@ -109,31 +101,6 @@ 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")
|
||||
|
||||
+11
-113
@@ -25,12 +25,7 @@ const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not
|
||||
// WITH NO OUTPUT yet is re-issued on a fresh connection before giving up. Only
|
||||
// the no-output case is retried (a partial turn would duplicate), so this is a
|
||||
// safe recovery for a stalled/dead pooled connection.
|
||||
//
|
||||
// Set to 1 (not 2): each attempt can itself idle for the full stream timeout
|
||||
// (~5min) before the stall is even detected, so 2 retries left an interactive
|
||||
// session frozen for ~15min. One retry keeps the common single-hiccup recovery
|
||||
// while bounding the worst case to ~2× the idle timeout.
|
||||
const maxStreamStallRetries = 1
|
||||
const maxStreamStallRetries = 2
|
||||
|
||||
const (
|
||||
toolResultMetaControl = "control"
|
||||
@@ -137,12 +132,6 @@ 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).
|
||||
@@ -156,31 +145,15 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// has already been demanded this run, so it fires at most once.
|
||||
acceptanceRequested := false
|
||||
|
||||
// toolDefCache memoizes each tool's rendered JSON-schema definition across
|
||||
// turns (a tool's advertised schema is stable for the run), so partitionTools
|
||||
// doesn't re-run the recursive schema→map conversion for every tool every turn.
|
||||
toolDefCache := map[string]zeroruntime.ToolDefinition{}
|
||||
|
||||
result := Result{Messages: copyMessages(messages)}
|
||||
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
|
||||
// the messages, so computing it before compaction is safe.
|
||||
exposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
|
||||
exposed, _ := partitionTools(registry, permissionMode, options, loaded)
|
||||
|
||||
// PROACTIVE compaction: if the history is approaching the model's
|
||||
// context window, summarize the oldest middle before building the
|
||||
@@ -190,7 +163,6 @@ 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
|
||||
@@ -226,7 +198,6 @@ 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
|
||||
@@ -287,7 +258,6 @@ 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 {
|
||||
@@ -346,7 +316,6 @@ 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 {
|
||||
@@ -371,16 +340,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
}
|
||||
|
||||
// Calibrate the compaction token estimator against the provider's real
|
||||
// prompt-token count for the request we just sent, so later turns trigger
|
||||
// compaction near true capacity instead of ~15% early on code-heavy history.
|
||||
// Recompute the estimate HERE (not at request-build time): a reactive
|
||||
// compaction may have replaced `messages` with a smaller set and re-sent, so
|
||||
// this reflects the request that actually produced collected.Usage. The
|
||||
// assistant reply is appended below, after this, so `messages` is still the
|
||||
// sent request.
|
||||
compactor.calibrate(estimateTokens(messages)+estimateToolDefTokens(exposed), collected.Usage.InputTokens)
|
||||
|
||||
// Carry the turn's terminal stop reason so a final answer cut off at the
|
||||
// output token cap (or by a content filter) is reported as truncated. A
|
||||
// tool-call turn normalizes to "" and clears any prior reason.
|
||||
@@ -491,19 +450,6 @@ 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
|
||||
@@ -620,14 +566,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.Output)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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 {
|
||||
changedFilesThisBatch = append(changedFilesThisBatch, toolResult.ChangedFiles...)
|
||||
postEditDiagnostics.enqueue(ctx, toolResult.ChangedFiles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,17 +653,6 @@ 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
|
||||
@@ -753,7 +685,6 @@ 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, ""
|
||||
@@ -1144,12 +1075,8 @@ 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.
|
||||
// Inline post-edit diagnostics for mutating tools (nil = disabled).
|
||||
Diagnostics: options.FileDiagnostics,
|
||||
// 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,
|
||||
@@ -2561,17 +2488,6 @@ func permissionActionFromSandbox(action sandbox.Action) PermissionAction {
|
||||
// exposed. The exposed slice is alpha-sorted by name, matching the legacy order
|
||||
// so the inactive path is stable.
|
||||
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
|
||||
return partitionToolsCached(registry, permissionMode, options, loaded, nil)
|
||||
}
|
||||
|
||||
// partitionToolsCached is partitionTools with an optional per-tool definition
|
||||
// cache. The partitioning itself (visibility, deferral, ordering) is recomputed
|
||||
// every call — it must be, because a tool's deferred state can flip mid-run (e.g.
|
||||
// swarm tools un-defer once a swarm is active). Only the expensive part —
|
||||
// rendering each tool's JSON-schema parameters — is memoized by tool name, since a
|
||||
// tool's advertised name/description/schema is stable for the run. defCache nil
|
||||
// disables caching (used by tests and the plain partitionTools entrypoint).
|
||||
func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool, defCache map[string]zeroruntime.ToolDefinition) ([]zeroruntime.ToolDefinition, string) {
|
||||
registeredTools := registry.All()
|
||||
|
||||
visible := make([]tools.Tool, 0, len(registeredTools))
|
||||
@@ -2615,7 +2531,7 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
|
||||
if tool.Name() == tools.ToolSearchToolName {
|
||||
continue
|
||||
}
|
||||
definitions = append(definitions, cachedRuntimeToolDefinition(defCache, tool))
|
||||
definitions = append(definitions, runtimeToolDefinition(tool))
|
||||
}
|
||||
sort.Slice(definitions, func(left int, right int) bool {
|
||||
return definitions[left].Name < definitions[right].Name
|
||||
@@ -2647,13 +2563,13 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
|
||||
}
|
||||
if tools.IsDeferred(tool) {
|
||||
if loaded[name] {
|
||||
loadedTail = append(loadedTail, cachedRuntimeToolDefinition(defCache, tool))
|
||||
loadedTail = append(loadedTail, runtimeToolDefinition(tool))
|
||||
} else {
|
||||
hiddenTools = append(hiddenTools, tool)
|
||||
}
|
||||
continue
|
||||
}
|
||||
eager = append(eager, cachedRuntimeToolDefinition(defCache, tool))
|
||||
eager = append(eager, runtimeToolDefinition(tool))
|
||||
}
|
||||
sort.Slice(eager, func(left int, right int) bool {
|
||||
return eager[left].Name < eager[right].Name
|
||||
@@ -2687,24 +2603,6 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
|
||||
return definitions, discovery
|
||||
}
|
||||
|
||||
// cachedRuntimeToolDefinition returns the tool's rendered definition, reusing a
|
||||
// cached render when defCache holds one for this tool name. A tool's advertised
|
||||
// definition is stable across a run, so caching skips the recursive schema→map
|
||||
// conversion (schemaToRuntimeMap) that would otherwise run for every tool on every
|
||||
// turn. tool_search is excluded by its callers (its description is dynamic), so it
|
||||
// never poisons the cache. A nil cache computes fresh.
|
||||
func cachedRuntimeToolDefinition(defCache map[string]zeroruntime.ToolDefinition, tool tools.Tool) zeroruntime.ToolDefinition {
|
||||
if defCache == nil {
|
||||
return runtimeToolDefinition(tool)
|
||||
}
|
||||
if def, ok := defCache[tool.Name()]; ok {
|
||||
return def
|
||||
}
|
||||
def := runtimeToolDefinition(tool)
|
||||
defCache[tool.Name()] = def
|
||||
return def
|
||||
}
|
||||
|
||||
// runtimeToolDefinition renders a tool's advertised definition (name, description,
|
||||
// JSON-schema parameters) as sent to the provider.
|
||||
func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -1946,11 +1945,6 @@ 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)
|
||||
@@ -1964,7 +1958,7 @@ func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T)
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: fmt.Sprintf(`{"command":%q}`, segmentedCommand)},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"ps aux | head -5"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
@@ -2913,10 +2907,8 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
|
||||
t.Fatalf("expected operating system in environment block, got %q", prompt)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
} else if !strings.Contains(prompt, "/bin/sh syntax") {
|
||||
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
|
||||
|
||||
@@ -51,33 +51,21 @@ func parallelSafeToolCall(registry *tools.Registry, call ToolCall, options Optio
|
||||
}
|
||||
|
||||
// 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).
|
||||
// maxParallelReadTools) and returns results indexed relative to start. Any
|
||||
// permission prompt that still occurs (a sandbox preflight can demand one even
|
||||
// for an auto-allowed read) is serialized so an interactive front-end never
|
||||
// sees two prompts 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 {
|
||||
var promptMutex sync.Mutex
|
||||
inner := options.OnPermissionRequest
|
||||
batchOptions.OnPermissionRequest = func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
callbackMutex.Lock()
|
||||
defer callbackMutex.Unlock()
|
||||
promptMutex.Lock()
|
||||
defer promptMutex.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)
|
||||
|
||||
@@ -17,9 +17,6 @@ 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
|
||||
@@ -27,24 +24,6 @@ type probeTool struct {
|
||||
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 {
|
||||
@@ -66,13 +45,7 @@ func (tool *probeTool) Run(_ context.Context, args map[string]any) tools.Result
|
||||
}
|
||||
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)
|
||||
@@ -154,9 +127,10 @@ func TestRunExecutesConsecutiveReadsConcurrently(t *testing.T) {
|
||||
}
|
||||
|
||||
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}
|
||||
read := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 30 * time.Millisecond}
|
||||
write := &probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite}
|
||||
// Shared log across both probes so relative ordering is observable.
|
||||
write.mu = sync.Mutex{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(read)
|
||||
registry.Register(write)
|
||||
@@ -177,26 +151,32 @@ func TestRunParallelReadsNeverSpanMutatingCall(t *testing.T) {
|
||||
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 {
|
||||
// The write must start only after r1+r2 finished, and r3/r4 must start only
|
||||
// after the write finished: batches never cross a mutating call.
|
||||
index := func(log []string, 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)
|
||||
readLog := func() []string { read.mu.Lock(); defer read.mu.Unlock(); return append([]string(nil), read.log...) }()
|
||||
writeLog := func() []string { write.mu.Lock(); defer write.mu.Unlock(); return append([]string(nil), write.log...) }()
|
||||
if index(writeLog, "start:w") == -1 {
|
||||
t.Fatalf("write probe never ran: %v", writeLog)
|
||||
}
|
||||
if secondBatchMinStart := min(index("start:r3"), index("start:r4")); secondBatchMinStart < writeEnd {
|
||||
t.Fatalf("second read batch started before the write finished: %v", log)
|
||||
for _, entry := range []string{"end:r1", "end:r2"} {
|
||||
if index(readLog, entry) == -1 {
|
||||
t.Fatalf("first read batch incomplete: %v", readLog)
|
||||
}
|
||||
}
|
||||
// r3/r4 must appear strictly after r1/r2 in the read log (the write barrier
|
||||
// between the two batches forces full separation).
|
||||
firstBatchMaxEnd := max(index(readLog, "end:r1"), index(readLog, "end:r2"))
|
||||
secondBatchMinStart := min(index(readLog, "start:r3"), index(readLog, "start:r4"))
|
||||
if secondBatchMinStart < firstBatchMaxEnd {
|
||||
t.Fatalf("second read batch started before first batch ended: %v", readLog)
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// countingSchemaTool records how many times its Parameters() schema is read, so a
|
||||
// test can prove the definition cache avoids re-rendering across turns.
|
||||
type countingSchemaTool struct {
|
||||
name string
|
||||
calls *int
|
||||
}
|
||||
|
||||
func (t countingSchemaTool) Name() string { return t.name }
|
||||
func (t countingSchemaTool) Description() string { return "counts schema reads" }
|
||||
func (t countingSchemaTool) Parameters() tools.Schema {
|
||||
*t.calls++
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false, Properties: map[string]tools.PropertySchema{
|
||||
"x": {Type: "string"},
|
||||
}}
|
||||
}
|
||||
func (t countingSchemaTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (t countingSchemaTool) Run(_ context.Context, _ map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
// The cache renders a tool's schema once and reuses it across calls, and its
|
||||
// output is identical to the uncached path.
|
||||
func TestPartitionToolsCacheRendersOnceAndMatchesUncached(t *testing.T) {
|
||||
calls := 0
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(countingSchemaTool{name: "alpha", calls: &calls})
|
||||
registry.Register(countingSchemaTool{name: "beta", calls: &calls})
|
||||
|
||||
uncached, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
|
||||
|
||||
base := calls
|
||||
cache := map[string]zeroruntime.ToolDefinition{}
|
||||
first, _ := partitionToolsCached(registry, PermissionModeAuto, Options{}, map[string]bool{}, cache)
|
||||
rendersAfterFirst := calls - base
|
||||
second, _ := partitionToolsCached(registry, PermissionModeAuto, Options{}, map[string]bool{}, cache)
|
||||
rendersAfterSecond := calls - base
|
||||
|
||||
if rendersAfterFirst != 2 {
|
||||
t.Fatalf("first cached call should render 2 tools once each, rendered %d times", rendersAfterFirst)
|
||||
}
|
||||
if rendersAfterSecond != 2 {
|
||||
t.Fatalf("second cached call must reuse the cache (no new schema reads), total renders = %d", rendersAfterSecond)
|
||||
}
|
||||
if !reflect.DeepEqual(first, second) {
|
||||
t.Fatal("cached calls must return identical definitions")
|
||||
}
|
||||
if !reflect.DeepEqual(first, uncached) {
|
||||
t.Fatal("cached output must match the uncached partitionTools output")
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func planUpdateTurn(status string) zeroruntime.CollectedStream {
|
||||
return zeroruntime.CollectedStream{ToolCalls: []zeroruntime.ToolCall{
|
||||
{Name: "update_plan", Arguments: `{"plan":[{"content":"step one","status":"` + status + `"}]}`},
|
||||
}}
|
||||
}
|
||||
|
||||
// Turn-based staleness: a plan that goes many turns without an update — while
|
||||
// items are still pending — draws the stale reminder even though few tool calls
|
||||
// have run (the tool-call streak alone would not have tripped).
|
||||
func TestPlanReminderFiresOnTurnStaleness(t *testing.T) {
|
||||
state := newGuardState()
|
||||
state.observeTurn(planUpdateTurn("in_progress"))
|
||||
|
||||
// Text-only turns advance the turn counter without adding tool calls.
|
||||
for range stalePlanTurnThreshold {
|
||||
state.observeTurn(zeroruntime.CollectedStream{Text: "still working…"})
|
||||
}
|
||||
|
||||
if state.toolCallsSincePlanUpdate >= staleToolCallThreshold {
|
||||
t.Fatalf("precondition: the tool-call trigger must not fire (%d calls)", state.toolCallsSincePlanUpdate)
|
||||
}
|
||||
if got := state.planReminder(stalePlanTurnThreshold + 2); !strings.Contains(got, planStaleReminderMarker) {
|
||||
t.Fatalf("expected a turn-based stale reminder, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A fully-completed plan is NOT stale: no pending items means the turn-based
|
||||
// trigger stays quiet even after many turns, so a finished plan isn't nagged.
|
||||
func TestPlanReminderSkipsTurnStalenessWhenComplete(t *testing.T) {
|
||||
state := newGuardState()
|
||||
state.observeTurn(planUpdateTurn("completed"))
|
||||
|
||||
for range stalePlanTurnThreshold + 3 {
|
||||
state.observeTurn(zeroruntime.CollectedStream{Text: "wrapping up…"})
|
||||
}
|
||||
|
||||
if got := state.planReminder(stalePlanTurnThreshold + 5); got != "" {
|
||||
t.Fatalf("a completed plan must not draw a stale reminder, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A plan update resets the turn counter, so the stale reminder does not fire
|
||||
// right after a fresh update.
|
||||
func TestPlanUpdateResetsTurnStaleness(t *testing.T) {
|
||||
state := newGuardState()
|
||||
state.observeTurn(planUpdateTurn("in_progress"))
|
||||
for range stalePlanTurnThreshold - 1 {
|
||||
state.observeTurn(zeroruntime.CollectedStream{Text: "working…"})
|
||||
}
|
||||
// Refresh the plan — resets the turn streak.
|
||||
state.observeTurn(planUpdateTurn("in_progress"))
|
||||
if got := state.planReminder(stalePlanTurnThreshold + 1); got != "" {
|
||||
t.Fatalf("a just-refreshed plan must not be stale, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,9 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/errhint"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -19,20 +17,10 @@ import (
|
||||
// times. We retry ONLY the connect (not a partially-consumed stream), so no
|
||||
// already-forwarded OnText is ever duplicated.
|
||||
const (
|
||||
// maxStreamReconnects is how many times the connect is re-issued after a
|
||||
// transient disconnect. 4 (not 2): with jittered exponential backoff this rides
|
||||
// out a multi-second network blip (~0.5s + 1s + 2s + 4s worst case) instead of
|
||||
// dying on a 2s hiccup and re-burning every token on a restart.
|
||||
maxStreamReconnects = 4
|
||||
// streamReconnectMax caps a single backoff so the tail attempts don't wait
|
||||
// minutes on a long outage.
|
||||
streamReconnectMax = 8 * time.Second
|
||||
maxStreamReconnects = 2
|
||||
streamReconnectBase = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// streamReconnectBase is the first-retry delay (doubled each subsequent attempt).
|
||||
// A var, not a const, so tests can shrink it to keep the exhaustion path fast.
|
||||
var streamReconnectBase = 500 * time.Millisecond
|
||||
|
||||
// reconnectNotifier is called before each retry with the 1-based attempt number
|
||||
// and the max, so the caller can surface a "Reconnecting N/max" notice. Nil is
|
||||
// fine.
|
||||
@@ -85,7 +73,7 @@ func streamWithReconnect(ctx context.Context, provider Provider, request zerorun
|
||||
if notify != nil {
|
||||
notify(attempt, maxStreamReconnects)
|
||||
}
|
||||
if waitErr := sleepWithContext(ctx, jitteredBackoff(attempt)); waitErr != nil {
|
||||
if waitErr := sleepWithContext(ctx, backoffFor(attempt)); waitErr != nil {
|
||||
return nil, err // ctx cancelled while waiting; surface the original error
|
||||
}
|
||||
stream, err = provider.StreamCompletion(ctx, request)
|
||||
@@ -108,19 +96,6 @@ func shouldReconnect(ctx context.Context, err error) bool {
|
||||
if isContextLimitError(msg) || isImageRejectionError(err) {
|
||||
return false
|
||||
}
|
||||
// HTTP 5xx statuses are non-reconnectable and must be excluded BEFORE the
|
||||
// transport substring match below — otherwise "504 Gateway Timeout" would slip
|
||||
// through on the generic "timeout" needle. 503 already exhausted
|
||||
// providerio.SendWithRetry (retrying is a redundant double-retry); 500/502/504
|
||||
// are non-idempotent by providerio's rule — the completion POST may already
|
||||
// have reached the model before the upstream/gateway gave up, so replaying the
|
||||
// connect risks duplicate billable work. Digit-boundary matched so an
|
||||
// incidental "504" inside a latency/id number is not mistaken for a status.
|
||||
if errhint.HasStatusCode(msg, "500", "502", "503", "504") {
|
||||
return false
|
||||
}
|
||||
// Transport-level disconnects only. A genuine transport failure (EOF, reset,
|
||||
// refused, timeout) means no response was received, which is safe to reconnect.
|
||||
for _, needle := range []string{
|
||||
"eof",
|
||||
"connection reset",
|
||||
@@ -131,6 +106,8 @@ func shouldReconnect(ctx context.Context, err error) bool {
|
||||
"timed out",
|
||||
"temporarily unavailable",
|
||||
"i/o timeout",
|
||||
"503",
|
||||
"502",
|
||||
"server closed",
|
||||
"unexpected end",
|
||||
} {
|
||||
@@ -141,31 +118,14 @@ func shouldReconnect(ctx context.Context, err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// backoffFor is the deterministic exponential base delay for a 1-based attempt,
|
||||
// capped at streamReconnectMax. Jitter is layered on separately (jitteredBackoff).
|
||||
func backoffFor(attempt int) time.Duration {
|
||||
d := streamReconnectBase
|
||||
for i := 1; i < attempt; i++ {
|
||||
if d >= streamReconnectMax {
|
||||
return streamReconnectMax
|
||||
}
|
||||
d *= 2
|
||||
}
|
||||
if d > streamReconnectMax {
|
||||
d = streamReconnectMax
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// jitteredBackoff adds up to 50% random jitter on top of backoffFor so concurrent
|
||||
// runs (swarm members, a cron fleet) that all trip on the same outage don't
|
||||
// reconnect in lockstep and hammer a recovering endpoint. Never shorter than the
|
||||
// deterministic base, so backoff still grows attempt over attempt.
|
||||
func jitteredBackoff(attempt int) time.Duration {
|
||||
base := backoffFor(attempt)
|
||||
return base + time.Duration(rand.Int63n(int64(base/2)+1))
|
||||
}
|
||||
|
||||
func sleepWithContext(ctx context.Context, d time.Duration) error {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
|
||||
@@ -44,10 +44,6 @@ func TestStreamWithReconnectRecoversFromTransientDisconnect(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStreamWithReconnectGivesUpAfterMax(t *testing.T) {
|
||||
// Shrink the backoff so exhausting all retries stays fast (real base would
|
||||
// sleep ~7.5s across 4 attempts).
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
// Always fails with a disconnect error → exhausts retries and returns it.
|
||||
p := &flakyProvider{failBefore: 99, failErr: errors.New("connection reset by peer")}
|
||||
_, err := streamWithReconnect(context.Background(), p, zeroruntime.CompletionRequest{}, nil)
|
||||
@@ -91,7 +87,7 @@ func TestShouldReconnectClassification(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
disconnects := []string{
|
||||
"unexpected EOF", "connection reset by peer", "broken pipe",
|
||||
"i/o timeout", "server closed the connection", "connection refused",
|
||||
"i/o timeout", "503 Service Unavailable", "server closed the connection",
|
||||
}
|
||||
for _, m := range disconnects {
|
||||
if !shouldReconnect(ctx, errors.New(m)) {
|
||||
@@ -101,13 +97,6 @@ func TestShouldReconnectClassification(t *testing.T) {
|
||||
notDisconnects := []string{
|
||||
"context length exceeded", "invalid api key", "model not found",
|
||||
"400 bad request: unsupported parameter",
|
||||
// HTTP 5xx statuses are handled by providerio.SendWithRetry (503) or are
|
||||
// non-idempotent (500/502/504); the reconnect path must not double-retry
|
||||
// them. "504 Gateway Timeout" must NOT slip through on the generic "timeout"
|
||||
// transport signal — a gateway timeout is non-idempotent, since the upstream
|
||||
// may have already processed the completion POST before giving up.
|
||||
"503 Service Unavailable", "502 Bad Gateway",
|
||||
"504 Gateway Timeout", "500 Internal Server Error",
|
||||
}
|
||||
for _, m := range notDisconnects {
|
||||
if shouldReconnect(ctx, errors.New(m)) {
|
||||
@@ -123,24 +112,6 @@ func TestBackoffGrows(t *testing.T) {
|
||||
if backoffFor(2) != 2*streamReconnectBase {
|
||||
t.Fatalf("attempt 2 backoff = %v, want %v", backoffFor(2), 2*streamReconnectBase)
|
||||
}
|
||||
// The exponential base is capped so late attempts don't wait minutes.
|
||||
if got := backoffFor(20); got != streamReconnectMax {
|
||||
t.Fatalf("attempt 20 backoff = %v, want cap %v", got, streamReconnectMax)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJitteredBackoffStaysInBounds(t *testing.T) {
|
||||
// Jitter never drops below the deterministic base and never exceeds base*1.5,
|
||||
// so backoff still grows attempt over attempt while decorrelating retries.
|
||||
for attempt := 1; attempt <= 5; attempt++ {
|
||||
base := backoffFor(attempt)
|
||||
for i := 0; i < 200; i++ {
|
||||
got := jitteredBackoff(attempt)
|
||||
if got < base || got > base+base/2 {
|
||||
t.Fatalf("attempt %d jittered backoff %v out of [%v, %v]", attempt, got, base, base+base/2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconnectNoticeRoutesThroughReasoning(t *testing.T) {
|
||||
|
||||
@@ -7,18 +7,14 @@ import (
|
||||
)
|
||||
|
||||
func TestSkillsContextCapsLongList(t *testing.T) {
|
||||
// 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++ {
|
||||
skills := make([]SkillInfo, 0, 40)
|
||||
for i := 0; i < 40; i++ {
|
||||
n := strconv.Itoa(i)
|
||||
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: longDesc + n})
|
||||
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: "does something useful, number " + n})
|
||||
}
|
||||
got := skillsContext(Options{Skills: skills})
|
||||
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 len(got) > 1200 {
|
||||
t.Fatalf("skills block should stay bounded, got %d bytes:\n%s", len(got), got)
|
||||
}
|
||||
if !strings.Contains(got, "more (call skill") {
|
||||
t.Fatalf("expected an overflow summary line, got:\n%s", got)
|
||||
@@ -29,27 +25,6 @@ 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)
|
||||
|
||||
+27
-127
@@ -9,7 +9,6 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/repomap"
|
||||
"github.com/Gitlawb/zero/internal/workspaceseed"
|
||||
)
|
||||
@@ -39,14 +38,6 @@ 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
|
||||
@@ -90,13 +81,6 @@ 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)
|
||||
}
|
||||
@@ -191,19 +175,10 @@ 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 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
|
||||
// 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
|
||||
|
||||
func skillsContext(options Options) string {
|
||||
if len(options.Skills) == 0 {
|
||||
@@ -211,7 +186,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. 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")
|
||||
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")
|
||||
listed, spent, omitted := 0, 0, 0
|
||||
for _, info := range options.Skills {
|
||||
name := strings.TrimSpace(info.Name)
|
||||
@@ -241,11 +216,9 @@ 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. 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.
|
||||
// verbose description can't dominate the skills-list budget.
|
||||
func truncateForSkillLine(desc string) string {
|
||||
const maxDescRunes = 200
|
||||
const maxDescRunes = 100
|
||||
runes := []rune(desc)
|
||||
if len(runes) <= maxDescRunes {
|
||||
return desc
|
||||
@@ -286,7 +259,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. 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")
|
||||
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")
|
||||
} 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")
|
||||
}
|
||||
@@ -339,7 +312,13 @@ func projectGuidelines(cwd, gitRoot string) string {
|
||||
if remaining := maxProjectContextTotalBytes - totalUsed; remaining < limit {
|
||||
limit = remaining
|
||||
}
|
||||
content = truncateGuidelineContent(content, limit)
|
||||
if len(content) > limit {
|
||||
cut := limit
|
||||
for cut > 0 && !utf8.RuneStart(content[cut]) {
|
||||
cut--
|
||||
}
|
||||
content = content[:cut] + "\n… (truncated)"
|
||||
}
|
||||
label := projectGuidelineLabel(match, gitRoot)
|
||||
b.WriteString("\n\n## Project guidelines (" + label + ")\n\n" + content)
|
||||
totalUsed += len(content)
|
||||
@@ -347,92 +326,6 @@ 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].
|
||||
@@ -485,16 +378,23 @@ 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 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 lowercased
|
||||
// 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
|
||||
}
|
||||
if match := findCaseInsensitiveFile(parent, filepath.Base(name)); match != "" {
|
||||
return match
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
|
||||
@@ -63,10 +63,6 @@ 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.
|
||||
|
||||
|
||||
@@ -9,25 +9,6 @@ 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{}))
|
||||
|
||||
@@ -147,88 +128,6 @@ 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.
|
||||
@@ -358,30 +257,6 @@ 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")
|
||||
|
||||
@@ -285,13 +285,11 @@ 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, when set, is passed to mutating tools so edit_file /
|
||||
// write_file append error-severity language diagnostics for the file they
|
||||
// just wrote directly to their tool output — the model sees an error it
|
||||
// introduced in the same turn instead of a later verification pass. Build
|
||||
// one with NewFileDiagnostics. nil disables inline diagnostics.
|
||||
FileDiagnostics func(ctx context.Context, absPath string) string
|
||||
|
||||
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
@@ -79,8 +78,6 @@ 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)
|
||||
@@ -182,8 +179,6 @@ 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,
|
||||
@@ -522,12 +517,6 @@ 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
|
||||
}
|
||||
@@ -783,14 +772,6 @@ 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,
|
||||
@@ -1286,26 +1267,3 @@ 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
|
||||
}
|
||||
}
|
||||
+4
-15
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/errhint"
|
||||
"github.com/Gitlawb/zero/internal/imageinput"
|
||||
"github.com/Gitlawb/zero/internal/lsp"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
@@ -544,7 +543,6 @@ 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{
|
||||
@@ -711,17 +709,16 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// sessions); it is a no-op when self-correct is off.
|
||||
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.
|
||||
// always-on inline post-edit diagnostics (agent.NewFileDiagnostics), 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)
|
||||
fileDiagnostics := agent.NewFileDiagnostics(manager)
|
||||
if !enabled {
|
||||
return nil, fileDiagnostics, cleanup
|
||||
}
|
||||
@@ -964,14 +961,6 @@ func writeExecProviderError(stdout io.Writer, stderr io.Writer, format execOutpu
|
||||
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", message); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
// Append a one-line next step for recognized provider failures (auth /
|
||||
// rate-limit / connectivity / …). The classifier gates on a provider-origin
|
||||
// marker, so non-provider codes (sandbox_error, mcp_error) never draw a hint.
|
||||
if hint := errhint.CLIHint(errors.New(message)); hint != "" {
|
||||
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", hint); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
return exitProvider
|
||||
}
|
||||
|
||||
|
||||
@@ -529,45 +529,6 @@ 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{
|
||||
@@ -790,17 +751,6 @@ 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 {
|
||||
@@ -827,19 +777,13 @@ func (provider toolCallingExecProvider) StreamCompletion(ctx context.Context, re
|
||||
|
||||
func findJSONEvent(t *testing.T, events []map[string]any, eventType string) map[string]any {
|
||||
t.Helper()
|
||||
_, 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 {
|
||||
for _, event := range events {
|
||||
if event["type"] == eventType {
|
||||
return idx, event
|
||||
return event
|
||||
}
|
||||
}
|
||||
t.Fatalf("event %q not found in %#v", eventType, events)
|
||||
return -1, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func findSessionEvent(t *testing.T, events []sessions.Event, eventType sessions.EventType) sessions.Event {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A recognized provider error in text mode prints the raw message plus a one-line
|
||||
// actionable hint; a non-provider error (or JSON mode) prints no hint.
|
||||
func TestWriteExecProviderErrorAppendsHint(t *testing.T) {
|
||||
t.Run("provider auth error gets a hint", func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := writeExecProviderError(&stdout, &stderr, execOutputText, "provider_error",
|
||||
"auth error: your API key is missing or invalid")
|
||||
if code != exitProvider {
|
||||
t.Fatalf("exit code = %d, want exitProvider", code)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "auth error:") {
|
||||
t.Fatalf("expected raw message, got %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "zero auth") {
|
||||
t.Fatalf("expected an actionable hint referencing `zero auth`, got %q", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-provider error gets no hint", func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
writeExecProviderError(&stdout, &stderr, execOutputText, "sandbox_error",
|
||||
"sandbox setup failed: permission denied")
|
||||
out := stderr.String()
|
||||
// Exactly one "[zero]" line — no spurious hint attached to a local error.
|
||||
if n := strings.Count(out, "[zero]"); n != 1 {
|
||||
t.Fatalf("expected exactly one [zero] line for a non-provider error, got %d:\n%s", n, out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json mode never appends a hint line", func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
writeExecProviderError(&stdout, &stderr, execOutputJSON, "provider_error",
|
||||
"auth error: bad key")
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("json mode must not write to stderr, got %q", stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "zero auth") {
|
||||
t.Fatalf("json mode must not inject a hint into the structured payload, got %q", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -80,16 +80,6 @@ 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{
|
||||
|
||||
@@ -88,15 +88,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -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", "Commits: 100", "Branches: 5", "Tags: 10"} {
|
||||
for _, want := range []string{"Files", "TypeScript", "main", "GitHub Actions", "42", "3"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("text output missing %q:\n%s", want, out)
|
||||
}
|
||||
@@ -113,16 +113,12 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-195
@@ -41,13 +41,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -273,10 +266,6 @@ 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))
|
||||
}
|
||||
@@ -476,41 +465,6 @@ 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, "-"):
|
||||
@@ -519,30 +473,15 @@ 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" && command != "push" && options.dryRun {
|
||||
return options, false, execUsageError{"--dry-run is only valid with commit or push"}
|
||||
}
|
||||
if command != "inspect" && options.baseRef != "" {
|
||||
if command == "commit" && 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
|
||||
}
|
||||
|
||||
@@ -820,24 +759,14 @@ 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, commits, pushes, and creates pull requests for local git changes.
|
||||
Inspects local git changes and optionally creates a commit.
|
||||
|
||||
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
|
||||
@@ -846,128 +775,6 @@ 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")
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
// Package errhint classifies provider/model failures into a few user-actionable
|
||||
// categories and turns them into a one-line "next step" hint.
|
||||
//
|
||||
// Provider errors already arrive with a classified string prefix from
|
||||
// providerio.ClassifiedError ("auth error:", "rate limit error:", …); lower-level
|
||||
// failures (DNS, TLS, timeouts, context-length) arrive as raw driver or library
|
||||
// messages. Classify matches both so the interactive error row (TUI) and the
|
||||
// `zero exec` provider-error path (CLI) can append one concrete next step instead
|
||||
// of dumping an identical red blob for every failure mode.
|
||||
package errhint
|
||||
|
||||
import "strings"
|
||||
|
||||
// Category buckets a provider/model failure into a small set of classes that each
|
||||
// map to a distinct recovery action.
|
||||
type Category int
|
||||
|
||||
const (
|
||||
// Unknown means the error didn't match any known signature; callers should
|
||||
// emit no hint rather than guess.
|
||||
Unknown Category = iota
|
||||
Auth
|
||||
RateLimit
|
||||
Connectivity
|
||||
ModelNotFound
|
||||
ContextOverflow
|
||||
)
|
||||
|
||||
// providerMarkers are the prefixes the provider layer attaches to every
|
||||
// provider-originated failure: providerio.ClassifiedError emits "auth error:",
|
||||
// "rate limit error:", "provider error:", and "provider request error:", and the
|
||||
// streaming paths wrap transport/decoding failures as "provider stream error:".
|
||||
// A UI surface's error can also be a *local* failure (a tool's "permission
|
||||
// denied", a "file does not exist", a config error), so Classify only proceeds
|
||||
// past this gate for messages that are recognizably from the provider — otherwise
|
||||
// a broad substring like "does not exist" would attach a bogus /model hint to an
|
||||
// unrelated local error.
|
||||
var providerMarkers = []string{
|
||||
"auth error:",
|
||||
"rate limit error:",
|
||||
"provider error:",
|
||||
"provider request error:",
|
||||
"provider stream error:",
|
||||
}
|
||||
|
||||
// Classify buckets err by scanning its message for known signatures. It is a
|
||||
// deliberately conservative string heuristic — the provider layer has already
|
||||
// discarded the numeric HTTP status by the time the error reaches a UI surface,
|
||||
// so the message is all we have. It first gates on a provider-origin marker (see
|
||||
// providerMarkers) so local failures never draw a provider hint, then sub-classifies.
|
||||
// Order matters: more specific signatures are tested before broader ones (e.g.
|
||||
// "context length" as overflow before the generic "timeout" as connectivity).
|
||||
func Classify(err error) Category {
|
||||
if err == nil {
|
||||
return Unknown
|
||||
}
|
||||
m := strings.ToLower(err.Error())
|
||||
if !containsAny(m, providerMarkers...) {
|
||||
return Unknown
|
||||
}
|
||||
switch {
|
||||
case containsAny(m, "auth error:", "unauthorized", "api key", "api_key", "invalid_api_key",
|
||||
"authentication", "permission denied", "forbidden") || containsStatusCode(m, "401", "403"):
|
||||
return Auth
|
||||
case containsAny(m, "rate limit", "rate_limit", "too many requests", "quota",
|
||||
"resource_exhausted", "overloaded") || containsStatusCode(m, "429", "529"):
|
||||
return RateLimit
|
||||
case containsAny(m, "context length", "context window", "maximum context", "context_length_exceeded",
|
||||
"too many tokens", "prompt is too long", "reduce the length", "maximum context length"):
|
||||
return ContextOverflow
|
||||
case containsAny(m, "model not found", "model_not_found", "does not exist", "unknown model",
|
||||
"no such model", "unsupported model", "invalid model", "model is not"):
|
||||
return ModelNotFound
|
||||
case containsAny(m, "dial tcp", "no such host", "connection refused", "network is unreachable",
|
||||
"i/o timeout", "context deadline exceeded", "tls handshake", "connection reset",
|
||||
"unexpected eof", "lookup ", "timeout"):
|
||||
return Connectivity
|
||||
default:
|
||||
return Unknown
|
||||
}
|
||||
}
|
||||
|
||||
// TUIHint returns a one-line hint referencing interactive slash commands, or ""
|
||||
// when the category is Unknown. Meant to sit under the raw error in the live
|
||||
// error row.
|
||||
func TUIHint(err error) string {
|
||||
switch Classify(err) {
|
||||
case Auth:
|
||||
return "API key rejected — run /provider to re-check your credentials"
|
||||
case RateLimit:
|
||||
return "Rate limited — wait a moment, or switch model with /model"
|
||||
case Connectivity:
|
||||
return "Can't reach the provider — run /doctor --connectivity"
|
||||
case ModelNotFound:
|
||||
return "Model unavailable — pick another with /model"
|
||||
case ContextOverflow:
|
||||
return "Context window full — run /compact to free space"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// CLIHint returns a one-line hint referencing `zero …` subcommands, or "" when the
|
||||
// category is Unknown. Meant for the non-interactive `zero exec` error path, where
|
||||
// slash commands don't apply.
|
||||
func CLIHint(err error) string {
|
||||
switch Classify(err) {
|
||||
case Auth:
|
||||
return "API key rejected — run `zero auth` or set the provider's API key, then retry"
|
||||
case RateLimit:
|
||||
return "Rate limited — wait a moment, or switch model with --model"
|
||||
case Connectivity:
|
||||
return "Can't reach the provider — run `zero doctor`"
|
||||
case ModelNotFound:
|
||||
return "Model unavailable — run `zero doctor` or pick another with --model"
|
||||
case ContextOverflow:
|
||||
return "Context window full — shorten the prompt or start a fresh session"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func containsAny(haystack string, needles ...string) bool {
|
||||
for _, n := range needles {
|
||||
if strings.Contains(haystack, n) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// containsStatusCode reports whether haystack contains any of the given HTTP
|
||||
// status codes as a standalone number — not embedded in a longer digit run like
|
||||
// "completed in 4290ms" or "request id 14015" — so an incidental number can't be
|
||||
// mis-bucketed as an auth/rate-limit failure.
|
||||
func containsStatusCode(haystack string, codes ...string) bool {
|
||||
return HasStatusCode(haystack, codes...)
|
||||
}
|
||||
|
||||
// HasStatusCode reports whether haystack contains any of the given HTTP status
|
||||
// codes as a standalone number — not embedded in a longer digit run like
|
||||
// "completed in 4290ms" or "request id 14015". Exported so other packages (e.g.
|
||||
// the reconnect classifier) can gate on a status code without re-implementing
|
||||
// the digit-boundary check.
|
||||
func HasStatusCode(haystack string, codes ...string) bool {
|
||||
for _, code := range codes {
|
||||
for from := 0; ; {
|
||||
rel := strings.Index(haystack[from:], code)
|
||||
if rel < 0 {
|
||||
break
|
||||
}
|
||||
pos := from + rel
|
||||
beforeOK := pos == 0 || !isASCIIDigit(haystack[pos-1])
|
||||
end := pos + len(code)
|
||||
afterOK := end >= len(haystack) || !isASCIIDigit(haystack[end])
|
||||
if beforeOK && afterOK {
|
||||
return true
|
||||
}
|
||||
from = pos + 1
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isASCIIDigit(b byte) bool {
|
||||
return b >= '0' && b <= '9'
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package errhint
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassify(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msg string
|
||||
want Category
|
||||
}{
|
||||
{"nil-ish empty", "", Unknown},
|
||||
{"providerio auth prefix", "auth error: your API key is missing or invalid — run `zero auth`", Auth},
|
||||
{"raw 401", "provider request error: 401 Unauthorized", Auth},
|
||||
{"invalid api key", "provider request error: invalid_api_key: incorrect key provided", Auth},
|
||||
{"rate limit prefix", "rate limit error: 429 too many requests", RateLimit},
|
||||
{"overloaded", "provider error: model is overloaded, please retry", RateLimit},
|
||||
{"resource exhausted gemini", "provider stream error: rpc error: code = ResourceExhausted desc = quota exceeded", RateLimit},
|
||||
{"context length openai", "provider request error: this model's maximum context length is 128000 tokens", ContextOverflow},
|
||||
{"context_length_exceeded", "provider request error: context_length_exceeded", ContextOverflow},
|
||||
{"model not found", "provider request error: 404 the model `gpt-9` does not exist", ModelNotFound},
|
||||
{"unknown model", "provider request error: unknown model: sonnet-99", ModelNotFound},
|
||||
{"dns failure", "provider stream error: Post \"https://api...\": dial tcp: lookup api.foo.com: no such host", Connectivity},
|
||||
{"connection refused", "provider stream error: dial tcp 127.0.0.1:443: connection refused", Connectivity},
|
||||
{"deadline", "provider stream error: context deadline exceeded (Client.Timeout exceeded)", Connectivity},
|
||||
{"provider-marked but no sub-signature", "provider error: something totally unexpected happened", Unknown},
|
||||
|
||||
// An incidental number that merely contains a status-code digit run must NOT
|
||||
// be mis-bucketed (digit-boundary check), even behind a provider marker.
|
||||
{"incidental latency number", "provider error: request completed in 4290ms then failed", Unknown},
|
||||
{"incidental request id", "provider error: request id 14015 failed unexpectedly", Unknown},
|
||||
{"standalone 401 still auth", "provider request error: got 401 from upstream", Auth},
|
||||
{"standalone 429 still rate limit", "provider error: 429 returned", RateLimit},
|
||||
|
||||
// Local (non-provider) failures must NOT be classified — no provider marker,
|
||||
// so no bogus recovery hint attaches to them.
|
||||
{"local fs permission denied", "open /etc/shadow: permission denied", Unknown},
|
||||
{"local file missing", "stat foo.go: no such file or directory", Unknown},
|
||||
{"local model config typo", "unknown model: sonnet-99", Unknown},
|
||||
{"tool timeout local", "tool bash timed out after 600s", Unknown},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := Classify(errors.New(tc.msg)); got != tc.want {
|
||||
t.Fatalf("Classify(%q) = %v, want %v", tc.msg, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyNil(t *testing.T) {
|
||||
if got := Classify(nil); got != Unknown {
|
||||
t.Fatalf("Classify(nil) = %v, want Unknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Context-overflow must win over the connectivity "timeout" catch-all: a message
|
||||
// mentioning both "context length" and a timeout is an overflow, not a network
|
||||
// problem.
|
||||
func TestContextOverflowBeatsConnectivity(t *testing.T) {
|
||||
err := errors.New("provider request error: maximum context length exceeded; request timeout")
|
||||
if got := Classify(err); got != ContextOverflow {
|
||||
t.Fatalf("Classify = %v, want ContextOverflow", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHintsPresentForKnownCategories(t *testing.T) {
|
||||
known := []error{
|
||||
errors.New("auth error: bad key"),
|
||||
errors.New("rate limit error: 429"),
|
||||
errors.New("provider stream error: dial tcp: no such host"),
|
||||
errors.New("provider request error: model does not exist"),
|
||||
errors.New("provider request error: maximum context length is 128000 tokens"),
|
||||
}
|
||||
for _, err := range known {
|
||||
if h := TUIHint(err); strings.TrimSpace(h) == "" {
|
||||
t.Fatalf("TUIHint(%q) empty, want a hint", err)
|
||||
}
|
||||
if h := CLIHint(err); strings.TrimSpace(h) == "" {
|
||||
t.Fatalf("CLIHint(%q) empty, want a hint", err)
|
||||
}
|
||||
}
|
||||
// Unknown yields no hint on either surface.
|
||||
unknown := errors.New("provider error: mystery")
|
||||
if h := TUIHint(unknown); h != "" {
|
||||
t.Fatalf("TUIHint(unknown) = %q, want empty", h)
|
||||
}
|
||||
if h := CLIHint(unknown); h != "" {
|
||||
t.Fatalf("CLIHint(unknown) = %q, want empty", h)
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ type Options struct {
|
||||
ModelRegistry *modelregistry.Registry
|
||||
// OAuthResolver, when set, lets the provider authenticate model calls with an
|
||||
// OAuth bearer token (preferred over the API key). nil => API-key auth only.
|
||||
// Applied to the OpenAI, Anthropic, and Google (Gemini) providers.
|
||||
// Applied to the OpenAI and Anthropic providers.
|
||||
OAuthResolver providerio.TokenResolver
|
||||
// OAuthLoginKey is the credential-store key OAuthResolver bound to (empty when
|
||||
// there is no OAuth login). It is the SAME selection the bearer resolver made,
|
||||
@@ -91,7 +91,6 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
|
||||
AuthScheme: profile.AuthScheme,
|
||||
AuthHeaderValue: profile.AuthHeaderValue,
|
||||
CustomHeaders: profile.CustomHeaders,
|
||||
OAuthResolver: options.OAuthResolver,
|
||||
MaxTokens: resolved.maxOutputTokens,
|
||||
HTTPClient: options.HTTPClient,
|
||||
UserAgent: options.UserAgent,
|
||||
|
||||
@@ -49,10 +49,6 @@ type Options struct {
|
||||
CustomHeaders map[string]string
|
||||
HTTPClient *http.Client
|
||||
UserAgent string
|
||||
// OAuthResolver, when set, supplies an OAuth bearer credential per request and
|
||||
// is retried once with a forced token refresh after an upstream 401 (matching
|
||||
// the OpenAI and Anthropic providers). Nil falls back to plain API-key auth.
|
||||
OAuthResolver providerio.TokenResolver
|
||||
// StreamIdleTimeout aborts the stream if no data arrives for this long.
|
||||
// When unset, Zero uses providerio.ResolveStreamIdleTimeout — the
|
||||
// ZERO_STREAM_IDLE_TIMEOUT override or providerio.DefaultStreamIdleTimeout.
|
||||
@@ -71,7 +67,6 @@ type Provider struct {
|
||||
customHeaders map[string]string
|
||||
httpClient *http.Client
|
||||
userAgent string
|
||||
oauthResolver providerio.TokenResolver
|
||||
streamIdleTimeout time.Duration
|
||||
}
|
||||
|
||||
@@ -100,7 +95,6 @@ func New(options Options) (*Provider, error) {
|
||||
customHeaders: providerio.CopyHeaders(options.CustomHeaders),
|
||||
httpClient: providerio.HTTPClient(options.HTTPClient),
|
||||
userAgent: options.UserAgent,
|
||||
oauthResolver: options.OAuthResolver,
|
||||
streamIdleTimeout: providerio.ResolveStreamIdleTimeout(options.StreamIdleTimeout),
|
||||
}, nil
|
||||
}
|
||||
@@ -133,22 +127,20 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
|
||||
streamCtx, cancelStream := context.WithCancel(ctx)
|
||||
defer cancelStream()
|
||||
|
||||
response, err := providerio.SendWithAuthRetry(streamCtx, provider.httpClient, http.MethodPost, provider.streamURL(), body,
|
||||
providerio.AuthHeaders{
|
||||
response, err := providerio.SendWithRetry(streamCtx, provider.httpClient, http.MethodPost, provider.streamURL(), body, func(request *http.Request) {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if provider.userAgent != "" {
|
||||
request.Header.Set("User-Agent", provider.userAgent)
|
||||
}
|
||||
providerio.ApplyAuthHeaders(request, providerio.AuthHeaders{
|
||||
APIKey: provider.apiKey,
|
||||
DefaultAuthHeader: "x-goog-api-key",
|
||||
AuthHeader: provider.authHeader,
|
||||
AuthScheme: provider.authScheme,
|
||||
AuthHeaderValue: provider.authHeaderValue,
|
||||
CustomHeaders: provider.customHeaders,
|
||||
},
|
||||
provider.oauthResolver,
|
||||
func(request *http.Request) {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if provider.userAgent != "" {
|
||||
request.Header.Set("User-Agent", provider.userAgent)
|
||||
}
|
||||
}, 0)
|
||||
})
|
||||
}, 0)
|
||||
if err != nil {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
|
||||
return
|
||||
|
||||
@@ -370,57 +370,6 @@ func TestStreamCompletionClassifiesHTTPAndPromptBlockErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A 401 with an OAuth resolver is retried once with a force-refreshed token; the
|
||||
// replayed request carries the refreshed bearer and succeeds.
|
||||
func TestStreamCompletionRetries401WithRefreshedToken(t *testing.T) {
|
||||
var attempts int
|
||||
var secondAuth string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
http.Error(w, `{"error":{"message":"token expired"}}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
secondAuth = r.Header.Get("Authorization")
|
||||
writeSSE(w, `{}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var forceRefreshOnRetry bool
|
||||
resolver := func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
|
||||
if forceRefresh {
|
||||
forceRefreshOnRetry = true
|
||||
return "Authorization", "Bearer refreshed", true, nil
|
||||
}
|
||||
return "Authorization", "Bearer stale", true, nil
|
||||
}
|
||||
|
||||
provider, err := New(Options{
|
||||
APIKey: "sk-google",
|
||||
BaseURL: server.URL,
|
||||
Model: "gemini-test",
|
||||
OAuthResolver: resolver,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), validRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned error: %v", err)
|
||||
}
|
||||
drain(stream)
|
||||
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts = %d, want 2 (initial 401 + one refreshed retry)", attempts)
|
||||
}
|
||||
if !forceRefreshOnRetry {
|
||||
t.Fatalf("resolver was not called with forceRefresh on the retry")
|
||||
}
|
||||
if secondAuth != "Bearer refreshed" {
|
||||
t.Fatalf("retry Authorization = %q, want refreshed bearer", secondAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsStreamErrorObject(t *testing.T) {
|
||||
provider := newTestProviderWithKey(t, "sk-google", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"error":{"code":429,"message":"stream failed sk-google","status":"RESOURCE_EXHAUSTED"}}`)
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -284,10 +283,10 @@ func (provider *Provider) emitChunk(
|
||||
events chan<- zeroruntime.StreamEvent,
|
||||
) {
|
||||
for _, choice := range chunk.Choices {
|
||||
if reasoning := choice.Delta.reasoningText(); reasoning != "" {
|
||||
if choice.Delta.ReasoningContent != "" {
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventReasoning,
|
||||
Content: reasoning,
|
||||
Content: choice.Delta.ReasoningContent,
|
||||
})
|
||||
}
|
||||
if choice.Delta.Content != "" {
|
||||
@@ -318,13 +317,6 @@ 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 {
|
||||
@@ -422,12 +414,6 @@ 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 {
|
||||
@@ -444,15 +430,6 @@ 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,39 +227,6 @@ 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."}}]}`)
|
||||
@@ -1259,61 +1226,3 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,6 @@ 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
|
||||
@@ -83,7 +78,6 @@ type streamChoice struct {
|
||||
type streamDelta struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
ToolCalls []streamToolCallDelta `json:"tool_calls"`
|
||||
}
|
||||
|
||||
|
||||
@@ -54,9 +54,6 @@ 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
|
||||
@@ -232,43 +229,6 @@ 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
|
||||
}
|
||||
@@ -368,13 +328,3 @@ 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
|
||||
}
|
||||
@@ -43,8 +43,6 @@ 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 {
|
||||
@@ -87,15 +85,6 @@ 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) {
|
||||
@@ -263,13 +252,12 @@ 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, "branch": true, "tag": true}
|
||||
allowed := map[string]bool{"ls-tree": true, "rev-parse": true, "remote": true, "log": true, "rev-list": 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 {
|
||||
|
||||
@@ -3,9 +3,8 @@ package sandbox
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// errWindowsSandboxNotInitialized is returned only to a caller that explicitly
|
||||
@@ -122,55 +121,9 @@ 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 = lookupExecutable
|
||||
lookup = exec.LookPath
|
||||
}
|
||||
switch goos {
|
||||
case "linux":
|
||||
|
||||
@@ -190,67 +190,6 @@ 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 != "" {
|
||||
|
||||
@@ -21,11 +21,6 @@ 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
|
||||
}
|
||||
@@ -127,139 +122,9 @@ 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 {
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
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
|
||||
}
|
||||
+39
-99
@@ -153,11 +153,6 @@ type AppendEventInput struct {
|
||||
Payload any
|
||||
}
|
||||
|
||||
type preparedAppendEvent struct {
|
||||
Type EventType
|
||||
Payload json.RawMessage
|
||||
}
|
||||
|
||||
type RecordSpecInput struct {
|
||||
SpecID string
|
||||
SpecFilePath string
|
||||
@@ -431,7 +426,7 @@ func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, err
|
||||
if err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
copyInputs := []AppendEventInput{}
|
||||
copied := 0
|
||||
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
|
||||
@@ -440,12 +435,11 @@ func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, err
|
||||
if event.Type == EventUsage {
|
||||
continue
|
||||
}
|
||||
copyInputs = append(copyInputs, AppendEventInput{Type: event.Type, Payload: event.Payload})
|
||||
if _, err := store.AppendEvent(fork.SessionID, AppendEventInput{Type: event.Type, Payload: event.Payload}); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
copied++
|
||||
}
|
||||
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
|
||||
@@ -519,34 +513,19 @@ func (store *Store) RecordSpec(sessionID string, input RecordSpecInput) (Metadat
|
||||
}
|
||||
|
||||
func (store *Store) AppendEvent(sessionID string, input AppendEventInput) (Event, error) {
|
||||
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)
|
||||
return Event{}, 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
|
||||
if strings.TrimSpace(string(input.Type)) == "" {
|
||||
return Event{}, fmt.Errorf("zero session event type is required")
|
||||
}
|
||||
unlock, err := store.lockSession(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return Event{}, err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
return store.appendPreparedEventsLocked(sessionID, prepared)
|
||||
return store.appendEventLocked(sessionID, input)
|
||||
}
|
||||
|
||||
// AppendEventUnlessExists appends input only when exists(currentEvents) is false,
|
||||
@@ -593,26 +572,13 @@ 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) {
|
||||
prepared, err := prepareAppendEventInputs([]AppendEventInput{input})
|
||||
if err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
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
|
||||
return Event{}, err
|
||||
}
|
||||
payload, err := rawPayload(input.Payload)
|
||||
if err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
sequence := session.EventCount + 1
|
||||
// The events log is the source of truth for sequencing. metadata.EventCount is
|
||||
@@ -624,54 +590,46 @@ func (store *Store) appendPreparedEventsLocked(sessionID string, inputs []prepar
|
||||
if logSeq, err := store.lastEventSequence(sessionID); err == nil && logSeq >= sequence {
|
||||
sequence = logSeq + 1
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
file, err := os.OpenFile(store.eventsPath(sessionID), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("append zero session event: %w", err)
|
||||
return Event{}, fmt.Errorf("append zero session event: %w", err)
|
||||
}
|
||||
if _, err := file.Write(batch.Bytes()); err != nil {
|
||||
if _, err := file.Write(append(data, '\n')); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("append zero session event: %w", err)
|
||||
return Event{}, fmt.Errorf("append zero session event: %w", err)
|
||||
}
|
||||
// fsync the events before reporting success: the derived metadata.json IS
|
||||
// fsync the event 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 just-appended events (incl. checkpoints
|
||||
// durable log — silently losing the just-appended event (incl. the checkpoint
|
||||
// that /rewind targets). Make the log at least as durable as its metadata. (AUDIT-M12)
|
||||
if err := file.Sync(); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("sync zero session event: %w", err)
|
||||
return Event{}, fmt.Errorf("sync zero session event: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return nil, fmt.Errorf("close zero session event file: %w", err)
|
||||
return Event{}, fmt.Errorf("close zero session event file: %w", err)
|
||||
}
|
||||
last := events[len(events)-1]
|
||||
session.UpdatedAt = last.CreatedAt
|
||||
session.EventCount = last.Sequence
|
||||
session.LastEventType = last.Type
|
||||
session.UpdatedAt = timestamp
|
||||
session.EventCount = sequence
|
||||
session.LastEventType = input.Type
|
||||
if err := store.writeMetadata(session); err != nil {
|
||||
return nil, err
|
||||
return Event{}, err
|
||||
}
|
||||
return events, nil
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// UpdateTitle replaces a session's Title and returns the updated metadata. It is
|
||||
@@ -1006,24 +964,6 @@ 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]
|
||||
|
||||
@@ -21,7 +21,6 @@ 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"
|
||||
|
||||
@@ -272,36 +272,15 @@ func coerceAskUserOptions(optionsValue, descsValue any) (labels []string, descri
|
||||
// FormatAskUserAnswers renders question/answer pairs into a clear, model-readable
|
||||
// block. Missing answers are surfaced explicitly so the model never silently
|
||||
// treats an unanswered question as answered.
|
||||
//
|
||||
// It distinguishes two shapes of "empty" the model would otherwise conflate: a
|
||||
// wholesale dismissal (the user closed the prompt without answering ANYTHING) is
|
||||
// flagged up front as a skip, so the model doesn't invent a default; a single
|
||||
// blank field amid other answers is marked "(left blank)".
|
||||
func FormatAskUserAnswers(questions []AskUserQuestion, answers []string) string {
|
||||
anyAnswered := false
|
||||
for index := range questions {
|
||||
if index < len(answers) && strings.TrimSpace(answers[index]) != "" {
|
||||
anyAnswered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(questions)*3+2)
|
||||
if len(questions) > 0 && !anyAnswered {
|
||||
lines = append(lines, "[note] The user dismissed this prompt without answering. Treat this as a skip: do not assume a default — ask again more specifically, or proceed only if the intent is already unambiguous.")
|
||||
lines = append(lines, "")
|
||||
}
|
||||
lines := make([]string, 0, len(questions)*3)
|
||||
for index, question := range questions {
|
||||
answer := ""
|
||||
if index < len(answers) {
|
||||
answer = strings.TrimSpace(answers[index])
|
||||
}
|
||||
if answer == "" {
|
||||
if anyAnswered {
|
||||
answer = "(left blank)"
|
||||
} else {
|
||||
answer = "(skipped)"
|
||||
}
|
||||
answer = "(no answer provided)"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%d. [question] %s", index+1, question.Question))
|
||||
lines = append(lines, "[answer] "+answer)
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatAskUserAnswersDistinguishesSkipFromBlank(t *testing.T) {
|
||||
questions := []AskUserQuestion{
|
||||
{Question: "Which database?"},
|
||||
{Question: "Migrate existing data?"},
|
||||
}
|
||||
|
||||
// Wholesale dismissal (nothing answered): flagged as a skip up front.
|
||||
dismissed := FormatAskUserAnswers(questions, []string{"", ""})
|
||||
if !strings.Contains(dismissed, "dismissed this prompt") || !strings.Contains(dismissed, "Treat this as a skip") {
|
||||
t.Fatalf("all-empty answers must be flagged as a skip, got:\n%s", dismissed)
|
||||
}
|
||||
if strings.Contains(dismissed, "left blank") {
|
||||
t.Fatalf("a full dismissal should read as skipped, not left-blank:\n%s", dismissed)
|
||||
}
|
||||
if !strings.Contains(dismissed, "(skipped)") {
|
||||
t.Fatalf("dismissed questions should render (skipped):\n%s", dismissed)
|
||||
}
|
||||
|
||||
// Partial answer: the blank one is "left blank", no wholesale-skip note.
|
||||
partial := FormatAskUserAnswers(questions, []string{"postgres", ""})
|
||||
if strings.Contains(partial, "dismissed this prompt") {
|
||||
t.Fatalf("a partial answer is not a dismissal:\n%s", partial)
|
||||
}
|
||||
if !strings.Contains(partial, "postgres") || !strings.Contains(partial, "(left blank)") {
|
||||
t.Fatalf("partial answers should show the answer and mark the blank one left-blank:\n%s", partial)
|
||||
}
|
||||
}
|
||||
+27
-220
@@ -1,6 +1,7 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -83,12 +84,7 @@ 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())
|
||||
}
|
||||
// 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) {
|
||||
if issue := detectShellCommandIssue(commandText, runtime.GOOS); issue != nil {
|
||||
return shellIssueBlockResult(*issue)
|
||||
}
|
||||
|
||||
@@ -111,6 +107,7 @@ 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,14 +123,10 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
defer plan.Cleanup()
|
||||
addSandboxMeta(meta, plan)
|
||||
|
||||
// Bound the capture so a command with runaway output (`cat huge.log`, `yes`)
|
||||
// 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(bashCaptureBudgetBytes, bashCaptureBudgetBytes)
|
||||
stderr := newBoundedBuffer(bashCaptureBudgetBytes, bashCaptureBudgetBytes)
|
||||
command.Stdout = stdout
|
||||
command.Stderr = stderr
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
command.Stdout = &stdout
|
||||
command.Stderr = &stderr
|
||||
|
||||
// Kill the shell as a process group on timeout and bound the post-kill I/O
|
||||
// wait, so a backgrounded child cannot outlive the command or hang Run().
|
||||
@@ -145,12 +138,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
err = command.Run()
|
||||
exitCode := commandExitCode(err)
|
||||
meta["exit_code"] = strconv.Itoa(exitCode)
|
||||
stdoutText := stdout.retained()
|
||||
stderrRetained := stderr.retained()
|
||||
stderrText := appendSandboxBlocks(stderrRetained, monitor.Stop())
|
||||
// Sandbox blocks are extra model-visible stderr bytes appended after capture;
|
||||
// count them toward the true total so the budget/marker stay accurate.
|
||||
stderrTotal := stderr.total + (len(stderrText) - len(stderrRetained))
|
||||
stderrText := appendSandboxBlocks(stderr.String(), monitor.Stop())
|
||||
|
||||
if errors.Is(commandCtx.Err(), context.DeadlineExceeded) {
|
||||
return Result{
|
||||
@@ -167,31 +155,26 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
markLikelySandboxDenial(meta, plan, exitCode, stdoutText, stderrText)
|
||||
outText, errText, truncated := budgetBashCapture(stdoutText, stdout.total, stderrText, stderrTotal, meta)
|
||||
markLikelySandboxDenial(meta, plan, exitCode, stdout.String(), stderrText)
|
||||
return Result{
|
||||
Status: StatusError,
|
||||
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
|
||||
Truncated: truncated,
|
||||
Meta: meta,
|
||||
Status: StatusError,
|
||||
Output: formatBashOutputWithShellHint(commandText, stdout.String(), stderrText, exitCode, meta),
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
|
||||
markLikelySandboxDenial(meta, plan, exitCode, stdoutText, stderrText)
|
||||
outText, errText, truncated := budgetBashCapture(stdoutText, stdout.total, stderrText, stderrTotal, meta)
|
||||
markLikelySandboxDenial(meta, plan, exitCode, stdout.String(), stderrText)
|
||||
if meta[SandboxLikelyDeniedMeta] == "true" {
|
||||
return Result{
|
||||
Status: StatusError,
|
||||
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
|
||||
Truncated: truncated,
|
||||
Meta: meta,
|
||||
Status: StatusError,
|
||||
Output: formatBashOutputWithShellHint(commandText, stdout.String(), stderrText, exitCode, meta),
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
return Result{
|
||||
Status: StatusOK,
|
||||
Output: formatBashOutput(outText, errText, exitCode),
|
||||
Truncated: truncated,
|
||||
Meta: meta,
|
||||
Status: StatusOK,
|
||||
Output: formatBashOutput(stdout.String(), stderrText, exitCode),
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,18 +185,6 @@ 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.
|
||||
@@ -383,181 +354,17 @@ func formatBashOutput(stdout string, stderr string, exitCode int) string {
|
||||
if len(parts) == 0 {
|
||||
return "Command completed with no output."
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
// Cap the context cost of a verbose command at the same default budget as
|
||||
// exec_command (head/tail window); the full output is spilled to disk and
|
||||
// referenced in the truncation notice, so nothing is lost. Previously bash
|
||||
// output was unbounded — one chatty build log could flood the context.
|
||||
output, _ := truncateExecOutputSpill(strings.Join(parts, "\n"), defaultMaxOutputTokens, "bash")
|
||||
return output
|
||||
}
|
||||
|
||||
// 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
|
||||
// counts plus a truncated flag in meta (mirroring outputBudgetMeta's shape for
|
||||
// the read/search tools). Detection that needs the full output (sandbox-denial
|
||||
// scanning) must run on the raw strings before this is applied.
|
||||
func budgetBashOutput(stdout string, stderr string, meta map[string]string) (string, string, bool) {
|
||||
return budgetBashCapture(stdout, len(stdout), stderr, len(stderr), meta)
|
||||
}
|
||||
|
||||
// budgetBashCapture is budgetBashOutput for the streaming-capture path: outTotal
|
||||
// and errTotal are the true byte counts (from boundedBuffer.total), which may
|
||||
// exceed the retained strings when the middle was dropped during capture. Meta's
|
||||
// raw_bytes therefore reflects everything the command produced, not just what was
|
||||
// kept in memory.
|
||||
func budgetBashCapture(out string, outTotal int, errStr string, errTotal int, meta map[string]string) (string, string, bool) {
|
||||
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)
|
||||
meta["emitted_bytes"] = strconv.Itoa(emitted)
|
||||
meta["estimated_tokens"] = strconv.Itoa(estimatedTokensFromBytes(emitted))
|
||||
if truncated {
|
||||
meta["truncated"] = "true"
|
||||
}
|
||||
}
|
||||
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
|
||||
// memory: the middle is discarded as it arrives instead of buffered whole and then
|
||||
// truncated. total records the full size for the truncation marker even though the
|
||||
// middle is never held. Not safe for concurrent writes; exec drives Stdout and
|
||||
// Stderr from separate goroutines, so each stream gets its own buffer.
|
||||
type boundedBuffer struct {
|
||||
head []byte
|
||||
headCap int
|
||||
tail []byte
|
||||
tailCap int
|
||||
total int
|
||||
}
|
||||
|
||||
func newBoundedBuffer(headCap, tailCap int) *boundedBuffer {
|
||||
return &boundedBuffer{headCap: headCap, tailCap: tailCap}
|
||||
}
|
||||
|
||||
func (b *boundedBuffer) Write(p []byte) (int, error) {
|
||||
n := len(p)
|
||||
b.total += n
|
||||
// Fill the head until it reaches headCap; the head is written once and frozen.
|
||||
if len(b.head) < b.headCap {
|
||||
take := b.headCap - len(b.head)
|
||||
if take > len(p) {
|
||||
take = len(p)
|
||||
}
|
||||
b.head = append(b.head, p[:take]...)
|
||||
p = p[take:]
|
||||
}
|
||||
// Anything past the head feeds a rolling tail that keeps only the last tailCap
|
||||
// bytes. Compact once it grows past 2×tailCap so the append stays amortized O(1)
|
||||
// while memory never exceeds ~2×tailCap.
|
||||
if len(p) > 0 && b.tailCap > 0 {
|
||||
b.tail = append(b.tail, p...)
|
||||
if len(b.tail) > 2*b.tailCap {
|
||||
b.tail = append(b.tail[:0], b.tail[len(b.tail)-b.tailCap:]...)
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// retained returns the kept head+tail bytes (marker-less) as a string. The junction
|
||||
// between head and tail lands in the middle, which the display budget trims away;
|
||||
// callers that need the true size read total separately.
|
||||
func (b *boundedBuffer) retained() string {
|
||||
if len(b.tail) > b.tailCap {
|
||||
// Not yet compacted since the last overflow; expose only the last tailCap.
|
||||
return string(b.head) + string(b.tail[len(b.tail)-b.tailCap:])
|
||||
}
|
||||
return string(b.head) + string(b.tail)
|
||||
}
|
||||
|
||||
// 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
|
||||
// bounded capture (boundedBuffer): value then holds only the retained head+tail,
|
||||
// and this trims it to the display budget while still reporting the true total.
|
||||
func truncateHeadTailWithTotal(value string, total, maxBytes int) (string, int, bool) {
|
||||
if maxBytes <= 0 || total <= maxBytes {
|
||||
return value, total, false
|
||||
}
|
||||
marker := fmt.Sprintf("\n[zero] output truncated: %d bytes omitted from the middle — redirect to a file and read_file a range for the full text\n", total-maxBytes)
|
||||
budget := maxBytes - len(marker)
|
||||
if budget < 0 {
|
||||
budget = 0
|
||||
}
|
||||
head := budget / 2
|
||||
tail := budget - head
|
||||
return utf8Prefix(value, head) + marker + utf8Suffix(value, tail), total, true
|
||||
}
|
||||
|
||||
func formatBashOutputWithShellHint(stdout string, stderr string, exitCode int, meta map[string]string) string {
|
||||
func formatBashOutputWithShellHint(command string, stdout string, stderr string, exitCode int, meta map[string]string) string {
|
||||
output := formatBashOutput(stdout, stderr, exitCode)
|
||||
if issue := detectShellOutputIssue(stdout+"\n"+stderr, runtime.GOOS); issue != nil {
|
||||
if issue := detectShellOutputIssue(command, stdout+"\n"+stderr, runtime.GOOS); issue != nil {
|
||||
meta["shell_issue"] = issue.Kind
|
||||
output = appendShellIssueHint(output, *issue)
|
||||
}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
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{}
|
||||
out, errStr, truncated := budgetBashOutput("hello\n", "warn\n", meta)
|
||||
if out != "hello\n" || errStr != "warn\n" {
|
||||
t.Fatalf("small output altered: out=%q err=%q", out, errStr)
|
||||
}
|
||||
if truncated {
|
||||
t.Fatalf("small output must report truncated=false")
|
||||
}
|
||||
if meta["truncated"] == "true" {
|
||||
t.Fatalf("small output must not be flagged truncated: %v", meta)
|
||||
}
|
||||
if meta["raw_bytes"] != strconv.Itoa(len("hello\n")+len("warn\n")) {
|
||||
t.Fatalf("raw_bytes wrong: %v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
// Oversized stdout is truncated head+tail: both the first and last lines survive,
|
||||
// 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
|
||||
|
||||
meta := map[string]string{}
|
||||
out, _, truncated := budgetBashOutput(big, "", meta)
|
||||
|
||||
if !truncated {
|
||||
t.Fatalf("oversized output must report truncated=true")
|
||||
}
|
||||
if !strings.Contains(out, "FIRST_LINE_MARKER") {
|
||||
t.Fatalf("head lost after truncation")
|
||||
}
|
||||
if !strings.Contains(out, "LAST_LINE_MARKER") {
|
||||
t.Fatalf("tail lost after truncation (failures live at the tail)")
|
||||
}
|
||||
if !strings.Contains(out, "output truncated") {
|
||||
t.Fatalf("expected a truncation marker, got:\n%s", out[:200])
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
if meta["raw_bytes"] != strconv.Itoa(len(big)) {
|
||||
t.Fatalf("raw_bytes = %s, want %d", meta["raw_bytes"], len(big))
|
||||
}
|
||||
if got, _ := strconv.Atoi(meta["emitted_bytes"]); got != len(out) {
|
||||
t.Fatalf("emitted_bytes = %s, want %d", meta["emitted_bytes"], len(out))
|
||||
}
|
||||
}
|
||||
|
||||
// boundedBuffer must keep memory bounded to head+tail while counting every byte,
|
||||
// so a runaway command can't OOM Zero before truncation runs. It keeps the very
|
||||
// first bytes (head) and the very last bytes (tail), discarding the middle.
|
||||
func TestBoundedBufferKeepsHeadAndTailBounded(t *testing.T) {
|
||||
b := newBoundedBuffer(8, 8)
|
||||
total := 0
|
||||
for i := 0; i < 1000; i++ {
|
||||
chunk := []byte(fmt.Sprintf("%05d.", i)) // 6 bytes each
|
||||
n, err := b.Write(chunk)
|
||||
if err != nil || n != len(chunk) {
|
||||
t.Fatalf("Write = (%d, %v), want (%d, nil)", n, err, len(chunk))
|
||||
}
|
||||
total += len(chunk)
|
||||
}
|
||||
if b.total != total {
|
||||
t.Fatalf("total = %d, want %d (every byte must be counted)", b.total, total)
|
||||
}
|
||||
// Retained memory is bounded regardless of how much streamed through.
|
||||
if len(b.head) > 8 {
|
||||
t.Fatalf("head grew past its cap: %d", len(b.head))
|
||||
}
|
||||
if len(b.tail) > 2*8 {
|
||||
t.Fatalf("tail grew past 2×cap (compaction failed): %d", len(b.tail))
|
||||
}
|
||||
r := b.retained()
|
||||
if len(r) > 8+8 {
|
||||
t.Fatalf("retained %d bytes, want <= headCap+tailCap", len(r))
|
||||
}
|
||||
if !strings.HasPrefix(r, "00000.") {
|
||||
t.Fatalf("retained head lost, got prefix %q", r[:min(6, len(r))])
|
||||
}
|
||||
if !strings.HasSuffix(r, "00999.") {
|
||||
t.Fatalf("retained tail lost, got %q", r)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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)
|
||||
|
||||
if !truncated {
|
||||
t.Fatal("oversized capture must report truncated=true")
|
||||
}
|
||||
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))])
|
||||
}
|
||||
// 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) {
|
||||
t.Fatalf("raw_bytes = %s, want the true total %d", meta["raw_bytes"], total)
|
||||
}
|
||||
// The marker must cite the true omitted count (total-budget), not retained-budget.
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -181,13 +180,9 @@ func TestDetectShellCommandIssueFlagsPipedPosixUtilities(t *testing.T) {
|
||||
`cat file.txt | wc -l`,
|
||||
`some_command | tail -n 20`,
|
||||
} {
|
||||
issue := detectShellCommandIssue(command, "windows")
|
||||
if issue == nil {
|
||||
if issue := detectShellCommandIssue(command, "windows"); 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +194,7 @@ func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
|
||||
`echo header text`,
|
||||
`tail-log.sh`,
|
||||
`grep-cli --version`,
|
||||
`mytool.exe --help`,
|
||||
`sed.exe --help`,
|
||||
} {
|
||||
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
|
||||
t.Fatalf("expected unrelated command to pass for %q, got %#v", command, issue)
|
||||
@@ -208,7 +203,7 @@ func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDetectShellOutputIssueAddsWindowsSyntaxHint(t *testing.T) {
|
||||
issue := detectShellOutputIssue("The syntax of the command is incorrect.", "windows")
|
||||
issue := detectShellOutputIssue(`cd /d/tmp/zero-pr-158 && ls -la`, "The syntax of the command is incorrect.", "windows")
|
||||
if issue == nil {
|
||||
t.Fatal("expected Windows syntax error to get shell guidance")
|
||||
}
|
||||
@@ -257,43 +252,6 @@ func TestBashToolRunsCommandInWorkspace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A command with runaway output must not be buffered whole in memory: the capture
|
||||
// is bounded to head+tail, yet raw_bytes still reports the true (much larger) size.
|
||||
// If capture were unbounded this would balloon Zero's memory before truncation.
|
||||
func TestBashToolBoundsRunawayOutputCapture(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell pipeline (yes | head)")
|
||||
}
|
||||
const produced = 500000 // ~5× the 96 KiB budget
|
||||
|
||||
result := NewBashTool(t.TempDir()).Run(context.Background(), map[string]any{
|
||||
"command": fmt.Sprintf("yes ABCDEFGH | head -c %d", produced),
|
||||
})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if !result.Truncated || result.Meta["truncated"] != "true" {
|
||||
t.Fatalf("runaway output should be flagged truncated, meta=%v", result.Meta)
|
||||
}
|
||||
if !strings.Contains(result.Output, "output truncated") {
|
||||
t.Fatalf("expected a truncation marker in output")
|
||||
}
|
||||
// The emitted (model-visible) text is capped near the budget...
|
||||
if len(result.Output) > bashOutputBudgetBytes+1024 {
|
||||
t.Fatalf("emitted %d bytes far exceeds budget %d", len(result.Output), bashOutputBudgetBytes)
|
||||
}
|
||||
// ...but raw_bytes reflects the full stream, proving the total was counted while
|
||||
// only a bounded slice was ever held (raw >> the ~2×budget retention cap).
|
||||
raw, err := strconv.Atoi(result.Meta["raw_bytes"])
|
||||
if err != nil {
|
||||
t.Fatalf("raw_bytes not an int: %q", result.Meta["raw_bytes"])
|
||||
}
|
||||
if raw < produced {
|
||||
t.Fatalf("raw_bytes = %d, want >= %d (full stream counted)", raw, produced)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashToolUsesRequestedCwd(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
nested := filepath.Join(root, "nested")
|
||||
@@ -438,105 +396,6 @@ 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()
|
||||
|
||||
@@ -114,10 +114,7 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
|
||||
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)
|
||||
newString = 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.")
|
||||
|
||||
@@ -13,7 +13,6 @@ package tools
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -46,42 +45,26 @@ func fuzzyEditMatch(content, find string, replaceAll bool) (string, error) {
|
||||
}
|
||||
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] {
|
||||
if search == "" {
|
||||
continue
|
||||
}
|
||||
seen[search] = true
|
||||
if !strings.Contains(content, search) {
|
||||
index := strings.Index(content, search)
|
||||
if index < 0 {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
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.LastIndex(content, search) != index {
|
||||
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
|
||||
@@ -219,33 +202,22 @@ func blockAnchorReplacer(content, find string) []string {
|
||||
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})
|
||||
best := candidates[0]
|
||||
if len(candidates) > 1 {
|
||||
bestSimilarity := -1.0
|
||||
for _, candidate := range candidates {
|
||||
if s := middleSimilarity(candidate); s > bestSimilarity {
|
||||
bestSimilarity = s
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(qualifying) == 0 {
|
||||
if bestSimilarity < editAnchorSimilarityThreshold {
|
||||
return nil
|
||||
}
|
||||
} else if middleSimilarity(best) < editAnchorSimilarityThreshold {
|
||||
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
|
||||
return []string{strings.Join(contentLines[best.start:best.end+1], "\n")}
|
||||
}
|
||||
|
||||
var editWhitespaceRun = regexp.MustCompile(`\s+`)
|
||||
@@ -450,75 +422,6 @@ func contextAwareReplacer(content, find string) []string {
|
||||
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 == "" {
|
||||
|
||||
@@ -47,13 +47,11 @@ func TestEditFuzzyLineTrimmed(t *testing.T) {
|
||||
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",
|
||||
"new_string": "\ty := 2\n\treturn 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)
|
||||
}
|
||||
@@ -114,69 +112,16 @@ func TestEditFuzzyIndentationFlexible(t *testing.T) {
|
||||
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)",
|
||||
"new_string": "\t\tsum += 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") {
|
||||
if !strings.Contains(after, "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.
|
||||
@@ -317,53 +262,3 @@ func TestLevenshtein(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -517,12 +517,7 @@ 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())
|
||||
}
|
||||
// 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) {
|
||||
if issue := detectShellCommandIssue(commandText, runtimeGOOS()); issue != nil {
|
||||
return shellIssueBlockResult(*issue)
|
||||
}
|
||||
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
|
||||
@@ -865,12 +860,6 @@ 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
|
||||
|
||||
@@ -141,46 +141,6 @@ 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()
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -123,24 +123,13 @@ 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. 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)
|
||||
}
|
||||
}()
|
||||
// args/paths) are redacted at the boundary just like tool output.
|
||||
defer func() { result = scrubResultSecrets(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
|
||||
|
||||
+21
-179
@@ -3,7 +3,6 @@ package tools
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type shellRuntime struct {
|
||||
@@ -18,31 +17,14 @@ 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_./~-]*`)
|
||||
// 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)`)
|
||||
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+|$)`)
|
||||
)
|
||||
|
||||
func detectShellRuntime(goos string) shellRuntime {
|
||||
@@ -55,162 +37,48 @@ 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. MSYS/Cygwin coreutils on PATH (Git for Windows usr\\bin) are not sandbox-compatible; prefer native Zero file tools."
|
||||
return "Uses " + runtime.Syntax + " syntax on Windows; prefer cwd over cd when changing directories."
|
||||
}
|
||||
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) {
|
||||
if windowsBashStyleCDPattern.MatchString(trimmed) ||
|
||||
windowsLSCommandPattern.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.",
|
||||
}
|
||||
}
|
||||
// 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.")
|
||||
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.",
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func detectShellOutputIssue(command string, output string, goos string) *shellIssue {
|
||||
if goos != "windows" {
|
||||
return nil
|
||||
}
|
||||
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") {
|
||||
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") {
|
||||
return &shellIssue{
|
||||
Kind: "windows_shell_syntax",
|
||||
Message: "Windows cmd.exe rejected the command syntax.",
|
||||
@@ -220,32 +88,6 @@ func detectShellOutputIssue(output string, goos string) *shellIssue {
|
||||
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
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+4
-65
@@ -1,14 +1,12 @@
|
||||
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
|
||||
@@ -23,78 +21,20 @@ import (
|
||||
// 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.
|
||||
// spillDir returns the shared spill directory, creating it on first use.
|
||||
func spillDir() (string, error) {
|
||||
dir := spillRootPath()
|
||||
dir := filepath.Join(os.TempDir(), "zero-tool-output")
|
||||
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.
|
||||
// tool boundary, so a spilled file never holds a secret the transcript would
|
||||
// have hidden.
|
||||
func spillTruncatedOutput(toolName, output string) string {
|
||||
dir, err := spillDir()
|
||||
if err != nil {
|
||||
@@ -113,7 +53,6 @@ func spillTruncatedOutput(toolName, output string) string {
|
||||
}
|
||||
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 ""
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
//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
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
//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
|
||||
}
|
||||
@@ -39,6 +39,18 @@ func TestTruncateExecOutputUnderBudgetUnchanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatBashOutputCapsVerboseOutput(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
long := strings.Repeat("x", 200*1024)
|
||||
formatted := formatBashOutput(long, "", 0)
|
||||
if len(formatted) > defaultMaxOutputTokens*4+1024 {
|
||||
t.Fatalf("bash output must be capped near the token budget, got %d bytes", len(formatted))
|
||||
}
|
||||
if !strings.Contains(formatted, "output truncated") {
|
||||
t.Fatal("capped bash output must carry the truncation notice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweepSpillDirRemovesOnlyOldFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldFile := filepath.Join(dir, "bash-old.txt")
|
||||
@@ -63,33 +75,6 @@ func TestSweepSpillDirRemovesOnlyOldFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
@@ -261,16 +261,10 @@ func (tool toolSearchTool) rankByKeyword(query string, deferred []Tool) []Tool {
|
||||
for index, candidate := range deferred {
|
||||
name := strings.ToLower(candidate.Name())
|
||||
desc := strings.ToLower(candidate.Description())
|
||||
nameSquashed := squashSeparators(name)
|
||||
score := 0
|
||||
for _, keyword := range keywords {
|
||||
switch {
|
||||
case strings.Contains(name, keyword):
|
||||
if strings.Contains(name, keyword) {
|
||||
score += 2
|
||||
case strings.Contains(nameSquashed, squashSeparators(keyword)):
|
||||
// Separator-insensitive fallback: "webfetch" matches "web_fetch".
|
||||
// Ranked below an exact substring so precise queries still win.
|
||||
score++
|
||||
}
|
||||
if strings.Contains(desc, keyword) {
|
||||
score++
|
||||
@@ -296,19 +290,6 @@ func (tool toolSearchTool) rankByKeyword(query string, deferred []Tool) []Tool {
|
||||
return matches
|
||||
}
|
||||
|
||||
// squashSeparators drops separators so "web_fetch", "web-fetch", and "web fetch"
|
||||
// all normalize to "webfetch". It lets a query missing the separators still match
|
||||
// a tool name, the common way a model mistypes a tool ("webfetch").
|
||||
func squashSeparators(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '_', '-', ' ', '.':
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
|
||||
// noMatchMessage reports that nothing loaded and names the available deferred
|
||||
// tools so the model can retry with a valid select: query.
|
||||
func (tool toolSearchTool) noMatchMessage(query string, deferred []Tool) string {
|
||||
|
||||
@@ -184,29 +184,6 @@ func TestToolSearchKeywordRanksByNameThenDescription(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A query missing the name's separators still matches: "webfetch" -> web_fetch.
|
||||
func TestToolSearchKeywordMatchesSeparatorInsensitive(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
reg.Register(searchFakeTool{
|
||||
name: "web_fetch",
|
||||
description: "Fetch a URL.",
|
||||
parameters: Schema{Type: "object", AdditionalProperties: false},
|
||||
})
|
||||
reg.Register(searchFakeTool{
|
||||
name: "stock_quote",
|
||||
description: "Get a stock price.",
|
||||
parameters: Schema{Type: "object", AdditionalProperties: false},
|
||||
})
|
||||
tool := NewToolSearchTool(reg).(optionsAwareTool)
|
||||
|
||||
result := tool.RunWithOptions(context.Background(),
|
||||
map[string]any{"query": "webfetch"}, RunOptions{})
|
||||
|
||||
if got := result.Meta["load_tools"]; got != "web_fetch" {
|
||||
t.Fatalf("load_tools = %q, want web_fetch (separator-insensitive match)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolSearchKeywordExcludesNonMatches(t *testing.T) {
|
||||
reg := newDeferredFixtureRegistry()
|
||||
tool := NewToolSearchTool(reg).(optionsAwareTool)
|
||||
|
||||
@@ -28,7 +28,7 @@ const (
|
||||
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 {
|
||||
@@ -348,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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -426,6 +426,7 @@ 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 {
|
||||
|
||||
@@ -254,14 +254,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -238,10 +237,8 @@ func renderAssistantMarkdownPlainText(text string, proseMeasure int, tableMeasur
|
||||
}
|
||||
|
||||
func stripMarkdownRenderControls(text string) string {
|
||||
// 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)
|
||||
text = strings.ReplaceAll(text, markdownBoldStart, "")
|
||||
return strings.ReplaceAll(text, markdownBoldEnd, "")
|
||||
}
|
||||
|
||||
func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
|
||||
|
||||
@@ -188,54 +188,7 @@ func (m model) matchCommandSuggestions(token string) []commandSuggestion {
|
||||
out := matchCommandSuggestionsWithFilter(token, func(command commandDefinition) bool {
|
||||
return command.kind != commandSandboxSetup || m.sandboxSetupCommand != nil
|
||||
})
|
||||
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
|
||||
return append(out, m.matchUserCommandSuggestions(token)...)
|
||||
}
|
||||
|
||||
// matchUserCommandSuggestions returns file-sourced /commands whose name has the
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// streamCoalesceInterval is roughly one 60fps frame. Assistant-text deltas that
|
||||
// arrive within this window are merged into a single agentTextMsg, so the render
|
||||
// rate decouples from the token rate: a fast local model (100+ tok/s) no longer
|
||||
// forces 100+ full Update→View cycles (each re-parsing the growing markdown) per
|
||||
// second. Rendering stays smooth regardless of provider speed.
|
||||
const streamCoalesceInterval = 16 * time.Millisecond
|
||||
|
||||
// textCoalescer batches agentTextMsg deltas before forwarding them to the Bubble
|
||||
// Tea program. Any OTHER message flushes the pending text first, so ordering
|
||||
// between streamed prose and tool-call / reasoning / row / usage messages is
|
||||
// preserved. The turn's final agentResponseMsg does not pass through here (it is
|
||||
// a tea.Cmd return, not a sink message), but the model drops deltas whose runID
|
||||
// is no longer active, so a flush that races just past end-of-turn is harmless.
|
||||
//
|
||||
// Sink messages originate from the single agent goroutine and so arrive
|
||||
// serially; the only concurrent caller is the flush timer. The mutex guards the
|
||||
// buffer/timer AND is held across the downstream forward, so a timer-fired text
|
||||
// flush can never overtake a concurrent non-text message: whoever holds the lock
|
||||
// drains and forwards atomically, and the other caller blocks until it is done.
|
||||
type textCoalescer struct {
|
||||
forward func(tea.Msg) // downstream sink (external sink + program.Send)
|
||||
// afterFunc schedules fn to run after one frame interval and returns a
|
||||
// stoppable timer. Defaults to a real time.AfterFunc(streamCoalesceInterval, …);
|
||||
// tests swap in a controllable timer so flush timing is deterministic instead of
|
||||
// racing the 16ms wall clock.
|
||||
afterFunc func(fn func()) coalesceTimer
|
||||
|
||||
mu sync.Mutex
|
||||
buf []byte
|
||||
runID int
|
||||
timer coalesceTimer
|
||||
}
|
||||
|
||||
// coalesceTimer is the subset of *time.Timer the coalescer needs. Abstracted
|
||||
// behind afterFunc so a test can substitute a timer it controls.
|
||||
type coalesceTimer interface {
|
||||
Stop() bool
|
||||
}
|
||||
|
||||
func newTextCoalescer(forward func(tea.Msg)) *textCoalescer {
|
||||
return &textCoalescer{
|
||||
forward: forward,
|
||||
afterFunc: func(fn func()) coalesceTimer {
|
||||
return time.AfterFunc(streamCoalesceInterval, fn)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// send is the coalescing entry point installed as the RuntimeMessageSink.
|
||||
func (c *textCoalescer) send(msg tea.Msg) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
text, ok := msg.(agentTextMsg)
|
||||
if !ok {
|
||||
// Non-text message: flush buffered text first (preserving order), then
|
||||
// forward it — both under the lock so nothing can interleave between them.
|
||||
c.drainAndForwardLocked()
|
||||
c.forward(msg)
|
||||
return
|
||||
}
|
||||
|
||||
// A delta for a different run than the one buffered: flush the old run's text
|
||||
// before buffering the new run's. In practice runs are sequential (the prior
|
||||
// run's end already flushed via a non-text message), so this is belt-and-braces.
|
||||
if len(c.buf) > 0 && text.runID != c.runID {
|
||||
c.drainAndForwardLocked()
|
||||
}
|
||||
c.runID = text.runID
|
||||
c.buf = append(c.buf, text.delta...)
|
||||
if c.timer == nil {
|
||||
c.timer = c.afterFunc(c.flush)
|
||||
}
|
||||
}
|
||||
|
||||
// flush forwards any buffered text as one agentTextMsg. Runs on the timer
|
||||
// goroutine; the lock it takes serializes it against send so its output can't be
|
||||
// reordered around a concurrent non-text message.
|
||||
func (c *textCoalescer) flush() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.drainAndForwardLocked()
|
||||
}
|
||||
|
||||
// drainAndForwardLocked forwards any buffered text as one agentTextMsg and stops
|
||||
// the timer, all while the caller holds c.mu — so a text flush and any non-text
|
||||
// forward are strictly ordered and never interleave. A no-op when nothing is
|
||||
// buffered. string(c.buf) copies, so reusing the backing array via [:0] is safe.
|
||||
func (c *textCoalescer) drainAndForwardLocked() {
|
||||
if len(c.buf) == 0 {
|
||||
return
|
||||
}
|
||||
if c.timer != nil {
|
||||
c.timer.Stop()
|
||||
c.timer = nil
|
||||
}
|
||||
msg := agentTextMsg{runID: c.runID, delta: string(c.buf)}
|
||||
c.buf = c.buf[:0]
|
||||
c.forward(msg)
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// recorder is a thread-safe sink that captures forwarded messages in order.
|
||||
type recorder struct {
|
||||
mu sync.Mutex
|
||||
msgs []tea.Msg
|
||||
}
|
||||
|
||||
func (r *recorder) forward(msg tea.Msg) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.msgs = append(r.msgs, msg)
|
||||
}
|
||||
|
||||
func (r *recorder) snapshot() []tea.Msg {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]tea.Msg, len(r.msgs))
|
||||
copy(out, r.msgs)
|
||||
return out
|
||||
}
|
||||
|
||||
// manualTimer is a coalesceTimer that never fires on its own, so a test can prove
|
||||
// buffering deterministically without racing the real 16ms frame timer: the flush
|
||||
// happens only when the test calls c.flush() explicitly.
|
||||
type manualTimer struct{ stopped bool }
|
||||
|
||||
func (m *manualTimer) Stop() bool {
|
||||
prev := m.stopped
|
||||
m.stopped = true
|
||||
return !prev
|
||||
}
|
||||
|
||||
// Rapid deltas within one frame collapse into a single agentTextMsg carrying the
|
||||
// concatenated text.
|
||||
func TestCoalescerBatchesDeltas(t *testing.T) {
|
||||
rec := &recorder{}
|
||||
c := newTextCoalescer(rec.forward)
|
||||
// Swap in a timer that never auto-fires so the "still buffered" assertion below
|
||||
// cannot flake if the scheduler pauses this goroutine past the frame interval.
|
||||
c.afterFunc = func(func()) coalesceTimer { return &manualTimer{} }
|
||||
|
||||
c.send(agentTextMsg{runID: 1, delta: "Hel"})
|
||||
c.send(agentTextMsg{runID: 1, delta: "lo, "})
|
||||
c.send(agentTextMsg{runID: 1, delta: "world"})
|
||||
|
||||
// Nothing forwarded yet — still buffered within the frame (the timer we injected
|
||||
// never fires; only the explicit flush below delivers the text).
|
||||
if got := rec.snapshot(); len(got) != 0 {
|
||||
t.Fatalf("deltas should buffer, forwarded %d early: %#v", len(got), got)
|
||||
}
|
||||
|
||||
c.flush()
|
||||
|
||||
got := rec.snapshot()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 coalesced message, got %d: %#v", len(got), got)
|
||||
}
|
||||
text, ok := got[0].(agentTextMsg)
|
||||
if !ok || text.delta != "Hello, world" || text.runID != 1 {
|
||||
t.Fatalf("coalesced message = %#v, want agentTextMsg{1, \"Hello, world\"}", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// A non-text message flushes buffered text first, so ordering (text before the
|
||||
// tool call it precedes) is preserved.
|
||||
func TestCoalescerFlushesTextBeforeOtherMessages(t *testing.T) {
|
||||
rec := &recorder{}
|
||||
c := newTextCoalescer(rec.forward)
|
||||
|
||||
c.send(agentTextMsg{runID: 1, delta: "about to run"})
|
||||
c.send(toolCallStreamStartMsg{runID: 1, id: "t1", name: "bash"})
|
||||
|
||||
got := rec.snapshot()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected text then tool-call, got %d: %#v", len(got), got)
|
||||
}
|
||||
if text, ok := got[0].(agentTextMsg); !ok || text.delta != "about to run" {
|
||||
t.Fatalf("first forwarded message must be the flushed text, got %#v", got[0])
|
||||
}
|
||||
if _, ok := got[1].(toolCallStreamStartMsg); !ok {
|
||||
t.Fatalf("second forwarded message must be the tool-call start, got %#v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
// A delta for a new run flushes the previous run's buffered text before buffering
|
||||
// the new run's, so text is never mis-attributed across runs.
|
||||
func TestCoalescerFlushesOnRunSwitch(t *testing.T) {
|
||||
rec := &recorder{}
|
||||
c := newTextCoalescer(rec.forward)
|
||||
|
||||
c.send(agentTextMsg{runID: 1, delta: "run one text"})
|
||||
c.send(agentTextMsg{runID: 2, delta: "run two text"})
|
||||
c.flush()
|
||||
|
||||
got := rec.snapshot()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected one message per run, got %d: %#v", len(got), got)
|
||||
}
|
||||
if first := got[0].(agentTextMsg); first.runID != 1 || first.delta != "run one text" {
|
||||
t.Fatalf("first message = %#v, want run 1 text flushed on switch", got[0])
|
||||
}
|
||||
if second := got[1].(agentTextMsg); second.runID != 2 || second.delta != "run two text" {
|
||||
t.Fatalf("second message = %#v, want run 2 text", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Under concurrency (the frame timer firing while a non-text message is sent),
|
||||
// forwarding must stay serialized and ordered: buffered text is always delivered
|
||||
// before a following non-text message, never after it. Run with -race to catch
|
||||
// the interleaving CodeRabbit flagged.
|
||||
func TestCoalescerOrdersTextBeforeNonTextUnderConcurrency(t *testing.T) {
|
||||
for iter := 0; iter < 200; iter++ {
|
||||
rec := &recorder{}
|
||||
c := newTextCoalescer(rec.forward)
|
||||
|
||||
// Buffer text, then let the timer race against an inline non-text send.
|
||||
c.send(agentTextMsg{runID: 1, delta: "before-tool"})
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
c.send(toolCallStreamStartMsg{runID: 1, id: "t1", name: "bash"})
|
||||
close(done)
|
||||
}()
|
||||
<-done
|
||||
|
||||
// Give the timer a chance to also fire, then settle.
|
||||
time.Sleep(2 * streamCoalesceInterval)
|
||||
c.flush()
|
||||
|
||||
got := rec.snapshot()
|
||||
textIdx, toolIdx := -1, -1
|
||||
for i, m := range got {
|
||||
switch m.(type) {
|
||||
case agentTextMsg:
|
||||
if textIdx == -1 {
|
||||
textIdx = i
|
||||
}
|
||||
case toolCallStreamStartMsg:
|
||||
toolIdx = i
|
||||
}
|
||||
}
|
||||
if textIdx == -1 || toolIdx == -1 {
|
||||
t.Fatalf("iter %d: missing messages: %#v", iter, got)
|
||||
}
|
||||
if textIdx > toolIdx {
|
||||
t.Fatalf("iter %d: text (%d) forwarded after tool-call (%d): %#v", iter, textIdx, toolIdx, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The frame timer flushes buffered text on its own without an explicit flush or a
|
||||
// following message.
|
||||
func TestCoalescerTimerFlushes(t *testing.T) {
|
||||
rec := &recorder{}
|
||||
c := newTextCoalescer(rec.forward)
|
||||
|
||||
c.send(agentTextMsg{runID: 1, delta: "timer-driven"})
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if len(rec.snapshot()) > 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(streamCoalesceInterval)
|
||||
}
|
||||
|
||||
got := rec.snapshot()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("timer should have flushed exactly one message, got %d: %#v", len(got), got)
|
||||
}
|
||||
if text := got[0].(agentTextMsg); text.delta != "timer-driven" {
|
||||
t.Fatalf("timer-flushed message = %#v", got[0])
|
||||
}
|
||||
}
|
||||
@@ -482,9 +482,6 @@ func (m model) handleModelCommand(args string) (model, string) {
|
||||
lines = append(lines, target.notice)
|
||||
}
|
||||
lines = append(lines, summary)
|
||||
if warn := m.visionDropWarning(); warn != "" {
|
||||
lines = append(lines, warn)
|
||||
}
|
||||
return m, strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
@@ -539,11 +536,7 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string,
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
status := fmt.Sprintf("Model\nSwitched to %s · %s", target.Name, target.Model)
|
||||
if warn := m.visionDropWarning(); warn != "" {
|
||||
status += "\n" + warn
|
||||
}
|
||||
return m, status, tea.Batch(cmds...)
|
||||
return m, fmt.Sprintf("Model\nSwitched to %s · %s", target.Name, target.Model), tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// profileWithCredential fills a profile's APIKey for provider construction the same
|
||||
|
||||
@@ -698,19 +698,3 @@ 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`)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -41,13 +41,7 @@ const (
|
||||
commandAddDir
|
||||
commandSelfCorrect
|
||||
commandTurns
|
||||
commandRetry
|
||||
commandEdit
|
||||
commandCopy
|
||||
commandExport
|
||||
commandNew
|
||||
commandSkills
|
||||
commandLoop
|
||||
commandUnknown
|
||||
)
|
||||
|
||||
@@ -133,13 +127,6 @@ 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",
|
||||
@@ -222,9 +209,9 @@ var commandDefinitions = []commandDefinition{
|
||||
},
|
||||
{
|
||||
name: "/compact",
|
||||
usage: "/compact [status|now]",
|
||||
usage: "/compact [status]",
|
||||
group: commandGroupSession,
|
||||
description: "Compact the transcript now, or show compaction state (/compact status).",
|
||||
description: "Show or request transcript compaction state.",
|
||||
kind: commandCompact,
|
||||
},
|
||||
{
|
||||
@@ -270,41 +257,6 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "Show or set the per-run tool-turn budget for this session (raise it for long multi-step tasks).",
|
||||
kind: commandTurns,
|
||||
},
|
||||
{
|
||||
name: "/retry",
|
||||
usage: "/retry",
|
||||
group: commandGroupSession,
|
||||
description: "Resend your last prompt.",
|
||||
kind: commandRetry,
|
||||
},
|
||||
{
|
||||
name: "/edit",
|
||||
usage: "/edit",
|
||||
group: commandGroupSession,
|
||||
description: "Recall your last prompt into the composer to edit and resend.",
|
||||
kind: commandEdit,
|
||||
},
|
||||
{
|
||||
name: "/copy",
|
||||
usage: "/copy",
|
||||
group: commandGroupSession,
|
||||
description: "Copy the last answer to the clipboard.",
|
||||
kind: commandCopy,
|
||||
},
|
||||
{
|
||||
name: "/export",
|
||||
usage: "/export [path]",
|
||||
group: commandGroupSession,
|
||||
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,27 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// "/compact now" is accepted as a manual trigger (not a usage error), while a
|
||||
// bogus argument still reports usage.
|
||||
func TestCompactNowAccepted(t *testing.T) {
|
||||
m := model{}
|
||||
|
||||
_, nowText, _ := m.handleCompactCommand("now")
|
||||
if strings.Contains(nowText, "usage:") {
|
||||
t.Fatalf("/compact now must be accepted as a trigger, got usage error: %q", nowText)
|
||||
}
|
||||
|
||||
_, statusText, _ := m.handleCompactCommand("status")
|
||||
if strings.Contains(statusText, "usage:") {
|
||||
t.Fatalf("/compact status must not error, got: %q", statusText)
|
||||
}
|
||||
|
||||
_, bogusText, _ := m.handleCompactCommand("frobnicate")
|
||||
if !strings.Contains(bogusText, "usage:") {
|
||||
t.Fatalf("/compact frobnicate must report usage, got: %q", bogusText)
|
||||
}
|
||||
}
|
||||
@@ -280,7 +280,7 @@ func (m model) composerPositionAtMouse(msg tea.MouseMsg) (int, bool) {
|
||||
}
|
||||
|
||||
func (m model) composerMouseSelectionBlocked() bool {
|
||||
return m.transcriptDetailed || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
|
||||
return m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
|
||||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A hinted error row renders the raw error and the faint one-line next step
|
||||
// below it; an unhinted row renders just the error.
|
||||
func TestRenderErrorRowShowsHint(t *testing.T) {
|
||||
withHint := renderErrorRow(transcriptRow{
|
||||
kind: rowError,
|
||||
text: "auth error: your API key is missing or invalid",
|
||||
hint: "API key rejected — run /provider to re-check your credentials",
|
||||
}, 80)
|
||||
if !strings.Contains(withHint, "auth error:") {
|
||||
t.Fatalf("expected raw error text in output, got:\n%s", withHint)
|
||||
}
|
||||
if !strings.Contains(withHint, "/provider") {
|
||||
t.Fatalf("expected hint referencing /provider, got:\n%s", withHint)
|
||||
}
|
||||
|
||||
noHint := renderErrorRow(transcriptRow{kind: rowError, text: "provider error: mystery"}, 80)
|
||||
if strings.Contains(noHint, "→") {
|
||||
t.Fatalf("unhinted error must not render a hint arrow, got:\n%s", noHint)
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,7 @@ func TestFileViewSwapsTranscriptBody(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
m = m.openFileView("internal/tui/sidebar.go")
|
||||
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("file view should swap the body to a single block item, got %d items", len(items))
|
||||
}
|
||||
@@ -264,55 +264,6 @@ 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).
|
||||
|
||||
@@ -372,7 +372,7 @@ func (m model) scrollOffsetForTranscriptRow(rowIndex int) (int, bool) {
|
||||
return 0, false
|
||||
}
|
||||
width := m.chatColumnWidth()
|
||||
items := m.transcriptBodyItems(width, "", false)
|
||||
items := m.transcriptBodyItems(width, "")
|
||||
metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
|
||||
startY := -1
|
||||
for _, span := range metrics.spans {
|
||||
|
||||
@@ -121,7 +121,7 @@ func TestEscCancellationLeavesVisibleMarker(t *testing.T) {
|
||||
m.pending = true
|
||||
m.activeRunID = 5
|
||||
m.runCancel = func() {}
|
||||
m.streamingText = []byte("half an answer")
|
||||
m.streamingText = "half an answer"
|
||||
|
||||
updated, _ := m.Update(testKey(tea.KeyEsc))
|
||||
next := updated.(model)
|
||||
@@ -133,7 +133,7 @@ func TestEscCancellationLeavesVisibleMarker(t *testing.T) {
|
||||
if !transcriptContains(next.transcript, "half an answer") {
|
||||
t.Fatalf("expected the partial streamed answer to be preserved, got %#v", next.transcript)
|
||||
}
|
||||
if len(next.streamingText) != 0 {
|
||||
if next.streamingText != "" {
|
||||
t.Fatal("streaming text should be cleared after cancel")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,18 +292,6 @@ func renderImageChips(labels []string) string {
|
||||
// and staged documents, e.g. "[Image #1] [Image #2] [Doc #1]". Returns "" when
|
||||
// nothing is staged. Numbered (not named) so a long screenshot path never shows
|
||||
// in the composer.
|
||||
// visionDropWarning returns a one-line notice when images are staged but the
|
||||
// (now active) model can't accept them, so switching to a non-vision model warns
|
||||
// the user immediately at switch time instead of silently dropping the images at
|
||||
// submit. Empty when there is nothing staged or the model supports vision.
|
||||
func (m model) visionDropWarning() string {
|
||||
if len(m.pendingImages) == 0 || m.modelSupportsVisionTUI() {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("⚠ %d staged image(s) will be dropped — %s has no vision support.",
|
||||
len(m.pendingImages), displayValue(m.modelName, "the active model"))
|
||||
}
|
||||
|
||||
func renderAttachmentChips(imageLabels []string, docs []pendingDocument) string {
|
||||
chips := make([]string, 0, len(imageLabels)+len(docs))
|
||||
for i := range imageLabels {
|
||||
|
||||
@@ -483,76 +483,3 @@ func TestRenderAttachmentChips(t *testing.T) {
|
||||
t.Fatalf("chip row %q should not show file names", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryResendsAttachments guards the /retry contract: it must resend the exact
|
||||
// request the last prompt carried — including its staged image and PDF document —
|
||||
// not a degraded text-only prompt. launchPrompt clears the pending queues once a
|
||||
// turn is sent, so without re-staging the remembered snapshot a vision/document-
|
||||
// backed turn that failed would silently retry as a different task.
|
||||
func TestRetryResendsAttachments(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
captured := make(chan []zeroruntime.ImageBlock, 1)
|
||||
provider := &fakeProvider{events: []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventText, Content: "ok"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}
|
||||
m := newModel(context.Background(), Options{
|
||||
Cwd: root,
|
||||
ProviderName: "openai",
|
||||
ModelName: "gpt-4.1",
|
||||
Provider: provider,
|
||||
Registry: tools.NewRegistry(),
|
||||
SessionStore: testSessionStore(t),
|
||||
})
|
||||
m.agentOptions.OnText = func(string) {}
|
||||
m.captureRunImages = func(imgs []zeroruntime.ImageBlock) { captured <- imgs }
|
||||
|
||||
// State left after a prior vision+PDF prompt was submitted: the pending queues
|
||||
// are cleared, but the remembered snapshot survives so /retry can reproduce it.
|
||||
m.lastPrompt = "describe both"
|
||||
m.lastImages = []zeroruntime.ImageBlock{{MediaType: "image/png", Data: []byte{0x89, 'P', 'N', 'G'}}}
|
||||
m.lastImageLabels = []string{"photo.png"}
|
||||
m.lastDocuments = []pendingDocument{{label: "spec.pdf", text: "Top secret design notes"}}
|
||||
|
||||
m.input.SetValue("/retry")
|
||||
updated, cmd := m.handleSubmit()
|
||||
next := updated.(model)
|
||||
if cmd == nil {
|
||||
t.Fatal("/retry with a remembered prompt should launch a run")
|
||||
}
|
||||
// The retried turn re-consumes and then clears the queues, exactly like a fresh
|
||||
// submit; the snapshot must survive so a second /retry stays reproducible.
|
||||
if len(next.pendingImages) != 0 || len(next.pendingImageLabels) != 0 || len(next.pendingDocuments) != 0 {
|
||||
t.Fatalf("retry must clear pending queues after resend, got imgs=%d labels=%d docs=%d",
|
||||
len(next.pendingImages), len(next.pendingImageLabels), len(next.pendingDocuments))
|
||||
}
|
||||
if len(next.lastImages) != 1 || len(next.lastDocuments) != 1 {
|
||||
t.Fatalf("retry must keep the snapshot for a subsequent retry, got imgs=%d docs=%d",
|
||||
len(next.lastImages), len(next.lastDocuments))
|
||||
}
|
||||
|
||||
updated, _ = next.Update(execCmd(cmd))
|
||||
_ = updated.(model)
|
||||
|
||||
select {
|
||||
case imgs := <-captured:
|
||||
if len(imgs) != 1 || imgs[0].MediaType != "image/png" {
|
||||
t.Fatalf("retried run must resend the remembered image, got %#v", imgs)
|
||||
}
|
||||
default:
|
||||
t.Fatal("expected the retried run to receive the remembered image")
|
||||
}
|
||||
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("expected one provider request, got %d", len(provider.requests))
|
||||
}
|
||||
msgs := provider.requests[0].Messages
|
||||
last := msgs[len(msgs)-1]
|
||||
if !strings.Contains(last.Content, "Top secret design notes") {
|
||||
t.Fatalf("retried prompt should re-prepend the document text, got:\n%s", last.Content)
|
||||
}
|
||||
if !strings.Contains(last.Content, "describe both") {
|
||||
t.Fatalf("retried prompt should include the remembered user text, got:\n%s", last.Content)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user