mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
330b10fe8e | ||
|
|
ac25352e12 | ||
|
|
de7f0b6a5b | ||
|
|
3eafaf584d | ||
|
|
9cfdb2fecf | ||
|
|
4ffbf4e0fe | ||
|
|
440d4df367 | ||
|
|
4bc9a97176 | ||
|
|
6d7d3946c8 | ||
|
|
d185f3ef0d | ||
|
|
8c6c49ad52 | ||
|
|
809489a6e3 | ||
|
|
56147a708e | ||
|
|
81aad58d97 | ||
|
|
95ccd5bc32 | ||
|
|
284a6c86cb | ||
|
|
8f3b8f0d13 | ||
|
|
0f69d995e9 | ||
|
|
87e7e69afd | ||
|
|
f4c998ac30 | ||
|
|
30bff28efc | ||
|
|
7b10aab74b | ||
|
|
3c81fea228 | ||
|
|
f6c060631e | ||
|
|
563a6dbe91 | ||
|
|
2312abe5dd |
No files matched your search
@@ -100,9 +100,10 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# govulncheck and deadcode resolve a toolchain from their own modules, which
|
||||
# can drop below the version go.mod requires and then fail to load our
|
||||
# packages. Pin GOTOOLCHAIN to the go.mod toolchain so both run under it.
|
||||
# govulncheck, deadcode, and golangci-lint each resolve a toolchain from
|
||||
# their own modules, which can drop below the version go.mod requires and
|
||||
# then fail to load our packages. Pin GOTOOLCHAIN to the go.mod toolchain
|
||||
# so all three run under it.
|
||||
- name: Pin toolchain from go.mod
|
||||
run: |
|
||||
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
|
||||
@@ -120,3 +121,13 @@ jobs:
|
||||
- name: deadcode (advisory)
|
||||
continue-on-error: true
|
||||
run: go run golang.org/x/tools/cmd/[email protected] -test=false ./...
|
||||
|
||||
# Advisory: catches what deadcode's whole-program reachability analysis
|
||||
# doesn't, unused private functions/assignments reachable within a
|
||||
# package but never actually called, plus staticcheck-style correctness
|
||||
# and readability issues. Scoped to a few linters rather than the full
|
||||
# default battery, and non-blocking, while the existing findings across
|
||||
# the repo are cleaned up incrementally (see #527).
|
||||
- name: golangci-lint (advisory)
|
||||
continue-on-error: true
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...
|
||||
@@ -37,8 +37,13 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
/zero
|
||||
/zero.exe
|
||||
/zero-skills
|
||||
/snake-game.html
|
||||
|
||||
# Superpowers skill artifacts (brainstorm mockups, specs, plans) — local only
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
|
||||
# Termux/Android build artifacts
|
||||
zero-termux-arm64
|
||||
zero-linux-sandbox
|
||||
@@ -47,6 +47,12 @@ Tips:
|
||||
- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). Zero picks those up automatically when you launch from inside the sub-tree.
|
||||
- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained.
|
||||
|
||||
### Personal guidelines, across every project
|
||||
|
||||
For preferences that follow *you*, not a specific repo (tone, tooling habits, workflow), drop a `ZERO.md` in your user config directory: `~/.config/zero/ZERO.md` on Linux/macOS, `%AppData%\Roaming\zero\ZERO.md` on Windows — the same directory as `config.json` and your personal specialists. Same format and 8 KiB cap as the project files above, and the same case-insensitive basename match.
|
||||
|
||||
This file is injected as its own `## User guidelines` section, before the project's `AGENTS.md`/`ZERO.md`, and is labeled as personal preference in the prompt: project guidelines are the later, more specific instruction and take precedence over it when the two conflict.
|
||||
|
||||
## 2. Custom specialists
|
||||
|
||||
Specialists are Zero's sub-agents. Three scopes, in priority order:
|
||||
@@ -307,6 +313,7 @@ A team that wants every contributor's Zero to behave the same way commits:
|
||||
Each contributor adds only:
|
||||
|
||||
- `~/.config/zero/config.json` — their personal API keys, theme, default mode.
|
||||
- `~/.config/zero/ZERO.md` — personal preferences that follow them across every project (see section 1).
|
||||
- `~/.local/share/zero/skills/` — personal skills they keep across projects.
|
||||
|
||||
That's it. Run `zero` from the repo root and the agent has the team's full instruction set, every contributor's personal setup, and nothing else.
|
||||
|
||||
@@ -186,6 +186,7 @@ Common slash commands:
|
||||
| `/spec`, `/plan` | draft and review a plan before building |
|
||||
| `/image` | attach an image for vision-capable models |
|
||||
| `/resume`, `/rewind` | continue or roll back local sessions |
|
||||
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
|
||||
| `/compact`, `/context` | manage context usage |
|
||||
| `/permissions`, `/tools` | inspect available tools and policy |
|
||||
| `/add-dir` | allow an extra write directory for this session |
|
||||
@@ -283,6 +284,39 @@ zero cron scheduled agent jobs
|
||||
zero update check for newer releases
|
||||
```
|
||||
|
||||
## Extending Zero
|
||||
|
||||
### Project and personal instructions
|
||||
|
||||
Zero appends project-specific guidance to the system prompt from the first
|
||||
`AGENTS.md`, `ZERO.md`, or `.zero/AGENTS.md` file found in each directory from
|
||||
the git root down to your current working directory (checked in that order
|
||||
per directory). Files are injected general-to-specific, capped at 8 KiB per
|
||||
file and 32 KiB total.
|
||||
|
||||
A personal `ZERO.md` under `config.UserConfigDir()/zero/ZERO.md`
|
||||
(`$XDG_CONFIG_HOME/zero/ZERO.md` or `~/.config/zero/ZERO.md` on Linux/macOS,
|
||||
`%AppData%\Roaming\zero\ZERO.md` on Windows) applies across every workspace, ahead of any project guidelines.
|
||||
|
||||
### Plugins
|
||||
|
||||
Plugins are discovered from `~/.config/zero/plugins/<name>/plugin.json` (user
|
||||
scope — `$XDG_CONFIG_HOME` or `~/.config` on every OS, independent of the
|
||||
`config.UserConfigDir()` path used above) and `<cwd>/.zero/plugins/<name>/plugin.json`
|
||||
(project scope — resolved from the current working directory, not the repo
|
||||
root), and managed with `zero plugins`. A manifest can declare:
|
||||
|
||||
- `tools` — custom tools (`command`, `args`, `inputSchema`, and a
|
||||
`permission` of `prompt` or `deny`; `allow` is honored only when manifest tool
|
||||
auto-approval is enabled)
|
||||
- `hooks` — commands run on `beforeTool`, `afterTool`, `sessionStart`, or
|
||||
`sessionEnd`
|
||||
- `prompts` and `skills` — additional prompt/skill files
|
||||
|
||||
MCP servers (`zero mcp`) and standalone markdown skills (`zero skills`) use
|
||||
the same extension points and can also be wired up outside of a plugin
|
||||
manifest.
|
||||
|
||||
## Appearance And Accessibility
|
||||
|
||||
| Control | Effect |
|
||||
|
||||
+4
-4
@@ -180,6 +180,7 @@ runs:
|
||||
ZERO_INPUT_ADD_DIR: ${{ inputs.add-dir }}
|
||||
ZERO_INPUT_WORKTREE: ${{ inputs.worktree }}
|
||||
ZERO_INPUT_OUTPUT_FORMAT: ${{ inputs.output-format }}
|
||||
ACTION_PATH: ${{ github.action_path }}
|
||||
RUNNER_TEMP: ${{ runner.temp }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -247,10 +248,9 @@ runs:
|
||||
echo "exit-code=${code}" >> "${GITHUB_OUTPUT}"
|
||||
echo "output-file=${output_file}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# Best-effort one-line summary: last non-empty line of the captured output.
|
||||
summary="$(grep -v '^[[:space:]]*$' "${output_file}" | tail -n 1 || true)"
|
||||
# Keep the summary single-line and bounded for the step output.
|
||||
summary="$(printf '%s' "${summary}" | cut -c1-280)"
|
||||
# Best-effort one-line summary. Structured output ends with lifecycle
|
||||
# metadata, so parse final/error events instead of tailing stdout.
|
||||
summary="$(node "${ACTION_PATH}/scripts/action-summary.mjs" "${ZERO_INPUT_OUTPUT_FORMAT}" "${output_file}" || true)"
|
||||
{
|
||||
echo "summary<<ZERO_EOF"
|
||||
printf '%s\n' "${summary}"
|
||||
|
||||
@@ -173,6 +173,61 @@ Linux native sandboxing also requires Bubblewrap to be installed.
|
||||
|
||||
macOS uses the system sandbox and does not need an extra helper binary.
|
||||
|
||||
### Termux (Android)
|
||||
|
||||
Zero can run natively on Android via [Termux](https://termux.dev/). Build with
|
||||
`GOOS=android` to avoid the `faccessat2` syscall that is blocked by Samsung's
|
||||
seccomp filter on Android:
|
||||
|
||||
```bash
|
||||
# Install Go in Termux
|
||||
pkg install golang
|
||||
|
||||
# Build Zero for Android
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
cd zero
|
||||
CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -ldflags="-s -w" -o zero ./cmd/zero
|
||||
|
||||
# Move into PATH
|
||||
mv zero ~/.local/bin/
|
||||
```
|
||||
|
||||
> **Why `GOOS=android`?** Go 1.26+ detects `runtime.GOOS == "android"` and skips
|
||||
> the `faccessat2` syscall inside `os/exec.findExecutable`, falling back to
|
||||
> permission-bit checks. Without this flag, Android's seccomp sends SIGSYS and
|
||||
> kills the process whenever Zero looks up a binary on `PATH` (git, sh, etc.).
|
||||
|
||||
**DNS.** Android does not expose `/etc/resolv.conf`. Go's pure-Go DNS resolver
|
||||
needs one. Use `proot` to bind-mount Termux's resolver config:
|
||||
|
||||
```bash
|
||||
pkg install proot
|
||||
proot -b "$PREFIX/etc/resolv.conf:/etc/resolv.conf" zero
|
||||
```
|
||||
|
||||
Create a wrapper at `~/.local/bin/zero` to avoid typing proot every time:
|
||||
|
||||
```bash
|
||||
#!/data/data/com.termux/files/usr/bin/bash
|
||||
exec proot -b "$PREFIX/etc/resolv.conf:/etc/resolv.conf" ~/.local/bin/zero.bin "$@"
|
||||
```
|
||||
|
||||
**Scroll.** On native Termux (not under PRoot), mouse scrolling works out of the
|
||||
box. The TUI uses Bubble Tea's `AllMotion` mouse mode by default. If you run Zero
|
||||
inside PRoot (e.g. through proot-distro), the scroll fix activates `CellMotion`
|
||||
to avoid PRoot's ptrace interference with the 1003 escape sequence.
|
||||
|
||||
**Providers.** Zero works with any OpenAI-compatible provider on Termux. For
|
||||
example, to use OpenCode Zen's free tier:
|
||||
|
||||
```bash
|
||||
zero providers add opencode \
|
||||
--name opencode \
|
||||
--model deepseek-v4-flash-free \
|
||||
--base-url https://opencode.ai/zen/v1 \
|
||||
--set-active
|
||||
```
|
||||
|
||||
Windows source builds can use the main `zero.exe` as the command runner and setup
|
||||
helper through Zero's built-in self-dispatch path. If you want a release-style
|
||||
layout anyway, build the standalone helper executables next to `zero.exe`:
|
||||
|
||||
@@ -41,6 +41,7 @@ events when present.
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 2, "type": "run_start", "runId": "run_20260603_abc123", "sessionId": "zero_20260603100000_abc123", "cwd": "/repo", "provider": "openai", "model": "gpt-4.1", "apiModel": "gpt-4.1" }
|
||||
{ "schemaVersion": 2, "type": "reasoning", "runId": "run_20260603_abc123", "delta": "Thinking..." }
|
||||
{ "schemaVersion": 2, "type": "text", "runId": "run_20260603_abc123", "delta": "..." }
|
||||
{ "schemaVersion": 2, "type": "tool_call", "runId": "run_20260603_abc123", "id": "call_1", "name": "read_file", "args": { "path": "README.md" }, "sideEffect": "read" }
|
||||
{ "schemaVersion": 2, "type": "permission_request", "runId": "run_20260603_abc123", "id": "call_2", "name": "write_file", "action": "prompt", "permission": "prompt", "permissionMode": "ask", "sideEffect": "write", "reason": "Creates or overwrites files." }
|
||||
@@ -51,6 +52,10 @@ events when present.
|
||||
{ "schemaVersion": 2, "type": "run_end", "runId": "run_20260603_abc123", "status": "success", "exitCode": 0 }
|
||||
```
|
||||
|
||||
`reasoning` events carry live model reasoning/status deltas for providers that
|
||||
stream them separately from answer text. They are liveness/progress events only:
|
||||
they are not folded into `text` or the final answer.
|
||||
|
||||
Permission events may include structured sandbox metadata:
|
||||
|
||||
```json
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Post-edit LSP diagnostics used to run synchronously inside edit_file /
|
||||
// write_file: every edit blocked its tool result on a fresh-publish wait with
|
||||
// a quiet-period debounce (≥300ms floor that reset on each publish, 10s cap) —
|
||||
// the largest recurring latency tax in an edit-heavy session. Collection is
|
||||
// now asynchronous: a file is enqueued the moment a mutating tool finishes, a
|
||||
// single worker checks it while the rest of the tool batch (and any
|
||||
// self-correct verification) runs, and the loop drains completed results just
|
||||
// before building the NEXT provider request, appending errors as a user-role
|
||||
// nudge. The model always issues another request after an edit turn — it has
|
||||
// to read its tool results — so it still sees introduced errors at the same
|
||||
// decision point as before, without any tool call stalling on the language
|
||||
// server.
|
||||
//
|
||||
// A file re-edited before its check runs is checked once in its final state
|
||||
// (the checker re-reads the file); a file re-edited after its check completed
|
||||
// is re-enqueued and the newer result wins. When the worker is still busy at
|
||||
// drain time, the drain gives up after a short wait and the results are
|
||||
// delivered on the following turn instead — they stay accurate because any
|
||||
// further edit re-enqueues the file.
|
||||
|
||||
// asyncDiagnosticsDrainTimeout bounds how long a turn waits on the in-flight
|
||||
// check before deferring delivery to the next turn. Most checks finish well
|
||||
// under it (debounce floor + analysis); a cold language server on a large
|
||||
// repo does not get to stall the loop the way the old inline 10s cap could.
|
||||
// A var so tests can shorten the wait.
|
||||
var asyncDiagnosticsDrainTimeout = 3 * time.Second
|
||||
|
||||
// asyncDiagnosticsFinalDrainTimeout is the wait used when the run is about to
|
||||
// FINALIZE (natural completion or the max-turns summary): there is no later
|
||||
// turn to defer to, so the gate waits out the old inline-era budget rather
|
||||
// than silently dropping an error the last edit introduced. A var so tests
|
||||
// can shorten the wait.
|
||||
var asyncDiagnosticsFinalDrainTimeout = 10 * time.Second
|
||||
|
||||
// asyncDiagnosticsNudge prefixes the drained diagnostics blocks; phrased like
|
||||
// the old inline block so the model reacts the same way.
|
||||
const asyncDiagnosticsNudge = "Diagnostics after your recent edits (fix any errors you introduced):\n"
|
||||
|
||||
type asyncDiagnostics struct {
|
||||
check func(context.Context, string) string
|
||||
workspaceRoot string
|
||||
|
||||
mu sync.Mutex
|
||||
queue []string
|
||||
queued map[string]bool
|
||||
results map[string]string
|
||||
// working is non-nil while the worker goroutine runs and is closed when it
|
||||
// exits, so drain can wait for quiescence without polling.
|
||||
working chan struct{}
|
||||
}
|
||||
|
||||
// newAsyncDiagnostics returns nil when check is nil, and every method on a nil
|
||||
// receiver is a no-op — callers wire it unconditionally.
|
||||
func newAsyncDiagnostics(check func(context.Context, string) string, workspaceRoot string) *asyncDiagnostics {
|
||||
if check == nil {
|
||||
return nil
|
||||
}
|
||||
return &asyncDiagnostics{
|
||||
check: check,
|
||||
workspaceRoot: workspaceRoot,
|
||||
queued: map[string]bool{},
|
||||
results: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// enqueue schedules changed files for a background check. Paths are
|
||||
// workspace-relative from Result.ChangedFiles (absolute when under a granted
|
||||
// extra write root), mirroring SelfCorrector's resolution.
|
||||
func (diagnostics *asyncDiagnostics) enqueue(ctx context.Context, files []string) {
|
||||
if diagnostics == nil || len(files) == 0 {
|
||||
return
|
||||
}
|
||||
diagnostics.mu.Lock()
|
||||
defer diagnostics.mu.Unlock()
|
||||
for _, file := range files {
|
||||
path := diagnostics.absPath(file)
|
||||
if diagnostics.queued[path] {
|
||||
continue
|
||||
}
|
||||
diagnostics.queued[path] = true
|
||||
// A completed result for this file describes a pre-edit state now.
|
||||
delete(diagnostics.results, path)
|
||||
diagnostics.queue = append(diagnostics.queue, path)
|
||||
}
|
||||
if diagnostics.working == nil && len(diagnostics.queue) > 0 {
|
||||
done := make(chan struct{})
|
||||
diagnostics.working = done
|
||||
go diagnostics.work(ctx, done)
|
||||
}
|
||||
}
|
||||
|
||||
func (diagnostics *asyncDiagnostics) work(ctx context.Context, done chan struct{}) {
|
||||
defer close(done)
|
||||
for {
|
||||
diagnostics.mu.Lock()
|
||||
if len(diagnostics.queue) == 0 || ctx.Err() != nil {
|
||||
diagnostics.working = nil
|
||||
diagnostics.mu.Unlock()
|
||||
return
|
||||
}
|
||||
path := diagnostics.queue[0]
|
||||
diagnostics.queue = diagnostics.queue[1:]
|
||||
// Un-mark before checking so an edit that lands DURING the check
|
||||
// re-enqueues the file and it is checked again in its final state.
|
||||
delete(diagnostics.queued, path)
|
||||
diagnostics.mu.Unlock()
|
||||
|
||||
block := diagnostics.check(ctx, path)
|
||||
|
||||
diagnostics.mu.Lock()
|
||||
if block != "" {
|
||||
diagnostics.results[path] = block
|
||||
} else {
|
||||
delete(diagnostics.results, path)
|
||||
}
|
||||
diagnostics.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// drain waits briefly for the worker to go quiet, then formats and consumes
|
||||
// all completed error blocks as one nudge. Returns "" when there is nothing
|
||||
// to report or the worker is still busy (results then surface next turn).
|
||||
func (diagnostics *asyncDiagnostics) drain(ctx context.Context) string {
|
||||
return diagnostics.drainWithin(ctx, asyncDiagnosticsDrainTimeout)
|
||||
}
|
||||
|
||||
// drainFinal is drain with the finalization budget: called before the run's
|
||||
// last model request, where an undelivered result would otherwise be lost.
|
||||
func (diagnostics *asyncDiagnostics) drainFinal(ctx context.Context) string {
|
||||
return diagnostics.drainWithin(ctx, asyncDiagnosticsFinalDrainTimeout)
|
||||
}
|
||||
|
||||
func (diagnostics *asyncDiagnostics) drainWithin(ctx context.Context, timeout time.Duration) string {
|
||||
if diagnostics == nil {
|
||||
return ""
|
||||
}
|
||||
diagnostics.mu.Lock()
|
||||
busy := diagnostics.working
|
||||
diagnostics.mu.Unlock()
|
||||
if busy != nil {
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-busy:
|
||||
case <-timer.C:
|
||||
return ""
|
||||
case <-ctx.Done():
|
||||
return ""
|
||||
}
|
||||
}
|
||||
diagnostics.mu.Lock()
|
||||
defer diagnostics.mu.Unlock()
|
||||
if len(diagnostics.results) == 0 {
|
||||
return ""
|
||||
}
|
||||
paths := make([]string, 0, len(diagnostics.results))
|
||||
for path := range diagnostics.results {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
blocks := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
blocks = append(blocks, diagnostics.results[path])
|
||||
}
|
||||
diagnostics.results = map[string]string{}
|
||||
return asyncDiagnosticsNudge + strings.Join(blocks, "\n")
|
||||
}
|
||||
|
||||
func (diagnostics *asyncDiagnostics) absPath(path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(diagnostics.workspaceRoot, path)
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestAsyncDiagnosticsNilCollectorNoOps(t *testing.T) {
|
||||
var diagnostics *asyncDiagnostics
|
||||
if diagnostics != newAsyncDiagnostics(nil, "/ws") {
|
||||
t.Fatal("nil check must yield a nil collector")
|
||||
}
|
||||
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||
if nudge := diagnostics.drain(context.Background()); nudge != "" {
|
||||
t.Fatalf("nil collector drain = %q, want empty", nudge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncDiagnosticsCollectsAndDrainsOnce(t *testing.T) {
|
||||
// A real absolute root: a literal "/ws" is not filepath.IsAbs on Windows
|
||||
// (no drive letter), which is exactly what the assertions below check.
|
||||
root := t.TempDir()
|
||||
var mu sync.Mutex
|
||||
var checked []string
|
||||
check := func(_ context.Context, absPath string) string {
|
||||
mu.Lock()
|
||||
checked = append(checked, absPath)
|
||||
mu.Unlock()
|
||||
if filepath.Base(absPath) == "clean.go" {
|
||||
return ""
|
||||
}
|
||||
return "ERR " + filepath.Base(absPath)
|
||||
}
|
||||
diagnostics := newAsyncDiagnostics(check, root)
|
||||
diagnostics.enqueue(context.Background(), []string{"broken.go", "clean.go"})
|
||||
|
||||
nudge := diagnostics.drain(context.Background())
|
||||
if !strings.HasPrefix(nudge, asyncDiagnosticsNudge) {
|
||||
t.Fatalf("nudge missing prefix: %q", nudge)
|
||||
}
|
||||
if !strings.Contains(nudge, "ERR broken.go") || strings.Contains(nudge, "clean.go") {
|
||||
t.Fatalf("nudge = %q, want broken.go errors only", nudge)
|
||||
}
|
||||
mu.Lock()
|
||||
for _, path := range checked {
|
||||
if !filepath.IsAbs(path) || !strings.HasPrefix(path, root+string(filepath.Separator)) {
|
||||
t.Fatalf("check received %q, want path under workspace root %q", path, root)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
if second := diagnostics.drain(context.Background()); second != "" {
|
||||
t.Fatalf("second drain = %q, want empty (results are consumed)", second)
|
||||
}
|
||||
}
|
||||
|
||||
// A check still running at drain time defers delivery to the next drain
|
||||
// instead of blocking the turn.
|
||||
func TestAsyncDiagnosticsSlowCheckDefersToNextDrain(t *testing.T) {
|
||||
previous := asyncDiagnosticsDrainTimeout
|
||||
asyncDiagnosticsDrainTimeout = 20 * time.Millisecond
|
||||
defer func() { asyncDiagnosticsDrainTimeout = previous }()
|
||||
|
||||
release := make(chan struct{})
|
||||
check := func(_ context.Context, absPath string) string {
|
||||
<-release
|
||||
return "ERR " + filepath.Base(absPath)
|
||||
}
|
||||
diagnostics := newAsyncDiagnostics(check, "/ws")
|
||||
diagnostics.enqueue(context.Background(), []string{"slow.go"})
|
||||
|
||||
start := time.Now()
|
||||
if nudge := diagnostics.drain(context.Background()); nudge != "" {
|
||||
t.Fatalf("drain during in-flight check = %q, want empty", nudge)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Fatalf("drain blocked %v, want bounded by the (shortened) timeout", elapsed)
|
||||
}
|
||||
|
||||
close(release)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
if nudge := diagnostics.drain(context.Background()); nudge != "" {
|
||||
if !strings.Contains(nudge, "ERR slow.go") {
|
||||
t.Fatalf("deferred nudge = %q", nudge)
|
||||
}
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("deferred diagnostics never delivered")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-enqueueing a file after its check completed replaces the stale result.
|
||||
func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
response := "ERR old"
|
||||
check := func(context.Context, string) string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return response
|
||||
}
|
||||
diagnostics := newAsyncDiagnostics(check, "/ws")
|
||||
|
||||
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||
waitForIdle(t, diagnostics)
|
||||
mu.Lock()
|
||||
response = "ERR new"
|
||||
mu.Unlock()
|
||||
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||
|
||||
nudge := diagnostics.drain(context.Background())
|
||||
if !strings.Contains(nudge, "ERR new") || strings.Contains(nudge, "ERR old") {
|
||||
t.Fatalf("nudge = %q, want only the re-check result", nudge)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForIdle(t *testing.T, diagnostics *asyncDiagnostics) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
diagnostics.mu.Lock()
|
||||
busy := diagnostics.working
|
||||
diagnostics.mu.Unlock()
|
||||
if busy == nil {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("worker never went idle")
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// changedFilesTool is a fake mutating tool reporting a changed file without
|
||||
// touching the filesystem.
|
||||
type changedFilesTool struct{}
|
||||
|
||||
func (changedFilesTool) Name() string { return "fake_edit" }
|
||||
func (changedFilesTool) Description() string { return "test mutating tool" }
|
||||
func (changedFilesTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
|
||||
}
|
||||
func (changedFilesTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionAllow, Reason: "test"}
|
||||
}
|
||||
func (changedFilesTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "edited", ChangedFiles: []string{"main.go"}}
|
||||
}
|
||||
|
||||
// End-to-end through Run: an edit turn does NOT carry diagnostics in the tool
|
||||
// result (the old inline path), but the next request contains the background
|
||||
// diagnostics nudge, before the model finalizes.
|
||||
func TestRunDeliversAsyncDiagnosticsNudgeNextTurn(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(changedFilesTool{})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
root := t.TempDir()
|
||||
var checkedPath string
|
||||
var mu sync.Mutex
|
||||
|
||||
result, err := Run(context.Background(), "fix main.go", provider, Options{
|
||||
Registry: registry,
|
||||
Cwd: root,
|
||||
FileDiagnostics: func(_ context.Context, absPath string) string {
|
||||
mu.Lock()
|
||||
checkedPath = absPath
|
||||
mu.Unlock()
|
||||
return "main.go:1:1 error: boom"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected 2 provider turns, got %d", len(provider.requests))
|
||||
}
|
||||
|
||||
// The tool result itself must NOT block on / embed diagnostics.
|
||||
for _, message := range provider.requests[1].Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool && strings.Contains(message.Content, "boom") {
|
||||
t.Fatalf("diagnostics leaked into the tool result: %q", message.Content)
|
||||
}
|
||||
}
|
||||
// The nudge must be present in the second request as a user message.
|
||||
found := false
|
||||
for _, message := range provider.requests[1].Messages {
|
||||
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
|
||||
if !strings.Contains(message.Content, "boom") {
|
||||
t.Fatalf("nudge missing diagnostics: %q", message.Content)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("no async diagnostics nudge in the follow-up request")
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if want := filepath.Join(root, "main.go"); checkedPath != want {
|
||||
t.Fatalf("checked path = %q, want %q", checkedPath, want)
|
||||
}
|
||||
}
|
||||
|
||||
// When the run hits the maxTurns ceiling right after an edit turn, the
|
||||
// final-answer request must still carry the diagnostics nudge — otherwise an
|
||||
// error introduced by the last edit would go unreported in the summary.
|
||||
func TestRunDrainsAsyncDiagnosticsBeforeMaxTurnsFinalAnswer(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(changedFilesTool{})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "summary"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "fix main.go", provider, Options{
|
||||
Registry: registry,
|
||||
Cwd: t.TempDir(),
|
||||
MaxTurns: 1,
|
||||
FileDiagnostics: func(context.Context, string) string {
|
||||
return "main.go:1:1 error: boom"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "summary" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected the max-turns final-answer request, got %d requests", len(provider.requests))
|
||||
}
|
||||
found := false
|
||||
for _, message := range provider.requests[1].Messages {
|
||||
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("max-turns final-answer request missing the diagnostics nudge")
|
||||
}
|
||||
}
|
||||
|
||||
// When the model finalizes while a slow check is still in flight (the
|
||||
// per-turn drain missed it), the finalization gate must wait it out and give
|
||||
// the model one more turn with the nudge instead of dropping the errors.
|
||||
func TestRunFinalizationGateDeliversLateDiagnostics(t *testing.T) {
|
||||
previous := asyncDiagnosticsDrainTimeout
|
||||
asyncDiagnosticsDrainTimeout = time.Millisecond
|
||||
defer func() { asyncDiagnosticsDrainTimeout = previous }()
|
||||
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(changedFilesTool{})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done after fix"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "fix main.go", provider, Options{
|
||||
Registry: registry,
|
||||
Cwd: t.TempDir(),
|
||||
FileDiagnostics: func(context.Context, string) string {
|
||||
// Slower than the (shortened) per-turn drain, well under the
|
||||
// finalization budget.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
return "main.go:1:1 error: boom"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done after fix" {
|
||||
t.Fatalf("final answer = %q, want the post-nudge answer", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 3 {
|
||||
t.Fatalf("expected a third request carrying the late nudge, got %d requests", len(provider.requests))
|
||||
}
|
||||
found := false
|
||||
for _, message := range provider.requests[2].Messages {
|
||||
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
|
||||
if !strings.Contains(message.Content, "boom") {
|
||||
t.Fatalf("nudge missing diagnostics: %q", message.Content)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("finalization gate did not deliver the late diagnostics nudge")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
@@ -27,10 +28,20 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, tokens := range segments {
|
||||
if !knownSafeCommandSegment(tokens) && sandbox.ValidCommandPrefix(tokens) {
|
||||
return append([]string(nil), tokens...)
|
||||
// Only propose approving a prefix of one segment when every other segment
|
||||
// in the command is independently known-safe. Once a prefix is approved,
|
||||
// shellExecutionArgsForApproval escalates the whole command (every
|
||||
// segment, not just the approved one) to bypass the sandbox, so offering a
|
||||
// prefix that leaves an MSYS-prone (or otherwise unsafe) segment uncovered
|
||||
// would let that segment run unsandboxed without ever being reviewed.
|
||||
for index, tokens := range segments {
|
||||
if knownSafeCommandSegment(tokens) {
|
||||
continue
|
||||
}
|
||||
if !sandbox.ValidCommandPrefix(tokens) || !otherSegmentsKnownSafe(segments, index) {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), tokens...)
|
||||
}
|
||||
if len(segments) == 0 || !sandbox.ValidCommandPrefix(segments[0]) {
|
||||
return nil
|
||||
@@ -38,6 +49,20 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
|
||||
return append([]string(nil), segments[0]...)
|
||||
}
|
||||
|
||||
// otherSegmentsKnownSafe reports whether every segment other than the one at
|
||||
// skip is known-safe on its own.
|
||||
func otherSegmentsKnownSafe(segments [][]string, skip int) bool {
|
||||
for index, tokens := range segments {
|
||||
if index == skip {
|
||||
continue
|
||||
}
|
||||
if !knownSafeCommandSegment(tokens) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func matchCommandPrefix(toolName string, args map[string]any, options Options) (sandbox.CommandPrefixGrant, bool, bool) {
|
||||
if !isShellCommandTool(toolName) || options.Sandbox == nil {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
@@ -168,10 +193,6 @@ func cleanPrefixRule(prefix []string) []string {
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func safeRequestedPrefix(prefix []string, command []string) bool {
|
||||
return safeRequestedPrefixForSegments(prefix, [][]string{command})
|
||||
}
|
||||
|
||||
func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
|
||||
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) {
|
||||
return false
|
||||
@@ -196,14 +217,6 @@ func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
|
||||
return matched
|
||||
}
|
||||
|
||||
func safeShellCommandTokens(command string) ([]string, bool) {
|
||||
segments, ok := safeShellCommandSegments(command)
|
||||
if !ok || len(segments) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
return segments[0], true
|
||||
}
|
||||
|
||||
func safeShellCommandSegments(command string) ([][]string, bool) {
|
||||
command = strings.TrimSpace(command)
|
||||
if command == "" {
|
||||
@@ -275,7 +288,11 @@ func knownSafeCommandSegment(command []string) bool {
|
||||
if len(command) == 0 {
|
||||
return false
|
||||
}
|
||||
switch commandName(command[0]) {
|
||||
name := commandName(command[0])
|
||||
if runtime.GOOS == "windows" && tools.MsysProneCommandName(name) {
|
||||
return false
|
||||
}
|
||||
switch name {
|
||||
case "cat", "cd", "cut", "echo", "expr", "false", "grep", "head", "id",
|
||||
"ls", "nl", "paste", "pwd", "rev", "seq", "stat", "tail", "tr",
|
||||
"true", "uname", "uniq", "wc", "which", "whoami":
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
|
||||
@@ -35,12 +37,37 @@ func TestProposedCommandPrefixHonorsValidatedRequestedPrefix(t *testing.T) {
|
||||
|
||||
func TestProposedCommandPrefixSupportsSegmentedCommands(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"})
|
||||
if runtime.GOOS == "windows" {
|
||||
// head is MSYS-prone on Windows (#458), so proposedCommandPrefix must
|
||||
// not offer "ps aux" as a reusable prefix here: approving it would
|
||||
// escalate the whole command, including the uncovered head segment,
|
||||
// to bypass the sandbox unreviewed. See
|
||||
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered for
|
||||
// the platform-independent regression coverage of this behavior.
|
||||
if got != nil {
|
||||
t.Fatalf("expected no prefix on Windows because head is MSYS-prone, got %#v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
want := []string{"ps", "aux"}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Fatalf("prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered guards
|
||||
// against proposedCommandPrefix offering to approve one segment of a
|
||||
// multi-segment command (e.g. "ps aux") while a different segment (e.g. "npm
|
||||
// install") is not known-safe. shellExecutionArgsForApproval escalates the
|
||||
// entire command once any prefix is approved, so an uncovered unsafe segment
|
||||
// would bypass the sandbox unreviewed. Uses npm, which is never known-safe on
|
||||
// any platform, so the assertion does not depend on runtime.GOOS.
|
||||
func TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered(t *testing.T) {
|
||||
if got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux && npm install"}); got != nil {
|
||||
t.Fatalf("expected no prefix because npm segment is not known-safe, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixHonorsRequestedPrefixAcrossSegments(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "git status --short && git status --branch",
|
||||
@@ -120,13 +147,35 @@ func TestProposedCommandPrefixRejectsUnsafeLaunchers(t *testing.T) {
|
||||
func TestMatchCommandPrefixCoversSegmentedCommandWithSafeTail(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
||||
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
|
||||
// head is MSYS-prone on Windows (#458) and must not count as a known-safe tail.
|
||||
command := "ps aux | head -5"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "ps aux | echo ok"
|
||||
}
|
||||
|
||||
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"}, Options{Sandbox: engine})
|
||||
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": command}, Options{Sandbox: engine})
|
||||
if !ok || !session || !equalStringSlices(grant.Prefix, []string{"ps", "aux"}) {
|
||||
t.Fatalf("match = %#v ok=%v session=%v, want session ps aux prefix", grant, ok, session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnownSafeCommandSegmentRejectsMsysProneOnWindows(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("windows-only known-safe MSYS guard")
|
||||
}
|
||||
for _, command := range [][]string{{"head", "-5"}, {"cat", "file.txt"}, {"grep", "pat"}} {
|
||||
if knownSafeCommandSegment(command) {
|
||||
t.Fatalf("expected %q to be unsafe on Windows, got known-safe", command)
|
||||
}
|
||||
}
|
||||
if !knownSafeCommandSegment([]string{"echo", "ok"}) {
|
||||
t.Fatal("expected echo to remain known-safe on Windows")
|
||||
}
|
||||
if !tools.MsysProneCommandName("head") {
|
||||
t.Fatal("expected head to be MSYS-prone")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchCommandPrefixRejectsUncoveredSegment(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
||||
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/lsp"
|
||||
)
|
||||
|
||||
// fileDiagnosticsTimeout bounds one inline post-edit diagnostics check so a
|
||||
// slow or wedged language server can never hang a tool call; on timeout the
|
||||
// edit simply reports without a diagnostics block.
|
||||
const fileDiagnosticsTimeout = 10 * time.Second
|
||||
|
||||
// NewFileDiagnostics adapts an *lsp.Manager to the per-edit inline diagnostics
|
||||
// callback (tools.RunOptions.Diagnostics): it reads the just-written file,
|
||||
// checks it against the file's language server, and formats error-severity
|
||||
// diagnostics for the model. Warnings and hints are excluded — nagging about
|
||||
// style on every edit is noise, while a type error the edit just introduced is
|
||||
// exactly what the model should see before its next step. Diagnostics are
|
||||
// rendered with workspace-relative paths: the absolute path would put the
|
||||
// local username/home directory into the model prompt and session transcript
|
||||
// on every edit. Returns nil when manager is nil, disabling inline diagnostics
|
||||
// entirely.
|
||||
func NewFileDiagnostics(manager *lsp.Manager, workspaceRoot string) func(context.Context, string) string {
|
||||
if manager == nil {
|
||||
return nil
|
||||
}
|
||||
return func(ctx context.Context, absPath string) string {
|
||||
text, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
checkCtx, cancel := context.WithTimeout(ctx, fileDiagnosticsTimeout)
|
||||
defer cancel()
|
||||
diagnostics, err := manager.Check(checkCtx, absPath, string(text))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
errors := lsp.FilterBySeverity(diagnostics, lsp.SeverityError)
|
||||
if len(errors) == 0 {
|
||||
return ""
|
||||
}
|
||||
return lsp.FormatDiagnostics(diagnosticsDisplayPath(workspaceRoot, absPath), errors)
|
||||
}
|
||||
}
|
||||
|
||||
// diagnosticsDisplayPath renders absPath relative to the workspace root for
|
||||
// model-facing output, falling back to the file's base name when the path is
|
||||
// outside the workspace (a bare name still identifies the file without
|
||||
// exposing the directory layout).
|
||||
func diagnosticsDisplayPath(workspaceRoot, absPath string) string {
|
||||
if workspaceRoot != "" {
|
||||
if rel, err := filepath.Rel(workspaceRoot, absPath); err == nil && !strings.HasPrefix(rel, "..") {
|
||||
return rel
|
||||
}
|
||||
}
|
||||
return filepath.Base(absPath)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Diagnostics are model-facing: absolute paths would leak the local username
|
||||
// and directory layout into the prompt and session transcript on every edit.
|
||||
func TestDiagnosticsDisplayPath(t *testing.T) {
|
||||
root := filepath.Join("/Users", "someone", "project")
|
||||
cases := []struct {
|
||||
root, abs, want string
|
||||
}{
|
||||
{root, filepath.Join(root, "internal", "a.go"), filepath.Join("internal", "a.go")},
|
||||
{root, filepath.Join("/etc", "other.go"), "other.go"}, // outside root -> base name only
|
||||
{"", filepath.Join("/home", "user", "x.go"), "x.go"}, // no root -> base name only
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := diagnosticsDisplayPath(c.root, c.abs); got != c.want {
|
||||
t.Errorf("diagnosticsDisplayPath(%q, %q) = %q, want %q", c.root, c.abs, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -429,8 +429,9 @@ func (state *guardState) observeToolResult(name string, failed bool, output stri
|
||||
func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (stop bool) {
|
||||
hasToolCalls := len(collected.ToolCalls) > 0
|
||||
hasVisibleText := strings.TrimSpace(collected.Text) != ""
|
||||
hasReasoning := collected.HasReasoning || len(collected.ReasoningBlocks) > 0
|
||||
|
||||
if hasToolCalls || hasVisibleText {
|
||||
if hasToolCalls || hasVisibleText || hasReasoning {
|
||||
state.emptyTurns = 0
|
||||
} else {
|
||||
state.emptyTurns++
|
||||
|
||||
@@ -22,6 +22,14 @@ func textTurn(content string) []zeroruntime.StreamEvent {
|
||||
}
|
||||
}
|
||||
|
||||
// reasoningTurn produces live reasoning without visible assistant text.
|
||||
func reasoningTurn(content string) []zeroruntime.StreamEvent {
|
||||
return []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventReasoning, Content: content},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}
|
||||
}
|
||||
|
||||
// toolTurn produces a turn that calls a named tool with the given args JSON.
|
||||
func toolTurn(callID string, toolName string, args string) []zeroruntime.StreamEvent {
|
||||
return []zeroruntime.StreamEvent{
|
||||
@@ -101,6 +109,31 @@ func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunResetsEmptyTurnCounterOnReasoning(t *testing.T) {
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
reasoningTurn("thinking 1"),
|
||||
reasoningTurn("thinking 2"),
|
||||
reasoningTurn("thinking 3"),
|
||||
textTurn("done"),
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 12,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected reasoning-only turns to keep the run live until final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 4 {
|
||||
t.Fatalf("expected 4 turns, got %d", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
|
||||
+84
-5
@@ -137,6 +137,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
guards := newGuardState()
|
||||
compactor := newCompactionState(options)
|
||||
|
||||
// Background post-edit diagnostics: files changed by mutating tools are
|
||||
// checked off the tool-call critical path and any errors are appended as a
|
||||
// nudge before the NEXT request (see async_diagnostics.go). nil when no
|
||||
// FileDiagnostics callback is wired; every method no-ops on nil.
|
||||
postEditDiagnostics := newAsyncDiagnostics(options.FileDiagnostics, options.Cwd)
|
||||
|
||||
// loaded tracks deferred-eligible tools the model has pulled via tool_search
|
||||
// during THIS run. It is consulted by partitionTools each turn to expose a
|
||||
// loaded tool's full schema; it lives only for the run (v1 within-run scope).
|
||||
@@ -159,6 +165,17 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
for turn := 0; turn < maxTurns; turn++ {
|
||||
result.Turns = turn + 1
|
||||
|
||||
// Deliver background post-edit diagnostics from the previous turn's edits
|
||||
// BEFORE compaction so the nudge is part of the request being budgeted.
|
||||
// A brief wait at most (asyncDiagnosticsDrainTimeout); an unfinished check
|
||||
// simply delivers on a later turn.
|
||||
if nudge := postEditDiagnostics.drain(ctx); nudge != "" {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: nudge,
|
||||
})
|
||||
}
|
||||
|
||||
// Build the per-turn tool list first so proactive compaction can include
|
||||
// the tool-definition tokens (they ride on every request) in its estimate.
|
||||
// partitionTools depends only on registry/permissions/options/loaded, not on
|
||||
@@ -173,6 +190,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
|
||||
// Report the per-category context budget for this turn so a surface can
|
||||
@@ -208,6 +226,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
// Pre-content connect after a context-limit compaction: route through the
|
||||
// reconnect helper so a transient upstream hiccup here doesn't fail the
|
||||
@@ -268,6 +287,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
retryStream, retryStreamErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
||||
if retryStreamErr != nil {
|
||||
@@ -326,6 +346,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
||||
if retryErr != nil {
|
||||
@@ -470,6 +491,19 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Finalization diagnostics gate: edits from this run may still have
|
||||
// checks in flight — the per-turn drain waits only briefly and defers
|
||||
// to "a later turn", but a final answer means there is no later turn.
|
||||
// Wait out the full inline-era budget once; an introduced error gives
|
||||
// the model one more turn to see (and fix) it instead of being lost.
|
||||
// Free for runs that never edited: an idle collector returns "".
|
||||
if nudge := postEditDiagnostics.drainFinal(ctx); nudge != "" {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: nudge,
|
||||
})
|
||||
continue
|
||||
}
|
||||
result.FinalAnswer = collected.Text
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
@@ -495,11 +529,35 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// between tool_results breaks strict provider replay) — same after-batch
|
||||
// rationale as turnRequestedModel above.
|
||||
var changedFilesThisBatch []string
|
||||
// Parallel read-ahead state: results for calls[precomputedStart:precomputedEnd]
|
||||
// executed concurrently, consumed strictly in order below.
|
||||
var precomputed []precomputedToolResult
|
||||
precomputedStart, precomputedEnd := 0, 0
|
||||
for index, call := range collected.ToolCalls {
|
||||
// When this call starts a consecutive run of >= 2 auto-allowed read-only
|
||||
// calls, execute the whole run concurrently now (see parallel_tools.go).
|
||||
// The scan happens lazily at the run's first index — never ahead of a
|
||||
// pending mutating call — so read-after-write ordering is preserved.
|
||||
if index >= precomputedEnd {
|
||||
runEnd := index
|
||||
for runEnd < len(collected.ToolCalls) && parallelSafeToolCall(registry, collected.ToolCalls[runEnd], options) {
|
||||
runEnd++
|
||||
}
|
||||
if runEnd-index >= 2 {
|
||||
precomputed = executeParallelReadBatch(ctx, registry, collected.ToolCalls, index, runEnd, permissionMode, options)
|
||||
precomputedStart, precomputedEnd = index, runEnd
|
||||
}
|
||||
}
|
||||
if options.OnToolCall != nil {
|
||||
options.OnToolCall(call)
|
||||
}
|
||||
toolResult, abortErr := executeToolCall(ctx, registry, call, permissionMode, options)
|
||||
var toolResult ToolResult
|
||||
var abortErr error
|
||||
if index >= precomputedStart && index < precomputedEnd {
|
||||
toolResult, abortErr = precomputed[index-precomputedStart].result, precomputed[index-precomputedStart].abortErr
|
||||
} else {
|
||||
toolResult, abortErr = executeToolCall(ctx, registry, call, permissionMode, options)
|
||||
}
|
||||
if options.OnToolResult != nil {
|
||||
options.OnToolResult(toolResult)
|
||||
}
|
||||
@@ -562,11 +620,14 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.Output)
|
||||
}
|
||||
|
||||
// Post-edit self-correction: collect the files this successful mutating
|
||||
// tool changed; verification runs once over the union after the batch.
|
||||
// A read-only tool (no ChangedFiles) never contributes.
|
||||
if options.SelfCorrect != nil && toolResult.Status == tools.StatusOK && len(toolResult.ChangedFiles) > 0 {
|
||||
// Collect the files this successful mutating tool changed. Self-correct
|
||||
// verification runs once over the union after the batch; background
|
||||
// diagnostics start NOW so the language server analyzes while the rest
|
||||
// of the batch executes. A read-only tool (no ChangedFiles) never
|
||||
// contributes.
|
||||
if toolResult.Status == tools.StatusOK && len(toolResult.ChangedFiles) > 0 {
|
||||
changedFilesThisBatch = append(changedFilesThisBatch, toolResult.ChangedFiles...)
|
||||
postEditDiagnostics.enqueue(ctx, toolResult.ChangedFiles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,6 +710,17 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// mid-run, not a natural completion (a finished run returns via the no-tool-call
|
||||
// success path before this). Under the headless completion gate that is INCOMPLETE,
|
||||
// not success, so a run that loops to the turn limit isn't reported as done.
|
||||
//
|
||||
// The final-answer call is one more model request, so diagnostics from the
|
||||
// LAST turn's edits get a drain too — with the finalization budget, since
|
||||
// there is no later turn to defer to — otherwise an error introduced by
|
||||
// the final edit would go unreported in the summary.
|
||||
if nudge := postEditDiagnostics.drainFinal(ctx); nudge != "" {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: nudge,
|
||||
})
|
||||
}
|
||||
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, options); strings.TrimSpace(answer) != "" {
|
||||
result.FinalAnswer = answer
|
||||
result.FinishReason = finishReason
|
||||
@@ -681,6 +753,7 @@ func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages [
|
||||
stream, err := streamWithReconnect(ctx, provider, zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(finalMessages),
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}, reconnectNoticeFor(options))
|
||||
if err != nil {
|
||||
return "", messages, ""
|
||||
@@ -1071,6 +1144,12 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
// Per-session file version tracker so write_file/edit_file refuse to clobber
|
||||
// a file that changed on disk outside Zero since it was last read.
|
||||
FileTracker: options.FileTracker,
|
||||
// Post-edit diagnostics are NOT forwarded to the tools: they used to run
|
||||
// synchronously inside edit_file/write_file and block every mutating tool
|
||||
// call on the language server (≥300ms debounce floor, 10s cap). The loop
|
||||
// now checks changed files in the background and nudges the model before
|
||||
// its next request instead (see async_diagnostics.go). The tools' inline
|
||||
// mechanism (tools.RunOptions.Diagnostics) stays for direct API callers.
|
||||
// Forward the run's operator tool filters so a filter-aware tool
|
||||
// (tool_search) never discloses or loads an operator-hidden deferred tool.
|
||||
EnabledTools: options.EnabledTools,
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -1945,6 +1946,11 @@ func TestRunCommandPrefixApprovalBypassesSandboxForMatchingShellCalls(t *testing
|
||||
|
||||
func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
segmentedCommand := `ps aux | head -5`
|
||||
if runtime.GOOS == "windows" {
|
||||
// head is MSYS-prone on Windows (#458) and no longer counts as a known-safe tail.
|
||||
segmentedCommand = `ps aux | echo ok`
|
||||
}
|
||||
retryTool := &sandboxDeniedRetryTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(retryTool)
|
||||
@@ -1958,7 +1964,7 @@ func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T)
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"ps aux | head -5"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: fmt.Sprintf(`{"command":%q}`, segmentedCommand)},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
@@ -2907,8 +2913,10 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
|
||||
t.Fatalf("expected operating system in environment block, got %q", prompt)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
if !strings.Contains(prompt, "Windows cmd.exe syntax") || !strings.Contains(prompt, "cwd argument") {
|
||||
t.Fatalf("expected Windows cmd.exe shell guidance in prompt, got %q", prompt)
|
||||
for _, want := range []string{"Windows cmd.exe syntax", "cwd argument", "MSYS binaries", "grep", "require_escalated"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected Windows shell guidance to mention %q, got %q", want, prompt)
|
||||
}
|
||||
}
|
||||
} else if !strings.Contains(prompt, "/bin/sh syntax") {
|
||||
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// Parallel read-ahead for tool batches. When a turn requests several
|
||||
// independent lookups (read_file + grep + glob is the common shape), executing
|
||||
// them one after another serializes pure I/O waits. A consecutive run of
|
||||
// auto-allowed read-only calls is executed concurrently instead; results are
|
||||
// then consumed in the original call order, so guard counters, message
|
||||
// ordering, abort semantics, and the surface's call/result event pairing are
|
||||
// byte-identical to sequential execution. Runs never span a mutating call: a
|
||||
// read that follows a write must observe the write, so eligibility is decided
|
||||
// per consecutive run, not per batch.
|
||||
|
||||
// maxParallelReadTools bounds concurrent read-only tool executions in a turn.
|
||||
const maxParallelReadTools = 8
|
||||
|
||||
// precomputedToolResult is one parallel read-ahead execution, keyed back to
|
||||
// its batch index by the caller.
|
||||
type precomputedToolResult struct {
|
||||
result ToolResult
|
||||
abortErr error
|
||||
}
|
||||
|
||||
// parallelSafeToolCall reports whether call may run concurrently with its
|
||||
// neighbors: the tool must exist, be side-effect-free (SideEffectRead), and be
|
||||
// auto-allowed for these args, so no interactive prompt or workspace mutation
|
||||
// is on the hot path. Loop-intercepted tools (ask_user, request_permissions)
|
||||
// and tool_search (mutates the deferred-tool set) stay sequential.
|
||||
func parallelSafeToolCall(registry *tools.Registry, call ToolCall, options Options) bool {
|
||||
switch call.Name {
|
||||
case "ask_user", tools.RequestPermissionsToolName, tools.ToolSearchToolName:
|
||||
return false
|
||||
}
|
||||
tool, found := registry.Get(call.Name)
|
||||
if !found || tool.Safety().SideEffect != tools.SideEffectRead {
|
||||
return false
|
||||
}
|
||||
args := map[string]any{}
|
||||
if call.Arguments != "" {
|
||||
if err := decodeToolArguments(call.Arguments, &args); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return effectivePermission(tool, args) == tools.PermissionAllow
|
||||
}
|
||||
|
||||
// executeParallelReadBatch runs calls[start:end] concurrently (bounded by
|
||||
// maxParallelReadTools) and returns results indexed relative to start. All
|
||||
// execution-side callbacks that can fire inside executeToolCall are serialized
|
||||
// behind one mutex: a permission prompt (a sandbox preflight can demand one
|
||||
// even for an auto-allowed read) must never appear twice at once on an
|
||||
// interactive front-end, and OnPermission event handlers append to shared
|
||||
// session-recording state without their own locking — two batched reads under
|
||||
// a granted extra root would otherwise race (the pre-batch serial loop never
|
||||
// had two callbacks in flight at once).
|
||||
func executeParallelReadBatch(ctx context.Context, registry *tools.Registry, calls []ToolCall, start, end int, permissionMode PermissionMode, options Options) []precomputedToolResult {
|
||||
batchOptions := options
|
||||
var callbackMutex sync.Mutex
|
||||
if options.OnPermissionRequest != nil {
|
||||
inner := options.OnPermissionRequest
|
||||
batchOptions.OnPermissionRequest = func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
callbackMutex.Lock()
|
||||
defer callbackMutex.Unlock()
|
||||
return inner(ctx, request)
|
||||
}
|
||||
}
|
||||
if options.OnPermission != nil {
|
||||
inner := options.OnPermission
|
||||
batchOptions.OnPermission = func(event PermissionEvent) {
|
||||
callbackMutex.Lock()
|
||||
defer callbackMutex.Unlock()
|
||||
inner(event)
|
||||
}
|
||||
}
|
||||
|
||||
results := make([]precomputedToolResult, end-start)
|
||||
semaphore := make(chan struct{}, maxParallelReadTools)
|
||||
var waitGroup sync.WaitGroup
|
||||
for index := start; index < end; index++ {
|
||||
waitGroup.Add(1)
|
||||
go func(index int) {
|
||||
defer waitGroup.Done()
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
result, abortErr := executeToolCall(ctx, registry, calls[index], permissionMode, batchOptions)
|
||||
results[index-start] = precomputedToolResult{result: result, abortErr: abortErr}
|
||||
}(index)
|
||||
}
|
||||
waitGroup.Wait()
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// probeTool records execution overlap and ordering so tests can assert what
|
||||
// actually ran concurrently.
|
||||
type probeTool struct {
|
||||
name string
|
||||
sideEffect tools.SideEffect
|
||||
delay time.Duration
|
||||
// shared, when set, receives this probe's start/end entries too, so a test
|
||||
// can observe ordering ACROSS probes (e.g. reads vs a write barrier).
|
||||
shared *probeLog
|
||||
|
||||
mu sync.Mutex
|
||||
active int
|
||||
maxActive int
|
||||
log []string
|
||||
}
|
||||
|
||||
// probeLog is a mutex-guarded event log shared between probes.
|
||||
type probeLog struct {
|
||||
mu sync.Mutex
|
||||
entries []string
|
||||
}
|
||||
|
||||
func (log *probeLog) append(entry string) {
|
||||
log.mu.Lock()
|
||||
defer log.mu.Unlock()
|
||||
log.entries = append(log.entries, entry)
|
||||
}
|
||||
|
||||
func (log *probeLog) snapshot() []string {
|
||||
log.mu.Lock()
|
||||
defer log.mu.Unlock()
|
||||
return append([]string(nil), log.entries...)
|
||||
}
|
||||
|
||||
func (tool *probeTool) Name() string { return tool.name }
|
||||
func (tool *probeTool) Description() string { return "test probe tool" }
|
||||
func (tool *probeTool) Parameters() tools.Schema {
|
||||
return tools.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]tools.PropertySchema{"id": {Type: "string"}},
|
||||
AdditionalProperties: false,
|
||||
}
|
||||
}
|
||||
func (tool *probeTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tool.sideEffect, Permission: tools.PermissionAllow, Reason: "test"}
|
||||
}
|
||||
func (tool *probeTool) Run(_ context.Context, args map[string]any) tools.Result {
|
||||
id, _ := args["id"].(string)
|
||||
tool.mu.Lock()
|
||||
tool.active++
|
||||
if tool.active > tool.maxActive {
|
||||
tool.maxActive = tool.active
|
||||
}
|
||||
tool.log = append(tool.log, "start:"+id)
|
||||
tool.mu.Unlock()
|
||||
if tool.shared != nil {
|
||||
tool.shared.append("start:" + id)
|
||||
}
|
||||
time.Sleep(tool.delay)
|
||||
if tool.shared != nil {
|
||||
tool.shared.append("end:" + id)
|
||||
}
|
||||
tool.mu.Lock()
|
||||
tool.active--
|
||||
tool.log = append(tool.log, "end:"+id)
|
||||
tool.mu.Unlock()
|
||||
return tools.Result{Status: tools.StatusOK, Output: "probe " + id}
|
||||
}
|
||||
|
||||
func probeCallEvents(callID, toolName, id string) []zeroruntime.StreamEvent {
|
||||
return []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: callID, ToolName: toolName},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: callID, ArgumentsFragment: fmt.Sprintf(`{"id":%q}`, id)},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: callID},
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelSafeToolCall(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(&probeTool{name: "probe_read", sideEffect: tools.SideEffectRead})
|
||||
registry.Register(&probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite})
|
||||
|
||||
call := func(name, args string) ToolCall { return ToolCall{ID: "c", Name: name, Arguments: args} }
|
||||
if !parallelSafeToolCall(registry, call("probe_read", `{"id":"a"}`), Options{}) {
|
||||
t.Fatal("auto-allowed read tool must be parallel-safe")
|
||||
}
|
||||
if parallelSafeToolCall(registry, call("probe_write", `{"id":"a"}`), Options{}) {
|
||||
t.Fatal("mutating tool must not be parallel-safe")
|
||||
}
|
||||
if parallelSafeToolCall(registry, call("unknown_tool", `{}`), Options{}) {
|
||||
t.Fatal("unknown tool must not be parallel-safe")
|
||||
}
|
||||
if parallelSafeToolCall(registry, call("probe_read", `{"id":`), Options{}) {
|
||||
t.Fatal("undecodable arguments must not be parallel-safe")
|
||||
}
|
||||
if parallelSafeToolCall(registry, call("ask_user", `{}`), Options{}) {
|
||||
t.Fatal("loop-intercepted tools must stay sequential")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecutesConsecutiveReadsConcurrently(t *testing.T) {
|
||||
probe := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 60 * time.Millisecond}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(probe)
|
||||
|
||||
turnOne := append(probeCallEvents("call-1", "probe_read", "a"), probeCallEvents("call-2", "probe_read", "b")...)
|
||||
turnOne = append(turnOne, probeCallEvents("call-3", "probe_read", "c")...)
|
||||
turnOne = append(turnOne, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
turnOne,
|
||||
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||
},
|
||||
}
|
||||
|
||||
var results []ToolResult
|
||||
_, err := Run(context.Background(), "probe", provider, Options{
|
||||
Registry: registry,
|
||||
OnToolResult: func(result ToolResult) { results = append(results, result) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if probe.maxActive < 2 {
|
||||
t.Fatalf("consecutive read-only calls must overlap, max concurrency was %d", probe.maxActive)
|
||||
}
|
||||
// Results must still be recorded in original call order.
|
||||
if len(results) != 3 || results[0].ToolCallID != "call-1" || results[1].ToolCallID != "call-2" || results[2].ToolCallID != "call-3" {
|
||||
t.Fatalf("tool results out of order: %#v", results)
|
||||
}
|
||||
messages := provider.requests[1].Messages
|
||||
var toolOrder []string
|
||||
for _, message := range messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
toolOrder = append(toolOrder, message.ToolCallID)
|
||||
}
|
||||
}
|
||||
if len(toolOrder) != 3 || toolOrder[0] != "call-1" || toolOrder[1] != "call-2" || toolOrder[2] != "call-3" {
|
||||
t.Fatalf("recorded tool messages out of order: %v", toolOrder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunParallelReadsNeverSpanMutatingCall(t *testing.T) {
|
||||
shared := &probeLog{}
|
||||
read := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 30 * time.Millisecond, shared: shared}
|
||||
write := &probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite, shared: shared}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(read)
|
||||
registry.Register(write)
|
||||
|
||||
turnOne := append(probeCallEvents("call-1", "probe_read", "r1"), probeCallEvents("call-2", "probe_read", "r2")...)
|
||||
turnOne = append(turnOne, probeCallEvents("call-3", "probe_write", "w")...)
|
||||
turnOne = append(turnOne, probeCallEvents("call-4", "probe_read", "r3")...)
|
||||
turnOne = append(turnOne, probeCallEvents("call-5", "probe_read", "r4")...)
|
||||
turnOne = append(turnOne, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
turnOne,
|
||||
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := Run(context.Background(), "probe", provider, Options{Registry: registry})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Cross-probe ordering on the SHARED log: the write must start only after
|
||||
// both first-batch reads finished, and both second-batch reads must start
|
||||
// only after the write finished — batches never cross a mutating call.
|
||||
log := shared.snapshot()
|
||||
index := func(entry string) int {
|
||||
for i, e := range log {
|
||||
if e == entry {
|
||||
return i
|
||||
}
|
||||
}
|
||||
t.Fatalf("entry %q missing from shared log: %v", entry, log)
|
||||
return -1
|
||||
}
|
||||
writeStart, writeEnd := index("start:w"), index("end:w")
|
||||
if firstBatchMaxEnd := max(index("end:r1"), index("end:r2")); writeStart < firstBatchMaxEnd {
|
||||
t.Fatalf("write started before the first read batch finished: %v", log)
|
||||
}
|
||||
if secondBatchMinStart := min(index("start:r3"), index("start:r4")); secondBatchMinStart < writeEnd {
|
||||
t.Fatalf("second read batch started before the write finished: %v", log)
|
||||
}
|
||||
if read.maxActive < 2 {
|
||||
t.Fatalf("reads within a batch must overlap, max concurrency was %d", read.maxActive)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
|
||||
t.Fatalf("expected read_file, tool_search and loaded alpha all exposed, got %#v", exposed)
|
||||
}
|
||||
// The eager tools must precede the loaded deferred tool (appended, not inserted).
|
||||
if !(readPos < alphaPos && searchPos < alphaPos) {
|
||||
if readPos >= alphaPos || searchPos >= alphaPos {
|
||||
t.Fatalf("loaded deferred tool must be appended after the eager block; got read=%d search=%d alpha=%d", readPos, searchPos, alphaPos)
|
||||
}
|
||||
// beta was never loaded — it stays hidden.
|
||||
|
||||
@@ -7,14 +7,18 @@ import (
|
||||
)
|
||||
|
||||
func TestSkillsContextCapsLongList(t *testing.T) {
|
||||
skills := make([]SkillInfo, 0, 40)
|
||||
for i := 0; i < 40; i++ {
|
||||
// 60 skills with realistic trigger-rich descriptions (~180 chars each, under
|
||||
// the 200-rune truncation) total well past the 4096-byte list budget, so the
|
||||
// overflow summary must kick in while the block stays bounded.
|
||||
longDesc := strings.Repeat("use when the request touches deployments, release notes, or verification; ", 2) + "number "
|
||||
skills := make([]SkillInfo, 0, 60)
|
||||
for i := 0; i < 60; i++ {
|
||||
n := strconv.Itoa(i)
|
||||
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: "does something useful, number " + n})
|
||||
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: longDesc + n})
|
||||
}
|
||||
got := skillsContext(Options{Skills: skills})
|
||||
if len(got) > 1200 {
|
||||
t.Fatalf("skills block should stay bounded, got %d bytes:\n%s", len(got), got)
|
||||
if len(got) > skillsContextListBudget+1000 {
|
||||
t.Fatalf("skills block should stay bounded near the %d-byte budget, got %d bytes:\n%s", skillsContextListBudget, len(got), got)
|
||||
}
|
||||
if !strings.Contains(got, "more (call skill") {
|
||||
t.Fatalf("expected an overflow summary line, got:\n%s", got)
|
||||
@@ -25,6 +29,27 @@ func TestSkillsContextCapsLongList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A realistic mid-size skill set (20 skills, trigger-rich descriptions) must be
|
||||
// listed in FULL — no overflow summary. This pins the fix for the old 640-byte
|
||||
// budget, under which skills past ~#6 were invisible to the model and therefore
|
||||
// never triggered.
|
||||
func TestSkillsContextListsRealisticSetInFull(t *testing.T) {
|
||||
desc := "Use when the user asks about deployments, release notes, or pre-merge verification runs."
|
||||
skills := make([]SkillInfo, 0, 20)
|
||||
for i := 0; i < 20; i++ {
|
||||
skills = append(skills, SkillInfo{Name: "skill-" + strconv.Itoa(i), Description: desc})
|
||||
}
|
||||
got := skillsContext(Options{Skills: skills})
|
||||
if strings.Contains(got, "more (call skill") {
|
||||
t.Fatalf("20 described skills must all be listed without overflow, got:\n%s", got)
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
if !strings.Contains(got, "- skill-"+strconv.Itoa(i)+":") {
|
||||
t.Fatalf("skill-%d missing from the list:\n%s", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsContext(t *testing.T) {
|
||||
if got := skillsContext(Options{}); got != "" {
|
||||
t.Fatalf("no skills should yield an empty section, got %q", got)
|
||||
|
||||
+127
-27
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/repomap"
|
||||
"github.com/Gitlawb/zero/internal/workspaceseed"
|
||||
)
|
||||
@@ -38,6 +39,14 @@ const fallbackSystemPrompt = "You are Zero, a terminal coding agent. Help with t
|
||||
// general-to-specific order.
|
||||
var projectContextFiles = []string{"AGENTS.md", "ZERO.md", ".zero/AGENTS.md"}
|
||||
|
||||
// userContextFile is the per-user instruction file, resolved under
|
||||
// config.UserConfigDir()/zero/ alongside the rest of Zero's per-user config
|
||||
// (config.json, commands, specialists) so users can keep personal guidance out
|
||||
// of individual repositories.
|
||||
const userContextFile = "ZERO.md"
|
||||
|
||||
var userConfigDirForPrompt = config.UserConfigDir
|
||||
|
||||
// maxProjectContextBytes caps how much of a single project doc is injected so
|
||||
// a large guidelines file can't blow the context budget.
|
||||
const maxProjectContextBytes = 8 << 10 // 8 KiB per file
|
||||
@@ -81,6 +90,13 @@ func buildSystemPrompt(options Options) string {
|
||||
if seed := workspaceSeedContext(options.Cwd); seed != "" {
|
||||
sections = append(sections, seed)
|
||||
}
|
||||
// User guidelines are injected before workspace/project guidelines so the
|
||||
// project's AGENTS.md/ZERO.md is the later, more specific instruction
|
||||
// block. See userGuidelines for the explicit precedence note carried in
|
||||
// the section text itself.
|
||||
if user := userGuidelines(); user != "" {
|
||||
sections = append(sections, user)
|
||||
}
|
||||
if ws := workspaceContext(options.Cwd); ws != "" {
|
||||
sections = append(sections, ws)
|
||||
}
|
||||
@@ -175,10 +191,19 @@ func specialistDelegationContext(options Options) string {
|
||||
// when no skills are installed, so a skill-less run reproduces the previous prompt
|
||||
// byte-for-byte.
|
||||
// skillsContextListBudget bounds the bytes spent listing individual skills so a
|
||||
// workspace with dozens of them can't bloat every turn's prompt. Skills past the
|
||||
// budget are summarized as a count rather than dropped — the model can still load
|
||||
// any of them by name (an unknown name returns the full list from the skill tool).
|
||||
const skillsContextListBudget = 640
|
||||
// workspace with an extreme number of them can't bloat every turn's prompt.
|
||||
// Skills past the budget are summarized as a count rather than dropped — the
|
||||
// model can still load any of them by name (an unknown name returns the full
|
||||
// list from the skill tool).
|
||||
//
|
||||
// The budget is deliberately generous — roughly 18 skills with maximally long
|
||||
// (200-rune, ASCII) descriptions, or 40+ with typical terser ones; non-ASCII
|
||||
// descriptions consume it faster since the budget counts bytes. This list is
|
||||
// the model's ONLY discovery surface, so a skill squeezed out of it effectively
|
||||
// never triggers. The previous 640-byte cap silently broke invocation for
|
||||
// anyone with more than ~6 skills — the model cannot match a request against a
|
||||
// skill it cannot see.
|
||||
const skillsContextListBudget = 4096
|
||||
|
||||
func skillsContext(options Options) string {
|
||||
if len(options.Skills) == 0 {
|
||||
@@ -186,7 +211,7 @@ func skillsContext(options Options) string {
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<available_skills>\n")
|
||||
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. When a request matches one, call skill with its exact name to pull in its guidance before acting — do not guess names or skip a relevant skill.\n")
|
||||
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. Before acting on a request, scan this list; when the request matches a skill's name or description, call skill with its exact name FIRST and follow the loaded guidance — do not guess names, do not skip a matching skill, and do not substitute your own approach for its instructions.\n")
|
||||
listed, spent, omitted := 0, 0, 0
|
||||
for _, info := range options.Skills {
|
||||
name := strings.TrimSpace(info.Name)
|
||||
@@ -216,9 +241,11 @@ func skillsContext(options Options) string {
|
||||
}
|
||||
|
||||
// truncateForSkillLine keeps a skill's one-line description short so a single
|
||||
// verbose description can't dominate the skills-list budget.
|
||||
// verbose description can't dominate the skills-list budget. The cap is roomy on
|
||||
// purpose: the description carries the skill's trigger conditions ("use when…"),
|
||||
// and truncating those is what stops the model from ever invoking the skill.
|
||||
func truncateForSkillLine(desc string) string {
|
||||
const maxDescRunes = 100
|
||||
const maxDescRunes = 200
|
||||
runes := []rune(desc)
|
||||
if len(runes) <= maxDescRunes {
|
||||
return desc
|
||||
@@ -259,7 +286,7 @@ func workspaceContext(cwd string) string {
|
||||
b.WriteString("Working directory: " + cwd + "\n")
|
||||
b.WriteString("Operating system: " + runtime.GOOS + "\n")
|
||||
if runtime.GOOS == "windows" {
|
||||
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
|
||||
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories. Do not pipe to or invoke POSIX coreutils from Git for Windows (usr\\bin head/grep/tail/cat/...): they are MSYS binaries and fail under the write-restricted sandbox; use native Zero tools (grep, read_file, list_directory, glob) or cmd.exe findstr/more instead, or sandbox_permissions require_escalated only when host-level execution is truly required.\n")
|
||||
} else {
|
||||
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
|
||||
}
|
||||
@@ -312,13 +339,7 @@ func projectGuidelines(cwd, gitRoot string) string {
|
||||
if remaining := maxProjectContextTotalBytes - totalUsed; remaining < limit {
|
||||
limit = remaining
|
||||
}
|
||||
if len(content) > limit {
|
||||
cut := limit
|
||||
for cut > 0 && !utf8.RuneStart(content[cut]) {
|
||||
cut--
|
||||
}
|
||||
content = content[:cut] + "\n… (truncated)"
|
||||
}
|
||||
content = truncateGuidelineContent(content, limit)
|
||||
label := projectGuidelineLabel(match, gitRoot)
|
||||
b.WriteString("\n\n## Project guidelines (" + label + ")\n\n" + content)
|
||||
totalUsed += len(content)
|
||||
@@ -326,6 +347,92 @@ func projectGuidelines(cwd, gitRoot string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// userGuidelines returns the per-user ZERO.md instructions block, if present.
|
||||
// The file lives in config.UserConfigDir()/zero/ next to Zero's other
|
||||
// per-user config; the basename match is case-insensitive so a file saved as
|
||||
// zero.md still resolves on case-sensitive filesystems, mirroring the project
|
||||
// guideline loader. The section carries an explicit precedence note because
|
||||
// this is a global, personal preferences file: it is injected earlier in the
|
||||
// prompt than the project's AGENTS.md/ZERO.md (see buildSystemPrompt), and
|
||||
// the note keeps that precedence unambiguous even if a model weighs later
|
||||
// context more heavily than section order alone implies.
|
||||
func userGuidelines() string {
|
||||
configDir, err := userConfigDirForPrompt()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
configDir = strings.TrimSpace(configDir)
|
||||
if configDir == "" {
|
||||
return ""
|
||||
}
|
||||
match := findCaseInsensitiveFile(filepath.Join(configDir, "zero"), userContextFile)
|
||||
if match == "" {
|
||||
return ""
|
||||
}
|
||||
data, err := os.ReadFile(match)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
content := strings.TrimSpace(string(data))
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
content = truncateGuidelineContent(content, maxProjectContextBytes)
|
||||
return "## User guidelines (" + filepath.Base(match) + ")\n\n" +
|
||||
"These are the operator's personal preferences, not project policy. " +
|
||||
"Where they conflict with a repository's project guidelines below (AGENTS.md/ZERO.md), the project guidelines take precedence.\n\n" +
|
||||
content
|
||||
}
|
||||
|
||||
// truncationMarker is appended to guideline content that was cut short.
|
||||
const truncationMarker = "\n… (truncated)"
|
||||
|
||||
// truncateGuidelineContent caps content at limit bytes without splitting a
|
||||
// UTF-8 rune, appending a truncation marker when anything was cut. Space for
|
||||
// the marker is reserved before choosing the cut point. When limit is too
|
||||
// small to fit the marker itself, the marker is dropped instead, so the
|
||||
// returned string never exceeds limit for any non-negative limit.
|
||||
func truncateGuidelineContent(content string, limit int) string {
|
||||
if len(content) <= limit {
|
||||
return content
|
||||
}
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
cut := limit - len(truncationMarker)
|
||||
if cut < 0 {
|
||||
cut = 0
|
||||
}
|
||||
for cut > 0 && !utf8.RuneStart(content[cut]) {
|
||||
cut--
|
||||
}
|
||||
if truncated := content[:cut] + truncationMarker; len(truncated) <= limit {
|
||||
return truncated
|
||||
}
|
||||
// limit is smaller than the marker itself: fall back to a hard,
|
||||
// rune-safe cut at limit bytes with no marker rather than exceeding it.
|
||||
end := limit
|
||||
for end > 0 && !utf8.RuneStart(content[end]) {
|
||||
end--
|
||||
}
|
||||
return content[:end]
|
||||
}
|
||||
|
||||
// findCaseInsensitiveFile returns the on-disk path of the regular file in dir
|
||||
// whose name matches basename case-insensitively, or "" when absent.
|
||||
func findCaseInsensitiveFile(dir, basename string) string {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.EqualFold(e.Name(), basename) {
|
||||
return filepath.Join(dir, e.Name())
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// projectGuidelineDirs returns the directory chain from gitRoot to cwd
|
||||
// (inclusive), in root-to-leaf order. If gitRoot is empty or unreachable
|
||||
// from cwd, the chain collapses to [cwd].
|
||||
@@ -378,23 +485,16 @@ func projectGuidelineLabel(match, gitRoot string) string {
|
||||
// Returns "" when nothing matches.
|
||||
func findProjectContextFile(dir string) string {
|
||||
for _, name := range projectContextFiles {
|
||||
baseLower := strings.ToLower(filepath.Base(name))
|
||||
// Walk to the file's parent through dir with case-insensitive segment
|
||||
// matching, then find a regular file with the same lowercased
|
||||
// basename. This works on both case-sensitive and case-insensitive
|
||||
// filesystems and always returns the on-disk filename.
|
||||
// matching, then find a regular file with the same basename. This works
|
||||
// on both case-sensitive and case-insensitive filesystems and always
|
||||
// returns the on-disk filename.
|
||||
parent, ok := resolveDirCaseInsensitive(filepath.Dir(filepath.Join(dir, name)), dir)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
entries, err := os.ReadDir(parent)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.ToLower(e.Name()) == baseLower {
|
||||
return filepath.Join(parent, e.Name())
|
||||
}
|
||||
if match := findCaseInsensitiveFile(parent, filepath.Base(name)); match != "" {
|
||||
return match
|
||||
}
|
||||
}
|
||||
return ""
|
||||
|
||||
@@ -63,6 +63,10 @@ work.
|
||||
targeted diffs. Match the existing indentation, imports, and idioms. Match the
|
||||
file's comment density: do not add explanatory comments unless the user asks or
|
||||
the code is already comment-dense.
|
||||
- Solve the problem as posed, not a more general version of it. Add no
|
||||
speculative abstraction, configurability, or handling for cases that cannot
|
||||
occur, and nothing the user did not ask for. A small diff can still be
|
||||
over-built; if a 200-line solution could be 50, rewrite it.
|
||||
- Preserve behavior you were not asked to change. Do not delete or rewrite code
|
||||
you did not author unless the task requires it; if you must, say so.
|
||||
|
||||
|
||||
@@ -9,6 +9,25 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// TestMain points userConfigDirForPrompt at an empty, package-wide temp
|
||||
// directory for every test in this package. Without this, buildSystemPrompt
|
||||
// falls back to the real config.UserConfigDir, so any developer with a
|
||||
// personal ~/.config/zero/ZERO.md would get its content folded into
|
||||
// otherwise-unrelated prompt assertions, making test runs non-deterministic
|
||||
// across contributor machines. Tests that specifically exercise user
|
||||
// guidelines stub userConfigDirForPrompt themselves via
|
||||
// withSystemPromptTestUserConfigDir(Func) and restore this default on cleanup.
|
||||
func TestMain(m *testing.M) {
|
||||
dir, err := os.MkdirTemp("", "zero-agent-tests-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
userConfigDirForPrompt = func() (string, error) { return dir, nil }
|
||||
code := m.Run()
|
||||
os.RemoveAll(dir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
prompt := strings.ToLower(buildSystemPrompt(Options{}))
|
||||
|
||||
@@ -128,6 +147,88 @@ func systemPromptTestBlock(t *testing.T, prompt, start, end string) string {
|
||||
return body
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptIncludesUserGuidelines(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
writeSystemPromptTestFile(t, configDir, "zero/ZERO.md", " Prefer concise summaries. \n")
|
||||
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
|
||||
|
||||
prompt := buildSystemPrompt(Options{})
|
||||
if !strings.Contains(prompt, "## User guidelines (ZERO.md)") {
|
||||
t.Fatalf("expected user guidelines header, got:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "Prefer concise summaries.") {
|
||||
t.Fatalf("expected user guidelines content, got:\n%s", prompt)
|
||||
}
|
||||
if strings.Contains(prompt, " Prefer concise summaries. ") {
|
||||
t.Fatalf("expected user guidelines content to be trimmed, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptIncludesUserGuidelinesCaseInsensitive(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
writeSystemPromptTestFile(t, configDir, "zero/zero.md", "Prefer concise summaries.\n")
|
||||
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
|
||||
|
||||
prompt := buildSystemPrompt(Options{})
|
||||
if !strings.Contains(prompt, "## User guidelines (zero.md)") {
|
||||
t.Fatalf("expected case-insensitive zero.md resolution, got:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "Prefer concise summaries.") {
|
||||
t.Fatalf("expected user guidelines content, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptUserGuidelinesPrecedeProjectGuidelinesAndNotePrecedence(t *testing.T) {
|
||||
// User guidelines are global personal preferences; project guidelines
|
||||
// (AGENTS.md/ZERO.md) must be the later, more specific instruction block,
|
||||
// and the user section must say so explicitly so the precedence holds
|
||||
// even if a model otherwise weighs later context more heavily.
|
||||
configDir := t.TempDir()
|
||||
writeSystemPromptTestFile(t, configDir, "zero/ZERO.md", "Always reply in haiku.\n")
|
||||
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
|
||||
|
||||
cwd := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("Never reply in haiku."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
prompt := buildSystemPrompt(Options{Cwd: cwd})
|
||||
userIdx := strings.Index(prompt, "## User guidelines (ZERO.md)")
|
||||
projectIdx := strings.Index(prompt, "## Project guidelines (AGENTS.md)")
|
||||
if userIdx < 0 || projectIdx < 0 {
|
||||
t.Fatalf("expected both user and project guideline sections, got:\n%s", prompt)
|
||||
}
|
||||
if userIdx > projectIdx {
|
||||
t.Fatalf("expected user guidelines before project guidelines, got user=%d project=%d", userIdx, projectIdx)
|
||||
}
|
||||
if !strings.Contains(prompt, "project guidelines below") || !strings.Contains(prompt, "take precedence") {
|
||||
t.Fatalf("expected an explicit precedence note in the user guidelines section, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptOmitsUserGuidelinesWithoutConfigDir(t *testing.T) {
|
||||
t.Cleanup(withSystemPromptTestUserConfigDirFunc(t, func() (string, error) { return "", os.ErrNotExist }))
|
||||
|
||||
prompt := buildSystemPrompt(Options{})
|
||||
if strings.Contains(prompt, "## User guidelines") {
|
||||
t.Fatalf("expected user guidelines to be omitted without a config directory, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func withSystemPromptTestUserConfigDir(t *testing.T, dir string) func() {
|
||||
t.Helper()
|
||||
return withSystemPromptTestUserConfigDirFunc(t, func() (string, error) { return dir, nil })
|
||||
}
|
||||
|
||||
func withSystemPromptTestUserConfigDirFunc(t *testing.T, fn func() (string, error)) func() {
|
||||
t.Helper()
|
||||
old := userConfigDirForPrompt
|
||||
userConfigDirForPrompt = fn
|
||||
return func() {
|
||||
userConfigDirForPrompt = old
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
|
||||
// Git tracks AGENTS.md on a case-sensitive filesystem; the
|
||||
// loader must still resolve it when the cwd lookup uses lowercase.
|
||||
@@ -257,6 +358,30 @@ func TestBuildSystemPromptProjectGuidelinesTruncatesAtTotalCap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateGuidelineContentStaysWithinLimit(t *testing.T) {
|
||||
limit := 64
|
||||
content := strings.Repeat("x", limit+1)
|
||||
got := truncateGuidelineContent(content, limit)
|
||||
if len(got) > limit {
|
||||
t.Fatalf("truncated result is %d bytes, want at most %d", len(got), limit)
|
||||
}
|
||||
if !strings.Contains(got, "truncated") {
|
||||
t.Fatalf("expected truncation marker, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateGuidelineContentStaysWithinLimitForSmallBudgets(t *testing.T) {
|
||||
// Limits at or below the truncation marker's own length can't fit the
|
||||
// marker at all; the helper must still never exceed the requested limit.
|
||||
content := strings.Repeat("x", 100)
|
||||
for _, limit := range []int{0, 1, len(truncationMarker) - 1, len(truncationMarker), len(truncationMarker) + 1} {
|
||||
got := truncateGuidelineContent(content, limit)
|
||||
if len(got) > limit {
|
||||
t.Fatalf("truncateGuidelineContent(_, %d) = %q (%d bytes), want at most %d bytes", limit, got, len(got), limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectGuidelineDirsOrdersRootToLeaf(t *testing.T) {
|
||||
root := filepath.Join("r")
|
||||
leaf := filepath.Join(root, "a", "b")
|
||||
|
||||
@@ -285,6 +285,14 @@ type Options struct {
|
||||
// ceiling and the autonomy gate. nil disables it entirely (the loop is
|
||||
// byte-identical to before). One instance per run — it holds attempt state.
|
||||
SelfCorrect *SelfCorrector
|
||||
// FileDiagnostics, when set, checks files changed by mutating tools for
|
||||
// error-severity language diagnostics IN THE BACKGROUND and appends any
|
||||
// errors as a nudge before the model's next request — the model still sees
|
||||
// an error it introduced at its next decision point, but no tool call ever
|
||||
// blocks on the language server (the old inline path stalled every edit on
|
||||
// a ≥300ms debounce, 10s cap). Build one with NewFileDiagnostics. nil
|
||||
// disables post-edit diagnostics.
|
||||
FileDiagnostics func(ctx context.Context, absPath string) string
|
||||
|
||||
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
|
||||
// any assistant turn that produces text but no tool call is accepted as the
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/localcontrol"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/observability"
|
||||
"github.com/Gitlawb/zero/internal/plugins"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
@@ -77,6 +79,8 @@ type appDeps struct {
|
||||
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
||||
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
||||
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
||||
pushChanges func(context.Context, zerogit.PushOptions) (zerogit.PushResult, error)
|
||||
createPR func(context.Context, zerogit.PROptions) (zerogit.PRResult, error)
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
@@ -178,6 +182,8 @@ func defaultAppDeps() appDeps {
|
||||
runAgentEval: defaultRunAgentEval,
|
||||
inspectChanges: zerogit.Inspect,
|
||||
commitChanges: zerogit.Commit,
|
||||
pushChanges: zerogit.Push,
|
||||
createPR: zerogit.CreatePR,
|
||||
runTUI: tui.Run,
|
||||
runEditor: openEditor,
|
||||
checkUpdate: update.Check,
|
||||
@@ -229,6 +235,12 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
defer observability.Recover(observability.DefaultCrashDir(), "cli", stderr, &exitCode)
|
||||
deps = fillAppDeps(deps)
|
||||
|
||||
// CLI runs opt into the models.dev overlay (cached live context limits and
|
||||
// pricing on top of the curated catalog). Explicitly enabled here — and only
|
||||
// here — so library consumers and hermetic tests are never perturbed by a
|
||||
// cache file on the machine. The refresh itself is fired in exec/TUI startup.
|
||||
modelregistry.EnableModelsDevOverlay()
|
||||
|
||||
addDirs, args, err := splitLeadingAddDirFlags(args)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
@@ -510,6 +522,12 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.commitChanges == nil {
|
||||
deps.commitChanges = defaults.commitChanges
|
||||
}
|
||||
if deps.pushChanges == nil {
|
||||
deps.pushChanges = defaults.pushChanges
|
||||
}
|
||||
if deps.createPR == nil {
|
||||
deps.createPR = defaults.createPR
|
||||
}
|
||||
if deps.runTUI == nil {
|
||||
deps.runTUI = defaults.runTUI
|
||||
}
|
||||
@@ -544,6 +562,11 @@ func runInteractiveTUI(stderr io.Writer, deps appDeps, permissionMode agent.Perm
|
||||
}
|
||||
|
||||
func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string, forceSetup bool) int {
|
||||
// Refresh the models.dev pricing/limits cache in the background when stale;
|
||||
// the overlay is read at registry construction from the cache file, so this
|
||||
// benefits the next run and never blocks or fails this one.
|
||||
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
|
||||
|
||||
workspaceRoot, err := deps.getwd()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), 1)
|
||||
@@ -760,6 +783,14 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
Specialists: specialistRuntime.specialists,
|
||||
Skills: pluginActivation.skillInfos(deps.skillsDir()),
|
||||
},
|
||||
// LoadSkills backs /skills and direct /<skill-name> invocation in the TUI.
|
||||
// It resolves against the same merged set (default dir + plugin skill
|
||||
// roots) as the skill tool and the system-prompt list, re-read per use so
|
||||
// newly installed skills work without a restart.
|
||||
LoadSkills: cachedSkillsLoader(func() []skills.Skill {
|
||||
merged, _ := plugins.MergedSkillsLoaded(deps.skillsDir(), pluginActivation.skillRoots)
|
||||
return merged
|
||||
}),
|
||||
PermissionMode: permissionMode,
|
||||
Notify: resolved.Notify,
|
||||
KeyBindings: resolved.KeyBindings,
|
||||
@@ -1255,3 +1286,26 @@ Flags:
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// cachedSkillsLoader memoizes a skills loader for a short interval. The TUI
|
||||
// calls the loader from autocomplete on every "/x" keystroke; without a cache
|
||||
// each keystroke would re-read every SKILL.md body. Two seconds is fresh enough
|
||||
// that a newly installed skill still shows up "immediately" while typing.
|
||||
func cachedSkillsLoader(load func() []skills.Skill) func() []skills.Skill {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
at time.Time
|
||||
cached []skills.Skill
|
||||
)
|
||||
const ttl = 2 * time.Second
|
||||
return func() []skills.Skill {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if cached != nil && time.Since(at) < ttl {
|
||||
return cached
|
||||
}
|
||||
cached = load()
|
||||
at = time.Now()
|
||||
return cached
|
||||
}
|
||||
}
|
||||
+27
-13
@@ -136,6 +136,11 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// Refresh the models.dev pricing/limits cache in the background when stale;
|
||||
// the overlay is read at registry construction from the cache file, so this
|
||||
// benefits the next run and never blocks or fails this one.
|
||||
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
|
||||
|
||||
// A mode seeds model/effort/max-turns/tool filters as a preset. Expand it up
|
||||
// front — before tool-filter validation and the --list-tools branch — so a
|
||||
// mode-injected tool filter is validated and reflected in --list-tools, and a
|
||||
@@ -500,9 +505,10 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// by default the corrector is nil, leaving the agent loop byte-identical. When
|
||||
// on we verify with both the workspace test plan and LSP diagnostics over the
|
||||
// changed files; the autonomy gate inside the corrector still decides whether
|
||||
// failures auto-fix or just report. lspShutdown tears down any language-server
|
||||
// sessions the LSP half spawned (no-op when self-correct is off).
|
||||
selfCorrector, lspShutdown := newExecSelfCorrector(options.selfCorrect, workspaceRoot, options.autonomy)
|
||||
// failures auto-fix or just report. fileDiagnostics (always on, lazy) gives
|
||||
// edit_file/write_file inline error diagnostics for the file they just wrote.
|
||||
// lspShutdown tears down any language-server sessions either half spawned.
|
||||
selfCorrector, fileDiagnostics, lspShutdown := newExecSelfCorrector(options.selfCorrect, workspaceRoot, options.autonomy)
|
||||
defer lspShutdown()
|
||||
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
@@ -526,6 +532,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: options.autonomy,
|
||||
SelfCorrect: selfCorrector,
|
||||
FileDiagnostics: fileDiagnostics,
|
||||
// Headless exec: don't accept a no-tool-call turn as "done" while work
|
||||
// clearly remains (pending plan items / a mid-step continuation cue) —
|
||||
// nudge to continue, and finalize as INCOMPLETE rather than false success
|
||||
@@ -537,6 +544,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
EnabledTools: options.enabledTools,
|
||||
DisabledTools: options.disabledTools,
|
||||
OnText: writer.text,
|
||||
OnReasoning: writer.reasoning,
|
||||
OnToolCall: func(call agent.ToolCall) {
|
||||
writer.toolCall(call, registry)
|
||||
sessionRecorder.append(sessions.EventToolCall, map[string]any{
|
||||
@@ -701,23 +709,29 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// unless a changed file's language actually has one installed on PATH. The
|
||||
// returned cleanup shuts that manager down (terminating any spawned server
|
||||
// sessions); it is a no-op when self-correct is off.
|
||||
func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (*agent.SelfCorrector, func()) {
|
||||
if !enabled {
|
||||
return nil, func() {}
|
||||
}
|
||||
func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (*agent.SelfCorrector, func(context.Context, string) string, func()) {
|
||||
// The manager is created regardless of --self-correct: it also backs the
|
||||
// always-on background post-edit diagnostics (agent.NewFileDiagnostics,
|
||||
// collected off the tool-call path by the loop), and it stays lazy — no
|
||||
// language server is spawned unless an edited file's language actually has
|
||||
// one installed on PATH.
|
||||
manager := lsp.NewManager(workspaceRoot)
|
||||
cleanup := func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = manager.Shutdown(ctx)
|
||||
}
|
||||
fileDiagnostics := agent.NewFileDiagnostics(manager, workspaceRoot)
|
||||
if !enabled {
|
||||
return nil, fileDiagnostics, cleanup
|
||||
}
|
||||
corrector := agent.NewSelfCorrector(workspaceRoot, agent.NewLSPDiagnosticsChecker(manager), agent.NewProjectVerifier(workspaceRoot), agent.SelfCorrectConfig{
|
||||
Enabled: true,
|
||||
IncludeTests: true,
|
||||
IncludeLSP: true,
|
||||
Autonomy: autonomy,
|
||||
})
|
||||
cleanup := func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = manager.Shutdown(ctx)
|
||||
}
|
||||
return corrector, cleanup
|
||||
return corrector, fileDiagnostics, cleanup
|
||||
}
|
||||
|
||||
func deferredEligibleCount(registry *tools.Registry, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) int {
|
||||
|
||||
@@ -529,6 +529,45 @@ func TestRunExecStreamJSONRunStartUsesResolvedAPIModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecStreamJSONEmitsReasoningEvents(t *testing.T) {
|
||||
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
||||
cwd := t.TempDir()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"exec", "--output-format", "stream-json", "think then answer"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return reasoningExecProvider{}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
events := decodeJSONLines(t, stdout.String())
|
||||
reasoningIdx, reasoning := findJSONEventIndex(t, events, "reasoning")
|
||||
if reasoning["delta"] != "Thinking. " {
|
||||
t.Fatalf("unexpected reasoning event: %#v", reasoning)
|
||||
}
|
||||
textIdx, text := findJSONEventIndex(t, events, "text")
|
||||
if text["delta"] != "done" {
|
||||
t.Fatalf("unexpected text event: %#v", text)
|
||||
}
|
||||
if reasoningIdx >= textIdx {
|
||||
t.Fatalf("expected reasoning event before text event, got indices %d and %d", reasoningIdx, textIdx)
|
||||
}
|
||||
final := findJSONEvent(t, events, "final")
|
||||
if final["text"] != "done" {
|
||||
t.Fatalf("reasoning must not be folded into final answer: %#v", final)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecEventWriterTruncatesStreamJSONToolResults(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
writer := execEventWriter{
|
||||
@@ -751,6 +790,17 @@ type toolCallingExecProvider struct {
|
||||
answer string
|
||||
}
|
||||
|
||||
type reasoningExecProvider struct{}
|
||||
|
||||
func (reasoningExecProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
ch := make(chan zeroruntime.StreamEvent, 3)
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventReasoning, Content: "Thinking. "}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (provider toolCallingExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
for _, message := range request.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
@@ -777,13 +827,19 @@ func (provider toolCallingExecProvider) StreamCompletion(ctx context.Context, re
|
||||
|
||||
func findJSONEvent(t *testing.T, events []map[string]any, eventType string) map[string]any {
|
||||
t.Helper()
|
||||
for _, event := range events {
|
||||
_, event := findJSONEventIndex(t, events, eventType)
|
||||
return event
|
||||
}
|
||||
|
||||
func findJSONEventIndex(t *testing.T, events []map[string]any, eventType string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
for idx, event := range events {
|
||||
if event["type"] == eventType {
|
||||
return event
|
||||
return idx, event
|
||||
}
|
||||
}
|
||||
t.Fatalf("event %q not found in %#v", eventType, events)
|
||||
return nil
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
func findSessionEvent(t *testing.T, events []sessions.Event, eventType sessions.EventType) sessions.Event {
|
||||
|
||||
@@ -80,6 +80,16 @@ func (writer *execEventWriter) text(delta string) {
|
||||
writer.writeStdout(delta)
|
||||
}
|
||||
|
||||
func (writer *execEventWriter) reasoning(delta string) {
|
||||
if writer.format == execOutputJSON {
|
||||
writer.writeJSON(map[string]any{"type": "reasoning", "delta": delta})
|
||||
return
|
||||
}
|
||||
if writer.format == execOutputStreamJSON {
|
||||
writer.writeStreamJSON(streamjson.Event{Type: streamjson.EventReasoning, RunID: writer.runID, Delta: delta})
|
||||
}
|
||||
}
|
||||
|
||||
func (writer *execEventWriter) toolCall(call agent.ToolCall, registry *tools.Registry) {
|
||||
if writer.format == execOutputJSON {
|
||||
writer.writeJSON(map[string]any{
|
||||
|
||||
@@ -88,6 +88,15 @@ func formatRepoInfo(info repoinfo.Info) string {
|
||||
fmt.Fprintf(&b, "Files: %d\n", info.FileCount)
|
||||
fmt.Fprintf(&b, "Directories: %d (max depth %d)\n", info.DirectoryCount, info.MaxDepth)
|
||||
fmt.Fprintf(&b, "Est. LOC: %d\n", info.LOCEstimate)
|
||||
if info.CommitCount != nil {
|
||||
fmt.Fprintf(&b, "Commits: %d\n", *info.CommitCount)
|
||||
}
|
||||
if info.BranchCount != nil {
|
||||
fmt.Fprintf(&b, "Branches: %d\n", *info.BranchCount)
|
||||
}
|
||||
if info.TagCount != nil {
|
||||
fmt.Fprintf(&b, "Tags: %d\n", *info.TagCount)
|
||||
}
|
||||
if info.PrimaryLanguage != "" {
|
||||
fmt.Fprintf(&b, "Primary lang: %s\n", info.PrimaryLanguage)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestFormatRepoInfoText(t *testing.T) {
|
||||
contrib := 3
|
||||
info := repoInfoTestData(age, contrib)
|
||||
out := formatRepoInfo(info)
|
||||
for _, want := range []string{"Files", "TypeScript", "main", "GitHub Actions", "42", "3"} {
|
||||
for _, want := range []string{"Files", "TypeScript", "main", "GitHub Actions", "42", "3", "Commits: 100", "Branches: 5", "Tags: 10"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("text output missing %q:\n%s", want, out)
|
||||
}
|
||||
@@ -113,12 +113,16 @@ func initTempGitRepo(t *testing.T) string {
|
||||
}
|
||||
|
||||
func repoInfoTestData(age, contrib int) repoinfo.Info {
|
||||
commits := 100
|
||||
branches := 5
|
||||
tags := 10
|
||||
return repoinfo.Info{
|
||||
FileCount: 10, DirectoryCount: 3, MaxDepth: 2, LOCEstimate: 500,
|
||||
Languages: []repoinfo.LangStat{{Name: "TypeScript", LOCEstimate: 400, FileCount: 4}},
|
||||
PrimaryLanguage: "TypeScript", LanguageCount: 1,
|
||||
WorkspaceType: "none", CICD: []string{"GitHub Actions"},
|
||||
HasGit: true, Branch: "main", AgeDays: &age, Contributors90d: &contrib,
|
||||
CommitCount: &commits, BranchCount: &branches, TagCount: &tags,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+195
-2
@@ -41,6 +41,13 @@ type changesCommandOptions struct {
|
||||
hasMessage bool
|
||||
dryRun bool
|
||||
maxDiffBytes int
|
||||
remote string
|
||||
force bool
|
||||
title string
|
||||
body string
|
||||
fill bool
|
||||
draft bool
|
||||
yes bool
|
||||
auto bool
|
||||
}
|
||||
|
||||
@@ -266,6 +273,10 @@ func runChanges(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
case "push":
|
||||
return runChangesPush(args, stdout, stderr, deps)
|
||||
case "pr", "pull-request":
|
||||
return runChangesPR(args, stdout, stderr, deps)
|
||||
default:
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown changes command %q", command))
|
||||
}
|
||||
@@ -465,6 +476,41 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
|
||||
return options, false, err
|
||||
}
|
||||
options.maxDiffBytes = maxDiffBytes
|
||||
case arg == "--remote":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.remote = strings.TrimSpace(value)
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--remote="):
|
||||
options.remote = strings.TrimSpace(strings.TrimPrefix(arg, "--remote="))
|
||||
case arg == "--force":
|
||||
options.force = true
|
||||
case arg == "--title":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.title = value
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--title="):
|
||||
options.title = strings.TrimPrefix(arg, "--title=")
|
||||
case arg == "--body":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.body = value
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--body="):
|
||||
options.body = strings.TrimPrefix(arg, "--body=")
|
||||
case arg == "--fill":
|
||||
options.fill = true
|
||||
case arg == "--draft":
|
||||
options.draft = true
|
||||
case arg == "--yes":
|
||||
options.yes = true
|
||||
case arg == "-a" || arg == "--auto":
|
||||
options.auto = true
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
@@ -473,15 +519,30 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected changes argument %q", arg)}
|
||||
}
|
||||
}
|
||||
if command != "commit" && options.message != "" {
|
||||
return options, false, execUsageError{"--message is only valid with `zero changes commit`"}
|
||||
}
|
||||
if command != "commit" && (options.hasMessage || options.dryRun || options.auto) {
|
||||
return options, false, execUsageError{"--message, --dry-run, and --auto are only valid with `zero changes commit`"}
|
||||
}
|
||||
if command == "commit" && options.hasMessage && options.auto {
|
||||
return options, false, execUsageError{"cannot specify both --message and --auto"}
|
||||
}
|
||||
if command == "commit" && options.baseRef != "" {
|
||||
if command != "commit" && command != "push" && options.dryRun {
|
||||
return options, false, execUsageError{"--dry-run is only valid with commit or push"}
|
||||
}
|
||||
if command != "inspect" && options.baseRef != "" {
|
||||
return options, false, execUsageError{"--base is only valid with `zero changes inspect`"}
|
||||
}
|
||||
if command != "push" && command != "pr" && (options.remote != "" || options.force) {
|
||||
return options, false, execUsageError{"--remote and --force are only valid with push or pr"}
|
||||
}
|
||||
if command != "pr" && (options.title != "" || options.body != "" || options.fill || options.draft) {
|
||||
return options, false, execUsageError{"--title, --body, --fill, and --draft are only valid with `zero changes pr`"}
|
||||
}
|
||||
if command != "push" && command != "pr" && options.yes {
|
||||
return options, false, execUsageError{"--yes is only valid with push or pr"}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
@@ -759,14 +820,24 @@ func writeChangesHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero changes inspect [flags]
|
||||
zero changes commit [flags]
|
||||
zero changes push [flags]
|
||||
zero changes pr [flags]
|
||||
|
||||
Inspects local git changes and optionally creates a commit.
|
||||
Inspects, commits, pushes, and creates pull requests for local git changes.
|
||||
|
||||
Flags:
|
||||
-C, --cwd <path> Workspace directory
|
||||
--base <ref> Diff against <ref>...HEAD instead of the working tree
|
||||
--diff-bytes <n> Maximum diff bytes to include
|
||||
-m, --message <text> Commit message for `+"`zero changes commit`"+`
|
||||
--dry-run Preview commit metadata / push without mutating git state
|
||||
--remote <name> Remote to push to (defaults to upstream tracked branch or origin)
|
||||
--force Use force-with-lease when pushing
|
||||
--title <text> PR title
|
||||
--body <text> PR body
|
||||
--fill Automatically populate PR title and body from commits
|
||||
--draft Create PR as a draft
|
||||
--yes Confirm pushing to a default/protected branch
|
||||
-a, --auto Auto-generate commit message using LLM (use --dry-run to preview)
|
||||
--dry-run Preview commit metadata without mutating git state
|
||||
--json Print JSON output
|
||||
@@ -775,6 +846,128 @@ Flags:
|
||||
return err
|
||||
}
|
||||
|
||||
func runChangesPush(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseChangesArgs(args, "push")
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeChangesHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
result, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
|
||||
Cwd: workspaceRoot,
|
||||
Remote: options.remote,
|
||||
Force: options.force,
|
||||
DryRun: options.dryRun,
|
||||
AllowPushDefaultBranch: options.yes,
|
||||
})
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, result); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
dryRunStr := ""
|
||||
if options.dryRun {
|
||||
dryRunStr = " (dry run)"
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "Pushed branch %s to remote %s%s\n", result.Branch, result.Remote, dryRunStr); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if result.Output != "" {
|
||||
if _, err := fmt.Fprintln(stdout, result.Output); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runChangesPR(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseChangesArgs(args, "pr")
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeChangesHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if !options.fill && options.title == "" {
|
||||
return writeExecUsageError(stderr, "must provide either --fill or --title to run non-interactively")
|
||||
}
|
||||
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
if !options.json {
|
||||
if _, err := fmt.Fprintln(stdout, "Pushing current branch to set upstream..."); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
pushResult, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
|
||||
Cwd: workspaceRoot,
|
||||
Remote: options.remote,
|
||||
Force: options.force,
|
||||
AllowPushDefaultBranch: options.yes,
|
||||
})
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("auto-push failed: %v", err))
|
||||
}
|
||||
if !options.json {
|
||||
if _, err := fmt.Fprintf(stdout, "Pushed branch %s to remote %s\n", pushResult.Branch, pushResult.Remote); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
|
||||
prResult, err := deps.createPR(context.Background(), zerogit.PROptions{
|
||||
Cwd: workspaceRoot,
|
||||
Fill: options.fill,
|
||||
Draft: options.draft,
|
||||
Title: options.title,
|
||||
Body: options.body,
|
||||
})
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
if options.json {
|
||||
type prJSONResult struct {
|
||||
Branch string `json:"branch"`
|
||||
Remote string `json:"remote"`
|
||||
Output string `json:"output"`
|
||||
}
|
||||
res := prJSONResult{
|
||||
Branch: pushResult.Branch,
|
||||
Remote: pushResult.Remote,
|
||||
Output: strings.TrimSpace(prResult.Output),
|
||||
}
|
||||
if err := writePrettyJSON(stdout, res); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintln(stdout, strings.TrimSpace(prResult.Output)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func generateAutoCommitMessage(ctx context.Context, provider zeroruntime.Provider, model string, summary zerogit.ChangeSummary) (string, error) {
|
||||
var promptBuilder strings.Builder
|
||||
promptBuilder.WriteString("Analyze the following git diff and generate a concise, conventional commit message.\n")
|
||||
|
||||
@@ -131,29 +131,45 @@ func doctorSandboxPolicy(cfg config.SandboxConfig) sandbox.Policy {
|
||||
// lspServersCheck reports which language servers ZERO would use are present on
|
||||
// PATH. Missing servers are not a failure — ZERO degrades to text-only edits for
|
||||
// those languages — so the worst status is WARN, and each missing server gets an
|
||||
// actionable install command keyed by its binary name.
|
||||
// actionable install command keyed by its binary name. Only the tier-1 servers
|
||||
// (lsp.CoreServerBinaries) drive the status: the long-tail servers configured
|
||||
// for breadth are listed informationally under missingOptional, because warning
|
||||
// about a missing zls on a machine with no Zig code would be permanent noise.
|
||||
func lspServersCheck(lookup func(string) (string, error)) Check {
|
||||
if lookup == nil {
|
||||
lookup = exec.LookPath
|
||||
}
|
||||
core := map[string]bool{}
|
||||
for _, binary := range lsp.CoreServerBinaries() {
|
||||
core[binary] = true
|
||||
}
|
||||
present := map[string]any{}
|
||||
missing := map[string]any{}
|
||||
missingOptional := map[string]any{}
|
||||
for _, binary := range lsp.ServerBinaries() {
|
||||
if _, err := lookup(binary); err == nil {
|
||||
present[binary] = "on PATH"
|
||||
continue
|
||||
}
|
||||
missing[binary] = lspRemedy(binary)
|
||||
if core[binary] {
|
||||
missing[binary] = lspRemedy(binary)
|
||||
} else {
|
||||
missingOptional[binary] = lspRemedy(binary)
|
||||
}
|
||||
}
|
||||
details := map[string]any{"present": present}
|
||||
if len(missingOptional) > 0 {
|
||||
details["missingOptional"] = missingOptional
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return check("lsp.servers", "LSP servers", StatusPass, "All known language servers are available on PATH.", map[string]any{
|
||||
"present": present,
|
||||
})
|
||||
message := "All core language servers are available on PATH."
|
||||
if len(missingOptional) > 0 {
|
||||
message = fmt.Sprintf("All core language servers are available on PATH (%d optional server(s) not installed).", len(missingOptional))
|
||||
}
|
||||
return check("lsp.servers", "LSP servers", StatusPass, message, details)
|
||||
}
|
||||
return check("lsp.servers", "LSP servers", StatusWarn, fmt.Sprintf("%d language server(s) missing from PATH; affected files degrade to text-only edits.", len(missing)), map[string]any{
|
||||
"present": present,
|
||||
"missing": missing,
|
||||
})
|
||||
details["missing"] = missing
|
||||
return check("lsp.servers", "LSP servers", StatusWarn, fmt.Sprintf("%d language server(s) missing from PATH; affected files degrade to text-only edits.", len(missing)), details)
|
||||
}
|
||||
|
||||
// lspRemedy returns an actionable install command for a missing language-server
|
||||
|
||||
+123
-15
@@ -9,26 +9,134 @@ import (
|
||||
|
||||
// serverCommands maps a file extension to the language-server command (argv) ZERO
|
||||
// will spawn. The first element is the binary looked up on PATH; missing binaries
|
||||
// are not an error, the agent just degrades to text-only for that file.
|
||||
// are not an error, the agent just degrades to text-only for that file. Every
|
||||
// command is the language's community-standard server invoked in stdio mode, so
|
||||
// a wrong guess can never spawn something surprising — only fail the PATH check.
|
||||
var serverCommands = map[string][]string{
|
||||
".go": {"gopls", "serve"},
|
||||
".ts": {"typescript-language-server", "--stdio"},
|
||||
".tsx": {"typescript-language-server", "--stdio"},
|
||||
".js": {"typescript-language-server", "--stdio"},
|
||||
".jsx": {"typescript-language-server", "--stdio"},
|
||||
".py": {"pyright-langserver", "--stdio"},
|
||||
".rs": {"rust-analyzer"},
|
||||
".go": {"gopls", "serve"},
|
||||
".ts": {"typescript-language-server", "--stdio"},
|
||||
".tsx": {"typescript-language-server", "--stdio"},
|
||||
".mts": {"typescript-language-server", "--stdio"},
|
||||
".cts": {"typescript-language-server", "--stdio"},
|
||||
".js": {"typescript-language-server", "--stdio"},
|
||||
".jsx": {"typescript-language-server", "--stdio"},
|
||||
".mjs": {"typescript-language-server", "--stdio"},
|
||||
".cjs": {"typescript-language-server", "--stdio"},
|
||||
".py": {"pyright-langserver", "--stdio"},
|
||||
".pyi": {"pyright-langserver", "--stdio"},
|
||||
".rs": {"rust-analyzer"},
|
||||
".c": {"clangd"},
|
||||
".h": {"clangd"},
|
||||
".cpp": {"clangd"},
|
||||
".cc": {"clangd"},
|
||||
".cxx": {"clangd"},
|
||||
".hpp": {"clangd"},
|
||||
".java": {"jdtls"},
|
||||
".kt": {"kotlin-language-server"},
|
||||
".kts": {"kotlin-language-server"},
|
||||
".rb": {"ruby-lsp"},
|
||||
".php": {"intelephense", "--stdio"},
|
||||
".zig": {"zls"},
|
||||
".lua": {"lua-language-server"},
|
||||
".ex": {"elixir-ls"},
|
||||
".exs": {"elixir-ls"},
|
||||
".hs": {"haskell-language-server-wrapper", "--lsp"},
|
||||
".swift": {"sourcekit-lsp"},
|
||||
".ml": {"ocamllsp"},
|
||||
".mli": {"ocamllsp"},
|
||||
".scala": {"metals"},
|
||||
".clj": {"clojure-lsp"},
|
||||
".cljs": {"clojure-lsp"},
|
||||
".cljc": {"clojure-lsp"},
|
||||
".dart": {"dart", "language-server"},
|
||||
".gleam": {"gleam", "lsp"},
|
||||
".nix": {"nixd"},
|
||||
".tf": {"terraform-ls", "serve"},
|
||||
".sh": {"bash-language-server", "start"},
|
||||
".bash": {"bash-language-server", "start"},
|
||||
".yaml": {"yaml-language-server", "--stdio"},
|
||||
".yml": {"yaml-language-server", "--stdio"},
|
||||
".json": {"vscode-json-language-server", "--stdio"},
|
||||
".css": {"vscode-css-language-server", "--stdio"},
|
||||
".scss": {"vscode-css-language-server", "--stdio"},
|
||||
".less": {"vscode-css-language-server", "--stdio"},
|
||||
".html": {"vscode-html-language-server", "--stdio"},
|
||||
".svelte": {"svelteserver", "--stdio"},
|
||||
".vue": {"vue-language-server", "--stdio"},
|
||||
".astro": {"astro-ls", "--stdio"},
|
||||
}
|
||||
|
||||
// languageIDs maps a file extension to the LSP languageId used in didOpen.
|
||||
var languageIDs = map[string]string{
|
||||
".go": "go",
|
||||
".ts": "typescript",
|
||||
".tsx": "typescriptreact",
|
||||
".js": "javascript",
|
||||
".jsx": "javascriptreact",
|
||||
".py": "python",
|
||||
".rs": "rust",
|
||||
".go": "go",
|
||||
".ts": "typescript",
|
||||
".tsx": "typescriptreact",
|
||||
".mts": "typescript",
|
||||
".cts": "typescript",
|
||||
".js": "javascript",
|
||||
".jsx": "javascriptreact",
|
||||
".mjs": "javascript",
|
||||
".cjs": "javascript",
|
||||
".py": "python",
|
||||
".pyi": "python",
|
||||
".rs": "rust",
|
||||
".c": "c",
|
||||
".h": "c",
|
||||
".cpp": "cpp",
|
||||
".cc": "cpp",
|
||||
".cxx": "cpp",
|
||||
".hpp": "cpp",
|
||||
".java": "java",
|
||||
".kt": "kotlin",
|
||||
".kts": "kotlin",
|
||||
".rb": "ruby",
|
||||
".php": "php",
|
||||
".zig": "zig",
|
||||
".lua": "lua",
|
||||
".ex": "elixir",
|
||||
".exs": "elixir",
|
||||
".hs": "haskell",
|
||||
".swift": "swift",
|
||||
".ml": "ocaml",
|
||||
".mli": "ocaml",
|
||||
".scala": "scala",
|
||||
".clj": "clojure",
|
||||
".cljs": "clojure",
|
||||
".cljc": "clojure",
|
||||
".dart": "dart",
|
||||
".gleam": "gleam",
|
||||
".nix": "nix",
|
||||
".tf": "terraform",
|
||||
".sh": "shellscript",
|
||||
".bash": "shellscript",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".json": "json",
|
||||
".css": "css",
|
||||
".scss": "scss",
|
||||
".less": "less",
|
||||
".html": "html",
|
||||
".svelte": "svelte",
|
||||
".vue": "vue",
|
||||
".astro": "astro",
|
||||
}
|
||||
|
||||
// coreServerBinaries are the tier-1 servers for the languages agents hit most;
|
||||
// `zero doctor` treats their absence as warn-worthy. The long tail of servers
|
||||
// configured above for breadth is reported informationally only — warning about
|
||||
// a missing zls on a machine with no Zig code would be permanent noise.
|
||||
var coreServerBinaries = []string{
|
||||
"gopls",
|
||||
"typescript-language-server",
|
||||
"pyright-langserver",
|
||||
"rust-analyzer",
|
||||
}
|
||||
|
||||
// CoreServerBinaries returns the tier-1 server binaries (sorted copy).
|
||||
func CoreServerBinaries() []string {
|
||||
binaries := append([]string(nil), coreServerBinaries...)
|
||||
sort.Strings(binaries)
|
||||
return binaries
|
||||
}
|
||||
|
||||
// ServerBinaries returns the unique set of language-server binaries ZERO may
|
||||
|
||||
@@ -63,6 +63,10 @@ func DefaultModelEntries() []ModelEntry {
|
||||
googleModel("gemini-2.5-flash-lite", "Gemini 2.5 Flash-Lite", "gemini-2.5-flash-lite", ModelStatusActive, []string{"google:gemini-2.5-flash-lite", "gemini-flash-lite"}, ContextLimits{ContextWindow: 1_048_576, MaxOutputTokens: 65_536}, ModelCost{InputPerMillion: 0.1, CachedInputPerMillion: 0.01, OutputPerMillion: 0.4}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityReasoning, ModelCapabilityLongContext}, standardReasoningEfforts(), "Google low-cost Flash model for background routing and summaries."),
|
||||
}
|
||||
decorateModelDepth(entries)
|
||||
// Overlay volatile facts (context limits, base pricing) from a cached
|
||||
// models.dev snapshot when one is present and fresh — see modelsdev.go.
|
||||
// Identity fields (ids, aliases, patterns, deprecations) stay curated.
|
||||
entries = applyModelsDevOverrides(entries, cachedModelsDevProviders())
|
||||
return cloneModelEntries(entries)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
package modelregistry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Live models.dev overlay for the curated catalog. The hand-maintained
|
||||
// DefaultModelEntries list is the source of truth for identity — ids, aliases,
|
||||
// match patterns, deprecations, escalation targets — but its VOLATILE facts
|
||||
// (context window, max output tokens, per-million pricing) go stale between
|
||||
// releases. When a cached snapshot of https://models.dev/api.json is present,
|
||||
// those fields are refreshed from it at registry construction; everything else
|
||||
// stays curated. The overlay never adds models (an auto-added entry would lack
|
||||
// aliases, match patterns, and provider wiring) and never touches the network
|
||||
// on the registry hot path — fetching happens only in the explicit background
|
||||
// refresh, cached to disk with a TTL.
|
||||
|
||||
const (
|
||||
modelsDevDefaultURL = "https://models.dev/api.json"
|
||||
// modelsDevRefreshAfter is how old the cache may get before a background
|
||||
// refresh re-fetches it.
|
||||
modelsDevRefreshAfter = 24 * time.Hour
|
||||
// modelsDevMaxAge is the oldest cache still applied as an overlay. Beyond
|
||||
// this the curated catalog (updated with the binary) is likely fresher than
|
||||
// the snapshot, so a stale file is ignored rather than trusted.
|
||||
modelsDevMaxAge = 7 * 24 * time.Hour
|
||||
modelsDevFetchLimit = 32 << 20 // 32MiB guard on the response body
|
||||
modelsDevFetchWindow = 15 * time.Second
|
||||
)
|
||||
|
||||
// modelsDevModel is the subset of a models.dev model record the overlay uses.
|
||||
type modelsDevModel struct {
|
||||
Limit struct {
|
||||
Context int `json:"context"`
|
||||
Output int `json:"output"`
|
||||
} `json:"limit"`
|
||||
Cost struct {
|
||||
Input float64 `json:"input"`
|
||||
Output float64 `json:"output"`
|
||||
CacheRead float64 `json:"cache_read"`
|
||||
CacheWrite float64 `json:"cache_write"`
|
||||
} `json:"cost"`
|
||||
}
|
||||
|
||||
// modelsDevProvider matches one provider object in api.json.
|
||||
type modelsDevProvider struct {
|
||||
Models map[string]modelsDevModel `json:"models"`
|
||||
}
|
||||
|
||||
// parseModelsDev decodes an api.json document into provider-slug -> api-model
|
||||
// -> record. api.json is a top-level object keyed by provider id.
|
||||
func parseModelsDev(data []byte) (map[string]map[string]modelsDevModel, error) {
|
||||
var doc map[string]modelsDevProvider
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
return nil, fmt.Errorf("modelregistry: parse models.dev: %w", err)
|
||||
}
|
||||
providers := make(map[string]map[string]modelsDevModel, len(doc))
|
||||
for slug, provider := range doc {
|
||||
if len(provider.Models) == 0 {
|
||||
continue
|
||||
}
|
||||
providers[slug] = provider.Models
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
return nil, fmt.Errorf("modelregistry: models.dev document has no providers")
|
||||
}
|
||||
return providers, nil
|
||||
}
|
||||
|
||||
// modelsDevSlugs maps an entry's provider kind to the models.dev provider ids
|
||||
// worth checking. Only first-party slugs are used: router entries on models.dev
|
||||
// carry generic or stale numbers.
|
||||
func modelsDevSlugs(kind ProviderKind) []string {
|
||||
switch kind {
|
||||
case ProviderAnthropic:
|
||||
return []string{"anthropic"}
|
||||
case ProviderOpenAI:
|
||||
return []string{"openai"}
|
||||
case ProviderGoogle:
|
||||
return []string{"google", "google-vertex"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// applyModelsDevOverrides refreshes each curated entry's context limits and
|
||||
// base pricing from the snapshot, when the snapshot knows the model. Tiered
|
||||
// pricing is never touched: models.dev has no tier data, and mixing a live
|
||||
// base rate with curated tiers would misprice the tier boundaries.
|
||||
func applyModelsDevOverrides(entries []ModelEntry, providers map[string]map[string]modelsDevModel) []ModelEntry {
|
||||
if len(providers) == 0 {
|
||||
return entries
|
||||
}
|
||||
for i := range entries {
|
||||
entry := &entries[i]
|
||||
var record modelsDevModel
|
||||
found := false
|
||||
for _, slug := range modelsDevSlugs(entry.Provider) {
|
||||
if models, ok := providers[slug]; ok {
|
||||
if candidate, ok := models[strings.TrimSpace(entry.APIModel)]; ok {
|
||||
record = candidate
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if record.Limit.Context > 0 {
|
||||
entry.ContextLimits.ContextWindow = record.Limit.Context
|
||||
}
|
||||
if record.Limit.Output > 0 {
|
||||
entry.ContextLimits.MaxOutputTokens = record.Limit.Output
|
||||
}
|
||||
if len(entry.Cost.Tiers) == 0 && record.Cost.Input > 0 && record.Cost.Output > 0 {
|
||||
entry.Cost.InputPerMillion = record.Cost.Input
|
||||
entry.Cost.OutputPerMillion = record.Cost.Output
|
||||
if record.Cost.CacheRead > 0 {
|
||||
entry.Cost.CachedInputPerMillion = record.Cost.CacheRead
|
||||
}
|
||||
if record.Cost.CacheWrite > 0 {
|
||||
entry.Cost.CacheWritePerMillion = record.Cost.CacheWrite
|
||||
}
|
||||
entry.Cost.Source = "models.dev/api.json (cached)"
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// modelsDevCachePath returns the on-disk cache location. ZERO_MODELS_CACHE_PATH
|
||||
// overrides it (used by tests and unusual setups).
|
||||
func modelsDevCachePath() (string, error) {
|
||||
if override := strings.TrimSpace(os.Getenv("ZERO_MODELS_CACHE_PATH")); override != "" {
|
||||
return override, nil
|
||||
}
|
||||
base, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(base, "zero", "modelsdev.json"), nil
|
||||
}
|
||||
|
||||
var (
|
||||
modelsDevOnce sync.Once
|
||||
modelsDevCached map[string]map[string]modelsDevModel
|
||||
modelsDevEnabled atomic.Bool
|
||||
)
|
||||
|
||||
// EnableModelsDevOverlay opts the process into applying the cached models.dev
|
||||
// snapshot on top of the curated catalog. The CLI entrypoint calls it; library
|
||||
// consumers and tests that never do get the curated catalog byte-identical to
|
||||
// before, so hermetic tests can't be perturbed by a cache file on the machine.
|
||||
// ZERO_DISABLE_MODELS_FETCH disables both the overlay and the fetch.
|
||||
func EnableModelsDevOverlay() {
|
||||
modelsDevEnabled.Store(true)
|
||||
}
|
||||
|
||||
// cachedModelsDevProviders loads the cached snapshot once per process. Not
|
||||
// enabled, missing, stale (> modelsDevMaxAge), or malformed all yield nil and
|
||||
// the curated catalog is used untouched. Read once deliberately:
|
||||
// DefaultRegistry is called on hot paths (pickers, cost views) and must not
|
||||
// re-stat the file every time; a background refresh benefits the NEXT process.
|
||||
func cachedModelsDevProviders() map[string]map[string]modelsDevModel {
|
||||
if !modelsDevEnabled.Load() || strings.TrimSpace(os.Getenv("ZERO_DISABLE_MODELS_FETCH")) != "" {
|
||||
return nil
|
||||
}
|
||||
modelsDevOnce.Do(func() {
|
||||
path, err := modelsDevCachePath()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || time.Since(info.ModTime()) > modelsDevMaxAge {
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
providers, err := parseModelsDev(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
modelsDevCached = providers
|
||||
})
|
||||
return modelsDevCached
|
||||
}
|
||||
|
||||
// resetModelsDevCacheForTest clears the process-level cache memoization and
|
||||
// disables the overlay.
|
||||
func resetModelsDevCacheForTest() {
|
||||
modelsDevOnce = sync.Once{}
|
||||
modelsDevCached = nil
|
||||
modelsDevEnabled.Store(false)
|
||||
}
|
||||
|
||||
// RefreshModelsDevCache fetches models.dev/api.json into the on-disk cache
|
||||
// when the cache is missing or older than modelsDevRefreshAfter. It is safe to
|
||||
// call fire-and-forget from startup (use a goroutine); it never affects the
|
||||
// current process's registry (see cachedModelsDevProviders). Disabled entirely
|
||||
// by ZERO_DISABLE_MODELS_FETCH. The URL can be overridden with ZERO_MODELS_URL.
|
||||
func RefreshModelsDevCache(ctx context.Context) error {
|
||||
if strings.TrimSpace(os.Getenv("ZERO_DISABLE_MODELS_FETCH")) != "" {
|
||||
return nil
|
||||
}
|
||||
path, err := modelsDevCachePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info, err := os.Stat(path); err == nil && time.Since(info.ModTime()) < modelsDevRefreshAfter {
|
||||
return nil
|
||||
}
|
||||
|
||||
url := strings.TrimSpace(os.Getenv("ZERO_MODELS_URL"))
|
||||
if url == "" {
|
||||
url = modelsDevDefaultURL
|
||||
}
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, modelsDevFetchWindow)
|
||||
defer cancel()
|
||||
request, err := http.NewRequestWithContext(fetchCtx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("User-Agent", "zero-models-refresh/0.1")
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("modelregistry: models.dev fetch: HTTP %d", response.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, modelsDevFetchLimit))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Validate before persisting: a bad body must never clobber a good cache.
|
||||
if _, err := parseModelsDev(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
temp, err := os.CreateTemp(filepath.Dir(path), "modelsdev-*.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
_ = temp.Close()
|
||||
_ = os.Remove(temp.Name())
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
_ = os.Remove(temp.Name())
|
||||
return err
|
||||
}
|
||||
return os.Rename(temp.Name(), path)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package modelregistry
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const sampleModelsDev = `{
|
||||
"anthropic": {
|
||||
"id": "anthropic",
|
||||
"models": {
|
||||
"claude-sonnet-4-5-20250929": {
|
||||
"limit": {"context": 1000000, "output": 64000},
|
||||
"cost": {"input": 3.5, "output": 17.5, "cache_read": 0.35, "cache_write": 4.4}
|
||||
}
|
||||
}
|
||||
},
|
||||
"google": {
|
||||
"id": "google",
|
||||
"models": {
|
||||
"gemini-2.5-pro": {
|
||||
"limit": {"context": 2097152, "output": 65536},
|
||||
"cost": {"input": 9.99, "output": 9.99}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
func TestParseModelsDev(t *testing.T) {
|
||||
providers, err := parseModelsDev([]byte(sampleModelsDev))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record, ok := providers["anthropic"]["claude-sonnet-4-5-20250929"]
|
||||
if !ok {
|
||||
t.Fatal("expected anthropic sonnet record")
|
||||
}
|
||||
if record.Limit.Context != 1_000_000 || record.Cost.Input != 3.5 {
|
||||
t.Fatalf("unexpected record: %+v", record)
|
||||
}
|
||||
if _, err := parseModelsDev([]byte(`{}`)); err == nil {
|
||||
t.Fatal("empty document must be rejected")
|
||||
}
|
||||
if _, err := parseModelsDev([]byte(`not json`)); err == nil {
|
||||
t.Fatal("malformed document must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyModelsDevOverrides(t *testing.T) {
|
||||
// Point the cache at a non-existent file so DefaultModelEntries returns the
|
||||
// pure curated catalog, then apply the sample snapshot explicitly.
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", filepath.Join(t.TempDir(), "absent.json"))
|
||||
resetModelsDevCacheForTest()
|
||||
t.Cleanup(resetModelsDevCacheForTest)
|
||||
|
||||
providers, err := parseModelsDev([]byte(sampleModelsDev))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries := applyModelsDevOverrides(DefaultModelEntries(), providers)
|
||||
|
||||
var sonnet, geminiPro, opus ModelEntry
|
||||
for _, entry := range entries {
|
||||
switch entry.ID {
|
||||
case "claude-sonnet-4.5":
|
||||
sonnet = entry
|
||||
case "gemini-2.5-pro":
|
||||
geminiPro = entry
|
||||
case "claude-opus-4.1":
|
||||
opus = entry
|
||||
}
|
||||
}
|
||||
|
||||
// Known model: limits and base pricing refreshed from the snapshot.
|
||||
if sonnet.ContextLimits.ContextWindow != 1_000_000 || sonnet.ContextLimits.MaxOutputTokens != 64_000 {
|
||||
t.Fatalf("sonnet limits not overridden: %+v", sonnet.ContextLimits)
|
||||
}
|
||||
if sonnet.Cost.InputPerMillion != 3.5 || sonnet.Cost.OutputPerMillion != 17.5 || sonnet.Cost.CacheWritePerMillion != 4.4 {
|
||||
t.Fatalf("sonnet cost not overridden: %+v", sonnet.Cost)
|
||||
}
|
||||
if sonnet.Cost.Source != "models.dev/api.json (cached)" {
|
||||
t.Fatalf("sonnet cost source not marked: %q", sonnet.Cost.Source)
|
||||
}
|
||||
|
||||
// Tiered pricing is curated: limits refresh, cost must NOT (gemini-2.5-pro
|
||||
// has curated tiers and the snapshot's flat 9.99 would misprice them).
|
||||
if geminiPro.ContextLimits.ContextWindow != 2_097_152 {
|
||||
t.Fatalf("gemini limits not overridden: %+v", geminiPro.ContextLimits)
|
||||
}
|
||||
if geminiPro.Cost.InputPerMillion == 9.99 || len(geminiPro.Cost.Tiers) == 0 {
|
||||
t.Fatalf("tiered cost must stay curated: %+v", geminiPro.Cost)
|
||||
}
|
||||
|
||||
// Model absent from the snapshot: untouched.
|
||||
if opus.ContextLimits.ContextWindow != 200_000 || opus.Cost.InputPerMillion != 15 {
|
||||
t.Fatalf("opus must be untouched: %+v %+v", opus.ContextLimits, opus.Cost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshModelsDevCacheFetchesAndCaches(t *testing.T) {
|
||||
hits := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
_, _ = w.Write([]byte(sampleModelsDev))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
t.Setenv("ZERO_MODELS_URL", server.URL)
|
||||
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "")
|
||||
|
||||
if err := RefreshModelsDevCache(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Fatalf("expected 1 fetch, got %d", hits)
|
||||
}
|
||||
if _, err := os.Stat(cachePath); err != nil {
|
||||
t.Fatalf("cache file missing: %v", err)
|
||||
}
|
||||
// Fresh cache: second call must not re-fetch.
|
||||
if err := RefreshModelsDevCache(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Fatalf("fresh cache must skip the fetch, got %d hits", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshModelsDevCacheRejectsBadBodyWithoutClobbering(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("not json"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
|
||||
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := time.Now().Add(-48 * time.Hour)
|
||||
if err := os.Chtimes(cachePath, stale, stale); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
t.Setenv("ZERO_MODELS_URL", server.URL)
|
||||
|
||||
if err := RefreshModelsDevCache(t.Context()); err == nil {
|
||||
t.Fatal("bad body must return an error")
|
||||
}
|
||||
content, err := os.ReadFile(cachePath)
|
||||
if err != nil || string(content) != sampleModelsDev {
|
||||
t.Fatal("bad fetch must not clobber the existing cache")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshModelsDevCacheDisabledByEnv(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("fetch must not happen when disabled")
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", filepath.Join(t.TempDir(), "modelsdev.json"))
|
||||
t.Setenv("ZERO_MODELS_URL", server.URL)
|
||||
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "1")
|
||||
if err := RefreshModelsDevCache(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedModelsDevProvidersIgnoresStaleCache(t *testing.T) {
|
||||
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
|
||||
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := time.Now().Add(-modelsDevMaxAge - time.Hour)
|
||||
if err := os.Chtimes(cachePath, stale, stale); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
resetModelsDevCacheForTest()
|
||||
t.Cleanup(resetModelsDevCacheForTest)
|
||||
EnableModelsDevOverlay()
|
||||
|
||||
if providers := cachedModelsDevProviders(); providers != nil {
|
||||
t.Fatal("stale cache must be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedModelsDevProvidersRequiresOptIn(t *testing.T) {
|
||||
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
|
||||
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
resetModelsDevCacheForTest()
|
||||
t.Cleanup(resetModelsDevCacheForTest)
|
||||
|
||||
// Without EnableModelsDevOverlay a fresh, valid cache must still be ignored:
|
||||
// library consumers and hermetic tests get the pure curated catalog.
|
||||
if providers := cachedModelsDevProviders(); providers != nil {
|
||||
t.Fatal("overlay must be opt-in")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultModelEntriesAppliesFreshCache(t *testing.T) {
|
||||
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
|
||||
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
resetModelsDevCacheForTest()
|
||||
t.Cleanup(resetModelsDevCacheForTest)
|
||||
EnableModelsDevOverlay()
|
||||
|
||||
for _, entry := range DefaultModelEntries() {
|
||||
if entry.ID == "claude-sonnet-4.5" {
|
||||
if entry.ContextLimits.ContextWindow != 1_000_000 {
|
||||
t.Fatalf("fresh cache must overlay the registry: %+v", entry.ContextLimits)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("claude-sonnet-4.5 not found")
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// The final two messages must carry a cache_control breakpoint on their last
|
||||
// cacheable block so the conversation transcript — not just system prompt and
|
||||
// tools — is a prompt-cache hit on the next turn. Earlier messages must stay
|
||||
// unmarked (Anthropic caps breakpoints at 4 per request: system, tools, and
|
||||
// these two).
|
||||
func TestAnthropicRequestMarksLastTwoMessagesForCaching(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
writeSSEEvent(w, "message_stop", `{"type":"message_stop"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider, err := New(Options{BaseURL: server.URL + "/", Model: "claude-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "first"},
|
||||
{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
Content: "calling a tool",
|
||||
ToolCalls: []zeroruntime.ToolCall{{ID: "toolu_1", Name: "grep", Arguments: `{}`}},
|
||||
},
|
||||
{Role: zeroruntime.MessageRoleTool, Content: "result", ToolCallID: "toolu_1"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned error: %v", err)
|
||||
}
|
||||
drain(stream)
|
||||
|
||||
messages := gotBody["messages"].([]any)
|
||||
if len(messages) != 3 {
|
||||
t.Fatalf("expected 3 wire messages, got %d: %#v", len(messages), messages)
|
||||
}
|
||||
lastBlockHasCache := func(message any) bool {
|
||||
blocks, ok := message.(map[string]any)["content"].([]any)
|
||||
if !ok || len(blocks) == 0 {
|
||||
return false
|
||||
}
|
||||
last := blocks[len(blocks)-1].(map[string]any)
|
||||
control, ok := last["cache_control"].(map[string]any)
|
||||
return ok && control["type"] == "ephemeral"
|
||||
}
|
||||
if lastBlockHasCache(messages[0]) {
|
||||
t.Fatalf("first message must not carry a breakpoint: %#v", messages[0])
|
||||
}
|
||||
if !lastBlockHasCache(messages[1]) {
|
||||
t.Fatalf("second-to-last message must carry a breakpoint: %#v", messages[1])
|
||||
}
|
||||
if !lastBlockHasCache(messages[2]) {
|
||||
t.Fatalf("last message must carry a breakpoint: %#v", messages[2])
|
||||
}
|
||||
}
|
||||
|
||||
// A string-content message must be converted to a block array so it can carry
|
||||
// the breakpoint, and thinking blocks must never carry cache_control (the API
|
||||
// rejects them) — the marker goes on the last cacheable block instead.
|
||||
func TestApplyMessageCacheBreakpointsSkipsThinkingBlocks(t *testing.T) {
|
||||
messages := []anthropicMessage{
|
||||
{Role: "user", Content: "plain string"},
|
||||
{Role: "assistant", Content: []map[string]any{
|
||||
{"type": "text", "text": "answer"},
|
||||
{"type": "thinking", "thinking": "hmm", "signature": "sig"},
|
||||
}},
|
||||
}
|
||||
applyMessageCacheBreakpoints(messages)
|
||||
|
||||
userBlocks := contentBlocks(messages[0].Content)
|
||||
if len(userBlocks) != 1 || userBlocks[0]["cache_control"] == nil {
|
||||
t.Fatalf("string content must become a marked block: %#v", messages[0].Content)
|
||||
}
|
||||
assistantBlocks := contentBlocks(messages[1].Content)
|
||||
if assistantBlocks[1]["cache_control"] != nil {
|
||||
t.Fatalf("thinking block must not be marked: %#v", assistantBlocks[1])
|
||||
}
|
||||
if assistantBlocks[0]["cache_control"] == nil {
|
||||
t.Fatalf("text block must carry the breakpoint instead: %#v", assistantBlocks[0])
|
||||
}
|
||||
}
|
||||
@@ -405,9 +405,35 @@ func (provider *Provider) anthropicRequest(request zeroruntime.CompletionRequest
|
||||
}
|
||||
mapped.Tools[len(mapped.Tools)-1].CacheControl = &cacheControl{Type: cacheEphemeral}
|
||||
}
|
||||
applyMessageCacheBreakpoints(mapped.Messages)
|
||||
return mapped, nil
|
||||
}
|
||||
|
||||
// applyMessageCacheBreakpoints marks the last content block of the final two
|
||||
// messages with cache_control so the conversation transcript is cached
|
||||
// turn-over-turn, not just the system prompt and tool definitions. Two message
|
||||
// breakpoints (not one) keep the previous turn's prefix a cache hit while the
|
||||
// newest suffix is being written. Anthropic allows at most 4 breakpoints per
|
||||
// request: system + tools use two above, these use the remaining two. Thinking
|
||||
// blocks cannot carry cache_control, so the marker goes on the last block that
|
||||
// can.
|
||||
func applyMessageCacheBreakpoints(messages []anthropicMessage) {
|
||||
marked := 0
|
||||
for i := len(messages) - 1; i >= 0 && marked < 2; i-- {
|
||||
blocks := contentBlocks(messages[i].Content)
|
||||
for j := len(blocks) - 1; j >= 0; j-- {
|
||||
blockType, _ := blocks[j]["type"].(string)
|
||||
if blockType == "thinking" || blockType == "redacted_thinking" {
|
||||
continue
|
||||
}
|
||||
blocks[j]["cache_control"] = map[string]any{"type": cacheEphemeral}
|
||||
messages[i].Content = blocks
|
||||
marked++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mapMessages(messages []zeroruntime.Message) (string, []anthropicMessage, error) {
|
||||
systemParts := []string{}
|
||||
mapped := []anthropicMessage{}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -283,10 +284,10 @@ func (provider *Provider) emitChunk(
|
||||
events chan<- zeroruntime.StreamEvent,
|
||||
) {
|
||||
for _, choice := range chunk.Choices {
|
||||
if choice.Delta.ReasoningContent != "" {
|
||||
if reasoning := choice.Delta.reasoningText(); reasoning != "" {
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventReasoning,
|
||||
Content: choice.Delta.ReasoningContent,
|
||||
Content: reasoning,
|
||||
})
|
||||
}
|
||||
if choice.Delta.Content != "" {
|
||||
@@ -317,6 +318,13 @@ func (provider *Provider) emitChunk(
|
||||
}
|
||||
}
|
||||
|
||||
func (delta streamDelta) reasoningText() string {
|
||||
if delta.ReasoningContent != "" {
|
||||
return delta.ReasoningContent
|
||||
}
|
||||
return delta.Reasoning
|
||||
}
|
||||
|
||||
// mapFinishReason maps OpenAI's finish_reason onto the runtime's normalized
|
||||
// terminal reasons. A normal finish ("stop"/"tool_calls"/"") returns "".
|
||||
func mapFinishReason(reason string) string {
|
||||
@@ -414,6 +422,12 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
|
||||
if effort := openAIReasoningEffort(request.ReasoningEffort); effort != "" {
|
||||
mapped.ReasoningEffort = effort
|
||||
}
|
||||
// prompt_cache_key is a documented OpenAI parameter; compatible servers
|
||||
// ignore unknown fields, but a strict endpoint that rejects it can be
|
||||
// accommodated with ZERO_DISABLE_PROMPT_CACHE_KEY=1.
|
||||
if key := strings.TrimSpace(request.PromptCacheKey); key != "" && !promptCacheKeyDisabled() {
|
||||
mapped.PromptCacheKey = key
|
||||
}
|
||||
if len(request.Tools) > 0 {
|
||||
mapped.Tools = make([]toolDefinition, 0, len(request.Tools))
|
||||
for _, tool := range request.Tools {
|
||||
@@ -430,6 +444,15 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
|
||||
return mapped
|
||||
}
|
||||
|
||||
// promptCacheKeyDisabled reports whether the ZERO_DISABLE_PROMPT_CACHE_KEY
|
||||
// kill switch is set to a truthy value. "0" and "false" (any case) are
|
||||
// no-ops, matching how ZERO_FORMAT_ON_WRITE parses boolean flags, so an
|
||||
// explicitly-disabled toggle never flips the behavior it names.
|
||||
func promptCacheKeyDisabled() bool {
|
||||
value := strings.TrimSpace(os.Getenv("ZERO_DISABLE_PROMPT_CACHE_KEY"))
|
||||
return value != "" && value != "0" && !strings.EqualFold(value, "false")
|
||||
}
|
||||
|
||||
// openAIReasoningEffort normalizes a requested effort to a value the OpenAI chat
|
||||
// completions API accepts, or "" to omit the field. "none" (and anything else)
|
||||
// is dropped rather than risking a 400 on an unrecognized enum.
|
||||
|
||||
@@ -227,6 +227,39 @@ func TestStreamCompletionEmitsReasoningContentDeltas(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsReasoningAliasDeltas(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":[{"delta":{"reasoning":"Thinking. "}}]}`)
|
||||
writeSSE(w, `{"choices":[{"delta":{"reasoning":"Answering now."}}]}`)
|
||||
writeSSE(w, `[DONE]`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
reasoning := eventsOfType(events, zeroruntime.StreamEventReasoning)
|
||||
if len(reasoning) != 2 {
|
||||
t.Fatalf("reasoning events = %#v, want two reasoning deltas", reasoning)
|
||||
}
|
||||
if reasoning[0].Content != "Thinking. " || reasoning[1].Content != "Answering now." {
|
||||
t.Fatalf("unexpected reasoning events: %#v", reasoning)
|
||||
}
|
||||
if text := eventsOfType(events, zeroruntime.StreamEventText); len(text) != 0 {
|
||||
t.Fatalf("reasoning must not emit text events, got %#v", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionPrefersReasoningContentOverAlias(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":[{"delta":{"reasoning_content":"standard","reasoning":"alias"}}]}`)
|
||||
writeSSE(w, `[DONE]`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
reasoning := eventsOfType(events, zeroruntime.StreamEventReasoning)
|
||||
if len(reasoning) != 1 || reasoning[0].Content != "standard" {
|
||||
t.Fatalf("reasoning events = %#v, want standard reasoning_content", reasoning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsReasoningBeforeRegularContent(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":[{"delta":{"reasoning_content":"Thinking. ","content":"Answer."}}]}`)
|
||||
@@ -1226,3 +1259,61 @@ func TestOpenAIRequestEmptyContentHandling(t *testing.T) {
|
||||
t.Fatalf("assistant tool calls dropped: %#v", req.Messages[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIRequestPromptCacheKey locks in prompt_cache_key forwarding: a
|
||||
// session-carrying request serializes the key so the backend can route to a
|
||||
// replica holding the cached prefix, a keyless request omits the field
|
||||
// entirely (strict servers see byte-identical requests to before), and
|
||||
// ZERO_DISABLE_PROMPT_CACHE_KEY suppresses it for endpoints that reject it.
|
||||
func TestOpenAIRequestPromptCacheKey(t *testing.T) {
|
||||
provider, err := New(Options{Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}}
|
||||
|
||||
req := provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: messages,
|
||||
PromptCacheKey: "sess_123",
|
||||
})
|
||||
if req.PromptCacheKey != "sess_123" {
|
||||
t.Fatalf("PromptCacheKey = %q, want sess_123", req.PromptCacheKey)
|
||||
}
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"prompt_cache_key":"sess_123"`) {
|
||||
t.Fatalf("prompt_cache_key not serialized: %s", data)
|
||||
}
|
||||
|
||||
req = provider.openAIRequest(zeroruntime.CompletionRequest{Messages: messages})
|
||||
if data, err = json.Marshal(req); err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), "prompt_cache_key") {
|
||||
t.Fatalf("keyless request must omit prompt_cache_key: %s", data)
|
||||
}
|
||||
|
||||
t.Setenv("ZERO_DISABLE_PROMPT_CACHE_KEY", "1")
|
||||
req = provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: messages,
|
||||
PromptCacheKey: "sess_123",
|
||||
})
|
||||
if req.PromptCacheKey != "" {
|
||||
t.Fatalf("kill switch ignored; PromptCacheKey = %q", req.PromptCacheKey)
|
||||
}
|
||||
|
||||
// Explicitly-falsy kill switch values must NOT disable forwarding — only
|
||||
// truthy values flip the toggle (same parsing as ZERO_FORMAT_ON_WRITE).
|
||||
for _, value := range []string{"0", "false", "FALSE"} {
|
||||
t.Setenv("ZERO_DISABLE_PROMPT_CACHE_KEY", value)
|
||||
req = provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: messages,
|
||||
PromptCacheKey: "sess_123",
|
||||
})
|
||||
if req.PromptCacheKey != "sess_123" {
|
||||
t.Fatalf("ZERO_DISABLE_PROMPT_CACHE_KEY=%q must be a no-op; PromptCacheKey = %q", value, req.PromptCacheKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,11 @@ type chatCompletionRequest struct {
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
StreamOptions *streamOptions `json:"stream_options,omitempty"`
|
||||
// PromptCacheKey asks the backend to route the request to a replica that
|
||||
// already holds this conversation's prefix in its prompt cache (the OpenAI
|
||||
// `prompt_cache_key` parameter). Omitted when the caller carries no session
|
||||
// identity or when ZERO_DISABLE_PROMPT_CACHE_KEY is set.
|
||||
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
|
||||
}
|
||||
|
||||
// streamOptions requests the final usage chunk on a streaming response. Without
|
||||
@@ -78,6 +83,7 @@ type streamChoice struct {
|
||||
type streamDelta struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
ToolCalls []streamToolCallDelta `json:"tool_calls"`
|
||||
}
|
||||
|
||||
|
||||
@@ -23,12 +23,18 @@ import (
|
||||
// (billable) work. Only the INITIAL request is ever in scope; once the response
|
||||
// body starts streaming it is never re-issued.
|
||||
|
||||
const defaultMaxRetryAttempts = 3
|
||||
const defaultMaxRetryAttempts = 6
|
||||
|
||||
// maxBackoff caps a single backoff wait so a hostile or buggy Retry-After can't
|
||||
// stall the agent for minutes.
|
||||
const maxBackoff = 30 * time.Second
|
||||
|
||||
// retryBackoffBase is the first wait when the server supplied no Retry-After.
|
||||
// Rate-limit windows are measured in seconds, not milliseconds: retrying a 429
|
||||
// after 400ms almost always burns the attempt while still limited, so the
|
||||
// schedule is 2s, 4s, 8s, 16s, then maxBackoff. A var so tests can shrink it.
|
||||
var retryBackoffBase = 2 * time.Second
|
||||
|
||||
// SendWithRetry issues an HTTP request, retrying ONLY the safe-to-replay server
|
||||
// responses (429 and 503, see ShouldRetryStatus) up to maxAttempts — backing off
|
||||
// between tries and honoring a server Retry-After header and context
|
||||
@@ -110,17 +116,11 @@ func ShouldRetryStatus(code int) bool {
|
||||
}
|
||||
|
||||
// Backoff waits before retry attempt N (1-based), returning false if the context
|
||||
// is cancelled during the wait. The wait is attempt*400ms unless the server
|
||||
// supplied a (positive) Retry-After, and is capped at maxBackoff.
|
||||
// is cancelled during the wait. A server-supplied (positive) Retry-After wins;
|
||||
// otherwise the wait doubles from retryBackoffBase per attempt. Either way the
|
||||
// wait is capped at maxBackoff.
|
||||
func Backoff(ctx context.Context, attempt int, retryAfter time.Duration) bool {
|
||||
wait := time.Duration(attempt) * 400 * time.Millisecond
|
||||
if retryAfter > 0 {
|
||||
wait = retryAfter
|
||||
}
|
||||
if wait > maxBackoff {
|
||||
wait = maxBackoff
|
||||
}
|
||||
timer := time.NewTimer(wait)
|
||||
timer := time.NewTimer(backoffWait(attempt, retryAfter))
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -130,6 +130,27 @@ func Backoff(ctx context.Context, attempt int, retryAfter time.Duration) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// backoffWait computes the wait before retry attempt N (1-based): Retry-After
|
||||
// when supplied, else exponential from retryBackoffBase, both capped at
|
||||
// maxBackoff. The exponent is clamped so a large attempt count cannot overflow.
|
||||
func backoffWait(attempt int, retryAfter time.Duration) time.Duration {
|
||||
wait := retryAfter
|
||||
if wait <= 0 {
|
||||
exponent := attempt - 1
|
||||
if exponent > 5 {
|
||||
exponent = 5
|
||||
}
|
||||
if exponent < 0 {
|
||||
exponent = 0
|
||||
}
|
||||
wait = retryBackoffBase * time.Duration(1<<exponent)
|
||||
}
|
||||
if wait > maxBackoff {
|
||||
wait = maxBackoff
|
||||
}
|
||||
return wait
|
||||
}
|
||||
|
||||
// RetryAfter parses a response's Retry-After header (delay-seconds or an HTTP
|
||||
// date) into a positive duration, or 0 when absent/unparseable. The result is
|
||||
// capped at maxBackoff by Backoff.
|
||||
|
||||
@@ -94,7 +94,40 @@ func TestBackoffWaitsThenReturnsTrue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// shrinkBackoff makes retry waits negligible for the duration of a test.
|
||||
func shrinkBackoff(t *testing.T) {
|
||||
t.Helper()
|
||||
saved := retryBackoffBase
|
||||
retryBackoffBase = time.Millisecond
|
||||
t.Cleanup(func() { retryBackoffBase = saved })
|
||||
}
|
||||
|
||||
func TestBackoffWaitSchedule(t *testing.T) {
|
||||
// Without Retry-After the wait doubles per attempt from 2s and caps at 30s;
|
||||
// a supplied Retry-After wins but is capped too.
|
||||
cases := []struct {
|
||||
attempt int
|
||||
retryAfter time.Duration
|
||||
want time.Duration
|
||||
}{
|
||||
{1, 0, 2 * time.Second},
|
||||
{2, 0, 4 * time.Second},
|
||||
{3, 0, 8 * time.Second},
|
||||
{4, 0, 16 * time.Second},
|
||||
{5, 0, 30 * time.Second}, // 32s capped
|
||||
{50, 0, 30 * time.Second}, // clamped exponent, no overflow
|
||||
{1, 7 * time.Second, 7 * time.Second},
|
||||
{1, 5 * time.Minute, 30 * time.Second}, // hostile Retry-After capped
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := backoffWait(c.attempt, c.retryAfter); got != c.want {
|
||||
t.Errorf("backoffWait(%d, %v) = %v, want %v", c.attempt, c.retryAfter, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendWithRetryRetriesThenSucceeds(t *testing.T) {
|
||||
shrinkBackoff(t)
|
||||
var hits int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if atomic.AddInt32(&hits, 1) == 1 {
|
||||
@@ -140,6 +173,7 @@ func TestSendWithRetryReturnsNonRetryableImmediately(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSendWithRetryReturnsLastResponseAfterMaxAttempts(t *testing.T) {
|
||||
shrinkBackoff(t)
|
||||
var hits int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
|
||||
@@ -54,6 +54,9 @@ type Info struct {
|
||||
AgeDays *int `json:"ageDays,omitempty"`
|
||||
Contributors90d *int `json:"contributors90d,omitempty"`
|
||||
CommitVelocity30d *int `json:"commitVelocity30d,omitempty"`
|
||||
BranchCount *int `json:"branchCount,omitempty"`
|
||||
CommitCount *int `json:"commitCount,omitempty"`
|
||||
TagCount *int `json:"tagCount,omitempty"`
|
||||
}
|
||||
|
||||
// RunGit runs a git subcommand in dir and returns raw stdout. A non-zero exit
|
||||
@@ -229,6 +232,43 @@ func Collect(ctx context.Context, opts Options) (Info, error) {
|
||||
info.CommitVelocity30d = &n
|
||||
}
|
||||
}
|
||||
if count, err := run(ctx, dir, "rev-list", "--count", "HEAD"); err == nil {
|
||||
if n, perr := strconv.Atoi(strings.TrimSpace(count)); perr == nil {
|
||||
info.CommitCount = &n
|
||||
}
|
||||
}
|
||||
if branches, err := run(ctx, dir, "branch", "-a"); err == nil {
|
||||
set := map[string]bool{}
|
||||
for _, line := range strings.Split(branches, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.Contains(line, "->") {
|
||||
continue
|
||||
}
|
||||
// Strip leading '* ' (current branch) or '+ ' (checked out in another worktree).
|
||||
line = strings.TrimPrefix(line, "* ")
|
||||
line = strings.TrimPrefix(line, "+ ")
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "(") {
|
||||
continue
|
||||
}
|
||||
// If it's a remote branch, extract the branch name.
|
||||
// E.g., remotes/origin/main -> main
|
||||
// E.g., remotes/origin/feature/xyz -> feature/xyz
|
||||
if strings.HasPrefix(line, "remotes/") {
|
||||
line = strings.TrimPrefix(line, "remotes/")
|
||||
if slashIdx := strings.Index(line, "/"); slashIdx != -1 {
|
||||
line = line[slashIdx+1:]
|
||||
}
|
||||
}
|
||||
set[line] = true
|
||||
}
|
||||
n := len(set)
|
||||
info.BranchCount = &n
|
||||
}
|
||||
if tags, err := run(ctx, dir, "tag"); err == nil {
|
||||
n := countNonEmptyLines(tags)
|
||||
info.TagCount = &n
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
@@ -328,3 +368,13 @@ func defaultRunGit(ctx context.Context, dir string, args ...string) (string, err
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
func countNonEmptyLines(s string) int {
|
||||
count := 0
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -43,6 +43,8 @@ func TestCollectCoreMetrics(t *testing.T) {
|
||||
"remote": "https://github.com/x/y.git\n",
|
||||
"log": "0\n",
|
||||
"rev-list": "12\n",
|
||||
"branch": " main\n remotes/origin/HEAD -> origin/main\n remotes/origin/main\n remotes/origin/feature\n",
|
||||
"tag": "v0.1.0\nv0.2.0\n",
|
||||
}, nil),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -85,6 +87,15 @@ func TestCollectCoreMetrics(t *testing.T) {
|
||||
if info.CommitVelocity30d == nil || *info.CommitVelocity30d != 12 {
|
||||
t.Fatalf("CommitVelocity30d=%v want 12", info.CommitVelocity30d)
|
||||
}
|
||||
if info.CommitCount == nil || *info.CommitCount != 12 {
|
||||
t.Fatalf("CommitCount=%v want 12", info.CommitCount)
|
||||
}
|
||||
if info.BranchCount == nil || *info.BranchCount != 2 {
|
||||
t.Fatalf("BranchCount=%v want 2", info.BranchCount)
|
||||
}
|
||||
if info.TagCount == nil || *info.TagCount != 2 {
|
||||
t.Fatalf("TagCount=%v want 2", info.TagCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectContributorsUnique(t *testing.T) {
|
||||
@@ -252,12 +263,13 @@ func TestCollectNotGitRepo(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectOnlyReadOnlyLocalSubcommands(t *testing.T) {
|
||||
allowed := map[string]bool{"ls-tree": true, "rev-parse": true, "remote": true, "log": true, "rev-list": true}
|
||||
allowed := map[string]bool{"ls-tree": true, "rev-parse": true, "remote": true, "log": true, "rev-list": true, "branch": true, "tag": true}
|
||||
var used []string
|
||||
_, err := Collect(context.Background(), Options{
|
||||
Now: time.Unix(0, 0),
|
||||
RunGit: fakeGit(t, map[string]string{
|
||||
"ls-tree": lsTree, "rev-parse": "main\n", "remote": "u\n", "log": "0\n", "rev-list": "1\n",
|
||||
"branch": "main\n", "tag": "v0.1.0\n",
|
||||
}, &used),
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -3,8 +3,9 @@ package sandbox
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// errWindowsSandboxNotInitialized is returned only to a caller that explicitly
|
||||
@@ -121,9 +122,55 @@ func (manager SandboxManager) Backend() Backend {
|
||||
return manager.backend
|
||||
}
|
||||
|
||||
// isExecutable checks whether a file is executable. On Unix, this checks the
|
||||
// execute permission bits. On Windows, Go's os.FileMode does not set execute
|
||||
// bits, so we check for common executable extensions instead.
|
||||
func isExecutable(fi os.FileInfo) bool {
|
||||
if !fi.Mode().IsRegular() {
|
||||
return false
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
name := strings.ToLower(fi.Name())
|
||||
return strings.HasSuffix(name, ".exe") ||
|
||||
strings.HasSuffix(name, ".com") ||
|
||||
strings.HasSuffix(name, ".bat") ||
|
||||
strings.HasSuffix(name, ".cmd")
|
||||
}
|
||||
return fi.Mode()&0o111 != 0
|
||||
}
|
||||
|
||||
// lookupExecutable checks whether a named binary exists and is executable,
|
||||
// using os.Stat instead of exec.LookPath. exec.LookPath internally uses the
|
||||
// faccessat2 syscall, which is blocked by Android's seccomp filter (SIGSYS).
|
||||
func lookupExecutable(name string) (string, error) {
|
||||
if !strings.Contains(name, string(os.PathSeparator)) {
|
||||
path := os.Getenv("PATH")
|
||||
for _, dir := range strings.Split(path, string(os.PathListSeparator)) {
|
||||
if dir == "" {
|
||||
continue // skip empty entries (e.g. trailing colon)
|
||||
}
|
||||
candidate := filepath.Join(dir, name)
|
||||
if fi, err := os.Stat(candidate); err == nil {
|
||||
if isExecutable(fi) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", errors.New("executable file not found in $PATH")
|
||||
}
|
||||
fi, err := os.Stat(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if isExecutable(fi) {
|
||||
return name, nil
|
||||
}
|
||||
return "", errors.New("executable file not found")
|
||||
}
|
||||
|
||||
func selectPlatformBackend(goos string, lookup func(string) (string, error)) Backend {
|
||||
if lookup == nil {
|
||||
lookup = exec.LookPath
|
||||
lookup = lookupExecutable
|
||||
}
|
||||
switch goos {
|
||||
case "linux":
|
||||
|
||||
@@ -190,6 +190,67 @@ func TestWindowsUnelevatedRealSandboxSmoke(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWindowsRestrictedTokenNestedPipeCapture pins the fix in
|
||||
// broadenWindowsRestrictedTokenDefaultDacl (windows_token_windows.go): a
|
||||
// nested subprocess spawned FROM WITHIN the sandboxed process, with its
|
||||
// output captured via a pipe, must succeed. Before the fix, the sandboxed
|
||||
// process's own attempt to create a pipe for such a subprocess failed with
|
||||
// ERROR_ACCESS_DENIED (Win32 error 5) — the WRITE_RESTRICTED token's extra
|
||||
// access check has no restricted-SID match against a freshly created pipe's
|
||||
// default security descriptor. This is exactly the pattern any tool that
|
||||
// shells out internally and captures output hits (`gh` invoking `git`, for
|
||||
// one; cmd.exe's own FOR /F does the identical CreatePipe+CreateProcess
|
||||
// dance internally, so it reproduces the bug with no external dependency
|
||||
// beyond cmd.exe itself).
|
||||
func TestWindowsRestrictedTokenNestedPipeCapture(t *testing.T) {
|
||||
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
|
||||
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real Windows sandbox smoke tests")
|
||||
}
|
||||
runnerExe := realSmokeExecutable(t, "ZERO_WINDOWS_COMMAND_RUNNER_EXE", WindowsSandboxCommandRunnerName)
|
||||
|
||||
root := t.TempDir()
|
||||
sandboxHome := filepath.Join(root, ".zero-sandbox")
|
||||
profile := PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
ReadRoots: []string{root},
|
||||
WriteRoots: []WritableRoot{{Root: root, ProtectedMetadataNames: []string{".git", ".zero", ".agents"}}},
|
||||
IncludePlatformRoots: true,
|
||||
AllowTemp: true,
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
}
|
||||
config := WindowsSandboxCommandArgsOptions{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: root,
|
||||
WorkspaceRoots: []string{root},
|
||||
PermissionProfile: profile,
|
||||
SandboxLevel: WindowsSandboxLevelUnelevated,
|
||||
}
|
||||
|
||||
marker := filepath.Join(root, "nested-pipe-marker.txt")
|
||||
script := filepath.Join(root, "nested-pipe.cmd")
|
||||
// FOR /F drives cmd.exe's own internal CreatePipe+CreateProcess capture
|
||||
// of the quoted command's output — the exact mechanism this fix targets.
|
||||
// The full path to whoami.exe sidesteps PATH resolution inside the
|
||||
// sandboxed process's minimal environment.
|
||||
scriptBody := "for /F %%i in ('C:\\Windows\\System32\\whoami.exe') do echo %%i> " + marker + "\r\n"
|
||||
if err := os.WriteFile(script, []byte(scriptBody), 0o644); err != nil {
|
||||
t.Fatalf("write nested-pipe script: %v", err)
|
||||
}
|
||||
|
||||
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
|
||||
"cmd.exe", "/d", "/s", "/c", script,
|
||||
}, 0)
|
||||
captured, err := os.ReadFile(marker)
|
||||
if err != nil {
|
||||
t.Fatalf("read nested-pipe marker: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(captured)) == "" {
|
||||
t.Fatalf("nested-pipe marker is empty; FOR /F failed to capture the subprocess's output")
|
||||
}
|
||||
}
|
||||
|
||||
func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) string {
|
||||
t.Helper()
|
||||
if path := os.Getenv(envKey); path != "" {
|
||||
|
||||
@@ -21,6 +21,11 @@ const (
|
||||
|
||||
var procCreateRestrictedToken = windows.NewLazySystemDLL("advapi32.dll").NewProc("CreateRestrictedToken")
|
||||
|
||||
// procSetEntriesInAclW merges new ACEs into an existing ACL. Not wrapped by
|
||||
// x/sys/windows (its internal setEntriesInAcl is unexported), so it is
|
||||
// declared directly the same way procCreateRestrictedToken is.
|
||||
var procSetEntriesInAclW = windows.NewLazySystemDLL("advapi32.dll").NewProc("SetEntriesInAclW")
|
||||
|
||||
type windowsLocalSID struct {
|
||||
sid *windows.SID
|
||||
}
|
||||
@@ -122,9 +127,139 @@ func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []w
|
||||
_ = restricted.Close()
|
||||
return 0, err
|
||||
}
|
||||
if err := broadenWindowsRestrictedTokenDefaultDacl(restricted, sidFromBytes(logonSID)); err != nil {
|
||||
_ = restricted.Close()
|
||||
return 0, err
|
||||
}
|
||||
runtime.KeepAlive(logonSID)
|
||||
return restricted, nil
|
||||
}
|
||||
|
||||
// broadenWindowsRestrictedTokenDefaultDacl adds the token's own logon SID to
|
||||
// its default DACL (the DACL applied to any kernel object the token's process
|
||||
// creates WITHOUT an explicit security descriptor, e.g. anonymous pipes,
|
||||
// events, mutexes).
|
||||
//
|
||||
// A WRITE_RESTRICTED token requires a WRITE-type access check to match BOTH
|
||||
// the normal enabled-SID grant AND a SEPARATE grant to one of the token's
|
||||
// restricted SIDs. CreateRestrictedToken does not touch the default DACL it
|
||||
// inherits from the base token, so newly created objects only carry ACEs for
|
||||
// the base token's normal identity (owner, SYSTEM, Administrators) — none of
|
||||
// which are in the restricted-SID list — so the second check fails. This
|
||||
// breaks anything that creates a pipe/handle for itself with default
|
||||
// security, which is exactly what any tool using a language runtime's
|
||||
// "spawn a subprocess and capture its output" primitive does (Go's os/exec,
|
||||
// and by extension `gh`, and many other CLI tools that shell out
|
||||
// internally): CreatePipe() succeeds, but the creating process's own token
|
||||
// then fails to actually read/write the handle it just made.
|
||||
//
|
||||
// The logon SID is already unconditionally present in this token's
|
||||
// restricted-SID list (see createWindowsRestrictedTokenFromBase), so adding
|
||||
// it here — and ONLY it — to the default DACL is the minimal change that
|
||||
// satisfies the WRITE_RESTRICTED extra check for self-created objects. It
|
||||
// does not touch file-system access: NTFS write-jailing is enforced by the
|
||||
// explicit ACL grants applied to workspace paths (windows_acl.go), which are
|
||||
// created WITH an explicit security descriptor and never consult the token's
|
||||
// default DACL. The exposure is bounded to the same logon session (other
|
||||
// processes running as the same signed-in user could, in principle, open a
|
||||
// NAMED kernel object the sandboxed process creates with default security;
|
||||
// anonymous pipes — the actual object type this fixes — have no name and are
|
||||
// only reachable via an inherited handle, so this is a no-op for them).
|
||||
func broadenWindowsRestrictedTokenDefaultDacl(token windows.Token, logonSID *windows.SID) error {
|
||||
oldDacl, oldDaclBuf, err := windowsTokenDefaultDacl(token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read token default DACL: %w", err)
|
||||
}
|
||||
// The second (WRITE-type) access check only needs a read/write match; a
|
||||
// full GENERIC_ALL grant (which also implies DELETE, WRITE_DAC, and
|
||||
// WRITE_OWNER) is broader than the pipe/event/mutex use case this exists
|
||||
// for actually requires.
|
||||
access := []windows.EXPLICIT_ACCESS{{
|
||||
AccessPermissions: windows.GENERIC_READ | windows.GENERIC_WRITE,
|
||||
AccessMode: windows.GRANT_ACCESS,
|
||||
Inheritance: windows.NO_INHERITANCE,
|
||||
Trustee: windows.TRUSTEE{
|
||||
MultipleTrusteeOperation: windows.NO_MULTIPLE_TRUSTEE,
|
||||
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||
TrusteeType: windows.TRUSTEE_IS_USER,
|
||||
TrusteeValue: windows.TrusteeValueFromSID(logonSID),
|
||||
},
|
||||
}}
|
||||
newDacl, err := setEntriesInACL(access, oldDacl)
|
||||
// oldDacl points INTO oldDaclBuf (see windowsTokenDefaultDacl) and is
|
||||
// dereferenced by native code inside setEntriesInACL, above. Nothing in Go
|
||||
// references oldDaclBuf once windowsTokenDefaultDacl returned, so without
|
||||
// this KeepAlive it is eligible for GC the moment an allocation in this
|
||||
// function (the EXPLICIT_ACCESS literal, TrusteeValueFromSID) hits a
|
||||
// safepoint, before the native call ever reads it - a real, if narrow,
|
||||
// use-after-free window in security-boundary code.
|
||||
runtime.KeepAlive(oldDaclBuf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("merge token default DACL: %w", err)
|
||||
}
|
||||
defer func() { _, _ = windows.LocalFree(windows.Handle(unsafe.Pointer(newDacl))) }()
|
||||
if err := windowsSetTokenDefaultDacl(token, newDacl); err != nil {
|
||||
return fmt.Errorf("set token default DACL: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type windowsTokenDefaultDaclInfo struct {
|
||||
DefaultDacl *windows.ACL
|
||||
}
|
||||
|
||||
// windowsTokenDefaultDacl returns the token's current default DACL along with
|
||||
// the buffer backing it. TOKEN_DEFAULT_DACL embeds the ACL data inside the
|
||||
// same allocation GetTokenInformation fills, so the returned *windows.ACL is
|
||||
// only valid for as long as the returned buffer stays reachable: the caller
|
||||
// must runtime.KeepAlive(buf) until every native call that dereferences the
|
||||
// ACL has completed. A KeepAlive inside this function, ending at its own
|
||||
// return, would not protect any use by the caller after that.
|
||||
func windowsTokenDefaultDacl(token windows.Token) (*windows.ACL, []byte, error) {
|
||||
var size uint32
|
||||
err := windows.GetTokenInformation(token, windows.TokenDefaultDacl, nil, 0, &size)
|
||||
if err == nil || err != windows.ERROR_INSUFFICIENT_BUFFER {
|
||||
return nil, nil, fmt.Errorf("size TokenDefaultDacl: %w", err)
|
||||
}
|
||||
buf := make([]byte, size)
|
||||
if err := windows.GetTokenInformation(token, windows.TokenDefaultDacl, &buf[0], size, &size); err != nil {
|
||||
return nil, nil, fmt.Errorf("get TokenDefaultDacl: %w", err)
|
||||
}
|
||||
info := (*windowsTokenDefaultDaclInfo)(unsafe.Pointer(&buf[0]))
|
||||
return info.DefaultDacl, buf, nil
|
||||
}
|
||||
|
||||
func windowsSetTokenDefaultDacl(token windows.Token, dacl *windows.ACL) error {
|
||||
info := windowsTokenDefaultDaclInfo{DefaultDacl: dacl}
|
||||
err := windows.SetTokenInformation(token, windows.TokenDefaultDacl, (*byte)(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)))
|
||||
runtime.KeepAlive(info)
|
||||
return err
|
||||
}
|
||||
|
||||
// setEntriesInACL merges entries into oldACL via Win32 SetEntriesInAclW,
|
||||
// returning a newly allocated ACL the caller must free with windows.LocalFree.
|
||||
func setEntriesInACL(entries []windows.EXPLICIT_ACCESS, oldACL *windows.ACL) (*windows.ACL, error) {
|
||||
if len(entries) == 0 {
|
||||
return nil, errors.New("setEntriesInACL requires at least one entry")
|
||||
}
|
||||
var newACL *windows.ACL
|
||||
// SetEntriesInAclW reports failure directly in its return value (ERROR_SUCCESS
|
||||
// on success, a Win32 error code otherwise); unlike many Win32 APIs it does not
|
||||
// set the thread's last-error, so the syscall trio's second/third return
|
||||
// values (which surface GetLastError) are not meaningful here.
|
||||
ret, _, _ := procSetEntriesInAclW.Call(
|
||||
uintptr(len(entries)),
|
||||
uintptr(unsafe.Pointer(&entries[0])),
|
||||
uintptr(unsafe.Pointer(oldACL)),
|
||||
uintptr(unsafe.Pointer(&newACL)),
|
||||
)
|
||||
runtime.KeepAlive(entries)
|
||||
if ret != 0 {
|
||||
return nil, fmt.Errorf("SetEntriesInAclW: %w", syscall.Errno(ret))
|
||||
}
|
||||
return newACL, nil
|
||||
}
|
||||
|
||||
func copyWindowsLogonSID(token windows.Token) ([]byte, error) {
|
||||
groups, err := token.GetTokenGroups()
|
||||
if err == nil {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStoreAppendEventsBatchesSequencesAndMetadata(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: sequenceClock([]time.Time{
|
||||
time.Date(2026, 6, 4, 15, 0, 0, 0, time.UTC),
|
||||
time.Date(2026, 6, 4, 15, 0, 1, 0, time.UTC),
|
||||
time.Date(2026, 6, 4, 15, 0, 2, 0, time.UTC),
|
||||
time.Date(2026, 6, 4, 15, 0, 3, 0, time.UTC),
|
||||
})})
|
||||
session, err := store.Create(CreateInput{SessionID: "batch"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
|
||||
appended, err := store.AppendEvents(session.SessionID, []AppendEventInput{
|
||||
{Type: EventMessage, Payload: map[string]any{"role": "user", "content": "one"}},
|
||||
{Type: EventToolCall, Payload: map[string]any{"id": "call_1", "name": "read_file"}},
|
||||
{Type: EventToolResult, Payload: map[string]any{"toolCallId": "call_1", "status": "ok"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AppendEvents returned error: %v", err)
|
||||
}
|
||||
if len(appended) != 3 {
|
||||
t.Fatalf("expected 3 appended events, got %#v", appended)
|
||||
}
|
||||
for index, event := range appended {
|
||||
wantSequence := index + 1
|
||||
if event.Sequence != wantSequence || event.ID != fmt.Sprintf("batch:%d", wantSequence) {
|
||||
t.Fatalf("event identity mismatch at %d: %#v", index, event)
|
||||
}
|
||||
}
|
||||
if appended[0].CreatedAt != "2026-06-04T15:00:01Z" || appended[2].CreatedAt != "2026-06-04T15:00:03Z" {
|
||||
t.Fatalf("unexpected event timestamps: %#v", appended)
|
||||
}
|
||||
|
||||
loaded, err := store.Get(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if loaded == nil || loaded.EventCount != 3 || loaded.LastEventType != EventToolResult || loaded.UpdatedAt != appended[2].CreatedAt {
|
||||
t.Fatalf("metadata not updated from final batch event: %#v", loaded)
|
||||
}
|
||||
events, err := store.ReadEvents(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents returned error: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(eventTypesForTest(events), []EventType{EventMessage, EventToolCall, EventToolResult}) {
|
||||
t.Fatalf("unexpected event types: %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAppendEventsEmptyBatchDoesNotRewriteMetadata(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T15:10:00Z")})
|
||||
session, err := store.Create(CreateInput{SessionID: "empty_batch", Title: "unchanged"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
|
||||
appended, err := store.AppendEvents(session.SessionID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("AppendEvents empty returned error: %v", err)
|
||||
}
|
||||
if len(appended) != 0 {
|
||||
t.Fatalf("empty AppendEvents returned %#v", appended)
|
||||
}
|
||||
loaded, err := store.Get(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if loaded == nil || !reflect.DeepEqual(*loaded, session) {
|
||||
t.Fatalf("empty AppendEvents rewrote metadata: before=%#v after=%#v", session, loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAppendEventsInvalidPayloadDoesNotPartiallyAppend(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T15:20:00Z")})
|
||||
session, err := store.Create(CreateInput{SessionID: "invalid_batch"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
|
||||
_, err = store.AppendEvents(session.SessionID, []AppendEventInput{
|
||||
{Type: EventMessage, Payload: map[string]any{"content": "valid first"}},
|
||||
{Type: EventMessage, Payload: json.RawMessage(`{"broken"`)},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid raw JSON payload") {
|
||||
t.Fatalf("expected invalid raw payload error, got %v", err)
|
||||
}
|
||||
events, err := store.ReadEvents(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents returned error: %v", err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Fatalf("invalid batch should not append partial events: %#v", events)
|
||||
}
|
||||
loaded, err := store.Get(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if loaded == nil || loaded.EventCount != 0 || loaded.LastEventType != "" {
|
||||
t.Fatalf("invalid batch should not update metadata: %#v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAppendEventsSequencesAfterMetadataLag(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T15:30:00Z")})
|
||||
session, err := store.Create(CreateInput{SessionID: "lagging"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
if _, err := store.AppendEvent(session.SessionID, AppendEventInput{Type: EventMessage, Payload: map[string]any{"content": "already durable"}}); err != nil {
|
||||
t.Fatalf("AppendEvent returned error: %v", err)
|
||||
}
|
||||
lagging, err := store.Get(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
lagging.EventCount = 0
|
||||
lagging.LastEventType = ""
|
||||
if err := store.writeMetadata(*lagging); err != nil {
|
||||
t.Fatalf("writeMetadata returned error: %v", err)
|
||||
}
|
||||
|
||||
appended, err := store.AppendEvents(session.SessionID, []AppendEventInput{
|
||||
{Type: EventToolCall, Payload: map[string]any{"id": "call"}},
|
||||
{Type: EventToolResult, Payload: map[string]any{"toolCallId": "call"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AppendEvents returned error: %v", err)
|
||||
}
|
||||
if len(appended) != 2 || appended[0].Sequence != 2 || appended[1].Sequence != 3 {
|
||||
t.Fatalf("batch did not sequence after durable log tail: %#v", appended)
|
||||
}
|
||||
loaded, err := store.Get(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if loaded == nil || loaded.EventCount != 3 || loaded.LastEventType != EventToolResult {
|
||||
t.Fatalf("metadata not repaired by batch append: %#v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAppendEventsSerializesConcurrentBatches(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T15:40:00Z")})
|
||||
session, err := store.Create(CreateInput{SessionID: "concurrent_batches"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
|
||||
const writers = 8
|
||||
const perBatch = 3
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, writers)
|
||||
for writer := 0; writer < writers; writer++ {
|
||||
wg.Add(1)
|
||||
go func(writer int) {
|
||||
defer wg.Done()
|
||||
appended, err := store.AppendEvents(session.SessionID, []AppendEventInput{
|
||||
{Type: EventMessage, Payload: map[string]int{"writer": writer, "index": 0}},
|
||||
{Type: EventMessage, Payload: map[string]int{"writer": writer, "index": 1}},
|
||||
{Type: EventMessage, Payload: map[string]int{"writer": writer, "index": 2}},
|
||||
})
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if len(appended) != perBatch {
|
||||
errs <- fmt.Errorf("writer %d appended %d events", writer, len(appended))
|
||||
return
|
||||
}
|
||||
for index := 1; index < len(appended); index++ {
|
||||
if appended[index].Sequence != appended[index-1].Sequence+1 {
|
||||
errs <- fmt.Errorf("writer %d batch was interleaved: %#v", writer, appended)
|
||||
return
|
||||
}
|
||||
}
|
||||
errs <- nil
|
||||
}(writer)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("AppendEvents returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
events, err := store.ReadEvents(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents returned error: %v", err)
|
||||
}
|
||||
total := writers * perBatch
|
||||
if len(events) != total {
|
||||
t.Fatalf("expected %d events, got %d", total, len(events))
|
||||
}
|
||||
seen := map[int]bool{}
|
||||
for _, event := range events {
|
||||
if seen[event.Sequence] {
|
||||
t.Fatalf("duplicate sequence %d in %#v", event.Sequence, events)
|
||||
}
|
||||
seen[event.Sequence] = true
|
||||
}
|
||||
for sequence := 1; sequence <= total; sequence++ {
|
||||
if !seen[sequence] {
|
||||
t.Fatalf("missing sequence %d in %#v", sequence, events)
|
||||
}
|
||||
}
|
||||
loaded, err := store.Get(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if loaded == nil || loaded.EventCount != total || loaded.LastEventType != EventMessage {
|
||||
t.Fatalf("metadata not updated after concurrent batches: %#v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func eventTypesForTest(events []Event) []EventType {
|
||||
types := make([]EventType, 0, len(events))
|
||||
for _, event := range events {
|
||||
types = append(types, event.Type)
|
||||
}
|
||||
return types
|
||||
}
|
||||
+99
-39
@@ -153,6 +153,11 @@ type AppendEventInput struct {
|
||||
Payload any
|
||||
}
|
||||
|
||||
type preparedAppendEvent struct {
|
||||
Type EventType
|
||||
Payload json.RawMessage
|
||||
}
|
||||
|
||||
type RecordSpecInput struct {
|
||||
SpecID string
|
||||
SpecFilePath string
|
||||
@@ -426,7 +431,7 @@ func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, err
|
||||
if err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
copied := 0
|
||||
copyInputs := []AppendEventInput{}
|
||||
for _, event := range events {
|
||||
// Do NOT copy usage accounting into the fork. It already counted against the
|
||||
// parent, and a usage report that aggregates the parent and the fork would
|
||||
@@ -435,11 +440,12 @@ func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, err
|
||||
if event.Type == EventUsage {
|
||||
continue
|
||||
}
|
||||
if _, err := store.AppendEvent(fork.SessionID, AppendEventInput{Type: event.Type, Payload: event.Payload}); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
copied++
|
||||
copyInputs = append(copyInputs, AppendEventInput{Type: event.Type, Payload: event.Payload})
|
||||
}
|
||||
if _, err := store.AppendEvents(fork.SessionID, copyInputs); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
copied := len(copyInputs)
|
||||
// Copy the parent's content-addressed checkpoint blobs into the fork so the
|
||||
// copied EventSessionCheckpoint events resolve to real blobs and a rewind on
|
||||
// the fork can restore file content (otherwise rewind reads missing blobs
|
||||
@@ -513,19 +519,34 @@ func (store *Store) RecordSpec(sessionID string, input RecordSpecInput) (Metadat
|
||||
}
|
||||
|
||||
func (store *Store) AppendEvent(sessionID string, input AppendEventInput) (Event, error) {
|
||||
if !ValidSessionID(sessionID) {
|
||||
return Event{}, fmt.Errorf("invalid zero session id %q", sessionID)
|
||||
}
|
||||
if strings.TrimSpace(string(input.Type)) == "" {
|
||||
return Event{}, fmt.Errorf("zero session event type is required")
|
||||
}
|
||||
unlock, err := store.lockSession(sessionID)
|
||||
events, err := store.AppendEvents(sessionID, []AppendEventInput{input})
|
||||
if err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
return events[0], nil
|
||||
}
|
||||
|
||||
// AppendEvents appends a batch of events atomically under one session lock and
|
||||
// durability pass. The returned events have contiguous sequences in input order.
|
||||
// An empty batch is a no-op and does not rewrite metadata.
|
||||
func (store *Store) AppendEvents(sessionID string, inputs []AppendEventInput) ([]Event, error) {
|
||||
if !ValidSessionID(sessionID) {
|
||||
return nil, fmt.Errorf("invalid zero session id %q", sessionID)
|
||||
}
|
||||
prepared, err := prepareAppendEventInputs(inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(prepared) == 0 {
|
||||
return []Event{}, nil
|
||||
}
|
||||
unlock, err := store.lockSession(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
return store.appendEventLocked(sessionID, input)
|
||||
return store.appendPreparedEventsLocked(sessionID, prepared)
|
||||
}
|
||||
|
||||
// AppendEventUnlessExists appends input only when exists(currentEvents) is false,
|
||||
@@ -572,14 +593,27 @@ func (store *Store) AppendEventUnlessExists(sessionID string, input AppendEventI
|
||||
// the single lock they already hold, instead of re-locking (which would deadlock
|
||||
// on the non-reentrant in-process mutex).
|
||||
func (store *Store) appendEventLocked(sessionID string, input AppendEventInput) (Event, error) {
|
||||
session, err := store.readMetadata(sessionID)
|
||||
prepared, err := prepareAppendEventInputs([]AppendEventInput{input})
|
||||
if err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
payload, err := rawPayload(input.Payload)
|
||||
events, err := store.appendPreparedEventsLocked(sessionID, prepared)
|
||||
if err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
return events[0], nil
|
||||
}
|
||||
|
||||
// appendPreparedEventsLocked appends pre-validated events WITHOUT acquiring the
|
||||
// session lock. The caller MUST already hold store.lockSession(sessionID).
|
||||
func (store *Store) appendPreparedEventsLocked(sessionID string, inputs []preparedAppendEvent) ([]Event, error) {
|
||||
if len(inputs) == 0 {
|
||||
return []Event{}, nil
|
||||
}
|
||||
session, err := store.readMetadata(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sequence := session.EventCount + 1
|
||||
// The events log is the source of truth for sequencing. metadata.EventCount is
|
||||
// persisted separately (after the event line below), so a crash between the two
|
||||
@@ -590,46 +624,54 @@ func (store *Store) appendEventLocked(sessionID string, input AppendEventInput)
|
||||
if logSeq, err := store.lastEventSequence(sessionID); err == nil && logSeq >= sequence {
|
||||
sequence = logSeq + 1
|
||||
}
|
||||
timestamp := store.timestamp()
|
||||
event := Event{
|
||||
ID: fmt.Sprintf("%s:%d", sessionID, sequence),
|
||||
SessionID: sessionID,
|
||||
Sequence: sequence,
|
||||
Type: input.Type,
|
||||
CreatedAt: timestamp,
|
||||
Payload: payload,
|
||||
}
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return Event{}, fmt.Errorf("encode zero session event: %w", err)
|
||||
events := make([]Event, 0, len(inputs))
|
||||
var batch bytes.Buffer
|
||||
for index, input := range inputs {
|
||||
timestamp := store.timestamp()
|
||||
event := Event{
|
||||
ID: fmt.Sprintf("%s:%d", sessionID, sequence+index),
|
||||
SessionID: sessionID,
|
||||
Sequence: sequence + index,
|
||||
Type: input.Type,
|
||||
CreatedAt: timestamp,
|
||||
Payload: input.Payload,
|
||||
}
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode zero session event: %w", err)
|
||||
}
|
||||
batch.Write(data)
|
||||
batch.WriteByte('\n')
|
||||
events = append(events, event)
|
||||
}
|
||||
file, err := os.OpenFile(store.eventsPath(sessionID), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600)
|
||||
if err != nil {
|
||||
return Event{}, fmt.Errorf("append zero session event: %w", err)
|
||||
return nil, fmt.Errorf("append zero session event: %w", err)
|
||||
}
|
||||
if _, err := file.Write(append(data, '\n')); err != nil {
|
||||
if _, err := file.Write(batch.Bytes()); err != nil {
|
||||
_ = file.Close()
|
||||
return Event{}, fmt.Errorf("append zero session event: %w", err)
|
||||
return nil, fmt.Errorf("append zero session event: %w", err)
|
||||
}
|
||||
// fsync the event before reporting success: the derived metadata.json IS
|
||||
// fsync the events before reporting success: the derived metadata.json IS
|
||||
// fsync'd (writeMetadata), so without this a crash after the metadata flush
|
||||
// but before the events.jsonl page reaches disk leaves EventCount ahead of the
|
||||
// durable log — silently losing the just-appended event (incl. the checkpoint
|
||||
// durable log — silently losing just-appended events (incl. checkpoints
|
||||
// that /rewind targets). Make the log at least as durable as its metadata. (AUDIT-M12)
|
||||
if err := file.Sync(); err != nil {
|
||||
_ = file.Close()
|
||||
return Event{}, fmt.Errorf("sync zero session event: %w", err)
|
||||
return nil, fmt.Errorf("sync zero session event: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return Event{}, fmt.Errorf("close zero session event file: %w", err)
|
||||
return nil, fmt.Errorf("close zero session event file: %w", err)
|
||||
}
|
||||
session.UpdatedAt = timestamp
|
||||
session.EventCount = sequence
|
||||
session.LastEventType = input.Type
|
||||
last := events[len(events)-1]
|
||||
session.UpdatedAt = last.CreatedAt
|
||||
session.EventCount = last.Sequence
|
||||
session.LastEventType = last.Type
|
||||
if err := store.writeMetadata(session); err != nil {
|
||||
return Event{}, err
|
||||
return nil, err
|
||||
}
|
||||
return event, nil
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// UpdateTitle replaces a session's Title and returns the updated metadata. It is
|
||||
@@ -964,6 +1006,24 @@ func rawPayload(payload any) (json.RawMessage, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func prepareAppendEventInputs(inputs []AppendEventInput) ([]preparedAppendEvent, error) {
|
||||
prepared := make([]preparedAppendEvent, 0, len(inputs))
|
||||
for _, input := range inputs {
|
||||
if strings.TrimSpace(string(input.Type)) == "" {
|
||||
return nil, fmt.Errorf("zero session event type is required")
|
||||
}
|
||||
payload, err := rawPayload(input.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prepared = append(prepared, preparedAppendEvent{
|
||||
Type: input.Type,
|
||||
Payload: payload,
|
||||
})
|
||||
}
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
func envValue(env map[string]string, key string) string {
|
||||
if env != nil {
|
||||
return env[key]
|
||||
|
||||
@@ -21,6 +21,7 @@ type EventType string
|
||||
const (
|
||||
EventRunStart EventType = "run_start"
|
||||
EventText EventType = "text"
|
||||
EventReasoning EventType = "reasoning"
|
||||
EventToolCall EventType = "tool_call"
|
||||
EventPermission EventType = "permission"
|
||||
EventPermissionRequest EventType = "permission_request"
|
||||
|
||||
+89
-22
@@ -83,7 +83,12 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for bash: " + err.Error())
|
||||
}
|
||||
if issue := detectShellCommandIssue(commandText, runtime.GOOS); issue != nil {
|
||||
// Resolve the command engine before the MSYS preflight check so an
|
||||
// approved require_escalated call (commandEngine == nil, truly
|
||||
// unsandboxed) can actually bypass the MSYS guard instead of being
|
||||
// hard-blocked by the same check it was meant to escalate past.
|
||||
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
|
||||
if issue := detectShellCommandIssue(commandText, runtime.GOOS); issue != nil && !msysGuardBypassed(issue, commandEngine) {
|
||||
return shellIssueBlockResult(*issue)
|
||||
}
|
||||
|
||||
@@ -106,7 +111,6 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
"cwd": relativeCwd,
|
||||
"timeout_ms": strconv.Itoa(timeoutMS),
|
||||
}
|
||||
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
|
||||
if commandEngine == nil && sandboxPermissions == SandboxPermissionsRequireEscalated {
|
||||
meta["sandbox_permissions"] = string(SandboxPermissionsRequireEscalated)
|
||||
}
|
||||
@@ -126,8 +130,8 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
// can't grow Zero's memory before truncation: only the head+tail each stream
|
||||
// will ever surface to the model are retained, the middle is discarded as it
|
||||
// streams, and the true size is counted for the truncation marker.
|
||||
stdout := newBoundedBuffer(bashOutputBudgetBytes, bashOutputBudgetBytes)
|
||||
stderr := newBoundedBuffer(bashOutputBudgetBytes, bashOutputBudgetBytes)
|
||||
stdout := newBoundedBuffer(bashCaptureBudgetBytes, bashCaptureBudgetBytes)
|
||||
stderr := newBoundedBuffer(bashCaptureBudgetBytes, bashCaptureBudgetBytes)
|
||||
command.Stdout = stdout
|
||||
command.Stderr = stderr
|
||||
|
||||
@@ -167,7 +171,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
outText, errText, truncated := budgetBashCapture(stdoutText, stdout.total, stderrText, stderrTotal, meta)
|
||||
return Result{
|
||||
Status: StatusError,
|
||||
Output: formatBashOutputWithShellHint(commandText, outText, errText, exitCode, meta),
|
||||
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
|
||||
Truncated: truncated,
|
||||
Meta: meta,
|
||||
}
|
||||
@@ -178,7 +182,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
if meta[SandboxLikelyDeniedMeta] == "true" {
|
||||
return Result{
|
||||
Status: StatusError,
|
||||
Output: formatBashOutputWithShellHint(commandText, outText, errText, exitCode, meta),
|
||||
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
|
||||
Truncated: truncated,
|
||||
Meta: meta,
|
||||
}
|
||||
@@ -198,6 +202,18 @@ func commandEngineForSandboxPermissions(engine *zeroSandbox.Engine, sandboxPermi
|
||||
return engine
|
||||
}
|
||||
|
||||
// msysGuardBypassed reports whether a windows_msys_sandbox preflight issue no
|
||||
// longer applies because sandbox_permissions: require_escalated actually
|
||||
// resolved to unsandboxed, host-level execution (commandEngine is nil only
|
||||
// then, per commandEngineForSandboxPermissions). MSYS coreutils fail because
|
||||
// of the sandbox's restricted token/handles, so running outside it removes
|
||||
// the failure mode this guard exists for. Any other issue kind (e.g. a real
|
||||
// cmd.exe syntax problem) still blocks regardless of escalation, since
|
||||
// running outside the sandbox does not fix a syntax error.
|
||||
func msysGuardBypassed(issue *shellIssue, commandEngine *zeroSandbox.Engine) bool {
|
||||
return issue.Kind == windowsMsysSandboxKind && commandEngine == nil
|
||||
}
|
||||
|
||||
// appendSandboxBlocks appends a <sandbox_blocks> block listing the denials
|
||||
// the sandbox log monitor captured, so the model can see what was blocked. With no
|
||||
// blocks the stderr is returned unchanged.
|
||||
@@ -370,12 +386,21 @@ func formatBashOutput(stdout string, stderr string, exitCode int) string {
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
// bashOutputBudgetBytes caps each of stdout/stderr shown to the model. bash is the
|
||||
// one tool that can emit unbounded output (`cat large.log`, `find /`, verbose test
|
||||
// runs); every other read/search tool already budgets its output. Head+tail
|
||||
// truncation keeps both the start and the end of an oversized stream, since
|
||||
// build/test failures usually surface at the tail.
|
||||
const bashOutputBudgetBytes = 96 * 1024
|
||||
// bashOutputBudgetBytes caps each of stdout/stderr shown to the model (32 KiB
|
||||
// ≈ 8k tokens per stream). bash is the one tool that can emit unbounded output
|
||||
// (`cat large.log`, `find /`, verbose test runs); a chatty build log at the old
|
||||
// 96 KiB budget injected ~24k tokens per call and was then re-billed on every
|
||||
// following turn. Head+tail truncation keeps both the start and the end of an
|
||||
// oversized stream, since build/test failures usually surface at the tail, and
|
||||
// the full captured text is spilled to disk so nothing is lost — the model
|
||||
// greps/reads the spill instead of re-running the command.
|
||||
const bashOutputBudgetBytes = 32 * 1024
|
||||
|
||||
// bashCaptureBudgetBytes bounds the head and tail retained IN MEMORY per
|
||||
// stream during capture. Deliberately larger than the emit budget: the extra
|
||||
// retained bytes never reach the transcript, but they make the spill file —
|
||||
// the model's recovery path — cover 6× more of the output.
|
||||
const bashCaptureBudgetBytes = 96 * 1024
|
||||
|
||||
// budgetBashOutput truncates stdout and stderr to bashOutputBudgetBytes each,
|
||||
// keeping the head and tail of anything larger, and records raw/emitted byte
|
||||
@@ -395,6 +420,19 @@ func budgetBashCapture(out string, outTotal int, errStr string, errTotal int, me
|
||||
outText, outRaw, outTrunc := truncateHeadTailWithTotal(out, outTotal, bashOutputBudgetBytes)
|
||||
errText, errRaw, errTrunc := truncateHeadTailWithTotal(errStr, errTotal, bashOutputBudgetBytes)
|
||||
truncated := outTrunc || errTrunc
|
||||
if truncated {
|
||||
if spillPath := spillBashStreams(out, outTotal, errStr, errTotal); spillPath != "" {
|
||||
hint := "\n[zero] captured output saved to " + spillPath + " (grep or read_file it instead of re-running)"
|
||||
if errTrunc {
|
||||
errText += hint
|
||||
} else {
|
||||
outText += hint
|
||||
}
|
||||
if meta != nil {
|
||||
meta["spill_path"] = spillPath
|
||||
}
|
||||
}
|
||||
}
|
||||
if meta != nil {
|
||||
emitted := len(outText) + len(errText)
|
||||
meta["raw_bytes"] = strconv.Itoa(outRaw + errRaw)
|
||||
@@ -407,6 +445,43 @@ func budgetBashCapture(out string, outTotal int, errStr string, errTotal int, me
|
||||
return outText, errText, truncated
|
||||
}
|
||||
|
||||
// spillBashStreams writes the retained (capture-bounded) stdout and stderr to
|
||||
// the spill directory as one sectioned file and returns its path, or "" when
|
||||
// spilling fails. The spill holds up to bashCaptureBudgetBytes of head and
|
||||
// tail per stream — everything zero kept in memory, which is more than the
|
||||
// emit budget shows the model but not necessarily the whole output. When the
|
||||
// capture itself dropped the middle of a stream (total exceeds the retained
|
||||
// bytes), a gap marker is inserted at the head/tail junction so the spilled
|
||||
// log never reads as contiguous when it is not.
|
||||
func spillBashStreams(stdout string, stdoutTotal int, stderr string, stderrTotal int) string {
|
||||
stdout = sectionWithCaptureGap(stdout, stdoutTotal)
|
||||
stderr = sectionWithCaptureGap(stderr, stderrTotal)
|
||||
var combined strings.Builder
|
||||
combined.Grow(len(stdout) + len(stderr) + 64)
|
||||
combined.WriteString("### stdout\n")
|
||||
combined.WriteString(stdout)
|
||||
if !strings.HasSuffix(stdout, "\n") {
|
||||
combined.WriteString("\n")
|
||||
}
|
||||
combined.WriteString("### stderr\n")
|
||||
combined.WriteString(stderr)
|
||||
return spillTruncatedOutput("bash", combined.String())
|
||||
}
|
||||
|
||||
// sectionWithCaptureGap marks the point where boundedBuffer dropped the middle
|
||||
// of a stream. When total exceeds the retained bytes, the retained text is the
|
||||
// frozen head (bashCaptureBudgetBytes, always full once overflow happened)
|
||||
// followed immediately by the rolling tail — the junction sits at the head cap,
|
||||
// snapped back to a rune boundary.
|
||||
func sectionWithCaptureGap(text string, total int) string {
|
||||
if total <= len(text) || len(text) <= bashCaptureBudgetBytes {
|
||||
return text
|
||||
}
|
||||
head := utf8Prefix(text, bashCaptureBudgetBytes)
|
||||
marker := fmt.Sprintf("\n[zero] capture gap: %d bytes omitted from the middle of this stream\n", total-len(text))
|
||||
return head + marker + text[len(head):]
|
||||
}
|
||||
|
||||
// boundedBuffer is an io.Writer that retains at most headCap bytes from the start
|
||||
// and tailCap bytes from the end of a stream while counting the total written, so
|
||||
// a command emitting unbounded output (`cat huge.log`, `yes`) cannot grow Zero's
|
||||
@@ -461,14 +536,6 @@ func (b *boundedBuffer) retained() string {
|
||||
return string(b.head) + string(b.tail)
|
||||
}
|
||||
|
||||
// truncateHeadTail keeps the first and last halves of value when it exceeds
|
||||
// maxBytes, dropping the middle behind a marker. Returns the possibly-truncated
|
||||
// text, the raw byte length, and whether truncation happened.
|
||||
func truncateHeadTail(value string, maxBytes int) (string, int, bool) {
|
||||
// value holds the whole stream, so its length is the true raw size.
|
||||
return truncateHeadTailWithTotal(value, len(value), maxBytes)
|
||||
}
|
||||
|
||||
// truncateHeadTailWithTotal head+tail-truncates value to maxBytes, using total —
|
||||
// the full original byte count — for the "N bytes omitted" marker and the raw
|
||||
// count. total may exceed len(value) when the middle was already discarded during
|
||||
@@ -488,9 +555,9 @@ func truncateHeadTailWithTotal(value string, total, maxBytes int) (string, int,
|
||||
return utf8Prefix(value, head) + marker + utf8Suffix(value, tail), total, true
|
||||
}
|
||||
|
||||
func formatBashOutputWithShellHint(command string, stdout string, stderr string, exitCode int, meta map[string]string) string {
|
||||
func formatBashOutputWithShellHint(stdout string, stderr string, exitCode int, meta map[string]string) string {
|
||||
output := formatBashOutput(stdout, stderr, exitCode)
|
||||
if issue := detectShellOutputIssue(command, stdout+"\n"+stderr, runtime.GOOS); issue != nil {
|
||||
if issue := detectShellOutputIssue(stdout+"\n"+stderr, runtime.GOOS); issue != nil {
|
||||
meta["shell_issue"] = issue.Kind
|
||||
output = appendShellIssueHint(output, *issue)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,23 @@ package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setTestTempDir points every platform's temp-dir lookup at a per-test
|
||||
// directory so spill files never land in the real temp dir: os.TempDir reads
|
||||
// TMPDIR on Unix but TMP/TEMP on Windows.
|
||||
func setTestTempDir(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("TMPDIR", dir)
|
||||
t.Setenv("TMP", dir)
|
||||
t.Setenv("TEMP", dir)
|
||||
}
|
||||
|
||||
// Small output passes through untouched and records raw==emitted, no truncated flag.
|
||||
func TestBudgetBashOutputSmallPassesThrough(t *testing.T) {
|
||||
meta := map[string]string{}
|
||||
@@ -26,8 +38,10 @@ func TestBudgetBashOutputSmallPassesThrough(t *testing.T) {
|
||||
}
|
||||
|
||||
// Oversized stdout is truncated head+tail: both the first and last lines survive,
|
||||
// the middle is dropped behind a marker, and meta is flagged.
|
||||
// the middle is dropped behind a marker, meta is flagged, and the captured text
|
||||
// is spilled to a re-readable file.
|
||||
func TestBudgetBashOutputTruncatesHeadAndTail(t *testing.T) {
|
||||
setTestTempDir(t)
|
||||
head := "FIRST_LINE_MARKER\n"
|
||||
tail := "\nLAST_LINE_MARKER"
|
||||
big := head + strings.Repeat("x", bashOutputBudgetBytes) + tail
|
||||
@@ -47,9 +61,23 @@ func TestBudgetBashOutputTruncatesHeadAndTail(t *testing.T) {
|
||||
if !strings.Contains(out, "output truncated") {
|
||||
t.Fatalf("expected a truncation marker, got:\n%s", out[:200])
|
||||
}
|
||||
if len(out) > bashOutputBudgetBytes {
|
||||
// The spill hint (path length varies) is the only allowed overage past the budget.
|
||||
if len(out) > bashOutputBudgetBytes+512 {
|
||||
t.Fatalf("emitted %d bytes exceeds budget %d", len(out), bashOutputBudgetBytes)
|
||||
}
|
||||
if !strings.Contains(out, "captured output saved to ") {
|
||||
t.Fatalf("truncated bash output must carry a spill hint:\n%s", out[len(out)-300:])
|
||||
}
|
||||
if meta["spill_path"] == "" {
|
||||
t.Fatal("spill path missing from meta")
|
||||
}
|
||||
content, err := os.ReadFile(meta["spill_path"])
|
||||
if err != nil {
|
||||
t.Fatalf("spill file unreadable: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(content), "### stdout") || !strings.Contains(string(content), strings.Repeat("x", 1024)) {
|
||||
t.Fatalf("spill must hold the sectioned captured streams (got %d bytes)", len(content))
|
||||
}
|
||||
if meta["truncated"] != "true" {
|
||||
t.Fatalf("expected truncated=true, got %v", meta)
|
||||
}
|
||||
@@ -100,10 +128,12 @@ func TestBoundedBufferKeepsHeadAndTailBounded(t *testing.T) {
|
||||
// budgetBashCapture reports the TRUE total (not the retained size) in the marker
|
||||
// and raw_bytes, even though only a bounded head+tail was ever held in memory.
|
||||
func TestBudgetBashCaptureReportsTrueTotal(t *testing.T) {
|
||||
// Retained head+tail as boundedBuffer would hand over; the real command produced
|
||||
// far more than was kept.
|
||||
retained := "HEAD_START" + strings.Repeat("y", bashOutputBudgetBytes) + "TAIL_END"
|
||||
total := 10 * bashOutputBudgetBytes
|
||||
setTestTempDir(t)
|
||||
// Retained head+tail as boundedBuffer would hand over after overflow: the
|
||||
// frozen head is full at bashCaptureBudgetBytes, then the rolling tail; the
|
||||
// real command produced far more than was kept.
|
||||
retained := "HEAD_START" + strings.Repeat("y", bashCaptureBudgetBytes) + "TAIL_END"
|
||||
total := 10 * bashCaptureBudgetBytes
|
||||
|
||||
meta := map[string]string{}
|
||||
out, _, truncated := budgetBashCapture(retained, total, "", 0, meta)
|
||||
@@ -114,7 +144,8 @@ func TestBudgetBashCaptureReportsTrueTotal(t *testing.T) {
|
||||
if !strings.Contains(out, "HEAD_START") || !strings.Contains(out, "TAIL_END") {
|
||||
t.Fatalf("head/tail lost after budgeting:\n%s", out[:min(120, len(out))])
|
||||
}
|
||||
if len(out) > bashOutputBudgetBytes {
|
||||
// Spill hint is the only allowed overage past the budget.
|
||||
if len(out) > bashOutputBudgetBytes+512 {
|
||||
t.Fatalf("emitted %d bytes exceeds budget %d", len(out), bashOutputBudgetBytes)
|
||||
}
|
||||
if meta["raw_bytes"] != strconv.Itoa(total) {
|
||||
@@ -124,4 +155,29 @@ func TestBudgetBashCaptureReportsTrueTotal(t *testing.T) {
|
||||
if !strings.Contains(out, strconv.Itoa(total-bashOutputBudgetBytes)) {
|
||||
t.Fatalf("marker should cite the true omitted byte count:\n%s", out[:min(200, len(out))])
|
||||
}
|
||||
|
||||
// The spill must be recorded, sectioned, and carry a capture-gap marker at
|
||||
// the head/tail junction — the capture dropped the middle, so the spilled
|
||||
// log must never read as contiguous.
|
||||
if meta["spill_path"] == "" {
|
||||
t.Fatal("spill path missing from meta")
|
||||
}
|
||||
content, err := os.ReadFile(meta["spill_path"])
|
||||
if err != nil {
|
||||
t.Fatalf("spill file unreadable: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(content), "### stdout") {
|
||||
t.Fatal("spill must be sectioned by stream")
|
||||
}
|
||||
gapMarker := fmt.Sprintf("capture gap: %d bytes omitted", total-len(retained))
|
||||
gapIndex := strings.Index(string(content), gapMarker)
|
||||
if gapIndex < 0 {
|
||||
t.Fatalf("spill missing capture-gap marker %q", gapMarker)
|
||||
}
|
||||
// The junction sits right after the frozen head (plus the section header),
|
||||
// not appended at the end of the stream.
|
||||
headerOffset := len("### stdout\n")
|
||||
if gapIndex < headerOffset+bashCaptureBudgetBytes-64 || gapIndex > headerOffset+bashCaptureBudgetBytes+64 {
|
||||
t.Fatalf("capture-gap marker at offset %d, want ~%d (the head cap)", gapIndex, headerOffset+bashCaptureBudgetBytes)
|
||||
}
|
||||
}
|
||||
@@ -181,9 +181,13 @@ func TestDetectShellCommandIssueFlagsPipedPosixUtilities(t *testing.T) {
|
||||
`cat file.txt | wc -l`,
|
||||
`some_command | tail -n 20`,
|
||||
} {
|
||||
if issue := detectShellCommandIssue(command, "windows"); issue == nil {
|
||||
issue := detectShellCommandIssue(command, "windows")
|
||||
if issue == nil {
|
||||
t.Fatalf("expected POSIX utility pipeline to be flagged for %q", command)
|
||||
}
|
||||
if issue.Kind != "windows_msys_sandbox" {
|
||||
t.Fatalf("expected windows_msys_sandbox for %q, got %q", command, issue.Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +199,7 @@ func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
|
||||
`echo header text`,
|
||||
`tail-log.sh`,
|
||||
`grep-cli --version`,
|
||||
`sed.exe --help`,
|
||||
`mytool.exe --help`,
|
||||
} {
|
||||
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
|
||||
t.Fatalf("expected unrelated command to pass for %q, got %#v", command, issue)
|
||||
@@ -204,7 +208,7 @@ func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDetectShellOutputIssueAddsWindowsSyntaxHint(t *testing.T) {
|
||||
issue := detectShellOutputIssue(`cd /d/tmp/zero-pr-158 && ls -la`, "The syntax of the command is incorrect.", "windows")
|
||||
issue := detectShellOutputIssue("The syntax of the command is incorrect.", "windows")
|
||||
if issue == nil {
|
||||
t.Fatal("expected Windows syntax error to get shell guidance")
|
||||
}
|
||||
@@ -434,6 +438,105 @@ func TestRegistryRunsWithDegradedUnavailableNativeSandbox(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBashToolRequireEscalatedMsysGuard covers the boundary of the fix for an
|
||||
// escalation-vs-preflight ordering bug: the MSYS sandbox guard exists only
|
||||
// because MSYS/Cygwin coreutils fail under the write-restricted sandbox, so
|
||||
// once sandbox_permissions: require_escalated is actually approved (running
|
||||
// the command unsandboxed), the guard must get out of the way, but an
|
||||
// unrelated windows_shell_syntax issue (a real cmd.exe syntax problem, not a
|
||||
// sandbox interaction) must still block regardless of escalation.
|
||||
func TestBashToolRequireEscalatedMsysGuard(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("windows-only MSYS sandbox guard")
|
||||
}
|
||||
root := t.TempDir()
|
||||
newEngine := func() *sandbox.Engine {
|
||||
return sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
|
||||
})
|
||||
}
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewBashTool(root))
|
||||
|
||||
t.Run("default sandboxing still blocks an MSYS-prone command", func(t *testing.T) {
|
||||
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
|
||||
"command": "cat somefile.txt",
|
||||
}, RunOptions{
|
||||
PermissionGranted: true,
|
||||
Sandbox: newEngine(),
|
||||
PermissionMode: string(sandbox.PermissionModeAsk),
|
||||
})
|
||||
if result.Meta["shell_issue"] != "windows_msys_sandbox" {
|
||||
t.Fatalf("expected windows_msys_sandbox block without escalation, got %#v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("approved require_escalated bypasses the MSYS guard", func(t *testing.T) {
|
||||
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
|
||||
"command": "cat somefile.txt",
|
||||
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
|
||||
}, RunOptions{
|
||||
PermissionGranted: true,
|
||||
Sandbox: newEngine(),
|
||||
PermissionMode: string(sandbox.PermissionModeAsk),
|
||||
})
|
||||
// Assert on the preflight block sentinel (exit_code "-1", set only by
|
||||
// shellIssueBlockResult) rather than shell_issue: once the guard is
|
||||
// bypassed, "cat somefile.txt" actually runs, and its real,
|
||||
// PATH-dependent output could otherwise trip the unrelated
|
||||
// post-execution detectShellOutputIssue heuristic and make this
|
||||
// assertion flaky for reasons unrelated to the guard under test.
|
||||
if result.Meta["exit_code"] == "-1" {
|
||||
t.Fatalf("expected approved require_escalated to bypass the MSYS guard, got blocked: %#v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("approved require_escalated still blocks an unrelated syntax issue", func(t *testing.T) {
|
||||
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
|
||||
"command": `cd /d/tmp/zero-pr-158 && dir`,
|
||||
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
|
||||
}, RunOptions{
|
||||
PermissionGranted: true,
|
||||
Sandbox: newEngine(),
|
||||
PermissionMode: string(sandbox.PermissionModeAsk),
|
||||
})
|
||||
if result.Meta["shell_issue"] != "windows_shell_syntax" {
|
||||
t.Fatalf("expected windows_shell_syntax block to still apply under require_escalated, got %#v", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBashToolIgnoresMsysMarkersInCommandArgumentsAfterFailure guards the
|
||||
// post-execution MSYS heuristic against treating the command line itself as
|
||||
// evidence. The helper command below fails for a reason unrelated to MSYS
|
||||
// (a plain non-zero exit), but its own argument text quotes an MSYS crash
|
||||
// marker the way a PR-comment or commit-message argument might. Before the
|
||||
// fix, detectShellOutputIssue scanned command+output together and would have
|
||||
// misdiagnosed this as an MSYS sandbox failure even though the real
|
||||
// stdout/stderr never mentions MSYS at all.
|
||||
func TestBashToolIgnoresMsysMarkersInCommandArgumentsAfterFailure(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("windows-only MSYS output guard")
|
||||
}
|
||||
root := t.TempDir()
|
||||
// The helper only reads argv[2] ("fail"); this trailing quoted argument is
|
||||
// otherwise unused by the helper but still part of the command line text.
|
||||
command := helperCommand("fail") + ` "fatal error - CreateFileMapping S-1-5-21, Win32 error 5. Terminating. cygheap_user::init"`
|
||||
|
||||
result := NewBashTool(root).Run(context.Background(), map[string]any{
|
||||
"command": command,
|
||||
})
|
||||
|
||||
if result.Status != StatusError || result.Meta["exit_code"] != "7" {
|
||||
t.Fatalf("expected the helper's real exit 7 failure, got %s: %#v", result.Status, result)
|
||||
}
|
||||
if result.Meta["shell_issue"] == "windows_msys_sandbox" {
|
||||
t.Fatalf("expected the MSYS marker in the command's own argument text to be ignored, got %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashToolRunsWithDegradedUnavailableNativeSandbox(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
policy := sandbox.DefaultPolicy()
|
||||
|
||||
@@ -2,6 +2,7 @@ package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -44,7 +45,7 @@ func (tool editFileTool) Run(ctx context.Context, args map[string]any) Result {
|
||||
return tool.RunWithOptions(ctx, args, RunOptions{})
|
||||
}
|
||||
|
||||
func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any, options RunOptions) Result {
|
||||
func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result {
|
||||
requestedPath, err := aliasedStringArg(args, []string{"path", "file", "file_path", "filename"}, "", true, false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for edit_file: " + err.Error())
|
||||
@@ -98,6 +99,35 @@ func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any,
|
||||
}
|
||||
}
|
||||
|
||||
// Fuzzy fallback: when the exact string (and its CRLF translation) is absent,
|
||||
// run a cascade of tolerant matchers (trimmed lines, block anchors, collapsed
|
||||
// whitespace, indentation drift, escape normalization) to locate the span the
|
||||
// model intended. Only a span that occurs literally in the file is accepted,
|
||||
// so the replacement applied below is still exact.
|
||||
if occurrences == 0 {
|
||||
findOld, findNew := oldString, newString
|
||||
if strings.Contains(content, "\r\n") && !strings.Contains(findOld, "\r\n") {
|
||||
findOld = strings.ReplaceAll(findOld, "\n", "\r\n")
|
||||
findNew = strings.ReplaceAll(findNew, "\n", "\r\n")
|
||||
}
|
||||
search, ferr := fuzzyEditMatch(content, findOld, replaceAll)
|
||||
switch {
|
||||
case ferr == nil:
|
||||
oldString = search
|
||||
// The model's new_string was written at old_string's (mismatched)
|
||||
// indentation; re-shape it to the span actually being replaced so a
|
||||
// tolerant match never strips indentation or a trailing CR.
|
||||
newString = adaptReplacementToSpan(search, findOld, findNew)
|
||||
occurrences = strings.Count(content, search)
|
||||
case errors.Is(ferr, errEditFuzzyAmbiguous):
|
||||
return errorResult("Error: old_string matches multiple locations in " + relativePath + " even after fuzzy matching. Provide more surrounding context to make the match unique, or pass replace_all: true.")
|
||||
case errors.Is(ferr, errEditFuzzyNotFound):
|
||||
// Fall through to the exact-match error below.
|
||||
default:
|
||||
return errorResult("Error editing " + relativePath + ": " + ferr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if occurrences == 0 {
|
||||
return errorResult("Error: Could not find the exact string to replace in " + relativePath + ". The old_string must match the file byte-for-byte.")
|
||||
}
|
||||
@@ -120,6 +150,10 @@ func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any,
|
||||
if err := os.WriteFile(absolutePath, []byte(updated), 0o644); err != nil {
|
||||
return errorResult("Error writing " + relativePath + ": " + err.Error())
|
||||
}
|
||||
// Optional format-on-write (ZERO_FORMAT_ON_WRITE). Must run BEFORE the
|
||||
// FileTracker re-baseline: recording pre-format content would make the very
|
||||
// next edit look like an external modification and trip the conflict guard.
|
||||
updated = maybeFormatWrittenFile(ctx, absolutePath, updated)
|
||||
// Re-baseline to the content we just wrote so subsequent edits in this session
|
||||
// compare against the current on-disk state, not the pre-edit version.
|
||||
newInfo, _ := os.Stat(absolutePath)
|
||||
@@ -130,6 +164,7 @@ func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any,
|
||||
suffix = "s"
|
||||
}
|
||||
summary := fmt.Sprintf("Successfully edited %s (replaced %d occurrence%s).", relativePath, replacedCount, suffix)
|
||||
summary += inlineDiagnostics(ctx, options, absolutePath, relativePath)
|
||||
result := okResult(summary)
|
||||
result.ChangedFiles = []string{relativePath}
|
||||
// Card-only preview (Display.Preview): the model's Output stays the one-line
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
package tools
|
||||
|
||||
// Fuzzy fallback matching for edit_file. When the model's old_string fails to
|
||||
// match byte-for-byte (drifted indentation, collapsed whitespace, escaped
|
||||
// characters, a slightly-misremembered middle line), these replacers propose
|
||||
// candidate spans that plausibly correspond to what the model intended. Only a
|
||||
// candidate that occurs literally in the file is accepted, so the replacement
|
||||
// itself is always exact even when the match was tolerant.
|
||||
//
|
||||
// Strategy cascade ported from opencode's edit tool, whose replacers were in
|
||||
// turn distilled from Cline's diff-apply evals and gemini-cli's edit corrector.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// editReplacer proposes candidate spans for find inside content. Candidates
|
||||
// are re-validated by fuzzyEditMatch before use.
|
||||
type editReplacer func(content, find string) []string
|
||||
|
||||
var (
|
||||
errEditFuzzyNotFound = errors.New("no fuzzy match for old_string")
|
||||
errEditFuzzyAmbiguous = errors.New("fuzzy match for old_string is ambiguous")
|
||||
)
|
||||
|
||||
// Minimum average middle-line similarity for block-anchor matches.
|
||||
const editAnchorSimilarityThreshold = 0.65
|
||||
|
||||
// fuzzyEditMatch runs the replacer cascade and returns the exact span of
|
||||
// content to replace. When replaceAll is false the span must be unique in
|
||||
// content; an ambiguous candidate is skipped in favor of later candidates and
|
||||
// only reported if nothing unique is found. A span wildly larger than
|
||||
// old_string is refused outright rather than risking a destructive edit.
|
||||
func fuzzyEditMatch(content, find string, replaceAll bool) (string, error) {
|
||||
replacers := []editReplacer{
|
||||
lineTrimmedReplacer,
|
||||
blockAnchorReplacer,
|
||||
whitespaceNormalizedReplacer,
|
||||
indentationFlexibleReplacer,
|
||||
escapeNormalizedReplacer,
|
||||
trimmedBoundaryReplacer,
|
||||
contextAwareReplacer,
|
||||
}
|
||||
found := false
|
||||
for _, replacer := range replacers {
|
||||
// Collect the replacer's DISTINCT candidate spans that literally occur in
|
||||
// content. Two or more distinct spans from one strategy (e.g. duplicate
|
||||
// blocks at different indentation, each occurring once) mean the model's
|
||||
// intent is genuinely ambiguous — silently editing the first would be a
|
||||
// wrong-span write, so it is rejected instead.
|
||||
var candidates []string
|
||||
seen := map[string]bool{}
|
||||
for _, search := range replacer(content, find) {
|
||||
if search == "" || seen[search] {
|
||||
continue
|
||||
}
|
||||
seen[search] = true
|
||||
if !strings.Contains(content, search) {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, search)
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if !replaceAll && len(candidates) > 1 {
|
||||
return "", errEditFuzzyAmbiguous
|
||||
}
|
||||
search := candidates[0]
|
||||
if isDisproportionateEditMatch(search, find) {
|
||||
return "", errors.New("refusing replacement because the matched span is much larger than old_string; re-read the file and provide the full exact old_string for the intended replacement")
|
||||
}
|
||||
if replaceAll {
|
||||
return search, nil
|
||||
}
|
||||
if strings.Index(content, search) == strings.LastIndex(content, search) {
|
||||
return search, nil
|
||||
}
|
||||
// The single candidate occurs at multiple positions; a later, stricter
|
||||
// strategy may still resolve a unique span, so keep cascading.
|
||||
}
|
||||
if !found {
|
||||
return "", errEditFuzzyNotFound
|
||||
}
|
||||
return "", errEditFuzzyAmbiguous
|
||||
}
|
||||
|
||||
// isDisproportionateEditMatch guards against anchor-style replacers matching a
|
||||
// span far larger than the text the model asked to replace (e.g. first/last
|
||||
// line anchors bridging hundreds of unrelated lines).
|
||||
func isDisproportionateEditMatch(search, find string) bool {
|
||||
findLines := strings.Count(find, "\n") + 1
|
||||
searchLines := strings.Count(search, "\n") + 1
|
||||
limit := findLines + 3
|
||||
if findLines*2 > limit {
|
||||
limit = findLines * 2
|
||||
}
|
||||
if searchLines >= limit {
|
||||
return true
|
||||
}
|
||||
if findLines == 1 {
|
||||
return false
|
||||
}
|
||||
searchTrimmed := len(strings.TrimSpace(search))
|
||||
findTrimmed := len(strings.TrimSpace(find))
|
||||
byteLimit := findTrimmed + 500
|
||||
if findTrimmed*4 > byteLimit {
|
||||
byteLimit = findTrimmed * 4
|
||||
}
|
||||
return searchTrimmed > byteLimit
|
||||
}
|
||||
|
||||
// splitFindLines splits find into lines, dropping the trailing empty line a
|
||||
// trailing newline produces so windows align with real content lines.
|
||||
func splitFindLines(find string) []string {
|
||||
lines := strings.Split(find, "\n")
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// lineTrimmedReplacer matches when every line equals the corresponding
|
||||
// old_string line after trimming surrounding whitespace.
|
||||
func lineTrimmedReplacer(content, find string) []string {
|
||||
contentLines := strings.Split(content, "\n")
|
||||
findLines := splitFindLines(find)
|
||||
if len(findLines) == 0 {
|
||||
return nil
|
||||
}
|
||||
var candidates []string
|
||||
for i := 0; i+len(findLines) <= len(contentLines); i++ {
|
||||
matches := true
|
||||
for j := range findLines {
|
||||
if strings.TrimSpace(contentLines[i+j]) != strings.TrimSpace(findLines[j]) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matches {
|
||||
candidates = append(candidates, strings.Join(contentLines[i:i+len(findLines)], "\n"))
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// blockAnchorReplacer anchors on the first and last lines (trimmed) and
|
||||
// accepts the block when the middle lines average >= the similarity threshold
|
||||
// (Levenshtein-based), tolerating a slightly misremembered interior.
|
||||
func blockAnchorReplacer(content, find string) []string {
|
||||
findLines := splitFindLines(find)
|
||||
if len(findLines) < 3 {
|
||||
return nil
|
||||
}
|
||||
contentLines := strings.Split(content, "\n")
|
||||
firstAnchor := strings.TrimSpace(findLines[0])
|
||||
lastAnchor := strings.TrimSpace(findLines[len(findLines)-1])
|
||||
searchBlockSize := len(findLines)
|
||||
maxLineDelta := searchBlockSize / 4
|
||||
if maxLineDelta < 1 {
|
||||
maxLineDelta = 1
|
||||
}
|
||||
|
||||
type span struct{ start, end int }
|
||||
var candidates []span
|
||||
for i := 0; i < len(contentLines); i++ {
|
||||
if strings.TrimSpace(contentLines[i]) != firstAnchor {
|
||||
continue
|
||||
}
|
||||
// Only the first occurrence of the last anchor after this start counts,
|
||||
// mirroring the reference implementation: a farther-away closing line is
|
||||
// assumed to close a different block.
|
||||
for j := i + 2; j < len(contentLines); j++ {
|
||||
if strings.TrimSpace(contentLines[j]) != lastAnchor {
|
||||
continue
|
||||
}
|
||||
actualBlockSize := j - i + 1
|
||||
delta := actualBlockSize - searchBlockSize
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
if delta <= maxLineDelta {
|
||||
candidates = append(candidates, span{start: i, end: j})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
middleSimilarity := func(candidate span) float64 {
|
||||
actualBlockSize := candidate.end - candidate.start + 1
|
||||
linesToCheck := searchBlockSize - 2
|
||||
if actualBlockSize-2 < linesToCheck {
|
||||
linesToCheck = actualBlockSize - 2
|
||||
}
|
||||
if linesToCheck <= 0 {
|
||||
return 1.0
|
||||
}
|
||||
similarity := 0.0
|
||||
for j := 1; j < searchBlockSize-1 && j < actualBlockSize-1; j++ {
|
||||
contentLine := strings.TrimSpace(contentLines[candidate.start+j])
|
||||
findLine := strings.TrimSpace(findLines[j])
|
||||
maxLen := len(contentLine)
|
||||
if len(findLine) > maxLen {
|
||||
maxLen = len(findLine)
|
||||
}
|
||||
if maxLen == 0 {
|
||||
continue
|
||||
}
|
||||
distance := levenshtein(contentLine, findLine)
|
||||
similarity += 1 - float64(distance)/float64(maxLen)
|
||||
}
|
||||
return similarity / float64(linesToCheck)
|
||||
}
|
||||
|
||||
// Return EVERY candidate that clears the similarity threshold, best first.
|
||||
// Picking only the best would hide competing blocks from fuzzyEditMatch's
|
||||
// distinct-candidate ambiguity check and could silently edit the wrong
|
||||
// block; with all qualifying spans surfaced, one clear winner still
|
||||
// resolves (single candidate) while two plausible blocks are rejected as
|
||||
// ambiguous. replaceAll consumers take the first (most similar) span.
|
||||
type scoredSpan struct {
|
||||
span span
|
||||
similarity float64
|
||||
}
|
||||
var qualifying []scoredSpan
|
||||
for _, candidate := range candidates {
|
||||
if s := middleSimilarity(candidate); s >= editAnchorSimilarityThreshold {
|
||||
qualifying = append(qualifying, scoredSpan{span: candidate, similarity: s})
|
||||
}
|
||||
}
|
||||
if len(qualifying) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.SliceStable(qualifying, func(i, j int) bool {
|
||||
return qualifying[i].similarity > qualifying[j].similarity
|
||||
})
|
||||
spans := make([]string, 0, len(qualifying))
|
||||
for _, scored := range qualifying {
|
||||
spans = append(spans, strings.Join(contentLines[scored.span.start:scored.span.end+1], "\n"))
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
var editWhitespaceRun = regexp.MustCompile(`\s+`)
|
||||
|
||||
func normalizeEditWhitespace(text string) string {
|
||||
return strings.TrimSpace(editWhitespaceRun.ReplaceAllString(text, " "))
|
||||
}
|
||||
|
||||
// whitespaceNormalizedReplacer matches after collapsing all whitespace runs to
|
||||
// a single space: full lines, sub-line spans (via a word-boundary regex), and
|
||||
// multi-line windows.
|
||||
func whitespaceNormalizedReplacer(content, find string) []string {
|
||||
normalizedFind := normalizeEditWhitespace(find)
|
||||
if normalizedFind == "" {
|
||||
return nil
|
||||
}
|
||||
contentLines := strings.Split(content, "\n")
|
||||
var candidates []string
|
||||
var subLinePattern *regexp.Regexp
|
||||
for _, line := range contentLines {
|
||||
normalizedLine := normalizeEditWhitespace(line)
|
||||
if normalizedLine == normalizedFind {
|
||||
candidates = append(candidates, line)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(normalizedLine, normalizedFind) {
|
||||
continue
|
||||
}
|
||||
if subLinePattern == nil {
|
||||
words := strings.Fields(find)
|
||||
quoted := make([]string, len(words))
|
||||
for i, word := range words {
|
||||
quoted[i] = regexp.QuoteMeta(word)
|
||||
}
|
||||
pattern, err := regexp.Compile(strings.Join(quoted, `\s+`))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
subLinePattern = pattern
|
||||
}
|
||||
if match := subLinePattern.FindString(line); match != "" {
|
||||
candidates = append(candidates, match)
|
||||
}
|
||||
}
|
||||
|
||||
findLines := strings.Split(find, "\n")
|
||||
if len(findLines) > 1 {
|
||||
for i := 0; i+len(findLines) <= len(contentLines); i++ {
|
||||
block := strings.Join(contentLines[i:i+len(findLines)], "\n")
|
||||
if normalizeEditWhitespace(block) == normalizedFind {
|
||||
candidates = append(candidates, block)
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// stripCommonIndentation removes the minimum leading-whitespace width shared
|
||||
// by all non-empty lines, so blocks match regardless of their nesting depth.
|
||||
func stripCommonIndentation(text string) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
minIndent := -1
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
indent := len(line) - len(strings.TrimLeft(line, " \t"))
|
||||
if minIndent < 0 || indent < minIndent {
|
||||
minIndent = indent
|
||||
}
|
||||
}
|
||||
if minIndent <= 0 {
|
||||
return text
|
||||
}
|
||||
stripped := make([]string, len(lines))
|
||||
for i, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
stripped[i] = line
|
||||
continue
|
||||
}
|
||||
stripped[i] = line[minIndent:]
|
||||
}
|
||||
return strings.Join(stripped, "\n")
|
||||
}
|
||||
|
||||
func indentationFlexibleReplacer(content, find string) []string {
|
||||
normalizedFind := stripCommonIndentation(find)
|
||||
contentLines := strings.Split(content, "\n")
|
||||
findLines := strings.Split(find, "\n")
|
||||
var candidates []string
|
||||
for i := 0; i+len(findLines) <= len(contentLines); i++ {
|
||||
block := strings.Join(contentLines[i:i+len(findLines)], "\n")
|
||||
if stripCommonIndentation(block) == normalizedFind {
|
||||
candidates = append(candidates, block)
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
var editEscapeSequence = regexp.MustCompile("\\\\(n|t|r|'|\"|`|\\\\|\\n|\\$)")
|
||||
|
||||
// unescapeEditString undoes one level of string escaping (\n, \t, \", \\, a
|
||||
// backslash-newline continuation, \$) — the model sometimes reproduces file
|
||||
// content as it appeared inside a quoted string literal.
|
||||
func unescapeEditString(text string) string {
|
||||
return editEscapeSequence.ReplaceAllStringFunc(text, func(match string) string {
|
||||
switch match[1:] {
|
||||
case "n":
|
||||
return "\n"
|
||||
case "t":
|
||||
return "\t"
|
||||
case "r":
|
||||
return "\r"
|
||||
case "\n":
|
||||
return "\n"
|
||||
default:
|
||||
// ', ", `, \, $ all unescape to themselves.
|
||||
return match[1:]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func escapeNormalizedReplacer(content, find string) []string {
|
||||
unescapedFind := unescapeEditString(find)
|
||||
var candidates []string
|
||||
if strings.Contains(content, unescapedFind) {
|
||||
candidates = append(candidates, unescapedFind)
|
||||
}
|
||||
contentLines := strings.Split(content, "\n")
|
||||
findLines := strings.Split(unescapedFind, "\n")
|
||||
for i := 0; i+len(findLines) <= len(contentLines); i++ {
|
||||
block := strings.Join(contentLines[i:i+len(findLines)], "\n")
|
||||
if unescapeEditString(block) == unescapedFind {
|
||||
candidates = append(candidates, block)
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// trimmedBoundaryReplacer tolerates stray leading/trailing whitespace (often
|
||||
// blank lines) around an otherwise-exact old_string.
|
||||
func trimmedBoundaryReplacer(content, find string) []string {
|
||||
trimmedFind := strings.TrimSpace(find)
|
||||
if trimmedFind == find || trimmedFind == "" {
|
||||
return nil
|
||||
}
|
||||
var candidates []string
|
||||
if strings.Contains(content, trimmedFind) {
|
||||
candidates = append(candidates, trimmedFind)
|
||||
}
|
||||
contentLines := strings.Split(content, "\n")
|
||||
findLines := strings.Split(find, "\n")
|
||||
for i := 0; i+len(findLines) <= len(contentLines); i++ {
|
||||
block := strings.Join(contentLines[i:i+len(findLines)], "\n")
|
||||
if strings.TrimSpace(block) == trimmedFind {
|
||||
candidates = append(candidates, block)
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// contextAwareReplacer anchors on the first and last lines and accepts an
|
||||
// equal-length block when at least half of its non-empty middle lines match
|
||||
// after trimming — a cheaper, stricter cousin of blockAnchorReplacer.
|
||||
func contextAwareReplacer(content, find string) []string {
|
||||
findLines := splitFindLines(find)
|
||||
if len(findLines) < 3 {
|
||||
return nil
|
||||
}
|
||||
contentLines := strings.Split(content, "\n")
|
||||
firstAnchor := strings.TrimSpace(findLines[0])
|
||||
lastAnchor := strings.TrimSpace(findLines[len(findLines)-1])
|
||||
var candidates []string
|
||||
for i := 0; i < len(contentLines); i++ {
|
||||
if strings.TrimSpace(contentLines[i]) != firstAnchor {
|
||||
continue
|
||||
}
|
||||
for j := i + 2; j < len(contentLines); j++ {
|
||||
if strings.TrimSpace(contentLines[j]) != lastAnchor {
|
||||
continue
|
||||
}
|
||||
if j-i+1 == len(findLines) {
|
||||
matching, nonEmpty := 0, 0
|
||||
for k := 1; k < len(findLines)-1; k++ {
|
||||
blockLine := strings.TrimSpace(contentLines[i+k])
|
||||
findLine := strings.TrimSpace(findLines[k])
|
||||
if blockLine == "" && findLine == "" {
|
||||
continue
|
||||
}
|
||||
nonEmpty++
|
||||
if blockLine == findLine {
|
||||
matching++
|
||||
}
|
||||
}
|
||||
if nonEmpty == 0 || float64(matching)/float64(nonEmpty) >= 0.5 {
|
||||
candidates = append(candidates, strings.Join(contentLines[i:j+1], "\n"))
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// adaptReplacementToSpan re-shapes the model's replacement to the span a
|
||||
// tolerant matcher resolved. When old_string only matched after normalization,
|
||||
// new_string was written at old_string's (wrong) shape, so applying it raw
|
||||
// would strip the file's indentation or drop a trailing CR:
|
||||
//
|
||||
// 1. Uniform re-indent: when every span line equals delta + the corresponding
|
||||
// find line's indentation (the line-trimmed / indentation-flexible shapes),
|
||||
// the same delta is prepended to every non-blank replacement line. Any
|
||||
// line that breaks the uniform-delta relationship disables the shift —
|
||||
// block-anchor matches with a drifted interior are left untouched.
|
||||
// 2. Trailing CR: a span from a CRLF file ends mid-line at "\r" (candidates
|
||||
// are built by joining lines split on "\n"); the replacement gets the same
|
||||
// trailing "\r" so the file's CRLF pairs stay intact.
|
||||
func adaptReplacementToSpan(span, find, replacement string) string {
|
||||
if delta, ok := uniformIndentDelta(span, find); ok && delta != "" {
|
||||
lines := strings.Split(replacement, "\n")
|
||||
for i, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
lines[i] = delta + line
|
||||
}
|
||||
replacement = strings.Join(lines, "\n")
|
||||
}
|
||||
if strings.HasSuffix(span, "\r") && !strings.HasSuffix(replacement, "\r") {
|
||||
replacement += "\r"
|
||||
}
|
||||
return replacement
|
||||
}
|
||||
|
||||
// uniformIndentDelta returns the indentation prefix that, prepended to every
|
||||
// non-blank find line, yields the corresponding span line's indentation. ok is
|
||||
// false when line counts differ, any line pair disagrees on the delta, or the
|
||||
// span is not simply a uniformly deeper-indented copy of find.
|
||||
func uniformIndentDelta(span, find string) (string, bool) {
|
||||
spanLines := strings.Split(span, "\n")
|
||||
findLines := splitFindLines(find)
|
||||
if len(spanLines) != len(findLines) {
|
||||
return "", false
|
||||
}
|
||||
leadingWhitespace := func(line string) string {
|
||||
return line[:len(line)-len(strings.TrimLeft(line, " \t"))]
|
||||
}
|
||||
delta := ""
|
||||
haveDelta := false
|
||||
for i := range findLines {
|
||||
spanLine := strings.TrimSuffix(spanLines[i], "\r")
|
||||
findLine := strings.TrimSuffix(findLines[i], "\r")
|
||||
if strings.TrimSpace(spanLine) == "" && strings.TrimSpace(findLine) == "" {
|
||||
continue
|
||||
}
|
||||
spanIndent := leadingWhitespace(spanLine)
|
||||
findIndent := leadingWhitespace(findLine)
|
||||
if !strings.HasSuffix(spanIndent, findIndent) {
|
||||
return "", false
|
||||
}
|
||||
lineDelta := spanIndent[:len(spanIndent)-len(findIndent)]
|
||||
if !haveDelta {
|
||||
delta = lineDelta
|
||||
haveDelta = true
|
||||
continue
|
||||
}
|
||||
if lineDelta != delta {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return delta, haveDelta
|
||||
}
|
||||
|
||||
// levenshtein computes edit distance with a two-row rolling matrix.
|
||||
func levenshtein(a, b string) int {
|
||||
if a == "" {
|
||||
return len(b)
|
||||
}
|
||||
if b == "" {
|
||||
return len(a)
|
||||
}
|
||||
previous := make([]int, len(b)+1)
|
||||
current := make([]int, len(b)+1)
|
||||
for j := range previous {
|
||||
previous[j] = j
|
||||
}
|
||||
for i := 1; i <= len(a); i++ {
|
||||
current[0] = i
|
||||
for j := 1; j <= len(b); j++ {
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
minimum := previous[j] + 1
|
||||
if current[j-1]+1 < minimum {
|
||||
minimum = current[j-1] + 1
|
||||
}
|
||||
if previous[j-1]+cost < minimum {
|
||||
minimum = previous[j-1] + cost
|
||||
}
|
||||
current[j] = minimum
|
||||
}
|
||||
previous, current = current, previous
|
||||
}
|
||||
return previous[len(b)]
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func runEdit(t *testing.T, dir, initial string, args map[string]any) (Result, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "target.txt")
|
||||
if err := os.WriteFile(path, []byte(initial), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if args["path"] == nil {
|
||||
args["path"] = "target.txt"
|
||||
}
|
||||
result := NewEditFileTool(dir).Run(context.Background(), args)
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result, string(after)
|
||||
}
|
||||
|
||||
func TestEditExactMatchStillPreferred(t *testing.T) {
|
||||
// The fast path must keep byte-exact semantics: an exact match wins even
|
||||
// when fuzzy strategies would also find candidates elsewhere.
|
||||
result, after := runEdit(t, t.TempDir(), "alpha\nbeta\ngamma\n", map[string]any{
|
||||
"old_string": "beta",
|
||||
"new_string": "delta",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if after != "alpha\ndelta\ngamma\n" {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyLineTrimmed(t *testing.T) {
|
||||
// Model reproduced the lines without the file's leading indentation; the
|
||||
// line-trimmed strategy must find the real indented span and preserve the
|
||||
// file's own indentation on the replaced region boundary.
|
||||
initial := "func a() {\n\tx := 1\n\treturn x\n}\n"
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": "x := 1\nreturn x",
|
||||
"new_string": "y := 2\nreturn y",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
// new_string was written at old_string's outdented shape; the span's
|
||||
// indentation must be re-applied so the file keeps its tabs.
|
||||
if !strings.Contains(after, "\ty := 2\n\treturn y\n}") {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
if strings.Contains(after, "x := 1") {
|
||||
t.Fatalf("old span survived: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyWhitespaceNormalizedSingleLine(t *testing.T) {
|
||||
// Whitespace runs collapsed: "x := 1" in the file, single spaces from
|
||||
// the model.
|
||||
initial := "start\nx := 1\nend\n"
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": "x := 1",
|
||||
"new_string": "x := 2",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if !strings.Contains(after, "x := 2") || strings.Contains(after, ":= 1") {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyBlockAnchorToleratesMiddleDrift(t *testing.T) {
|
||||
// First and last lines anchor the block; one interior line differs slightly
|
||||
// (comment text drifted). Levenshtein similarity keeps it above 0.65.
|
||||
initial := strings.Join([]string{
|
||||
"func handler(w http.ResponseWriter, r *http.Request) {",
|
||||
"\t// write the response body to the client",
|
||||
"\tw.WriteHeader(http.StatusOK)",
|
||||
"\tfmt.Fprint(w, \"done\")",
|
||||
"}",
|
||||
"",
|
||||
}, "\n")
|
||||
find := strings.Join([]string{
|
||||
"func handler(w http.ResponseWriter, r *http.Request) {",
|
||||
"\t// write the response body to client",
|
||||
"\tw.WriteHeader(http.StatusOK)",
|
||||
"\tfmt.Fprint(w, \"done\")",
|
||||
"}",
|
||||
}, "\n")
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": find,
|
||||
"new_string": "func handler(w http.ResponseWriter, r *http.Request) {\n\tw.WriteHeader(http.StatusNoContent)\n}",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if !strings.Contains(after, "StatusNoContent") || strings.Contains(after, "StatusOK") {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyIndentationFlexible(t *testing.T) {
|
||||
// The whole block sits one nesting level deeper in the file than in the
|
||||
// model's old_string, with interior relative indentation preserved.
|
||||
initial := "if ok {\n\t\tfor i := range xs {\n\t\t\tsum += xs[i]\n\t\t}\n}\n"
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": "for i := range xs {\n\tsum += xs[i]\n}",
|
||||
"new_string": "sum += total(xs)",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
// The replacement lands at the span's real depth (two tabs), not at
|
||||
// old_string's outdented depth.
|
||||
if !strings.Contains(after, "\t\tsum += total(xs)") || strings.Contains(after, "range xs") {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyCRLFLineTrimmedPreservesCarriageReturns(t *testing.T) {
|
||||
// CRLF file + a multi-line outdented old_string: the resolved span carries
|
||||
// tabs and trailing CRs; the replacement must inherit both so the file's
|
||||
// CRLF pairs and indentation survive the edit.
|
||||
initial := "func a() {\r\n\tx := 1\r\n\treturn x\r\n}\r\n"
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": "x := 1\nreturn x",
|
||||
"new_string": "y := 2\nreturn y",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if !strings.Contains(after, "\ty := 2\r\n\treturn y\r\n}") {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
if strings.Contains(after, "\n\treturn y\n") {
|
||||
t.Fatalf("bare LF introduced into CRLF file: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniformIndentDelta(t *testing.T) {
|
||||
cases := []struct {
|
||||
span, find string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"\tx := 1\n\treturn x", "x := 1\nreturn x", "\t", true},
|
||||
{"\t\tfor {\n\t\t\tgo()\n\t\t}", "for {\n\tgo()\n}", "\t\t", true},
|
||||
{"x := 1", "x := 1", "", true}, // no shift needed
|
||||
{"\tx := 1\n return x", "x := 1\nreturn x", "", false}, // mixed delta
|
||||
{"\ta\n\tb\n\tc", "a\nb", "", false}, // line-count mismatch
|
||||
{" a", "\ta", "", false}, // find indent not a suffix
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := uniformIndentDelta(c.span, c.find)
|
||||
if ok != c.ok || (ok && got != c.want) {
|
||||
t.Errorf("uniformIndentDelta(%q, %q) = %q,%v want %q,%v", c.span, c.find, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptReplacementLeavesNonUniformSpansAlone(t *testing.T) {
|
||||
// Block-anchor style match with a drifted interior: no uniform delta, so
|
||||
// the replacement must pass through untouched.
|
||||
span := "\tfunc h() {\n\t\t// drifted comment\n\t}"
|
||||
find := "func h() {\n// different comment\n}"
|
||||
if got := adaptReplacementToSpan(span, find, "replacement()"); got != "replacement()" {
|
||||
t.Fatalf("non-uniform span must not re-indent: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyEscapeNormalized(t *testing.T) {
|
||||
// Model reproduced the line as it would appear inside a quoted string
|
||||
// literal, with escaped quotes.
|
||||
initial := "console.log(\"hello world\")\n"
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": `console.log(\"hello world\")`,
|
||||
"new_string": `console.log("goodbye")`,
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if !strings.Contains(after, `console.log("goodbye")`) {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyTrimmedBoundary(t *testing.T) {
|
||||
// Stray whitespace around an otherwise-exact old_string; " two " is not a
|
||||
// substring of the file, so only the fuzzy cascade can resolve it.
|
||||
initial := "one\ntwo\nthree\n"
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": " two ",
|
||||
"new_string": "2",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if after != "one\n2\nthree\n" {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimmedBoundaryReplacerYieldsTrimmedSpan(t *testing.T) {
|
||||
candidates := trimmedBoundaryReplacer("one\ntwo\nthree\n", "\ntwo\n")
|
||||
if len(candidates) == 0 || candidates[0] != "two" {
|
||||
t.Fatalf("expected trimmed candidate \"two\", got %v", candidates)
|
||||
}
|
||||
if trimmedBoundaryReplacer("anything", "already-trimmed") != nil {
|
||||
t.Fatal("already-trimmed find must yield no candidates")
|
||||
}
|
||||
}
|
||||
|
||||
// Two identical blocks that only match old_string after indentation-tolerant
|
||||
// matching: exact search finds nothing, fuzzy finds both.
|
||||
const ambiguousBlocks = "\tif x {\n\t\tgo()\n\t}\nmid\n\tif x {\n\t\tgo()\n\t}\n"
|
||||
|
||||
func TestEditFuzzyAmbiguousReportsError(t *testing.T) {
|
||||
// No strategy can disambiguate identical blocks, so the tool must refuse
|
||||
// rather than guess, and the file must be untouched.
|
||||
result, after := runEdit(t, t.TempDir(), ambiguousBlocks, map[string]any{
|
||||
"old_string": "if x {\n\tgo()\n}",
|
||||
"new_string": "stop()",
|
||||
})
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, "multiple locations") {
|
||||
t.Fatalf("expected ambiguity error, got %q", result.Output)
|
||||
}
|
||||
if after != ambiguousBlocks {
|
||||
t.Fatalf("file must be unchanged, got %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyReplaceAll(t *testing.T) {
|
||||
// replace_all applies the fuzzy-resolved span at every occurrence.
|
||||
result, after := runEdit(t, t.TempDir(), ambiguousBlocks, map[string]any{
|
||||
"old_string": "if x {\n\tgo()\n}",
|
||||
"new_string": "stop()",
|
||||
"replace_all": true,
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if strings.Count(after, "stop()") != 2 || strings.Contains(after, "go()") {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyNotFoundKeepsExactError(t *testing.T) {
|
||||
result, after := runEdit(t, t.TempDir(), "alpha\n", map[string]any{
|
||||
"old_string": "omega",
|
||||
"new_string": "x",
|
||||
})
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, "Could not find the exact string") {
|
||||
t.Fatalf("expected not-found error, got %q", result.Output)
|
||||
}
|
||||
if after != "alpha\n" {
|
||||
t.Fatalf("file must be unchanged, got %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyCRLFFile(t *testing.T) {
|
||||
// CRLF file + LF old_string with indentation drift: the CRLF translation
|
||||
// feeds the cascade and the replacement preserves CRLF endings.
|
||||
initial := "func a() {\r\n\tx := 1\r\n}\r\n"
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": "x := 1",
|
||||
"new_string": "x := 2",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if !strings.Contains(after, "\tx := 2\r\n") {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDisproportionateEditMatch(t *testing.T) {
|
||||
// A candidate span that dwarfs old_string must be refused: anchors bridging
|
||||
// unrelated code would otherwise delete it all.
|
||||
big := strings.Repeat("line\n", 10)
|
||||
if !isDisproportionateEditMatch(big, "a\nb\nc") {
|
||||
t.Fatal("10-line span for 3-line find must be disproportionate")
|
||||
}
|
||||
if isDisproportionateEditMatch("a\nb\nc\nd", "a\nb\nc") {
|
||||
t.Fatal("one extra line within tolerance must be allowed")
|
||||
}
|
||||
if isDisproportionateEditMatch("single line", "single") {
|
||||
t.Fatal("single-line finds are exempt from the byte-length guard")
|
||||
}
|
||||
if !isDisproportionateEditMatch("xx\n"+strings.Repeat("y", 900)+"\nzz", "xx\nab\nzz") {
|
||||
t.Fatal("byte-length blowup must be disproportionate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"abc", "", 3},
|
||||
{"", "abc", 3},
|
||||
{"kitten", "sitting", 3},
|
||||
{"same", "same", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := levenshtein(c.a, c.b); got != c.want {
|
||||
t.Fatalf("levenshtein(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyDistinctCandidatesAreAmbiguous(t *testing.T) {
|
||||
// Two same-content blocks at DIFFERENT indentation: each resolved span is
|
||||
// distinct and occurs exactly once, so the old literal-uniqueness check
|
||||
// passed and silently edited the first block. Distinct-shaped candidates
|
||||
// from one strategy must instead report ambiguity and leave the file alone.
|
||||
initial := "\tif x {\n\t\tgo()\n\t}\nmid\n\t\tif x {\n\t\t\tgo()\n\t\t}\n"
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": "if x {\n\tgo()\n}",
|
||||
"new_string": "stop()",
|
||||
})
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, "multiple locations") {
|
||||
t.Fatalf("distinct fuzzy candidates must be ambiguous, got %q", result.Output)
|
||||
}
|
||||
if after != initial {
|
||||
t.Fatalf("file must be unchanged, got %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyBlockAnchorTwoPlausibleBlocksAmbiguous(t *testing.T) {
|
||||
// Two blocks share the same first/last anchor lines and BOTH interiors sit
|
||||
// above the similarity threshold. Picking the "best" one would silently
|
||||
// edit a block the model may not have meant; both must surface so the
|
||||
// cascade reports ambiguity and the file stays untouched.
|
||||
initial := strings.Join([]string{
|
||||
"func setup(cfg Config) {",
|
||||
"\tvalue := compute(alpha, beta, gamma)",
|
||||
"}",
|
||||
"",
|
||||
"func setup(cfg Config) {",
|
||||
"\tvalue := compute(alpha, delta, gamma)",
|
||||
"}",
|
||||
"",
|
||||
}, "\n")
|
||||
find := strings.Join([]string{
|
||||
"func setup(cfg Config) {",
|
||||
"\tvalue := compute(alpha, omega, gamma)",
|
||||
"}",
|
||||
}, "\n")
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": find,
|
||||
"new_string": "func setup(cfg Config) {}",
|
||||
})
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, "multiple locations") {
|
||||
t.Fatalf("two plausible anchor blocks must be ambiguous, got %q", result.Output)
|
||||
}
|
||||
if after != initial {
|
||||
t.Fatalf("file must be unchanged, got %q", after)
|
||||
}
|
||||
}
|
||||
@@ -517,7 +517,12 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
|
||||
}
|
||||
if issue := detectShellCommandIssue(commandText, runtimeGOOS()); issue != nil {
|
||||
// Resolve the command engine before the MSYS preflight check so an
|
||||
// approved require_escalated call (commandEngine == nil, truly
|
||||
// unsandboxed) can actually bypass the MSYS guard instead of being
|
||||
// hard-blocked by the same check it was meant to escalate past.
|
||||
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
|
||||
if issue := detectShellCommandIssue(commandText, runtimeGOOS()); issue != nil && !msysGuardBypassed(issue, commandEngine) {
|
||||
return shellIssueBlockResult(*issue)
|
||||
}
|
||||
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
|
||||
@@ -860,6 +865,12 @@ func execToolResult(input execToolResultInput) Result {
|
||||
status = StatusError
|
||||
}
|
||||
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
|
||||
if status == StatusError && input.exited && !input.interrupted {
|
||||
if issue := detectShellOutputIssue(output, runtimeGOOS()); issue != nil {
|
||||
meta["shell_issue"] = issue.Kind
|
||||
body = appendShellIssueHint(body, *issue)
|
||||
}
|
||||
}
|
||||
if input.outputBufferTruncated {
|
||||
// Appended after truncateExecOutput's own head/tail slicing, not
|
||||
// embedded in the text that goes through it — a marker inside that
|
||||
@@ -908,6 +919,15 @@ func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode
|
||||
}
|
||||
|
||||
func truncateExecOutput(output string, maxOutputTokens int) (string, bool) {
|
||||
return truncateExecOutputSpill(output, maxOutputTokens, "exec_command")
|
||||
}
|
||||
|
||||
// truncateExecOutputSpill keeps a head/tail window of the output within the
|
||||
// token budget and, on truncation, spills the full output to disk so the model
|
||||
// can grep/read the elided middle instead of re-running the command with a
|
||||
// bigger budget. The spill is best-effort: when it fails the notice simply
|
||||
// omits the file hint.
|
||||
func truncateExecOutputSpill(output string, maxOutputTokens int, toolName string) (string, bool) {
|
||||
if maxOutputTokens <= 0 {
|
||||
maxOutputTokens = defaultMaxOutputTokens
|
||||
}
|
||||
@@ -915,9 +935,13 @@ func truncateExecOutput(output string, maxOutputTokens int) (string, bool) {
|
||||
if len(output) <= maxBytes {
|
||||
return output, false
|
||||
}
|
||||
notice := "\n[zero] output truncated\n"
|
||||
if spillPath := spillTruncatedOutput(toolName, output); spillPath != "" {
|
||||
notice = "\n[zero] output truncated — full output saved to " + spillPath + " (grep or read_file it instead of re-running)\n"
|
||||
}
|
||||
head := maxBytes / 2
|
||||
tail := maxBytes - head
|
||||
return utf8Prefix(output, head) + "\n[zero] output truncated\n" + utf8Suffix(output, tail), true
|
||||
return utf8Prefix(output, head) + notice + utf8Suffix(output, tail), true
|
||||
}
|
||||
|
||||
func utf8Prefix(value string, maxBytes int) string {
|
||||
|
||||
@@ -141,6 +141,46 @@ func TestExecCommandRequireEscalatedBypassesNativeSandboxAfterApproval(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval mirrors
|
||||
// TestBashToolRequireEscalatedMsysGuard for exec_command: the MSYS sandbox
|
||||
// guard exists only because MSYS/Cygwin coreutils fail under the
|
||||
// write-restricted sandbox, so once require_escalated is actually approved
|
||||
// (unsandboxed execution), the guard must not block the same command it was
|
||||
// meant to let escalate past.
|
||||
func TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("windows-only MSYS sandbox guard")
|
||||
}
|
||||
root := t.TempDir()
|
||||
manager := newExecSessionManager()
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewScopedExecCommandTool(root, nil, manager))
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
|
||||
})
|
||||
|
||||
result := registry.RunWithOptions(context.Background(), ExecCommandToolName, map[string]any{
|
||||
"cmd": "cat somefile.txt",
|
||||
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
|
||||
}, RunOptions{
|
||||
PermissionGranted: true,
|
||||
Sandbox: engine,
|
||||
PermissionMode: string(sandbox.PermissionModeAsk),
|
||||
})
|
||||
|
||||
// Assert on the preflight block sentinel (exit_code "-1", set only by
|
||||
// shellIssueBlockResult) rather than shell_issue: once the guard is
|
||||
// bypassed, "cat somefile.txt" actually runs, and its real,
|
||||
// PATH-dependent output could otherwise trip the unrelated
|
||||
// post-execution detectShellOutputIssue heuristic and make this
|
||||
// assertion flaky for reasons unrelated to the guard under test.
|
||||
if result.Meta["exit_code"] == "-1" {
|
||||
t.Fatalf("expected approved require_escalated to bypass the MSYS guard, got blocked: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecCommandReturnsExitCodeWhenCommandCompletesDuringInitialYield(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manager := newExecSessionManager()
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Format-on-write for the mutating file tools. When enabled, a successful
|
||||
// edit_file/write_file runs the language's standard formatter on the file it
|
||||
// just wrote, so the model's output always lands in project-canonical style
|
||||
// and never fails a CI format check it cannot see. Off by default (set
|
||||
// ZERO_FORMAT_ON_WRITE=1): auto-reformatting changes bytes the model did not
|
||||
// write, which strict workflows may not want.
|
||||
//
|
||||
// Ordering matters: formatting runs BEFORE the FileTracker re-baseline, and
|
||||
// the caller records the POST-format content. Formatting after the baseline
|
||||
// would make the very next edit look like an external modification and trip
|
||||
// the conflict guard.
|
||||
|
||||
// formatOnWriteTimeout bounds one formatter run; a wedged formatter must never
|
||||
// hang a tool call. On timeout the unformatted write stands.
|
||||
const formatOnWriteTimeout = 10 * time.Second
|
||||
|
||||
// formatterCommands maps a file extension to the formatter argv; the file path
|
||||
// is appended as the final argument. Only in-place, config-respecting,
|
||||
// community-standard formatters — a missing binary silently skips formatting.
|
||||
var formatterCommands = map[string][]string{
|
||||
".go": {"gofmt", "-w"},
|
||||
".rs": {"rustfmt"},
|
||||
".py": {"ruff", "format", "--quiet"},
|
||||
".ts": {"prettier", "--log-level", "silent", "--write"},
|
||||
".tsx": {"prettier", "--log-level", "silent", "--write"},
|
||||
".js": {"prettier", "--log-level", "silent", "--write"},
|
||||
".jsx": {"prettier", "--log-level", "silent", "--write"},
|
||||
".json": {"prettier", "--log-level", "silent", "--write"},
|
||||
".css": {"prettier", "--log-level", "silent", "--write"},
|
||||
".scss": {"prettier", "--log-level", "silent", "--write"},
|
||||
".html": {"prettier", "--log-level", "silent", "--write"},
|
||||
".md": {"prettier", "--log-level", "silent", "--write"},
|
||||
".yaml": {"prettier", "--log-level", "silent", "--write"},
|
||||
".yml": {"prettier", "--log-level", "silent", "--write"},
|
||||
".zig": {"zig", "fmt"},
|
||||
".dart": {"dart", "format"},
|
||||
".tf": {"terraform", "fmt"},
|
||||
".gleam": {"gleam", "format"},
|
||||
".sh": {"shfmt", "-w"},
|
||||
".bash": {"shfmt", "-w"},
|
||||
".c": {"clang-format", "-i"},
|
||||
".h": {"clang-format", "-i"},
|
||||
".cpp": {"clang-format", "-i"},
|
||||
".hpp": {"clang-format", "-i"},
|
||||
".cc": {"clang-format", "-i"},
|
||||
".kt": {"ktlint", "-F"},
|
||||
".swift": {"swiftformat"},
|
||||
".lua": {"stylua"},
|
||||
}
|
||||
|
||||
// formatOnWriteEnabled reports whether the opt-in env toggle is set.
|
||||
func formatOnWriteEnabled() bool {
|
||||
value := strings.TrimSpace(os.Getenv("ZERO_FORMAT_ON_WRITE"))
|
||||
return value != "" && value != "0" && !strings.EqualFold(value, "false")
|
||||
}
|
||||
|
||||
// maybeFormatWrittenFile runs the configured formatter for absolutePath (when
|
||||
// enabled and on PATH) and returns the file's content afterwards. Best-effort
|
||||
// throughout: any failure — no formatter, formatter error, timeout, unreadable
|
||||
// result — returns writtenContent so the caller's state matches the last write
|
||||
// it performed itself.
|
||||
func maybeFormatWrittenFile(ctx context.Context, absolutePath string, writtenContent string) string {
|
||||
if !formatOnWriteEnabled() {
|
||||
return writtenContent
|
||||
}
|
||||
command, ok := formatterCommands[strings.ToLower(filepath.Ext(absolutePath))]
|
||||
if !ok {
|
||||
return writtenContent
|
||||
}
|
||||
if _, err := exec.LookPath(command[0]); err != nil {
|
||||
return writtenContent
|
||||
}
|
||||
formatCtx, cancel := context.WithTimeout(ctx, formatOnWriteTimeout)
|
||||
defer cancel()
|
||||
arguments := append(append([]string(nil), command[1:]...), absolutePath)
|
||||
formatter := exec.CommandContext(formatCtx, command[0], arguments...)
|
||||
formatter.Dir = filepath.Dir(absolutePath)
|
||||
if err := formatter.Run(); err != nil {
|
||||
return writtenContent
|
||||
}
|
||||
formatted, err := os.ReadFile(absolutePath)
|
||||
if err != nil {
|
||||
return writtenContent
|
||||
}
|
||||
return string(formatted)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// gofmt ships with the Go toolchain, so it is the one formatter guaranteed to
|
||||
// exist wherever these tests run.
|
||||
func requireGofmt(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("gofmt"); err != nil {
|
||||
t.Skip("gofmt not on PATH")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOnWriteDisabledByDefault(t *testing.T) {
|
||||
requireGofmt(t)
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "")
|
||||
dir := t.TempDir()
|
||||
ugly := "package a\n\nfunc A( ) { }\n"
|
||||
|
||||
result := NewWriteFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "a.go",
|
||||
"content": ugly,
|
||||
}, RunOptions{})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("write failed: %q", result.Output)
|
||||
}
|
||||
onDisk, err := os.ReadFile(filepath.Join(dir, "a.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(onDisk) != ugly {
|
||||
t.Fatalf("formatting must be off by default, got %q", onDisk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOnWriteFormatsAndKeepsTrackerConsistent(t *testing.T) {
|
||||
requireGofmt(t)
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
|
||||
dir := t.TempDir()
|
||||
tracker := NewFileTracker()
|
||||
|
||||
write := NewWriteFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "a.go",
|
||||
"content": "package a\n\nfunc A( ) { }\n",
|
||||
}, RunOptions{FileTracker: tracker})
|
||||
if write.Status != StatusOK {
|
||||
t.Fatalf("write failed: %q", write.Output)
|
||||
}
|
||||
onDisk, err := os.ReadFile(filepath.Join(dir, "a.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(onDisk), "func A() {") {
|
||||
t.Fatalf("expected gofmt-formatted content, got %q", onDisk)
|
||||
}
|
||||
|
||||
// The tracker must have been re-baselined to the POST-format content: a
|
||||
// follow-up edit must not trip the external-modification conflict guard.
|
||||
edit := NewEditFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "a.go",
|
||||
"old_string": "func A() {",
|
||||
"new_string": "func B() {",
|
||||
}, RunOptions{FileTracker: tracker})
|
||||
if edit.Status != StatusOK {
|
||||
t.Fatalf("follow-up edit must not conflict after formatting: %q", edit.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOnWriteSkipsUnknownExtensions(t *testing.T) {
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
|
||||
content := maybeFormatWrittenFile(context.Background(), filepath.Join(t.TempDir(), "notes.xyz"), "raw text")
|
||||
if content != "raw text" {
|
||||
t.Fatalf("unknown extension must pass through: %q", content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package tools
|
||||
|
||||
import "context"
|
||||
|
||||
// inlineDiagnostics renders the post-write diagnostics block a mutating tool
|
||||
// appends to its output. Empty when no Diagnostics callback is wired, the file
|
||||
// is clean, or no language server is available — the tool output is then
|
||||
// byte-identical to the pre-diagnostics behavior.
|
||||
func inlineDiagnostics(ctx context.Context, options RunOptions, absolutePath, relativePath string) string {
|
||||
if options.Diagnostics == nil {
|
||||
return ""
|
||||
}
|
||||
block := options.Diagnostics(ctx, absolutePath)
|
||||
if block == "" {
|
||||
return ""
|
||||
}
|
||||
return "\n\nDiagnostics in " + relativePath + " after this change (fix any errors you introduced):\n" + block
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A wired Diagnostics callback must surface its block in the tool output for
|
||||
// both mutating tools, and a clean file (empty block) must leave the output
|
||||
// byte-identical to the pre-diagnostics behavior.
|
||||
func TestMutatingToolsAppendInlineDiagnostics(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "a.go"), []byte("package a\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
diagnostics := func(_ context.Context, absPath string) string {
|
||||
return absPath + ":1:1: error: undefined: x"
|
||||
}
|
||||
|
||||
editResult := NewEditFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "a.go",
|
||||
"old_string": "package a",
|
||||
"new_string": "package b",
|
||||
}, RunOptions{Diagnostics: diagnostics})
|
||||
if editResult.Status != StatusOK {
|
||||
t.Fatalf("edit failed: %q", editResult.Output)
|
||||
}
|
||||
if !strings.Contains(editResult.Output, "Diagnostics in a.go after this change") || !strings.Contains(editResult.Output, "undefined: x") {
|
||||
t.Fatalf("edit output missing diagnostics block: %q", editResult.Output)
|
||||
}
|
||||
|
||||
writeResult := NewWriteFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "b.go",
|
||||
"content": "package b\n",
|
||||
}, RunOptions{Diagnostics: diagnostics})
|
||||
if writeResult.Status != StatusOK {
|
||||
t.Fatalf("write failed: %q", writeResult.Output)
|
||||
}
|
||||
if !strings.Contains(writeResult.Output, "Diagnostics in b.go after this change") {
|
||||
t.Fatalf("write output missing diagnostics block: %q", writeResult.Output)
|
||||
}
|
||||
|
||||
clean := NewWriteFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "c.go",
|
||||
"content": "package c\n",
|
||||
}, RunOptions{Diagnostics: func(context.Context, string) string { return "" }})
|
||||
if strings.Contains(clean.Output, "Diagnostics") {
|
||||
t.Fatalf("clean file must not gain a diagnostics block: %q", clean.Output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Universal output ceiling. Every tool result leaving the registry boundary is
|
||||
// capped at a single token-denominated budget unless the tool manages its own
|
||||
// deliberate budget (selfBudgeting below). This is the safety net for tools
|
||||
// with no cap of their own — web_fetch bodies, skill files, MCP server tools,
|
||||
// browser snapshots, anything added later — so no single call can flood the
|
||||
// context window with output that is then re-billed on every following turn.
|
||||
// Truncation keeps head+tail and spills the full text to disk (re-readable via
|
||||
// grep/read_file), so nothing is lost, only deferred.
|
||||
|
||||
// defaultOutputCeilingTokens is the per-call ceiling in estimated tokens
|
||||
// (bytes/4). 16k tokens = 64 KiB — deliberately equal to the search-tool
|
||||
// budget: generous enough for a large fetched document, small enough that one
|
||||
// call cannot eat a third of a small context window.
|
||||
const defaultOutputCeilingTokens = 16_000
|
||||
|
||||
// outputCeilingEnv overrides the ceiling (in tokens). Zero or negative
|
||||
// disables the ceiling entirely; unset or unparsable keeps the default.
|
||||
const outputCeilingEnv = "ZERO_TOOL_OUTPUT_CEILING_TOKENS"
|
||||
|
||||
// selfBudgeting marks a tool that enforces its own deliberate output budget —
|
||||
// possibly model-raisable (exec_command) — which the registry ceiling must not
|
||||
// second-guess. The method is unexported on purpose: only tools in this
|
||||
// package can opt out, so an MCP-served tool can never exempt itself.
|
||||
type selfBudgeting interface{ managesOutputBudget() }
|
||||
|
||||
// The exemption list, kept in one place. Each of these applies its own budget
|
||||
// before returning: bash (bashOutputBudgetBytes per stream + spill),
|
||||
// exec_command (model-raisable token budget + spill), read tools (128 KiB),
|
||||
// search tools (64 KiB).
|
||||
func (bashTool) managesOutputBudget() {}
|
||||
func (execCommandTool) managesOutputBudget() {}
|
||||
func (readFileTool) managesOutputBudget() {}
|
||||
func (readMinifiedFileTool) managesOutputBudget() {}
|
||||
func (grepTool) managesOutputBudget() {}
|
||||
func (globTool) managesOutputBudget() {}
|
||||
func (listDirectoryTool) managesOutputBudget() {}
|
||||
|
||||
func resolveOutputCeilingTokens() int {
|
||||
raw := strings.TrimSpace(os.Getenv(outputCeilingEnv))
|
||||
if raw == "" {
|
||||
return defaultOutputCeilingTokens
|
||||
}
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return defaultOutputCeilingTokens
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// enforceOutputCeiling truncates an over-ceiling result to head+tail within
|
||||
// the token budget, spilling the full (already redaction-scrubbed) output to
|
||||
// disk with a recovery hint. Runs after scrubResultSecrets so the transcript
|
||||
// and the spill file agree on what was hidden.
|
||||
func enforceOutputCeiling(toolName string, result Result) Result {
|
||||
ceiling := resolveOutputCeilingTokens()
|
||||
if ceiling <= 0 {
|
||||
return result
|
||||
}
|
||||
if len(result.Output) <= ceiling*4 {
|
||||
return result
|
||||
}
|
||||
rawBytes := len(result.Output)
|
||||
truncated, _ := truncateExecOutputSpill(result.Output, ceiling, toolName)
|
||||
result.Output = truncated
|
||||
result.Truncated = true
|
||||
if result.Meta == nil {
|
||||
result.Meta = map[string]string{}
|
||||
}
|
||||
result.Meta["raw_bytes"] = strconv.Itoa(rawBytes)
|
||||
result.Meta["emitted_bytes"] = strconv.Itoa(len(result.Output))
|
||||
result.Meta["estimated_tokens"] = strconv.Itoa(estimatedTokensFromBytes(len(result.Output)))
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ceilingFakeTool is a minimal tool with no output budget of its own — the
|
||||
// stand-in for an MCP-served tool or any future builtin that forgets to cap
|
||||
// its output.
|
||||
type ceilingFakeTool struct {
|
||||
baseTool
|
||||
output string
|
||||
}
|
||||
|
||||
func (tool ceilingFakeTool) Run(context.Context, map[string]any) Result {
|
||||
return okResult(tool.output)
|
||||
}
|
||||
|
||||
// ceilingExemptTool is the same fake but marked self-budgeting.
|
||||
type ceilingExemptTool struct{ ceilingFakeTool }
|
||||
|
||||
func (ceilingExemptTool) managesOutputBudget() {}
|
||||
|
||||
func newCeilingFakeTool(name, output string) ceilingFakeTool {
|
||||
return ceilingFakeTool{
|
||||
baseTool: baseTool{name: name, safety: readOnlySafety("test tool")},
|
||||
output: output,
|
||||
}
|
||||
}
|
||||
|
||||
// An unbudgeted tool's oversized output is capped at the universal ceiling,
|
||||
// head and tail survive, and the full output is spilled to a re-readable file.
|
||||
func TestOutputCeilingCapsUnbudgetedTool(t *testing.T) {
|
||||
setTestTempDir(t)
|
||||
big := "HEAD_MARK\n" + strings.Repeat("z", defaultOutputCeilingTokens*4*3) + "\nTAIL_MARK"
|
||||
|
||||
registry := NewRegistry()
|
||||
registry.Register(newCeilingFakeTool("fake_big", big))
|
||||
result := registry.Run(context.Background(), "fake_big", map[string]any{})
|
||||
|
||||
if !result.Truncated {
|
||||
t.Fatal("over-ceiling output must report truncated=true")
|
||||
}
|
||||
maxBytes := defaultOutputCeilingTokens * 4
|
||||
if len(result.Output) > maxBytes+512 {
|
||||
t.Fatalf("emitted %d bytes, ceiling is %d", len(result.Output), maxBytes)
|
||||
}
|
||||
if !strings.Contains(result.Output, "HEAD_MARK") || !strings.Contains(result.Output, "TAIL_MARK") {
|
||||
t.Fatal("head/tail lost at the ceiling")
|
||||
}
|
||||
if result.Meta["raw_bytes"] != strconv.Itoa(len(big)) {
|
||||
t.Fatalf("raw_bytes = %s, want %d", result.Meta["raw_bytes"], len(big))
|
||||
}
|
||||
if !strings.Contains(result.Output, "full output saved to ") {
|
||||
t.Fatalf("ceiling truncation must include a spill hint: %q", result.Output[:200])
|
||||
}
|
||||
start := strings.Index(result.Output, "full output saved to ") + len("full output saved to ")
|
||||
end := strings.Index(result.Output[start:], " (grep")
|
||||
content, err := os.ReadFile(result.Output[start : start+end])
|
||||
if err != nil {
|
||||
t.Fatalf("spill file unreadable: %v", err)
|
||||
}
|
||||
if string(content) != big {
|
||||
t.Fatalf("spill must hold the full output: got %d bytes, want %d", len(content), len(big))
|
||||
}
|
||||
}
|
||||
|
||||
// A self-budgeting tool's output passes the boundary untouched even when it
|
||||
// exceeds the universal ceiling — its own (possibly model-raised) budget wins.
|
||||
func TestOutputCeilingSkipsSelfBudgetingTool(t *testing.T) {
|
||||
big := strings.Repeat("z", defaultOutputCeilingTokens*4*2)
|
||||
registry := NewRegistry()
|
||||
registry.Register(ceilingExemptTool{newCeilingFakeTool("fake_exempt", big)})
|
||||
|
||||
result := registry.Run(context.Background(), "fake_exempt", map[string]any{})
|
||||
if result.Truncated || result.Output != big {
|
||||
t.Fatalf("self-budgeting tool must bypass the ceiling (truncated=%v, %d bytes)", result.Truncated, len(result.Output))
|
||||
}
|
||||
}
|
||||
|
||||
// Under-ceiling output is untouched and gains no budget metadata.
|
||||
func TestOutputCeilingSmallOutputUntouched(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
registry.Register(newCeilingFakeTool("fake_small", "hello"))
|
||||
|
||||
result := registry.Run(context.Background(), "fake_small", map[string]any{})
|
||||
if result.Truncated || result.Output != "hello" {
|
||||
t.Fatalf("small output altered: %+v", result)
|
||||
}
|
||||
if _, ok := result.Meta["raw_bytes"]; ok {
|
||||
t.Fatal("small output must not gain budget metadata")
|
||||
}
|
||||
}
|
||||
|
||||
// ZERO_TOOL_OUTPUT_CEILING_TOKENS tightens, loosens, or disables the ceiling.
|
||||
func TestOutputCeilingEnvOverride(t *testing.T) {
|
||||
setTestTempDir(t)
|
||||
big := strings.Repeat("z", 100*1024)
|
||||
|
||||
t.Setenv(outputCeilingEnv, "1000")
|
||||
registry := NewRegistry()
|
||||
registry.Register(newCeilingFakeTool("fake_env", big))
|
||||
result := registry.Run(context.Background(), "fake_env", map[string]any{})
|
||||
if !result.Truncated || len(result.Output) > 1000*4+512 {
|
||||
t.Fatalf("ceiling override ignored: truncated=%v, %d bytes", result.Truncated, len(result.Output))
|
||||
}
|
||||
|
||||
t.Setenv(outputCeilingEnv, "0")
|
||||
result = registry.Run(context.Background(), "fake_env", map[string]any{})
|
||||
if result.Truncated || len(result.Output) != len(big) {
|
||||
t.Fatalf("ceiling=0 must disable the net: truncated=%v, %d bytes", result.Truncated, len(result.Output))
|
||||
}
|
||||
}
|
||||
|
||||
// The exemption list matches the tools that really do budget themselves, and
|
||||
// the unbudgeted ones (web_fetch, skill, browser) stay under the net.
|
||||
func TestSelfBudgetingExemptionList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
exempt := []Tool{
|
||||
NewBashTool(dir),
|
||||
NewExecCommandTool(dir, newExecSessionManager()),
|
||||
NewReadFileTool(dir),
|
||||
NewReadMinifiedFileTool(dir),
|
||||
NewGrepTool(dir),
|
||||
NewGlobTool(dir),
|
||||
NewListDirectoryTool(dir),
|
||||
}
|
||||
for _, tool := range exempt {
|
||||
if _, ok := tool.(selfBudgeting); !ok {
|
||||
t.Errorf("%s must be exempt from the output ceiling", tool.Name())
|
||||
}
|
||||
}
|
||||
if _, ok := NewWebFetchTool().(selfBudgeting); ok {
|
||||
t.Error("web_fetch must NOT be exempt — the ceiling is its only budget")
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,12 @@ type RunOptions struct {
|
||||
// tool-call progress in the specialist card. nil is a no-op (the default
|
||||
// for every non-Task tool).
|
||||
Progress func(streamjson.Event)
|
||||
// Diagnostics, when set, returns a formatted language-diagnostics block for
|
||||
// a file a mutating tool just wrote ("" when clean or no server available).
|
||||
// edit_file/write_file append it to their output so the model sees an error
|
||||
// it introduced in the same turn instead of waiting for a later verification
|
||||
// pass. nil disables inline diagnostics.
|
||||
Diagnostics func(ctx context.Context, absPath string) string
|
||||
}
|
||||
|
||||
type sandboxAwareTool interface {
|
||||
@@ -117,13 +123,24 @@ func (registry *Registry) Run(ctx context.Context, name string, args map[string]
|
||||
func (registry *Registry) RunWithOptions(ctx context.Context, name string, args map[string]any, options RunOptions) (result Result) {
|
||||
// Every return path passes through scrubResultSecrets exactly once, so denial,
|
||||
// permission, and unknown-tool error messages (which can echo secret-bearing
|
||||
// args/paths) are redacted at the boundary just like tool output.
|
||||
defer func() { result = scrubResultSecrets(result) }()
|
||||
// args/paths) are redacted at the boundary just like tool output. The output
|
||||
// ceiling runs after the scrub so the transcript and the spill file agree on
|
||||
// what was hidden.
|
||||
ceilingExempt := false
|
||||
defer func() {
|
||||
result = scrubResultSecrets(result)
|
||||
if !ceilingExempt {
|
||||
result = enforceOutputCeiling(name, result)
|
||||
}
|
||||
}()
|
||||
|
||||
tool, ok := registry.Get(name)
|
||||
if !ok {
|
||||
return errorResult(`Error: Unknown tool "` + name + `".`)
|
||||
}
|
||||
if _, ok := tool.(selfBudgeting); ok {
|
||||
ceilingExempt = true
|
||||
}
|
||||
if rejecter, ok := tool.(PrePermissionRejecter); ok {
|
||||
if res, rejected := rejecter.RejectBeforePermission(args); rejected {
|
||||
return res
|
||||
|
||||
+179
-21
@@ -3,6 +3,7 @@ package tools
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type shellRuntime struct {
|
||||
@@ -17,14 +18,31 @@ type shellIssue struct {
|
||||
Suggestion string
|
||||
}
|
||||
|
||||
const windowsMsysSandboxKind = "windows_msys_sandbox"
|
||||
|
||||
const windowsMsysSandboxSuggestion = "MSYS/Cygwin coreutils from Git for Windows cannot run under Zero's write-restricted Windows sandbox. Prefer Zero native tools (grep, read_file with offset/limit, list_directory, glob), cmd.exe findstr/more, or PowerShell Select-Object -First/-Last. If host-level execution is truly required, rerun with sandbox_permissions: \"require_escalated\" and a narrow justification."
|
||||
|
||||
// windowsMsysProneNames is the single source of truth for POSIX coreutil names
|
||||
// that commonly resolve to a Git-for-Windows MSYS/Cygwin binary rather than a
|
||||
// cmd.exe-native command, and so fail under the write-restricted Windows
|
||||
// sandbox (#458). Every Windows MSYS-detection path (the preflight command
|
||||
// scan below, the exported MsysProneCommandName, and the known-safe-segment
|
||||
// guard in internal/agent/command_prefix.go) derives from this one set, so
|
||||
// they cannot drift out of sync with each other.
|
||||
var windowsMsysProneNames = map[string]bool{
|
||||
"cat": true, "cut": true, "expr": true, "grep": true, "head": true,
|
||||
"id": true, "ls": true, "nl": true, "paste": true, "rev": true,
|
||||
"seq": true, "stat": true, "tail": true, "tr": true, "uname": true,
|
||||
"uniq": true, "wc": true, "which": true, "awk": true, "sed": true,
|
||||
"xargs": true,
|
||||
}
|
||||
|
||||
var (
|
||||
windowsBashStyleCDPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)cd\s+/(?:[a-ce-z0-9_./~-]|d[a-z0-9_./~-])[a-z0-9_./~-]*`)
|
||||
windowsLSCommandPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)ls\b(?:\s+|$)`)
|
||||
// windowsPosixUtilityPattern catches the other common POSIX coreutils/grep
|
||||
// family commands cmd.exe has no equivalent for (unlike `dir`/`findstr`,
|
||||
// which sound similar enough that a model might reach for the POSIX name
|
||||
// instead) — most often seen piped in, e.g. `git log ... | head`.
|
||||
windowsPosixUtilityPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)(head|tail|grep|wc|awk|sed|cut|xargs|tr)(?:\s+|$)`)
|
||||
// windowsMsysBinaryPathPattern catches explicit Git-for-Windows / MSYS usr\bin
|
||||
// paths. These executables are valid Windows PE files but fail under the
|
||||
// write-restricted sandbox with CreateFileMapping ACCESS_DENIED (#458).
|
||||
windowsMsysBinaryPathPattern = regexp.MustCompile(`(?i)(?:\\usr\\bin\\|\\mingw64\\bin\\|msys-2\.0\.dll|cygwin1\.dll)`)
|
||||
)
|
||||
|
||||
func detectShellRuntime(goos string) shellRuntime {
|
||||
@@ -37,48 +55,162 @@ func detectShellRuntime(goos string) shellRuntime {
|
||||
func shellGuidanceForGOOS(goos string) string {
|
||||
runtime := detectShellRuntime(goos)
|
||||
if goos == "windows" {
|
||||
return "Uses " + runtime.Syntax + " syntax on Windows; prefer cwd over cd when changing directories."
|
||||
return "Uses " + runtime.Syntax + " syntax on Windows; prefer cwd over cd when changing directories. MSYS/Cygwin coreutils on PATH (Git for Windows usr\\bin) are not sandbox-compatible; prefer native Zero file tools."
|
||||
}
|
||||
guidance := "Uses " + runtime.Syntax + " syntax."
|
||||
if goos == "darwin" {
|
||||
// `ps` is setuid root and cannot run under the macOS sandbox; `pgrep` needs a
|
||||
// blocked system service. Point the model at the tools that DO work so it
|
||||
// doesn't waste turns: lsof to find a process, kill to stop it.
|
||||
guidance += " To find or stop a process, use `lsof -i :PORT` (or `lsof -nP -iTCP -sTCP:LISTEN`) for the PID then `kill <pid>`; `ps` and `pgrep` do not work under the sandbox."
|
||||
}
|
||||
return guidance
|
||||
}
|
||||
|
||||
// MsysProneCommandName reports whether a bare command name commonly resolves to
|
||||
// a Git-for-Windows MSYS binary that fails under the Windows restricted sandbox.
|
||||
func MsysProneCommandName(name string) bool {
|
||||
return windowsMsysProneNames[strings.ToLower(strings.TrimSpace(name))]
|
||||
}
|
||||
|
||||
func windowsMsysSandboxIssue(message string) *shellIssue {
|
||||
return &shellIssue{
|
||||
Kind: windowsMsysSandboxKind,
|
||||
Message: message,
|
||||
Suggestion: windowsMsysSandboxSuggestion,
|
||||
}
|
||||
}
|
||||
|
||||
// windowsCommandSegments splits a command into cmd.exe-operator-separated
|
||||
// segments (&, |, and their doubled forms &&/||), respecting double quotes
|
||||
// (cmd.exe's grouping construct) and the caret (^) escape character, so an
|
||||
// operator or command name mentioned inside a quoted argument (e.g. a commit
|
||||
// message or PR comment body), or an operator escaped with ^ (cmd.exe prints
|
||||
// `echo ^| head` as literal text instead of piping to head), is not mistaken
|
||||
// for a real segment boundary or invocation. Unlike bash, cmd.exe does not
|
||||
// treat ; as a statement separator, so it is left as ordinary argument text
|
||||
// (e.g. `echo foo; head` is a single `echo` invocation with literal args).
|
||||
func windowsCommandSegments(command string) []string {
|
||||
var segments []string
|
||||
var current strings.Builder
|
||||
inQuotes := false
|
||||
runes := []rune(command)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
c := runes[i]
|
||||
if !inQuotes && c == '^' && i+1 < len(runes) {
|
||||
current.WriteRune(c)
|
||||
i++
|
||||
current.WriteRune(runes[i])
|
||||
continue
|
||||
}
|
||||
if c == '"' {
|
||||
inQuotes = !inQuotes
|
||||
current.WriteRune(c)
|
||||
continue
|
||||
}
|
||||
if !inQuotes && (c == '&' || c == '|') {
|
||||
if seg := strings.TrimSpace(current.String()); seg != "" {
|
||||
segments = append(segments, seg)
|
||||
}
|
||||
current.Reset()
|
||||
continue
|
||||
}
|
||||
current.WriteRune(c)
|
||||
}
|
||||
if seg := strings.TrimSpace(current.String()); seg != "" {
|
||||
segments = append(segments, seg)
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
// firstCommandWord returns the first token of a command segment. A leading
|
||||
// double-quoted span counts as one token with its quotes stripped, since
|
||||
// cmd.exe treats a quoted path as a single argument: the command invoked by
|
||||
// `"C:\Program Files\Git\usr\bin\head.exe" file.txt` is the quoted path, not
|
||||
// "C:\Program. For an unquoted command, the token ends at whitespace or a
|
||||
// redirection operator (<, >): cmd.exe accepts redirection attached directly
|
||||
// to the command name with no separating space (head>out.txt, cat<in.txt), so
|
||||
// stopping only at whitespace would return "head>out.txt" as one word and
|
||||
// miss the invoked command.
|
||||
func firstCommandWord(segment string) string {
|
||||
trimmed := strings.TrimSpace(segment)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if trimmed[0] == '"' {
|
||||
if end := strings.IndexByte(trimmed[1:], '"'); end >= 0 {
|
||||
return trimmed[1 : end+1]
|
||||
}
|
||||
return trimmed[1:]
|
||||
}
|
||||
if end := strings.IndexFunc(trimmed, isCommandWordBoundary); end >= 0 {
|
||||
return trimmed[:end]
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func isCommandWordBoundary(r rune) bool {
|
||||
return unicode.IsSpace(r) || r == '<' || r == '>'
|
||||
}
|
||||
|
||||
// msysProneCommandWord reports whether word (the first token of a command
|
||||
// segment, as returned by firstCommandWord) names an MSYS-prone coreutil,
|
||||
// bare or with a directory prefix and/or .exe suffix (head, head.exe,
|
||||
// C:\...\usr\bin\head.exe, ...).
|
||||
func msysProneCommandWord(word string) bool {
|
||||
word = strings.Trim(word, `"`)
|
||||
if i := strings.LastIndexAny(word, `\/`); i >= 0 {
|
||||
word = word[i+1:]
|
||||
}
|
||||
word = strings.TrimSuffix(strings.ToLower(word), ".exe")
|
||||
return MsysProneCommandName(word)
|
||||
}
|
||||
|
||||
func detectShellCommandIssue(command string, goos string) *shellIssue {
|
||||
if goos != "windows" {
|
||||
return nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(command)
|
||||
if windowsBashStyleCDPattern.MatchString(trimmed) ||
|
||||
windowsLSCommandPattern.MatchString(trimmed) {
|
||||
if windowsBashStyleCDPattern.MatchString(trimmed) {
|
||||
return &shellIssue{
|
||||
Kind: "windows_shell_syntax",
|
||||
Message: "Command looks like POSIX/Bash syntax, but Zero runs bash tool commands through Windows cmd.exe on this host.",
|
||||
Suggestion: "Use the cwd argument instead of cd, use Windows cmd.exe syntax, or use native tools such as list_directory, read_file, grep, and glob.",
|
||||
}
|
||||
}
|
||||
if windowsPosixUtilityPattern.MatchString(trimmed) {
|
||||
return &shellIssue{
|
||||
Kind: "windows_shell_syntax",
|
||||
Message: "Command uses a POSIX utility (head/tail/grep/wc/awk/sed/cut/xargs/tr) that Windows cmd.exe does not have.",
|
||||
Suggestion: "Use cmd.exe equivalents (e.g. `findstr` for grep, `more` to page output) or Zero's native tools (grep, read_file with offset/limit) instead of piping to a POSIX utility.",
|
||||
// Check the first word of each operator-separated segment (not the raw
|
||||
// text anywhere in the command) against the MSYS binary-path pattern and
|
||||
// the single MSYS-prone name set, covering bare names (head), .exe names
|
||||
// (head.exe), and directory-prefixed forms (C:\...\head.exe) uniformly.
|
||||
// Being segment/word anchored rather than a whole-string regex or scan,
|
||||
// neither check matches text that only appears inside a quoted argument
|
||||
// (e.g. a commit message or PR comment body discussing head.exe).
|
||||
for _, segment := range windowsCommandSegments(trimmed) {
|
||||
word := firstCommandWord(segment)
|
||||
if windowsMsysBinaryPathPattern.MatchString(word) {
|
||||
return windowsMsysSandboxIssue("Command invokes an MSYS/Cygwin binary path that cannot run under Zero's Windows sandbox.")
|
||||
}
|
||||
if msysProneCommandWord(word) {
|
||||
return windowsMsysSandboxIssue("Command uses a POSIX coreutil (head/tail/grep/cat/...) that commonly resolves to Git-for-Windows MSYS binaries incompatible with the Windows sandbox.")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func detectShellOutputIssue(command string, output string, goos string) *shellIssue {
|
||||
// detectShellOutputIssue looks for MSYS runtime crash markers and cmd.exe
|
||||
// syntax-error text in output only, never in the command that was run. The
|
||||
// command line is attacker/user-controlled argument text (e.g. a `gh pr
|
||||
// comment --body` quoting a sample failure), not something the shell
|
||||
// produced, so treating it as evidence would reintroduce the same
|
||||
// quoted-text false positives the preflight command-position check exists to
|
||||
// avoid, just after execution instead of before it.
|
||||
func detectShellOutputIssue(output string, goos string) *shellIssue {
|
||||
if goos != "windows" {
|
||||
return nil
|
||||
}
|
||||
haystack := strings.ToLower(command + "\n" + output)
|
||||
if strings.Contains(haystack, "the syntax of the command is incorrect") ||
|
||||
strings.Contains(haystack, "is not recognized as an internal or external command") {
|
||||
lower := strings.ToLower(output)
|
||||
if msysRuntimeFailedInOutput(lower) {
|
||||
return windowsMsysSandboxIssue("An MSYS/Cygwin runtime failed under Zero's Windows sandbox (ACCESS_DENIED during MSYS startup).")
|
||||
}
|
||||
if strings.Contains(lower, "the syntax of the command is incorrect") ||
|
||||
strings.Contains(lower, "is not recognized as an internal or external command") {
|
||||
return &shellIssue{
|
||||
Kind: "windows_shell_syntax",
|
||||
Message: "Windows cmd.exe rejected the command syntax.",
|
||||
@@ -88,6 +220,32 @@ func detectShellOutputIssue(command string, output string, goos string) *shellIs
|
||||
return nil
|
||||
}
|
||||
|
||||
func msysRuntimeFailedInOutput(lower string) bool {
|
||||
if strings.Contains(lower, "fatal error - createfilemapping") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, "couldn't create signal pipe") && strings.Contains(lower, "win32 error 5") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, "cygheap_user::init") && strings.Contains(lower, "fatal error") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, "usr\\bin\\") && strings.Contains(lower, "fatal error") {
|
||||
return true
|
||||
}
|
||||
if !strings.Contains(lower, "win32 error 5") || !strings.Contains(lower, "terminating") {
|
||||
return false
|
||||
}
|
||||
// Anchor the broad win32-error-5 fallback to an MSYS-specific marker so
|
||||
// unrelated access-denied failures are not mislabeled as MSYS sandbox
|
||||
// incompatibilities.
|
||||
return strings.Contains(lower, `usr\bin\`) ||
|
||||
strings.Contains(lower, "cygheap") ||
|
||||
strings.Contains(lower, "msys-2.0.dll") ||
|
||||
strings.Contains(lower, "cygwin1.dll") ||
|
||||
strings.Contains(lower, "[main]")
|
||||
}
|
||||
|
||||
func appendShellIssueHint(output string, issue shellIssue) string {
|
||||
output = strings.TrimRight(output, "\r\n")
|
||||
hint := "[zero] shell issue: " + issue.Message
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectShellCommandIssueFlagsMsysBinaryPaths(t *testing.T) {
|
||||
for _, command := range []string{
|
||||
`for /F %i in ('whoami') do echo %i | "C:\Program Files\Git\usr\bin\head.exe" -1`,
|
||||
`C:\Git\usr\bin\grep.exe pattern file.txt`,
|
||||
} {
|
||||
issue := detectShellCommandIssue(command, "windows")
|
||||
if issue == nil {
|
||||
t.Fatalf("expected MSYS path issue for %q", command)
|
||||
}
|
||||
if issue.Kind != "windows_msys_sandbox" {
|
||||
t.Fatalf("expected windows_msys_sandbox kind, got %q", issue.Kind)
|
||||
}
|
||||
if !strings.Contains(issue.Suggestion, "require_escalated") {
|
||||
t.Fatalf("expected escalation guidance, got %#v", issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShellCommandIssueFlagsStandaloneCat(t *testing.T) {
|
||||
issue := detectShellCommandIssue(`cat README.md`, "windows")
|
||||
if issue == nil || issue.Kind != "windows_msys_sandbox" {
|
||||
t.Fatalf("expected MSYS sandbox issue for cat, got %#v", issue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShellOutputIssueFlagsMsysCreateFileMappingError(t *testing.T) {
|
||||
output := `0 [main] head (3568) C:\Program Files\Git\usr\bin\head.exe: *** fatal error - CreateFileMapping S-1-5-21-3149109338-1484423945-518236903-1001.1, Win32 error 5. Terminating.`
|
||||
issue := detectShellOutputIssue(output, "windows")
|
||||
if issue == nil || issue.Kind != "windows_msys_sandbox" {
|
||||
t.Fatalf("expected MSYS output issue, got %#v", issue)
|
||||
}
|
||||
if !strings.Contains(issue.Suggestion, "require_escalated") {
|
||||
t.Fatalf("expected escalation guidance, got %#v", issue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShellOutputIssueFlagsMsysSignalPipeError(t *testing.T) {
|
||||
output := `0 [main] head (39684) cygheap_user::init: NtSetInformationToken (TokenDefaultDacl), 0xC0000022
|
||||
648 [main] head (39684) C:\Program Files\Git\usr\bin\head.exe: *** fatal error - couldn't create signal pipe, Win32 error 5`
|
||||
issue := detectShellOutputIssue(output, "windows")
|
||||
if issue == nil || issue.Kind != "windows_msys_sandbox" {
|
||||
t.Fatalf("expected MSYS output issue, got %#v", issue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShellOutputIssueFlagsMsysTerminatingWithMsysMarker(t *testing.T) {
|
||||
output := `1 [main] tail (4321) tail: *** MapViewOfFileEx failed, Win32 error 5. Terminating.`
|
||||
issue := detectShellOutputIssue(output, "windows")
|
||||
if issue == nil || issue.Kind != "windows_msys_sandbox" {
|
||||
t.Fatalf("expected MSYS output issue, got %#v", issue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShellOutputIssueIgnoresNonMsysWin32Error5(t *testing.T) {
|
||||
output := `myapp.exe: unable to open service handle, Win32 error 5 (access denied). Terminating worker.`
|
||||
issue := detectShellOutputIssue(output, "windows")
|
||||
if issue != nil {
|
||||
t.Fatalf("expected no issue for non-MSYS access-denied output, got %#v", issue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellIssueBlockResultMsysCommand(t *testing.T) {
|
||||
result := shellIssueBlockResult(*detectShellCommandIssue(`cat README.md`, "windows"))
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("status = %q, want error", result.Status)
|
||||
}
|
||||
for _, want := range []string{"[zero] shell issue:", "MSYS/Cygwin", "grep", "read_file", "require_escalated"} {
|
||||
if !strings.Contains(result.Output, want) {
|
||||
t.Fatalf("expected %q in blocked output, got %q", want, result.Output)
|
||||
}
|
||||
}
|
||||
if result.Meta["shell_issue"] != "windows_msys_sandbox" {
|
||||
t.Fatalf("meta shell_issue = %q", result.Meta["shell_issue"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsysProneCommandName(t *testing.T) {
|
||||
if !MsysProneCommandName("HEAD") || MsysProneCommandName("echo") {
|
||||
t.Fatalf("unexpected MsysProneCommandName results")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectShellCommandIssueFlagsExprAndLsConsistently guards against the
|
||||
// preflight regex list drifting from the canonical windowsMsysProneNames set
|
||||
// (both listed expr and ls as MSYS-prone, but the old regex alternations
|
||||
// omitted expr entirely and let ls hit the older windows_shell_syntax branch
|
||||
// first, so it never got MSYS-kind guidance).
|
||||
func TestDetectShellCommandIssueFlagsExprAndLsConsistently(t *testing.T) {
|
||||
for _, command := range []string{
|
||||
`expr 1 + 1`,
|
||||
`expr.exe 1 + 1`,
|
||||
`ls -la`,
|
||||
`ls`,
|
||||
} {
|
||||
issue := detectShellCommandIssue(command, "windows")
|
||||
if issue == nil || issue.Kind != windowsMsysSandboxKind {
|
||||
t.Fatalf("expected windows_msys_sandbox for %q, got %#v", command, issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectShellCommandIssueIgnoresQuotedMsysMentions guards against
|
||||
// treating an MSYS-prone name that only appears inside a quoted argument
|
||||
// (e.g. a commit message, a PR comment body, or a doc string discussing the
|
||||
// command) as an actual invocation. The preflight check must anchor on the
|
||||
// first word of each command segment, not scan the raw text anywhere.
|
||||
func TestDetectShellCommandIssueIgnoresQuotedMsysMentions(t *testing.T) {
|
||||
for _, command := range []string{
|
||||
`git commit -m "fix head.exe crash"`,
|
||||
`gh pr comment --body "grep.exe fails under MSYS"`,
|
||||
`echo "log | head is broken on windows"`,
|
||||
`git commit -m "note: | head does not work here"`,
|
||||
} {
|
||||
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
|
||||
t.Fatalf("expected quoted MSYS mention to pass for %q, got %#v", command, issue)
|
||||
}
|
||||
}
|
||||
|
||||
// A real invocation alongside quoted text must still be caught.
|
||||
if issue := detectShellCommandIssue(`echo "not a real command" && head file.txt`, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
|
||||
t.Fatalf("expected real head invocation to still be flagged, got %#v", issue)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectShellCommandIssueIgnoresQuotedMsysPathMentions guards the
|
||||
// explicit MSYS-binary-path check the same way as the coreutil-name check:
|
||||
// a full usr\bin\ path that only appears inside a quoted argument (e.g. a
|
||||
// commit message describing the failure) must not be treated as an
|
||||
// invocation, since the path check is now anchored to the first word of each
|
||||
// command segment rather than scanned across the raw command text.
|
||||
func TestDetectShellCommandIssueIgnoresQuotedMsysPathMentions(t *testing.T) {
|
||||
for _, command := range []string{
|
||||
`git commit -m "C:\Program Files\Git\usr\bin\head.exe fails"`,
|
||||
`gh pr comment --body "C:\Git\usr\bin\grep.exe is blocked"`,
|
||||
} {
|
||||
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
|
||||
t.Fatalf("expected quoted MSYS path mention to pass for %q, got %#v", command, issue)
|
||||
}
|
||||
}
|
||||
|
||||
// A real invocation by full path must still be caught.
|
||||
if issue := detectShellCommandIssue(`C:\Git\usr\bin\grep.exe pattern file.txt`, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
|
||||
t.Fatalf("expected real MSYS path invocation to still be flagged, got %#v", issue)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectShellCommandIssueRespectsCaretEscapedOperators guards against
|
||||
// misreading cmd.exe's ^ escape character: `echo ^| head` prints the pipe and
|
||||
// "head" as literal text (the caret escapes the pipe so it never splits into
|
||||
// a separate `head` invocation), and `echo foo; head` is a single `echo`
|
||||
// command with literal arguments since cmd.exe (unlike bash) does not treat
|
||||
// ; as a statement separator.
|
||||
func TestDetectShellCommandIssueRespectsCaretEscapedOperators(t *testing.T) {
|
||||
for _, command := range []string{
|
||||
`echo ^| head`,
|
||||
`echo ^& head`,
|
||||
`echo foo; head`,
|
||||
} {
|
||||
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
|
||||
t.Fatalf("expected no issue for %q, got %#v", command, issue)
|
||||
}
|
||||
}
|
||||
|
||||
// An unescaped pipe must still split into a real head invocation.
|
||||
if issue := detectShellCommandIssue(`echo foo | head`, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
|
||||
t.Fatalf("expected real head invocation to still be flagged, got %#v", issue)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectShellCommandIssueFlagsRedirectionAttachedToCommand guards against
|
||||
// firstCommandWord treating cmd.exe redirection operators as part of the
|
||||
// command name. cmd.exe accepts redirection with no separating space
|
||||
// (head>out.txt, cat<in.txt), so splitting only on whitespace would return
|
||||
// "head>out.txt" as one word and miss the invoked command entirely.
|
||||
func TestDetectShellCommandIssueFlagsRedirectionAttachedToCommand(t *testing.T) {
|
||||
for _, command := range []string{
|
||||
`some-command | head>out.txt`,
|
||||
`cat<in.txt`,
|
||||
`grep>matches.txt pattern`,
|
||||
} {
|
||||
issue := detectShellCommandIssue(command, "windows")
|
||||
if issue == nil || issue.Kind != windowsMsysSandboxKind {
|
||||
t.Fatalf("expected windows_msys_sandbox for %q, got %#v", command, issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectShellOutputIssueSignatureOmitsCommandText documents, at the type
|
||||
// level, that detectShellOutputIssue can no longer take the command line as
|
||||
// evidence: it only accepts the real output. Harmless output must not be
|
||||
// flagged, and output that genuinely carries the MSYS failure markers must
|
||||
// still be flagged.
|
||||
func TestDetectShellOutputIssueSignatureOmitsCommandText(t *testing.T) {
|
||||
if issue := detectShellOutputIssue("hello from bash", "windows"); issue != nil {
|
||||
t.Fatalf("expected no issue for harmless output, got %#v", issue)
|
||||
}
|
||||
output := `0 [main] head (3568) C:\Program Files\Git\usr\bin\head.exe: *** fatal error - CreateFileMapping ..., Win32 error 5. Terminating.`
|
||||
if issue := detectShellOutputIssue(output, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
|
||||
t.Fatalf("expected real MSYS output to still be flagged, got %#v", issue)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/secrets"
|
||||
)
|
||||
|
||||
// Spill-to-disk for truncated tool output. When a command produces more than
|
||||
// its context budget, the tail was previously simply gone — the model's only
|
||||
// recourse was re-running the (possibly expensive or non-idempotent) command
|
||||
// with a bigger budget. Instead, the full output is written to a cache file
|
||||
// whose path is included in the truncation notice, so the model can grep or
|
||||
// read_file the remainder. Spilling is best-effort: on any error truncation
|
||||
// behaves exactly as before, just without the file hint.
|
||||
|
||||
// spillRetention is how long spilled outputs are kept; files older than this
|
||||
// are opportunistically removed whenever a new spill happens.
|
||||
const spillRetention = 7 * 24 * time.Hour
|
||||
|
||||
// spillRootPath returns the spill directory path without creating or checking
|
||||
// it. Used by the read-path resolver to recognize spill files as readable.
|
||||
func spillRootPath() string {
|
||||
name := "zero-tool-output"
|
||||
if uid := os.Getuid(); uid >= 0 {
|
||||
name = fmt.Sprintf("zero-tool-output-%d", uid)
|
||||
}
|
||||
return filepath.Join(os.TempDir(), name)
|
||||
}
|
||||
|
||||
// resolveSpillReadPath reports whether requestedPath is a file inside the
|
||||
// spill directory and, if so, returns its verified absolute path. Spill files
|
||||
// are zero-created, per-uid owned, and already secret-redacted, so letting the
|
||||
// scoped read tools open them is what makes the truncation notice's
|
||||
// "read_file it" recovery actually work. Symlinks are resolved and the result
|
||||
// must still be inside the spill dir, so a planted link cannot smuggle an
|
||||
// out-of-scope file through this gate.
|
||||
func resolveSpillReadPath(requestedPath string) (string, bool) {
|
||||
if requestedPath == "" || !filepath.IsAbs(requestedPath) {
|
||||
return "", false
|
||||
}
|
||||
root := spillRootPath()
|
||||
cleaned := filepath.Clean(requestedPath)
|
||||
if cleaned == root || !strings.HasPrefix(cleaned, root+string(filepath.Separator)) {
|
||||
return "", false
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(cleaned)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
resolvedRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if !strings.HasPrefix(resolved, resolvedRoot+string(filepath.Separator)) {
|
||||
return "", false
|
||||
}
|
||||
return resolved, true
|
||||
}
|
||||
|
||||
// spillDir returns the per-user spill directory, creating it on first use.
|
||||
// Hardened for shared temp dirs (Linux /tmp): the name carries the uid so
|
||||
// users cannot collide, and a pre-existing path is only accepted when it is a
|
||||
// real directory (not a symlink that would redirect spills) owned by the
|
||||
// current user. Any doubt fails the spill — it is best-effort anyway.
|
||||
func spillDir() (string, error) {
|
||||
dir := spillRootPath()
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// MkdirAll follows symlinks and leaves an existing directory untouched, so
|
||||
// verify what is actually at the path.
|
||||
info, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("spill path %s is not a directory", dir)
|
||||
}
|
||||
if err := checkSpillDirOwner(info); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// spillTruncatedOutput writes the full pre-truncation output to the spill
|
||||
// directory and returns the file path, or "" when spilling fails. Output is
|
||||
// scrubbed with the same configured-key redaction the registry applies at the
|
||||
// tool boundary PLUS the pattern-based secret scanner (AWS keys, tokens, PEM
|
||||
// blocks, JWTs) that bash applies to its model-visible output — a spill runs
|
||||
// before that formatter, so without the scan here a spilled file would hold
|
||||
// pattern-matched credentials in cleartext that the transcript hides.
|
||||
func spillTruncatedOutput(toolName, output string) string {
|
||||
dir, err := spillDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sweepSpillDir(dir)
|
||||
prefix := strings.Map(func(r rune) rune {
|
||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-' {
|
||||
return r
|
||||
}
|
||||
return '-'
|
||||
}, toolName)
|
||||
file, err := os.CreateTemp(dir, prefix+"-*.txt")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer file.Close()
|
||||
scrubbed := redaction.RedactString(output, redaction.Options{})
|
||||
scrubbed, _ = secrets.Redact(scrubbed)
|
||||
if _, err := file.WriteString(scrubbed); err != nil {
|
||||
_ = os.Remove(file.Name())
|
||||
return ""
|
||||
}
|
||||
return file.Name()
|
||||
}
|
||||
|
||||
// sweepSpillDir removes spill files older than spillRetention. Best-effort:
|
||||
// errors are ignored, the directory is small, and a sweep runs only when a new
|
||||
// spill is about to happen anyway.
|
||||
func sweepSpillDir(dir string) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Add(-spillRetention)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.ModTime().Before(cutoff) {
|
||||
_ = os.Remove(filepath.Join(dir, entry.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func spillDirName() string {
|
||||
if uid := os.Getuid(); uid >= 0 {
|
||||
return fmt.Sprintf("zero-tool-output-%d", uid)
|
||||
}
|
||||
return "zero-tool-output"
|
||||
}
|
||||
|
||||
func TestSpillDirRejectsSymlink(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation needs privileges on Windows")
|
||||
}
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("TMPDIR", tmp)
|
||||
elsewhere := filepath.Join(tmp, "elsewhere")
|
||||
if err := os.Mkdir(elsewhere, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// An attacker pre-creates the spill path as a symlink to redirect spills.
|
||||
if err := os.Symlink(elsewhere, filepath.Join(tmp, spillDirName())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path := spillTruncatedOutput("bash", "sensitive output"); path != "" {
|
||||
t.Fatalf("spill must refuse a symlinked directory, wrote %s", path)
|
||||
}
|
||||
entries, _ := os.ReadDir(elsewhere)
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("nothing may land behind the symlink: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpillDirAcceptsOwnedDirectory(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
if path := spillTruncatedOutput("bash", "ok"); path == "" {
|
||||
t.Fatal("spill must work in a clean per-user temp dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSpillReadPathContainment(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
spillPath := spillTruncatedOutput("exec_command", "spilled body")
|
||||
if spillPath == "" {
|
||||
t.Fatal("spill must succeed in a clean temp dir")
|
||||
}
|
||||
resolved, ok := resolveSpillReadPath(spillPath)
|
||||
if !ok || resolved == "" {
|
||||
t.Fatalf("a real spill file must resolve, got ok=%v", ok)
|
||||
}
|
||||
if _, ok := resolveSpillReadPath(filepath.Join(spillRootPath(), "..", "escape.txt")); ok {
|
||||
t.Fatal("path traversal out of the spill dir must be rejected")
|
||||
}
|
||||
if _, ok := resolveSpillReadPath("/etc/hosts"); ok {
|
||||
t.Fatal("paths outside the spill dir must be rejected")
|
||||
}
|
||||
if _, ok := resolveSpillReadPath(spillRootPath()); ok {
|
||||
t.Fatal("the spill dir itself is not a readable file target")
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
// A symlink planted inside the spill dir pointing outside must not leak.
|
||||
link := filepath.Join(spillRootPath(), "sneaky-link")
|
||||
if err := os.Symlink("/etc/hosts", link); err == nil {
|
||||
if _, ok := resolveSpillReadPath(link); ok {
|
||||
t.Fatal("symlink escaping the spill dir must be rejected")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileCanReadSpillFile(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
spillPath := spillTruncatedOutput("exec_command", "line one\nline two\n")
|
||||
if spillPath == "" {
|
||||
t.Fatal("spill must succeed")
|
||||
}
|
||||
workspace := t.TempDir()
|
||||
result := NewReadFileTool(workspace).Run(context.Background(), map[string]any{"path": spillPath})
|
||||
if result.Status != StatusOK || !strings.Contains(result.Output, "line two") {
|
||||
t.Fatalf("read_file must be able to follow the truncation notice: %s %q", result.Status, result.Output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//go:build !windows
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// checkSpillDirOwner rejects a spill directory not owned by the current user:
|
||||
// on a shared /tmp another user could have pre-created the path and would then
|
||||
// control its lifetime (deletion/renaming) even though the 0600 spill files
|
||||
// keep their contents private.
|
||||
func checkSpillDirOwner(info os.FileInfo) error {
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if int(stat.Uid) != os.Geteuid() {
|
||||
return fmt.Errorf("spill directory is owned by uid %d, not the current user", stat.Uid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build windows
|
||||
|
||||
package tools
|
||||
|
||||
import "os"
|
||||
|
||||
// checkSpillDirOwner is a no-op on Windows: %TEMP% is per-user by default and
|
||||
// there is no portable uid to compare. The Lstat symlink/dir check in spillDir
|
||||
// still applies.
|
||||
func checkSpillDirOwner(os.FileInfo) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTruncateExecOutputSpillWritesFullOutput(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
long := strings.Repeat("line of build output\n", 5000) // ~100KB > 40KB budget
|
||||
|
||||
truncated, wasTruncated := truncateExecOutputSpill(long, defaultMaxOutputTokens, "bash")
|
||||
if !wasTruncated {
|
||||
t.Fatal("output over budget must truncate")
|
||||
}
|
||||
if !strings.Contains(truncated, "full output saved to ") {
|
||||
t.Fatalf("truncation notice missing spill path: %q", truncated[:200])
|
||||
}
|
||||
// Extract the path and verify the file holds the complete output.
|
||||
start := strings.Index(truncated, "full output saved to ") + len("full output saved to ")
|
||||
end := strings.Index(truncated[start:], " (grep")
|
||||
spillPath := truncated[start : start+end]
|
||||
content, err := os.ReadFile(spillPath)
|
||||
if err != nil {
|
||||
t.Fatalf("spill file unreadable: %v", err)
|
||||
}
|
||||
if string(content) != long {
|
||||
t.Fatalf("spill file must hold the full output: got %d bytes, want %d", len(content), len(long))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateExecOutputUnderBudgetUnchanged(t *testing.T) {
|
||||
output, wasTruncated := truncateExecOutputSpill("short output", defaultMaxOutputTokens, "bash")
|
||||
if wasTruncated || output != "short output" {
|
||||
t.Fatalf("under-budget output must pass through untouched: %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweepSpillDirRemovesOnlyOldFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldFile := filepath.Join(dir, "bash-old.txt")
|
||||
newFile := filepath.Join(dir, "bash-new.txt")
|
||||
for _, path := range []string{oldFile, newFile} {
|
||||
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
stale := time.Now().Add(-spillRetention - time.Hour)
|
||||
if err := os.Chtimes(oldFile, stale, stale); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sweepSpillDir(dir)
|
||||
|
||||
if _, err := os.Stat(oldFile); !os.IsNotExist(err) {
|
||||
t.Fatal("stale spill file must be removed")
|
||||
}
|
||||
if _, err := os.Stat(newFile); err != nil {
|
||||
t.Fatal("fresh spill file must survive the sweep")
|
||||
}
|
||||
}
|
||||
|
||||
// A spill happens BEFORE bash's model-facing formatter runs its pattern-based
|
||||
// secret scan, so the spill itself must scrub pattern-matched credentials —
|
||||
// otherwise the file would hold in cleartext exactly what the transcript hides.
|
||||
func TestSpillTruncatedOutputScrubsPatternSecrets(t *testing.T) {
|
||||
setTestTempDir(t)
|
||||
githubToken := "ghp_" + strings.Repeat("a", 36)
|
||||
body := "before\nAKIAIOSFODNN7EXAMPLE\n" + githubToken + "\nafter"
|
||||
|
||||
path := spillTruncatedOutput("bash", body)
|
||||
if path == "" {
|
||||
t.Fatal("spill must return a file path")
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(content), "AKIAIOSFODNN7EXAMPLE") {
|
||||
t.Fatal("AWS access key reached the spill file in cleartext")
|
||||
}
|
||||
if strings.Contains(string(content), githubToken) {
|
||||
t.Fatal("GitHub token reached the spill file in cleartext")
|
||||
}
|
||||
if !strings.Contains(string(content), "before") || !strings.Contains(string(content), "after") {
|
||||
t.Fatalf("non-secret content must survive the scrub: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpillTruncatedOutputWritesFile(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
path := spillTruncatedOutput("exec_command", "some output body")
|
||||
if path == "" {
|
||||
t.Fatal("spill must return a file path")
|
||||
}
|
||||
if base := filepath.Base(path); !strings.HasPrefix(base, "exec_command-") {
|
||||
t.Fatalf("spill file name must carry the tool prefix: %s", base)
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "some output body" {
|
||||
t.Fatalf("unexpected spill content: %q", content)
|
||||
}
|
||||
}
|
||||
+48
-10
@@ -18,12 +18,17 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWebFetchMaxBytes = 64 * 1024
|
||||
maxWebFetchMaxBytes = 512 * 1024
|
||||
webFetchTimeout = 10 * time.Second
|
||||
// Raw-body budgets. HTML responses are converted to markdown before they
|
||||
// reach the model (see web_fetch_markdown.go), so a generous raw budget
|
||||
// does not translate into a generous context cost — conversion typically
|
||||
// shrinks a page by an order of magnitude. 64KiB of raw HTML often held
|
||||
// nothing but a page's <head>, which starved research tasks.
|
||||
defaultWebFetchMaxBytes = 256 * 1024
|
||||
maxWebFetchMaxBytes = 2 * 1024 * 1024
|
||||
webFetchTimeout = 30 * time.Second
|
||||
webFetchRedirectLimit = 5
|
||||
webFetchErrorBodyLimit = 4 * 1024
|
||||
webFetchPublicOnlyHint = "web_fetch only supports public remote HTTP/HTTPS URLs. For localhost or private network URLs, use bash with curl so sandbox network permission can apply."
|
||||
webFetchPublicOnlyHint = "web_fetch only supports public remote HTTP/HTTPS URLs. For localhost or private network URLs, use bash with curl so sandbox network permission can apply"
|
||||
)
|
||||
|
||||
type webFetchTool struct {
|
||||
@@ -115,11 +120,17 @@ func newWebFetchToolWithClientAndResolver(client *http.Client, resolver webFetch
|
||||
},
|
||||
"max_bytes": {
|
||||
Type: "integer",
|
||||
Description: "Maximum response body bytes to return.",
|
||||
Description: "Maximum raw response body bytes to download before conversion.",
|
||||
Default: defaultWebFetchMaxBytes,
|
||||
Minimum: intPtr(1),
|
||||
Maximum: intPtr(maxWebFetchMaxBytes),
|
||||
},
|
||||
"format": {
|
||||
Type: "string",
|
||||
Description: "auto (default): HTML responses are converted to compact markdown, everything else is returned as-is. raw: never convert. markdown: force conversion.",
|
||||
Enum: []string{"auto", "raw", "markdown"},
|
||||
Default: "auto",
|
||||
},
|
||||
},
|
||||
Required: []string{"url"},
|
||||
AdditionalProperties: false,
|
||||
@@ -170,6 +181,18 @@ func (tool webFetchTool) run(ctx context.Context, args map[string]any) Result {
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for web_fetch: " + err.Error())
|
||||
}
|
||||
format, err := stringArg(args, "format", "auto", false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for web_fetch: " + err.Error())
|
||||
}
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
switch format {
|
||||
case "", "auto":
|
||||
format = "auto"
|
||||
case "raw", "markdown":
|
||||
default:
|
||||
return errorResult(`Error: Invalid arguments for web_fetch: format must be "auto", "raw", or "markdown".`)
|
||||
}
|
||||
if err := validateWebFetchURLBeforePermission(rawURL); err != nil {
|
||||
return errorResult("Error: Unsafe URL for web_fetch: " + err.Error())
|
||||
}
|
||||
@@ -224,14 +247,28 @@ func (tool webFetchTool) run(ctx context.Context, args map[string]any) Result {
|
||||
}
|
||||
body = redactWebFetchText(body)
|
||||
|
||||
output := strings.Join([]string{
|
||||
// HTML responses are converted to compact markdown by default: raw HTML is
|
||||
// mostly markup, so conversion typically shrinks the page by an order of
|
||||
// magnitude and the model reads content instead of boilerplate. format=raw
|
||||
// is the escape hatch for pages the converter mangles.
|
||||
converted := false
|
||||
if format == "markdown" || (format == "auto" && looksLikeHTML(contentType, body)) {
|
||||
if markdown := htmlToMarkdown(body); markdown != "" {
|
||||
body = markdown
|
||||
converted = true
|
||||
}
|
||||
}
|
||||
|
||||
headers := []string{
|
||||
"URL: " + finalURL,
|
||||
"Status: " + webFetchStatusLine(response),
|
||||
"Content-Type: " + firstNonEmptyString(contentType, "unknown"),
|
||||
"Bytes: " + strconv.Itoa(len(body)),
|
||||
"",
|
||||
body,
|
||||
}, "\n")
|
||||
}
|
||||
if converted {
|
||||
headers = append(headers, "Converted: html -> markdown (pass format: \"raw\" for the original HTML)")
|
||||
}
|
||||
output := strings.Join(append(headers, "", body), "\n")
|
||||
|
||||
return Result{
|
||||
Status: StatusOK,
|
||||
@@ -243,6 +280,7 @@ func (tool webFetchTool) run(ctx context.Context, args map[string]any) Result {
|
||||
"content_type": contentType,
|
||||
"bytes": strconv.Itoa(len(body)),
|
||||
"truncated": strconv.FormatBool(truncated),
|
||||
"converted": strconv.FormatBool(converted),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -310,7 +348,7 @@ func webFetchRedirectPolicy(previous func(*http.Request, []*http.Request) error,
|
||||
return fmt.Errorf("too many redirects: maximum is %d", webFetchRedirectLimit)
|
||||
}
|
||||
if err := validateParsedWebFetchURL(request.Context(), request.URL, resolver); err != nil {
|
||||
return fmt.Errorf("Unsafe redirect URL: %w", err)
|
||||
return fmt.Errorf("unsafe redirect URL: %w", err)
|
||||
}
|
||||
if previous != nil {
|
||||
return previous(request, via)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HTML-to-markdown conversion for web_fetch. Raw HTML is mostly boilerplate —
|
||||
// on a typical page well under a tenth of the bytes are readable content — so
|
||||
// returning it raw wastes nearly the whole response budget on markup. The
|
||||
// converter is deliberately dependency-free (regex passes + stdlib entity
|
||||
// unescaping, no HTML parser): the output feeds a language model, not a
|
||||
// browser, so best-effort structure (headings, links, list markers) is enough,
|
||||
// and the format=raw escape hatch covers pages it mangles.
|
||||
|
||||
var (
|
||||
webFetchDropBlockRe = regexp.MustCompile(`(?is)<(script|style|head|noscript|svg|template|iframe)\b[^>]*>.*?</\s*(?:script|style|head|noscript|svg|template|iframe)\s*>`)
|
||||
webFetchCommentRe = regexp.MustCompile(`(?s)<!--.*?-->`)
|
||||
webFetchAnchorRe = regexp.MustCompile(`(?is)<a\b[^>]*?href\s*=\s*["']?([^"'\s>]+)["']?[^>]*>(.*?)</\s*a\s*>`)
|
||||
webFetchHeadingRe = regexp.MustCompile(`(?is)<h([1-6])[^>]*>(.*?)</\s*h[1-6]\s*>`)
|
||||
webFetchListItemRe = regexp.MustCompile(`(?i)<li\b[^>]*>`)
|
||||
webFetchBreakRe = regexp.MustCompile(`(?i)<(?:br|hr)\s*/?>`)
|
||||
webFetchBlockTagRe = regexp.MustCompile(`(?i)</?(?:p|div|section|article|main|header|footer|nav|aside|table|tr|ul|ol|blockquote|pre|figure|form)\b[^>]*>`)
|
||||
webFetchAnyTagRe = regexp.MustCompile(`(?s)<[^>]*>`)
|
||||
webFetchSpaceRunRe = regexp.MustCompile(`[ \t]+`)
|
||||
webFetchBlankRunRe = regexp.MustCompile(`\n{3,}`)
|
||||
)
|
||||
|
||||
// htmlToMarkdown converts an HTML document to compact markdown-flavored text:
|
||||
// headings become #-prefixed lines, anchors become [text](href), list items
|
||||
// become "- " bullets, block boundaries become blank lines, all other markup
|
||||
// is stripped, and entities are unescaped.
|
||||
func htmlToMarkdown(body string) string {
|
||||
text := webFetchCommentRe.ReplaceAllString(body, " ")
|
||||
text = webFetchDropBlockRe.ReplaceAllString(text, " ")
|
||||
text = webFetchHeadingRe.ReplaceAllStringFunc(text, func(match string) string {
|
||||
groups := webFetchHeadingRe.FindStringSubmatch(match)
|
||||
level := int(groups[1][0] - '0')
|
||||
if level < 1 || level > 6 {
|
||||
level = 1
|
||||
}
|
||||
title := strings.TrimSpace(webFetchAnyTagRe.ReplaceAllString(groups[2], " "))
|
||||
return "\n\n" + strings.Repeat("#", level) + " " + title + "\n\n"
|
||||
})
|
||||
text = webFetchAnchorRe.ReplaceAllStringFunc(text, func(match string) string {
|
||||
groups := webFetchAnchorRe.FindStringSubmatch(match)
|
||||
href := groups[1]
|
||||
label := strings.TrimSpace(webFetchAnyTagRe.ReplaceAllString(groups[2], " "))
|
||||
if label == "" {
|
||||
return " "
|
||||
}
|
||||
if strings.HasPrefix(href, "#") || strings.HasPrefix(strings.ToLower(href), "javascript:") {
|
||||
return label
|
||||
}
|
||||
return fmt.Sprintf("[%s](%s)", label, href)
|
||||
})
|
||||
text = webFetchListItemRe.ReplaceAllString(text, "\n- ")
|
||||
text = webFetchBreakRe.ReplaceAllString(text, "\n")
|
||||
text = webFetchBlockTagRe.ReplaceAllString(text, "\n\n")
|
||||
text = webFetchAnyTagRe.ReplaceAllString(text, " ")
|
||||
text = html.UnescapeString(text)
|
||||
|
||||
// Whitespace normalization: collapse space runs, trim line edges, cap blank
|
||||
// runs at one empty line.
|
||||
lines := strings.Split(text, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = strings.TrimSpace(webFetchSpaceRunRe.ReplaceAllString(line, " "))
|
||||
}
|
||||
text = strings.Join(lines, "\n")
|
||||
text = webFetchBlankRunRe.ReplaceAllString(text, "\n\n")
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
// looksLikeHTML reports whether a response should be treated as an HTML
|
||||
// document, by Content-Type first and a body sniff as fallback (servers
|
||||
// mislabel HTML as text/plain often enough to matter).
|
||||
func looksLikeHTML(contentType, body string) bool {
|
||||
lowered := strings.ToLower(contentType)
|
||||
if strings.Contains(lowered, "text/html") || strings.Contains(lowered, "application/xhtml") {
|
||||
return true
|
||||
}
|
||||
if lowered != "" && !strings.Contains(lowered, "text/plain") {
|
||||
return false
|
||||
}
|
||||
head := strings.ToLower(strings.TrimSpace(body))
|
||||
if len(head) > 512 {
|
||||
head = head[:512]
|
||||
}
|
||||
return strings.Contains(head, "<!doctype html") || strings.Contains(head, "<html")
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const sampleHTML = `<!DOCTYPE html>
|
||||
<html><head><title>ignored</title><style>body{color:red}</style></head>
|
||||
<body>
|
||||
<script>var tracking = "noise";</script>
|
||||
<h1>Main Title</h1>
|
||||
<p>First paragraph with a <a href="https://example.com/docs">docs link</a> and & entity.</p>
|
||||
<ul><li>alpha</li><li>beta</li></ul>
|
||||
<!-- hidden comment -->
|
||||
<h2>Section</h2>
|
||||
<p>Body with extra spaces.</p>
|
||||
</body></html>`
|
||||
|
||||
func TestHTMLToMarkdown(t *testing.T) {
|
||||
markdown := htmlToMarkdown(sampleHTML)
|
||||
|
||||
for _, want := range []string{
|
||||
"# Main Title",
|
||||
"## Section",
|
||||
"[docs link](https://example.com/docs)",
|
||||
"- alpha",
|
||||
"- beta",
|
||||
"with a", // paragraph text survives
|
||||
"& entity",
|
||||
} {
|
||||
if !strings.Contains(markdown, want) {
|
||||
t.Fatalf("markdown missing %q:\n%s", want, markdown)
|
||||
}
|
||||
}
|
||||
for _, banned := range []string{"tracking", "color:red", "hidden comment", "<p>", "</html>", "ignored"} {
|
||||
if strings.Contains(markdown, banned) {
|
||||
t.Fatalf("markdown must not contain %q:\n%s", banned, markdown)
|
||||
}
|
||||
}
|
||||
if strings.Contains(markdown, "extra spaces") {
|
||||
t.Fatalf("space runs must collapse:\n%s", markdown)
|
||||
}
|
||||
if strings.Contains(markdown, "\n\n\n") {
|
||||
t.Fatalf("blank-line runs must be capped:\n%s", markdown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeHTML(t *testing.T) {
|
||||
cases := []struct {
|
||||
contentType string
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{"text/html; charset=utf-8", "anything", true},
|
||||
{"application/xhtml+xml", "anything", true},
|
||||
{"application/json", `{"html":"<html>"}`, false},
|
||||
{"text/plain", "<!doctype html><html>", true}, // mislabeled HTML sniffed
|
||||
{"text/plain", "just text", false},
|
||||
{"", "<html><body>x</body></html>", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := looksLikeHTML(c.contentType, c.body); got != c.want {
|
||||
t.Errorf("looksLikeHTML(%q, %.20q) = %v, want %v", c.contentType, c.body, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebFetchConvertsHTMLByDefault(t *testing.T) {
|
||||
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
||||
return webFetchTestResponse(request, http.StatusOK, "text/html; charset=utf-8", sampleHTML), nil
|
||||
}))
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com/page"})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "# Main Title") || strings.Contains(result.Output, "<h1>") {
|
||||
t.Fatalf("HTML must convert to markdown by default:\n%s", result.Output)
|
||||
}
|
||||
if result.Meta["converted"] != "true" {
|
||||
t.Fatalf("converted meta must be true: %#v", result.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebFetchRawFormatSkipsConversion(t *testing.T) {
|
||||
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
||||
return webFetchTestResponse(request, http.StatusOK, "text/html", sampleHTML), nil
|
||||
}))
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com/page", "format": "raw"})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "<h1>Main Title</h1>") {
|
||||
t.Fatalf("format=raw must keep original HTML:\n%s", result.Output)
|
||||
}
|
||||
if result.Meta["converted"] != "false" {
|
||||
t.Fatalf("converted meta must be false: %#v", result.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebFetchLeavesNonHTMLUntouched(t *testing.T) {
|
||||
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
||||
return webFetchTestResponse(request, http.StatusOK, "application/json", `{"key":"<b>value</b>"}`), nil
|
||||
}))
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com/api"})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, `{"key":"<b>value</b>"}`) {
|
||||
t.Fatalf("non-HTML must pass through untouched:\n%s", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebFetchRejectsInvalidFormat(t *testing.T) {
|
||||
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
||||
return webFetchTestResponse(request, http.StatusOK, "text/plain", "x"), nil
|
||||
}))
|
||||
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com", "format": "xml"})
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, "format must be") {
|
||||
t.Fatalf("invalid format must be rejected: %s %s", result.Status, result.Output)
|
||||
}
|
||||
}
|
||||
@@ -405,7 +405,7 @@ func TestWebFetchToolRejectsUnsafeRedirects(t *testing.T) {
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected redirect safety error, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "Unsafe redirect URL") {
|
||||
if !strings.Contains(result.Output, "unsafe redirect URL") {
|
||||
t.Fatalf("expected unsafe redirect message, got %q", result.Output)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -426,7 +426,6 @@ func stringListArgWebSearch(args map[string]any, key string) (domains []string,
|
||||
if !ok || value == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
provided = true
|
||||
raw, ok := value.([]any)
|
||||
if !ok {
|
||||
if asString, ok2 := value.([]string); ok2 {
|
||||
|
||||
@@ -254,6 +254,14 @@ func scopedReadRoots(workspaceRoot string, scope PathScope) ([]string, error) {
|
||||
}
|
||||
|
||||
func resolveScopedReadPath(workspaceRoot string, scope PathScope, requestedPath string) (string, string, error) {
|
||||
// Spill files (truncated tool output saved under the per-uid temp dir) are
|
||||
// readable regardless of scope: the truncation notice tells the model to
|
||||
// read_file/grep them, which must actually work. resolveSpillReadPath
|
||||
// verifies containment after symlink resolution, so this cannot be used to
|
||||
// reach anything outside the spill dir.
|
||||
if spillPath, ok := resolveSpillReadPath(requestedPath); ok {
|
||||
return spillPath, spillPath, nil
|
||||
}
|
||||
if requestedPath == "" || !filepath.IsAbs(requestedPath) || scope == nil {
|
||||
return resolveWorkspacePath(workspaceRoot, requestedPath)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func (tool writeFileTool) Run(ctx context.Context, args map[string]any) Result {
|
||||
return tool.RunWithOptions(ctx, args, RunOptions{})
|
||||
}
|
||||
|
||||
func (tool writeFileTool) RunWithOptions(_ context.Context, args map[string]any, options RunOptions) Result {
|
||||
func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result {
|
||||
requestedPath, err := aliasedStringArg(args, []string{"path", "file", "file_path", "filename"}, "", true, false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for write_file: " + err.Error())
|
||||
@@ -110,6 +110,10 @@ func (tool writeFileTool) RunWithOptions(_ context.Context, args map[string]any,
|
||||
if err := os.WriteFile(absolutePath, []byte(content), 0o644); err != nil {
|
||||
return errorResult("Error writing file " + relativePath + ": " + err.Error())
|
||||
}
|
||||
// Optional format-on-write (ZERO_FORMAT_ON_WRITE). Must run BEFORE the
|
||||
// FileTracker baseline: recording pre-format content would make the very
|
||||
// next edit look like an external modification and trip the conflict guard.
|
||||
content = maybeFormatWrittenFile(ctx, absolutePath, content)
|
||||
// Baseline the freshly written content so a later edit/overwrite in this
|
||||
// session compares against what is now on disk.
|
||||
newInfo, _ := os.Stat(absolutePath)
|
||||
@@ -126,6 +130,7 @@ func (tool writeFileTool) RunWithOptions(_ context.Context, args map[string]any,
|
||||
lines++
|
||||
}
|
||||
summary := fmt.Sprintf("%s %s (%d lines).", verb, relativePath, lines)
|
||||
summary += inlineDiagnostics(ctx, options, absolutePath, relativePath)
|
||||
result := okResult(summary)
|
||||
result.ChangedFiles = []string{relativePath}
|
||||
// Card-only preview: a real unified diff (all-green for a create, red/green for
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -237,8 +238,10 @@ func renderAssistantMarkdownPlainText(text string, proseMeasure int, tableMeasur
|
||||
}
|
||||
|
||||
func stripMarkdownRenderControls(text string) string {
|
||||
text = strings.ReplaceAll(text, markdownBoldStart, "")
|
||||
return strings.ReplaceAll(text, markdownBoldEnd, "")
|
||||
// renderAssistantMarkdownText embeds markdownBoldStart/markdownBoldEnd
|
||||
// markers for prose text, and highlightCodeAuto embeds ANSI color sequences
|
||||
// for fenced code blocks. Strip everything so clipboard text is pure plain text.
|
||||
return ansi.Strip(text)
|
||||
}
|
||||
|
||||
func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
|
||||
|
||||
@@ -188,7 +188,54 @@ func (m model) matchCommandSuggestions(token string) []commandSuggestion {
|
||||
out := matchCommandSuggestionsWithFilter(token, func(command commandDefinition) bool {
|
||||
return command.kind != commandSandboxSetup || m.sandboxSetupCommand != nil
|
||||
})
|
||||
return append(out, m.matchUserCommandSuggestions(token)...)
|
||||
out = append(out, m.matchUserCommandSuggestions(token)...)
|
||||
out = append(out, m.matchSkillSuggestions(token)...)
|
||||
// Each source caps itself, but the merged list must honor the shared cap too
|
||||
// (three sources could otherwise stack up to ~3x the palette bound).
|
||||
if len(out) > maxCommandSuggestions {
|
||||
out = out[:maxCommandSuggestions]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matchSkillSuggestions returns installed skills whose slash name has the typed
|
||||
// prefix, so skills are discoverable and invocable from the palette like user
|
||||
// commands. Precedence (builtin > user command > skill) is enforced here by
|
||||
// skipping any skill whose name is claimed by a builtin (or alias) or a user
|
||||
// command — unlike the builtin/user pair, a shadowed skill row would be dead at
|
||||
// dispatch time, so it must not be advertised at all.
|
||||
func (m model) matchSkillSuggestions(token string) []commandSuggestion {
|
||||
prefix := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(token, "/")))
|
||||
if prefix == "" || m.loadSkills == nil {
|
||||
return nil
|
||||
}
|
||||
taken := m.takenSlashNames()
|
||||
var out []commandSuggestion
|
||||
// Read through the loader (not the startup snapshot in agentOptions) so the
|
||||
// palette matches what dispatch will actually resolve — a skill installed or
|
||||
// removed mid-session appears/disappears without a restart.
|
||||
for _, skill := range m.installedSkills() {
|
||||
name := skillSlashName(skill.Name)
|
||||
if name == "" || taken[name] || !strings.HasPrefix(name, prefix) {
|
||||
continue
|
||||
}
|
||||
// Two skills whose names collide after lowercasing (e.g. "Deploy" and
|
||||
// "deploy") map to one slash name; only the first — the one dispatch will
|
||||
// run — may be advertised, or the row's description and the executed
|
||||
// instructions silently diverge.
|
||||
taken[name] = true
|
||||
desc := strings.TrimSpace(skill.Description)
|
||||
if desc == "" {
|
||||
desc = "Skill: /" + name
|
||||
} else {
|
||||
desc += " (skill)"
|
||||
}
|
||||
out = append(out, commandSuggestion{Name: "/" + name, Desc: desc})
|
||||
if len(out) >= maxCommandSuggestions {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matchUserCommandSuggestions returns file-sourced /commands whose name has the
|
||||
|
||||
@@ -698,3 +698,19 @@ func (m model) debugText() string {
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
// skillsText is the /skills fallback when NO skills are installed — an install
|
||||
// hint. With skills present /skills opens the searchable skill picker instead
|
||||
// (see newSkillPicker), matching how /model works.
|
||||
func (m model) skillsText() string {
|
||||
return renderCommandOutput(commandOutput{
|
||||
Title: "Skills",
|
||||
Status: commandStatusInfo,
|
||||
Sections: []commandSection{{
|
||||
Lines: []string{"No skills installed."},
|
||||
}},
|
||||
Hints: []string{
|
||||
"install one: create <skills-dir>/<name>/SKILL.md (see `zero skills`)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -46,6 +46,8 @@ const (
|
||||
commandCopy
|
||||
commandExport
|
||||
commandNew
|
||||
commandSkills
|
||||
commandLoop
|
||||
commandUnknown
|
||||
)
|
||||
|
||||
@@ -131,6 +133,13 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "List registered tools.",
|
||||
kind: commandTools,
|
||||
},
|
||||
{
|
||||
name: "/skills",
|
||||
usage: "/skills",
|
||||
group: commandGroupTools,
|
||||
description: "List installed skills; run one directly with /<skill-name> [args].",
|
||||
kind: commandSkills,
|
||||
},
|
||||
{
|
||||
name: "/context",
|
||||
usage: "/context",
|
||||
@@ -289,6 +298,13 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "Write the conversation transcript to a file.",
|
||||
kind: commandExport,
|
||||
},
|
||||
{
|
||||
name: "/loop",
|
||||
usage: "/loop [interval] <prompt|/command> | /loop list | /loop stop [id|all]",
|
||||
group: commandGroupSession,
|
||||
description: "Repeat a prompt or command on an interval (e.g. /loop 5m /babysit-prs), or self-paced when no interval is given.",
|
||||
kind: commandLoop,
|
||||
},
|
||||
{
|
||||
name: "/help",
|
||||
usage: "/help",
|
||||
|
||||
@@ -280,7 +280,7 @@ func (m model) composerPositionAtMouse(msg tea.MouseMsg) (int, bool) {
|
||||
}
|
||||
|
||||
func (m model) composerMouseSelectionBlocked() bool {
|
||||
return m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
|
||||
return m.transcriptDetailed || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
|
||||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive()
|
||||
}
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ func TestFileViewSwapsTranscriptBody(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
m = m.openFileView("internal/tui/sidebar.go")
|
||||
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("file view should swap the body to a single block item, got %d items", len(items))
|
||||
}
|
||||
@@ -264,6 +264,55 @@ func TestFileViewFullBodyTruncatesLongFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetailedTranscriptClosesFileView: entering detailed transcript mode
|
||||
// closes an active file drill-in so the full transcript body replaces the file
|
||||
// content.
|
||||
func TestDetailedTranscriptClosesFileView(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
m.altScreen = true
|
||||
m = m.openFileView("web/app.js")
|
||||
|
||||
if !m.fileView.active {
|
||||
t.Fatal("sanity check: openFileView should activate the file view")
|
||||
}
|
||||
|
||||
updated, _ := m.Update(testKeyCtrl('o'))
|
||||
m = updated.(model)
|
||||
|
||||
if m.fileView.active {
|
||||
t.Fatal("detailed transcript should close the file drill-in")
|
||||
}
|
||||
if !m.transcriptDetailed {
|
||||
t.Fatal("Ctrl+O should enter detailed mode")
|
||||
}
|
||||
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "", true)
|
||||
if len(items) <= 1 {
|
||||
t.Fatalf("detailed transcript should show multiple body items, got %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetailedTranscriptStaysClosedOnSecondToggle: toggling out and back into
|
||||
// detailed mode does not re-open the closed file view.
|
||||
func TestDetailedTranscriptStaysClosedOnSecondToggle(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
m.altScreen = true
|
||||
m = m.openFileView("web/app.js")
|
||||
|
||||
updated, _ := m.Update(testKeyCtrl('o'))
|
||||
m = updated.(model)
|
||||
|
||||
updated, _ = m.Update(testKeyCtrl('o'))
|
||||
m = updated.(model)
|
||||
|
||||
if m.fileView.active {
|
||||
t.Fatal("exiting detailed mode must not re-open the file drill-in")
|
||||
}
|
||||
if m.transcriptDetailed {
|
||||
t.Fatal("second Ctrl+O should exit detailed mode")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileViewKeysDeferToBlockingModal: with a permission prompt up, Esc and
|
||||
// the d/f shortcuts belong to the prompt — the drill-in must not swallow them
|
||||
// (Esc exiting the view instead of reaching the prompt's deny handling).
|
||||
|
||||
@@ -372,7 +372,7 @@ func (m model) scrollOffsetForTranscriptRow(rowIndex int) (int, bool) {
|
||||
return 0, false
|
||||
}
|
||||
width := m.chatColumnWidth()
|
||||
items := m.transcriptBodyItems(width, "")
|
||||
items := m.transcriptBodyItems(width, "", false)
|
||||
metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
|
||||
startY := -1
|
||||
for _, span := range metrics.spans {
|
||||
|
||||
@@ -0,0 +1,693 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// The /loop command repeats a prompt or slash command in one of two modes:
|
||||
//
|
||||
// - Fixed interval — `/loop 5m <prompt>` re-fires on a wall-clock cadence.
|
||||
// - Self-paced — bare `/loop <prompt>` runs a maintenance playbook: the model
|
||||
// does one increment of work each iteration and ends its reply with a control
|
||||
// line (`LOOP: DONE` / `LOOP: CONTINUE [interval]`) that stops the loop or picks
|
||||
// the next wake. With no control line it falls back to an adaptive cadence.
|
||||
//
|
||||
// Loops are foreground and session-scoped: a tick only fires while the session is
|
||||
// idle between turns (it never interrupts a streaming turn), the loop lives with the
|
||||
// session that created it, and switching sessions (/new, /resume) stops it. Safety
|
||||
// rails cap iterations, age, consecutive failures, and no-progress repetition.
|
||||
// Everything here is pure/loop-local; the turn-lifecycle wiring (ticker, launch,
|
||||
// continuation) lives in model.go.
|
||||
|
||||
type loopMode int
|
||||
|
||||
const (
|
||||
loopModeFixed loopMode = iota // re-fire every fixed interval
|
||||
loopModeSelfPaced // model chooses the next wake each iteration
|
||||
)
|
||||
|
||||
// Safety bounds. A loop is foreground + session-scoped, but these keep a runaway
|
||||
// or forgotten loop from burning tokens forever.
|
||||
const (
|
||||
loopMinInterval = 30 * time.Second
|
||||
loopMaxInterval = 24 * time.Hour
|
||||
loopMaxIterations = 500 // hard ceiling; the loop auto-stops after this many
|
||||
|
||||
// loopMaxAge auto-expires a loop this long after it was created, so a forgotten
|
||||
// loop stops on its own even if it never reaches the iteration cap.
|
||||
loopMaxAge = 24 * time.Hour
|
||||
|
||||
loopSelfPaceMin = 1 * time.Minute
|
||||
loopSelfPaceMax = 1 * time.Hour
|
||||
loopSelfPaceDefault = 15 * time.Minute
|
||||
|
||||
// loopDoomThreshold ends a loop after this many consecutive identical final
|
||||
// answers — a self-paced or interval loop that gets stuck repeating the same
|
||||
// no-progress result should stop rather than churn.
|
||||
loopDoomThreshold = 5
|
||||
|
||||
// loopMaxFailures ends a loop after this many consecutive failed iterations, so
|
||||
// a loop whose iterations keep erroring stops instead of churning to the
|
||||
// iteration cap (a failed turn has no final answer, so the doom guard can't see
|
||||
// the lack of progress).
|
||||
loopMaxFailures = 3
|
||||
|
||||
// loopPollInterval is how often the controller checks whether a loop is due.
|
||||
// A loop only fires while idle, so this is a cheap between-turn poll.
|
||||
loopPollInterval = 1 * time.Second
|
||||
)
|
||||
|
||||
// loopState is one active loop owned by the current session.
|
||||
type loopState struct {
|
||||
id string
|
||||
mode loopMode
|
||||
prompt string // the prompt or "/command" re-run each iteration
|
||||
interval time.Duration
|
||||
iteration int
|
||||
maxIter int // 0 => loopMaxIterations
|
||||
createdAt time.Time
|
||||
nextRunAt time.Time
|
||||
// lastResult / repeatRun drive doom-loop detection: consecutive identical
|
||||
// final answers past loopDoomThreshold end the loop.
|
||||
lastResult string
|
||||
repeatRun int
|
||||
// failRun counts consecutive failed iterations; past loopMaxFailures the loop
|
||||
// stops (a failed turn has no final answer for the doom guard to compare).
|
||||
failRun int
|
||||
paused bool
|
||||
}
|
||||
|
||||
func (l *loopState) iterationCap() int {
|
||||
if l.maxIter > 0 {
|
||||
return l.maxIter
|
||||
}
|
||||
return loopMaxIterations
|
||||
}
|
||||
|
||||
// due reports whether the loop should fire at now (active, not paused, past its
|
||||
// next-run mark).
|
||||
func (l *loopState) due(now time.Time) bool {
|
||||
return !l.paused && !l.nextRunAt.IsZero() && !now.Before(l.nextRunAt)
|
||||
}
|
||||
|
||||
// cadenceText renders the loop's cadence for status lines.
|
||||
func (l *loopState) cadenceText() string {
|
||||
if l.mode == loopModeSelfPaced {
|
||||
return "self-paced"
|
||||
}
|
||||
return "every " + formatLoopDuration(l.interval)
|
||||
}
|
||||
|
||||
type loopAction int
|
||||
|
||||
const (
|
||||
loopActionUsage loopAction = iota // show usage / list when empty
|
||||
loopActionStart // start a new loop
|
||||
loopActionStop // stop one by id
|
||||
loopActionStopAll // stop every loop
|
||||
loopActionList // list active loops
|
||||
)
|
||||
|
||||
// loopCommand is the parsed form of a `/loop ...` invocation (the text after the
|
||||
// command name).
|
||||
type loopCommand struct {
|
||||
action loopAction
|
||||
targetID string
|
||||
mode loopMode
|
||||
prompt string
|
||||
interval time.Duration
|
||||
// note carries a non-fatal advisory (e.g. an interval that was clamped) to
|
||||
// surface back to the user.
|
||||
note string
|
||||
}
|
||||
|
||||
var loopIntervalRe = regexp.MustCompile(`^(\d+)(s|m|h|d)$`)
|
||||
|
||||
// loopControlRe matches a self-paced loop's control line at the start of a line,
|
||||
// tolerant of a leading markdown bullet/quote marker and any trailing note after
|
||||
// the keyword: "LOOP: DONE", "- loop: continue", "LOOP: CONTINUE 10m — still tidying".
|
||||
// The leading ^ anchor keeps a mid-sentence mention ("I'll emit LOOP: DONE when…")
|
||||
// from matching; parseLoopControl normalizes CRLF first so `$` can reach line end.
|
||||
var loopControlRe = regexp.MustCompile(`(?im)^[^\S\r\n]*(?:[-*>•][^\S\r\n]*)?loop:[^\S\r\n]*(done|continue)\b(?:[^\S\r\n]+(\d+[smhd])\b)?.*$`)
|
||||
|
||||
// parseLoopCommand interprets the arguments to `/loop`. Forms:
|
||||
//
|
||||
// (empty) -> usage/list
|
||||
// list | status -> list active loops
|
||||
// stop | stop all | stop <id>
|
||||
// <interval> <prompt> -> fixed-interval loop (e.g. "5m /babysit")
|
||||
// <prompt> every <interval>
|
||||
// <prompt> -> self-paced loop (no interval)
|
||||
func parseLoopCommand(args string) loopCommand {
|
||||
trimmed := strings.TrimSpace(args)
|
||||
if trimmed == "" {
|
||||
return loopCommand{action: loopActionUsage}
|
||||
}
|
||||
|
||||
first := strings.ToLower(strings.Fields(trimmed)[0])
|
||||
switch first {
|
||||
case "list", "status", "ls":
|
||||
return loopCommand{action: loopActionList}
|
||||
case "stop", "cancel", "kill":
|
||||
rest := strings.TrimSpace(trimmed[len(strings.Fields(trimmed)[0]):])
|
||||
if rest == "" || strings.EqualFold(rest, "all") {
|
||||
if strings.EqualFold(rest, "all") {
|
||||
return loopCommand{action: loopActionStopAll}
|
||||
}
|
||||
// bare "stop" stops all when ambiguous; the caller may special-case a
|
||||
// single active loop.
|
||||
return loopCommand{action: loopActionStop, targetID: ""}
|
||||
}
|
||||
return loopCommand{action: loopActionStop, targetID: strings.TrimSpace(rest)}
|
||||
}
|
||||
|
||||
// Leading interval token: "5m <prompt>".
|
||||
if fields := strings.Fields(trimmed); len(fields) >= 2 {
|
||||
if d, ok := parseLoopInterval(fields[0]); ok {
|
||||
prompt := strings.TrimSpace(trimmed[len(fields[0]):])
|
||||
cmd := loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt}
|
||||
cmd.interval, cmd.note = clampLoopInterval(d)
|
||||
return cmd
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing "every <interval>".
|
||||
if prompt, d, ok := splitTrailingEvery(trimmed); ok {
|
||||
cmd := loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt}
|
||||
cmd.interval, cmd.note = clampLoopInterval(d)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// No interval anywhere -> self-paced.
|
||||
return loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: trimmed}
|
||||
}
|
||||
|
||||
// splitTrailingEvery matches "<prompt> every <interval>" (the interval must be a
|
||||
// real duration token so "check every PR" is not mis-parsed as a cadence).
|
||||
func splitTrailingEvery(input string) (string, time.Duration, bool) {
|
||||
fields := strings.Fields(input)
|
||||
if len(fields) < 3 {
|
||||
return "", 0, false
|
||||
}
|
||||
last := fields[len(fields)-1]
|
||||
prev := fields[len(fields)-2]
|
||||
if !strings.EqualFold(prev, "every") {
|
||||
return "", 0, false
|
||||
}
|
||||
d, ok := parseLoopInterval(last)
|
||||
if !ok {
|
||||
return "", 0, false
|
||||
}
|
||||
prompt := strings.TrimSpace(strings.Join(fields[:len(fields)-2], " "))
|
||||
if prompt == "" {
|
||||
return "", 0, false
|
||||
}
|
||||
return prompt, d, true
|
||||
}
|
||||
|
||||
// parseLoopInterval parses a bare "<N><unit>" token (s/m/h/d) into a duration.
|
||||
func parseLoopInterval(token string) (time.Duration, bool) {
|
||||
m := loopIntervalRe.FindStringSubmatch(strings.ToLower(strings.TrimSpace(token)))
|
||||
if m == nil {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(m[1])
|
||||
if err != nil || n <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
unit := map[string]time.Duration{
|
||||
"s": time.Second,
|
||||
"m": time.Minute,
|
||||
"h": time.Hour,
|
||||
"d": 24 * time.Hour,
|
||||
}[m[2]]
|
||||
return time.Duration(n) * unit, true
|
||||
}
|
||||
|
||||
// clampLoopInterval bounds an interval to [loopMinInterval, loopMaxInterval] and
|
||||
// returns an advisory note when it was adjusted.
|
||||
func clampLoopInterval(d time.Duration) (time.Duration, string) {
|
||||
switch {
|
||||
case d < loopMinInterval:
|
||||
return loopMinInterval, fmt.Sprintf("interval raised to the %s minimum", formatLoopDuration(loopMinInterval))
|
||||
case d > loopMaxInterval:
|
||||
return loopMaxInterval, fmt.Sprintf("interval lowered to the %s maximum", formatLoopDuration(loopMaxInterval))
|
||||
default:
|
||||
return d, ""
|
||||
}
|
||||
}
|
||||
|
||||
// loopTickMsg is the between-turn poll that fires a due loop while the session is
|
||||
// idle. seq invalidates a tick left over from before the loop set changed.
|
||||
type loopTickMsg struct{ seq int }
|
||||
|
||||
// handleLoopCommand dispatches a parsed `/loop ...` invocation.
|
||||
func (m model) handleLoopCommand(args string) (model, tea.Cmd) {
|
||||
cmd := parseLoopCommand(args)
|
||||
switch cmd.action {
|
||||
case loopActionList:
|
||||
return m.appendLoopSystem(m.loopListText()), nil
|
||||
case loopActionUsage:
|
||||
if len(m.loops) > 0 {
|
||||
return m.appendLoopSystem(m.loopListText()), nil
|
||||
}
|
||||
return m.appendLoopSystem(loopUsageText()), nil
|
||||
case loopActionStop:
|
||||
return m.stopLoop(cmd.targetID)
|
||||
case loopActionStopAll:
|
||||
return m.stopAllLoops()
|
||||
case loopActionStart:
|
||||
return m.startLoop(cmd)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// startLoop registers a new loop and (re)starts the idle poll ticker. The first
|
||||
// iteration fires on the next idle tick (nextRunAt = now).
|
||||
func (m model) startLoop(cmd loopCommand) (model, tea.Cmd) {
|
||||
if reason, ok := m.validateLoopTarget(cmd.prompt); !ok {
|
||||
return m.appendLoopSystem(reason), nil
|
||||
}
|
||||
// A /command loop must be fixed-interval. Self-paced mode drives a free-form
|
||||
// goal through the playbook + LOOP: control line (see firePrompt), which a
|
||||
// custom command's expanded template never carries — so a bare self-paced
|
||||
// /command would silently degrade to adaptive polling with no way to report
|
||||
// done. Require an explicit interval for command loops instead.
|
||||
if cmd.mode == loopModeSelfPaced && strings.HasPrefix(strings.TrimSpace(cmd.prompt), "/") {
|
||||
return m.appendLoopSystem("A /command loop needs an interval — e.g. /loop 5m " + strings.Fields(cmd.prompt)[0] + ". Self-paced mode (no interval) is for a free-form goal."), nil
|
||||
}
|
||||
m.loopCounter++
|
||||
id := "L" + strconv.Itoa(m.loopCounter)
|
||||
loop := &loopState{
|
||||
id: id,
|
||||
mode: cmd.mode,
|
||||
prompt: cmd.prompt,
|
||||
interval: cmd.interval,
|
||||
createdAt: m.now(),
|
||||
nextRunAt: m.now(), // fire the first iteration on the next idle tick
|
||||
}
|
||||
m.loops = append(m.loops, loop)
|
||||
note := ""
|
||||
if cmd.note != "" {
|
||||
note = " (" + cmd.note + ")"
|
||||
}
|
||||
m = m.appendLoopSystem(fmt.Sprintf(
|
||||
"Loop %s started — %s%s. Runs while this session is idle; stop with /loop stop %s.",
|
||||
id, loop.cadenceText(), note, id))
|
||||
return m.ensureLoopTick()
|
||||
}
|
||||
|
||||
// stopLoop stops one loop by id, or the single active loop when the id is omitted.
|
||||
func (m model) stopLoop(id string) (model, tea.Cmd) {
|
||||
if len(m.loops) == 0 {
|
||||
return m.appendLoopSystem("No active loops."), nil
|
||||
}
|
||||
if id == "" {
|
||||
if len(m.loops) == 1 {
|
||||
return m.removeLoop(m.loops[0].id, "Loop "+m.loops[0].id+" stopped.").ensureLoopTick()
|
||||
}
|
||||
return m.appendLoopSystem("Multiple loops active — use /loop stop <id> or /loop stop all.\n" + m.loopListText()), nil
|
||||
}
|
||||
if m.findLoop(id) == nil {
|
||||
return m.appendLoopSystem("No loop " + id + ". " + m.loopListText()), nil
|
||||
}
|
||||
// removeLoop stops the poll ticker; re-arm it so any OTHER loops that remain
|
||||
// keep firing (ensureLoopTick no-ops when this was the last loop).
|
||||
return m.removeLoop(id, "Loop "+id+" stopped.").ensureLoopTick()
|
||||
}
|
||||
|
||||
func (m model) stopAllLoops() (model, tea.Cmd) {
|
||||
if len(m.loops) == 0 {
|
||||
return m.appendLoopSystem("No active loops."), nil
|
||||
}
|
||||
n := len(m.loops)
|
||||
m.loops = nil
|
||||
m.loopSeq++ // invalidate the pending poll tick
|
||||
m.loopTicking = false
|
||||
return m.appendLoopSystem(fmt.Sprintf("Stopped all %d loop(s).", n)), nil
|
||||
}
|
||||
|
||||
// fireDueLoopIfIdle fires the earliest due loop when the session is idle. Called
|
||||
// from the poll tick; a no-op while a turn, modal, or queued user message is
|
||||
// pending (the loop simply waits for the next idle tick).
|
||||
func (m model) fireDueLoopIfIdle() (model, tea.Cmd) {
|
||||
if m.loopBusy() || len(m.loops) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
now := m.now()
|
||||
var due *loopState
|
||||
for _, l := range m.loops {
|
||||
if l.due(now) && (due == nil || l.nextRunAt.Before(due.nextRunAt)) {
|
||||
due = l
|
||||
}
|
||||
}
|
||||
if due == nil {
|
||||
return m, nil
|
||||
}
|
||||
m.activeLoopID = due.id
|
||||
due.nextRunAt = time.Time{} // mark running
|
||||
return m.fireLoopPrompt(due.firePrompt())
|
||||
}
|
||||
|
||||
// firePrompt is the text actually sent for this iteration. A self-paced plain
|
||||
// prompt is wrapped in the maintenance playbook so the model knows to do one
|
||||
// increment of work and end with a LOOP: control line; a /command or a
|
||||
// fixed-interval prompt is sent verbatim.
|
||||
func (l *loopState) firePrompt() string {
|
||||
if l.mode == loopModeSelfPaced && !strings.HasPrefix(strings.TrimSpace(l.prompt), "/") {
|
||||
return loopSelfPacePlaybook(l)
|
||||
}
|
||||
return l.prompt
|
||||
}
|
||||
|
||||
// fireLoopPrompt runs a loop's prompt as a turn — a custom /command if it resolves
|
||||
// to one, otherwise a plain prompt.
|
||||
func (m model) fireLoopPrompt(prompt string) (model, tea.Cmd) {
|
||||
if strings.HasPrefix(strings.TrimSpace(prompt), "/") {
|
||||
if next, teaCmd, ok := m.handleUserCommand(prompt); ok {
|
||||
return next, teaCmd
|
||||
}
|
||||
}
|
||||
return m.launchPrompt(prompt)
|
||||
}
|
||||
|
||||
// advanceLoop updates a loop after one of its iterations completes: it folds the
|
||||
// result into the failure/no-progress guards, honors a self-paced DONE/next-wake
|
||||
// control line, enforces the iteration and age caps, then schedules the next wake
|
||||
// (or stops the loop).
|
||||
func (m model) advanceLoop(id, finalAnswer string, runErr error) model {
|
||||
l := m.findLoop(id)
|
||||
if l == nil {
|
||||
return m // stopped mid-run
|
||||
}
|
||||
l.iteration++
|
||||
if runErr != nil {
|
||||
l.failRun++
|
||||
} else {
|
||||
l.failRun = 0
|
||||
}
|
||||
res := strings.TrimSpace(finalAnswer)
|
||||
if res != "" && res == l.lastResult {
|
||||
l.repeatRun++
|
||||
} else {
|
||||
l.repeatRun = 0
|
||||
}
|
||||
l.lastResult = res
|
||||
|
||||
// Only self-paced iterations run the playbook, so only they can carry a control
|
||||
// line; a failed turn has no trustworthy final answer to read one from.
|
||||
done, wake := false, time.Duration(0)
|
||||
if l.mode == loopModeSelfPaced && runErr == nil {
|
||||
done, wake = parseLoopControl(finalAnswer)
|
||||
}
|
||||
|
||||
switch {
|
||||
case done:
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s finished — the task reported done after %d iteration(s).", id, l.iteration))
|
||||
case l.failRun >= loopMaxFailures:
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped after %d consecutive failed iterations.", id, l.failRun))
|
||||
case l.iteration >= l.iterationCap():
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped at its %d-iteration cap.", id, l.iteration))
|
||||
case !l.createdAt.IsZero() && m.now().Sub(l.createdAt) >= loopMaxAge:
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s expired after %s.", id, formatLoopDuration(loopMaxAge)))
|
||||
case l.repeatRun >= loopDoomThreshold:
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped — %d identical results in a row (no progress).", id, l.repeatRun))
|
||||
}
|
||||
|
||||
if l.mode == loopModeSelfPaced {
|
||||
// A model-chosen next wake (from the control line) wins; otherwise fall back to
|
||||
// an adaptive cadence that checks back often while work is fresh and settles to
|
||||
// a slow heartbeat as iterations accrue.
|
||||
delay := wake
|
||||
if delay <= 0 {
|
||||
delay = adaptiveSelfPaceDelay(l.iteration)
|
||||
}
|
||||
l.nextRunAt = m.now().Add(clampSelfPaceDelay(delay))
|
||||
} else {
|
||||
l.nextRunAt = m.now().Add(l.interval)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// parseLoopControl reads a self-paced loop's control signal from the model's final
|
||||
// answer. The last control line wins (the model may reference the protocol before
|
||||
// emitting it). Returns done=true to stop the loop, or a next-wake hint (0 = none).
|
||||
func parseLoopControl(answer string) (done bool, wake time.Duration) {
|
||||
// Normalize CRLF/CR so the line-end anchor can reach `$` (Go's (?m)$ sits right
|
||||
// before `\n`, and the horizontal-whitespace class can't consume a stray `\r`).
|
||||
norm := strings.ReplaceAll(answer, "\r\n", "\n")
|
||||
norm = strings.ReplaceAll(norm, "\r", "\n")
|
||||
matches := loopControlRe.FindAllStringSubmatch(norm, -1)
|
||||
if len(matches) == 0 {
|
||||
return false, 0
|
||||
}
|
||||
last := matches[len(matches)-1]
|
||||
if strings.EqualFold(last[1], "done") {
|
||||
return true, 0
|
||||
}
|
||||
if last[2] != "" {
|
||||
if d, ok := parseLoopInterval(last[2]); ok {
|
||||
return false, d
|
||||
}
|
||||
}
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// loopSelfPacePlaybook wraps a self-paced loop's goal in the maintenance-loop
|
||||
// instructions the model follows each iteration: do one real increment of work,
|
||||
// then emit a single control line so the harness knows whether to stop or when to
|
||||
// wake next — the self-paced maintenance playbook the feature ships with.
|
||||
func loopSelfPacePlaybook(l *loopState) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("You are running in a self-paced maintenance loop (iteration ")
|
||||
b.WriteString(strconv.Itoa(l.iteration + 1))
|
||||
b.WriteString("). Do one concrete increment of work toward the goal below — actual work, not just a plan — then stop and report.\n\n")
|
||||
b.WriteString("Goal: ")
|
||||
b.WriteString(strings.TrimSpace(l.prompt))
|
||||
b.WriteString("\n\nEnd your reply with a control line — on its own line, starting with \"LOOP:\":\n")
|
||||
b.WriteString(" LOOP: DONE — the goal is fully met; the loop stops.\n")
|
||||
b.WriteString(" LOOP: CONTINUE — more to do; the loop wakes again automatically.\n")
|
||||
b.WriteString(" LOOP: CONTINUE 10m — more to do; wake after the delay you name (e.g. 5m, 1h).\n")
|
||||
b.WriteString("A short note after the keyword is fine. Report DONE only when the goal is genuinely complete — the loop trusts that signal to stop.")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// adaptiveSelfPaceDelay picks a self-paced loop's next-wake by iteration: frequent
|
||||
// early (work is actively progressing), slowing to a heartbeat as it matures.
|
||||
func adaptiveSelfPaceDelay(iteration int) time.Duration {
|
||||
switch {
|
||||
case iteration < 2:
|
||||
return 2 * time.Minute
|
||||
case iteration < 5:
|
||||
return 5 * time.Minute
|
||||
case iteration < 10:
|
||||
return 15 * time.Minute
|
||||
default:
|
||||
return 30 * time.Minute
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) findLoop(id string) *loopState {
|
||||
for _, l := range m.loops {
|
||||
if l.id == id {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m model) removeLoop(id, note string) model {
|
||||
kept := make([]*loopState, 0, len(m.loops))
|
||||
for _, l := range m.loops {
|
||||
if l.id != id {
|
||||
kept = append(kept, l)
|
||||
}
|
||||
}
|
||||
m.loops = kept
|
||||
m.loopSeq++ // invalidate the pending poll tick; a fresh one is scheduled if loops remain
|
||||
m.loopTicking = false
|
||||
if note != "" {
|
||||
m = m.appendLoopSystem(note)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ensureLoopTick starts the idle poll ticker if it is not already running.
|
||||
func (m model) ensureLoopTick() (model, tea.Cmd) {
|
||||
if m.loopTicking || len(m.loops) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
m.loopTicking = true
|
||||
return m, m.scheduleLoopTick()
|
||||
}
|
||||
|
||||
func (m model) scheduleLoopTick() tea.Cmd {
|
||||
seq := m.loopSeq
|
||||
return tea.Tick(loopPollInterval, func(time.Time) tea.Msg {
|
||||
return loopTickMsg{seq: seq}
|
||||
})
|
||||
}
|
||||
|
||||
// loopBusy reports whether the session is too busy for a loop to fire — a turn,
|
||||
// queued user message, compaction, or ANY blocking modal (permission/askUser/spec
|
||||
// review, provider or MCP wizard, MCP manager, or an open picker) is in flight.
|
||||
// The modal check is load-bearing: a loop must not launch a run behind an open
|
||||
// /resume picker, or that run would complete into whatever session the user then
|
||||
// switches to. Mirrors launchQueuedMessageIfReady so loops and queued prompts never
|
||||
// collide.
|
||||
func (m model) loopBusy() bool {
|
||||
return m.pending || m.exiting || m.compactInFlight || m.hasQueuedMessage() || !m.noBlockingModal()
|
||||
}
|
||||
|
||||
func (m model) appendLoopSystem(text string) model {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
return m
|
||||
}
|
||||
|
||||
// loopActive reports whether any loop is running (for the footer + leave warning).
|
||||
func (m model) loopActive() bool { return len(m.loops) > 0 }
|
||||
|
||||
// loopFooterSummary renders the persistent "N loops · next 3:05pm" footer segment,
|
||||
// or "" when no loops are active.
|
||||
func (m model) loopFooterSummary() string {
|
||||
if len(m.loops) == 0 {
|
||||
return ""
|
||||
}
|
||||
var next time.Time
|
||||
for _, l := range m.loops {
|
||||
if l.nextRunAt.IsZero() || l.paused {
|
||||
continue
|
||||
}
|
||||
if next.IsZero() || l.nextRunAt.Before(next) {
|
||||
next = l.nextRunAt
|
||||
}
|
||||
}
|
||||
label := fmt.Sprintf("%d loop", len(m.loops))
|
||||
if len(m.loops) != 1 {
|
||||
label += "s"
|
||||
}
|
||||
if next.IsZero() {
|
||||
return label
|
||||
}
|
||||
return label + " · next " + next.Format("3:04pm")
|
||||
}
|
||||
|
||||
func (m model) loopListText() string {
|
||||
if len(m.loops) == 0 {
|
||||
return "No active loops."
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("Active loops:\n")
|
||||
now := m.now()
|
||||
for _, l := range m.loops {
|
||||
when := "running"
|
||||
if !l.nextRunAt.IsZero() {
|
||||
if d := l.nextRunAt.Sub(now); d > 0 {
|
||||
when = "next in " + formatLoopDuration(d.Round(time.Second))
|
||||
} else {
|
||||
when = "due"
|
||||
}
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %s · %s · iter %d · %s · %s\n",
|
||||
l.id, l.cadenceText(), l.iteration, when, truncateLoopPrompt(l.prompt)))
|
||||
}
|
||||
b.WriteString("Stop with /loop stop <id> or /loop stop all.")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func loopUsageText() string {
|
||||
return "Repeat a prompt or command:\n" +
|
||||
" /loop 5m /babysit-prs — run a command every 5 minutes\n" +
|
||||
" /loop watch CI every 2m — trailing interval form\n" +
|
||||
" /loop keep tidying docs — self-paced (the model picks its own cadence and stops when done)\n" +
|
||||
" /loop list — show active loops\n" +
|
||||
" /loop stop [id|all] — stop a loop\n" +
|
||||
"Loops run while this session is idle and stop when you close it."
|
||||
}
|
||||
|
||||
// validateLoopTarget rejects looping a built-in command or an unresolved custom
|
||||
// command — only a prompt or an existing custom /command (.zero/commands/<name>.md)
|
||||
// may loop. Resolving custom commands up front stops a typo'd `/loop 5m /nope` from
|
||||
// being scheduled and then re-sent as literal text on every tick.
|
||||
func (m model) validateLoopTarget(prompt string) (string, bool) {
|
||||
p := strings.TrimSpace(prompt)
|
||||
if p == "" {
|
||||
return "Nothing to loop — give a prompt or a /command.", false
|
||||
}
|
||||
if strings.HasPrefix(p, "/") {
|
||||
switch parseCommand(p).kind {
|
||||
case commandPrompt:
|
||||
// A bare "/" or similar that isn't a command — treat as a plain prompt.
|
||||
case commandUnknown:
|
||||
name, _ := splitUserCommand(p)
|
||||
if _, ok := m.lookupUserCommand(name); !ok {
|
||||
return "No command /" + name + " — a loop can only run a prompt or an existing custom /command.", false
|
||||
}
|
||||
default:
|
||||
return "A loop can only run a prompt or a custom /command, not a built-in like " + strings.Fields(p)[0] + ".", false
|
||||
}
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
// clearLoopsForSessionSwitch drops every active loop and its ticker. Loops belong to
|
||||
// the session that created them; carrying them across /new or /resume would fire the
|
||||
// old session's prompt into an unrelated conversation. Returns the count cleared so
|
||||
// the caller can note it. Pure state reset — no transcript writes.
|
||||
func (m model) clearLoopsForSessionSwitch() (model, int) {
|
||||
n := len(m.loops)
|
||||
if n == 0 {
|
||||
return m, 0
|
||||
}
|
||||
m.loops = nil
|
||||
m.activeLoopID = ""
|
||||
m.loopTicking = false
|
||||
m.loopSeq++ // invalidate any pending poll tick
|
||||
return m, n
|
||||
}
|
||||
|
||||
func truncateLoopPrompt(prompt string) string {
|
||||
const max = 48
|
||||
p := strings.TrimSpace(strings.ReplaceAll(prompt, "\n", " "))
|
||||
runes := []rune(p)
|
||||
if len(runes) <= max {
|
||||
return p
|
||||
}
|
||||
return strings.TrimSpace(string(runes[:max])) + "…"
|
||||
}
|
||||
|
||||
// clampSelfPaceDelay bounds a model-chosen self-paced delay to the self-pace
|
||||
// band, defaulting an unset/invalid value.
|
||||
func clampSelfPaceDelay(d time.Duration) time.Duration {
|
||||
switch {
|
||||
case d <= 0:
|
||||
return loopSelfPaceDefault
|
||||
case d < loopSelfPaceMin:
|
||||
return loopSelfPaceMin
|
||||
case d > loopSelfPaceMax:
|
||||
return loopSelfPaceMax
|
||||
default:
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
// formatLoopDuration renders a duration compactly (90s, 5m, 2h, 1d) for status
|
||||
// text, preferring the largest whole unit.
|
||||
func formatLoopDuration(d time.Duration) string {
|
||||
switch {
|
||||
case d%(24*time.Hour) == 0 && d >= 24*time.Hour:
|
||||
return strconv.Itoa(int(d/(24*time.Hour))) + "d"
|
||||
case d%time.Hour == 0 && d >= time.Hour:
|
||||
return strconv.Itoa(int(d/time.Hour)) + "h"
|
||||
case d%time.Minute == 0 && d >= time.Minute:
|
||||
return strconv.Itoa(int(d/time.Minute)) + "m"
|
||||
default:
|
||||
return strconv.Itoa(int(d/time.Second)) + "s"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/usercommands"
|
||||
)
|
||||
|
||||
func loopTestModel(t *testing.T, now time.Time) model {
|
||||
t.Helper()
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.now = func() time.Time { return now }
|
||||
return m
|
||||
}
|
||||
|
||||
func startFixedLoop(m model, prompt string, interval time.Duration) model {
|
||||
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt, interval: interval})
|
||||
return m
|
||||
}
|
||||
|
||||
func TestStartLoopRegistersAndSchedules(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "check the build", 5*time.Minute)
|
||||
|
||||
if len(m.loops) != 1 {
|
||||
t.Fatalf("want 1 loop, got %d", len(m.loops))
|
||||
}
|
||||
l := m.loops[0]
|
||||
if l.mode != loopModeFixed || l.interval != 5*time.Minute || l.prompt != "check the build" {
|
||||
t.Fatalf("unexpected loop %+v", l)
|
||||
}
|
||||
// First iteration is due immediately (nextRunAt == now).
|
||||
if !l.due(now) {
|
||||
t.Errorf("first iteration should be due at creation")
|
||||
}
|
||||
if !m.loopTicking {
|
||||
t.Errorf("starting a loop should start the poll ticker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvanceLoopSchedulesNextWake(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "poll", 5*time.Minute)
|
||||
id := m.loops[0].id
|
||||
|
||||
m = m.advanceLoop(id, "did some work", nil)
|
||||
l := m.loops[0]
|
||||
if l.iteration != 1 {
|
||||
t.Fatalf("iteration = %d, want 1", l.iteration)
|
||||
}
|
||||
if !l.nextRunAt.Equal(now.Add(5 * time.Minute)) {
|
||||
t.Fatalf("nextRunAt = %v, want +5m", l.nextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfPacedUsesModelDelay(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "keep tidying"})
|
||||
id := m.loops[0].id
|
||||
// The model carries its chosen next wake in the reply's control line.
|
||||
m = m.advanceLoop(id, "made progress\nLOOP: CONTINUE 10m", nil)
|
||||
if len(m.loops) == 0 {
|
||||
t.Fatal("a CONTINUE control line should keep the loop running")
|
||||
}
|
||||
if !m.loops[0].nextRunAt.Equal(now.Add(10 * time.Minute)) {
|
||||
t.Fatalf("self-paced nextRunAt = %v, want +10m", m.loops[0].nextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopDoneStops(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "finish the task"})
|
||||
id := m.loops[0].id
|
||||
m = m.advanceLoop(id, "all done\nLOOP: DONE", nil)
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatalf("loop should stop when the task reports done, %d remain", len(m.loops))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixedLoopIgnoresControlLine(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "watch CI", time.Minute)
|
||||
id := m.loops[0].id
|
||||
// A fixed-interval loop never runs the playbook, so a stray control line in the
|
||||
// output is not a completion signal — it keeps its wall-clock cadence.
|
||||
m = m.advanceLoop(id, "LOOP: DONE", nil)
|
||||
if len(m.loops) != 1 {
|
||||
t.Fatal("a fixed-interval loop should ignore a control line and keep running")
|
||||
}
|
||||
if !m.loops[0].nextRunAt.Equal(now.Add(time.Minute)) {
|
||||
t.Fatalf("fixed nextRunAt = %v, want +1m", m.loops[0].nextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopExpiresByAge(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "forgotten", time.Minute)
|
||||
id := m.loops[0].id
|
||||
m.loops[0].createdAt = now.Add(-loopMaxAge - time.Hour) // created past the age cap
|
||||
m = m.advanceLoop(id, "still going", nil)
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatal("a loop past its age cap should auto-expire")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopDoomGuardStops(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "spin", time.Minute)
|
||||
id := m.loops[0].id
|
||||
for i := 0; i < loopDoomThreshold+1 && len(m.loops) > 0; i++ {
|
||||
m = m.advanceLoop(id, "identical answer", nil)
|
||||
}
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatalf("doom guard should stop a loop repeating identical results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopIterationCapStops(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "bounded", time.Minute)
|
||||
m.loops[0].maxIter = 3
|
||||
id := m.loops[0].id
|
||||
for i := 0; i < 3 && len(m.loops) > 0; i++ {
|
||||
m = m.advanceLoop(id, "answer "+string(rune('a'+i)), nil) // distinct answers so doom doesn't fire
|
||||
}
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatalf("loop should stop at its iteration cap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopLoopByID(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "a", time.Minute)
|
||||
m = startFixedLoop(m, "b", time.Minute)
|
||||
id := m.loops[0].id
|
||||
m, cmd := m.stopLoop(id)
|
||||
if len(m.loops) != 1 || m.findLoop(id) != nil {
|
||||
t.Fatalf("stopLoop(%s) should remove exactly that loop", id)
|
||||
}
|
||||
// Stopping one of several loops must keep the poll ticker alive for the rest:
|
||||
// removeLoop kills the ticker, so stopLoop has to re-arm it (jatmn).
|
||||
if cmd == nil || !m.loopTicking {
|
||||
t.Fatal("stopping one of several loops must leave the remaining loop scheduled (ticker re-armed)")
|
||||
}
|
||||
// Bare stop with a single active loop stops that sole loop (parseLoopCommand
|
||||
// leaves the target empty and stopLoop special-cases the one-loop case).
|
||||
soleID := m.loops[0].id
|
||||
m, _ = m.stopLoop("")
|
||||
if len(m.loops) != 0 || m.findLoop(soleID) != nil {
|
||||
t.Fatalf("bare stop should remove the sole active loop")
|
||||
}
|
||||
m = startFixedLoop(m, "c", time.Minute)
|
||||
m = startFixedLoop(m, "d", time.Minute)
|
||||
m, _ = m.stopAllLoops()
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatalf("stopAllLoops should clear all loops")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireDueLoopSkipsWhenBusy(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", time.Minute)
|
||||
m.pending = true // a turn is in flight
|
||||
got, cmd := m.fireDueLoopIfIdle()
|
||||
if got.activeLoopID != "" || cmd != nil {
|
||||
t.Fatalf("a due loop must not fire while a turn is pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireDueLoopSkipsBehindModal(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "poll", time.Minute) // due immediately
|
||||
m.picker = &commandPicker{} // e.g. an open /resume session picker
|
||||
got, cmd := m.fireDueLoopIfIdle()
|
||||
if got.activeLoopID != "" || cmd != nil {
|
||||
t.Fatal("a due loop must not launch a run behind an open picker/modal — it would complete into whatever session the user switches to")
|
||||
}
|
||||
if got.loops[0].nextRunAt.IsZero() {
|
||||
t.Fatal("a loop skipped because a modal is open must stay scheduled, not have its next run cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptiveSelfPaceDelayWidensOverTime(t *testing.T) {
|
||||
if adaptiveSelfPaceDelay(0) >= adaptiveSelfPaceDelay(5) {
|
||||
t.Error("early iterations should check back sooner than later ones")
|
||||
}
|
||||
if adaptiveSelfPaceDelay(20) != 30*time.Minute {
|
||||
t.Errorf("a mature loop should settle to a 30m heartbeat, got %v", adaptiveSelfPaceDelay(20))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopFooterSummary(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 15, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
if m.loopFooterSummary() != "" {
|
||||
t.Error("no loops -> empty footer summary")
|
||||
}
|
||||
m = startFixedLoop(m, "a", 5*time.Minute)
|
||||
m.loops[0].nextRunAt = now.Add(5 * time.Minute)
|
||||
got := m.loopFooterSummary()
|
||||
wantWake := "next " + now.Add(5*time.Minute).Format("3:04pm")
|
||||
if !strings.HasPrefix(got, "1 loop") || !strings.Contains(got, wantWake) {
|
||||
t.Fatalf("footer summary = %q, want it to start with a loop count and contain %q", got, wantWake)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopCommandThroughSubmit(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m.input.SetValue("/loop 5m tidy the imports")
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if len(next.loops) != 1 {
|
||||
t.Fatalf("submitting /loop should create a loop, got %d", len(next.loops))
|
||||
}
|
||||
if next.loops[0].mode != loopModeFixed || next.loops[0].interval != 5*time.Minute {
|
||||
t.Fatalf("unexpected loop from submit: %+v", next.loops[0])
|
||||
}
|
||||
if !next.loopTicking {
|
||||
t.Fatalf("submitting /loop should start the poll ticker")
|
||||
}
|
||||
|
||||
// /loop stop all through the same path clears it.
|
||||
next.input.SetValue("/loop stop all")
|
||||
updated2, _ := next.Update(testKey(tea.KeyEnter))
|
||||
final := updated2.(model)
|
||||
if len(final.loops) != 0 {
|
||||
t.Fatalf("/loop stop all should clear loops, got %d", len(final.loops))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleLoopTickIgnored(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", time.Minute)
|
||||
m.loopSeq = 7
|
||||
// A tick from before the seq changed must be dropped and stop the ticker.
|
||||
updated, cmd := m.Update(loopTickMsg{seq: 3})
|
||||
next := updated.(model)
|
||||
if next.loopTicking {
|
||||
t.Error("a stale loop tick should stop the ticker")
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Error("a stale loop tick should not reschedule")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopStopsAfterConsecutiveFailures(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "flaky", time.Minute)
|
||||
id := m.loops[0].id
|
||||
boom := errors.New("boom")
|
||||
for i := 0; i < loopMaxFailures && len(m.loops) > 0; i++ {
|
||||
m = m.advanceLoop(id, "", boom)
|
||||
}
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatalf("loop should stop after %d consecutive failed iterations", loopMaxFailures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopFailureCounterResetsOnSuccess(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "recovers", time.Minute)
|
||||
id := m.loops[0].id
|
||||
m = m.advanceLoop(id, "", errors.New("x"))
|
||||
m = m.advanceLoop(id, "", errors.New("x"))
|
||||
m = m.advanceLoop(id, "back to healthy", nil) // a success resets the streak
|
||||
if len(m.loops) != 1 {
|
||||
t.Fatalf("a recovered failure streak should not stop the loop")
|
||||
}
|
||||
if m.loops[0].failRun != 0 {
|
||||
t.Fatalf("a successful iteration should reset failRun, got %d", m.loops[0].failRun)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelRunClearsActiveLoopAndRearms(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", 5*time.Minute)
|
||||
m.activeLoopID = m.loops[0].id
|
||||
m.loops[0].nextRunAt = time.Time{} // as if the iteration is running
|
||||
m.pending = true
|
||||
m.activeRunID = 7
|
||||
m.runCancel = func() {}
|
||||
|
||||
m.cancelRun()
|
||||
|
||||
if m.activeLoopID != "" {
|
||||
t.Fatal("cancel should clear the active-loop tag so the next turn isn't misattributed")
|
||||
}
|
||||
if m.loops[0].nextRunAt.IsZero() {
|
||||
t.Fatal("cancel should re-arm the interrupted loop, not leave it running forever")
|
||||
}
|
||||
if !m.loops[0].nextRunAt.Equal(now.Add(5 * time.Minute)) {
|
||||
t.Fatalf("re-armed nextRunAt = %v, want +5m", m.loops[0].nextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLoopTargetRejectsBuiltin(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
if _, ok := m.validateLoopTarget("/model list"); ok {
|
||||
t.Error("looping a built-in command should be rejected")
|
||||
}
|
||||
if _, ok := m.validateLoopTarget("keep improving docs"); !ok {
|
||||
t.Error("a plain prompt should be a valid loop target")
|
||||
}
|
||||
if _, ok := m.validateLoopTarget(""); ok {
|
||||
t.Error("an empty target should be rejected")
|
||||
}
|
||||
// A custom command that does not exist is rejected up front, so it is never
|
||||
// scheduled and then re-sent as literal "/…" text on every tick.
|
||||
if _, ok := m.validateLoopTarget("/babysit-prs"); ok {
|
||||
t.Error("an unresolved custom command should be rejected")
|
||||
}
|
||||
// A registered custom command is accepted.
|
||||
m.userCommands = []usercommands.Command{{Name: "babysit-prs", Template: "check the PRs"}}
|
||||
if _, ok := m.validateLoopTarget("/babysit-prs"); !ok {
|
||||
t.Error("an existing custom command should be a valid loop target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfPacedCommandLoopRejected(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m.userCommands = []usercommands.Command{{Name: "babysit-prs", Template: "check the PRs"}}
|
||||
// A /command with no interval parses as self-paced, but self-paced mode drives
|
||||
// a free-form goal via the playbook + LOOP: protocol, which a command's
|
||||
// expanded template never carries — so a bare self-paced /command is rejected.
|
||||
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "/babysit-prs"})
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatal("a bare self-paced /command loop should be rejected (a command loop needs an interval)")
|
||||
}
|
||||
// The same command with an interval (fixed) is accepted.
|
||||
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: "/babysit-prs", interval: 5 * time.Minute})
|
||||
if len(m.loops) != 1 {
|
||||
t.Fatal("a fixed-interval /command loop should be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearLoopsForSessionSwitch(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "a", time.Minute)
|
||||
m = startFixedLoop(m, "b", time.Minute)
|
||||
m.activeLoopID = m.loops[0].id
|
||||
m, n := m.clearLoopsForSessionSwitch()
|
||||
if n != 2 || len(m.loops) != 0 || m.activeLoopID != "" || m.loopTicking {
|
||||
t.Fatalf("session switch should clear all loop state; got n=%d loops=%d active=%q ticking=%v",
|
||||
n, len(m.loops), m.activeLoopID, m.loopTicking)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartNewSessionStopsLoops(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", time.Minute)
|
||||
m = m.startNewSession()
|
||||
if len(m.loops) != 0 || m.loopTicking {
|
||||
t.Fatal("/new should stop loops tied to the previous session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopClearRequiresConfirm(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", time.Minute)
|
||||
|
||||
// First /clear with a loop active arms the confirm and does not clear yet.
|
||||
m.input.SetValue("/clear")
|
||||
u1, _ := m.Update(testKey(tea.KeyEnter))
|
||||
n1 := u1.(model)
|
||||
if n1.loopLeavePrompt != commandClear {
|
||||
t.Fatal("first /clear with active loops should arm the confirm, not clear")
|
||||
}
|
||||
if len(n1.loops) != 1 {
|
||||
t.Fatal("the loop should still be active after the warning")
|
||||
}
|
||||
|
||||
// A different command disarms the confirm.
|
||||
n1.input.SetValue("/loop list")
|
||||
u2, _ := n1.Update(testKey(tea.KeyEnter))
|
||||
n2 := u2.(model)
|
||||
if n2.loopLeavePrompt != commandEmpty {
|
||||
t.Fatal("an intervening command should disarm the leave confirm")
|
||||
}
|
||||
|
||||
// Re-arm, then a second consecutive /clear confirms (disarms + clears).
|
||||
n2.input.SetValue("/clear")
|
||||
u3, _ := n2.Update(testKey(tea.KeyEnter))
|
||||
n3 := u3.(model)
|
||||
n3.input.SetValue("/clear")
|
||||
u4, _ := n3.Update(testKey(tea.KeyEnter))
|
||||
n4 := u4.(model)
|
||||
if n4.loopLeavePrompt != commandEmpty {
|
||||
t.Fatal("a second consecutive /clear should confirm and disarm")
|
||||
}
|
||||
if len(n4.loops) != 1 {
|
||||
t.Fatal("/clear keeps loops running (it wipes the screen, not the session)")
|
||||
}
|
||||
// The confirmed clear must actually wipe the transcript, not silently no-op:
|
||||
// only the clear path appends the "Transcript cleared" note, and the pre-clear
|
||||
// loop-start row must be gone.
|
||||
clearedNote, staleRow := false, false
|
||||
for _, r := range n4.transcript {
|
||||
if strings.Contains(r.text, "Transcript cleared") {
|
||||
clearedNote = true
|
||||
}
|
||||
if strings.Contains(r.text, "Loop L1 started") {
|
||||
staleRow = true
|
||||
}
|
||||
}
|
||||
if !clearedNote || staleRow {
|
||||
t.Fatalf("the second /clear must wipe the transcript (clearedNote=%v staleRow=%v)", clearedNote, staleRow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaveConfirmDisarmedByQueuedPrompt(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", time.Minute)
|
||||
|
||||
// Arm the /clear confirm.
|
||||
m.input.SetValue("/clear")
|
||||
u1, _ := m.Update(testKey(tea.KeyEnter))
|
||||
n1 := u1.(model)
|
||||
if n1.loopLeavePrompt != commandClear {
|
||||
t.Fatal("first /clear should arm the confirm")
|
||||
}
|
||||
|
||||
// A prompt submitted while a run streams is queued via an early return in
|
||||
// handleSubmit; it must still disarm the confirm (the disarm runs before those
|
||||
// returns). Otherwise a later /clear would be treated as the second press.
|
||||
n1.pending = true
|
||||
n1.input.SetValue("a follow-up question")
|
||||
u2, _ := n1.Update(testKey(tea.KeyEnter))
|
||||
n2 := u2.(model)
|
||||
if n2.loopLeavePrompt != commandEmpty {
|
||||
t.Fatal("a queued prompt should disarm the leave confirm; the early return must not skip the disarm")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseLoopCommandModes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
action loopAction
|
||||
mode loopMode
|
||||
prompt string
|
||||
interval time.Duration
|
||||
}{
|
||||
{"", loopActionUsage, 0, "", 0},
|
||||
{"list", loopActionList, 0, "", 0},
|
||||
{"status", loopActionList, 0, "", 0},
|
||||
{"stop all", loopActionStopAll, 0, "", 0},
|
||||
{"stop ab12", loopActionStop, 0, "", 0},
|
||||
{"5m /babysit-prs", loopActionStart, loopModeFixed, "/babysit-prs", 5 * time.Minute},
|
||||
{"90s check the build", loopActionStart, loopModeFixed, "check the build", 90 * time.Second},
|
||||
{"watch CI every 2m", loopActionStart, loopModeFixed, "watch CI", 2 * time.Minute},
|
||||
{"keep improving the docs", loopActionStart, loopModeSelfPaced, "keep improving the docs", 0},
|
||||
// "every PR" is not a cadence — the token after "every" isn't a duration.
|
||||
{"review every PR", loopActionStart, loopModeSelfPaced, "review every PR", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := parseLoopCommand(c.in)
|
||||
if got.action != c.action {
|
||||
t.Errorf("%q action = %v, want %v", c.in, got.action, c.action)
|
||||
}
|
||||
if c.action == loopActionStart {
|
||||
if got.mode != c.mode {
|
||||
t.Errorf("%q mode = %v, want %v", c.in, got.mode, c.mode)
|
||||
}
|
||||
if got.prompt != c.prompt {
|
||||
t.Errorf("%q prompt = %q, want %q", c.in, got.prompt, c.prompt)
|
||||
}
|
||||
if got.interval != c.interval {
|
||||
t.Errorf("%q interval = %v, want %v", c.in, got.interval, c.interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLoopCommandStopTarget(t *testing.T) {
|
||||
if got := parseLoopCommand("stop"); got.action != loopActionStop || got.targetID != "" {
|
||||
t.Fatalf("bare stop = %+v, want stop with empty target", got)
|
||||
}
|
||||
if got := parseLoopCommand("stop XyZ9"); got.targetID != "XyZ9" {
|
||||
t.Fatalf("stop target = %q, want XyZ9", got.targetID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampLoopInterval(t *testing.T) {
|
||||
if d, note := clampLoopInterval(5 * time.Second); d != loopMinInterval || note == "" {
|
||||
t.Fatalf("below-min = (%v,%q), want raised to min with note", d, note)
|
||||
}
|
||||
if d, note := clampLoopInterval(48 * time.Hour); d != loopMaxInterval || note == "" {
|
||||
t.Fatalf("above-max = (%v,%q), want lowered to max with note", d, note)
|
||||
}
|
||||
if d, note := clampLoopInterval(5 * time.Minute); d != 5*time.Minute || note != "" {
|
||||
t.Fatalf("in-range = (%v,%q), want unchanged, no note", d, note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampSelfPaceDelay(t *testing.T) {
|
||||
if got := clampSelfPaceDelay(0); got != loopSelfPaceDefault {
|
||||
t.Errorf("zero delay = %v, want default %v", got, loopSelfPaceDefault)
|
||||
}
|
||||
if got := clampSelfPaceDelay(10 * time.Second); got != loopSelfPaceMin {
|
||||
t.Errorf("below-min = %v, want %v", got, loopSelfPaceMin)
|
||||
}
|
||||
if got := clampSelfPaceDelay(3 * time.Hour); got != loopSelfPaceMax {
|
||||
t.Errorf("above-max = %v, want %v", got, loopSelfPaceMax)
|
||||
}
|
||||
if got := clampSelfPaceDelay(10 * time.Minute); got != 10*time.Minute {
|
||||
t.Errorf("in-band = %v, want unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLoopDuration(t *testing.T) {
|
||||
cases := map[time.Duration]string{
|
||||
30 * time.Second: "30s",
|
||||
90 * time.Second: "90s",
|
||||
5 * time.Minute: "5m",
|
||||
2 * time.Hour: "2h",
|
||||
24 * time.Hour: "1d",
|
||||
}
|
||||
for d, want := range cases {
|
||||
if got := formatLoopDuration(d); got != want {
|
||||
t.Errorf("formatLoopDuration(%v) = %q, want %q", d, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLoopControl(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
done bool
|
||||
wake time.Duration
|
||||
}{
|
||||
{"did the work\nLOOP: DONE", true, 0},
|
||||
{"progress\nLOOP: CONTINUE", false, 0},
|
||||
{"progress\nLOOP: CONTINUE 10m", false, 10 * time.Minute},
|
||||
{"progress\n- loop: continue 1h", false, time.Hour}, // markdown bullet, lowercase
|
||||
{"LOOP: CONTINUE 5m\n...more...\nLOOP: DONE", true, 0}, // last control line wins
|
||||
{"no control line here", false, 0}, // absent -> adaptive
|
||||
{"LOOP: DONE — all tests pass", true, 0}, // a trailing note is fine
|
||||
{"progress\nLOOP: CONTINUE 5m — still tidying", false, 5 * time.Minute}, // note after the interval
|
||||
{"work done.\r\nLOOP: DONE\r\n", true, 0}, // CRLF endings still parse
|
||||
{"work\r\nLOOP: CONTINUE 10m\r\n", false, 10 * time.Minute}, // CRLF + interval
|
||||
{"I will emit LOOP: DONE when finished", false, 0}, // mid-sentence mention doesn't match
|
||||
{"LOOP: CONTINUE 99z", false, 0}, // bad unit -> continue, no wake hint
|
||||
}
|
||||
for _, c := range cases {
|
||||
done, wake := parseLoopControl(c.in)
|
||||
if done != c.done || wake != c.wake {
|
||||
t.Errorf("parseLoopControl(%q) = (%v,%v), want (%v,%v)", c.in, done, wake, c.done, c.wake)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirePromptWrapsSelfPacedOnly(t *testing.T) {
|
||||
self := &loopState{mode: loopModeSelfPaced, prompt: "tidy the docs"}
|
||||
got := self.firePrompt()
|
||||
if !strings.Contains(got, "LOOP: DONE") || !strings.Contains(got, "tidy the docs") {
|
||||
t.Errorf("self-paced firePrompt should embed the playbook and goal, got %q", got)
|
||||
}
|
||||
fixed := &loopState{mode: loopModeFixed, prompt: "check the build"}
|
||||
if fixed.firePrompt() != "check the build" {
|
||||
t.Errorf("fixed-interval firePrompt should be verbatim, got %q", fixed.firePrompt())
|
||||
}
|
||||
// A self-paced /command is a real command, not a goal — send it verbatim, no playbook.
|
||||
cmd := &loopState{mode: loopModeSelfPaced, prompt: "/babysit"}
|
||||
if cmd.firePrompt() != "/babysit" {
|
||||
t.Errorf("self-paced /command firePrompt should be verbatim, got %q", cmd.firePrompt())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopStateDue(t *testing.T) {
|
||||
base := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
l := &loopState{nextRunAt: base}
|
||||
if l.due(base.Add(-time.Second)) {
|
||||
t.Error("loop should not be due before nextRunAt")
|
||||
}
|
||||
if !l.due(base) {
|
||||
t.Error("loop should be due at nextRunAt")
|
||||
}
|
||||
l.paused = true
|
||||
if l.due(base.Add(time.Hour)) {
|
||||
t.Error("paused loop should never be due")
|
||||
}
|
||||
}
|
||||
+260
-27
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/skills"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/usage"
|
||||
@@ -62,6 +63,7 @@ type model struct {
|
||||
ctx context.Context
|
||||
cwd string
|
||||
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
|
||||
loadSkills func() []skills.Skill // lazy installed-skills loader for /skills + /<skill-name>
|
||||
userConfigPath string
|
||||
doctorUserConfigPath string
|
||||
projectConfigPath string
|
||||
@@ -188,10 +190,22 @@ type model struct {
|
||||
// like a frozen terminal (for ANY provider, not just slow ones). Zero = idle.
|
||||
turnStartedAt time.Time
|
||||
queuedMessage string
|
||||
exiting bool
|
||||
runCancel context.CancelFunc
|
||||
runID int
|
||||
activeRunID int
|
||||
// loops holds the session's active /loop definitions (see loop.go). activeLoopID
|
||||
// tags the in-flight run when it is a loop iteration (empty = a user turn), so the
|
||||
// completion seam knows whether to advance a loop. loopSeq invalidates a stale
|
||||
// pending poll tick when loops are stopped; loopTicking guards against scheduling
|
||||
// a second poll ticker. loopLeavePrompt arms a one-shot confirm before /clear or
|
||||
// /quit while loops are active (see handleSubmit).
|
||||
loops []*loopState
|
||||
activeLoopID string
|
||||
loopSeq int
|
||||
loopCounter int
|
||||
loopTicking bool
|
||||
loopLeavePrompt commandKind
|
||||
exiting bool
|
||||
runCancel context.CancelFunc
|
||||
runID int
|
||||
activeRunID int
|
||||
// flushRunIDs holds the ids of runs cancelled while still in flight, mapped
|
||||
// to the session they were recording into AT CANCEL TIME. Each cancelled
|
||||
// agent goroutine keeps running to completion and returns its accumulated
|
||||
@@ -735,6 +749,7 @@ func newModel(ctx context.Context, options Options) model {
|
||||
cwd: cwd,
|
||||
swarmDoneAt: map[string]time.Time{},
|
||||
userCommands: loadedUserCommands,
|
||||
loadSkills: options.LoadSkills,
|
||||
composerCursorVisible: true,
|
||||
userConfigPath: options.UserConfigPath,
|
||||
doctorUserConfigPath: doctorUserConfigPath,
|
||||
@@ -1354,7 +1369,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// home screen or a narrow terminal. Hiding reflows the chat to full
|
||||
// width, so mirror the width-change bookkeeping (re-wrap the streaming
|
||||
// fade, resize the composer) the WindowSizeMsg path does.
|
||||
if m.noBlockingModal() && m.sidebarToggleAllowed() {
|
||||
if !m.transcriptDetailed && m.noBlockingModal() && m.sidebarToggleAllowed() {
|
||||
// Just show/hide — no transcript notice. The reflow IS the feedback,
|
||||
// and emitting a line every toggle piled up noise in the chat.
|
||||
m.sidebarHidden = !m.sidebarHidden
|
||||
@@ -1421,22 +1436,50 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
case keyIs(msg, tea.KeyPgUp):
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
}
|
||||
// A stationary mouse over a bodyY-keyed transcript hover target would
|
||||
// otherwise stay lit at the scrolled-away bodyY until the next real
|
||||
// motion event (see clearHover) — same reasoning as the wheel-scroll
|
||||
// cases in mouse.go.
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(m.chatPageScrollLines()), nil
|
||||
case keyIs(msg, tea.KeyPgDown):
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(-m.chatPageScrollLines()), nil
|
||||
case keyShift(msg) && keyIs(msg, tea.KeyUp):
|
||||
// Shift+Up scrolls the transcript up one line. Must be checked before
|
||||
// plain KeyUp so shifted arrows aren't consumed by the composer-path.
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
}
|
||||
if m.pendingPermission != nil {
|
||||
return m.movePermissionCursor(-1), nil
|
||||
}
|
||||
if m.pendingAskUser != nil {
|
||||
return m.moveAskUserCursor(-1), nil
|
||||
}
|
||||
if m.providerWizard != nil {
|
||||
return m.handleProviderWizardKey(msg)
|
||||
}
|
||||
if m.mcpAddWizard != nil {
|
||||
return m.handleMCPAddWizardKey(msg)
|
||||
}
|
||||
if m.mcpManager != nil {
|
||||
return m.handleMCPManagerKey(msg)
|
||||
}
|
||||
if m.picker != nil {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.pickerMoved(-1)
|
||||
return m, nil
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
break
|
||||
}
|
||||
if m.composerValue() != "" {
|
||||
break // let the input handle multiline navigation
|
||||
}
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(-m.chatPageScrollLines()), nil
|
||||
case keyIs(msg, tea.KeyDown):
|
||||
return m.scrollChat(1), nil
|
||||
case keyShift(msg) && keyIs(msg, tea.KeyDown):
|
||||
// Shift+Down scrolls the transcript down one line. Must be checked
|
||||
// before plain KeyDown so shifted arrows aren't consumed.
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
}
|
||||
@@ -1462,6 +1505,41 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.pickerMoved(1)
|
||||
return m, nil
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
break
|
||||
}
|
||||
if m.composerValue() != "" {
|
||||
break // let the input handle multiline navigation
|
||||
}
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(-1), nil
|
||||
case keyIs(msg, tea.KeyDown):
|
||||
if m.transcriptDetailed {
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(-1), nil
|
||||
}
|
||||
if m.pendingPermission != nil {
|
||||
return m.movePermissionCursor(1), nil
|
||||
}
|
||||
if m.pendingAskUser != nil {
|
||||
return m.moveAskUserCursor(1), nil
|
||||
}
|
||||
if m.providerWizard != nil {
|
||||
return m.handleProviderWizardKey(msg)
|
||||
}
|
||||
if m.mcpAddWizard != nil {
|
||||
return m.handleMCPAddWizardKey(msg)
|
||||
}
|
||||
if m.mcpManager != nil {
|
||||
return m.handleMCPManagerKey(msg)
|
||||
}
|
||||
if m.picker != nil {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.pickerMoved(1)
|
||||
return m, nil
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
m.moveSuggestion(1)
|
||||
return m, nil
|
||||
@@ -1480,7 +1558,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(1), nil
|
||||
}
|
||||
if m.pendingPermission != nil {
|
||||
return m.movePermissionCursor(-1), nil
|
||||
@@ -1514,6 +1593,48 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.historyRecallActive() {
|
||||
return m.recallHistory(-1), nil
|
||||
}
|
||||
case keyCtrl(msg, 'u'):
|
||||
// Ctrl+U scrolls up half a page, or moves the cursor up in
|
||||
// permission/ask-user prompts. Falls through to the active modal
|
||||
// (wizard, etc.) when none of the above are in focus.
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
}
|
||||
if m.pendingPermission != nil {
|
||||
return m.movePermissionCursor(-1), nil
|
||||
}
|
||||
if m.pendingAskUser != nil {
|
||||
return m.moveAskUserCursor(-1), nil
|
||||
}
|
||||
if m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.pendingSpecReview != nil {
|
||||
break
|
||||
}
|
||||
if m.composerValue() != "" {
|
||||
break // let the input handle its own Ctrl+U (delete-to-bol)
|
||||
}
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(m.chatPageScrollLines()), nil
|
||||
case keyCtrl(msg, 'd'):
|
||||
// Ctrl+D scrolls down half a page, or moves the cursor down in
|
||||
// permission/ask-user prompts. Falls through to the active modal
|
||||
// when none of the above are in focus.
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
}
|
||||
if m.pendingPermission != nil {
|
||||
return m.movePermissionCursor(1), nil
|
||||
}
|
||||
if m.pendingAskUser != nil {
|
||||
return m.moveAskUserCursor(1), nil
|
||||
}
|
||||
if m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.pendingSpecReview != nil {
|
||||
break
|
||||
}
|
||||
if m.composerValue() != "" {
|
||||
break // let the input handle its own Ctrl+D (delete-next-char)
|
||||
}
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(-m.chatPageScrollLines()), nil
|
||||
}
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
@@ -1787,6 +1908,16 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.clearComposer()
|
||||
m.clearSuggestions()
|
||||
return m, nil
|
||||
case loopTickMsg:
|
||||
// Idle poll for due loops. A stale tick (loops changed) or an empty loop set
|
||||
// ends the ticker; otherwise fire the earliest due loop if idle and reschedule.
|
||||
if msg.seq != m.loopSeq || len(m.loops) == 0 {
|
||||
m.loopTicking = false
|
||||
return m, nil
|
||||
}
|
||||
var fireCmd tea.Cmd
|
||||
m, fireCmd = m.fireDueLoopIfIdle()
|
||||
return m, tea.Batch(fireCmd, m.scheduleLoopTick())
|
||||
case agentResponseMsg:
|
||||
if msg.runID != m.activeRunID {
|
||||
// A run cancelled while in flight still finishes in its goroutine and
|
||||
@@ -1956,8 +2087,25 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// complete once the turn settles.
|
||||
var sweepCmd tea.Cmd
|
||||
m, sweepCmd = m.maybeGitSweep()
|
||||
// If this run was a loop iteration, advance that loop (schedule its next wake
|
||||
// or stop it). Done before launchQueuedMessageIfReady so a user's queued prompt
|
||||
// still wins the immediate re-launch; the loop fires on the next idle tick.
|
||||
var loopTickCmd tea.Cmd
|
||||
if loopID := m.activeLoopID; loopID != "" {
|
||||
m.activeLoopID = ""
|
||||
loopFinalAnswer := ""
|
||||
for _, row := range msg.rows {
|
||||
if row.kind == rowAssistant && row.final {
|
||||
loopFinalAnswer = row.text
|
||||
}
|
||||
}
|
||||
m = m.advanceLoop(loopID, loopFinalAnswer, msg.err)
|
||||
// advanceLoop -> removeLoop may have stopped the ticker; restart it if
|
||||
// other loops remain.
|
||||
m, loopTickCmd = m.ensureLoopTick()
|
||||
}
|
||||
next, queuedCmd := m.launchQueuedMessageIfReady()
|
||||
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd)
|
||||
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd)
|
||||
case sessionTitleGeneratedMsg:
|
||||
return m.handleSessionTitleGenerated(msg)
|
||||
case recapGeneratedMsg:
|
||||
@@ -2215,14 +2363,23 @@ func (m model) View() tea.View {
|
||||
}
|
||||
view.ReportFocus = m.notifier != nil
|
||||
if m.wantsMouseCapture() {
|
||||
// AllMotion (not CellMotion) is required for hover highlighting: it
|
||||
// reports cursor movement even with no button pressed. CellMotion only
|
||||
// reports motion while a button is held (drag) — see bubbletea's
|
||||
// MouseMode docs. AllMotion has marginally worse terminal compatibility
|
||||
// but is well supported by the terminals this app targets; the existing
|
||||
// 15ms mouse-event throttle (mouseEventThrottleInterval) already bounds
|
||||
// the redraw rate from the extra motion events.
|
||||
view.MouseMode = tea.MouseModeAllMotion
|
||||
if isRunningUnderPRoot() {
|
||||
// Under PRoot the AllMotion (1003) sequence doesn't work
|
||||
// reliably, breaking touch-gesture scrolling. Fall back to
|
||||
// CellMotion which still delivers wheel events, clicks, and
|
||||
// drag — the only thing lost is hover-highlighting.
|
||||
view.MouseMode = tea.MouseModeCellMotion
|
||||
} else {
|
||||
// AllMotion (not CellMotion) is required for hover highlighting:
|
||||
// it reports cursor movement even with no button pressed.
|
||||
// CellMotion only reports motion while a button is held (drag) —
|
||||
// see bubbletea's MouseMode docs. AllMotion has marginally worse
|
||||
// terminal compatibility but is well supported by the terminals
|
||||
// this app targets; the existing 15ms mouse-event throttle
|
||||
// (mouseEventThrottleInterval) already bounds the redraw rate
|
||||
// from the extra motion events.
|
||||
view.MouseMode = tea.MouseModeAllMotion
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
@@ -2297,7 +2454,7 @@ func (m model) transcriptView() string {
|
||||
if m.transcriptEmpty() && !m.pending && viewportOverlay != "" {
|
||||
emptyOverlay = viewportOverlay
|
||||
}
|
||||
bodyItems := m.transcriptBodyItems(width, emptyOverlay)
|
||||
bodyItems := m.transcriptBodyItems(width, emptyOverlay, false)
|
||||
|
||||
footer := m.footerView(width)
|
||||
|
||||
@@ -2337,7 +2494,7 @@ func (m model) twoColumnTranscriptView() string {
|
||||
width := chatW
|
||||
|
||||
suggestionOverlay := m.suggestionOverlay(width)
|
||||
bodyItems := m.transcriptBodyItems(width, "")
|
||||
bodyItems := m.transcriptBodyItems(width, "", false)
|
||||
footer := m.footerView(width)
|
||||
overlayForViewport := suggestionOverlay
|
||||
if m.transcriptEmpty() && !m.pending {
|
||||
@@ -2767,7 +2924,15 @@ func (m model) chatTranscriptViewport() (transcriptViewport, bool) {
|
||||
return transcriptViewport{}, false
|
||||
}
|
||||
width := m.chatColumnWidth()
|
||||
items := m.transcriptBodyItems(width, "")
|
||||
if m.transcriptDetailed {
|
||||
items := m.transcriptBodyItems(width, "", true)
|
||||
body := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
|
||||
header := detailedTranscriptHeader(width) + "\n" + zeroTheme.line.Render(strings.Repeat("-", width))
|
||||
footer := m.detailedTranscriptFooter(width)
|
||||
frame := m.scrollableTranscriptFrame(header, footer)
|
||||
return transcriptViewportForLayout(body, frame, m.chatScrollOffset), true
|
||||
}
|
||||
items := m.transcriptBodyItems(width, "", false)
|
||||
body := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
|
||||
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
|
||||
return transcriptViewportForLayout(body, frame, m.chatScrollOffset), true
|
||||
@@ -3607,6 +3772,11 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
|
||||
if text != "" {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
}
|
||||
case pickerSkill:
|
||||
// Fill the composer with "/name " so the user adds their request before
|
||||
// submitting (a bare second Enter runs it without one); names the slash
|
||||
// path cannot reach run immediately instead.
|
||||
m, cmd = m.chooseSkillFromPicker(item)
|
||||
case pickerTheme:
|
||||
// The hovered palette is already live from the preview; handleThemeCommand
|
||||
// records the choice (m.themeMode) and re-applies it, and reports the switch.
|
||||
@@ -3658,6 +3828,13 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
command := parseCommand(input)
|
||||
// A pending /clear or /quit leave-confirmation is armed for the immediately-next
|
||||
// repeat of that same command only; any other submission — including one queued
|
||||
// or deferred by the early returns below — disarms it. Runs before those returns
|
||||
// so an interposed prompt can't be skipped over and leave the gate falsely armed.
|
||||
if m.loopLeavePrompt != commandEmpty && command.kind != m.loopLeavePrompt {
|
||||
m.loopLeavePrompt = commandEmpty
|
||||
}
|
||||
// While exiting (Ctrl+C waiting on the cancelled run's checkpoint flush) a
|
||||
// new run must not start: the deferred tea.Quit would abort it mid-flight
|
||||
// and orphan its checkpoint blobs — the exact loss flushRunIDs prevents.
|
||||
@@ -3691,6 +3868,14 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: helpText()})
|
||||
return m, nil
|
||||
case commandClear:
|
||||
// A foreground loop keeps firing after /clear (it wipes the screen, not the
|
||||
// session), so warn once before clearing the context it will run into.
|
||||
if m.loopActive() && m.loopLeavePrompt != commandClear {
|
||||
m.loopLeavePrompt = commandClear
|
||||
m = m.appendLoopSystem(m.loopFooterSummary() + " still running — /clear keeps them firing behind a cleared screen. Run /clear again to confirm, or /loop stop all first.")
|
||||
return m, nil
|
||||
}
|
||||
m.loopLeavePrompt = commandEmpty
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionClear})
|
||||
// Clearing wipes the visible transcript only — the session's context is
|
||||
// intact, so the next prompt still replays the full history. Say so, and
|
||||
@@ -3699,6 +3884,11 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
// Scrollback above can't be un-printed; a faint divider marks where the
|
||||
// cleared surface ended and the frontier restarts for the fresh transcript.
|
||||
m.resetFlushFrontier("· cleared ·")
|
||||
// /clear wipes the transcript but not the session, so loops keep running;
|
||||
// say so rather than let them silently fire into a "cleared" screen.
|
||||
if m.loopActive() {
|
||||
m = m.appendLoopSystem(m.loopFooterSummary() + " still running — /loop stop all to end them.")
|
||||
}
|
||||
return m, nil
|
||||
case commandNew:
|
||||
// A fresh session mid-run would strand the in-flight turn's events; make the
|
||||
@@ -3709,7 +3899,17 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
return m.startNewSession(), nil
|
||||
case commandLoop:
|
||||
return m.handleLoopCommand(command.text)
|
||||
case commandExit:
|
||||
// Closing the session stops its foreground loops mid-task; warn once so a
|
||||
// token-spending loop isn't ended by reflex.
|
||||
if m.loopActive() && m.loopLeavePrompt != commandExit {
|
||||
m.loopLeavePrompt = commandExit
|
||||
m = m.appendLoopSystem(m.loopFooterSummary() + " active — closing the session stops them. Run /exit again to confirm, or /loop stop all first.")
|
||||
return m, nil
|
||||
}
|
||||
m.loopLeavePrompt = commandEmpty
|
||||
// /exit gets the same protection as Ctrl+C: cancel any in-flight run and
|
||||
// defer the quit until its checkpoint session events flush — quitting
|
||||
// immediately would orphan the blobs and break /rewind.
|
||||
@@ -3722,6 +3922,15 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
case commandTools:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.toolsText()})
|
||||
return m, nil
|
||||
case commandSkills:
|
||||
// With skills installed, /skills opens a searchable picker (like /model);
|
||||
// the text card remains only as the no-skills install hint.
|
||||
if picker := m.newSkillPicker(); picker != nil {
|
||||
m.picker = picker
|
||||
return m, nil
|
||||
}
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.skillsText()})
|
||||
return m, nil
|
||||
case commandMCP:
|
||||
if strings.TrimSpace(command.text) == "" {
|
||||
return m.openMCPManager(), nil
|
||||
@@ -3917,6 +4126,11 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
if next, cmd, handled := m.handleUserCommand(command.text); handled {
|
||||
return next, cmd
|
||||
}
|
||||
// Then an installed skill: "/<skill-name> [args]" runs the skill directly
|
||||
// (deterministic invocation, vs waiting for the model to pull it in).
|
||||
if next, cmd, handled := m.handleSkillCommand(command.text); handled {
|
||||
return next, cmd
|
||||
}
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendError,
|
||||
text: "unknown command: " + command.text,
|
||||
@@ -4188,6 +4402,21 @@ func (m *model) cancelRun() {
|
||||
m.runCancel()
|
||||
}
|
||||
m.clearStreamingToolCall() // a cancelled file-write must not linger into the next run
|
||||
// A cancelled loop iteration bypasses the agentResponseMsg completion seam (its
|
||||
// late message is drained through flushRunIDs, not advanceLoop), so clear the
|
||||
// loop tag here and re-arm the interrupted loop for its next cadence. Otherwise
|
||||
// the loop is left "running" forever (nextRunAt stays zero) and the NEXT
|
||||
// unrelated turn would be misattributed as this loop's completion.
|
||||
if m.activeLoopID != "" {
|
||||
if l := m.findLoop(m.activeLoopID); l != nil {
|
||||
if l.mode == loopModeSelfPaced {
|
||||
l.nextRunAt = m.now().Add(clampSelfPaceDelay(adaptiveSelfPaceDelay(l.iteration)))
|
||||
} else {
|
||||
l.nextRunAt = m.now().Add(l.interval)
|
||||
}
|
||||
}
|
||||
m.activeLoopID = ""
|
||||
}
|
||||
// Remember the in-flight run — and the session it was recording into — so
|
||||
// its final agentResponseMsg is still drained for session-event persistence
|
||||
// after activeRunID is cleared. Otherwise the checkpoint blobs it captured
|
||||
@@ -4323,6 +4552,10 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
IncludeLSP: true,
|
||||
Autonomy: selfCorrectAutonomyForMode(options.PermissionMode),
|
||||
})
|
||||
// Background post-edit diagnostics: the loop checks files changed by
|
||||
// edit_file/write_file off the tool-call path and nudges the model with
|
||||
// any errors before its next request. Shares the run's lazy manager.
|
||||
options.FileDiagnostics = agent.NewFileDiagnostics(lspManager, options.Cwd)
|
||||
}
|
||||
|
||||
// Some providers synthesize tool-call ids that repeat within a run (e.g.
|
||||
|
||||
@@ -2142,7 +2142,7 @@ func TestTranscriptSelectionPaintsHighlightOnceNotTwice(t *testing.T) {
|
||||
width := m.chatColumnWidth()
|
||||
|
||||
findRow := func(mm model) (transcriptBodyItem, bool) {
|
||||
for _, it := range mm.transcriptBodyItems(width, "") {
|
||||
for _, it := range mm.transcriptBodyItems(width, "", false) {
|
||||
if it.kind == transcriptBodyItemRow && it.rowIndex >= 0 {
|
||||
return it, true
|
||||
}
|
||||
@@ -2198,7 +2198,7 @@ func TestReasoningAfterToolCardGetsBlankSeparator(t *testing.T) {
|
||||
transcriptRow{kind: rowReasoning, text: "Considering the next step in detail before acting"},
|
||||
transcriptRow{kind: rowToolResult, id: "t2", tool: "read_file", status: tools.StatusOK, detail: "x\ny"},
|
||||
)
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
|
||||
reasoningIdx := -1
|
||||
for i := range items {
|
||||
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
|
||||
@@ -2223,7 +2223,7 @@ func TestAssistantNarrationBeforeToolCardGetsBlankSeparator(t *testing.T) {
|
||||
transcriptRow{kind: rowAssistant, text: "I'll inspect the existing file, then run it."},
|
||||
transcriptRow{kind: rowToolResult, id: "t1", tool: "read_file", status: tools.StatusOK, detail: "File: time_test.py\n\n 1 | print('x')"},
|
||||
)
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
|
||||
toolIdx := -1
|
||||
for i := range items {
|
||||
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
|
||||
@@ -2247,7 +2247,7 @@ func TestUserPromptBeforeToolCardGetsBlankSeparator(t *testing.T) {
|
||||
transcriptRow{kind: rowUser, text: "create the landing page"},
|
||||
transcriptRow{kind: rowToolResult, id: "t1", tool: "write_file", status: tools.StatusOK, detail: "--- /dev/null\n+++ b/index.html\n@@ -0,0 +1,1 @@\n+<!DOCTYPE html>"},
|
||||
)
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
|
||||
toolIdx := -1
|
||||
for i := range items {
|
||||
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
|
||||
@@ -2273,7 +2273,7 @@ func TestAssistantAfterToolCardGetsRuleSeparator(t *testing.T) {
|
||||
transcriptRow{kind: rowToolResult, id: "t1", tool: "bash", status: tools.StatusOK, detail: "stdout:\nok\nexit_code: 0"},
|
||||
transcriptRow{kind: rowAssistant, text: "Done.", final: true},
|
||||
)
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
|
||||
finalIdx := -1
|
||||
for i := range items {
|
||||
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
|
||||
@@ -2307,7 +2307,7 @@ func TestStreamingAssistantAfterToolCardGetsRuleSeparator(t *testing.T) {
|
||||
transcriptRow{kind: rowAssistant, text: "I'll run it first."},
|
||||
transcriptRow{kind: rowToolResult, id: "t1", tool: "bash", status: tools.StatusOK, detail: "stdout:\nok\nexit_code: 0"},
|
||||
)
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
|
||||
pendingIdx := -1
|
||||
for i := range items {
|
||||
if items[i].kind == transcriptBodyItemPendingInterim {
|
||||
@@ -2357,7 +2357,7 @@ func TestTranscriptBodyRowsUseFullWidthAndAlignSelection(t *testing.T) {
|
||||
t.Fatalf("expected no body gutter at width %d, got %d", width, gutter)
|
||||
}
|
||||
|
||||
items := m.transcriptBodyItems(width, "")
|
||||
items := m.transcriptBodyItems(width, "", false)
|
||||
var row *transcriptBodyItem
|
||||
for i := range items {
|
||||
if items[i].kind == transcriptBodyItemRow && items[i].rowIndex >= 0 {
|
||||
|
||||
+32
-2
@@ -1,6 +1,36 @@
|
||||
package tui
|
||||
|
||||
import tea "charm.land/bubbletea/v2"
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// parseTracerPid returns true when /proc/self/status contains a non-zero
|
||||
// TracerPid. This detects any ptrace tracer (PRoot, gdb, strace, dlv, etc.),
|
||||
// not PRoot specifically. That's intentional: under any ptrace tracer the
|
||||
// AllMotion (1003) sequence is unreliable, so CellMotion is the safer
|
||||
// fallback. The only loss is hover-highlighting, which has no functional
|
||||
// impact.
|
||||
func parseTracerPid(data []byte) bool {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasPrefix(line, "TracerPid:") {
|
||||
pid := strings.TrimSpace(line[10:])
|
||||
return pid != "0"
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var isRunningUnderPRoot = sync.OnceValue(func() bool {
|
||||
data, err := os.ReadFile("/proc/self/status")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return parseTracerPid(data)
|
||||
})
|
||||
|
||||
type mouseOverlayHit struct {
|
||||
x int
|
||||
@@ -257,7 +287,7 @@ func (m model) syncMouseCapture() (model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m model) mouseOverComposer(msg tea.MouseMsg) bool {
|
||||
if !m.altScreen || m.height <= 0 {
|
||||
if !m.altScreen || m.height <= 0 || m.transcriptDetailed {
|
||||
return false
|
||||
}
|
||||
width := m.chatColumnWidth()
|
||||
|
||||
@@ -920,6 +920,30 @@ func TestMouseCaptureOnlyDuringInteractiveSetupStages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestComposerMouseSelectionBlockedInDetailedMode: composer mouse hit-testing
|
||||
// is disabled while the detailed transcript view is active, so wheel and click
|
||||
// events in the composer area reach the transcript body instead.
|
||||
func TestComposerMouseSelectionBlockedInDetailedMode(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
m.input.SetValue("some text")
|
||||
|
||||
updated, _ := m.Update(testKeyCtrl('o'))
|
||||
m = updated.(model)
|
||||
|
||||
if !m.transcriptDetailed {
|
||||
t.Fatal("sanity check: Ctrl+O should enter detailed mode")
|
||||
}
|
||||
if !m.composerMouseSelectionBlocked() {
|
||||
t.Fatal("composerMouseSelectionBlocked should be true in detailed mode")
|
||||
}
|
||||
|
||||
width := m.chatColumnWidth()
|
||||
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
|
||||
if m.mouseOverComposer(testMouseWheel(tea.MouseWheelUp, 0, frame.composerRect.y)) {
|
||||
t.Fatal("mouseOverComposer should return false in detailed mode")
|
||||
}
|
||||
}
|
||||
|
||||
func firstTranscriptTextMouseY(t *testing.T, m model) int {
|
||||
t.Helper()
|
||||
_, y := firstTranscriptTextMousePoint(t, m)
|
||||
@@ -986,6 +1010,38 @@ func withSidebarContent(m model) model {
|
||||
return m
|
||||
}
|
||||
|
||||
func TestParseTracerPidMissing(t *testing.T) {
|
||||
if got := parseTracerPid([]byte("Pid: 123\nName: foo\n")); got {
|
||||
t.Fatal("missing TracerPid should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTracerPidZero(t *testing.T) {
|
||||
if got := parseTracerPid([]byte("TracerPid: 0\nName: foo\n")); got {
|
||||
t.Fatal("TracerPid: 0 should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTracerPidNonZero(t *testing.T) {
|
||||
if got := parseTracerPid([]byte("Name: foo\nTracerPid: 42\n")); !got {
|
||||
t.Fatal("TracerPid: 42 should return true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTracerPidMalformed(t *testing.T) {
|
||||
// Non-numeric values are treated as non-zero since any non-"0" string
|
||||
// indicates a tracer is attached, matching the function's semantics.
|
||||
if got := parseTracerPid([]byte("TracerPid: abc\n")); !got {
|
||||
t.Fatal("malformed TracerPid should return true (non-zero string)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTracerPidEmpty(t *testing.T) {
|
||||
if got := parseTracerPid([]byte{}); got {
|
||||
t.Fatal("empty input should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func setupMouseTestModel() model {
|
||||
m := newModel(context.Background(), Options{
|
||||
Setup: SetupOptions{
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/skills"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/usage"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
@@ -48,7 +49,13 @@ type Options struct {
|
||||
SessionCompactor SessionCompactor
|
||||
PrService *PrService
|
||||
|
||||
AgentOptions agent.Options
|
||||
AgentOptions agent.Options
|
||||
// LoadSkills returns the installed skills (default skills dir merged with any
|
||||
// plugin skill roots), bodies included, for /skills and direct /<skill-name>
|
||||
// invocation. Called lazily per use so newly installed skills are picked up
|
||||
// without a restart. Nil means the session has no skills wiring (skills stay
|
||||
// model-pulled via the skill tool only).
|
||||
LoadSkills func() []skills.Skill
|
||||
PermissionMode agent.PermissionMode
|
||||
ReasoningEffort modelregistry.ReasoningEffort
|
||||
ResponseStyle string
|
||||
|
||||
@@ -309,3 +309,70 @@ func TestPermissionRenderShowsNetworkTargetAndHostScopedAlways(t *testing.T) {
|
||||
t.Fatalf("network prompt should render target label, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPermissionCursorCtrlU ensures Ctrl+U moves the permission cursor UP
|
||||
// (toward the first option). Regression: the original implementation moved
|
||||
// the cursor DOWN on Ctrl+U and UP on Ctrl+D.
|
||||
func TestPermissionCursorCtrlU(t *testing.T) {
|
||||
m := pendingPermissionModel(t, func(agent.PermissionDecision) {})
|
||||
m.pendingPermission.cursor = 2 // middle option
|
||||
|
||||
updated, _ := m.Update(testKeyCtrl('u'))
|
||||
next := updated.(model)
|
||||
if next.pendingPermission.cursor != 1 {
|
||||
t.Fatalf("Ctrl+U moved cursor to %d, want 1 (one step up)", next.pendingPermission.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPermissionCursorCtrlD ensures Ctrl+D moves the permission cursor DOWN
|
||||
// (toward the last option). Regression: the original implementation moved
|
||||
// the cursor UP on Ctrl+D and DOWN on Ctrl+U.
|
||||
func TestPermissionCursorCtrlD(t *testing.T) {
|
||||
m := pendingPermissionModel(t, func(agent.PermissionDecision) {})
|
||||
m.pendingPermission.cursor = 0 // first option
|
||||
|
||||
updated, _ := m.Update(testKeyCtrl('d'))
|
||||
next := updated.(model)
|
||||
if next.pendingPermission.cursor != 1 {
|
||||
t.Fatalf("Ctrl+D moved cursor to %d, want 1 (one step down)", next.pendingPermission.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShiftUpComposerGuard ensures Shift+Up does NOT scroll the transcript
|
||||
// when the composer has text, so it falls through to the input's own line
|
||||
// navigation.
|
||||
func TestShiftUpComposerGuard(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
// Add enough transcript rows so scrolling is valid.
|
||||
for i := 0; i < 20; i++ {
|
||||
m.transcript = appendRow(m.transcript, rowAssistant, "line")
|
||||
}
|
||||
m.input.SetValue("some text")
|
||||
m.chatScrollOffset = 5
|
||||
|
||||
updated, cmd := m.Update(testKeyShift(tea.KeyUp))
|
||||
next := updated.(model)
|
||||
_ = cmd
|
||||
if got := next.chatScrollOffset; got != 5 {
|
||||
t.Fatalf("Shift+Up with non-empty composer scrolled offset to %d, want 5 (unchanged)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShiftDownComposerGuard ensures Shift+Down does NOT scroll the transcript
|
||||
// when the composer has text, falling through to the input's navigation.
|
||||
func TestShiftDownComposerGuard(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
// Add enough transcript rows so scrolling is valid.
|
||||
for i := 0; i < 20; i++ {
|
||||
m.transcript = appendRow(m.transcript, rowAssistant, "line")
|
||||
}
|
||||
m.input.SetValue("some text")
|
||||
m.chatScrollOffset = 3
|
||||
|
||||
updated, cmd := m.Update(testKeyShift(tea.KeyDown))
|
||||
next := updated.(model)
|
||||
_ = cmd
|
||||
if got := next.chatScrollOffset; got != 3 {
|
||||
t.Fatalf("Shift+Down with non-empty composer scrolled offset to %d, want 3 (unchanged)", got)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
pickerEffort
|
||||
pickerSession
|
||||
pickerTheme
|
||||
pickerSkill
|
||||
)
|
||||
|
||||
// pickerItem is one selectable row: Label is shown, Value is passed to the
|
||||
@@ -225,6 +226,35 @@ func (m model) newModelPicker() *commandPicker {
|
||||
return &commandPicker{kind: pickerModel, title: "Choose a model", items: items, allItems: append([]pickerItem{}, items...), selected: 0}
|
||||
}
|
||||
|
||||
// newSkillPicker lists the installed skills for browse-and-run: type to filter,
|
||||
// arrow keys to select, Enter runs the chosen skill immediately. Selection is by
|
||||
// the skill's EXACT name (item.Value), so even skills the slash path cannot
|
||||
// reach — names shadowed by a builtin or user command, or names that don't fit
|
||||
// a slash token — are runnable here: picking from the Skills picker is
|
||||
// unambiguous. Returns nil when no skills are installed (the caller falls back
|
||||
// to the install-hint card).
|
||||
func (m model) newSkillPicker() *commandPicker {
|
||||
installed := m.installedSkills()
|
||||
items := make([]pickerItem, 0, len(installed))
|
||||
for _, skill := range installed {
|
||||
name := strings.TrimSpace(skill.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
label := name
|
||||
if slash := skillSlashName(name); slash != "" {
|
||||
// Show the slash form where one exists — it doubles as documentation
|
||||
// of the direct "/name [args]" invocation.
|
||||
label = "/" + slash
|
||||
}
|
||||
items = append(items, pickerItem{Label: label, Value: name, Meta: strings.TrimSpace(skill.Description)})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &commandPicker{kind: pickerSkill, title: "pick a skill · ⏎ fills /name — add your request", items: items, allItems: append([]pickerItem{}, items...), selected: 0}
|
||||
}
|
||||
|
||||
// modelPickerProviders returns the providers to list in /model: all saved
|
||||
// providers, falling back to the active profile when none were threaded in.
|
||||
func (m model) modelPickerProviders() []config.ProviderProfile {
|
||||
|
||||
@@ -511,6 +511,50 @@ func TestSelectableAssistantRowKeepsMarkdownSemanticsPlain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripMarkdownRenderControlsStripsAllANSI(t *testing.T) {
|
||||
// Bold markers that renderAssistantMarkdownText embeds for prose formatting.
|
||||
withBold := "\x1b[1mhello\x1b[22m world"
|
||||
if got := stripMarkdownRenderControls(withBold); got != "hello world" {
|
||||
t.Fatalf("stripMarkdownRenderControls(%q) = %q, want %q", withBold, got, "hello world")
|
||||
}
|
||||
// Color sequences from highlightCodeAuto for fenced code blocks.
|
||||
withColor := "\x1b[38;2;236;236;238mdef\x1b[0m hello():"
|
||||
if got := stripMarkdownRenderControls(withColor); got != "def hello():" {
|
||||
t.Fatalf("stripMarkdownRenderControls(%q) = %q, want %q", withColor, got, "def hello():")
|
||||
}
|
||||
// Sequences that combine both (bold + underline + color).
|
||||
withBoldColor := "\x1b[1;4;38;2;236;236;238mW\x1b[m"
|
||||
if got := stripMarkdownRenderControls(withBoldColor); got != "W" {
|
||||
t.Fatalf("stripMarkdownRenderControls(%q) = %q, want %q", withBoldColor, got, "W")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectedTranscriptTextStripsANSIFromHighlightedCode(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
m.width, m.height = 120, 40
|
||||
m.altScreen = true
|
||||
m.headerPrinted = true
|
||||
code := `package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
fmt.Println("hello")
|
||||
}`
|
||||
row := transcriptRow{kind: rowAssistant, text: "First, here's the code:\n\n```go\n" + code + "\n```\n\nThat's it.", final: true}
|
||||
m.transcript = append(m.transcript, row)
|
||||
m.flushed = len(m.transcript)
|
||||
|
||||
// The highlighted code block contains ANSI color sequences from chroma;
|
||||
// verify none survive in the selectable text used for clipboard copy.
|
||||
_, selectable := m.renderSelectableAssistantRow(0, row, 72, 0)
|
||||
for _, line := range selectable {
|
||||
if ansiPattern.MatchString(line.text) {
|
||||
t.Fatalf("selectable text leaked ANSI escapes: %q", line.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalAnswerRendersPlainTextAndCompletionLine(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
row := transcriptRow{
|
||||
|
||||
+39
-12
@@ -99,6 +99,12 @@ func (m model) startNewSession() model {
|
||||
// Scrollback above can't be un-printed; a faint divider marks the boundary and
|
||||
// the flush frontier restarts for the fresh transcript (mirrors /clear, /resume).
|
||||
m.resetFlushFrontier("· new session ·")
|
||||
// Loops belong to the previous session; stop them so they don't fire the old
|
||||
// conversation's prompt into the fresh one.
|
||||
if updated, cleared := m.clearLoopsForSessionSwitch(); cleared > 0 {
|
||||
m = updated
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: fmt.Sprintf("Stopped %d loop(s) tied to the previous session.", cleared)})
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -123,13 +129,24 @@ func (m model) appendSessionEvent(eventType sessions.EventType, payload any) (mo
|
||||
|
||||
func (m model) appendSessionEvents(events []pendingSessionEvent) (model, []transcriptRow) {
|
||||
rows := []transcriptRow{}
|
||||
if m.activeSession.SessionID == "" || len(events) == 0 {
|
||||
return m, rows
|
||||
}
|
||||
inputs := make([]sessions.AppendEventInput, 0, len(events))
|
||||
for _, event := range events {
|
||||
next, err := m.appendSessionEvent(event.Type, event.Payload)
|
||||
if err != nil {
|
||||
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
|
||||
continue
|
||||
}
|
||||
m = next
|
||||
inputs = append(inputs, sessions.AppendEventInput{Type: event.Type, Payload: event.Payload})
|
||||
}
|
||||
appended, err := m.sessionStore.AppendEvents(m.activeSession.SessionID, inputs)
|
||||
if err != nil {
|
||||
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
|
||||
return m, rows
|
||||
}
|
||||
if len(appended) > 0 {
|
||||
last := appended[len(appended)-1]
|
||||
m.activeSession.UpdatedAt = last.CreatedAt
|
||||
m.activeSession.EventCount = last.Sequence
|
||||
m.activeSession.LastEventType = last.Type
|
||||
m.sessionEvents = append(m.sessionEvents, appended...)
|
||||
}
|
||||
return m, rows
|
||||
}
|
||||
@@ -142,13 +159,12 @@ func (m model) appendSessionEventsTo(sessionID string, events []pendingSessionEv
|
||||
if m.sessionStore == nil || sessionID == "" {
|
||||
return rows
|
||||
}
|
||||
inputs := make([]sessions.AppendEventInput, 0, len(events))
|
||||
for _, event := range events {
|
||||
if _, err := m.sessionStore.AppendEvent(sessionID, sessions.AppendEventInput{
|
||||
Type: event.Type,
|
||||
Payload: event.Payload,
|
||||
}); err != nil {
|
||||
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
|
||||
}
|
||||
inputs = append(inputs, sessions.AppendEventInput{Type: event.Type, Payload: event.Payload})
|
||||
}
|
||||
if _, err := m.sessionStore.AppendEvents(sessionID, inputs); err != nil {
|
||||
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -195,6 +211,10 @@ func (m model) handleResumeCommand(args string) (model, string) {
|
||||
return m, "Sessions\nerror: " + err.Error()
|
||||
}
|
||||
|
||||
// Capture the current session id before switching so loops are only torn down
|
||||
// on a real change — `/resume latest` or `/resume <currentID>` can resolve to
|
||||
// the already-active session, whose loops belong to it, not a "previous" one.
|
||||
previousID := m.activeSession.SessionID
|
||||
m.activeSession = *session
|
||||
m.sessionEvents = append([]sessions.Event{}, events...)
|
||||
if m.providerName == "" {
|
||||
@@ -203,9 +223,16 @@ func (m model) handleResumeCommand(args string) (model, string) {
|
||||
if m.modelName == "" {
|
||||
m.modelName = session.ModelID
|
||||
}
|
||||
loopsCleared := 0
|
||||
if session.SessionID != previousID {
|
||||
m, loopsCleared = m.clearLoopsForSessionSwitch()
|
||||
}
|
||||
|
||||
rows := initialTranscript()
|
||||
rows = appendRow(rows, rowSystem, m.formatResumeSummary(*session, len(events)))
|
||||
if loopsCleared > 0 {
|
||||
rows = appendRow(rows, rowSystem, fmt.Sprintf("Stopped %d loop(s) tied to the previous session.", loopsCleared))
|
||||
}
|
||||
rows = appendTranscriptRowsDedup(rows, transcriptRowsFromSessionEvents(events))
|
||||
m.transcript = rows
|
||||
// Every rehydrated row is settled by construction, so resetting the flush
|
||||
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user