Compare commits

..
Author SHA1 Message Date
anandh8x fbfc2a07fa fix: add android platform support for Termux npm install
`npm install -g @gitlawb/zero` failed on Android/Termux with EBADPLATFORM
because `package.json` `os` array lacked `"android"`. Termux provides a
Linux userspace — the existing `linux-arm64` binary works natively.

- Add `"android"` to the `os` array in package.json.
- Map `android` platform to `linux` in postinstall.mjs resolvePlatform().

Fixes #449
2026-07-03 19:17:51 +05:30
124 changed files with 408 additions and 7372 deletions

No files matched your search

+1 -17
View File
@@ -4,14 +4,12 @@ on:
pull_request:
paths:
- action.yml
- docs/GITHUB_ACTION.md
- .github/workflows/zero-action-smoke.yml
push:
branches:
- main
paths:
- action.yml
- docs/GITHUB_ACTION.md
- .github/workflows/zero-action-smoke.yml
jobs:
@@ -31,8 +29,7 @@ jobs:
import sys, yaml
with open("action.yml", "r", encoding="utf-8") as handle:
action_raw = handle.read()
doc = yaml.safe_load(action_raw)
doc = yaml.safe_load(handle)
assert isinstance(doc, dict), "action.yml must be a mapping"
assert doc.get("name"), "action must declare a name"
@@ -68,19 +65,6 @@ jobs:
assert "--skip-permissions-unsafe" not in serialized, \
"action must never pass --skip-permissions-unsafe"
run_zero = next((step for step in steps if step.get("name") == "Run ZERO"), None)
assert run_zero is not None, "action.yml must contain a Run ZERO step"
run_zero_script = run_zero.get("run") or ""
assert 'export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"' in run_zero_script, \
"Run ZERO must export the provider key for the current step"
assert "later steps inherit" not in action_raw.lower(), \
"provider keys must not be documented as inherited by later steps"
for line in run_zero_script.splitlines():
if "GITHUB_ENV" in line and (
"ZERO_INPUT_API_KEY" in line or "ZERO_INPUT_API_KEY_ENV" in line
):
raise AssertionError("provider key must not be persisted to GITHUB_ENV")
print(f"action.yml OK: {len(inputs)} inputs, {len(outputs)} outputs, {len(steps)} steps")
PY
+1 -1
View File
@@ -26,7 +26,7 @@ Accepted file names, in priority order at each level:
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.md` — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.MD` (uppercase `MD`) — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
+1 -1
View File
@@ -1,4 +1,4 @@
# Zero build/test/lint targets. AGENTS.md says "Build with `make`" and "Run `make
# Zero build/test/lint targets. AGENTS.MD says "Build with `make`" and "Run `make
# lint` before opening a PR" — these targets back those instructions.
.DEFAULT_GOAL := build
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean help
+8 -12
View File
@@ -59,24 +59,20 @@ Bun does not run dependency lifecycle scripts by default, so the `postinstall`
that fetches the Zero binary is skipped and the first run fails with
`No native binary found next to the npm wrapper`.
The simplest fix is to trust the package after installing, which runs the
blocked postinstall. This works for project and global installs:
Install with Bun, then fetch the binary in one of two ways:
```bash
# project install
# Option A: run the installer manually
bun add @gitlawb/zero
bun pm trust @gitlawb/zero
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
# global install
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
# Option B: allow the postinstall to run on install
# add to your package.json: "trustedDependencies": ["@gitlawb/zero"]
bun add @gitlawb/zero
```
Alternatives: allow the postinstall up front by adding
`"trustedDependencies": ["@gitlawb/zero"]` to your project's package.json
before `bun add`, or run the installer manually
(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`) on Bun versions
that do not have `bun pm trust`.
For global installs (`bun add -g @gitlawb/zero`), use Option A against the
global install path, or prefer the install scripts below.
### Install scripts
+7 -7
View File
@@ -44,19 +44,19 @@ npm 包安装一个小型包装器以及与你平台匹配的 Zero 二进制文
Bun 默认不运行依赖的生命周期脚本,因此获取 Zero 二进制文件的 `postinstall` 会被跳过,首次运行会失败并显示 `No native binary found next to the npm wrapper`。
最简单的解决方法是安装后信任该包,这会运行被阻止的 postinstall。项目安装和全局安装均适用:
使用 Bun 安装后,通过以下两种方式之一获取二进制文件:
```bash
# 项目安装
# 选项 A:手动运行安装程序
bun add @gitlawb/zero
bun pm trust @gitlawb/zero
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
# 全局安装
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
# 选项 B:允许 postinstall 在安装时运行
# 添加到你的 package.json: "trustedDependencies": ["@gitlawb/zero"]
bun add @gitlawb/zero
```
其他方式:在 `bun add` 之前将 `"trustedDependencies": ["@gitlawb/zero"]` 添加到项目的 package.json 以提前允许 postinstall;或在不支持 `bun pm trust` 的旧版 Bun 上手动运行安装程序(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`)。
对于全局安装(`bun add -g @gitlawb/zero`),请对全局安装路径使用选项 A,或使用下面的安装脚本。
### 安装脚本
+5 -4
View File
@@ -27,14 +27,14 @@ inputs:
api-key:
description: >-
The provider API key. Pass it from a repository or organization secret;
it is exported only for the ZERO step and never written to the log.
it is exported into the step environment and never written to the log.
required: false
default: ""
api-key-env:
description: >-
Name of the environment variable the chosen provider reads its key from
(for example OPENAI_API_KEY or ANTHROPIC_API_KEY). When set together with
api-key, that variable is exported only for the ZERO step. Leave the action
api-key, that variable is exported for the run. Leave the action
provider-agnostic by passing the env name your provider expects rather
than hardcoding one here.
required: false
@@ -192,9 +192,10 @@ runs:
echo "::error::api-key-env must be a valid environment variable name." >&2
exit 2
fi
# Export in-process for this Run ZERO step so zero can read it; do
# not persist provider credentials to later job steps.
# Export in-process so the zero invocation in this same run block sees
# it, and append to GITHUB_ENV so any later steps inherit it too.
export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"
printf '%s=%s\n' "${ZERO_INPUT_API_KEY_ENV}" "${ZERO_INPUT_API_KEY}" >> "${GITHUB_ENV}"
fi
if [ -n "${ZERO_INPUT_PROVIDER}" ]; then
export ZERO_PROVIDER="${ZERO_INPUT_PROVIDER}"
+3 -5
View File
@@ -66,12 +66,10 @@ if (!existsSync(nativePath)) {
'\n' +
(ranByBun
? 'You installed with Bun, which does not run dependency lifecycle scripts\n' +
'by default. Trust the package to run the blocked postinstall:\n' +
' bun pm trust @gitlawb/zero (project install)\n' +
' bun pm -g trust @gitlawb/zero (global install)\n' +
'On Bun versions without `bun pm trust`, add\n' +
'by default. To let it run on future installs, add zero to your\n' +
'project\'s trustedDependencies:\n' +
' "trustedDependencies": ["@gitlawb/zero"]\n' +
'to your project package.json and reinstall.\n' +
'then reinstall.\n' +
'\n'
: '') +
'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
+7 -9
View File
@@ -41,9 +41,8 @@ jobs:
> provider-neutral: `api-key-env` is the environment variable name the chosen
> provider reads its key from (for example `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`,
> `OPENROUTER_API_KEY`), and `api-key` is the secret value. The action exports
> that variable only for the ZERO step and never prints it. If your repository
> already commits a `.zero/config.json` with an active provider, you can omit
> `provider`.
> that variable for the run and never prints it. If your repository already
> commits a `.zero/config.json` with an active provider, you can omit `provider`.
## Inputs
@@ -52,8 +51,8 @@ jobs:
| `prompt` | one of `prompt`/`prompt-file` | `""` | The instruction for ZERO to execute. |
| `prompt-file` | one of `prompt`/`prompt-file` | `""` | Path (relative to `working-directory`) to a file whose contents are the prompt. |
| `provider` | no | `""` | Provider id to activate (e.g. `openai`, `anthropic`, `gemini`, `ollama`, or any compatible endpoint). |
| `api-key` | no | `""` | The provider API key. Pass from a secret; exported only for the ZERO step and never logged. |
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported only for the ZERO step when used with `api-key`. |
| `api-key` | no | `""` | The provider API key. Pass from a secret; never logged. |
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported with `api-key`. |
| `model` | no | `""` | Model id. Defaults to the resolved provider's default. |
| `mode` | no | `""` | Run mode (`zero exec --mode`), e.g. `smart`, `deep`, `fast`. |
| `auto` | no | `low` | Autonomy ceiling (`zero exec --auto`): `low`, `medium`, or `high`. Conservative by default. |
@@ -163,10 +162,9 @@ jobs:
## Security notes
- **Secrets are passed as Action secrets and never logged.** The action exports
`api-key` (under `api-key-env`) only for the ZERO step. `slack-webhook-url`
is passed only to the Slack post step. They are never echoed or persisted to
later workflow steps, and ZERO redacts secret-shaped strings from its own
output.
`api-key` (under `api-key-env`) and `slack-webhook-url` into the step
environment only. They are never echoed, and ZERO redacts secret-shaped
strings from its own output.
- **The sandbox is always active.** This action never passes
`--skip-permissions-unsafe`, so writes stay inside the checked-out repository
(plus any roots you grant with `add-dir`). Unsafe mode is never enabled
+6 -19
View File
@@ -31,24 +31,15 @@ dependencies (only the installing project's own scripts), so the `postinstall`
that fetches the Zero binary is silently skipped. The first run then fails with
`No native binary found next to the npm wrapper`.
The simplest fix is to trust the package after installing, which runs the
blocked postinstall. This works for project and global installs:
To install with Bun, either run the installer manually after installing:
```bash
# project install
bun add @gitlawb/zero
bun pm trust @gitlawb/zero
# global install
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
```
`bun pm untrusted` (or `bun pm -g untrusted`) lists the blocked postinstalls if
you want to inspect before trusting.
Alternatively, allow the postinstall to run at install time by adding the
package to your project's `trustedDependencies` before installing:
Or allow the postinstall to run by adding the package to your project's
`trustedDependencies` before installing:
```json
{
@@ -60,12 +51,8 @@ package to your project's `trustedDependencies` before installing:
bun add @gitlawb/zero
```
On Bun versions that do not have `bun pm trust`, run the installer manually
after installing:
```bash
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
```
For global installs (`bun add -g @gitlawb/zero`), run the installer manually
against the global install path, or use the install scripts below.
Reference: <https://bun.sh/docs/pm/lifecycle>
@@ -1,146 +0,0 @@
package agent
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
// A shell call that sets sandbox_permissions: with_additional_permissions but
// omits (or malforms) additional_permissions can never succeed: the same
// validation runs again at grant time regardless of the user's decision. It
// must be rejected as a plain tool error BEFORE any permission prompt, not
// surfaced as a confusing "approved but denied" prompt outcome.
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
promptCalls := 0
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions"}`,
},
PermissionModeAuto,
Options{
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
promptCalls++
return PermissionDecision{Action: PermissionDecisionAllow}, nil
},
},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusError {
t.Fatalf("status = %q, want error", result.Status)
}
if promptCalls != 0 {
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
}
}
// The mismatched-flag case (additional_permissions present without the
// sandbox_permissions flag) must be rejected the same way.
func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
promptCalls := 0
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"echo hi","additional_permissions":{"network":{"enabled":true}}}`,
},
PermissionModeAuto,
Options{
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
promptCalls++
return PermissionDecision{Action: PermissionDecisionAllow}, nil
},
},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusError {
t.Fatalf("status = %q, want error", result.Status)
}
if promptCalls != 0 {
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
}
}
// The rejection message must show a concrete, correctly-shaped example: a
// model that gets this wrong once (attaching sandbox_permissions out of habit
// from an earlier call, without a valid payload) needs enough in the error to
// self-correct on retry instead of repeating the identical mistake.
func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"ping example.com","sandbox_permissions":"with_additional_permissions"}`,
},
PermissionModeAuto,
Options{},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if !strings.Contains(result.Output, `{"network": {"enabled": true}}`) {
t.Fatalf("output = %q, want a concrete additional_permissions example", result.Output)
}
if !strings.Contains(result.Output, "omit sandbox_permissions") {
t.Fatalf("output = %q, want guidance that the flag can simply be omitted", result.Output)
}
}
// A well-formed additional_permissions payload must still reach the normal
// permission prompt: this fix only rejects malformed payloads.
func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
promptCalls := 0
_, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions",` +
`"additional_permissions":{"network":{"enabled":true}}}`,
},
PermissionModeAuto,
Options{
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
promptCalls++
return PermissionDecision{Action: PermissionDecisionDeny}, nil
},
},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if promptCalls != 1 {
t.Fatalf("OnPermissionRequest called %d times, want exactly 1 for a valid elevation request", promptCalls)
}
}
+4 -9
View File
@@ -21,17 +21,12 @@ import (
// the agent loop owns the provider wiring.
// defaultCompactionPreserveLast is how many trailing messages are kept verbatim
// when the caller does not specify a preserve count. Kept in sync with the replay
// reconstruction default in internal/sessions/replay.go.
const defaultCompactionPreserveLast = 6
// when the caller does not specify a preserve count.
const defaultCompactionPreserveLast = 8
// compactionTriggerRatio is the fraction of the context window at which
// proactive compaction kicks in (top of each turn). NOTE: this is speculative
// tuning — validate against the per-turn latency/compaction metrics (roadmap 5.2)
// before trusting it. Measured fixed overhead is ~7.4k tokens (not the ~16k the
// roadmap assumed), so context fills slower; if data shows 0.7 triggers redundant
// summarization, raise it back toward 0.8.
const compactionTriggerRatio = 0.7
// proactive compaction kicks in (top of each turn).
const compactionTriggerRatio = 0.8
// summaryLabel prefixes the injected summary so it is unmistakable in the
// transcript (and so tests can assert on it).
-44
View File
@@ -1,44 +0,0 @@
package agent
import (
"context"
"os"
"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. Returns nil when
// manager is nil, disabling inline diagnostics entirely.
func NewFileDiagnostics(manager *lsp.Manager) func(context.Context, string) string {
if manager == nil {
return nil
}
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(absPath, errors)
}
}
+57 -130
View File
@@ -475,35 +475,11 @@ 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)
}
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)
}
toolResult, abortErr := executeToolCall(ctx, registry, call, permissionMode, options)
if options.OnToolResult != nil {
options.OnToolResult(toolResult)
}
@@ -840,25 +816,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}
}
// A shell command that sets sandbox_permissions: with_additional_permissions
// must carry a valid additional_permissions payload; inlineAdditionalPermissionsProfile
// is the single source of truth for that shape, and buildPermissionEvent (below)
// calls it again to render the prompt's scope text. Check it here, before any
// permission prompt is shown: a malformed payload can never be satisfied no
// matter what the user decides, so surfacing it as a plain tool error (which the
// model can see and retry) is both clearer and avoids presenting a normal-looking
// prompt whose "allow" path is guaranteed to fail with a confusing denial.
if toolFound && isShellCommandTool(call.Name) {
if _, _, err := inlineAdditionalPermissionsProfile(args, options.Cwd); err != nil {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: " + err.Error(),
}, nil
}
}
// ask_user is intercepted in the loop (like permissions) so the question can
// be routed to an interactive front-end instead of blocking inside the tool.
// When no front-end is wired up it degrades to the tool's own graceful Run().
@@ -1075,8 +1032,6 @@ 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,
// Inline post-edit diagnostics for mutating tools (nil = disabled).
Diagnostics: options.FileDiagnostics,
// Forward the run's operator tool filters so a filter-aware tool
// (tool_search) never discloses or loads an operator-hidden deferred tool.
EnabledTools: options.EnabledTools,
@@ -2367,12 +2322,7 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
}
raw, ok := args["additional_permissions"]
if !ok || raw == nil {
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
"sandbox_permissions was set to %q but no additional_permissions object was provided. "+
`Include one, for example additional_permissions: {"network": {"enabled": true}} or `+
`{"file_system": {"write": ["/path"]}}. If this command does not need elevated permissions, `+
"omit sandbox_permissions entirely and retry",
tools.SandboxPermissionsWithAdditionalPermissions)
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("missing additional_permissions; provide at least one of network or file_system")
}
data, err := json.Marshal(raw)
if err != nil {
@@ -2387,9 +2337,7 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
return sandbox.RequestPermissionProfile{}, true, err
}
if normalized.Empty() {
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
"additional_permissions must include at least one of network or file_system, for example " +
`{"network": {"enabled": true}} or {"file_system": {"write": ["/path"]}}`)
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("additional_permissions must include at least one requested permission in network or file_system")
}
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
if err != nil {
@@ -2522,97 +2470,76 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
active := options.DeferThreshold > 0 && eligible >= options.DeferThreshold && loaderUsable
// INACTIVE: every visible tool eager (except tool_search), alpha-sorted. With no
// deferral there is no mid-session loading, so this is byte-stable across turns
// and byte-identical to the pre-deferral output.
if !active {
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
for _, tool := range visible {
if tool.Name() == tools.ToolSearchToolName {
continue
}
definitions = append(definitions, runtimeToolDefinition(tool))
}
sort.Slice(definitions, func(left int, right int) bool {
return definitions[left].Name < definitions[right].Name
})
return definitions, ""
}
// ACTIVE: lay the tools array out so its cacheable prefix does NOT shift when a
// deferred tool loads mid-session. The tools block is part of the provider's
// cached prefix; if a load reorders it, the cache invalidates from that point
// through the system prompt and messages — on EVERY load. Previously the whole
// array was alpha-sorted each turn, so a newly loaded tool was INSERTED into the
// middle and shifted every later definition. Instead we build three append-only
// regions:
// 1. non-deferred tools, alpha-sorted — always present, identical every turn;
// 2. tool_search — always present, right after the stable block;
// 3. loaded deferred tools, alpha-sorted — APPENDED, so an unloaded->loaded
// transition grows the tail instead of inserting into the eager block.
// (tool_search's discovery text still shrinks as tools load, so keeping it and
// the loaded tail AFTER the eager block preserves the eager tools' cache across a
// load; fully stabilizing the loader's own description is a scoped follow-up.)
eager := make([]zeroruntime.ToolDefinition, 0, len(visible))
loadedTail := make([]zeroruntime.ToolDefinition, 0)
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
exposedNames := make(map[string]bool, len(visible))
var hiddenTools []tools.Tool
for _, tool := range visible {
name := tool.Name()
if name == tools.ToolSearchToolName {
continue // added explicitly between the eager block and the loaded tail
}
if tools.IsDeferred(tool) {
if loaded[name] {
loadedTail = append(loadedTail, runtimeToolDefinition(tool))
} else {
hiddenTools = append(hiddenTools, tool)
deferred := tools.IsDeferred(tool)
if !active {
// Inactive: byte-identical to legacy, but tool_search is never advertised.
if name == tools.ToolSearchToolName {
continue
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: name,
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
})
exposedNames[name] = true
continue
}
eager = append(eager, runtimeToolDefinition(tool))
}
sort.Slice(eager, func(left int, right int) bool {
return eager[left].Name < eager[right].Name
})
sort.Slice(loadedTail, func(left int, right int) bool {
return loadedTail[left].Name < loadedTail[right].Name
})
// Active path.
if deferred && !loaded[name] {
hiddenTools = append(hiddenTools, tool)
continue
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: name,
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
})
exposedNames[name] = true
}
// On the ACTIVE path tool_search is guaranteed runnable (active implies
// loaderUsable), so it must ALWAYS be reachable — even when a non-empty
// EnabledTools allowlist omits it (the operator allowlisted the deferred
// tools, not the loader). Expose its full definition whenever it is not
// already in the exposed set. This never runs on the inactive path, so the
// byte-identical below-threshold output is preserved.
discovery := ""
if len(hiddenTools) > 0 {
if active && len(hiddenTools) > 0 {
discovery = tools.BuildToolSearchDescription(hiddenTools)
}
// tool_search is guaranteed runnable on the active path, so it is ALWAYS exposed
// — even when a non-empty EnabledTools allowlist omits it (the operator
// allowlisted the deferred tools, not the loader). It sits right after the stable
// eager block and carries the discovery list for still-hidden tools.
description := loader.Description()
if discovery != "" {
description = discovery
if active && !exposedNames[tools.ToolSearchToolName] {
description := loader.Description()
if discovery != "" {
description = discovery
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: loader.Name(),
Description: description,
Parameters: schemaToRuntimeMap(loader.Parameters()),
})
} else if active && discovery != "" {
for i := range definitions {
if definitions[i].Name == tools.ToolSearchToolName {
definitions[i].Description = discovery
break
}
}
}
definitions := make([]zeroruntime.ToolDefinition, 0, len(eager)+1+len(loadedTail))
definitions = append(definitions, eager...)
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: loader.Name(),
Description: description,
Parameters: schemaToRuntimeMap(loader.Parameters()),
sort.Slice(definitions, func(left int, right int) bool {
return definitions[left].Name < definitions[right].Name
})
definitions = append(definitions, loadedTail...)
return definitions, discovery
}
// runtimeToolDefinition renders a tool's advertised definition (name, description,
// JSON-schema parameters) as sent to the provider.
func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
return zeroruntime.ToolDefinition{
Name: tool.Name(),
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
}
}
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
}
-85
View File
@@ -1,85 +0,0 @@
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. Any
// permission prompt that still occurs (a sandbox preflight can demand one even
// for an auto-allowed read) is serialized so an interactive front-end never
// sees two prompts at once.
func executeParallelReadBatch(ctx context.Context, registry *tools.Registry, calls []ToolCall, start, end int, permissionMode PermissionMode, options Options) []precomputedToolResult {
batchOptions := options
if options.OnPermissionRequest != nil {
var promptMutex sync.Mutex
inner := options.OnPermissionRequest
batchOptions.OnPermissionRequest = func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
promptMutex.Lock()
defer promptMutex.Unlock()
return inner(ctx, request)
}
}
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
}
-184
View File
@@ -1,184 +0,0 @@
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
mu sync.Mutex
active int
maxActive int
log []string
}
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()
time.Sleep(tool.delay)
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) {
read := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 30 * time.Millisecond}
write := &probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite}
// Shared log across both probes so relative ordering is observable.
write.mu = sync.Mutex{}
registry := tools.NewRegistry()
registry.Register(read)
registry.Register(write)
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)
}
// The write must start only after r1+r2 finished, and r3/r4 must start only
// after the write finished: batches never cross a mutating call.
index := func(log []string, entry string) int {
for i, e := range log {
if e == entry {
return i
}
}
return -1
}
readLog := func() []string { read.mu.Lock(); defer read.mu.Unlock(); return append([]string(nil), read.log...) }()
writeLog := func() []string { write.mu.Lock(); defer write.mu.Unlock(); return append([]string(nil), write.log...) }()
if index(writeLog, "start:w") == -1 {
t.Fatalf("write probe never ran: %v", writeLog)
}
for _, entry := range []string{"end:r1", "end:r2"} {
if index(readLog, entry) == -1 {
t.Fatalf("first read batch incomplete: %v", readLog)
}
}
// r3/r4 must appear strictly after r1/r2 in the read log (the write barrier
// between the two batches forces full separation).
firstBatchMaxEnd := max(index(readLog, "end:r1"), index(readLog, "end:r2"))
secondBatchMinStart := min(index(readLog, "start:r3"), index(readLog, "start:r4"))
if secondBatchMinStart < firstBatchMaxEnd {
t.Fatalf("second read batch started before first batch ended: %v", readLog)
}
if read.maxActive < 2 {
t.Fatalf("reads within a batch must overlap, max concurrency was %d", read.maxActive)
}
}
@@ -1,44 +0,0 @@
package agent
import (
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
// Cache-stability property: loading a deferred tool must APPEND it after the
// always-eager tools, not sort it into the middle of them — otherwise the tool
// definitions (the provider's cacheable prefix) shift on every load. The old
// behaviour alpha-sorted the whole array, so "mcp__srv__alpha" jumped ahead of
// "read_file".
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root)) // non-deferred, "read_file"
registry.Register(fakeToolSearchTool{})
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, map[string]bool{"mcp__srv__alpha": true})
pos := func(name string) int {
for i := range exposed {
if exposed[i].Name == name {
return i
}
}
return -1
}
readPos, searchPos, alphaPos := pos("read_file"), pos("tool_search"), pos("mcp__srv__alpha")
if readPos < 0 || searchPos < 0 || alphaPos < 0 {
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) {
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.
if pos("mcp__srv__beta") != -1 {
t.Fatalf("unloaded deferred tool must stay hidden, got %#v", exposed)
}
}
-51
View File
@@ -1,51 +0,0 @@
package agent
import (
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
// These ceilings are a deliberate ratchet on Zero's fixed per-turn overhead — the
// system prompt and the eager tool schemas that ride on EVERY request. They are
// set ~10% above the current measured cost, using ApproxTextTokens (the same
// estimate the compaction loop and /context use, ~non-whitespace-bytes/4). A change
// that pushes either past its ceiling must be justified (and the ceiling raised
// deliberately) or trimmed — the per-turn floor should not creep up silently.
//
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
// the auto-mode advertised set, not the full core registry — higher-risk tools that
// only advertise in other modes are excluded, matching what a real turn actually
// pays.
const (
maxBaseSystemPromptTokens = 3500
maxEagerToolSchemaTokens = 2700
)
func TestSystemPromptTokenBudget(t *testing.T) {
// Minimal render: a model is set (so the session block renders) but no Cwd, so
// the workspace map, project guidelines, and repo map are excluded — this is the
// fixed base every session pays regardless of the workspace it runs in.
prompt := buildSystemPrompt(Options{Model: "claude-opus-4-8"})
got := ApproxTextTokens(prompt)
t.Logf("base system prompt: %d tokens (%d bytes)", got, len(prompt))
if got > maxBaseSystemPromptTokens {
t.Fatalf("base system prompt is %d tokens, over the %d ceiling — trim it or raise the ceiling deliberately", got, maxBaseSystemPromptTokens)
}
}
func TestEagerToolSchemaTokenBudget(t *testing.T) {
registry := tools.NewRegistry()
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
registry.Register(tool)
}
// Options{} keeps DeferThreshold at 0, so deferral is inactive and every core
// tool is exposed eagerly — exactly what a plugin-free session sends each turn.
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
got := estimateToolDefTokens(exposed)
t.Logf("eager core tool schemas: %d tokens across %d tools", got, len(exposed))
if got > maxEagerToolSchemaTokens {
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — defer a tool or raise the ceiling deliberately", got, maxEagerToolSchemaTokens)
}
}
-63
View File
@@ -1,63 +0,0 @@
package agent
import (
"strconv"
"strings"
"testing"
)
func TestSkillsContextCapsLongList(t *testing.T) {
skills := make([]SkillInfo, 0, 40)
for i := 0; i < 40; i++ {
n := strconv.Itoa(i)
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: "does something useful, number " + 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 !strings.Contains(got, "more (call skill") {
t.Fatalf("expected an overflow summary line, got:\n%s", got)
}
// The first skill is always listed regardless of budget.
if !strings.Contains(got, "- skill-0:") {
t.Fatalf("expected the first skill to always be listed, got:\n%s", got)
}
}
func TestSkillsContext(t *testing.T) {
if got := skillsContext(Options{}); got != "" {
t.Fatalf("no skills should yield an empty section, got %q", got)
}
got := skillsContext(Options{Skills: []SkillInfo{
{Name: "commit-writer", Description: "Write a conventional-commit message."},
{Name: " ", Description: "nameless, should be skipped"},
{Name: "reviewer"},
}})
if !strings.Contains(got, "<available_skills>") || !strings.Contains(got, "</available_skills>") {
t.Fatalf("missing available_skills block: %q", got)
}
if !strings.Contains(got, "- commit-writer: Write a conventional-commit message.") {
t.Fatalf("missing commit-writer line: %q", got)
}
if !strings.Contains(got, "- reviewer\n") {
t.Fatalf("reviewer (no description) line missing: %q", got)
}
if strings.Contains(got, "nameless") {
t.Fatalf("nameless entry should be skipped: %q", got)
}
}
func TestSystemPromptIncludesSkillsOnlyWhenInstalled(t *testing.T) {
with := buildSystemPrompt(Options{Skills: []SkillInfo{
{Name: "commit-writer", Description: "Write a commit message."},
}})
if !strings.Contains(with, "<available_skills>") || !strings.Contains(with, "skill tool") {
t.Fatalf("expected available_skills guidance in system prompt: %q", with)
}
// Default (no skills) must reproduce the prior prompt: no skills block.
without := buildSystemPrompt(Options{})
if strings.Contains(without, "<available_skills>") {
t.Fatalf("available_skills block must not appear without skills")
}
}
+3 -65
View File
@@ -5,7 +5,6 @@ import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"unicode/utf8"
@@ -87,9 +86,6 @@ func buildSystemPrompt(options Options) string {
if delegation := specialistDelegationContext(options); delegation != "" {
sections = append(sections, delegation)
}
if skillsBlock := skillsContext(options); skillsBlock != "" {
sections = append(sections, skillsBlock)
}
if style := responseStyleContext(options); style != "" {
sections = append(sections, style)
}
@@ -168,64 +164,6 @@ func specialistDelegationContext(options Options) string {
return b.String()
}
// skillsContext lists the reusable skills the model can pull in on demand via the
// skill tool, so it invokes the right one on the first try instead of guessing a
// name, failing, and reading the error. It mirrors specialistDelegationContext:
// names + one-line descriptions only (never skill bodies), and it renders nothing
// 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
func skillsContext(options Options) string {
if len(options.Skills) == 0 {
return ""
}
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")
listed, spent, omitted := 0, 0, 0
for _, info := range options.Skills {
name := strings.TrimSpace(info.Name)
if name == "" {
continue
}
line := "- " + name
if desc := strings.TrimSpace(info.Description); desc != "" {
line += ": " + truncateForSkillLine(desc)
}
line += "\n"
// Always list at least one skill; past the budget, summarize the remainder
// as a count instead of silently dropping it.
if listed > 0 && spent+len(line) > skillsContextListBudget {
omitted++
continue
}
b.WriteString(line)
spent += len(line)
listed++
}
if omitted > 0 {
b.WriteString("- …and " + strconv.Itoa(omitted) + " more (call skill with a name; an unknown name lists them all)\n")
}
b.WriteString("</available_skills>")
return b.String()
}
// truncateForSkillLine keeps a skill's one-line description short so a single
// verbose description can't dominate the skills-list budget.
func truncateForSkillLine(desc string) string {
const maxDescRunes = 100
runes := []rune(desc)
if len(runes) <= maxDescRunes {
return desc
}
return strings.TrimSpace(string(runes[:maxDescRunes])) + "…"
}
func sessionRuntimeContext(options Options) string {
provider := strings.TrimSpace(options.ProviderName)
model := strings.TrimSpace(options.Model)
@@ -372,9 +310,9 @@ func projectGuidelineLabel(match, gitRoot string) string {
// findProjectContextFile returns the first matching project guideline file
// in dir. Lookup is case-insensitive on the basename and on the actual
// filename returned, so a git-tracked file like AGENTS.md still resolves to
// its true filename on case-sensitive filesystems — the returned path is what
// should appear in the project guidelines label.
// filename returned, so a git-tracked file like AGENTS.MD (uppercase MD)
// still resolves to its true filename on case-sensitive filesystems — the
// returned path is what should appear in the project guidelines label.
// Returns "" when nothing matches.
func findProjectContextFile(dir string) string {
for _, name := range projectContextFiles {
+9 -7
View File
@@ -46,10 +46,14 @@ work.
formatting-only edits unless the user asked for them.
4. **Verify.** Verify after edits; see the testing gate below. This is
mandatory.
5. **Summarize.** Close with a summary scaled to the work: a trivial fix earns one
line; a substantial change earns a few short bullets covering what changed, where
it lives, and what you did and did not verify. Lead with the outcome, and never
pad — do not restate the task or narrate steps the user already watched you take.
5. **Summarize.** For a substantial task, close with an ELABORATE, well-structured
summary — never a single throwaway line. Use short headings and bullets: a
one-line "what I built/changed"; **Where it lives** — the key files and how they
fit together; **How each requirement is met** — walk through each requirement
from the request and name the specifics that satisfy it; what validation ran and
what was NOT verified; and a brief "Next steps" or "Want me to…?" offer. Scale
the depth to the work — a big build earns several sections, a trivial fix earns
a line or two.
## Editing discipline
@@ -60,9 +64,7 @@ work.
- Make one tool call per file. Do not batch multi-file writes into a single
shell or script invocation.
- For edits to existing files, prefer edit_file or apply_patch with minimal,
targeted diffs. Match the existing indentation, imports, and idioms. Match the
file's comment density: do not add explanatory comments unless the user asks or
the code is already comment-dense.
targeted diffs. Match the existing indentation, imports, and idioms.
- 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.
+12 -10
View File
@@ -6,13 +6,11 @@ import "strings"
//
// Different model families respond to different framing: the GPT family benefits
// from an explicit markdown/output spec and a strong "prefer native tools" nudge;
// Gemini benefits from explicit tool-preference and conciseness guidance. The
// Claude family is already aligned with the core prompt and needs no addendum —
// comment discipline is universal in the core prompt now, so every family gets it.
// modelPromptAddendum returns the family-specific block appended after the core
// prompt, or "" when the family is unknown or needs no specialization.
// Classification is by model id (the agent only has the model string — no registry
// dependency).
// Gemini benefits from explicit tool-preference and conciseness guidance; the
// Claude family is already well-aligned with the core prompt and only needs a
// light comment-discipline nudge. modelPromptAddendum returns the family-specific
// block appended after the core prompt. Classification is by model id (the agent
// only has the model string — no registry dependency).
const (
familyOpenAI = "openai"
@@ -47,10 +45,9 @@ func modelPromptAddendum(model string) string {
return openAIPromptAddendum
case familyGemini:
return geminiPromptAddendum
case familyAnthropic:
return anthropicPromptAddendum
default:
// Anthropic (aligned with the core prompt) and unknown families get no
// family-specific block; comment discipline now lives in the core prompt
// for every model.
return ""
}
}
@@ -73,3 +70,8 @@ const geminiPromptAddendum = `<model_guidance>
in one short clause why it is needed.
- Use update_plan for any multi-step task and keep it current.
</model_guidance>`
const anthropicPromptAddendum = `<model_guidance>
- Do not add code comments unless the user asks or the surrounding code is
comment-dense; match the file's existing comment density.
</model_guidance>`
+2 -4
View File
@@ -29,11 +29,9 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
}
// Claude is aligned with the core prompt and gets no family addendum now that
// comment discipline is universal; it must not pick up another family's block.
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
if strings.Contains(claude, "<model_guidance>") {
t.Fatalf("expected no model_guidance block for Claude (aligned with core prompt)")
if !strings.Contains(claude, anthropicPromptAddendum) {
t.Fatalf("expected the Anthropic addendum in the claude prompt")
}
if strings.Contains(claude, openAIPromptAddendum) {
t.Fatalf("the claude prompt must not contain the OpenAI addendum")
+2 -3
View File
@@ -23,8 +23,7 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
"avoid broad refactors",
"search the web before answering",
"do not recognize",
"scaled to the work",
"comment density",
"how each requirement is met",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
@@ -129,7 +128,7 @@ func systemPromptTestBlock(t *testing.T, prompt, start, end string) string {
}
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
// Git tracks AGENTS.md on a case-sensitive filesystem; the
// Git tracks AGENTS.MD (uppercase MD) on a case-sensitive filesystem; the
// loader must still resolve it when the cwd lookup uses lowercase.
cwd := t.TempDir()
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.MD"), []byte("Always run `make lint`."), 0o644); err != nil {
-22
View File
@@ -174,16 +174,6 @@ type SpecialistInfo struct {
WhenToUse string
}
// SkillInfo is a one-line summary of a reusable, on-demand skill (its name and
// frontmatter description) surfaced to the system prompt so the model can invoke
// the right skill with the skill tool on the first try instead of guessing a name
// and reading the failure. Like SpecialistInfo it is plain data, so the agent
// package needs no dependency on internal/skills.
type SkillInfo struct {
Name string
Description string
}
type Options struct {
MaxTurns int
// DeferThreshold activates deferred MCP-tool loading: when the number of
@@ -199,12 +189,6 @@ type Options struct {
// populated only where the Task tool is actually registered, so an empty slice
// (the default) reproduces the previous prompt byte-for-byte.
Specialists []SpecialistInfo
// Skills lists the reusable skills installed for this run (the default skills
// dir merged with any plugin skill roots). When non-empty the system prompt
// gains an <available_skills> block naming them so the model loads the right one
// via the skill tool on the first try. Empty (the default) reproduces the
// previous prompt byte-for-byte.
Skills []SkillInfo
// Specialist/sub-agent metadata is carried through exec now and consumed by
// the specialist runtime in later slices.
SessionID string
@@ -285,12 +269,6 @@ 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, is passed to mutating tools so edit_file /
// write_file append error-severity language diagnostics for the file they
// just wrote directly to their tool output — the model sees an error it
// introduced in the same turn instead of a later verification pass. Build
// one with NewFileDiagnostics. nil disables inline diagnostics.
FileDiagnostics func(ctx context.Context, absPath string) string
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
// any assistant turn that produces text but no tool call is accepted as the
+4 -37
View File
@@ -19,7 +19,6 @@ 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"
@@ -81,7 +80,6 @@ type appDeps struct {
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
now func() time.Time
}
@@ -182,7 +180,6 @@ func defaultAppDeps() appDeps {
runTUI: tui.Run,
runEditor: openEditor,
checkUpdate: update.Check,
applyUpdate: update.Apply,
now: time.Now,
}
}
@@ -230,12 +227,6 @@ 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)
@@ -392,8 +383,6 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
return runSandbox(args[1:], stdout, stderr, deps)
case "update":
return runUpdate(args[1:], stdout, stderr, deps)
case "upgrade":
return runUpgrade(args[1:], stdout, stderr, deps)
case "worktrees", "worktree":
return runWorktrees(args[1:], stdout, stderr, deps)
case "verify":
@@ -526,9 +515,6 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.checkUpdate == nil {
deps.checkUpdate = defaults.checkUpdate
}
if deps.applyUpdate == nil {
deps.applyUpdate = defaults.applyUpdate
}
if deps.now == nil {
deps.now = defaults.now
}
@@ -551,11 +537,6 @@ 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)
@@ -576,19 +557,8 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
if !errors.Is(err, config.ErrNoActiveProvider) && !errors.Is(err, config.ErrProviderRequiresModel) {
return writeAppError(stderr, err.Error(), 1)
}
// ErrNoActiveProvider can mean "nothing configured yet" (needs onboarding)
// or "providers ARE configured, just none marked active" (e.g. config.json's
// activeProvider is blank/stale). In the second case resolved.Providers still
// carries the already-normalized list — prefer falling back to one of those
// over wiping everything and forcing the user to re-enter credentials they
// already saved.
if usable, ok := firstUsableProvider(resolved.Providers); errors.Is(err, config.ErrNoActiveProvider) && ok {
resolved.Provider = usable
resolved.ActiveProvider = usable.Name
} else {
resolved = config.ResolvedConfig{}
forceSetup = true
}
resolved = config.ResolvedConfig{}
forceSetup = true
}
userConfigPath, err := deps.userConfigPath()
if err != nil {
@@ -770,11 +740,9 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
DeferThreshold: resolved.Tools.DeferThreshold,
Specialists: specialistRuntime.specialists,
Skills: pluginActivation.skillInfos(deps.skillsDir()),
},
PermissionMode: permissionMode,
Notify: resolved.Notify,
KeyBindings: resolved.KeyBindings,
Setup: tui.SetupOptions{
Visible: setupVisible,
Required: needsSetup,
@@ -1190,8 +1158,7 @@ func profileHasCredential(profile config.ProviderProfile) bool {
}
// baseURLIsLoopback reports whether a provider base_url points at a loopback host
// or a private-network host (192.168.x.y / 10.x.y.z / 172.16.x.y) — a local
// provider like Ollama/LM Studio that needs no API key.
// (a keyless local provider like Ollama/LM Studio), which needs no API key.
func baseURLIsLoopback(baseURL string) bool {
u := strings.TrimSpace(baseURL)
if u == "" {
@@ -1206,7 +1173,7 @@ func baseURLIsLoopback(baseURL string) bool {
return true
}
if addr, err := netip.ParseAddr(host); err == nil {
return addr.IsLoopback() || addr.IsPrivate()
return addr.IsLoopback()
}
return false
}
-208
View File
@@ -205,67 +205,6 @@ func TestRunNoArgsEntersSetupWhenResolveReportsNoActiveProvider(t *testing.T) {
}
}
func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
setCLIUserConfigRoot(t)
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
var launchedOptions tui.Options
launched := false
var providerProfile config.ProviderProfile
fake := &cliFakeProvider{}
usable := config.ProviderProfile{
Name: "work",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
APIKey: "sk-test",
Model: "gpt-test",
}
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
// config.json has providers configured but none marked active (e.g. a
// blank/stale activeProvider field) — Resolve returns the successfully
// normalized providers list alongside ErrNoActiveProvider.
return config.ResolvedConfig{Providers: []config.ProviderProfile{usable}},
fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "")
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
providerProfile = profile
return fake, nil
},
userConfigPath: func() (string, error) {
return userConfigPath, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return noopMCPRuntime{}, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
launched = true
launchedOptions = options
return 0
},
})
if exitCode != 0 {
t.Fatalf("exit code = %d, want 0, stderr=%q", exitCode, stderr.String())
}
if !launched {
t.Fatal("TUI was not launched")
}
if launchedOptions.Setup.Visible || launchedOptions.Setup.Required {
t.Fatalf("Setup = %#v, want no setup wizard — a usable saved provider exists", launchedOptions.Setup)
}
if providerProfile.Name != "work" {
t.Fatalf("provider used = %q, want fallback to the usable saved provider %q", providerProfile.Name, "work")
}
}
func TestRunNoArgsFailsWhenResolveErrorIsNotProviderRelated(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -1328,153 +1267,6 @@ func TestRunUpdateReportsUpToDate(t *testing.T) {
}
}
func TestRunUpdateApplyTextAndJSON(t *testing.T) {
applyResult := update.ApplyResult{
Result: update.Result{
CurrentVersion: "dev",
LatestVersion: "0.2.0",
UpdateAvailable: true,
},
Applied: true,
InstallMethod: update.InstallMethodStandalone,
BinaryPath: "/usr/local/bin/zero",
Message: "updated to 0.2.0",
}
var gotOptions update.Options
deps := appDeps{
applyUpdate: func(_ context.Context, options update.Options) (update.ApplyResult, error) {
gotOptions = options
return applyResult, nil
},
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
t.Fatal("checkUpdate should not run for --apply")
return update.Result{}, nil
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "updated to 0.2.0") {
t.Fatalf("unexpected apply text: %q", stdout.String())
}
if gotOptions.CurrentVersion != "dev" {
t.Fatalf("unexpected options passed to applyUpdate: %#v", gotOptions)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"update", "--apply", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
var payload struct {
Applied bool `json:"applied"`
InstallMethod string `json:"installMethod"`
BinaryPath string `json:"binaryPath"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("apply JSON did not decode: %v\n%s", err, stdout.String())
}
if !payload.Applied || payload.InstallMethod != string(update.InstallMethodStandalone) || payload.BinaryPath != applyResult.BinaryPath {
t.Fatalf("unexpected apply JSON: %#v", payload)
}
}
func TestRunUpgradeDefaultsToApply(t *testing.T) {
var applyCalled bool
deps := appDeps{
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
applyCalled = true
return update.ApplyResult{Message: "already up to date"}, nil
},
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
t.Fatal("checkUpdate should not run for `zero upgrade`")
return update.Result{}, nil
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"upgrade"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if !applyCalled {
t.Fatal("expected `zero upgrade` to call applyUpdate")
}
}
func TestRunUpdateRejectsCheckAndApplyTogether(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"update", "--check", "--apply"}, &stdout, &stderr, appDeps{
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
t.Fatal("checkUpdate should not run")
return update.Result{}, nil
},
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
t.Fatal("applyUpdate should not run")
return update.ApplyResult{}, nil
},
})
if exitCode != exitUsage {
t.Fatalf("expected usage exit code, got %d", exitCode)
}
if got := stderr.String(); !strings.Contains(got, "only one of --check or --apply") {
t.Fatalf("expected --check/--apply usage error, got %q", got)
}
}
func TestRunUpdateRejectsTargetWithApply(t *testing.T) {
for _, args := range [][]string{
{"update", "--apply", "--target", "linux-arm64"},
{"upgrade", "--target", "linux-arm64"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
t.Fatal("applyUpdate should not run when --target is combined with --apply")
return update.ApplyResult{}, nil
},
})
if exitCode != exitUsage {
t.Fatalf("expected usage exit code, got %d", exitCode)
}
if got := stderr.String(); !strings.Contains(got, "--target cannot be combined with --apply") {
t.Fatalf("expected --target/--apply usage error, got %q", got)
}
})
}
}
func TestRunUpdateReportsApplyError(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, appDeps{
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
return update.ApplyResult{}, errors.New("download failed")
},
})
if exitCode == exitSuccess {
t.Fatalf("expected non-success exit code, got %d", exitCode)
}
if got := stderr.String(); !strings.Contains(got, "download failed") {
t.Fatalf("expected apply error, got %q", got)
}
}
func assertHelpOutput(t *testing.T, args []string) {
t.Helper()
+3 -19
View File
@@ -301,27 +301,12 @@ func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if len(parsed.positional) != 1 {
return writeExecUsageError(stderr, "usage: zero auth login <provider> [--device] [--scope <scope>]")
}
provider := parsed.positional[0]
// ChatGPT (Codex) requires a fixed redirect_uri (http://localhost:1455/
// auth/callback) and mandatory authorize params (id_token_add_organizations,
// codex_cli_simplified_flow, originator) that the generic loopback flow
// cannot supply. Route it to the dedicated ChatGPT login so
// `zero auth login chatgpt` behaves identically to `zero auth chatgpt`.
if strings.EqualFold(provider, "chatgpt") {
if parsed.device {
return writeExecUsageError(stderr, "ChatGPT login does not support --device (it is loopback-only)")
}
if len(parsed.scopes) > 0 {
return writeExecUsageError(stderr, "ChatGPT login does not support --scope (the required scopes are fixed by the Codex client registration)")
}
return runAuthChatGPT(nil, stdout, stderr, deps)
}
manager, err := newAuthManager(deps, stdout)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
status, err := manager.Login(context.Background(), oauth.LoginOptions{
Provider: provider,
Provider: parsed.positional[0],
Device: parsed.device,
ExtraScopes: parsed.scopes,
})
@@ -504,9 +489,8 @@ Commands:
A provider is any OAuth 2.0 / OIDC server. "openrouter" ('zero auth openrouter')
works out of the box. "xai" ('zero auth login xai') uses a built-in preset that is
off by default — enable it with ZERO_OAUTH_ALLOW_PRESETS=1, or set the
ZERO_OAUTH_XAI_* vars yourself. "chatgpt" ('zero auth login chatgpt' or
'zero auth chatgpt') uses a fixed-port loopback flow against the Codex backend.
Any preset field is overridable via the env vars below. For a custom provider named <name>, set:
ZERO_OAUTH_XAI_* vars yourself. Any preset field is overridable via the env vars
below. For a custom provider named <name>, set:
ZERO_OAUTH_<NAME>_CLIENT_ID (required)
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
ZERO_OAUTH_<NAME>_AUTHORIZE_URL ZERO_OAUTH_<NAME>_TOKEN_URL
-41
View File
@@ -156,44 +156,3 @@ func TestRunAuthHelp(t *testing.T) {
}
}
}
// TestRunAuthLoginChatGPTRoutesToDedicatedFlow verifies `zero auth login
// chatgpt` reaches the dedicated ChatGPT login (fixed-port loopback + mandatory
// authorize params), not the generic manager path. The generic login accepts
// --device, so a ChatGPT-specific rejection proves the routing took effect.
// See issue #430: the generic path built a random-port 127.0.0.1 redirect_uri
// without the required extra params, so OpenAI's authorize endpoint rejected it.
func TestRunAuthLoginChatGPTRoutesToDedicatedFlow(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("auth login chatgpt --device should be rejected (ChatGPT is loopback-only)")
}
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
t.Fatalf("stderr = %q, want the ChatGPT-specific --device rejection", stderr.String())
}
// Case-insensitive provider name should still route.
stdout.Reset()
stderr.Reset()
if code := runWithDeps([]string{"auth", "login", "ChatGPT", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("auth login ChatGPT --device should be rejected")
}
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
t.Fatalf("stderr = %q, want the ChatGPT-specific rejection (case-insensitive)", stderr.String())
}
}
// TestRunAuthLoginChatGPTRejectsScope mirrors the --device rejection: --scope
// must not be silently dropped on the ChatGPT path. The Codex client
// registration pins a fixed scope set (incl. api.connectors.*), so custom
// scopes are rejected up front rather than plumbed through.
func TestRunAuthLoginChatGPTRejectsScope(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--scope", "custom-scope"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("auth login chatgpt --scope should be rejected")
}
if !strings.Contains(stderr.String(), "ChatGPT login does not support --scope") {
t.Fatalf("stderr = %q, want the ChatGPT-specific --scope rejection", stderr.String())
}
}
+12 -25
View File
@@ -135,11 +135,6 @@ 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
@@ -504,17 +499,15 @@ 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. 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)
// 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)
defer lspShutdown()
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
MaxTurns: resolved.MaxTurns,
ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider),
DeferThreshold: effectiveDeferThreshold,
Specialists: specialistRuntime.specialistInfos(),
Skills: pluginActivation.skillInfos(deps.skillsDir()),
SessionID: preparedSession.Session.SessionID,
CallingSessionID: options.callingSessionID,
CallingToolUseID: options.callingToolUseID,
@@ -531,7 +524,6 @@ 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
@@ -707,28 +699,23 @@ 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(context.Context, string) string, func()) {
// The manager is created regardless of --self-correct: it also backs the
// always-on inline post-edit diagnostics (agent.NewFileDiagnostics), and it
// stays lazy — no language server is spawned unless an edited file's
// language actually has one installed on PATH.
manager := lsp.NewManager(workspaceRoot)
cleanup := func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = manager.Shutdown(ctx)
}
fileDiagnostics := agent.NewFileDiagnostics(manager)
func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (*agent.SelfCorrector, func()) {
if !enabled {
return nil, fileDiagnostics, cleanup
return nil, func() {}
}
manager := lsp.NewManager(workspaceRoot)
corrector := agent.NewSelfCorrector(workspaceRoot, agent.NewLSPDiagnosticsChecker(manager), agent.NewProjectVerifier(workspaceRoot), agent.SelfCorrectConfig{
Enabled: true,
IncludeTests: true,
IncludeLSP: true,
Autonomy: autonomy,
})
return corrector, fileDiagnostics, cleanup
cleanup := func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = manager.Shutdown(ctx)
}
return corrector, cleanup
}
func deferredEligibleCount(registry *tools.Registry, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) int {
-23
View File
@@ -3,9 +3,7 @@ package cli
import (
"fmt"
"io"
"strings"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/tools"
@@ -59,27 +57,6 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
return pluginActivation{hooks: result.Hooks, skillRoots: result.SkillRoots}
}
// skillInfos resolves the reusable skills the model can load via the skill tool —
// the default skills dir merged with any plugin skill roots, the same set the
// plugin-aware skill tool resolves against — as plain data for the agent's system
// prompt. It returns nil when no skills are installed, so a skill-less run leaves
// the prompt byte-identical.
func (a pluginActivation) skillInfos(defaultDir string) []agent.SkillInfo {
merged, _ := plugins.MergedSkills(defaultDir, a.skillRoots)
if len(merged) == 0 {
return nil
}
infos := make([]agent.SkillInfo, 0, len(merged))
for _, skill := range merged {
name := strings.TrimSpace(skill.Name)
if name == "" {
continue
}
infos = append(infos, agent.SkillInfo{Name: name, Description: strings.TrimSpace(skill.Description)})
}
return infos
}
// formatLoadDiagnostic renders a plugin load diagnostic into a single warning
// line, prefixing the diagnostic kind and appending the most specific locator
// available (manifest path, then plugin path, then plugin ID) so an operator can
-6
View File
@@ -3,7 +3,6 @@ package cli
import (
"fmt"
"io"
"net"
"net/url"
"strconv"
"strings"
@@ -454,11 +453,6 @@ func providerProfileIsLocal(profile config.ProviderProfile) bool {
case "localhost", "127.0.0.1", "::1":
return true
}
// Also treat explicit private-network IPs (192.168.x.y, 10.x.y.z,
// 172.16-31.x.y) as local — the user is pointing at their own LAN box.
if ip := net.ParseIP(parsed.Hostname()); ip != nil && ip.IsPrivate() {
return true
}
return false
}
+8 -51
View File
@@ -12,7 +12,6 @@ import (
type updateOptions struct {
check bool
apply bool
json bool
repository string
endpoint string
@@ -21,18 +20,6 @@ type updateOptions struct {
}
func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
return runUpdateCommand(args, stdout, stderr, deps, false)
}
func runUpgrade(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
return runUpdateCommand(args, stdout, stderr, deps, true)
}
// runUpdateCommand backs both `zero update` and `zero upgrade`. defaultApply
// makes `--apply` the implicit behavior for `zero upgrade` when neither
// `--check` nor `--apply` is passed explicitly; `zero update` keeps requiring
// one of the two so existing scripts around `zero update --check` don't change.
func runUpdateCommand(args []string, stdout io.Writer, stderr io.Writer, deps appDeps, defaultApply bool) int {
options, help, err := parseUpdateArgs(args)
if err != nil {
return writeUsageError(stderr, err.Error())
@@ -43,19 +30,10 @@ func runUpdateCommand(args []string, stdout io.Writer, stderr io.Writer, deps ap
}
return exitSuccess
}
if options.check && options.apply {
return writeUsageError(stderr, "Pass only one of --check or --apply.")
if !options.check {
return writeUsageError(stderr, "Only `zero update --check` is available right now.")
}
if !options.check && !options.apply {
if !defaultApply {
return writeUsageError(stderr, "Pass --check to check for updates or --apply to install one (or use `zero upgrade`).")
}
options.apply = true
}
if options.apply && options.target != "" {
return writeUsageError(stderr, "--target cannot be combined with --apply; it only verifies release assets for --check.")
}
updateOptions := update.Options{
checkOptions := update.Options{
CurrentVersion: version,
Repository: options.repository,
Endpoint: options.endpoint,
@@ -66,26 +44,10 @@ func runUpdateCommand(args []string, stdout io.Writer, stderr io.Writer, deps ap
if err != nil {
return writeUsageError(stderr, err.Error())
}
updateOptions.GOOS = target.GOOS
updateOptions.GOARCH = target.GOARCH
checkOptions.GOOS = target.GOOS
checkOptions.GOARCH = target.GOARCH
}
if options.apply {
result, err := deps.applyUpdate(context.Background(), updateOptions)
if err != nil {
return writeAppError(stderr, "Could not install update: "+err.Error(), exitCrash)
}
if options.json {
if err := writePrettyJSON(stdout, result); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, update.FormatApply(result)); err != nil {
return exitCrash
}
return exitSuccess
}
result, err := deps.checkUpdate(context.Background(), updateOptions)
result, err := deps.checkUpdate(context.Background(), checkOptions)
if err != nil {
return writeAppError(stderr, "Could not check for updates: "+err.Error(), exitCrash)
}
@@ -110,8 +72,6 @@ func parseUpdateArgs(args []string) (updateOptions, bool, error) {
return options, true, nil
case arg == "--check":
options.check = true
case arg == "--apply":
options.apply = true
case arg == "--json":
options.json = true
case arg == "--repo":
@@ -195,17 +155,14 @@ func parseUpdateTarget(value string) (string, error) {
func writeUpdateHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero update --check [flags]
zero update --apply [flags]
zero upgrade [flags]
Flags:
--check Check the latest GitHub release without installing
--apply Download, verify, and install the latest release
--json Print the update result as JSON
--json Print the update check result as JSON
--repo <owner/repo> Repository to check when no endpoint is provided
--endpoint <url|repo> Release API URL or owner/repo slug to check
--timeout <duration> Release check timeout (default 5s)
--target <platform> Release target to verify with --check (for example windows-x64); not valid with --apply
--target <platform> Release target to verify (for example windows-x64)
-h, --help Show this help
`)
return err
-141
View File
@@ -662,144 +662,3 @@ func TestRunExecRejectsWorktreeDirWithoutWorktree(t *testing.T) {
t.Fatalf("expected empty stdout, got %q", stdout.String())
}
}
func TestParseChangesArgsAuto(t *testing.T) {
// 1. Correct parsing of auto
for _, args := range [][]string{
{"--auto"},
{"-a"},
} {
options, help, err := parseChangesArgs(args, "commit")
if err != nil {
t.Fatalf("parseChangesArgs(%v) error: %v", args, err)
}
if help {
t.Fatalf("parseChangesArgs(%v) returned help", args)
}
if !options.auto {
t.Fatalf("auto = false, want true (args %v)", args)
}
}
// 2. Reject --auto on inspect
_, _, err := parseChangesArgs([]string{"--auto"}, "inspect")
if err == nil || !strings.Contains(err.Error(), "--auto") {
t.Fatalf("expected --auto rejection on inspect, got %v", err)
}
// 3. Reject both --message and --auto on commit
_, _, err = parseChangesArgs([]string{"--message", "foo", "--auto"}, "commit")
if err == nil || !strings.Contains(err.Error(), "cannot specify both --message and --auto") {
t.Fatalf("expected --message and --auto conflict error, got %v", err)
}
// 4. Reject both empty message and --auto on commit
_, _, err = parseChangesArgs([]string{"--message=", "--auto"}, "commit")
if err == nil || !strings.Contains(err.Error(), "cannot specify both --message and --auto") {
t.Fatalf("expected empty --message and --auto conflict error, got %v", err)
}
}
type mockCommitMsgProvider struct {
response string
req zeroruntime.CompletionRequest
}
func (p *mockCommitMsgProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
p.req = request
events := make(chan zeroruntime.StreamEvent, 2)
events <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.response}
events <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(events)
return events, nil
}
func TestRunChangesCommitAuto(t *testing.T) {
cwd := t.TempDir()
summary := zerogit.ChangeSummary{
Root: cwd,
Branch: "main",
Files: []zerogit.FileChange{{Path: "README.md", Status: "modified"}},
Diff: "some diff content with ghp_SECRETKEYHERE",
}
mockProv := &mockCommitMsgProvider{
response: "```\nfeat: auto commit message\n```",
}
var stdout bytes.Buffer
var stderr bytes.Buffer
commitCalled := false
exitCode := runWithDeps([]string{"changes", "commit", "--auto"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
return summary, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
cfg := execResolvedConfig()
cfg.Provider.Name = "openai"
cfg.Provider.Model = "gpt-4o"
return cfg, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return mockProv, nil
},
commitChanges: func(ctx context.Context, options zerogit.CommitOptions) (zerogit.CommitResult, error) {
commitCalled = true
if options.Message != "feat: auto commit message" {
t.Fatalf("expected commit message 'feat: auto commit message', got %q", options.Message)
}
return zerogit.CommitResult{
Root: cwd,
Message: options.Message,
Committed: true,
CommitHash: "abc1234",
Before: summary,
}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if !commitCalled {
t.Fatal("expected commitChanges to be called")
}
// Verify that secret in the diff was redacted
promptContent := mockProv.req.Messages[0].Content
if strings.Contains(promptContent, "ghp_SECRETKEYHERE") {
t.Fatal("expected secret in diff to be redacted, but it was found in the prompt")
}
if !strings.Contains(promptContent, "[REDACTED]") && !strings.Contains(promptContent, "REDACTED") {
t.Fatalf("expected redacted diff content in prompt, got: %q", promptContent)
}
if !strings.Contains(stdout.String(), "Generating commit message using LLM...") {
t.Fatalf("expected status message in stdout, got %q", stdout.String())
}
t.Run("EmptyLLMResponse", func(t *testing.T) {
mockProvEmpty := &mockCommitMsgProvider{
response: " \n\n ",
}
var stdout, stderr bytes.Buffer
code := runWithDeps([]string{"changes", "commit", "--auto"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
return summary, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return mockProvEmpty, nil
},
})
if code == exitSuccess {
t.Fatal("expected command to fail when LLM returns empty response, got success")
}
if !strings.Contains(stderr.String(), "empty commit message") {
t.Fatalf("expected empty commit message error in stderr, got %q", stderr.String())
}
})
}
+3 -96
View File
@@ -6,16 +6,13 @@ import (
"io"
"strconv"
"strings"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/selfverify"
"github.com/Gitlawb/zero/internal/testrunner"
"github.com/Gitlawb/zero/internal/verify"
"github.com/Gitlawb/zero/internal/worktrees"
"github.com/Gitlawb/zero/internal/zerogit"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type worktreeCommandOptions struct {
@@ -38,10 +35,8 @@ type changesCommandOptions struct {
cwd string
baseRef string
message string
hasMessage bool
dryRun bool
maxDiffBytes int
auto bool
}
func runWorktrees(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
@@ -203,52 +198,9 @@ func runChanges(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
}
return exitSuccess
case "commit":
var message string
if options.auto {
summary, err := deps.inspectChanges(context.Background(), zerogit.InspectOptions{
Cwd: workspaceRoot,
MaxDiffBytes: options.maxDiffBytes,
})
if err != nil {
return writeExecUsageError(stderr, fmt.Sprintf("failed to inspect changes: %v", err))
}
if summary.Clean {
return writeExecUsageError(stderr, "no changes to commit")
}
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
if err != nil {
return writeExecUsageError(stderr, fmt.Sprintf("failed to resolve config: %v", err))
}
if !config.HasProviderProfile(resolved.Provider) {
return writeExecUsageError(stderr, "no provider configured for auto-commit message")
}
provider, err := deps.newProvider(resolved.Provider)
if err != nil {
return writeExecUsageError(stderr, fmt.Sprintf("failed to create provider: %v", err))
}
if !options.json {
if _, err := fmt.Fprintln(stdout, "Generating commit message using LLM..."); err != nil {
return exitCrash
}
}
safeSummary := redactChangeSummary(summary)
genCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
msg, err := generateAutoCommitMessage(genCtx, provider, resolved.Provider.Model, safeSummary)
if err != nil {
return writeExecUsageError(stderr, fmt.Sprintf("failed to generate commit message: %v", err))
}
message = msg
} else {
message = options.message
}
result, err := deps.commitChanges(context.Background(), zerogit.CommitOptions{
Cwd: workspaceRoot,
Message: message,
Message: options.message,
DryRun: options.dryRun,
MaxDiffBytes: options.maxDiffBytes,
})
@@ -443,11 +395,9 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
return options, false, err
}
options.message = value
options.hasMessage = true
index = next
case strings.HasPrefix(arg, "--message="):
options.message = strings.TrimSpace(strings.TrimPrefix(arg, "--message="))
options.hasMessage = true
case arg == "--diff-bytes":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
@@ -465,19 +415,14 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
return options, false, err
}
options.maxDiffBytes = maxDiffBytes
case arg == "-a" || arg == "--auto":
options.auto = true
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown changes flag %q", arg)}
default:
return options, false, execUsageError{fmt.Sprintf("unexpected changes argument %q", arg)}
}
}
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.message != "" || options.dryRun) {
return options, false, execUsageError{"--message and --dry-run are only valid with `zero changes commit`"}
}
if command == "commit" && options.baseRef != "" {
return options, false, execUsageError{"--base is only valid with `zero changes inspect`"}
@@ -767,47 +712,9 @@ Flags:
--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`"+`
-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
-h, --help Show this help
`)
return err
}
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")
promptBuilder.WriteString("The commit message subject line must be 72 characters or fewer, starting with a conventional commit type (e.g., feat, fix, docs, style, refactor, test, chore) followed by a colon and space, and a lowercase description.\n")
promptBuilder.WriteString("You may optionally include a blank line and a bulleted list of changes for the body if there are multiple files or complex changes.\n")
promptBuilder.WriteString("Output ONLY the raw commit message text. Do not wrap the message in markdown code block fence, do not include quotes or any introduction/explanation.\n\n")
promptBuilder.WriteString("Git Diff:\n")
promptBuilder.WriteString(summary.Diff)
request := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: promptBuilder.String()},
},
}
stream, err := provider.StreamCompletion(ctx, request)
if err != nil {
return "", err
}
collected := zeroruntime.CollectStream(ctx, stream)
if collected.Error != "" {
return "", fmt.Errorf("%s", collected.Error)
}
msg := strings.TrimSpace(collected.Text)
if strings.HasPrefix(msg, "```") {
if idx := strings.Index(msg, "\n"); idx != -1 {
msg = msg[idx+1:]
}
}
msg = strings.TrimSuffix(msg, "```")
msg = strings.TrimSpace(msg)
if msg == "" {
return "", fmt.Errorf("provider returned empty commit message")
}
return msg, nil
}
+2 -40
View File
@@ -130,11 +130,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
providers, active, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider, options.Env)
if err != nil {
// On ErrNoActiveProvider, providers may still hold the successfully
// normalized (but active-less) profile list — keep it so a caller can fall
// back to an already-configured usable provider instead of treating this
// like a config with nothing set up at all.
return ResolvedConfig{Providers: providers}, err
return ResolvedConfig{}, err
}
return ResolvedConfig{
@@ -148,7 +144,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Preferences: cfg.Preferences,
KeyBindings: cfg.KeyBindings,
LocalControl: cfg.LocalControl,
}, nil
}
@@ -230,7 +225,6 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
dst.Preferences.Theme = strings.TrimSpace(src.Preferences.Theme)
}
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
}
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
@@ -283,7 +277,6 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
if src.Swarm.MaxTeamSize != 0 {
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
}
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
// Local control is intentionally user-config/override only. A cloned project
// must not be able to make browser, desktop, or terminal automation tools
// appear in the model's tool surface.
@@ -590,17 +583,9 @@ func applyProviderEnv(cfg *FileConfig, providerKind ProviderKind, env envProfile
if profile.Name == "" {
profile.Name = string(providerKind)
}
// A non-official baseURL from the environment signals a proxy/gateway, so promote
// the first-party kind to its -compatible transport (which accepts a custom URL).
// Env-only by design: a custom baseURL in a project config.json is left as-is and
// rejected by Resolve, so an untrusted project can't redirect a first-party key to
// another host.
if providerKind == ProviderKindOpenAI && baseURL != "" && !isOfficialOpenAIBaseURL(baseURL) {
profile.ProviderKind = ProviderKindOpenAICompatible
}
if providerKind == ProviderKindAnthropic && baseURL != "" && !isOfficialAnthropicBaseURL(baseURL) {
profile.ProviderKind = ProviderKindAnthropicCompat
}
// When the env supplies only credentials (no baseURL) for a provider name that
// already exists, don't force the standard transport kind — a same-named
// openai-compatible/anthropic-compatible proxy or gateway must keep its kind.
@@ -680,7 +665,6 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
cfg.Tools.deferThresholdSet = true
}
mergeLocalControlConfig(&cfg.LocalControl, overrides.LocalControl)
mergeKeyBindings(&cfg.KeyBindings, overrides.KeyBindings)
for _, provider := range overrides.Providers {
mergeProvider(cfg, provider)
}
@@ -716,24 +700,6 @@ func mergeLocalControlDriverConfig(dst *LocalControlDriverConfig, src LocalContr
}
}
func mergeKeyBindings(dst *KeyBindingsConfig, src KeyBindingsConfig) {
if src.ToggleDetailed != "" {
dst.ToggleDetailed = src.ToggleDetailed
}
if src.ToggleMouse != "" {
dst.ToggleMouse = src.ToggleMouse
}
if src.CycleReasoning != "" {
dst.CycleReasoning = src.CycleReasoning
}
if src.TogglePlan != "" {
dst.TogglePlan = src.TogglePlan
}
if src.ToggleSidebar != "" {
dst.ToggleSidebar = src.ToggleSidebar
}
}
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
if len(src.Servers) == 0 {
return
@@ -868,11 +834,7 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
}
if !activeFound {
// Return the successfully normalized list alongside the error (rather than
// nil) so a caller like the interactive TUI can still fall back to an
// already-configured, usable provider instead of forcing a full
// re-onboarding wizard just because none was marked active.
return normalized, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
}
if active.Model == "" {
return nil, ProviderProfile{}, &setupFixableError{
-48
View File
@@ -617,35 +617,6 @@ func TestResolveUsesOpenAIEnvFallback(t *testing.T) {
}
}
func TestResolveUsesAnthropicEnvBaseURLAsCompatible(t *testing.T) {
// ANTHROPIC_BASE_URL pointing at a proxy/gateway must resolve to an
// anthropic-compatible provider (mirroring OPENAI_BASE_URL), not the "requires
// official baseURL" rejection — issue #479. The env is user-controlled, so a
// custom URL there is a deliberate gateway choice, unlike a project config.json.
resolved, err := Resolve(ResolveOptions{
Env: map[string]string{
"ANTHROPIC_API_KEY": "sk-ant-env",
"ANTHROPIC_BASE_URL": "https://gateway.example/anthropic",
"ANTHROPIC_MODEL": "claude-custom",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.ActiveProvider != "anthropic" {
t.Fatalf("ActiveProvider = %q, want anthropic", resolved.ActiveProvider)
}
if resolved.Provider.ProviderKind != ProviderKindAnthropicCompat {
t.Fatalf("ProviderKind = %q, want anthropic-compatible", resolved.Provider.ProviderKind)
}
if resolved.Provider.BaseURL != "https://gateway.example/anthropic" {
t.Fatalf("BaseURL = %q, want gateway URL", resolved.Provider.BaseURL)
}
if resolved.Provider.APIKey != "sk-ant-env" || resolved.Provider.Model != "claude-custom" {
t.Fatalf("Provider = %#v, want env credentials/model", resolved.Provider)
}
}
func TestResolveUsesOpenAIAPIKeyOnlyWithDefaultModel(t *testing.T) {
resolved, err := Resolve(ResolveOptions{
Env: map[string]string{
@@ -945,25 +916,6 @@ func TestResolveRejectsActiveProviderWithoutConfiguredProfiles(t *testing.T) {
}
}
func TestResolveKeepsNormalizedProvidersWhenNoneMarkedActive(t *testing.T) {
// Multiple providers configured (e.g. via `zero provider add`) but
// activeProvider is blank/stale — a caller like the interactive TUI still
// needs the normalized list to fall back to an already-usable provider
// instead of forcing a full re-onboarding wizard.
path := writeConfig(t, `{"providers":[
{"name":"work","provider_kind":"openai","apiKey":"sk-test","model":"gpt-test"},
{"name":"other","provider_kind":"openai","apiKey":"sk-other","model":"gpt-test"}
]}`)
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if !errors.Is(err, ErrNoActiveProvider) {
t.Fatalf("error = %v, want errors.Is(err, ErrNoActiveProvider)", err)
}
if len(resolved.Providers) != 2 {
t.Fatalf("Providers = %#v, want the 2 normalized profiles preserved despite the error", resolved.Providers)
}
}
func TestResolveTrimsProviderProfileAliasesBeforeFallback(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "custom",
-26
View File
@@ -112,25 +112,6 @@ func (p PreferencesConfig) RecapsEnabled() bool {
return p.Recaps == nil || *p.Recaps
}
// KeyBindingDef defines one key binding string (e.g. "ctrl+o") that the TUI
// can remap. An empty string means "use the built-in default" for that action.
type KeyBindingDef string
// KeyBindingsConfig holds the subset of TUI keybindings that users may remap
// via config.json. Each field defaults to a sensible built-in when empty.
type KeyBindingsConfig struct {
// ToggleDetailed toggles the detailed transcript view (default: ctrl+o).
ToggleDetailed KeyBindingDef `json:"toggleDetailed,omitempty"`
// ToggleMouse toggles mouse capture release (default: ctrl+e).
ToggleMouse KeyBindingDef `json:"toggleMouse,omitempty"`
// CycleReasoning cycles through reasoning effort levels (default: ctrl+t).
CycleReasoning KeyBindingDef `json:"cycleReasoning,omitempty"`
// TogglePlan toggles the plan panel expansion (default: ctrl+p).
TogglePlan KeyBindingDef `json:"togglePlan,omitempty"`
// ToggleSidebar toggles the right context sidebar (default: ctrl+b).
ToggleSidebar KeyBindingDef `json:"toggleSidebar,omitempty"`
}
// LocalControlConfig controls local browser/desktop/terminal automation helpers.
// Helpers are discovered lazily by the tool that needs them; no setup command or
// background probe is required during startup.
@@ -225,7 +206,6 @@ type FileConfig struct {
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
LocalControl LocalControlConfig `json:"localControl,omitempty"`
}
@@ -240,7 +220,6 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
}
raw := rawConfig{
@@ -253,7 +232,6 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Preferences: cfg.Preferences,
KeyBindings: cfg.KeyBindings,
}
if !cfg.LocalControl.Empty() {
raw.LocalControl = &cfg.LocalControl
@@ -278,7 +256,6 @@ type Overrides struct {
Sandbox SandboxConfig
Notify NotifyConfig
Tools ToolsConfig
KeyBindings KeyBindingsConfig
LocalControl LocalControlConfig
}
@@ -293,7 +270,6 @@ type ResolvedConfig struct {
Tools ToolsConfig
Swarm SwarmConfig
Preferences PreferencesConfig
KeyBindings KeyBindingsConfig
LocalControl LocalControlConfig
}
@@ -340,7 +316,6 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
Tools ToolsConfig `json:"tools"`
Swarm SwarmConfig `json:"swarm"`
Preferences PreferencesConfig `json:"preferences"`
KeyBindings KeyBindingsConfig `json:"keybindings"`
LocalControl LocalControlConfig `json:"localControl"`
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
@@ -367,7 +342,6 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
cfg.Tools = raw.Tools
cfg.Swarm = raw.Swarm
cfg.Preferences = raw.Preferences
cfg.KeyBindings = raw.KeyBindings
cfg.LocalControl = raw.LocalControl
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
cfg.MCP.Servers = map[string]MCPServerConfig{}
+9 -25
View File
@@ -131,45 +131,29 @@ 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. 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.
// actionable install command keyed by its binary name.
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
}
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
missing[binary] = lspRemedy(binary)
}
if len(missing) == 0 {
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", StatusPass, "All known language servers are available on PATH.", map[string]any{
"present": present,
})
}
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)
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,
})
}
// lspRemedy returns an actionable install command for a missing language-server
+15 -123
View File
@@ -9,134 +9,26 @@ 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. 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.
// are not an error, the agent just degrades to text-only for that file.
var serverCommands = map[string][]string{
".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"},
".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"},
}
// languageIDs maps a file extension to the LSP languageId used in didOpen.
var languageIDs = map[string]string{
".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
".go": "go",
".ts": "typescript",
".tsx": "typescriptreact",
".js": "javascript",
".jsx": "javascriptreact",
".py": "python",
".rs": "rust",
}
// ServerBinaries returns the unique set of language-server binaries ZERO may
-4
View File
@@ -63,10 +63,6 @@ func DefaultModelEntries() []ModelEntry {
googleModel("gemini-2.5-flash-lite", "Gemini 2.5 Flash-Lite", "gemini-2.5-flash-lite", ModelStatusActive, []string{"google:gemini-2.5-flash-lite", "gemini-flash-lite"}, ContextLimits{ContextWindow: 1_048_576, MaxOutputTokens: 65_536}, ModelCost{InputPerMillion: 0.1, CachedInputPerMillion: 0.01, OutputPerMillion: 0.4}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityReasoning, ModelCapabilityLongContext}, standardReasoningEfforts(), "Google low-cost Flash model for background routing and summaries."),
}
decorateModelDepth(entries)
// Overlay volatile facts (context limits, base pricing) from a cached
// models.dev snapshot when one is present and fresh — see modelsdev.go.
// Identity fields (ids, aliases, patterns, deprecations) stay curated.
entries = applyModelsDevOverrides(entries, cachedModelsDevProviders())
return cloneModelEntries(entries)
}
-269
View File
@@ -1,269 +0,0 @@
package modelregistry
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
)
// Live models.dev overlay for the curated catalog. The hand-maintained
// DefaultModelEntries list is the source of truth for identity — ids, aliases,
// match patterns, deprecations, escalation targets — but its VOLATILE facts
// (context window, max output tokens, per-million pricing) go stale between
// releases. When a cached snapshot of https://models.dev/api.json is present,
// those fields are refreshed from it at registry construction; everything else
// stays curated. The overlay never adds models (an auto-added entry would lack
// aliases, match patterns, and provider wiring) and never touches the network
// on the registry hot path — fetching happens only in the explicit background
// refresh, cached to disk with a TTL.
const (
modelsDevDefaultURL = "https://models.dev/api.json"
// modelsDevRefreshAfter is how old the cache may get before a background
// refresh re-fetches it.
modelsDevRefreshAfter = 24 * time.Hour
// modelsDevMaxAge is the oldest cache still applied as an overlay. Beyond
// this the curated catalog (updated with the binary) is likely fresher than
// the snapshot, so a stale file is ignored rather than trusted.
modelsDevMaxAge = 7 * 24 * time.Hour
modelsDevFetchLimit = 32 << 20 // 32MiB guard on the response body
modelsDevFetchWindow = 15 * time.Second
)
// modelsDevModel is the subset of a models.dev model record the overlay uses.
type modelsDevModel struct {
Limit struct {
Context int `json:"context"`
Output int `json:"output"`
} `json:"limit"`
Cost struct {
Input float64 `json:"input"`
Output float64 `json:"output"`
CacheRead float64 `json:"cache_read"`
CacheWrite float64 `json:"cache_write"`
} `json:"cost"`
}
// modelsDevProvider matches one provider object in api.json.
type modelsDevProvider struct {
Models map[string]modelsDevModel `json:"models"`
}
// parseModelsDev decodes an api.json document into provider-slug -> api-model
// -> record. api.json is a top-level object keyed by provider id.
func parseModelsDev(data []byte) (map[string]map[string]modelsDevModel, error) {
var doc map[string]modelsDevProvider
if err := json.Unmarshal(data, &doc); err != nil {
return nil, fmt.Errorf("modelregistry: parse models.dev: %w", err)
}
providers := make(map[string]map[string]modelsDevModel, len(doc))
for slug, provider := range doc {
if len(provider.Models) == 0 {
continue
}
providers[slug] = provider.Models
}
if len(providers) == 0 {
return nil, fmt.Errorf("modelregistry: models.dev document has no providers")
}
return providers, nil
}
// modelsDevSlugs maps an entry's provider kind to the models.dev provider ids
// worth checking. Only first-party slugs are used: router entries on models.dev
// carry generic or stale numbers.
func modelsDevSlugs(kind ProviderKind) []string {
switch kind {
case ProviderAnthropic:
return []string{"anthropic"}
case ProviderOpenAI:
return []string{"openai"}
case ProviderGoogle:
return []string{"google", "google-vertex"}
default:
return nil
}
}
// applyModelsDevOverrides refreshes each curated entry's context limits and
// base pricing from the snapshot, when the snapshot knows the model. Tiered
// pricing is never touched: models.dev has no tier data, and mixing a live
// base rate with curated tiers would misprice the tier boundaries.
func applyModelsDevOverrides(entries []ModelEntry, providers map[string]map[string]modelsDevModel) []ModelEntry {
if len(providers) == 0 {
return entries
}
for i := range entries {
entry := &entries[i]
var record modelsDevModel
found := false
for _, slug := range modelsDevSlugs(entry.Provider) {
if models, ok := providers[slug]; ok {
if candidate, ok := models[strings.TrimSpace(entry.APIModel)]; ok {
record = candidate
found = true
break
}
}
}
if !found {
continue
}
if record.Limit.Context > 0 {
entry.ContextLimits.ContextWindow = record.Limit.Context
}
if record.Limit.Output > 0 {
entry.ContextLimits.MaxOutputTokens = record.Limit.Output
}
if len(entry.Cost.Tiers) == 0 && record.Cost.Input > 0 && record.Cost.Output > 0 {
entry.Cost.InputPerMillion = record.Cost.Input
entry.Cost.OutputPerMillion = record.Cost.Output
if record.Cost.CacheRead > 0 {
entry.Cost.CachedInputPerMillion = record.Cost.CacheRead
}
if record.Cost.CacheWrite > 0 {
entry.Cost.CacheWritePerMillion = record.Cost.CacheWrite
}
entry.Cost.Source = "models.dev/api.json (cached)"
}
}
return entries
}
// modelsDevCachePath returns the on-disk cache location. ZERO_MODELS_CACHE_PATH
// overrides it (used by tests and unusual setups).
func modelsDevCachePath() (string, error) {
if override := strings.TrimSpace(os.Getenv("ZERO_MODELS_CACHE_PATH")); override != "" {
return override, nil
}
base, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(base, "zero", "modelsdev.json"), nil
}
var (
modelsDevOnce sync.Once
modelsDevCached map[string]map[string]modelsDevModel
modelsDevEnabled atomic.Bool
)
// EnableModelsDevOverlay opts the process into applying the cached models.dev
// snapshot on top of the curated catalog. The CLI entrypoint calls it; library
// consumers and tests that never do get the curated catalog byte-identical to
// before, so hermetic tests can't be perturbed by a cache file on the machine.
// ZERO_DISABLE_MODELS_FETCH disables both the overlay and the fetch.
func EnableModelsDevOverlay() {
modelsDevEnabled.Store(true)
}
// cachedModelsDevProviders loads the cached snapshot once per process. Not
// enabled, missing, stale (> modelsDevMaxAge), or malformed all yield nil and
// the curated catalog is used untouched. Read once deliberately:
// DefaultRegistry is called on hot paths (pickers, cost views) and must not
// re-stat the file every time; a background refresh benefits the NEXT process.
func cachedModelsDevProviders() map[string]map[string]modelsDevModel {
if !modelsDevEnabled.Load() || strings.TrimSpace(os.Getenv("ZERO_DISABLE_MODELS_FETCH")) != "" {
return nil
}
modelsDevOnce.Do(func() {
path, err := modelsDevCachePath()
if err != nil {
return
}
info, err := os.Stat(path)
if err != nil || time.Since(info.ModTime()) > modelsDevMaxAge {
return
}
data, err := os.ReadFile(path)
if err != nil {
return
}
providers, err := parseModelsDev(data)
if err != nil {
return
}
modelsDevCached = providers
})
return modelsDevCached
}
// resetModelsDevCacheForTest clears the process-level cache memoization and
// disables the overlay.
func resetModelsDevCacheForTest() {
modelsDevOnce = sync.Once{}
modelsDevCached = nil
modelsDevEnabled.Store(false)
}
// RefreshModelsDevCache fetches models.dev/api.json into the on-disk cache
// when the cache is missing or older than modelsDevRefreshAfter. It is safe to
// call fire-and-forget from startup (use a goroutine); it never affects the
// current process's registry (see cachedModelsDevProviders). Disabled entirely
// by ZERO_DISABLE_MODELS_FETCH. The URL can be overridden with ZERO_MODELS_URL.
func RefreshModelsDevCache(ctx context.Context) error {
if strings.TrimSpace(os.Getenv("ZERO_DISABLE_MODELS_FETCH")) != "" {
return nil
}
path, err := modelsDevCachePath()
if err != nil {
return err
}
if info, err := os.Stat(path); err == nil && time.Since(info.ModTime()) < modelsDevRefreshAfter {
return nil
}
url := strings.TrimSpace(os.Getenv("ZERO_MODELS_URL"))
if url == "" {
url = modelsDevDefaultURL
}
fetchCtx, cancel := context.WithTimeout(ctx, modelsDevFetchWindow)
defer cancel()
request, err := http.NewRequestWithContext(fetchCtx, http.MethodGet, url, nil)
if err != nil {
return err
}
request.Header.Set("User-Agent", "zero-models-refresh/0.1")
response, err := http.DefaultClient.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("modelregistry: models.dev fetch: HTTP %d", response.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(response.Body, modelsDevFetchLimit))
if err != nil {
return err
}
// Validate before persisting: a bad body must never clobber a good cache.
if _, err := parseModelsDev(data); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
temp, err := os.CreateTemp(filepath.Dir(path), "modelsdev-*.json")
if err != nil {
return err
}
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
_ = os.Remove(temp.Name())
return err
}
if err := temp.Close(); err != nil {
_ = os.Remove(temp.Name())
return err
}
return os.Rename(temp.Name(), path)
}
-228
View File
@@ -1,228 +0,0 @@
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")
}
+1 -6
View File
@@ -114,12 +114,7 @@ func createSecretFile(path string) ([]byte, error) {
}
return writeNewSecretFile(path)
}
// On Windows a concurrent holder's os.Remove leaves the lock file in a
// "delete pending" state, so an O_EXCL create races it with
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
// as contention and retry too — mirroring acquireFileLock in lock.go —
// otherwise concurrent secret creation spuriously fails on Windows.
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
if !errors.Is(err, os.ErrExist) {
return nil, fmt.Errorf("oauth: create token secret lock: %w", err)
}
if data, rerr := readSecretFileRetry(path); rerr == nil {
@@ -169,8 +169,8 @@ func TestValidateEndpointAllowsLoopbackOnlyForLocalProvider(t *testing.T) {
if err := validateEndpoint(ctx, ep, r, true); err != nil {
t.Errorf("local provider endpoint %q should be allowed with allowLoopback=true, got %v", ep, err)
}
if !endpointIsLoopbackOrPrivate(ep) {
t.Errorf("endpointIsLoopbackOrPrivate(%q) = false, want true", ep)
if !endpointIsLoopback(ep) {
t.Errorf("endpointIsLoopback(%q) = false, want true", ep)
}
}
@@ -179,27 +179,14 @@ func TestValidateEndpointAllowsLoopbackOnlyForLocalProvider(t *testing.T) {
t.Error("loopback must remain blocked when allowLoopback=false (redirects / non-local)")
}
// allowLoopbackOrPrivate relaxes loopback AND private-network ranges — but
// link-local and other special ranges (cloud metadata, documentation) stay
// blocked even for a local provider config.
for _, ep := range []string{"http://169.254.169.254/latest/meta-data", "http://192.0.2.1:8080/v1"} {
// allowLoopback relaxes loopback ONLY — other special ranges (cloud metadata,
// link-local, private) stay blocked even for a local provider config.
for _, ep := range []string{"http://169.254.169.254/latest/meta-data", "http://10.0.0.5:8080/v1"} {
if err := validateEndpoint(ctx, ep, staticResolver{addr: netip.MustParseAddr("169.254.169.254")}, true); err == nil {
t.Errorf("special-use address %q must stay blocked even with flag=true", ep)
t.Errorf("non-loopback special address %q must stay blocked even with allowLoopback=true", ep)
}
if endpointIsLoopbackOrPrivate(ep) {
t.Errorf("endpointIsLoopbackOrPrivate(%q) = true, want false", ep)
}
}
// Private-network addresses (192.168.x.y / 10.x.y.z / 172.16.x.y) are
// now allowed when the flag is true — the user is pointing at their own
// LAN box (e.g. llama.cpp on another local machine).
for _, ep := range []string{"http://192.168.1.100:8080/v1", "http://10.0.0.5:8080/v1", "http://172.16.0.10:8080/v1"} {
if err := validateEndpoint(ctx, ep, staticResolver{addr: netip.MustParseAddr("192.168.1.100")}, true); err != nil {
t.Errorf("private-network address %q should be allowed with flag=true, got %v", ep, err)
}
if !endpointIsLoopbackOrPrivate(ep) {
t.Errorf("endpointIsLoopbackOrPrivate(%q) = false, want true", ep)
if endpointIsLoopback(ep) {
t.Errorf("endpointIsLoopback(%q) = true, want false", ep)
}
}
}
+25 -27
View File
@@ -269,7 +269,7 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
requestCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
request, allowLoopbackOrPrivate, err := healthRequest(requestCtx, profile, kind, options)
request, allowLoopback, err := healthRequest(requestCtx, profile, kind, options)
if err != nil {
category := CategoryConfig
var safety endpointSafetyError
@@ -280,7 +280,7 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
}
client := options.HTTPClient
if client == nil {
client = newConnectivityClient(timeout, options.Resolver, sensitiveAuthHeaderNames(profile, kind), allowLoopbackOrPrivate)
client = newConnectivityClient(timeout, options.Resolver, sensitiveAuthHeaderNames(profile, kind), allowLoopback)
}
response, err := client.Do(request)
if err != nil {
@@ -332,8 +332,8 @@ func healthRequest(ctx context.Context, profile config.ProviderProfile, kind con
// Loopback is permitted only when the user's OWN configured base_url is loopback
// (a local provider like Ollama/LM Studio) — never for a redirect target. This is
// the flag returned to the caller so the dial path applies the same policy. (AUDIT-H1)
allowLoopbackOrPrivate := endpointIsLoopbackOrPrivate(endpoint)
if err := validateEndpoint(ctx, endpoint, options.Resolver, allowLoopbackOrPrivate); err != nil {
allowLoopback := endpointIsLoopback(endpoint)
if err := validateEndpoint(ctx, endpoint, options.Resolver, allowLoopback); err != nil {
return nil, false, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
@@ -344,7 +344,7 @@ func healthRequest(ctx context.Context, profile config.ProviderProfile, kind con
request.Header.Set("User-Agent", options.UserAgent)
}
applyAuth(request, profile, kind)
return request, allowLoopbackOrPrivate, nil
return request, allowLoopback, nil
}
func resolvedBaseURL(profile config.ProviderProfile, kind config.ProviderKind) (string, error) {
@@ -367,11 +367,11 @@ func resolvedBaseURL(profile config.ProviderProfile, kind config.ProviderKind) (
}
// validateEndpoint enforces the SSRF policy for the provider connectivity probe.
// allowLoopbackOrPrivate permits loopback hosts (localhost / 127.0.0.0/8 / ::1)
// and private-network hosts (192.168.0.0/16 / 10.0.0.0/8 / 172.16.0.0/12) — it
// is set true exclusively for the user's own explicitly-configured base_url
// (e.g. Ollama/LM Studio on the LAN), never for redirect targets.
func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, allowLoopbackOrPrivate bool) error {
// allowLoopback permits loopback hosts (localhost / 127.0.0.0/8 / ::1) ONLY — it is
// set true exclusively for the user's own explicitly-configured local provider
// base_url (e.g. Ollama/LM Studio), never for redirect targets, and it relaxes
// loopback alone (other private/special ranges, incl. cloud metadata, stay blocked).
func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, allowLoopback bool) error {
parsed, err := url.Parse(endpoint)
if err != nil {
return err
@@ -385,13 +385,13 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
}
normalized := strings.TrimSuffix(strings.ToLower(host), ".")
if normalized == "localhost" || strings.HasSuffix(normalized, ".localhost") {
if allowLoopbackOrPrivate {
if allowLoopback {
return nil // user-configured local provider; loopback is intentional
}
return endpointSafetyError{message: "provider connectivity URL is unsafe: localhost hosts are blocked"}
}
if addr, err := netip.ParseAddr(normalized); err == nil {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
return nil
@@ -407,19 +407,17 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
return endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
}
for _, addr := range addrs {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
}
return nil
}
// endpointIsLoopbackOrPrivate reports whether the configured endpoint's host is a loopback
// or private-network host the user typed themselves — a local provider on the LAN
// (e.g. 192.168.x.y / 10.x.y.z) or localhost. Used to decide whether the
// connectivity probe may relax address-blocking and reach the user's own
// explicitly-configured provider.
func endpointIsLoopbackOrPrivate(endpoint string) bool {
// endpointIsLoopback reports whether the configured endpoint's host is a loopback
// host the user typed themselves (localhost or a 127.0.0.0/8 / ::1 literal). Used to
// decide whether the connectivity probe may reach the user's own local provider.
func endpointIsLoopback(endpoint string) bool {
parsed, err := url.Parse(endpoint)
if err != nil {
return false
@@ -429,7 +427,7 @@ func endpointIsLoopbackOrPrivate(endpoint string) bool {
return true
}
if addr, err := netip.ParseAddr(host); err == nil {
return addr.IsLoopback() || addr.IsPrivate()
return addr.IsLoopback()
}
return false
}
@@ -448,7 +446,7 @@ const maxConnectivityRedirects = 5
// time and then dials the validated IP literal, closing the TOCTOU window
// between the pre-flight validateEndpoint check and the actual connection
// (DNS rebinding).
func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHeaders []string, allowLoopbackOrPrivate bool) *http.Client {
func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHeaders []string, allowLoopback bool) *http.Client {
if resolver == nil {
resolver = defaultResolver{}
}
@@ -458,7 +456,7 @@ func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHe
} else {
transport = &http.Transport{}
}
transport.DialContext = safeDialContext(resolver, allowLoopbackOrPrivate)
transport.DialContext = safeDialContext(resolver, allowLoopback)
return &http.Client{
Timeout: timeout,
Transport: transport,
@@ -478,8 +476,8 @@ func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHe
}
}
// Redirects are NEVER allowed to reach loopback, even for a local provider:
// allowLoopbackOrPrivate covers only the user's own configured base_url,
// not a 3xx target (which could be attacker-controlled SSRF). (AUDIT-H1)
// allowLoopback covers only the user's own configured base_url, not a 3xx
// target (which could be attacker-controlled SSRF). (AUDIT-H1)
return validateEndpoint(req.Context(), req.URL.String(), resolver, false)
},
}
@@ -521,7 +519,7 @@ func sensitiveAuthHeaderNames(profile config.ProviderProfile, kind config.Provid
// safeDialContext returns a dial function that resolves the target host, refuses
// the connection if any resolved address is blocked, then dials the validated IP
// literal so the kernel cannot re-resolve to a different address after the check.
func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(context.Context, string, string) (net.Conn, error) {
func safeDialContext(resolver Resolver, allowLoopback bool) func(context.Context, string, string) (net.Conn, error) {
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
return func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
@@ -529,7 +527,7 @@ func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(contex
return nil, err
}
if addr, parseErr := netip.ParseAddr(host); parseErr == nil {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
return dialer.DialContext(ctx, network, address)
@@ -542,7 +540,7 @@ func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(contex
return nil, endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
}
for _, addr := range addrs {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
}
@@ -1,96 +0,0 @@
package anthropic
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// The final two messages must carry a cache_control breakpoint on their last
// cacheable block so the conversation transcript — not just system prompt and
// tools — is a prompt-cache hit on the next turn. Earlier messages must stay
// unmarked (Anthropic caps breakpoints at 4 per request: system, tools, and
// these two).
func TestAnthropicRequestMarksLastTwoMessagesForCaching(t *testing.T) {
var gotBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSEEvent(w, "message_stop", `{"type":"message_stop"}`)
}))
defer server.Close()
provider, err := New(Options{BaseURL: server.URL + "/", Model: "claude-test"})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: "first"},
{
Role: zeroruntime.MessageRoleAssistant,
Content: "calling a tool",
ToolCalls: []zeroruntime.ToolCall{{ID: "toolu_1", Name: "grep", Arguments: `{}`}},
},
{Role: zeroruntime.MessageRoleTool, Content: "result", ToolCallID: "toolu_1"},
},
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
messages := gotBody["messages"].([]any)
if len(messages) != 3 {
t.Fatalf("expected 3 wire messages, got %d: %#v", len(messages), messages)
}
lastBlockHasCache := func(message any) bool {
blocks, ok := message.(map[string]any)["content"].([]any)
if !ok || len(blocks) == 0 {
return false
}
last := blocks[len(blocks)-1].(map[string]any)
control, ok := last["cache_control"].(map[string]any)
return ok && control["type"] == "ephemeral"
}
if lastBlockHasCache(messages[0]) {
t.Fatalf("first message must not carry a breakpoint: %#v", messages[0])
}
if !lastBlockHasCache(messages[1]) {
t.Fatalf("second-to-last message must carry a breakpoint: %#v", messages[1])
}
if !lastBlockHasCache(messages[2]) {
t.Fatalf("last message must carry a breakpoint: %#v", messages[2])
}
}
// A string-content message must be converted to a block array so it can carry
// the breakpoint, and thinking blocks must never carry cache_control (the API
// rejects them) — the marker goes on the last cacheable block instead.
func TestApplyMessageCacheBreakpointsSkipsThinkingBlocks(t *testing.T) {
messages := []anthropicMessage{
{Role: "user", Content: "plain string"},
{Role: "assistant", Content: []map[string]any{
{"type": "text", "text": "answer"},
{"type": "thinking", "thinking": "hmm", "signature": "sig"},
}},
}
applyMessageCacheBreakpoints(messages)
userBlocks := contentBlocks(messages[0].Content)
if len(userBlocks) != 1 || userBlocks[0]["cache_control"] == nil {
t.Fatalf("string content must become a marked block: %#v", messages[0].Content)
}
assistantBlocks := contentBlocks(messages[1].Content)
if assistantBlocks[1]["cache_control"] != nil {
t.Fatalf("thinking block must not be marked: %#v", assistantBlocks[1])
}
if assistantBlocks[0]["cache_control"] == nil {
t.Fatalf("text block must carry the breakpoint instead: %#v", assistantBlocks[0])
}
}
-26
View File
@@ -405,35 +405,9 @@ 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{}
+11 -32
View File
@@ -23,18 +23,12 @@ import (
// (billable) work. Only the INITIAL request is ever in scope; once the response
// body starts streaming it is never re-issued.
const defaultMaxRetryAttempts = 6
const defaultMaxRetryAttempts = 3
// 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
@@ -116,11 +110,17 @@ func ShouldRetryStatus(code int) bool {
}
// Backoff waits before retry attempt N (1-based), returning false if the context
// 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.
// is cancelled during the wait. The wait is attempt*400ms unless the server
// supplied a (positive) Retry-After, and is capped at maxBackoff.
func Backoff(ctx context.Context, attempt int, retryAfter time.Duration) bool {
timer := time.NewTimer(backoffWait(attempt, retryAfter))
wait := time.Duration(attempt) * 400 * time.Millisecond
if retryAfter > 0 {
wait = retryAfter
}
if wait > maxBackoff {
wait = maxBackoff
}
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case <-ctx.Done():
@@ -130,27 +130,6 @@ 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,40 +94,7 @@ 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 {
@@ -173,7 +140,6 @@ 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)
-3
View File
@@ -72,9 +72,6 @@ func TestPermissionProfileFromPolicyIncludesDefaultTempWriteRoots(t *testing.T)
if !writeRootsContain(profile.FileSystem.WriteRoots, tmpdir) {
t.Fatalf("write roots = %#v, want temp root %q", profile.FileSystem.WriteRoots, tmpdir)
}
// /tmp is a default temp write root on POSIX only (see
// defaultTempWriteRootCandidatesForGOOS); on Windows the bare path resolves
// against the current drive, so a stray C:\tmp must not turn this on.
if runtime.GOOS != "windows" && pathExists("/tmp") && !writeRootsContain(profile.FileSystem.WriteRoots, "/tmp") {
t.Fatalf("write roots = %#v, want /tmp", profile.FileSystem.WriteRoots)
}
-3
View File
@@ -129,9 +129,6 @@ func TestNewScopeNormalizesAndValidatesExtraRoots(t *testing.T) {
if !stringSliceContains(roots, normalizeWorkspaceRootBestEffort(extra)) {
t.Fatalf("Roots()=%v want extra root %q", roots, normalizeWorkspaceRootBestEffort(extra))
}
// /tmp is a default temp write root on POSIX only (see
// defaultTempWriteRootCandidatesForGOOS); on Windows the bare path resolves
// against the current drive, so a stray C:\tmp must not turn this on.
if runtime.GOOS != "windows" && pathExists("/tmp") && !stringSliceContains(roots, normalizeWorkspaceRootBestEffort("/tmp")) {
t.Fatalf("Roots()=%v want default /tmp write root", roots)
}
+1 -10
View File
@@ -87,20 +87,11 @@ func windowsCommandLine(args []string) (string, error) {
if len(args) == 0 {
return "", errorsNewWindowsProcess("missing command")
}
out := make([]string, 0, len(args))
for _, arg := range args {
if strings.ContainsRune(arg, 0) {
return "", errorsNewWindowsProcess("command argument contains NUL")
}
}
// Zero's cmd.exe shell invocation needs its final element (the shell
// command text) reaching cmd.exe raw, not escaped like a normal argv
// element: see WindowsShellCommandLine for why. Every other command this
// runner might launch is escaped normally below.
if commandLine, ok := windowsShellCommandLineFromArgs(args); ok {
return commandLine, nil
}
out := make([]string, 0, len(args))
for _, arg := range args {
out = append(out, syscall.EscapeArg(arg))
}
return strings.Join(out, " "), nil
-50
View File
@@ -105,56 +105,6 @@ const (
WindowsSandboxLevelDisabled WindowsSandboxLevel = "disabled"
)
// WindowsShellArgs returns cmd.exe's argv (after the executable name) for
// running commandText as a shell command on Windows: no wrapping quotes and
// no /S, so commandText reaches cmd.exe's own /C remainder parsing exactly as
// written. See WindowsShellCommandLine for why that matters. Used both as
// CommandSpec.Args for the plain (unwrapped) path and, via
// windowsShellCommandLineFromArgs, to recognize this same shape once it has
// round-tripped through the sandboxed runner's CLI argv.
func WindowsShellArgs(commandText string) []string {
return []string{"/d", "/c", commandText}
}
// WindowsShellCommandLine builds the raw command line a Windows CreateProcess
// call should receive to run commandText as a shell command: "cmd.exe /d /c "
// followed by commandText completely unescaped.
//
// cmd.exe's own /C remainder parsing is not CommandLineToArgvW-compatible: it
// applies its own quote-stripping heuristic (documented in `cmd /?`) whenever
// the remainder starts with a literal quote, and that heuristic does not
// understand backslash-escaped inner quotes the way a normal argv consumer
// would. A command like `python -c "print(15 / 3)"` passed through the
// standard exec.Cmd Args mechanism gets wrapped in an outer pair of quotes
// (because it contains spaces) with its own quotes escaped as \" — exactly
// the shape that heuristic corrupts, stripping the outer quotes but leaving
// the literal backslashes behind. Passing commandText through raw, exactly as
// the model wrote it, means cmd.exe parses it the same way it would if typed
// directly at an interactive prompt: a leading quoted executable path like
// `"C:\Program Files\foo.exe" arg` is preserved correctly by cmd.exe's own
// quote-preserving special case, and a command with no leading quote at all,
// like the python example above, is never touched by the stripping heuristic
// in the first place.
func WindowsShellCommandLine(commandText string) string {
return "cmd.exe /d /c " + commandText
}
// windowsShellCommandLineFromArgs reports whether args is exactly
// ["cmd.exe"]+WindowsShellArgs(text) for some text and, if so, returns
// WindowsShellCommandLine's result for that text. The sandboxed runner path
// only has the deserialized argv (WindowsSandboxCommandConfig.Command) to
// work from, not the original commandText, so it recovers the raw command
// line by recognizing the shape instead.
func windowsShellCommandLineFromArgs(args []string) (string, bool) {
if len(args) != 4 {
return "", false
}
if !strings.EqualFold(args[0], "cmd.exe") || args[1] != "/d" || args[2] != "/c" {
return "", false
}
return WindowsShellCommandLine(args[3]), true
}
type WindowsSandboxCommandArgsOptions struct {
SandboxHome string
CommandCWD string
-53
View File
@@ -147,56 +147,3 @@ func TestRunWindowsSandboxCommandRunnerRejectsInvalidArgs(t *testing.T) {
t.Fatalf("stderr = %q, want runner name", stderr.String())
}
}
func TestWindowsShellArgsHasNoSAndKeepsCommandTextWhole(t *testing.T) {
args := WindowsShellArgs(`python -c "print(15 / 3)"`)
want := []string{"/d", "/c", `python -c "print(15 / 3)"`}
if len(args) != len(want) {
t.Fatalf("args = %#v, want %#v", args, want)
}
for index := range want {
if args[index] != want[index] {
t.Fatalf("args = %#v, want %#v", args, want)
}
}
}
func TestWindowsShellCommandLineLeavesCommandTextUnescaped(t *testing.T) {
// The whole point of this function: commandText's own quotes must reach
// cmd.exe exactly as written, not re-quoted or backslash-escaped the way
// a normal argv-escaping function (like syscall.EscapeArg) would treat an
// argument containing spaces and embedded quotes.
commandText := `python -c "print(15 / 3)"`
got := WindowsShellCommandLine(commandText)
want := `cmd.exe /d /c python -c "print(15 / 3)"`
if got != want {
t.Fatalf("WindowsShellCommandLine = %q, want %q", got, want)
}
}
func TestWindowsShellCommandLineFromArgsRecognizesTheShellShape(t *testing.T) {
commandLine, ok := windowsShellCommandLineFromArgs([]string{"cmd.exe", "/d", "/c", `echo "hi"`})
if !ok {
t.Fatal("windowsShellCommandLineFromArgs did not recognize the shell shape")
}
if want := `cmd.exe /d /c echo "hi"`; commandLine != want {
t.Fatalf("commandLine = %q, want %q", commandLine, want)
}
}
func TestWindowsShellCommandLineFromArgsRejectsOtherShapes(t *testing.T) {
cases := [][]string{
nil,
{"cmd.exe"},
{"cmd.exe", "/d", "/c"},
{"cmd.exe", "/d", "/c", "echo hi", "extra"},
{"git.exe", "/d", "/c", "echo hi"},
{"cmd.exe", "/s", "/c", "echo hi"},
{"cmd.exe", "/d", "/k", "echo hi"},
}
for _, args := range cases {
if _, ok := windowsShellCommandLineFromArgs(args); ok {
t.Fatalf("windowsShellCommandLineFromArgs(%#v) matched, want no match", args)
}
}
}
+3 -5
View File
@@ -61,10 +61,8 @@ func buildWindowsUnelevatedAppliedPlan(config WindowsSandboxCommandConfig) (Wind
// loadWindowsUnelevatedSetupMarker reads the marker; a missing file is the
// common first-run state and returns an empty marker, not an error. A marker
// with an unexpected schema version or a corrupt/truncated body is likewise
// treated as empty so the runner re-applies (and rewrites) rather than failing:
// the marker only memoizes idempotent ACL applies, so resetting it can only
// cost redundant work, never skip enforcement.
// with an unexpected schema version is treated as empty so the runner re-applies
// (and rewrites) rather than failing.
func loadWindowsUnelevatedSetupMarker(sandboxHome string) (WindowsUnelevatedSetupMarker, error) {
sandboxHome = strings.TrimSpace(sandboxHome)
if sandboxHome == "" {
@@ -80,7 +78,7 @@ func loadWindowsUnelevatedSetupMarker(sandboxHome string) (WindowsUnelevatedSetu
}
var marker WindowsUnelevatedSetupMarker
if err := json.Unmarshal(bytes, &marker); err != nil {
return WindowsUnelevatedSetupMarker{SchemaVersion: windowsUnelevatedSetupMarkerSchemaVersion}, nil
return WindowsUnelevatedSetupMarker{}, fmt.Errorf("parse windows unelevated setup marker %s: %w", path, err)
}
if marker.SchemaVersion != windowsUnelevatedSetupMarkerSchemaVersion {
return WindowsUnelevatedSetupMarker{SchemaVersion: windowsUnelevatedSetupMarkerSchemaVersion}, nil
@@ -138,38 +138,6 @@ func TestWindowsUnelevatedSetupMarkerEvictsOldestPastCap(t *testing.T) {
}
}
func TestLoadWindowsUnelevatedSetupMarkerSelfHealsCorruptFile(t *testing.T) {
home := filepath.Join(t.TempDir(), ".zero-sandbox")
if err := os.MkdirAll(home, 0o700); err != nil {
t.Fatalf("mkdir sandbox home: %v", err)
}
path := WindowsUnelevatedSetupMarkerPath(home)
// A truncated body must reset like an unknown schema, not brick every
// unelevated command until the file is hand-deleted.
if err := os.WriteFile(path, []byte(`{"schemaVersion":1,"appliedPlans":[{"aclPlanHash":"h`), 0o600); err != nil {
t.Fatalf("write corrupt marker: %v", err)
}
marker, err := loadWindowsUnelevatedSetupMarker(home)
if err != nil {
t.Fatalf("corrupt marker must self-heal, got error: %v", err)
}
if len(marker.AppliedPlans) != 0 || marker.SchemaVersion != windowsUnelevatedSetupMarkerSchemaVersion {
t.Fatalf("corrupt marker must reset to empty current-schema marker, got %+v", marker)
}
// The healed marker must accept new recordings over the corrupt file.
applied := WindowsUnelevatedAppliedPlan{ACLPlanHash: "hash-heal", ACLPlanEntries: 2}
if err := recordWindowsUnelevatedAppliedPlan(home, applied); err != nil {
t.Fatalf("record over corrupt marker: %v", err)
}
marker, err = loadWindowsUnelevatedSetupMarker(home)
if err != nil {
t.Fatalf("reload healed marker: %v", err)
}
if !marker.contains(applied) {
t.Fatalf("healed marker = %+v, want the recorded plan", marker)
}
}
func TestLoadWindowsUnelevatedSetupMarkerResetsUnknownSchema(t *testing.T) {
home := filepath.Join(t.TempDir(), ".zero-sandbox")
if err := os.MkdirAll(home, 0o700); err != nil {
+4 -20
View File
@@ -160,7 +160,10 @@ func FormatExecPrompt(prompt string, prepared PreparedExec) string {
if prepared.Mode == ModeNew || len(prepared.ContextEvents) == 0 {
return prompt
}
events := promptContextEvents(prepared.ContextEvents)
events := prepared.ContextEvents
if len(events) > 20 {
events = events[len(events)-20:]
}
lines := []string{}
for _, event := range events {
@@ -184,25 +187,6 @@ func FormatExecPrompt(prompt string, prepared PreparedExec) string {
}, "\n")
}
func promptContextEvents(events []Event) []Event {
const maxPromptContextEvents = 80
filtered := make([]Event, 0, len(events))
for _, event := range events {
switch event.Type {
case EventMessage, EventCompaction, EventSessionFork, EventSessionChild, EventSpecialistStart, EventSpecialistStop, EventError:
filtered = append(filtered, event)
}
}
if len(filtered) == 0 {
filtered = append(filtered, events...)
}
if len(filtered) > maxPromptContextEvents {
filtered = filtered[len(filtered)-maxPromptContextEvents:]
}
return filtered
}
func forkTitle(title string) string {
if title == "" {
return ""
+1 -1
View File
@@ -71,7 +71,7 @@ type CompactionPayload struct {
Truncated bool `json:"truncated,omitempty"`
}
const defaultCompactionPreserveLast = 6
const defaultCompactionPreserveLast = 8
const defaultCompactionMaxPromptChars = 8000
func (store *Store) PlanRewind(sessionID string, options RewindOptions) (RewindPlan, error) {
-85
View File
@@ -555,91 +555,6 @@ func TestPrepareExecSessionResolvesResumeAndFork(t *testing.T) {
}
}
func TestFormatExecPromptKeepsConversationMessagesWhenNoisyEventsFollow(t *testing.T) {
events := []Event{
{Sequence: 1, Type: EventMessage, Payload: json.RawMessage(`{"role":"user","content":"first user request"}`)},
{Sequence: 2, Type: EventMessage, Payload: json.RawMessage(`{"role":"assistant","content":"first assistant answer"}`)},
}
for sequence := 3; sequence <= 45; sequence++ {
events = append(events, Event{Sequence: sequence, Type: EventToolResult, Payload: json.RawMessage(`{"name":"read_file","output":"noisy tool result"}`)})
}
events = append(events, Event{Sequence: 46, Type: EventMessage, Payload: json.RawMessage(`{"role":"user","content":"latest user request"}`)})
prompt := FormatExecPrompt("continue", PreparedExec{
Mode: ModeResume,
Session: Metadata{SessionID: "session-with-noise"},
ContextEvents: events,
})
for _, want := range []string{"first user request", "first assistant answer", "latest user request", "Current user request:", "continue"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected prompt to contain %q, got %q", want, prompt)
}
}
if strings.Contains(prompt, "noisy tool result") {
t.Fatalf("expected prompt to omit noisy non-conversation events, got %q", prompt)
}
}
func TestFormatExecPromptTruncatesConversationMessagesAfterFilteringNoise(t *testing.T) {
events := make([]Event, 0, 100)
for sequence := 1; sequence <= 85; sequence++ {
events = append(events, Event{
Sequence: sequence,
Type: EventMessage,
Payload: json.RawMessage(fmt.Sprintf(`{"role":"user","content":"conversation message %03d"}`, sequence)),
})
if sequence%10 == 0 {
events = append(events, Event{
Sequence: 1000 + sequence,
Type: EventToolResult,
Payload: json.RawMessage(fmt.Sprintf(`{"name":"read_file","output":"noisy tool result %03d"}`, sequence)),
})
}
}
events = append(events, Event{
Sequence: 2000,
Type: EventToolResult,
Payload: json.RawMessage(`{"name":"bash","output":"trailing noisy tool result"}`),
})
contextEvents := promptContextEvents(events)
if len(contextEvents) != 80 {
t.Fatalf("expected prompt context to keep 80 events, got %d", len(contextEvents))
}
if contextEvents[0].Sequence != 6 || contextEvents[len(contextEvents)-1].Sequence != 85 {
t.Fatalf("expected prompt context to keep message sequences 6-85, got first=%d last=%d", contextEvents[0].Sequence, contextEvents[len(contextEvents)-1].Sequence)
}
prompt := FormatExecPrompt("continue", PreparedExec{
Mode: ModeResume,
Session: Metadata{SessionID: "long-session-with-noise"},
ContextEvents: events,
})
if got, want := strings.Count(prompt, "- #"), len(contextEvents); got != want {
t.Fatalf("expected prompt to contain %d context event lines, got %d in %q", want, got, prompt)
}
for _, want := range []string{"conversation message 006", "conversation message 085", "Current user request:", "continue"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected prompt to contain %q, got %q", want, prompt)
}
}
for _, unwanted := range []string{
"conversation message 001",
"conversation message 002",
"conversation message 003",
"conversation message 004",
"conversation message 005",
"noisy tool result",
"trailing noisy tool result",
} {
if strings.Contains(prompt, unwanted) {
t.Fatalf("expected prompt to omit %q, got %q", unwanted, prompt)
}
}
}
func TestPrepareExecPersistsSpecialistMetadataForNewSession(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T14:30:00Z")})
-8
View File
@@ -25,7 +25,6 @@ import (
const (
sessionTagSpecialist = "specialist"
promptFileThresholdBytes = 4 * 1024
maxSpecialistDepth = 8 // hard cap to prevent infinite recursion/resource exhaustion via Task
)
const SessionTagSpecialist = sessionTagSpecialist
@@ -219,13 +218,6 @@ func (executor Executor) Run(ctx context.Context, params TaskParameters, options
if options.CurrentDepth < 0 {
return ExecResult{}, fmt.Errorf("current depth cannot be negative")
}
// >= (not >): this Run call spawns a CHILD at options.CurrentDepth+1, so a
// parent already AT the cap must still be rejected here rather than being
// allowed to launch one more level before the child's own next call trips
// the guard.
if options.CurrentDepth >= maxSpecialistDepth {
return ExecResult{}, fmt.Errorf("spawning a specialist at depth %d would exceed maximum nesting depth %d", options.CurrentDepth+1, maxSpecialistDepth)
}
if strings.TrimSpace(params.Prompt) == "" {
return ExecResult{}, fmt.Errorf("specialist prompt is required")
}
-34
View File
@@ -1,7 +1,6 @@
package specialist
import (
"context"
"os"
"path/filepath"
"reflect"
@@ -337,39 +336,6 @@ func TestBuildArgsRejectsInvalidInputs(t *testing.T) {
}
}
func TestRunRejectsDepthExceedingMax(t *testing.T) {
_, err := (Executor{}).Run(context.Background(), TaskParameters{
Prompt: "hi",
}, TaskRunOptions{CurrentDepth: maxSpecialistDepth + 1})
if err == nil || !strings.Contains(err.Error(), "depth") {
t.Fatalf("Run error = %v, want depth error", err)
}
}
// TestRunRejectsDepthAtMax covers the boundary: a parent already AT the cap
// must be rejected too, since this Run call would launch a child one level
// past it (--depth CurrentDepth+1). Only checking ">" here would let that
// child start before the guard ever caught it. The guard sits before the
// fresh/resume branch in Run, so both call shapes must be proven to reject
// here rather than falling through to runFresh/runResume.
func TestRunRejectsDepthAtMax(t *testing.T) {
tests := []struct {
name string
params TaskParameters
}{
{name: "fresh", params: TaskParameters{Prompt: "hi"}},
{name: "resume", params: TaskParameters{Prompt: "hi", Resume: "child_session"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := (Executor{}).Run(context.Background(), tc.params, TaskRunOptions{CurrentDepth: maxSpecialistDepth})
if err == nil || !strings.Contains(err.Error(), "depth") {
t.Fatalf("Run error = %v, want depth error", err)
}
})
}
}
func TestBuildArgsRejectsInvalidSessionIDs(t *testing.T) {
_, err := (Executor{NewSessionID: func() (string, error) { return "../escape", nil }}).BuildArgs(BuildArgsInput{
Prompt: "hi",
+4 -21
View File
@@ -120,6 +120,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
Meta: meta,
}
}
// Release any plan-scoped resources once the command has finished running.
defer plan.Cleanup()
addSandboxMeta(meta, plan)
@@ -220,14 +221,6 @@ func shellIssueBlockResult(issue shellIssue) Result {
}
}
// buildBashCommand returns the exec.Cmd and the sandbox plan for running
// commandText. On Windows, when the command is not wrapped by the sandbox
// engine (plan.Wrapped == false), it also overrides the child's raw command
// line so commandText reaches cmd.exe unescaped; see
// zeroSandbox.WindowsShellCommandLine for why that matters. The wrapped case
// gets the same treatment inside the sandboxed runner process itself
// (internal/sandbox/windows_process_windows.go), since that command line is
// built there, not here.
func buildBashCommand(ctx context.Context, commandText string, absoluteCwd string, engine *zeroSandbox.Engine) (*exec.Cmd, zeroSandbox.CommandPlan, error) {
spec := zeroSandbox.CommandSpec{
Name: shellExecutable(),
@@ -235,11 +228,7 @@ func buildBashCommand(ctx context.Context, commandText string, absoluteCwd strin
Dir: absoluteCwd,
}
if engine != nil {
command, plan, err := engine.CommandContext(ctx, spec)
if err == nil {
applyWindowsShellCommandLine(command, commandText, plan.Wrapped)
}
return command, plan, err
return engine.CommandContext(ctx, spec)
}
plan := zeroSandbox.CommandPlan{
Backend: zeroSandbox.Backend{
@@ -253,7 +242,6 @@ func buildBashCommand(ctx context.Context, commandText string, absoluteCwd strin
}
command := exec.CommandContext(ctx, spec.Name, spec.Args...)
command.Dir = spec.Dir
applyWindowsShellCommandLine(command, commandText, plan.Wrapped)
return command, plan, nil
}
@@ -314,7 +302,7 @@ func shellExecutable() string {
func shellArguments(command string) []string {
if runtime.GOOS == "windows" {
return zeroSandbox.WindowsShellArgs(command)
return []string{"/d", "/s", "/c", command}
}
return []string{"-c", command}
}
@@ -354,12 +342,7 @@ func formatBashOutput(stdout string, stderr string, exitCode int) string {
if len(parts) == 0 {
return "Command completed with no output."
}
// Cap the context cost of a verbose command at the same default budget as
// exec_command (head/tail window); the full output is spilled to disk and
// referenced in the truncation notice, so nothing is lost. Previously bash
// output was unbounded — one chatty build log could flood the context.
output, _ := truncateExecOutputSpill(strings.Join(parts, "\n"), defaultMaxOutputTokens, "bash")
return output
return strings.Join(parts, "\n")
}
func formatBashOutputWithShellHint(command string, stdout string, stderr string, exitCode int, meta map[string]string) string {
-4
View File
@@ -42,7 +42,3 @@ func hardenProcessLifetime(command *exec.Cmd) {
return nil
}
}
// applyWindowsShellCommandLine is a no-op outside Windows; there is no
// cmd.exe command-line-parsing quirk to work around here.
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool) {}
-17
View File
@@ -5,10 +5,7 @@ package tools
import (
"os/exec"
"strconv"
"syscall"
"time"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
)
// bashWaitDelay bounds how long Wait blocks for the I/O pipes to drain after the
@@ -31,17 +28,3 @@ func hardenProcessLifetime(command *exec.Cmd) {
return nil
}
}
// applyWindowsShellCommandLine overrides command's raw child command line so
// commandText reaches cmd.exe unescaped instead of auto-quoted the way
// exec.Cmd would normally encode a single Args element. Skipped when wrapped
// is true: the sandbox engine then routes execution through a separate
// zero-windows-command-runner process, which builds its own child command
// line from scratch (internal/sandbox/windows_process_windows.go) rather than
// inheriting whatever this outer exec.Cmd is configured with.
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool) {
if wrapped {
return
}
command.SysProcAttr = &syscall.SysProcAttr{CmdLine: zeroSandbox.WindowsShellCommandLine(commandText)}
}
-103
View File
@@ -17,18 +17,6 @@ import (
func TestMain(m *testing.M) {
if len(os.Args) >= 3 && os.Args[1] == "--zero-bash-helper" {
if os.Args[2] == "echo-arg" {
// Echoes back exactly the one argument it received, to verify a
// quoted, space-and-slash-containing argument (the same shape as
// `python -c "print(15 / 3)"`) survives the shell invocation
// intact instead of being truncated/corrupted.
if len(os.Args) < 4 {
fmt.Fprintln(os.Stderr, "echo-arg requires exactly one argument")
os.Exit(1)
}
fmt.Println(os.Args[3])
return
}
runBashToolHelper(os.Args[2])
return
}
@@ -173,35 +161,6 @@ func TestDetectShellCommandIssueRequiresActualLSCommand(t *testing.T) {
}
}
func TestDetectShellCommandIssueFlagsPipedPosixUtilities(t *testing.T) {
for _, command := range []string{
`git log --oneline -- pinokio_compat.py | head`,
`git blame -L 40,46 pinokio_compat.py --format=%author | grep summary`,
`cat file.txt | wc -l`,
`some_command | tail -n 20`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue == nil {
t.Fatalf("expected POSIX utility pipeline to be flagged for %q", command)
}
}
}
func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
for _, command := range []string{
`git log --oneline`,
`dir /b`,
`findstr /r "summary" file.txt`,
`echo header text`,
`tail-log.sh`,
`grep-cli --version`,
`sed.exe --help`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
t.Fatalf("expected unrelated command to pass for %q, got %#v", command, issue)
}
}
}
func TestDetectShellOutputIssueAddsWindowsSyntaxHint(t *testing.T) {
issue := detectShellOutputIssue(`cd /d/tmp/zero-pr-158 && ls -la`, "The syntax of the command is incorrect.", "windows")
if issue == nil {
@@ -547,68 +506,6 @@ func TestBashToolAllowsNonInteractiveCommand(t *testing.T) {
}
}
// TestBashToolPreservesEmbeddedQuotesOnWindows pins a real, previously-broken
// case: passing commandText as a normal exec.Cmd argument makes Go wrap it in
// an outer pair of quotes (it contains spaces) with its own quotes escaped as
// \", and cmd.exe's /C remainder parsing strips the first and last literal
// quote character in that remainder without undoing the backslash-escaping -
// corrupting a command whose own text contains embedded double quotes,
// exactly the shape of `python -c "print(15 / 3)"`, `git commit -m
// "message"`, `node -e "..."`. Before the fix, the helper below received a
// truncated, mis-quoted argument (starting with a stray literal `"`) instead
// of the text between the quotes.
func TestBashToolPreservesEmbeddedQuotesOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("cmd.exe /S/C quote-remainder parsing is Windows-specific")
}
root := t.TempDir()
executable := shellQuote(os.Args[0])
// Space and a slash: the two characters whose position in the original
// bug report's SyntaxError ("print(15, truncated right before the /")
// showed exactly where the corruption happened.
commandText := executable + ` --zero-bash-helper echo-arg "hello / world"`
result := NewBashTool(root).Run(context.Background(), map[string]any{
"command": commandText,
})
if result.Status != StatusOK {
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
}
if !strings.Contains(result.Output, "hello / world") {
t.Fatalf("expected the embedded-quote argument to survive intact, got %q", result.Output)
}
}
// TestBashToolRunsCommandLineForLoopSyntax pins cmd.exe command-line syntax,
// not batch-file syntax: a `for %i in (...) do ...` loop with a single
// percent sign is valid typed directly at a cmd.exe prompt (or via
// `cmd /C "..."`), but requires %%i inside an actual .bat/.cmd FILE, because
// batch files perform an extra pass of percent-substitution before FOR ever
// runs, consuming the single percent so the loop variable never binds. An
// earlier version of this fix ran commandText from a temporary .cmd file
// instead of passing it straight through to cmd.exe's /C remainder, which
// silently broke single-percent syntax like this.
func TestBashToolRunsCommandLineForLoopSyntax(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("cmd.exe command-line vs batch-file parsing is Windows-specific")
}
root := t.TempDir()
result := NewBashTool(root).Run(context.Background(), map[string]any{
"command": "for %i in (1 2 3) do echo %i",
})
if result.Status != StatusOK {
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
}
for _, want := range []string{"1", "2", "3"} {
if !strings.Contains(result.Output, want) {
t.Fatalf("expected the for-loop to expand %%i to %q, got %q", want, result.Output)
}
}
}
func helperCommand(name string) string {
executable := shellQuote(os.Args[0])
return executable + " --zero-bash-helper " + name
+1 -33
View File
@@ -2,7 +2,6 @@ package tools
import (
"context"
"errors"
"fmt"
"os"
"strings"
@@ -45,7 +44,7 @@ func (tool editFileTool) Run(ctx context.Context, args map[string]any) Result {
return tool.RunWithOptions(ctx, args, RunOptions{})
}
func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result {
func (tool editFileTool) RunWithOptions(_ 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())
@@ -99,32 +98,6 @@ func (tool editFileTool) RunWithOptions(ctx 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
newString = findNew
occurrences = strings.Count(content, search)
case errors.Is(ferr, errEditFuzzyAmbiguous):
return errorResult("Error: old_string matches multiple locations in " + relativePath + " even after fuzzy matching. Provide more surrounding context to make the match unique, or pass replace_all: true.")
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.")
}
@@ -147,10 +120,6 @@ func (tool editFileTool) RunWithOptions(ctx 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)
@@ -161,7 +130,6 @@ func (tool editFileTool) RunWithOptions(ctx 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
-457
View File
@@ -1,457 +0,0 @@
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"
"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 {
for _, search := range replacer(content, find) {
if search == "" {
continue
}
index := strings.Index(content, search)
if index < 0 {
continue
}
found = true
if isDisproportionateEditMatch(search, find) {
return "", errors.New("refusing replacement because the matched span is much larger than old_string; re-read the file and provide the full exact old_string for the intended replacement")
}
if replaceAll {
return search, nil
}
if strings.LastIndex(content, search) != index {
continue
}
return search, nil
}
}
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)
}
best := candidates[0]
if len(candidates) > 1 {
bestSimilarity := -1.0
for _, candidate := range candidates {
if s := middleSimilarity(candidate); s > bestSimilarity {
bestSimilarity = s
best = candidate
}
}
if bestSimilarity < editAnchorSimilarityThreshold {
return nil
}
} else if middleSimilarity(best) < editAnchorSimilarityThreshold {
return nil
}
return []string{strings.Join(contentLines[best.start:best.end+1], "\n")}
}
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
}
// 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)]
}
-264
View File
@@ -1,264 +0,0 @@
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": "\ty := 2\n\treturn y",
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %q", result.Output)
}
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": "\t\tsum += total(xs)",
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %q", result.Output)
}
if !strings.Contains(after, "total(xs)") || strings.Contains(after, "range xs") {
t.Fatalf("unexpected content: %q", after)
}
}
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)
}
}
}
+1 -14
View File
@@ -908,15 +908,6 @@ 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
}
@@ -924,13 +915,9 @@ func truncateExecOutputSpill(output string, maxOutputTokens int, toolName string
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) + notice + utf8Suffix(output, tail), true
return utf8Prefix(output, head) + "\n[zero] output truncated\n" + utf8Suffix(output, tail), true
}
func utf8Prefix(value string, maxBytes int) string {
-97
View File
@@ -1,97 +0,0 @@
package tools
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// Format-on-write for the mutating file tools. When enabled, a successful
// edit_file/write_file runs the language's standard formatter on the file it
// just wrote, so the model's output always lands in project-canonical style
// and never fails a CI format check it cannot see. Off by default (set
// ZERO_FORMAT_ON_WRITE=1): auto-reformatting changes bytes the model did not
// write, which strict workflows may not want.
//
// Ordering matters: formatting runs BEFORE the FileTracker re-baseline, and
// the caller records the POST-format content. Formatting after the baseline
// would make the very next edit look like an external modification and trip
// the conflict guard.
// formatOnWriteTimeout bounds one formatter run; a wedged formatter must never
// hang a tool call. On timeout the unformatted write stands.
const formatOnWriteTimeout = 10 * time.Second
// formatterCommands maps a file extension to the formatter argv; the file path
// is appended as the final argument. Only in-place, config-respecting,
// community-standard formatters — a missing binary silently skips formatting.
var formatterCommands = map[string][]string{
".go": {"gofmt", "-w"},
".rs": {"rustfmt"},
".py": {"ruff", "format", "--quiet"},
".ts": {"prettier", "--log-level", "silent", "--write"},
".tsx": {"prettier", "--log-level", "silent", "--write"},
".js": {"prettier", "--log-level", "silent", "--write"},
".jsx": {"prettier", "--log-level", "silent", "--write"},
".json": {"prettier", "--log-level", "silent", "--write"},
".css": {"prettier", "--log-level", "silent", "--write"},
".scss": {"prettier", "--log-level", "silent", "--write"},
".html": {"prettier", "--log-level", "silent", "--write"},
".md": {"prettier", "--log-level", "silent", "--write"},
".yaml": {"prettier", "--log-level", "silent", "--write"},
".yml": {"prettier", "--log-level", "silent", "--write"},
".zig": {"zig", "fmt"},
".dart": {"dart", "format"},
".tf": {"terraform", "fmt"},
".gleam": {"gleam", "format"},
".sh": {"shfmt", "-w"},
".bash": {"shfmt", "-w"},
".c": {"clang-format", "-i"},
".h": {"clang-format", "-i"},
".cpp": {"clang-format", "-i"},
".hpp": {"clang-format", "-i"},
".cc": {"clang-format", "-i"},
".kt": {"ktlint", "-F"},
".swift": {"swiftformat"},
".lua": {"stylua"},
}
// formatOnWriteEnabled reports whether the opt-in env toggle is set.
func formatOnWriteEnabled() bool {
value := strings.TrimSpace(os.Getenv("ZERO_FORMAT_ON_WRITE"))
return value != "" && value != "0" && !strings.EqualFold(value, "false")
}
// maybeFormatWrittenFile runs the configured formatter for absolutePath (when
// enabled and on PATH) and returns the file's content afterwards. Best-effort
// throughout: any failure — no formatter, formatter error, timeout, unreadable
// result — returns writtenContent so the caller's state matches the last write
// it performed itself.
func maybeFormatWrittenFile(ctx context.Context, absolutePath string, writtenContent string) string {
if !formatOnWriteEnabled() {
return writtenContent
}
command, ok := formatterCommands[strings.ToLower(filepath.Ext(absolutePath))]
if !ok {
return writtenContent
}
if _, err := exec.LookPath(command[0]); err != nil {
return writtenContent
}
formatCtx, cancel := context.WithTimeout(ctx, formatOnWriteTimeout)
defer cancel()
arguments := append(append([]string(nil), command[1:]...), absolutePath)
formatter := exec.CommandContext(formatCtx, command[0], arguments...)
formatter.Dir = filepath.Dir(absolutePath)
if err := formatter.Run(); err != nil {
return writtenContent
}
formatted, err := os.ReadFile(absolutePath)
if err != nil {
return writtenContent
}
return string(formatted)
}
-82
View File
@@ -1,82 +0,0 @@
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)
}
}
+7 -16
View File
@@ -45,18 +45,18 @@ func NewScopedGlobTool(workspaceRoot string, scope PathScope) Tool {
}
}
func (tool globTool) Run(ctx context.Context, args map[string]any) Result {
return tool.runWith(ctx, args, readExcluder{})
func (tool globTool) Run(_ context.Context, args map[string]any) Result {
return tool.runWith(args, readExcluder{})
}
// RunWithSandbox runs glob while skipping subtrees the sandbox policy denies
// reads to (DenyRead). With no DenyRead configured the excluder is a no-op and
// behavior is unchanged.
func (tool globTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *sandbox.Engine) Result {
return tool.runWith(ctx, args, sandboxReadExcluder(engine))
func (tool globTool) RunWithSandbox(_ context.Context, args map[string]any, engine *sandbox.Engine) Result {
return tool.runWith(args, sandboxReadExcluder(engine))
}
func (tool globTool) runWith(ctx context.Context, args map[string]any, exclude readExcluder) Result {
func (tool globTool) runWith(args map[string]any, exclude readExcluder) Result {
pattern, err := aliasedStringArg(args, []string{"pattern", "glob", "match", "query", "expression"}, "", true, false)
if err != nil {
return errorResult("Error: Invalid arguments for glob: " + err.Error())
@@ -88,11 +88,8 @@ func (tool globTool) runWith(ctx context.Context, args map[string]any, exclude r
return errorResult("Error running glob " + fmt.Sprintf("%q", pattern) + ": " + err.Error())
}
matches, err := scanGlob(ctx, root, displayRoot, matcher, includeDirs, exclude)
matches, err := scanGlob(root, displayRoot, matcher, includeDirs, exclude)
if err != nil {
if res, ok := searchCancelledResult("glob", err); ok {
return res
}
return errorResult("Error running glob " + fmt.Sprintf("%q", pattern) + ": " + err.Error())
}
if len(matches) == 0 {
@@ -129,15 +126,9 @@ func (tool globTool) runWith(ctx context.Context, args map[string]any, exclude r
}
}
func scanGlob(ctx context.Context, root string, displayRoot string, matcher *regexp.Regexp, includeDirs bool, exclude readExcluder) ([]string, error) {
func scanGlob(root string, displayRoot string, matcher *regexp.Regexp, includeDirs bool, exclude readExcluder) ([]string, error) {
matches := []string{}
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
// Checked first, ahead of walkErr: an unscoped scan over a large tree can
// run long enough that cancelling the run must stop the walk promptly
// rather than visiting every remaining entry to completion.
if err := ctx.Err(); err != nil {
return err
}
if walkErr != nil {
if path == root {
return walkErr
+10 -33
View File
@@ -55,19 +55,19 @@ func NewScopedGrepTool(workspaceRoot string, scope PathScope) Tool {
}
}
func (tool grepTool) Run(ctx context.Context, args map[string]any) Result {
return tool.runWith(ctx, args, readExcluder{})
func (tool grepTool) Run(_ context.Context, args map[string]any) Result {
return tool.runWith(args, readExcluder{})
}
// RunWithSandbox runs the search while skipping subtrees the sandbox policy
// denies reads to (DenyRead), so grep never surfaces content from a read-denied
// path. With no DenyRead configured the excluder is a no-op and behavior is
// unchanged.
func (tool grepTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *sandbox.Engine) Result {
return tool.runWith(ctx, args, sandboxReadExcluder(engine))
func (tool grepTool) RunWithSandbox(_ context.Context, args map[string]any, engine *sandbox.Engine) Result {
return tool.runWith(args, sandboxReadExcluder(engine))
}
func (tool grepTool) runWith(ctx context.Context, args map[string]any, exclude readExcluder) Result {
func (tool grepTool) runWith(args map[string]any, exclude readExcluder) Result {
pattern, err := aliasedStringArg(args, []string{"pattern", "query", "regex", "search", "expression"}, "", true, false)
if err != nil {
return errorResult("Error: Invalid arguments for grep: " + err.Error())
@@ -138,24 +138,15 @@ func (tool grepTool) runWith(ctx context.Context, args map[string]any, exclude r
}
}
files, err := grepFiles(ctx, resolvedRoot, target, globMatcher, exclude)
files, err := grepFiles(resolvedRoot, target, globMatcher, exclude)
if err != nil {
if res, ok := searchCancelledResult("grep", err); ok {
return res
}
return errorResult("Error running grep: " + err.Error())
}
// resolveScopedPath returns an absolute displayRoot when the target resolved
// to an extra (non-workspace) granted root; emit absolute match paths there so
// they survive a round-trip through read_file/edit_file (mirrors glob).
matches, err := collectGrepMatches(ctx, resolvedRoot, filepath.IsAbs(displayRoot), files, compiled)
if err != nil {
if res, ok := searchCancelledResult("grep", err); ok {
return res
}
return errorResult("Error running grep: " + err.Error())
}
matches := collectGrepMatches(resolvedRoot, filepath.IsAbs(displayRoot), files, compiled)
if len(matches) == 0 {
if outputMode == "count" {
return okResult("0 matches found")
@@ -270,7 +261,7 @@ func confineGrepFile(resolvedRoot string, path string) (string, string, bool) {
return filepath.ToSlash(relative), resolved, true
}
func grepFiles(ctx context.Context, resolvedRoot string, target string, globMatcher *regexp.Regexp, exclude readExcluder) ([]string, error) {
func grepFiles(resolvedRoot string, target string, globMatcher *regexp.Regexp, exclude readExcluder) ([]string, error) {
info, err := os.Stat(target)
if err != nil {
return nil, err
@@ -297,13 +288,6 @@ func grepFiles(ctx context.Context, resolvedRoot string, target string, globMatc
files := []string{}
err = filepath.WalkDir(target, func(path string, entry os.DirEntry, walkErr error) error {
// Checked first, ahead of walkErr: an unscoped search over a large tree
// (e.g. a broad parent directory, not just the workspace) can run long
// enough that cancelling the run must stop the walk promptly rather than
// visiting every remaining entry to completion.
if err := ctx.Err(); err != nil {
return err
}
if walkErr != nil {
if path == target {
return walkErr
@@ -358,16 +342,9 @@ func grepFiles(ctx context.Context, resolvedRoot string, target string, globMatc
return files, nil
}
func collectGrepMatches(ctx context.Context, resolvedRoot string, absolutePaths bool, files []string, compiled *regexp.Regexp) ([]grepMatch, error) {
func collectGrepMatches(resolvedRoot string, absolutePaths bool, files []string, compiled *regexp.Regexp) []grepMatch {
matches := []grepMatch{}
for _, file := range files {
// Checked per file, matching the walk's per-entry check in grepFiles: a
// broad search can already have collected many files before the run is
// cancelled, and reading/regex-scanning each one is real work that must
// stop promptly rather than running to completion.
if err := ctx.Err(); err != nil {
return matches, err
}
// Re-confine at read time (defense-in-depth) AND to compute the clean
// workspace-relative path used in output.
relative, resolvedPath, ok := confineGrepFile(resolvedRoot, file)
@@ -404,5 +381,5 @@ func collectGrepMatches(ctx context.Context, resolvedRoot string, absolutePaths
})
}
}
return matches, nil
return matches
}
-18
View File
@@ -1,18 +0,0 @@
package tools
import "context"
// inlineDiagnostics renders the post-write diagnostics block a mutating tool
// appends to its output. Empty when no Diagnostics callback is wired, the file
// is clean, or no language server is available — the tool output is then
// byte-identical to the pre-diagnostics behavior.
func inlineDiagnostics(ctx context.Context, options RunOptions, absolutePath, relativePath string) string {
if options.Diagnostics == nil {
return ""
}
block := options.Diagnostics(ctx, absolutePath)
if block == "" {
return ""
}
return "\n\nDiagnostics in " + relativePath + " after this change (fix any errors you introduced):\n" + block
}
-53
View File
@@ -1,53 +0,0 @@
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)
}
}
-6
View File
@@ -40,12 +40,6 @@ 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 {
-13
View File
@@ -1,13 +0,0 @@
package tools
import (
"context"
"errors"
)
func searchCancelledResult(tool string, err error) (Result, bool) {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return Result{Status: StatusError, Output: "Error: " + tool + " cancelled."}, true
}
return Result{}, false
}
-206
View File
@@ -1,206 +0,0 @@
package tools
import (
"context"
"errors"
"os"
"path/filepath"
"regexp"
"strconv"
"testing"
)
// countingCancelContext returns context.Canceled from Err() once it has been
// called more than "remaining" times, letting a test deterministically
// cancel a loop after a fixed number of iterations instead of relying on
// timing. A context.WithTimeout test would be flaky under a loaded or
// shared CI runner, exactly the kind of test PR #464 was criticized for
// elsewhere (TestCollectRespectsDeadlineUnderContinuousOutput).
type countingCancelContext struct {
context.Context
remaining int
}
func (c *countingCancelContext) Err() error {
c.remaining--
if c.remaining < 0 {
return context.Canceled
}
return nil
}
// buildLargeSearchTree creates n files, each containing a grep-matchable
// line, so a walk that does NOT respect cancellation would have plenty of
// work left to do (and matches to find) after the very first entry.
func buildLargeSearchTree(t *testing.T, n int) string {
t.Helper()
root := t.TempDir()
for i := 0; i < n; i++ {
path := filepath.Join(root, "file"+strconv.Itoa(i)+".txt")
if err := os.WriteFile(path, []byte("needle\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
}
return root
}
// A cancelled run must stop the filesystem walk promptly instead of visiting
// every remaining entry to completion. Before this fix, grep's Run/
// RunWithSandbox discarded their context entirely, so cancelling the run
// (Ctrl+C / /exit) had no effect on an in-flight, unscoped search: the walk
// ran to completion regardless, and — because the TUI's exit path waits for
// the cancelled run's own response before it can quit — the whole
// application was stuck until the walk finished on its own.
func TestGrepStopsWalkOnCancelledContext(t *testing.T) {
root := buildLargeSearchTree(t, 500)
tool := NewGrepTool(root)
ctx, cancel := context.WithCancel(context.Background())
cancel() // already cancelled before the walk starts
result := tool.Run(ctx, map[string]any{"pattern": "needle"})
if result.Status != StatusError {
t.Fatalf("status = %q, want error", result.Status)
}
if result.Output != "Error: grep cancelled." {
t.Fatalf("output = %q, want the cancellation message", result.Output)
}
}
func TestGrepRunWithSandboxStopsWalkOnCancelledContext(t *testing.T) {
root := buildLargeSearchTree(t, 500)
tool := NewGrepTool(root)
ctx, cancel := context.WithCancel(context.Background())
cancel()
sandboxed := tool.(sandboxAwareTool)
result := sandboxed.RunWithSandbox(ctx, map[string]any{"pattern": "needle"}, nil)
if result.Status != StatusError {
t.Fatalf("status = %q, want error", result.Status)
}
if result.Output != "Error: grep cancelled." {
t.Fatalf("output = %q, want the cancellation message", result.Output)
}
}
// grep with a live (non-cancelled) context must still work normally: this
// fix only short-circuits on cancellation, it does not change matching.
func TestGrepStillMatchesWithLiveContext(t *testing.T) {
root := buildLargeSearchTree(t, 3)
tool := NewGrepTool(root)
result := tool.Run(context.Background(), map[string]any{"pattern": "needle"})
if result.Status != StatusOK {
t.Fatalf("status = %q, want ok; output=%q", result.Status, result.Output)
}
}
// Before this fix, only grepFiles' walk checked ctx: once the walk had
// finished discovering candidate files, collectGrepMatches read and
// regex-scanned every one of them to completion regardless of cancellation.
// This proves the match-collection phase itself stops partway through,
// not only when the context was already cancelled before the walk started.
// grepFiles must stop mid-walk once ctx is cancelled, not only when the
// context was already cancelled before WalkDir starts.
func TestGrepFilesStopsMidWalkOnCancelledContext(t *testing.T) {
root := buildLargeSearchTree(t, 50)
resolvedRoot, err := filepath.EvalSymlinks(root)
if err != nil {
t.Fatalf("EvalSymlinks: %v", err)
}
const allowed = 5
ctx := &countingCancelContext{Context: context.Background(), remaining: allowed}
files, err := grepFiles(ctx, resolvedRoot, root, nil, readExcluder{})
if !errors.Is(err, context.Canceled) {
t.Fatalf("err = %v, want context.Canceled", err)
}
if len(files) >= 50 {
t.Fatalf("files = %d; walk should stop mid-traversal instead of visiting every entry", len(files))
}
}
func TestGrepCollectMatchesStopsMidCollectionOnCancelledContext(t *testing.T) {
root := buildLargeSearchTree(t, 50)
files := make([]string, 50)
for i := range files {
files[i] = filepath.Join(root, "file"+strconv.Itoa(i)+".txt")
}
resolvedRoot, err := filepath.EvalSymlinks(root)
if err != nil {
t.Fatalf("EvalSymlinks: %v", err)
}
compiled := regexp.MustCompile("needle")
const allowed = 5
ctx := &countingCancelContext{Context: context.Background(), remaining: allowed}
matches, err := collectGrepMatches(ctx, resolvedRoot, false, files, compiled)
if !errors.Is(err, context.Canceled) {
t.Fatalf("err = %v, want context.Canceled", err)
}
if len(matches) != allowed {
t.Fatalf("matches = %d, want exactly %d collected before cancellation stopped the loop", len(matches), allowed)
}
}
func TestScanGlobStopsMidWalkOnCancelledContext(t *testing.T) {
root := buildLargeSearchTree(t, 50)
matcher, err := compileGlob("**/*.txt")
if err != nil {
t.Fatalf("compileGlob: %v", err)
}
const allowed = 5
ctx := &countingCancelContext{Context: context.Background(), remaining: allowed}
matches, err := scanGlob(ctx, root, root, matcher, false, readExcluder{})
if !errors.Is(err, context.Canceled) {
t.Fatalf("err = %v, want context.Canceled", err)
}
if len(matches) >= 50 {
t.Fatalf("matches = %d; walk should stop mid-traversal instead of visiting every entry", len(matches))
}
}
func TestGlobStopsWalkOnCancelledContext(t *testing.T) {
root := buildLargeSearchTree(t, 500)
tool := NewGlobTool(root)
ctx, cancel := context.WithCancel(context.Background())
cancel()
result := tool.Run(ctx, map[string]any{"pattern": "**/*.txt"})
if result.Status != StatusError {
t.Fatalf("status = %q, want error", result.Status)
}
if result.Output != "Error: glob cancelled." {
t.Fatalf("output = %q, want the cancellation message", result.Output)
}
}
func TestGlobRunWithSandboxStopsWalkOnCancelledContext(t *testing.T) {
root := buildLargeSearchTree(t, 500)
tool := NewGlobTool(root)
ctx, cancel := context.WithCancel(context.Background())
cancel()
sandboxed := tool.(sandboxAwareTool)
result := sandboxed.RunWithSandbox(ctx, map[string]any{"pattern": "**/*.txt"}, nil)
if result.Status != StatusError {
t.Fatalf("status = %q, want error", result.Status)
}
if result.Output != "Error: glob cancelled." {
t.Fatalf("output = %q, want the cancellation message", result.Output)
}
}
func TestGlobStillMatchesWithLiveContext(t *testing.T) {
root := buildLargeSearchTree(t, 3)
tool := NewGlobTool(root)
result := tool.Run(context.Background(), map[string]any{"pattern": "**/*.txt"})
if result.Status != StatusOK {
t.Fatalf("status = %q, want ok; output=%q", result.Status, result.Output)
}
}
-12
View File
@@ -20,11 +20,6 @@ type shellIssue struct {
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+|$)`)
)
func detectShellRuntime(goos string) shellRuntime {
@@ -62,13 +57,6 @@ func detectShellCommandIssue(command string, goos string) *shellIssue {
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.",
}
}
return nil
}
-84
View File
@@ -1,84 +0,0 @@
package tools
import (
"os"
"path/filepath"
"strings"
"time"
"github.com/Gitlawb/zero/internal/redaction"
)
// 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
// spillDir returns the shared spill directory, creating it on first use.
func spillDir() (string, error) {
dir := filepath.Join(os.TempDir(), "zero-tool-output")
if err := os.MkdirAll(dir, 0o700); 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, so a spilled file never holds a secret the transcript would
// have hidden.
func spillTruncatedOutput(toolName, output string) string {
dir, err := spillDir()
if err != nil {
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{})
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()))
}
}
}
-94
View File
@@ -1,94 +0,0 @@
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 TestFormatBashOutputCapsVerboseOutput(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
long := strings.Repeat("x", 200*1024)
formatted := formatBashOutput(long, "", 0)
if len(formatted) > defaultMaxOutputTokens*4+1024 {
t.Fatalf("bash output must be capped near the token budget, got %d bytes", len(formatted))
}
if !strings.Contains(formatted, "output truncated") {
t.Fatal("capped bash output must carry the truncation notice")
}
}
func TestSweepSpillDirRemovesOnlyOldFiles(t *testing.T) {
dir := t.TempDir()
oldFile := filepath.Join(dir, "bash-old.txt")
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")
}
}
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)
}
}
+8 -46
View File
@@ -18,14 +18,9 @@ import (
)
const (
// 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
defaultWebFetchMaxBytes = 64 * 1024
maxWebFetchMaxBytes = 512 * 1024
webFetchTimeout = 10 * 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."
@@ -120,17 +115,11 @@ func newWebFetchToolWithClientAndResolver(client *http.Client, resolver webFetch
},
"max_bytes": {
Type: "integer",
Description: "Maximum raw response body bytes to download before conversion.",
Description: "Maximum response body bytes to return.",
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,
@@ -181,18 +170,6 @@ 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())
}
@@ -247,28 +224,14 @@ func (tool webFetchTool) run(ctx context.Context, args map[string]any) Result {
}
body = redactWebFetchText(body)
// 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{
output := strings.Join([]string{
"URL: " + finalURL,
"Status: " + webFetchStatusLine(response),
"Content-Type: " + firstNonEmptyString(contentType, "unknown"),
"Bytes: " + strconv.Itoa(len(body)),
}
if converted {
headers = append(headers, "Converted: html -> markdown (pass format: \"raw\" for the original HTML)")
}
output := strings.Join(append(headers, "", body), "\n")
"",
body,
}, "\n")
return Result{
Status: StatusOK,
@@ -280,7 +243,6 @@ 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),
},
}
}
-92
View File
@@ -1,92 +0,0 @@
package tools
import (
"fmt"
"html"
"regexp"
"strings"
)
// HTML-to-markdown conversion for web_fetch. Raw HTML is mostly boilerplate —
// on a typical page well under a tenth of the bytes are readable content — so
// returning it raw wastes nearly the whole response budget on markup. The
// converter is deliberately dependency-free (regex passes + stdlib entity
// unescaping, no HTML parser): the output feeds a language model, not a
// browser, so best-effort structure (headings, links, list markers) is enough,
// and the format=raw escape hatch covers pages it mangles.
var (
webFetchDropBlockRe = regexp.MustCompile(`(?is)<(script|style|head|noscript|svg|template|iframe)\b[^>]*>.*?</\s*(?:script|style|head|noscript|svg|template|iframe)\s*>`)
webFetchCommentRe = regexp.MustCompile(`(?s)<!--.*?-->`)
webFetchAnchorRe = regexp.MustCompile(`(?is)<a\b[^>]*?href\s*=\s*["']?([^"'\s>]+)["']?[^>]*>(.*?)</\s*a\s*>`)
webFetchHeadingRe = regexp.MustCompile(`(?is)<h([1-6])[^>]*>(.*?)</\s*h[1-6]\s*>`)
webFetchListItemRe = regexp.MustCompile(`(?i)<li\b[^>]*>`)
webFetchBreakRe = regexp.MustCompile(`(?i)<(?:br|hr)\s*/?>`)
webFetchBlockTagRe = regexp.MustCompile(`(?i)</?(?:p|div|section|article|main|header|footer|nav|aside|table|tr|ul|ol|blockquote|pre|figure|form)\b[^>]*>`)
webFetchAnyTagRe = regexp.MustCompile(`(?s)<[^>]*>`)
webFetchSpaceRunRe = regexp.MustCompile(`[ \t]+`)
webFetchBlankRunRe = regexp.MustCompile(`\n{3,}`)
)
// htmlToMarkdown converts an HTML document to compact markdown-flavored text:
// headings become #-prefixed lines, anchors become [text](href), list items
// become "- " bullets, block boundaries become blank lines, all other markup
// is stripped, and entities are unescaped.
func htmlToMarkdown(body string) string {
text := webFetchCommentRe.ReplaceAllString(body, " ")
text = webFetchDropBlockRe.ReplaceAllString(text, " ")
text = webFetchHeadingRe.ReplaceAllStringFunc(text, func(match string) string {
groups := webFetchHeadingRe.FindStringSubmatch(match)
level := int(groups[1][0] - '0')
if level < 1 || level > 6 {
level = 1
}
title := strings.TrimSpace(webFetchAnyTagRe.ReplaceAllString(groups[2], " "))
return "\n\n" + strings.Repeat("#", level) + " " + title + "\n\n"
})
text = webFetchAnchorRe.ReplaceAllStringFunc(text, func(match string) string {
groups := webFetchAnchorRe.FindStringSubmatch(match)
href := groups[1]
label := strings.TrimSpace(webFetchAnyTagRe.ReplaceAllString(groups[2], " "))
if label == "" {
return " "
}
if strings.HasPrefix(href, "#") || strings.HasPrefix(strings.ToLower(href), "javascript:") {
return label
}
return fmt.Sprintf("[%s](%s)", label, href)
})
text = webFetchListItemRe.ReplaceAllString(text, "\n- ")
text = webFetchBreakRe.ReplaceAllString(text, "\n")
text = webFetchBlockTagRe.ReplaceAllString(text, "\n\n")
text = webFetchAnyTagRe.ReplaceAllString(text, " ")
text = html.UnescapeString(text)
// Whitespace normalization: collapse space runs, trim line edges, cap blank
// runs at one empty line.
lines := strings.Split(text, "\n")
for i, line := range lines {
lines[i] = strings.TrimSpace(webFetchSpaceRunRe.ReplaceAllString(line, " "))
}
text = strings.Join(lines, "\n")
text = webFetchBlankRunRe.ReplaceAllString(text, "\n\n")
return strings.TrimSpace(text)
}
// looksLikeHTML reports whether a response should be treated as an HTML
// document, by Content-Type first and a body sniff as fallback (servers
// mislabel HTML as text/plain often enough to matter).
func looksLikeHTML(contentType, body string) bool {
lowered := strings.ToLower(contentType)
if strings.Contains(lowered, "text/html") || strings.Contains(lowered, "application/xhtml") {
return true
}
if lowered != "" && !strings.Contains(lowered, "text/plain") {
return false
}
head := strings.ToLower(strings.TrimSpace(body))
if len(head) > 512 {
head = head[:512]
}
return strings.Contains(head, "<!doctype html") || strings.Contains(head, "<html")
}
-127
View File
@@ -1,127 +0,0 @@
package tools
import (
"context"
"net/http"
"strings"
"testing"
)
const sampleHTML = `<!DOCTYPE html>
<html><head><title>ignored</title><style>body{color:red}</style></head>
<body>
<script>var tracking = "noise";</script>
<h1>Main Title</h1>
<p>First paragraph with a <a href="https://example.com/docs">docs link</a> and &amp; entity.</p>
<ul><li>alpha</li><li>beta</li></ul>
<!-- hidden comment -->
<h2>Section</h2>
<p>Body with extra spaces.</p>
</body></html>`
func TestHTMLToMarkdown(t *testing.T) {
markdown := htmlToMarkdown(sampleHTML)
for _, want := range []string{
"# Main Title",
"## Section",
"[docs link](https://example.com/docs)",
"- alpha",
"- beta",
"with a", // paragraph text survives
"& entity",
} {
if !strings.Contains(markdown, want) {
t.Fatalf("markdown missing %q:\n%s", want, markdown)
}
}
for _, banned := range []string{"tracking", "color:red", "hidden comment", "<p>", "</html>", "ignored"} {
if strings.Contains(markdown, banned) {
t.Fatalf("markdown must not contain %q:\n%s", banned, markdown)
}
}
if strings.Contains(markdown, "extra spaces") {
t.Fatalf("space runs must collapse:\n%s", markdown)
}
if strings.Contains(markdown, "\n\n\n") {
t.Fatalf("blank-line runs must be capped:\n%s", markdown)
}
}
func TestLooksLikeHTML(t *testing.T) {
cases := []struct {
contentType string
body string
want bool
}{
{"text/html; charset=utf-8", "anything", true},
{"application/xhtml+xml", "anything", true},
{"application/json", `{"html":"<html>"}`, false},
{"text/plain", "<!doctype html><html>", true}, // mislabeled HTML sniffed
{"text/plain", "just text", false},
{"", "<html><body>x</body></html>", true},
}
for _, c := range cases {
if got := looksLikeHTML(c.contentType, c.body); got != c.want {
t.Errorf("looksLikeHTML(%q, %.20q) = %v, want %v", c.contentType, c.body, got, c.want)
}
}
}
func TestWebFetchConvertsHTMLByDefault(t *testing.T) {
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
return webFetchTestResponse(request, http.StatusOK, "text/html; charset=utf-8", sampleHTML), nil
}))
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com/page"})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %s: %s", result.Status, result.Output)
}
if !strings.Contains(result.Output, "# Main Title") || strings.Contains(result.Output, "<h1>") {
t.Fatalf("HTML must convert to markdown by default:\n%s", result.Output)
}
if result.Meta["converted"] != "true" {
t.Fatalf("converted meta must be true: %#v", result.Meta)
}
}
func TestWebFetchRawFormatSkipsConversion(t *testing.T) {
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
return webFetchTestResponse(request, http.StatusOK, "text/html", sampleHTML), nil
}))
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com/page", "format": "raw"})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %s: %s", result.Status, result.Output)
}
if !strings.Contains(result.Output, "<h1>Main Title</h1>") {
t.Fatalf("format=raw must keep original HTML:\n%s", result.Output)
}
if result.Meta["converted"] != "false" {
t.Fatalf("converted meta must be false: %#v", result.Meta)
}
}
func TestWebFetchLeavesNonHTMLUntouched(t *testing.T) {
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
return webFetchTestResponse(request, http.StatusOK, "application/json", `{"key":"<b>value</b>"}`), nil
}))
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com/api"})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %s: %s", result.Status, result.Output)
}
if !strings.Contains(result.Output, `{"key":"<b>value</b>"}`) {
t.Fatalf("non-HTML must pass through untouched:\n%s", result.Output)
}
}
func TestWebFetchRejectsInvalidFormat(t *testing.T) {
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
return webFetchTestResponse(request, http.StatusOK, "text/plain", "x"), nil
}))
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com", "format": "xml"})
if result.Status != StatusError || !strings.Contains(result.Output, "format must be") {
t.Fatalf("invalid format must be rejected: %s %s", result.Status, result.Output)
}
}
+1 -6
View File
@@ -44,7 +44,7 @@ func (tool writeFileTool) Run(ctx context.Context, args map[string]any) Result {
return tool.RunWithOptions(ctx, args, RunOptions{})
}
func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result {
func (tool writeFileTool) RunWithOptions(_ 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,10 +110,6 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
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)
@@ -130,7 +126,6 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
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
-291
View File
@@ -1,291 +0,0 @@
package tui
import (
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/config"
)
func TestParseBindingLabel(t *testing.T) {
tests := []struct {
input string
want string // expected Label()
}{
{"option+o", "Alt+O"},
{"ctrl+o", "Ctrl+O"},
{"ctrl+e", "Ctrl+E"},
{"option+b", "Alt+B"},
{"option+O", "Alt+O"},
{"alt+o", "Alt+O"},
{"cmd+o", "Cmd+O"},
{"super+o", "Cmd+O"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
p := parseBinding(tt.input)
got := p.Label()
if got != tt.want {
t.Errorf("parseBinding(%q).Label() = %q, want %q", tt.input, got, tt.want)
}
if p.isZero() {
t.Errorf("parseBinding(%q).isZero() = true, want false", tt.input)
}
})
}
}
func TestOptionOMatchesOptionO(t *testing.T) {
p := parseBinding("option+o")
matcher := p.Matcher()
// Simulate iTerm2 with "Option as Meta" pressing Option+O
// Terminal sends ESC o → KeyPressEvent{Code: 'o', Mod: ModAlt}
msg := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt})
if !matcher(msg) {
t.Errorf("option+o matcher should match {Code:'o', Mod:ModAlt}")
}
// Should NOT match plain 'o'
msg2 := tea.KeyPressMsg(tea.Key{Code: 'o'})
if matcher(msg2) {
t.Errorf("option+o matcher should NOT match {Code:'o'} without ModAlt")
}
// Should NOT match ctrl+o
msg3 := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})
if matcher(msg3) {
t.Errorf("option+o matcher should NOT match {Code:'o', Mod:ModCtrl}")
}
}
func TestOptionOMatchesComposedCharacters(t *testing.T) {
p := parseBinding("option+o")
matcher := p.Matcher()
// On macOS without "Option as Meta", Option+O produces ø (U+00F8)
msg := tea.KeyPressMsg(tea.Key{Code: 0xF8, Text: "ø"})
if matcher(msg) {
t.Logf("NOTE: option+o matcher also matches option+ø (macOS compose behavior)")
}
}
func TestCtrlEMatchesDefault(t *testing.T) {
p := parseBinding("ctrl+e")
matcher := p.Matcher()
// Terminal sends Ctrl+E → byte 0x05 → KeyPressEvent{Code: 'e', Mod: ModCtrl}
msg := tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl})
if !matcher(msg) {
t.Errorf("ctrl+e matcher should match {Code:'e', Mod:ModCtrl}")
}
}
func TestCtrlBindingRejectsSupersetModifiers(t *testing.T) {
// Configured ctrl+e must NOT match ctrl+alt+e or ctrl+shift+e
// (Mod.Contains superset correctness for the ctrl-letter fast path).
p := parseBinding("ctrl+e")
matcher := p.Matcher()
bad := []struct {
name string
msg tea.KeyMsg
}{
{"ctrl+alt+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModAlt})},
{"ctrl+shift+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModShift})},
{"ctrl+alt+shift+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModAlt | tea.ModShift})},
{"ctrl+cmd+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModSuper})},
}
for _, tt := range bad {
t.Run(tt.name, func(t *testing.T) {
if matcher(tt.msg) {
t.Errorf("ctrl+e matcher should NOT match %s", tt.name)
}
})
}
}
func TestAltBindingRejectsSupersetModifiers(t *testing.T) {
// Configured alt+o must NOT match alt+shift+o (code path, exact mod).
p := parseBinding("alt+o")
matcher := p.Matcher()
bad := []struct {
name string
msg tea.KeyMsg
}{
{"alt+shift+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt | tea.ModShift})},
{"alt+ctrl+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt | tea.ModCtrl})},
{"alt+cmd+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt | tea.ModSuper})},
}
for _, tt := range bad {
t.Run(tt.name, func(t *testing.T) {
if matcher(tt.msg) {
t.Errorf("alt+o matcher should NOT match %s", tt.name)
}
})
}
}
func TestPlainKeyBindingRejectsModifiers(t *testing.T) {
// A configured plain-key binding (no modifiers, e.g. "o") should only
// match when Mod is exactly 0, not when a modifier is held.
p := parseBinding("o")
matcher := p.Matcher()
plain := tea.KeyPressMsg(tea.Key{Code: 'o'})
if !matcher(plain) {
t.Errorf("'o' matcher should match {Code:'o'} with no modifiers")
}
bad := []struct {
name string
msg tea.KeyMsg
}{
{"ctrl+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})},
{"alt+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt})},
{"shift+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModShift})},
}
for _, tt := range bad {
t.Run(tt.name, func(t *testing.T) {
if matcher(tt.msg) {
t.Errorf("'o' matcher should NOT match %s", tt.name)
}
})
}
}
func TestParseBindingUTF8Character(t *testing.T) {
// A 2-byte UTF-8 key like é (U+00E9) should parse as a single character,
// not be rejected by a byte-length check.
p := parseBinding("é")
if p.isZero() {
t.Errorf("parseBinding('é') should not be zero — it's a valid single character")
}
if p.code != 'é' {
t.Errorf("parseBinding('é').code = %d (U+%04X), want %d (U+00E9)", p.code, p.code, 'é')
}
if p.text != "" {
t.Errorf("parseBinding('é').text = %q, want empty string", p.text)
}
// Also verify the matcher works
matcher := p.Matcher()
msg := tea.KeyPressMsg(tea.Key{Code: 'é', Text: "é"})
if !matcher(msg) {
t.Errorf("'é' matcher should match {Code:'é', Text:'é'}")
}
// Should not match with a modifier
msgAlt := tea.KeyPressMsg(tea.Key{Code: 'é', Text: "é", Mod: tea.ModAlt})
if matcher(msgAlt) {
t.Errorf("'é' matcher should NOT match with modifier")
}
}
func TestDispatchRejectsSupersetModifiers(t *testing.T) {
// Integration: model.keyMatch with a configured binding must reject
// modifier supersets.
cfg := config.KeyBindingsConfig{
ToggleDetailed: "ctrl+o",
}
bindings := resolveKeyBindings(cfg)
defaultFn := func(tea.KeyMsg) bool { return false }
m := model{keyBindings: bindings}
// Ctrl+O should match
msgCtrlO := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})
if !m.keyMatch(bindings.toggleDetailed, msgCtrlO, defaultFn) {
t.Errorf("keyMatch should match ctrl+o when configured")
}
// Ctrl+Alt+O should NOT match
msgCtrlAltO := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl | tea.ModAlt})
if m.keyMatch(bindings.toggleDetailed, msgCtrlAltO, defaultFn) {
t.Errorf("keyMatch should NOT match ctrl+alt+o when ctrl+o is configured")
}
}
func TestConfigToBindingPipeline(t *testing.T) {
cfg := config.KeyBindingsConfig{
ToggleDetailed: "option+o",
ToggleMouse: "ctrl+e",
CycleReasoning: "ctrl+t",
TogglePlan: "ctrl+p",
ToggleSidebar: "option+b",
}
bindings := resolveKeyBindings(cfg)
tests := []struct {
name string
binding parsedBinding
wantKey string
}{
{"toggleDetailed", bindings.toggleDetailed, "Alt+O"},
{"toggleMouse", bindings.toggleMouse, "Ctrl+E"},
{"cycleReasoning", bindings.cycleReasoning, "Ctrl+T"},
{"togglePlan", bindings.togglePlan, "Ctrl+P"},
{"toggleSidebar", bindings.toggleSidebar, "Alt+B"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.binding.isZero() {
t.Errorf("%s binding is zero (unset) — config not loaded", tt.name)
}
if got := tt.binding.Label(); got != tt.wantKey {
t.Errorf("%s.Label() = %q, want %q", tt.name, got, tt.wantKey)
}
})
}
}
func TestHelpBindingSubstitution(t *testing.T) {
// Verify that the help overlay correctly shows user-configured bindings
// by checking that Label() returns non-empty for config-sourced bindings.
cfg := config.KeyBindingsConfig{
ToggleDetailed: "option+o",
ToggleSidebar: "option+b",
}
bindings := resolveKeyBindings(cfg)
if l := bindings.toggleDetailed.Label(); l != "Alt+O" {
t.Errorf("toggleDetailed.Label() = %q, want %q", l, "Alt+O")
}
if l := bindings.toggleSidebar.Label(); l != "Alt+B" {
t.Errorf("toggleSidebar.Label() = %q, want %q", l, "Alt+B")
}
}
// TestDispatchOptionO is an integration-style test: it verifies that pressing
// Option+O dispatches as toggleDetailed when configured.
func TestDispatchOptionO(t *testing.T) {
cfg := config.KeyBindingsConfig{
ToggleDetailed: "option+o",
}
bindings := resolveKeyBindings(cfg)
// Simulate Option+O in iTerm2 with "Option as Meta" → ESC o → {Code:'o', Mod:ModAlt}
msg := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt})
defaultFn := func(tea.KeyMsg) bool { return false }
// This is the dispatch check the model uses
m := model{keyBindings: bindings}
if !m.keyMatch(bindings.toggleDetailed, msg, defaultFn) {
t.Errorf("model.keyMatch should match option+o")
}
// Also verify default doesn't match
msgCtrlO := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})
if m.keyMatch(bindings.toggleDetailed, msgCtrlO, defaultFn) {
t.Errorf("model.keyMatch should NOT match ctrl+o when option+o is configured")
}
}
+1 -1
View File
@@ -377,7 +377,7 @@ func TestContextCommandCardHandlesNilRegistryAndStableStyle(t *testing.T) {
for _, want := range []string{
"Context",
"0 tools",
"style concise",
"style balanced",
"root unknown",
} {
assertContains(t, text, want)
-17
View File
@@ -559,21 +559,6 @@ func (m model) modelText(args string) string {
})
}
// avgTurnLatencyText reports the session's rolling average turn wall-time for
// /context — the "is it slow?" signal a user otherwise can only feel. "n/a" until
// a turn has completed.
func (m model) avgTurnLatencyText() string {
if m.turnLatencyCount == 0 {
return "n/a"
}
avgSeconds := m.turnLatencySum.Seconds() / float64(m.turnLatencyCount)
if m.turnTTFTCount > 0 {
ttftSeconds := m.turnTTFTSum.Seconds() / float64(m.turnTTFTCount)
return fmt.Sprintf("%.1fs avg (%.1fs to first token, %d turns)", avgSeconds, ttftSeconds, m.turnLatencyCount)
}
return fmt.Sprintf("%.1fs avg (%d turns)", avgSeconds, m.turnLatencyCount)
}
func (m model) contextText() string {
toolCount := len(m.registeredTools())
return renderCommandCardTranscript(commandCard{
@@ -593,8 +578,6 @@ func (m model) contextText() string {
{Key: "effort", Value: m.effortDisplay()},
{Key: "style", Value: displayValue(m.responseStyle, defaultResponseStyle)},
{Key: "usage", Value: m.usageSummaryText()},
{Key: "cache", Value: m.cacheEfficiencyText()},
{Key: "latency", Value: m.avgTurnLatencyText()},
{Key: "max turns", Value: fmt.Sprint(m.agentOptions.MaxTurns)},
},
},
-8
View File
@@ -41,7 +41,6 @@ const (
commandAddDir
commandSelfCorrect
commandTurns
commandNew
commandUnknown
)
@@ -155,13 +154,6 @@ var commandDefinitions = []commandDefinition{
description: "Clear the visible transcript.",
kind: commandClear,
},
{
name: "/new",
usage: "/new",
group: commandGroupSession,
description: "Start a fresh session; the current one stays resumable via /resume.",
kind: commandNew,
},
{
name: "/search",
aliases: []string{"/find"},
+1 -1
View File
@@ -306,7 +306,7 @@ func (m model) composerPositionAtVisualCell(x int, y int, width int) (int, bool)
func (m model) applyComposerKey(msg tea.KeyMsg) (model, bool) {
state := m.currentComposerState()
switch {
case keyIs(msg, tea.KeyEnter) && (keyAlt(msg) || keyShift(msg)):
case keyIs(msg, tea.KeyEnter) && keyAlt(msg):
m = m.insertComposerTextWithPastePreview(state, "\n", "")
case keyCtrl(msg, 'j'):
m = m.insertComposerTextWithPastePreview(state, "\n", "")
+1 -2
View File
@@ -299,8 +299,7 @@ func TestModifiedEnterInsertsNewlineWithoutSubmitting(t *testing.T) {
key tea.Msg
}{
{name: "alt enter", key: testKeyAlt(tea.KeyEnter)},
{name: "shift enter", key: testKeyShift(tea.KeyEnter)},
{name: "ctrl j", key: testKeyCtrl('j')},
{name: "shift enter", key: testKeyCtrl('j')},
}
for _, tc := range tests {
+4 -7
View File
@@ -105,14 +105,11 @@ func TestClearResetsFlushFrontier(t *testing.T) {
m.input.SetValue("/clear")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if len(next.transcript) != 2 || next.transcript[0].kind != rowWelcome {
t.Fatalf("expected /clear to reset transcript to welcome + note, got %#v", next.transcript)
if len(next.transcript) != 1 || next.transcript[0].kind != rowWelcome {
t.Fatalf("expected /clear to reset transcript, got %#v", next.transcript)
}
if !transcriptContains(next.transcript, "/new") {
t.Fatalf("expected /clear to point users to /new, got %#v", next.transcript)
}
if next.flushed != len(next.transcript) {
t.Fatalf("expected frontier reset to match cleared transcript (%d rows), got %d", len(next.transcript), next.flushed)
if next.flushed > 1 {
t.Fatalf("expected frontier reset after /clear, got %d", next.flushed)
}
}
-28
View File
@@ -1,28 +0,0 @@
package tui
import "testing"
func TestHumanCount(t *testing.T) {
cases := []struct {
n int
want string
}{
{0, "0"},
{999, "999"},
{1000, "1K"},
{12400, "12.4K"},
{200000, "200K"},
{999999, "1000K"},
// The bug: a million-scale count must switch to an M suffix instead
// of continuing to render as "1000K", "1200K", etc.
{1_000_000, "1M"},
{1_048_576, "1M"},
{1_200_000, "1.2M"},
{-5, "0"},
}
for _, c := range cases {
if got := humanCount(c.n); got != c.want {
t.Errorf("humanCount(%d) = %q, want %q", c.n, got, c.want)
}
}
}
+10 -3
View File
@@ -98,9 +98,16 @@ func (m model) modelSupportsVisionTUI() bool {
if trimmed == "" {
return false
}
// The curated catalog is authoritative only when it knows the model.
if entry, known := m.modelCatalog.Resolve(trimmed); known {
return entry.Supports(modelregistry.ModelCapabilityVision)
// Check the curated registry first.
registry, err := modelregistry.DefaultRegistry()
if err == nil {
if modelregistry.SupportsVision(registry, trimmed) {
return true
}
// Catalog knows this model and it lacks vision — trust that.
if _, known := registry.Get(trimmed); known {
return false
}
}
// Check the discovered model list (from models.dev) for InputModalities
// containing "image". This covers custom/ollama/cloud models not in the
+44 -52
View File
@@ -5,10 +5,6 @@
// lists them grouped, so the keymap is discoverable the way the reference TUIs
// do it. The list is declarative and hand-curated to match the real handlers in
// model.go's Update switch; keep them in sync when a binding changes.
//
// Configurable bindings (toggleDetailed, toggleMouse, cycleReasoning,
// togglePlan, toggleSidebar) pull their key label from m.keyBindings so a user
// who remaps them in config.json sees the actual chords, not the defaults.
package tui
import "strings"
@@ -25,67 +21,63 @@ type keybindingGroup struct {
bindings []keybinding
}
// buildKeybindingGroups returns the full, grouped shortcut list shown by the
// `?` overlay. Configurable bindings use the user's remapped labels (via
// m.keyBindings) when present; unconfigured bindings get their built-in labels.
// keybindingGroups is the full, grouped shortcut list shown by the `?` overlay.
// Sourced from the real key cases in model.go (Update) — not invented. When a
// binding is added/changed there, update this method too.
func (m model) buildKeybindingGroups() []keybindingGroup {
return []keybindingGroup{
{
title: "Chat",
bindings: []keybinding{
{"Enter", "send the message"},
{"Shift+Enter / Alt+Enter", "insert a newline (multi-line compose)"},
{"Esc (\u00d72)", "cancel the run / dismiss a popup / clear the input"},
{"Ctrl+C", "cancel the run, then quit"},
{"?", "show this help (on an empty input)"},
},
// binding is added/changed there, update it here too (a test guards that the
// data is non-empty and well-formed, but it can't verify the bindings exist).
var keybindingGroups = []keybindingGroup{
{
title: "Chat",
bindings: []keybinding{
{"Enter", "send the message"},
{"Alt+Enter", "insert a newline (multi-line compose)"},
{"Esc (×2)", "cancel the run / dismiss a popup / clear the input"},
{"Ctrl+C", "cancel the run, then quit"},
{"?", "show this help (on an empty input)"},
},
{
title: "Model & run controls",
bindings: []keybinding{
{labelOr(m.keyBindings.cycleReasoning, "Ctrl+T"), "cycle reasoning effort (auto \u2192 low \u2192 medium \u2192 high)"},
{"Shift+Tab", "cycle permission mode (auto \u2194 ask)"},
{labelOr(m.keyBindings.togglePlan, "Ctrl+P"), "expand / collapse the plan panel"},
},
},
{
title: "Model & run controls",
bindings: []keybinding{
{"Ctrl+T", "cycle reasoning effort (auto → low → medium → high)"},
{"Shift+Tab", "cycle permission mode (auto ↔ ask)"},
{"Ctrl+P", "expand / collapse the plan panel"},
},
{
title: "Navigation & scrollback",
bindings: []keybinding{
{"PgUp / PgDn", "scroll the transcript by a page"},
{"\u2191 / \u2193", "scroll, or move within a popup / multi-line input"},
{labelOr(m.keyBindings.toggleDetailed, "Ctrl+O"), "toggle the detailed (full-screen) transcript"},
{labelOr(m.keyBindings.toggleSidebar, "Ctrl+B"), "hide / show the right context sidebar"},
{labelOr(m.keyBindings.toggleMouse, "Ctrl+E"), "release the mouse to drag-select & copy text"},
{"Tab", "accept the autocomplete / picker selection"},
},
},
{
title: "Navigation & scrollback",
bindings: []keybinding{
{"PgUp / PgDn", "scroll the transcript by a page"},
{"↑ / ↓", "scroll, or move within a popup / multi-line input"},
{"Ctrl+O", "toggle the detailed (full-screen) transcript"},
{"Ctrl+B", "hide / show the right context sidebar"},
{"Ctrl+E", "release the mouse to drag-select & copy text"},
{"Tab", "accept the autocomplete / picker selection"},
},
{
title: "Specialists & pickers",
bindings: []keybinding{
{"Click a specialist card", "drill into its sub-session"},
{"\u2191 / Esc (in a sub-session)", "return to the main chat"},
{"Ctrl+F (in /model)", "toggle the highlighted model as a favorite"},
{"Click a tool card", "expand / collapse its output"},
{"Right-click", "paste the clipboard"},
},
},
{
title: "Specialists & pickers",
bindings: []keybinding{
{"Click a specialist card", "drill into its sub-session"},
{"↑ / Esc (in a sub-session)", "return to the main chat"},
{"Ctrl+F (in /model)", "toggle the highlighted model as a favorite"},
{"Click a tool card", "expand / collapse its output"},
{"Right-click", "paste the clipboard"},
},
}
},
}
// keybindingHelpFooter is the dismiss hint shown at the bottom of the overlay.
const keybindingHelpFooter = "? or Esc to close \u00b7 /help for slash commands"
const keybindingHelpFooter = "? or Esc to close · /help for slash commands"
// renderKeybindingHelpLines builds the overlay body lines (group titles +
// aligned key/description rows + footer), wrapped to the inner width. Exposed
// separately from the framed renderer so tests can assert on content without
// the border chrome.
func (m model) renderKeybindingHelpLines(innerWidth int) []string {
groups := m.buildKeybindingGroups()
keyColumn := keybindingKeyColumnWidth(groups)
keyColumn := keybindingKeyColumnWidth()
lines := make([]string, 0, 64)
for index, group := range groups {
for index, group := range keybindingGroups {
if index > 0 {
lines = append(lines, "")
}
@@ -101,9 +93,9 @@ func (m model) renderKeybindingHelpLines(innerWidth int) []string {
// keybindingKeyColumnWidth returns the width of the key column: the widest key
// chord across all groups, so the descriptions align in a clean second column.
func keybindingKeyColumnWidth(groups []keybindingGroup) int {
func keybindingKeyColumnWidth() int {
widest := 0
for _, group := range groups {
for _, group := range keybindingGroups {
for _, binding := range group.bindings {
if n := len([]rune(binding.keys)); n > widest {
widest = n
+4 -6
View File
@@ -87,13 +87,11 @@ func TestHelpOverlayViewRendersGroupsAndKeys(t *testing.T) {
}
}
func TestBuildKeybindingGroupsAreWellFormed(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
groups := m.buildKeybindingGroups()
if len(groups) == 0 {
t.Fatal("buildKeybindingGroups must not return empty")
func TestKeybindingGroupsAreWellFormed(t *testing.T) {
if len(keybindingGroups) == 0 {
t.Fatal("keybindingGroups must not be empty")
}
for _, group := range groups {
for _, group := range keybindingGroups {
if strings.TrimSpace(group.title) == "" {
t.Fatal("every keybinding group needs a title")
}
-265
View File
@@ -1,265 +0,0 @@
package tui
import (
"strings"
"unicode/utf8"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/config"
)
// parsedBinding is the parsed representation of a keybinding string such as
// "ctrl+o" or "alt+enter". The zero value is a sentinel meaning "use default".
type parsedBinding struct {
ctrl bool
alt bool
shift bool
cmd bool // ⌘ Command (macOS) / ⊞ Win → ModSuper
code rune // tea.KeyCode or 0 for text-based matching
text string // for text-based matching (e.g. "?")
}
// isZero returns true when p is the nil sentinel (no binding configured).
func (p parsedBinding) isZero() bool {
return p.code == 0 && p.text == ""
}
// Label returns a human-readable representation of the binding, e.g. "Ctrl+O"
// or "Cmd+Shift+Enter". Used in the help overlay. Returns empty string for
// zero (unset) bindings.
func (p parsedBinding) Label() string {
if p.isZero() {
return ""
}
var b strings.Builder
if p.ctrl {
b.WriteString("Ctrl+")
}
if p.alt {
b.WriteString("Alt+")
}
if p.shift {
b.WriteString("Shift+")
}
if p.cmd {
b.WriteString("Cmd+")
}
if p.text != "" {
b.WriteString(p.text)
} else if p.code != 0 {
switch p.code {
case tea.KeyEnter:
b.WriteString("Enter")
case tea.KeyTab:
b.WriteString("Tab")
case tea.KeyEsc:
b.WriteString("Esc")
case tea.KeySpace:
b.WriteString("Space")
case tea.KeyBackspace:
b.WriteString("Backspace")
case tea.KeyDelete:
b.WriteString("Delete")
case tea.KeyUp:
b.WriteString("↑")
case tea.KeyDown:
b.WriteString("↓")
case tea.KeyLeft:
b.WriteString("←")
case tea.KeyRight:
b.WriteString("→")
case tea.KeyHome:
b.WriteString("Home")
case tea.KeyEnd:
b.WriteString("End")
case tea.KeyPgUp:
b.WriteString("PgUp")
case tea.KeyPgDown:
b.WriteString("PgDn")
default:
// Printable character — uppercase for display
if p.code >= 'a' && p.code <= 'z' {
b.WriteRune(p.code - 32)
} else {
b.WriteRune(p.code)
}
}
}
return b.String()
}
// Matcher returns a function that matches a tea.KeyMsg against this binding.
// It is the hot path for the key dispatch — kept cheap intentionally.
func (p parsedBinding) Matcher() func(tea.KeyMsg) bool {
if p.isZero() {
// A zero binding should never be matched — the caller is expected to
// check useDefault() first and fall through to the built-in check.
return func(tea.KeyMsg) bool { return false }
}
// Build the required modifier mask from the parsed flags.
var mod tea.KeyMod
if p.ctrl {
mod |= tea.ModCtrl
}
if p.alt {
mod |= tea.ModAlt
}
if p.shift {
mod |= tea.ModShift
}
if p.cmd {
mod |= tea.ModSuper // ⌘ Command on macOS, ⊞ Win on Windows
}
// ctrl+letter fast path — use exact modifier equality so that a
// configured ctrl+o does NOT fire on ctrl+alt+o or ctrl+shift+o.
// Note: in raw terminal mode Bubble Tea may report ctrl+letter as
// {Code:letter, Mod:ModCtrl} (handled below), or as a control char
// code (e.g. 0x0F) without a modifier flag. The latter is handled by
// the text-based fallback in model.go for default bindings; for
// configured bindings the code+mod path below is what the user
// expressed.
if mod == tea.ModCtrl && p.code >= 'a' && p.code <= 'z' {
return func(msg tea.KeyMsg) bool {
return msg.Key().Code == p.code && msg.Key().Mod == mod
}
}
if p.text != "" {
return func(msg tea.KeyMsg) bool {
return msg.Key().Text == p.text && msg.Key().Mod == mod
}
}
return func(msg tea.KeyMsg) bool {
return msg.Key().Code == p.code && msg.Key().Mod == mod
}
}
// parseBinding converts a user-written keybinding string (e.g. "ctrl+o") into
// a parsedBinding. The empty string returns zero parsedBinding (the "use
// default" sentinel).
func parseBinding(s string) parsedBinding {
s = strings.TrimSpace(s)
if s == "" {
return parsedBinding{}
}
parts := strings.Split(strings.ToLower(s), "+")
var p parsedBinding
var keyPart string
for _, part := range parts {
part = strings.TrimSpace(part)
switch part {
case "ctrl", "control":
p.ctrl = true
case "alt", "option":
p.alt = true
case "shift":
p.shift = true
case "cmd", "command":
p.cmd = true
case "super":
// "super" matches the Bubble Tea naming; on macOS this is ⌘
p.cmd = true
default:
keyPart = part
}
}
if p.ctrl && len(keyPart) == 1 && keyPart[0] >= 'a' && keyPart[0] <= 'z' {
// ctrl+<letter> — store the code so the match is exact
p.code = []rune(keyPart)[0]
p.text = ""
return p
}
// Map named keys to their tea.KeyCode
switch keyPart {
case "enter", "return":
p.code = tea.KeyEnter
case "tab":
p.code = tea.KeyTab
case "esc", "escape":
p.code = tea.KeyEsc
case "space":
p.code = tea.KeySpace
case "backspace":
p.code = tea.KeyBackspace
case "delete":
p.code = tea.KeyDelete
case "up":
p.code = tea.KeyUp
case "down":
p.code = tea.KeyDown
case "left":
p.code = tea.KeyLeft
case "right":
p.code = tea.KeyRight
case "home":
p.code = tea.KeyHome
case "end":
p.code = tea.KeyEnd
case "pgup", "pageup":
p.code = tea.KeyPgUp
case "pgdown", "pagedown":
p.code = tea.KeyPgDown
case "?":
p.text = "?"
p.code = 0
default:
// Single character, any modifier context
if utf8.RuneCountInString(keyPart) == 1 {
p.code = []rune(keyPart)[0]
}
// else unrecognised — leave zero so it falls through to default
}
return p
}
// labelOr returns b.Label() when b is configured (non-zero), otherwise it
// returns the caller-supplied default label string. This is the display-layer
// counterpart to keyMatch — dispatch falls through to the hardcoded default
// function, so the label displayed in help / hints must match that fallback.
func labelOr(b parsedBinding, defaultLabel string) string {
if !b.isZero() {
return b.Label()
}
return defaultLabel
}
// keyBindings holds the parsed, resolved key bindings the model uses at
// dispatch time. When a binding's parsedBinding is zero, the built-in default
// check in model.go's updateModel should be used.
type keyBindings struct {
toggleDetailed parsedBinding
toggleMouse parsedBinding
cycleReasoning parsedBinding
togglePlan parsedBinding
toggleSidebar parsedBinding
}
// resolveKeyBindings converts a user-facing KeyBindingsConfig into the
// dispatch-ready parsed form, using empty-is-default semantics.
func resolveKeyBindings(cfg config.KeyBindingsConfig) keyBindings {
return keyBindings{
toggleDetailed: parseBinding(string(cfg.ToggleDetailed)),
toggleMouse: parseBinding(string(cfg.ToggleMouse)),
cycleReasoning: parseBinding(string(cfg.CycleReasoning)),
togglePlan: parseBinding(string(cfg.TogglePlan)),
toggleSidebar: parseBinding(string(cfg.ToggleSidebar)),
}
}
// keyMatch returns true when msg matches either the user-configured binding b
// or (when b is zero/unset) the built-in default matcher defaultFn. This is
// the bridge between the config surface and the hot dispatch path in model.go.
func (m model) keyMatch(b parsedBinding, msg tea.KeyMsg, defaultFn func(tea.KeyMsg) bool) bool {
if !b.isZero() {
return b.Matcher()(msg)
}
return defaultFn(msg)
}
-33
View File
@@ -1,33 +0,0 @@
package tui
import (
"context"
"testing"
"time"
"github.com/Gitlawb/zero/internal/sessions"
)
func TestAvgTurnLatencyText(t *testing.T) {
m := newModel(context.Background(), Options{})
if got := m.avgTurnLatencyText(); got != "n/a" {
t.Fatalf("empty latency = %q, want n/a", got)
}
m.turnLatencySum = 9 * time.Second
m.turnLatencyCount = 2
if got := m.avgTurnLatencyText(); got != "4.5s avg (2 turns)" {
t.Fatalf("avgTurnLatencyText = %q, want \"4.5s avg (2 turns)\"", got)
}
// With TTFT recorded, the line also reports time-to-first-token.
m.turnTTFTSum = 3 * time.Second
m.turnTTFTCount = 2
if got := m.avgTurnLatencyText(); got != "4.5s avg (1.5s to first token, 2 turns)" {
t.Fatalf("avgTurnLatencyText with ttft = %q, want \"4.5s avg (1.5s to first token, 2 turns)\"", got)
}
// /new must reset the rolling latency + ttft so a fresh session starts from zero.
m.activeSession = sessions.Metadata{SessionID: "x"}
next := m.startNewSession()
if next.turnLatencyCount != 0 || next.turnLatencySum != 0 || next.turnTTFTCount != 0 || next.turnTTFTSum != 0 {
t.Fatalf("startNewSession must reset latency+ttft, got latency=%d/%v ttft=%d/%v", next.turnLatencyCount, next.turnLatencySum, next.turnTTFTCount, next.turnTTFTSum)
}
}
+42 -105
View File
@@ -36,7 +36,7 @@ import (
)
const tuiToolOutputLimit = 240
const defaultResponseStyle = "concise"
const defaultResponseStyle = "balanced"
const chatWheelScrollLines = 5
const ctrlCExitConfirmDuration = 3 * time.Second
const ctrlCExitConfirmText = "Press Ctrl+C again to exit"
@@ -67,7 +67,6 @@ type model struct {
gitBranch string
providerName string
modelName string
modelCatalog modelregistry.Registry
providerProfile config.ProviderProfile
savedProviders []config.ProviderProfile
provider zeroruntime.Provider
@@ -101,43 +100,35 @@ type model struct {
titledSessions map[string]bool
// retitle* drive the sequential /retitle backfill: queued session ids still
// awaiting a title, whether a backfill is running, and its progress counters.
retitleQueue []string
retitleActive bool
retitleTotal int
retitleDone int
retitleOK int
usageTracker *usage.Tracker
sessionCompactor SessionCompactor
prService *PrService
prState PrState
prWatcherStop func()
runtimeMessageSink func(tea.Msg)
agentOptions agent.Options
notifier *notify.Notifier
permissionMode agent.PermissionMode
selfCorrectTests bool
reasoningEffort modelregistry.ReasoningEffort
responseStyle string
keyBindings keyBindings
themeMode themeMode // palette preference: auto (default), dark, light
hasDarkBg bool // last terminal background-detection result (auto mode)
userAgent string
compactRequests int
compactInFlight bool
compactFrame int
lastCompactResult *CompactResult
lastCompactError string
unpricedRequests int
unpricedTokens int
lastUsage usage.Normalized
lastUsageSeen bool
// turnLatencySum / turnLatencyCount accumulate completed-run wall time so
// /context can show a rolling average turn latency (the "is it slow?" signal).
// Reset by /new.
turnLatencySum time.Duration
turnLatencyCount int
turnTTFTSum time.Duration
turnTTFTCount int
retitleQueue []string
retitleActive bool
retitleTotal int
retitleDone int
retitleOK int
usageTracker *usage.Tracker
sessionCompactor SessionCompactor
prService *PrService
prState PrState
prWatcherStop func()
runtimeMessageSink func(tea.Msg)
agentOptions agent.Options
notifier *notify.Notifier
permissionMode agent.PermissionMode
selfCorrectTests bool
reasoningEffort modelregistry.ReasoningEffort
responseStyle string
themeMode themeMode // palette preference: auto (default), dark, light
hasDarkBg bool // last terminal background-detection result (auto mode)
userAgent string
compactRequests int
compactInFlight bool
compactFrame int
lastCompactResult *CompactResult
lastCompactError string
unpricedRequests int
unpricedTokens int
lastUsage usage.Normalized
lastUsageSeen bool
transcript []transcriptRow
transcriptDetailed bool
helpOverlay bool // the `?` keyboard-shortcut overlay is open
@@ -460,9 +451,6 @@ type agentResponseMsg struct {
// Turn metadata for settled rows that do not otherwise carry it.
turnTools int
turnElapsed time.Duration
// ttft is time-to-first-token for the turn (0 when nothing streamed — a
// tool-only or errored turn). Set only on the success path.
ttft time.Duration
}
type agentRowMsg struct {
@@ -662,13 +650,9 @@ func newModel(ctx context.Context, options Options) model {
sessionStore = sessions.NewStore(sessions.StoreOptions{})
}
sandboxStore := options.SandboxStore
modelCatalog, err := modelregistry.DefaultRegistry()
if err != nil {
panic(err)
}
usageTracker := options.UsageTracker
if usageTracker == nil {
usageTracker = usage.NewTracker(usage.TrackerOptions{Registry: &modelCatalog})
usageTracker = usage.NewTracker(usage.TrackerOptions{})
}
prService := options.PrService
if prService == nil {
@@ -720,7 +704,6 @@ func newModel(ctx context.Context, options Options) model {
gitBranch: gitBranch(cwd),
providerName: options.ProviderName,
modelName: options.ModelName,
modelCatalog: modelCatalog,
providerProfile: options.ProviderProfile,
favoriteModels: favoriteModelSet(options.FavoriteModels),
recapsEnabled: options.RecapsEnabled,
@@ -743,7 +726,6 @@ func newModel(ctx context.Context, options Options) model {
permissionMode: permissionMode,
reasoningEffort: options.ReasoningEffort,
responseStyle: defaultedResponseStyle(options.ResponseStyle),
keyBindings: resolveKeyBindings(options.KeyBindings),
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
hasDarkBg: true,
userAgent: options.UserAgent,
@@ -1096,7 +1078,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
switch {
case keyCtrl(msg, 'c'):
return m.handleCtrlC()
case m.keyMatch(m.keyBindings.toggleDetailed, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'o') }):
case keyCtrl(msg, 'o'):
return m.toggleDetailedTranscript(), nil
case m.fileView.active && m.noBlockingModal() && m.composerValue() == "" && (keyText(msg) == "d" || keyText(msg) == "f"):
// Mode toggle for the file drill-in, only while the composer is empty
@@ -1106,13 +1088,12 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.setFileViewMode(fileViewFull), nil
}
return m.setFileViewMode(fileViewDiff), nil
case m.keyMatch(m.keyBindings.toggleMouse, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'e') }):
case keyCtrl(msg, 'e'):
// Release/recapture the mouse so the user can drag-select and copy text
// natively (mouse capture otherwise intercepts terminal selection).
m.mouseReleased = !m.mouseReleased
if m.mouseReleased {
mouseKey := labelOr(m.keyBindings.toggleMouse, "Ctrl+E")
return m.appendSystemNotice(fmt.Sprintf("Mouse released — drag to select and copy text. Press %s again to re-enable mouse interaction (clicks, right-click paste).", mouseKey)), nil
return m.appendSystemNotice("Mouse released — drag to select and copy text. Press Ctrl+E again to re-enable mouse interaction (clicks, right-click paste)."), nil
}
return m.appendSystemNotice("Mouse interaction re-enabled."), nil
case keyIs(msg, tea.KeyEsc):
@@ -1255,7 +1236,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.picker != nil {
return m.choosePicker()
}
if keyAlt(msg) || keyShift(msg) {
if keyAlt(msg) {
if next, ok := m.applyComposerKey(msg); ok {
return next, nil
}
@@ -1287,7 +1268,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.permissionMode = nextPermissionMode(m.permissionMode)
return m, nil
}
case m.keyMatch(m.keyBindings.cycleReasoning, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 't') }):
case keyCtrl(msg, 't'):
if m.transcriptDetailed {
return m, nil
}
@@ -1300,20 +1281,20 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.noBlockingModal() {
return m.cycleReasoningEffort()
}
case m.keyMatch(m.keyBindings.togglePlan, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'p') }):
case keyCtrl(msg, 'p'):
// Ctrl+P toggles the plan panel expansion (collapse/expand step list).
if m.noBlockingModal() && !m.plan.isEmpty() {
m.plan.expanded = !m.plan.expanded
return m, nil
}
case m.keyMatch(m.keyBindings.toggleSidebar, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'b') }):
case keyCtrl(msg, 'b'):
// Ctrl+B collapses / restores the right context sidebar. Only acts when
// the sidebar would otherwise be on screen (managed mode, wide enough,
// real conversation) so it's a no-op — not a confusing notice — on the
// 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.noBlockingModal() && m.sidebarAvailable() {
// 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
@@ -1875,16 +1856,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.streamingText = ""
m.streamingReasoning = ""
m.streamingReasoningExpanded = false
// Roll the completed run's wall-time into the session's rolling average so
// /context can surface typical turn latency, not just token counts.
if msg.turnElapsed > 0 {
m.turnLatencySum += msg.turnElapsed
m.turnLatencyCount++
}
if msg.ttft > 0 {
m.turnTTFTSum += msg.ttft
m.turnTTFTCount++
}
if msg.specReview != nil {
m = m.activateSpecReview(*msg.specReview)
}
@@ -2389,10 +2360,6 @@ func (m model) composerIdleHint() string {
m.subchat.active || m.suggestionsActive() || m.transcriptDetailed {
return ""
}
sidebarKey := labelOr(m.keyBindings.toggleSidebar, "Ctrl+B")
detailKey := labelOr(m.keyBindings.toggleDetailed, "Ctrl+O")
mouseKey := labelOr(m.keyBindings.toggleMouse, "Ctrl+E")
var hint string
switch widthTier(m.width) {
case tierTiny:
@@ -2400,9 +2367,9 @@ func (m model) composerIdleHint() string {
case tierNarrow:
hint = "? shortcuts"
case tierMedium:
hint = fmt.Sprintf("? shortcuts · %s sidebar · %s copy", sidebarKey, mouseKey)
hint = "? shortcuts · Ctrl+B sidebar · Ctrl+E copy"
default:
hint = fmt.Sprintf("? shortcuts · %s sidebar · %s detail · %s copy · Shift+Tab mode", sidebarKey, detailKey, mouseKey)
hint = "? shortcuts · Ctrl+B sidebar · Ctrl+O detail · Ctrl+E copy · Shift+Tab mode"
}
return zeroTheme.faint.Render(hint)
}
@@ -3648,23 +3615,10 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
return m, nil
case commandClear:
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
// point to /new, so "cleared screen" isn't mistaken for "fresh context."
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Transcript cleared. The agent still has the full session context — use /new to start a fresh session."})
// 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 ·")
return m, nil
case commandNew:
// A fresh session mid-run would strand the in-flight turn's events; make the
// user cancel first. Idle, /new saves the current session (already on disk)
// and clears the conversation in place.
if m.pending || m.compactInFlight {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "A run is in progress. Press Esc to cancel it first, then /new."})
return m, nil
}
return m.startNewSession(), nil
case commandExit:
// /exit gets the same protection as Ctrl+C: cancel any in-flight run and
// defer the quit until its checkpoint session events flush — quitting
@@ -4149,9 +4103,6 @@ func selfCorrectAutonomyForMode(mode agent.PermissionMode) string {
func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt string, images []zeroruntime.ImageBlock, runOptions tuiAgentRunOptions) tea.Cmd {
return func() tea.Msg {
started := m.now()
// firstTokenAt is stamped when the first token (reasoning or text) streams,
// so the turn can report time-to-first-token alongside total wall time.
var firstTokenAt time.Time
toolCalls := 0
rows := []transcriptRow{}
usageEvents := []zeroruntime.Usage{}
@@ -4208,10 +4159,6 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
IncludeLSP: true,
Autonomy: selfCorrectAutonomyForMode(options.PermissionMode),
})
// Inline post-edit diagnostics: edit_file/write_file append error
// diagnostics for the file they just wrote to their own output, so the
// model sees a break in the same turn. Shares the run's lazy manager.
options.FileDiagnostics = agent.NewFileDiagnostics(lspManager)
}
// Some providers synthesize tool-call ids that repeat within a run (e.g.
@@ -4248,9 +4195,6 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
onText := options.OnText
options.OnText = func(delta string) {
if firstTokenAt.IsZero() {
firstTokenAt = m.now()
}
if strings.TrimSpace(reasoningText) != "" {
flushReasoning(m.now())
}
@@ -4346,9 +4290,6 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
onReasoning := options.OnReasoning
options.OnReasoning = func(delta string) {
now := m.now()
if firstTokenAt.IsZero() && strings.TrimSpace(delta) != "" {
firstTokenAt = now
}
if strings.TrimSpace(reasoningText) == "" && strings.TrimSpace(delta) != "" {
reasoningStarted = now
}
@@ -4585,10 +4526,6 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
}
flushReasoning(m.now())
elapsed := m.now().Sub(started)
ttft := time.Duration(0)
if !firstTokenAt.IsZero() {
ttft = firstTokenAt.Sub(started)
}
rows = append(rows, transcriptRow{
kind: rowAssistant,
text: result.FinalAnswer,
@@ -4606,7 +4543,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
"content": result.FinalAnswer,
},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, turnTools: toolCalls, turnElapsed: elapsed, ttft: ttft}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, turnTools: toolCalls, turnElapsed: elapsed}
}
}
+4 -2
View File
@@ -66,8 +66,10 @@ func (m model) modelContextWindow(modelName string) int {
if trimmed == "" {
return 0
}
if entry, ok := m.modelCatalog.Resolve(trimmed); ok && entry.ContextLimits.ContextWindow > 0 {
return entry.ContextLimits.ContextWindow
if registry, err := modelregistry.DefaultRegistry(); err == nil {
if entry, ok := registry.Resolve(trimmed); ok && entry.ContextLimits.ContextWindow > 0 {
return entry.ContextLimits.ContextWindow
}
}
// Live-discovered window, preferring the ACTIVE provider's models so a model ID
// shared across providers resolves to the provider actually in use; only then
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more