Compare commits

..
Author SHA1 Message Date
anandh8x e7e36fddec test(config): remove Windows startup timing assumptions
Cold PowerShell startup under concurrent Windows CI can consume most of the provider timeout, so an unsynchronized PID file may never be written and a fixed four-second bound can fail despite correct process-tree cleanup. Treat an absent PID as a helper that never reached startup, retain strict liveness checks for synchronized children, and bound completion by the child's natural lifetime instead.
2026-07-24 11:14:56 +05:30
253 changed files with 1399 additions and 19956 deletions

No files matched your search

-3
View File
@@ -1,3 +0,0 @@
*.go text eol=lf
*.mod text eol=lf
*.sum text eol=lf
+12 -33
View File
@@ -30,36 +30,6 @@ jobs:
go-version-file: go.mod
cache: true
- name: Install Make on Windows
if: matrix.os == 'windows-latest'
shell: pwsh
run: choco install make --no-progress -y
- name: Validate quality targets with stock macOS Make
if: matrix.os == 'macos-latest'
env:
GIT_CONFIG_COUNT: 1
GIT_CONFIG_KEY_0: grep.patternType
GIT_CONFIG_VALUE_0: fixed
run: make -n vulncheck
- name: Check vulnerabilities through native Windows Make
if: matrix.os == 'windows-latest'
shell: cmd
run: make vulncheck
- name: Check dead code through native Windows Make
if: matrix.os == 'windows-latest'
continue-on-error: true
shell: cmd
run: make deadcode
- name: Run static lint through native Windows Make
if: matrix.os == 'windows-latest'
continue-on-error: true
shell: cmd
run: make lint-static
- name: Check formatting
if: matrix.os == 'ubuntu-latest'
shell: bash
@@ -130,18 +100,27 @@ 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.
- name: Pin toolchain from go.mod
run: |
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
echo "GOTOOLCHAIN=${toolchain:-auto}" >> "$GITHUB_ENV"
# Hard gate: fails the build when code reaches a known vulnerability. A stdlib
# CVE is cleared by a toolchain bump (see go.mod). May also flag a newly
# published advisory on an unrelated PR — intentional: do not ship known vulns.
- name: govulncheck
run: make vulncheck
run: go run golang.org/x/vuln/cmd/[email protected] ./...
# Advisory: reports functions unreachable from any cmd/* main so dormant
# code is visible in CI. Non-blocking while the dormant subsystems are
# still being wired or removed.
- name: deadcode (advisory)
continue-on-error: true
run: make deadcode
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
@@ -151,4 +130,4 @@ jobs:
# the repo are cleaned up incrementally (see #527).
- name: golangci-lint (advisory)
continue-on-error: true
run: make lint-static
run: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...
+1 -1
View File
@@ -1 +1 @@
{".":"0.6.0"}
{".":"0.5.0"}
+4 -38
View File
@@ -51,8 +51,10 @@ request, or completing an implementation task:
or run focused tests with `-race`, when concurrency is affected.
4. **Build**: `go run ./cmd/zero-release build`.
5. **Smoke test**: `go run ./cmd/zero-release smoke`.
6. **Advisory lint**: `make lint-static`.
7. **Security**: `make vulncheck`.
6. **Advisory lint**:
`go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
7. **Security**:
`go run golang.org/x/vuln/cmd/[email protected] ./...`.
8. **Diff hygiene**: `git diff HEAD --check` (covers staged and unstaged
tracked changes).
@@ -67,39 +69,3 @@ requirements. If a related check fails, fix it. If a required check cannot run
because of the environment or fails for an unrelated external reason, report
the exact failure and obtain maintainer direction rather than silently ignoring
it.
## 4. Common Review Blockers
These classes drive multi-round reviews. Fix them before requesting review:
- **Fresh base:** Rebase onto the current PR base (`main` or stacked target)
before review. A stale head that rolls back mainline commits is a hard
blocker, not a merge-time detail. Resolve conflicts by keeping upstream
behavior unless the PR intentionally changes it.
- **Platform truth:** Code and tests must pass on Linux, macOS, and Windows.
Compare paths after canonicalization (physical/`EvalSymlinks` form); never
assert raw `t.TempDir()` spellings (`/var` vs `/private/var` on macOS, short
paths on Windows). Prefer one cross-platform function with small `GOOS`
checks over duplicated helpers that drift.
- **Security edges:** Keep secrets out of argv, env dumps, and logs. Redact
success and error paths (including stderr). Fail closed on ownership, lease,
and permission checks. Do not rely on pre-open path resolution
(`EvalSymlinks` then open) for containment: that is a check-to-use race.
Bind containment at open/use time with rooted or handle-relative,
traversal-resistant APIs. If a no-follow API is used, apply it to every
traversed component and enforce the platform's reparse-point protections;
final-component-only no-follow is insufficient. On multi-step setup, roll back
only what this run created; never destroy pre-existing resources you did
not create; never report success when cleanup or unlock failed.
- **Atomic shared state:** Write a complete temporary file, then atomically
replace the destination so concurrent readers never see a partial write.
Exclusive create or a write lock alone is not enough for readers. Serialize
the full read-modify-write sequence for lockfiles and shared stores.
- **Tests match the claim:** Every behavior or security-boundary change needs a
regression test, including the failure path. Path-sensitive logic needs at
least one non-Linux path case (or a hermetic fake that exercises the same
normalization).
- **Honest scope:** PR description, help text, and comments must match what
shipped. Wire advertised entry points or shrink the claim. Do not bundle
unrelated fixes. Do not widen security allowlists by name alone; check
classification and side effects too.
-31
View File
@@ -6,37 +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.6.0](https://github.com/Gitlawb/zero/compare/v0.5.0...v0.6.0) (2026-07-29)
### Features
* add persistent session goals ([#803](https://github.com/Gitlawb/zero/issues/803)) ([5d1869e](https://github.com/Gitlawb/zero/commit/5d1869e7bc193c97e07418b57b823905c1151d5d))
* **providers:** add Fireworks AI provider preset ([#772](https://github.com/Gitlawb/zero/issues/772)) ([58daea7](https://github.com/Gitlawb/zero/commit/58daea7a482927e5ab35afaf97761c60bb35e113))
* **skills:** report hash drift in skill info ([#795](https://github.com/Gitlawb/zero/issues/795)) ([9fbdc34](https://github.com/Gitlawb/zero/commit/9fbdc341551601c13434cf7b9b6f5e4ea4c483b1))
### Bug Fixes
* **acp:** interrupt idle reads on cancellation ([#782](https://github.com/Gitlawb/zero/issues/782)) ([15b7a54](https://github.com/Gitlawb/zero/commit/15b7a5475cfeefddbc59634551451a8872b996b7))
* **cli:** include plugins info in shell completions ([#794](https://github.com/Gitlawb/zero/issues/794)) ([fa8734f](https://github.com/Gitlawb/zero/commit/fa8734f17927de8832dfe041e043cfb86f93d12a))
* **config:** kill provider-command process tree via job object on Windows ([#690](https://github.com/Gitlawb/zero/issues/690)) ([5c58256](https://github.com/Gitlawb/zero/commit/5c58256abc29c185b449f8ce79647eb3fae61882))
* **config:** make the provider-command timeout an upper bound ([#811](https://github.com/Gitlawb/zero/issues/811)) ([#813](https://github.com/Gitlawb/zero/issues/813)) ([1c38e8c](https://github.com/Gitlawb/zero/commit/1c38e8cba50db255f3cda8b7cf4298712b2933e1))
* **config:** stop billing provider-command fixtures to the 5s timeout ([#810](https://github.com/Gitlawb/zero/issues/810)) ([f5fa4ff](https://github.com/Gitlawb/zero/commit/f5fa4ffc64537897aa78a56edca36d8f1dacd021))
* **cron:** reserve job IDs atomically ([#686](https://github.com/Gitlawb/zero/issues/686)) ([d9b882e](https://github.com/Gitlawb/zero/commit/d9b882eb0a94f38de8447cb54b57860ccd8ed053))
* **lint:** resolve static analysis baseline ([#769](https://github.com/Gitlawb/zero/issues/769)) ([583e653](https://github.com/Gitlawb/zero/commit/583e6530bd849a8efcb2b88bbe8c297e8592e62c))
* **providers:** retry provably pre-send transport failures ([#447](https://github.com/Gitlawb/zero/issues/447)) ([#750](https://github.com/Gitlawb/zero/issues/750)) ([8acd42a](https://github.com/Gitlawb/zero/commit/8acd42a3bd743a35807d762f23a33fb61c171839))
* **sandbox:** add platform-specific write lock around grant state file ([#752](https://github.com/Gitlawb/zero/issues/752)) ([#755](https://github.com/Gitlawb/zero/issues/755)) ([88fb7b6](https://github.com/Gitlawb/zero/commit/88fb7b6192c1ac8173089967d5e9119f92d7a101))
* **sandbox:** clarify scoped permission prompts ([#798](https://github.com/Gitlawb/zero/issues/798)) ([9fe02ae](https://github.com/Gitlawb/zero/commit/9fe02ae52e3234cb9dae76e0c5594a287ccbb52a))
* **sandbox:** deny reads of git's credential stores ([#815](https://github.com/Gitlawb/zero/issues/815)) ([#816](https://github.com/Gitlawb/zero/issues/816)) ([33c94ec](https://github.com/Gitlawb/zero/commit/33c94ec7a3e7987e6e36b5fd5e037ff4e6e84c26))
* **sandbox:** preserve user config and retry denied writes ([#801](https://github.com/Gitlawb/zero/issues/801)) ([81a5e6c](https://github.com/Gitlawb/zero/commit/81a5e6cb716ba4b7ecf7ff3ff261dfa51fd01098))
* **sandbox:** scrub the daemon token file pointer ([#677](https://github.com/Gitlawb/zero/issues/677)) ([#818](https://github.com/Gitlawb/zero/issues/818)) ([3e7692d](https://github.com/Gitlawb/zero/commit/3e7692d8f0885d346809869935c81a9cf055d89e))
* **swarm:** close lifecycle admission on shutdown ([#776](https://github.com/Gitlawb/zero/issues/776)) ([bdbda89](https://github.com/Gitlawb/zero/commit/bdbda89f3e42d93e8058ab3e1e9ef402659530cd))
* **tui:** fix rendering corruption over multipass + Windows Terminal ([#709](https://github.com/Gitlawb/zero/issues/709)) ([5bb1bc1](https://github.com/Gitlawb/zero/commit/5bb1bc11c3188dcea7709ff07c8b25dda3541de8))
* **tui:** paste clipboard images with Ctrl+V ([#534](https://github.com/Gitlawb/zero/issues/534)) ([#807](https://github.com/Gitlawb/zero/issues/807)) ([ec3b822](https://github.com/Gitlawb/zero/commit/ec3b8225050eb62f0f41a9582ed2881d2f9dcc8e))
* **tui:** reset quiet clock for new runs ([#799](https://github.com/Gitlawb/zero/issues/799)) ([a4a805a](https://github.com/Gitlawb/zero/commit/a4a805a6c82c51cb255e909bd87ca183c8805cf2))
* **windows:** use PowerShell for command execution ([#804](https://github.com/Gitlawb/zero/issues/804)) ([518d2e5](https://github.com/Gitlawb/zero/commit/518d2e53ba3fa073090a9b41c278face85b59b64))
## [0.5.0](https://github.com/Gitlawb/zero/compare/v0.4.0...v0.5.0) (2026-07-22)
+4 -26
View File
@@ -1,13 +1,7 @@
# Zero build/test/lint targets. AGENTS.md says to build and run quality checks
# with `make` — these targets back those instructions.
# Zero build/test/lint targets. AGENTS.md says "Build with `make`" and "Run `make
# lint` before opening a PR" — these targets back those instructions.
.DEFAULT_GOAL := build
GO_VERSION = $(word 2,$(shell git grep -G -h "^go[[:space:]]" -- go.mod))
GO_TOOLCHAIN = go$(GO_VERSION)
DEADCODE_VERSION := v0.46.0
GOLANGCI_LINT_VERSION := v2.12.2
GOVULNCHECK_VERSION := v1.3.0
.PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck tidy clean baseline help
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean baseline help
# Build the main CLI binary into ./zero.
build:
@@ -39,22 +33,6 @@ fmt-check:
# Lint = formatting check + vet (no extra tooling required).
lint: fmt-check vet
# Versioned tools select the toolchain from their own modules when invoked with
# package@version. Read this module's go directive directly, without invoking
# the possibly stale Go toolchain or consulting a multi-module GOWORK. git grep
# works with both POSIX shells and cmd.exe, including GNU Make 3.81 on macOS.
# The target-specific export is shell-independent.
lint-static deadcode vulncheck: export GOTOOLCHAIN = $(GO_TOOLCHAIN)
lint-static:
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run --enable-only unused,ineffassign,staticcheck ./...
deadcode:
go run golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION) -test=false ./...
vulncheck:
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
tidy:
go mod tidy
@@ -77,4 +55,4 @@ baseline: build
--output internal/perfbench/reports/baseline.json
help:
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, tidy, clean, baseline"
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, tidy, clean, baseline"
+14 -15
View File
@@ -131,7 +131,6 @@ export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export AIMLAPI_API_KEY=...
export LONGCAT_API_KEY=...
export FIREWORKS_API_KEY=...
export MINIMAX_API_KEY=...
export MINIMAXI_API_KEY=...
```
@@ -148,12 +147,6 @@ To configure Meituan LongCat (LongCat-2.0) directly, run:
zero providers setup longcat --set-active
```
To configure Fireworks AI directly, run:
```bash
zero providers setup fireworks --set-active
```
MiniMax presets use the Anthropic-compatible endpoints for the global and China
regions:
@@ -374,16 +367,22 @@ variable unset and set.
### Code Quality and Security Checks
Before committing any changes, ensure all Go code quality and security checks pass. The `make` targets below pin each tool to this module's Go version, so they load correctly even when your default `go` toolchain is older — running the plain `go run ...@version` form yourself can select the tool module's own (older) toolchain and fail to load this module instead.
Before committing any changes, ensure all Go code quality and security checks pass. Pinned `go run` commands matching CI constraints can be used directly without prior installation:
1. **Formatting**: Run `make fmt` (or `go fmt ./...`).
2. **Vetting**: Run `make vet` (or `go vet ./...`).
3. **Linting**: Run `make lint-static`.
4. **Vulnerability Scan**: Run `make vulncheck`.
1. **Formatting**: Run `go fmt ./...` (or `make fmt`).
2. **Vetting**: Run `go vet ./...` (or `make vet`).
3. **Linting**: Run `go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
4. **Vulnerability Scan**: Run `go run golang.org/x/vuln/cmd/[email protected] ./...`.
Use the repository-managed targets rather than globally installed binaries:
the targets apply the module's required Go toolchain as well as the reviewed,
pinned tool versions.
If you prefer to install these tools globally on your path, you can run:
```bash
# Install golangci-lint
go install github.com/golangci/golangci-lint/v2/cmd/[email protected]
# Install govulncheck
go install golang.org/x/vuln/cmd/[email protected]
```
The installed binaries land in `$GOBIN` when it is set, otherwise in
`$GOPATH/bin` (default `~/go/bin`). That directory must be on your `PATH` to
+2 -2
View File
@@ -108,10 +108,10 @@ Discovery roots (earlier wins on name collisions):
2. **Shared multi-agent dir** — `~/.agents/skills/` when present (read-only discovery; never an install target)
3. **Plugin skill roots** — skills bundled by active plugins (section 6)
A missing directory is fine — Zero just omits it. Management commands (`zero skill add` / `zero skill remove`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`. `skill info` reports lock source/hash when present and whether the on-disk `SKILL.md` has drifted from the recorded hash.
A missing directory is fine — Zero just omits it. Management commands (`zero skills add` / `remove` / `lock`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`.
```text
~/.local/share/zero/skills/ # primary (install/remove target)
~/.local/share/zero/skills/ # primary (install/remove/lock target)
run-benchmarks/
SKILL.md
write-changelog/
-1
View File
@@ -6,7 +6,6 @@ require (
charm.land/bubbles/v2 v2.1.1
charm.land/bubbletea/v2 v2.0.8
charm.land/lipgloss/v2 v2.0.5
github.com/Microsoft/go-winio v0.6.2
github.com/alecthomas/chroma/v2 v2.27.0
github.com/atotto/clipboard v0.1.4
github.com/aymanbagabas/go-udiff v0.4.1
-2
View File
@@ -4,8 +4,6 @@ charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
+6 -138
View File
@@ -17,7 +17,6 @@ import (
"fmt"
"io"
"sync"
"sync/atomic"
)
// JSON-RPC 2.0 standard error codes.
@@ -81,9 +80,8 @@ type NotifyFunc func(ctx context.Context, params json.RawMessage)
// requests/notifications — needed because ACP is bidirectional (the agent calls
// the client for session/request_permission, fs/*, terminal/*).
type Conn struct {
rawReader io.Reader // wrapped lazily in Serve, once ctx is known — see interruptibleReader
readerCloser io.Closer
w io.Writer
reader *bufio.Reader
w io.Writer
writeMu sync.Mutex // serializes all writes to w
@@ -98,15 +96,10 @@ type Conn struct {
wg sync.WaitGroup // tracks in-flight inbound handlers
}
// NewConn builds a peer reading ndjson from r and writing ndjson to w. This
// Conn does not own r: cancelling Serve's context cannot forcibly close it, so
// a Read on r that's genuinely idle (never returns on its own) leaves Serve
// blocked until r itself produces something — e.g. a test that closes r's own
// write side to unblock it. Use NewOwnedConn when this Conn should own r's
// lifetime.
// NewConn builds a peer reading ndjson from r and writing ndjson to w.
func NewConn(r io.Reader, w io.Writer) *Conn {
return &Conn{
rawReader: r,
reader: bufio.NewReader(r),
w: w,
handlers: make(map[string]HandlerFunc),
notifiers: make(map[string]NotifyFunc),
@@ -114,18 +107,6 @@ func NewConn(r io.Reader, w io.Writer) *Conn {
}
}
// NewOwnedConn is like NewConn but declares that this Conn exclusively owns r:
// if r implements io.Closer, Serve closes it on context cancellation to
// interrupt an otherwise-idle blocking Read. Only pass a reader whose lifetime
// this Conn should control — e.g. the process's own stdin — never a reader a
// caller retains or shares elsewhere, since cancellation tears it down
// permanently for every consumer, not just this Conn.
func NewOwnedConn(r io.Reader, w io.Writer) *Conn {
c := NewConn(r, w)
c.readerCloser, _ = r.(io.Closer)
return c
}
// Handle registers a request handler for method.
func (c *Conn) Handle(method string, fn HandlerFunc) { c.handlers[method] = fn }
@@ -147,36 +128,17 @@ func (c *Conn) Serve(ctx context.Context) error {
c.wg.Wait()
}()
interruptible := newInterruptibleReader(ctx, c.rawReader, c.readerCloser)
reader := bufio.NewReader(interruptible)
for {
// One ndjson value per line. Framing per line (rather than streaming the
// decoder) keeps a single malformed line from making the whole connection
// unrecoverable — we report -32700 and continue.
//
// generation brackets this SPECIFIC ReadBytes call: bufio may invoke
// interruptible.Read zero or more times underneath it (zero when the
// answer is already sitting in bufio's own buffer — including a
// previously-buffered, not-yet-surfaced error: bufio can return a
// complete line with a nil error while quietly caching an error it
// received ALONGSIDE that line's bytes, which then surfaces on the VERY
// NEXT call with no further contact with interruptible.Read at all). The
// generation only advances inside interruptible.Read's own cancellation
// branch, so "unchanged across this call" is proof this call's outcome
// didn't pass through that branch — whether because it was answered from
// the buffer or because it raced ctx.Done() and won. Only that proof
// makes it safe to call the outcome genuine.
before := interruptible.generation()
line, err := reader.ReadBytes('\n')
interrupted := interruptible.generation() != before
line, err := c.reader.ReadBytes('\n')
if len(bytes.TrimSpace(line)) > 0 {
c.handleLine(ctx, line)
}
if err != nil {
c.failAllPending(err)
if errors.Is(err, io.EOF) || interrupted {
if errors.Is(err, io.EOF) || ctx.Err() != nil {
return nil
}
return err
@@ -184,100 +146,6 @@ func (c *Conn) Serve(ctx context.Context) error {
}
}
// interruptibleReader wraps a reader so a context cancellation can unblock a
// currently in-flight Read without ever discarding — or misattributing — the
// real outcome of a call that wasn't actually blocked.
//
// Each Read runs the underlying call on its own goroutine and races it against
// ctx.Done(). If the underlying call finishes first, its result is returned
// exactly as received; ctx being cancelled a moment later has no bearing on a
// call that already had its answer. If ctx.Done() fires first, closer (when
// non-nil) is closed to nudge a genuinely blocked read, the generation counter
// is bumped, and the call STILL waits for and returns the underlying call's own
// result — never a synthesized one — since closing a reader's outcome is
// whatever the reader itself reports for it, not ours to invent.
//
// The counter is what lets Serve tell a call that was actually raced against
// cancellation apart from one merely answered while cancellation happened to be
// pending — the distinction the read loop's own doc comment explains.
type interruptibleReader struct {
inner io.Reader
closer io.Closer
ctx context.Context
closeOnce sync.Once
gen atomic.Int64
}
func newInterruptibleReader(ctx context.Context, inner io.Reader, closer io.Closer) *interruptibleReader {
return &interruptibleReader{inner: inner, closer: closer, ctx: ctx}
}
func (r *interruptibleReader) generation() int64 { return r.gen.Load() }
type interruptibleReadResult struct {
n int
err error
}
// interruptibleReadDecision arbitrates whether an underlying Read completed or
// cancellation claimed it first. Completion is recorded before the result is
// published so cancellation cannot misclassify a finished read merely because
// its goroutine has not sent the result yet.
type interruptibleReadDecision struct {
once sync.Once
}
func (d *interruptibleReadDecision) complete() { d.once.Do(func() {}) }
func (d *interruptibleReadDecision) interrupt() (claimed bool) {
d.once.Do(func() { claimed = true })
return claimed
}
// readInner records completion in the Read call's deferred epilogue. It also
// converts a reader panic into a terminal error so the helper goroutine always
// publishes a result for Read to join.
func (r *interruptibleReader) readInner(p []byte, decision *interruptibleReadDecision) (res interruptibleReadResult) {
defer func() {
if recovered := recover(); recovered != nil {
res = interruptibleReadResult{err: fmt.Errorf("acp: reader panicked: %v", recovered)}
}
decision.complete()
}()
res.n, res.err = r.inner.Read(p)
return res
}
func (r *interruptibleReader) Read(p []byte) (int, error) {
resultCh := make(chan interruptibleReadResult, 1)
var decision interruptibleReadDecision
go func() {
resultCh <- r.readInner(p, &decision)
}()
select {
case res := <-resultCh:
return res.n, res.err
case <-r.ctx.Done():
// resultCh may not be readable yet even when inner.Read has returned: its
// goroutine can be descheduled between recording completion and publishing
// the result. Only interrupt a call that cancellation actually claims.
if decision.interrupt() {
r.gen.Add(1)
if r.closer != nil {
r.closeOnce.Do(func() { _ = r.closer.Close() })
}
}
// Wait for the real result rather than returning synthetically: p is
// still being written by the goroutine above until it does, so returning
// early would race that write, and the actual error (whatever closing
// the reader produced, or whatever the reader was already about to
// report) is what the caller should see.
res := <-resultCh
return res.n, res.err
}
}
// handleLine decodes and dispatches one ndjson frame. A parse failure replies
// with -32700 (id null) and keeps the connection alive.
func (c *Conn) handleLine(ctx context.Context, line []byte) {
-288
View File
@@ -3,30 +3,11 @@ package acp
import (
"context"
"encoding/json"
"errors"
"io"
"sync"
"sync/atomic"
"testing"
"time"
)
type testReadCloser struct {
read func([]byte) (int, error)
close func() error
}
func (r testReadCloser) Read(p []byte) (int, error) { return r.read(p) }
func (r testReadCloser) Close() error { return r.close() }
type testReader func([]byte) (int, error)
func (r testReader) Read(p []byte) (int, error) { return r(p) }
type testWriter func([]byte) (int, error)
func (w testWriter) Write(p []byte) (int, error) { return w(p) }
// connPair wires two Conns together over in-memory pipes and serves both.
func connPair(t *testing.T) (a, b *Conn, stop func()) {
t.Helper()
@@ -44,275 +25,6 @@ func connPair(t *testing.T) (a, b *Conn, stop func()) {
}
}
func TestConnServeCancellationInterruptsIdleRead(t *testing.T) {
pipeReader, writer := io.Pipe()
t.Cleanup(func() { _ = writer.Close() })
// Buffered + non-blocking send, not close: interruptibleReader retries each
// underlying Read on its own goroutine, so if bufio ever calls this more than
// once, a close() here would panic on the second call.
readStarted := make(chan struct{}, 1)
closeCalls := make(chan struct{}, 2)
reader := testReadCloser{
read: func(p []byte) (int, error) {
select {
case readStarted <- struct{}{}:
default:
}
return pipeReader.Read(p)
},
close: func() error {
closeCalls <- struct{}{}
return pipeReader.Close()
},
}
conn := NewOwnedConn(reader, io.Discard)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- conn.Serve(ctx) }()
<-readStarted
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("Serve returned %v after cancellation, want nil", err)
}
case <-time.After(time.Second):
t.Fatal("Serve did not return after cancelling an idle connection")
}
if got := len(closeCalls); got != 1 {
t.Fatalf("reader Close calls = %d, want 1", got)
}
}
func TestConnServePreservesTerminalReadErrorDuringCancellation(t *testing.T) {
for _, closable := range []bool{false, true} {
name := "non-closable"
if closable {
name = "closable"
}
t.Run(name, func(t *testing.T) {
wantErr := errors.New("read failed")
read := testReader(func(p []byte) (int, error) {
return copy(p, "not json"), wantErr
})
closeCalls := make(chan struct{}, 1)
var reader io.Reader = read
if closable {
reader = testReadCloser{
read: read,
close: func() error {
closeCalls <- struct{}{}
return nil
},
}
}
// Buffered + non-blocking send, not close: a later change adding a
// second write (e.g. an additional error frame) must not panic on a
// repeated close of an already-closed channel.
writeStarted := make(chan struct{}, 1)
releaseWrite := make(chan struct{})
writer := testWriter(func(p []byte) (int, error) {
select {
case writeStarted <- struct{}{}:
default:
}
<-releaseWrite
return len(p), nil
})
newConn := NewConn
if closable {
// Ownership matters here: this case exists to prove that even a
// Conn entitled to close its reader on cancellation still
// preserves a terminal error that was already decided.
newConn = NewOwnedConn
}
conn := newConn(reader, writer)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- conn.Serve(ctx) }()
<-writeStarted
cancel()
close(releaseWrite)
select {
case err := <-done:
if !errors.Is(err, wantErr) {
t.Fatalf("Serve returned %v, want terminal read error %v", err, wantErr)
}
case <-time.After(time.Second):
t.Fatal("Serve did not return after terminal read error")
}
if got := len(closeCalls); got != 0 {
t.Fatalf("reader Close calls = %d, want 0 after terminal read", got)
}
})
}
}
// TestConnServePreservesReadErrorBufferedAlongsideAPriorLine is the deterministic
// regression test for jatmn's #782 finding: a real, non-EOF ReadBytes failure can
// be DECIDED before cancellation ever happens, yet only SURFACE afterward, and
// must still be reported rather than swallowed as a clean shutdown.
//
// bufio.Reader can obtain data AND a terminal error from ONE underlying Read call
// (io.Reader explicitly permits returning (n>0, err) together). If a delimiter is
// found within that data, ReadBytes returns the line with a nil error for THIS
// call, but bufio caches the error internally and returns it — WITHOUT calling the
// underlying reader again — on the very next ReadBytes call. This test forces that
// exact sequence: the underlying reader hands back one complete, validly-framed
// line together with wantErr in a SINGLE call (asserted below to be its ONLY
// call), the loop dispatches that line through a synchronous path that blocks the
// read loop, cancellation is asserted to have happened before the loop attempts
// its next read, and only then is the block released. By the time the read loop
// asks for more input, cancellation is already in effect and the underlying
// reader is never touched again — so if Serve attributed the resulting error to
// cancellation merely because ctx was already done, it would incorrectly return
// nil. It must instead recognize that this read was answered entirely from
// bufio's own cache, never raced against ctx.Done(), and report wantErr.
func TestConnServePreservesReadErrorBufferedAlongsideAPriorLine(t *testing.T) {
wantErr := errors.New("read failed")
var readCalls atomic.Int32
read := testReader(func(p []byte) (int, error) {
readCalls.Add(1)
// An unsupported jsonrpc version with an id takes the SYNCHRONOUS
// writeError path in handleLine (not a dispatched goroutine), giving this
// test a reliable blocking point between processing this line and the
// loop's next ReadBytes call.
return copy(p, `{"jsonrpc":"1.0","id":1}`+"\n"), wantErr
})
writeStarted := make(chan struct{}, 1)
releaseWrite := make(chan struct{})
var releaseWriteOnce sync.Once
release := func() { releaseWriteOnce.Do(func() { close(releaseWrite) }) }
t.Cleanup(release)
writer := testWriter(func(p []byte) (int, error) {
select {
case writeStarted <- struct{}{}:
default:
}
<-releaseWrite
return len(p), nil
})
conn := NewConn(read, writer)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- conn.Serve(ctx) }()
// The synchronous write confirms the first (and, per the panic guard above,
// only) read has already returned and is being processed — cancelling now
// lands squarely in the gap between that read and the loop's next one, never
// racing an in-flight interruptibleReader.Read call.
select {
case <-writeStarted:
case <-time.After(time.Second):
t.Fatal("Serve did not reach the synchronous write")
}
cancel()
release()
select {
case err := <-done:
if !errors.Is(err, wantErr) {
t.Fatalf("Serve returned %v, want the buffered terminal read error %v", err, wantErr)
}
case <-time.After(time.Second):
t.Fatal("Serve did not return after the buffered terminal read error")
}
if got := readCalls.Load(); got != 1 {
t.Fatalf("underlying Read calls = %d, want exactly 1 (bufio must have served the second ReadBytes from its cache)", got)
}
}
// TestInterruptibleReaderPrefersDecidedResultOverCancellation is the regression
// test for jatmn's second #782 finding: interruptibleReader.Read's select
// between resultCh and ctx.Done() chooses pseudo-randomly when both are ready,
// so a real, already-decided outcome (e.g. a terminal transport error) could be
// misattributed to cancellation purely because ctx also happened to be done by
// the time the select ran. Each iteration lets the underlying Read return (so
// resultCh already holds the real outcome) before cancelling, maximizing the
// odds of landing exactly in that ambiguous window; the generation counter must
// never advance when the real, already-decided result is what gets returned.
func TestInterruptibleReaderPrefersDecidedResultOverCancellation(t *testing.T) {
wantErr := errors.New("read failed")
for i := 0; i < 100; i++ {
resultReady := make(chan struct{})
read := testReader(func(p []byte) (int, error) {
defer close(resultReady)
return copy(p, "x"), wantErr
})
ctx, cancel := context.WithCancel(context.Background())
r := newInterruptibleReader(ctx, read, nil)
readDone := make(chan interruptibleReadResult, 1)
go func() {
n, err := r.Read(make([]byte, 8))
readDone <- interruptibleReadResult{n, err}
}()
<-resultReady
time.Sleep(time.Millisecond) // bias resultCh's send ahead of cancel
cancel()
res := <-readDone
if !errors.Is(res.err, wantErr) {
t.Fatalf("iteration %d: Read returned %v, want %v", i, res.err, wantErr)
}
if got := r.generation(); got != 0 {
t.Fatalf("iteration %d: generation = %d, want 0 (a decided result must never be attributed to cancellation)", i, got)
}
cancel()
}
}
// TestInterruptibleReadCompletionWinsBeforeResultPublication exercises the
// actual inner-read wrapper at the handoff between the underlying Read and the
// helper goroutine's result publication. Cancellation must not reclassify a
// result returned by this wrapper as interrupted.
func TestInterruptibleReadCompletionWinsBeforeResultPublication(t *testing.T) {
wantErr := errors.New("read failed")
read := testReader(func(p []byte) (int, error) {
return copy(p, "x"), wantErr
})
r := newInterruptibleReader(context.Background(), read, nil)
var decision interruptibleReadDecision
readComplete := make(chan struct{})
resultCh := make(chan interruptibleReadResult)
go func() {
res := r.readInner(make([]byte, 8), &decision)
close(readComplete)
resultCh <- res
}()
<-readComplete
if decision.interrupt() {
t.Fatal("cancellation claimed a read that completed before result publication")
}
res := <-resultCh
if res.n != 1 || !errors.Is(res.err, wantErr) {
t.Fatalf("inner read result = (%d, %v), want (1, %v)", res.n, res.err, wantErr)
}
}
func TestConnServeReportsReaderPanicAsTerminalError(t *testing.T) {
read := testReader(func([]byte) (int, error) {
panic("boom")
})
conn := NewConn(read, io.Discard)
done := make(chan error, 1)
go func() { done <- conn.Serve(context.Background()) }()
select {
case err := <-done:
const want = "acp: reader panicked: boom"
if err == nil || err.Error() != want {
t.Fatalf("Serve returned %v, want %q", err, want)
}
case <-time.After(time.Second):
t.Fatal("Serve did not return after reader panic")
}
}
func TestConnRequestResponse(t *testing.T) {
a, b, stop := connPair(t)
defer stop()
@@ -5,29 +5,9 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
type normalizationRecordingShellTool struct {
calls []map[string]any
}
func (tool *normalizationRecordingShellTool) Name() string { return "bash" }
func (tool *normalizationRecordingShellTool) Description() string {
return "record normalized shell arguments"
}
func (tool *normalizationRecordingShellTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: true}
}
func (tool *normalizationRecordingShellTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionAllow}
}
func (tool *normalizationRecordingShellTool) Run(_ context.Context, args map[string]any) tools.Result {
tool.calls = append(tool.calls, cloneArgs(args))
return tools.Result{Status: tools.StatusOK}
}
// A shell call that sets sandbox_permissions: with_additional_permissions but
// omits (or malforms) additional_permissions can never succeed: the same
// validation runs again at grant time regardless of the user's decision. It
@@ -127,7 +107,9 @@ func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing
if !strings.Contains(result.Output, `{"network": {"enabled": true}}`) {
t.Fatalf("output = %q, want a concrete additional_permissions example", result.Output)
}
assertNamesOmitRemedy(t, "missing additional_permissions", result.Output)
if !strings.Contains(result.Output, "omit sandbox_permissions") {
t.Fatalf("output = %q, want guidance that the flag can simply be omitted", result.Output)
}
}
// A well-formed additional_permissions payload must still reach the normal
@@ -162,237 +144,3 @@ func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T)
t.Fatalf("OnPermissionRequest called %d times, want exactly 1 for a valid elevation request", promptCalls)
}
}
// runShellCall drives one shell tool call through the real registry (and so
// through Registry.RunWithOptions) exactly as the agent loop would.
func runShellCall(t *testing.T, root string, arguments string) ToolResult {
t.Helper()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedExecCommandTool(root, nil, nil))
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{ID: "c1", Name: "exec_command", Arguments: arguments},
PermissionModeUnsafe,
Options{Cwd: root},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
return result
}
// TestAdditionalPermissionsRejectionsConverge is the regression guard for
// #845/#847. A model that attaches a permission object to a command needing
// none used to be told only "set sandbox_permissions", which pushed it into the
// OTHER invalid shape (flag set, payload empty) and its "include a permission"
// error pushed it straight back — the reported loop that burned turns on
// `git branch --show-current`.
//
// The contract this pins is convergence, not tolerance: both shapes are still
// REJECTED (nothing here grants access), but each rejection must name the one
// follow-up call that works, and that follow-up must actually succeed.
func TestAdditionalPermissionsRejectionsConverge(t *testing.T) {
root := t.TempDir()
const readOnly = `"cmd":"git --version"`
// Step 1 of the reported loop: a permission object with no flag.
first := runShellCall(t, root, `{`+readOnly+`,"additional_permissions":{"network":{"enabled":true}}}`)
if first.Status != tools.StatusError {
t.Fatalf("unflagged additional_permissions status = %q, want error (rejecting is correct)", first.Status)
}
assertNamesOmitRemedy(t, "unflagged additional_permissions", first.Output)
// Step 2 of the reported loop: the model keeps the flag but empties the
// payload. This must not be the dead end it was.
second := runShellCall(t, root, `{`+readOnly+`,"sandbox_permissions":"with_additional_permissions"}`)
if second.Status != tools.StatusError {
t.Fatalf("flag without payload status = %q, want error", second.Status)
}
assertNamesOmitRemedy(t, "flag without payload", second.Output)
// The call both errors point at must actually work.
converged := runShellCall(t, root, `{`+readOnly+`}`)
if converged.Status != tools.StatusOK {
t.Fatalf("the call both errors instruct the model to make failed: status=%q output=%q", converged.Status, converged.Output)
}
}
// assertNamesOmitRemedy pins the actionable half of the error text: the message
// must spell out that BOTH fields can simply be dropped. "Set the flag" or
// "include a permission" alone is what made the two errors chase each other.
func assertNamesOmitRemedy(t *testing.T, label string, output string) {
t.Helper()
if !strings.Contains(output, "omit both sandbox_permissions and additional_permissions") {
t.Fatalf("%s error = %q, want it to say to omit both fields entirely", label, output)
}
if !strings.Contains(output, "retry") {
t.Fatalf("%s error = %q, want it to tell the model to retry", label, output)
}
}
// The empty-payload rejection carries the same remedy. It is pinned on the
// validator directly because normalizeNoopAdditionalPermissions absorbs the
// empty payload before executeToolCall reaches this branch;
// inlineAdditionalPermissionsProfile is still the shared source of truth that
// buildPermissionEvent and the grant path re-run, so its text must converge too.
func TestEmptyAdditionalPermissionsErrorNamesOmitRemedy(t *testing.T) {
args := map[string]any{
"cmd": "git --version",
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
"additional_permissions": map[string]any{
"network": map[string]any{"enabled": false},
"file_system": map[string]any{"read": []any{}, "write": []any{}},
},
}
_, requested, err := inlineAdditionalPermissionsProfile(args, t.TempDir())
if !requested {
t.Fatal("requested = false, want true for an explicit with_additional_permissions call")
}
if err == nil {
t.Fatal("an empty additional_permissions payload was accepted; it must still be rejected")
}
assertNamesOmitRemedy(t, "empty payload", err.Error())
}
// A provider that materializes every optional property may send
// "additional_permissions": null. That grants nothing and carries no flag, so it
// is the field omitted — rejecting it stalled an ordinary command behind an
// error the model could not act on.
func TestExecuteToolCallTreatsNullAdditionalPermissionsAsOmitted(t *testing.T) {
result := runShellCall(t, t.TempDir(), `{"cmd":"git --version","additional_permissions":null}`)
if result.Status != tools.StatusOK {
t.Fatalf("null additional_permissions was rejected: status=%q output=%q", result.Status, result.Output)
}
}
// A null payload alongside the flag must still be rejected, and must land on
// the same error as an absent payload rather than being quietly downgraded.
func TestExecuteToolCallRejectsFlaggedNullAdditionalPermissions(t *testing.T) {
result := runShellCall(t, t.TempDir(),
`{"cmd":"git --version","sandbox_permissions":"with_additional_permissions","additional_permissions":null}`)
if result.Status != tools.StatusError {
t.Fatalf("flagged null status = %q, want error", result.Status)
}
if !strings.Contains(result.Output, "no additional_permissions object was provided") {
t.Fatalf("output = %q, want the missing-payload error", result.Output)
}
assertNamesOmitRemedy(t, "flagged null", result.Output)
}
func TestNormalizeProviderExpandedEmptyAdditionalPermissions(t *testing.T) {
for _, mode := range []string{
string(tools.SandboxPermissionsUseDefault),
string(tools.SandboxPermissionsWithAdditionalPermissions),
} {
args := map[string]any{
"command": "./zero --version",
"sandbox_permissions": mode,
"additional_permissions": map[string]any{
"file_system": map[string]any{
"deny_read": []any{},
"entries": []any{},
"read": []any{},
"write": []any{},
},
"network": map[string]any{"enabled": false},
},
}
normalizeNoopAdditionalPermissions(args, t.TempDir(), nil)
if _, exists := args["additional_permissions"]; exists {
t.Fatalf("mode %q retained empty additional_permissions: %#v", mode, args)
}
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsUseDefault) {
t.Fatalf("mode %q normalized to %q, want use_default", mode, got)
}
}
}
func TestExecuteToolCallNormalizesProviderExpandedEmptyAdditionalPermissions(t *testing.T) {
tool := &normalizationRecordingShellTool{}
registry := tools.NewRegistry()
registry.Register(tool)
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"./zero --version","sandbox_permissions":"with_additional_permissions",` +
`"additional_permissions":{"file_system":{"deny_read":[],"entries":[],"read":[],"write":[]},"network":{"enabled":false}}}`,
},
PermissionModeAuto,
Options{Cwd: t.TempDir()},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusOK {
t.Fatalf("provider-expanded empty permissions were rejected: %s", result.Output)
}
if len(tool.calls) != 1 {
t.Fatalf("shell tool calls = %d, want 1", len(tool.calls))
}
args := tool.calls[0]
if _, exists := args["additional_permissions"]; exists {
t.Fatalf("executeToolCall retained empty additional_permissions: %#v", args)
}
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsUseDefault) {
t.Fatalf("sandbox mode = %q, want use_default", got)
}
}
func TestNormalizeAdditionalPermissionsPreservesRealRequest(t *testing.T) {
args := map[string]any{
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
"additional_permissions": map[string]any{
"network": map[string]any{"enabled": true},
},
}
normalizeNoopAdditionalPermissions(args, t.TempDir(), nil)
if _, exists := args["additional_permissions"]; !exists {
t.Fatal("non-empty additional_permissions was removed")
}
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsWithAdditionalPermissions) {
t.Fatalf("sandbox mode changed to %q", got)
}
}
func TestNormalizeAdditionalPermissionsRemovesWorkspaceReadAlreadyCoveredBySandbox(t *testing.T) {
root := t.TempDir()
engine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
})
args := map[string]any{
"command": "./zero --version",
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
"additional_permissions": map[string]any{
"file_system": map[string]any{
"entries": []any{
map[string]any{
"access": "read",
"path": map[string]any{
"type": "path",
"path": ".",
},
},
},
"deny_read": []any{},
"read": []any{},
"write": []any{},
},
"network": map[string]any{"enabled": false},
},
}
normalizeNoopAdditionalPermissions(args, root, engine)
if _, exists := args["additional_permissions"]; exists {
t.Fatalf("already-covered additional_permissions was retained: %#v", args)
}
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsUseDefault) {
t.Fatalf("sandbox mode changed to %q", got)
}
}
+20 -89
View File
@@ -38,9 +38,8 @@ const compactionTriggerRatio = 0.7
// transcript (and so tests can assert on it).
const summaryLabel = "[Summary of earlier conversation]"
// CompactionSummaryInstructions is the system prompt handed to both automatic
// and manually requested compaction summarizers.
const CompactionSummaryInstructions = "You are compacting a coding-assistant conversation to save context. " +
// summaryInstructions is the system prompt handed to the summarizer model.
const summaryInstructions = "You are compacting a coding-assistant conversation to save context. " +
"Write a dense, factual summary of the conversation so far. Preserve: the user's goals and explicit constraints; " +
"decisions made and why; files created or modified (with paths) and key code changes; commands run and their important " +
"results; and anything still in progress or unresolved. Omit pleasantries. Use terse bullet points. Do not invent details. " +
@@ -76,12 +75,6 @@ type CompactionResult struct {
// summary message also includes summaryLabel and any preserved structured
// state needed by later compactions.
SummaryText string
// ProjectedChars is the text content sent to the summarizer after semantic
// projection, excluding provider protocol framing.
ProjectedChars int
// Truncated reports whether projection dropped content to satisfy its
// bounded brief or previous-summary limits.
Truncated bool
// Compacted reports whether Messages contains an injected summary.
Compacted bool
}
@@ -223,11 +216,11 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
}, nil
}
summaryResult, err := SummarizeCompactionMessages(middle, opts.Summarize)
summary, err := opts.Summarize(middle)
if err != nil {
return CompactionResult{}, err
}
summary := summaryResult.SummaryText
summary = strings.TrimSpace(summary)
// Preserve structured state (active plan + loaded skills) from the elided
// middle verbatim, so it is not lost or paraphrased away by the prose summary.
@@ -245,53 +238,10 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
RemovedCount: len(middle),
PreservedCount: len(messages) - len(middle),
SummaryText: summary,
ProjectedChars: summaryResult.ProjectedChars,
Truncated: summaryResult.Truncated,
Compacted: true,
}, nil
}
// CompactionSummaryResult describes the shared summarization input and output.
type CompactionSummaryResult struct {
SummaryText string
ProjectedChars int
Truncated bool
}
// SummarizeCompactionMessages applies the shared semantic projection before
// invoking a caller-supplied summarizer. Automatic context-pressure compaction
// and manual session compaction both use this path.
func SummarizeCompactionMessages(messages []zeroruntime.Message, summarize func([]zeroruntime.Message) (string, error)) (CompactionSummaryResult, error) {
if summarize == nil {
return CompactionSummaryResult{}, errors.New("compaction requires a Summarize function")
}
projection := projectCompactionInput(messages)
summaryInput := projection.messages
if len(summaryInput) == 0 {
summaryInput = messages
}
summary, err := summarize(summaryInput)
if err != nil {
return CompactionSummaryResult{}, err
}
return CompactionSummaryResult{
SummaryText: strings.TrimSpace(summary),
ProjectedChars: compactionMessageChars(summaryInput),
Truncated: projection.truncated,
}, nil
}
func compactionMessageChars(messages []zeroruntime.Message) int {
total := 0
for _, message := range messages {
total += len(message.Content)
for _, call := range message.ToolCalls {
total += len(call.Name) + len(call.Arguments)
}
}
return total
}
// safeSuffixBoundary walks the preserve boundary backward (toward systemEnd) so
// the preserved suffix begins on a user or assistant message rather than a
// tool/tool_result message. A tool result with no preceding assistant tool call
@@ -381,8 +331,6 @@ type compactionState struct {
// but its token COST must still be counted so usage reports and budgets include it.
onUsage func(Usage)
task *taskState
planner *contextPlanner
trace *trace.Recorder
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
@@ -429,8 +377,6 @@ func newCompactionState(options Options, task *taskState) *compactionState {
preserveLast: options.CompactionPreserveLast,
onUsage: options.OnUsage,
task: task,
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow}),
trace: options.Trace,
}
return state
}
@@ -467,14 +413,6 @@ func (state *compactionState) maybeCompact(
// the bodies of old, large tool results (the model has already acted on
// them). If that brings us back under threshold, skip the paid summarizer
// entirely and preserve recent turns verbatim.
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
messages = pruned
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
if size <= state.threshold {
state.lowWaterMark = size
return messages
}
}
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
messages = pruned
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
@@ -486,7 +424,7 @@ func (state *compactionState) maybeCompact(
compacted, err := Compact(messages, CompactionOptions{
PreserveLast: state.preserveLast,
Summarize: state.summarizeClosure(ctx, provider),
Summarize: summarizeClosure(ctx, provider, state.onUsage),
taskState: state.task.snapshotForCompaction(messages),
})
if err != nil {
@@ -537,7 +475,7 @@ func (state *compactionState) recover(
result, compactErr := Compact(messages, CompactionOptions{
PreserveLast: state.preserveLast,
Summarize: state.summarizeClosure(ctx, provider),
Summarize: summarizeClosure(ctx, provider, state.onUsage),
taskState: state.task.snapshotForCompaction(messages),
})
if compactErr != nil {
@@ -571,12 +509,9 @@ func (state *compactionState) recover(
// provider call. The summary stream intentionally does NOT forward OnText (so
// compaction stays invisible on the user-facing surface), but it DOES forward
// OnUsage so the summarizer's token cost is still counted by usage/budgeting.
func (state *compactionState) summarizeClosure(ctx context.Context, provider Provider) func([]zeroruntime.Message) (string, error) {
if state.planner == nil {
state.planner = newContextPlanner(contextPlannerConfig{})
}
func summarizeClosure(ctx context.Context, provider Provider, onUsage func(Usage)) func([]zeroruntime.Message) (string, error) {
return func(toSummarize []zeroruntime.Message) (string, error) {
return summarizeWithPlanner(ctx, provider, toSummarize, state.onUsage, state.planner, state.trace)
return summarizeWithFallback(ctx, provider, toSummarize, onUsage)
}
}
@@ -588,11 +523,7 @@ func (state *compactionState) summarizeClosure(ctx context.Context, provider Pro
// Non-context-limit errors (and a single message that still won't fit) surface
// to the caller unchanged.
func summarizeWithFallback(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage)) (string, error) {
return summarizeWithPlanner(ctx, provider, messages, onUsage, newContextPlanner(contextPlannerConfig{}), nil)
}
func summarizeWithPlanner(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage), planner *contextPlanner, recorder *trace.Recorder) (string, error) {
summary, err := summarizeMessagesOnce(ctx, provider, messages, onUsage, planner, recorder)
summary, err := summarizeMessagesOnce(ctx, provider, messages, onUsage)
if err == nil {
return summary, nil
}
@@ -601,11 +532,11 @@ func summarizeWithPlanner(ctx context.Context, provider Provider, messages []zer
}
mid := len(messages) / 2
left, leftErr := summarizeWithPlanner(ctx, provider, messages[:mid], onUsage, planner, recorder)
left, leftErr := summarizeWithFallback(ctx, provider, messages[:mid], onUsage)
if leftErr != nil {
return "", leftErr
}
right, rightErr := summarizeWithPlanner(ctx, provider, messages[mid:], onUsage, planner, recorder)
right, rightErr := summarizeWithFallback(ctx, provider, messages[mid:], onUsage)
if rightErr != nil {
return "", rightErr
}
@@ -618,7 +549,7 @@ func summarizeWithPlanner(ctx context.Context, provider Provider, messages []zer
combined := strings.TrimSpace(left + "\n\n" + right)
reduced, reduceErr := summarizeMessagesOnce(ctx, provider, []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: combined},
}, onUsage, planner, recorder)
}, onUsage)
if reduceErr != nil {
if isContextLimitError(reduceErr.Error()) {
// Even the two combined partials don't fit (extreme): fall back to the
@@ -634,15 +565,15 @@ func summarizeWithPlanner(ctx context.Context, provider Provider, messages []zer
}
// summarizeMessagesOnce performs a single tool-less summarization call.
func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage), planner *contextPlanner, recorder *trace.Recorder) (string, error) {
requestMessages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: CompactionSummaryInstructions},
{Role: zeroruntime.MessageRoleUser, Content: "Summarize this conversation:\n\n" + renderTranscript(messages)},
func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage)) (string, error) {
request := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: summaryInstructions},
{Role: zeroruntime.MessageRoleUser, Content: "Summarize this conversation:\n\n" + renderTranscript(messages)},
},
// No tools: this is a plain text summarization call.
}
parts := systemPromptParts{prompt: CompactionSummaryInstructions, baseInstructions: CompactionSummaryInstructions}
plan := planner.planWithPromptParts(requestMessages, nil, "", parts)
recordContextPlanTrace(recorder, plan)
stream, err := provider.StreamCompletion(ctx, plan.Request)
stream, err := provider.StreamCompletion(ctx, request)
if err != nil {
return "", err
}
+2 -43
View File
@@ -43,14 +43,8 @@ func TestCompactMessagesReturnsMetadataForManualCompaction(t *testing.T) {
if result.SummaryText != "manual summary" {
t.Fatalf("SummaryText = %q, want trimmed summary", result.SummaryText)
}
if result.ProjectedChars != compactionMessageChars(captured) || result.ProjectedChars == 0 {
t.Fatalf("ProjectedChars = %d, want %d", result.ProjectedChars, compactionMessageChars(captured))
}
if result.Truncated {
t.Fatal("Truncated = true for a projection within its limits")
}
if len(captured) != 1 || !strings.Contains(captured[0].Content, "first question") || !strings.Contains(captured[0].Content, "second question") {
t.Fatalf("summarized projection = %#v, want intent from the non-preserved middle", captured)
if len(captured) != 3 || captured[0].Content != "first question" || captured[2].Content != "second question" {
t.Fatalf("summarized middle = %#v, want the three non-preserved non-system messages", captured)
}
if len(result.Messages) != 4 {
t.Fatalf("compacted message count = %d, want 4", len(result.Messages))
@@ -102,46 +96,11 @@ func TestCompactMessagesNoopReturnsUncompactedMetadata(t *testing.T) {
if result.SummaryText != "" {
t.Fatalf("SummaryText = %q, want empty", result.SummaryText)
}
if result.ProjectedChars != 0 || result.Truncated {
t.Fatalf("no-op projection metadata = (%d, %t), want zero values", result.ProjectedChars, result.Truncated)
}
if !reflect.DeepEqual(result.Messages, messages) {
t.Fatalf("Messages changed on no-op: %#v", result.Messages)
}
}
func TestCompactMessagesReturnsTruncatedProjectionMetadata(t *testing.T) {
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "system"}}
for index := range 20 {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: strings.Repeat("contextword ", 256) + string(rune('a'+index)),
})
}
messages = append(messages,
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "recent answer"},
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "latest question"},
)
var captured []zeroruntime.Message
result, err := CompactMessages(messages, CompactionOptions{
PreserveLast: 2,
Summarize: func(toSummarize []zeroruntime.Message) (string, error) {
captured = append([]zeroruntime.Message(nil), toSummarize...)
return "bounded summary", nil
},
})
if err != nil {
t.Fatal(err)
}
if !result.Compacted || !result.Truncated {
t.Fatalf("compaction metadata = compacted %t, truncated %t; want both true", result.Compacted, result.Truncated)
}
if result.ProjectedChars != compactionMessageChars(captured) || result.ProjectedChars == 0 {
t.Fatalf("ProjectedChars = %d, want %d", result.ProjectedChars, compactionMessageChars(captured))
}
}
func TestCompactMessagesPropagatesSummarizeError(t *testing.T) {
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
+12 -131
View File
@@ -102,7 +102,7 @@ type skillEntry struct {
body string
}
// recentEdits returns files mutated by editing calls in messages
// 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
@@ -113,7 +113,6 @@ type skillEntry struct {
// rather than an earlier, now-stale entry.
func recentEdits(messages []zeroruntime.Message) []skillEntry {
pathByID := map[string]string{}
noteByPath := map[string]string{}
sequence := make([]string, 0)
for _, message := range messages {
for _, call := range message.ToolCalls {
@@ -130,24 +129,12 @@ func recentEdits(messages []zeroruntime.Message) []skillEntry {
sequence = append(sequence, path)
}
}
for _, message := range messages {
if message.Role != zeroruntime.MessageRoleTool || len(message.ChangedFiles) == 0 {
continue
}
for _, path := range message.ChangedFiles {
path = strings.TrimSpace(path)
if path == "" {
continue
}
sequence = append(sequence, path)
noteByPath[path] = editNote(message.Content)
}
}
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
@@ -438,20 +425,15 @@ type preservedState struct {
}
type preservedTaskState struct {
Objective string `json:"objective"`
Status taskStatus `json:"status,omitempty"`
Pending int `json:"pending,omitempty"`
InProgress int `json:"in_progress,omitempty"`
Completed int `json:"completed,omitempty"`
Failed int `json:"failed,omitempty"`
VerificationPassed int `json:"verification_passed,omitempty"`
VerificationFailed int `json:"verification_failed,omitempty"`
VerificationOutcome Outcome `json:"verification_outcome,omitempty"`
Constraints []string `json:"constraints,omitempty"`
ChangedFiles []string `json:"changed_files,omitempty"`
UnresolvedFailures []taskFailureState `json:"unresolved_failures,omitempty"`
Approvals []taskApprovalState `json:"approvals,omitempty"`
Artifacts []taskArtifactState `json:"artifacts,omitempty"`
Objective string `json:"objective"`
Status taskStatus `json:"status,omitempty"`
Pending int `json:"pending,omitempty"`
InProgress int `json:"in_progress,omitempty"`
Completed int `json:"completed,omitempty"`
Failed int `json:"failed,omitempty"`
VerificationPassed int `json:"verification_passed,omitempty"`
VerificationFailed int `json:"verification_failed,omitempty"`
VerificationOutcome Outcome `json:"verification_outcome,omitempty"`
}
type preservedEdit struct {
@@ -486,12 +468,7 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
task := priorState.Task
if taskSnapshot != nil {
task = &preservedTaskState{
Objective: capTaskObjective(taskSnapshot.Objective),
Constraints: mergeBoundedComparable(priorTaskConstraints(priorState.Task), taskSnapshot.Constraints, maxTaskConstraints),
ChangedFiles: mergeBoundedComparable(priorTaskChangedFiles(priorState.Task), taskSnapshot.ChangedFiles, maxTaskEvidenceEntries),
UnresolvedFailures: mergeTaskFailures(priorTaskFailures(priorState.Task), taskSnapshot.UnresolvedFailures, taskSnapshot.ResolvedFailureKeys),
Approvals: mergeBoundedComparable(priorTaskApprovals(priorState.Task), taskSnapshot.Approvals, maxTaskEvidenceEntries),
Artifacts: mergeBoundedComparable(priorTaskArtifacts(priorState.Task), taskSnapshot.Artifacts, maxTaskEvidenceEntries),
Objective: capTaskObjective(taskSnapshot.Objective),
}
// Plan parity corroborates only the mutable task projection. The objective
// comes directly from the run prompt and is immutable, so it must survive
@@ -507,13 +484,6 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
task.VerificationOutcome = taskSnapshot.Verification.LastOutcome
}
}
freshConstraints := explicitConstraintsFromMessages(middle)
if len(freshConstraints) > 0 {
if task == nil {
task = &preservedTaskState{}
}
task.Constraints = mergeBoundedComparable(task.Constraints, freshConstraints, maxTaskConstraints)
}
// Plan: a fresh update_plan in middle is authoritative; otherwise carry the
// plan preserved by an earlier compaction.
@@ -547,95 +517,6 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
return summary
}
func explicitConstraintsFromMessages(messages []zeroruntime.Message) []string {
var constraints []string
for _, message := range messages {
if message.Role != zeroruntime.MessageRoleUser || strings.Contains(message.Content, preservedStateLabel) {
continue
}
if _, body := projectInstructionBlock(message.Content); body != "" {
continue
}
constraints = mergeBoundedComparable(constraints, extractExplicitConstraints(message.Content), maxTaskConstraints)
}
return constraints
}
func priorTaskConstraints(task *preservedTaskState) []string {
if task == nil {
return nil
}
return task.Constraints
}
func priorTaskChangedFiles(task *preservedTaskState) []string {
if task == nil {
return nil
}
return task.ChangedFiles
}
func priorTaskFailures(task *preservedTaskState) []taskFailureState {
if task == nil {
return nil
}
return task.UnresolvedFailures
}
func priorTaskApprovals(task *preservedTaskState) []taskApprovalState {
if task == nil {
return nil
}
return task.Approvals
}
func priorTaskArtifacts(task *preservedTaskState) []taskArtifactState {
if task == nil {
return nil
}
return task.Artifacts
}
func mergeTaskFailures(older, newer []taskFailureState, resolved []string) []taskFailureState {
resolvedSet := make(map[string]struct{}, len(resolved))
for _, key := range resolved {
resolvedSet[key] = struct{}{}
}
merged := make([]taskFailureState, 0, len(older)+len(newer))
for _, failure := range append(append([]taskFailureState(nil), older...), newer...) {
if _, ok := resolvedSet[failure.Key]; ok {
continue
}
withoutSameKey := make([]taskFailureState, 0, len(merged))
for _, existing := range merged {
if existing.Key != failure.Key {
withoutSameKey = append(withoutSameKey, existing)
}
}
merged = append(withoutSameKey, failure)
}
if len(merged) > maxTaskEvidenceEntries {
merged = merged[len(merged)-maxTaskEvidenceEntries:]
}
return merged
}
func mergeBoundedComparable[T comparable](older, newer []T, limit int) []T {
merged := make([]T, 0, len(older)+len(newer))
seen := make(map[T]struct{}, len(older)+len(newer))
for _, value := range append(append([]T(nil), older...), newer...) {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
merged = append(merged, value)
}
if len(merged) > limit {
merged = merged[len(merged)-limit:]
}
return merged
}
func capTaskObjective(objective string) string {
objective = strings.TrimSpace(objective)
if len(objective) <= maxTaskObjectiveBytes {
@@ -6,7 +6,6 @@ import (
"testing"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -65,9 +64,6 @@ func TestCompactPreservesBoundedTaskContext(t *testing.T) {
objective := strings.Repeat("世", maxTaskObjectiveBytes)
task := newTaskState(objective, nil)
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`})
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"patch":"*** Update File: internal/db.go"}`, toolResult: ToolResult{
Name: "apply_patch", Status: tools.StatusOK, Output: "Done!", ChangedFiles: []string{"internal/db.go"},
}})
messages := stateConversation()
compacted, err := Compact(messages, CompactionOptions{
PreserveLast: 2,
@@ -84,69 +80,6 @@ func TestCompactPreservesBoundedTaskContext(t *testing.T) {
if len(state.Task.Objective) > maxTaskObjectiveBytes || !utf8.ValidString(state.Task.Objective) {
t.Fatalf("objective was not safely bounded: %d bytes %q", len(state.Task.Objective), state.Task.Objective)
}
if len(state.Task.ChangedFiles) != 1 || state.Task.ChangedFiles[0] != "internal/db.go" {
t.Fatalf("changed files were not preserved: %#v", state.Task.ChangedFiles)
}
}
func TestCompactPreservesRuntimeEvidenceAcrossRepeatedCompaction(t *testing.T) {
task := newTaskState("Please keep the change focused.", nil)
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"cmd":"go test ./..."}`, toolResult: ToolResult{
Name: "exec_command", Status: tools.StatusError, Output: "Error: tests failed",
Meta: map[string]string{"spill_path": ".zero/artifacts/tests.txt"},
}})
task.observe(taskStateEvent{kind: taskStateEventPermission, permission: PermissionEvent{
ToolName: "exec_command", DecisionAction: PermissionDecisionAllowForSession, Scope: "/tmp",
}})
messages := stateConversation()
first, err := Compact(messages, CompactionOptions{
PreserveLast: 2,
Summarize: func([]zeroruntime.Message) (string, error) { return "FIRST", nil },
taskState: task.snapshotForCompaction(messages),
})
if err != nil {
t.Fatal(err)
}
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"cmd":"go test ./..."}`, toolResult: ToolResult{
Name: "exec_command", Status: tools.StatusError, Output: "Error: tests still fail with newer evidence",
Meta: map[string]string{"spill_path": ".zero/artifacts/tests.txt"},
}})
secondInput := append(append([]zeroruntime.Message{}, first...),
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "Never add background memory models."},
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "understood"},
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "continue"},
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
)
second, err := Compact(secondInput, CompactionOptions{
PreserveLast: 2,
Summarize: func([]zeroruntime.Message) (string, error) { return "SECOND", nil },
taskState: task.snapshotForCompaction(secondInput),
})
if err != nil {
t.Fatal(err)
}
state := parsePreservedStateBlock(second[1].Content)
if state.Task == nil || len(state.Task.UnresolvedFailures) != 1 || len(state.Task.Approvals) != 1 || len(state.Task.Artifacts) != 1 {
t.Fatalf("runtime evidence did not survive repeated compaction: %#v", state.Task)
}
if got := state.Task.UnresolvedFailures[0].Summary; got != "Error: tests still fail with newer evidence" {
t.Fatalf("newer failure evidence did not replace the older summary: %#v", state.Task.UnresolvedFailures)
}
for _, want := range []string{"Please keep the change focused.", "Never add background memory models."} {
if !containsString(state.Task.Constraints, want) {
t.Fatalf("constraint %q missing after repeated compaction: %#v", want, state.Task.Constraints)
}
}
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func TestCompactPreservesObjectiveAfterPlanParityMismatch(t *testing.T) {
-315
View File
@@ -1,315 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const (
compactionUserWordBudget = 256
compactionAssistantWordBudget = 200
compactionToolCallsPerTurn = 8
compactionToolArgumentBytes = 120
compactionErrorBytes = 150
compactionResultBytes = 1200
compactionPreviousSummaryBytes = 16 * 1024
compactionBriefMaxBytes = 24 * 1024
)
var compactionWordPattern = regexp.MustCompile(`[\p{L}\p{N}]+`)
type compactionProjection struct {
messages []zeroruntime.Message
truncated bool
}
// projectCompactionInput removes reconstructible tool output before the model
// summary call. It retains a bounded transcript of user intent, assistant
// decisions, tool actions, and concise errors. Exact durable state is appended
// separately from the original messages after the summary returns.
func projectCompactionInput(messages []zeroruntime.Message) compactionProjection {
sections := make([]string, 0, len(messages))
previousSummary := ""
truncated := false
for index, message := range messages {
switch message.Role {
case zeroruntime.MessageRoleUser:
content := message.Content
if marker := strings.Index(content, preservedStateLabel); marker >= 0 {
content = content[:marker]
}
trimmed := strings.TrimSpace(content)
if strings.HasPrefix(trimmed, summaryLabel) {
fullSummary := strings.TrimSpace(strings.TrimPrefix(trimmed, summaryLabel))
previousSummary = clipHeadTailBytes(fullSummary, compactionPreviousSummaryBytes)
truncated = truncated || len(fullSummary) > compactionPreviousSummaryBytes
continue
}
if content = clipInformativeWords(content, compactionUserWordBudget); content != "" {
sections = append(sections, fmt.Sprintf("[user #%d]\n%s", index, content))
}
case zeroruntime.MessageRoleAssistant:
lines := make([]string, 0, 1+len(message.ToolCalls))
if content := clipInformativeWords(message.Content, compactionAssistantWordBudget); content != "" {
lines = append(lines, content)
}
calls := message.ToolCalls
omitted := 0
if len(calls) > compactionToolCallsPerTurn {
omitted = len(calls) - compactionToolCallsPerTurn
calls = calls[omitted:]
}
if omitted > 0 {
lines = append(lines, fmt.Sprintf("* (%d earlier tool calls omitted)", omitted))
}
for _, call := range calls {
lines = append(lines, compactionToolCallLine(call))
}
if len(lines) > 0 {
sections = append(sections, fmt.Sprintf("[assistant #%d]\n%s", index, strings.Join(lines, "\n")))
}
case zeroruntime.MessageRoleTool:
// New tool-result messages carry the execution status directly, as the
// source ToolResult does. Keep the text check for older session history
// created before Message exposed IsError.
name := toolNameForResult(messages, index)
mustPreserve := name == "ask_user" || len(message.ChangedFiles) > 0
if !mustPreserve && !message.IsError && !isLikelyToolError(message.Content) {
continue
}
if name == "" {
name = "tool"
}
switch {
case message.IsError || isLikelyToolError(message.Content):
sections = append(sections, fmt.Sprintf("[tool_error #%d] %s\n%s", index, name, clipBytes(firstLine(message.Content), compactionErrorBytes)))
case name == "ask_user":
sections = append(sections, fmt.Sprintf("[user_answer #%d] %s\n%s", index, name, clipBytes(message.Content, compactionResultBytes)))
default:
sections = append(sections, fmt.Sprintf("[tool_result #%d] %s changed %s\n%s", index, name,
clipBytes(strings.Join(message.ChangedFiles, ", "), compactionToolArgumentBytes), clipBytes(firstLine(message.Content), compactionErrorBytes)))
}
}
}
if len(sections) == 0 && previousSummary == "" {
return compactionProjection{}
}
brief, briefTruncated := capCompactionBrief(strings.Join(sections, "\n\n"))
truncated = truncated || briefTruncated
if previousSummary != "" {
brief = "[previous summary]\n" + previousSummary + "\n\n" + brief
}
return compactionProjection{
messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: strings.TrimSpace(brief)}},
truncated: truncated,
}
}
func capCompactionBrief(brief string) (string, bool) {
if len(brief) <= compactionBriefMaxBytes {
return brief, false
}
marker := "\n\n...[middle transcript omitted to fit compaction budget]...\n\n"
available := compactionBriefMaxBytes - len(marker)
// Keep both chronological edges: the oldest material has not necessarily
// been summarized before, while the newest material is most actionable.
headBytes := available * 2 / 5
tailBytes := available - headBytes
head := clipPrefixAtBoundary(brief, headBytes)
tail := clipSuffixAtBoundary(brief, tailBytes)
return strings.TrimSpace(head) + marker + strings.TrimSpace(tail), true
}
func compactionToolCallLine(call zeroruntime.ToolCall) string {
detail := ""
var arguments map[string]any
if json.Unmarshal([]byte(strings.TrimSpace(call.Arguments)), &arguments) == nil {
for _, key := range []string{"file_path", "path", "url", "query", "pattern", "prompt", "description", "question"} {
if value, ok := arguments[key].(string); ok && strings.TrimSpace(value) != "" {
detail = value
break
}
}
if detail == "" && call.Name == "ask_user" {
detail = askUserQuestionsDetail(arguments)
}
if detail == "" && call.Name == "apply_patch" {
if patch, ok := arguments["patch"].(string); ok {
detail = strings.Join(patchPaths(patch), ", ")
}
}
}
if detail == "" {
detail = commandFromArguments(call.Arguments)
}
if detail == "" {
return "* " + call.Name
}
return fmt.Sprintf("* %s %q", call.Name, clipBytes(detail, compactionToolArgumentBytes))
}
func isLikelyToolError(content string) bool {
prefix := strings.TrimSpace(content)
if len(prefix) > compactionErrorBytes {
prefix = prefix[:compactionErrorBytes]
}
lower := strings.ToLower(prefix)
for _, prefix := range []string{"error:", "tool error:", "failed:", "permission denied", "command failed"} {
if strings.HasPrefix(lower, prefix) {
return true
}
}
return false
}
func clipInformativeWords(text string, limit int) string {
flat := strings.Join(strings.Fields(text), " ")
if flat == "" || limit <= 0 {
return ""
}
count := 0
for _, location := range compactionWordPattern.FindAllStringIndex(flat, -1) {
word := strings.ToLower(flat[location[0]:location[1]])
if compactionStopWords[word] {
continue
}
count++
if count > limit {
end := location[0]
return strings.TrimSpace(flat[:end]) + "...(truncated)"
}
}
return flat
}
func clipBytes(text string, limit int) string {
text = strings.TrimSpace(text)
if limit <= 0 {
return ""
}
if len(text) <= limit {
return text
}
if limit <= 3 {
end := limit
for end > 0 && end < len(text) && text[end]&0xc0 == 0x80 {
end--
}
return text[:end]
}
end := limit - 3
for end > 0 && end < len(text) && text[end]&0xc0 == 0x80 {
end--
}
return strings.TrimSpace(text[:end]) + "..."
}
func clipHeadTailBytes(text string, limit int) string {
text = strings.TrimSpace(text)
if len(text) <= limit {
return text
}
if limit <= 0 {
return ""
}
marker := "\n...[middle omitted]...\n"
if limit <= len(marker) {
return clipBytes(text, limit)
}
available := limit - len(marker)
head := clipPrefixAtBoundary(text, available/2)
tail := clipSuffixAtBoundary(text, available-available/2)
return strings.TrimSpace(head) + marker + strings.TrimSpace(tail)
}
func clipPrefixAtBoundary(text string, limit int) string {
if limit <= 0 {
return ""
}
if len(text) <= limit {
return text
}
end := limit
for end > 0 && text[end]&0xc0 == 0x80 {
end--
}
if boundary := strings.LastIndex(text[:end], "\n\n["); boundary > 0 {
end = boundary
}
return text[:end]
}
func clipSuffixAtBoundary(text string, limit int) string {
if limit <= 0 {
return ""
}
if len(text) <= limit {
return text
}
start := len(text) - limit
for start < len(text) && text[start]&0xc0 == 0x80 {
start++
}
if boundary := strings.Index(text[start:], "\n\n["); boundary >= 0 {
start += boundary + 2
}
return text[start:]
}
func askUserQuestionsDetail(arguments map[string]any) string {
raw, ok := arguments["questions"].([]any)
if !ok {
return ""
}
questions := make([]string, 0, len(raw))
for _, item := range raw {
object, ok := item.(map[string]any)
if !ok {
continue
}
if question, ok := object["question"].(string); ok && strings.TrimSpace(question) != "" {
questions = append(questions, strings.TrimSpace(question))
}
}
return strings.Join(questions, " | ")
}
func patchPaths(patch string) []string {
var paths []string
seen := map[string]bool{}
for _, line := range strings.Split(patch, "\n") {
for _, prefix := range []string{"*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: "} {
if !strings.HasPrefix(line, prefix) {
continue
}
path := strings.TrimSpace(strings.TrimPrefix(line, prefix))
if path != "" && !seen[path] {
seen[path] = true
paths = append(paths, path)
}
}
}
return paths
}
var compactionStopWords = map[string]bool{
"a": true, "an": true, "the": true, "is": true, "are": true, "was": true, "were": true,
"be": true, "been": true, "being": true, "have": true, "has": true, "had": true,
"do": true, "does": true, "did": true, "will": true, "would": true, "could": true,
"should": true, "may": true, "might": true, "shall": true, "can": true, "need": true, "must": true,
"to": true, "of": true, "in": true, "for": true, "on": true, "with": true, "at": true,
"by": true, "from": true, "as": true, "into": true, "through": true, "during": true,
"before": true, "after": true, "above": true, "below": true, "between": true, "under": true, "over": true,
"and": true, "but": true, "or": true, "nor": true, "not": true, "so": true, "yet": true,
"both": true, "either": true, "neither": true, "each": true, "every": true, "all": true,
"any": true, "few": true, "more": true, "most": true, "other": true, "some": true, "such": true, "no": true,
"that": true, "this": true, "these": true, "those": true, "it": true, "its": true,
"i": true, "me": true, "my": true, "we": true, "our": true, "you": true, "your": true,
"he": true, "him": true, "his": true, "she": true, "her": true, "they": true, "them": true, "their": true,
"who": true, "which": true, "what": true, "if": true, "then": true, "than": true,
"when": true, "where": true, "how": true, "just": true, "also": true,
}
@@ -1,130 +0,0 @@
package agent
import (
"strings"
"testing"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestClipBytesHandlesTinyLimitsOnRuneBoundaries(t *testing.T) {
for _, limit := range []int{0, 1, 2, 3} {
if clipped := clipBytes("世界", limit); len(clipped) > limit || !utf8.ValidString(clipped) {
t.Fatalf("clipBytes limit %d returned invalid result %q", limit, clipped)
}
}
}
func TestBoundaryClippersHandleNonPositiveLimits(t *testing.T) {
for _, clip := range []func(string, int) string{clipPrefixAtBoundary, clipSuffixAtBoundary} {
for _, limit := range []int{-1, 0} {
if got := clip("世界", limit); got != "" {
t.Fatalf("clipper limit %d = %q, want empty", limit, got)
}
}
}
}
func TestProjectCompactionInputDropsRecoverableToolBodies(t *testing.T) {
largeBody := strings.Repeat("recoverable source line\n", 1000)
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: "Inspect the parser and preserve its current behavior."},
{Role: zeroruntime.MessageRoleAssistant, Content: "I will inspect before editing.", ToolCalls: []zeroruntime.ToolCall{{ID: "read", Name: "read_file", Arguments: `{"path":"internal/parser.go"}`}}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "read", Content: largeBody},
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "test", Name: "exec_command", Arguments: `{"cmd":"go test ./internal/parser"}`}}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "test", Content: "Error: parser regression\nlong diagnostic details"},
}
projection := projectCompactionInput(messages)
projected := projection.messages
if len(projected) != 1 {
t.Fatalf("projection = %#v, want one compact message", projected)
}
content := projected[0].Content
for _, want := range []string{"Inspect the parser", `read_file "internal/parser.go"`, `exec_command "go test ./internal/parser"`, "Error: parser regression"} {
if !strings.Contains(content, want) {
t.Fatalf("projection missing %q: %q", want, content)
}
}
if strings.Contains(content, "recoverable source line") || strings.Contains(content, "long diagnostic details") {
t.Fatalf("projection retained reconstructible or overlong tool output: %q", content)
}
if estimateTokens(projected)*20 >= estimateTokens(messages) {
t.Fatalf("projection did not materially reduce tokens: before=%d after=%d", estimateTokens(messages), estimateTokens(projected))
}
}
func TestProjectCompactionInputRetainsStructurallyMarkedToolErrors(t *testing.T) {
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "git", Name: "exec_command", Arguments: `{"cmd":"git status"}`}}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "git", Content: "fatal: not a git repository", IsError: true},
}
projected := projectCompactionInput(messages).messages
if len(projected) != 1 || !strings.Contains(projected[0].Content, "fatal: not a git repository") {
t.Fatalf("structured tool error was dropped from projection: %#v", projected)
}
}
func TestProjectCompactionInputRetainsAskUserExchangeAndSemanticArguments(t *testing.T) {
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "ask", Name: "ask_user", Arguments: `{"questions":[{"question":"Which database should remain?"}]}`}}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "ask", Content: "Which database should remain?: Postgres only; do not touch MySQL."},
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{ID: "patch", Name: "apply_patch", Arguments: `{"patch":"*** Begin Patch\n*** Update File: internal/db.go\n*** End Patch"}`},
{ID: "fetch", Name: "web_fetch", Arguments: `{"url":"https://example.com/spec"}`},
{ID: "task", Name: "task", Arguments: `{"prompt":"Inspect the Windows implementation"}`},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "patch", Content: "Done!", ChangedFiles: []string{"internal/db.go"}},
}
content := projectCompactionInput(messages).messages[0].Content
for _, want := range []string{"Which database should remain?", "Postgres only; do not touch MySQL", "internal/db.go", "https://example.com/spec", "Inspect the Windows implementation"} {
if !strings.Contains(content, want) {
t.Fatalf("projection missing %q: %q", want, content)
}
}
}
func TestProjectCompactionInputCapsToolCallsPerTurn(t *testing.T) {
calls := make([]zeroruntime.ToolCall, 12)
for index := range calls {
calls[index] = zeroruntime.ToolCall{Name: "read_file", Arguments: `{"path":"file.go"}`}
}
projected := projectCompactionInput([]zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: calls}}).messages
content := projected[0].Content
if strings.Count(content, "* read_file") != compactionToolCallsPerTurn || !strings.Contains(content, "4 earlier tool calls omitted") {
t.Fatalf("tool-call tail was not bounded: %q", content)
}
}
func TestProjectCompactionInputCarriesPreviousSummaryOutsideBriefCap(t *testing.T) {
previous := "keep the earlier architecture decision\n" + strings.Repeat("prior detail ", 2000) + "\nkeep the newest prior decision"
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: summaryLabel + "\n" + previous + "\n\n" + preservedStateLabel + "\n{}"}}
for index := range 500 {
messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("later transcript detail ", 20)})
messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "later request " + strings.Repeat("context ", 20) + string(rune('a'+index%26))})
}
projection := projectCompactionInput(messages)
content := projection.messages[0].Content
if !strings.Contains(content, "[previous summary]\nkeep the earlier architecture decision") || !strings.Contains(content, "keep the newest prior decision") {
t.Fatalf("previous summary was lost when the brief tail was capped: %q", content)
}
if !strings.Contains(content, "middle transcript omitted to fit compaction budget") {
t.Fatalf("oversized brief was not head/tail capped: %q", content)
}
if !projection.truncated {
t.Fatal("projection did not structurally report truncation")
}
}
func TestProjectCompactionInputDoesNotInferTruncationFromUserText(t *testing.T) {
projection := projectCompactionInput([]zeroruntime.Message{{
Role: zeroruntime.MessageRoleUser, Content: "Keep this literal marker: [middle omitted]",
}})
if projection.truncated {
t.Fatal("literal user text produced a false truncation signal")
}
}
@@ -20,15 +20,11 @@ func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
{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"},
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{ID: "e3", Name: "apply_patch", Arguments: `{"patch":"*** Update File: internal/baz.go"}`},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e3", Content: "Done!", ChangedFiles: []string{"internal/baz.go"}},
}
edits := recentEdits(messages)
if len(edits) != 3 {
t.Fatalf("expected 3 edited files, got %d: %#v", len(edits), edits)
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])
@@ -36,9 +32,6 @@ func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
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])
}
if edits[2].name != "internal/baz.go" || edits[2].body != "Done!" {
t.Fatalf("third edit = %#v, want apply_patch changed file with its note", edits[2])
}
}
// After compaction elides the editing turns, the preserved-state block still
+5 -108
View File
@@ -7,7 +7,6 @@ import (
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -59,15 +58,12 @@ func TestCompactKeepsSystemAndPreservedSuffix(t *testing.T) {
if last.Content != "most recent question" {
t.Fatalf("expected most recent message preserved, got %q", last.Content)
}
// The summarizer receives a compact semantic projection of the middle rather
// than reconstructible raw tool output.
if len(captured) != 1 || captured[0].Role != zeroruntime.MessageRoleUser {
t.Fatalf("expected one projected summary input, got %#v", captured)
// The summarized middle excludes system and preserved suffix.
if len(captured) != 3 {
t.Fatalf("expected 3 summarized messages, got %d: %#v", len(captured), captured)
}
for _, want := range []string{"[user #0]", "first question", "[assistant #1]", "first answer", "second question"} {
if !strings.Contains(captured[0].Content, want) {
t.Fatalf("projected summary input missing %q: %q", want, captured[0].Content)
}
if captured[0].Content != "first question" {
t.Fatalf("expected oldest non-system message first, got %#v", captured[0])
}
// Compaction must shrink the conversation.
if estimateTokens(result) >= estimateTokens(messages) {
@@ -383,43 +379,6 @@ func TestRunNoCompactionWhenContextWindowZero(t *testing.T) {
}
}
func TestMaybeCompactStopsAfterSupersededReadsReachThreshold(t *testing.T) {
body := strings.Repeat("same source line\n", 1000)
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
toolCallWithArgs("old", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "old", Content: body},
toolCallWithArgs("new", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "new", Content: body},
}
pruned, reclaimed := pruneSupersededReadResults(messages)
if reclaimed <= 0 {
t.Fatal("test setup did not reclaim superseded read output")
}
prunedSize := estimateTokens(pruned)
if estimateTokens(messages) <= prunedSize {
t.Fatal("test setup does not cross the compaction threshold")
}
provider := &summarizeRecordingProvider{}
state := &compactionState{
enabled: true,
threshold: prunedSize,
preserveLast: 2,
}
got := state.maybeCompact(context.Background(), provider, messages, nil)
if provider.summarizeCalls != 0 {
t.Fatalf("cheap pruning should avoid the summarizer, got %d calls", provider.summarizeCalls)
}
if state.lowWaterMark != prunedSize {
t.Fatalf("lowWaterMark = %d, want %d", state.lowWaterMark, prunedSize)
}
if !strings.Contains(got[2].Content, "superseded identical") || got[4].Content != body {
t.Fatalf("unexpected pruned messages: %#v", got)
}
}
// reactiveProvider builds up history with a tool call on turn 1, then errors
// with a context-limit message on turn 2 (once the history is large enough that
// compaction can shrink it), then succeeds on the same-turn retry. Summary
@@ -429,7 +388,6 @@ type reactiveProvider struct {
summarizeCalls int
turnRequests int
failedOnce bool
connectError bool
bigText string
finalText string
}
@@ -451,9 +409,6 @@ func (provider *reactiveProvider) StreamCompletion(ctx context.Context, request
return streamEvents(toolTurnWithText(provider.bigText, "1", "read_file", `{"path":"x"}`)), nil
case provider.turnRequests == 2 && !provider.failedOnce:
provider.failedOnce = true
if provider.connectError {
return nil, errors.New("context_length_exceeded")
}
return streamEvents([]zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventError, Error: "This model's maximum context length is 1000 tokens. Please reduce the length of the messages."},
}), nil
@@ -462,39 +417,6 @@ func (provider *reactiveProvider) StreamCompletion(ctx context.Context, request
}
}
func TestRunConnectErrorCompactionRecordsReplacementPlan(t *testing.T) {
provider := &reactiveProvider{
connectError: true,
bigText: strings.Repeat("b", 6000),
finalText: "recovered",
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
recorder := trace.NewRecorder("connect-compaction", "run-1", "test")
var contextPlans []ContextBreakdown
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
ContextWindow: 10_000_000,
CompactionPreserveLast: 2,
Trace: recorder,
OnContext: func(breakdown ContextBreakdown) {
contextPlans = append(contextPlans, breakdown)
},
})
if err != nil || result.FinalAnswer != "recovered" {
t.Fatalf("connect-error compaction failed: result=%#v err=%v", result, err)
}
if len(contextPlans) != 3 || contextPlans[2].MessageTokens >= contextPlans[1].MessageTokens {
t.Fatalf("replacement request context not reported: %#v", contextPlans)
}
gotTrace := recorder.Finish()
if len(gotTrace.PrefixHashes) != 4 || gotTrace.PrefixHashes[3].InvalidationReason != "unchanged" {
t.Fatalf("replacement request trace not recorded: %#v", gotTrace.PrefixHashes)
}
}
func TestRunReactiveCompactionRecovers(t *testing.T) {
provider := &reactiveProvider{
bigText: strings.Repeat("b", 6000),
@@ -506,8 +428,6 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
// bloats the history.
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
recorder := trace.NewRecorder("reactive-compaction", "run-1", "test")
var contextPlans []ContextBreakdown
// ContextWindow large enough that proactive compaction never triggers, so
// only the reactive path can save the run.
@@ -516,10 +436,6 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
PermissionMode: PermissionModeUnsafe,
ContextWindow: 10_000_000,
CompactionPreserveLast: 2,
Trace: recorder,
OnContext: func(breakdown ContextBreakdown) {
contextPlans = append(contextPlans, breakdown)
},
})
if err != nil {
t.Fatalf("expected reactive compaction to recover the run, got error: %v", err)
@@ -530,25 +446,6 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
if provider.summarizeCalls == 0 {
t.Fatal("expected reactive compaction to invoke the summarizer")
}
if len(contextPlans) != 3 {
t.Fatalf("main request context callbacks = %d, want initial turns plus compacted retry", len(contextPlans))
}
if contextPlans[2].MessageTokens >= contextPlans[1].MessageTokens {
t.Fatalf("retry context was not updated after compaction: before=%d after=%d", contextPlans[1].MessageTokens, contextPlans[2].MessageTokens)
}
gotTrace := recorder.Finish()
if len(gotTrace.PrefixHashes) != 4 {
t.Fatalf("prefix evidence count = %d, want two turns, compaction, and retry: %#v", len(gotTrace.PrefixHashes), gotTrace.PrefixHashes)
}
if gotTrace.PrefixHashes[0].InvalidationReason != "initial" ||
gotTrace.PrefixHashes[1].InvalidationReason != "unchanged" ||
gotTrace.PrefixHashes[2].InvalidationReason != "initial" ||
gotTrace.PrefixHashes[3].InvalidationReason != "unchanged" {
t.Fatalf("unexpected request-specific invalidation evidence: %#v", gotTrace.PrefixHashes)
}
if gotTrace.PrefixHashes[2].CompletePrefixHash == gotTrace.PrefixHashes[1].CompletePrefixHash {
t.Fatalf("compaction fingerprint reused the main request prefix: %#v", gotTrace.PrefixHashes)
}
}
// midStreamReactiveProvider forwards some text BEFORE surfacing a context-limit
-36
View File
@@ -23,24 +23,6 @@ type ContextBreakdown struct {
TotalTokens int // SystemTokens + ToolTokens + MessageTokens
ContextWindow int // model context window; 0 when unknown
UsedFraction float64 // TotalTokens / ContextWindow; 0 when window unknown
// Blocks explain why each model-visible category is present without exposing
// its content. The planner currently preserves all three categories.
Blocks []ContextBlock
// CompletePrefixHash identifies the provider-visible system+tool prefix.
CompletePrefixHash string
// PrefixInvalidationReason is "initial", "unchanged", or a stable comma-
// separated list of changed prefix categories.
PrefixInvalidationReason string
}
// ContextBlock is content-free evidence for one model-visible context category.
type ContextBlock struct {
Kind string
Tokens int
CacheClass string
Authority string
Recoverable bool
Reason string
}
// MeasureContext estimates the per-category token footprint of a request: the
@@ -59,24 +41,6 @@ func MeasureContext(messages []zeroruntime.Message, tools []zeroruntime.ToolDefi
ContextWindow: contextWindow,
}
breakdown.TotalTokens = breakdown.SystemTokens + breakdown.ToolTokens + breakdown.MessageTokens
if breakdown.SystemTokens > 0 {
breakdown.Blocks = append(breakdown.Blocks, ContextBlock{
Kind: "system", Tokens: breakdown.SystemTokens, CacheClass: "stable_prefix",
Authority: "required", Reason: "instructions, workspace policy, and safety policy",
})
}
if breakdown.ToolTokens > 0 {
breakdown.Blocks = append(breakdown.Blocks, ContextBlock{
Kind: "tools", Tokens: breakdown.ToolTokens, CacheClass: "stable_prefix",
Authority: "capability", Reason: "currently permitted and selected tool definitions",
})
}
if breakdown.MessageTokens > 0 {
breakdown.Blocks = append(breakdown.Blocks, ContextBlock{
Kind: "conversation", Tokens: breakdown.MessageTokens, CacheClass: "dynamic_tail",
Authority: "transcript", Reason: "valid provider replay history and current task context",
})
}
if contextWindow > 0 {
breakdown.UsedFraction = float64(breakdown.TotalTokens) / float64(contextWindow)
}
@@ -32,9 +32,6 @@ func TestMeasureContextSplitsByCategory(t *testing.T) {
if breakdown.UsedFraction != wantFraction {
t.Fatalf("UsedFraction = %v, want %v", breakdown.UsedFraction, wantFraction)
}
if len(breakdown.Blocks) != 3 {
t.Fatalf("Blocks = %#v, want system/tools/conversation", breakdown.Blocks)
}
}
func TestMeasureContextUnknownWindowHasZeroFraction(t *testing.T) {
-183
View File
@@ -1,183 +0,0 @@
package agent
import (
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type contextPlannerConfig struct {
contextWindow int
promptCacheKey string
promptParts systemPromptParts
}
// contextPlanner is the single seam for constructing model-visible requests.
// Planning is deterministic and does not retrieve content, execute tools, or
// change permissions. The initial implementation deliberately preserves every
// message and tool definition while making composition and cache drift
// inspectable; later selection policies must continue to cross this seam.
type contextPlanner struct {
config contextPlannerConfig
previousPrefix prefixFingerprint
hasPrevious bool
toolSnapshotKey string
toolSnapshot []zeroruntime.ToolDefinition
}
type contextPlan struct {
Request zeroruntime.CompletionRequest
Breakdown ContextBreakdown
PrefixFingerprint prefixFingerprint
}
func newContextPlanner(config contextPlannerConfig) *contextPlanner {
return &contextPlanner{config: config}
}
// Plan returns a provider request snapshot plus content-free accounting.
// It intentionally performs no relevance filtering: preserving current model
// capability is the baseline contract for future planner policies.
func (planner *contextPlanner) Plan(messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, reasoningEffort string) contextPlan {
return planner.plan(messages, toolDefs, reasoningEffort, planner.config.promptParts)
}
// planWithPromptParts plans a request whose stable system sections differ from
// the planner's configured main-run prompt, such as a compaction summary call.
func (planner *contextPlanner) planWithPromptParts(messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, reasoningEffort string, parts systemPromptParts) contextPlan {
return planner.plan(messages, toolDefs, reasoningEffort, parts)
}
func (planner *contextPlanner) plan(messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, reasoningEffort string, parts systemPromptParts) contextPlan {
requestMessages := copyMessages(messages)
requestSystemPrompt := leadingSystemContent(requestMessages)
if parts.prompt != requestSystemPrompt {
// Component boundaries from a different request would make the detailed
// invalidation reason misleading. Retain the exact request prefix and
// classify it conservatively as an unstructured system prompt instead.
parts = systemPromptParts{prompt: requestSystemPrompt}
}
fingerprint := computePrefixFingerprint(buildPromptSubstringsFromParts(parts, toolDefs))
request := zeroruntime.CompletionRequest{
Messages: requestMessages,
Tools: planner.snapshotTools(toolDefs, fingerprint),
ReasoningEffort: reasoningEffort,
PromptCacheKey: planner.config.promptCacheKey,
}
breakdown := MeasureContext(request.Messages, request.Tools, planner.config.contextWindow)
breakdown.CompletePrefixHash = fingerprint.CompletePrefixHash
var previous *prefixFingerprint
if planner.hasPrevious {
previous = &planner.previousPrefix
}
breakdown.PrefixInvalidationReason = explainPrefixChange(previous, fingerprint)
planner.previousPrefix = fingerprint
planner.hasPrevious = true
return contextPlan{
Request: request,
Breakdown: breakdown,
PrefixFingerprint: fingerprint,
}
}
// snapshotTools freezes provider-visible tool definitions when their semantic
// fingerprint changes. Stable turns reuse the frozen schemas instead of
// recursively cloning every map and slice for every request.
func (planner *contextPlanner) snapshotTools(toolDefs []zeroruntime.ToolDefinition, fingerprint prefixFingerprint) []zeroruntime.ToolDefinition {
key := fingerprint.ToolsHash + "\x00" + fingerprint.SchemaHash
if planner.toolSnapshotKey != key {
planner.toolSnapshot = copyToolDefinitions(toolDefs)
planner.toolSnapshotKey = key
}
return planner.toolSnapshot
}
func copyToolDefinitions(toolDefs []zeroruntime.ToolDefinition) []zeroruntime.ToolDefinition {
if toolDefs == nil {
return nil
}
copied := make([]zeroruntime.ToolDefinition, len(toolDefs))
for index, definition := range toolDefs {
copied[index] = definition
copied[index].Parameters = copySchemaMap(definition.Parameters)
}
return copied
}
func copySchemaMap(schema map[string]any) map[string]any {
if schema == nil {
return nil
}
copied := make(map[string]any, len(schema))
for key, value := range schema {
copied[key] = copySchemaValue(value)
}
return copied
}
func copySchemaValue(value any) any {
switch typed := value.(type) {
case map[string]any:
return copySchemaMap(typed)
case []any:
copied := make([]any, len(typed))
for index, item := range typed {
copied[index] = copySchemaValue(item)
}
return copied
case []string:
return append([]string(nil), typed...)
default:
return value
}
}
func leadingSystemContent(messages []zeroruntime.Message) string {
contents := make([]string, 0, 1)
for _, message := range messages {
if message.Role != zeroruntime.MessageRoleSystem {
break
}
contents = append(contents, message.Content)
}
return strings.Join(contents, "\n\n")
}
func explainPrefixChange(previous *prefixFingerprint, current prefixFingerprint) string {
if previous == nil {
return "initial"
}
if previous.CompletePrefixHash == current.CompletePrefixHash {
return "unchanged"
}
reasons := make([]string, 0, 3)
if previous.SystemPromptHash != current.SystemPromptHash {
parts := make([]string, 0, 4)
if previous.BaseInstructionsHash != current.BaseInstructionsHash {
parts = append(parts, "base_instructions")
}
if previous.ConfirmationPolicyHash != current.ConfirmationPolicyHash {
parts = append(parts, "confirmation_policy")
}
if previous.ProjectContextHash != current.ProjectContextHash {
parts = append(parts, "project_context")
}
if previous.SkillsHash != current.SkillsHash {
parts = append(parts, "skills")
}
if len(parts) == 0 {
parts = append(parts, "system_prompt")
}
reasons = append(reasons, parts...)
}
if previous.ToolsHash != current.ToolsHash {
reasons = append(reasons, "tools")
}
if previous.SchemaHash != current.SchemaHash {
reasons = append(reasons, "schema")
}
if len(reasons) == 0 {
return "prefix_changed"
}
return strings.Join(reasons, ",")
}
-185
View File
@@ -1,185 +0,0 @@
package agent
import (
"reflect"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestContextPlannerPreservesProviderRequest(t *testing.T) {
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
{Role: zeroruntime.MessageRoleUser, Content: "inspect this", Images: []zeroruntime.ImageBlock{{MediaType: "image/png", Data: []byte{1, 2, 3}}}},
{Role: zeroruntime.MessageRoleAssistant, Content: "working", ToolCalls: []zeroruntime.ToolCall{{ID: "call-1", Name: "read_file", Arguments: `{"path":"main.go"}`}}, Reasoning: []zeroruntime.ReasoningBlock{{Provider: "test", Type: "thinking", Signature: "sig"}}},
}
toolDefs := []zeroruntime.ToolDefinition{{
Name: "read_file", Description: "Read a file",
Parameters: map[string]any{
"type": "object",
"required": []string{"path"},
"properties": map[string]any{"path": map[string]any{"type": "string"}},
"anyOf": []any{map[string]any{"type": "object"}},
},
}}
planner := newContextPlanner(contextPlannerConfig{
contextWindow: 128_000,
promptCacheKey: "session-1",
promptParts: systemPromptParts{prompt: "system", baseInstructions: "system"},
})
plan := planner.Plan(messages, toolDefs, "medium")
want := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: toolDefs,
ReasoningEffort: "medium",
PromptCacheKey: "session-1",
}
if !reflect.DeepEqual(plan.Request, want) {
t.Fatalf("planned request changed provider input:\n got: %#v\nwant: %#v", plan.Request, want)
}
stable := planner.Plan(messages, toolDefs, "medium")
if &stable.Request.Tools[0] != &plan.Request.Tools[0] {
t.Fatal("stable tool fingerprint rebuilt the immutable schema snapshot")
}
// A plan owns its request snapshot; later caller mutation cannot change its
// messages or its recursively nested tool schema.
messages[1].Content = "changed"
messages[1].Images[0].Data[0] = 9
toolDefs[0].Name = "changed_tool"
toolDefs[0].Parameters["required"].([]string)[0] = "other"
toolDefs[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] = "integer"
toolDefs[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] = "array"
if plan.Request.Messages[1].Content != "inspect this" || plan.Request.Messages[1].Images[0].Data[0] != 1 {
t.Fatalf("planned messages alias caller state: %#v", plan.Request.Messages[1])
}
if plan.Request.Tools[0].Name != "read_file" ||
plan.Request.Tools[0].Parameters["required"].([]string)[0] != "path" ||
plan.Request.Tools[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] != "string" ||
plan.Request.Tools[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] != "object" {
t.Fatalf("planned tools alias caller state: %#v", plan.Request.Tools[0])
}
next := planner.Plan(messages, toolDefs, "medium")
if next.PrefixFingerprint == plan.PrefixFingerprint || next.Request.Tools[0].Name != "changed_tool" {
t.Fatalf("changed tool schema did not refresh snapshot: %#v", next)
}
if plan.Request.Tools[0].Name != "read_file" {
t.Fatalf("refresh mutated earlier request: %#v", plan.Request.Tools[0])
}
}
func TestContextPlannerReportsInspectableBlocks(t *testing.T) {
planner := newContextPlanner(contextPlannerConfig{contextWindow: 100_000})
plan := planner.Plan([]zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: filler(400)},
{Role: zeroruntime.MessageRoleUser, Content: filler(200)},
}, []zeroruntime.ToolDefinition{{Name: "read_file", Description: "Read a file"}}, "")
if plan.Breakdown.PrefixInvalidationReason != "initial" || plan.Breakdown.CompletePrefixHash == "" {
t.Fatalf("prefix evidence = %#v", plan.Breakdown)
}
if len(plan.Breakdown.Blocks) != 3 {
t.Fatalf("blocks = %#v, want system/tools/conversation", plan.Breakdown.Blocks)
}
wantKinds := []string{"system", "tools", "conversation"}
total := 0
for index, block := range plan.Breakdown.Blocks {
if block.Kind != wantKinds[index] || block.Tokens <= 0 || block.Reason == "" || block.Authority == "" || block.CacheClass == "" {
t.Fatalf("block %d = %#v", index, block)
}
total += block.Tokens
}
if total != plan.Breakdown.TotalTokens {
t.Fatalf("block tokens = %d, want total %d", total, plan.Breakdown.TotalTokens)
}
}
func TestContextPlannerFingerprintsActualRequestSystemPrompt(t *testing.T) {
planner := newContextPlanner(contextPlannerConfig{promptParts: systemPromptParts{
prompt: "configured system", baseInstructions: "configured system",
}})
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "request-specific system"},
{Role: zeroruntime.MessageRoleUser, Content: "task"},
}
plan := planner.Plan(messages, nil, "")
want := computePrefixFingerprint(buildPromptSubstringsFromParts(
systemPromptParts{prompt: "request-specific system"}, nil,
))
if plan.PrefixFingerprint != want {
t.Fatalf("fingerprint describes configured prompt instead of request:\n got: %#v\nwant: %#v", plan.PrefixFingerprint, want)
}
}
func TestContextPlannerExplainsPrefixInvalidation(t *testing.T) {
planner := newContextPlanner(contextPlannerConfig{promptParts: systemPromptParts{
prompt: "system", baseInstructions: "base", projectContext: "project",
}})
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "system"}, {Role: zeroruntime.MessageRoleUser, Content: "task"}}
tools := []zeroruntime.ToolDefinition{{Name: "read_file", Description: "Read", Parameters: map[string]any{"type": "object"}}}
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "initial" {
t.Fatalf("first reason = %q, want initial", got)
}
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "unchanged" {
t.Fatalf("stable reason = %q, want unchanged", got)
}
tools[0].Description = "Read an exact file"
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "tools" {
t.Fatalf("tool reason = %q, want tools", got)
}
tools[0].Parameters["required"] = []any{"path"}
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "schema" {
t.Fatalf("schema reason = %q, want schema", got)
}
planner.config.promptParts.prompt = "system with changed project"
planner.config.promptParts.projectContext = "changed project"
messages[0].Content = "system with changed project"
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "project_context" {
t.Fatalf("project reason = %q, want project_context", got)
}
}
func TestExplainPrefixChangeNamesEveryChangedComponent(t *testing.T) {
baseline := prefixFingerprint{
SystemPromptHash: "system",
BaseInstructionsHash: "base",
ConfirmationPolicyHash: "policy",
ProjectContextHash: "project",
SkillsHash: "skills",
ToolsHash: "tools",
SchemaHash: "schema",
CompletePrefixHash: "complete",
}
tests := []struct {
name string
mutate func(*prefixFingerprint)
want string
}{
{name: "base instructions", mutate: func(value *prefixFingerprint) {
value.SystemPromptHash, value.BaseInstructionsHash = "changed", "changed"
}, want: "base_instructions"},
{name: "confirmation policy", mutate: func(value *prefixFingerprint) {
value.SystemPromptHash, value.ConfirmationPolicyHash = "changed", "changed"
}, want: "confirmation_policy"},
{name: "project context", mutate: func(value *prefixFingerprint) {
value.SystemPromptHash, value.ProjectContextHash = "changed", "changed"
}, want: "project_context"},
{name: "skills", mutate: func(value *prefixFingerprint) { value.SystemPromptHash, value.SkillsHash = "changed", "changed" }, want: "skills"},
{name: "unclassified system prompt", mutate: func(value *prefixFingerprint) { value.SystemPromptHash = "changed" }, want: "system_prompt"},
{name: "tools and schema", mutate: func(value *prefixFingerprint) { value.ToolsHash, value.SchemaHash = "changed", "changed" }, want: "tools,schema"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
current := baseline
test.mutate(&current)
current.CompletePrefixHash = "changed"
if got := explainPrefixChange(&baseline, current); got != test.want {
t.Fatalf("reason = %q, want %q", got, test.want)
}
})
}
}
+6 -5
View File
@@ -341,15 +341,15 @@ func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
if search.Description != discovery || !strings.Contains(search.Description, "mcp") {
t.Fatalf("tool_search description must carry discovery source, got %q", search.Description)
}
if !strings.Contains(search.Description, "mcp__srv__alpha") {
t.Fatalf("stable tool_search catalog must retain already-loaded tool names, got %q", search.Description)
if strings.Contains(search.Description, "mcp__srv__alpha") {
t.Fatalf("tool_search description must not list already-loaded tool names, got %q", search.Description)
}
if !strings.Contains(search.Description, "mcp__srv__beta") {
t.Fatalf("tool_search description must list exact hidden tool names, got %q", search.Description)
}
}
func TestPartitionToolsActiveNothingHiddenKeepsStableCatalog(t *testing.T) {
func TestPartitionToolsActiveNothingHiddenEmptyReminder(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
@@ -368,8 +368,9 @@ func TestPartitionToolsActiveNothingHiddenKeepsStableCatalog(t *testing.T) {
if !exposedNames["tool_search"] {
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
}
if !strings.Contains(reminder, "mcp__srv__alpha") || !strings.Contains(reminder, "mcp__srv__beta") {
t.Fatalf("stable catalog must retain all deferred names after loading, got %q", reminder)
// No hidden tools means no dynamic discovery text is needed.
if reminder != "" {
t.Fatalf("expected empty discovery text when nothing is hidden, got %q", reminder)
}
}
+112 -257
View File
@@ -183,22 +183,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
defer runPermissions.cleanup()
promptParts := buildSystemPromptParts(options)
planner := newContextPlanner(contextPlannerConfig{
contextWindow: options.ContextWindow,
promptCacheKey: options.SessionID,
promptParts: promptParts,
})
messages := zeroruntime.SeedMessagesWithImages(promptParts.prompt, prompt, options.Images)
guards := newGuardState()
task := newTaskState(prompt, options.Trace)
onPermission := options.OnPermission
options.OnPermission = func(event PermissionEvent) {
task.observe(taskStateEvent{kind: taskStateEventPermission, permission: event})
if onPermission != nil {
onPermission(event)
}
}
compactor := newCompactionState(options, task)
defer func() {
// A final transcript comparison is observational. It records drift but
@@ -287,15 +275,42 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
exposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
toolPartitionSpan.End()
// Fingerprint the exact system prompt built at run start plus this turn's
// provider-visible tool definitions in their emitted order. Reusing the
// retained prompt parts avoids rebuilding workspace context while ensuring
// the trace describes the same bytes carried by the request.
if options.Trace != nil {
fp := computePrefixFingerprint(buildPromptSubstringsFromParts(promptParts, exposed))
options.Trace.EmitPrefixHash(trace.PrefixHash{
SystemPromptHash: fp.SystemPromptHash,
BaseInstructionsHash: fp.BaseInstructionsHash,
ConfirmationPolicyHash: fp.ConfirmationPolicyHash,
ProjectContextHash: fp.ProjectContextHash,
SkillsHash: fp.SkillsHash,
ToolsHash: fp.ToolsHash,
SchemaHash: fp.SchemaHash,
CompletePrefixHash: fp.CompletePrefixHash,
})
}
// PROACTIVE compaction: if the history is approaching the model's
// context window, summarize the oldest middle before building the
// request. A no-op when ContextWindow == 0 (compaction disabled).
compactionSpan := options.Trace.Span(trace.SpanCompaction)
messages = compactor.maybeCompact(ctx, provider, messages, exposed)
compactionSpan.End()
plan := planner.Plan(messages, exposed, options.ReasoningEffort)
request := plan.Request
recordContextPlan(options, plan)
request := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}
// Report the per-category context budget for this turn so a surface can
// show utilization. Opt-in: a no-op when OnContext is unset.
if options.OnContext != nil {
options.OnContext(MeasureContext(messages, request.Tools, options.ContextWindow))
}
// A transient upstream disconnect on the initial connect is retried with
// backoff (before any content is forwarded, so no OnText is duplicated);
@@ -320,9 +335,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// on registry+loaded, not on the messages, so they stay valid after
// compaction. Using the bare toolDefinitions here would route through an
// empty-loaded partition, re-hiding every already-loaded deferred tool.
retryPlan := planner.Plan(messages, exposed, options.ReasoningEffort)
request = retryPlan.Request
recordContextPlan(options, retryPlan)
request = zeroruntime.CompletionRequest{
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
// whole run and re-burn every token (AUDIT-L1).
@@ -406,9 +424,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// Reuse the SAME active-mode partition (exposed) from this turn rather
// than the bare toolDefinitions: exposed depends on registry+loaded (not
// the messages), so it stays valid after compaction.
retryPlan := planner.Plan(messages, exposed, options.ReasoningEffort)
retryRequest := retryPlan.Request
recordContextPlan(options, retryPlan)
retryRequest := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}
retryStream, retryStreamErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
if retryStreamErr != nil {
return collected, retryStreamErr
@@ -466,9 +487,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, err
}
retryPlan := planner.Plan(messages, exposed, options.ReasoningEffort)
retryRequest := retryPlan.Request
recordContextPlan(options, retryPlan)
retryRequest := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
if retryErr != nil {
result.Messages = copyMessages(messages)
@@ -632,13 +656,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// between tool_results breaks strict provider replay) — same after-batch
// rationale as turnRequestedModel above.
var changedFilesThisBatch []string
// Images produced by tools this turn, held until every tool_result is
// recorded. They travel as user messages, and a user message between two
// tool_results breaks strict provider replay — Anthropic coalesces them
// into one user block list and requires the tool_result blocks first, so
// interleaving yields [tool_result, text, image, tool_result] and a 400.
// Same reason the self-correction feedback below is deferred.
var toolImageMessages []zeroruntime.Message
// Parallel read-ahead state: results for calls[precomputedStart:precomputedEnd]
// executed concurrently, consumed strictly in order below.
var precomputed []precomputedToolResult
@@ -674,7 +691,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
options.Trace.Counter(trace.CounterToolCalls, 1)
recordOutputBudgetTrace(options.Trace, toolResult)
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: call.Arguments, toolResult: toolResult})
task.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: toolResult})
if options.OnToolResult != nil {
options.OnToolResult(toolResult)
}
@@ -688,22 +705,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
turnRequestedModel = toolResult.RequestedModel
}
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleTool,
Content: toolResult.Output,
ToolCallID: toolResult.ToolCallID,
IsError: toolResult.Status == tools.StatusError,
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
Role: zeroruntime.MessageRoleTool,
Content: toolResult.Output,
ToolCallID: toolResult.ToolCallID,
})
// Images ride a following USER message rather than the tool result
// above. Every provider drops images on a tool-role message —
// Anthropic's tool_result content is a string, Gemini's is a
// functionResponse, and OpenAI guards its image parts to the user role
// — so attaching them there would silently deliver nothing. A separate
// message also keeps the one-tool-result-per-tool-call pairing intact,
// which the providers validate.
if imageMessage, ok := toolResultImageMessage(toolResult); ok {
toolImageMessages = append(toolImageMessages, imageMessage)
}
// A tool may demand the run ABORT — a canceled/timed-out ask_user prompt
// returns context.Canceled rather than fabricating a headless answer. Stop
@@ -714,13 +719,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
if abortErr != nil {
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
messages = append(messages, toolImageMessages...)
result.Messages = copyMessages(messages)
return result, abortErr
}
if stopReason := stopReasonFromToolResult(toolResult); stopReason != "" {
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
messages = append(messages, toolImageMessages...)
result.FinalAnswer = toolResult.Output
result.StopReason = stopReason
result.Messages = copyMessages(messages)
@@ -744,7 +747,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// messages stay valid for a strict provider replay (Anthropic
// rejects a tool_use with no answering tool_result).
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
messages = append(messages, toolImageMessages...)
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count)
result.Messages = copyMessages(messages)
return result, nil
@@ -763,10 +765,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
postEditDiagnostics.enqueue(ctx, toolResult.ChangedFiles)
}
}
// Every tool_result for this turn is now recorded, including aborted
// placeholders, so the images can follow without splitting them.
messages = append(messages, toolImageMessages...)
toolImageMessages = nil
// Run post-edit self-correction once over the union of files this turn
// changed, then append any feedback after every tool_result is recorded so
@@ -898,7 +896,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
})
}
finalExposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, planner, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
result.FinalAnswer = answer
result.FinishReason = finishReason
result.Messages = copyMessages(finalMessages)
@@ -940,32 +938,7 @@ func recordOutputBudgetTrace(recorder *trace.Recorder, result ToolResult) {
})
}
func recordContextPlan(options Options, plan contextPlan) {
recordContextPlanTrace(options.Trace, plan)
if options.OnContext != nil {
options.OnContext(plan.Breakdown)
}
}
func recordContextPlanTrace(recorder *trace.Recorder, plan contextPlan) {
if recorder == nil {
return
}
fingerprint := plan.PrefixFingerprint
recorder.EmitPrefixHash(trace.PrefixHash{
SystemPromptHash: fingerprint.SystemPromptHash,
BaseInstructionsHash: fingerprint.BaseInstructionsHash,
ConfirmationPolicyHash: fingerprint.ConfirmationPolicyHash,
ProjectContextHash: fingerprint.ProjectContextHash,
SkillsHash: fingerprint.SkillsHash,
ToolsHash: fingerprint.ToolsHash,
SchemaHash: fingerprint.SchemaHash,
CompletePrefixHash: fingerprint.CompletePrefixHash,
InvalidationReason: plan.Breakdown.PrefixInvalidationReason,
})
}
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, planner *contextPlanner, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
finalMessages := copyMessages(messages)
finalMessages = append(finalMessages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
@@ -974,9 +947,12 @@ func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, planner *c
// The max-turns final-answer call is a pre-content connect, often after a long
// autonomous/cron run — route it through the reconnect helper so a single
// transient hiccup doesn't drop the final summary (AUDIT-L1).
plan := planner.Plan(finalMessages, toolDefs, options.ReasoningEffort)
recordContextPlan(options, plan)
stream, err := streamWithReconnect(ctx, provider, plan.Request, reconnectNoticeFor(options))
stream, err := streamWithReconnect(ctx, provider, zeroruntime.CompletionRequest{
Messages: copyMessages(finalMessages),
Tools: toolDefs,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}, reconnectNoticeFor(options))
if err != nil {
return "", messages, ""
}
@@ -1100,9 +1076,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}, nil
}
}
if isShellCommandTool(call.Name) {
normalizeNoopAdditionalPermissions(args, options.Cwd, options.Sandbox)
}
// tool_search is the gateway to the allowlisted deferred tools, so a non-empty
// EnabledTools allowlist that omits it must NOT reject the call — otherwise the
// discovery tool exposed to the model rejects at dispatch time (an inescapable
@@ -1381,8 +1354,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
Cwd: options.Cwd,
// 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,
DeferFileObservationCommit: true,
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
@@ -1434,7 +1406,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
result = registry.RebudgetAfterHook(call.Name, args, result)
}
}
result = registry.CommitFileObservation(result, options.FileTracker)
// Stamp the sandbox risk classification for this EXECUTED call so run-policy
// observers can see the risk of an allowed mutation. Prefer the preflight
// decision's classification when the sandbox evaluated the call; otherwise
@@ -1460,7 +1431,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
Output: result.Output,
Truncated: result.Truncated,
Meta: result.Meta,
Images: result.Images,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
ChangeSummaries: result.ChangeSummaries,
@@ -1647,15 +1617,9 @@ func sandboxRestrictedShellRetryCandidate(call ToolCall, args map[string]any, re
func sandboxDeniedShellResult(result tools.Result) bool {
if result.ExecutionOutcome != nil {
// Only a typed platform denial that explicitly requests unrestricted
// recovery may enter the legacy retry path. Narrow capability denials
// remain on their exact capability path.
denial := result.ExecutionOutcome.Denial
return denial != nil &&
denial.Source == execution.DenialSourcePlatformSandbox &&
denial.Capability.Kind == execution.CapabilityUnrestricted &&
denial.Recoverable &&
denial.NextAction == execution.DenialNextActionRequestApproval
// Typed denials must be handled by their exact capability path. Never
// turn a structured narrow denial into the legacy unrestricted retry.
return false
}
return result.Status == tools.StatusError && result.Meta[tools.SandboxLikelyDeniedMeta] == "true"
}
@@ -1720,21 +1684,20 @@ func sandboxRestrictionRetryReason(result tools.Result) string {
func runToolForNetworkRetry(ctx context.Context, registry *tools.Registry, name string, toolCallID string, args map[string]any, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) tools.Result {
return registry.RunWithOptions(ctx, name, cloneArgs(args), tools.RunOptions{
PermissionGranted: true,
PermissionMode: string(permissionMode),
Autonomy: options.Autonomy,
Sandbox: options.Sandbox,
ToolCallID: toolCallID,
SessionID: options.SessionID,
Model: options.Model,
ReasoningEffort: options.ReasoningEffort,
Depth: options.Depth,
Cwd: options.Cwd,
FileTracker: options.FileTracker,
DeferFileObservationCommit: true,
EnabledTools: options.EnabledTools,
DisabledTools: options.DisabledTools,
Progress: progressCallback,
PermissionGranted: true,
PermissionMode: string(permissionMode),
Autonomy: options.Autonomy,
Sandbox: options.Sandbox,
ToolCallID: toolCallID,
SessionID: options.SessionID,
Model: options.Model,
ReasoningEffort: options.ReasoningEffort,
Depth: options.Depth,
Cwd: options.Cwd,
FileTracker: options.FileTracker,
EnabledTools: options.EnabledTools,
DisabledTools: options.DisabledTools,
Progress: progressCallback,
})
}
@@ -1749,21 +1712,20 @@ func unsandboxedRetryArgs(args map[string]any) map[string]any {
func runToolForUnsandboxedRetry(ctx context.Context, registry *tools.Registry, name string, toolCallID string, args map[string]any, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) tools.Result {
return registry.RunWithOptions(ctx, name, args, tools.RunOptions{
PermissionGranted: true,
PermissionMode: string(permissionMode),
Autonomy: options.Autonomy,
Sandbox: options.Sandbox,
ToolCallID: toolCallID,
SessionID: options.SessionID,
Model: options.Model,
ReasoningEffort: options.ReasoningEffort,
Depth: options.Depth,
Cwd: options.Cwd,
FileTracker: options.FileTracker,
DeferFileObservationCommit: true,
EnabledTools: options.EnabledTools,
DisabledTools: options.DisabledTools,
Progress: progressCallback,
PermissionGranted: true,
PermissionMode: string(permissionMode),
Autonomy: options.Autonomy,
Sandbox: options.Sandbox,
ToolCallID: toolCallID,
SessionID: options.SessionID,
Model: options.Model,
ReasoningEffort: options.ReasoningEffort,
Depth: options.Depth,
Cwd: options.Cwd,
FileTracker: options.FileTracker,
EnabledTools: options.EnabledTools,
DisabledTools: options.DisabledTools,
Progress: progressCallback,
})
}
@@ -2780,89 +2742,21 @@ func shellCommandAdditionalPermissionsRequested(args map[string]any) bool {
return strings.TrimSpace(value) == string(tools.SandboxPermissionsWithAdditionalPermissions)
}
// normalizeNoopAdditionalPermissions treats a provider-expanded empty or
// already-covered additional_permissions object as if it had been omitted.
// Some strict-schema providers materialize every optional property and may add
// a workspace read that the default sandbox already grants; rejecting either
// no-op makes the model retry an otherwise valid command. Requests that broaden
// access and malformed payloads are left untouched so validation stays closed.
func normalizeNoopAdditionalPermissions(args map[string]any, basePath string, engine *sandbox.Engine) {
raw, exists := args["additional_permissions"]
if !exists {
return
}
// An explicit null is the field omitted, not a request: drop the key so the
// rest of the pipeline sees the same args a model that omitted it would send.
// sandbox_permissions is deliberately left alone — a flagged null must reach
// the same "no additional_permissions object was provided" error as a flagged
// absent field rather than being silently downgraded to a different outcome.
if raw == nil {
delete(args, "additional_permissions")
return
}
data, err := json.Marshal(raw)
if err != nil {
return
}
var profile sandbox.RequestPermissionProfile
if err := json.Unmarshal(data, &profile); err != nil {
return
}
normalized, err := sandbox.NormalizeRequestPermissionProfile(profile, basePath)
if err != nil {
return
}
noop := normalized.Empty()
if !noop && engine != nil {
if grantProfile, grantErr := sandbox.RequestPermissionGrantProfile(normalized); grantErr == nil {
noop = engine.CoversRequestPermissions(grantProfile)
}
}
if !noop {
return
}
delete(args, "additional_permissions")
if rawMode, ok := args["sandbox_permissions"].(string); ok &&
strings.TrimSpace(rawMode) == string(tools.SandboxPermissionsWithAdditionalPermissions) {
args["sandbox_permissions"] = string(tools.SandboxPermissionsUseDefault)
}
}
// omitAdditionalPermissionsHint is the one instruction that ends the retry loop
// in #845/#847. Every additional_permissions rejection must carry it: the model
// reaches these errors by attaching a permission object to a command that needs
// none, so "omit it and retry" is the answer in practice, and an error that
// only explains how to build a VALID permission object sends the model round
// the loop again with a different invalid shape.
const omitAdditionalPermissionsHint = "If this command does not need extra sandbox access — ordinary " +
"read-only commands such as `git branch --show-current` do not — omit both sandbox_permissions and " +
"additional_permissions entirely and retry; that is the fix in almost every case."
func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (sandbox.RequestPermissionProfile, bool, error) {
if !shellCommandAdditionalPermissionsRequested(args) {
// An explicit null grants nothing and carries no flag, so it is
// indistinguishable from an omitted field; rejecting it would be a pure
// false positive against providers that materialize every optional
// property.
if raw, exists := args["additional_permissions"]; exists && raw != nil {
return sandbox.RequestPermissionProfile{}, false, fmt.Errorf(
"additional_permissions was provided without sandbox_permissions set to %q, so it cannot be applied. %s "+
"Only if the command genuinely needs file or network access the sandbox denies, resend with "+
`sandbox_permissions: %q AND a non-empty additional_permissions, for example {"network": {"enabled": true}}`,
tools.SandboxPermissionsWithAdditionalPermissions,
omitAdditionalPermissionsHint,
tools.SandboxPermissionsWithAdditionalPermissions)
if _, exists := args["additional_permissions"]; exists {
return sandbox.RequestPermissionProfile{}, false, fmt.Errorf("additional_permissions requires sandbox_permissions set to %q", tools.SandboxPermissionsWithAdditionalPermissions)
}
return sandbox.RequestPermissionProfile{}, false, nil
}
raw, ok := args["additional_permissions"]
if !ok || raw == nil {
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
"sandbox_permissions was set to %q but no additional_permissions object was provided. %s "+
`Otherwise include one, for example additional_permissions: {"network": {"enabled": true}} or `+
`{"file_system": {"write": ["/path"]}}`,
tools.SandboxPermissionsWithAdditionalPermissions,
omitAdditionalPermissionsHint)
"sandbox_permissions was set to %q but no additional_permissions object was provided. "+
`Include one, for example additional_permissions: {"network": {"enabled": true}} or `+
`{"file_system": {"write": ["/path"]}}. If this command does not need elevated permissions, `+
"omit sandbox_permissions entirely and retry",
tools.SandboxPermissionsWithAdditionalPermissions)
}
data, err := json.Marshal(raw)
if err != nil {
@@ -2878,10 +2772,8 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
}
if normalized.Empty() {
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
"additional_permissions granted nothing, so it cannot be applied. %s "+
`Otherwise include a real permission, for example {"network": {"enabled": true}} or `+
`{"file_system": {"write": ["/path"]}}`,
omitAdditionalPermissionsHint)
"additional_permissions must include at least one of network or file_system, for example " +
`{"network": {"enabled": true}} or {"file_system": {"write": ["/path"]}}`)
}
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
if err != nil {
@@ -3040,20 +2932,22 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
// 2. tool_search — always present, right after the stable block;
// 3. loaded deferred tools, alpha-sorted — APPENDED, so an unloaded->loaded
// transition grows the tail instead of inserting into the eager block.
// tool_search's compact catalog also stays byte-stable as tools load, so only
// the appended full-schema tail changes.
// (tool_search's discovery text still shrinks as tools load, so keeping it and
// the loaded tail AFTER the eager block preserves the eager tools' cache across a
// load; fully stabilizing the loader's own description is a scoped follow-up.)
eager := make([]zeroruntime.ToolDefinition, 0, len(visible))
loadedTail := make([]zeroruntime.ToolDefinition, 0)
var deferredCatalog []tools.Tool
var hiddenTools []tools.Tool
for _, tool := range visible {
name := tool.Name()
if name == tools.ToolSearchToolName {
continue // added explicitly between the eager block and the loaded tail
}
if tools.IsDeferred(tool) {
deferredCatalog = append(deferredCatalog, tool)
if loaded[name] {
loadedTail = append(loadedTail, cachedRuntimeToolDefinition(defCache, tool))
} else {
hiddenTools = append(hiddenTools, tool)
}
continue
}
@@ -3066,16 +2960,15 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
return loadedTail[left].Name < loadedTail[right].Name
})
// Keep the catalog byte-stable as tools load. tool_search already redirects
// requests for an exposed tool back to the current tool list, so retaining
// loaded names costs only compact catalog lines and avoids invalidating the
// provider cache at the loader definition on every load.
discovery := tools.BuildToolSearchDescription(deferredCatalog)
discovery := ""
if len(hiddenTools) > 0 {
discovery = tools.BuildToolSearchDescription(hiddenTools)
}
// tool_search is guaranteed runnable on the active path, so it is ALWAYS exposed
// — even when a non-empty EnabledTools allowlist omits it (the operator
// allowlisted the deferred tools, not the loader). It sits right after the stable
// eager block and carries the stable deferred-tool catalog.
// eager block and carries the discovery list for still-hidden tools.
description := loader.Description()
if discovery != "" {
description = discovery
@@ -3278,7 +3171,6 @@ func appendAbortedToolResults(messages []Message, remaining []ToolCall) []Messag
Role: zeroruntime.MessageRoleTool,
Content: abortedToolResultNotice,
ToolCallID: call.ID,
IsError: true,
})
}
return messages
@@ -3309,46 +3201,9 @@ func copyMessages(messages []Message) []Message {
if message.Reasoning != nil {
copied[index].Reasoning = append([]zeroruntime.ReasoningBlock{}, message.Reasoning...)
}
if message.ChangedFiles != nil {
copied[index].ChangedFiles = append([]string(nil), message.ChangedFiles...)
}
// Deep-copy image attachments (slice AND each Data byte slice) so the
// raw image bytes are never aliased across history/request/result copies.
copied[index].Images = zeroruntime.CloneImageBlocks(message.Images)
}
return copied
}
// toolResultImageMessage builds the user message that carries a tool's images
// to the model, or reports false when the tool produced none.
//
// The text names the tool so the model can tell which call an image came from
// when several ran in one turn — the images arrive detached from their tool
// result, so nothing else associates them.
func toolResultImageMessage(result ToolResult) (zeroruntime.Message, bool) {
images := make([]zeroruntime.ImageBlock, 0, len(result.Images))
for _, image := range result.Images {
if len(image.Data) == 0 {
continue
}
mediaType := zeroruntime.NormalizeImageMediaType(image.MediaType)
if mediaType == "" {
// Outside the provider allow-list. Dropping it beats sending bytes a
// provider will reject and failing the whole turn.
continue
}
images = append(images, zeroruntime.ImageBlock{MediaType: mediaType, Data: image.Data})
}
if len(images) == 0 {
return zeroruntime.Message{}, false
}
label := result.Name
if label == "" {
label = "tool"
}
return zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: "Image output from " + label + ":",
Images: images,
}, true
}
+18 -112
View File
@@ -64,22 +64,6 @@ func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) {
t.Fatal("narrow protected-metadata denial must not become an unrestricted retry")
}
nonRecoverableDenial := protectedDenial
nonRecoverableDenial.ExecutionOutcome = &execution.Outcome{
State: execution.StateDenied,
Kind: execution.OutcomeEnforcementDenied,
Denial: &execution.Denial{
Capability: execution.Capability{Kind: execution.CapabilityUnrestricted, Scope: "host"},
Source: execution.DenialSourcePlatformSandbox,
Reason: "sandbox enforcement failed closed",
Recoverable: false,
NextAction: execution.DenialNextActionRequestApproval,
},
}
if sandboxRestrictedShellRetryCandidate(call, nil, nonRecoverableDenial, options) {
t.Fatal("non-recoverable sandbox denial must not become an unrestricted retry")
}
networkDenial := protectedDenial
networkDenial.ExecutionOutcome = &execution.Outcome{
State: execution.StateDenied,
@@ -304,18 +288,6 @@ func (tool *sandboxDeniedRetryTool) Run(_ context.Context, args map[string]any)
return tools.Result{
Status: tools.StatusError,
Output: "touch: cannot touch '/home/user/.npm/cache': Read-only file system",
ExecutionOutcome: &execution.Outcome{
State: execution.StateDenied,
Kind: execution.OutcomeEnforcementDenied,
Exit: &execution.Exit{Code: 1},
Denial: &execution.Denial{
Capability: execution.Capability{Kind: execution.CapabilityUnrestricted, Scope: "host"},
Source: execution.DenialSourcePlatformSandbox,
Reason: "sandbox blocked command execution",
Recoverable: true,
NextAction: execution.DenialNextActionRequestApproval,
},
},
Meta: map[string]string{
"exit_code": "1",
tools.SandboxLikelyDeniedMeta: "true",
@@ -332,8 +304,7 @@ type sandboxDeniedExecCommandRetryTool struct {
func (tool *sandboxDeniedExecCommandRetryTool) Name() string { return "exec_command" }
type sandboxNetworkDeniedRetryTool struct {
calls []map[string]any
deferFileObservationCommit []bool
calls []map[string]any
}
func (tool *sandboxNetworkDeniedRetryTool) Name() string { return "bash" }
@@ -356,7 +327,6 @@ func (tool *sandboxNetworkDeniedRetryTool) Run(ctx context.Context, args map[str
}
func (tool *sandboxNetworkDeniedRetryTool) RunWithOptions(ctx context.Context, args map[string]any, options tools.RunOptions) tools.Result {
tool.calls = append(tool.calls, cloneArgs(args))
tool.deferFileObservationCommit = append(tool.deferFileObservationCommit, options.DeferFileObservationCommit)
if shellNetworkAllowed(ctx, options.Sandbox, args) {
return tools.Result{Status: tools.StatusOK, Output: "server started"}
}
@@ -399,8 +369,7 @@ func agentNativeBackendStub() sandbox.Backend {
}
type sandboxNamespaceLimitedRetryTool struct {
calls []map[string]any
deferFileObservationCommit []bool
calls []map[string]any
}
func (tool *sandboxNamespaceLimitedRetryTool) Name() string { return "bash" }
@@ -419,12 +388,8 @@ func (tool *sandboxNamespaceLimitedRetryTool) Parameters() tools.Schema {
func (tool *sandboxNamespaceLimitedRetryTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs shell commands"}
}
func (tool *sandboxNamespaceLimitedRetryTool) Run(ctx context.Context, args map[string]any) tools.Result {
return tool.RunWithOptions(ctx, args, tools.RunOptions{})
}
func (tool *sandboxNamespaceLimitedRetryTool) RunWithOptions(_ context.Context, args map[string]any, options tools.RunOptions) tools.Result {
func (tool *sandboxNamespaceLimitedRetryTool) Run(_ context.Context, args map[string]any) tools.Result {
tool.calls = append(tool.calls, cloneArgs(args))
tool.deferFileObservationCommit = append(tool.deferFileObservationCommit, options.DeferFileObservationCommit)
if args["sandbox_permissions"] == string(tools.SandboxPermissionsRequireEscalated) {
return tools.Result{Status: tools.StatusOK, Output: "stdout:\nUSER PID COMMAND\nanaxy 42 firefox\nanaxy 43 Discord"}
}
@@ -490,9 +455,6 @@ func TestRunRetriesShellUnsandboxedAfterSandboxNamespaceLimitedOutput(t *testing
if retryTool.calls[1]["sandbox_permissions"] != string(tools.SandboxPermissionsRequireEscalated) {
t.Fatalf("retry args = %#v, want require_escalated", retryTool.calls[1])
}
if len(retryTool.deferFileObservationCommit) != 2 || !retryTool.deferFileObservationCommit[1] {
t.Fatalf("retry defer flags = %#v, want retry observation deferred", retryTool.deferFileObservationCommit)
}
if len(requests) != 1 {
t.Fatalf("permission requests = %#v, want one unsandboxed retry approval", requests)
}
@@ -644,9 +606,6 @@ func TestRunRetriesNetworkDeniedShellWithNetworkGrant(t *testing.T) {
if _, escalated := retryTool.calls[1]["sandbox_permissions"]; escalated {
t.Fatalf("network retry must stay sandboxed, got retry args %#v", retryTool.calls[1])
}
if len(retryTool.deferFileObservationCommit) != 2 || !retryTool.deferFileObservationCommit[1] {
t.Fatalf("retry defer flags = %#v, want retry observation deferred", retryTool.deferFileObservationCommit)
}
if len(requests) != 1 || requests[0].Reason != sandbox.ReasonNetworkBlocked {
t.Fatalf("permission requests = %#v, want one network approval", requests)
}
@@ -2445,11 +2404,7 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
root := t.TempDir()
command := "PATH=.:$PATH curl https://example.com"
if runtime.GOOS == "windows" {
if windowsTestUsesPowerShell() {
command = `$env:PATH = '.;' + $env:PATH; curl.cmd https://example.com`
} else {
command = "set PATH=.;%PATH% && curl https://example.com"
}
command = "set PATH=.;%PATH% && curl https://example.com"
fakeCurl := filepath.Join(root, "curl.cmd")
if err := os.WriteFile(fakeCurl, []byte("@echo fake curl %*\r\n"), 0o755); err != nil {
t.Fatal(err)
@@ -2570,17 +2525,13 @@ func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
func TestRunPromptsForDestructiveShellInsteadOfSandboxDeny(t *testing.T) {
root := t.TempDir()
command := "echo rm -rf /"
if runtime.GOOS == "windows" && windowsTestUsesPowerShell() {
command = `Write-Output 'rm -rf /'`
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedBashTool(root, nil))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":` + quoteJSONString(command) + `}`},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo rm -rf /"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
@@ -2676,12 +2627,6 @@ func TestRunAlwaysAllowWithoutSandboxStillAllowsCall(t *testing.T) {
}
}
func windowsTestUsesPowerShell() bool {
executable, _ := tools.HostShellCommand("")
name := strings.ToLower(filepath.Base(executable))
return strings.Contains(name, "powershell") || strings.HasPrefix(name, "pwsh")
}
func containsPermissionDecision(decisions []PermissionDecisionAction, want PermissionDecisionAction) bool {
for _, decision := range decisions {
if decision == want {
@@ -3215,17 +3160,22 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
t.Fatalf("expected operating system in environment block, got %q", prompt)
}
if runtime.GOOS == "windows" {
wants := []string{"workdir/cwd", "MSYS binaries"}
if windowsTestUsesPowerShell() {
wants = append(wants, "PowerShell", "Get-ChildItem", "Select-String", "$env:NAME", "require_escalated")
} else {
wants = append(wants, "cmd.exe", "double quotes")
}
for _, want := range wants {
for _, want := range []string{"Windows cmd.exe syntax", "cwd argument", "MSYS binaries", "grep", "require_escalated", "use double quotes around the value"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected selected Windows shell guidance to mention %q, got %q", want, prompt)
t.Fatalf("expected Windows shell guidance to mention %q, got %q", want, prompt)
}
}
// The examples must themselves use the safe (double-quoted) form; a
// single-quoted example here would teach the model the exact syntax
// that fails under cmd.exe.
for _, want := range []string{`--jq ".a | b"`, `-run "A|B"`} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected double-quoted example %q in Windows shell guidance, got %q", want, prompt)
}
}
if strings.Contains(prompt, `'.a | b'`) || strings.Contains(prompt, `'A|B'`) {
t.Fatalf("Windows shell guidance must not show the unsafe single-quoted form, got %q", prompt)
}
} else if !strings.Contains(prompt, "/bin/sh syntax") {
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
}
@@ -3592,11 +3542,6 @@ func TestRunAppendsAbortedPlaceholderForUnexecutedToolCallsOnGuardStop(t *testin
if !strings.Contains(strings.ToLower(placeholder), "aborted") {
t.Fatalf("expected the placeholder result to mark the call as aborted, got %q", placeholder)
}
for _, message := range result.Messages {
if message.ToolCallID == "flaky-2" && !message.IsError {
t.Fatalf("aborted placeholder must carry error status: %#v", message)
}
}
// Every tool_use in the final assistant message must have a matching result.
for _, message := range result.Messages {
@@ -3611,33 +3556,6 @@ func TestRunAppendsAbortedPlaceholderForUnexecutedToolCallsOnGuardStop(t *testin
}
}
func TestRunCarriesToolErrorStatusIntoMessageHistory(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(alwaysFailingTool{})
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "failed-call", ToolName: "flaky"},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "failed-call"},
{Type: zeroruntime.StreamEventDone},
},
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
}}
result, err := Run(context.Background(), "go", provider, Options{Registry: registry})
if err != nil {
t.Fatal(err)
}
for _, message := range result.Messages {
if message.ToolCallID == "failed-call" {
if !message.IsError {
t.Fatalf("failed tool result lost its structured status: %#v", message)
}
return
}
}
t.Fatalf("failed tool result missing from message history: %#v", result.Messages)
}
type secretEmittingTool struct{ output string }
func (t secretEmittingTool) Name() string { return "leak" }
@@ -3729,8 +3647,6 @@ func TestRunTracingWrapperStampsUsage(t *testing.T) {
{Type: zeroruntime.StreamEventDone},
}}}
onUsageCalls := 0
onContextCalls := 0
var contextPlan ContextBreakdown
rec := trace.NewRecorder("tracing-session", "run-1", "test")
if _, err := Run(context.Background(), "hi", provider, Options{
SessionID: "tracing-session",
@@ -3739,10 +3655,6 @@ func TestRunTracingWrapperStampsUsage(t *testing.T) {
Model: "test-model",
Trace: rec,
OnUsage: func(Usage) { onUsageCalls++ },
OnContext: func(breakdown ContextBreakdown) {
onContextCalls++
contextPlan = breakdown
},
}); err != nil {
t.Fatalf("Run: %v", err)
}
@@ -3769,12 +3681,6 @@ func TestRunTracingWrapperStampsUsage(t *testing.T) {
if onUsageCalls == 0 {
t.Fatal("wrapped OnUsage did not forward to the caller's callback")
}
if onContextCalls != 1 || len(contextPlan.Blocks) != 2 || contextPlan.PrefixInvalidationReason != "initial" {
t.Fatalf("context plan callback = calls %d, plan %#v", onContextCalls, contextPlan)
}
if len(tr.PrefixHashes) != 1 || tr.PrefixHashes[0].InvalidationReason != "initial" || tr.PrefixHashes[0].CompletePrefixHash != contextPlan.CompletePrefixHash {
t.Fatalf("trace context evidence = %#v, plan %#v", tr.PrefixHashes, contextPlan)
}
}
// TestRunNilTraceForwardsUsage verifies a nil recorder leaves the loop
@@ -3,7 +3,6 @@ package agent
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
@@ -20,16 +19,6 @@ type propagationOutputTool struct {
func TestOutputBudgetHookHelperProcess(t *testing.T) {
for index, arg := range os.Args {
if arg == "--zero-output-budget-hook-repeat" && index+1 < len(os.Args) {
repeats, err := strconv.Atoi(os.Args[index+1])
if err != nil {
os.Exit(2)
}
if _, err := os.Stdout.WriteString(strings.Repeat("hook feedback ", repeats)); err != nil {
os.Exit(2)
}
os.Exit(0)
}
if arg != "--zero-output-budget-hook" || index+1 >= len(os.Args) {
continue
}
@@ -41,102 +30,25 @@ func TestOutputBudgetHookHelperProcess(t *testing.T) {
}
func largeOutputBudgetHookDispatcher() *hooks.Dispatcher {
return outputBudgetHookDispatcherFor("propagation_output", 200)
}
func outputBudgetHookDispatcherFor(toolName string, repeats int) *hooks.Dispatcher {
feedback := strings.Repeat("hook feedback ", repeats)
hookArgs := []string{
"-test.run=TestOutputBudgetHookHelperProcess",
"--",
"--zero-output-budget-hook",
feedback,
}
if repeats > 1_000 {
hookArgs = []string{
"-test.run=TestOutputBudgetHookHelperProcess",
"--",
"--zero-output-budget-hook-repeat",
strconv.Itoa(repeats),
}
}
feedback := strings.Repeat("hook feedback ", 200)
return hooks.NewDispatcher(hooks.DispatcherOptions{Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{{
ID: "large-feedback",
Event: hooks.EventAfterTool,
Matcher: toolName,
Matcher: "propagation_output",
Command: os.Args[0],
Args: hookArgs,
Args: []string{
"-test.run=TestOutputBudgetHookHelperProcess",
"--",
"--zero-output-budget-hook",
feedback,
},
Enabled: true,
}},
}})
}
func TestExecuteToolCallCommitsReadObservationAfterFinalBoundary(t *testing.T) {
root := t.TempDir()
targetPath := filepath.Join(root, "target.txt")
if err := os.WriteFile(targetPath, []byte("known content\n"), 0o600); err != nil {
t.Fatal(err)
}
linkPath := filepath.Join(root, "small.txt")
if err := os.Symlink(targetPath, linkPath); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
resolvedPath, err := filepath.EvalSymlinks(linkPath)
if err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
tracker := tools.NewFileTracker()
result, abortErr := executeToolCall(context.Background(), registry, ToolCall{
ID: "read-small", Name: "read_file", Arguments: `{"path":"small.txt"}`,
}, PermissionModeAuto, Options{Cwd: root, FileTracker: tracker})
if abortErr != nil || result.Status != tools.StatusOK {
t.Fatalf("executeToolCall: result=%#v err=%v", result, abortErr)
}
if !tracker.SeenWhole(resolvedPath) {
t.Fatal("unmodified final read output was not committed as observed")
}
}
func TestExecuteToolCallDoesNotCommitReadObservationBeforeHookBudget(t *testing.T) {
t.Setenv("ZERO_TOOL_OUTPUT_CEILING_TOKENS", "80")
root := t.TempDir()
targetPath := filepath.Join(root, "target.txt")
if err := os.WriteFile(targetPath, []byte("known content\n"), 0o600); err != nil {
t.Fatal(err)
}
linkPath := filepath.Join(root, "small.txt")
if err := os.Symlink(targetPath, linkPath); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
resolvedPath, err := filepath.EvalSymlinks(linkPath)
if err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
tracker := tools.NewFileTracker()
result, abortErr := executeToolCall(context.Background(), registry, ToolCall{
ID: "read-hook", Name: "read_file", Arguments: `{"path":"small.txt"}`,
}, PermissionModeAuto, Options{
Cwd: root, FileTracker: tracker, Hooks: outputBudgetHookDispatcherFor("read_file", 20_000),
})
if abortErr != nil {
t.Fatalf("executeToolCall: %v", abortErr)
}
if !result.Truncated {
t.Fatalf("precondition: hook feedback was not truncated: %#v", result)
}
if tracker.SeenWhole(resolvedPath) {
t.Fatal("read observation committed before hook output was budgeted")
}
}
func (tool propagationOutputTool) Name() string { return "propagation_output" }
func (tool propagationOutputTool) Description() string { return "returns output for propagation tests" }
func (tool propagationOutputTool) Parameters() tools.Schema {
+1 -1
View File
@@ -91,7 +91,7 @@ func (c *profileController) maybeEscalate() (PostureEscalation, bool) {
if c.policy == nil || c.escalated {
return PostureEscalation{}, false
}
if !c.failureTripped && !c.riskTripped && !c.scTripped && !c.uncertainTrip {
if !(c.failureTripped || c.riskTripped || c.scTripped || c.uncertainTrip) {
return PostureEscalation{}, false
}
c.escalated = true
-15
View File
@@ -123,21 +123,6 @@ func TestComputePrefixFingerprintCoversEverySystemPromptSection(t *testing.T) {
}
}
func TestComputePrefixFingerprintCoversTransientSystemPrompt(t *testing.T) {
base := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
changed := base
changed.TransientSystemPrompt = "turn-only guidance"
fpBase := ComputePrefixFingerprint(base, nil)
fpChanged := ComputePrefixFingerprint(changed, nil)
if fpBase.SystemPromptHash == fpChanged.SystemPromptHash {
t.Fatal("SystemPromptHash must change for transient system guidance")
}
if fpBase.CompletePrefixHash == fpChanged.CompletePrefixHash {
t.Fatal("CompletePrefixHash must cover transient system guidance")
}
}
// TestBuildPromptSubstringsDefaultOptions asserts the invariants of the
// substrings helper for default Options: which substrings are non-empty,
// which are empty, and that the substring-to-hash round-trip is lossless.
-64
View File
@@ -121,67 +121,3 @@ func toolNameForResult(messages []zeroruntime.Message, index int) string {
func isPrunedPlaceholder(content string) bool {
return strings.HasPrefix(strings.TrimSpace(content), "[pruned ")
}
var supersedableReadTools = map[string]bool{
"read_file": true,
"read_minified_file": true,
"grep": true,
"glob": true,
"list_directory": true,
}
// pruneSupersededReadResults removes only older byte-identical results of the
// same read call. It runs when context pressure already requires a rewrite, so
// it does not trade a warm provider-cache prefix for a small apparent saving.
func pruneSupersededReadResults(messages []zeroruntime.Message) ([]zeroruntime.Message, int) {
type seenResultKey struct {
call string
content string
}
seen := map[seenResultKey]struct{}{}
out := messages
reclaimed := 0
copied := false
for index := len(messages) - 1; index >= 0; index-- {
message := messages[index]
if message.Role != zeroruntime.MessageRoleTool || isPrunedPlaceholder(message.Content) {
continue
}
call, ok := toolCallForResult(messages, index)
if !ok || !supersedableReadTools[call.Name] {
continue
}
key := seenResultKey{
call: call.Name + "\x00" + strings.TrimSpace(call.Arguments),
content: message.Content,
}
if _, exists := seen[key]; exists {
placeholder := fmt.Sprintf("[superseded identical %s result — the newer result remains in context]", call.Name)
if !copied {
out = append([]zeroruntime.Message(nil), messages...)
copied = true
}
out[index].Content = placeholder
reclaimed += max(0, ApproxTextTokens(message.Content)-ApproxTextTokens(placeholder))
continue
}
seen[key] = struct{}{}
}
return out, reclaimed
}
func toolCallForResult(messages []zeroruntime.Message, resultIndex int) (zeroruntime.ToolCall, bool) {
id := messages[resultIndex].ToolCallID
if id == "" {
return zeroruntime.ToolCall{}, false
}
for index := resultIndex - 1; index >= 0; index-- {
for _, call := range messages[index].ToolCalls {
if call.ID == id {
return call, true
}
}
}
return zeroruntime.ToolCall{}, false
}
-73
View File
@@ -25,13 +25,6 @@ func toolCallMsg(id, name string) zeroruntime.Message {
}
}
func toolCallWithArgs(id, name, args string) zeroruntime.Message {
return zeroruntime.Message{
Role: zeroruntime.MessageRoleAssistant,
ToolCalls: []zeroruntime.ToolCall{{ID: id, Name: name, Arguments: args}},
}
}
func TestPruneSkipsSmallSessions(t *testing.T) {
// Total reclaimable is under the gate → no pruning.
msgs := []zeroruntime.Message{
@@ -127,69 +120,3 @@ func TestPruneDoesNotMutateInput(t *testing.T) {
t.Fatal("pruneStaleToolOutput must not mutate the caller's slice")
}
}
func TestPruneSupersededReadResultsOnlyDropsIdenticalOlderReads(t *testing.T) {
body := strings.Repeat("same source line\n", 1000)
messages := []zeroruntime.Message{
toolCallWithArgs("old", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "old", Content: body},
toolCallWithArgs("different", "read_file", `{"path":"b.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "different", Content: body},
toolCallWithArgs("new", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "new", Content: body},
}
out, reclaimed := pruneSupersededReadResults(messages)
if reclaimed <= 0 || !strings.Contains(out[1].Content, "superseded identical") {
t.Fatalf("older duplicate was not pruned: reclaimed=%d output=%q", reclaimed, out[1].Content)
}
if out[3].Content != body || out[5].Content != body {
t.Fatal("different call and newest result must remain exact")
}
if messages[1].Content != body {
t.Fatal("input slice was mutated")
}
}
func TestPruneSupersededReadResultsKeepsChangedAndMutatingResults(t *testing.T) {
messages := []zeroruntime.Message{
toolCallWithArgs("r1", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "r1", Content: "version one"},
toolCallWithArgs("r2", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "r2", Content: "version two"},
toolCallWithArgs("e1", "edit_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "edited"},
toolCallWithArgs("e2", "edit_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "edited"},
}
out, reclaimed := pruneSupersededReadResults(messages)
if reclaimed != 0 {
t.Fatalf("unsafe results were pruned: %d", reclaimed)
}
for index := range messages {
if out[index].Content != messages[index].Content {
t.Fatalf("message %d changed unexpectedly", index)
}
}
}
func TestPruneSupersededReadResultsFindsMatchingBodyAcrossChangedRead(t *testing.T) {
bodyA := strings.Repeat("version a\n", 100)
bodyB := strings.Repeat("version b\n", 100)
messages := []zeroruntime.Message{
toolCallWithArgs("old-a", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "old-a", Content: bodyA},
toolCallWithArgs("middle-b", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "middle-b", Content: bodyB},
toolCallWithArgs("new-a", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "new-a", Content: bodyA},
}
out, reclaimed := pruneSupersededReadResults(messages)
if reclaimed <= 0 || !strings.Contains(out[1].Content, "superseded identical") {
t.Fatalf("older matching body was not pruned across a changed result: reclaimed=%d output=%q", reclaimed, out[1].Content)
}
if out[3].Content != bodyB || out[5].Content != bodyA {
t.Fatal("changed middle result and newest matching result must remain exact")
}
}
@@ -26,16 +26,6 @@ func TestSpecialistDelegationContext(t *testing.T) {
if strings.Contains(got, "nameless") {
t.Fatalf("nameless entry should be skipped: %q", got)
}
for _, want := range []string{"reduce total context", "Do not delegate small direct work", "bounded assignment"} {
if !strings.Contains(got, want) {
t.Fatalf("missing evidence-driven delegation guidance %q: %q", want, got)
}
}
for _, unwanted := range []string{"delegate to it proactively", "launch several specialists in parallel"} {
if strings.Contains(got, unwanted) {
t.Fatalf("retained broad delegation guidance %q: %q", unwanted, got)
}
}
}
func TestSystemPromptIncludesDelegationOnlyWithSpecialists(t *testing.T) {
+14 -12
View File
@@ -11,7 +11,6 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/repomap"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/workspaceseed"
)
@@ -95,9 +94,6 @@ func buildSystemPromptParts(options Options) systemPromptParts {
core = fallbackSystemPrompt
}
sections := []string{core}
if transient := strings.TrimSpace(options.TransientSystemPrompt); transient != "" {
sections = append(sections, transient)
}
if addendum := modelPromptAddendum(options.Model); addendum != "" {
sections = append(sections, addendum)
}
@@ -182,19 +178,21 @@ func approvedCommandPrefixContext(options Options) string {
return "## Approved Command Prefixes\n\nThe following command prefixes have already been approved and do not need another permission prompt:\n" + strings.Join(lines, "\n")
}
// specialistDelegationContext describes when a specialist earns its extra
// context and coordination cost. It renders only when specialists are known
// (which is only where the Task tool is actually registered), so a run with no
// delegatable specialists produces the previous prompt unchanged.
// specialistDelegationContext nudges the orchestrator to offload read-heavy or
// parallelizable work to a specialist sub-agent via the Task tool, keeping large
// tool outputs out of the main context. It renders only when specialists are
// known (which is only where the Task tool is actually registered), so a run with
// no delegatable specialists produces the previous prompt unchanged.
func specialistDelegationContext(options Options) string {
if len(options.Specialists) == 0 {
return ""
}
var b strings.Builder
b.WriteString("<specialists>\n")
b.WriteString("Use the Task tool when a specialist's focused context, expertise, or an independent parallel subtask is likely to improve quality or reduce total context. ")
b.WriteString("Do not delegate small direct work, duplicate exploration, or split sequential steps merely because a specialist is available. ")
b.WriteString("When delegating, give a bounded assignment and consume its concise evidence instead of importing a long transcript.\n")
b.WriteString("Delegate focused or read-heavy work to a specialist sub-agent with the Task tool instead of doing it inline. ")
b.WriteString("When a request matches a specialist's purpose, delegate to it proactively — you do not need the user to ask first. ")
b.WriteString("This keeps large tool outputs — searches, file dumps, multi-step exploration — out of your own context, so you stay fast and token-efficient. ")
b.WriteString("Prefer delegating codebase search and exploration; for independent subtasks, launch several specialists in parallel. Handle small, direct edits yourself.\n")
b.WriteString("Available specialists (call Task with the matching name when the task fits its purpose):\n")
for _, info := range options.Specialists {
name := strings.TrimSpace(info.Name)
@@ -312,7 +310,11 @@ func workspaceContext(cwd string) string {
b.WriteString("<environment>\n")
b.WriteString("Working directory: " + cwd + "\n")
b.WriteString("Operating system: " + runtime.GOOS + "\n")
b.WriteString(tools.HostShellEnvironmentGuidance() + "\n")
if runtime.GOOS == "windows" {
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools. To put | & > < etc inside an arg value, use double quotes around the value, not single quotes (single quotes do not protect metachars in cmd.exe): gh --jq \".a | b\", go test -run \"A|B\". 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. Prefer the workdir/cwd argument over 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")
}
if branch := gitBranchForPrompt(cwd); branch != "" {
b.WriteString("Git branch: " + branch + "\n")
}
+14 -16
View File
@@ -30,13 +30,16 @@ work.
read-before-edit discipline: inspect the target file and nearby callers,
tests, or config before you modify behavior. Never edit a file you have not
read.
2. **Plan.** Use update_plan for implementation or investigation that has at
least three meaningful dependent steps, then keep it current at milestones.
Mark completed work and the next in-progress step together when practical;
do not spend separate turns updating it after every file or command. Keep at
most one item in_progress. Skip plans for simple lookups, explanations, code
navigation, and other short tasks. Never create a plan after the work is
already complete merely to describe what you did.
2. **Plan.** For multi-step work, call update_plan with an ordered checklist and
keep it live. The plan bar is the user's progress signal — call update_plan
after EACH concrete unit of work (every file written, every command run), not
just at coarse milestones: mark the finished item completed and the next one
in_progress before you start it. A plan stuck at 0/N while files are landing is
a bug, not economy — these calls are cheap, expected, and the update_plan cards
are hidden from the transcript, so frequent updates cost the user nothing and
never clutter the conversation. Keep at most one item in_progress, and never
batch the updates to the end of the turn. Skip the plan for trivial one-step
tasks.
3. **Implement.** Make focused changes that match the surrounding code's style,
naming, and conventions. Prefer the smallest change that fully solves the
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
@@ -51,16 +54,11 @@ work.
## Editing discipline
- Choose the narrowest tool that safely accomplishes the step. Prefer native
file tools - read_file, read_minified_file, list_directory, glob, grep,
write_file, edit_file, apply_patch - over shelling out to
cat/sed/awk/python for file operations.
file tools - read_file, list_directory, glob, grep, write_file, edit_file,
apply_patch - over shelling out to cat/sed/awk/python for file operations.
They are safer, reviewable, and produce clean diffs.
- Prefer read_minified_file when initially exploring source code; it preserves
code while removing comments and redundant whitespace. Use read_file when
exact text, comments, or line numbers are needed.
- Keep edits focused and reviewable. A single patch may update several related
files when they form one coherent change; do not hide unrelated edits in a
bulk shell or script rewrite.
- Make one tool call per file. Do not batch multi-file writes into a single
shell or script invocation.
- For edits to existing files, prefer edit_file or apply_patch with minimal,
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
+1 -2
View File
@@ -71,6 +71,5 @@ const geminiPromptAddendum = `<model_guidance>
equivalent shell commands; they are safer and produce cleaner diffs.
- Be concise and concrete. When you run a shell command with side effects, state
in one short clause why it is needed.
- Use update_plan only for work with at least three meaningful dependent steps;
skip it for simple lookups and never create one after the work is complete.
- Use update_plan for any multi-step task and keep it current.
</model_guidance>`
-10
View File
@@ -51,16 +51,6 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
}
}
func TestTransientSystemPromptIsIncludedOnlyWhenProvided(t *testing.T) {
const transient = "Handle this peer request under the current session policy."
if prompt := buildSystemPrompt(Options{}); strings.Contains(prompt, transient) {
t.Fatal("ordinary system prompt unexpectedly contains transient instructions")
}
if prompt := buildSystemPrompt(Options{TransientSystemPrompt: transient}); !strings.Contains(prompt, transient) {
t.Fatalf("transient instructions missing from system prompt:\n%s", prompt)
}
}
func TestSystemPromptExplainsSandboxEscalationForHiddenHostState(t *testing.T) {
prompt := strings.ToLower(buildSystemPrompt(Options{}))
for _, want := range []string{
+10 -217
View File
@@ -1,14 +1,10 @@
package agent
import (
"crypto/sha256"
"encoding/json"
"fmt"
"reflect"
"regexp"
"sort"
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
@@ -56,41 +52,17 @@ type taskVerificationState struct {
LastOutcome Outcome `json:"last_outcome,omitempty"`
}
type taskFailureState struct {
Key string `json:"key,omitempty"`
Tool string `json:"tool"`
Command string `json:"command,omitempty"`
Summary string `json:"summary"`
}
type taskApprovalState struct {
Tool string `json:"tool"`
Decision PermissionDecisionAction `json:"decision,omitempty"`
Scope string `json:"scope,omitempty"`
Reason string `json:"reason,omitempty"`
}
type taskArtifactState struct {
Tool string `json:"tool"`
Path string `json:"path"`
}
type taskStateSnapshot struct {
Revision int `json:"revision"`
Objective string `json:"objective"`
Status taskStatus `json:"status"`
Plan taskPlanState `json:"plan"`
Tools taskToolState `json:"tools"`
Verification taskVerificationState `json:"verification"`
ChangedFiles []string `json:"changed_files,omitempty"`
Constraints []string `json:"constraints,omitempty"`
UnresolvedFailures []taskFailureState `json:"unresolved_failures,omitempty"`
Approvals []taskApprovalState `json:"approvals,omitempty"`
Artifacts []taskArtifactState `json:"artifacts,omitempty"`
ResolvedFailureKeys []string `json:"-"`
CompletionDecision CompletionDecision `json:"completion_decision,omitempty"`
CompletionReason string `json:"completion_reason,omitempty"`
PlanParity taskPlanParity `json:"plan_parity"`
Revision int `json:"revision"`
Objective string `json:"objective"`
Status taskStatus `json:"status"`
Plan taskPlanState `json:"plan"`
Tools taskToolState `json:"tools"`
Verification taskVerificationState `json:"verification"`
ChangedFiles []string `json:"changed_files,omitempty"`
CompletionDecision CompletionDecision `json:"completion_decision,omitempty"`
CompletionReason string `json:"completion_reason,omitempty"`
PlanParity taskPlanParity `json:"plan_parity"`
}
type taskStateEventKind int
@@ -100,7 +72,6 @@ const (
taskStateEventToolResult
taskStateEventVerification
taskStateEventCompletion
taskStateEventPermission
)
type taskStateEvent struct {
@@ -109,7 +80,6 @@ type taskStateEvent struct {
toolResult ToolResult
verification Outcome
completion completionEvaluation
permission PermissionEvent
}
// taskState is a deterministic projection of facts the loop already observes.
@@ -131,7 +101,6 @@ func newTaskState(objective string, recorder *trace.Recorder) *taskState {
},
changedFiles: map[string]struct{}{},
}
state.snapshotValue.Constraints = extractExplicitConstraints(objective)
state.recorder = recorder
return state
}
@@ -165,7 +134,6 @@ func (state *taskState) observe(event taskStateEvent) bool {
}
}
state.snapshotValue.ChangedFiles = sortedKeys(state.changedFiles)
state.observeToolEvidence(event.arguments, event.toolResult)
changed = true
case taskStateEventVerification:
if event.verification == OutcomeDisabled {
@@ -192,10 +160,6 @@ func (state *taskState) observe(event taskStateEvent) bool {
state.snapshotValue.Status = taskStatusActive
}
changed = true
case taskStateEventPermission:
state.markActive()
state.observePermission(event.permission)
changed = true
}
if changed {
state.snapshotValue.Revision++
@@ -219,180 +183,9 @@ func (state *taskState) snapshot() taskStateSnapshot {
snapshot := state.snapshotValue
snapshot.Plan.Items = append([]taskPlanItem(nil), state.snapshotValue.Plan.Items...)
snapshot.ChangedFiles = append([]string(nil), state.snapshotValue.ChangedFiles...)
snapshot.Constraints = append([]string(nil), state.snapshotValue.Constraints...)
snapshot.UnresolvedFailures = append([]taskFailureState(nil), state.snapshotValue.UnresolvedFailures...)
snapshot.Approvals = append([]taskApprovalState(nil), state.snapshotValue.Approvals...)
snapshot.Artifacts = append([]taskArtifactState(nil), state.snapshotValue.Artifacts...)
snapshot.ResolvedFailureKeys = append([]string(nil), state.snapshotValue.ResolvedFailureKeys...)
return snapshot
}
const (
maxTaskEvidenceEntries = 8
maxTaskEvidenceBytes = 320
maxTaskConstraints = 10
)
func (state *taskState) observeToolEvidence(arguments string, result ToolResult) {
command := capTaskEvidence(commandFromArguments(arguments))
key := taskFailureKey(result.Name, command, arguments)
if result.Status == tools.StatusOK {
if hasTaskFailure(state.snapshotValue.UnresolvedFailures, key) {
state.snapshotValue.UnresolvedFailures = removeTaskFailure(state.snapshotValue.UnresolvedFailures, key)
state.snapshotValue.ResolvedFailureKeys = appendBoundedUnique(state.snapshotValue.ResolvedFailureKeys, key, maxTaskEvidenceEntries)
}
} else {
state.snapshotValue.ResolvedFailureKeys = removeString(state.snapshotValue.ResolvedFailureKeys, key)
state.snapshotValue.UnresolvedFailures = removeTaskFailure(state.snapshotValue.UnresolvedFailures, key)
state.snapshotValue.UnresolvedFailures = appendBounded(state.snapshotValue.UnresolvedFailures, taskFailureState{
Key: key, Tool: result.Name, Command: command, Summary: capTaskEvidence(firstLine(result.Output)),
}, maxTaskEvidenceEntries)
}
for _, metaKey := range []string{"spill_path", "artifact_path"} {
if path := strings.TrimSpace(result.Meta[metaKey]); path != "" {
state.snapshotValue.Artifacts = appendBoundedUnique(state.snapshotValue.Artifacts,
taskArtifactState{Tool: result.Name, Path: capTaskEvidence(path)}, maxTaskEvidenceEntries)
}
}
}
func taskFailureKey(toolName, command, arguments string) string {
identity := command
if identity == "" {
canonical := []byte(strings.TrimSpace(arguments))
var value any
if json.Unmarshal(canonical, &value) == nil {
if encoded, err := json.Marshal(value); err == nil {
canonical = encoded
}
}
digest := sha256.Sum256(canonical)
identity = fmt.Sprintf("args:%x", digest[:8])
}
return strings.TrimSpace(toolName) + "\x00" + identity
}
func (state *taskState) observePermission(event PermissionEvent) {
decision := event.DecisionAction
if decision == "" {
decision = PermissionDecisionAction(event.Action)
}
state.snapshotValue.Approvals = appendBoundedUnique(state.snapshotValue.Approvals, taskApprovalState{
Tool: event.ToolName, Decision: decision, Scope: capTaskEvidence(event.Scope),
Reason: capTaskEvidence(firstNonEmptyString(event.DecisionReason, event.Reason)),
}, maxTaskEvidenceEntries)
}
func commandFromArguments(arguments string) string {
var object map[string]json.RawMessage
if json.Unmarshal([]byte(strings.TrimSpace(arguments)), &object) != nil {
return ""
}
for _, key := range []string{"cmd", "command", "script", "shell"} {
var command string
if raw, ok := object[key]; ok && json.Unmarshal(raw, &command) == nil {
return strings.TrimSpace(command)
}
}
return ""
}
func firstLine(value string) string {
value = strings.TrimSpace(value)
if index := strings.IndexByte(value, '\n'); index >= 0 {
value = value[:index]
}
return value
}
func capTaskEvidence(value string) string {
value = strings.TrimSpace(value)
if len(value) <= maxTaskEvidenceBytes {
return value
}
limit := maxTaskEvidenceBytes - 3
for limit > 0 && !utf8.RuneStart(value[limit]) {
limit--
}
return strings.TrimSpace(value[:limit]) + "..."
}
var explicitConstraintPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)\bprefer(?:s|red|ring)?\s+\w`),
regexp.MustCompile(`(?i)\bdon'?t want\b`),
regexp.MustCompile(`(?i)\balways (?:use|do|run|prefer|keep|make|format|write|add|set|put|prefix|start|include|append)\b`),
regexp.MustCompile(`(?i)\bnever (?:use|do|run|push|commit|write|ignore|add|set|put|remove|delete|include|deploy)\b`),
regexp.MustCompile(`(?i)\bplease (?:use|avoid|keep|make|don'?t|do not|format|write)\b`),
regexp.MustCompile(`(?i)\b(?:style|format|language|naming)\s*[:=]\s*\S`),
}
func extractExplicitConstraints(text string) []string {
var constraints []string
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if len(line) < 5 || len(line) > 200 || strings.HasSuffix(line, "?") || strings.Contains(line, "?...") {
continue
}
for _, pattern := range explicitConstraintPatterns {
if pattern.MatchString(line) {
constraints = appendBoundedUnique(constraints, line, maxTaskConstraints)
break
}
}
if len(constraints) == maxTaskConstraints {
break
}
}
return constraints
}
func appendBounded[T any](values []T, value T, limit int) []T {
values = append(values, value)
if len(values) > limit {
values = values[len(values)-limit:]
}
return values
}
func appendBoundedUnique[T comparable](values []T, value T, limit int) []T {
filtered := values[:0]
for _, existing := range values {
if existing != value {
filtered = append(filtered, existing)
}
}
return appendBounded(filtered, value, limit)
}
func hasTaskFailure(values []taskFailureState, key string) bool {
for _, value := range values {
if value.Key == key {
return true
}
}
return false
}
func removeTaskFailure(values []taskFailureState, key string) []taskFailureState {
out := values[:0]
for _, value := range values {
if value.Key != key {
out = append(out, value)
}
}
return out
}
func removeString(values []string, target string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
if value != target {
out = append(out, value)
}
}
return out
}
// observePlanParity compares only the latest plan projection with the plan tool
// calls still present in messages. It mutates the snapshot and emits when the
// parity value changes; objective, tool, and verification fields are not part of
-62
View File
@@ -115,68 +115,6 @@ func TestTaskStateSnapshotIsImmutable(t *testing.T) {
}
}
func TestTaskStateRecordsDurableRuntimeEvidence(t *testing.T) {
state := newTaskState("Please keep the change focused.\nNever commit generated reports.", nil)
arguments := `{"cmd":"go test ./internal/agent"}`
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: arguments, toolResult: ToolResult{
ToolCallID: "test-1", Name: "exec_command", Status: tools.StatusError,
Output: "Error: TestResumeRetainsState failed\nfull diagnostic",
Meta: map[string]string{"spill_path": ".zero/artifacts/test-1.txt"},
}})
state.observe(taskStateEvent{kind: taskStateEventPermission, permission: PermissionEvent{
ToolName: "exec_command", DecisionAction: PermissionDecisionAllowForSession,
Scope: "/tmp", DecisionReason: "write a temporary fixture",
}})
snapshot := state.snapshot()
if !reflect.DeepEqual(snapshot.Constraints, []string{"Please keep the change focused.", "Never commit generated reports."}) {
t.Fatalf("unexpected explicit constraints: %#v", snapshot.Constraints)
}
if len(snapshot.UnresolvedFailures) != 1 || snapshot.UnresolvedFailures[0].Command != "go test ./internal/agent" || snapshot.UnresolvedFailures[0].Summary != "Error: TestResumeRetainsState failed" {
t.Fatalf("unexpected failure evidence: %#v", snapshot.UnresolvedFailures)
}
if len(snapshot.Artifacts) != 1 || snapshot.Artifacts[0].Path != ".zero/artifacts/test-1.txt" {
t.Fatalf("unexpected artifact evidence: %#v", snapshot.Artifacts)
}
if len(snapshot.Approvals) != 1 || snapshot.Approvals[0].Decision != PermissionDecisionAllowForSession || snapshot.Approvals[0].Scope != "/tmp" {
t.Fatalf("unexpected approval evidence: %#v", snapshot.Approvals)
}
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
ToolCallID: "read-a", Name: "read_file", Status: tools.StatusError, Output: "Error: a.go is unavailable",
}})
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"b.go"}`, toolResult: ToolResult{
ToolCallID: "read-b", Name: "read_file", Status: tools.StatusOK, Output: "package b",
}})
if failures := state.snapshot().UnresolvedFailures; len(failures) != 2 || failures[1].Summary != "Error: a.go is unavailable" {
t.Fatalf("success for a different target resolved the wrong failure: %#v", failures)
}
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: arguments, toolResult: ToolResult{
ToolCallID: "test-2", Name: "exec_command", Status: tools.StatusOK, Output: "ok",
}})
if failures := state.snapshot().UnresolvedFailures; len(failures) != 1 || failures[0].Summary != "Error: a.go is unavailable" {
t.Fatalf("successful retry did not resolve only its matching failure: %#v", failures)
}
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
ToolCallID: "read-a-2", Name: "read_file", Status: tools.StatusOK, Output: "package a",
}})
if failures := state.snapshot().UnresolvedFailures; len(failures) != 0 {
t.Fatalf("successful non-command retry did not resolve its matching failure: %#v", failures)
}
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
ToolCallID: "read-a-3", Name: "read_file", Status: tools.StatusError, Output: "Error: a.go failed again",
}})
snapshot = state.snapshot()
if len(snapshot.UnresolvedFailures) != 1 || snapshot.UnresolvedFailures[0].Summary != "Error: a.go failed again" {
t.Fatalf("a failure recurring after success was hidden: %#v", snapshot.UnresolvedFailures)
}
for _, key := range snapshot.ResolvedFailureKeys {
if key == snapshot.UnresolvedFailures[0].Key {
t.Fatalf("recurring failure retained a stale resolved key: %#v", snapshot.ResolvedFailureKeys)
}
}
}
func TestTaskStatePlanParityUsesLatestPlan(t *testing.T) {
state := newTaskState("objective", nil)
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"old","status":"completed"}]}`})
-207
View File
@@ -1,207 +0,0 @@
package agent
import (
"context"
"fmt"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// imageTool returns a tool result carrying an image, the way a screenshot tool
// should be able to.
type imageTool struct {
media string
data []byte
}
func (imageTool) Name() string { return "capture" }
func (imageTool) Description() string { return "Captures an image." }
func (imageTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
}
func (imageTool) Safety() tools.Safety {
return tools.Safety{Permission: tools.PermissionAllow}
}
func (t imageTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{
Status: tools.StatusOK,
Output: "Captured a screenshot.",
Images: []zeroruntime.ImageBlock{{MediaType: t.media, Data: t.data}},
}
}
// A tool that produces an image must get that image in front of the model.
//
// It cannot ride the tool-result message: every provider drops images there.
// Anthropic maps a tool result to a tool_result block with string content,
// Gemini to a functionResponse, and OpenAI guards its image parts to the user
// role explicitly. So the image has to arrive as a following user message,
// which is also the only shape that keeps one tool result per tool call.
func TestRunDeliversToolResultImagesToTheModel(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(imageTool{media: "image/png", data: []byte("\x89PNG\r\n\x1a\nfake")})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "capture"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "I can see it."},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "screenshot please", provider, Options{
Registry: registry,
MaxTurns: 2,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
var carrier *zeroruntime.Message
for index := range result.Messages {
if len(result.Messages[index].Images) > 0 {
carrier = &result.Messages[index]
break
}
}
if carrier == nil {
t.Fatal("no message carried the tool's image; the model never saw it")
}
if carrier.Role != zeroruntime.MessageRoleUser {
t.Errorf("image rode a %q message; every provider only serializes images on the user role", carrier.Role)
}
if got := carrier.Images[0].MediaType; got != "image/png" {
t.Errorf("media type = %q, want image/png", got)
}
// The tool-result pairing must be untouched: one tool result per tool call.
toolResults := 0
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
toolResults++
}
}
if toolResults != 1 {
t.Errorf("got %d tool-result messages for 1 tool call", toolResults)
}
}
// textTool is a second, image-free tool so a turn can carry two calls.
type textTool struct{}
func (textTool) Name() string { return "note" }
func (textTool) Description() string { return "Notes something." }
func (textTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
}
func (textTool) Safety() tools.Safety { return tools.Safety{Permission: tools.PermissionAllow} }
func (textTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "noted"}
}
// Tool results for one assistant turn must stay contiguous. The image messages
// are user-role, and a user message between two tool_results breaks strict
// provider replay: Anthropic coalesces consecutive user content into one block
// list and requires tool_result blocks first, so interleaving produces
// [tool_result, text, image, tool_result] and the request is rejected.
//
// The first version of this feature appended each image immediately after its
// own tool result, which is exactly that shape. A single-tool-call test passed
// and said nothing about it.
func TestRunKeepsToolResultsContiguousWhenAToolReturnsAnImage(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(imageTool{media: "image/png", data: []byte("\x89PNG\r\n\x1a\nfake")})
registry.Register(textTool{})
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "capture"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c2", ToolName: "note"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c2", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c2"},
{Type: zeroruntime.StreamEventDone},
},
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
}}
result, err := Run(context.Background(), "two calls", provider, Options{Registry: registry, MaxTurns: 2})
if err != nil {
t.Fatalf("Run: %v", err)
}
// Two separate ifs rather than a switch: a tool-role message that also
// carried images would match only the first arm of a switch, so a regression
// that put the image back on the tool result would read as "no image at all"
// instead of naming what actually happened.
type toolResult struct {
index int
id string
}
var toolResults []toolResult
imageIndex := -1
for index, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
toolResults = append(toolResults, toolResult{index: index, id: message.ToolCallID})
}
if len(message.Images) > 0 {
if imageIndex >= 0 {
t.Fatalf("image delivered twice, at %d and %d; recorded %s", imageIndex, index, messageShape(result.Messages))
}
imageIndex = index
}
}
if imageIndex < 0 {
t.Fatalf("the image never reached the model; recorded %s", messageShape(result.Messages))
}
if len(toolResults) != 2 {
t.Fatalf("got %d tool-result messages for 2 tool calls; recorded %s", len(toolResults), messageShape(result.Messages))
}
if toolResults[0].id != "c1" || toolResults[1].id != "c2" {
t.Errorf("tool results answered %q then %q, want c1 then c2", toolResults[0].id, toolResults[1].id)
}
// The contract Anthropic enforces: every tool_result block has to come before
// any other block in the user message it lands in. Anything wedged between
// the two results breaks that, which is the 400 this PR exists to avoid.
if toolResults[1].index != toolResults[0].index+1 {
t.Errorf("tool results at %d and %d are not adjacent; %q message at %d splits them; recorded %s",
toolResults[0].index, toolResults[1].index, result.Messages[toolResults[0].index+1].Role,
toolResults[0].index+1, messageShape(result.Messages))
}
// Follows the last tool result, deliberately NOT pinned to exactly last+1.
// appendUserBlocks coalesces consecutive user-role content, so
// [tool_result, tool_result, text, image] still maps to tool_result blocks
// first and replays fine; pinning would redden that valid shape for nothing.
if imageIndex <= toolResults[1].index {
t.Errorf("image message at %d does not follow the last tool result at %d; recorded %s",
imageIndex, toolResults[1].index, messageShape(result.Messages))
}
}
// messageShape renders the conversation as a compact role list so a failure says
// what the ordering actually was instead of dumping whole messages.
func messageShape(messages []zeroruntime.Message) string {
parts := make([]string, 0, len(messages))
for index, message := range messages {
part := fmt.Sprintf("%d:%s", index, message.Role)
if message.ToolCallID != "" {
part += "(" + message.ToolCallID + ")"
}
if len(message.Images) > 0 {
part += "+image"
}
parts = append(parts, part)
}
return "[" + strings.Join(parts, " ") + "]"
}
+5 -13
View File
@@ -68,11 +68,8 @@ type ToolResult struct {
Output string
// Truncated reports that the tool's model-visible output omitted content.
// The full result may be recoverable through Meta["spill_path"].
Truncated bool
Meta map[string]string
// Images the tool produced, delivered to the model as a following user
// message rather than on this result. See tools.Result.Images.
Images []zeroruntime.ImageBlock
Truncated bool
Meta map[string]string
Redacted bool
ChangedFiles []string
// ChangeSummaries are non-selectable generated-tree summaries emitted by
@@ -283,9 +280,6 @@ type Options struct {
ReasoningEffort string
Cwd string
SystemPrompt string
// TransientSystemPrompt adds trusted runtime guidance for this run only.
// Empty preserves the ordinary system prompt byte-for-byte.
TransientSystemPrompt string
// ResponseStyle is the operator-selected reply style from the TUI /style
// command (e.g. "concise", "explanatory", "review"). It is rendered into the
// system prompt as a short directive. Empty or "balanced" adds nothing — the
@@ -336,11 +330,9 @@ type Options struct {
// specialist child process emits while running. The toolCallID identifies
// which Task tool call the progress belongs to. nil is a no-op.
OnToolProgress func(toolCallID string, event streamjson.Event)
// OnContext, when set, is called for each main agent request with its
// per-category context budget, including a replacement request after
// compaction or a stall retry. Internal summarizer requests are excluded so
// surfaces keep showing the active conversation budget. Opt-in like the other
// callbacks; nil is a no-op.
// OnContext, when set, is called once per turn with the per-category context
// budget of the request about to be sent, so a surface (TUI/CLI) can show
// context utilization. Opt-in like the other callbacks; nil is a no-op.
OnContext func(ContextBreakdown)
// ModelSwitcher, when set, lets a tool escalate the run to a stronger model
// mid-run: the loop calls it with the requested model id and, on success,
+3 -1
View File
@@ -125,7 +125,9 @@ func Score(suite Suite, input ScoreInput) Report {
if len(task.RequiredTraceEvents) > 0 {
report.Results = append(report.Results, scoreTraceEvents(task.RequiredTraceEvents, input))
}
report.Results = append(report.Results, unknownCommandResults(input.CommandResults, seenCommands, input)...)
for _, result := range unknownCommandResults(input.CommandResults, seenCommands, input) {
report.Results = append(report.Results, result)
}
report.finishSummary()
return report
}
+3 -3
View File
@@ -40,14 +40,14 @@ func FormatFacts(info repoinfo.Info) string {
b.WriteString("Pre-computed repository facts (from a local scan — verify and fill gaps):\n")
if info.PrimaryLanguage != "" {
fmt.Fprintf(&b, "- Primary language: %s (of %d detected)\n", info.PrimaryLanguage, info.LanguageCount)
b.WriteString(fmt.Sprintf("- Primary language: %s (of %d detected)\n", info.PrimaryLanguage, info.LanguageCount))
}
if langs := topLanguages(info.Languages, 5); langs != "" {
b.WriteString("- Languages: " + langs + "\n")
}
fmt.Fprintf(&b, "- Size: ~%d files, ~%d LOC, max dir depth %d\n", info.FileCount, info.LOCEstimate, info.MaxDepth)
b.WriteString(fmt.Sprintf("- Size: ~%d files, ~%d LOC, max dir depth %d\n", info.FileCount, info.LOCEstimate, info.MaxDepth))
if info.WorkspaceType != "" && info.WorkspaceType != "none" {
fmt.Fprintf(&b, "- Workspace: %s (%d packages)\n", info.WorkspaceType, info.WorkspacePackageCount)
b.WriteString(fmt.Sprintf("- Workspace: %s (%d packages)\n", info.WorkspaceType, info.WorkspacePackageCount))
}
if len(info.BuildTools) > 0 {
b.WriteString("- Build tools: " + strings.Join(info.BuildTools, ", ") + "\n")
+6 -3
View File
@@ -149,9 +149,12 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
// The forked child must be gone too (poll until reaped by init).
deadline := time.Now().Add(2 * time.Second)
// Only ESRCH proves the child is gone; any other error (e.g. EPERM) would
// wrongly pass the test, so treat it as still-present and keep polling.
for !errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) {
for {
// Only ESRCH proves the child is gone; any other error (e.g. EPERM) would
// wrongly pass the test, so treat it as still-present and keep polling.
if errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) {
break // child no longer exists
}
if time.Now().After(deadline) {
_ = syscall.Kill(childPID, syscall.SIGKILL)
t.Fatalf("forked child %d survived terminateProcess — group kill failed", childPID)
+3 -7
View File
@@ -27,8 +27,6 @@ Usage:
Not meant to be run interactively — point your editor's ACP / external-agent
setting at "zero acp".`
var acpSignalContext = signalContext
// runACP serves ACP over stdio so an editor can drive ZERO's agent core. It
// speaks JSON-RPC 2.0 (newline-delimited JSON) on stdin/stdout; stderr stays free
// for human-readable diagnostics. The session lifecycle maps onto ZERO's own
@@ -44,9 +42,7 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
}
}
// The process's own stdin belongs exclusively to this Conn for the life of
// the command, so cancellation may close it to interrupt an idle read.
conn := acp.NewOwnedConn(deps.stdin, stdout)
conn := acp.NewConn(deps.stdin, stdout)
acp.NewAgent(conn, acp.Deps{
ResolveConfig: deps.resolveConfig,
DiscoverModels: func(ctx context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
@@ -77,9 +73,9 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
AgentInfo: acp.Implementation{Name: "zero", Version: version},
})
ctx, stop := acpSignalContext()
ctx, stop := signalContext()
defer stop()
if err := conn.Serve(ctx); err != nil {
if err := conn.Serve(ctx); err != nil && ctx.Err() == nil {
return writeAppError(stderr, "acp: "+err.Error(), exitCrash)
}
return exitSuccess
-120
View File
@@ -1,120 +0,0 @@
package cli
import (
"bytes"
"context"
"errors"
"io"
"strings"
"sync"
"testing"
"time"
)
type acpTestReader func([]byte) (int, error)
func (r acpTestReader) Read(p []byte) (int, error) { return r(p) }
type acpTestWriter func([]byte) (int, error)
func (w acpTestWriter) Write(p []byte) (int, error) { return w(p) }
func installACPTestContext(t *testing.T) context.CancelFunc {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
previous := acpSignalContext
acpSignalContext = func() (context.Context, context.CancelFunc) {
return ctx, cancel
}
t.Cleanup(func() {
cancel()
acpSignalContext = previous
})
return cancel
}
func TestRunACPCancellationPreservesTerminalReadError(t *testing.T) {
cancel := installACPTestContext(t)
wantErr := errors.New("transport read failed")
reader := acpTestReader(func(p []byte) (int, error) {
return copy(p, `{"jsonrpc":"1.0","id":1}`+"\n"), wantErr
})
writeStarted := make(chan struct{}, 1)
releaseWrite := make(chan struct{})
var stdout bytes.Buffer
writer := acpTestWriter(func(p []byte) (int, error) {
writeStarted <- struct{}{}
<-releaseWrite
return stdout.Write(p)
})
var stderr bytes.Buffer
done := make(chan int, 1)
go func() {
done <- runACP(nil, writer, &stderr, fillAppDeps(appDeps{stdin: reader}))
}()
select {
case <-writeStarted:
case <-time.After(time.Second):
t.Fatal("ACP did not reach the response write")
}
// Keep Serve in the synchronous write until cancellation is observable. The
// regression requires the genuine read error to win over that cancellation.
cancel()
close(releaseWrite)
select {
case code := <-done:
if code != exitCrash {
t.Fatalf("exit code = %d, want crash %d", code, exitCrash)
}
case <-time.After(time.Second):
t.Fatal("ACP did not exit after terminal read error")
}
if got := stderr.String(); !strings.Contains(got, "acp: "+wantErr.Error()) {
t.Fatalf("stderr = %q, want terminal read error", got)
}
}
func TestRunACPIdleCancellationExitsCleanly(t *testing.T) {
cancel := installACPTestContext(t)
pipeReader, pipeWriter := io.Pipe()
t.Cleanup(func() { _ = pipeWriter.Close() })
readStarted := make(chan struct{}, 1)
reader := &acpNotifyingReadCloser{ReadCloser: pipeReader, readStarted: readStarted}
var stdout, stderr bytes.Buffer
done := make(chan int, 1)
go func() {
done <- runACP(nil, &stdout, &stderr, fillAppDeps(appDeps{stdin: reader}))
}()
select {
case <-readStarted:
case <-time.After(time.Second):
t.Fatal("ACP did not begin reading")
}
cancel()
select {
case code := <-done:
if code != exitSuccess {
t.Fatalf("exit code = %d, want success %d; stderr: %s", code, exitSuccess, stderr.String())
}
case <-time.After(time.Second):
t.Fatal("ACP did not exit after cancellation")
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
type acpNotifyingReadCloser struct {
io.ReadCloser
readStarted chan<- struct{}
once sync.Once
}
func (r *acpNotifyingReadCloser) Read(p []byte) (int, error) {
r.once.Do(func() { r.readStarted <- struct{}{} })
return r.ReadCloser.Read(p)
}
+3 -1
View File
@@ -515,7 +515,9 @@ func agentEvalReportFromBenchmark(suite agenteval.Suite, report agenteval.Benchm
if task.Agent.Truncated {
converted.Truncated = true
}
converted.Failures = append(converted.Failures, agentEvalFailuresFromTaskReport(task)...)
for _, failure := range agentEvalFailuresFromTaskReport(task) {
converted.Failures = append(converted.Failures, failure)
}
}
return converted
}
+1 -26
View File
@@ -23,7 +23,6 @@ import (
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/observability"
"github.com/Gitlawb/zero/internal/peermsg"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
@@ -586,9 +585,6 @@ func fillAppDeps(deps appDeps) appDeps {
baseProbeProviderHealth := deps.probeProviderHealth
deps.probeProviderHealth = func(ctx context.Context, options providerhealth.Options) providerhealth.Result {
options.Profile = applyStoredProviderKeyAt(options.Profile, userConfigPath)
if options.OAuthResolver == nil {
options.OAuthResolver, _ = oauthLoginForProfile(options.Profile)
}
return baseProbeProviderHealth(ctx, options)
}
return deps
@@ -787,26 +783,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
if permissionMode == "" {
permissionMode = agent.PermissionModeAsk
}
sessionStore := deps.newSessionStore()
peerClass := peermsg.PermissionPrompting
if permissionMode == agent.PermissionModeUnsafe {
peerClass = peermsg.PermissionBypass
}
peerService, peerErr := peermsg.New(peermsg.Options{
Identity: peermsg.Identity{
Cwd: workspaceRoot,
PermissionClass: peerClass,
},
InboundPolicy: peermsg.InboundPolicy(resolved.CrossSessionInbound),
})
if peerErr != nil {
fmt.Fprintf(stderr, "warning: cross-session messaging unavailable: %s\n", peerErr)
peerService = nil
} else {
for _, tool := range tools.NewPeerSessionTools(peerService) {
registry.Register(tool)
}
}
// Activate deferred MCP-tool loading for the interactive run only when the
// VISIBLE deferred-eligible count meets the resolved threshold, matching exec.
// The registry is complete (core + specialist + MCP + plugins) here, so the
@@ -858,8 +834,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
return scratchFileWarning(workspaceRoot, scratchBaseline)
},
Registry: registry,
SessionStore: sessionStore,
PeerService: peerService,
SessionStore: deps.newSessionStore(),
SandboxStore: sandboxStore,
MCPConfig: mcpConfig,
MCPPermissionStore: mcpPermissionStore,
+4 -5
View File
@@ -40,11 +40,10 @@ func runContext(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
return writeAppError(stderr, err.Error(), exitCrash)
}
report, err := contextreport.Build(contextreport.Options{
WorkspaceRoot: workspaceRoot,
Provider: resolved.Provider,
Registry: newCoreRegistry(workspaceRoot),
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
DeferThreshold: resolved.Tools.DeferThreshold,
WorkspaceRoot: workspaceRoot,
Provider: resolved.Provider,
Registry: newCoreRegistry(workspaceRoot),
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
+4 -3
View File
@@ -77,10 +77,11 @@ func TestRegisterToolSearchIfEligibleSkipsWhenThresholdZero(t *testing.T) {
}
}
func TestDeferredEligibleCountIncludesOptionalBuiltins(t *testing.T) {
func TestDeferredEligibleCountIgnoresCoreTools(t *testing.T) {
registry := newCoreRegistry(t.TempDir())
if got := deferredEligibleCount(registry, agent.PermissionModeAuto, nil, nil); got < 2 {
t.Fatalf("deferredEligibleCount(core) = %d, want at least 2 optional built-ins", got)
// newCoreRegistry holds only built-ins; none implement Deferred().
if got := deferredEligibleCount(registry, agent.PermissionModeAuto, nil, nil); got != 0 {
t.Fatalf("deferredEligibleCount(core) = %d, want 0", got)
}
}
-1
View File
@@ -139,7 +139,6 @@ func runSkillInfo(args []string, dir string, stdout io.Writer, stderr io.Writer)
}
if info.Hash != "" {
lines = append(lines, " hash: "+info.Hash)
lines = append(lines, fmt.Sprintf(" hash drift: %t", info.HashDrift))
}
lines = append(lines, " path: "+info.Skill.Path)
if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil {
-42
View File
@@ -83,48 +83,6 @@ func TestRunSkillAddInfoRemove(t *testing.T) {
if !strings.Contains(info, src) || !strings.Contains(info, "sha256:") {
t.Fatalf("info missing source/hash:\n%s", info)
}
if !strings.Contains(info, "hash drift: false") {
t.Fatalf("info missing hash drift status:\n%s", info)
}
stdout.Reset()
stderr.Reset()
if exit := runWithDeps([]string{"skill", "info", "confirmation-policy", "--json"}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("skill info --json exit = %d, stderr = %s", exit, stderr.String())
}
var beforeJSON map[string]any
if err := json.Unmarshal(stdout.Bytes(), &beforeJSON); err != nil {
t.Fatalf("parse skill info json: %v\n%s", err, stdout.String())
}
if beforeJSON["hashDrift"] != false {
t.Fatalf("hashDrift before edit = %#v, want false", beforeJSON["hashDrift"])
}
skillPath := filepath.Join(skillsDir, "confirmation-policy", "SKILL.md")
if err := os.WriteFile(skillPath, []byte("---\ndescription: edited\n---\nchanged\n"), 0o644); err != nil {
t.Fatal(err)
}
stdout.Reset()
stderr.Reset()
if exit := runWithDeps([]string{"skill", "info", "confirmation-policy"}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("skill info after edit exit = %d, stderr = %s", exit, stderr.String())
}
if !strings.Contains(stdout.String(), "hash drift: true") {
t.Fatalf("info missing hash drift after edit:\n%s", stdout.String())
}
stdout.Reset()
stderr.Reset()
if exit := runWithDeps([]string{"skill", "info", "confirmation-policy", "--json"}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("skill info --json after edit exit = %d, stderr = %s", exit, stderr.String())
}
var afterJSON map[string]any
if err := json.Unmarshal(stdout.Bytes(), &afterJSON); err != nil {
t.Fatalf("parse skill info json after edit: %v\n%s", err, stdout.String())
}
if afterJSON["hashDrift"] != true {
t.Fatalf("hashDrift after edit = %#v, want true", afterJSON["hashDrift"])
}
// remove deletes it
stdout.Reset()
+7 -42
View File
@@ -5,7 +5,6 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -25,47 +24,12 @@ func TestRunExecOptimizedSessionUnderGate(t *testing.T) {
cwd := t.TempDir()
var heads, posts atomic.Int64
// Closed when the prewarm probe lands, so the turn can be held open until it
// does. See the POST handler for why that ordering has to be forced.
headSeen := make(chan struct{})
var headOnce sync.Once
var prewarmWaitTimedOut atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodHead:
heads.Add(1)
headOnce.Do(func() { close(headSeen) })
w.WriteHeader(http.StatusMethodNotAllowed)
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions"):
// Hold the turn open until the prewarm probe arrives. The probe is
// issued on the run's context, and runExec cancels that context the
// moment it returns, because the stop function signalContext hands
// back is a CancelFunc. A probe that has not reached the wire by the
// end of the run therefore never reaches it at all. That is why the
// poll this replaced could not work at any deadline: once the run is
// over the probe is cancelled, not merely slow.
//
// This widens the window, it does not close it. Once the goroutine is
// scheduled it still has only prewarmTimeout to dial loopback and
// write the request, and that timeout is armed inside the goroutine
// itself, so holding the turn here cannot extend it. If this test
// ever fails again, prewarmTimeout is where to look, not this hold.
//
// No self-deadlock: the pinned transport leaves MaxConnsPerHost at 0,
// so the HEAD dials its own connection instead of queueing behind
// this held POST. Do not set a per-host connection limit there.
//
// Bounded, so a probe that genuinely never fires fails the assertion
// below with a useful message instead of hanging the test.
select {
case <-headSeen:
case <-time.After(10 * time.Second):
// Record it. A probe that shows up after this gave up would still
// leave heads at 1, so the count alone proves the probe was
// eventually received, not that it was ordered before the turn,
// which is the whole claim of this test.
prewarmWaitTimedOut.Store(true)
}
posts.Add(1)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"done\"}}]}\n\n"))
@@ -111,13 +75,14 @@ func TestRunExecOptimizedSessionUnderGate(t *testing.T) {
if got := posts.Load(); got < 1 {
t.Fatalf("server saw %d chat-completions POSTs, want >= 1", got)
}
// Checked before the count, because the count cannot tell the two apart: a
// probe that arrived only after the guard expired still leaves heads at 1.
if prewarmWaitTimedOut.Load() {
t.Fatal("the turn was not held until the prewarm probe arrived; the probe did not precede it")
// The prewarm probe is asynchronous; it fires well before the model turn
// finishes, but poll rather than assuming scheduling order. The deadline
// exceeds the production prewarmTimeout (3s) so a slow probe cannot flake
// this assertion.
deadline := time.Now().Add(5 * time.Second)
for heads.Load() == 0 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
// No polling: the turn above could not complete until the probe landed, so
// the count is settled by this point.
if got := heads.Load(); got != 1 {
t.Fatalf("server saw %d prewarm HEAD probes, want exactly 1", got)
}
+2 -28
View File
@@ -308,34 +308,6 @@ func runMCPCheck(ctx context.Context, args []string, stdout io.Writer, stderr io
}
defer closeMCPRuntime(stderr, mcpRuntime)
// A connect failure does not come back as err. RegisterTools is deliberately
// best-effort so one unreachable server cannot stop Zero launching: it records
// the failure on the runtime and returns nil. That is right for startup and
// wrong to ignore here, because this is the command a user runs precisely when
// a server is misbehaving, and reporting "is reachable" for a server that never
// started sends them looking somewhere else.
for _, skipped := range mcpRuntime.Skipped() {
if skipped.Name != serverName {
continue
}
message := fmt.Sprintf("MCP server %s is not reachable", serverName)
if skipped.Err != nil {
message = fmt.Sprintf("%s: %s", message, redaction.ErrorMessage(skipped.Err, redaction.Options{}))
}
if options.json {
payload := struct {
ServerName string `json:"serverName"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}{ServerName: serverName, Status: "unreachable", Error: message}
if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil {
return exitCrash
}
return exitCrash
}
return writeAppError(stderr, message, exitCrash)
}
items := mcpToolList(registry)
if options.json {
payload := struct {
@@ -762,12 +734,14 @@ func (cfg *mcpWritableConfig) setServerDisabled(name string, disabled bool) (boo
switch {
case legacyFound:
raw = legacyRaw
found = true
case config.IsDefaultMCPServer(name):
// A built-in default server isn't written to the file until the user
// overrides it. Treat it as present with an empty base so disabling it
// writes a minimal {"disabled":true} entry that merges over the default —
// letting `zero mcp disable <default>` work even though it lives in code.
raw = nil
found = true
default:
return false, false, nil
}
-79
View File
@@ -6,7 +6,6 @@ package cli
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
@@ -220,81 +219,3 @@ func TestRunMCPCheckNormalizeError(t *testing.T) {
t.Fatalf("want normalize error, got %q", got)
}
}
// TestRunMCPCheckReportsUnreachableServer covers the case the command exists
// for: a server that fails to start.
//
// RegisterTools is best-effort by design, so a connect failure is recorded on
// the runtime and registration returns nil. runMCPCheck used to inspect only
// that nil error and then print "is reachable", which told the user their
// broken server was fine. The command is spawned for real here rather than
// stubbed, because the whole failure mode was a mismatch between what
// registration returns and what it records.
func TestRunMCPCheckReportsUnreachableServer(t *testing.T) {
setTrustConfigRoot(t)
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"broken": {Type: "stdio", Command: "zero-definitely-not-a-real-binary-xyz"},
}}, nil
},
}
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "check", "broken"}, &out, &errBuf, deps); code == exitSuccess {
t.Fatalf("mcp check must fail for a server that did not start; stdout=%q stderr=%q", out.String(), errBuf.String())
}
if got := out.String(); strings.Contains(got, "is reachable") {
t.Fatalf("mcp check reported a failed server as reachable: %q", got)
}
if got := errBuf.String(); !strings.Contains(got, "not reachable") {
t.Fatalf("want an unreachable error naming the server, got %q", got)
}
}
// The --json contract for an unreachable server, which the text case above does
// not touch.
//
// The JSON branch is a separate return path with its own payload, so a caller
// parsing it would otherwise be relying on a shape nothing pins: reverting the
// check leaves it emitting status "ok" with a tool count, which is valid JSON
// and completely wrong.
func TestRunMCPCheckReportsUnreachableServerJSON(t *testing.T) {
setTrustConfigRoot(t)
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"broken": {Type: "stdio", Command: "zero-definitely-not-a-real-binary-xyz"},
}}, nil
},
}
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "check", "broken", "--json"}, &out, &errBuf, deps); code == exitSuccess {
t.Fatalf("mcp check --json must fail for a server that did not start; stdout=%q stderr=%q", out.String(), errBuf.String())
}
var payload struct {
ServerName string `json:"serverName"`
Status string `json:"status"`
Error string `json:"error"`
ToolCount int `json:"toolCount"`
}
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("decode mcp check --json output %q: %v", out.String(), err)
}
if payload.ServerName != "broken" {
t.Fatalf("serverName = %q, want the server that was checked", payload.ServerName)
}
if payload.Status != "unreachable" {
t.Fatalf("status = %q, want %q", payload.Status, "unreachable")
}
if !strings.Contains(payload.Error, "not reachable") {
t.Fatalf("error = %q, want it to say the server is not reachable", payload.Error)
}
// A reachable payload carries a tool count; an unreachable one must not
// imply zero tools were found, which reads as a server that simply exposes
// none.
if payload.ToolCount != 0 {
t.Fatalf("toolCount = %d, want it absent from an unreachable payload", payload.ToolCount)
}
}
-62
View File
@@ -5,8 +5,6 @@ import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@@ -14,7 +12,6 @@ import (
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/sessions"
)
@@ -112,65 +109,6 @@ func TestRunDoctorConnectivityProbesProvider(t *testing.T) {
}
}
func TestRunDoctorConnectivityUsesOAuthLogin(t *testing.T) {
tokenPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("ZERO_OAUTH_STORAGE", "file")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokenPath)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: tokenPath})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("xai"), oauth.Token{
AccessToken: "oauth-doctor-token",
ExpiresAt: time.Now().Add(time.Hour),
}); err != nil {
t.Fatalf("seed OAuth token: %v", err)
}
var gotAuth string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[]}`))
}))
defer server.Close()
profile := config.ProviderProfile{
Name: "xai",
CatalogID: "xai",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/v1",
Model: "grok-4",
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"doctor", "--connectivity"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return t.TempDir(), nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{
Provider: profile,
Providers: []config.ProviderProfile{profile},
ActiveProvider: profile.Name,
}, nil
},
probeProviderHealth: providerhealth.Probe,
now: fixedCLITime("2026-07-29T20:00:00Z"),
})
if exitCode != exitSuccess {
t.Fatalf("doctor exit = %d, want success; stderr=%q\n%s", exitCode, stderr.String(), stdout.String())
}
if gotAuth != "Bearer oauth-doctor-token" {
t.Fatalf("Authorization = %q, want OAuth bearer", gotAuth)
}
if !strings.Contains(stdout.String(), "[pass] provider.connectivity") {
t.Fatalf("doctor did not report passing connectivity:\n%s", stdout.String())
}
}
func TestRunDoctorConnectivityReportsProviderHealthFailure(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -19,7 +19,6 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
{id: "openai", name: "OpenAI", transport: "openai", defaultModel: "gpt-4.1"},
{id: "groq", name: "Groq", transport: "openai-compatible", defaultModel: "llama-3.3-70b-versatile"},
{id: "longcat", name: "LongCat", transport: "openai-compatible", defaultModel: "LongCat-2.0"},
{id: "fireworks", name: "Fireworks AI", transport: "openai-compatible", defaultModel: "accounts/fireworks/models/kimi-k2p7-code"},
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
{id: "ollama", name: "Ollama Local", transport: "openai-compatible", defaultModel: "llama3.1"},
}
+3 -3
View File
@@ -83,10 +83,10 @@ func runProvidersDetect(args []string, stdout io.Writer, stderr io.Writer, deps
func parseProviderDetectArgs(args []string) (providerDetectOptions, bool, error) {
options := providerDetectOptions{}
for _, arg := range args {
switch arg {
case "-h", "--help", "help":
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case "--json":
case arg == "--json":
options.json = true
default:
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
+9 -9
View File
@@ -244,19 +244,19 @@ func parseUsageArgs(args []string) (usageOptions, bool, error) {
func FormatReport(report usage.Report, insertions int, deletions int) string {
var builder strings.Builder
builder.WriteString("Usage report (cost is a reconstructed estimate)\n\n")
fmt.Fprintf(&builder, "%-12s %10s %14s %14s\n", "date", "requests", "tokens", "est. cost")
builder.WriteString(fmt.Sprintf("%-12s %10s %14s %14s\n", "date", "requests", "tokens", "est. cost"))
for _, bucket := range report.Buckets {
fmt.Fprintf(&builder, "%-12s %10d %14s %14s\n",
bucket.Date, bucket.Requests, groupThousands(bucket.TotalTokens), formatUSD(bucket.TotalCost))
builder.WriteString(fmt.Sprintf("%-12s %10d %14s %14s\n",
bucket.Date, bucket.Requests, groupThousands(bucket.TotalTokens), formatUSD(bucket.TotalCost)))
}
fmt.Fprintf(&builder, "\n%-12s %10d %14s %14s\n",
"total", report.Total.Requests, groupThousands(report.Total.TotalTokens), formatUSD(report.Total.TotalCost))
builder.WriteString(fmt.Sprintf("\n%-12s %10d %14s %14s\n",
"total", report.Total.Requests, groupThousands(report.Total.TotalTokens), formatUSD(report.Total.TotalCost)))
fmt.Fprintf(&builder, "\nnet LOC (estimate): +%d / -%d = %d\n",
insertions, deletions, report.NetLOC)
builder.WriteString(fmt.Sprintf("\nnet LOC (estimate): +%d / -%d = %d\n",
insertions, deletions, report.NetLOC))
if report.NetLOCPositive {
fmt.Fprintf(&builder, "tokens per net LOC: %.1f\n", report.TokensPerNetLOC)
fmt.Fprintf(&builder, "est. cost per net LOC: %s\n", formatUSD(report.CostPerNetLOC))
builder.WriteString(fmt.Sprintf("tokens per net LOC: %.1f\n", report.TokensPerNetLOC))
builder.WriteString(fmt.Sprintf("est. cost per net LOC: %s\n", formatUSD(report.CostPerNetLOC)))
} else {
builder.WriteString("tokens per net LOC: n/a (net LOC <= 0)\n")
builder.WriteString("est. cost per net LOC: n/a (net LOC <= 0)\n")
+16 -121
View File
@@ -8,7 +8,6 @@ import (
"os/exec"
"runtime"
"strings"
"sync"
"time"
)
@@ -18,11 +17,7 @@ func LoadProviderCommand(command string) (FileConfig, error) {
stdout, stderr, err := runProviderCommand(command, providerCommandTimeout)
if err != nil {
if errors.Is(err, errProviderCommandTimeout) {
// Report which phase ran out of time, and anything the command
// managed to say. This used to be a bare string with the underlying
// error and stderr both dropped, which left a user whose command was
// slow on a cold machine with nothing to act on.
return FileConfig{}, fmt.Errorf("provider command timed out after %s: %w%s", providerCommandTimeout, err, commandOutput(stderr))
return FileConfig{}, fmt.Errorf("provider command timed out after 5s")
}
return FileConfig{}, fmt.Errorf("provider command failed: %w%s", err, commandOutput(stderr))
}
@@ -45,111 +40,20 @@ func LoadProviderCommand(command string) (FileConfig, error) {
var errProviderCommandTimeout = errors.New("provider command timeout")
// providerCommandDrainGrace bounds how long the timeout path waits for cmd.Wait
// to return after the process tree has been terminated. Terminate normally makes
// that immediate; this exists so a descendant that refuses to die cannot extend
// a call whose deadline has already expired.
const providerCommandDrainGrace = 2 * time.Second
// syncBuffer serialises the command's I/O pump against reads from this package.
// On the timeout path runProviderCommand can return while cmd.Wait is still
// draining, so the buffers outlive the call and a plain bytes.Buffer would be
// read and written concurrently.
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
// snapshot copies out what has been written so far. A copy rather than the
// buffer's own slice, which the pump may still append to.
func (b *syncBuffer) snapshot() []byte {
b.mu.Lock()
defer b.mu.Unlock()
return append([]byte(nil), b.buf.Bytes()...)
}
// awaitWithGrace waits for done and gives up after grace, reporting whether the
// wait completed rather than expired.
//
// Split out from the timeout path so the bound can be asserted on its own. In
// place it is effectively untestable: Terminate kills the tree, so Wait returns
// promptly and an unbounded receive would behave identically in every test that
// is not pathological, which is exactly how the old unbounded receive survived.
func awaitWithGrace(done <-chan error, grace time.Duration) bool {
timer := time.NewTimer(grace)
defer timer.Stop()
select {
case <-done:
return true
case <-timer.C:
return false
}
}
// runProviderCommand runs a provider command under a real deadline.
//
// "Real" is the point: timeout used to bound only the wait, so the call could
// take arbitrarily longer than it. Two phases sat outside it. Starting the
// process ran before the clock began, and on Windows that is CREATE_SUSPENDED,
// job-object creation and assignment, and a system-wide thread snapshot, none of
// which is fast on a cold or contended machine. Draining after termination was
// unbounded as well. Both are inside the budget now, so the deadline is an upper
// bound rather than a floor.
func runProviderCommand(command string, timeout time.Duration) ([]byte, []byte, error) {
cmd := shellCommand(command)
// Bound the time Wait spends draining I/O pipes after the process exits,
// in case an orphaned descendant still holds the write ends open.
cmd.WaitDelay = time.Second
stdout := &syncBuffer{}
stderr := &syncBuffer{}
cmd.Stdout = stdout
cmd.Stderr = stderr
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
// Armed before the process is started, so process creation is inside the
// budget rather than ahead of it.
deadline := time.NewTimer(timeout)
defer deadline.Stop()
type startResult struct {
proc *commandProcess
err error
}
// Buffered, so the goroutine can finish and exit even when nobody is left
// waiting on it because the deadline already fired.
started := make(chan startResult, 1)
go func() {
proc, err := startCommandProcess(cmd)
started <- startResult{proc: proc, err: err}
}()
var proc *commandProcess
select {
case result := <-started:
if result.err != nil {
return stdout.snapshot(), stderr.snapshot(), result.err
}
proc = result.proc
case <-deadline.C:
// The start is still in flight and will eventually hand back whatever it
// created, so cleanup is handed off rather than abandoned. Wait reaps the
// process; without it a started-but-orphaned command becomes a zombie.
go func() {
result := <-started
if result.err != nil {
return
}
result.proc.Terminate()
_ = cmd.Wait()
result.proc.Close()
}()
return stdout.snapshot(), stderr.snapshot(), fmt.Errorf("%w: starting the command exceeded %s", errProviderCommandTimeout, timeout)
proc, err := startCommandProcess(cmd)
if err != nil {
return nil, nil, err
}
defer proc.Close()
@@ -158,6 +62,9 @@ func runProviderCommand(command string, timeout time.Duration) ([]byte, []byte,
done <- cmd.Wait()
}()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case err := <-done:
if err != nil {
@@ -170,25 +77,13 @@ func runProviderCommand(command string, timeout time.Duration) ([]byte, []byte,
proc.Terminate()
}
if errors.Is(err, exec.ErrWaitDelay) {
// Wrap rather than replace. A descendant holding the inherited pipes
// open and the command never finishing at all are different failures
// that both collapsed into the same bare sentinel, which left callers
// and tests unable to tell which one happened. Callers that only ask
// errors.Is(err, errProviderCommandTimeout) are unaffected, so the
// message LoadProviderCommand reports is unchanged.
return stdout.snapshot(), stderr.snapshot(), fmt.Errorf("%w: %w", errProviderCommandTimeout, err)
return stdout.Bytes(), stderr.Bytes(), errProviderCommandTimeout
}
return stdout.snapshot(), stderr.snapshot(), err
case <-deadline.C:
return stdout.Bytes(), stderr.Bytes(), err
case <-timer.C:
proc.Terminate()
// Bounded, unlike the bare receive this replaces. Terminate kills the
// tree so Wait almost always returns at once, but "almost always" was
// doing real work here: a tree that resists termination used to hold the
// call open indefinitely, long past the deadline that had just expired.
// Returning early leaves cmd.Wait running, which is why the buffers are
// synchronised.
awaitWithGrace(done, providerCommandDrainGrace)
return stdout.snapshot(), stderr.snapshot(), fmt.Errorf("%w: the command did not finish within %s", errProviderCommandTimeout, timeout)
<-done
return stdout.Bytes(), stderr.Bytes(), errProviderCommandTimeout
}
}
+27 -256
View File
@@ -3,7 +3,6 @@ package config
import (
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
@@ -92,29 +91,6 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
assertProcessTerminatedIfStarted(t, pidFile)
}
// providerCommandTestBudget bounds the two background-child tests below, and is
// deliberately NOT providerCommandTimeout. Those tests assert which error each
// cmd.Wait path yields and that the leftover descendant is killed; neither is a
// claim about how fast Windows can start a process. Driving them through
// LoadProviderCommand billed the fixture's own startup to the production 5s
// budget, so on a loaded runner the outer timer won the select in
// runProviderCommand: the nonzero-exit test saw a timeout instead of the exit
// status it asserts, and the timer's Terminate killed the background child
// before it recorded its PID, putting the PID file permanently out of reach no
// matter how long the assertion polled for it.
//
// This is a hang-breaker, not a tuned margin. Nothing in a healthy run waits on
// it, since both tests return about one WaitDelay after the fixture exits.
const providerCommandTestBudget = 60 * time.Second
// backgroundChildLifetime must exceed providerCommandTestBudget so that a child
// dying of old age is unobservable: any run where runProviderCommand has not
// returned yet trips the budget first. That is what lets both tests drop their
// old elapsed-versus-lifetime bounds. "Still alive when the call returned" is
// now true by construction, so a child found dead afterwards can only have been
// killed by proc.Terminate.
const backgroundChildLifetime = 120 * time.Second
// TestLoadProviderCommandTerminatesBackgroundChild covers a command that
// exits immediately but leaves a detached child holding the inherited
// stdout/stderr pipes open (e.g. `sleep 600 & exit`). cmd.Wait() only
@@ -122,22 +98,24 @@ const backgroundChildLifetime = 120 * time.Second
// leftover child instead of just returning and leaking it.
func TestLoadProviderCommandTerminatesBackgroundChild(t *testing.T) {
pidFile := filepath.Join(t.TempDir(), "bg.pid")
childLifetime := 10 * time.Second
command := writeCommand(t, commandScript{
Stdout: `{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}`,
BackgroundSleepSeconds: int(backgroundChildLifetime / time.Second),
BackgroundSleepSeconds: int(childLifetime / time.Second),
BackgroundPidFile: pidFile,
})
_, _, err := runProviderCommand(command, providerCommandTestBudget)
start := time.Now()
_, err := LoadProviderCommand(command)
elapsed := time.Since(start)
if err == nil {
t.Fatal("runProviderCommand() error = nil, want error from WaitDelay-bounded wait")
t.Fatal("LoadProviderCommand() error = nil, want error from WaitDelay-bounded wait")
}
// Name the exact path. The outer-timer path reports the bare sentinel, so
// matching only that, or the "timed out after 5s" text this test used to
// match, would also be satisfied by a slow runner taking precisely the path
// this test exists to rule out.
if !errors.Is(err, errProviderCommandTimeout) || !errors.Is(err, exec.ErrWaitDelay) {
t.Fatalf("error = %v, want errProviderCommandTimeout wrapping exec.ErrWaitDelay", err)
if !strings.Contains(err.Error(), "timed out after 5s") {
t.Fatalf("error = %q, want timeout", err.Error())
}
if elapsed >= childLifetime {
t.Fatalf("returned after %s, want before the background child finishes naturally after %s", elapsed, childLifetime)
}
assertProcessTerminated(t, pidFile)
}
@@ -150,56 +128,29 @@ func TestLoadProviderCommandTerminatesBackgroundChild(t *testing.T) {
// be terminated on that path.
func TestLoadProviderCommandTerminatesBackgroundChildOnFailure(t *testing.T) {
pidFile := filepath.Join(t.TempDir(), "bg-fail.pid")
childLifetime := 10 * time.Second
command := writeCommand(t, commandScript{
Stderr: "boom",
ExitCode: 7,
BackgroundSleepSeconds: int(backgroundChildLifetime / time.Second),
BackgroundSleepSeconds: int(childLifetime / time.Second),
BackgroundPidFile: pidFile,
})
_, _, err := runProviderCommand(command, providerCommandTestBudget)
// The shell's own exit status has to survive. A timeout here means the
// budget swallowed it, which is exactly what this test kept reporting on CI.
if errors.Is(err, errProviderCommandTimeout) {
t.Fatalf("error = %v, want the shell's exit status rather than a timeout", err)
start := time.Now()
_, err := LoadProviderCommand(command)
elapsed := time.Since(start)
if err == nil {
t.Fatal("LoadProviderCommand() error = nil, want failure from nonzero exit")
}
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error = %v, want *exec.ExitError from the nonzero exit", err)
if !strings.Contains(err.Error(), "exit status") {
t.Fatalf("error = %q, want exit status failure", err.Error())
}
if exitErr.ExitCode() != 7 {
t.Fatalf("exit code = %d, want 7", exitErr.ExitCode())
if elapsed >= childLifetime {
t.Fatalf("returned after %s, want before the background child finishes naturally after %s", elapsed, childLifetime)
}
assertProcessTerminated(t, pidFile)
}
func TestAssertProcessTerminatedWaitsForDelayedPIDFile(t *testing.T) {
pidFile := filepath.Join(t.TempDir(), "delayed.pid")
firstMissing := make(chan struct{})
writeDone := make(chan error, 1)
go func() {
<-firstMissing
tempFile := pidFile + ".tmp"
if err := os.WriteFile(tempFile, []byte("2147483647\n"), 0o600); err != nil {
writeDone <- err
return
}
writeDone <- os.Rename(tempFile, pidFile)
}()
missingObserved := false
assertProcessTerminatedAfterMissing(t, pidFile, func() {
missingObserved = true
close(firstMissing)
})
if !missingObserved {
t.Fatal("PID file existed before the missing-file polling path was exercised")
}
if err := <-writeDone; err != nil {
t.Fatalf("write delayed pid file: %v", err)
}
}
func assertProcessTerminatedIfStarted(t *testing.T, pidFile string) {
t.Helper()
@@ -216,41 +167,16 @@ func assertProcessTerminatedIfStarted(t *testing.T, pidFile string) {
func assertProcessTerminated(t *testing.T, pidFile string) {
t.Helper()
assertProcessTerminatedAfterMissing(t, pidFile, nil)
}
func assertProcessTerminatedAfterMissing(t *testing.T, pidFile string, onMissing func()) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
var data []byte
var err error
missingObserved := false
for time.Now().Before(deadline) {
data, err = os.ReadFile(pidFile)
if err == nil {
break
}
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("read sleeper pid file: %v", err)
}
if !missingObserved {
missingObserved = true
if onMissing != nil {
onMissing()
}
}
time.Sleep(10 * time.Millisecond)
}
data, err := os.ReadFile(pidFile)
if err != nil {
t.Fatalf("read sleeper pid file before timeout: %v", err)
t.Fatalf("read sleeper pid file: %v", err)
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
t.Fatalf("parse sleeper pid %q: %v", data, err)
}
deadline = time.Now().Add(3 * time.Second)
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if !processAlive(pid) {
return
@@ -327,33 +253,8 @@ func writeCommand(t *testing.T, script commandScript) string {
"Set-Content -LiteralPath '" + psSingleQuote(readyFile) + "' -Value ready -Encoding Ascii; " +
"Start-Sleep -Seconds " + itoa(script.BackgroundSleepSeconds)
lines = append(lines, "start /B powershell -NoProfile -Command \""+bgSleep+"\"")
// Wait for the child to record its PID before exiting, so the PID is
// on disk while the tree is still alive: runProviderCommand kills the
// tree one WaitDelay after the script exits, and a child that has not
// written by then never will.
//
// This waits in cmd itself. Spawning a second PowerShell just to poll
// cost another interpreter cold start, and that start is pure overhead
// added AFTER the PID is already written. On a loaded runner it pushed
// the script's exit past the 5s provider-command timeout, so the
// nonzero-exit test saw a timeout instead of the exit status it
// asserts. The wait is also bounded now: giving up and letting the PID
// assertion fail is far more diagnosable than blocking until the
// timeout fires and reporting an unrelated error. Each iteration is
// about a second, since `ping -n 2` sleeps between its two echoes,
// so the ceiling is roughly 15s: far longer than any cold PowerShell
// start seen on a runner, and far shorter than
// providerCommandTestBudget, so giving up surfaces a legible
// PID-file failure rather than a timeout that hides the real cause.
lines = append(lines,
":zeroWaitReady",
"if exist \""+readyFile+"\" goto zeroReady",
"set /a ZERO_READY_TRIES+=1",
"if %ZERO_READY_TRIES% GEQ 15 goto zeroReady",
"ping -n 2 127.0.0.1 >nul",
"goto zeroWaitReady",
":zeroReady",
)
waitForReady := "while (-not (Test-Path -LiteralPath '" + psSingleQuote(readyFile) + "')) { Start-Sleep -Milliseconds 10 }"
lines = append(lines, "powershell -NoProfile -Command \""+waitForReady+"\"")
}
lines = append(lines, "exit /b "+itoa(script.ExitCode))
if err := os.WriteFile(path, []byte(strings.Join(lines, "\r\n")), 0o700); err != nil {
@@ -414,133 +315,3 @@ func itoa(value int) string {
}
return string(digits[index:])
}
// The whole point of #811: providerCommandTimeout has to be an upper bound on
// how long the call takes, not a floor. Process creation used to run before the
// clock started and the post-termination drain was unbounded, so a slow or
// contended machine could block startup for far longer than the error text
// promised.
//
// This one guards the end-to-end contract against gross regressions. It is NOT
// the assertion that pins the drain bound: Terminate kills the tree, so Wait
// returns promptly and this passes even with the drain unbounded. That property
// is pinned by TestAwaitWithGraceGivesUp, and the start-phase bound by
// TestRunProviderCommandCountsProcessStartAgainstTheDeadline.
func TestRunProviderCommandBoundsTotalDuration(t *testing.T) {
pidFile := filepath.Join(t.TempDir(), "bound.pid")
command := writeCommand(t, commandScript{SleepSeconds: 60, PidFile: pidFile})
const budget = 2 * time.Second
start := time.Now()
_, _, err := runProviderCommand(command, budget)
elapsed := time.Since(start)
if !errors.Is(err, errProviderCommandTimeout) {
t.Fatalf("error = %v, want the timeout sentinel", err)
}
// Budget, plus the bounded drain, plus room for a loaded runner. A failure
// here means some phase is outside the deadline again, which is the bug.
ceiling := budget + providerCommandDrainGrace + 8*time.Second
if elapsed > ceiling {
t.Fatalf("returned after %s with a %s budget; the timeout is not bounding the call (ceiling %s)", elapsed, budget, ceiling)
}
assertProcessTerminatedIfStarted(t, pidFile)
}
// The timeout error has to carry something an operator can act on. It used to be
// a bare string with the underlying error and the command's stderr both dropped,
// so a user whose provider command was slow got no clue which phase ran out or
// what the command had said.
func TestLoadProviderCommandTimeoutReportsDiagnostics(t *testing.T) {
command := writeCommand(t, commandScript{
Stderr: "provider is warming up",
SleepSeconds: 30,
PidFile: filepath.Join(t.TempDir(), "diag.pid"),
})
_, err := LoadProviderCommand(command)
if err == nil {
t.Fatal("LoadProviderCommand() error = nil, want timeout")
}
if !strings.Contains(err.Error(), "timed out") {
t.Fatalf("error = %q, want a timeout message", err.Error())
}
// Wrapped, so callers can still classify it.
if !errors.Is(err, errProviderCommandTimeout) {
t.Fatalf("error = %v, want errors.Is against the timeout sentinel", err)
}
// And it must say which phase, rather than only that time ran out.
if !strings.Contains(err.Error(), "did not finish") && !strings.Contains(err.Error(), "starting the command") {
t.Fatalf("error = %q, want it to name the phase that exceeded the deadline", err.Error())
}
}
// The output buffers are written by cmd.Wait's I/O pump, which keeps running
// after the timeout path has returned them to the caller, so a plain
// bytes.Buffer would be read and written concurrently. Asserted on the buffer
// directly: driving it end to end does not reliably produce the overlap, because
// a terminated command usually stops writing before the snapshot is taken, so
// such a test passes with the locking removed and proves nothing.
//
// Only meaningful under -race, which CI runs.
func TestSyncBufferAllowsConcurrentWriteAndSnapshot(t *testing.T) {
buffer := &syncBuffer{}
writing := make(chan struct{})
go func() {
defer close(writing)
for i := 0; i < 2000; i++ {
_, _ = buffer.Write([]byte("x"))
}
}()
for i := 0; i < 2000; i++ {
_ = buffer.snapshot()
}
<-writing
if len(buffer.snapshot()) != 2000 {
t.Fatalf("buffer holds %d bytes, want 2000", len(buffer.snapshot()))
}
}
// The drain after termination must be bounded. Asserted directly on the helper,
// because in place it is untestable without a tree that refuses to die: Wait
// normally returns at once, which is precisely why an unbounded receive sat
// there unnoticed. A channel that never fires is the pathological case made
// cheap.
func TestAwaitWithGraceGivesUp(t *testing.T) {
never := make(chan error)
start := time.Now()
if awaitWithGrace(never, 50*time.Millisecond) {
t.Fatal("reported completion for a wait that never finished")
}
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Fatalf("gave up after %s, want roughly the grace period", elapsed)
}
// Still reports completion when the wait does finish, or the timeout path
// would always claim the tree hung.
done := make(chan error, 1)
done <- nil
if !awaitWithGrace(done, time.Second) {
t.Fatal("reported expiry for a wait that had already finished")
}
}
// Process creation has to be inside the budget. It used to run before the clock
// started, and on Windows that phase is CREATE_SUSPENDED plus job-object setup
// plus a system-wide thread snapshot, none of it fast on a contended machine.
//
// A budget that is already spent when the call begins can only be exceeded
// during the start, so the phase named in the error is what distinguishes the
// two orderings: with the clock started afterwards, the start would always
// complete first and the failure would come from the wait instead.
func TestRunProviderCommandCountsProcessStartAgainstTheDeadline(t *testing.T) {
command := writeCommand(t, commandScript{Stdout: `{"name":"cmd","provider":"openai","apiKey":"sk-x","model":"m"}`})
_, _, err := runProviderCommand(command, time.Nanosecond)
if !errors.Is(err, errProviderCommandTimeout) {
t.Fatalf("error = %v, want the timeout sentinel", err)
}
if !strings.Contains(err.Error(), "starting the command") {
t.Fatalf("error = %q, want the start phase to be the one that exceeded the deadline; "+
"anything else means process creation is outside the budget again", err.Error())
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ func mergeMCPServer(base MCPServerConfig, next MCPServerConfig, canReenable bool
// server the user explicitly enabled. The only layer allowed to
// override an explicit higher-scope decision is the CLI override scope
// (canReenable=true).
if canReenable || !baseDisabledSet || baseDisabled == next.Disabled {
if canReenable || !(baseDisabledSet && baseDisabled != next.Disabled) {
base.Disabled = next.Disabled
}
}
+13 -44
View File
@@ -103,7 +103,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
// here rather than in mergeConfig because that helper is shared with the
// trusted global-config merge, which must keep honouring the setting.
commandConfig.Sandbox.Enabled = nil
commandConfig.CrossSessionInbound = ""
mergeConfig(&cfg, commandConfig)
}
@@ -141,13 +140,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
return ResolvedConfig{}, fmt.Errorf("invalid notify.focusMode %q: expected unfocused, always, or focused", focusMode)
}
}
if inbound := strings.TrimSpace(cfg.CrossSessionInbound); inbound != "" {
switch inbound {
case "accept", "hold", "refuse":
default:
return ResolvedConfig{}, fmt.Errorf("invalid crossSessionInbound %q: expected accept, hold, or refuse", inbound)
}
}
if err := validateSTTConfig(cfg.STT); err != nil {
return ResolvedConfig{}, err
}
@@ -162,20 +154,19 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
}
return ResolvedConfig{
ActiveProvider: active.Name,
Providers: providers,
Provider: active,
MaxTurns: cfg.MaxTurns,
MCP: cfg.MCP,
Sandbox: cfg.Sandbox,
Notify: cfg.Notify,
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Preferences: cfg.Preferences,
KeyBindings: cfg.KeyBindings,
LocalControl: cfg.LocalControl,
STT: cfg.STT,
CrossSessionInbound: cfg.CrossSessionInbound,
ActiveProvider: active.Name,
Providers: providers,
Provider: active,
MaxTurns: cfg.MaxTurns,
MCP: cfg.MCP,
Sandbox: cfg.Sandbox,
Notify: cfg.Notify,
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Preferences: cfg.Preferences,
KeyBindings: cfg.KeyBindings,
LocalControl: cfg.LocalControl,
STT: cfg.STT,
}, nil
}
@@ -276,9 +267,6 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
mergeSTTConfig(&dst.STT, src.STT)
if inbound := strings.TrimSpace(src.CrossSessionInbound); inbound != "" {
dst.CrossSessionInbound = inbound
}
}
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
@@ -337,9 +325,6 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
if src.Swarm.MaxTeamSize != 0 {
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
}
if inbound := strings.TrimSpace(src.CrossSessionInbound); inboundPolicyRank(inbound) > inboundPolicyRank(dst.CrossSessionInbound) {
dst.CrossSessionInbound = inbound
}
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
// Local control is intentionally user-config/override only. A cloned project
// must not be able to make browser, desktop, or terminal automation tools
@@ -742,9 +727,6 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
mergeLocalControlConfig(&cfg.LocalControl, overrides.LocalControl)
mergeKeyBindings(&cfg.KeyBindings, overrides.KeyBindings)
mergeSTTConfig(&cfg.STT, overrides.STT)
if inbound := strings.TrimSpace(overrides.CrossSessionInbound); inbound != "" {
cfg.CrossSessionInbound = inbound
}
for _, provider := range overrides.Providers {
mergeProvider(cfg, provider)
}
@@ -754,19 +736,6 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
mergeMCPConfig(&cfg.MCP, overrides.MCP, true)
}
func inboundPolicyRank(policy string) int {
switch strings.TrimSpace(policy) {
case "refuse":
return 3
case "hold":
return 2
case "accept":
return 0
default:
return 1 // permission-mode parity
}
}
func mergeLocalControlConfig(dst *LocalControlConfig, src LocalControlConfig) {
if src.enabledSet {
dst.Enabled = src.Enabled
-37
View File
@@ -2199,40 +2199,3 @@ func TestResolveRequiresModelErrorIsSetupFixable(t *testing.T) {
t.Fatalf("error leaked API key: %q", err.Error())
}
}
func TestCrossSessionInboundProjectConfigCanOnlyTighten(t *testing.T) {
tests := []struct {
name string
user string
project string
want string
}{
{name: "hold tightens accept", user: "accept", project: "hold", want: "hold"},
{name: "refuse stays refuse", user: "refuse", project: "accept", want: "refuse"},
{name: "accept cannot weaken parity", project: "accept", want: ""},
{name: "refuse tightens parity", project: "refuse", want: "refuse"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cfg := FileConfig{CrossSessionInbound: test.user}
if err := mergeProjectConfig(&cfg, FileConfig{CrossSessionInbound: test.project}); err != nil {
t.Fatal(err)
}
if cfg.CrossSessionInbound != test.want {
t.Fatalf("crossSessionInbound = %q, want %q", cfg.CrossSessionInbound, test.want)
}
})
}
}
func TestResolveRejectsInvalidCrossSessionInbound(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "openai",
"providers": [{"name":"openai","provider_kind":"openai","apiKey":"sk-x","model":"gpt-5"}],
"crossSessionInbound": "sometimes"
}`)
_, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err == nil || !strings.Contains(err.Error(), "expected accept, hold, or refuse") {
t.Fatalf("error = %v", err)
}
}
+74 -81
View File
@@ -113,7 +113,7 @@ type PreferencesConfig struct {
// ZERO_THEME, so a /theme choice survives restart. Empty = unset (defaults auto).
Theme string `json:"theme,omitempty"`
// Recaps is a tri-state: nil (unset) defaults to ON; an explicit false means
// the user turned idle recaps off. A *bool is its own tri-state, so no
// the user turned post-turn recaps off. A *bool is its own tri-state, so no
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
Recaps *bool `json:"recaps,omitempty"`
}
@@ -130,7 +130,7 @@ type RecentModelEntry struct {
// MaxRecentModels caps the persisted/displayed recent-selection history.
const MaxRecentModels = 5
// RecapsEnabled reports whether idle recaps are on. Unset defaults to ON.
// RecapsEnabled reports whether post-turn recaps are on. Unset defaults to ON.
func (p PreferencesConfig) RecapsEnabled() bool {
return p.Recaps == nil || *p.Recaps
}
@@ -340,49 +340,46 @@ func (cfg *ToolsConfig) UnmarshalJSON(data []byte) error {
}
type FileConfig struct {
ActiveProvider string `json:"activeProvider,omitempty"`
Providers []ProviderProfile `json:"providers,omitempty"`
MaxTurns int `json:"maxTurns,omitempty"`
MCP MCPConfig `json:"mcp,omitempty"`
Sandbox SandboxConfig `json:"sandbox,omitempty"`
Notify NotifyConfig `json:"notify,omitempty"`
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
LocalControl LocalControlConfig `json:"localControl,omitempty"`
STT STTConfig `json:"stt,omitempty"`
CrossSessionInbound string `json:"crossSessionInbound,omitempty"`
ActiveProvider string `json:"activeProvider,omitempty"`
Providers []ProviderProfile `json:"providers,omitempty"`
MaxTurns int `json:"maxTurns,omitempty"`
MCP MCPConfig `json:"mcp,omitempty"`
Sandbox SandboxConfig `json:"sandbox,omitempty"`
Notify NotifyConfig `json:"notify,omitempty"`
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
LocalControl LocalControlConfig `json:"localControl,omitempty"`
STT STTConfig `json:"stt,omitempty"`
}
func (cfg FileConfig) MarshalJSON() ([]byte, error) {
type rawConfig struct {
ActiveProvider string `json:"activeProvider,omitempty"`
Providers []ProviderProfile `json:"providers,omitempty"`
MaxTurns int `json:"maxTurns,omitempty"`
MCP MCPConfig `json:"mcp,omitempty"`
Sandbox SandboxConfig `json:"sandbox,omitempty"`
Notify NotifyConfig `json:"notify,omitempty"`
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
STT *STTConfig `json:"stt,omitempty"`
CrossSessionInbound string `json:"crossSessionInbound,omitempty"`
ActiveProvider string `json:"activeProvider,omitempty"`
Providers []ProviderProfile `json:"providers,omitempty"`
MaxTurns int `json:"maxTurns,omitempty"`
MCP MCPConfig `json:"mcp,omitempty"`
Sandbox SandboxConfig `json:"sandbox,omitempty"`
Notify NotifyConfig `json:"notify,omitempty"`
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
STT *STTConfig `json:"stt,omitempty"`
}
raw := rawConfig{
ActiveProvider: cfg.ActiveProvider,
Providers: cfg.Providers,
MaxTurns: cfg.MaxTurns,
MCP: cfg.MCP,
Sandbox: cfg.Sandbox,
Notify: cfg.Notify,
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Preferences: cfg.Preferences,
KeyBindings: cfg.KeyBindings,
CrossSessionInbound: cfg.CrossSessionInbound,
ActiveProvider: cfg.ActiveProvider,
Providers: cfg.Providers,
MaxTurns: cfg.MaxTurns,
MCP: cfg.MCP,
Sandbox: cfg.Sandbox,
Notify: cfg.Notify,
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Preferences: cfg.Preferences,
KeyBindings: cfg.KeyBindings,
}
if !cfg.LocalControl.Empty() {
raw.LocalControl = &cfg.LocalControl
@@ -407,35 +404,33 @@ type ResolveOptions struct {
}
type Overrides struct {
ActiveProvider string
Providers []ProviderProfile
Provider ProviderProfile
MaxTurns int
MCP MCPConfig
Sandbox SandboxConfig
Notify NotifyConfig
Tools ToolsConfig
KeyBindings KeyBindingsConfig
LocalControl LocalControlConfig
STT STTConfig
CrossSessionInbound string
ActiveProvider string
Providers []ProviderProfile
Provider ProviderProfile
MaxTurns int
MCP MCPConfig
Sandbox SandboxConfig
Notify NotifyConfig
Tools ToolsConfig
KeyBindings KeyBindingsConfig
LocalControl LocalControlConfig
STT STTConfig
}
type ResolvedConfig struct {
ActiveProvider string
Providers []ProviderProfile
Provider ProviderProfile
MaxTurns int
MCP MCPConfig
Sandbox SandboxConfig
Notify NotifyConfig
Tools ToolsConfig
Swarm SwarmConfig
Preferences PreferencesConfig
KeyBindings KeyBindingsConfig
LocalControl LocalControlConfig
STT STTConfig
CrossSessionInbound string
ActiveProvider string
Providers []ProviderProfile
Provider ProviderProfile
MaxTurns int
MCP MCPConfig
Sandbox SandboxConfig
Notify NotifyConfig
Tools ToolsConfig
Swarm SwarmConfig
Preferences PreferencesConfig
KeyBindings KeyBindingsConfig
LocalControl LocalControlConfig
STT STTConfig
}
type MCPConfig struct {
@@ -485,21 +480,20 @@ type MCPOAuthConfig struct {
func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
type rawConfig struct {
ActiveProvider string `json:"activeProvider"`
Providers []ProviderProfile `json:"providers"`
MaxTurns int `json:"maxTurns"`
MCP MCPConfig `json:"mcp"`
Sandbox SandboxConfig `json:"sandbox"`
Notify NotifyConfig `json:"notify"`
Tools ToolsConfig `json:"tools"`
Swarm SwarmConfig `json:"swarm"`
Preferences PreferencesConfig `json:"preferences"`
KeyBindings KeyBindingsConfig `json:"keybindings"`
LocalControl LocalControlConfig `json:"localControl"`
STT STTConfig `json:"stt"`
CrossSessionInbound string `json:"crossSessionInbound"`
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
ActiveProvider string `json:"activeProvider"`
Providers []ProviderProfile `json:"providers"`
MaxTurns int `json:"maxTurns"`
MCP MCPConfig `json:"mcp"`
Sandbox SandboxConfig `json:"sandbox"`
Notify NotifyConfig `json:"notify"`
Tools ToolsConfig `json:"tools"`
Swarm SwarmConfig `json:"swarm"`
Preferences PreferencesConfig `json:"preferences"`
KeyBindings KeyBindingsConfig `json:"keybindings"`
LocalControl LocalControlConfig `json:"localControl"`
STT STTConfig `json:"stt"`
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
}
var raw rawConfig
@@ -526,7 +520,6 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
cfg.KeyBindings = raw.KeyBindings
cfg.LocalControl = raw.LocalControl
cfg.STT = raw.STT
cfg.CrossSessionInbound = raw.CrossSessionInbound
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
cfg.MCP.Servers = map[string]MCPServerConfig{}
}
+1 -1
View File
@@ -546,7 +546,7 @@ func SetRecentModels(path string, entries []RecentModelEntry) (FileConfig, error
return cfg, nil
}
// SetRecapsEnabled persists the idle recap preference, mirroring
// SetRecapsEnabled persists the post-turn recap preference, mirroring
// SetFavoriteModels (read-modify-atomic-write).
func SetRecapsEnabled(path string, enabled bool) (FileConfig, error) {
path = strings.TrimSpace(path)
+9 -40
View File
@@ -40,7 +40,6 @@ type Options struct {
Provider config.ProviderProfile
Registry *tools.Registry
ContextWindow int
DeferThreshold int
ProjectContextFiles []string
}
@@ -57,8 +56,6 @@ type Report struct {
FreeTokens int `json:"freeTokens"`
UsedFraction float64 `json:"usedFraction"`
ToolCount int `json:"toolCount"`
AvailableToolCount int `json:"availableToolCount,omitempty"`
DeferredToolCount int `json:"deferredToolCount,omitempty"`
ProjectGuidelineFile string `json:"projectGuidelineFile,omitempty"`
Categories []Category `json:"categories"`
}
@@ -116,10 +113,8 @@ func Build(options Options) (Report, error) {
categories = append(categories, category(CategoryWorkspaceMap, "Workspace map", estimateTextTokens(workspaceMap), report.ContextWindow))
}
toolCount, availableTools, deferredTools, toolTokens := estimateRegistryTools(options.Registry, options.DeferThreshold)
toolCount, toolTokens := estimateRegistryTools(options.Registry)
report.ToolCount = toolCount
report.AvailableToolCount = availableTools
report.DeferredToolCount = deferredTools
if toolTokens > 0 {
categories = append(categories, category(CategoryTools, "Tools", toolTokens, report.ContextWindow))
}
@@ -180,11 +175,7 @@ func Format(report Report) string {
lines = append(lines, fmt.Sprintf("usage: %s tokens (context window unknown)", compactNumber(report.UsedTokens)))
}
if report.ToolCount > 0 {
toolLine := fmt.Sprintf("tools: %d exposed", report.ToolCount)
if report.AvailableToolCount > report.ToolCount {
toolLine += fmt.Sprintf(", %d available, %d deferred", report.AvailableToolCount, report.DeferredToolCount)
}
lines = append(lines, toolLine)
lines = append(lines, fmt.Sprintf("tools: %d", report.ToolCount))
}
if report.ProjectGuidelineFile != "" {
lines = append(lines, "project_guidelines: "+report.ProjectGuidelineFile)
@@ -221,51 +212,29 @@ func category(key string, name string, tokens int, contextWindow int) Category {
return cat
}
func estimateRegistryTools(registry *tools.Registry, deferThreshold int) (int, int, int, int) {
func estimateRegistryTools(registry *tools.Registry) (int, int) {
if registry == nil {
return 0, 0, 0, 0
return 0, 0
}
all := registry.All()
sort.Slice(all, func(left int, right int) bool {
return all[left].Name() < all[right].Name()
})
var visible []tools.Tool
var deferred []tools.Tool
total := 0
count := 0
for _, tool := range all {
if tool.Safety().Permission == tools.PermissionDeny {
continue
}
visible = append(visible, tool)
if tools.IsDeferred(tool) {
deferred = append(deferred, tool)
}
}
active := deferThreshold > 0 && len(deferred) >= deferThreshold
exposed := visible
if active {
exposed = make([]tools.Tool, 0, len(visible)-len(deferred)+1)
for _, tool := range visible {
if !tools.IsDeferred(tool) && tool.Name() != tools.ToolSearchToolName {
exposed = append(exposed, tool)
}
}
exposed = append(exposed, tools.NewToolSearchTool(registry))
}
total := 0
for _, tool := range exposed {
count++
total += estimateTextTokens(tool.Name())
description := tool.Description()
if active && tool.Name() == tools.ToolSearchToolName {
description = tools.BuildToolSearchDescription(deferred)
}
total += estimateTextTokens(description)
total += estimateTextTokens(tool.Description())
if encoded, err := json.Marshal(tool.Parameters()); err == nil {
total += estimateTextTokens(string(encoded))
}
total += toolDefinitionOverheadTokens
}
return len(exposed), len(visible), len(deferred), total
return count, total
}
func readProjectGuidelines(root string, names []string) (string, string) {
@@ -130,35 +130,6 @@ func TestBuildHasStableJSONContractAndCategoryMath(t *testing.T) {
}
}
func TestBuildReportsInitialDeferredToolSurface(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
for _, tool := range tools.CoreToolsScoped(root, nil) {
registry.Register(tool)
}
eager, err := Build(Options{WorkspaceRoot: root, Registry: registry})
if err != nil {
t.Fatal(err)
}
deferred, err := Build(Options{WorkspaceRoot: root, Registry: registry, DeferThreshold: 3})
if err != nil {
t.Fatal(err)
}
if deferred.DeferredToolCount < 3 {
t.Fatalf("DeferredToolCount = %d, want at least 3", deferred.DeferredToolCount)
}
if deferred.AvailableToolCount != eager.ToolCount {
t.Fatalf("available tools = %d, want eager total %d", deferred.AvailableToolCount, eager.ToolCount)
}
if deferred.ToolCount >= deferred.AvailableToolCount {
t.Fatalf("initial exposed tools = %d, available = %d; expected a smaller initial surface", deferred.ToolCount, deferred.AvailableToolCount)
}
if categoryByKey(deferred, CategoryTools).Tokens >= categoryByKey(eager, CategoryTools).Tokens {
t.Fatalf("deferred tool schemas did not reduce the initial context")
}
}
func TestBuildClampsFreeBudgetWhenOverContextWindow(t *testing.T) {
root := t.TempDir()
writeTestFile(t, root, "AGENTS.md", strings.Repeat("very large project rule\n", 1000))
+1 -1
View File
@@ -100,7 +100,7 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha
}
default:
}
return compose(), fmt.Errorf("Deepgram stream error: %w", err) //nolint:staticcheck // Preserve established user-facing error text.
return compose(), fmt.Errorf("Deepgram stream error: %w", err)
}
if typ != websocket.MessageText {
continue
+18 -62
View File
@@ -2,7 +2,6 @@ package imageinput
import (
"bytes"
"errors"
"fmt"
"net/http"
"os"
@@ -17,13 +16,6 @@ import (
// clipboard, or (nil, "", nil) when the clipboard has no image. Called when
// text clipboard is empty (the user pasted a screenshot). The media type is
// sniffed from the bytes, not trusted from the clipboard.
// ErrClipboardImageUnreadable reports that the clipboard holds an image the host
// has no way to extract. It is deliberately distinct from "no image on the
// clipboard", which is an ordinary no-op: this one is actionable, and silence is
// the wrong response to it.
var ErrClipboardImageUnreadable = errors.New(
"the clipboard holds an image but no helper on this host can read it; on macOS install pngpaste (brew install pngpaste) or a Python with pyobjc")
func ReadClipboardImage() ([]byte, string, error) {
data, err := readClipboardImageBytes()
if err != nil {
@@ -103,71 +95,35 @@ func readClipboardImageWindows() ([]byte, error) {
// readClipboardImageDarwin uses osascript to check for and read a clipboard
// image. Returns (nil, nil) when no image is present.
// darwinClipboardInfo reports what the pasteboard is currently offering, and
// darwinClipboardExtract pulls the PNG bytes out of it.
//
// Both are vars so the two-step logic below can be driven in a test. The step
// that matters, "the clipboard holds an image and nothing here can extract it",
// only happens when the first succeeds and the second fails, which cannot be
// arranged by stubbing the caller: a test that substitutes ReadClipboardImage
// asserts the plumbing around this function rather than the decision inside it,
// and stays green with that decision reverted.
var (
darwinClipboardInfo = func() (string, error) {
out, err := exec.Command("sh", "-c", `osascript -e 'clipboard info'`).Output()
return string(out), err
func readClipboardImageDarwin() ([]byte, error) {
// Check clipboard info for image classes.
check := `osascript -e 'clipboard info'`
out, err := exec.Command("sh", "-c", check).Output()
if err != nil {
return nil, nil
}
darwinClipboardExtract = func() ([]byte, error) {
// pngpaste if available, falling back to a Python one-liner. Neither
// ships with macOS: pngpaste is Homebrew-only and Apple dropped the
// bundled PyObjC, so a stock Mac fails here every time.
cmd := exec.Command("sh", "-c", `pngpaste - 2>/dev/null || python3 -c "
info := string(out)
if !strings.Contains(info, "PNG") && !strings.Contains(info, "JPEG") && !strings.Contains(info, "TIFF") && !strings.Contains(info, "GIF") {
return nil, nil
}
// Write clipboard image to a temp file via AppleScript, then read it.
// Using pngpaste if available, falling back to a Python one-liner.
cmd := exec.Command("sh", "-c", `pngpaste - 2>/dev/null || python3 -c "
import AppKit, sys
pb = AppKit.NSPasteboard.generalPasteboard()
data = pb.dataForType_(AppKit.NSPasteboardTypePNG)
if data:
sys.stdout.buffer.write(data.bytes())
"`)
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return nil, err
}
return stdout.Bytes(), nil
}
)
// darwinClipboardOffersImage reports whether the pasteboard info line names an
// image class.
func darwinClipboardOffersImage(info string) bool {
for _, class := range []string{"PNG", "JPEG", "TIFF", "GIF"} {
if strings.Contains(info, class) {
return true
}
}
return false
}
func readClipboardImageDarwin() ([]byte, error) {
info, err := darwinClipboardInfo()
if err != nil {
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return nil, nil
}
if !darwinClipboardOffersImage(info) {
if stdout.Len() == 0 {
return nil, nil
}
// Past this point the clipboard is KNOWN to hold an image, because the probe
// above said so. A failure here is therefore not "no image", it is "there is
// an image and nothing on this host can extract it". Reporting that as
// absence is what left users staring at a paste that silently did nothing.
data, err := darwinClipboardExtract()
if err != nil {
return nil, ErrClipboardImageUnreadable
}
if len(data) == 0 {
return nil, ErrClipboardImageUnreadable
}
return data, nil
return stdout.Bytes(), nil
}
// readClipboardImageLinux tries wl-paste (Wayland) then xclip (X11) to read
@@ -1,84 +0,0 @@
package imageinput
import (
"errors"
"testing"
)
// Drives readClipboardImageDarwin's own decision rather than the plumbing around
// it. Both shell steps are substituted, so this runs on any platform and, more
// importantly, actually reaches the branch under test: a test that stubs
// ReadClipboardImage instead stays green with the production change reverted,
// which is how the first version of this coverage slipped through.
func TestReadClipboardImageDarwinDistinguishesUnreadableFromAbsent(t *testing.T) {
restore := func() func() {
info, extract := darwinClipboardInfo, darwinClipboardExtract
return func() { darwinClipboardInfo, darwinClipboardExtract = info, extract }
}()
t.Cleanup(restore)
const pngInfo = `«class PNGf», «class 8BPS»`
t.Run("image present but no extractor is actionable", func(t *testing.T) {
darwinClipboardInfo = func() (string, error) { return pngInfo, nil }
darwinClipboardExtract = func() ([]byte, error) { return nil, errors.New("exit status 127") }
data, err := readClipboardImageDarwin()
if !errors.Is(err, ErrClipboardImageUnreadable) {
t.Fatalf("err = %v, want ErrClipboardImageUnreadable; a stock Mac gets silence instead of a remedy", err)
}
if data != nil {
t.Fatalf("data = %q, want none", data)
}
})
t.Run("extractor producing nothing is also actionable", func(t *testing.T) {
darwinClipboardInfo = func() (string, error) { return pngInfo, nil }
darwinClipboardExtract = func() ([]byte, error) { return nil, nil }
if _, err := readClipboardImageDarwin(); !errors.Is(err, ErrClipboardImageUnreadable) {
t.Fatalf("err = %v, want ErrClipboardImageUnreadable for an extractor that exits 0 with no bytes", err)
}
})
// The other half of the distinction, and the reason this cannot simply always
// report an error: an empty clipboard is an ordinary no-op, and turning that
// into a notice would make every stray Ctrl+V nag the user.
t.Run("no image on the clipboard stays silent", func(t *testing.T) {
darwinClipboardInfo = func() (string, error) { return `«class utf8»`, nil }
darwinClipboardExtract = func() ([]byte, error) {
t.Fatal("extractor ran despite the clipboard offering no image")
return nil, nil
}
data, err := readClipboardImageDarwin()
if err != nil || data != nil {
t.Fatalf("data=%q err=%v, want a silent no-op", data, err)
}
})
t.Run("probe failure stays silent", func(t *testing.T) {
darwinClipboardInfo = func() (string, error) { return "", errors.New("osascript missing") }
darwinClipboardExtract = func() ([]byte, error) {
t.Fatal("extractor ran despite the info probe failing")
return nil, nil
}
if data, err := readClipboardImageDarwin(); err != nil || data != nil {
t.Fatalf("data=%q err=%v, want a silent no-op", data, err)
}
})
t.Run("working extractor returns the bytes", func(t *testing.T) {
darwinClipboardInfo = func() (string, error) { return pngInfo, nil }
darwinClipboardExtract = func() ([]byte, error) { return []byte("png-bytes"), nil }
data, err := readClipboardImageDarwin()
if err != nil {
t.Fatalf("err = %v, want none", err)
}
if string(data) != "png-bytes" {
t.Fatalf("data = %q, want the extractor's bytes", data)
}
})
}
+2 -2
View File
@@ -48,7 +48,7 @@ func buildMinimalPDF(text string) []byte {
buf.WriteString("0 " + strconv.Itoa(len(offsets)+1) + "\n")
buf.WriteString("0000000000 65535 f \n")
for _, off := range offsets {
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
buf.WriteString(fmt.Sprintf("%010d 00000 n \n", off))
}
buf.WriteString("trailer\n<< /Size " + strconv.Itoa(len(offsets)+1) + " /Root 1 0 R >>\n")
buf.WriteString("startxref\n" + strconv.Itoa(xrefStart) + "\n%%EOF\n")
@@ -277,7 +277,7 @@ func buildEmptyTextPDF() []byte {
buf.WriteString("0 " + strconv.Itoa(len(offsets)+1) + "\n")
buf.WriteString("0000000000 65535 f \n")
for _, off := range offsets {
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
buf.WriteString(fmt.Sprintf("%010d 00000 n \n", off))
}
buf.WriteString("trailer\n<< /Size " + strconv.Itoa(len(offsets)+1) + " /Root 1 0 R >>\n")
buf.WriteString("startxref\n" + strconv.Itoa(xrefStart) + "\n%%EOF\n")
+2 -3
View File
@@ -1,7 +1,6 @@
package lsp
import (
"context"
"encoding/json"
"testing"
)
@@ -65,7 +64,7 @@ func TestDecodeSymbols(t *testing.T) {
func TestNavigateUnknownOp(t *testing.T) {
m := NewManager(t.TempDir())
_, _, ok, err := m.Navigate(context.TODO(), NavRequest{Op: "bogus", Path: "x.go"})
_, _, ok, err := m.Navigate(nil, NavRequest{Op: "bogus", Path: "x.go"})
if err == nil {
t.Fatal("expected an error for an unknown nav op")
}
@@ -77,7 +76,7 @@ func TestNavigateUnknownOp(t *testing.T) {
func TestNavigateUnsupportedExtensionDegrades(t *testing.T) {
// A file type with no configured server degrades to ok=false, no error.
m := NewManager(t.TempDir())
_, _, ok, err := m.Navigate(context.TODO(), NavRequest{Op: NavDefinition, Path: "notes.unknownext", Line: 1, Character: 1})
_, _, ok, err := m.Navigate(nil, NavRequest{Op: NavDefinition, Path: "notes.unknownext", Line: 1, Character: 1})
if err != nil {
t.Fatalf("unsupported extension should not error, got %v", err)
}
+2 -134
View File
@@ -13,10 +13,8 @@ package minify
import (
"bytes"
"go/ast"
"go/parser"
"go/printer"
"go/scanner"
"go/token"
"path/filepath"
"strings"
@@ -36,11 +34,8 @@ func File(path string, content []byte) Result {
if out, ok := minifyGo(content); ok {
return Result{Content: out, Language: "go", Applied: true}
}
if out, ok := minifyGoFragment(content); ok {
return Result{Content: out, Language: "go-fragment", Applied: true}
}
// An incomplete fragment that cannot be wrapped safely falls through to
// whitespace-only normalization; no source text is guessed or discarded.
// Unparsable Go (a snippet, generics edge, or syntax error): fall through
// to the safe generic path rather than risk a partial AST reprint.
} else if style, ok := commentStyles[ext]; ok {
// Strip comments with a string-aware lexer, then collapse the whitespace the
// removed comments left behind. The stripper only handles languages whose
@@ -51,133 +46,6 @@ func File(path string, content []byte) Result {
return Result{Content: minifyGeneric(content), Language: "text", Applied: false}
}
// Fragment minifies a bounded source range without assuming lexical state from
// text outside the range. Go remains parser-validated; other languages use the
// conservative whitespace-only transform because a range may begin inside a
// multiline string or block comment.
func Fragment(path string, content []byte) Result {
if strings.EqualFold(filepath.Ext(path), ".go") {
if out, ok := minifyGo(content); ok {
return Result{Content: out, Language: "go", Applied: true}
}
if out, ok := minifyGoFragment(content); ok {
return Result{Content: out, Language: "go-fragment", Applied: true}
}
}
return Result{Content: minifyGeneric(content), Language: "text", Applied: false}
}
// ContextualFragment minifies a bounded fragment after checking its starting
// position against the complete source. A Go range that begins inside a string
// or comment must remain verbatim apart from whitespace normalization: parsing
// that fragment by itself would mistake literal text for Go syntax.
func ContextualFragment(path string, source, content []byte, startOffset int) Result {
if strings.EqualFold(filepath.Ext(path), ".go") && goFragmentStartsInsideToken(source, startOffset) {
return Result{Content: minifyGeneric(content), Language: "text", Applied: false}
}
return Fragment(path, content)
}
func goFragmentStartsInsideToken(source []byte, startOffset int) bool {
if startOffset <= 0 || startOffset >= len(source) {
return false
}
fset := token.NewFileSet()
file := fset.AddFile("", fset.Base(), len(source))
var lexer scanner.Scanner
lexer.Init(file, source, nil, scanner.ScanComments)
for {
position, kind, literal := lexer.Scan()
if kind == token.EOF {
return false
}
if kind != token.STRING && kind != token.COMMENT {
continue
}
start := file.Offset(position)
if startOffset > start && startOffset < start+len(literal) {
return true
}
}
}
// minifyGoFragment handles bounded reads that do not contain a package clause.
// It finds the longest complete declaration or statement prefix that the real
// Go parser accepts, prints that prefix without comments, and preserves any
// incomplete tail conservatively. Ranges are intentionally capped: repeatedly
// parsing a large broken file would otherwise turn a cheap read into O(n²) work.
func minifyGoFragment(content []byte) (string, bool) {
lines := strings.Split(strings.ReplaceAll(string(content), "\r\n", "\n"), "\n")
if len(lines) > 1 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
if len(lines) > 512 {
return "", false
}
for end := len(lines); end > 0; end-- {
prefix := strings.Join(lines[:end], "\n")
if compact, ok := parseGoDeclarations(prefix); ok {
return joinCompactPrefix(compact, lines[end:]), true
}
if compact, ok := parseGoStatements(prefix); ok {
return joinCompactPrefix(compact, lines[end:]), true
}
}
return "", false
}
func parseGoDeclarations(fragment string) (string, bool) {
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, "", "package compact\n"+fragment, parser.SkipObjectResolution)
if err != nil || len(file.Decls) == 0 {
return "", false
}
nodes := make([]ast.Node, len(file.Decls))
for i, declaration := range file.Decls {
nodes[i] = declaration
}
return printGoNodes(fset, nodes)
}
func parseGoStatements(fragment string) (string, bool) {
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, "", "package compact\nfunc _(){\n"+fragment+"\n}", parser.SkipObjectResolution)
if err != nil || len(file.Decls) != 1 {
return "", false
}
function, ok := file.Decls[0].(*ast.FuncDecl)
if !ok || function.Body == nil || len(function.Body.List) == 0 {
return "", false
}
nodes := make([]ast.Node, len(function.Body.List))
for i, statement := range function.Body.List {
nodes[i] = statement
}
return printGoNodes(fset, nodes)
}
func printGoNodes(fset *token.FileSet, nodes []ast.Node) (string, bool) {
var buf bytes.Buffer
cfg := printer.Config{Mode: printer.TabIndent, Tabwidth: 1}
for i, node := range nodes {
if i > 0 {
buf.WriteByte('\n')
}
if err := cfg.Fprint(&buf, fset, node); err != nil {
return "", false
}
}
return strings.TrimSpace(buf.String()), true
}
func joinCompactPrefix(compact string, remainder []string) string {
tail := minifyGeneric([]byte(strings.Join(remainder, "\n")))
if tail == "" {
return compact
}
return compact + "\n" + tail
}
// minifyGo parses Go WITHOUT comments (omitting parser.ParseComments leaves them
// unattached) and reprints the AST, yielding valid, comment-free, gofmt-shaped
// source with tab indentation (1 char per level). It returns ok=false on any
+7 -48
View File
@@ -39,58 +39,17 @@ func Greet(name string) string {
}
}
func TestMinifyGoStatementFragment(t *testing.T) {
func TestMinifyGoFallsBackOnUnparsableGo(t *testing.T) {
// A snippet (no package clause) cannot parse as a file -> safe generic path.
r := File("snippet.go", []byte("x := 1 \n\n\n// keep me\ny := 2\n"))
if !r.Applied || r.Language != "go-fragment" {
t.Fatalf("expected parsed Go fragment, got %+v", r)
if r.Applied {
t.Fatalf("expected fallback for unparsable Go, got %+v", r)
}
if strings.Contains(r.Content, "\n\n\n") {
t.Errorf("fragment should collapse blank runs:\n%q", r.Content)
t.Errorf("generic should collapse blank runs:\n%q", r.Content)
}
if strings.Contains(r.Content, "keep me") || !strings.Contains(r.Content, "y := 2") {
t.Errorf("fragment was not compacted safely: %q", r.Content)
}
}
func TestMinifyGoFragmentAccepts512LinesWithFinalNewline(t *testing.T) {
src := strings.Repeat("x++\n", 512)
r := File("snippet.go", []byte(src))
if !r.Applied || r.Language != "go-fragment" {
t.Fatalf("expected 512-line fragment to remain eligible, got %+v", r)
}
if got := strings.Count(r.Content, "x++"); got != 512 {
t.Fatalf("compacted statement count = %d, want 512", got)
}
}
func TestMinifyGoDeclarationPrefixPreservesIncompleteTail(t *testing.T) {
src := `func Handle737(n int) (int, error) {
if n%2 == 1 {
return 0, errBad
}
return n + 737, nil
}
// Handle738 begins outside the requested range.
func Handle738(n int) (int, error) {
if n%2 == 1 {`
r := File("range.go", []byte(src))
if !r.Applied || r.Language != "go-fragment" {
t.Fatalf("expected declaration-prefix compaction, got %+v", r)
}
if strings.Contains(r.Content, "Handle737 returns") || !strings.Contains(r.Content, "return n + 737") {
t.Fatalf("complete declaration was not compacted correctly:\n%s", r.Content)
}
if !strings.Contains(r.Content, "func Handle738") || !strings.Contains(r.Content, "if n%2 == 1 {") {
t.Fatalf("incomplete tail was not preserved:\n%s", r.Content)
}
}
func TestMinifyGoLargeInvalidInputFallsBackConservatively(t *testing.T) {
src := strings.Repeat("x := 1 // keep\n", 513)
r := File("broken.go", []byte(src))
if r.Applied || !strings.Contains(r.Content, "// keep") {
t.Fatalf("large invalid input must retain conservative fallback: %+v", r)
if !strings.Contains(r.Content, "// keep me") {
t.Errorf("generic must NOT strip comments (unsafe without a parser): %q", r.Content)
}
}
-40
View File
@@ -1,40 +0,0 @@
//go:build !unix && !windows
package peermsg
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
func ensurePrivateDir(path string) error {
abs, err := filepath.Abs(path)
if err != nil {
return err
}
volume := filepath.VolumeName(abs)
current := volume + string(filepath.Separator)
for _, component := range strings.Split(strings.TrimPrefix(abs[len(volume):], string(filepath.Separator)), string(filepath.Separator)) {
if component == "" {
continue
}
current = filepath.Join(current, component)
info, statErr := os.Lstat(current)
if errors.Is(statErr, os.ErrNotExist) {
if err := os.Mkdir(current, 0o700); err != nil && !errors.Is(err, os.ErrExist) {
return err
}
info, statErr = os.Lstat(current)
}
if statErr != nil {
return statErr
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("refusing non-directory or symlink runtime path %q", current)
}
}
return os.Chmod(abs, 0o700)
}
-62
View File
@@ -1,62 +0,0 @@
//go:build unix
package peermsg
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"golang.org/x/sys/unix"
)
func ensurePrivateDir(path string) error {
abs, err := filepath.Abs(path)
if err != nil {
return err
}
components := strings.Split(strings.TrimPrefix(filepath.Clean(abs), string(filepath.Separator)), string(filepath.Separator))
parentFD, err := unix.Open(string(filepath.Separator), unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
return err
}
defer unix.Close(parentFD)
for index, component := range components {
if component == "" {
continue
}
var stat unix.Stat_t
err = unix.Fstatat(parentFD, component, &stat, unix.AT_SYMLINK_NOFOLLOW)
if errors.Is(err, unix.ENOENT) {
if err = unix.Mkdirat(parentFD, component, 0o700); err != nil && !errors.Is(err, unix.EEXIST) {
return err
}
err = unix.Fstatat(parentFD, component, &stat, unix.AT_SYMLINK_NOFOLLOW)
}
if err != nil {
return err
}
if stat.Mode&unix.S_IFMT != unix.S_IFDIR {
return fmt.Errorf("refusing non-directory or symlink runtime path component %q", component)
}
nextFD, openErr := unix.Openat(parentFD, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
if openErr != nil {
return openErr
}
if index == len(components)-1 {
if int(stat.Uid) != os.Getuid() {
unix.Close(nextFD)
return fmt.Errorf("refusing runtime directory not owned by the current user: %q", path)
}
if err = unix.Fchmod(nextFD, 0o700); err != nil {
unix.Close(nextFD)
return err
}
}
unix.Close(parentFD)
parentFD = nextFD
}
return nil
}
-39
View File
@@ -1,39 +0,0 @@
//go:build unix
package peermsg
import (
"os"
"path/filepath"
"testing"
)
func TestEnsurePrivateDirRejectsSymlink(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "target")
if err := os.Mkdir(target, 0o700); err != nil {
t.Fatal(err)
}
link := filepath.Join(root, "link")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
if err := ensurePrivateDir(link); err == nil {
t.Fatal("expected symlink runtime directory to be rejected")
}
}
func TestEnsurePrivateDirRejectsSymlinkedParent(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "target")
if err := os.Mkdir(target, 0o700); err != nil {
t.Fatal(err)
}
link := filepath.Join(root, "link")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
if err := ensurePrivateDir(filepath.Join(link, "peers")); err == nil {
t.Fatal("expected symlinked parent to be rejected")
}
}
-239
View File
@@ -1,239 +0,0 @@
//go:build windows
package peermsg
import (
"errors"
"fmt"
"path/filepath"
"strings"
"unsafe"
"golang.org/x/sys/windows"
)
func ensurePrivateDir(path string) (resultErr error) {
abs, err := filepath.Abs(path)
if err != nil {
return err
}
descriptor, userSID, tokenOwnerSID, err := privateDirectoryDescriptor()
if err != nil {
return err
}
volume := filepath.VolumeName(abs)
current := volume + string(filepath.Separator)
parent, err := openWindowsDirectory(current, windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE|windows.SYNCHRONIZE)
if err != nil {
return err
}
parentPath := current
defer func() {
if parent != 0 {
resultErr = errors.Join(resultErr, closePrivateWindowsHandle(parent, parentPath))
}
}()
components := strings.Split(strings.TrimPrefix(abs[len(volume):], string(filepath.Separator)), string(filepath.Separator))
for index, component := range components {
if component == "" {
continue
}
current = filepath.Join(current, component)
access := uint32(windows.FILE_READ_ATTRIBUTES | windows.FILE_TRAVERSE | windows.SYNCHRONIZE)
if index == len(components)-1 {
access |= windows.READ_CONTROL | windows.WRITE_DAC
}
next, openErr := openOrCreatePrivateWindowsDirectory(parent, component, access, descriptor)
if openErr != nil {
return fmt.Errorf("open private runtime path component %q: %w", current, openErr)
}
if closeErr := closePrivateWindowsHandle(parent, parentPath); closeErr != nil {
return errors.Join(closeErr, closePrivateWindowsHandle(next, current))
}
parent = next
parentPath = current
}
if err := securePrivateDirectory(parent, abs, descriptor, userSID, tokenOwnerSID); err != nil {
return err
}
if err := closePrivateWindowsHandle(parent, abs); err != nil {
return err
}
parent = 0
return nil
}
func closePrivateWindowsHandle(handle windows.Handle, path string) error {
if err := windows.CloseHandle(handle); err != nil {
return fmt.Errorf("close private runtime directory %q: %w", path, err)
}
return nil
}
func privateDirectoryDescriptor() (*windows.SECURITY_DESCRIPTOR, *windows.SID, *windows.SID, error) {
token := windows.GetCurrentProcessToken()
user, err := token.GetTokenUser()
if err != nil {
return nil, nil, nil, fmt.Errorf("resolve current Windows user: %w", err)
}
sid := user.User.Sid
tokenOwnerSID, err := windowsTokenOwner(token)
if err != nil {
return nil, nil, nil, fmt.Errorf("resolve current Windows token owner: %w", err)
}
descriptor, err := windows.SecurityDescriptorFromString(fmt.Sprintf("O:%sD:P(A;OICI;GA;;;%s)(A;OICI;GA;;;SY)", sid.String(), sid.String()))
if err != nil {
return nil, nil, nil, fmt.Errorf("build private Windows directory ACL: %w", err)
}
return descriptor, sid, tokenOwnerSID, nil
}
type windowsTokenOwnerInfo struct {
owner *windows.SID
}
func windowsTokenOwner(token windows.Token) (*windows.SID, error) {
var size uint32
err := windows.GetTokenInformation(token, windows.TokenOwner, nil, 0, &size)
if err != windows.ERROR_INSUFFICIENT_BUFFER {
return nil, err
}
buffer := make([]byte, size)
if err := windows.GetTokenInformation(token, windows.TokenOwner, &buffer[0], size, &size); err != nil {
return nil, err
}
owner := (*windowsTokenOwnerInfo)(unsafe.Pointer(&buffer[0])).owner
if owner == nil {
return nil, errors.New("Windows access token has no default owner")
}
return owner.Copy()
}
func openWindowsDirectory(path string, access uint32) (windows.Handle, error) {
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, err
}
handle, err := windows.CreateFile(
pathPtr,
access,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT,
0,
)
if err != nil {
return 0, fmt.Errorf("open private runtime directory %q: %w", path, err)
}
return handle, nil
}
func openOrCreatePrivateWindowsDirectory(parent windows.Handle, name string, access uint32, descriptor *windows.SECURITY_DESCRIPTOR) (windows.Handle, error) {
handle, err := openPrivateWindowsDirectoryAt(parent, name, access, windows.FILE_OPEN, nil)
if err == nil {
return handle, nil
}
if !windowsDirectoryMissing(err) {
return 0, err
}
handle, err = openPrivateWindowsDirectoryAt(parent, name, access, windows.FILE_CREATE, descriptor)
if err == windows.STATUS_OBJECT_NAME_COLLISION || errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
return openPrivateWindowsDirectoryAt(parent, name, access, windows.FILE_OPEN, nil)
}
return handle, err
}
func openPrivateWindowsDirectoryAt(parent windows.Handle, name string, access, disposition uint32, descriptor *windows.SECURITY_DESCRIPTOR) (windows.Handle, error) {
objectName, err := windows.NewNTUnicodeString(name)
if err != nil {
return 0, err
}
objectAttributes := &windows.OBJECT_ATTRIBUTES{
Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})),
RootDirectory: parent,
ObjectName: objectName,
Attributes: windows.OBJ_CASE_INSENSITIVE | windows.OBJ_DONT_REPARSE,
SecurityDescriptor: descriptor,
}
var handle windows.Handle
err = windows.NtCreateFile(
&handle,
access,
objectAttributes,
&windows.IO_STATUS_BLOCK{},
nil,
windows.FILE_ATTRIBUTE_DIRECTORY,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
disposition,
windows.FILE_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
0,
0,
)
if err != nil {
return 0, err
}
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
return 0, errors.Join(err, closePrivateWindowsHandle(handle, name))
}
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 || info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 {
return 0, errors.Join(
fmt.Errorf("refusing non-directory or reparse-point component %q", name),
closePrivateWindowsHandle(handle, name),
)
}
return handle, nil
}
func windowsDirectoryMissing(err error) bool {
return err == windows.STATUS_NO_SUCH_FILE ||
err == windows.STATUS_OBJECT_NAME_NOT_FOUND ||
err == windows.STATUS_OBJECT_PATH_NOT_FOUND ||
errors.Is(err, windows.ERROR_FILE_NOT_FOUND) ||
errors.Is(err, windows.ERROR_PATH_NOT_FOUND)
}
func securePrivateDirectory(handle windows.Handle, path string, desired *windows.SECURITY_DESCRIPTOR, allowedOwners ...*windows.SID) error {
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
return fmt.Errorf("inspect private runtime directory %q: %w", path, err)
}
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 || info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 {
return fmt.Errorf("refusing non-directory or reparse-point runtime path %q", path)
}
current, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION)
if err != nil {
return fmt.Errorf("read owner of private runtime directory %q: %w", path, err)
}
owner, _, err := current.Owner()
if err != nil {
return fmt.Errorf("read owner of private runtime directory %q: %w", path, err)
}
ownedByCurrentToken := false
for _, allowedOwner := range allowedOwners {
if allowedOwner != nil && owner.Equals(allowedOwner) {
ownedByCurrentToken = true
break
}
}
if !ownedByCurrentToken {
return fmt.Errorf("refusing runtime directory %q not owned by the current user", path)
}
dacl, _, err := desired.DACL()
if err != nil {
return fmt.Errorf("read private Windows directory DACL: %w", err)
}
if err := windows.SetSecurityInfo(
handle,
windows.SE_FILE_OBJECT,
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
nil,
nil,
dacl,
nil,
); err != nil {
return fmt.Errorf("secure private runtime directory %q: %w", path, err)
}
return nil
}
@@ -1,223 +0,0 @@
//go:build windows
package peermsg
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"unsafe"
"golang.org/x/sys/windows"
)
func TestEnsurePrivateDirAppliesOwnerOnlyProtectedDACL(t *testing.T) {
const directoryAllAccess = windows.STANDARD_RIGHTS_REQUIRED | windows.SYNCHRONIZE | 0x1ff
path := filepath.Join(t.TempDir(), "zero", "peers", "registry")
if err := os.MkdirAll(path, 0o700); err != nil {
t.Fatal(err)
}
worldSID, err := windows.CreateWellKnownSid(windows.WinWorldSid)
if err != nil {
t.Fatal(err)
}
var pinner runtime.Pinner
pinner.Pin(worldSID)
defer pinner.Unpin()
broadDACL, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{{
AccessPermissions: windows.GENERIC_ALL,
AccessMode: windows.GRANT_ACCESS,
Inheritance: windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT,
Trustee: windows.TRUSTEE{
TrusteeForm: windows.TRUSTEE_IS_SID,
TrusteeType: windows.TRUSTEE_IS_WELL_KNOWN_GROUP,
TrusteeValue: windows.TrusteeValueFromSID(worldSID),
},
}}, nil)
if err != nil {
t.Fatal(err)
}
if err := windows.SetNamedSecurityInfo(
path,
windows.SE_FILE_OBJECT,
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
nil,
nil,
broadDACL,
nil,
); err != nil {
t.Fatal(err)
}
if !windowsDirectoryDACLContains(t, path, worldSID) {
t.Fatal("test setup did not grant the broad Everyone ACE")
}
if err := ensurePrivateDir(path); err != nil {
t.Fatal(err)
}
descriptor, err := windows.GetNamedSecurityInfo(
path,
windows.SE_FILE_OBJECT,
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION,
)
if err != nil {
t.Fatal(err)
}
control, _, err := descriptor.Control()
if err != nil {
t.Fatal(err)
}
if control&windows.SE_DACL_PROTECTED == 0 {
t.Fatal("runtime directory DACL inherits access from its parent")
}
user, err := windows.GetCurrentProcessToken().GetTokenUser()
if err != nil {
t.Fatal(err)
}
owner, _, err := descriptor.Owner()
if err != nil {
t.Fatal(err)
}
tokenOwner, err := windowsTokenOwner(windows.GetCurrentProcessToken())
if err != nil {
t.Fatal(err)
}
if !owner.Equals(user.User.Sid) && !owner.Equals(tokenOwner) {
t.Fatalf("runtime directory owner = %s, want user %s or token owner %s", owner.String(), user.User.Sid.String(), tokenOwner.String())
}
systemSID, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
if err != nil {
t.Fatal(err)
}
dacl, _, err := descriptor.DACL()
if err != nil {
t.Fatal(err)
}
wantTrustees := []*windows.SID{user.User.Sid, systemSID}
seen := make([]bool, len(wantTrustees))
for index := uint32(0); ; index++ {
var ace *windows.ACCESS_ALLOWED_ACE
if err := windows.GetAce(dacl, index, &ace); err != nil {
break
}
hasFullAccess := ace.Mask == windows.GENERIC_ALL || ace.Mask&directoryAllAccess == directoryAllAccess
if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE || !hasFullAccess {
t.Fatalf("DACL ACE %d has type %d and mask %#x", index, ace.Header.AceType, ace.Mask)
}
trustee := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
matched := false
for trusteeIndex, wanted := range wantTrustees {
if trustee.Equals(wanted) {
seen[trusteeIndex] = true
matched = true
break
}
}
if !matched {
t.Fatalf("DACL ACE %d grants access to unexpected trustee %s", index, trustee.String())
}
}
for index, found := range seen {
if !found {
t.Fatalf("DACL does not grant access to required trustee %s", wantTrustees[index].String())
}
}
}
func windowsDirectoryDACLContains(t *testing.T, path string, wanted *windows.SID) bool {
t.Helper()
descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
if err != nil {
t.Fatal(err)
}
dacl, _, err := descriptor.DACL()
if err != nil {
t.Fatal(err)
}
for index := uint32(0); ; index++ {
var ace *windows.ACCESS_ALLOWED_ACE
if err := windows.GetAce(dacl, index, &ace); err != nil {
return false
}
if (*windows.SID)(unsafe.Pointer(&ace.SidStart)).Equals(wanted) {
return true
}
}
}
func TestSecurePrivateDirectoryRejectsOwnerMismatch(t *testing.T) {
path := filepath.Join(t.TempDir(), "private")
if err := ensurePrivateDir(path); err != nil {
t.Fatal(err)
}
handle, err := openWindowsDirectory(path, windows.FILE_LIST_DIRECTORY|windows.FILE_TRAVERSE|windows.SYNCHRONIZE|windows.READ_CONTROL|windows.WRITE_DAC)
if err != nil {
t.Fatal(err)
}
defer windows.CloseHandle(handle)
descriptor, _, _, err := privateDirectoryDescriptor()
if err != nil {
t.Fatal(err)
}
unexpectedOwner, err := windows.CreateWellKnownSid(windows.WinWorldSid)
if err != nil {
t.Fatal(err)
}
if err := securePrivateDirectory(handle, path, descriptor, unexpectedOwner); err == nil || !strings.Contains(err.Error(), "not owned") {
t.Fatalf("owner mismatch error = %v", err)
}
}
func TestSecurePrivateDirectoryReportsDACLWriteFailure(t *testing.T) {
path := filepath.Join(t.TempDir(), "private")
if err := ensurePrivateDir(path); err != nil {
t.Fatal(err)
}
handle, err := openWindowsDirectory(path, windows.FILE_LIST_DIRECTORY|windows.FILE_TRAVERSE|windows.SYNCHRONIZE|windows.READ_CONTROL)
if err != nil {
t.Fatal(err)
}
defer windows.CloseHandle(handle)
descriptor, userSID, tokenOwnerSID, err := privateDirectoryDescriptor()
if err != nil {
t.Fatal(err)
}
if err := securePrivateDirectory(handle, path, descriptor, userSID, tokenOwnerSID); err == nil || !strings.Contains(err.Error(), "secure private runtime directory") {
t.Fatalf("DACL write error = %v", err)
}
}
func TestClosePrivateWindowsHandleReportsFailure(t *testing.T) {
err := closePrivateWindowsHandle(windows.Handle(0), `C:\private`)
if err == nil || !strings.Contains(err.Error(), "close private runtime directory") {
t.Fatalf("close handle error = %v", err)
}
}
func TestEnsurePrivateDirRejectsWindowsReparseParent(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "target")
if err := os.Mkdir(target, 0o700); err != nil {
t.Fatal(err)
}
link := filepath.Join(root, "link")
if err := os.Symlink(target, link); err != nil {
t.Skipf("cannot create Windows directory symlink: %v", err)
}
if err := ensurePrivateDir(filepath.Join(link, "peers")); err == nil {
t.Fatal("expected reparse-point parent to be rejected")
}
}
func TestEnsurePrivateDirRejectsWindowsFileComponent(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "file")
if err := os.WriteFile(file, []byte("not a directory"), 0o600); err != nil {
t.Fatal(err)
}
if err := ensurePrivateDir(filepath.Join(file, "peers")); err == nil {
t.Fatal("expected file path component to be rejected")
}
}
File diff suppressed because it is too large. Load diff
-896
View File
@@ -1,896 +0,0 @@
package peermsg
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestServicesDiscoverAndDeliverAcceptedMessage(t *testing.T) {
root := t.TempDir()
transport := newMemoryTransport()
receiver := newStartedService(t, root, transport, 9102, Identity{
SessionID: "receiver-session",
Name: "reviewer",
Cwd: root,
PermissionClass: PermissionPrompting,
})
sender := newStartedService(t, root, transport, 9101, Identity{
SessionID: "sender-session",
Name: "builder",
Cwd: root,
PermissionClass: PermissionPrompting,
})
received := make(chan InboundMessage, 1)
receiver.mu.Lock()
receiver.handler = func(message InboundMessage) bool { received <- message; return true }
receiver.mu.Unlock()
peers, err := sender.List(context.Background())
if err != nil {
t.Fatal(err)
}
if len(peers) != 1 || peers[0].SessionID != "receiver-session" || peers[0].Ref == "" {
t.Fatalf("peers = %#v", peers)
}
result, err := sender.Send(context.Background(), "reviewer", "check status", "Are the tests green?")
if err != nil {
t.Fatal(err)
}
if result.Status != DeliveryAccepted || result.Peer.SessionID != "receiver-session" || result.MessageID == "" {
t.Fatalf("result = %#v", result)
}
select {
case message := <-received:
if message.Body != "Are the tests green?" || message.Summary != "check status" || message.RequiresApproval {
t.Fatalf("message = %#v", message)
}
if message.From.SessionID != "sender-session" {
t.Fatalf("from = %#v", message.From)
}
if len(message.HopChain) != 1 || message.HopChain[0] != sender.Self().Ref {
t.Fatalf("hop chain = %#v", message.HopChain)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for message")
}
}
func TestFreshSessionIsDiscoverableBeforeItsFirstPrompt(t *testing.T) {
root := t.TempDir()
transport := newMemoryTransport()
receiver := newStartedService(t, root, transport, 9152, Identity{Name: ""})
sender := newStartedService(t, root, transport, 9151, Identity{SessionID: "sender", Name: "sender"})
peers, err := sender.List(context.Background())
if err != nil {
t.Fatal(err)
}
if len(peers) != 1 || !strings.HasPrefix(peers[0].SessionID, "live-") || displayPeer(peers[0]) != "Zero session ["+peers[0].Ref+"]" {
t.Fatalf("fresh peer = %#v", peers)
}
if receiver.Self().SessionID != peers[0].SessionID {
t.Fatalf("receiver identity = %#v, discovered = %#v", receiver.Self(), peers[0])
}
}
func TestPermissionClassMismatchHoldsMessage(t *testing.T) {
root := t.TempDir()
transport := newMemoryTransport()
received := make(chan InboundMessage, 1)
receiver := newService(t, root, transport, 9202, Identity{SessionID: "receiver", Name: "safe", PermissionClass: PermissionPrompting})
if err := receiver.Start(func(message InboundMessage) bool { received <- message; return true }); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = receiver.Close() })
sender := newStartedService(t, root, transport, 9201, Identity{SessionID: "sender", Name: "unsafe", PermissionClass: PermissionBypass})
result, err := sender.Send(context.Background(), "safe", "request", "run this")
if err != nil {
t.Fatal(err)
}
if result.Status != DeliveryHeld {
t.Fatalf("status = %q", result.Status)
}
select {
case message := <-received:
if !message.RequiresApproval || message.HoldCause != HoldCauseModeMismatch {
t.Fatalf("message should require approval: %#v", message)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for held message")
}
}
func TestExplicitInboundPoliciesOverridePermissionParity(t *testing.T) {
tests := []struct {
name string
policy InboundPolicy
senderMode PermissionClass
want DeliveryStatus
wantCause HoldCause
wantRecv bool
}{
{name: "accept mismatch", policy: InboundPolicyAccept, senderMode: PermissionBypass, want: DeliveryAccepted, wantRecv: true},
{name: "hold match", policy: InboundPolicyHold, senderMode: PermissionPrompting, want: DeliveryHeld, wantCause: HoldCauseExplicit, wantRecv: true},
{name: "refuse match", policy: InboundPolicyRefuse, senderMode: PermissionPrompting, want: DeliveryRefused},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := t.TempDir()
transport := newMemoryTransport()
receiver, err := New(Options{
RootDir: root, PID: 9602, Transport: transport, InboundPolicy: test.policy,
Identity: Identity{SessionID: "receiver", Name: "receiver", PermissionClass: PermissionPrompting},
})
if err != nil {
t.Fatal(err)
}
received := make(chan InboundMessage, 1)
if err := receiver.Start(func(message InboundMessage) bool { received <- message; return true }); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = receiver.Close() })
sender := newStartedService(t, root, transport, 9601, Identity{SessionID: "sender", Name: "sender", PermissionClass: test.senderMode})
result, err := sender.Send(context.Background(), "receiver", "policy", "hello")
if err != nil {
t.Fatal(err)
}
if result.Status != test.want {
t.Fatalf("status = %q, want %q", result.Status, test.want)
}
select {
case message := <-received:
if !test.wantRecv {
t.Fatalf("refused message delivered: %#v", message)
}
if message.HoldCause != test.wantCause {
t.Fatalf("hold cause = %q, want %q", message.HoldCause, test.wantCause)
}
default:
if test.wantRecv {
t.Fatal("message was not delivered to handler")
}
}
})
}
}
func TestUnknownSenderPermissionModeHoldsOnlyForBypassReceiver(t *testing.T) {
tests := []struct {
name string
receiverMode PermissionClass
wantStatus DeliveryStatus
wantCause HoldCause
}{
{name: "prompting receiver accepts", receiverMode: PermissionPrompting, wantStatus: DeliveryAccepted},
{name: "bypass receiver holds", receiverMode: PermissionBypass, wantStatus: DeliveryHeld, wantCause: HoldCauseModeUnknown},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
service := &Service{policy: InboundPolicyParity}
status, cause := service.inboundDecision("", test.receiverMode)
if status != test.wantStatus || cause != test.wantCause {
t.Fatalf("inboundDecision() = (%q, %q), want (%q, %q)", status, cause, test.wantStatus, test.wantCause)
}
})
}
}
func TestHeldMessageResolutionSendsTerminalReceipt(t *testing.T) {
root := t.TempDir()
transport := newMemoryTransport()
received := make(chan InboundMessage, 1)
receiver := newService(t, root, transport, 9702, Identity{SessionID: "receiver", Name: "reviewer", PermissionClass: PermissionPrompting})
if err := receiver.Start(func(message InboundMessage) bool { received <- message; return true }); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = receiver.Close() })
sender := newStartedService(t, root, transport, 9701, Identity{SessionID: "sender", Name: "builder", PermissionClass: PermissionBypass})
statuses := make(chan StatusEvent, 1)
sender.SetStatusHandler(func(event StatusEvent) { statuses <- event })
result, err := sender.Send(context.Background(), "reviewer", "question", "How many files?")
if err != nil || result.Status != DeliveryHeld {
t.Fatalf("send result = %#v, err = %v", result, err)
}
var message InboundMessage
select {
case message = <-received:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for held message")
}
if err := receiver.ResolveHeld(context.Background(), message.ID, DeliveryDelivered); err != nil {
t.Fatal(err)
}
select {
case event := <-statuses:
if event.MessageID != result.MessageID || event.Status != DeliveryDelivered || event.Peer.SessionID != "receiver" {
t.Fatalf("status event = %#v", event)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for delivery receipt")
}
}
func TestPermissionModeChangeReleasesNewlyCompatibleHeldMessage(t *testing.T) {
root := t.TempDir()
transport := newMemoryTransport()
held := make(chan InboundMessage, 1)
released := make(chan InboundMessage, 1)
receiver := newService(t, root, transport, 9752, Identity{SessionID: "receiver", Name: "reviewer", PermissionClass: PermissionPrompting})
receiver.SetHeldReleaseHandler(func(message InboundMessage) { released <- message })
if err := receiver.Start(func(message InboundMessage) bool { held <- message; return true }); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = receiver.Close() })
sender := newStartedService(t, root, transport, 9751, Identity{SessionID: "sender", Name: "builder", PermissionClass: PermissionBypass})
statuses := make(chan StatusEvent, 1)
sender.SetStatusHandler(func(event StatusEvent) { statuses <- event })
result, err := sender.Send(context.Background(), "reviewer", "question", "How many files?")
if err != nil || result.Status != DeliveryHeld {
t.Fatalf("send result = %#v, err = %v", result, err)
}
var message InboundMessage
select {
case message = <-held:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for held message")
}
if err := receiver.UpdateIdentity(Identity{SessionID: "receiver", Name: "reviewer", PermissionClass: PermissionBypass}); err != nil {
t.Fatal(err)
}
select {
case got := <-released:
if got.ID != message.ID || got.RequiresApproval || got.HoldCause != "" {
t.Fatalf("released message = %#v", got)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for held message release")
}
select {
case event := <-statuses:
if event.MessageID != result.MessageID || event.Status != DeliveryDelivered {
t.Fatalf("status event = %#v", event)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for release receipt")
}
}
func TestReceiverAdmissionAndLoopGuardsFailClosed(t *testing.T) {
root := t.TempDir()
transport := newMemoryTransport()
receiver := newService(t, root, transport, 9802, Identity{SessionID: "receiver", Name: "receiver"})
if err := receiver.Start(func(InboundMessage) bool { return false }); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = receiver.Close() })
sender := newStartedService(t, root, transport, 9801, Identity{SessionID: "sender", Name: "sender"})
if _, err := sender.Send(context.Background(), "receiver", "full", "not admitted"); err == nil || !strings.Contains(err.Error(), "receiver queue") {
t.Fatalf("admission error = %v", err)
}
receiver.mu.Lock()
receiver.handler = func(InboundMessage) bool { return true }
receiver.mu.Unlock()
if _, err := sender.Send(context.Background(), "receiver", "once", "duplicate body"); err != nil {
t.Fatal(err)
}
if _, err := sender.Send(context.Background(), "receiver", "twice", "duplicate body"); err == nil || !strings.Contains(err.Error(), "duplicate") {
t.Fatalf("duplicate error = %v", err)
}
chain := make([]string, peerMaxSelfHops)
for index := range chain {
chain[index] = receiver.Self().Ref
}
ctx := context.WithValue(context.Background(), inboundContextKey{}, chain)
if _, err := sender.Send(ctx, "receiver", "loop", "different body"); err == nil || !strings.Contains(err.Error(), "loop") {
t.Fatalf("loop error = %v", err)
}
}
func TestUntitledPeerAddressResolvesByNeutralNameAndRef(t *testing.T) {
peer := Peer{Identity: Identity{SessionID: "zero_opaque"}, Ref: "22222222"}
resolved, err := resolvePeer([]Peer{peer}, "Zero session [22222222]")
if err != nil {
t.Fatal(err)
}
if resolved.SessionID != peer.SessionID {
t.Fatalf("resolved peer = %#v", resolved)
}
}
func TestIdentityUpdateChangesAddressableSession(t *testing.T) {
root := t.TempDir()
transport := newMemoryTransport()
receiver := newStartedService(t, root, transport, 9302, Identity{SessionID: "old", Name: "old name"})
sender := newStartedService(t, root, transport, 9301, Identity{SessionID: "sender", Name: "sender"})
if err := receiver.UpdateIdentity(Identity{SessionID: "new", Name: "new name"}); err != nil {
t.Fatal(err)
}
if _, err := sender.Send(context.Background(), "old", "stale", "hello"); err == nil || !strings.Contains(err.Error(), "no reachable") {
t.Fatalf("old identity error = %v", err)
}
result, err := sender.Send(context.Background(), "new name", "fresh", "hello")
if err != nil {
t.Fatal(err)
}
if result.Peer.SessionID != "new" {
t.Fatalf("peer = %#v", result.Peer)
}
}
func TestClosedServiceDisappearsFromDiscovery(t *testing.T) {
root := t.TempDir()
transport := newMemoryTransport()
receiver := newStartedService(t, root, transport, 9402, Identity{SessionID: "receiver", Name: "receiver"})
sender := newStartedService(t, root, transport, 9401, Identity{SessionID: "sender", Name: "sender"})
if err := receiver.Close(); err != nil {
t.Fatal(err)
}
peers, err := sender.List(context.Background())
if err != nil {
t.Fatal(err)
}
if len(peers) != 0 {
t.Fatalf("peers after close = %#v", peers)
}
}
func TestListReturnsCancellationInsteadOfPartialResults(t *testing.T) {
root := t.TempDir()
transport := newMemoryTransport()
service := newStartedService(t, root, transport, 9401, Identity{SessionID: "sender", Name: "sender"})
_ = newStartedService(t, root, transport, 9402, Identity{SessionID: "receiver", Name: "receiver"})
ctx, cancel := context.WithCancel(context.Background())
cancel()
peers, err := service.List(ctx)
if !errors.Is(err, context.Canceled) {
t.Fatalf("List() error = %v, want context.Canceled", err)
}
if peers != nil {
t.Fatalf("List() peers = %#v, want nil after cancellation", peers)
}
}
func TestCloseReportsTransportCleanupFailure(t *testing.T) {
transport := &removeErrorTransport{memoryTransport: newMemoryTransport()}
service := newService(t, t.TempDir(), transport, 9403, Identity{SessionID: "sender", Name: "sender"})
if err := service.Start(func(InboundMessage) bool { return true }); err != nil {
t.Fatal(err)
}
if err := service.Close(); err == nil || !strings.Contains(err.Error(), "forced remove failure") {
t.Fatalf("Close() error = %v, want transport cleanup failure", err)
}
}
func TestDiscoveryDoesNotDeleteRecordAfterTransientDialFailure(t *testing.T) {
root := t.TempDir()
transport := newMemoryTransport()
service := newStartedService(t, root, transport, 9411, Identity{SessionID: "sender", Name: "sender"})
endpoint := "memory:temporarily-unreachable"
peer := Peer{Identity: Identity{SessionID: "receiver", Name: "receiver"}, Endpoint: endpoint, PID: 9412,
StartedAt: time.Now(), UpdatedAt: time.Now(), Ref: peerRef(endpoint)}
data, err := json.Marshal(peer)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(service.registryDir(), "9412-test.json")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
peers, err := service.List(context.Background())
if err != nil {
t.Fatal(err)
}
if len(peers) != 0 {
t.Fatalf("unreachable peers = %#v", peers)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("transient failure removed registry record: %v", err)
}
}
func TestReceiverRefusesForgedSenderReference(t *testing.T) {
root := t.TempDir()
transport := newMemoryTransport()
received := make(chan InboundMessage, 1)
receiver := newService(t, root, transport, 9502, Identity{SessionID: "receiver", Name: "receiver"})
if err := receiver.Start(func(message InboundMessage) bool { received <- message; return true }); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = receiver.Close() })
conn, err := transport.Dial(context.Background(), receiver.Self().Endpoint)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
frame := sendFrame{
Version: ProtocolVersion,
Type: "message",
ID: "forged-message",
From: Peer{
Identity: Identity{SessionID: "sender", Name: "sender"},
Endpoint: "memory:sender",
Ref: "forged",
},
To: "receiver",
Summary: "hello",
Body: "This must not be delivered.",
}
if err := json.NewEncoder(conn).Encode(frame); err != nil {
t.Fatal(err)
}
var response responseFrame
if err := decodeFrame(conn, &response); err != nil {
t.Fatal(err)
}
if response.Status != DeliveryRefused || !strings.Contains(response.Error, "invalid message envelope") {
t.Fatalf("response = %#v", response)
}
select {
case message := <-received:
t.Fatalf("forged message was delivered: %#v", message)
default:
}
}
func TestReceiverRefusesUnregisteredSenderWithValidReference(t *testing.T) {
root := t.TempDir()
transport := newMemoryTransport()
receiver := newStartedService(t, root, transport, 9512, Identity{SessionID: "receiver", Name: "receiver"})
conn, err := transport.Dial(context.Background(), receiver.Self().Endpoint)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
endpoint := "memory:unregistered"
frame := sendFrame{
Version: ProtocolVersion, Type: "message", ID: "forged-valid-ref",
From: Peer{Identity: Identity{SessionID: "attacker", PermissionClass: PermissionPrompting}, Endpoint: endpoint, Ref: peerRef(endpoint)},
To: receiver.Self().SessionID, Summary: "spoof", Body: "must not arrive",
}
frame.HopChain = []string{frame.From.Ref}
if err := json.NewEncoder(conn).Encode(frame); err != nil {
t.Fatal(err)
}
var response responseFrame
if err := decodeFrame(conn, &response); err != nil {
t.Fatal(err)
}
if response.Status != DeliveryRefused || !strings.Contains(response.Error, "not registered") {
t.Fatalf("response = %#v", response)
}
}
func TestReceiverUsesRegisteredPermissionClass(t *testing.T) {
root := t.TempDir()
transport := newMemoryTransport()
received := make(chan InboundMessage, 1)
receiver := newService(t, root, transport, 9522, Identity{SessionID: "receiver", PermissionClass: PermissionPrompting})
if err := receiver.Start(func(message InboundMessage) bool { received <- message; return true }); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = receiver.Close() })
sender := newStartedService(t, root, transport, 9521, Identity{SessionID: "sender", PermissionClass: PermissionBypass})
conn, err := transport.Dial(context.Background(), receiver.Self().Endpoint)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
claimed := sender.Self()
claimed.PermissionClass = PermissionPrompting
frame := sendFrame{Version: ProtocolVersion, Type: "message", ID: "permission-spoof", From: claimed,
To: receiver.Self().SessionID, Summary: "spoof", Body: "hold this", HopChain: []string{claimed.Ref}}
if err := json.NewEncoder(conn).Encode(frame); err != nil {
t.Fatal(err)
}
var response responseFrame
if err := decodeFrame(conn, &response); err != nil {
t.Fatal(err)
}
if response.Status != DeliveryHeld {
t.Fatalf("status = %q, want held", response.Status)
}
select {
case message := <-received:
if message.From.PermissionClass != PermissionBypass || !message.RequiresApproval {
t.Fatalf("message = %#v", message)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for held message")
}
}
func TestOutstandingMessagesAreBoundedAndExpired(t *testing.T) {
now := time.Unix(1000, 0)
service := &Service{outstanding: make(map[string]outstandingMessage)}
for index := range peerMaxOutstanding + 20 {
service.outstanding[fmt.Sprint(index)] = outstandingMessage{createdAt: now.Add(time.Duration(index) * time.Second)}
}
service.pruneOutstandingLocked(now.Add(peerOutstandingTTL + time.Hour))
if len(service.outstanding) != 0 {
t.Fatalf("expired outstanding entries = %d", len(service.outstanding))
}
for index := range peerMaxOutstanding + 20 {
service.outstanding[fmt.Sprint(index)] = outstandingMessage{createdAt: now.Add(time.Duration(index) * time.Second)}
}
service.pruneOutstandingLocked(now)
if len(service.outstanding) >= peerMaxOutstanding {
t.Fatalf("bounded outstanding entries = %d", len(service.outstanding))
}
}
func TestDuplicateAttemptsConsumeRateCapacityWithoutRefreshingActivity(t *testing.T) {
now := time.Unix(1000, 0)
service := &Service{now: func() time.Time { return now }, guards: make(map[string]*senderGuard)}
sender := Peer{Endpoint: "memory:sender"}
if reason := service.admitMessage(sender, "same", []string{"11111111"}, "22222222"); reason != "" {
t.Fatal(reason)
}
activity := service.guards[sender.Endpoint].lastActivity
for range int(peerBucketCapacity) - 1 {
if reason := service.admitMessage(sender, "same", []string{"11111111"}, "22222222"); !strings.Contains(reason, "duplicate") {
t.Fatalf("reason = %q", reason)
}
}
if reason := service.admitMessage(sender, "different", []string{"11111111"}, "22222222"); !strings.Contains(reason, "rate limit") {
t.Fatalf("reason = %q", reason)
}
if !service.guards[sender.Endpoint].lastActivity.Equal(activity) {
t.Fatal("rejected duplicates refreshed sender activity")
}
}
func TestHeldEvictionRepairsMissingOrderEntry(t *testing.T) {
service := &Service{held: make(map[string]InboundMessage)}
for index := range peerMaxHeldMessages {
id := fmt.Sprint(index)
service.held[id] = InboundMessage{ID: id, ReceivedAt: time.Unix(int64(index), 0)}
}
evicted := service.popOldestHeldLocked()
if evicted.ID != "0" || len(service.held) != peerMaxHeldMessages-1 {
t.Fatalf("evicted=%#v remaining=%d", evicted, len(service.held))
}
}
func TestHeldEvictionReceiptDoesNotDelayInboundResponse(t *testing.T) {
root := t.TempDir()
transport := &slowEndpointTransport{memoryTransport: newMemoryTransport(), slowEndpoint: "memory:slow-sender"}
receiver := newService(t, root, transport, 9812, Identity{
SessionID: "receiver", PermissionClass: PermissionPrompting,
})
evicted := make(chan string, 1)
receiver.SetHeldEvictionHandler(func(messageID string) { evicted <- messageID })
if err := receiver.Start(func(InboundMessage) bool { return true }); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = receiver.Close() })
sender := Peer{
Identity: Identity{SessionID: "sender", PermissionClass: PermissionBypass},
Endpoint: transport.slowEndpoint,
PID: 9811,
Ref: peerRef(transport.slowEndpoint),
}
record, err := json.Marshal(sender)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(receiver.registryDir(), "9811-sender.json"), record, 0o600); err != nil {
t.Fatal(err)
}
receiver.mu.Lock()
for index := range peerMaxHeldMessages {
id := fmt.Sprintf("held-%d", index)
receiver.held[id] = InboundMessage{ID: id, From: sender, ReceivedAt: time.Unix(int64(index), 0)}
receiver.heldOrder = append(receiver.heldOrder, id)
}
receiver.mu.Unlock()
conn, err := transport.memoryTransport.Dial(context.Background(), receiver.Self().Endpoint)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
frame := sendFrame{
Version: ProtocolVersion, Type: "message", ID: "new-held", From: sender,
To: receiver.Self().SessionID, Summary: "question", Body: "answer this", HopChain: []string{sender.Ref},
}
started := time.Now()
if err := json.NewEncoder(conn).Encode(frame); err != nil {
t.Fatal(err)
}
var response responseFrame
if err := decodeFrame(conn, &response); err != nil {
t.Fatal(err)
}
if response.Status != DeliveryHeld {
t.Fatalf("response = %#v", response)
}
if elapsed := time.Since(started); elapsed >= peerReceiptTimeout/2 {
t.Fatalf("inbound response waited %s for an eviction receipt", elapsed)
}
select {
case messageID := <-evicted:
if messageID != "held-0" {
t.Fatalf("eviction callback message ID = %q, want held-0", messageID)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for held-message eviction callback")
}
}
func TestIdentityUpdateSkipsStaleHeldOrderEntries(t *testing.T) {
root := t.TempDir()
transport := newMemoryTransport()
service := newService(t, root, transport, 9822, Identity{SessionID: "receiver", PermissionClass: PermissionPrompting})
released := make(chan InboundMessage, 2)
service.SetHeldReleaseHandler(func(message InboundMessage) { released <- message })
if err := service.Start(func(InboundMessage) bool { return true }); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = service.Close() })
valid := InboundMessage{
ID: "valid", From: Peer{Identity: Identity{SessionID: "sender", PermissionClass: PermissionPrompting}, Endpoint: "memory:gone", Ref: peerRef("memory:gone")},
}
service.mu.Lock()
service.held[valid.ID] = valid
service.heldOrder = []string{"missing", valid.ID}
service.mu.Unlock()
if err := service.UpdateIdentity(Identity{SessionID: "receiver", PermissionClass: PermissionPrompting}); err != nil {
t.Fatal(err)
}
select {
case message := <-released:
if message.ID != valid.ID {
t.Fatalf("released stale entry as %#v", message)
}
case <-time.After(time.Second):
t.Fatal("valid held message was not released")
}
select {
case message := <-released:
t.Fatalf("unexpected additional release: %#v", message)
default:
}
}
func TestSendStatusesGivesEveryReceiptAnIndependentDeadline(t *testing.T) {
transport := &receiptTransport{}
service := &Service{transport: transport, now: time.Now}
service.self = Peer{Identity: Identity{SessionID: "receiver"}, Endpoint: "receiver", Ref: peerRef("receiver")}
messages := make([]InboundMessage, 0, peerReceiptWorkers+1)
for index := range peerReceiptWorkers + 1 {
endpoint := fmt.Sprintf("fast-%d", index)
if index == 0 {
endpoint = "slow"
}
messages = append(messages, InboundMessage{ID: fmt.Sprint(index), From: Peer{
Identity: Identity{SessionID: fmt.Sprintf("sender-%d", index)}, Endpoint: endpoint, Ref: peerRef(endpoint),
}})
}
started := time.Now()
service.sendStatuses(messages, DeliveryExpired)
if elapsed := time.Since(started); elapsed > 2*peerReceiptTimeout {
t.Fatalf("receipt batch took %s; attempts appear serialized", elapsed)
}
if got, want := transport.fastDials.Load(), int32(len(messages)-1); got != want {
t.Fatalf("fast receipt attempts = %d, want %d", got, want)
}
}
func TestSenderGuardEvictsLeastRecentlyAcceptedSender(t *testing.T) {
now := time.Unix(1000, 0)
service := &Service{now: func() time.Time { return now }, guards: make(map[string]*senderGuard)}
for index := range peerMaxTrackedSenders {
service.guards[fmt.Sprint(index)] = &senderGuard{lastActivity: now.Add(time.Duration(index) * time.Second)}
}
if reason := service.admitMessage(Peer{Endpoint: "new"}, "body", []string{"11111111"}, "22222222"); reason != "" {
t.Fatal(reason)
}
if _, exists := service.guards["0"]; exists {
t.Fatal("least-recently-active sender was not evicted")
}
if _, exists := service.guards["new"]; !exists {
t.Fatal("new sender was not tracked")
}
}
func TestResolvePeerRequiresReferenceForDuplicateNames(t *testing.T) {
peers := []Peer{
{Identity: Identity{SessionID: "one", Name: "worker"}, Ref: "11111111"},
{Identity: Identity{SessionID: "two", Name: "worker"}, Ref: "22222222"},
}
if _, err := resolvePeer(peers, "worker"); err == nil || !strings.Contains(err.Error(), "ambiguous") {
t.Fatalf("ambiguous resolution error = %v", err)
}
peer, err := resolvePeer(peers, "worker [22222222]")
if err != nil {
t.Fatal(err)
}
if peer.SessionID != "two" {
t.Fatalf("peer = %#v", peer)
}
}
func TestPlainTextNormalizationRemovesTerminalControls(t *testing.T) {
if got := normalizeBody(" hello\x1b[31m\nworld\x00 "); got != "hello[31m\nworld" {
t.Fatalf("normalized body = %q", got)
}
if got := normalizeSummary(" status\x1b[31m\nignored "); got != "status[31m" {
t.Fatalf("normalized summary = %q", got)
}
}
func TestRemoteErrorNormalizationStripsControlsAndCapsLength(t *testing.T) {
got := normalizeRemoteError("\x1b[31m" + strings.Repeat("x", peerErrorMaxRunes+20))
if strings.ContainsRune(got, '\x1b') || len([]rune(got)) != peerErrorMaxRunes {
t.Fatalf("normalized remote error length=%d value=%q", len([]rune(got)), got)
}
if got := normalizeRemoteError("\x00\x1b"); got != "receiver rejected the message" {
t.Fatalf("empty remote error = %q", got)
}
}
func newStartedService(t *testing.T, root string, transport localTransport, pid int, identity Identity) *Service {
t.Helper()
service := newService(t, root, transport, pid, identity)
if err := service.Start(func(InboundMessage) bool { return true }); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = service.Close() })
return service
}
func newService(t *testing.T, root string, transport localTransport, pid int, identity Identity) *Service {
t.Helper()
service, err := New(Options{RootDir: root, PID: pid, Identity: identity, Transport: transport})
if err != nil {
t.Fatal(err)
}
return service
}
type memoryTransport struct {
mu sync.Mutex
listeners map[string]*memoryListener
}
type removeErrorTransport struct{ *memoryTransport }
func (transport *removeErrorTransport) Remove(endpoint string) error {
_ = transport.memoryTransport.Remove(endpoint)
return errors.New("forced remove failure")
}
type slowEndpointTransport struct {
*memoryTransport
slowEndpoint string
}
func (transport *slowEndpointTransport) Dial(ctx context.Context, endpoint string) (net.Conn, error) {
if endpoint == transport.slowEndpoint {
<-ctx.Done()
return nil, ctx.Err()
}
return transport.memoryTransport.Dial(ctx, endpoint)
}
type receiptTransport struct{ fastDials atomic.Int32 }
func (transport *receiptTransport) Endpoint(_ string, _ string, _ int) (string, error) {
return "", errors.New("not implemented")
}
func (transport *receiptTransport) Listen(string) (net.Listener, error) {
return nil, errors.New("not implemented")
}
func (transport *receiptTransport) Dial(ctx context.Context, endpoint string) (net.Conn, error) {
if endpoint == "slow" {
<-ctx.Done()
return nil, ctx.Err()
}
transport.fastDials.Add(1)
client, server := net.Pipe()
go func() {
_, _ = io.Copy(io.Discard, server)
_ = server.Close()
}()
return client, nil
}
func (transport *receiptTransport) Remove(string) error { return nil }
func newMemoryTransport() *memoryTransport {
return &memoryTransport{listeners: map[string]*memoryListener{}}
}
func (transport *memoryTransport) Endpoint(_ string, nonce string, pid int) (string, error) {
return fmt.Sprintf("memory:%d:%s", pid, nonce), nil
}
func (transport *memoryTransport) Listen(endpoint string) (net.Listener, error) {
transport.mu.Lock()
defer transport.mu.Unlock()
if _, exists := transport.listeners[endpoint]; exists {
return nil, errors.New("already listening")
}
listener := &memoryListener{endpoint: endpoint, conns: make(chan net.Conn), done: make(chan struct{})}
transport.listeners[endpoint] = listener
return listener, nil
}
func (transport *memoryTransport) Dial(ctx context.Context, endpoint string) (net.Conn, error) {
transport.mu.Lock()
listener := transport.listeners[endpoint]
transport.mu.Unlock()
if listener == nil {
return nil, errors.New("not listening")
}
client, server := net.Pipe()
select {
case listener.conns <- server:
return client, nil
case <-listener.done:
_ = client.Close()
_ = server.Close()
return nil, net.ErrClosed
case <-ctx.Done():
_ = client.Close()
_ = server.Close()
return nil, ctx.Err()
}
}
func (transport *memoryTransport) Remove(endpoint string) error {
transport.mu.Lock()
delete(transport.listeners, endpoint)
transport.mu.Unlock()
return nil
}
type memoryListener struct {
endpoint string
conns chan net.Conn
done chan struct{}
once sync.Once
}
func (listener *memoryListener) Accept() (net.Conn, error) {
select {
case conn := <-listener.conns:
return conn, nil
case <-listener.done:
return nil, net.ErrClosed
}
}
func (listener *memoryListener) Close() error {
listener.once.Do(func() { close(listener.done) })
return nil
}
func (listener *memoryListener) Addr() net.Addr { return memoryAddr(listener.endpoint) }
type memoryAddr string
func (address memoryAddr) Network() string { return "memory" }
func (address memoryAddr) String() string { return string(address) }
-15
View File
@@ -1,15 +0,0 @@
package peermsg
import (
"context"
"net"
)
type localTransport interface {
Endpoint(root, nonce string, pid int) (string, error)
Listen(endpoint string) (net.Listener, error)
Dial(context.Context, string) (net.Conn, error)
Remove(string) error
}
func platformTransport() localTransport { return newPlatformTransport() }
@@ -1,62 +0,0 @@
package peermsg
import (
"context"
"io"
"testing"
"time"
)
func TestPlatformTransportRoundTrip(t *testing.T) {
transport := platformTransport()
endpoint, err := transport.Endpoint(t.TempDir(), "0123456789abcdef", 4242)
if err != nil {
t.Fatal(err)
}
listener, err := transport.Listen(endpoint)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = listener.Close()
_ = transport.Remove(endpoint)
})
serverErr := make(chan error, 1)
go func() {
conn, acceptErr := listener.Accept()
if acceptErr != nil {
serverErr <- acceptErr
return
}
defer conn.Close()
payload := make([]byte, 4)
if _, readErr := io.ReadFull(conn, payload); readErr != nil {
serverErr <- readErr
return
}
_, writeErr := conn.Write(payload)
serverErr <- writeErr
}()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
conn, err := transport.Dial(ctx, endpoint)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if _, err := conn.Write([]byte("ping")); err != nil {
t.Fatal(err)
}
reply := make([]byte, 4)
if _, err := io.ReadFull(conn, reply); err != nil {
t.Fatal(err)
}
if string(reply) != "ping" {
t.Fatalf("reply = %q", reply)
}
if err := <-serverErr; err != nil {
t.Fatal(err)
}
}
-78
View File
@@ -1,78 +0,0 @@
//go:build !windows
package peermsg
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"time"
)
type unixTransport struct{}
const unixSocketPathMax = 103
func newPlatformTransport() localTransport { return unixTransport{} }
func (unixTransport) Endpoint(root, nonce string, pid int) (string, error) {
dir := filepath.Join(root, "sockets")
dir, err := canonicalPrivateDir(dir)
if err != nil {
return "", fmt.Errorf("peer messaging: create socket directory: %w", err)
}
path := filepath.Join(dir, fmt.Sprintf("%d-%s.sock", pid, nonce))
// macOS has the smallest supported sockaddr_un path (103 usable bytes).
if len(path) > unixSocketPathMax {
dir = filepath.Join(os.TempDir(), fmt.Sprintf("zero-peers-%d", os.Getuid()))
dir, err = canonicalPrivateDir(dir)
if err != nil {
return "", fmt.Errorf("peer messaging: create fallback socket directory: %w", err)
}
path = filepath.Join(dir, fmt.Sprintf("%d-%s.sock", pid, nonce))
}
if len(path) > unixSocketPathMax {
return "", fmt.Errorf("peer messaging: socket path is too long: %s", path)
}
return path, nil
}
func canonicalPrivateDir(path string) (string, error) {
canonical, err := canonicalRuntimePath(path)
if err != nil {
return "", err
}
if err := ensurePrivateDir(canonical); err != nil {
return "", err
}
return canonical, nil
}
func (unixTransport) Listen(endpoint string) (net.Listener, error) {
_ = os.Remove(endpoint)
listener, err := net.Listen("unix", endpoint)
if err != nil {
return nil, err
}
if err := os.Chmod(endpoint, 0o600); err != nil {
_ = listener.Close()
_ = os.Remove(endpoint)
return nil, err
}
return listener, nil
}
func (unixTransport) Dial(ctx context.Context, endpoint string) (net.Conn, error) {
dialer := net.Dialer{Timeout: 5 * time.Second}
return dialer.DialContext(ctx, "unix", endpoint)
}
func (unixTransport) Remove(endpoint string) error {
err := os.Remove(endpoint)
if os.IsNotExist(err) {
return nil
}
return err
}
-74
View File
@@ -1,74 +0,0 @@
//go:build unix
package peermsg
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestUnixTransportFallbackAndSocketMode(t *testing.T) {
transport := unixTransport{}
longRoot := filepath.Join(t.TempDir(), strings.Repeat("long", 30))
endpoint, err := transport.Endpoint(longRoot, "0123456789abcdef", 4242)
if err != nil {
t.Fatal(err)
}
if len(endpoint) > unixSocketPathMax {
t.Fatalf("endpoint length = %d", len(endpoint))
}
listener, err := transport.Listen(endpoint)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = listener.Close(); _ = transport.Remove(endpoint) })
info, err := os.Stat(endpoint)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("socket mode = %o", info.Mode().Perm())
}
}
func TestUnixTransportCanonicalizesSymlinkedRoot(t *testing.T) {
root, err := os.MkdirTemp("/tmp", "zp-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := os.RemoveAll(root); err != nil {
t.Errorf("remove temporary transport root: %v", err)
}
})
target := filepath.Join(root, "physical")
if err := os.Mkdir(target, 0o700); err != nil {
t.Fatal(err)
}
alias := filepath.Join(root, "alias")
if err := os.Symlink(target, alias); err != nil {
t.Fatal(err)
}
endpoint, err := (unixTransport{}).Endpoint(alias, "0123456789abcdef", 4242)
if err != nil {
t.Fatal(err)
}
canonicalTarget, err := filepath.EvalSymlinks(target)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(endpoint, canonicalTarget+string(filepath.Separator)) {
t.Fatalf("endpoint %q is not rooted at canonical target %q", endpoint, canonicalTarget)
}
}
func TestUnixTransportRejectsPathLongerThanFallback(t *testing.T) {
transport := unixTransport{}
longTmp := filepath.Join(t.TempDir(), strings.Repeat("x", unixSocketPathMax))
t.Setenv("TMPDIR", longTmp)
if _, err := transport.Endpoint(filepath.Join(t.TempDir(), strings.Repeat("y", unixSocketPathMax)), "0123456789abcdef", 4242); err == nil {
t.Fatal("expected too-long fallback path error")
}
}
-51
View File
@@ -1,51 +0,0 @@
//go:build windows
package peermsg
import (
"context"
"fmt"
"net"
"time"
winio "github.com/Microsoft/go-winio"
"golang.org/x/sys/windows"
)
type windowsPipeTransport struct{}
func newPlatformTransport() localTransport { return windowsPipeTransport{} }
func (windowsPipeTransport) Endpoint(_ string, nonce string, pid int) (string, error) {
return fmt.Sprintf(`\\.\pipe\zero-peer-%d-%s`, pid, nonce), nil
}
func (windowsPipeTransport) Listen(endpoint string) (net.Listener, error) {
var token windows.Token
if err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_QUERY, &token); err != nil {
return nil, fmt.Errorf("peer messaging: query current process token: %w", err)
}
defer token.Close()
user, err := token.GetTokenUser()
if err != nil {
return nil, fmt.Errorf("peer messaging: query current user: %w", err)
}
userSID := user.User.Sid.String()
return winio.ListenPipe(endpoint, &winio.PipeConfig{
// Limit the pipe to LocalSystem and the exact user running this process.
// The receiver still treats every peer message as untrusted agent input,
// never as user authority.
SecurityDescriptor: fmt.Sprintf("D:P(A;;GA;;;SY)(A;;GA;;;%s)", userSID),
MessageMode: false,
InputBufferSize: maxFrameBytes,
OutputBufferSize: 4096,
})
}
func (windowsPipeTransport) Dial(ctx context.Context, endpoint string) (net.Conn, error) {
dialCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return winio.DialPipeContext(dialCtx, endpoint)
}
func (windowsPipeTransport) Remove(string) error { return nil }
-110
View File
@@ -1,110 +0,0 @@
package peermsg
import "time"
const (
ProtocolVersion = 1
maxMessageBytes = 64 * 1024
maxFrameBytes = 128 * 1024
)
type PermissionClass string
const (
PermissionPrompting PermissionClass = "prompting"
PermissionBypass PermissionClass = "bypass"
)
type DeliveryStatus string
const (
DeliveryAccepted DeliveryStatus = "accepted"
DeliveryHeld DeliveryStatus = "held"
DeliveryRefused DeliveryStatus = "refused"
DeliveryDelivered DeliveryStatus = "delivered"
DeliveryDenied DeliveryStatus = "denied"
DeliveryExpired DeliveryStatus = "expired"
)
type InboundPolicy string
const (
InboundPolicyParity InboundPolicy = ""
InboundPolicyAccept InboundPolicy = "accept"
InboundPolicyHold InboundPolicy = "hold"
InboundPolicyRefuse InboundPolicy = "refuse"
)
type HoldCause string
const (
HoldCauseModeMismatch HoldCause = "mode-mismatch"
HoldCauseExplicit HoldCause = "explicit-setting"
HoldCauseModeUnknown HoldCause = "mode-unknown"
)
// Identity is the model-independent identity published by one live Zero
// process. SessionID and Name are updated when the TUI creates, resumes, or
// renames its active session.
type Identity struct {
SessionID string `json:"sessionId,omitempty"`
Name string `json:"name,omitempty"`
Cwd string `json:"cwd,omitempty"`
PermissionClass PermissionClass `json:"permissionClass"`
}
// Peer is one reachable local Zero session.
type Peer struct {
Identity
Endpoint string `json:"endpoint"`
PID int `json:"pid"`
StartedAt time.Time `json:"startedAt"`
UpdatedAt time.Time `json:"updatedAt"`
Ref string `json:"ref"`
}
// InboundMessage is delivered to the receiving application. RequiresApproval
// is true when local policy requires the user to approve delivery.
type InboundMessage struct {
ID string
From Peer
Body string
Summary string
ReceivedAt time.Time
RequiresApproval bool
HoldCause HoldCause
HopChain []string
}
type sendFrame struct {
Version int `json:"version"`
Type string `json:"type"`
ID string `json:"id"`
From Peer `json:"from"`
To string `json:"to"`
Summary string `json:"summary"`
Body string `json:"body"`
HopChain []string `json:"hopChain,omitempty"`
OrigID string `json:"origMessageId,omitempty"`
Status DeliveryStatus `json:"status,omitempty"`
}
type responseFrame struct {
Version int `json:"version"`
Type string `json:"type"`
ID string `json:"id"`
Status DeliveryStatus `json:"status"`
Error string `json:"error,omitempty"`
}
type SendResult struct {
MessageID string
Peer Peer
Status DeliveryStatus
}
type StatusEvent struct {
MessageID string
Peer Peer
Status DeliveryStatus
}
-1
View File
@@ -138,7 +138,6 @@ var descriptors = []Descriptor{
openAICompat("groq", "Groq", "https://api.groq.com/openai/v1", "llama-3.3-70b-versatile", []string{"GROQ_API_KEY"}),
openAICompat("deepseek", "DeepSeek", "https://api.deepseek.com/v1", "deepseek-chat", []string{"DEEPSEEK_API_KEY"}),
openAICompat("together", "Together AI", "https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo", []string{"TOGETHER_API_KEY"}),
openAICompat("fireworks", "Fireworks AI", "https://api.fireworks.ai/inference/v1", "accounts/fireworks/models/kimi-k2p7-code", []string{"FIREWORKS_API_KEY"}, "fireworks-ai"),
openAICompat("dashscope", "DashScope", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-plus", []string{"DASHSCOPE_API_KEY", "QWEN_API_KEY"}, "qwen"),
openAICompat("moonshot", "Moonshot AI", "https://api.moonshot.ai/v1", "kimi-k2-0905-preview", []string{"MOONSHOT_API_KEY"}, "kimi"),
openAICompat("atlascloud", "Atlas Cloud", "https://api.atlascloud.ai/v1", "qwen/qwen3.5-flash", []string{"ATLASCLOUD_API_KEY"}, "atlas cloud", "atlas"),
+1 -24
View File
@@ -22,7 +22,6 @@ var expectedCatalogIDs = []string{
"groq",
"deepseek",
"together",
"fireworks",
"dashscope",
"moonshot",
"atlascloud",
@@ -156,28 +155,6 @@ func TestLongCatDescriptor(t *testing.T) {
}
}
func TestFireworksDescriptor(t *testing.T) {
descriptor, err := Require("fireworks")
if err != nil {
t.Fatalf("Require(fireworks) error = %v", err)
}
if descriptor.Name != "Fireworks AI" {
t.Fatalf("Name = %q, want Fireworks AI", descriptor.Name)
}
if descriptor.DefaultBaseURL != "https://api.fireworks.ai/inference/v1" {
t.Fatalf("DefaultBaseURL = %q, want Fireworks OpenAI-compatible endpoint", descriptor.DefaultBaseURL)
}
if descriptor.DefaultModel != "accounts/fireworks/models/kimi-k2p7-code" {
t.Fatalf("DefaultModel = %q, want accounts/fireworks/models/kimi-k2p7-code", descriptor.DefaultModel)
}
if descriptor.Transport != TransportOpenAICompatible {
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
}
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"FIREWORKS_API_KEY"}) {
t.Fatalf("AuthEnvVars = %#v, want FIREWORKS_API_KEY", descriptor.AuthEnvVars)
}
}
func TestAtlasCloudDescriptor(t *testing.T) {
descriptor, err := Require("atlascloud")
if err != nil {
@@ -369,7 +346,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
TransportBedrock: {"bedrock"},
TransportVertex: {"vertex"},
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "opencode-go-anthropic-compatible", "custom-anthropic-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "fireworks", "dashscope", "moonshot", "atlascloud", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "atlascloud", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
}
for transport, wantIDs := range cases {
+15 -77
View File
@@ -46,13 +46,12 @@ const (
const defaultTimeout = 5 * time.Second
type Options struct {
Profile config.ProviderProfile
Connectivity bool
HTTPClient *http.Client
Resolver Resolver
OAuthResolver providerio.TokenResolver
Timeout time.Duration
UserAgent string
Profile config.ProviderProfile
Connectivity bool
HTTPClient *http.Client
Resolver Resolver
Timeout time.Duration
UserAgent string
}
type Resolver interface {
@@ -208,26 +207,11 @@ func Probe(ctx context.Context, options Options) Result {
"providerKind": metadata.ProviderKind,
}, profile))
oauthConfigured := options.OAuthResolver != nil
if oauthConfigured {
var resolveErr error
profile, oauthConfigured, resolveErr = profileWithOAuthCredential(ctx, profile, options.OAuthResolver, false)
if resolveErr != nil {
result.add(check("provider.auth", "Provider auth", StatusFail, CategoryAuth, "Provider OAuth credential could not be resolved: "+resolveErr.Error(), map[string]any{
"oauthLogin": true,
}, profile))
return result.finalize()
}
}
if credentialRequired(profile, metadata.ProviderKind) && !hasCredential(profile) && !oauthConfigured {
if credentialRequired(profile, metadata.ProviderKind) && !hasCredential(profile) {
result.add(check("provider.auth", "Provider auth", StatusFail, CategoryAuth, fmt.Sprintf("Provider %s requires API credentials.", providerName(profile)), credentialDetails(profile), profile))
return result.finalize()
}
if oauthConfigured {
result.add(check("provider.auth", "Provider auth", StatusPass, CategoryAuth, fmt.Sprintf("Provider %s has OAuth credentials configured.", providerName(profile)), map[string]any{
"oauthLogin": true,
}, profile))
} else if hasCredential(profile) {
if hasCredential(profile) {
result.add(check("provider.auth", "Provider auth", StatusPass, CategoryAuth, fmt.Sprintf("Provider %s has credentials configured.", providerName(profile)), credentialDetails(profile), profile))
} else {
result.add(check("provider.auth", "Provider auth", StatusPass, CategoryAuth, fmt.Sprintf("Provider %s does not require API credentials.", providerName(profile)), credentialDetails(profile), profile))
@@ -302,35 +286,9 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
if err != nil {
return classifyTransportError(err, profile)
}
defer func(body io.ReadCloser) {
_ = body.Close()
}(response.Body)
if response.StatusCode == http.StatusUnauthorized && options.OAuthResolver != nil {
refreshed, ok, refreshErr := profileWithOAuthCredential(requestCtx, options.Profile, options.OAuthResolver, true)
if refreshErr != nil {
return check("provider.connectivity", "Provider connectivity", StatusFail, CategoryAuth, "Provider OAuth credential refresh failed: "+refreshErr.Error(), map[string]any{
"statusCode": response.StatusCode,
}, profile)
}
if ok {
_ = response.Body.Close()
profile = refreshed
request, allowLoopbackOrPrivate, err = healthRequest(requestCtx, profile, kind, options)
if err != nil {
return check("provider.connectivity", "Provider connectivity", StatusFail, CategoryConfig, err.Error(), nil, profile)
}
if options.HTTPClient == nil {
client = newConnectivityClient(timeout, options.Resolver, sensitiveAuthHeaderNames(profile, kind), allowLoopbackOrPrivate)
}
response, err = client.Do(request)
if err != nil {
return classifyTransportError(err, profile)
}
defer func(body io.ReadCloser) {
_ = body.Close()
}(response.Body)
}
}
defer func() {
_ = response.Body.Close()
}()
body, err := io.ReadAll(io.LimitReader(response.Body, 64*1024))
if err != nil {
@@ -362,23 +320,6 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
}, profile)
}
func profileWithOAuthCredential(ctx context.Context, profile config.ProviderProfile, resolver providerio.TokenResolver, forceRefresh bool) (config.ProviderProfile, bool, error) {
if resolver == nil {
return profile, false, nil
}
header, value, ok, err := resolver(ctx, forceRefresh)
if err != nil || !ok {
return profile, false, err
}
if strings.TrimSpace(header) == "" {
header = "Authorization"
}
profile.AuthHeader = header
profile.AuthScheme = ""
profile.AuthHeaderValue = value
return profile, true, nil
}
func healthRequest(ctx context.Context, profile config.ProviderProfile, kind config.ProviderKind, options Options) (*http.Request, bool, error) {
baseURL, err := resolvedBaseURL(profile, kind)
if err != nil {
@@ -450,7 +391,7 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
return endpointSafetyError{message: "provider connectivity URL is unsafe: localhost hosts are blocked"}
}
if addr, err := netip.ParseAddr(normalized); err == nil {
if reason := blockedAddrReason(addr); reason != "" && (!allowLoopbackOrPrivate || (!addr.IsLoopback() && !addr.IsPrivate())) {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
return nil
@@ -466,7 +407,7 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
return endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
}
for _, addr := range addrs {
if reason := blockedAddrReason(addr); reason != "" && (!allowLoopbackOrPrivate || (!addr.IsLoopback() && !addr.IsPrivate())) {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
}
@@ -588,7 +529,7 @@ func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(contex
return nil, err
}
if addr, parseErr := netip.ParseAddr(host); parseErr == nil {
if reason := blockedAddrReason(addr); reason != "" && (!allowLoopbackOrPrivate || (!addr.IsLoopback() && !addr.IsPrivate())) {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
return dialer.DialContext(ctx, network, address)
@@ -601,7 +542,7 @@ func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(contex
return nil, endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
}
for _, addr := range addrs {
if reason := blockedAddrReason(addr); reason != "" && (!allowLoopbackOrPrivate || (!addr.IsLoopback() && !addr.IsPrivate())) {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
}
@@ -807,9 +748,6 @@ func redact(message string, profile config.ProviderProfile) string {
func providerSecrets(profile config.ProviderProfile) []string {
secrets := []string{profile.APIKey, profile.AuthHeaderValue}
if fields := strings.Fields(profile.AuthHeaderValue); len(fields) == 2 {
secrets = append(secrets, fields[1])
}
for _, value := range profile.CustomHeaders {
if strings.TrimSpace(value) != "" {
secrets = append(secrets, value)
@@ -180,182 +180,6 @@ func TestProbeResolvedKindConnectivityAuthErrorRedactsSecret(t *testing.T) {
}
}
func TestProbeConnectivityUsesOAuthCredential(t *testing.T) {
var gotAuth string
resolverCalled := false
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
gotAuth = r.Header.Get("Authorization")
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Body: io.NopCloser(strings.NewReader(`{"data":[]}`)),
Header: make(http.Header),
}, nil
})}
result := Probe(context.Background(), Options{
Profile: config.ProviderProfile{
Name: "xai",
CatalogID: "xai",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://api.example.com/v1",
Model: "grok-4",
},
Connectivity: true,
HTTPClient: client,
Resolver: staticResolver{addr: netip.MustParseAddr("93.184.216.34")},
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
resolverCalled = true
return "Authorization", "Bearer oauth-test-token", true, nil
},
})
if result.Status != StatusPass {
t.Fatalf("Status = %q, want pass: %#v", result.Status, result.Checks)
}
if !resolverCalled {
t.Fatal("OAuth resolver was not called")
}
if gotAuth != "Bearer oauth-test-token" {
t.Fatalf("Authorization = %q, want OAuth bearer", gotAuth)
}
}
func TestProbeConnectivityOAuthCredentialsAreRedacted(t *testing.T) {
const (
staticKey = "STATICKEYVALUE1234567890abcdef"
oauthToken = "hf_VASANTHSENTINEL0011223344"
)
var authHeaders []string
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
authHeaders = append(authHeaders, r.Header.Get("Authorization"))
return &http.Response{
StatusCode: http.StatusUnauthorized,
Status: "401 Unauthorized",
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"invalid credentials ` + staticKey + ` and ` + oauthToken + `"}}`)),
Header: make(http.Header),
}, nil
})}
result := Probe(context.Background(), Options{
Profile: config.ProviderProfile{
Name: "xai",
CatalogID: "xai",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://api.example.com/v1",
APIKey: staticKey,
Model: "grok-4",
},
Connectivity: true,
HTTPClient: client,
Resolver: staticResolver{addr: netip.MustParseAddr("93.184.216.34")},
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
return "Authorization", "Bearer " + oauthToken, true, nil
},
})
if len(authHeaders) != 2 {
t.Fatalf("Authorization headers = %#v, want initial request and one refresh retry", authHeaders)
}
for _, header := range authHeaders {
if header != "Bearer "+oauthToken {
t.Fatalf("Authorization = %q, want OAuth bearer", header)
}
}
check := result.Check("provider.connectivity")
if check == nil || check.Category != CategoryAuth {
t.Fatalf("provider.connectivity = %#v, want auth failure", check)
}
if strings.Contains(check.Message, staticKey) {
t.Fatalf("static API key leaked in message: %q", check.Message)
}
if strings.Contains(check.Message, oauthToken) {
t.Fatalf("OAuth token leaked in message: %q", check.Message)
}
if strings.Count(check.Message, "[REDACTED]") != 2 {
t.Fatalf("expected both credentials to be redacted, got %q", check.Message)
}
}
func TestProbeWithoutConnectivityRejectsUnavailableOAuthCredential(t *testing.T) {
resolverCalled := false
result := Probe(context.Background(), Options{
Profile: config.ProviderProfile{
Name: "xai",
CatalogID: "xai",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://api.example.com/v1",
Model: "grok-4",
},
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
resolverCalled = true
return "", "", false, nil
},
})
if !resolverCalled {
t.Fatal("OAuth resolver was not called")
}
if result.Status != StatusFail {
t.Fatalf("Status = %q, want fail: %#v", result.Status, result.Checks)
}
check := result.Check("provider.auth")
if check == nil || check.Status != StatusFail || check.Category != CategoryAuth {
t.Fatalf("provider.auth = %#v, want auth failure", check)
}
}
func TestProbeConnectivityRefreshesOAuthCredentialAfterUnauthorized(t *testing.T) {
var authHeaders []string
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
authHeaders = append(authHeaders, r.Header.Get("Authorization"))
status := http.StatusOK
body := `{"data":[]}`
if len(authHeaders) == 1 {
status = http.StatusUnauthorized
body = `{"error":{"message":"expired"}}`
}
return &http.Response{
StatusCode: status,
Status: http.StatusText(status),
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
})}
var refreshFlags []bool
result := Probe(context.Background(), Options{
Profile: config.ProviderProfile{
Name: "xai",
CatalogID: "xai",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://api.example.com/v1",
Model: "grok-4",
},
Connectivity: true,
HTTPClient: client,
Resolver: staticResolver{addr: netip.MustParseAddr("93.184.216.34")},
OAuthResolver: func(_ context.Context, forceRefresh bool) (string, string, bool, error) {
refreshFlags = append(refreshFlags, forceRefresh)
token := "stale-token"
if forceRefresh {
token = "fresh-token"
}
return "Authorization", "Bearer " + token, true, nil
},
})
if result.Status != StatusPass {
t.Fatalf("Status = %q, want pass after refresh: %#v", result.Status, result.Checks)
}
if len(refreshFlags) != 2 || refreshFlags[0] || !refreshFlags[1] {
t.Fatalf("OAuth refresh flags = %#v, want [false true]", refreshFlags)
}
if len(authHeaders) != 2 || authHeaders[0] != "Bearer stale-token" || authHeaders[1] != "Bearer fresh-token" {
t.Fatalf("Authorization headers = %#v, want stale then fresh bearer", authHeaders)
}
}
func TestProbeConnectivityClassifiesTimeout(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, context.DeadlineExceeded
-8
View File
@@ -122,14 +122,6 @@ var curatedModels = map[string][]Model{
{ID: "deepseek-ai/DeepSeek-R1", Description: "reasoning model"},
{ID: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", Description: "multimodal model"},
},
"fireworks": {
// Serverless-only curated set: ordinary API-key setups hit /inference/v1
// and cannot use on-demand deployment IDs.
{ID: "accounts/fireworks/models/kimi-k2p7-code", Description: "catalog default"},
{ID: "accounts/fireworks/models/deepseek-v4-flash", Description: "fast coding model"},
{ID: "accounts/fireworks/models/gpt-oss-120b", Description: "general model"},
{ID: "accounts/fireworks/models/deepseek-v4-pro", Description: "reasoning model"},
},
"dashscope": {
{ID: "qwen-plus", Description: "catalog default"},
{ID: "qwen-max", Description: "strong general model"},
@@ -28,11 +28,6 @@ func TestModelsAreProviderScoped(t *testing.T) {
want: []string{"llama-3.3-70b-versatile", "openai/gpt-oss-120b"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
},
{
provider: "fireworks",
want: []string{"accounts/fireworks/models/kimi-k2p7-code", "accounts/fireworks/models/deepseek-v4-flash"},
notWant: []string{"gpt-4.1", "llama-3.3-70b-versatile"},
},
{
provider: "chatgpt",
want: []string{"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"},
-2
View File
@@ -132,8 +132,6 @@ func ModelsDevProviderID(provider providercatalog.Descriptor) string {
return "zai"
case "minimaxi-cn":
return "minimax"
case "fireworks":
return "fireworks-ai"
default:
return strings.TrimSpace(provider.ID)
}
@@ -131,7 +131,6 @@ func TestModelsDevProviderIDMapsZeroAliases(t *testing.T) {
"ollama-cloud": "ollama-cloud",
"zai-cn": "zai",
"minimaxi-cn": "minimax",
"fireworks": "fireworks-ai",
}
for zeroID, want := range tests {
provider, ok := providercatalog.Get(zeroID)
@@ -62,7 +62,7 @@ func TestDetectLocalRuntimesReportsReachableRuntime(t *testing.T) {
if !detected[0].Reachable {
t.Fatalf("runtime should be reachable: %#v", detected[0])
}
if len(detected[0].Models) == 0 || detected[0].Models[0] != "llama3.1" {
if detected[0].Models == nil || len(detected[0].Models) == 0 || detected[0].Models[0] != "llama3.1" {
t.Fatalf("expected discovered model list, got %#v", detected[0].Models)
}
}
+1 -3
View File
@@ -25,9 +25,7 @@ type TokenResolver func(ctx context.Context, forceRefresh bool) (header string,
//
// base carries the API-key auth config (key + default header/scheme). setExtra
// (optional) sets the provider's non-auth headers on every attempt. Transient
// retries are handled by the inner SendWithRetry: 429/503/529 on their own
// budget, and provably pre-send transport failures (refused or timed-out
// connect, DNS, TLS handshake) on a separate, shorter one.
// retries (429/503/529) are handled by the inner SendWithRetry.
func SendWithAuthRetry(
ctx context.Context,
client *http.Client,
+28 -292
View File
@@ -3,9 +3,6 @@ package providerio
import (
"bytes"
"context"
"errors"
"io"
"net"
"net/http"
"strconv"
"strings"
@@ -20,22 +17,13 @@ import (
// (previously only the OpenAI provider retried; Anthropic and Gemini surfaced the
// first failure).
//
// What is retried: 429 (rate limit) and 503 (service unavailable) — the statuses
// where the server explicitly did NOT accept the request — plus PROVABLY
// pre-send transport failures (DNS resolution, a refused/unreachable dial, a TLS
// handshake timeout) where no request bytes ever left this host. In every one of
// those cases we KNOW the server did not receive and process the request, so a
// replay cannot duplicate work. Other 5xx (500/502/504) and every ambiguous or
// post-send transport error (connection reset, broken pipe, EOF, a generic i/o
// timeout, context deadline) are NOT retried: a completion POST is
// non-idempotent and may already have reached and been processed by the server,
// so replaying it could duplicate (billable) work. Only the INITIAL request is
// ever in scope; once the response body starts streaming it is never re-issued.
// Pre-send classification is by syscall errno (portable across POSIX and
// Windows), scoped to an Op=="dial" *net.OpError so the same errno raised on a
// post-send read/write can't be misread as pre-send. NXDOMAIN is treated as
// permanent (not retried), and the pre-send path fails fast on its own
// sub-second schedule rather than the seconds-long 429 backoff.
// What is retried: ONLY 429 (rate limit) and 503 (service unavailable) — the
// statuses where the server explicitly did NOT accept the request, so replaying
// it cannot duplicate work. Other 5xx (500/502/504) and transport/network errors
// are NOT retried: a completion POST is non-idempotent and may have reached and
// been processed by the server, so replaying it could duplicate
// (billable) work. Only the INITIAL request is ever in scope; once the response
// body starts streaming it is never re-issued.
const defaultMaxRetryAttempts = 6
@@ -49,32 +37,12 @@ const maxBackoff = 30 * time.Second
// schedule is 2s, 4s, 8s, 16s, then maxBackoff. A var so tests can shrink it.
var retryBackoffBase = 2 * time.Second
// preSendMaxAttempts bounds retries of a provably pre-send transport failure.
// Unlike a 429 (whose window is measured in seconds), a refused/unreachable dial
// either recovers within a few hundred ms or is a deterministic misconfiguration
// (wrong host/port, dead local daemon) that will never recover — so a short,
// low-count schedule fails fast instead of stalling the agent ~60s on the
// rate-limit schedule. This bounds the pre-send path independently of the caller's
// maxAttempts, which is tuned for 429/503.
const preSendMaxAttempts = 3
// preSendBackoffBase is the first wait before replaying a pre-send transport
// failure. Modeled on the mid-stream reconnect base (internal/agent/reconnect.go),
// it is sub-second so a transient dial blip recovers quickly and a permanent one
// fails fast. A var so tests can shrink it.
var preSendBackoffBase = 500 * time.Millisecond
// SendWithRetry issues an HTTP request, retrying ONLY the safe-to-replay server
// responses (429 and 503, see ShouldRetryStatus) up to maxAttempts — backing off
// between tries and honoring a server Retry-After header and context
// cancellation. The one transport-error exception is a PROVABLY pre-send failure
// (see isPreSendTransportError): a refused/unreachable dial or DNS failure where
// no request bytes left this host, retried on its own short schedule
// (preSendMaxAttempts / preSendBackoff). Every other 5xx and every ambiguous or
// post-send transport error is returned immediately, never replayed, because a
// non-idempotent completion POST may already have been received. The request is
// rebuilt from body each attempt; setHeader (if non-nil) sets headers on every
// attempt.
// cancellation. Other 5xx and transport/network errors are returned immediately,
// never replayed (see the package note). The request is rebuilt from body each
// attempt; setHeader (if non-nil) sets headers on every attempt.
//
// It returns the final *http.Response (which the caller inspects for a non-2xx
// status, exactly as before) or an error for a network failure / context
@@ -92,47 +60,7 @@ func SendWithRetry(
if maxAttempts <= 0 {
maxAttempts = defaultMaxRetryAttempts
}
if client == nil {
client = http.DefaultClient
}
// Redirects are still FOLLOWED, so a redirecting gateway keeps working, but
// entering one makes that attempt non-retryable. The hazard is that client.Do
// can transmit the original POST to the first host, follow a 307/308, and then
// fail the dial to the redirect target: that error is an Op=="dial" error, so
// isPreSendTransportError would read it as "nothing was sent" and replay a
// completion the first host already received. Observing the redirect rather
// than blocking it keeps both properties, since a request that was redirected
// is never replayed while redirecting endpoints stay usable. The caller's own
// CheckRedirect still runs and still decides whether to proceed.
redirected := false
callerCheckRedirect := client.CheckRedirect
redirectAwareClient := *client
redirectAwareClient.CheckRedirect = func(request *http.Request, via []*http.Request) error {
redirected = true
if callerCheckRedirect != nil {
return callerCheckRedirect(request, via)
}
// Mirror net/http's default policy, which a nil CheckRedirect would have
// applied: stop after 10 hops.
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
return nil
}
// The two retry budgets are fully independent, each with its own counter and
// its own backoff ordinal, because they answer different questions: a
// dial/DNS/TLS failure is a fast local blip (preSendMaxAttempts, sub-second
// schedule) while a 429/503 is a server-side window measured in seconds
// (maxAttempts, retryBackoffBase). A single shared loop counter coupled them
// in both directions: status retries starved the pre-send budget, and
// pre-send retries shortened the status window and started its backoff at a
// later rung. Neither kind of failure now consumes or advances the other.
preSendAttempts := 0
statusAttempts := 0
for {
// Per attempt: only a redirect entered by THIS attempt can make its own
// transport error unsafe to replay.
redirected = false
for attempt := 1; ; attempt++ {
request, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
if err != nil {
return nil, err
@@ -142,78 +70,32 @@ func SendWithRetry(
}
connectSpan := trace.FromContext(ctx).Span(trace.SpanProviderConnect)
response, err := redirectAwareClient.Do(request)
response, err := client.Do(request)
connectSpan.End()
if err != nil {
// Context cancellation always surfaces as cancellation, never a retry.
// A transport failure on a POST does NOT mean the server didn't receive
// it — the request may have arrived and be generating a (billable,
// non-idempotent) completion while only the response/connection failed.
// Replaying it could duplicate that work, so surface the error instead
// of retrying. Only 429/503 responses (below), where we KNOW the request
// was not accepted, are safe to retry.
if ctx.Err() != nil {
return nil, ctx.Err()
}
// A transport failure on a POST does NOT usually mean the server didn't
// receive it — the request may have arrived and be generating a
// (billable, non-idempotent) completion while only the response or
// connection failed. Replaying that could duplicate work, so it is
// surfaced immediately. The one safe exception is a PROVABLY pre-send
// failure (see isPreSendTransportError): the request never left this
// host, so a replay cannot duplicate anything — exactly like a 429/503
// where we KNOW the request was not accepted. The pre-send path uses
// its own short bound and sub-second schedule (preSendMaxAttempts /
// preSendBackoff), not the seconds-long 429 schedule, so a permanent
// dial failure fails fast instead of stalling the agent for ~60s.
// A redirect means the original POST already left this host, so the
// dial that failed belongs to a later hop and replaying would duplicate
// a completion the first host may already have processed.
if isPreSendTransportError(err) && !redirected {
preSendAttempts++
if preSendAttempts < preSendMaxAttempts {
if r := trace.FromContext(ctx); r != nil {
r.Counter(trace.CounterRetryCount, 1)
}
if preSendBackoff(ctx, preSendAttempts) {
continue
}
return nil, ctx.Err()
}
}
return nil, err
}
// Deliberately NOT guarded on `redirected`, unlike the transport-error
// branch above. The two differ in what is known about the request. A
// transport failure after a redirect leaves it unknown whether the
// redirect target received and began processing the POST, so replaying
// could duplicate billable work. Here every hop answered: a 307/308 is the
// origin declining and delegating, and a retryable status is the target
// declining as well, so nothing accepted the request and a replay
// duplicates nothing.
//
// The exposure is also identical to a retryable status with no redirect in
// play, which this package has always retried. Guarding on redirects here
// would not close a gap, it would make a rate-limited completion behind a
// redirecting gateway fail outright instead of backing off.
// The transport hop succeeded, so the connect phase is healthy again and
// the pre-send budget starts over. It bounds CONSECUTIVE connect failures,
// not the lifetime of the call: without this reset, two early dial retries
// followed by a 429 and then one more dial blip would surface a transport
// error while the status budget still had room, which is the coupling
// these two counters were split apart to remove. Total work stays bounded,
// since every reset costs a status attempt and those are capped.
preSendAttempts = 0
if ShouldRetryStatus(response.StatusCode) {
statusAttempts++
if statusAttempts < maxAttempts {
if r := trace.FromContext(ctx); r != nil {
r.Counter(trace.CounterRetryCount, 1)
}
wait := RetryAfter(response)
_ = response.Body.Close()
if Backoff(ctx, statusAttempts, wait) {
continue
}
// Backoff aborted: the only reason it returns false is ctx cancellation.
return nil, ctx.Err()
if ShouldRetryStatus(response.StatusCode) && attempt < maxAttempts {
if r := trace.FromContext(ctx); r != nil {
r.Counter(trace.CounterRetryCount, 1)
}
wait := RetryAfter(response)
_ = response.Body.Close()
if Backoff(ctx, attempt, wait) {
continue
}
// Backoff aborted: the only reason it returns false is ctx cancellation.
return nil, ctx.Err()
}
// Success, a non-retryable status, or retries exhausted on a retryable
@@ -227,119 +109,6 @@ func SendWithRetry(
}
}
// isPreSendTransportError reports whether a transport error PROVES no request
// bytes reached the server, so replaying the request cannot duplicate a
// billable completion. Only narrow, unambiguous pre-connect failures qualify: a
// recoverable DNS lookup failure, a refused/unreachable dial, and a TLS
// handshake timeout (the handshake completes before any HTTP request bytes are
// written).
//
// The refused/unreachable errnos (ECONNREFUSED/ENETUNREACH/EHOSTUNREACH) are
// matched by syscall errno via errors.Is — portable across platforms, since Go
// maps the WSA* codes on Windows too — but ONLY when carried by an Op=="dial"
// *net.OpError. errors.Is matches an errno anywhere in the chain regardless of
// which socket operation raised it, and the kernel raises these same errnos on
// an established connection when a route drops mid-generation; scoping to the
// dial phase is what makes "no request bytes were sent" structural rather than
// argued, so a post-send read/write can never replay a non-idempotent POST.
// NXDOMAIN is treated as permanent (deterministic) and is NOT retried.
//
// Ambiguous or post-send failures are deliberately excluded and checked FIRST,
// so an error that is both is never treated as pre-send: a reset or broken pipe
// can follow a request that was already sent, and a bare "i/o timeout" covers
// read timeouts after send as well as dial timeouts, so none of these prove the
// request was not received and a non-idempotent POST must not be replayed on
// them.
func isPreSendTransportError(err error) bool {
if err == nil {
return false
}
// DNS resolution precedes any connection, so a lookup failure proves nothing
// was sent — but only retry a lookup that could plausibly succeed on a replay.
// NXDOMAIN (IsNotFound) is authoritative and deterministic (a mistyped or
// non-existent host), so retrying it just stalls the agent through the whole
// backoff schedule before failing anyway; a timeout or temporary SERVFAIL is
// worth another try. errors.As unwraps url.Error/OpError to reach the DNSError.
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return !dnsErr.IsNotFound
}
// Post-send / ambiguous failures, excluded first. EOF and reset are matched
// by identity so wording (or a hostname containing "eof") can't fool them.
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) || isConnResetErrno(err) {
return false
}
// A CONNECT-phase timeout is provably pre-send for the same structural reason
// a refused dial is: the connection was never established, so no request
// bytes left this host. It has to be admitted BEFORE the wording exclusions
// below, because net surfaces it with "i/o timeout" in the message, which the
// blanket exclusion would otherwise reject. The gate is what keeps that safe:
// a post-send read timeout is also Timeout(), but carries Op "read", so it
// never reaches here.
if opErr := connectOpError(err); opErr != nil && opErr.Timeout() {
return true
}
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "connection reset") ||
strings.Contains(msg, "broken pipe") ||
strings.Contains(msg, "i/o timeout") {
return false
}
// A refused/unreachable connection is provably pre-send ONLY when it was
// raised during the DIAL (connection-establishment) phase. The same errnos
// are also raised by the kernel on an ESTABLISHED connection — when a route
// drops or an ICMP unreachable arrives mid-generation — which is post-send.
// errors.Is matches an errno anywhere in the chain regardless of the
// operation, so we first require an Op=="dial" *net.OpError: that makes "no
// request bytes left this host" structural, so a post-send read/write carrying
// one of these can never replay a non-idempotent POST. dialPreSendErrnos is
// platform-specific: on Windows a refused dial carries the raw WSA* errno
// (WSAECONNREFUSED etc.), which does NOT satisfy errors.Is against the POSIX
// syscall.ECONNREFUSED, so the Windows list adds those codes. The string
// markers only catch a POSIX dial error already flattened past its errno.
if connectOpError(err) != nil {
for _, errno := range dialPreSendErrnos {
if errors.Is(err, errno) {
return true
}
}
switch {
case strings.Contains(msg, "connection refused"),
strings.Contains(msg, "network is unreachable"),
strings.Contains(msg, "no route to host"):
return true
}
}
// A TLS handshake times out after the TCP dial connects but before any HTTP
// request bytes are written, so it is structurally pre-send. net/http surfaces
// it as its own error string, not a *net.OpError, so it is matched by wording
// independent of the dial gate.
if strings.Contains(msg, "tls handshake timeout") {
return true
}
return false
}
// connectOpError returns the outermost *net.OpError in err's chain when it comes
// from the connection-establishment phase, else nil. Go tags a direct dial with
// Op "dial" and a failed CONNECT through an HTTP proxy with Op "proxyconnect";
// a failure on an already-established connection carries Op "read"/"write".
// This is how isPreSendTransportError scopes the pre-send match to the connect
// phase so a post-send failure can't be misclassified as pre-send.
//
// "proxyconnect" belongs here for the same reason "dial" does: when HTTPS_PROXY
// is set and the CONNECT fails, the tunnel to the model host was never built, so
// no request bytes reached it. Omitting it meant every pre-send failure behind a
// corporate proxy fell through to no-retry, which is the environment where
// transient connect failures are most common.
func connectOpError(err error) *net.OpError {
var opErr *net.OpError
if errors.As(err, &opErr) && (opErr.Op == "dial" || opErr.Op == "proxyconnect") {
return opErr
}
return nil
}
// ShouldRetryStatus reports whether an HTTP status is safe to retry for a
// non-idempotent completion POST: 429 (Too Many Requests), 503 (Service
// Unavailable), and 529 (Anthropic's "overloaded"). All mean the server
@@ -368,39 +137,6 @@ func Backoff(ctx context.Context, attempt int, retryAfter time.Duration) bool {
}
}
// preSendBackoff waits before pre-send retry attempt N (1-based) on a short
// sub-second schedule (500ms, 1s, ...), returning false if the context is
// cancelled during the wait. Separate from Backoff's seconds-long rate-limit
// schedule because a pre-send dial failure recovers in milliseconds or never.
func preSendBackoff(ctx context.Context, attempt int) bool {
timer := time.NewTimer(preSendBackoffWait(attempt))
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
// preSendBackoffWait computes the pre-send wait before retry attempt N (1-based):
// exponential from preSendBackoffBase (500ms, 1s, 2s, ...), capped at maxBackoff.
// The exponent is clamped so a large attempt count cannot overflow.
func preSendBackoffWait(attempt int) time.Duration {
exponent := attempt - 1
if exponent > 5 {
exponent = 5
}
if exponent < 0 {
exponent = 0
}
wait := preSendBackoffBase * time.Duration(1<<exponent)
if wait > maxBackoff {
wait = maxBackoff
}
return wait
}
// backoffWait computes the wait before retry attempt N (1-based): Retry-After
// when supplied, else exponential from retryBackoffBase, both capped at
// maxBackoff. The exponent is clamped so a large attempt count cannot overflow.
Loaded 100 of 253 files, more files were not shown because too many files have changed in this diff. Show more