Compare commits

...
Author SHA1 Message Date
anandh8x 85b1689a62 Trim eager read tool schemas 2026-08-07 20:50:35 +05:30
anandh8x 6a258e1876 Merge main into feat/structural-code-query 2026-08-07 20:13:49 +05:30
euxaristia 1e73ecae8a docs(agents): Codify recurring review blockers in AGENTS.md (#857)
* docs(agents): codify recurring review blockers in AGENTS.md

Capture the high-frequency classes that drive multi-round maintainer reviews
(stale bases, path canonicalization, secret handling, partial setup, missing
regression tests, scope honesty) so coding agents fix them before first review.

Refs #856

* docs(agents): tighten security and atomic-write review guidance

Require open-time containment binding instead of pre-open EvalSymlinks, and
require temp-file then atomic replace rather than exclusive create alone.

Refs #856

* docs(agents): require traversal-wide containment for no-follow APIs

Final-component-only O_NOFOLLOW is insufficient on Linux and reparse
points need platform protections. Align the Security edges guidance
with the full-path containment requirement from review.
2026-08-07 20:18:11 +08:00
KRATOS cae0269e77 fix(tui): a copied NUL byte no longer panics the TUI on Windows (#876)
syscall.StringToUTF16 PANICS rather than returning an error when the
string contains a NUL — documented standard-library behaviour — and
github.com/atotto/clipboard's Windows path calls it directly. So one NUL
anywhere in a copied transcript selection killed the whole program:
"zero: tui error: program was killed: program experienced a panic",
reported from a real Windows machine.

The NUL reaches the clipboard because ansi.Strip removes escape
SEQUENCES and leaves C0 bytes untouched, and the transcript carries them
from two independent directions: tool output (a binary read, git's -z
NUL-separated listings — which this repo itself parses in
files_git_sweep.go) and Zero's own card protocol, whose row prefixes are
literally "\x00command-card\x00" and "\x00plan-card\x00".

Sanitised at the WRITE rather than at each source, because the sources
are unbounded: any tool result a user can select from is one, and a fix
per source is a fix the next tool re-breaks. The same pass protects the
OSC52 fallback, where a stray control byte terminates the escape
sequence early and silently corrupts the copy instead of crashing —
which is likely why this went unnoticed off Windows, where the identical
NUL produces a bad copy rather than a dead process.

\n and \t survive: a multi-line selection is the normal case and both
are legal clipboard content.

Fixes #875
2026-08-07 20:17:19 +08:00
Vasanth TandClaude Opus 5 cd0eb194e4 feat(tools): give tool results an image channel and add view_image (#843)
* feat(tools): give tool results an image channel and add view_image

Closes #824.

tools.Result carried only text, so no tool could hand the model a picture.
The sharpest edge was our own capture tooling: browser_screenshot,
desktop_screenshot and browser_pdf wrote a PNG, set Meta["artifact_path"]
and returned "Artifact captured: <path>". Nothing read that path
afterwards, so the model asked for a screenshot, was told one existed, and
could not look at it.

Zero was never image-blind on the way in — zeroruntime.ImageBlock,
Message.Images and internal/imageinput are mature. What was missing is the
model-initiated leg.

Result.Images is delivered as a following USER message, not on the tool
result itself. Every provider drops images on a tool-role message:
Anthropic maps a tool result to a tool_result block whose content is a
string, Gemini to a functionResponse, and OpenAI guards its image
content-parts to the user role with an explicit comment saying so. Attaching
them to the tool result would have plumbed the whole path and delivered
nothing. A separate message also keeps one tool result per tool call, which
the providers validate.

Blocks with no data, or a media type outside the provider allow-list, are
dropped rather than sent — a rejected image would fail the whole turn, and
the tool's text still stands on its own. The message names the tool, since
the images arrive detached from their result and nothing else says which
call produced them when several ran in one turn.

view_image resolves through resolveScopedReadPath before imageinput.LoadFile.
That scoping is load-bearing: LoadFile joins a relative path to the
workspace root and otherwise takes the path as given, so without it the tool
would read anywhere on disk. It is registered with the other scoped readers
and declared read-only and thread-safe, matching read_file.

The capture tools now attach what they wrote. A PDF or an unreadable file
yields no image and the text stands alone, so a capture is never failed over
its attachment.

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

* fix(agent): keep tool results contiguous when a tool returns an image

The image messages are user-role, and the first version appended each one
immediately after its own tool result. With two tool calls in a turn that
produces tool_result, user, tool_result — and a user message between two
tool results breaks strict provider replay.

Anthropic makes it concrete. appendUserBlocks coalesces consecutive user
content into one block list, and tool_result blocks must come first in that
message, so the interleaved shape maps to:

    [tool_result, text, image, tool_result]

which the API rejects. loop.go already documents this exact rule three lines
below the change, for the self-correction feedback it defers for the same
reason.

Images are now collected during the tool loop and appended once every
tool_result is recorded — including the aborted placeholders on the abort,
stop-reason and repeated-failure paths, so an early exit cannot strand them
mid-sequence either.

The original test used a single tool call, which cannot exhibit the bug. The
new one runs two calls where the first returns an image and asserts the image
message does not fall between the first and last tool result.

Also from review: view_image joins the "execute" tool category, which grants
read_file but not view_image, so a specialist declared with tools: [execute]
could not look at a screenshot it had just captured. Covered by a test that
holds for every category granting read_file rather than naming one. And the
core read-only set is now asserted by tool name, not only by count — a count
still passes when one reader is swapped for another.

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

* test(agent): pin the tool-result/image ordering contract

The assertions only tracked the first and last tool-result index, so four
distinct broken orderings passed: a message wedged between the two tool results,
a duplicated image flush, a duplicated tool result, and both results answering
the same tool_call_id. Mutation-testing each of those against the real loop
confirmed the old assertions were green on all four.

Now it collects every tool-result index and id and requires exactly two, in
c1-then-c2 order, at adjacent indexes, with the image following the last one and
delivered exactly once. Adjacency is the assertion that carries the contract:
Anthropic needs every tool_result block ahead of any other block in the user
message they coalesce into, so anything between the two results is the 400 this
change exists to avoid.

The image is deliberately NOT pinned to exactly last+1. appendUserBlocks merges
consecutive user-role content, so [tool_result, tool_result, text, image] maps to
tool_result blocks first and replays fine; pinning would fail that valid shape
while killing nothing the adjacency and count assertions do not already catch.

messageShape reports the actual role ordering on failure, and the loop uses two
ifs rather than a switch so an image left on a tool-role message is reported as
the misplacement it is instead of "the image never reached the model".

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-06 21:35:41 +05:30
Vasanth T 4ca4fa75d3 fix(agent): end the additional_permissions retry loop (#864)
* fix(agent): end the additional_permissions retry loop

Asking the agent to run an ordinary read-only command on Windows could leave it
oscillating between two rejections instead of executing:

    additional_permissions requires sandbox_permissions set to "with_additional_permissions"
    additional_permissions must include at least one of network or file_system, ...

Both come from inlineAdditionalPermissionsProfile, and each names only the fix
that ADDS something. The first pushes the model toward setting the flag; the
second pushes it back toward requesting a permission the command does not need.
Neither ever says to omit the field, which is the correct action for a read-only
command, so the two messages form a two-state oscillator. Every rejection now
carries that remedy.

The schema half is why the object is attached at all. Neither field description
said to omit them for ordinary commands, so a model that legitimately used
with_additional_permissions once in a session had nothing telling it to stop
attaching a permission object to the next command. Both descriptions now lead
with "omit", and they are shared constants rather than duplicated verbatim in
bash.go and exec_command.go, where they were free to drift.

Rejection is still rejection. Nothing that was refused is now accepted on the
strength of an empty or mismatched payload, and sandbox_permissions is never
upgraded on the model's behalf; only the guidance changed.

The one behavioural change is that an explicit additional_permissions: null is
now treated as the omitted field, since a null grants nothing and carries no
flag. That flips three shapes from reject to execute: null with use_default,
with require_escalated, and with no mode at all. The escalated one still routes
through the normal escalation prompt and is not a new capability — the identical
request without the null key already behaved that way. A FLAGGED null still
errors, so with_additional_permissions plus null is not silently downgraded.

Fixes #845. Fixes #847.

* fix(tools): condition the omit guidance on needed access, not read-only

The description said to omit sandbox_permissions "for ordinary commands,
including every read-only one". That overstates it: curl and gh are read-only
and still need sandboxed network, so a model following it literally would omit
the override on a command that genuinely needs one and hit the opposite failure.

It now says to omit unless the command needs access the sandbox does not already
give it. That is the claim that is actually true, and it is one a model can act
on without first deciding what counts as read-only.

The schema test asserted the wrong half too — a bare substring check for
"read-only" pinned the overstatement rather than the contract. It now pins the
conditional, with the reasoning recorded so the weaker assertion does not come
back.

Reported by CodeRabbit on #864.
2026-08-06 21:34:54 +05:30
Anandan edf660ab85 perf: compact common test output (#846)
* perf: compact common test output

* test: strengthen command reducer guarantees

* fix: tighten command output summary detection
2026-08-06 20:35:13 +05:30
anandh8x e3337d06d5 Prevent overflow in ranged minified reads 2026-08-05 20:25:55 +05:30
anandh8x 3aa190bd7f Fix bounded source read edge cases 2026-08-05 17:22:19 +05:30
anandh8x 00ad4d4b8d Preserve lexical context in ranged reads 2026-08-04 21:28:28 +05:30
anandh8x a394fd60ee Improve source read efficiency and compatibility 2026-08-04 21:12:15 +05:30
Vasanth T 91b413c514 fix(sandbox): stop the Windows write jail honouring Everyone-granted paths (#865)
* fix(sandbox): stop the Windows write jail honouring Everyone-granted paths

The restricted token put the World SID (S-1-1-0, Everyone) in its
restricted-SID list unconditionally, from the original sandbox baseline
and with no comment saying why.

A WRITE_RESTRICTED token runs two checks for a write and needs both: the
normal one against its enabled SIDs, and a second against the restricted
list. The write jail rests entirely on that second check only succeeding
where Zero has explicitly ACL'd a capability SID. Everyone is a SID every
principal carries, so on any path whose DACL grants Everyone write, the
restricted half passed for free and confinement fell back to the ordinary
user's own permissions -- the exact boundary this token exists to be
stricter than.

Reachable with no privilege, no symlink and no race: an Everyone-writable
directory is enough, and share roots opened with Everyone:F and loose
third-party installer ACLs supply them. Ruled out as the common case:
C:\Users\Public\Documents grants BATCH, not Everyone.

The runner already stated the rule this broke -- it declines to add the
user SID, Administrators or SYSTEM because "each has write access nearly
everywhere" -- while listing Everyone among the restricted SIDs.

Removed only from the WRITE_RESTRICTED token, because without that flag it
is load-bearing rather than gratuitous. The restricted-SID check then
applies to reads too, and default Windows DACLs grant BUILTIN\Users rather
than anything in this list, so a token with no Everyone cannot open cmd.exe
and dies at launch with STATUS_ACCESS_DENIED (0xC0000022). Dropping it
there was tried first and takes TestWindowsUnelevatedRealSandboxSmoke with
it. That path is taken only when a profile configures DenyRead, which is
already the posture trading capability for read-deny enforcement (#612), so
the bypass survives there deliberately and narrowly; closing it needs a
read-side grant that is not a universal group.

Verified against real tokens rather than reasoned about. The new smoke test
drives the real runner and asserts three outcomes that only agree when the
jail is intact: a granted root is writable, an ordinary outside path is
denied, and an Everyone-writable outside path is denied too. Before the
change its third case wrote the file with exit 0; the first two were
already correct, which is what made the gap invisible. Restoring the SID
unconditionally turns it red again.

* test(sandbox): make a denied write distinguishable from a runner failure

The denial assertions expected exit 1, which the runner also returns for
its own errors -- a failed marker validation, a bad argument, a token it
could not build. Written that way the test passes both when the sandbox
denies the write and when nothing ever ran, and the second case proves
nothing while being indistinguishable from success.

That is a poor property for any test and a bad one for this test, whose
entire job is to notice a confinement regression. The leading
"granted root is writable" assertion covered part of it, but said nothing
about whether the two later commands launched.

The denial commands now report a distinctive exit code the runner cannot
produce for itself, so the code proves cmd.exe actually ran and its
redirect was refused.

Re-checked in both directions rather than assumed: the test passes against
the fix, and restoring the World SID unconditionally fails it with
"exit code = 0, want 77" -- still catching the bug it was written for.

Raised by CodeRabbit on #865.
2026-08-04 18:51:35 +05:30
Vasanth T 021281ebba fix(cli): report MCP servers that failed to start (#822) (#827)
* fix(cli): report MCP servers that failed to start (#822)

`zero mcp check` inspected only the error returned by registration and then
printed "is reachable". RegisterTools is best-effort by design, so a server
that fails to connect is recorded on the runtime and registration returns
nil. That is right for startup, where one bad server must not stop Zero
launching, and wrong here: this is the command a user runs when a server is
misbehaving, and it told them the broken server was fine.

The failure data already existed as SkippedServer{Name, Err}; nothing
consumed it in this path. The check now fails with the recorded error, in
both text and JSON output.

Covered by a test that spawns a genuinely missing command rather than
stubbing the runtime, because the defect was precisely a mismatch between
what registration returns and what it records. Reverting the fix makes it
print "MCP server broken is reachable. 0 tool(s) available."

* test(cli): cover the unreachable --json contract

The text path was covered and the JSON path was not, though the change
adds its own return with a different payload: status "unreachable" plus a
redacted error, rather than status "ok" with a tool count. A caller
parsing that shape was relying on something nothing pinned.

Reverting the fix now fails both cases. The JSON one is the more useful
of the two, because the wrong output is still valid JSON and would decode
cleanly into a caller expecting success.
2026-08-03 22:50:15 +05:30
8e266797a8 perf: improve harness context efficiency (#838)
* Improve harness context efficiency

* Address harness efficiency review findings

* Make recaps idle and cancellable

Delay recap generation until the session has been inactive, orient it around the active goal and next task, cancel stale work on activity, and render the result ephemerally above the composer.

Also strengthen the formatter safety regression to exercise formatter-modified edit output.

* Track every replace-all edit span

Translate each replacement into its post-edit line range, including cumulative shifts from earlier replacements, so follow-up edits remain authorized for every model-supplied change.

* Make review regressions cross-platform

Verify replace-all tracking through the canonical edit tool path instead of a platform-sensitive temp path, and cover cancellation when recaps are disabled during generation.

* fix(tools): derive SeenWhole from the recorded ranges

SeenWhole was a flag set only by a SINGLE read covering the file, while
SeenRange judged coverage from the accumulated ranges. The two then
disagreed about identical reads: after reading lines 1-50 and 51-100 of a
100-line file, SeenRange(1,100) was true and SeenWhole was false.

That matters because write_file gates overwrites on SeenWhole. A file past
the read byte budget cannot be read whole in one call — the read is
truncated and RecordSeenRange is correctly skipped, since the model did not
see those lines — so no sequence of reads could ever set the flag, and the
overwrite was refused permanently with:

    the intended change depends on content that has not been read exactly
    in this session. Read the affected lines with read_file, then retry.

Advice that could not change the answer. edit_file was unaffected because it
gates on SeenRange, so the escape was to edit rather than rewrite, but a
model following the error text would loop.

SeenWhole is now computed from the ranges against the file's recorded line
count, so scoped reads that together cover a file make it writable and the
error text becomes actionable. The observation carries that line count, and
a read reporting a different one clears the accumulated ranges: they
describe a shape the file no longer has.

Coverage is judged by merging sorted ranges rather than walking every line
against every range, which the previous SeenRange did — same answer, and it
no longer scans line-by-line over a large file.

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

* test(tools): key the large-file test on the resolved path

read_file resolves the workspace root through EvalSymlinks before recording
anything, so the tracker is keyed on the resolved path. The test asserted
against the raw t.TempDir() path, which is the same string on Windows and a
different one on macOS, where the temp dir sits under /var — a symlink to
/private/var. The SeenWhole assertions would have looked up a key that never
existed: passing here, failing on macOS CI.

Also switches the line generator to fmt.Fprintf (staticcheck QF1012).

Both from CodeRabbit on #842.

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

* test(tools): actually switch the line generator to fmt.Fprintf

The previous commit's message claimed this was done. It was not: the edit
silently did not apply and I reported it fixed without re-reading the file,
so builder.WriteString(fmt.Sprintf(...)) survived at the head — caught by
@anandh8x, not by me.

Verified this time by running the linter rather than by reading the diff:
golangci-lint --enable-only unused,ineffassign,staticcheck ./internal/tools/
reports 0 issues.

* fix: keep exact-read workflows recoverable

* test: cover permission normalization wiring

* fix: commit file observations after output budgeting

* fix: defer file observations across retries

* test: normalize patch line endings

---------

Co-authored-by: Vasanthdev2004 <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
2026-07-31 23:30:03 +05:30
Anandan d37de9214b fix: use OAuth credentials in provider health checks (#828)
* fix: use OAuth credentials in provider health checks

* fix(providerhealth): verify OAuth login for all probes

* fix(providerhealth): retain API key redaction

* fix(providerhealth): redact bare OAuth tokens

* test(cli): isolate OAuth doctor config

* test(cli): isolate doctor config path
2026-07-30 22:32:58 +05:30
Anandan 588635487f feat(tui): replace /retitle with local /rename (#826)
* Improve session naming and resume picker

* Address session rename review feedback
2026-07-30 19:58:02 +05:30
Anandan 845df6c7d2 fix(tui): hide unavailable sidebar shortcut (#821)
* fix(tui): hide unavailable sidebar shortcut

* test(tui): cover medium sidebar footer hint
2026-07-30 17:33:08 +05:30
Anandan 15eb16f736 fix(tui): exclude permission waits from turn time (#820)
* fix(tui): exclude permission waits from turn time

* fix(tui): exclude permission waits from TTFT
2026-07-30 17:03:27 +05:30
94 changed files with 5934 additions and 560 deletions

No files matched your search

+36
View File
@@ -67,3 +67,39 @@ requirements. If a related check fails, fix it. If a required check cannot run
because of the environment or fails for an unrelated external reason, report
the exact failure and obtain maintainer direction rather than silently ignoring
it.
## 4. Common Review Blockers
These classes drive multi-round reviews. Fix them before requesting review:
- **Fresh base:** Rebase onto the current PR base (`main` or stacked target)
before review. A stale head that rolls back mainline commits is a hard
blocker, not a merge-time detail. Resolve conflicts by keeping upstream
behavior unless the PR intentionally changes it.
- **Platform truth:** Code and tests must pass on Linux, macOS, and Windows.
Compare paths after canonicalization (physical/`EvalSymlinks` form); never
assert raw `t.TempDir()` spellings (`/var` vs `/private/var` on macOS, short
paths on Windows). Prefer one cross-platform function with small `GOOS`
checks over duplicated helpers that drift.
- **Security edges:** Keep secrets out of argv, env dumps, and logs. Redact
success and error paths (including stderr). Fail closed on ownership, lease,
and permission checks. Do not rely on pre-open path resolution
(`EvalSymlinks` then open) for containment: that is a check-to-use race.
Bind containment at open/use time with rooted or handle-relative,
traversal-resistant APIs. If a no-follow API is used, apply it to every
traversed component and enforce the platform's reparse-point protections;
final-component-only no-follow is insufficient. On multi-step setup, roll back
only what this run created; never destroy pre-existing resources you did
not create; never report success when cleanup or unlock failed.
- **Atomic shared state:** Write a complete temporary file, then atomically
replace the destination so concurrent readers never see a partial write.
Exclusive create or a write lock alone is not enough for readers. Serialize
the full read-modify-write sequence for lockfiles and shared stores.
- **Tests match the claim:** Every behavior or security-boundary change needs a
regression test, including the failure path. Path-sensitive logic needs at
least one non-Linux path case (or a hermetic fake that exercises the same
normalization).
- **Honest scope:** PR description, help text, and comments must match what
shipped. Wire advertised entry points or shrink the claim. Do not bundle
unrelated fixes. Do not widen security allowlists by name alone; check
classification and side effects too.
@@ -5,9 +5,29 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
type normalizationRecordingShellTool struct {
calls []map[string]any
}
func (tool *normalizationRecordingShellTool) Name() string { return "bash" }
func (tool *normalizationRecordingShellTool) Description() string {
return "record normalized shell arguments"
}
func (tool *normalizationRecordingShellTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: true}
}
func (tool *normalizationRecordingShellTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionAllow}
}
func (tool *normalizationRecordingShellTool) Run(_ context.Context, args map[string]any) tools.Result {
tool.calls = append(tool.calls, cloneArgs(args))
return tools.Result{Status: tools.StatusOK}
}
// A shell call that sets sandbox_permissions: with_additional_permissions but
// omits (or malforms) additional_permissions can never succeed: the same
// validation runs again at grant time regardless of the user's decision. It
@@ -107,9 +127,7 @@ func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing
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)
}
assertNamesOmitRemedy(t, "missing additional_permissions", result.Output)
}
// A well-formed additional_permissions payload must still reach the normal
@@ -144,3 +162,237 @@ func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T)
t.Fatalf("OnPermissionRequest called %d times, want exactly 1 for a valid elevation request", promptCalls)
}
}
// runShellCall drives one shell tool call through the real registry (and so
// through Registry.RunWithOptions) exactly as the agent loop would.
func runShellCall(t *testing.T, root string, arguments string) ToolResult {
t.Helper()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedExecCommandTool(root, nil, nil))
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{ID: "c1", Name: "exec_command", Arguments: arguments},
PermissionModeUnsafe,
Options{Cwd: root},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
return result
}
// TestAdditionalPermissionsRejectionsConverge is the regression guard for
// #845/#847. A model that attaches a permission object to a command needing
// none used to be told only "set sandbox_permissions", which pushed it into the
// OTHER invalid shape (flag set, payload empty) and its "include a permission"
// error pushed it straight back — the reported loop that burned turns on
// `git branch --show-current`.
//
// The contract this pins is convergence, not tolerance: both shapes are still
// REJECTED (nothing here grants access), but each rejection must name the one
// follow-up call that works, and that follow-up must actually succeed.
func TestAdditionalPermissionsRejectionsConverge(t *testing.T) {
root := t.TempDir()
const readOnly = `"cmd":"git --version"`
// Step 1 of the reported loop: a permission object with no flag.
first := runShellCall(t, root, `{`+readOnly+`,"additional_permissions":{"network":{"enabled":true}}}`)
if first.Status != tools.StatusError {
t.Fatalf("unflagged additional_permissions status = %q, want error (rejecting is correct)", first.Status)
}
assertNamesOmitRemedy(t, "unflagged additional_permissions", first.Output)
// Step 2 of the reported loop: the model keeps the flag but empties the
// payload. This must not be the dead end it was.
second := runShellCall(t, root, `{`+readOnly+`,"sandbox_permissions":"with_additional_permissions"}`)
if second.Status != tools.StatusError {
t.Fatalf("flag without payload status = %q, want error", second.Status)
}
assertNamesOmitRemedy(t, "flag without payload", second.Output)
// The call both errors point at must actually work.
converged := runShellCall(t, root, `{`+readOnly+`}`)
if converged.Status != tools.StatusOK {
t.Fatalf("the call both errors instruct the model to make failed: status=%q output=%q", converged.Status, converged.Output)
}
}
// assertNamesOmitRemedy pins the actionable half of the error text: the message
// must spell out that BOTH fields can simply be dropped. "Set the flag" or
// "include a permission" alone is what made the two errors chase each other.
func assertNamesOmitRemedy(t *testing.T, label string, output string) {
t.Helper()
if !strings.Contains(output, "omit both sandbox_permissions and additional_permissions") {
t.Fatalf("%s error = %q, want it to say to omit both fields entirely", label, output)
}
if !strings.Contains(output, "retry") {
t.Fatalf("%s error = %q, want it to tell the model to retry", label, output)
}
}
// The empty-payload rejection carries the same remedy. It is pinned on the
// validator directly because normalizeNoopAdditionalPermissions absorbs the
// empty payload before executeToolCall reaches this branch;
// inlineAdditionalPermissionsProfile is still the shared source of truth that
// buildPermissionEvent and the grant path re-run, so its text must converge too.
func TestEmptyAdditionalPermissionsErrorNamesOmitRemedy(t *testing.T) {
args := map[string]any{
"cmd": "git --version",
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
"additional_permissions": map[string]any{
"network": map[string]any{"enabled": false},
"file_system": map[string]any{"read": []any{}, "write": []any{}},
},
}
_, requested, err := inlineAdditionalPermissionsProfile(args, t.TempDir())
if !requested {
t.Fatal("requested = false, want true for an explicit with_additional_permissions call")
}
if err == nil {
t.Fatal("an empty additional_permissions payload was accepted; it must still be rejected")
}
assertNamesOmitRemedy(t, "empty payload", err.Error())
}
// A provider that materializes every optional property may send
// "additional_permissions": null. That grants nothing and carries no flag, so it
// is the field omitted — rejecting it stalled an ordinary command behind an
// error the model could not act on.
func TestExecuteToolCallTreatsNullAdditionalPermissionsAsOmitted(t *testing.T) {
result := runShellCall(t, t.TempDir(), `{"cmd":"git --version","additional_permissions":null}`)
if result.Status != tools.StatusOK {
t.Fatalf("null additional_permissions was rejected: status=%q output=%q", result.Status, result.Output)
}
}
// A null payload alongside the flag must still be rejected, and must land on
// the same error as an absent payload rather than being quietly downgraded.
func TestExecuteToolCallRejectsFlaggedNullAdditionalPermissions(t *testing.T) {
result := runShellCall(t, t.TempDir(),
`{"cmd":"git --version","sandbox_permissions":"with_additional_permissions","additional_permissions":null}`)
if result.Status != tools.StatusError {
t.Fatalf("flagged null status = %q, want error", result.Status)
}
if !strings.Contains(result.Output, "no additional_permissions object was provided") {
t.Fatalf("output = %q, want the missing-payload error", result.Output)
}
assertNamesOmitRemedy(t, "flagged null", result.Output)
}
func TestNormalizeProviderExpandedEmptyAdditionalPermissions(t *testing.T) {
for _, mode := range []string{
string(tools.SandboxPermissionsUseDefault),
string(tools.SandboxPermissionsWithAdditionalPermissions),
} {
args := map[string]any{
"command": "./zero --version",
"sandbox_permissions": mode,
"additional_permissions": map[string]any{
"file_system": map[string]any{
"deny_read": []any{},
"entries": []any{},
"read": []any{},
"write": []any{},
},
"network": map[string]any{"enabled": false},
},
}
normalizeNoopAdditionalPermissions(args, t.TempDir(), nil)
if _, exists := args["additional_permissions"]; exists {
t.Fatalf("mode %q retained empty additional_permissions: %#v", mode, args)
}
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsUseDefault) {
t.Fatalf("mode %q normalized to %q, want use_default", mode, got)
}
}
}
func TestExecuteToolCallNormalizesProviderExpandedEmptyAdditionalPermissions(t *testing.T) {
tool := &normalizationRecordingShellTool{}
registry := tools.NewRegistry()
registry.Register(tool)
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"./zero --version","sandbox_permissions":"with_additional_permissions",` +
`"additional_permissions":{"file_system":{"deny_read":[],"entries":[],"read":[],"write":[]},"network":{"enabled":false}}}`,
},
PermissionModeAuto,
Options{Cwd: t.TempDir()},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusOK {
t.Fatalf("provider-expanded empty permissions were rejected: %s", result.Output)
}
if len(tool.calls) != 1 {
t.Fatalf("shell tool calls = %d, want 1", len(tool.calls))
}
args := tool.calls[0]
if _, exists := args["additional_permissions"]; exists {
t.Fatalf("executeToolCall retained empty additional_permissions: %#v", args)
}
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsUseDefault) {
t.Fatalf("sandbox mode = %q, want use_default", got)
}
}
func TestNormalizeAdditionalPermissionsPreservesRealRequest(t *testing.T) {
args := map[string]any{
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
"additional_permissions": map[string]any{
"network": map[string]any{"enabled": true},
},
}
normalizeNoopAdditionalPermissions(args, t.TempDir(), nil)
if _, exists := args["additional_permissions"]; !exists {
t.Fatal("non-empty additional_permissions was removed")
}
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsWithAdditionalPermissions) {
t.Fatalf("sandbox mode changed to %q", got)
}
}
func TestNormalizeAdditionalPermissionsRemovesWorkspaceReadAlreadyCoveredBySandbox(t *testing.T) {
root := t.TempDir()
engine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
})
args := map[string]any{
"command": "./zero --version",
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
"additional_permissions": map[string]any{
"file_system": map[string]any{
"entries": []any{
map[string]any{
"access": "read",
"path": map[string]any{
"type": "path",
"path": ".",
},
},
},
"deny_read": []any{},
"read": []any{},
"write": []any{},
},
"network": map[string]any{"enabled": false},
},
}
normalizeNoopAdditionalPermissions(args, root, engine)
if _, exists := args["additional_permissions"]; exists {
t.Fatalf("already-covered additional_permissions was retained: %#v", args)
}
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsUseDefault) {
t.Fatalf("sandbox mode changed to %q", got)
}
}
+8
View File
@@ -413,6 +413,14 @@ func (state *compactionState) maybeCompact(
// the bodies of old, large tool results (the model has already acted on
// them). If that brings us back under threshold, skip the paid summarizer
// entirely and preserve recent turns verbatim.
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
messages = pruned
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
if size <= state.threshold {
state.lowWaterMark = size
return messages
}
}
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
messages = pruned
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
+37
View File
@@ -379,6 +379,43 @@ func TestRunNoCompactionWhenContextWindowZero(t *testing.T) {
}
}
func TestMaybeCompactStopsAfterSupersededReadsReachThreshold(t *testing.T) {
body := strings.Repeat("same source line\n", 1000)
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
toolCallWithArgs("old", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "old", Content: body},
toolCallWithArgs("new", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "new", Content: body},
}
pruned, reclaimed := pruneSupersededReadResults(messages)
if reclaimed <= 0 {
t.Fatal("test setup did not reclaim superseded read output")
}
prunedSize := estimateTokens(pruned)
if estimateTokens(messages) <= prunedSize {
t.Fatal("test setup does not cross the compaction threshold")
}
provider := &summarizeRecordingProvider{}
state := &compactionState{
enabled: true,
threshold: prunedSize,
preserveLast: 2,
}
got := state.maybeCompact(context.Background(), provider, messages, nil)
if provider.summarizeCalls != 0 {
t.Fatalf("cheap pruning should avoid the summarizer, got %d calls", provider.summarizeCalls)
}
if state.lowWaterMark != prunedSize {
t.Fatalf("lowWaterMark = %d, want %d", state.lowWaterMark, prunedSize)
}
if !strings.Contains(got[2].Content, "superseded identical") || got[4].Content != body {
t.Fatalf("unexpected pruned messages: %#v", got)
}
}
// reactiveProvider builds up history with a tool call on turn 1, then errors
// with a context-limit message on turn 2 (once the history is large enough that
// compaction can shrink it), then succeeds on the same-turn retry. Summary
+5 -6
View File
@@ -341,15 +341,15 @@ func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
if search.Description != discovery || !strings.Contains(search.Description, "mcp") {
t.Fatalf("tool_search description must carry discovery source, got %q", search.Description)
}
if strings.Contains(search.Description, "mcp__srv__alpha") {
t.Fatalf("tool_search description must not list already-loaded tool names, got %q", search.Description)
if !strings.Contains(search.Description, "mcp__srv__alpha") {
t.Fatalf("stable tool_search catalog must retain already-loaded tool names, got %q", search.Description)
}
if !strings.Contains(search.Description, "mcp__srv__beta") {
t.Fatalf("tool_search description must list exact hidden tool names, got %q", search.Description)
}
}
func TestPartitionToolsActiveNothingHiddenEmptyReminder(t *testing.T) {
func TestPartitionToolsActiveNothingHiddenKeepsStableCatalog(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
@@ -368,9 +368,8 @@ func TestPartitionToolsActiveNothingHiddenEmptyReminder(t *testing.T) {
if !exposedNames["tool_search"] {
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
}
// No hidden tools means no dynamic discovery text is needed.
if reminder != "" {
t.Fatalf("expected empty discovery text when nothing is hidden, got %q", reminder)
if !strings.Contains(reminder, "mcp__srv__alpha") || !strings.Contains(reminder, "mcp__srv__beta") {
t.Fatalf("stable catalog must retain all deferred names after loading, got %q", reminder)
}
}
+184 -49
View File
@@ -656,6 +656,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// between tool_results breaks strict provider replay) — same after-batch
// rationale as turnRequestedModel above.
var changedFilesThisBatch []string
// Images produced by tools this turn, held until every tool_result is
// recorded. They travel as user messages, and a user message between two
// tool_results breaks strict provider replay — Anthropic coalesces them
// into one user block list and requires the tool_result blocks first, so
// interleaving yields [tool_result, text, image, tool_result] and a 400.
// Same reason the self-correction feedback below is deferred.
var toolImageMessages []zeroruntime.Message
// Parallel read-ahead state: results for calls[precomputedStart:precomputedEnd]
// executed concurrently, consumed strictly in order below.
var precomputed []precomputedToolResult
@@ -709,6 +716,16 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
Content: toolResult.Output,
ToolCallID: toolResult.ToolCallID,
})
// Images ride a following USER message rather than the tool result
// above. Every provider drops images on a tool-role message —
// Anthropic's tool_result content is a string, Gemini's is a
// functionResponse, and OpenAI guards its image parts to the user role
// — so attaching them there would silently deliver nothing. A separate
// message also keeps the one-tool-result-per-tool-call pairing intact,
// which the providers validate.
if imageMessage, ok := toolResultImageMessage(toolResult); ok {
toolImageMessages = append(toolImageMessages, imageMessage)
}
// A tool may demand the run ABORT — a canceled/timed-out ask_user prompt
// returns context.Canceled rather than fabricating a headless answer. Stop
@@ -719,11 +736,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
if abortErr != nil {
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
messages = append(messages, toolImageMessages...)
result.Messages = copyMessages(messages)
return result, abortErr
}
if stopReason := stopReasonFromToolResult(toolResult); stopReason != "" {
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
messages = append(messages, toolImageMessages...)
result.FinalAnswer = toolResult.Output
result.StopReason = stopReason
result.Messages = copyMessages(messages)
@@ -747,6 +766,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// messages stay valid for a strict provider replay (Anthropic
// rejects a tool_use with no answering tool_result).
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
messages = append(messages, toolImageMessages...)
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count)
result.Messages = copyMessages(messages)
return result, nil
@@ -765,6 +785,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
postEditDiagnostics.enqueue(ctx, toolResult.ChangedFiles)
}
}
// Every tool_result for this turn is now recorded, including aborted
// placeholders, so the images can follow without splitting them.
messages = append(messages, toolImageMessages...)
toolImageMessages = nil
// Run post-edit self-correction once over the union of files this turn
// changed, then append any feedback after every tool_result is recorded so
@@ -1076,6 +1100,9 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}, nil
}
}
if isShellCommandTool(call.Name) {
normalizeNoopAdditionalPermissions(args, options.Cwd, options.Sandbox)
}
// tool_search is the gateway to the allowlisted deferred tools, so a non-empty
// EnabledTools allowlist that omits it must NOT reject the call — otherwise the
// discovery tool exposed to the model rejects at dispatch time (an inescapable
@@ -1354,7 +1381,8 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
Cwd: options.Cwd,
// Per-session file version tracker so write_file/edit_file refuse to clobber
// a file that changed on disk outside Zero since it was last read.
FileTracker: options.FileTracker,
FileTracker: options.FileTracker,
DeferFileObservationCommit: true,
// Post-edit diagnostics are NOT forwarded to the tools: they used to run
// synchronously inside edit_file/write_file and block every mutating tool
// call on the language server (≥300ms debounce floor, 10s cap). The loop
@@ -1406,6 +1434,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
result = registry.RebudgetAfterHook(call.Name, args, result)
}
}
result = registry.CommitFileObservation(result, options.FileTracker)
// Stamp the sandbox risk classification for this EXECUTED call so run-policy
// observers can see the risk of an allowed mutation. Prefer the preflight
// decision's classification when the sandbox evaluated the call; otherwise
@@ -1431,6 +1460,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
Output: result.Output,
Truncated: result.Truncated,
Meta: result.Meta,
Images: result.Images,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
ChangeSummaries: result.ChangeSummaries,
@@ -1690,20 +1720,21 @@ func sandboxRestrictionRetryReason(result tools.Result) string {
func runToolForNetworkRetry(ctx context.Context, registry *tools.Registry, name string, toolCallID string, args map[string]any, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) tools.Result {
return registry.RunWithOptions(ctx, name, cloneArgs(args), tools.RunOptions{
PermissionGranted: true,
PermissionMode: string(permissionMode),
Autonomy: options.Autonomy,
Sandbox: options.Sandbox,
ToolCallID: toolCallID,
SessionID: options.SessionID,
Model: options.Model,
ReasoningEffort: options.ReasoningEffort,
Depth: options.Depth,
Cwd: options.Cwd,
FileTracker: options.FileTracker,
EnabledTools: options.EnabledTools,
DisabledTools: options.DisabledTools,
Progress: progressCallback,
PermissionGranted: true,
PermissionMode: string(permissionMode),
Autonomy: options.Autonomy,
Sandbox: options.Sandbox,
ToolCallID: toolCallID,
SessionID: options.SessionID,
Model: options.Model,
ReasoningEffort: options.ReasoningEffort,
Depth: options.Depth,
Cwd: options.Cwd,
FileTracker: options.FileTracker,
DeferFileObservationCommit: true,
EnabledTools: options.EnabledTools,
DisabledTools: options.DisabledTools,
Progress: progressCallback,
})
}
@@ -1718,20 +1749,21 @@ func unsandboxedRetryArgs(args map[string]any) map[string]any {
func runToolForUnsandboxedRetry(ctx context.Context, registry *tools.Registry, name string, toolCallID string, args map[string]any, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) tools.Result {
return registry.RunWithOptions(ctx, name, args, tools.RunOptions{
PermissionGranted: true,
PermissionMode: string(permissionMode),
Autonomy: options.Autonomy,
Sandbox: options.Sandbox,
ToolCallID: toolCallID,
SessionID: options.SessionID,
Model: options.Model,
ReasoningEffort: options.ReasoningEffort,
Depth: options.Depth,
Cwd: options.Cwd,
FileTracker: options.FileTracker,
EnabledTools: options.EnabledTools,
DisabledTools: options.DisabledTools,
Progress: progressCallback,
PermissionGranted: true,
PermissionMode: string(permissionMode),
Autonomy: options.Autonomy,
Sandbox: options.Sandbox,
ToolCallID: toolCallID,
SessionID: options.SessionID,
Model: options.Model,
ReasoningEffort: options.ReasoningEffort,
Depth: options.Depth,
Cwd: options.Cwd,
FileTracker: options.FileTracker,
DeferFileObservationCommit: true,
EnabledTools: options.EnabledTools,
DisabledTools: options.DisabledTools,
Progress: progressCallback,
})
}
@@ -2748,21 +2780,89 @@ func shellCommandAdditionalPermissionsRequested(args map[string]any) bool {
return strings.TrimSpace(value) == string(tools.SandboxPermissionsWithAdditionalPermissions)
}
// normalizeNoopAdditionalPermissions treats a provider-expanded empty or
// already-covered additional_permissions object as if it had been omitted.
// Some strict-schema providers materialize every optional property and may add
// a workspace read that the default sandbox already grants; rejecting either
// no-op makes the model retry an otherwise valid command. Requests that broaden
// access and malformed payloads are left untouched so validation stays closed.
func normalizeNoopAdditionalPermissions(args map[string]any, basePath string, engine *sandbox.Engine) {
raw, exists := args["additional_permissions"]
if !exists {
return
}
// An explicit null is the field omitted, not a request: drop the key so the
// rest of the pipeline sees the same args a model that omitted it would send.
// sandbox_permissions is deliberately left alone — a flagged null must reach
// the same "no additional_permissions object was provided" error as a flagged
// absent field rather than being silently downgraded to a different outcome.
if raw == nil {
delete(args, "additional_permissions")
return
}
data, err := json.Marshal(raw)
if err != nil {
return
}
var profile sandbox.RequestPermissionProfile
if err := json.Unmarshal(data, &profile); err != nil {
return
}
normalized, err := sandbox.NormalizeRequestPermissionProfile(profile, basePath)
if err != nil {
return
}
noop := normalized.Empty()
if !noop && engine != nil {
if grantProfile, grantErr := sandbox.RequestPermissionGrantProfile(normalized); grantErr == nil {
noop = engine.CoversRequestPermissions(grantProfile)
}
}
if !noop {
return
}
delete(args, "additional_permissions")
if rawMode, ok := args["sandbox_permissions"].(string); ok &&
strings.TrimSpace(rawMode) == string(tools.SandboxPermissionsWithAdditionalPermissions) {
args["sandbox_permissions"] = string(tools.SandboxPermissionsUseDefault)
}
}
// omitAdditionalPermissionsHint is the one instruction that ends the retry loop
// in #845/#847. Every additional_permissions rejection must carry it: the model
// reaches these errors by attaching a permission object to a command that needs
// none, so "omit it and retry" is the answer in practice, and an error that
// only explains how to build a VALID permission object sends the model round
// the loop again with a different invalid shape.
const omitAdditionalPermissionsHint = "If this command does not need extra sandbox access — ordinary " +
"read-only commands such as `git branch --show-current` do not — omit both sandbox_permissions and " +
"additional_permissions entirely and retry; that is the fix in almost every case."
func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (sandbox.RequestPermissionProfile, bool, error) {
if !shellCommandAdditionalPermissionsRequested(args) {
if _, exists := args["additional_permissions"]; exists {
return sandbox.RequestPermissionProfile{}, false, fmt.Errorf("additional_permissions requires sandbox_permissions set to %q", tools.SandboxPermissionsWithAdditionalPermissions)
// An explicit null grants nothing and carries no flag, so it is
// indistinguishable from an omitted field; rejecting it would be a pure
// false positive against providers that materialize every optional
// property.
if raw, exists := args["additional_permissions"]; exists && raw != nil {
return sandbox.RequestPermissionProfile{}, false, fmt.Errorf(
"additional_permissions was provided without sandbox_permissions set to %q, so it cannot be applied. %s "+
"Only if the command genuinely needs file or network access the sandbox denies, resend with "+
`sandbox_permissions: %q AND a non-empty additional_permissions, for example {"network": {"enabled": true}}`,
tools.SandboxPermissionsWithAdditionalPermissions,
omitAdditionalPermissionsHint,
tools.SandboxPermissionsWithAdditionalPermissions)
}
return sandbox.RequestPermissionProfile{}, false, nil
}
raw, ok := args["additional_permissions"]
if !ok || raw == nil {
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
"sandbox_permissions was set to %q but no additional_permissions object was provided. "+
`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)
"sandbox_permissions was set to %q but no additional_permissions object was provided. %s "+
`Otherwise include one, for example additional_permissions: {"network": {"enabled": true}} or `+
`{"file_system": {"write": ["/path"]}}`,
tools.SandboxPermissionsWithAdditionalPermissions,
omitAdditionalPermissionsHint)
}
data, err := json.Marshal(raw)
if err != nil {
@@ -2778,8 +2878,10 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
}
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"]}}`)
"additional_permissions granted nothing, so it cannot be applied. %s "+
`Otherwise include a real permission, for example {"network": {"enabled": true}} or `+
`{"file_system": {"write": ["/path"]}}`,
omitAdditionalPermissionsHint)
}
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
if err != nil {
@@ -2938,22 +3040,20 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
// 2. tool_search — always present, right after the stable block;
// 3. loaded deferred tools, alpha-sorted — APPENDED, so an unloaded->loaded
// transition grows the tail instead of inserting into the eager block.
// (tool_search's 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.)
// tool_search's compact catalog also stays byte-stable as tools load, so only
// the appended full-schema tail changes.
eager := make([]zeroruntime.ToolDefinition, 0, len(visible))
loadedTail := make([]zeroruntime.ToolDefinition, 0)
var hiddenTools []tools.Tool
var deferredCatalog []tools.Tool
for _, tool := range visible {
name := tool.Name()
if name == tools.ToolSearchToolName {
continue // added explicitly between the eager block and the loaded tail
}
if tools.IsDeferred(tool) {
deferredCatalog = append(deferredCatalog, tool)
if loaded[name] {
loadedTail = append(loadedTail, cachedRuntimeToolDefinition(defCache, tool))
} else {
hiddenTools = append(hiddenTools, tool)
}
continue
}
@@ -2966,15 +3066,16 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
return loadedTail[left].Name < loadedTail[right].Name
})
discovery := ""
if len(hiddenTools) > 0 {
discovery = tools.BuildToolSearchDescription(hiddenTools)
}
// Keep the catalog byte-stable as tools load. tool_search already redirects
// requests for an exposed tool back to the current tool list, so retaining
// loaded names costs only compact catalog lines and avoids invalidating the
// provider cache at the loader definition on every load.
discovery := tools.BuildToolSearchDescription(deferredCatalog)
// 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.
// eager block and carries the stable deferred-tool catalog.
description := loader.Description()
if discovery != "" {
description = discovery
@@ -3213,3 +3314,37 @@ func copyMessages(messages []Message) []Message {
}
return copied
}
// toolResultImageMessage builds the user message that carries a tool's images
// to the model, or reports false when the tool produced none.
//
// The text names the tool so the model can tell which call an image came from
// when several ran in one turn — the images arrive detached from their tool
// result, so nothing else associates them.
func toolResultImageMessage(result ToolResult) (zeroruntime.Message, bool) {
images := make([]zeroruntime.ImageBlock, 0, len(result.Images))
for _, image := range result.Images {
if len(image.Data) == 0 {
continue
}
mediaType := zeroruntime.NormalizeImageMediaType(image.MediaType)
if mediaType == "" {
// Outside the provider allow-list. Dropping it beats sending bytes a
// provider will reject and failing the whole turn.
continue
}
images = append(images, zeroruntime.ImageBlock{MediaType: mediaType, Data: image.Data})
}
if len(images) == 0 {
return zeroruntime.Message{}, false
}
label := result.Name
if label == "" {
label = "tool"
}
return zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: "Image output from " + label + ":",
Images: images,
}, true
}
+16 -3
View File
@@ -332,7 +332,8 @@ type sandboxDeniedExecCommandRetryTool struct {
func (tool *sandboxDeniedExecCommandRetryTool) Name() string { return "exec_command" }
type sandboxNetworkDeniedRetryTool struct {
calls []map[string]any
calls []map[string]any
deferFileObservationCommit []bool
}
func (tool *sandboxNetworkDeniedRetryTool) Name() string { return "bash" }
@@ -355,6 +356,7 @@ func (tool *sandboxNetworkDeniedRetryTool) Run(ctx context.Context, args map[str
}
func (tool *sandboxNetworkDeniedRetryTool) RunWithOptions(ctx context.Context, args map[string]any, options tools.RunOptions) tools.Result {
tool.calls = append(tool.calls, cloneArgs(args))
tool.deferFileObservationCommit = append(tool.deferFileObservationCommit, options.DeferFileObservationCommit)
if shellNetworkAllowed(ctx, options.Sandbox, args) {
return tools.Result{Status: tools.StatusOK, Output: "server started"}
}
@@ -397,7 +399,8 @@ func agentNativeBackendStub() sandbox.Backend {
}
type sandboxNamespaceLimitedRetryTool struct {
calls []map[string]any
calls []map[string]any
deferFileObservationCommit []bool
}
func (tool *sandboxNamespaceLimitedRetryTool) Name() string { return "bash" }
@@ -416,8 +419,12 @@ func (tool *sandboxNamespaceLimitedRetryTool) Parameters() tools.Schema {
func (tool *sandboxNamespaceLimitedRetryTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs shell commands"}
}
func (tool *sandboxNamespaceLimitedRetryTool) Run(_ context.Context, args map[string]any) tools.Result {
func (tool *sandboxNamespaceLimitedRetryTool) Run(ctx context.Context, args map[string]any) tools.Result {
return tool.RunWithOptions(ctx, args, tools.RunOptions{})
}
func (tool *sandboxNamespaceLimitedRetryTool) RunWithOptions(_ context.Context, args map[string]any, options tools.RunOptions) tools.Result {
tool.calls = append(tool.calls, cloneArgs(args))
tool.deferFileObservationCommit = append(tool.deferFileObservationCommit, options.DeferFileObservationCommit)
if args["sandbox_permissions"] == string(tools.SandboxPermissionsRequireEscalated) {
return tools.Result{Status: tools.StatusOK, Output: "stdout:\nUSER PID COMMAND\nanaxy 42 firefox\nanaxy 43 Discord"}
}
@@ -483,6 +490,9 @@ func TestRunRetriesShellUnsandboxedAfterSandboxNamespaceLimitedOutput(t *testing
if retryTool.calls[1]["sandbox_permissions"] != string(tools.SandboxPermissionsRequireEscalated) {
t.Fatalf("retry args = %#v, want require_escalated", retryTool.calls[1])
}
if len(retryTool.deferFileObservationCommit) != 2 || !retryTool.deferFileObservationCommit[1] {
t.Fatalf("retry defer flags = %#v, want retry observation deferred", retryTool.deferFileObservationCommit)
}
if len(requests) != 1 {
t.Fatalf("permission requests = %#v, want one unsandboxed retry approval", requests)
}
@@ -634,6 +644,9 @@ func TestRunRetriesNetworkDeniedShellWithNetworkGrant(t *testing.T) {
if _, escalated := retryTool.calls[1]["sandbox_permissions"]; escalated {
t.Fatalf("network retry must stay sandboxed, got retry args %#v", retryTool.calls[1])
}
if len(retryTool.deferFileObservationCommit) != 2 || !retryTool.deferFileObservationCommit[1] {
t.Fatalf("retry defer flags = %#v, want retry observation deferred", retryTool.deferFileObservationCommit)
}
if len(requests) != 1 || requests[0].Reason != sandbox.ReasonNetworkBlocked {
t.Fatalf("permission requests = %#v, want one network approval", requests)
}
@@ -3,6 +3,7 @@ package agent
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
@@ -19,6 +20,16 @@ type propagationOutputTool struct {
func TestOutputBudgetHookHelperProcess(t *testing.T) {
for index, arg := range os.Args {
if arg == "--zero-output-budget-hook-repeat" && index+1 < len(os.Args) {
repeats, err := strconv.Atoi(os.Args[index+1])
if err != nil {
os.Exit(2)
}
if _, err := os.Stdout.WriteString(strings.Repeat("hook feedback ", repeats)); err != nil {
os.Exit(2)
}
os.Exit(0)
}
if arg != "--zero-output-budget-hook" || index+1 >= len(os.Args) {
continue
}
@@ -30,25 +41,102 @@ func TestOutputBudgetHookHelperProcess(t *testing.T) {
}
func largeOutputBudgetHookDispatcher() *hooks.Dispatcher {
feedback := strings.Repeat("hook feedback ", 200)
return outputBudgetHookDispatcherFor("propagation_output", 200)
}
func outputBudgetHookDispatcherFor(toolName string, repeats int) *hooks.Dispatcher {
feedback := strings.Repeat("hook feedback ", repeats)
hookArgs := []string{
"-test.run=TestOutputBudgetHookHelperProcess",
"--",
"--zero-output-budget-hook",
feedback,
}
if repeats > 1_000 {
hookArgs = []string{
"-test.run=TestOutputBudgetHookHelperProcess",
"--",
"--zero-output-budget-hook-repeat",
strconv.Itoa(repeats),
}
}
return hooks.NewDispatcher(hooks.DispatcherOptions{Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{{
ID: "large-feedback",
Event: hooks.EventAfterTool,
Matcher: "propagation_output",
Matcher: toolName,
Command: os.Args[0],
Args: []string{
"-test.run=TestOutputBudgetHookHelperProcess",
"--",
"--zero-output-budget-hook",
feedback,
},
Args: hookArgs,
Enabled: true,
}},
}})
}
func TestExecuteToolCallCommitsReadObservationAfterFinalBoundary(t *testing.T) {
root := t.TempDir()
targetPath := filepath.Join(root, "target.txt")
if err := os.WriteFile(targetPath, []byte("known content\n"), 0o600); err != nil {
t.Fatal(err)
}
linkPath := filepath.Join(root, "small.txt")
if err := os.Symlink(targetPath, linkPath); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
resolvedPath, err := filepath.EvalSymlinks(linkPath)
if err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
tracker := tools.NewFileTracker()
result, abortErr := executeToolCall(context.Background(), registry, ToolCall{
ID: "read-small", Name: "read_file", Arguments: `{"path":"small.txt"}`,
}, PermissionModeAuto, Options{Cwd: root, FileTracker: tracker})
if abortErr != nil || result.Status != tools.StatusOK {
t.Fatalf("executeToolCall: result=%#v err=%v", result, abortErr)
}
if !tracker.SeenWhole(resolvedPath) {
t.Fatal("unmodified final read output was not committed as observed")
}
}
func TestExecuteToolCallDoesNotCommitReadObservationBeforeHookBudget(t *testing.T) {
t.Setenv("ZERO_TOOL_OUTPUT_CEILING_TOKENS", "80")
root := t.TempDir()
targetPath := filepath.Join(root, "target.txt")
if err := os.WriteFile(targetPath, []byte("known content\n"), 0o600); err != nil {
t.Fatal(err)
}
linkPath := filepath.Join(root, "small.txt")
if err := os.Symlink(targetPath, linkPath); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
resolvedPath, err := filepath.EvalSymlinks(linkPath)
if err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
tracker := tools.NewFileTracker()
result, abortErr := executeToolCall(context.Background(), registry, ToolCall{
ID: "read-hook", Name: "read_file", Arguments: `{"path":"small.txt"}`,
}, PermissionModeAuto, Options{
Cwd: root, FileTracker: tracker, Hooks: outputBudgetHookDispatcherFor("read_file", 20_000),
})
if abortErr != nil {
t.Fatalf("executeToolCall: %v", abortErr)
}
if !result.Truncated {
t.Fatalf("precondition: hook feedback was not truncated: %#v", result)
}
if tracker.SeenWhole(resolvedPath) {
t.Fatal("read observation committed before hook output was budgeted")
}
}
func (tool propagationOutputTool) Name() string { return "propagation_output" }
func (tool propagationOutputTool) Description() string { return "returns output for propagation tests" }
func (tool propagationOutputTool) Parameters() tools.Schema {
+64
View File
@@ -121,3 +121,67 @@ func toolNameForResult(messages []zeroruntime.Message, index int) string {
func isPrunedPlaceholder(content string) bool {
return strings.HasPrefix(strings.TrimSpace(content), "[pruned ")
}
var supersedableReadTools = map[string]bool{
"read_file": true,
"read_minified_file": true,
"grep": true,
"glob": true,
"list_directory": true,
}
// pruneSupersededReadResults removes only older byte-identical results of the
// same read call. It runs when context pressure already requires a rewrite, so
// it does not trade a warm provider-cache prefix for a small apparent saving.
func pruneSupersededReadResults(messages []zeroruntime.Message) ([]zeroruntime.Message, int) {
type seenResultKey struct {
call string
content string
}
seen := map[seenResultKey]struct{}{}
out := messages
reclaimed := 0
copied := false
for index := len(messages) - 1; index >= 0; index-- {
message := messages[index]
if message.Role != zeroruntime.MessageRoleTool || isPrunedPlaceholder(message.Content) {
continue
}
call, ok := toolCallForResult(messages, index)
if !ok || !supersedableReadTools[call.Name] {
continue
}
key := seenResultKey{
call: call.Name + "\x00" + strings.TrimSpace(call.Arguments),
content: message.Content,
}
if _, exists := seen[key]; exists {
placeholder := fmt.Sprintf("[superseded identical %s result — the newer result remains in context]", call.Name)
if !copied {
out = append([]zeroruntime.Message(nil), messages...)
copied = true
}
out[index].Content = placeholder
reclaimed += max(0, ApproxTextTokens(message.Content)-ApproxTextTokens(placeholder))
continue
}
seen[key] = struct{}{}
}
return out, reclaimed
}
func toolCallForResult(messages []zeroruntime.Message, resultIndex int) (zeroruntime.ToolCall, bool) {
id := messages[resultIndex].ToolCallID
if id == "" {
return zeroruntime.ToolCall{}, false
}
for index := resultIndex - 1; index >= 0; index-- {
for _, call := range messages[index].ToolCalls {
if call.ID == id {
return call, true
}
}
}
return zeroruntime.ToolCall{}, false
}
+73
View File
@@ -25,6 +25,13 @@ func toolCallMsg(id, name string) zeroruntime.Message {
}
}
func toolCallWithArgs(id, name, args string) zeroruntime.Message {
return zeroruntime.Message{
Role: zeroruntime.MessageRoleAssistant,
ToolCalls: []zeroruntime.ToolCall{{ID: id, Name: name, Arguments: args}},
}
}
func TestPruneSkipsSmallSessions(t *testing.T) {
// Total reclaimable is under the gate → no pruning.
msgs := []zeroruntime.Message{
@@ -120,3 +127,69 @@ func TestPruneDoesNotMutateInput(t *testing.T) {
t.Fatal("pruneStaleToolOutput must not mutate the caller's slice")
}
}
func TestPruneSupersededReadResultsOnlyDropsIdenticalOlderReads(t *testing.T) {
body := strings.Repeat("same source line\n", 1000)
messages := []zeroruntime.Message{
toolCallWithArgs("old", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "old", Content: body},
toolCallWithArgs("different", "read_file", `{"path":"b.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "different", Content: body},
toolCallWithArgs("new", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "new", Content: body},
}
out, reclaimed := pruneSupersededReadResults(messages)
if reclaimed <= 0 || !strings.Contains(out[1].Content, "superseded identical") {
t.Fatalf("older duplicate was not pruned: reclaimed=%d output=%q", reclaimed, out[1].Content)
}
if out[3].Content != body || out[5].Content != body {
t.Fatal("different call and newest result must remain exact")
}
if messages[1].Content != body {
t.Fatal("input slice was mutated")
}
}
func TestPruneSupersededReadResultsKeepsChangedAndMutatingResults(t *testing.T) {
messages := []zeroruntime.Message{
toolCallWithArgs("r1", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "r1", Content: "version one"},
toolCallWithArgs("r2", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "r2", Content: "version two"},
toolCallWithArgs("e1", "edit_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "edited"},
toolCallWithArgs("e2", "edit_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "edited"},
}
out, reclaimed := pruneSupersededReadResults(messages)
if reclaimed != 0 {
t.Fatalf("unsafe results were pruned: %d", reclaimed)
}
for index := range messages {
if out[index].Content != messages[index].Content {
t.Fatalf("message %d changed unexpectedly", index)
}
}
}
func TestPruneSupersededReadResultsFindsMatchingBodyAcrossChangedRead(t *testing.T) {
bodyA := strings.Repeat("version a\n", 100)
bodyB := strings.Repeat("version b\n", 100)
messages := []zeroruntime.Message{
toolCallWithArgs("old-a", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "old-a", Content: bodyA},
toolCallWithArgs("middle-b", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "middle-b", Content: bodyB},
toolCallWithArgs("new-a", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "new-a", Content: bodyA},
}
out, reclaimed := pruneSupersededReadResults(messages)
if reclaimed <= 0 || !strings.Contains(out[1].Content, "superseded identical") {
t.Fatalf("older matching body was not pruned across a changed result: reclaimed=%d output=%q", reclaimed, out[1].Content)
}
if out[3].Content != bodyB || out[5].Content != bodyA {
t.Fatal("changed middle result and newest matching result must remain exact")
}
}
@@ -26,6 +26,16 @@ func TestSpecialistDelegationContext(t *testing.T) {
if strings.Contains(got, "nameless") {
t.Fatalf("nameless entry should be skipped: %q", got)
}
for _, want := range []string{"reduce total context", "Do not delegate small direct work", "bounded assignment"} {
if !strings.Contains(got, want) {
t.Fatalf("missing evidence-driven delegation guidance %q: %q", want, got)
}
}
for _, unwanted := range []string{"delegate to it proactively", "launch several specialists in parallel"} {
if strings.Contains(got, unwanted) {
t.Fatalf("retained broad delegation guidance %q: %q", unwanted, got)
}
}
}
func TestSystemPromptIncludesDelegationOnlyWithSpecialists(t *testing.T) {
+7 -9
View File
@@ -179,21 +179,19 @@ func approvedCommandPrefixContext(options Options) string {
return "## Approved Command Prefixes\n\nThe following command prefixes have already been approved and do not need another permission prompt:\n" + strings.Join(lines, "\n")
}
// specialistDelegationContext nudges the orchestrator to offload read-heavy or
// parallelizable work to a specialist sub-agent via the Task tool, keeping large
// tool outputs out of the main context. It renders only when specialists are
// known (which is only where the Task tool is actually registered), so a run with
// no delegatable specialists produces the previous prompt unchanged.
// specialistDelegationContext describes when a specialist earns its extra
// context and coordination cost. It renders only when specialists are known
// (which is only where the Task tool is actually registered), so a run with no
// delegatable specialists produces the previous prompt unchanged.
func specialistDelegationContext(options Options) string {
if len(options.Specialists) == 0 {
return ""
}
var b strings.Builder
b.WriteString("<specialists>\n")
b.WriteString("Delegate focused or read-heavy work to a specialist sub-agent with the Task tool instead of doing it inline. ")
b.WriteString("When a request matches a specialist's purpose, delegate to it proactively — you do not need the user to ask first. ")
b.WriteString("This keeps large tool outputs — searches, file dumps, multi-step exploration — out of your own context, so you stay fast and token-efficient. ")
b.WriteString("Prefer delegating codebase search and exploration; for independent subtasks, launch several specialists in parallel. Handle small, direct edits yourself.\n")
b.WriteString("Use the Task tool when a specialist's focused context, expertise, or an independent parallel subtask is likely to improve quality or reduce total context. ")
b.WriteString("Do not delegate small direct work, duplicate exploration, or split sequential steps merely because a specialist is available. ")
b.WriteString("When delegating, give a bounded assignment and consume its concise evidence instead of importing a long transcript.\n")
b.WriteString("Available specialists (call Task with the matching name when the task fits its purpose):\n")
for _, info := range options.Specialists {
name := strings.TrimSpace(info.Name)
+16 -14
View File
@@ -30,16 +30,13 @@ work.
read-before-edit discipline: inspect the target file and nearby callers,
tests, or config before you modify behavior. Never edit a file you have not
read.
2. **Plan.** For multi-step work, call update_plan with an ordered checklist and
keep it live. The plan bar is the user's progress signal — call update_plan
after EACH concrete unit of work (every file written, every command run), not
just at coarse milestones: mark the finished item completed and the next one
in_progress before you start it. A plan stuck at 0/N while files are landing is
a bug, not economy — these calls are cheap, expected, and the update_plan cards
are hidden from the transcript, so frequent updates cost the user nothing and
never clutter the conversation. Keep at most one item in_progress, and never
batch the updates to the end of the turn. Skip the plan for trivial one-step
tasks.
2. **Plan.** Use update_plan for implementation or investigation that has at
least three meaningful dependent steps, then keep it current at milestones.
Mark completed work and the next in-progress step together when practical;
do not spend separate turns updating it after every file or command. Keep at
most one item in_progress. Skip plans for simple lookups, explanations, code
navigation, and other short tasks. Never create a plan after the work is
already complete merely to describe what you did.
3. **Implement.** Make focused changes that match the surrounding code's style,
naming, and conventions. Prefer the smallest change that fully solves the
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
@@ -54,11 +51,16 @@ work.
## Editing discipline
- Choose the narrowest tool that safely accomplishes the step. Prefer native
file tools - read_file, list_directory, glob, grep, write_file, edit_file,
apply_patch - over shelling out to cat/sed/awk/python for file operations.
file tools - read_file, read_minified_file, list_directory, glob, grep,
write_file, edit_file, apply_patch - over shelling out to
cat/sed/awk/python for file operations.
They are safer, reviewable, and produce clean diffs.
- Make one tool call per file. Do not batch multi-file writes into a single
shell or script invocation.
- Prefer read_minified_file when initially exploring source code; it preserves
code while removing comments and redundant whitespace. Use read_file when
exact text, comments, or line numbers are needed.
- Keep edits focused and reviewable. A single patch may update several related
files when they form one coherent change; do not hide unrelated edits in a
bulk shell or script rewrite.
- 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
+2 -1
View File
@@ -71,5 +71,6 @@ const geminiPromptAddendum = `<model_guidance>
equivalent shell commands; they are safer and produce cleaner diffs.
- Be concise and concrete. When you run a shell command with side effects, state
in one short clause why it is needed.
- Use update_plan for any multi-step task and keep it current.
- Use update_plan only for work with at least three meaningful dependent steps;
skip it for simple lookups and never create one after the work is complete.
</model_guidance>`
+207
View File
@@ -0,0 +1,207 @@
package agent
import (
"context"
"fmt"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// imageTool returns a tool result carrying an image, the way a screenshot tool
// should be able to.
type imageTool struct {
media string
data []byte
}
func (imageTool) Name() string { return "capture" }
func (imageTool) Description() string { return "Captures an image." }
func (imageTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
}
func (imageTool) Safety() tools.Safety {
return tools.Safety{Permission: tools.PermissionAllow}
}
func (t imageTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{
Status: tools.StatusOK,
Output: "Captured a screenshot.",
Images: []zeroruntime.ImageBlock{{MediaType: t.media, Data: t.data}},
}
}
// A tool that produces an image must get that image in front of the model.
//
// It cannot ride the tool-result message: every provider drops images there.
// Anthropic maps a tool result to a tool_result block with string content,
// Gemini to a functionResponse, and OpenAI guards its image parts to the user
// role explicitly. So the image has to arrive as a following user message,
// which is also the only shape that keeps one tool result per tool call.
func TestRunDeliversToolResultImagesToTheModel(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(imageTool{media: "image/png", data: []byte("\x89PNG\r\n\x1a\nfake")})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "capture"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "I can see it."},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "screenshot please", provider, Options{
Registry: registry,
MaxTurns: 2,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
var carrier *zeroruntime.Message
for index := range result.Messages {
if len(result.Messages[index].Images) > 0 {
carrier = &result.Messages[index]
break
}
}
if carrier == nil {
t.Fatal("no message carried the tool's image; the model never saw it")
}
if carrier.Role != zeroruntime.MessageRoleUser {
t.Errorf("image rode a %q message; every provider only serializes images on the user role", carrier.Role)
}
if got := carrier.Images[0].MediaType; got != "image/png" {
t.Errorf("media type = %q, want image/png", got)
}
// The tool-result pairing must be untouched: one tool result per tool call.
toolResults := 0
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
toolResults++
}
}
if toolResults != 1 {
t.Errorf("got %d tool-result messages for 1 tool call", toolResults)
}
}
// textTool is a second, image-free tool so a turn can carry two calls.
type textTool struct{}
func (textTool) Name() string { return "note" }
func (textTool) Description() string { return "Notes something." }
func (textTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
}
func (textTool) Safety() tools.Safety { return tools.Safety{Permission: tools.PermissionAllow} }
func (textTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "noted"}
}
// Tool results for one assistant turn must stay contiguous. The image messages
// are user-role, and a user message between two tool_results breaks strict
// provider replay: Anthropic coalesces consecutive user content into one block
// list and requires tool_result blocks first, so interleaving produces
// [tool_result, text, image, tool_result] and the request is rejected.
//
// The first version of this feature appended each image immediately after its
// own tool result, which is exactly that shape. A single-tool-call test passed
// and said nothing about it.
func TestRunKeepsToolResultsContiguousWhenAToolReturnsAnImage(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(imageTool{media: "image/png", data: []byte("\x89PNG\r\n\x1a\nfake")})
registry.Register(textTool{})
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "capture"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c2", ToolName: "note"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c2", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c2"},
{Type: zeroruntime.StreamEventDone},
},
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
}}
result, err := Run(context.Background(), "two calls", provider, Options{Registry: registry, MaxTurns: 2})
if err != nil {
t.Fatalf("Run: %v", err)
}
// Two separate ifs rather than a switch: a tool-role message that also
// carried images would match only the first arm of a switch, so a regression
// that put the image back on the tool result would read as "no image at all"
// instead of naming what actually happened.
type toolResult struct {
index int
id string
}
var toolResults []toolResult
imageIndex := -1
for index, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
toolResults = append(toolResults, toolResult{index: index, id: message.ToolCallID})
}
if len(message.Images) > 0 {
if imageIndex >= 0 {
t.Fatalf("image delivered twice, at %d and %d; recorded %s", imageIndex, index, messageShape(result.Messages))
}
imageIndex = index
}
}
if imageIndex < 0 {
t.Fatalf("the image never reached the model; recorded %s", messageShape(result.Messages))
}
if len(toolResults) != 2 {
t.Fatalf("got %d tool-result messages for 2 tool calls; recorded %s", len(toolResults), messageShape(result.Messages))
}
if toolResults[0].id != "c1" || toolResults[1].id != "c2" {
t.Errorf("tool results answered %q then %q, want c1 then c2", toolResults[0].id, toolResults[1].id)
}
// The contract Anthropic enforces: every tool_result block has to come before
// any other block in the user message it lands in. Anything wedged between
// the two results breaks that, which is the 400 this PR exists to avoid.
if toolResults[1].index != toolResults[0].index+1 {
t.Errorf("tool results at %d and %d are not adjacent; %q message at %d splits them; recorded %s",
toolResults[0].index, toolResults[1].index, result.Messages[toolResults[0].index+1].Role,
toolResults[0].index+1, messageShape(result.Messages))
}
// Follows the last tool result, deliberately NOT pinned to exactly last+1.
// appendUserBlocks coalesces consecutive user-role content, so
// [tool_result, tool_result, text, image] still maps to tool_result blocks
// first and replays fine; pinning would redden that valid shape for nothing.
if imageIndex <= toolResults[1].index {
t.Errorf("image message at %d does not follow the last tool result at %d; recorded %s",
imageIndex, toolResults[1].index, messageShape(result.Messages))
}
}
// messageShape renders the conversation as a compact role list so a failure says
// what the ordering actually was instead of dumping whole messages.
func messageShape(messages []zeroruntime.Message) string {
parts := make([]string, 0, len(messages))
for index, message := range messages {
part := fmt.Sprintf("%d:%s", index, message.Role)
if message.ToolCallID != "" {
part += "(" + message.ToolCallID + ")"
}
if len(message.Images) > 0 {
part += "+image"
}
parts = append(parts, part)
}
return "[" + strings.Join(parts, " ") + "]"
}
+5 -2
View File
@@ -68,8 +68,11 @@ type ToolResult struct {
Output string
// Truncated reports that the tool's model-visible output omitted content.
// The full result may be recoverable through Meta["spill_path"].
Truncated bool
Meta map[string]string
Truncated bool
Meta map[string]string
// Images the tool produced, delivered to the model as a following user
// message rather than on this result. See tools.Result.Images.
Images []zeroruntime.ImageBlock
Redacted bool
ChangedFiles []string
// ChangeSummaries are non-selectable generated-tree summaries emitted by
+3
View File
@@ -585,6 +585,9 @@ func fillAppDeps(deps appDeps) appDeps {
baseProbeProviderHealth := deps.probeProviderHealth
deps.probeProviderHealth = func(ctx context.Context, options providerhealth.Options) providerhealth.Result {
options.Profile = applyStoredProviderKeyAt(options.Profile, userConfigPath)
if options.OAuthResolver == nil {
options.OAuthResolver, _ = oauthLoginForProfile(options.Profile)
}
return baseProbeProviderHealth(ctx, options)
}
return deps
+5 -4
View File
@@ -40,10 +40,11 @@ func runContext(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
return writeAppError(stderr, err.Error(), exitCrash)
}
report, err := contextreport.Build(contextreport.Options{
WorkspaceRoot: workspaceRoot,
Provider: resolved.Provider,
Registry: newCoreRegistry(workspaceRoot),
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
WorkspaceRoot: workspaceRoot,
Provider: resolved.Provider,
Registry: newCoreRegistry(workspaceRoot),
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
DeferThreshold: resolved.Tools.DeferThreshold,
})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
+3 -4
View File
@@ -77,11 +77,10 @@ func TestRegisterToolSearchIfEligibleSkipsWhenThresholdZero(t *testing.T) {
}
}
func TestDeferredEligibleCountIgnoresCoreTools(t *testing.T) {
func TestDeferredEligibleCountIncludesOptionalBuiltins(t *testing.T) {
registry := newCoreRegistry(t.TempDir())
// newCoreRegistry holds only built-ins; none implement Deferred().
if got := deferredEligibleCount(registry, agent.PermissionModeAuto, nil, nil); got != 0 {
t.Fatalf("deferredEligibleCount(core) = %d, want 0", got)
if got := deferredEligibleCount(registry, agent.PermissionModeAuto, nil, nil); got < 2 {
t.Fatalf("deferredEligibleCount(core) = %d, want at least 2 optional built-ins", got)
}
}
+28
View File
@@ -308,6 +308,34 @@ func runMCPCheck(ctx context.Context, args []string, stdout io.Writer, stderr io
}
defer closeMCPRuntime(stderr, mcpRuntime)
// A connect failure does not come back as err. RegisterTools is deliberately
// best-effort so one unreachable server cannot stop Zero launching: it records
// the failure on the runtime and returns nil. That is right for startup and
// wrong to ignore here, because this is the command a user runs precisely when
// a server is misbehaving, and reporting "is reachable" for a server that never
// started sends them looking somewhere else.
for _, skipped := range mcpRuntime.Skipped() {
if skipped.Name != serverName {
continue
}
message := fmt.Sprintf("MCP server %s is not reachable", serverName)
if skipped.Err != nil {
message = fmt.Sprintf("%s: %s", message, redaction.ErrorMessage(skipped.Err, redaction.Options{}))
}
if options.json {
payload := struct {
ServerName string `json:"serverName"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}{ServerName: serverName, Status: "unreachable", Error: message}
if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil {
return exitCrash
}
return exitCrash
}
return writeAppError(stderr, message, exitCrash)
}
items := mcpToolList(registry)
if options.json {
payload := struct {
+79
View File
@@ -6,6 +6,7 @@ package cli
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
@@ -219,3 +220,81 @@ func TestRunMCPCheckNormalizeError(t *testing.T) {
t.Fatalf("want normalize error, got %q", got)
}
}
// TestRunMCPCheckReportsUnreachableServer covers the case the command exists
// for: a server that fails to start.
//
// RegisterTools is best-effort by design, so a connect failure is recorded on
// the runtime and registration returns nil. runMCPCheck used to inspect only
// that nil error and then print "is reachable", which told the user their
// broken server was fine. The command is spawned for real here rather than
// stubbed, because the whole failure mode was a mismatch between what
// registration returns and what it records.
func TestRunMCPCheckReportsUnreachableServer(t *testing.T) {
setTrustConfigRoot(t)
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"broken": {Type: "stdio", Command: "zero-definitely-not-a-real-binary-xyz"},
}}, nil
},
}
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "check", "broken"}, &out, &errBuf, deps); code == exitSuccess {
t.Fatalf("mcp check must fail for a server that did not start; stdout=%q stderr=%q", out.String(), errBuf.String())
}
if got := out.String(); strings.Contains(got, "is reachable") {
t.Fatalf("mcp check reported a failed server as reachable: %q", got)
}
if got := errBuf.String(); !strings.Contains(got, "not reachable") {
t.Fatalf("want an unreachable error naming the server, got %q", got)
}
}
// The --json contract for an unreachable server, which the text case above does
// not touch.
//
// The JSON branch is a separate return path with its own payload, so a caller
// parsing it would otherwise be relying on a shape nothing pins: reverting the
// check leaves it emitting status "ok" with a tool count, which is valid JSON
// and completely wrong.
func TestRunMCPCheckReportsUnreachableServerJSON(t *testing.T) {
setTrustConfigRoot(t)
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"broken": {Type: "stdio", Command: "zero-definitely-not-a-real-binary-xyz"},
}}, nil
},
}
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "check", "broken", "--json"}, &out, &errBuf, deps); code == exitSuccess {
t.Fatalf("mcp check --json must fail for a server that did not start; stdout=%q stderr=%q", out.String(), errBuf.String())
}
var payload struct {
ServerName string `json:"serverName"`
Status string `json:"status"`
Error string `json:"error"`
ToolCount int `json:"toolCount"`
}
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("decode mcp check --json output %q: %v", out.String(), err)
}
if payload.ServerName != "broken" {
t.Fatalf("serverName = %q, want the server that was checked", payload.ServerName)
}
if payload.Status != "unreachable" {
t.Fatalf("status = %q, want %q", payload.Status, "unreachable")
}
if !strings.Contains(payload.Error, "not reachable") {
t.Fatalf("error = %q, want it to say the server is not reachable", payload.Error)
}
// A reachable payload carries a tool count; an unreachable one must not
// imply zero tools were found, which reads as a server that simply exposes
// none.
if payload.ToolCount != 0 {
t.Fatalf("toolCount = %d, want it absent from an unreachable payload", payload.ToolCount)
}
}
+62
View File
@@ -5,6 +5,8 @@ import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@@ -12,6 +14,7 @@ import (
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/sessions"
)
@@ -109,6 +112,65 @@ func TestRunDoctorConnectivityProbesProvider(t *testing.T) {
}
}
func TestRunDoctorConnectivityUsesOAuthLogin(t *testing.T) {
tokenPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("ZERO_OAUTH_STORAGE", "file")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokenPath)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: tokenPath})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("xai"), oauth.Token{
AccessToken: "oauth-doctor-token",
ExpiresAt: time.Now().Add(time.Hour),
}); err != nil {
t.Fatalf("seed OAuth token: %v", err)
}
var gotAuth string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[]}`))
}))
defer server.Close()
profile := config.ProviderProfile{
Name: "xai",
CatalogID: "xai",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/v1",
Model: "grok-4",
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"doctor", "--connectivity"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return t.TempDir(), nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{
Provider: profile,
Providers: []config.ProviderProfile{profile},
ActiveProvider: profile.Name,
}, nil
},
probeProviderHealth: providerhealth.Probe,
now: fixedCLITime("2026-07-29T20:00:00Z"),
})
if exitCode != exitSuccess {
t.Fatalf("doctor exit = %d, want success; stderr=%q\n%s", exitCode, stderr.String(), stdout.String())
}
if gotAuth != "Bearer oauth-doctor-token" {
t.Fatalf("Authorization = %q, want OAuth bearer", gotAuth)
}
if !strings.Contains(stdout.String(), "[pass] provider.connectivity") {
t.Fatalf("doctor did not report passing connectivity:\n%s", stdout.String())
}
}
func TestRunDoctorConnectivityReportsProviderHealthFailure(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
+2 -2
View File
@@ -113,7 +113,7 @@ type PreferencesConfig struct {
// ZERO_THEME, so a /theme choice survives restart. Empty = unset (defaults auto).
Theme string `json:"theme,omitempty"`
// Recaps is a tri-state: nil (unset) defaults to ON; an explicit false means
// the user turned post-turn recaps off. A *bool is its own tri-state, so no
// the user turned idle recaps off. A *bool is its own tri-state, so no
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
Recaps *bool `json:"recaps,omitempty"`
}
@@ -130,7 +130,7 @@ type RecentModelEntry struct {
// MaxRecentModels caps the persisted/displayed recent-selection history.
const MaxRecentModels = 5
// RecapsEnabled reports whether post-turn recaps are on. Unset defaults to ON.
// RecapsEnabled reports whether idle recaps are on. Unset defaults to ON.
func (p PreferencesConfig) RecapsEnabled() bool {
return p.Recaps == nil || *p.Recaps
}
+1 -1
View File
@@ -546,7 +546,7 @@ func SetRecentModels(path string, entries []RecentModelEntry) (FileConfig, error
return cfg, nil
}
// SetRecapsEnabled persists the post-turn recap preference, mirroring
// SetRecapsEnabled persists the idle recap preference, mirroring
// SetFavoriteModels (read-modify-atomic-write).
func SetRecapsEnabled(path string, enabled bool) (FileConfig, error) {
path = strings.TrimSpace(path)
+40 -9
View File
@@ -40,6 +40,7 @@ type Options struct {
Provider config.ProviderProfile
Registry *tools.Registry
ContextWindow int
DeferThreshold int
ProjectContextFiles []string
}
@@ -56,6 +57,8 @@ type Report struct {
FreeTokens int `json:"freeTokens"`
UsedFraction float64 `json:"usedFraction"`
ToolCount int `json:"toolCount"`
AvailableToolCount int `json:"availableToolCount,omitempty"`
DeferredToolCount int `json:"deferredToolCount,omitempty"`
ProjectGuidelineFile string `json:"projectGuidelineFile,omitempty"`
Categories []Category `json:"categories"`
}
@@ -113,8 +116,10 @@ func Build(options Options) (Report, error) {
categories = append(categories, category(CategoryWorkspaceMap, "Workspace map", estimateTextTokens(workspaceMap), report.ContextWindow))
}
toolCount, toolTokens := estimateRegistryTools(options.Registry)
toolCount, availableTools, deferredTools, toolTokens := estimateRegistryTools(options.Registry, options.DeferThreshold)
report.ToolCount = toolCount
report.AvailableToolCount = availableTools
report.DeferredToolCount = deferredTools
if toolTokens > 0 {
categories = append(categories, category(CategoryTools, "Tools", toolTokens, report.ContextWindow))
}
@@ -175,7 +180,11 @@ func Format(report Report) string {
lines = append(lines, fmt.Sprintf("usage: %s tokens (context window unknown)", compactNumber(report.UsedTokens)))
}
if report.ToolCount > 0 {
lines = append(lines, fmt.Sprintf("tools: %d", report.ToolCount))
toolLine := fmt.Sprintf("tools: %d exposed", report.ToolCount)
if report.AvailableToolCount > report.ToolCount {
toolLine += fmt.Sprintf(", %d available, %d deferred", report.AvailableToolCount, report.DeferredToolCount)
}
lines = append(lines, toolLine)
}
if report.ProjectGuidelineFile != "" {
lines = append(lines, "project_guidelines: "+report.ProjectGuidelineFile)
@@ -212,29 +221,51 @@ func category(key string, name string, tokens int, contextWindow int) Category {
return cat
}
func estimateRegistryTools(registry *tools.Registry) (int, int) {
func estimateRegistryTools(registry *tools.Registry, deferThreshold int) (int, int, int, int) {
if registry == nil {
return 0, 0
return 0, 0, 0, 0
}
all := registry.All()
sort.Slice(all, func(left int, right int) bool {
return all[left].Name() < all[right].Name()
})
total := 0
count := 0
var visible []tools.Tool
var deferred []tools.Tool
for _, tool := range all {
if tool.Safety().Permission == tools.PermissionDeny {
continue
}
count++
visible = append(visible, tool)
if tools.IsDeferred(tool) {
deferred = append(deferred, tool)
}
}
active := deferThreshold > 0 && len(deferred) >= deferThreshold
exposed := visible
if active {
exposed = make([]tools.Tool, 0, len(visible)-len(deferred)+1)
for _, tool := range visible {
if !tools.IsDeferred(tool) && tool.Name() != tools.ToolSearchToolName {
exposed = append(exposed, tool)
}
}
exposed = append(exposed, tools.NewToolSearchTool(registry))
}
total := 0
for _, tool := range exposed {
total += estimateTextTokens(tool.Name())
total += estimateTextTokens(tool.Description())
description := tool.Description()
if active && tool.Name() == tools.ToolSearchToolName {
description = tools.BuildToolSearchDescription(deferred)
}
total += estimateTextTokens(description)
if encoded, err := json.Marshal(tool.Parameters()); err == nil {
total += estimateTextTokens(string(encoded))
}
total += toolDefinitionOverheadTokens
}
return count, total
return len(exposed), len(visible), len(deferred), total
}
func readProjectGuidelines(root string, names []string) (string, string) {
@@ -130,6 +130,35 @@ func TestBuildHasStableJSONContractAndCategoryMath(t *testing.T) {
}
}
func TestBuildReportsInitialDeferredToolSurface(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
for _, tool := range tools.CoreToolsScoped(root, nil) {
registry.Register(tool)
}
eager, err := Build(Options{WorkspaceRoot: root, Registry: registry})
if err != nil {
t.Fatal(err)
}
deferred, err := Build(Options{WorkspaceRoot: root, Registry: registry, DeferThreshold: 3})
if err != nil {
t.Fatal(err)
}
if deferred.DeferredToolCount < 3 {
t.Fatalf("DeferredToolCount = %d, want at least 3", deferred.DeferredToolCount)
}
if deferred.AvailableToolCount != eager.ToolCount {
t.Fatalf("available tools = %d, want eager total %d", deferred.AvailableToolCount, eager.ToolCount)
}
if deferred.ToolCount >= deferred.AvailableToolCount {
t.Fatalf("initial exposed tools = %d, available = %d; expected a smaller initial surface", deferred.ToolCount, deferred.AvailableToolCount)
}
if categoryByKey(deferred, CategoryTools).Tokens >= categoryByKey(eager, CategoryTools).Tokens {
t.Fatalf("deferred tool schemas did not reduce the initial context")
}
}
func TestBuildClampsFreeBudgetWhenOverContextWindow(t *testing.T) {
root := t.TempDir()
writeTestFile(t, root, "AGENTS.md", strings.Repeat("very large project rule\n", 1000))
+134 -2
View File
@@ -13,8 +13,10 @@ package minify
import (
"bytes"
"go/ast"
"go/parser"
"go/printer"
"go/scanner"
"go/token"
"path/filepath"
"strings"
@@ -34,8 +36,11 @@ func File(path string, content []byte) Result {
if out, ok := minifyGo(content); ok {
return Result{Content: out, Language: "go", Applied: true}
}
// Unparsable Go (a snippet, generics edge, or syntax error): fall through
// to the safe generic path rather than risk a partial AST reprint.
if out, ok := minifyGoFragment(content); ok {
return Result{Content: out, Language: "go-fragment", Applied: true}
}
// An incomplete fragment that cannot be wrapped safely falls through to
// whitespace-only normalization; no source text is guessed or discarded.
} else if style, ok := commentStyles[ext]; ok {
// Strip comments with a string-aware lexer, then collapse the whitespace the
// removed comments left behind. The stripper only handles languages whose
@@ -46,6 +51,133 @@ func File(path string, content []byte) Result {
return Result{Content: minifyGeneric(content), Language: "text", Applied: false}
}
// Fragment minifies a bounded source range without assuming lexical state from
// text outside the range. Go remains parser-validated; other languages use the
// conservative whitespace-only transform because a range may begin inside a
// multiline string or block comment.
func Fragment(path string, content []byte) Result {
if strings.EqualFold(filepath.Ext(path), ".go") {
if out, ok := minifyGo(content); ok {
return Result{Content: out, Language: "go", Applied: true}
}
if out, ok := minifyGoFragment(content); ok {
return Result{Content: out, Language: "go-fragment", Applied: true}
}
}
return Result{Content: minifyGeneric(content), Language: "text", Applied: false}
}
// ContextualFragment minifies a bounded fragment after checking its starting
// position against the complete source. A Go range that begins inside a string
// or comment must remain verbatim apart from whitespace normalization: parsing
// that fragment by itself would mistake literal text for Go syntax.
func ContextualFragment(path string, source, content []byte, startOffset int) Result {
if strings.EqualFold(filepath.Ext(path), ".go") && goFragmentStartsInsideToken(source, startOffset) {
return Result{Content: minifyGeneric(content), Language: "text", Applied: false}
}
return Fragment(path, content)
}
func goFragmentStartsInsideToken(source []byte, startOffset int) bool {
if startOffset <= 0 || startOffset >= len(source) {
return false
}
fset := token.NewFileSet()
file := fset.AddFile("", fset.Base(), len(source))
var lexer scanner.Scanner
lexer.Init(file, source, nil, scanner.ScanComments)
for {
position, kind, literal := lexer.Scan()
if kind == token.EOF {
return false
}
if kind != token.STRING && kind != token.COMMENT {
continue
}
start := file.Offset(position)
if startOffset > start && startOffset < start+len(literal) {
return true
}
}
}
// minifyGoFragment handles bounded reads that do not contain a package clause.
// It finds the longest complete declaration or statement prefix that the real
// Go parser accepts, prints that prefix without comments, and preserves any
// incomplete tail conservatively. Ranges are intentionally capped: repeatedly
// parsing a large broken file would otherwise turn a cheap read into O(n²) work.
func minifyGoFragment(content []byte) (string, bool) {
lines := strings.Split(strings.ReplaceAll(string(content), "\r\n", "\n"), "\n")
if len(lines) > 1 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
if len(lines) > 512 {
return "", false
}
for end := len(lines); end > 0; end-- {
prefix := strings.Join(lines[:end], "\n")
if compact, ok := parseGoDeclarations(prefix); ok {
return joinCompactPrefix(compact, lines[end:]), true
}
if compact, ok := parseGoStatements(prefix); ok {
return joinCompactPrefix(compact, lines[end:]), true
}
}
return "", false
}
func parseGoDeclarations(fragment string) (string, bool) {
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, "", "package compact\n"+fragment, parser.SkipObjectResolution)
if err != nil || len(file.Decls) == 0 {
return "", false
}
nodes := make([]ast.Node, len(file.Decls))
for i, declaration := range file.Decls {
nodes[i] = declaration
}
return printGoNodes(fset, nodes)
}
func parseGoStatements(fragment string) (string, bool) {
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, "", "package compact\nfunc _(){\n"+fragment+"\n}", parser.SkipObjectResolution)
if err != nil || len(file.Decls) != 1 {
return "", false
}
function, ok := file.Decls[0].(*ast.FuncDecl)
if !ok || function.Body == nil || len(function.Body.List) == 0 {
return "", false
}
nodes := make([]ast.Node, len(function.Body.List))
for i, statement := range function.Body.List {
nodes[i] = statement
}
return printGoNodes(fset, nodes)
}
func printGoNodes(fset *token.FileSet, nodes []ast.Node) (string, bool) {
var buf bytes.Buffer
cfg := printer.Config{Mode: printer.TabIndent, Tabwidth: 1}
for i, node := range nodes {
if i > 0 {
buf.WriteByte('\n')
}
if err := cfg.Fprint(&buf, fset, node); err != nil {
return "", false
}
}
return strings.TrimSpace(buf.String()), true
}
func joinCompactPrefix(compact string, remainder []string) string {
tail := minifyGeneric([]byte(strings.Join(remainder, "\n")))
if tail == "" {
return compact
}
return compact + "\n" + tail
}
// minifyGo parses Go WITHOUT comments (omitting parser.ParseComments leaves them
// unattached) and reprints the AST, yielding valid, comment-free, gofmt-shaped
// source with tab indentation (1 char per level). It returns ok=false on any
+48 -7
View File
@@ -39,17 +39,58 @@ func Greet(name string) string {
}
}
func TestMinifyGoFallsBackOnUnparsableGo(t *testing.T) {
// A snippet (no package clause) cannot parse as a file -> safe generic path.
func TestMinifyGoStatementFragment(t *testing.T) {
r := File("snippet.go", []byte("x := 1 \n\n\n// keep me\ny := 2\n"))
if r.Applied {
t.Fatalf("expected fallback for unparsable Go, got %+v", r)
if !r.Applied || r.Language != "go-fragment" {
t.Fatalf("expected parsed Go fragment, got %+v", r)
}
if strings.Contains(r.Content, "\n\n\n") {
t.Errorf("generic should collapse blank runs:\n%q", r.Content)
t.Errorf("fragment should collapse blank runs:\n%q", r.Content)
}
if !strings.Contains(r.Content, "// keep me") {
t.Errorf("generic must NOT strip comments (unsafe without a parser): %q", r.Content)
if strings.Contains(r.Content, "keep me") || !strings.Contains(r.Content, "y := 2") {
t.Errorf("fragment was not compacted safely: %q", r.Content)
}
}
func TestMinifyGoFragmentAccepts512LinesWithFinalNewline(t *testing.T) {
src := strings.Repeat("x++\n", 512)
r := File("snippet.go", []byte(src))
if !r.Applied || r.Language != "go-fragment" {
t.Fatalf("expected 512-line fragment to remain eligible, got %+v", r)
}
if got := strings.Count(r.Content, "x++"); got != 512 {
t.Fatalf("compacted statement count = %d, want 512", got)
}
}
func TestMinifyGoDeclarationPrefixPreservesIncompleteTail(t *testing.T) {
src := `func Handle737(n int) (int, error) {
if n%2 == 1 {
return 0, errBad
}
return n + 737, nil
}
// Handle738 begins outside the requested range.
func Handle738(n int) (int, error) {
if n%2 == 1 {`
r := File("range.go", []byte(src))
if !r.Applied || r.Language != "go-fragment" {
t.Fatalf("expected declaration-prefix compaction, got %+v", r)
}
if strings.Contains(r.Content, "Handle737 returns") || !strings.Contains(r.Content, "return n + 737") {
t.Fatalf("complete declaration was not compacted correctly:\n%s", r.Content)
}
if !strings.Contains(r.Content, "func Handle738") || !strings.Contains(r.Content, "if n%2 == 1 {") {
t.Fatalf("incomplete tail was not preserved:\n%s", r.Content)
}
}
func TestMinifyGoLargeInvalidInputFallsBackConservatively(t *testing.T) {
src := strings.Repeat("x := 1 // keep\n", 513)
r := File("broken.go", []byte(src))
if r.Applied || !strings.Contains(r.Content, "// keep") {
t.Fatalf("large invalid input must retain conservative fallback: %+v", r)
}
}
+73 -11
View File
@@ -46,12 +46,13 @@ const (
const defaultTimeout = 5 * time.Second
type Options struct {
Profile config.ProviderProfile
Connectivity bool
HTTPClient *http.Client
Resolver Resolver
Timeout time.Duration
UserAgent string
Profile config.ProviderProfile
Connectivity bool
HTTPClient *http.Client
Resolver Resolver
OAuthResolver providerio.TokenResolver
Timeout time.Duration
UserAgent string
}
type Resolver interface {
@@ -207,11 +208,26 @@ func Probe(ctx context.Context, options Options) Result {
"providerKind": metadata.ProviderKind,
}, profile))
if credentialRequired(profile, metadata.ProviderKind) && !hasCredential(profile) {
oauthConfigured := options.OAuthResolver != nil
if oauthConfigured {
var resolveErr error
profile, oauthConfigured, resolveErr = profileWithOAuthCredential(ctx, profile, options.OAuthResolver, false)
if resolveErr != nil {
result.add(check("provider.auth", "Provider auth", StatusFail, CategoryAuth, "Provider OAuth credential could not be resolved: "+resolveErr.Error(), map[string]any{
"oauthLogin": true,
}, profile))
return result.finalize()
}
}
if credentialRequired(profile, metadata.ProviderKind) && !hasCredential(profile) && !oauthConfigured {
result.add(check("provider.auth", "Provider auth", StatusFail, CategoryAuth, fmt.Sprintf("Provider %s requires API credentials.", providerName(profile)), credentialDetails(profile), profile))
return result.finalize()
}
if hasCredential(profile) {
if oauthConfigured {
result.add(check("provider.auth", "Provider auth", StatusPass, CategoryAuth, fmt.Sprintf("Provider %s has OAuth credentials configured.", providerName(profile)), map[string]any{
"oauthLogin": true,
}, profile))
} else if hasCredential(profile) {
result.add(check("provider.auth", "Provider auth", StatusPass, CategoryAuth, fmt.Sprintf("Provider %s has credentials configured.", providerName(profile)), credentialDetails(profile), profile))
} else {
result.add(check("provider.auth", "Provider auth", StatusPass, CategoryAuth, fmt.Sprintf("Provider %s does not require API credentials.", providerName(profile)), credentialDetails(profile), profile))
@@ -286,9 +302,35 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
if err != nil {
return classifyTransportError(err, profile)
}
defer func() {
_ = response.Body.Close()
}()
defer func(body io.ReadCloser) {
_ = body.Close()
}(response.Body)
if response.StatusCode == http.StatusUnauthorized && options.OAuthResolver != nil {
refreshed, ok, refreshErr := profileWithOAuthCredential(requestCtx, options.Profile, options.OAuthResolver, true)
if refreshErr != nil {
return check("provider.connectivity", "Provider connectivity", StatusFail, CategoryAuth, "Provider OAuth credential refresh failed: "+refreshErr.Error(), map[string]any{
"statusCode": response.StatusCode,
}, profile)
}
if ok {
_ = response.Body.Close()
profile = refreshed
request, allowLoopbackOrPrivate, err = healthRequest(requestCtx, profile, kind, options)
if err != nil {
return check("provider.connectivity", "Provider connectivity", StatusFail, CategoryConfig, err.Error(), nil, profile)
}
if options.HTTPClient == nil {
client = newConnectivityClient(timeout, options.Resolver, sensitiveAuthHeaderNames(profile, kind), allowLoopbackOrPrivate)
}
response, err = client.Do(request)
if err != nil {
return classifyTransportError(err, profile)
}
defer func(body io.ReadCloser) {
_ = body.Close()
}(response.Body)
}
}
body, err := io.ReadAll(io.LimitReader(response.Body, 64*1024))
if err != nil {
@@ -320,6 +362,23 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
}, profile)
}
func profileWithOAuthCredential(ctx context.Context, profile config.ProviderProfile, resolver providerio.TokenResolver, forceRefresh bool) (config.ProviderProfile, bool, error) {
if resolver == nil {
return profile, false, nil
}
header, value, ok, err := resolver(ctx, forceRefresh)
if err != nil || !ok {
return profile, false, err
}
if strings.TrimSpace(header) == "" {
header = "Authorization"
}
profile.AuthHeader = header
profile.AuthScheme = ""
profile.AuthHeaderValue = value
return profile, true, nil
}
func healthRequest(ctx context.Context, profile config.ProviderProfile, kind config.ProviderKind, options Options) (*http.Request, bool, error) {
baseURL, err := resolvedBaseURL(profile, kind)
if err != nil {
@@ -748,6 +807,9 @@ func redact(message string, profile config.ProviderProfile) string {
func providerSecrets(profile config.ProviderProfile) []string {
secrets := []string{profile.APIKey, profile.AuthHeaderValue}
if fields := strings.Fields(profile.AuthHeaderValue); len(fields) == 2 {
secrets = append(secrets, fields[1])
}
for _, value := range profile.CustomHeaders {
if strings.TrimSpace(value) != "" {
secrets = append(secrets, value)
@@ -180,6 +180,182 @@ func TestProbeResolvedKindConnectivityAuthErrorRedactsSecret(t *testing.T) {
}
}
func TestProbeConnectivityUsesOAuthCredential(t *testing.T) {
var gotAuth string
resolverCalled := false
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
gotAuth = r.Header.Get("Authorization")
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Body: io.NopCloser(strings.NewReader(`{"data":[]}`)),
Header: make(http.Header),
}, nil
})}
result := Probe(context.Background(), Options{
Profile: config.ProviderProfile{
Name: "xai",
CatalogID: "xai",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://api.example.com/v1",
Model: "grok-4",
},
Connectivity: true,
HTTPClient: client,
Resolver: staticResolver{addr: netip.MustParseAddr("93.184.216.34")},
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
resolverCalled = true
return "Authorization", "Bearer oauth-test-token", true, nil
},
})
if result.Status != StatusPass {
t.Fatalf("Status = %q, want pass: %#v", result.Status, result.Checks)
}
if !resolverCalled {
t.Fatal("OAuth resolver was not called")
}
if gotAuth != "Bearer oauth-test-token" {
t.Fatalf("Authorization = %q, want OAuth bearer", gotAuth)
}
}
func TestProbeConnectivityOAuthCredentialsAreRedacted(t *testing.T) {
const (
staticKey = "STATICKEYVALUE1234567890abcdef"
oauthToken = "hf_VASANTHSENTINEL0011223344"
)
var authHeaders []string
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
authHeaders = append(authHeaders, r.Header.Get("Authorization"))
return &http.Response{
StatusCode: http.StatusUnauthorized,
Status: "401 Unauthorized",
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"invalid credentials ` + staticKey + ` and ` + oauthToken + `"}}`)),
Header: make(http.Header),
}, nil
})}
result := Probe(context.Background(), Options{
Profile: config.ProviderProfile{
Name: "xai",
CatalogID: "xai",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://api.example.com/v1",
APIKey: staticKey,
Model: "grok-4",
},
Connectivity: true,
HTTPClient: client,
Resolver: staticResolver{addr: netip.MustParseAddr("93.184.216.34")},
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
return "Authorization", "Bearer " + oauthToken, true, nil
},
})
if len(authHeaders) != 2 {
t.Fatalf("Authorization headers = %#v, want initial request and one refresh retry", authHeaders)
}
for _, header := range authHeaders {
if header != "Bearer "+oauthToken {
t.Fatalf("Authorization = %q, want OAuth bearer", header)
}
}
check := result.Check("provider.connectivity")
if check == nil || check.Category != CategoryAuth {
t.Fatalf("provider.connectivity = %#v, want auth failure", check)
}
if strings.Contains(check.Message, staticKey) {
t.Fatalf("static API key leaked in message: %q", check.Message)
}
if strings.Contains(check.Message, oauthToken) {
t.Fatalf("OAuth token leaked in message: %q", check.Message)
}
if strings.Count(check.Message, "[REDACTED]") != 2 {
t.Fatalf("expected both credentials to be redacted, got %q", check.Message)
}
}
func TestProbeWithoutConnectivityRejectsUnavailableOAuthCredential(t *testing.T) {
resolverCalled := false
result := Probe(context.Background(), Options{
Profile: config.ProviderProfile{
Name: "xai",
CatalogID: "xai",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://api.example.com/v1",
Model: "grok-4",
},
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
resolverCalled = true
return "", "", false, nil
},
})
if !resolverCalled {
t.Fatal("OAuth resolver was not called")
}
if result.Status != StatusFail {
t.Fatalf("Status = %q, want fail: %#v", result.Status, result.Checks)
}
check := result.Check("provider.auth")
if check == nil || check.Status != StatusFail || check.Category != CategoryAuth {
t.Fatalf("provider.auth = %#v, want auth failure", check)
}
}
func TestProbeConnectivityRefreshesOAuthCredentialAfterUnauthorized(t *testing.T) {
var authHeaders []string
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
authHeaders = append(authHeaders, r.Header.Get("Authorization"))
status := http.StatusOK
body := `{"data":[]}`
if len(authHeaders) == 1 {
status = http.StatusUnauthorized
body = `{"error":{"message":"expired"}}`
}
return &http.Response{
StatusCode: status,
Status: http.StatusText(status),
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
})}
var refreshFlags []bool
result := Probe(context.Background(), Options{
Profile: config.ProviderProfile{
Name: "xai",
CatalogID: "xai",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://api.example.com/v1",
Model: "grok-4",
},
Connectivity: true,
HTTPClient: client,
Resolver: staticResolver{addr: netip.MustParseAddr("93.184.216.34")},
OAuthResolver: func(_ context.Context, forceRefresh bool) (string, string, bool, error) {
refreshFlags = append(refreshFlags, forceRefresh)
token := "stale-token"
if forceRefresh {
token = "fresh-token"
}
return "Authorization", "Bearer " + token, true, nil
},
})
if result.Status != StatusPass {
t.Fatalf("Status = %q, want pass after refresh: %#v", result.Status, result.Checks)
}
if len(refreshFlags) != 2 || refreshFlags[0] || !refreshFlags[1] {
t.Fatalf("OAuth refresh flags = %#v, want [false true]", refreshFlags)
}
if len(authHeaders) != 2 || authHeaders[0] != "Bearer stale-token" || authHeaders[1] != "Bearer fresh-token" {
t.Fatalf("Authorization headers = %#v, want stale then fresh bearer", authHeaders)
}
}
func TestProbeConnectivityClassifiesTimeout(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, context.DeadlineExceeded
@@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
@@ -386,6 +387,127 @@ func runWindowsRealSmokeCommand(t *testing.T, runnerExe string, base WindowsSand
}
}
// The write jail must hold on a path whose DACL grants Everyone write access.
//
// A WRITE_RESTRICTED token runs TWO checks for a write and needs both to pass:
// the normal one against its enabled SIDs, and a second against its RESTRICTED
// SID list. The jail is built on the second check only succeeding where Zero has
// explicitly ACL'd a capability SID. Putting the World SID (S-1-1-0) in the
// restricted list breaks that globally — every principal is a member of
// Everyone, so on a DACL that grants Everyone write, the restricted half passes
// for free and confinement collapses back to the ordinary user's own
// permissions, which is precisely the boundary the sandbox exists to be
// stricter than.
//
// The runner already states this rule for the SIDs it refuses to add ("None of
// the granted SIDs can be added to the restricted list without collapsing the
// write jail"); Everyone was in the list anyway, since the original sandbox
// baseline.
//
// Realistic rather than theoretical: administrators open share roots with
// Everyone:F, and third-party installers ship loose ACLs. It needs no privilege,
// no symlink and no race. Checked and ruled out: C:\Users\Public\Documents
// grants BATCH, not Everyone, so the common "public folder" case is not this.
func TestWindowsRestrictedTokenDeniesWritesToEveryoneWritablePaths(t *testing.T) {
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real Windows sandbox smoke tests")
}
runnerExe := realSmokeExecutable(t, "ZERO_WINDOWS_COMMAND_RUNNER_EXE", WindowsSandboxCommandRunnerName)
root := t.TempDir()
outside := t.TempDir()
everyoneDir := filepath.Join(outside, "everyone-writable")
if err := os.MkdirAll(everyoneDir, 0o700); err != nil {
t.Fatalf("MkdirAll everyone-writable: %v", err)
}
// Granted through the production ACL applier, so the hostile DACL is built
// the same way a real one is rather than by a test-only shortcut.
snapshot, applied, err := applyWindowsACLPathGroup(windowsACLPathGroup{
Path: everyoneDir,
Entries: []WindowsACLEntry{{
Action: WindowsACLAllowWrite,
Path: everyoneDir,
Capability: "S-1-1-0",
}},
})
if err != nil {
t.Fatalf("grant Everyone write: %v", err)
}
if !applied {
t.Fatal("precondition: the Everyone grant did not apply, so there is no bypass to test for")
}
t.Cleanup(func() { _ = rollbackWindowsACLSnapshots([]windowsACLSnapshot{snapshot}) })
sandboxHome := filepath.Join(root, ".zero-sandbox")
// No DenyRead on purpose: that is what makes the runner choose the
// WRITE_RESTRICTED token, which is the default posture and the one whose
// restricted-SID list this test is about.
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{root},
WriteRoots: []WritableRoot{{Root: root, ProtectedMetadataNames: []string{".git", ".zero", ".agents"}}},
IncludePlatformRoots: true,
AllowTemp: true,
},
Network: NetworkPolicy{Mode: NetworkDeny},
}
config := WindowsSandboxCommandArgsOptions{
SandboxHome: sandboxHome,
CommandCWD: root,
WorkspaceRoots: []string{root},
PermissionProfile: profile,
SandboxLevel: WindowsSandboxLevelUnelevated,
}
// Granted root first. A jail that denies everything would satisfy the two
// assertions below while being useless, and this separates the two outcomes.
insideMarker := filepath.Join(root, "write-ok.txt")
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
"cmd.exe", "/d", "/s", "/c", "echo ok>" + insideMarker,
}, 0)
if contents, err := os.ReadFile(insideMarker); err != nil || strings.TrimSpace(string(contents)) != "ok" {
t.Fatalf("sandboxed write to a granted root = %q, %v; want ok", contents, err)
}
// Control: an ordinary directory outside every granted root. This is the
// behaviour the Everyone case must match.
controlMarker := filepath.Join(outside, "control-denied.txt")
runWindowsRealSmokeCommand(t, runnerExe, config, deniedWriteCommand(controlMarker), deniedWriteExitCode)
if _, err := os.Stat(controlMarker); err == nil {
t.Fatal("precondition: the sandbox allowed a write to an ordinary path outside every granted root, so this test cannot measure the Everyone case")
}
// The assertion. Same as the control in every respect except the DACL.
everyoneMarker := filepath.Join(everyoneDir, "everyone-denied.txt")
runWindowsRealSmokeCommand(t, runnerExe, config, deniedWriteCommand(everyoneMarker), deniedWriteExitCode)
if _, err := os.Stat(everyoneMarker); err == nil {
t.Error("the sandbox wrote outside every granted root because the path grants Everyone write; the restricted-SID list is satisfied by a SID every principal carries")
} else if !os.IsNotExist(err) {
t.Errorf("stat the Everyone-writable marker: %v", err)
}
}
// deniedWriteExitCode is chosen so a denial cannot be confused with the runner
// failing to start the command at all.
//
// The obvious spelling of these assertions is `echo leaked>path` expecting exit
// 1, but the runner itself exits 1 for its own errors — a failed marker
// validation, a bad argument, a token it could not build. A test written that
// way passes both when the sandbox denies the write and when nothing ever ran,
// and the second case proves nothing while looking identical to success. On a
// test whose whole job is catching a confinement regression, that is a failure
// mode to design out rather than hope about.
//
// 77 is arbitrary beyond being outside the range the runner produces for itself.
const deniedWriteExitCode = 77
// deniedWriteCommand attempts a write and reports deniedWriteExitCode when the
// redirect is refused, so the exit code also proves cmd.exe actually ran.
func deniedWriteCommand(marker string) []string {
return []string{"cmd.exe", "/d", "/s", "/c", "echo leaked>" + marker + " || exit " + strconv.Itoa(deniedWriteExitCode)}
}
func powershellSingleQuote(value string) string {
out := "'"
for _, r := range value {
+45 -9
View File
@@ -88,19 +88,55 @@ func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []w
if err != nil {
return 0, err
}
worldSID, err := windows.CreateWellKnownSid(windows.WinWorldSid)
if err != nil {
return 0, fmt.Errorf("create world SID: %w", err)
}
entries := make([]windows.SIDAndAttributes, 0, len(capabilitySIDs)+2)
for _, sid := range capabilitySIDs {
entries = append(entries, windows.SIDAndAttributes{Sid: sid.sid})
}
entries = append(entries,
windows.SIDAndAttributes{Sid: sidFromBytes(logonSID)},
windows.SIDAndAttributes{Sid: worldSID},
)
entries = append(entries, windows.SIDAndAttributes{Sid: sidFromBytes(logonSID)})
// The World SID (S-1-1-0, Everyone) is added ONLY to the token that does not
// carry WRITE_RESTRICTED, and putting it back unconditionally would reopen a
// write-jail bypass.
//
// A restricted SID is a key to every object whose DACL names it. That is why
// the runner refuses to add the user SID, Administrators or SYSTEM — "each
// has write access nearly everywhere". Everyone is the broadest of the lot:
// every principal carries it, so under WRITE_RESTRICTED the second
// (restricted-SID) check passes for free on any path whose DACL grants
// Everyone write, and confinement falls back to the ordinary user's own
// permissions — the exact boundary this token exists to be stricter than. It
// needs no privilege, no symlink and no race: an Everyone-writable directory
// is enough, and share roots opened with Everyone:F and loose installer ACLs
// supply them. It was present from the original sandbox baseline,
// uncommented, and under WRITE_RESTRICTED nothing depends on it, because that
// flag already exempts reads from the restricted-SID check.
//
// Without the flag it IS load-bearing and cannot simply be dropped. The
// restricted-SID check then applies to READS too, and default Windows DACLs
// grant BUILTIN\Users rather than anything in this list, so a token without
// Everyone cannot open cmd.exe — the process dies at launch with
// STATUS_ACCESS_DENIED (0xC0000022) before it runs anything. That path is
// only taken when the profile configures DenyRead, which is already the
// posture that trades capability for read-deny enforcement (#612).
//
// So the bypass survives for DenyRead profiles, deliberately and narrowly,
// rather than being traded for a sandbox that cannot start a command. Closing
// it there needs a different mechanism (a read-side grant that is not a
// universal group), tracked separately.
//
// The logon SID above stays in both modes: it is this token's own rather than
// a broad group, and broadenWindowsRestrictedTokenDefaultDacl depends on it so
// the process can use pipes and events it creates for itself.
//
// Anything added here needs the same scrutiny — Authenticated Users, Users,
// INTERACTIVE and BATCH would each produce this bypass on a DACL naming them.
if !writeRestricted {
worldSID, err := windows.CreateWellKnownSid(windows.WinWorldSid)
if err != nil {
return 0, fmt.Errorf("create world SID: %w", err)
}
entries = append(entries, windows.SIDAndAttributes{Sid: worldSID})
}
// WRITE_RESTRICTED scopes the restricted-SID check to write-type accesses:
// reads use only the normal token identity, so the sandboxed process can
+26 -2
View File
@@ -43,7 +43,7 @@ func TestUpdateTitle(t *testing.T) {
t.Fatalf("title not trimmed/stored: %q", updated.Title)
}
if updated.UpdatedAt != before.UpdatedAt {
t.Fatalf("UpdatedAt must not change on retitle: before=%q after=%q", before.UpdatedAt, updated.UpdatedAt)
t.Fatalf("UpdatedAt must not change on rename: before=%q after=%q", before.UpdatedAt, updated.UpdatedAt)
}
if updated.EventCount != before.EventCount {
t.Fatalf("EventCount changed: before=%d after=%d", before.EventCount, updated.EventCount)
@@ -67,7 +67,7 @@ func TestUpdateTitle(t *testing.T) {
// An unchanged title is a no-op (still succeeds).
if _, err := store.UpdateTitle(session.SessionID, "Clean Generated Title"); err != nil {
t.Fatalf("no-op retitle should succeed: %v", err)
t.Fatalf("no-op rename should succeed: %v", err)
}
// An invalid session id is rejected.
@@ -76,6 +76,30 @@ func TestUpdateTitle(t *testing.T) {
}
}
func TestUpdateTitleIfCurrent(t *testing.T) {
store := newTitleTestStore(t)
session, err := store.Create(CreateInput{Title: "Automatic title"})
if err != nil {
t.Fatalf("create: %v", err)
}
updated, applied, err := store.UpdateTitleIfCurrent(session.SessionID, "Automatic title", "Generated title")
if err != nil || !applied || updated.Title != "Generated title" {
t.Fatalf("matching update = (%#v, %v, %v)", updated, applied, err)
}
if _, err := store.UpdateTitle(session.SessionID, "Manual title"); err != nil {
t.Fatalf("manual rename: %v", err)
}
updated, applied, err = store.UpdateTitleIfCurrent(session.SessionID, "Generated title", "Late generated title")
if err != nil {
t.Fatalf("stale update: %v", err)
}
if applied || updated.Title != "Manual title" {
t.Fatalf("stale update should preserve manual title: (%#v, %v)", updated, applied)
}
}
func TestUpdateModel(t *testing.T) {
store := newTitleTestStore(t)
session, err := store.Create(CreateInput{ModelID: "model-a"})
+35 -1
View File
@@ -719,7 +719,7 @@ func (store *Store) appendPreparedEventsLocked(sessionID string, inputs []prepar
// serialized under the same per-session lock as AppendEvent and re-reads the
// latest metadata under that lock before rewriting, so a concurrent append can't
// clobber the new title (nor the title clobber a concurrent append's event
// count/timestamp). UpdatedAt is deliberately left untouched: a retitle is not
// count/timestamp). UpdatedAt is deliberately left untouched: a rename is not
// activity, so it must not reorder the session in the resumable list. A blank
// title is rejected so a failed model generation can never erase a useful
// first-message title, and an unchanged title is a no-op (no rewrite/fsync).
@@ -751,6 +751,40 @@ func (store *Store) UpdateTitle(sessionID string, title string) (Metadata, error
return session, nil
}
// UpdateTitleIfCurrent replaces a session title only when it still matches
// expected. It lets background automatic naming avoid overwriting a newer manual
// rename while keeping the check and write under the same per-session lock.
func (store *Store) UpdateTitleIfCurrent(sessionID string, expected string, title string) (Metadata, bool, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, false, fmt.Errorf("invalid zero session id %q", sessionID)
}
trimmed := strings.TrimSpace(title)
if trimmed == "" {
return Metadata{}, false, fmt.Errorf("zero session title is required")
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, false, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, false, err
}
if session.Title != strings.TrimSpace(expected) {
return session, false, nil
}
if session.Title == trimmed {
return session, true, nil
}
session.Title = trimmed
if err := store.writeMetadata(session); err != nil {
return Metadata{}, false, err
}
return session, true, nil
}
// UpdateModel replaces a session's selected model without changing its activity
// timestamp or event counters. An empty model clears the session override.
func (store *Store) UpdateModel(sessionID string, modelID string) (Metadata, error) {
+7 -4
View File
@@ -167,10 +167,13 @@ const permissionModeUnsafe = "unsafe"
var readOnlySpecialistTools = map[string]bool{
"read_file": true,
"read_minified_file": true,
"list_directory": true,
"grep": true,
"glob": true,
"update_plan": true,
// Reads one image file through read_file's own path scoping and mutates
// nothing, so it carries the same auto-approval as the other pure readers.
"view_image": true,
"list_directory": true,
"grep": true,
"glob": true,
"update_plan": true,
}
// IsReadOnlySpecialist reports whether the named specialist resolves to a
+1 -1
View File
@@ -255,7 +255,7 @@ func TestBuildArgsDefaultsToReadOnlyToolAllowlist(t *testing.T) {
if err != nil {
t.Fatalf("BuildArgs returned error: %v", err)
}
if !containsSequence(result.Args, []string{"--enabled-tools", "glob,grep,list_directory,read_file,read_minified_file"}) {
if !containsSequence(result.Args, []string{"--enabled-tools", "glob,grep,list_directory,read_file,read_minified_file,view_image"}) {
t.Fatalf("args missing default read-only allowlist: %#v", result.Args)
}
}
+4 -3
View File
@@ -82,9 +82,9 @@ var knownMetadataKeys = map[string]bool{
}
var toolCategories = map[string][]string{
"read-only": {"read_file", "read_minified_file", "list_directory", "grep", "glob"},
"edit": {"read_file", "read_minified_file", "list_directory", "grep", "glob", "write_file", "edit_file", "apply_patch"},
"execute": {"read_file", "read_minified_file", "list_directory", "grep", "glob", "exec_command", "write_stdin", "bash"},
"read-only": {"read_file", "read_minified_file", "view_image", "list_directory", "grep", "glob"},
"edit": {"read_file", "read_minified_file", "view_image", "list_directory", "grep", "glob", "write_file", "edit_file", "apply_patch"},
"execute": {"read_file", "read_minified_file", "view_image", "list_directory", "grep", "glob", "exec_command", "write_stdin", "bash"},
"plan": {"update_plan"},
}
@@ -102,6 +102,7 @@ var defaultToolSelection = []string{"read-only"}
var knownToolNames = map[string]bool{
"read_file": true,
"read_minified_file": true,
"view_image": true,
"list_directory": true,
"glob": true,
"grep": true,
@@ -0,0 +1,31 @@
package specialist
import (
"slices"
"testing"
)
// view_image is a pure workspace-scoped read, so every category that already
// grants read_file should grant it too. Categories are what specialist authors
// actually write ("tools: [execute]"), so an omission here silently denies a
// specialist the ability to look at a screenshot it just captured.
func TestToolCategoriesIncludeViewImageWhereverReadFileIsGranted(t *testing.T) {
for category, names := range toolCategories {
if !slices.Contains(names, "read_file") {
continue
}
if !slices.Contains(names, "view_image") {
t.Errorf("category %q grants read_file but not view_image", category)
}
}
}
func TestResolveToolsExpandsExecuteToIncludeViewImage(t *testing.T) {
resolved, err := ResolveTools([]string{"execute"})
if err != nil {
t.Fatalf("ResolveTools: %v", err)
}
if !slices.Contains(resolved, "view_image") {
t.Errorf("execute resolved to %v, missing view_image", resolved)
}
}
+97 -14
View File
@@ -80,8 +80,19 @@ func (tool applyPatchTool) RunWithOptions(ctx context.Context, args map[string]a
return errorResult("Error applying patch: " + err.Error())
}
var createdTargets []string
var fullySuppliedTargets []string
wholeBefore := map[string]bool{}
if options.FileTracker != nil {
createdTargets = missingPatchTargets(applyRoot, patch)
fullySuppliedTargets = completeCreatedPatchTargets(applyRoot, patch)
for _, path := range patchHeaderPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
if absolute, _, rerr := resolveWorkspaceTargetPath(applyRoot, path); rerr == nil {
wholeBefore[absolute] = options.FileTracker.SeenWhole(absolute)
}
}
}
command := exec.CommandContext(ctx, "git", "apply", "--whitespace=nowarn", patchPath)
@@ -102,13 +113,28 @@ func (tool applyPatchTool) RunWithOptions(ctx context.Context, args map[string]a
result := okResult(summary)
result.ChangedFiles = changedFilesFromPatch(relativeRoot, patch)
result.Display = Display{Summary: summary, Kind: "diff", Preview: capPreviewDiff(patch)}
// git apply already rejects a patch whose context drifted, so it has its own
// staleness guard. Drop any tracked baseline for the files it rewrote so a
// subsequent edit_file/write_file re-reads instead of false-flagging the
// patch's own change as an external modification.
fullySupplied := make(map[string]bool, len(fullySuppliedTargets))
for _, absolute := range fullySuppliedTargets {
fullySupplied[absolute] = true
}
// Re-baseline files changed by this tool. When the model had already seen the
// whole input (or supplied a complete new file), the exact patch plus that
// input determines the whole output, so a follow-up edit needs no wasted read.
// Partial observations remain conservative and are cleared by Record.
for _, changed := range result.ChangedFiles {
if absolute, _, rerr := resolveScopedPath(tool.workspaceRoot, tool.scope, changed); rerr == nil {
options.FileTracker.Forget(absolute)
content, readErr := os.ReadFile(absolute)
if readErr != nil {
options.FileTracker.Forget(absolute)
continue
}
info, _ := os.Stat(absolute)
wasWhole := wholeBefore[absolute] || fullySupplied[absolute]
options.FileTracker.Record(absolute, content, info)
if wasWhole {
lines := lineCount(string(content))
options.FileTracker.RecordSeenRange(absolute, 1, lines, lines)
}
}
}
recordCreatedPatchTargets(options.FileTracker, createdTargets)
@@ -134,6 +160,60 @@ func missingPatchTargets(root string, patch string) []string {
return missing
}
// completeCreatedPatchTargets returns only files whose full contents are
// supplied by a /dev/null creation patch. A missing rename/copy destination is
// created by git too, but its bytes come from an unread source and must not gain
// whole-file observation credit.
func completeCreatedPatchTargets(root string, patch string) []string {
seen := map[string]bool{}
var created []string
oldRemaining, newRemaining := 0, 0
inHunk := false
fromDevNull := false
for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") {
if inHunk && (oldRemaining > 0 || newRemaining > 0) {
switch {
case strings.HasPrefix(line, "-"):
oldRemaining--
case strings.HasPrefix(line, "+"):
newRemaining--
case strings.HasPrefix(line, "\\"):
default:
oldRemaining--
newRemaining--
}
continue
}
inHunk = false
switch {
case strings.HasPrefix(line, "diff --git "):
fromDevNull = false
case strings.HasPrefix(line, "@@"):
oldRemaining, newRemaining = parseHunkCounts(line)
inHunk = oldRemaining > 0 || newRemaining > 0
case strings.HasPrefix(line, "--- "):
fromDevNull = patchFileHeaderPath(line) == "/dev/null"
case strings.HasPrefix(line, "+++ "):
path := patchFileHeaderPath(line)
if !fromDevNull || path == "" || path == "/dev/null" {
fromDevNull = false
continue
}
fromDevNull = false
absolute, _, err := resolveWorkspaceTargetPath(root, stripPatchPrefix(path))
if err != nil || seen[absolute] {
continue
}
if _, err := os.Stat(absolute); !os.IsNotExist(err) {
continue
}
seen[absolute] = true
created = append(created, absolute)
}
}
return created
}
func recordCreatedPatchTargets(tracker *FileTracker, missingBefore []string) {
if tracker == nil {
return
@@ -240,15 +320,7 @@ func patchHeaderPaths(patch string) []string {
oldRemaining, newRemaining = parseHunkCounts(line)
inHunk = oldRemaining > 0 || newRemaining > 0
case strings.HasPrefix(line, "--- "), strings.HasPrefix(line, "+++ "):
// Take everything after the fixed 4-char prefix instead of splitting on
// spaces, so a path that contains spaces survives. Drop a trailing
// tab-delimited timestamp (unified-diff convention) and unquote a
// C-quoted git path (git quotes names with spaces/specials) (L18).
rest := line[len("--- "):] // "--- " and "+++ " are both 4 bytes
if tab := strings.IndexByte(rest, '\t'); tab >= 0 {
rest = rest[:tab]
}
if p := strings.TrimSpace(unquoteGitPath(rest)); p != "" && p != "/dev/null" {
if p := patchFileHeaderPath(line); p != "" && p != "/dev/null" {
paths = append(paths, stripPatchPrefix(p))
}
}
@@ -256,6 +328,17 @@ func patchHeaderPaths(patch string) []string {
return paths
}
func patchFileHeaderPath(line string) string {
if len(line) < len("--- ") {
return ""
}
rest := line[len("--- "):] // "--- " and "+++ " are both 4 bytes
if tab := strings.IndexByte(rest, '\t'); tab >= 0 {
rest = rest[:tab]
}
return strings.TrimSpace(unquoteGitPath(rest))
}
// parseHunkCounts reads the old/new line counts from a "@@ -a,b +c,d @@" header.
// A missing count (e.g. "@@ -a +c @@") means 1 per unified-diff convention.
//
+28
View File
@@ -132,6 +132,34 @@ func intPtr(value int) *int {
return &value
}
// sandboxPermissionsDescription and additionalPermissionsDescription are shared
// by every shell tool (bash, exec_command) so the two fields cannot drift apart.
//
// Both lead with "omit" on purpose. The pair used to be described only in terms
// of what each value does, which left a model that had used
// with_additional_permissions once (say, for `gh issue create`) no instruction
// to stop attaching a permission object to the next, ordinary command. That
// attachment is then rejected — correctly, but the rejection alone does not tell
// the model that dropping the field is the fix, so it flips between an
// unflagged permission object and a flagged empty one and never converges
// (#845, #847). Making "omit it" the first sentence of both descriptions stops
// the invalid shape from being emitted in the first place; the validator errors
// are the backstop, not the teacher.
//
// These ride on every turn's exec_command schema, so they are kept close to the
// length of the text they replaced; TestEagerToolSchemaTokenBudget is the ratchet.
const (
sandboxPermissionsDescription = "Per-command sandbox override. Omit it unless the command needs access the " +
"sandbox does not already give it; use `with_additional_permissions` with a non-empty " +
"`additional_permissions` for sandboxed file/network access, or `require_escalated` only when the command " +
"must run outside the sandbox, such as host/global process, socket, service, or desktop state hidden by " +
"sandbox namespaces."
additionalPermissionsDescription = "Extra sandbox access. Omit this field entirely unless `sandbox_permissions` " +
"is `with_additional_permissions`; sending it with any other mode is rejected, as is an empty or all-false " +
"object. Example: {\"network\": {\"enabled\": true}}."
)
func additionalPermissionsProperties() map[string]PropertySchema {
return requestPermissionsProfileSchema().Properties
}
+3 -2
View File
@@ -43,16 +43,17 @@ func NewScopedBashTool(workspaceRoot string, scope PathScope) Tool {
baseTool: baseTool{
name: "bash",
description: "Execute a shell command inside the workspace (or an explicitly granted extra directory) after permission is granted. " + shellGuidance,
deferred: true,
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"command": {Type: "string", Description: "Shell command to execute using the host shell. " + shellGuidance},
"cwd": {Type: "string", Description: "Directory to run the command in. Relative paths stay in the workspace; use an absolute path to run in a granted extra directory. Defaults to workspace root. Prefer cwd over cd when changing directories.", Default: "."},
"timeout_ms": {Type: "integer", Description: "Command timeout in milliseconds.", Default: defaultBashTimeoutMS, Minimum: intPtr(1), Maximum: intPtr(maxBashTimeoutMS)},
"sandbox_permissions": {Type: "string", Enum: []string{string(SandboxPermissionsUseDefault), string(SandboxPermissionsWithAdditionalPermissions), string(SandboxPermissionsRequireEscalated)}, Description: "Per-command sandbox override. Defaults to `use_default`; use `with_additional_permissions` with `additional_permissions` for sandboxed file/network access, or `require_escalated` only when the command must run outside the sandbox, such as host/global process, socket, service, or desktop state hidden by sandbox namespaces.", Default: string(SandboxPermissionsUseDefault)},
"sandbox_permissions": {Type: "string", Enum: []string{string(SandboxPermissionsUseDefault), string(SandboxPermissionsWithAdditionalPermissions), string(SandboxPermissionsRequireEscalated)}, Description: sandboxPermissionsDescription, Default: string(SandboxPermissionsUseDefault)},
"additional_permissions": {
Type: "object",
Description: "Sandboxed filesystem or network access for this command; only with `sandbox_permissions: \"with_additional_permissions\"`.",
Description: additionalPermissionsDescription,
Properties: additionalPermissionsProperties(),
},
"justification": {Type: "string", Description: "User-facing approval question for `require_escalated`; omit otherwise."},
+71
View File
@@ -74,6 +74,11 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
if cerr := options.FileTracker.CheckConflict(absolutePath, contentBytes); cerr != nil {
return errorResult(fileConflictMessage(relativePath))
}
if options.FileTracker != nil {
if _, tracked := options.FileTracker.Version(absolutePath); !tracked {
return errorResult(fileUnseenMessage(relativePath))
}
}
content := string(contentBytes)
occurrences := strings.Count(content, oldString)
@@ -131,7 +136,11 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
if !replaceAll && occurrences > 1 {
return errorResult(fmt.Sprintf("Error: old_string matches %d locations in %s. Either make old_string more specific, or pass replace_all: true to replace every occurrence.", occurrences, relativePath))
}
if options.FileTracker != nil && !allOccurrencesSeen(options.FileTracker, absolutePath, content, oldString, replaceAll) {
return errorResult(fileUnseenMessage(relativePath))
}
previouslySeenWhole := options.FileTracker.SeenWhole(absolutePath)
updated := strings.Replace(content, oldString, newString, 1)
replacedCount := 1
if replaceAll {
@@ -141,12 +150,14 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
if updated == content {
return okResult("No changes: new_string is identical to old_string.")
}
editedSpans := replacementByteSpans(content, oldString, newString, replaceAll)
if err := recheckScopedWriteTarget(tool.workspaceRoot, tool.scope, requestedPath); err != nil {
return errorResult("Error writing " + relativePath + ": " + err.Error())
}
if err := os.WriteFile(absolutePath, []byte(updated), 0o644); err != nil {
return errorResult("Error writing " + relativePath + ": " + err.Error())
}
modelKnownContent := updated
// 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.
@@ -155,6 +166,15 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
// compare against the current on-disk state, not the pre-edit version.
newInfo, _ := os.Stat(absolutePath)
options.FileTracker.Record(absolutePath, []byte(updated), newInfo)
if updated == modelKnownContent {
if previouslySeenWhole {
options.FileTracker.RecordSeenRange(absolutePath, 1, lineCount(updated), lineCount(updated))
} else {
for _, span := range editedSpans {
options.FileTracker.RecordSeenBytes(absolutePath, span.start, span.end, len(updated))
}
}
}
suffix := ""
if replacedCount != 1 {
@@ -169,3 +189,54 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
result.Display = Display{Summary: fmt.Sprintf("Edited %s", relativePath), Kind: "diff", Preview: boundedUnifiedDiff(relativePath, content, updated)}
return result
}
// replacementByteSpans returns every half-open post-edit byte range whose exact
// contents came from newString. Offsets are translated from the original with a
// cumulative delta so replace_all remains correct after earlier replacements.
func replacementByteSpans(content, oldString, newString string, replaceAll bool) []lineRange {
spans := make([]lineRange, 0, 1)
offset := 0
delta := 0
for {
index := strings.Index(content[offset:], oldString)
if index < 0 {
return spans
}
index += offset
start := index + delta
spans = append(spans, lineRange{start: start, end: start + len(newString)})
if !replaceAll {
return spans
}
offset = index + len(oldString)
delta += len(newString) - len(oldString)
}
}
func allOccurrencesSeen(tracker *FileTracker, path, content, oldString string, replaceAll bool) bool {
offset := 0
for {
index := strings.Index(content[offset:], oldString)
if index < 0 {
return true
}
index += offset
start, end := lineSpanForOffset(content, index, len(oldString))
if !tracker.SeenBytes(path, index, index+len(oldString)) && !tracker.SeenRange(path, start, end) {
return false
}
if !replaceAll {
return true
}
offset = index + len(oldString)
}
}
func lineSpanForOffset(content string, offset, length int) (int, int) {
if offset < 0 {
return 1, 1
}
start := strings.Count(content[:offset], "\n") + 1
end := start + strings.Count(content[offset:min(len(content), offset+length)], "\n")
return start, end
}
+2 -2
View File
@@ -91,10 +91,10 @@ func NewScopedExecCommandTool(workspaceRoot string, scope PathScope, manager *ex
"cwd": {Type: "string", Description: "Alias for workdir. Prefer workdir.", Default: "."},
"yield_time_ms": {Type: "integer", Description: "Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms.", Default: defaultExecYieldTimeMS, Minimum: intPtr(1), Maximum: intPtr(maxExecYieldTimeMS)},
"max_output_tokens": {Type: "integer", Description: "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.", Default: defaultMaxOutputTokens, Minimum: intPtr(1), Maximum: intPtr(maxExecOutputTokenRequest)},
"sandbox_permissions": {Type: "string", Enum: []string{string(SandboxPermissionsUseDefault), string(SandboxPermissionsWithAdditionalPermissions), string(SandboxPermissionsRequireEscalated)}, Description: "Per-command sandbox override. Defaults to `use_default`; use `with_additional_permissions` with `additional_permissions` for sandboxed file/network access, or `require_escalated` only when the command must run outside the sandbox, such as host/global process, socket, service, or desktop state hidden by sandbox namespaces.", Default: string(SandboxPermissionsUseDefault)},
"sandbox_permissions": {Type: "string", Enum: []string{string(SandboxPermissionsUseDefault), string(SandboxPermissionsWithAdditionalPermissions), string(SandboxPermissionsRequireEscalated)}, Description: sandboxPermissionsDescription, Default: string(SandboxPermissionsUseDefault)},
"additional_permissions": {
Type: "object",
Description: "Sandboxed filesystem or network access for this command; only with `sandbox_permissions: \"with_additional_permissions\"`.",
Description: additionalPermissionsDescription,
Properties: additionalPermissionsProperties(),
},
"justification": {Type: "string", Description: "User-facing approval question for `require_escalated`; omit otherwise."},
+294 -6
View File
@@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
@@ -79,6 +80,147 @@ func TestEditFileSucceedsWhenUnchangedAndRebaselines(t *testing.T) {
}
}
func TestApplyPatchPreservesWholeFileObservationForFollowUpEdit(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
if err := os.WriteFile(path, []byte("alpha\nbeta\n"), 0o644); err != nil {
t.Fatal(err)
}
registry := safetyRegistry(t, dir)
registry.Register(NewScopedApplyPatchTool(dir, nil))
tracker := NewFileTracker()
opts := grantedOpts(tracker)
if result := registry.RunWithOptions(context.Background(), "read_file", map[string]any{"path": "f.txt"}, opts); result.Status != StatusOK {
t.Fatalf("read_file failed: %s", result.Output)
}
patch := strings.Join([]string{
"diff --git a/f.txt b/f.txt",
"--- a/f.txt",
"+++ b/f.txt",
"@@ -1,2 +1,2 @@",
"-alpha",
"+gamma",
" beta",
"",
}, "\n")
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
if gitApplyUnavailable(result.Output) {
t.Skipf("git binary unavailable: %s", result.Output)
}
t.Fatalf("apply_patch failed: %s", result.Output)
}
result := registry.RunWithOptions(context.Background(), "edit_file", map[string]any{
"path": "f.txt", "old_string": "beta", "new_string": "delta",
}, opts)
if result.Status != StatusOK {
t.Fatalf("follow-up edit should not require a wasted re-read: %s", result.Output)
}
}
func TestApplyPatchRenameOrCopyDoesNotCreditUnreadDestination(t *testing.T) {
for _, tc := range []struct {
name string
patch string
}{
{
name: "rename",
patch: strings.Join([]string{
"diff --git a/source.txt b/destination.txt",
"similarity index 100%",
"rename from source.txt",
"rename to destination.txt",
"",
}, "\n"),
},
{
name: "copy",
patch: strings.Join([]string{
"diff --git a/source.txt b/destination.txt",
"similarity index 100%",
"copy from source.txt",
"copy to destination.txt",
"",
}, "\n"),
},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "source.txt"), []byte("unread source\n"), 0o644); err != nil {
t.Fatal(err)
}
registry := safetyRegistry(t, dir)
registry.Register(NewScopedApplyPatchTool(dir, nil))
tracker := NewFileTracker()
opts := grantedOpts(tracker)
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": tc.patch}, opts); result.Status != StatusOK {
if gitApplyUnavailable(result.Output) {
t.Skipf("git binary unavailable: %s", result.Output)
}
t.Fatalf("apply_patch failed: %s", result.Output)
}
destination, err := filepath.EvalSymlinks(filepath.Join(dir, "destination.txt"))
if err != nil {
t.Fatal(err)
}
if tracker.SeenWhole(destination) {
t.Fatal("rename/copy destination was credited as fully seen without a read")
}
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": "destination.txt", "content": "blind overwrite\n", "overwrite": true,
}, opts)
if result.Status != StatusError || !strings.Contains(result.Output, "has not been read exactly") {
t.Fatalf("blind destination overwrite was not refused: %#v", result)
}
})
}
}
func TestApplyPatchCompleteCreationCreditsSuppliedFile(t *testing.T) {
dir := t.TempDir()
registry := safetyRegistry(t, dir)
registry.Register(NewScopedApplyPatchTool(dir, nil))
tracker := NewFileTracker()
opts := grantedOpts(tracker)
patch := strings.Join([]string{
"diff --git a/new.txt b/new.txt",
"new file mode 100644",
"--- /dev/null",
"+++ b/new.txt",
"@@ -0,0 +1 @@",
"+supplied content",
"",
}, "\n")
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
if gitApplyUnavailable(result.Output) {
t.Skipf("git binary unavailable: %s", result.Output)
}
t.Fatalf("apply_patch failed: %s", result.Output)
}
created, err := filepath.EvalSymlinks(filepath.Join(dir, "new.txt"))
if err != nil {
t.Fatal(err)
}
if !tracker.SeenWhole(created) {
t.Fatal("complete /dev/null creation was not credited as supplied content")
}
result := registry.RunWithOptions(context.Background(), "edit_file", map[string]any{
"path": "new.txt", "old_string": "supplied", "new_string": "updated",
}, opts)
if result.Status != StatusOK {
t.Fatalf("follow-up edit of supplied file failed: %s", result.Output)
}
updated, err := os.ReadFile(filepath.Join(dir, "new.txt"))
if err != nil {
t.Fatal(err)
}
if got, want := strings.ReplaceAll(string(updated), "\r\n", "\n"), "updated content\n"; got != want {
t.Fatalf("follow-up edit content = %q, want %q", got, want)
}
}
func TestWriteFileOverwriteRefusedWhenChangedOnDisk(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
@@ -103,7 +245,7 @@ func TestWriteFileOverwriteRefusedWhenChangedOnDisk(t *testing.T) {
}
}
func TestWriteFileOverwriteAllowedForUntrackedFile(t *testing.T) {
func TestWriteFileOverwriteRequiresExactRead(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
if err := os.WriteFile(path, []byte("v1\n"), 0o644); err != nil {
@@ -112,15 +254,161 @@ func TestWriteFileOverwriteAllowedForUntrackedFile(t *testing.T) {
registry := safetyRegistry(t, dir)
tracker := NewFileTracker()
// Never read through a tool → no baseline → overwrite is the model's call.
// Never read through a tool: an overwrite would discard unseen content.
res := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": "f.txt", "content": "v3\n", "overwrite": true,
}, grantedOpts(tracker))
if res.Status != StatusOK {
t.Fatalf("untracked overwrite should be allowed, got %q", res.Output)
if res.Status != StatusError || !strings.Contains(res.Output, "has not been read exactly") {
t.Fatalf("unseen overwrite should be refused, got %q", res.Output)
}
if got, _ := os.ReadFile(path); string(got) != "v3\n" {
t.Fatalf("file = %q, want v3", got)
if got, _ := os.ReadFile(path); string(got) != "v1\n" {
t.Fatalf("file = %q, want original content", got)
}
}
func TestEditFileAllowsSeenRangeAndRejectsUnseenRange(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
if err := os.WriteFile(path, []byte("alpha\nbeta\ngamma\n"), 0o644); err != nil {
t.Fatal(err)
}
registry := safetyRegistry(t, dir)
tracker := NewFileTracker()
opts := grantedOpts(tracker)
if res := registry.RunWithOptions(context.Background(), "read_file", map[string]any{
"path": "f.txt", "start_line": 2, "end_line": 2,
}, opts); res.Status != StatusOK {
t.Fatalf("partial read failed: %s", res.Output)
}
if res := registry.RunWithOptions(context.Background(), "edit_file", map[string]any{
"path": "f.txt", "old_string": "gamma", "new_string": "delta",
}, opts); res.Status != StatusError || !strings.Contains(res.Output, "has not been read exactly") {
t.Fatalf("unseen edit should be refused, got %q", res.Output)
}
if res := registry.RunWithOptions(context.Background(), "edit_file", map[string]any{
"path": "f.txt", "old_string": "beta", "new_string": "bravo",
}, opts); res.Status != StatusOK {
t.Fatalf("seen-range edit should succeed, got %q", res.Output)
}
if content, err := os.ReadFile(path); err != nil || !strings.Contains(string(content), "bravo") {
t.Fatalf("seen-range edit did not update the file: content=%q err=%v", content, err)
}
}
func TestCanonicalLimitedReadAuthorizesEdit(t *testing.T) {
dir := t.TempDir()
var source strings.Builder
for line := 1; line <= 1000; line++ {
source.WriteString("line ")
source.WriteString(strconv.Itoa(line))
source.WriteByte('\n')
}
path := filepath.Join(dir, "f.txt")
if err := os.WriteFile(path, []byte(source.String()), 0o644); err != nil {
t.Fatal(err)
}
registry := safetyRegistry(t, dir)
tracker := NewFileTracker()
opts := grantedOpts(tracker)
read := registry.RunWithOptions(context.Background(), "read_file", map[string]any{
"path": "f.txt", "offset": 500, "limit": 10,
}, opts)
if read.Status != StatusOK || read.Truncated {
t.Fatalf("bounded canonical read must be exact: status=%s truncated=%v meta=%#v", read.Status, read.Truncated, read.Meta)
}
if read.Meta["seen_lines"] != "500-509" {
t.Fatalf("bounded canonical read did not receive exact-read credit: %#v", read.Meta)
}
edit := registry.RunWithOptions(context.Background(), "edit_file", map[string]any{
"path": "f.txt", "old_string": "line 505", "new_string": "updated 505",
}, opts)
if edit.Status != StatusOK {
t.Fatalf("edit inside canonical read range was refused: %s", edit.Output)
}
}
func TestEditFileReplaceAllRecordsEveryShiftedReplacementSpan(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
if err := os.WriteFile(path, []byte("before\nneedle\nmiddle\nneedle\nafter\n"), 0o644); err != nil {
t.Fatal(err)
}
registry := safetyRegistry(t, dir)
tracker := NewFileTracker()
opts := grantedOpts(tracker)
if res := registry.RunWithOptions(context.Background(), "read_file", map[string]any{
"path": "f.txt", "start_line": 2, "end_line": 4,
}, opts); res.Status != StatusOK {
t.Fatalf("partial read failed: %q", res.Output)
}
if res := registry.RunWithOptions(context.Background(), "edit_file", map[string]any{
"path": "f.txt",
"old_string": "needle",
"new_string": "left\nright",
"replace_all": true,
}, opts); res.Status != StatusOK {
t.Fatalf("replace_all failed: %q", res.Output)
}
// This follow-up targets both generated "right" lines. It succeeds only if
// replace_all recorded the shifted second replacement as seen.
if res := registry.RunWithOptions(context.Background(), "edit_file", map[string]any{
"path": "f.txt",
"old_string": "right",
"new_string": "done",
"replace_all": true,
}, opts); res.Status != StatusOK {
t.Fatalf("follow-up edit of every replacement should succeed: %q", res.Output)
}
if content, err := os.ReadFile(path); err != nil || strings.Count(string(content), "done") != 2 {
t.Fatalf("unexpected follow-up content: content=%q err=%v", content, err)
}
}
func TestWriteFileCanOverwriteNewlyCreatedEmptyFile(t *testing.T) {
dir := t.TempDir()
registry := safetyRegistry(t, dir)
tracker := NewFileTracker()
opts := grantedOpts(tracker)
if res := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": "empty.txt", "content": "",
}, opts); res.Status != StatusOK {
t.Fatalf("empty create failed: %q", res.Output)
}
if res := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": "empty.txt", "content": "now populated\n", "overwrite": true,
}, opts); res.Status != StatusOK {
t.Fatalf("empty overwrite should succeed: %q", res.Output)
}
if content, err := os.ReadFile(filepath.Join(dir, "empty.txt")); err != nil || string(content) != "now populated\n" {
t.Fatalf("unexpected overwritten content: content=%q err=%v", content, err)
}
}
func TestMinifiedReadDoesNotAuthorizeExactEdit(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.go")
if err := os.WriteFile(path, []byte("package p\n\nvar value = 1\n"), 0o644); err != nil {
t.Fatal(err)
}
registry := NewRegistry()
registry.Register(NewScopedReadMinifiedFileTool(dir, nil))
registry.Register(NewScopedEditFileTool(dir, nil))
tracker := NewFileTracker()
opts := grantedOpts(tracker)
if res := registry.RunWithOptions(context.Background(), "read_minified_file", map[string]any{"path": "f.go"}, opts); res.Status != StatusOK {
t.Fatalf("minified read failed: %s", res.Output)
}
res := registry.RunWithOptions(context.Background(), "edit_file", map[string]any{
"path": "f.go", "old_string": "var value = 1", "new_string": "var value = 2",
}, opts)
if res.Status != StatusError || !strings.Contains(res.Output, "has not been read exactly") {
t.Fatalf("minified view must not authorize exact edit, got %q", res.Output)
}
}
+104 -2
View File
@@ -38,6 +38,108 @@ func TestReadFileToolReadsLineRanges(t *testing.T) {
}
}
func TestReadFileToolReadsCanonicalLineRange(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\ngamma\ndelta")
result := NewScopedReadFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "notes.txt",
"offset": 2,
"limit": 2,
})
if result.Status != StatusOK {
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
}
if result.Truncated {
t.Fatalf("canonical limit defines the requested range and must not mark it truncated: %#v", result.Meta)
}
if !strings.Contains(result.Output, "2 | beta") || !strings.Contains(result.Output, "3 | gamma") {
t.Fatalf("canonical range returned the wrong lines: %q", result.Output)
}
if strings.Contains(result.Output, "alpha") || strings.Contains(result.Output, "delta") {
t.Fatalf("canonical range leaked outside requested slice: %q", result.Output)
}
}
func TestReadFileToolCanonicalOffsetPastEnd(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta")
result := NewScopedReadFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "notes.txt", "offset": 10,
})
if result.Status != StatusOK || !strings.Contains(result.Output, "offset 10 is past the end") {
t.Fatalf("expected canonical out-of-range message, got status=%s output=%q", result.Status, result.Output)
}
if strings.Contains(result.Output, "start_line") {
t.Fatalf("out-of-range message exposed legacy argument: %q", result.Output)
}
}
func TestReadFileToolMixedLegacyRangesPreferLines(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\ngamma\ndelta")
result := NewScopedReadFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "notes.txt",
"start_line": 2,
"end_line": 3,
"max_lines": 2,
"byte_offset": 0,
"byte_limit": readFileByteChunkMax,
})
if result.Status != StatusOK {
t.Fatalf("mixed legacy range should recover, got %s: %s", result.Status, result.Output)
}
if !strings.Contains(result.Output, "2 | beta") || !strings.Contains(result.Output, "3 | gamma") {
t.Fatalf("mixed legacy range did not prefer lines: %q", result.Output)
}
}
func TestReadFileToolCombinesLegacyStartWithCanonicalLimit(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\ngamma\ndelta")
result := NewScopedReadFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "notes.txt", "start_line": 2, "limit": 2,
})
if result.Status != StatusOK || result.Truncated {
t.Fatalf("mixed compatible range should be exact, got status=%s truncated=%v: %s", result.Status, result.Truncated, result.Output)
}
if !strings.Contains(result.Output, "2 | beta") || !strings.Contains(result.Output, "3 | gamma") {
t.Fatalf("mixed compatible range returned the wrong lines: %q", result.Output)
}
if strings.Contains(result.Output, "alpha") || strings.Contains(result.Output, "delta") {
t.Fatalf("mixed compatible range leaked outside requested slice: %q", result.Output)
}
}
func TestFileToolSchemasExposeOnlyCanonicalArguments(t *testing.T) {
readProperties := NewScopedReadFileTool(t.TempDir(), nil).Parameters().Properties
for _, want := range []string{"path", "offset", "limit", "byte_offset", "byte_limit"} {
if _, ok := readProperties[want]; !ok {
t.Fatalf("read_file schema missing %q", want)
}
}
for _, legacy := range []string{"start_line", "end_line", "max_lines"} {
if _, ok := readProperties[legacy]; ok {
t.Fatalf("read_file schema must not expose legacy argument %q", legacy)
}
}
grepProperties := NewScopedGrepTool(t.TempDir(), nil).Parameters().Properties
if _, ok := grepProperties["case_insensitive"]; !ok {
t.Fatal("grep schema missing case_insensitive")
}
if _, ok := grepProperties["-i"]; ok {
t.Fatal("grep schema must not expose the -i alias")
}
}
func TestReadFileToolMarksTruncation(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "notes.txt"), "a\nb\nc\nd\ne")
@@ -52,7 +154,7 @@ func TestReadFileToolMarksTruncation(t *testing.T) {
}
// The cut must be visible in the rendered output, not just the Truncated flag,
// and must tell the model where to continue.
if !strings.Contains(result.Output, "[truncated:") || !strings.Contains(result.Output, "start_line=3") {
if !strings.Contains(result.Output, "[truncated:") || !strings.Contains(result.Output, "offset=3") {
t.Fatalf("expected truncation marker pointing to the next line, got %q", result.Output)
}
}
@@ -68,7 +170,7 @@ func TestReadFileToolAppliesByteBudget(t *testing.T) {
if result.Status != StatusOK || !result.Truncated {
t.Fatalf("expected ok+truncated, got status=%s truncated=%v", result.Status, result.Truncated)
}
if !strings.Contains(result.Output, "output exceeded") || !strings.Contains(result.Output, "start_line") {
if !strings.Contains(result.Output, "output exceeded") || !strings.Contains(result.Output, "offset/limit") {
t.Fatalf("expected byte-budget continuation hint, got %q", result.Output[len(result.Output)-200:])
}
if result.Meta["raw_bytes"] == "" || result.Meta["emitted_bytes"] == "" {
+227 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/hex"
"errors"
"os"
"sort"
"sync"
"time"
)
@@ -34,6 +35,7 @@ type FileVersion struct {
type FileTracker struct {
mu sync.Mutex
versions map[string]FileVersion
seen map[string]fileObservation
// created preserves the order in which brand-new files (paths that did not
// exist before a write tool created them) were first written this session.
// A map would lose that order and complicates de-duplication, so a slice
@@ -43,7 +45,36 @@ type FileTracker struct {
}
func NewFileTracker() *FileTracker {
return &FileTracker{versions: make(map[string]FileVersion)}
return &FileTracker{
versions: make(map[string]FileVersion),
seen: make(map[string]fileObservation),
}
}
type lineRange struct {
start int
end int
}
type fileObservation struct {
whole bool
// total is the file's line count as of the last recorded read, so coverage
// can be judged from the ranges alone. Without it a file read in pieces
// could never be known to have been seen in full.
total int
ranges []lineRange
totalBytes int
byteRanges []lineRange // zero-based, half-open byte intervals
}
type pendingFileObservation struct {
path string
output string
hash string
start int
end int
total int
byteMode bool
}
// Record stores the version of absPath given its content and optional stat info.
@@ -63,10 +94,200 @@ func (tracker *FileTracker) RecordHash(absPath string, hash string, info os.File
version.MTime = info.ModTime()
}
tracker.mu.Lock()
if previous, ok := tracker.versions[absPath]; !ok || previous.Hash != hash {
delete(tracker.seen, absPath)
}
tracker.versions[absPath] = version
tracker.mu.Unlock()
}
// RecordSeenRange records exact, non-elided source lines returned to the model.
// Ranges accumulate while the content hash remains unchanged.
func (tracker *FileTracker) RecordSeenRange(absPath string, start, end, total int) {
if tracker == nil || start < 1 {
return
}
tracker.mu.Lock()
defer tracker.mu.Unlock()
observation := tracker.seen[absPath]
if total == 0 {
observation.whole = true
observation.ranges = nil
tracker.seen[absPath] = observation
return
}
if end < start {
return
}
// A new line count means the file changed shape since the last read, so
// previously recorded ranges no longer describe it.
if observation.total != 0 && observation.total != total {
observation = fileObservation{}
}
observation.total = total
if start == 1 && end >= total {
observation.whole = true
observation.ranges = nil
tracker.seen[absPath] = observation
return
}
observation.ranges = append(observation.ranges, lineRange{start: start, end: end})
tracker.seen[absPath] = observation
}
// RecordSeenBytes records an exact, non-elided byte interval returned to the
// model. Byte intervals make oversized single-line files recoverable without
// crediting the model for the omitted middle of a truncated line read.
func (tracker *FileTracker) RecordSeenBytes(absPath string, start, end, total int) {
if tracker == nil || start < 0 || end < start || total < 0 || end > total {
return
}
tracker.mu.Lock()
defer tracker.mu.Unlock()
observation := tracker.seen[absPath]
if total == 0 {
observation.whole = true
observation.byteRanges = nil
tracker.seen[absPath] = observation
return
}
if observation.totalBytes != 0 && observation.totalBytes != total {
observation = fileObservation{}
}
observation.totalBytes = total
if start == 0 && end >= total {
observation.whole = true
observation.byteRanges = nil
tracker.seen[absPath] = observation
return
}
observation.byteRanges = append(observation.byteRanges, lineRange{start: start, end: end})
tracker.seen[absPath] = observation
}
// coversFully reports whether ranges together cover every line in [start, end].
//
// Ranges are merged rather than scanned line by line: a caller asking about a
// large file would otherwise walk every line against every recorded range, and
// the answer is the same.
func coversFully(ranges []lineRange, start int, end int) bool {
if start > end {
return true
}
if len(ranges) == 0 {
return false
}
sorted := make([]lineRange, len(ranges))
copy(sorted, ranges)
sort.Slice(sorted, func(left int, right int) bool {
if sorted[left].start != sorted[right].start {
return sorted[left].start < sorted[right].start
}
return sorted[left].end < sorted[right].end
})
next := start
for _, seen := range sorted {
if seen.start > next {
return false
}
if seen.end >= next {
next = seen.end + 1
}
if next > end {
return true
}
}
return next > end
}
// coversBytes reports whether half-open byte ranges cover [start, end).
func coversBytes(ranges []lineRange, start, end int) bool {
if start >= end {
return true
}
if len(ranges) == 0 {
return false
}
sorted := make([]lineRange, len(ranges))
copy(sorted, ranges)
sort.Slice(sorted, func(left, right int) bool {
if sorted[left].start != sorted[right].start {
return sorted[left].start < sorted[right].start
}
return sorted[left].end < sorted[right].end
})
next := start
for _, seen := range sorted {
if seen.start > next {
return false
}
if seen.end > next {
next = seen.end
}
if next >= end {
return true
}
}
return false
}
// SeenRange reports whether every line in the requested range was returned
// exactly to the model for the currently tracked version.
func (tracker *FileTracker) SeenRange(absPath string, start, end int) bool {
if tracker == nil {
return true
}
tracker.mu.Lock()
defer tracker.mu.Unlock()
observation, ok := tracker.seen[absPath]
if !ok {
return false
}
if observation.whole {
return true
}
return coversFully(observation.ranges, start, end)
}
// SeenBytes reports whether every byte in [start, end) was returned exactly to
// the model for the currently tracked version.
func (tracker *FileTracker) SeenBytes(absPath string, start, end int) bool {
if tracker == nil {
return true
}
tracker.mu.Lock()
defer tracker.mu.Unlock()
observation, ok := tracker.seen[absPath]
if !ok {
return false
}
return observation.whole || coversBytes(observation.byteRanges, start, end)
}
func (tracker *FileTracker) SeenWhole(absPath string) bool {
if tracker == nil {
return true
}
tracker.mu.Lock()
defer tracker.mu.Unlock()
observation, ok := tracker.seen[absPath]
if !ok {
return false
}
if observation.whole {
return true
}
// Derived from the ranges rather than tracked as its own flag. The flag was
// only ever set by a SINGLE read covering the file, so a file read in two
// halves stayed "not seen whole" forever even though SeenRange agreed every
// line had been seen — and write_file, which gates on this, then refused the
// overwrite with advice to read the file that could not change the answer.
if observation.total > 0 && coversFully(observation.ranges, 1, observation.total) {
return true
}
return observation.totalBytes > 0 && coversBytes(observation.byteRanges, 0, observation.totalBytes)
}
// Version returns the recorded version for absPath and whether one exists.
func (tracker *FileTracker) Version(absPath string) (FileVersion, bool) {
if tracker == nil {
@@ -119,6 +340,7 @@ func (tracker *FileTracker) Forget(absPath string) {
}
tracker.mu.Lock()
delete(tracker.versions, absPath)
delete(tracker.seen, absPath)
tracker.mu.Unlock()
}
@@ -153,3 +375,7 @@ func fileConflictMessage(relativePath string) string {
return "Error writing " + relativePath + ": " + ErrFileChangedOnDisk.Error() +
" (it may have been edited outside Zero). Re-read it with read_file, then re-apply your change so you do not overwrite the newer content."
}
func fileUnseenMessage(relativePath string) string {
return "Error writing " + relativePath + ": the intended change depends on content that has not been read exactly in this session. Read the affected lines with read_file using offset/limit (or byte_offset/byte_limit for a very long line), then retry the edit."
}
@@ -0,0 +1,96 @@
package tools
import (
"path/filepath"
"testing"
)
// SeenWhole used to be a flag set only by a SINGLE read that covered the file,
// while SeenRange judged coverage from the accumulated ranges. The two then
// disagreed about the same reads, and write_file — which gates on SeenWhole —
// refused overwrites it should have allowed.
func TestSeenWholeIsDerivedFromAccumulatedRanges(t *testing.T) {
path := filepath.Join(t.TempDir(), "f.go")
for name, testCase := range map[string]struct {
reads [][3]int // start, end, total
want bool
}{
"one read covering the file": {
reads: [][3]int{{1, 100, 100}},
want: true,
},
"two adjacent reads covering it together": {
reads: [][3]int{{1, 50, 100}, {51, 100, 100}},
want: true,
},
"overlapping reads covering it together": {
reads: [][3]int{{1, 60, 100}, {40, 100, 100}},
want: true,
},
"out-of-order reads covering it together": {
reads: [][3]int{{51, 100, 100}, {1, 50, 100}},
want: true,
},
"a gap in the middle": {
reads: [][3]int{{1, 40, 100}, {60, 100, 100}},
want: false,
},
"missing the first line": {
reads: [][3]int{{2, 100, 100}},
want: false,
},
"missing the last line": {
reads: [][3]int{{1, 99, 100}},
want: false,
},
"nothing read at all": {
reads: nil,
want: false,
},
} {
t.Run(name, func(t *testing.T) {
tracker := NewFileTracker()
for _, read := range testCase.reads {
tracker.RecordSeenRange(path, read[0], read[1], read[2])
}
if got := tracker.SeenWhole(path); got != testCase.want {
t.Errorf("SeenWhole = %v, want %v after reads %v", got, testCase.want, testCase.reads)
}
})
}
}
// SeenWhole and SeenRange must not disagree about the same reads.
func TestSeenWholeAgreesWithSeenRange(t *testing.T) {
path := filepath.Join(t.TempDir(), "f.go")
tracker := NewFileTracker()
tracker.RecordSeenRange(path, 1, 50, 100)
tracker.RecordSeenRange(path, 51, 100, 100)
if !tracker.SeenRange(path, 1, 100) {
t.Fatal("SeenRange says the whole file was not seen")
}
if !tracker.SeenWhole(path) {
t.Error("SeenRange covers every line but SeenWhole disagrees")
}
}
// A file that changed length since the recorded reads must not stay "seen":
// the old ranges describe a shape the file no longer has.
func TestSeenWholeResetsWhenTheFileChangesLength(t *testing.T) {
path := filepath.Join(t.TempDir(), "f.go")
tracker := NewFileTracker()
tracker.RecordSeenRange(path, 1, 100, 100)
if !tracker.SeenWhole(path) {
t.Fatal("precondition: the whole file was read")
}
// The file grew; only the first 100 lines of 200 have been seen.
tracker.RecordSeenRange(path, 1, 100, 200)
if tracker.SeenWhole(path) {
t.Error("file grew to 200 lines but is still reported as seen whole")
}
if !tracker.SeenRange(path, 1, 100) {
t.Error("the lines that were read should still count as seen")
}
}
@@ -0,0 +1,263 @@
package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"unicode/utf8"
)
// A file past the read byte budget cannot be read whole in one call: the read is
// truncated and RecordSeenRange is skipped, which is correct — the model did not
// see those lines. But SeenWhole was a flag only a single whole-file read could
// set, so no sequence of scoped reads could ever clear it either, and write_file
// refused the overwrite forever while telling the model to read the file again.
func TestLargeFileBecomesWritableAfterScopedReadsCoverIt(t *testing.T) {
dir := t.TempDir()
// read_file resolves the workspace root through EvalSymlinks before it
// records anything, so the tracker is keyed on the resolved path. On macOS
// t.TempDir() sits under /var, a symlink to /private/var, and asserting
// against the raw path would look up a key that never existed — passing on
// Windows and failing there.
resolvedDir, err := filepath.EvalSymlinks(dir)
if err != nil {
t.Fatalf("resolve temp dir: %v", err)
}
path := filepath.Join(resolvedDir, "big.go")
const lines = 2400
var builder strings.Builder
for index := 0; index < lines; index++ {
fmt.Fprintf(&builder, "line %06d %s\n", index, strings.Repeat("x", 60))
}
if writeErr := os.WriteFile(path, []byte(builder.String()), 0o600); writeErr != nil {
t.Fatalf("seed file: %v", writeErr)
}
tracker := NewFileTracker()
options := RunOptions{FileTracker: tracker}
ctx := context.Background()
read := NewReadFileTool(dir).(interface {
RunWithOptions(context.Context, map[string]any, RunOptions) Result
})
write := NewScopedWriteFileTool(dir, nil).(interface {
RunWithOptions(context.Context, map[string]any, RunOptions) Result
})
// One unbounded read is byte-truncated, so nothing is recorded.
full := read.RunWithOptions(ctx, map[string]any{"path": "big.go"}, options)
if full.Meta["truncation_reason"] != "byte_budget" {
t.Fatalf("precondition: expected the file to exceed the read byte budget, got reason %q", full.Meta["truncation_reason"])
}
if tracker.SeenWhole(path) {
t.Fatal("a truncated read must not credit the model with the whole file")
}
// Scoped reads that together cover it must.
for _, span := range [][2]int{{1, 1200}, {1201, 2400}} {
result := read.RunWithOptions(ctx, map[string]any{
"path": "big.go", "start_line": span[0], "end_line": span[1],
}, options)
if result.Status != StatusOK {
t.Fatalf("scoped read %v: %s", span, result.Output)
}
if result.Meta["truncation_reason"] == "byte_budget" {
t.Fatalf("scoped read %v was itself truncated; pick smaller spans", span)
}
}
if !tracker.SeenWhole(path) {
t.Fatal("scoped reads covered every line but the file is still not seen whole")
}
overwrite := write.RunWithOptions(ctx, map[string]any{
"path": "big.go", "content": "replaced\n", "overwrite": true,
}, options)
if overwrite.Status != StatusOK {
t.Fatalf("overwrite refused after the file was fully read in scoped reads: %s", overwrite.Output)
}
}
func TestSingleLongLineBecomesWritableAfterExactByteReads(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bundle.min.js")
content := strings.Repeat("x", readOutputBudgetBytes*3)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("seed file: %v", err)
}
resolvedPath, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatalf("resolve file: %v", err)
}
tracker := NewFileTracker()
options := RunOptions{FileTracker: tracker}
ctx := context.Background()
read := NewReadFileTool(dir).(interface {
RunWithOptions(context.Context, map[string]any, RunOptions) Result
})
write := NewScopedWriteFileTool(dir, nil).(interface {
RunWithOptions(context.Context, map[string]any, RunOptions) Result
})
full := read.RunWithOptions(ctx, map[string]any{"path": "bundle.min.js"}, options)
if full.Meta["truncation_reason"] != "byte_budget" {
t.Fatalf("precondition: expected byte truncation, got %#v", full.Meta)
}
if tracker.SeenWhole(resolvedPath) {
t.Fatal("a truncated line read must not credit the whole file")
}
const chunkSize = 64 * 1024
for offset := 0; offset < len(content); offset += chunkSize {
result := read.RunWithOptions(ctx, map[string]any{
"path": "bundle.min.js",
"byte_offset": offset,
"byte_limit": chunkSize,
}, options)
if result.Status != StatusOK || result.Truncated {
t.Fatalf("byte read at %d was not exact: status=%s truncated=%v output=%q", offset, result.Status, result.Truncated, result.Output)
}
}
if !tracker.SeenWhole(resolvedPath) {
t.Fatal("exact byte reads covered the file but it is not seen whole")
}
overwrite := write.RunWithOptions(ctx, map[string]any{
"path": "bundle.min.js", "content": "replaced\n", "overwrite": true,
}, options)
if overwrite.Status != StatusOK {
t.Fatalf("overwrite refused after exact byte reads: %s", overwrite.Output)
}
}
func TestExactByteReadsAdvanceAlongUTF8Boundaries(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "unicode.min.js")
content := strings.Repeat("😀", 40_000)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
resolvedPath, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
tracker := NewFileTracker()
options := RunOptions{FileTracker: tracker}
read := NewReadFileTool(dir).(interface {
RunWithOptions(context.Context, map[string]any, RunOptions) Result
})
offset := 0
for offset < len(content) {
result := read.RunWithOptions(context.Background(), map[string]any{
"path": "unicode.min.js", "byte_offset": offset, "byte_limit": 65_535,
}, options)
if result.Status != StatusOK || result.Truncated || !utf8.ValidString(result.Output) {
t.Fatalf("invalid exact UTF-8 read at %d: %#v", offset, result)
}
next, parseErr := strconv.Atoi(result.Meta["next_byte_offset"])
if parseErr != nil || next <= offset {
t.Fatalf("invalid continuation offset %q after %d", result.Meta["next_byte_offset"], offset)
}
offset = next
}
if !tracker.SeenWhole(resolvedPath) {
t.Fatal("UTF-8-safe byte reads covered the file but it is not seen whole")
}
}
func TestRegistryTruncationDoesNotCreditUndeliveredByteRanges(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "unicode.min.js")
content := strings.Repeat("界", 84_000)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
resolvedPath, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
registry := safetyRegistry(t, dir)
tracker := NewFileTracker()
result := registry.RunWithOptions(context.Background(), "read_file", map[string]any{
"path": "unicode.min.js", "byte_offset": 0, "byte_limit": readFileByteChunkMax,
}, grantedOpts(tracker))
if !result.Truncated {
t.Fatalf("precondition: multibyte chunk was not truncated at the registry boundary: %#v", result.Meta)
}
next, err := strconv.Atoi(result.Meta["next_byte_offset"])
if err != nil || next <= 0 {
t.Fatalf("invalid continuation offset %q", result.Meta["next_byte_offset"])
}
if tracker.SeenBytes(resolvedPath, 0, next) || tracker.SeenWhole(resolvedPath) {
t.Fatal("registry-truncated bytes were credited as delivered to the model")
}
}
func TestRegistryTruncationDoesNotCreditWholeLineRead(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "unicode.go")
content := strings.Repeat("界界界界界界界界\n", 4_000)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
resolvedPath, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
registry := safetyRegistry(t, dir)
tracker := NewFileTracker()
result := registry.RunWithOptions(context.Background(), "read_file", map[string]any{
"path": "unicode.go",
}, grantedOpts(tracker))
if !result.Truncated {
t.Fatalf("precondition: multibyte line read was not truncated at the registry boundary: %#v", result.Meta)
}
if tracker.SeenWhole(resolvedPath) {
t.Fatal("registry-truncated line output credited the whole file")
}
}
func TestPartialByteEditDoesNotCreditAnEntireLongLine(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bundle.min.js")
content := "UNIQUE_TARGET" + strings.Repeat("x", readOutputBudgetBytes*2)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
resolvedPath, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
registry := safetyRegistry(t, dir)
tracker := NewFileTracker()
opts := grantedOpts(tracker)
read := registry.RunWithOptions(context.Background(), "read_file", map[string]any{
"path": "bundle.min.js", "byte_offset": 0, "byte_limit": 64,
}, opts)
if read.Status != StatusOK || read.Truncated {
t.Fatalf("exact byte read failed: %#v", read)
}
edit := registry.RunWithOptions(context.Background(), "edit_file", map[string]any{
"path": "bundle.min.js", "old_string": "UNIQUE_TARGET", "new_string": "UPDATED_TARGET",
}, opts)
if edit.Status != StatusOK {
t.Fatalf("edit of exactly observed bytes failed: %s", edit.Output)
}
if tracker.SeenWhole(resolvedPath) {
t.Fatal("editing observed bytes credited the unread remainder of the long line")
}
overwrite := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": "bundle.min.js", "content": "replacement", "overwrite": true,
}, opts)
if overwrite.Status != StatusError {
t.Fatalf("whole overwrite should remain blocked after a partial byte read, got %#v", overwrite)
}
}
+25 -3
View File
@@ -61,15 +61,37 @@ func TestFormatOnWriteFormatsAndKeepsTrackerConsistent(t *testing.T) {
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.
// The tracker is re-baselined to the post-format bytes, but those bytes were
// not returned exactly to the model, so an edit must require an exact read.
edit := NewScopedEditFileTool(dir, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": "a.go",
"old_string": "func A() {",
"new_string": "func B() {",
}, RunOptions{FileTracker: tracker})
if edit.Status != StatusError || !strings.Contains(edit.Output, "has not been read exactly") {
t.Fatalf("formatter-modified content must require an exact read: %q", edit.Output)
}
read := NewScopedReadFileTool(dir, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": "a.go",
}, RunOptions{FileTracker: tracker})
if read.Status != StatusOK {
t.Fatalf("exact read failed: %q", read.Output)
}
edit = NewScopedEditFileTool(dir, nil).(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)
t.Fatalf("follow-up edit after exact read failed: %q", edit.Output)
}
edit = NewScopedEditFileTool(dir, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": "a.go",
"old_string": "func B() {",
"new_string": "func C() {",
}, RunOptions{FileTracker: tracker})
if edit.Status != StatusError || !strings.Contains(edit.Output, "has not been read exactly") {
t.Fatalf("formatter-modified edit must require an exact read: %q", edit.Output)
}
}
-1
View File
@@ -48,7 +48,6 @@ func NewScopedGrepTool(workspaceRoot string, scope PathScope) Tool {
"path": {Type: "string", Description: "Directory or file to search. Relative paths stay in the workspace; use an absolute path to search a granted extra root. Defaults to workspace root.", Default: "."},
"glob": {Type: "string", Description: `Optional glob filter, for example "**/*.go".`},
"output_mode": {Type: "string", Description: "Output mode.", Enum: []string{"content", "files_with_matches", "count"}, Default: "content"},
"-i": {Type: "boolean", Description: "Case insensitive search.", Default: false},
"case_insensitive": {Type: "boolean", Description: "Case insensitive search.", Default: false},
"head_limit": {Type: "integer", Description: "Maximum content results to return.", Default: 50, Minimum: intPtr(1)},
},
+9
View File
@@ -45,6 +45,7 @@ func newBrowserInstallTool(options localcontrol.BrowserOptions) Tool {
return browserInstallTool{
baseTool: baseTool{
name: "browser_install",
deferred: true,
description: "Install the local browser runtime used by browser automation.",
parameters: Schema{
Type: "object",
@@ -86,6 +87,7 @@ func newBrowserLaunchToolWithLauncher(options localcontrol.BrowserOptions, launc
return browserLaunchTool{
baseTool: baseTool{
name: "browser_launch",
deferred: true,
description: "Launch a supported local Chromium/Electron app with Chrome DevTools enabled and attach browser automation to it. Use this for installed Electron apps such as Discord instead of launching GUI apps through shell commands.",
parameters: Schema{
Type: "object",
@@ -165,6 +167,7 @@ func newBrowserConnectTool(options localcontrol.BrowserOptions) Tool {
return browserConnectTool{
baseTool: baseTool{
name: "browser_connect",
deferred: true,
description: "Attach local browser automation to an existing Chrome/Electron DevTools endpoint. For Electron apps, first launch the app with --remote-debugging-port=<port>, then connect to that port instead of using desktop control.",
parameters: Schema{
Type: "object",
@@ -205,6 +208,7 @@ func newBrowserOpenTool(options localcontrol.BrowserOptions) Tool {
return browserOpenTool{
baseTool: baseTool{
name: "browser_open",
deferred: true,
description: "Open a URL in the local browser automation session.",
parameters: Schema{
Type: "object",
@@ -245,6 +249,7 @@ func newBrowserSnapshotTool(options localcontrol.BrowserOptions) Tool {
return browserSnapshotTool{
baseTool: baseTool{
name: "browser_snapshot",
deferred: true,
description: "Return an accessibility snapshot from the current local browser automation session.",
parameters: Schema{
Type: "object",
@@ -288,6 +293,7 @@ func newBrowserClickTool(options localcontrol.BrowserOptions) Tool {
return browserClickTool{
baseTool: baseTool{
name: "browser_click",
deferred: true,
description: "Click a ref from browser_snapshot in the current local browser automation session.",
parameters: Schema{
Type: "object",
@@ -328,6 +334,7 @@ func newBrowserTypeTool(options localcontrol.BrowserOptions) Tool {
return browserTypeTool{
baseTool: baseTool{
name: "browser_type",
deferred: true,
description: "Type text into a ref from browser_snapshot in the current local browser automation session.",
parameters: Schema{
Type: "object",
@@ -369,6 +376,7 @@ func newBrowserPressTool(options localcontrol.BrowserOptions) Tool {
return browserPressTool{
baseTool: baseTool{
name: "browser_press",
deferred: true,
description: "Press a keyboard key in the current local browser automation session.",
parameters: Schema{
Type: "object",
@@ -454,6 +462,7 @@ func newBrowserActionTool(options localcontrol.BrowserOptions) Tool {
return browserActionTool{
baseTool: baseTool{
name: "browser_action",
deferred: true,
description: "Run an allowed action against the current local browser automation session.",
parameters: Schema{
Type: "object",
+12
View File
@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/Gitlawb/zero/internal/imageinput"
"github.com/Gitlawb/zero/internal/zeroruntime"
"os"
"path/filepath"
"regexp"
@@ -40,6 +42,7 @@ func newCaptureArtifactTool(options LocalControlArtifactOptions) Tool {
return captureArtifactTool{
baseTool: baseTool{
name: "capture_artifact",
deferred: true,
description: "Capture local browser, desktop, or terminal state into the configured artifact directory.",
parameters: Schema{
Type: "object",
@@ -344,9 +347,18 @@ func captureOKResult(driver string, request captureArtifactRequest, path string,
if helperOutput != "" {
output += "\n\n" + helperOutput
}
// Attach the artifact itself. Without this the model is told a screenshot
// exists and cannot look at it, which is the whole point of asking for one.
// A PDF or an unreadable file simply yields no image and the text stands on
// its own, so a capture is never failed over the attachment.
var images []zeroruntime.ImageBlock
if image, err := imageinput.LoadFile(path, ""); err == nil {
images = []zeroruntime.ImageBlock{image}
}
return Result{
Status: StatusOK,
Output: output,
Images: images,
Meta: map[string]string{
"artifact_path": path,
"action": request.action,
+3
View File
@@ -27,6 +27,7 @@ func newDesktopWindowsTool(options localcontrol.DesktopOptions) Tool {
return desktopWindowsTool{
baseTool: baseTool{
name: "desktop_windows",
deferred: true,
description: "List native desktop windows through the local desktop automation helper.",
parameters: Schema{
Type: "object",
@@ -70,6 +71,7 @@ func newDesktopSnapshotTool(options localcontrol.DesktopOptions) Tool {
return desktopSnapshotTool{
baseTool: baseTool{
name: "desktop_snapshot",
deferred: true,
description: "Read a native desktop window accessibility snapshot through the local desktop automation helper.",
parameters: Schema{
Type: "object",
@@ -117,6 +119,7 @@ func newDesktopActionTool(options localcontrol.DesktopOptions) Tool {
return desktopActionTool{
baseTool: baseTool{
name: "desktop_action",
deferred: true,
description: "Run an allowed native desktop action through the local desktop automation helper.",
parameters: Schema{
Type: "object",
+1
View File
@@ -23,6 +23,7 @@ func newTerminalSessionTool(options localcontrol.TerminalOptions) Tool {
return terminalSessionTool{
baseTool: baseTool{
name: "terminal_session",
deferred: true,
description: "Launch or control a local virtual terminal session through the local terminal automation helper.",
parameters: Schema{
Type: "object",
+2 -1
View File
@@ -32,7 +32,8 @@ func NewScopedLSPNavigateTool(workspaceRoot string, scope PathScope) Tool {
root := normalizeWorkspaceRoot(workspaceRoot)
return lspNavigateTool{
baseTool: baseTool{
name: "lsp_navigate",
name: "lsp_navigate",
deferred: true,
description: "Navigate code semantically via the language server: jump to a symbol's " +
"definition, find all references, find implementations of an interface/method, or " +
"search workspace symbols by name. Use this instead of grep when you need precise " +
+4 -1
View File
@@ -57,6 +57,9 @@ func applySelfManagedOutputBudget(tool Tool, toolName string, args map[string]an
category := resolveOutputCategory(tool, toolName, args)
budgeted := budgetSemanticOutput(result.Output, category, budget)
if result.Truncated && budgeted.spillPath == "" {
budgeted.spillPath = result.Meta["spill_path"]
}
if result.Truncated && !budgeted.truncated {
budgeted.truncated = true
budgeted.reason = result.Meta["truncation_reason"]
@@ -64,7 +67,7 @@ func applySelfManagedOutputBudget(tool Tool, toolName string, args map[string]an
budgeted.reason = "upstream_tool_budget"
}
}
if budgeted.truncated {
if budgeted.truncated && budgeted.spillPath == "" {
budgeted = attachExistingSpill(toolName, result.Output, budget, budgeted)
}
result.Output = budgeted.text
+151 -14
View File
@@ -10,8 +10,11 @@ import (
"os"
"strconv"
"strings"
"unicode/utf8"
)
const readFileByteChunkMax = 64 * 1024
type readFileTool struct {
baseTool
workspaceRoot string
@@ -28,14 +31,15 @@ func NewScopedReadFileTool(workspaceRoot string, scope PathScope) Tool {
return readFileTool{
baseTool: baseTool{
name: "read_file",
description: "Read a file with optional 1-based inclusive line range and max line cap.",
description: "Read exact file text with line numbers. Use for comments, formatting, or edits; prefer read_minified_file for initial code understanding. Use offset and limit for a line range.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"path": {Type: "string", Description: "Path of the file to read."},
"start_line": {Type: "integer", Description: "1-based inclusive line number to start reading from.", Minimum: intPtr(1)},
"end_line": {Type: "integer", Description: "1-based inclusive line number to stop reading at.", Minimum: intPtr(1)},
"max_lines": {Type: "integer", Description: "Maximum number of lines to return.", Minimum: intPtr(1)},
"path": {Type: "string", Description: "File path."},
"offset": {Type: "integer", Description: "Optional 1-based source line to start from.", Minimum: intPtr(1)},
"limit": {Type: "integer", Description: "Optional number of source lines to return.", Minimum: intPtr(1)},
"byte_offset": {Type: "integer", Description: "Optional zero-based offset for exact byte reads.", Minimum: intPtr(0)},
"byte_limit": {Type: "integer", Description: "Optional byte count for an exact byte read; use with byte_offset.", Minimum: intPtr(1), Maximum: intPtr(readFileByteChunkMax)},
},
Required: []string{"path"},
AdditionalProperties: false,
@@ -64,7 +68,27 @@ func (tool readFileTool) run(args map[string]any, options RunOptions, directBudg
if err != nil {
return errorResult("Error: Invalid arguments for read_file: " + err.Error())
}
startLine, err := intArg(args, "start_line", 1, 1, 0)
_, hasOffset := args["offset"]
_, hasLimit := args["limit"]
_, hasStartLine := args["start_line"]
_, hasEndLine := args["end_line"]
_, hasMaxLines := args["max_lines"]
if hasOffset && hasStartLine {
return errorResult("Error: Invalid arguments for read_file: use either offset or start_line, not both")
}
if hasLimit && (hasEndLine || hasMaxLines) {
return errorResult("Error: Invalid arguments for read_file: use limit instead of end_line or max_lines, not both")
}
startKey := "offset"
if !hasOffset && hasStartLine {
startKey = "start_line"
}
startLine, err := intArg(args, startKey, 1, 1, 0)
if err != nil {
return errorResult("Error: Invalid arguments for read_file: " + err.Error())
}
limit, err := intArg(args, "limit", 0, 1, 0)
if err != nil {
return errorResult("Error: Invalid arguments for read_file: " + err.Error())
}
@@ -76,6 +100,26 @@ func (tool readFileTool) run(args map[string]any, options RunOptions, directBudg
if err != nil {
return errorResult("Error: Invalid arguments for read_file: " + err.Error())
}
if hasLimit {
const maxInt = int(^uint(0) >> 1)
endLine = startLine + min(limit-1, maxInt-startLine)
}
hasLineRange := hasOffset || hasLimit || hasStartLine || hasEndLine || hasMaxLines
_, hasByteOffset := args["byte_offset"]
_, hasByteLimit := args["byte_limit"]
byteMode := (hasByteOffset || hasByteLimit) && !hasLineRange
byteOffset := 0
byteLimit := readFileByteChunkMax
if byteMode {
byteOffset, err = intArg(args, "byte_offset", 0, 0, 0)
if err != nil {
return errorResult("Error: Invalid arguments for read_file: " + err.Error())
}
byteLimit, err = intArg(args, "byte_limit", readFileByteChunkMax, 1, readFileByteChunkMax)
if err != nil {
return errorResult("Error: Invalid arguments for read_file: " + err.Error())
}
}
absolutePath, relativePath, err := resolveScopedReadPath(tool.workspaceRoot, tool.scope, requestedPath)
if err != nil {
@@ -91,17 +135,66 @@ func (tool readFileTool) run(args map[string]any, options RunOptions, directBudg
// Stat is best-effort: a missing FileInfo only drops the diagnostic size/mtime,
// not the authoritative content hash.
options.FileTracker.RecordHash(absolutePath, stats.hash, stats.info)
if byteMode {
result, seenStart, seenEnd := renderReadFileBytes(absolutePath, relativePath, stats.bytes, byteOffset, byteLimit)
if result.Status == StatusOK && !result.Truncated && seenEnd > seenStart {
if options.deferFileObservation {
result.pendingFileObservation = &pendingFileObservation{
path: absolutePath, output: result.Output, hash: stats.hash,
start: seenStart, end: seenEnd, total: stats.bytes, byteMode: true,
}
} else {
options.FileTracker.RecordSeenBytes(absolutePath, seenStart, seenEnd, stats.bytes)
result.Meta["file_version"] = stats.hash
result.Meta["seen_bytes"] = fmt.Sprintf("%d-%d", seenStart, seenEnd)
}
}
return result
}
maxBytes := 0
if directBudget {
maxBytes = readOutputBudgetBytes
}
return renderReadFileRange(absolutePath, relativePath, stats.lines, startLine, endLine, maxLines, maxBytes)
result := renderReadFileRange(absolutePath, relativePath, stats.lines, startLine, endLine, maxLines, maxBytes)
if result.Status == StatusOK && result.Meta["truncation_reason"] != "byte_budget" {
seenStart, seenEnd := renderedReadRange(stats.lines, startLine, endLine, maxLines)
if options.deferFileObservation {
result.pendingFileObservation = &pendingFileObservation{
path: absolutePath, output: result.Output, hash: stats.hash,
start: seenStart, end: seenEnd, total: stats.lines,
}
} else {
options.FileTracker.RecordSeenRange(absolutePath, seenStart, seenEnd, stats.lines)
if result.Meta == nil {
result.Meta = map[string]string{}
}
result.Meta["file_version"] = stats.hash
result.Meta["seen_lines"] = fmt.Sprintf("%d-%d", seenStart, seenEnd)
}
}
return result
}
func renderedReadRange(total, start, end, maxLines int) (int, int) {
if start > total {
return start, start
}
if end == 0 || end > total {
end = total
}
if end < start {
end = start
}
if maxLines > 0 && end-start+1 > maxLines {
end = start + maxLines - 1
}
return start, end
}
func renderReadFileRange(absolutePath string, relativePath string, total int, startLine int, endLine int, maxLines int, maxBytes int) Result {
if startLine > total {
return okResult(fmt.Sprintf("File: %s\n(start_line %d is past the end of the file, which has %d lines)", relativePath, startLine, total))
return okResult(fmt.Sprintf("File: %s\n(offset %d is past the end of the file, which has %d lines)", relativePath, startLine, total))
}
if endLine == 0 || endLine > total {
endLine = total
@@ -140,9 +233,9 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
// Tool.Run calls preserve the legacy prefix-only byte budget.
var budgetedOutput *outputBudgetBuilder
if maxBytes > 0 {
budgetedOutput = newOutputBudgetBuilder(maxBytes, "use start_line/end_line or max_lines to continue with a smaller range")
budgetedOutput = newOutputBudgetBuilder(maxBytes, "use offset/limit to request a smaller line range")
} else {
budgetedOutput = newHeadTailOutputBudgetBuilder(readOutputBudgetBytes, "use start_line/end_line or max_lines to continue with a smaller range")
budgetedOutput = newHeadTailOutputBudgetBuilder(readOutputBudgetBytes, "use offset/limit to request a smaller line range")
}
budgetedOutput.WriteString(header)
budgetedOutput.WriteString("\n")
@@ -156,9 +249,9 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
}
if truncated {
// The Truncated flag alone is invisible to the model in the rendered
// output, so it cannot tell a max_lines cut from a complete read. Make the
// output, so it cannot tell a limit cut from a complete read. Make the
// cut explicit and tell it how to continue.
budgetedOutput.WriteString(fmt.Sprintf("\n\n[truncated: %d more line(s) in the requested range not shown; set start_line=%d to continue]", endLine-lastLine, lastLine+1))
budgetedOutput.WriteString(fmt.Sprintf("\n\n[truncated: %d more line(s) in the requested range not shown; set offset=%d to continue]", endLine-lastLine, lastLine+1))
}
budgeted := budgetedOutput.Result()
@@ -173,7 +266,7 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
if budgeted.Truncated {
meta["truncation_reason"] = "byte_budget"
} else {
meta["truncation_reason"] = "max_lines"
meta["truncation_reason"] = "limit"
}
}
return Result{
@@ -186,6 +279,7 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
type readFileStats struct {
lines int
bytes int
hash string
info os.FileInfo
}
@@ -200,6 +294,7 @@ func scanReadFileStats(path string) (readFileStats, error) {
hasher := sha256.New()
reader := bufio.NewReader(file)
lines := 0
bytes := 0
for {
raw, _, err := readRawLine(reader)
if err == io.EOF {
@@ -211,13 +306,55 @@ func scanReadFileStats(path string) (readFileStats, error) {
if _, err := hasher.Write(raw); err != nil {
return readFileStats{}, err
}
bytes += len(raw)
lines++
}
if lines == 0 {
lines = 1
}
info, _ := file.Stat()
return readFileStats{lines: lines, hash: hex.EncodeToString(hasher.Sum(nil)), info: info}, nil
return readFileStats{lines: lines, bytes: bytes, hash: hex.EncodeToString(hasher.Sum(nil)), info: info}, nil
}
func renderReadFileBytes(path, relativePath string, total, requestedStart, limit int) (Result, int, int) {
if requestedStart >= total {
return okResult(fmt.Sprintf("File: %s\n(byte_offset %d is past the end of the file, which has %d bytes)", relativePath, requestedStart, total)), 0, 0
}
file, err := os.Open(path)
if err != nil {
return errorResult("Error reading file " + relativePath + ": " + err.Error()), 0, 0
}
defer file.Close()
want := min(limit, total-requestedStart)
data := make([]byte, want)
read, err := file.ReadAt(data, int64(requestedStart))
if err != nil && err != io.EOF {
return errorResult("Error reading file " + relativePath + ": " + err.Error()), 0, 0
}
data = data[:read]
start := requestedStart
for len(data) > 0 && data[0]&0xc0 == 0x80 {
data = data[1:]
start++
}
for trim := 0; !utf8.Valid(data) && trim < utf8.UTFMax && len(data) > 0; trim++ {
data = data[:len(data)-1]
}
if !utf8.Valid(data) {
return errorResult("Error reading file " + relativePath + ": exact byte mode requires UTF-8 text"), 0, 0
}
end := start + len(data)
if end <= start {
return errorResult("Error reading file " + relativePath + ": byte_limit is too small to return the next UTF-8 character"), 0, 0
}
header := fmt.Sprintf("File: %s (bytes %d-%d of %d)", relativePath, start, end-1, total)
output := header + "\n\n" + string(data)
if end < total {
output += fmt.Sprintf("\n\n[continue with byte_offset=%d]", end)
}
return Result{Status: StatusOK, Output: output, Meta: map[string]string{"next_byte_offset": strconv.Itoa(end)}}, start, end
}
func appendReadFileRange(output *outputBudgetBuilder, path string, startLine int, selectedLines int, width int) error {
+71 -7
View File
@@ -28,11 +28,13 @@ func NewScopedReadMinifiedFileTool(workspaceRoot string, scope PathScope) Tool {
return readMinifiedFileTool{
baseTool: baseTool{
name: "read_minified_file",
description: "Read a source file in a dense, token-cheap form: comments and redundant whitespace removed, no line numbers. Use it to scan or understand code for far fewer tokens than read_file. For exact text, comments, line numbers, or before editing, use read_file instead.",
description: "Read source code in a token-efficient, language-aware form. Prefer this for initial understanding; use read_file for exact text or line numbers.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"path": {Type: "string", Description: "Path of the file to read in minified form."},
"path": {Type: "string", Description: "Source file path."},
"offset": {Type: "integer", Description: "Optional 1-based source line to start from.", Minimum: intPtr(1)},
"limit": {Type: "integer", Description: "Optional maximum number of source lines to minify.", Minimum: intPtr(1)},
},
Required: []string{"path"},
AdditionalProperties: false,
@@ -58,6 +60,14 @@ func (tool readMinifiedFileTool) run(args map[string]any, options RunOptions, di
if err != nil {
return errorResult("Error: Invalid arguments for read_minified_file: " + err.Error())
}
offset, err := intArg(args, "offset", 1, 1, 0)
if err != nil {
return errorResult("Error: Invalid arguments for read_minified_file: " + err.Error())
}
limit, err := intArg(args, "limit", 0, 1, 0)
if err != nil {
return errorResult("Error: Invalid arguments for read_minified_file: " + err.Error())
}
absolutePath, relativePath, err := resolveScopedReadPath(tool.workspaceRoot, tool.scope, requestedPath)
if err != nil {
@@ -74,11 +84,19 @@ func (tool readMinifiedFileTool) run(args map[string]any, options RunOptions, di
info, _ := os.Stat(absolutePath)
options.FileTracker.Record(absolutePath, content, info)
result := minify.File(relativePath, content)
rawLines := lineCount(string(content))
selected := selectSourceLines(content, offset, limit)
if selected.pastEnd {
return okResult(fmt.Sprintf("File: %s\n(offset %d is past the end of the file, which has %d lines)", relativePath, offset, selected.totalLines))
}
ranged := offset > 1 || limit > 0
result := minify.File(relativePath, selected.content)
if ranged {
result = minify.ContextualFragment(relativePath, content, selected.content, selected.startByte)
}
rawLines := lineCount(string(selected.content))
minLines := lineCount(result.Content)
pct := 0
if rawBytes := len(content); rawBytes > 0 {
if rawBytes := len(selected.content); rawBytes > 0 {
if saved := rawBytes - len(result.Content); saved > 0 {
pct = saved * 100 / rawBytes
}
@@ -88,12 +106,15 @@ func (tool readMinifiedFileTool) run(args map[string]any, options RunOptions, di
if result.Applied {
header = fmt.Sprintf("File: %s — minified %s view (comments stripped, no line numbers; %d→%d lines, ~%d%% fewer bytes). For exact text/comments or before editing, use read_file.",
relativePath, result.Language, rawLines, minLines, pct)
} else if ranged {
header = fmt.Sprintf("File: %s — safe ranged view (whitespace normalized, no line numbers; %d→%d lines; context-sensitive stripping disabled because the range may begin inside a multiline construct). For exact text, use read_file.",
relativePath, rawLines, minLines)
} else {
header = fmt.Sprintf("File: %s — whitespace-normalized view (no line numbers; %d→%d lines; full minification not available for this file type). For exact text, use read_file.",
relativePath, rawLines, minLines)
}
rawBytes := len(content)
rawBytes := len(selected.content)
compactBytes := len(result.Content)
savedTokens := 0
if savedBytes := rawBytes - compactBytes; savedBytes > 0 {
@@ -112,11 +133,54 @@ func (tool readMinifiedFileTool) run(args map[string]any, options RunOptions, di
meta["estimated_tokens_saved"] = strconv.Itoa(savedTokens)
toolResult := Result{Status: StatusOK, Output: output, Meta: meta}
if directBudget {
return applyLegacyByteBudgetToResult(toolResult, readOutputBudgetBytes, "use read_file with start_line/end_line or max_lines for a smaller exact range")
return applyLegacyByteBudgetToResult(toolResult, readOutputBudgetBytes, "use offset/limit to request a smaller source range, or read_file when exact text is required")
}
return toolResult
}
type sourceSelection struct {
content []byte
startByte int
totalLines int
pastEnd bool
}
func selectSourceLines(content []byte, offset, limit int) sourceSelection {
if offset <= 1 && limit == 0 {
return sourceSelection{content: content, totalLines: sourceLineCount(content)}
}
lines := strings.Split(string(content), "\n")
totalLines := sourceLineCount(content)
start := offset - 1
if start >= totalLines {
return sourceSelection{startByte: len(content), totalLines: totalLines, pastEnd: true}
}
startByte := 0
for index := 0; index < start; index++ {
startByte += len(lines[index]) + 1
}
end := totalLines
if limit > 0 && limit < end-start {
end = start + limit
}
return sourceSelection{
content: []byte(strings.Join(lines[start:end], "\n")),
startByte: startByte,
totalLines: totalLines,
}
}
func sourceLineCount(content []byte) int {
if len(content) == 0 {
return 1
}
lines := strings.Count(string(content), "\n")
if content[len(content)-1] != '\n' {
lines++
}
return lines
}
// lineCount reports the number of newline-separated lines in s (an empty string
// counts as 0 lines, matching how a reader perceives an empty file).
func lineCount(s string) int {
+117
View File
@@ -45,6 +45,123 @@ func TestReadMinifiedFileRejectsTraversal(t *testing.T) {
}
}
func TestReadMinifiedFileSelectsSourceLineRangeBeforeMinifying(t *testing.T) {
dir := t.TempDir()
src := "package demo\n\nfunc One() int { return 1 }\n\nfunc Two() int { return 2 }\n"
if err := os.WriteFile(filepath.Join(dir, "f.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
res := NewScopedReadMinifiedFileTool(dir, nil).Run(context.Background(), map[string]any{
"path": "f.go", "offset": 5, "limit": 1,
})
if res.Status != StatusOK || !strings.Contains(res.Output, "func Two") || strings.Contains(res.Output, "func One") {
t.Fatalf("unexpected ranged compact read: status=%s\n%s", res.Status, res.Output)
}
}
func TestReadMinifiedFileRangesPreserveUnknownLexicalContext(t *testing.T) {
tests := []struct {
name string
path string
src string
want []string
}{
{
name: "multiline string",
path: "snippet.py",
src: "value = \"\"\"\n# literal text\nstill literal\n\"\"\"\nprint(value)\n",
want: []string{"# literal text", "still literal"},
},
{
name: "template literal",
path: "snippet.js",
src: "const value = `\n// literal text\nstill literal\n`;\n",
want: []string{"// literal text", "still literal"},
},
{
name: "block comment",
path: "snippet.c",
src: "/* open\ncomment body\n*/\nint live;\n",
want: []string{"comment body", "*/"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, tc.path), []byte(tc.src), 0o644); err != nil {
t.Fatal(err)
}
res := NewScopedReadMinifiedFileTool(dir, nil).Run(context.Background(), map[string]any{
"path": tc.path, "offset": 2, "limit": 2,
})
if res.Status != StatusOK || res.Meta["compacted"] != "false" {
t.Fatalf("ranged read must use conservative normalization: status=%s meta=%#v\n%s", res.Status, res.Meta, res.Output)
}
for _, want := range tc.want {
if !strings.Contains(res.Output, want) {
t.Fatalf("ranged read lost lexical content %q:\n%s", want, res.Output)
}
}
})
}
}
func TestReadMinifiedFileRangeInsideGoRawStringIsConservative(t *testing.T) {
dir := t.TempDir()
src := "package demo\nvar value = `first\nSECRET-MARKER-ONE\n// literal line\nlast`\n"
if err := os.WriteFile(filepath.Join(dir, "f.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
res := NewScopedReadMinifiedFileTool(dir, nil).Run(context.Background(), map[string]any{
"path": "f.go", "offset": 3, "limit": 2,
})
if res.Status != StatusOK || res.Meta["compacted"] != "false" {
t.Fatalf("raw-string range must use conservative normalization: status=%s meta=%#v\n%s", res.Status, res.Meta, res.Output)
}
for _, want := range []string{"SECRET-MARKER-ONE", "// literal line"} {
if !strings.Contains(res.Output, want) {
t.Fatalf("raw-string range lost or rewrote %q:\n%s", want, res.Output)
}
}
if strings.Contains(res.Output, "SECRET - MARKER - ONE") {
t.Fatalf("raw-string contents were parsed as Go code:\n%s", res.Output)
}
}
func TestReadMinifiedFileCanonicalOffsetPastEnd(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "f.go"), []byte("package demo\nvar value = 1\n"), 0o644); err != nil {
t.Fatal(err)
}
res := NewScopedReadMinifiedFileTool(dir, nil).Run(context.Background(), map[string]any{
"path": "f.go", "offset": 10,
})
if res.Status != StatusOK || !strings.Contains(res.Output, "offset 10 is past the end") {
t.Fatalf("expected canonical out-of-range response, got status=%s output=%q", res.Status, res.Output)
}
}
func TestReadMinifiedFileMaximumLimitDoesNotOverflow(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("alpha\nbeta\ngamma\n"), 0o644); err != nil {
t.Fatal(err)
}
const maxInt = int(^uint(0) >> 1)
res := NewScopedReadMinifiedFileTool(dir, nil).Run(context.Background(), map[string]any{
"path": "notes.txt", "offset": 2, "limit": maxInt,
})
if res.Status != StatusOK {
t.Fatalf("maximum limit should return the remaining range without panicking: status=%s output=%q", res.Status, res.Output)
}
if !strings.Contains(res.Output, "beta") || !strings.Contains(res.Output, "gamma") || strings.Contains(res.Output, "alpha") {
t.Fatalf("maximum limit returned the wrong range: %q", res.Output)
}
}
func TestReadMinifiedFileAppliesByteBudget(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "large.txt"), []byte(strings.Repeat("0123456789abcdef\n", 9000)), 0o644); err != nil {
+42 -1
View File
@@ -2,7 +2,9 @@ package tools
import (
"context"
"fmt"
"sort"
"strings"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sandbox"
@@ -29,6 +31,10 @@ type RunOptions struct {
// disk outside Zero since it was last read. nil disables the feature entirely
// (the read/write tools behave exactly as before).
FileTracker *FileTracker
// DeferFileObservationCommit lets a caller with an additional output layer
// commit exact-read credit after that final layer has run.
DeferFileObservationCommit bool
deferFileObservation bool
// EnabledTools / DisabledTools carry the run's operator tool filters so a
// filter-aware tool (tool_search) never discloses or loads an operator-hidden
// tool. They use the same allow/deny semantics as the agent's filter gate:
@@ -130,17 +136,23 @@ func (registry *Registry) RunWithOptions(ctx context.Context, name string, args
// args/paths) are redacted at the boundary just like tool output. The output
// ceiling runs after the scrub so the transcript and the spill file agree on
// what was hidden.
commitFileObservation := !options.DeferFileObservationCommit
options.deferFileObservation = true
selfManagedOutput := false
var tool Tool
var ok bool
defer func() {
result = scrubResultSecrets(result)
result = reduceCommandOutput(name, args, result)
if selfManagedOutput {
result = applySelfManagedOutputBudget(tool, name, args, result)
} else {
result = applyRegistryOutputBudget(tool, name, args, result)
result = enforceOutputCeiling(name, result)
}
if commitFileObservation {
result = registry.CommitFileObservation(result, options.FileTracker)
}
}()
tool, ok = registry.Get(name)
@@ -220,6 +232,32 @@ func (registry *Registry) RunWithOptions(ctx context.Context, name string, args
return res
}
// CommitFileObservation grants exact-read credit only when the tool's complete
// output remains intact at the final boundary that will be sent to the model.
func (registry *Registry) CommitFileObservation(result Result, tracker *FileTracker) Result {
observation := result.pendingFileObservation
result.pendingFileObservation = nil
if observation == nil || tracker == nil || result.Status != StatusOK || result.Truncated ||
!strings.HasPrefix(result.Output, observation.output) {
return result
}
if observation.byteMode {
tracker.RecordSeenBytes(observation.path, observation.start, observation.end, observation.total)
} else {
tracker.RecordSeenRange(observation.path, observation.start, observation.end, observation.total)
}
if result.Meta == nil {
result.Meta = map[string]string{}
}
result.Meta["file_version"] = observation.hash
if observation.byteMode {
result.Meta["seen_bytes"] = fmt.Sprintf("%d-%d", observation.start, observation.end)
} else {
result.Meta["seen_lines"] = fmt.Sprintf("%d-%d", observation.start, observation.end)
}
return result
}
func effectiveToolPermission(tool Tool, args map[string]any) Permission {
if permissioner, ok := tool.(ArgsPermissioner); ok {
return permissioner.PermissionForArgs(args)
@@ -259,8 +297,11 @@ func scrubResultSecrets(res Result) Result {
func CoreReadOnlyToolsScoped(workspaceRoot string, scope PathScope) []Tool {
return []Tool{
NewScopedReadFileTool(workspaceRoot, scope),
NewScopedReadMinifiedFileTool(workspaceRoot, scope),
NewScopedReadFileTool(workspaceRoot, scope),
// view_image is read-only and shares read_file's path scoping, so it
// belongs with the other readers rather than behind a gate.
NewScopedViewImageTool(workspaceRoot, scope),
NewScopedListDirectoryTool(workspaceRoot, scope),
NewScopedGlobTool(workspaceRoot, scope),
NewScopedGrepTool(workspaceRoot, scope),
+47 -2
View File
@@ -30,8 +30,8 @@ func tempDirOutsideDefaultTemp(t *testing.T) string {
func TestCoreReadOnlyToolsExposeSafeMetadata(t *testing.T) {
toolset := CoreReadOnlyToolsScoped(t.TempDir(), nil)
if len(toolset) != 9 {
t.Fatalf("expected 9 core read-only tools, got %d", len(toolset))
if len(toolset) != 10 {
t.Fatalf("expected 10 core read-only tools, got %d", len(toolset))
}
seen := map[string]bool{}
@@ -71,6 +71,11 @@ func TestCoreReadOnlyToolsExposeSafeMetadata(t *testing.T) {
if !seen[RequestPermissionsToolName] {
t.Fatalf("expected %s in core read-only tools", RequestPermissionsToolName)
}
// By name as well as by count: the count alone still passes if one reader is
// swapped for another.
if !seen[ViewImageToolName] {
t.Fatalf("expected %s in core read-only tools", ViewImageToolName)
}
}
func TestRegistryAllReturnsToolsSortedByName(t *testing.T) {
@@ -515,3 +520,43 @@ func TestIsDeferralEligibleDecouplesFromDeferred(t *testing.T) {
t.Fatal("IsDeferralEligible(optedOut) = true, want false")
}
}
func TestOptionalBuiltinsUseDeferredDiscovery(t *testing.T) {
wantDeferred := map[string]bool{
"bash": true,
"browser_action": true,
"browser_click": true,
"browser_connect": true,
"browser_install": true,
"browser_launch": true,
"browser_open": true,
"browser_press": true,
"browser_snapshot": true,
"browser_type": true,
"capture_artifact": true,
"desktop_action": true,
"desktop_snapshot": true,
"desktop_windows": true,
"lsp_navigate": true,
"terminal_session": true,
"web_fetch": true,
"web_search": true,
}
for _, tool := range BuiltinCatalog(t.TempDir()) {
if _, listed := wantDeferred[tool.Name()]; listed {
if !IsDeferred(tool) {
t.Errorf("%s should be deferred", tool.Name())
}
delete(wantDeferred, tool.Name())
} else if tool.Name() == "read_file" || tool.Name() == "read_minified_file" || tool.Name() == "grep" || tool.Name() == ExecCommandToolName || tool.Name() == "edit_file" {
if IsDeferred(tool) {
t.Errorf("essential tool %s must stay eager", tool.Name())
}
} else if IsDeferred(tool) {
t.Errorf("unexpected deferred builtin %s", tool.Name())
}
}
for missing := range wantDeferred {
t.Errorf("deferred builtin %s missing from catalog", missing)
}
}
@@ -0,0 +1,72 @@
package tools
import (
"strings"
"testing"
)
// The cheapest fix for #845/#847 is that the invalid shape is never emitted.
// A model only learns "omit it" from the schema it is shown, so both shell
// tools must say so in the field descriptions themselves — the validator errors
// are the backstop, and by the time they fire a turn has already been burned.
func TestShellToolsAdvertiseOmittingSandboxPermissionFields(t *testing.T) {
root := t.TempDir()
for _, shell := range []struct {
name string
tool Tool
}{
{"bash", NewScopedBashTool(root, nil)},
{"exec_command", NewScopedExecCommandTool(root, nil, nil)},
} {
properties := shell.tool.Parameters().Properties
mode, ok := properties["sandbox_permissions"]
if !ok {
t.Fatalf("%s: sandbox_permissions property missing", shell.name)
}
if !strings.Contains(mode.Description, "Omit it") {
t.Errorf("%s: sandbox_permissions description = %q, want it to lead with omitting the field", shell.name, mode.Description)
}
// Deliberately NOT "read-only". An earlier revision said every read-only
// command could omit the override, and that is false: `curl` and `gh` are
// read-only and still need sandboxed network. Conditioning omission on
// whether the command needs access it does not already have is the claim
// that is actually true, and the one a model can act on without first
// having to decide what counts as read-only.
if !strings.Contains(mode.Description, "unless the command needs access") {
t.Errorf("%s: sandbox_permissions description = %q, want omission conditioned on needing extra access", shell.name, mode.Description)
}
extra, ok := properties["additional_permissions"]
if !ok {
t.Fatalf("%s: additional_permissions property missing", shell.name)
}
if !strings.Contains(extra.Description, "Omit this field entirely") {
t.Errorf("%s: additional_permissions description = %q, want an explicit instruction to omit the field", shell.name, extra.Description)
}
// Both invalid shapes from the issue must be named as invalid up front,
// so neither is emitted "to be safe".
if !strings.Contains(extra.Description, "with any other mode is rejected") {
t.Errorf("%s: additional_permissions description = %q, want it to say the unflagged shape is rejected", shell.name, extra.Description)
}
if !strings.Contains(extra.Description, "empty or all-false object") {
t.Errorf("%s: additional_permissions description = %q, want it to say an empty payload is rejected", shell.name, extra.Description)
}
}
}
// The two shell tools describe the same two fields; they were copy-pasted and
// must not drift apart again, or a model would be taught different rules by
// bash and exec_command in the same session.
func TestShellToolsShareSandboxPermissionDescriptions(t *testing.T) {
root := t.TempDir()
bash := NewScopedBashTool(root, nil).Parameters().Properties
exec := NewScopedExecCommandTool(root, nil, nil).Parameters().Properties
for _, field := range []string{"sandbox_permissions", "additional_permissions"} {
if bash[field].Description != exec[field].Description {
t.Errorf("%s description differs between bash and exec_command:\n bash = %q\n exec = %q",
field, bash[field].Description, exec[field].Description)
}
}
}
+290
View File
@@ -0,0 +1,290 @@
package tools
import (
"fmt"
"strconv"
"strings"
)
const commandReducerMinPassingLines = 12
type commandOutputReducer struct {
kind string
label string
reduce func(string) (string, int)
}
// reduceCommandOutput removes repetitive success-only lines from recognized
// simple commands. Unsupported or compound shell input passes through exactly,
// and every changed result retains the pre-reduction output as an artifact.
func reduceCommandOutput(toolName string, args map[string]any, result Result) Result {
if toolName != ExecCommandToolName && toolName != "bash" {
return result
}
command, err := commandTextForReducer(toolName, args)
if err != nil {
return result
}
reducer, ok := commandOutputReducerFor(command)
if !ok {
return result
}
reduced, omitted := reducer.reduce(result.Output)
if omitted < commandReducerMinPassingLines || len(reduced) >= len(result.Output) {
return result
}
originalBytes := len(result.Output)
originalTokens := estimateOutputTokens(result.Output)
spillPath := spillTruncatedOutput(toolName, result.Output)
if spillPath == "" {
return result
}
notice := fmt.Sprintf("[zero] compacted %d %s; exact output saved to %s", omitted, reducer.label, spillPath)
result.Output = strings.TrimRight(reduced, "\n") + "\n" + notice
result.Truncated = true
if result.Meta == nil {
result.Meta = map[string]string{}
}
result.Meta["command_output_reduced"] = "true"
result.Meta["command_output_reducer"] = reducer.kind
result.Meta["command_output_omitted_lines"] = strconv.Itoa(omitted)
result.Meta["command_output_original_bytes"] = strconv.Itoa(originalBytes)
result.Meta["command_output_original_tokens"] = strconv.Itoa(originalTokens)
result.Meta["command_output_retained_tokens"] = strconv.Itoa(estimateOutputTokens(result.Output))
result.Meta["spill_path"] = spillPath
result.Meta["truncation_reason"] = "command_aware_reduction"
return result
}
func commandTextForReducer(toolName string, args map[string]any) (string, error) {
if toolName == "bash" {
return bashCommandArg(args)
}
return execCommandArg(args)
}
func simpleCommandFields(command string) ([]string, bool) {
command = strings.TrimSpace(command)
if separator := strings.LastIndexAny(command, " \t"); separator >= 0 && command[separator+1:] == "2>&1" {
command = strings.TrimSpace(command[:separator])
}
if command == "" || strings.ContainsAny(command, "|;&<>\n\r`") || strings.Contains(command, "$(") {
return nil, false
}
fields := strings.Fields(command)
if len(fields) == 0 {
return nil, false
}
return fields, true
}
func commandOutputReducerFor(command string) (commandOutputReducer, bool) {
fields, ok := simpleCommandFields(command)
if !ok {
return commandOutputReducer{}, false
}
if len(fields) >= 2 && fields[0] == "go" && fields[1] == "test" {
return commandOutputReducer{kind: "go_test", label: "passing package lines", reduce: reduceGoTestPassingPackages}, true
}
if len(fields) >= 2 && fields[0] == "cargo" {
switch fields[1] {
case "test":
return commandOutputReducer{kind: "cargo_test", label: "passing Rust test lines", reduce: reduceCargoTestPassingLines}, true
case "build", "check":
return commandOutputReducer{kind: "cargo_build", label: "successful Rust build progress lines", reduce: reduceCargoBuildProgressLines}, true
}
}
if isPytestCommand(fields) {
return commandOutputReducer{kind: "pytest", label: "passing pytest progress lines", reduce: reducePytestPassingLines}, true
}
if isJavaScriptTestCommand(fields) {
return commandOutputReducer{kind: "javascript_test", label: "passing JavaScript test-file lines", reduce: reduceJavaScriptTestPassingLines}, true
}
return commandOutputReducer{}, false
}
func isPytestCommand(fields []string) bool {
if fields[0] == "pytest" || fields[0] == "py.test" {
return true
}
return len(fields) >= 3 && (fields[0] == "python" || fields[0] == "python3") && fields[1] == "-m" && fields[2] == "pytest"
}
func isJavaScriptTestCommand(fields []string) bool {
switch fields[0] {
case "jest", "vitest":
return true
case "npx":
return len(fields) >= 2 && (fields[1] == "jest" || fields[1] == "vitest")
case "npm", "pnpm", "yarn":
if len(fields) >= 2 && fields[1] == "test" {
return true
}
if len(fields) >= 3 && fields[1] == "run" && fields[2] == "test" {
return true
}
return len(fields) >= 3 && fields[1] == "exec" && (fields[2] == "jest" || fields[2] == "vitest")
default:
return false
}
}
func reduceGoTestPassingPackages(output string) (string, int) {
return reduceRepeatedOutputLines(output, "passing package lines", func(line string) bool {
packageSuccess := strings.HasPrefix(line, "ok \t") || strings.HasPrefix(line, "? \t") ||
strings.HasPrefix(line, "ok ") || strings.HasPrefix(line, "? ")
return packageSuccess && !strings.Contains(line, "coverage:") && !strings.Contains(line, "[no test files]")
})
}
func reduceCargoTestPassingLines(output string) (string, int) {
if !strings.Contains(output, "test result:") {
return output, 0
}
return reduceRepeatedOutputLines(output, "passing Rust test lines", func(line string) bool {
trimmed := strings.TrimSpace(line)
return strings.HasPrefix(trimmed, "test ") && strings.HasSuffix(trimmed, " ... ok")
})
}
func reduceCargoBuildProgressLines(output string) (string, int) {
if !outputHasCargoFinishedSummary(output) {
return output, 0
}
return reduceRepeatedOutputLines(output, "successful Rust build progress lines", func(line string) bool {
trimmed := strings.TrimSpace(line)
return strings.HasPrefix(trimmed, "Checking ") || strings.HasPrefix(trimmed, "Compiling ")
})
}
func reducePytestPassingLines(output string) (string, int) {
if !outputHasPytestSummary(output) {
return output, 0
}
return reduceRepeatedOutputLines(output, "passing pytest progress lines", isPassingPytestProgressLine)
}
func isPassingPytestProgressLine(line string) bool {
trimmed := strings.TrimSpace(line)
open := strings.LastIndexByte(trimmed, '[')
if open < 0 || !strings.HasSuffix(trimmed, "%]") {
return false
}
progress := strings.TrimSpace(trimmed[:open])
fields := strings.Fields(progress)
if len(fields) == 0 {
return false
}
marks := fields[len(fields)-1]
return marks != "" && strings.Trim(marks, ".") == ""
}
func reduceJavaScriptTestPassingLines(output string) (string, int) {
if !outputHasJavaScriptTestSummary(output) {
return output, 0
}
return reduceRepeatedOutputLines(output, "passing JavaScript test-file lines", func(line string) bool {
trimmed := strings.TrimSpace(line)
return strings.HasPrefix(line, "PASS ") || strings.HasPrefix(trimmed, "✓ ") || strings.HasPrefix(trimmed, "✔ ")
})
}
func outputHasCargoFinishedSummary(output string) bool {
for _, line := range strings.Split(output, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "Finished `") && strings.Contains(trimmed, "` profile ") &&
strings.Contains(trimmed, " target(s) in ") {
return true
}
}
return false
}
func outputHasPytestSummary(output string) bool {
for _, line := range strings.Split(output, "\n") {
summary := strings.Trim(strings.TrimSpace(line), "= ")
if summary == "short test summary info" || isPytestResultSummary(summary) {
return true
}
}
return false
}
func isPytestResultSummary(summary string) bool {
fields := strings.Fields(summary)
if len(fields) < 4 {
return false
}
first := strings.Trim(fields[0], ",;:()")
if _, err := strconv.Atoi(first); err != nil || !summaryHasCountedStatus(summary, "passed") {
return false
}
for index := 1; index+1 < len(fields); index++ {
if fields[index] != "in" {
continue
}
duration := strings.TrimSuffix(strings.Trim(fields[index+1], ",;:()"), "s")
if _, err := strconv.ParseFloat(duration, 64); err == nil {
return true
}
}
return false
}
func outputHasJavaScriptTestSummary(output string) bool {
for _, line := range strings.Split(output, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "Test Suites:") && summaryHasCountedStatus(trimmed, "passed") {
return true
}
if strings.HasPrefix(trimmed, "Test Files ") && summaryHasCountedStatus(trimmed, "passed") {
return true
}
}
return false
}
func summaryHasCountedStatus(summary string, status string) bool {
fields := strings.Fields(summary)
for index := 1; index < len(fields); index++ {
if strings.Trim(fields[index], ",;:()") != status {
continue
}
count := strings.Trim(fields[index-1], ",;:()")
if _, err := strconv.Atoi(count); err == nil {
return true
}
}
return false
}
func reduceRepeatedOutputLines(output string, label string, omit func(string) bool) (string, int) {
lines := strings.Split(output, "\n")
kept := make([]string, 0, len(lines))
omitted := 0
for _, line := range lines {
if omit(line) {
omitted++
continue
}
kept = append(kept, line)
}
if omitted == 0 {
return output, 0
}
insertAt := len(kept)
for index, line := range kept {
if strings.HasPrefix(line, "exit_code:") || strings.HasPrefix(line, "session_id:") {
insertAt = index
break
}
}
summary := fmt.Sprintf("[zero] %d %s omitted", omitted, label)
kept = append(kept, "")
copy(kept[insertAt+1:], kept[insertAt:])
kept[insertAt] = summary
return strings.Join(kept, "\n"), omitted
}
@@ -0,0 +1,107 @@
package tools
import (
"fmt"
"os"
"strings"
"testing"
)
type commandReducerCorpusCase struct {
name string
command string
output string
want string
}
func commandReducerCorpus() []commandReducerCorpusCase {
goLines := []string{"output:"}
cargoTestLines := []string{"output:", "running 24 tests"}
cargoCheckLines := []string{"output:"}
pytestLines := []string{"output:"}
vitestLines := []string{"output:", " RUN v3.2.4 /workspace"}
jestLines := []string{"output:"}
for index := 0; index < 24; index++ {
goLines = append(goLines, fmt.Sprintf("ok \texample.test/pkg%d\t0.01s", index))
cargoTestLines = append(cargoTestLines, fmt.Sprintf("test module::case_%02d ... ok", index))
cargoCheckLines = append(cargoCheckLines, fmt.Sprintf(" Checking dependency_%02d v1.0.%d", index, index))
pytestLines = append(pytestLines, fmt.Sprintf("tests/test_module_%02d.py ........ [%3d%%]", index, (index+1)*100/24))
vitestLines = append(vitestLines, fmt.Sprintf(" ✓ src/module_%02d.test.ts (8 tests) 12ms", index))
jestLines = append(jestLines, fmt.Sprintf("PASS src/module_%02d.test.ts", index))
}
goLines = append(goLines, "exit_code: 0")
cargoTestLines = append(cargoTestLines,
"test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s",
"exit_code: 0",
)
cargoCheckLines = append(cargoCheckLines,
" Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.41s",
"exit_code: 0",
)
pytestLines = append(pytestLines,
"============================= 192 passed in 3.21s =============================",
"exit_code: 0",
)
vitestLines = append(vitestLines,
" Test Files 24 passed (24)",
" Tests 192 passed (192)",
" Duration 3.21s",
"exit_code: 0",
)
jestLines = append(jestLines,
"Test Suites: 24 passed, 24 total",
"Tests: 192 passed, 192 total",
"Time: 3.21 s",
"exit_code: 0",
)
return []commandReducerCorpusCase{
{name: "go_test", command: "go test ./...", output: strings.Join(goLines, "\n"), want: "exit_code: 0"},
{name: "cargo_test", command: "cargo test --workspace", output: strings.Join(cargoTestLines, "\n"), want: "24 passed"},
{name: "cargo_check", command: "cargo check --workspace", output: strings.Join(cargoCheckLines, "\n"), want: "Finished `dev` profile"},
{name: "pytest", command: "pytest -q", output: strings.Join(pytestLines, "\n"), want: "192 passed"},
{name: "npm_vitest", command: "npm test", output: strings.Join(vitestLines, "\n"), want: "192 passed"},
{name: "pnpm_vitest", command: "pnpm test", output: strings.Join(vitestLines, "\n"), want: "192 passed"},
{name: "yarn_jest", command: "yarn test", output: strings.Join(jestLines, "\n"), want: "192 passed"},
}
}
func TestCommandOutputReductionCorpusMetrics(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
totalRawTokens := 0
totalModelTokens := 0
for _, testCase := range commandReducerCorpus() {
t.Run(testCase.name, func(t *testing.T) {
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": testCase.command}, Result{
Status: StatusOK,
Output: testCase.output,
})
if !strings.Contains(result.Output, testCase.want) {
t.Fatalf("decisive summary %q was lost: %q", testCase.want, result.Output)
}
if spillPath := result.Meta["spill_path"]; spillPath != "" {
spilled, err := os.ReadFile(spillPath)
if err != nil {
t.Fatalf("read raw artifact: %v", err)
}
if string(spilled) != testCase.output {
t.Fatal("raw artifact differs from original output")
}
}
rawTokens := estimateOutputTokens(testCase.output)
modelTokens := estimateOutputTokens(result.Output)
totalRawTokens += rawTokens
totalModelTokens += modelTokens
reduction := 100 * (rawTokens - modelTokens) / rawTokens
t.Logf("raw_bytes=%d model_bytes=%d raw_tokens=%d model_tokens=%d reduction_pct=%d reduced=%t",
len(testCase.output), len(result.Output), rawTokens, modelTokens, reduction, result.Meta["command_output_reduced"] == "true")
})
}
if totalRawTokens == 0 {
t.Fatal("empty reducer corpus")
}
if totalModelTokens >= totalRawTokens {
t.Fatalf("aggregate reducer output did not save tokens: raw=%d model=%d", totalRawTokens, totalModelTokens)
}
t.Logf("aggregate raw_tokens=%d model_tokens=%d reduction_pct=%d",
totalRawTokens, totalModelTokens, 100*(totalRawTokens-totalModelTokens)/totalRawTokens)
}
+317
View File
@@ -0,0 +1,317 @@
package tools
import (
"context"
"fmt"
"os"
"strings"
"testing"
)
type commandReducerFixtureTool struct {
name string
output string
}
func (tool commandReducerFixtureTool) Name() string { return tool.name }
func (tool commandReducerFixtureTool) Description() string {
return "returns command output for reducer integration tests"
}
func (tool commandReducerFixtureTool) Parameters() Schema {
return Schema{Type: "object", AdditionalProperties: true}
}
func (tool commandReducerFixtureTool) Safety() Safety {
return Safety{SideEffect: SideEffectRead, Permission: PermissionAllow, Reason: "test output"}
}
func (tool commandReducerFixtureTool) Run(context.Context, map[string]any) Result {
return Result{Status: StatusOK, Output: tool.output}
}
func TestReduceCommandOutputCompactsPassingGoPackagesAndKeepsRawArtifact(t *testing.T) {
var lines []string
lines = append(lines, "output:")
for index := 0; index < commandReducerMinPassingLines+3; index++ {
lines = append(lines, fmt.Sprintf("ok \texample.test/pkg%d\t0.01s", index))
}
lines = append(lines, "exit_code: 0")
original := strings.Join(lines, "\n")
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": "go test ./..."}, Result{
Status: StatusOK,
Output: original,
})
if result.Output == original || !strings.Contains(result.Output, "passing package lines omitted") {
t.Fatalf("expected compact result, got %q", result.Output)
}
if !strings.Contains(result.Output, "exit_code: 0") {
t.Fatalf("exit status was lost: %q", result.Output)
}
spillPath := result.Meta["spill_path"]
spilled, err := os.ReadFile(spillPath)
if err != nil {
t.Fatalf("read raw artifact: %v", err)
}
if string(spilled) != original {
t.Fatalf("raw artifact differs from original output")
}
budgeted := applySelfManagedOutputBudget(
NewExecCommandTool(t.TempDir(), newExecSessionManager()),
ExecCommandToolName,
map[string]any{"cmd": "go test ./..."},
result,
)
if budgeted.Meta["spill_path"] != spillPath {
t.Fatalf("budget boundary replaced the exact raw artifact: got %q want %q", budgeted.Meta["spill_path"], spillPath)
}
spilled, err = os.ReadFile(budgeted.Meta["spill_path"])
if err != nil || string(spilled) != original {
t.Fatalf("budgeted raw artifact was not preserved: err=%v", err)
}
}
func TestReduceCommandOutputFailsOpenForCompoundAndFailedOutput(t *testing.T) {
failure := "output:\n--- FAIL: TestThing\npanic: broken\nFAIL\texample.test/pkg\nexit_code: 1"
for _, command := range []string{"go test ./... | tee test.log", "go test ./... && echo done"} {
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": command}, Result{Status: StatusError, Output: failure})
if result.Output != failure || result.Meta != nil {
t.Fatalf("compound command %q should pass through, got %#v", command, result)
}
}
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": "go test ./..."}, Result{Status: StatusError, Output: failure})
if result.Output != failure {
t.Fatalf("failure evidence should pass through when there is no repetitive success bulk: %q", result.Output)
}
}
func TestReduceGoTestPassingPackagesPreservesCoverageResults(t *testing.T) {
output := strings.Join([]string{
"ok \texample.test/a\t0.01s\tcoverage: 82.4% of statements",
"? \texample.test/b\t[no test files]",
"ok \texample.test/c\t0.01s",
}, "\n")
reduced, omitted := reduceGoTestPassingPackages(output)
if !strings.Contains(reduced, "coverage: 82.4% of statements") {
t.Fatalf("coverage result was removed: %q", reduced)
}
if !strings.Contains(reduced, "[no test files]") {
t.Fatalf("no-test-files result was removed: %q", reduced)
}
if omitted != 1 {
t.Fatalf("omitted=%d, want only the plain passing line omitted", omitted)
}
}
func TestReduceGoTestPassingPackagesPreservesIndentedTestLogs(t *testing.T) {
output := strings.Join([]string{
" ok user-provided test log",
" ? another test log",
"ok \texample.test/pkg\t0.01s",
}, "\n")
reduced, omitted := reduceGoTestPassingPackages(output)
if !strings.Contains(reduced, " ok user-provided test log") ||
!strings.Contains(reduced, " ? another test log") {
t.Fatalf("indented test log was removed: %q", reduced)
}
if omitted != 1 {
t.Fatalf("omitted=%d, want only the package result omitted", omitted)
}
}
func TestSelfManagedBudgetPreservesRawSpillWhenReducedOutputAlsoTruncates(t *testing.T) {
raw := "raw output\n" + strings.Repeat("ok \texample.test/raw\t0.01s\n", 1000)
rawSpill := spillTruncatedOutput(ExecCommandToolName, raw)
if rawSpill == "" {
t.Fatal("failed to create raw spill")
}
reduced := strings.Repeat("reduced but still large\n", 1000)
result := applySelfManagedOutputBudget(
NewExecCommandTool(t.TempDir(), newExecSessionManager()),
ExecCommandToolName,
map[string]any{"cmd": "go test ./...", "max_output_tokens": 100},
Result{
Status: StatusOK,
Output: reduced,
Truncated: true,
Meta: map[string]string{
"spill_path": rawSpill,
"truncation_reason": "command_aware_reduction",
},
},
)
if result.Meta["spill_path"] != rawSpill {
t.Fatalf("budget boundary replaced raw spill: got %q want %q", result.Meta["spill_path"], rawSpill)
}
spilled, err := os.ReadFile(result.Meta["spill_path"])
if err != nil || string(spilled) != raw {
t.Fatalf("raw spill was not preserved: err=%v", err)
}
}
func TestReduceCommandOutputCompactsRecognizedTestAndBuildNoise(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
for _, testCase := range commandReducerCorpus() {
t.Run(testCase.name, func(t *testing.T) {
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": testCase.command}, Result{
Status: StatusOK,
Output: testCase.output,
})
if result.Meta["command_output_reduced"] != "true" {
t.Fatalf("recognized command output was not reduced: %#v", result)
}
if !strings.Contains(result.Output, testCase.want) || !strings.Contains(result.Output, "exit_code: 0") {
t.Fatalf("decisive summary or exit status was lost: %q", result.Output)
}
spillPath := result.Meta["spill_path"]
spilled, err := os.ReadFile(spillPath)
if err != nil {
t.Fatalf("read raw artifact: %v", err)
}
if string(spilled) != testCase.output {
t.Fatal("raw artifact differs from original output")
}
})
}
}
func TestReduceCommandOutputPreservesFailureEvidence(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
passing := make([]string, 0, commandReducerMinPassingLines+2)
for index := 0; index < commandReducerMinPassingLines+2; index++ {
passing = append(passing, fmt.Sprintf("test module::case_%02d ... ok", index))
}
original := strings.Join(append(passing,
"test module::broken ... FAILED",
"failures:",
"---- module::broken stdout ----",
"assertion failed: expected zero",
"test result: FAILED. 14 passed; 1 failed; finished in 0.08s",
"exit_code: 101",
), "\n")
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": "cargo test --workspace"}, Result{
Status: StatusError,
Output: original,
})
for _, evidence := range []string{"module::broken ... FAILED", "assertion failed: expected zero", "14 passed; 1 failed", "exit_code: 101"} {
if !strings.Contains(result.Output, evidence) {
t.Fatalf("failure evidence %q was lost: %q", evidence, result.Output)
}
}
if result.Meta["command_output_reduced"] != "true" {
t.Fatalf("repetitive passing output was not reduced: %#v", result)
}
spillPath := result.Meta["spill_path"]
if spillPath == "" {
t.Fatal("reduced failure output did not retain a raw artifact")
}
spilled, err := os.ReadFile(spillPath)
if err != nil {
t.Fatalf("read raw artifact: %v", err)
}
if string(spilled) != original {
t.Fatal("raw artifact differs from original output")
}
}
func TestReduceCommandOutputPassesThroughUnsupportedAndCompoundCommands(t *testing.T) {
outputs := make(map[string]string)
for _, fixture := range commandReducerCorpus() {
outputs[fixture.name] = fixture.output
}
testCases := []struct {
command string
output string
}{
{command: "npm test | tee test.log", output: outputs["npm_vitest"]},
{command: "cargo test && echo done", output: outputs["cargo_test"]},
{command: "pytest; notify-send done", output: outputs["pytest"]},
{command: "cargo test 1>test.log", output: outputs["cargo_test"]},
{command: "cargo test 2>>errors.log", output: outputs["cargo_test"]},
{command: "git status --short", output: outputs["npm_vitest"]},
}
for _, testCase := range testCases {
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": testCase.command}, Result{Status: StatusOK, Output: testCase.output})
if result.Output != testCase.output || result.Meta != nil {
t.Fatalf("command %q should pass through exactly, got %#v", testCase.command, result)
}
}
}
func TestReduceCommandOutputAllowsTrailingStderrMerge(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
fixture := commandReducerCorpus()[1]
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": fixture.command + " 2>&1"}, Result{
Status: StatusOK,
Output: fixture.output,
})
if result.Meta["command_output_reducer"] != "cargo_test" {
t.Fatalf("trailing stderr merge prevented reduction: %#v", result)
}
}
func TestReduceCommandOutputRequiresAuthoritativeRunnerSummary(t *testing.T) {
lookalikes := map[string]string{
"cargo test": "test user::message ... ok",
"cargo check": "Checking a custom deployment state",
"pytest": "application log ........ [100%]",
"npm test": "PASS user-provided log message",
}
for command, line := range lookalikes {
original := strings.Repeat(line+"\n", commandReducerMinPassingLines+2) + "exit_code: 0"
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": command}, Result{Status: StatusOK, Output: original})
if result.Output != original || result.Meta != nil {
t.Fatalf("summary-free output for %q should pass through, got %#v", command, result)
}
}
}
func TestReduceCommandOutputRejectsLookalikeRunnerSummaries(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
testCases := []struct {
command string
line string
summary string
}{
{command: "cargo check", line: "Compiling dependency v1.0.0", summary: "Finished generating bindings"},
{command: "pytest", line: "tests/test_module.py ........ [100%]", summary: "application log: 14 passed validation in staging"},
{command: "npm test", line: "PASS src/example.test.ts", summary: "Test Files discovered: 14"},
}
for _, testCase := range testCases {
original := strings.Repeat(testCase.line+"\n", commandReducerMinPassingLines+2) + testCase.summary + "\nexit_code: 1"
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": testCase.command}, Result{Status: StatusError, Output: original})
if result.Output != original || result.Meta != nil {
t.Fatalf("lookalike summary for %q should pass through exactly, got %#v", testCase.command, result)
}
}
}
func TestRegistryAppliesCommandReducerToExecAndBashTools(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
fixture := commandReducerCorpus()[1]
for _, toolName := range []string{ExecCommandToolName, "bash"} {
t.Run(toolName, func(t *testing.T) {
registry := NewRegistry()
registry.Register(commandReducerFixtureTool{name: toolName, output: fixture.output})
args := map[string]any{"cmd": fixture.command}
if toolName == "bash" {
args = map[string]any{"command": fixture.command}
}
result := registry.Run(context.Background(), toolName, args)
if result.Status != StatusOK || result.Meta["command_output_reducer"] != "cargo_test" {
t.Fatalf("registry result was not reduced: %#v", result)
}
if !strings.Contains(result.Output, fixture.want) {
t.Fatalf("registry reduction lost summary: %q", result.Output)
}
spilled, err := os.ReadFile(result.Meta["spill_path"])
if err != nil || string(spilled) != fixture.output {
t.Fatalf("registry raw artifact mismatch: err=%v", err)
}
})
}
}
+20
View File
@@ -5,6 +5,7 @@ import (
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type SideEffect string
@@ -102,6 +103,16 @@ type Result struct {
// while callers migrate away from parsing text and string metadata.
ExecutionRequest *execution.Request `json:"-"`
ExecutionOutcome *execution.Outcome `json:"-"`
// Images are pictures the tool is handing the model — a screenshot it just
// captured, a file it was asked to look at. Text-only tools leave it nil.
//
// They are NOT delivered on this result's own tool-result message. Every
// provider drops images there: Anthropic maps a tool result to a tool_result
// block whose content is a string, Gemini to a functionResponse, and OpenAI
// guards its image content-parts to the user role. The agent loop therefore
// emits them as a following user message, which is also the only shape that
// keeps one tool result per tool call.
Images []zeroruntime.ImageBlock `json:"-"`
// Redacted is set when secret scrubbing altered Output before it left the
// tool-execution boundary.
Redacted bool
@@ -115,6 +126,9 @@ type Result struct {
ChangeSummaries []execution.Change
// Display carries a short, structured summary for the TUI / stream.
Display Display
// pendingFileObservation is proposed by read_file and committed only after
// the final model-visible output boundary confirms the exact content survived.
pendingFileObservation *pendingFileObservation
}
// Display carries a short, structured summary of a tool result for the TUI/stream.
@@ -159,6 +173,7 @@ type baseTool struct {
parameters Schema
safety Safety
capabilities ToolCapabilities // zero value = EffectUnknown, not thread-safe
deferred bool
}
func (tool baseTool) Name() string {
@@ -177,6 +192,11 @@ func (tool baseTool) Safety() Safety {
return tool.safety
}
// Deferred reports whether this built-in is discoverable on demand.
func (tool baseTool) Deferred() bool {
return tool.deferred
}
func okResult(output string) Result {
return Result{Status: StatusOK, Output: output}
}
+1 -1
View File
@@ -26,7 +26,7 @@ func NewUpdatePlanTool() *updatePlanTool {
return &updatePlanTool{
baseTool: baseTool{
name: "update_plan",
description: "Create or update the in-memory plan for the current task. " +
description: "Create or update the in-memory plan for implementation or investigation with at least three meaningful dependent steps. Do not use it for simple lookups, explanations, or code navigation. " +
"Pass the full ordered list of steps each call; it replaces the previous plan. " +
"Each item needs a `content` string; `status` defaults to \"pending\" and `id` is " +
"auto-numbered, so you only need to supply `content` (and `status` as the task progresses). " +
+80
View File
@@ -0,0 +1,80 @@
package tools
import (
"context"
"github.com/Gitlawb/zero/internal/imageinput"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// ViewImageToolName is the canonical registry name of the image viewer.
const ViewImageToolName = "view_image"
// NewViewImageTool builds the workspace-scoped image viewer.
func NewViewImageTool(workspaceRoot string) Tool {
return NewScopedViewImageTool(workspaceRoot, nil)
}
// NewScopedViewImageTool builds the image viewer bound to a path scope.
//
// The scope is not optional decoration. imageinput.LoadFile resolves a relative
// path by joining it to the workspace root and otherwise takes the path as
// given, so on its own it would happily read outside the workspace. Every path
// goes through resolveScopedReadPath first, exactly like read_file, so this tool
// grants no reach that read_file does not already have.
func NewScopedViewImageTool(workspaceRoot string, scope PathScope) Tool {
return viewImageTool{
baseTool: baseTool{
name: ViewImageToolName,
description: "Look at an image file. Use this to see a screenshot, diagram, mockup or photo " +
"the user mentioned but did not attach, or one a previous tool wrote to disk. " +
"Accepts PNG, JPEG, GIF and WebP up to 10 MiB.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"path": {Type: "string", Description: "Path of the image to look at."},
},
Required: []string{"path"},
AdditionalProperties: false,
},
safety: readOnlySafety("Reads an image file without modifying anything."),
// Same shape as read_file: reads one file, mutates nothing, and is
// safe to run concurrently.
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: true, ResourceKeys: fileResourceKeys},
},
workspaceRoot: workspaceRoot,
scope: scope,
}
}
type viewImageTool struct {
baseTool
workspaceRoot string
scope PathScope
}
func (tool viewImageTool) Run(_ context.Context, args map[string]any) Result {
requestedPath, err := aliasedStringArg(args, []string{"path", "file", "file_path", "filepath", "filename", "image"}, "", true, false)
if err != nil {
return errorResult("Error: Invalid arguments for " + ViewImageToolName + ": " + err.Error())
}
absolutePath, relativePath, err := resolveScopedReadPath(tool.workspaceRoot, tool.scope, requestedPath)
if err != nil {
return errorResult("Error: " + err.Error())
}
image, err := imageinput.LoadFile(absolutePath, tool.workspaceRoot)
if err != nil {
// LoadFile's errors already name the problem (missing, too large,
// unsupported type) and are safe to show: they carry the path the caller
// asked for, which it already knows.
return errorResult("Error viewing " + relativePath + ": " + err.Error())
}
return Result{
Status: StatusOK,
// The text says what arrived; the image itself reaches the model through
// the agent loop, which delivers Images on a following user message.
Output: "Viewing " + relativePath + " (" + image.MediaType + ").",
Images: []zeroruntime.ImageBlock{image},
Meta: map[string]string{"media_type": image.MediaType},
}
}
+100
View File
@@ -0,0 +1,100 @@
package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// A 1x1 PNG. Small enough to inline, real enough to pass media-type sniffing.
var onePixelPNG = []byte{
0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 'I', 'H', 'D', 'R',
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0a, 'I', 'D', 'A', 'T',
0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05,
0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00,
0x00, 0x00, 'I', 'E', 'N', 'D', 0xae, 0x42, 0x60, 0x82,
}
func TestViewImageReturnsTheImage(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "shot.png"), onePixelPNG, 0o600); err != nil {
t.Fatal(err)
}
result := NewViewImageTool(root).Run(context.Background(), map[string]any{"path": "shot.png"})
if result.Status != StatusOK {
t.Fatalf("status = %v: %s", result.Status, result.Output)
}
if len(result.Images) != 1 {
t.Fatalf("got %d images, want 1 — the text alone is useless to the model", len(result.Images))
}
if result.Images[0].MediaType != "image/png" {
t.Errorf("media type = %q, want image/png", result.Images[0].MediaType)
}
if len(result.Images[0].Data) != len(onePixelPNG) {
t.Errorf("image data is %d bytes, want %d", len(result.Images[0].Data), len(onePixelPNG))
}
}
// The tool must not reach outside the workspace. imageinput.LoadFile does no
// containment check of its own, so the scoping is entirely this tool's job.
func TestViewImageRefusesPathsOutsideTheWorkspace(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
secret := filepath.Join(outside, "outside.png")
if err := os.WriteFile(secret, onePixelPNG, 0o600); err != nil {
t.Fatal(err)
}
tool := NewViewImageTool(root)
for name, path := range map[string]string{
"absolute path outside": secret,
"parent traversal": filepath.Join("..", filepath.Base(outside), "outside.png"),
} {
t.Run(name, func(t *testing.T) {
result := tool.Run(context.Background(), map[string]any{"path": path})
if result.Status == StatusOK {
t.Fatalf("read an image outside the workspace: %s", result.Output)
}
if len(result.Images) != 0 {
t.Error("a refused read still returned image bytes")
}
})
}
}
func TestViewImageRejectsNonImages(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("not an image"), 0o600); err != nil {
t.Fatal(err)
}
result := NewViewImageTool(root).Run(context.Background(), map[string]any{"path": "notes.txt"})
if result.Status == StatusOK {
t.Fatalf("accepted a text file as an image: %s", result.Output)
}
if len(result.Images) != 0 {
t.Error("a rejected file still returned image bytes")
}
}
func TestViewImageReportsAMissingFileClearly(t *testing.T) {
result := NewViewImageTool(t.TempDir()).Run(context.Background(), map[string]any{"path": "nope.png"})
if result.Status == StatusOK {
t.Fatal("a missing file reported success")
}
// The error names the path the caller asked for, matching read_file. Both
// also carry the resolved absolute path from the underlying syscall error;
// that is pre-existing behaviour shared with read_file, not something this
// tool introduces, so it is not asserted either way here.
if !strings.Contains(result.Output, "nope.png") {
t.Errorf("error should name the file, got %q", result.Output)
}
if len(result.Images) != 0 {
t.Error("a failed read still returned image bytes")
}
}
+1
View File
@@ -107,6 +107,7 @@ func newWebFetchToolWithClientAndResolver(client *http.Client, resolver webFetch
baseTool: baseTool{
name: "web_fetch",
description: "Fetch text from a public remote HTTP or HTTPS URL after network permission is granted. Do not use for localhost, private network URLs, or local dev servers; use bash with curl for those.",
deferred: true,
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
+1
View File
@@ -58,6 +58,7 @@ func newWebSearchToolWithBackend(backend searchBackend) Tool {
baseTool: baseTool{
name: "web_search",
description: "Search the web for a query and return ranked results (title, URL, snippet). Complements web_fetch, which retrieves a single known URL.",
deferred: true,
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
+7
View File
@@ -75,6 +75,9 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
// stale view. Only read current bytes when there is a baseline to compare,
// so a first-touch create/overwrite stays a single write with no extra read.
if existed {
if options.FileTracker != nil && !options.FileTracker.SeenWhole(absolutePath) {
return errorResult(fileUnseenMessage(relativePath))
}
if _, tracked := options.FileTracker.Version(absolutePath); tracked {
// Fail CLOSED: if the tracked file can't be re-read to verify it, refuse
// the overwrite rather than clobbering a file whose current state is
@@ -107,6 +110,7 @@ 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())
}
modelKnownContent := content
// 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.
@@ -115,6 +119,9 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
// session compares against what is now on disk.
newInfo, _ := os.Stat(absolutePath)
options.FileTracker.Record(absolutePath, []byte(content), newInfo)
if content == modelKnownContent {
options.FileTracker.RecordSeenRange(absolutePath, 1, lineCount(content), lineCount(content))
}
if !existed {
options.FileTracker.RecordCreated(absolutePath)
}
+1 -1
View File
@@ -206,7 +206,7 @@ func (m model) leaveBTW() (model, tea.Cmd) {
func btwCommandUnavailable(command parsedCommand) bool {
arg := strings.ToLower(strings.TrimSpace(command.text))
switch command.kind {
case commandNew, commandResume, commandRetitle, commandSpec, commandLoop, commandGoal,
case commandNew, commandResume, commandRename, commandSpec, commandLoop, commandGoal,
commandRewind, commandCompact, commandSTTModel, commandMCP:
return true
case commandModel:
+10 -1
View File
@@ -58,7 +58,8 @@ func pasteFromClipboardCmd() tea.Cmd {
// right-click paste (clipboardReadMsg) so a bracketed paste and a right-click
// paste behave identically. Surfaces with no editable text field (a permission/
// spec prompt, the MCP manager, an open picker, the detailed transcript) swallow
// the paste; empty content is a no-op.
// the paste. The session rename editor accepts text only; empty content is a
// no-op there rather than an image probe.
func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
// A paste is a deliberate action, same as a keypress or click — it means
// the user moved on to something else, so it disarms a stale Esc
@@ -75,6 +76,14 @@ func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
// toggle instead of going solid.
m.lastCharTime = m.now()
m.composerCursorVisible = true
if m.renamePrompt != nil {
if content == "" {
return m, nil
}
var cmd tea.Cmd
m.input, cmd = m.input.Update(tea.PasteMsg{Content: sanitizeComposerInput(content)})
return m, cmd
}
if content == "" {
// Empty text clipboard — the user may have pasted a screenshot.
// Probe the OS clipboard for image content asynchronously.
+60
View File
@@ -0,0 +1,60 @@
package tui
import (
"strings"
"testing"
"github.com/charmbracelet/x/ansi"
)
// A COPIED NUL KILLED THE WHOLE PROGRAM ON WINDOWS.
//
// github.com/atotto/clipboard's Windows path calls syscall.StringToUTF16, which
// PANICS rather than returning an error when the string contains a NUL — so
// selecting transcript text that carried one took the TUI down with "program
// experienced a panic" and a stack ending in clipboard.writeAll. A teammate hit
// it on a real Windows machine.
//
// The NUL reaches the clipboard because ansi.Strip removes escape SEQUENCES and
// leaves C0 bytes alone, and the transcript carries them from two directions:
// tool output (binary reads, git's -z listings) and this package's own card
// protocol, whose prefixes are literally "\x00command-card\x00".
func TestNulNeverReachesTheClipboard(t *testing.T) {
// The card prefix is a real, in-repo source: ansi.Strip leaves it intact.
if !strings.ContainsRune(ansi.Strip(commandCardTranscriptPrefix+"Profile"), 0) {
t.Fatal("setup: the card prefix no longer carries a NUL; this test guards the wrong thing")
}
for _, tc := range []struct{ name, in string }{
{"card prefix", commandCardTranscriptPrefix + "Profile\nstatus: ok"},
{"plan card prefix", planCardTranscriptPrefix + "plan"},
{"binary tool output", "before\x00after"},
{"git -z listing", "a.go\x00b.go\x00c.go"},
} {
t.Run(tc.name, func(t *testing.T) {
got := clipboardSafeText(tc.in)
if strings.ContainsRune(got, 0) {
t.Fatalf("a NUL survived to the clipboard write: %q — Windows would panic here", got)
}
// The readable content must survive: this sanitises, it does not truncate.
for _, want := range strings.Split(strings.ReplaceAll(tc.in, "\x00", "\n"), "\n") {
if want = strings.TrimSpace(want); want != "" && !strings.Contains(got, want) {
t.Fatalf("sanitising dropped real content %q from %q", want, got)
}
}
})
}
}
// Newlines and tabs are legal clipboard content and must survive — a multi-line
// selection is the normal case, not the exception.
func TestClipboardSanitisingKeepsLayout(t *testing.T) {
in := "line one\n\tindented\nline three"
if got := clipboardSafeText(in); got != in {
t.Fatalf("clean multi-line text was altered:\n got %q\nwant %q", got, in)
}
// Other C0 bytes go: they would terminate the OSC52 escape early and
// corrupt the copy even where they do not panic.
if got := clipboardSafeText("a\x07b\x1bc"); got != "abc" {
t.Fatalf("control bytes survived: %q", got)
}
}
+5 -5
View File
@@ -27,7 +27,7 @@ const (
commandPlan
commandSearch
commandResume
commandRetitle
commandRename
commandSpec
commandInit
commandCompact
@@ -226,11 +226,11 @@ var commandDefinitions = []commandDefinition{
kind: commandResume,
},
{
name: "/retitle",
usage: "/retitle",
name: "/rename",
usage: "/rename [title]",
group: commandGroupSession,
description: "Generate concise titles for resumable sessions.",
kind: commandRetitle,
description: "Rename the current session (no arg opens an editor).",
kind: commandRename,
},
{
name: "/spec",
+1 -1
View File
@@ -281,7 +281,7 @@ func (m model) composerPositionAtMouse(msg tea.MouseMsg) (int, bool) {
func (m model) composerMouseSelectionBlocked() bool {
return m.transcriptDetailed || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive()
m.mcpManager != nil || m.picker != nil || m.renamePrompt != nil || m.suggestionsActive()
}
func (m model) composerPositionAtVisualCell(x int, y int, width int) (int, bool) {
+92 -51
View File
@@ -102,6 +102,7 @@ type model struct {
doctorInFlight bool
doctorFrame int
activeSession sessions.Metadata
pendingSessionTitle string
sessionEvents []sessions.Event
btw btwState
// btwRunIDSeq is the highest run ID issued by any completed or abandoned BTW
@@ -112,14 +113,8 @@ type model struct {
// already been attempted this process, so a finished turn re-fires the title
// generator at most once per session (even before its async result lands).
// Lazily initialized.
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
titledSessions map[string]bool
renamePrompt *sessionRenamePrompt
usageTracker *usage.Tracker
sessionCompactor SessionCompactor
prService *PrService
@@ -221,6 +216,9 @@ type model struct {
// renders the live elapsed time from it so a long or stalled turn never looks
// like a frozen terminal (for ANY provider, not just slow ones). Zero = idle.
turnStartedAt time.Time
// turnTimer is shared with the agent command so both the live status and the
// settled "worked for" duration exclude time blocked on a user permission.
turnTimer *activeTurnTimer
// lastCharTime tracks when the last non-Enter key was received, for paste detection.
lastCharTime time.Time
// lastKeyTime tracks every keypress timestamp for burst calculation.
@@ -474,8 +472,14 @@ type model struct {
// pins), this is maintained by recordRecentModel on every successful
// switch and persisted via config.SetRecentModels.
recentModels []config.RecentModelEntry
recapsEnabled bool // post-turn "※ recap:" line (config: recaps on|off)
recappedRuns map[int]bool // per-run guard so a recap fires at most once per turn
recapsEnabled bool // idle orientation note (config: recaps on|off)
recapSeq int
recapRunning bool
recapCancel context.CancelFunc
recapTimerCancel context.CancelFunc
recapIdleArmed bool
recapIdleRunID int
idleRecap string
modelPickerLoading bool
modelPickerLoadingProviderID string
modelPickerLoadError string
@@ -1069,7 +1073,7 @@ func (m *model) stopPRWatcher() {
func (m model) noBlockingModal() bool {
return m.pendingPermission == nil && m.pendingAskUser == nil && m.pendingSpecReview == nil &&
m.providerWizard == nil && m.mcpAddWizard == nil && m.mcpManager == nil && m.picker == nil &&
m.sttKeyPrompt == nil
m.sttKeyPrompt == nil && m.renamePrompt == nil
}
func (m model) quit() (tea.Model, tea.Cmd) {
@@ -1157,6 +1161,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.printInFlight = false
return m.drainFlushQueue()
}
var recapActivityCmd tea.Cmd
switch msg.(type) {
case tea.KeyPressMsg, tea.PasteMsg, tea.MouseMsg:
m, recapActivityCmd = m.resetIdleRecapAfterActivity()
}
next, cmd := m.updateModel(msg)
nm, ok := next.(model)
if !ok {
@@ -1165,7 +1174,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
nm = nm.syncChatScroll()
nm, mouseCmd := nm.syncMouseCapture()
nm, flushCmd := nm.settleTranscript()
return nm, batchCommands(cmd, mouseCmd, flushCmd)
return nm, batchCommands(cmd, mouseCmd, flushCmd, recapActivityCmd)
}
func batchCommands(cmds ...tea.Cmd) tea.Cmd {
@@ -1352,6 +1361,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.sttKeyPrompt != nil {
return m.handleSTTKeyPromptKey(msg)
}
if m.renamePrompt != nil {
return m.handleSessionRenameKey(msg)
}
m.transcriptSelection = transcriptSelectionState{}
m.composerSelection = composerSelectionState{}
m.clearMouseSelection()
@@ -2465,15 +2477,15 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
var titleCmd, recapCmd tea.Cmd
if msg.err == nil {
m, titleCmd = m.maybeAutoTitleActiveSession()
// Post-turn recap (gated on the recaps preference): one short sentence
// summarizing the turn's final answer, shown as a "※ recap:" footnote.
// Arm an idle recap only after a successful answer. The timer performs
// no provider work unless the session stays untouched long enough.
var finalAnswer string
for _, row := range msg.rows {
if row.kind == rowAssistant && row.final {
finalAnswer = row.text
}
}
m, recapCmd = m.maybeRecapTurn(msg.runID, finalAnswer)
m, recapCmd = m.maybeScheduleIdleRecap(msg.runID, finalAnswer)
}
// End-of-turn git sweep: catch file mutations the tool stream couldn't
// report (bash scaffolding, subagent edits) so the FILES sidebar is
@@ -2506,6 +2518,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return next, tea.Batch(pendingClearCmd, titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd, goalCmd)
case sessionTitleGeneratedMsg:
return m.handleSessionTitleGenerated(msg)
case recapIdleMsg:
return m.handleRecapIdle(msg)
case recapGeneratedMsg:
return m.handleRecapGenerated(msg)
case compactResultMsg:
@@ -2948,6 +2962,12 @@ func (m model) pinnedTitleBar(width int) string {
func (m model) footerView(width int) string {
var footer strings.Builder
if m.renamePrompt != nil {
footer.WriteString(m.sessionRenamePromptView(width))
footer.WriteString("\n")
footer.WriteString(m.statusLine(width))
return footer.String()
}
// While an ask-user questionnaire is active it REPLACES the composer box (the
// text box becomes the questionnaire): render the tabbed prompt + status line and
// skip the plan panel / idle hints / composer for a focused modal.
@@ -2986,6 +3006,8 @@ func (m model) footerView(width int) string {
// so the footer height is unchanged.
if copyStatus := strings.TrimSpace(m.copyStatus); copyStatus != "" {
footer.WriteString(rightAlignedLine(zeroTheme.ink.Render(copyStatus), width))
} else if recap := strings.TrimSpace(m.idleRecap); recap != "" {
footer.WriteString(fitStyledLine(" "+zeroTheme.faint.Render("※ "+recap), width))
} else if left, right := m.composerIdleHint(), m.jumpToBottomHint(); left != "" || right != "" {
footer.WriteString(fitStyledLine(joinHeaderLine(" "+left, right, width), width))
}
@@ -3036,9 +3058,18 @@ func (m model) composerIdleHint() string {
case tierNarrow:
hint = "? shortcuts"
case tierMedium:
hint = fmt.Sprintf("? shortcuts · Ctrl+X cmds · %s sidebar", sidebarKey)
parts := []string{"? shortcuts", "Ctrl+X cmds"}
if m.sidebarAvailable() {
parts = append(parts, sidebarKey+" sidebar")
}
hint = strings.Join(parts, " · ")
default:
hint = fmt.Sprintf("? shortcuts · Ctrl+X cmds · %s sidebar · %s detail · %s copy · Shift+Tab mode", sidebarKey, detailKey, mouseKey)
parts := []string{"? shortcuts", "Ctrl+X cmds"}
if m.sidebarAvailable() {
parts = append(parts, sidebarKey+" sidebar")
}
parts = append(parts, detailKey+" detail", mouseKey+" copy", "Shift+Tab mode")
hint = strings.Join(parts, " · ")
}
return zeroTheme.faint.Render(hint)
}
@@ -3502,7 +3533,7 @@ func (m model) workingStatusLine() string {
// (reasoning, waiting on the model, or running a tool).
line += zeroTheme.faint.Render(" · " + m.workingActivity())
if !m.turnStartedAt.IsZero() {
line += zeroTheme.faint.Render(" · " + formatWorkingElapsed(m.now().Sub(m.turnStartedAt)))
line += zeroTheme.faint.Render(" · " + formatWorkingElapsed(m.activeTurnElapsed(m.turnStartedAt)))
}
// Live token estimate so the working line visibly climbs as the model reasons
// and writes, instead of a static figure. Shown from the start of the turn (at
@@ -3587,7 +3618,7 @@ const quietWorkingHint = 8 * time.Second
// the ticking number was the only signal, and it looks identical whether real
// (if slow) content is coming or nothing ever will.
func (m model) quietGenerationHint() string {
if m.activeRunID == 0 {
if m.activeRunID == 0 || m.pendingPermission != nil {
return ""
}
last := m.lastStreamActivity
@@ -4136,6 +4167,10 @@ func (m model) resolvePermissionWithReason(decision permissionDecision, reason s
})
}
m.pendingPermission = nil
// Time spent at the prompt is user wait, not provider silence. Restart the
// quiet-generation clock so resuming a long-blocked run does not immediately
// claim the model has been inactive for the entire approval interval.
m.lastStreamActivity = m.now()
return m, nil
}
@@ -4520,21 +4555,11 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
}
return m, nil
case commandRetitle:
if m.pending {
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "Cannot retitle sessions while a run is active.",
})
return m, nil
case commandRename:
if title := strings.TrimSpace(command.text); title != "" {
return m.renameActiveSession(title), nil
}
text := ""
var retitleCmd tea.Cmd
m, retitleCmd, text = m.startSessionRetitle()
if text != "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
}
return m, retitleCmd
return m.openSessionRenamePrompt(), nil
case commandSpec:
return m.handleSpecCommand(command.text)
case commandInit:
@@ -4838,6 +4863,7 @@ func (m model) launchPrompt(prompt string) (model, tea.Cmd) {
// (normal prompt + spec draft/impl) keeps these in sync — a missing
// turnStartedAt previously dropped the elapsed timer on spec-mode runs.
func (m model) beginRun(cancel context.CancelFunc) model {
m = m.cancelIdleRecap()
if m.prepareRunCompletionWarning != nil {
m.prepareRunCompletionWarning()
}
@@ -4859,6 +4885,7 @@ func (m model) beginRun(cancel context.CancelFunc) model {
// suppressed by a stale preference.
m.sidebarHidden = false
m.turnStartedAt = m.now()
m.turnTimer = newActiveTurnTimer(m.turnStartedAt)
m.lastStreamActivity = m.turnStartedAt
m.turnStreamedRunes = 0
m.spinnerTicking = true
@@ -5039,9 +5066,21 @@ 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
if m.turnTimer != nil {
m.turnTimer.start(started)
}
// firstTokenElapsed is stamped from the pause-aware turn timer when the
// first reasoning or text token streams, so TTFT and total elapsed use
// the same clock.
var firstTokenElapsed time.Duration
firstTokenSeen := false
stampFirstToken := func(at time.Time) {
if firstTokenSeen {
return
}
firstTokenSeen = true
firstTokenElapsed = m.activeTurnElapsedAt(started, at)
}
toolCalls := 0
rows := []transcriptRow{}
usageEvents := []zeroruntime.Usage{}
@@ -5157,11 +5196,10 @@ 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()
}
now := m.now()
stampFirstToken(now)
if strings.TrimSpace(reasoningText) != "" {
flushReasoning(m.now())
flushReasoning(now)
}
m.sendAgentText(runID, delta)
if onText != nil {
@@ -5178,6 +5216,12 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
}
onPermissionRequest := options.OnPermissionRequest
options.OnPermissionRequest = func(ctx context.Context, request agent.PermissionRequest) (agent.PermissionDecision, error) {
if m.turnTimer != nil {
m.turnTimer.pause(m.now())
defer func() {
m.turnTimer.resume(m.now())
}()
}
if onPermissionRequest != nil {
return onPermissionRequest(ctx, request)
}
@@ -5255,8 +5299,8 @@ 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(delta) != "" {
stampFirstToken(now)
}
if strings.TrimSpace(reasoningText) == "" && strings.TrimSpace(delta) != "" {
reasoningStarted = now
@@ -5367,6 +5411,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
tool: result.Name,
status: result.Status,
detail: toolResultDetail(result),
meta: result.Meta,
runID: runID,
changedFiles: result.ChangedFiles,
changeSummaries: result.ChangeSummaries,
@@ -5481,7 +5526,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
Type: sessions.EventError,
Payload: map[string]any{"message": err.Error()},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.activeTurnElapsed(started)}
}
if runOptions.specDraft {
if result.StopReason != agent.StopReasonSpecReviewRequired || specReview == nil || specReview.SpecID == "" || specReview.SpecFilePath == "" {
@@ -5491,17 +5536,13 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
Type: sessions.EventError,
Payload: map[string]any{"message": err.Error()},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.activeTurnElapsed(started)}
}
flushReasoning(m.now())
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, specReview: specReview, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, specReview: specReview, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.activeTurnElapsed(started)}
}
flushReasoning(m.now())
elapsed := m.now().Sub(started)
ttft := time.Duration(0)
if !firstTokenAt.IsZero() {
ttft = firstTokenAt.Sub(started)
}
elapsed := m.activeTurnElapsed(started)
rows = append(rows, transcriptRow{
kind: rowAssistant,
text: result.FinalAnswer,
@@ -5519,7 +5560,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, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: elapsed, ttft: ttft}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: elapsed, ttft: firstTokenElapsed}
}
}
+72 -9
View File
@@ -886,8 +886,9 @@ func TestResumeCommandListsRecentSessions(t *testing.T) {
if next.picker == nil || next.picker.kind != pickerSession {
t.Fatalf("expected /resume to open the session picker, got picker=%#v", next.picker)
}
// Every row carries the session title (in the Label, after the timestamp) and
// resolves to / shows the session id (Value + Meta), for both sessions.
// Every row carries the session title after the timestamp and resolves by its
// hidden session id. Raw ids are implementation detail and must not consume
// the visible title width.
findByID := func(id string) (pickerItem, bool) {
for _, item := range next.picker.items {
if item.Value == id {
@@ -904,17 +905,52 @@ func TestResumeCommandListsRecentSessions(t *testing.T) {
if !strings.Contains(item.Label, want.title) {
t.Fatalf("picker Label %q should contain the title %q", item.Label, want.title)
}
if !strings.Contains(item.Meta, want.id) {
t.Fatalf("picker %q Meta should show the id %q, got %q", want.title, want.id, item.Meta)
if item.Meta != "" {
t.Fatalf("picker %q should not expose raw session id metadata, got %q", want.title, item.Meta)
}
}
// The picker overlay renders the titles and ids.
// The picker overlay renders clean title rows plus a position indicator.
view := viewString(next.View())
for _, want := range []string{"Resume a session", "Newer", "Older", first.SessionID, second.SessionID} {
for _, want := range []string{"Resume a session", "Newer", "Older", "1 / 2"} {
if !strings.Contains(view, want) {
t.Fatalf("session picker view missing %q:\n%s", want, view)
}
}
for _, id := range []string{first.SessionID, second.SessionID} {
if strings.Contains(view, id) {
t.Fatalf("session picker view should not expose raw id %q:\n%s", id, view)
}
}
// IDs remain searchable even though they are hidden from normal rows.
searchByID := *next.picker
searchByID.query = first.SessionID
searchByID.applyQuery()
if len(searchByID.items) != 1 || searchByID.items[0].Value != first.SessionID {
t.Fatalf("hidden session id should remain searchable, got %#v", searchByID.items)
}
// An empty result set reports an unambiguous zero position.
noResults := next
noResults.picker.query = "__missing_session__"
noResults.picker.applyQuery()
if view := viewString(noResults.View()); !strings.Contains(view, "0 / 0") {
t.Fatalf("empty session search should show 0 / 0:\n%s", view)
}
}
func TestSessionPickerLabelAlignsTitles(t *testing.T) {
today := sessionPickerLabel("20:47:50", "Today title")
older := sessionPickerLabel("Jul 24 10:47", "Older title")
todayColumn := strings.Index(today, "Today title")
olderColumn := strings.Index(older, "Older title")
if todayColumn < 0 || olderColumn < 0 {
t.Fatalf("sessionPickerLabel omitted a title: today=%q older=%q", today, older)
}
if todayColumn != olderColumn {
t.Fatalf("title columns differ: today=%d (%q), older=%d (%q)", todayColumn, today, olderColumn, older)
}
}
func TestResumePickerSelectionHydratesSession(t *testing.T) {
@@ -2501,9 +2537,36 @@ func TestComposerIdleHintAndJumpCue(t *testing.T) {
m.width, m.height = 100, 30
m.transcript = append(m.transcript, transcriptRow{kind: rowAssistant, text: "hi", final: true})
// Idle, empty composer, managed mode -> the discoverability hint shows.
if h := plainRender(t, m.composerIdleHint()); !strings.Contains(h, "shortcuts") {
t.Fatalf("expected idle hint, got %q", h)
for _, test := range []struct {
name string
width int
}{
{name: "medium", width: 80},
{name: "full", width: 100},
} {
t.Run(test.name, func(t *testing.T) {
idle := m
idle.width = test.width
// Idle, empty composer, managed mode -> the discoverability hint shows.
hint := plainRender(t, idle.composerIdleHint())
if !strings.Contains(hint, "shortcuts") {
t.Fatalf("expected idle hint, got %q", hint)
}
if strings.Contains(hint, "sidebar") {
t.Fatalf("empty sidebar should not be advertised, got %q", hint)
}
withSidebar := idle
withSidebar.plan.steps = []planStep{{content: "inspect footer", status: "in_progress"}}
if hint := plainRender(t, withSidebar.composerIdleHint()); !strings.Contains(hint, "Ctrl+B sidebar") {
t.Fatalf("available sidebar should be advertised, got %q", hint)
}
withSidebar.sidebarHidden = true
if hint := plainRender(t, withSidebar.composerIdleHint()); !strings.Contains(hint, "Ctrl+B sidebar") {
t.Fatalf("collapsed sidebar should keep its restore shortcut, got %q", hint)
}
})
}
// Hidden during a run.
busy := m
+162 -41
View File
@@ -13,21 +13,33 @@ import (
)
const (
// recapIdleDelay keeps recap generation off the critical path of ordinary
// turns. A recap is useful when returning to a quiet session, not immediately
// after reading the answer that it would otherwise repeat.
recapIdleDelay = 4 * time.Minute
// recapTimeout bounds a single recap generation so a hung provider can never
// wedge the background command.
recapTimeout = 30 * time.Second
// recapMaxAnswerChars bounds how much of the final answer feeds the recap
// prompt — the gist is enough, and it keeps the call cheap.
recapMaxAnswerChars = 1600
// recapMaxContextChars bounds the recent conversation excerpt sent to the
// provider. Goal and plan hints are added separately.
recapMaxContextChars = 4800
recapMaxRows = 8
)
const recapSystemPrompt = "You write ONE short, plain-English sentence summarizing what the assistant just did this turn, for a transcript footnote. " +
"Reply with ONLY that sentence: no preamble, no markdown, no bullet list, no trailing question."
const recapSystemPrompt = "The user is returning to an idle coding session. In under 40 words and 1-2 plain sentences, state the overall goal or current task, then the next actionable step. Reply with only the recap: no markdown, preamble, or trailing question."
// recapIdleMsg marks the end of an inactivity window. seq makes abandoned
// timers cheap: any subsequent user activity or run invalidates the message.
type recapIdleMsg struct {
runID int
seq int
}
// recapGeneratedMsg carries the outcome of a background recap generation back to
// the Update loop.
type recapGeneratedMsg struct {
runID int
seq int
recap string
err error
}
@@ -43,21 +55,20 @@ func cleanGeneratedRecap(raw string) string {
return ""
}
// generateRecap asks the provider for a one-sentence recap of the turn's final
// answer. Provider-shaped exactly like generateSessionTitle: system + a single
// user turn, no tools.
func generateRecap(ctx context.Context, provider zeroruntime.Provider, answer string) (string, error) {
// generateRecap asks the provider for a compact orientation note using bounded
// session context. It is an isolated side request with no tools.
func generateRecap(ctx context.Context, provider zeroruntime.Provider, sessionContext string) (string, error) {
if provider == nil {
return "", errors.New("no provider configured")
}
answer = strings.TrimSpace(answer)
if answer == "" {
return "", errors.New("no answer to recap")
sessionContext = strings.TrimSpace(sessionContext)
if sessionContext == "" {
return "", errors.New("no session context to recap")
}
request := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: recapSystemPrompt},
{Role: zeroruntime.MessageRoleUser, Content: "Assistant's final answer:\n\n" + cutRunes(answer, recapMaxAnswerChars) + "\n\nOne-sentence recap:"},
{Role: zeroruntime.MessageRoleUser, Content: sessionContext},
},
}
stream, err := provider.StreamCompletion(ctx, request)
@@ -75,51 +86,158 @@ func generateRecap(ctx context.Context, provider zeroruntime.Provider, answer st
return recap, nil
}
// generateRecapCmd builds the background command that generates a recap for the
// turn, off the Update goroutine (mirrors generateSessionTitleCmd).
func (m model) generateRecapCmd(runID int, answer string) tea.Cmd {
// recapContext builds the bounded orientation material used by the idle recap.
// Goal and plan state are authoritative; recent user/final-assistant rows fill
// in enough conversational context when neither exists.
func (m model) recapContext() string {
var sections []string
if goal := m.activeSession.Goal; goal != nil && strings.TrimSpace(goal.Objective) != "" {
sections = append(sections, "Overall goal: "+strings.TrimSpace(goal.Objective)+"\nGoal status: "+string(goal.Status))
}
if current := strings.TrimSpace(currentStepContent(m.plan.steps)); current != "" {
sections = append(sections, "Current task: "+current)
}
rows := make([]string, 0, recapMaxRows)
used := 0
for i := len(m.transcript) - 1; i >= 0 && len(rows) < recapMaxRows && used < recapMaxContextChars; i-- {
row := m.transcript[i]
role := ""
switch {
case row.kind == rowUser:
role = "User"
case row.kind == rowAssistant && row.final:
role = "Assistant"
default:
continue
}
content := strings.TrimSpace(row.text)
if content == "" {
continue
}
content = cutRunes(content, recapMaxContextChars-used)
rows = append(rows, role+": "+content)
used += len([]rune(content))
}
for left, right := 0, len(rows)-1; left < right; left, right = left+1, right-1 {
rows[left], rows[right] = rows[right], rows[left]
}
if len(rows) > 0 {
sections = append(sections, "Recent conversation:\n"+strings.Join(rows, "\n"))
}
return strings.Join(sections, "\n\n")
}
// generateRecapCmd builds the cancellable background command that generates an
// idle recap off the Update goroutine.
func (m model) generateRecapCmd(ctx context.Context, cancel context.CancelFunc, runID, seq int) tea.Cmd {
provider := m.provider
sessionContext := m.recapContext()
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), recapTimeout)
defer cancel()
recap, err := generateRecap(ctx, provider, answer)
return recapGeneratedMsg{runID: runID, recap: recap, err: err}
recap, err := generateRecap(ctx, provider, sessionContext)
return recapGeneratedMsg{runID: runID, seq: seq, recap: recap, err: err}
}
}
// maybeRecapTurn fires a one-shot recap generation after a successful turn, when
// recaps are enabled, a provider exists, and there is a final answer. It is a
// no-op otherwise, and the per-run gate prevents a double-fire for the same turn.
func (m model) maybeRecapTurn(runID int, answer string) (model, tea.Cmd) {
// maybeScheduleIdleRecap arms a one-shot inactivity window after a successful
// turn. It performs no provider work until that window actually elapses.
func (m model) maybeScheduleIdleRecap(runID int, answer string) (model, tea.Cmd) {
if !m.recapsEnabled || m.provider == nil || strings.TrimSpace(answer) == "" {
return m, nil
}
if m.recappedRuns[runID] {
return m, nil
}
if m.recappedRuns == nil {
m.recappedRuns = map[int]bool{}
}
m.recappedRuns[runID] = true
return m, m.generateRecapCmd(runID, answer)
m.recapIdleArmed = true
m.recapIdleRunID = runID
return m.scheduleIdleRecap(runID)
}
// handleRecapGenerated appends the finished recap as a footnote row. A failed or
// empty generation is silent (and releases the per-run gate so a future turn can
// recap normally).
func (m model) scheduleIdleRecap(runID int) (model, tea.Cmd) {
m.recapSeq++
seq := m.recapSeq
ctx, cancel := context.WithCancel(context.Background())
m.recapTimerCancel = cancel
return m, func() tea.Msg {
timer := time.NewTimer(recapIdleDelay)
defer timer.Stop()
select {
case <-timer.C:
return recapIdleMsg{runID: runID, seq: seq}
case <-ctx.Done():
return nil
}
}
}
// cancelIdleRecap invalidates pending timers, aborts provider work, and clears
// the ephemeral orientation note as soon as the user resumes activity.
func (m model) cancelIdleRecap() model {
m.recapIdleArmed = false
m.recapIdleRunID = 0
return m.clearIdleRecapWork()
}
func (m model) clearIdleRecapWork() model {
m.recapSeq++
if m.recapTimerCancel != nil {
m.recapTimerCancel()
m.recapTimerCancel = nil
}
if m.recapCancel != nil {
m.recapCancel()
m.recapCancel = nil
}
m.recapRunning = false
m.idleRecap = ""
return m
}
// resetIdleRecapAfterActivity restarts the inactivity window for a completed
// turn. Activity still cancels in-flight generation and clears the ephemeral
// note, but scrolling or reading no longer disables recaps until another turn.
func (m model) resetIdleRecapAfterActivity() (model, tea.Cmd) {
armed := m.recapIdleArmed
runID := m.recapIdleRunID
m = m.clearIdleRecapWork()
if !armed || !m.recapsEnabled || m.provider == nil || runID != m.runID {
return m, nil
}
return m.scheduleIdleRecap(runID)
}
// handleRecapIdle starts generation only if the same run is still genuinely
// idle. Busy UI, a draft, a modal, or newer activity silently abandons it.
func (m model) handleRecapIdle(msg recapIdleMsg) (model, tea.Cmd) {
if msg.seq != m.recapSeq || msg.runID != m.runID || !m.recapsEnabled ||
m.provider == nil || m.pending || m.compactInFlight || m.recapRunning ||
strings.TrimSpace(m.composerValue()) != "" || !m.noBlockingModal() {
return m, nil
}
if strings.TrimSpace(m.recapContext()) == "" {
return m, nil
}
m.recapTimerCancel = nil
ctx, cancel := context.WithTimeout(context.Background(), recapTimeout)
m.recapCancel = cancel
m.recapRunning = true
return m, m.generateRecapCmd(ctx, cancel, msg.runID, msg.seq)
}
// handleRecapGenerated publishes an ephemeral orientation note above the
// composer. Stale, cancelled, failed, or now-busy results are silent.
func (m model) handleRecapGenerated(msg recapGeneratedMsg) (model, tea.Cmd) {
// Drop a recap whose run is no longer the latest: a newer turn has started, so
// appending now would land the recap on the wrong (current) conversation.
if msg.runID != m.runID {
delete(m.recappedRuns, msg.runID)
if msg.seq != m.recapSeq || msg.runID != m.runID {
return m, nil
}
m.recapCancel = nil
m.recapRunning = false
m.recapIdleArmed = false
m.recapIdleRunID = 0
recap := strings.TrimSpace(msg.recap)
if msg.err != nil || recap == "" {
delete(m.recappedRuns, msg.runID)
if msg.err != nil || recap == "" || m.pending || m.compactInFlight ||
strings.TrimSpace(m.composerValue()) != "" || !m.noBlockingModal() {
return m, nil
}
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowRecap, text: recap})
m.idleRecap = recap
return m, nil
}
@@ -130,6 +248,9 @@ func (m model) handleConfigCommand(arg string) (model, string) {
switch arg {
case "recaps on", "recaps off":
m.recapsEnabled = arg == "recaps on"
if !m.recapsEnabled {
m = m.cancelIdleRecap()
}
if err := m.persistRecapsEnabled(); err != nil {
return m, "Config\nFailed to save recaps preference: " + err.Error()
}
+186 -69
View File
@@ -1,16 +1,20 @@
package tui
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// TestHandleConfigCommandReportsPersistError: a failed persist surfaces an error
// instead of falsely reporting "recaps: on/off".
func TestHandleConfigCommandReportsPersistError(t *testing.T) {
// Point the config path under a regular file, so the write can't create it.
dir := t.TempDir()
parent := filepath.Join(dir, "afile")
if err := os.WriteFile(parent, []byte("x"), 0o600); err != nil {
@@ -22,85 +26,179 @@ func TestHandleConfigCommandReportsPersistError(t *testing.T) {
}
}
// TestMaybeRecapTurnGating: a recap fires only when enabled, a provider exists,
// and there's a final answer — and at most once per run.
func TestMaybeRecapTurnGating(t *testing.T) {
func TestMaybeScheduleIdleRecapDefersProviderWork(t *testing.T) {
provider := &fakeProvider{}
withProvider := func() model {
return model{recapsEnabled: true, provider: &fakeProvider{}}
return model{recapsEnabled: true, provider: provider}
}
// Disabled -> no cmd.
if _, cmd := (model{recapsEnabled: false, provider: &fakeProvider{}}).maybeRecapTurn(1, "did X"); cmd != nil {
t.Error("recaps disabled: want no cmd")
if _, cmd := (model{provider: provider}).maybeScheduleIdleRecap(1, "did X"); cmd != nil {
t.Error("recaps disabled: want no timer")
}
// No provider -> no cmd.
if _, cmd := (model{recapsEnabled: true}).maybeRecapTurn(1, "did X"); cmd != nil {
t.Error("no provider: want no cmd")
if _, cmd := (model{recapsEnabled: true}).maybeScheduleIdleRecap(1, "did X"); cmd != nil {
t.Error("no provider: want no timer")
}
// Empty answer -> no cmd.
if _, cmd := withProvider().maybeRecapTurn(1, " "); cmd != nil {
t.Error("empty answer: want no cmd")
if _, cmd := withProvider().maybeScheduleIdleRecap(1, " "); cmd != nil {
t.Error("empty answer: want no timer")
}
// Enabled + provider + answer -> a cmd, and the per-run gate is set.
m, cmd := withProvider().maybeRecapTurn(7, "Built the website")
m, cmd := withProvider().maybeScheduleIdleRecap(7, "Built the website")
if cmd == nil || m.recapSeq != 1 {
t.Fatalf("successful turn should arm one idle timer: seq=%d cmd=%v", m.recapSeq, cmd != nil)
}
if len(provider.requests) != 0 {
t.Fatal("scheduling an idle recap must not call the provider")
}
}
func TestHandleRecapIdleUsesGoalPlanAndRecentConversation(t *testing.T) {
provider := &fakeProvider{events: []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventText, Content: "The goal is to ship safer edits. Next, run the cross-platform checks."},
{Type: zeroruntime.StreamEventDone},
}}
m := newModel(context.Background(), Options{Provider: provider})
m.recapsEnabled = true
m.runID = 4
m.recapSeq = 9
m.activeSession.Goal = &sessions.Goal{Objective: "Ship safer edits", Status: sessions.GoalStatusActive}
m.plan.steps = []planStep{{content: "Run cross-platform checks", status: "in_progress"}}
m.transcript = []transcriptRow{
{kind: rowUser, text: "Make edit safety reliable"},
{kind: rowAssistant, text: "The implementation is ready.", final: true},
}
next, cmd := m.handleRecapIdle(recapIdleMsg{runID: 4, seq: 9})
if cmd == nil || !next.recapRunning || next.recapCancel == nil {
t.Fatal("an eligible idle session should start cancellable recap generation")
}
if len(provider.requests) != 0 {
t.Fatal("provider work must remain inside the returned command")
}
msg, ok := cmd().(recapGeneratedMsg)
if !ok {
t.Fatalf("command returned %T, want recapGeneratedMsg", msg)
}
if len(provider.requests) != 1 || len(provider.requests[0].Messages) != 2 {
t.Fatalf("recap request = %#v", provider.requests)
}
contextMessage := provider.requests[0].Messages[1].Content
for _, want := range []string{"Overall goal: Ship safer edits", "Current task: Run cross-platform checks", "User: Make edit safety reliable", "Assistant: The implementation is ready."} {
if !strings.Contains(contextMessage, want) {
t.Errorf("recap context missing %q:\n%s", want, contextMessage)
}
}
next, _ = next.handleRecapGenerated(msg)
if !strings.Contains(next.idleRecap, "cross-platform checks") || next.recapRunning {
t.Fatalf("generated idle recap not published correctly: recap=%q running=%v", next.idleRecap, next.recapRunning)
}
}
func TestRecapIdleAndGeneratedMessagesAreDroppedWhenStaleOrBusy(t *testing.T) {
provider := &fakeProvider{}
base := newModel(context.Background(), Options{Provider: provider})
base.recapsEnabled = true
base.runID = 3
base.recapSeq = 5
base.transcript = []transcriptRow{{kind: rowAssistant, text: "done", final: true}}
cases := []struct {
name string
edit func(*model)
msg recapIdleMsg
}{
{name: "stale activity", msg: recapIdleMsg{runID: 3, seq: 4}},
{name: "newer run", msg: recapIdleMsg{runID: 2, seq: 5}},
{name: "pending run", msg: recapIdleMsg{runID: 3, seq: 5}, edit: func(m *model) { m.pending = true }},
{name: "draft", msg: recapIdleMsg{runID: 3, seq: 5}, edit: func(m *model) { m.input.SetValue("still typing") }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := base
if tc.edit != nil {
tc.edit(&m)
}
if _, cmd := m.handleRecapIdle(tc.msg); cmd != nil {
t.Fatal("ineligible idle message started provider work")
}
})
}
base.idleRecap = ""
base, _ = base.handleRecapGenerated(recapGeneratedMsg{runID: 3, seq: 4, recap: "stale"})
if base.idleRecap != "" {
t.Fatal("stale generated recap must be dropped")
}
}
func TestUserActivityCancelsAndClearsIdleRecap(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
m := newModel(context.Background(), Options{})
m.recapSeq = 2
m.recapRunning = true
m.recapCancel = cancel
m.idleRecap = "Continue with the release checks."
updated, _ := m.Update(tea.KeyPressMsg{Code: 'x', Text: "x"})
next := updated.(model)
if next.recapSeq != 3 || next.recapRunning || next.recapCancel != nil || next.idleRecap != "" {
t.Fatalf("activity did not clear recap state: seq=%d running=%v cancel=%v recap=%q", next.recapSeq, next.recapRunning, next.recapCancel != nil, next.idleRecap)
}
select {
case <-ctx.Done():
default:
t.Fatal("activity did not cancel in-flight recap context")
}
}
func TestUserActivityRearmsPendingIdleRecap(t *testing.T) {
provider := &fakeProvider{}
m := newModel(context.Background(), Options{Provider: provider})
m.recapsEnabled = true
m.runID = 7
m.transcript = []transcriptRow{{kind: rowAssistant, text: "done", final: true}}
m, initial := m.maybeScheduleIdleRecap(7, "done")
if initial == nil {
t.Fatal("precondition: successful turn did not arm recap")
}
oldTimerDone := make(chan tea.Msg, 1)
go func() { oldTimerDone <- initial() }()
updated, cmd := m.Update(tea.MouseMotionMsg{})
next := updated.(model)
if cmd == nil {
t.Fatal("enabled run with an answer should dispatch a recap cmd")
t.Fatal("activity canceled the pending recap without rearming the idle timer")
}
if !m.recappedRuns[7] {
t.Error("the per-run gate should be set")
if next.recapSeq <= m.recapSeq {
t.Fatalf("activity did not invalidate the old timer: before=%d after=%d", m.recapSeq, next.recapSeq)
}
// Second call for the same run -> no cmd (already recapped).
if _, cmd2 := m.maybeRecapTurn(7, "Built the website"); cmd2 != nil {
t.Error("a second recap for the same run must not fire")
select {
case msg := <-oldTimerDone:
if msg != nil {
t.Fatalf("canceled timer returned %#v, want nil", msg)
}
case <-time.After(time.Second):
t.Fatal("activity left the old four-minute timer running")
}
}
// TestHandleRecapGenerated: a successful recap appends a "※ recap:" row; a
// failed/empty one appends nothing and releases the gate.
func TestHandleRecapGenerated(t *testing.T) {
// runID matches the latest run -> the recap appends.
m := model{runID: 5, recappedRuns: map[int]bool{5: true}}
m, _ = m.handleRecapGenerated(recapGeneratedMsg{runID: 5, recap: "Built a fashion site with cart and blog"})
if len(m.transcript) != 1 {
t.Fatalf("a recap for the current run should append one row, got %d", len(m.transcript))
}
last := m.transcript[len(m.transcript)-1]
if last.kind != rowRecap || !strings.Contains(last.text, "fashion site") {
t.Errorf("recap row wrong: %+v", last)
}
if got := plainRender(t, renderRecapRow(last, 100)); !strings.Contains(got, "※ recap:") {
t.Errorf("recap render should show the ※ marker, got %q", got)
}
// A recap from a STALE run (a newer turn started) is dropped, not appended to
// the wrong conversation; the gate is released.
stale := model{runID: 8, recappedRuns: map[int]bool{6: true}}
stale, _ = stale.handleRecapGenerated(recapGeneratedMsg{runID: 6, recap: "old run recap"})
if len(stale.transcript) != 0 {
t.Error("a stale-run recap must not be appended")
}
if stale.recappedRuns[6] {
t.Error("a stale-run recap must release the per-run gate")
}
// Failed recap: no row, gate released.
m2 := model{runID: 9, recappedRuns: map[int]bool{9: true}}
m2, _ = m2.handleRecapGenerated(recapGeneratedMsg{runID: 9, err: errExampleRecap})
if len(m2.transcript) != 0 {
t.Error("a failed recap must append nothing")
}
if m2.recappedRuns[9] {
t.Error("a failed recap must release the per-run gate")
func TestIdleRecapRendersAboveComposer(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width = 100
m.height = 30
m.idleRecap = "Continue by running the release checks."
got := plainRender(t, m.footerView(100))
if !strings.Contains(got, "※ Continue by running the release checks.") {
t.Fatalf("footer missing idle recap:\n%s", got)
}
}
// TestHandleConfigCommandTogglesRecaps: /config recaps on|off flips the flag and
// reports it; unknown args are rejected.
func TestHandleConfigCommandTogglesRecaps(t *testing.T) {
m := model{recapsEnabled: true}
m := model{recapsEnabled: true, idleRecap: "old recap"}
m, text := m.handleConfigCommand("recaps off")
if m.recapsEnabled || !strings.Contains(text, "recaps: off") {
t.Errorf("recaps off: enabled=%v text=%q", m.recapsEnabled, text)
if m.recapsEnabled || m.idleRecap != "" || !strings.Contains(text, "recaps: off") {
t.Errorf("recaps off: enabled=%v recap=%q text=%q", m.recapsEnabled, m.idleRecap, text)
}
m, text = m.handleConfigCommand("recaps on")
if !m.recapsEnabled || !strings.Contains(text, "recaps: on") {
@@ -111,8 +209,27 @@ func TestHandleConfigCommandTogglesRecaps(t *testing.T) {
}
}
var errExampleRecap = errExample("recap failed")
func TestHandleConfigCommandCancelsInFlightRecap(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
m := model{
recapsEnabled: true,
recapRunning: true,
recapCancel: cancel,
idleRecap: "old recap",
}
type errExample string
func (e errExample) Error() string { return string(e) }
m, _ = m.handleConfigCommand("recaps off")
if m.recapRunning || m.recapCancel != nil || m.idleRecap != "" {
t.Fatalf(
"disabling recaps did not clear in-flight state: running=%v cancel=%v recap=%q",
m.recapRunning,
m.recapCancel != nil,
m.idleRecap,
)
}
select {
case <-ctx.Done():
default:
t.Fatal("disabling recaps did not cancel in-flight generation context")
}
}
+45 -2
View File
@@ -1477,13 +1477,13 @@ func renderToolResultCard(row transcriptRow, width int, rc rowContext, opts card
collapsedFooter = collapsedToolFooter(row.detail)
}
if collapsedFooter != "" && !row.expanded {
head := toolCardHead(name, headTarget, headArg, "", row.detail, row.text, false, nameStyle, rc.auto[key], width, opts)
head := toolCardHead(name, headTarget, headArg, toolResultBudgetTag(row.meta), row.detail, row.text, false, nameStyle, rc.auto[key], width, opts)
return toolCard(head, glyph, nil, collapsedFooter, borderStyle, width)
}
bodyOpts := opts
bodyOpts.expanded = row.expanded
body := toolCardBody(name, rc.hints[key], rc.args[key], row.detail, width, bodyOpts, failed)
head := toolCardHead(name, headTarget, headArg, body.headTag, row.detail, row.text, false, nameStyle, rc.auto[key], width, opts)
head := toolCardHead(name, headTarget, headArg, joinToolHeadTags(body.headTag, toolResultBudgetTag(row.meta)), row.detail, row.text, false, nameStyle, rc.auto[key], width, opts)
footer := body.footer
if collapsedFooter != "" && row.expanded && footer == "" {
footer = "▾ collapse"
@@ -1491,6 +1491,49 @@ func renderToolResultCard(row transcriptRow, width int, rc rowContext, opts card
return toolCard(head, glyph, body.lines, footer, borderStyle, width)
}
func joinToolHeadTags(tags ...string) string {
var present []string
for _, tag := range tags {
if tag = strings.TrimSpace(tag); tag != "" {
present = append(present, tag)
}
}
return strings.Join(present, " · ")
}
func toolResultBudgetTag(meta map[string]string) string {
if len(meta) == 0 {
return ""
}
reduced := meta["command_output_reduced"] == "true" || meta["truncated"] == "true"
if !reduced {
return ""
}
original, originalErr := strconv.Atoi(meta["output_budget_estimated_original_tokens"])
retained, retainedErr := strconv.Atoi(meta["output_budget_estimated_retained_tokens"])
if commandOriginal, err := strconv.Atoi(meta["command_output_original_tokens"]); err == nil {
original, originalErr = commandOriginal, nil
}
if commandRetained, err := strconv.Atoi(meta["command_output_retained_tokens"]); err == nil {
retained, retainedErr = commandRetained, nil
}
tag := "compact output"
if originalErr == nil && retainedErr == nil && original > retained {
tag = fmt.Sprintf("compact %s→%s tok", compactCount(original), compactCount(retained))
}
if strings.TrimSpace(meta["spill_path"]) != "" {
tag += " · raw saved"
}
return tag
}
func compactCount(value int) string {
if value < 1000 {
return strconv.Itoa(value)
}
return fmt.Sprintf("%.1fk", float64(value)/1000)
}
// confirmationVerbPattern matches a single-line success confirmation that only
// restates the action + target: a leading verb ("Created", "Overwrote",
// "Successfully created directory", "Wrote", "Deleted", …) optionally followed
+44 -7
View File
@@ -29,8 +29,13 @@ func (m model) ensureActiveSession(prompt string) (model, error) {
return m, nil
}
title := strings.TrimSpace(m.pendingSessionTitle)
manuallyNamed := title != ""
if !manuallyNamed {
title = tuiSessionTitle(prompt)
}
session, err := m.sessionStore.Create(sessions.CreateInput{
Title: tuiSessionTitle(prompt),
Title: title,
Cwd: m.cwd,
ModelID: m.modelName,
Provider: m.providerName,
@@ -39,7 +44,14 @@ func (m model) ensureActiveSession(prompt string) (model, error) {
return m, err
}
m.activeSession = session
m.pendingSessionTitle = ""
m.sessionEvents = []sessions.Event{}
if manuallyNamed {
if m.titledSessions == nil {
m.titledSessions = map[string]bool{}
}
m.titledSessions[session.SessionID] = true
}
return m, nil
}
@@ -55,6 +67,7 @@ func (m model) startNewSession() model {
previousID := m.activeSession.SessionID
m.activeSession = sessions.Metadata{}
m.pendingSessionTitle = ""
m.sessionEvents = nil
// Reset the per-session usage + compaction display so the new session starts
@@ -217,6 +230,7 @@ func (m model) handleResumeCommand(args string) (model, string) {
// the already-active session, whose loops belong to it, not a "previous" one.
previousID := m.activeSession.SessionID
m.activeSession = *session
m.pendingSessionTitle = ""
m.sessionEvents = append([]sessions.Event{}, events...)
if m.providerName == "" {
m.providerName = session.Provider
@@ -392,17 +406,16 @@ func (m model) newSessionPicker() *commandPicker {
if !m.sessionHasResumableContent(meta.SessionID) {
continue
}
// Lead with the timestamp so same-titled sessions (e.g. the same first
// prompt run several times) are visually distinct; the id (right, faint)
// stays for reference and is what selection actually resolves.
// Lead with a fixed-width timestamp so titles form one scannable column.
// The raw id remains the selection/search value but stays out of the row:
// rendering it consumed half the picker and truncated the useful title.
label := displayValue(meta.Title, "untitled")
if when := sessionWhen(meta.UpdatedAt, now); when != "" {
label = when + " " + label
label = sessionPickerLabel(when, label)
}
items = append(items, pickerItem{
Label: label,
Value: meta.SessionID,
Meta: meta.SessionID,
})
}
if len(items) == 0 {
@@ -417,6 +430,12 @@ func (m model) newSessionPicker() *commandPicker {
}
}
const sessionPickerTimeWidth = len("Jan 02 15:04")
func sessionPickerLabel(when, title string) string {
return fmt.Sprintf("%-*s %s", sessionPickerTimeWidth, when, title)
}
// sessionHasResumableContent reports whether a session has anything worth
// resuming: a tool call/result, or a non-user message with real content (not the
// no-output guardrail stop). Empty/failed runs return false and are hidden from
@@ -479,7 +498,7 @@ func (m model) sessionHasResumableContent(sessionID string) bool {
// anything worth resuming: a tool call/result, or a non-user message with real
// content (not the no-output guardrail stop). It is the pure core of
// sessionHasResumableContent so callers that already hold the events (e.g. the
// /retitle scan) don't re-read them.
// session picker refresh) don't re-read them.
func eventsHaveResumableContent(events []sessions.Event) bool {
for _, event := range events {
switch event.Type {
@@ -616,6 +635,7 @@ func transcriptRowsFromSessionEvents(events []sessions.Event) []transcriptRow {
tool: name,
status: status,
detail: output,
meta: payloadStringMap(payload, "meta"),
changedFiles: payloadStringSlice(payload, "changedFiles"),
changeSummaries: payloadExecutionChanges(payload, "changeSummaries"),
})
@@ -809,6 +829,23 @@ func payloadStringSlice(payload map[string]any, key string) []string {
}
}
func payloadStringMap(payload map[string]any, key string) map[string]string {
value, ok := payloadMap(payload, key)
if !ok {
return nil
}
out := make(map[string]string, len(value))
for name, raw := range value {
if text, ok := raw.(string); ok {
out[name] = text
}
}
if len(out) == 0 {
return nil
}
return out
}
func payloadExecutionChanges(payload map[string]any, key string) []execution.Change {
value, ok := payload[key]
if !ok {
+106
View File
@@ -0,0 +1,106 @@
package tui
import (
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
type sessionRenamePrompt struct{}
func (m model) openSessionRenamePrompt() model {
if m.sessionStore == nil {
return m.appendSessionRenameError("session storage is unavailable")
}
m.clearComposer()
title := m.activeSession.Title
if m.activeSession.SessionID == "" {
title = m.pendingSessionTitle
}
m.input.SetValue(title)
m.input.CursorEnd()
m.renamePrompt = &sessionRenamePrompt{}
return m
}
func (m model) handleSessionRenameKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case keyIs(msg, tea.KeyEsc), keyCtrl(msg, 'c'):
m.renamePrompt = nil
m.clearComposer()
return m, nil
case keyIs(msg, tea.KeyEnter):
title := m.input.Value()
m.renamePrompt = nil
m.clearComposer()
return m.renameActiveSession(title), nil
default:
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
}
func (m model) renameActiveSession(title string) model {
title = cutRunes(strings.TrimSpace(title), tuiSessionTitleLimit)
if title == "" {
return m.appendSessionRenameError("session name cannot be empty")
}
if m.sessionStore == nil {
return m.appendSessionRenameError("session storage is unavailable")
}
if m.activeSession.SessionID == "" {
m.pendingSessionTitle = title
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendSystem,
text: "Session renamed to " + title + ".",
})
return m
}
updated, err := m.sessionStore.UpdateTitle(m.activeSession.SessionID, title)
if err != nil {
return m.appendSessionRenameError(err.Error())
}
m.activeSession.Title = updated.Title
if m.titledSessions == nil {
m.titledSessions = map[string]bool{}
}
m.titledSessions[updated.SessionID] = true
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendSystem,
text: "Session renamed to " + updated.Title + ".",
})
return m
}
func (m model) appendSessionRenameError(detail string) model {
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: fmt.Sprintf("Could not rename session: %s.", strings.TrimSuffix(strings.TrimSpace(detail), ".")),
})
return m
}
func (m model) sessionRenamePromptView(width int) string {
if width <= 0 {
width = defaultStartupWidth
}
input := m.input
input.Prompt = "> "
input.Placeholder = "Type a name"
innerWidth := maxInt(1, width-4)
line := renderComposerInput(
input,
composerState{text: input.Value(), cursor: input.Position()},
innerWidth,
m.composerCursorVisible,
composerSelectionState{},
)
lines := append(strings.Split(line, "\n"),
zeroTheme.line.Render(strings.Repeat("─", innerWidth)),
zeroTheme.faint.Render("Enter save Esc cancel"),
)
return styledBlockFillTitle(width, "Rename session", lines, zeroTheme.lineStrong, lipgloss.NewStyle())
}
+190
View File
@@ -0,0 +1,190 @@
package tui
import (
"context"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/sessions"
)
func renameTestModel(t *testing.T) (model, *sessions.Store, sessions.Metadata) {
t.Helper()
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{Title: "Current session name"})
if err != nil {
t.Fatalf("create session: %v", err)
}
m := newModel(context.Background(), Options{SessionStore: store})
m.activeSession = session
return m, store, session
}
func TestRenameCommandRenamesCurrentSessionWithoutAgentRun(t *testing.T) {
m, store, session := renameTestModel(t)
m.input.SetValue("/rename Better session name ")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("/rename must not start an agent or background command")
}
if next.activeSession.Title != "Better session name" {
t.Fatalf("active title = %q", next.activeSession.Title)
}
stored, err := store.Get(session.SessionID)
if err != nil || stored == nil {
t.Fatalf("get renamed session: %v", err)
}
if stored.Title != "Better session name" {
t.Fatalf("stored title = %q", stored.Title)
}
if !next.titledSessions[session.SessionID] {
t.Fatal("manual rename should suppress later automatic naming")
}
if !transcriptContains(next.transcript, "Session renamed to Better session name.") {
t.Fatalf("missing rename confirmation: %#v", next.transcript)
}
}
func TestRenameCommandCapsSessionTitle(t *testing.T) {
m, store, session := renameTestModel(t)
longTitle := strings.Repeat("界", tuiSessionTitleLimit+20)
m.input.SetValue("/rename " + longTitle)
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("/rename must not start an agent or background command")
}
want := cutRunes(longTitle, tuiSessionTitleLimit)
if next.activeSession.Title != want {
t.Fatalf("active title has %d runes, want %d", len([]rune(next.activeSession.Title)), tuiSessionTitleLimit)
}
stored, err := store.Get(session.SessionID)
if err != nil || stored == nil || stored.Title != want {
t.Fatalf("stored capped title = %#v, err=%v", stored, err)
}
}
func TestRenameEditorBordersWrappedTitleLines(t *testing.T) {
m, _, _ := renameTestModel(t)
m.input.SetValue(strings.Repeat("x", tuiSessionTitleLimit))
m.input.CursorEnd()
m.renamePrompt = &sessionRenamePrompt{}
view := plainRender(t, m.sessionRenamePromptView(80))
lines := strings.Split(view, "\n")
if len(lines) < 6 {
t.Fatalf("expected wrapped rename editor, got:\n%s", view)
}
for _, line := range lines[1 : len(lines)-1] {
if !strings.HasPrefix(line, "│ ") || !strings.HasSuffix(line, " │") {
t.Fatalf("wrapped editor row lost its border: %q\n%s", line, view)
}
}
}
func TestBareRenameOpensPrefilledEditorAndSaves(t *testing.T) {
m, store, session := renameTestModel(t)
m.input.SetValue("/rename")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil || next.renamePrompt == nil {
t.Fatalf("bare /rename should open editor without a command: prompt=%#v cmd=%v", next.renamePrompt, cmd)
}
if next.input.Value() != "Current session name" {
t.Fatalf("editor value = %q", next.input.Value())
}
view := plainRender(t, next.View())
for _, want := range []string{"Rename session", "Current session name", "Enter save", "Esc cancel"} {
if !strings.Contains(view, want) {
t.Fatalf("rename editor missing %q:\n%s", want, view)
}
}
next.input.SetValue("Edited in prompt")
updated, cmd = next.Update(testKey(tea.KeyEnter))
next = updated.(model)
if cmd != nil || next.renamePrompt != nil {
t.Fatalf("saving should close editor without a command: prompt=%#v cmd=%v", next.renamePrompt, cmd)
}
stored, err := store.Get(session.SessionID)
if err != nil || stored == nil || stored.Title != "Edited in prompt" {
t.Fatalf("stored session after editor save = %#v, err=%v", stored, err)
}
}
func TestRenameNamesFreshSessionBeforeFirstPrompt(t *testing.T) {
store := testSessionStore(t)
m := newModel(context.Background(), Options{SessionStore: store})
m.input.SetValue("/rename Fresh session name")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("fresh /rename must stay local")
}
if next.activeSession.SessionID != "" || next.pendingSessionTitle != "Fresh session name" {
t.Fatalf("fresh rename state = active:%q pending:%q", next.activeSession.SessionID, next.pendingSessionTitle)
}
next, err := next.ensureActiveSession("first real prompt")
if err != nil {
t.Fatalf("ensure active session: %v", err)
}
if next.activeSession.Title != "Fresh session name" || next.pendingSessionTitle != "" {
t.Fatalf("created session title = %q, pending=%q", next.activeSession.Title, next.pendingSessionTitle)
}
if !next.titledSessions[next.activeSession.SessionID] {
t.Fatal("a manually named fresh session must skip automatic naming")
}
stored, err := store.Get(next.activeSession.SessionID)
if err != nil || stored == nil || stored.Title != "Fresh session name" {
t.Fatalf("stored fresh session = %#v, err=%v", stored, err)
}
}
func TestRenameEditorRejectsBlankAndEscCancels(t *testing.T) {
m, store, session := renameTestModel(t)
m = m.openSessionRenamePrompt()
m.input.SetValue(" ")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if next.renamePrompt != nil {
t.Fatal("blank submit should close the editor")
}
if !transcriptContains(next.transcript, "session name cannot be empty") {
t.Fatalf("missing blank-name error: %#v", next.transcript)
}
stored, _ := store.Get(session.SessionID)
if stored == nil || stored.Title != "Current session name" {
t.Fatalf("blank rename changed title: %#v", stored)
}
next = next.openSessionRenamePrompt()
next.input.SetValue("Do not save")
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if next.renamePrompt != nil || next.input.Value() != "" {
t.Fatalf("Esc should close and clear editor: prompt=%#v input=%q", next.renamePrompt, next.input.Value())
}
stored, _ = store.Get(session.SessionID)
if stored == nil || stored.Title != "Current session name" {
t.Fatalf("cancelled rename changed title: %#v", stored)
}
}
func TestRetitleCommandIsRemoved(t *testing.T) {
if command, ok := resolveCommand("/retitle"); ok {
t.Fatalf("/retitle should not resolve, got %#v", command)
}
command, ok := resolveCommand("/rename")
if !ok || command.kind != commandRename {
t.Fatalf("/rename should resolve, got ok=%v command=%#v", ok, command)
}
}
+94 -3
View File
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
@@ -19,9 +20,10 @@ import (
)
type scriptedProvider struct {
scripts [][]zeroruntime.StreamEvent
requests []zeroruntime.CompletionRequest
calls int
scripts [][]zeroruntime.StreamEvent
requests []zeroruntime.CompletionRequest
beforeCall func(int)
calls int
}
func (provider *scriptedProvider) StreamCompletion(
@@ -39,6 +41,9 @@ func (provider *scriptedProvider) StreamCompletion(
if index >= len(provider.scripts) {
index = len(provider.scripts) - 1
}
if provider.beforeCall != nil {
provider.beforeCall(index)
}
ch := make(chan zeroruntime.StreamEvent, len(provider.scripts[index]))
for _, event := range provider.scripts[index] {
ch <- event
@@ -358,6 +363,92 @@ func TestPromptSubmitPersistsPermissionSessionEvents(t *testing.T) {
}
}
func TestPermissionWaitDoesNotCountTowardTurnElapsed(t *testing.T) {
store := testSessionStore(t)
root := t.TempDir()
base := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC)
var clockNanos atomic.Int64
clockNanos.Store(base.UnixNano())
provider := &scriptedProvider{scripts: [][]zeroruntime.StreamEvent{
writeFileToolScript("call_write", "notes.txt", "hello"),
textScript("write blocked"),
}}
provider.beforeCall = func(index int) {
switch index {
case 0:
clockNanos.Store(base.Add(2 * time.Second).UnixNano())
case 1:
clockNanos.Store(base.Add(95 * time.Second).UnixNano())
}
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedWriteFileTool(root, nil))
runtimeMessageCh := make(chan tea.Msg, 8)
m := newPermissionTestModel(root, provider, registry, store, nil, runtimeMessageCh)
m.now = func() time.Time {
return time.Unix(0, clockNanos.Load()).UTC()
}
m.input.SetValue("write notes")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd == nil {
t.Fatal("expected prompt submit to start an agent run")
}
finalCh := make(chan tea.Msg, 1)
go func() {
finalCh <- execCmd(cmd)
}()
for received := 0; received < 4; {
runtimeMsg := receiveRuntimeMessage(t, runtimeMessageCh)
updated, _ = next.Update(runtimeMsg)
next = updated.(model)
switch runtimeMsg.(type) {
case toolCallStreamStartMsg, toolCallStreamDeltaMsg:
continue
case permissionRequestMsg:
clockNanos.Store(base.Add(92 * time.Second).UnixNano())
if status := plainRender(t, next.workingStatusLine()); !strings.Contains(status, "· 2s ·") || strings.Contains(status, "still generating") {
t.Fatalf("live elapsed advanced during permission wait: %q", status)
}
updated, _ = next.Update(testKeyText("d"))
next = updated.(model)
if hint := next.quietGenerationHint(); hint != "" {
t.Fatalf("permission wait leaked into quiet-generation age: %q", hint)
}
}
received++
}
finalMsg := receiveFinalMessage(t, finalCh)
response, ok := finalMsg.(agentResponseMsg)
if !ok {
t.Fatalf("expected agent response, got %T", finalMsg)
}
if response.turnElapsed != 5*time.Second {
t.Fatalf("turn elapsed = %s, want 5s of active work", response.turnElapsed)
}
if response.ttft != 5*time.Second {
t.Fatalf("ttft = %s, want 5s with permission wait excluded", response.ttft)
}
if response.ttft > response.turnElapsed {
t.Fatalf("ttft = %s exceeds turn elapsed = %s", response.ttft, response.turnElapsed)
}
updated, _ = next.Update(finalMsg)
next = updated.(model)
answer, ok := findTranscriptRow(next.transcript, rowAssistant)
if !ok {
t.Fatalf("expected final assistant row, got %#v", next.transcript)
}
if answer.turnElapsed != 5*time.Second {
t.Fatalf("assistant turn elapsed = %s, want 5s", answer.turnElapsed)
}
}
func TestPermissionPromptAllowWritesFileAndRecordsDecision(t *testing.T) {
store := testSessionStore(t)
root := t.TempDir()
+17 -89
View File
@@ -3,7 +3,6 @@ package tui
import (
"context"
"errors"
"fmt"
"strings"
"time"
@@ -42,12 +41,11 @@ const sessionTitleSystemPrompt = "You write a short, specific title for a coding
var errSessionTitleNoContent = errors.New("session has no content to title")
// sessionTitleGeneratedMsg carries the outcome of a background title generation
// back to the Update loop. backfill distinguishes a /retitle queue step (which
// advances the queue and updates a status row) from a silent auto-title.
// back to the Update loop.
type sessionTitleGeneratedMsg struct {
sessionID string
title string
backfill bool
applied bool
err error
}
@@ -168,32 +166,25 @@ func generateSessionTitle(ctx context.Context, provider zeroruntime.Provider, di
}
// generateSessionTitleCmd builds the background command that generates and
// persists a title for sessionID. When precomputedDigest is empty the command
// reads the session's events itself (the backfill path), keeping that I/O off the
// Update goroutine; the auto-title path passes the in-memory digest directly.
func (m model) generateSessionTitleCmd(sessionID string, precomputedDigest string, backfill bool) tea.Cmd {
// persists a title for sessionID if its original automatic title is still
// current. The compare-and-update keeps a manual /rename authoritative when it
// races a slow provider response.
func (m model) generateSessionTitleCmd(sessionID string, digest string) tea.Cmd {
provider := m.provider
store := m.sessionStore
originalTitle := m.activeSession.Title
return func() tea.Msg {
digest := precomputedDigest
if strings.TrimSpace(digest) == "" {
events, err := store.ReadEvents(sessionID)
if err != nil {
return sessionTitleGeneratedMsg{sessionID: sessionID, backfill: backfill, err: err}
}
digest = sessionTitleDigest(events)
}
ctx, cancel := context.WithTimeout(context.Background(), sessionTitleTimeout)
defer cancel()
title, err := generateSessionTitle(ctx, provider, digest)
if err != nil {
return sessionTitleGeneratedMsg{sessionID: sessionID, backfill: backfill, err: err}
return sessionTitleGeneratedMsg{sessionID: sessionID, err: err}
}
updated, err := store.UpdateTitle(sessionID, title)
updated, applied, err := store.UpdateTitleIfCurrent(sessionID, originalTitle, title)
if err != nil {
return sessionTitleGeneratedMsg{sessionID: sessionID, title: title, backfill: backfill, err: err}
return sessionTitleGeneratedMsg{sessionID: sessionID, title: title, err: err}
}
return sessionTitleGeneratedMsg{sessionID: sessionID, title: updated.Title, backfill: backfill}
return sessionTitleGeneratedMsg{sessionID: sessionID, title: updated.Title, applied: applied}
}
}
@@ -256,87 +247,24 @@ func (m model) maybeAutoTitleActiveSession() (model, tea.Cmd) {
m.titledSessions = map[string]bool{}
}
m.titledSessions[sessionID] = true
return m, m.generateSessionTitleCmd(sessionID, digest, false)
return m, m.generateSessionTitleCmd(sessionID, digest)
}
// startSessionRetitle scans resumable sessions for ones still carrying their
// default first-message title and queues a model-generated title for each,
// firing them one at a time. It returns a status line for the transcript.
func (m model) startSessionRetitle() (model, tea.Cmd, string) {
if m.provider == nil {
return m, nil, "Cannot retitle sessions: no active provider is configured."
}
if m.retitleActive {
return m, nil, fmt.Sprintf("Already generating titles (%d/%d). Let it finish first.", m.retitleDone, m.retitleTotal)
}
list, err := m.sessionStore.ListResumable()
if err != nil {
return m, nil, "Sessions\nFailed to list sessions: " + err.Error()
}
candidates := make([]string, 0, len(list))
for _, session := range list {
events, err := m.sessionStore.ReadEvents(session.SessionID)
if err != nil {
continue
}
if !eventsHaveResumableContent(events) {
continue // empty/failed run — nothing worth titling
}
if !sessionTitleIsAuto(session.Title, events) {
continue // already has a model-generated title
}
candidates = append(candidates, session.SessionID)
}
if len(candidates) == 0 {
return m, nil, "All resumable sessions already have a generated title."
}
if m.titledSessions == nil {
m.titledSessions = map[string]bool{}
}
for _, id := range candidates {
m.titledSessions[id] = true
}
m.retitleQueue = append([]string(nil), candidates[1:]...)
m.retitleActive = true
m.retitleTotal = len(candidates)
m.retitleDone = 0
m.retitleOK = 0
cmd := m.generateSessionTitleCmd(candidates[0], "", true)
return m, cmd, fmt.Sprintf("Generating titles for %d session(s)… this runs in the background.", len(candidates))
}
// handleSessionTitleGenerated applies a finished title and, for the /retitle
// backfill, advances the sequential queue and reports completion.
// handleSessionTitleGenerated applies a finished automatic title to the active
// model. The store update has already been skipped when a manual rename won.
func (m model) handleSessionTitleGenerated(msg sessionTitleGeneratedMsg) (model, tea.Cmd) {
titleOK := msg.err == nil && msg.title != ""
titleOK := msg.err == nil && msg.title != "" && msg.applied
if titleOK {
if msg.sessionID == m.activeSession.SessionID {
m.activeSession.Title = msg.title
}
} else {
} else if msg.err != nil || msg.title == "" {
// titledSessions is marked optimistically when the cmd is scheduled (so a
// second turn can't double-fire a title for the same session while the
// first is in flight). A FAILED generation — provider error, empty title,
// or store write error — must not leave that gate set forever, so release
// it here; a later turn or /retitle can then retry. Success keeps the gate.
// it here so a later turn can retry. Success keeps the gate.
delete(m.titledSessions, msg.sessionID)
}
if !msg.backfill {
// Auto-title is silent: on failure the first-message title simply stays
// (and the retry gate above was released).
return m, nil
}
m.retitleDone++
if titleOK {
m.retitleOK++
}
if len(m.retitleQueue) > 0 {
next := m.retitleQueue[0]
m.retitleQueue = m.retitleQueue[1:]
return m, m.generateSessionTitleCmd(next, "", true)
}
m.retitleActive = false
summary := fmt.Sprintf("Generated titles for %d of %d session(s). Open /resume to see them.", m.retitleOK, m.retitleTotal)
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, tool: "sessions", text: summary})
return m, nil
}
+31 -63
View File
@@ -6,8 +6,6 @@ import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -32,7 +30,7 @@ func appendSessionMessage(t *testing.T, store *sessions.Store, id, role, content
}
// createAutoTitledSession creates a real, resumable session whose Title is the
// default first-message title (so it is a retitle/auto-title candidate).
// default first-message title (so it is an auto-title candidate).
func createAutoTitledSession(t *testing.T, store *sessions.Store, prompt, answer string) sessions.Metadata {
t.Helper()
session, err := store.Create(sessions.CreateInput{Title: tuiSessionTitle(prompt)})
@@ -179,7 +177,7 @@ func TestAutoTitleGeneratesTitleForActiveSession(t *testing.T) {
if !ok {
t.Fatalf("expected sessionTitleGeneratedMsg, got %#v", msg)
}
if result.err != nil || result.backfill {
if result.err != nil || !result.applied {
t.Fatalf("unexpected auto-title result: %#v", result)
}
if result.title != "Add Fetch Call To Client" {
@@ -268,82 +266,52 @@ func TestAutoTitleFailureReleasesRetryGate(t *testing.T) {
// A successful generation keeps the session gated (one-shot, no re-fire).
ok := newModel(context.Background(), Options{})
ok.titledSessions = map[string]bool{id: true}
ok, _ = ok.handleSessionTitleGenerated(sessionTitleGeneratedMsg{sessionID: id, title: "Real Title"})
ok, _ = ok.handleSessionTitleGenerated(sessionTitleGeneratedMsg{sessionID: id, title: "Real Title", applied: true})
if !ok.titledSessions[id] {
t.Fatal("a successful title generation must keep the session gated")
}
}
func TestRetitleBackfillTitlesOnlyAutoTitledSessions(t *testing.T) {
func TestManualRenameWinsOverInFlightAutoTitle(t *testing.T) {
store := testSessionStore(t)
first := createAutoTitledSession(t, store, "build the resume picker", "Working on it.")
second := createAutoTitledSession(t, store, "fix the dst bug", "Fixed.")
// An empty/failed run: user prompt + the no-output guardrail stop. Skipped.
empty, err := store.Create(sessions.CreateInput{Title: tuiSessionTitle("do a thing")})
if err != nil {
t.Fatalf("create empty: %v", err)
}
appendSessionMessage(t, store, empty.SessionID, "user", "do a thing")
appendSessionMessage(t, store, empty.SessionID, "assistant",
"Agent stopped after 3 turns with no output (no visible text and no tool calls) to avoid consuming tokens without making progress.")
// A session that already has a distinct (hand/model) title. Skipped.
named, err := store.Create(sessions.CreateInput{Title: "Hand Named Session"})
if err != nil {
t.Fatalf("create named: %v", err)
}
appendSessionMessage(t, store, named.SessionID, "user", "something")
appendSessionMessage(t, store, named.SessionID, "assistant", "ok")
session := createAutoTitledSession(t, store, "build the resume picker", "Working on it.")
m := newModel(context.Background(), Options{
SessionStore: store,
Provider: titleProvider("Generated Backfill Title"),
Provider: titleProvider("Generated Automatic Title"),
})
m.input.SetValue("/retitle")
updated, cmd := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if !m.retitleActive {
t.Fatal("expected a backfill to be active")
m.activeSession = session
events, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatalf("read events: %v", err)
}
if m.retitleTotal != 2 {
t.Fatalf("retitle total = %d, want 2 (only auto-titled real sessions)", m.retitleTotal)
m.sessionEvents = events
m, cmd := m.maybeAutoTitleActiveSession()
if cmd == nil {
t.Fatal("expected an automatic title command")
}
if !transcriptContains(m.transcript, "Generating titles for 2 session") {
t.Fatalf("expected a kickoff status row, got %#v", m.transcript)
if _, err := store.UpdateTitle(session.SessionID, "Manual Name"); err != nil {
t.Fatalf("manual rename: %v", err)
}
// Drain the sequential queue.
guard := 0
for cmd != nil {
guard++
if guard > 10 {
t.Fatal("retitle queue did not drain")
}
msg := execCmd(cmd)
updated, cmd = m.Update(msg)
m = updated.(model)
result, ok := execCmd(cmd).(sessionTitleGeneratedMsg)
if !ok {
t.Fatal("expected a title result")
}
if m.retitleActive {
t.Fatal("backfill should be finished after the queue drains")
if result.err != nil || result.applied {
t.Fatalf("late automatic title should be skipped, got %#v", result)
}
if !transcriptContains(m.transcript, "Generated titles for 2 of 2") {
t.Fatalf("expected a completion status row, got %#v", m.transcript)
got, err := store.Get(session.SessionID)
if err != nil || got == nil {
t.Fatalf("get session: %v", err)
}
if got.Title != "Manual Name" {
t.Fatalf("late automatic title overwrote manual name: %q", got.Title)
}
for _, id := range []string{first.SessionID, second.SessionID} {
got, err := store.Get(id)
if err != nil || got == nil {
t.Fatalf("get %s: %v", id, err)
}
if got.Title != "Generated Backfill Title" {
t.Fatalf("session %s title = %q, want generated", id, got.Title)
}
}
if got, _ := store.Get(named.SessionID); got == nil || got.Title != "Hand Named Session" {
t.Fatalf("a named session must keep its title, got %#v", got)
}
if got, _ := store.Get(empty.SessionID); got == nil || got.Title != tuiSessionTitle("do a thing") {
t.Fatalf("an empty session must be skipped and keep its title, got %#v", got)
next, _ := m.handleSessionTitleGenerated(result)
if !next.titledSessions[session.SessionID] {
t.Fatal("a stale automatic title result must keep the retry gate set")
}
}
+2 -2
View File
@@ -65,7 +65,7 @@ func (m model) sidebarToggleAllowed() bool {
return false
}
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
m.mcpManager != nil || m.picker != nil || m.renamePrompt != nil || m.suggestionsActive() {
return false
}
// Home/welcome screen: stay single-column until there's real conversation.
@@ -113,7 +113,7 @@ func (m model) sidebarAvailable() bool {
// suggestionsActive() guard, so clicks still go to the palette and not to the
// sidebar rows underneath it.
if m.setup.visible || m.helpOverlay || m.leaderHelpOverlay || m.providerWizard != nil || m.mcpAddWizard != nil ||
m.mcpManager != nil || m.picker != nil {
m.mcpManager != nil || m.picker != nil || m.renamePrompt != nil {
return false
}
// Home/welcome screen: stay single-column until there's real conversation, so
+22
View File
@@ -39,3 +39,25 @@ func TestToolCardHeadShowsCleanMCPName(t *testing.T) {
t.Errorf("card head should show the clean 'web search' label:\n%s", out)
}
}
func TestToolResultCardShowsCompactOutputAndRawArtifact(t *testing.T) {
row := transcriptRow{
kind: rowToolResult,
id: "compact",
tool: "exec_command",
status: tools.StatusOK,
detail: "output:\n[zero] 20 passing package lines omitted\nexit_code: 0",
meta: map[string]string{
"truncated": "true",
"spill_path": "/tmp/zero-output.log",
"output_budget_estimated_original_tokens": "4200",
"output_budget_estimated_retained_tokens": "180",
},
}
rendered := plainRender(t, (model{}).renderRow(row, 100, buildRowContext([]transcriptRow{row})))
for _, want := range []string{"compact 4.2k→180 tok", "raw saved"} {
if !strings.Contains(rendered, want) {
t.Fatalf("rendered compact result missing %q:\n%s", want, rendered)
}
}
}
+2 -1
View File
@@ -38,7 +38,8 @@ type transcriptRow struct {
detail string // raw multi-line output (e.g. a diff to render as a card)
hint string // one-line actionable hint, rendered faintly below error rows
arg string // secondary argument hint (pattern/command), for tool call rows
runID int // owning run, for tool call rows (0 = rehydrated/unknown)
meta map[string]string
runID int // owning run, for tool call rows (0 = rehydrated/unknown)
permission *agent.PermissionEvent
askUser *agent.AskUserRequest
expanded bool // collapsible transcript rows, e.g. provider thoughts
+49 -1
View File
@@ -1125,7 +1125,8 @@ func (m model) transcriptHitTestSource() (header string, items []transcriptBodyI
// transcriptHitTestBlocked reports whether mouse hit-testing must be skipped
// outright — a modal/overlay is up, or there's no alt-screen viewport at all.
func (m model) transcriptHitTestBlocked() bool {
return !m.altScreen || m.height <= 0 || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive()
return !m.altScreen || m.height <= 0 || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
m.mcpManager != nil || m.picker != nil || m.renamePrompt != nil || m.suggestionsActive()
}
// transcriptHitTestLayout computes the frame/window/layout mouse hit-testing needs,
@@ -1498,7 +1499,54 @@ func (m model) selectedTranscriptText() string {
return strings.Join(parts, "\n")
}
// clipboardSafeText removes the control characters a clipboard write cannot
// carry, keeping newlines and tabs.
//
// A NUL PANICS THE WHOLE PROGRAM ON WINDOWS, and that is not a theoretical
// concern: syscall.StringToUTF16 — which github.com/atotto/clipboard calls on
// Windows — PANICS rather than erroring when the string contains one, so a
// single copied NUL killed the TUI with "program experienced a panic". It
// reached the clipboard because ansi.Strip removes escape SEQUENCES and leaves
// C0 bytes untouched, and the transcript legitimately carries them from two
// directions: tool output (a read of a binary file, git's -z NUL-separated
// listings) and this package's own card protocol, whose row prefixes are
// literally "\x00command-card\x00".
//
// Stripped at the WRITE, not at each source, because the sources are unbounded:
// any tool result a user can select from is a source, and a fix per source is
// one that the next tool re-breaks. The same pass protects the OSC52 fallback,
// where a stray control byte would terminate the escape sequence early and
// corrupt the copy rather than crash.
//
// \n and \t survive: a multi-line selection is the normal case, and both are
// legal clipboard content.
func clipboardSafeText(text string) string {
if !strings.ContainsFunc(text, unsafeClipboardRune) {
return text
}
var out strings.Builder
out.Grow(len(text))
for _, r := range text {
if unsafeClipboardRune(r) {
continue
}
out.WriteRune(r)
}
return out.String()
}
func unsafeClipboardRune(r rune) bool {
if r == '\n' || r == '\t' {
return false
}
return r < 0x20 || r == 0x7f
}
func copyTranscriptSelectionCmd(text string) tea.Cmd {
// SANITISED ONCE, HERE, before either destination sees it — the native
// clipboard would panic on a NUL and the OSC52 fallback would be corrupted
// by one.
text = clipboardSafeText(text)
return func() tea.Msg {
// Prefer the native OS clipboard (pbcopy / clip.exe / xclip): it works on
// local terminals — including macOS Terminal.app, which has no OSC52 support
+93
View File
@@ -0,0 +1,93 @@
package tui
import (
"sync"
"time"
)
// activeTurnTimer measures run time while excluding intervals where execution
// is paused waiting for a user decision. It is shared by the Bubble Tea model
// and the agent command, so access must remain safe across their goroutines.
type activeTurnTimer struct {
mu sync.Mutex
startedAt time.Time
pausedAt time.Time
pausedFor time.Duration
}
func newActiveTurnTimer(startedAt time.Time) *activeTurnTimer {
return &activeTurnTimer{startedAt: startedAt}
}
func (timer *activeTurnTimer) start(startedAt time.Time) {
if timer == nil {
return
}
timer.mu.Lock()
defer timer.mu.Unlock()
timer.startedAt = startedAt
timer.pausedAt = time.Time{}
timer.pausedFor = 0
}
func (timer *activeTurnTimer) pause(at time.Time) {
if timer == nil {
return
}
timer.mu.Lock()
defer timer.mu.Unlock()
if timer.startedAt.IsZero() || !timer.pausedAt.IsZero() {
return
}
timer.pausedAt = at
}
func (timer *activeTurnTimer) resume(at time.Time) {
if timer == nil {
return
}
timer.mu.Lock()
defer timer.mu.Unlock()
if timer.pausedAt.IsZero() {
return
}
if at.After(timer.pausedAt) {
timer.pausedFor += at.Sub(timer.pausedAt)
}
timer.pausedAt = time.Time{}
}
func (timer *activeTurnTimer) elapsed(at time.Time) time.Duration {
if timer == nil {
return 0
}
timer.mu.Lock()
defer timer.mu.Unlock()
if timer.startedAt.IsZero() {
return 0
}
end := at
if !timer.pausedAt.IsZero() && timer.pausedAt.Before(end) {
end = timer.pausedAt
}
elapsed := end.Sub(timer.startedAt) - timer.pausedFor
if elapsed < 0 {
return 0
}
return elapsed
}
func (m model) activeTurnElapsed(fallbackStartedAt time.Time) time.Duration {
return m.activeTurnElapsedAt(fallbackStartedAt, m.now())
}
func (m model) activeTurnElapsedAt(fallbackStartedAt, at time.Time) time.Duration {
if m.turnTimer != nil {
return m.turnTimer.elapsed(at)
}
elapsed := at.Sub(fallbackStartedAt)
if elapsed < 0 {
return 0
}
return elapsed
}
+10 -1
View File
@@ -813,7 +813,16 @@ func (m model) pickerOverlay(width int) string {
// Hints live in the footer (a separator + faint keys), matching the /model
// picker and the other bordered boxes.
lines = append(lines, zeroTheme.line.Render(strings.Repeat("─", innerWidth)))
lines = append(lines, zeroTheme.faint.Render("↑/↓ move Enter select Esc close"))
footer := zeroTheme.faint.Render("↑/↓ move Enter select Esc close")
if m.picker.kind == pickerSession {
position := 0
if len(m.picker.items) > 0 {
position = clampInt(m.picker.selected, 0, len(m.picker.items)-1) + 1
}
count := zeroTheme.faint.Render(fmt.Sprintf("%d / %d", position, len(m.picker.items)))
footer = joinHeaderLine(footer, count, innerWidth)
}
lines = append(lines, footer)
return centerRenderedBlock(styledBlockFillTitle(overlayWidth, title, lines, zeroTheme.lineStrong, lipgloss.NewStyle()), width)
}