Compare commits

...
Author SHA1 Message Date
anandh8x 73e07cd3ea test: normalize patch line endings 2026-07-31 23:07:58 +05:30
anandh8x 7d16bda4da fix: defer file observations across retries 2026-07-31 22:48:52 +05:30
anandh8x 74c06a1358 fix: commit file observations after output budgeting 2026-07-31 22:16:21 +05:30
anandh8x 2ddb134071 test: cover permission normalization wiring 2026-07-31 22:01:40 +05:30
anandh8x 1c1cb94245 fix: keep exact-read workflows recoverable 2026-07-31 21:22:07 +05:30
Anandan b4bea8c604 Merge pull request #842 from Gitlawb/fix/838-seenwhole-from-ranges
fix(tools): derive SeenWhole from the recorded ranges
2026-07-31 19:26:30 +05:30
Vasanthdev2004 9aac8237e3 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.
2026-07-31 19:08:56 +05:30
Vasanthdev2004andClaude Opus 5 77462b0b31 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]>
2026-07-31 13:22:24 +05:30
Vasanthdev2004andClaude Opus 5 a1a9503b62 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]>
2026-07-31 12:58:31 +05:30
anandh8x 6474b144ec 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.
2026-07-31 12:38:16 +05:30
anandh8x 689591d47b 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.
2026-07-31 12:26:12 +05:30
anandh8x 400e285249 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.
2026-07-31 12:10:33 +05:30
anandh8x 6ce0931a63 Address harness efficiency review findings 2026-07-31 11:14:03 +05:30
anandh8x 68a60f14b4 Improve harness context efficiency 2026-07-31 00:35:43 +05:30
49 changed files with 2590 additions and 252 deletions

No files matched your search

@@ -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
@@ -144,3 +164,120 @@ func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T)
t.Fatalf("OnPermissionRequest called %d times, want exactly 1 for a valid elevation request", promptCalls)
}
}
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)
}
}
+85 -40
View File
@@ -1076,6 +1076,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 +1357,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 +1410,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
@@ -1690,20 +1695,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 +1724,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,6 +2755,45 @@ 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 || raw == nil {
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)
}
}
func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (sandbox.RequestPermissionProfile, bool, error) {
if !shellCommandAdditionalPermissionsRequested(args) {
if _, exists := args["additional_permissions"]; exists {
@@ -2938,22 +2984,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 +3010,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
+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)
+7 -11
View File
@@ -31,15 +31,10 @@ work.
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.
keep it current at meaningful milestones. Mark completed work and the next
in-progress step together when practical; do not spend separate model turns
updating the plan after every file or command. Keep at most one item
in_progress. Skip the plan for trivial one-step tasks.
3. **Implement.** Make focused changes that match the surrounding code's style,
naming, and conventions. Prefer the smallest change that fully solves the
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
@@ -57,8 +52,9 @@ work.
file tools - read_file, list_directory, glob, grep, write_file, edit_file,
apply_patch - over shelling out to cat/sed/awk/python for file operations.
They are safer, reviewable, and produce clean diffs.
- Make one tool call per file. Do not batch multi-file writes into a single
shell or script invocation.
- 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
+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 < 3 {
t.Fatalf("deferredEligibleCount(core) = %d, want at least 3 optional built-ins", got)
}
}
+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))
+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.
//
+1
View File
@@ -43,6 +43,7 @@ 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{
+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
}
+259 -6
View File
@@ -79,6 +79,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 +244,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 +253,127 @@ 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 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)
}
}
+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, or use byte_offset/byte_limit for an oversized single 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)
}
}
+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",
+1
View File
@@ -40,6 +40,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",
+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
+121 -9
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,16 @@ 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 a file by 1-based inclusive line range, or by exact byte chunks for oversized single-line files.",
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: "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)},
"byte_offset": {Type: "integer", Description: "Zero-based byte offset for an exact chunk read. Use with byte_limit for oversized single-line files; cannot be combined with line ranges.", Minimum: intPtr(0)},
"byte_limit": {Type: "integer", Description: "Maximum source bytes returned in exact byte mode. Defaults to 65536.", Default: readFileByteChunkMax, Minimum: intPtr(1), Maximum: intPtr(readFileByteChunkMax)},
},
Required: []string{"path"},
AdditionalProperties: false,
@@ -76,6 +81,20 @@ func (tool readFileTool) run(args map[string]any, options RunOptions, directBudg
if err != nil {
return errorResult("Error: Invalid arguments for read_file: " + err.Error())
}
_, hasByteOffset := args["byte_offset"]
_, hasByteLimit := args["byte_limit"]
byteMode := hasByteOffset || hasByteLimit
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())
}
if byteMode && (args["start_line"] != nil || args["end_line"] != nil || args["max_lines"] != nil) {
return errorResult("Error: Invalid arguments for read_file: byte_offset/byte_limit cannot be combined with line ranges")
}
absolutePath, relativePath, err := resolveScopedReadPath(tool.workspaceRoot, tool.scope, requestedPath)
if err != nil {
@@ -91,12 +110,61 @@ 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 {
@@ -140,9 +208,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 start_line/end_line or max_lines for normal files; use byte_offset/byte_limit for an oversized single line")
} else {
budgetedOutput = newHeadTailOutputBudgetBuilder(readOutputBudgetBytes, "use start_line/end_line or max_lines to continue with a smaller range")
budgetedOutput = newHeadTailOutputBudgetBuilder(readOutputBudgetBytes, "use start_line/end_line or max_lines for normal files; use byte_offset/byte_limit for an oversized single line")
}
budgetedOutput.WriteString(header)
budgetedOutput.WriteString("\n")
@@ -186,6 +254,7 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
type readFileStats struct {
lines int
bytes int
hash string
info os.FileInfo
}
@@ -200,6 +269,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 +281,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 {
+2 -1
View File
@@ -29,6 +29,7 @@ func NewScopedReadMinifiedFileTool(workspaceRoot string, scope PathScope) Tool {
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.",
deferred: true,
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
@@ -112,7 +113,7 @@ 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 read_file with start_line/end_line or max_lines for normal files, or byte_offset/byte_limit for an oversized single line")
}
return toolResult
}
+38
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)
+41
View File
@@ -515,3 +515,44 @@ 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,
"read_minified_file": 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() == "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)
}
}
+95
View File
@@ -0,0 +1,95 @@
package tools
import (
"fmt"
"strconv"
"strings"
)
const commandReducerMinPassingLines = 12
// 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 || !isSimpleGoTestCommand(command) {
return result
}
reduced, omitted := reduceGoTestPassingPackages(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 passing package lines; exact output saved to %s", omitted, 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_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 isSimpleGoTestCommand(command string) bool {
command = strings.TrimSpace(command)
if command == "" || strings.ContainsAny(command, "|;&<>\n\r`") || strings.Contains(command, "$(") {
return false
}
fields := strings.Fields(command)
return len(fields) >= 2 && fields[0] == "go" && fields[1] == "test"
}
func reduceGoTestPassingPackages(output string) (string, int) {
lines := strings.Split(output, "\n")
kept := make([]string, 0, len(lines))
omitted := 0
for _, line := range lines {
packageSuccess := strings.HasPrefix(line, "ok \t") || strings.HasPrefix(line, "? \t") ||
strings.HasPrefix(line, "ok ") || strings.HasPrefix(line, "? ")
if packageSuccess && !strings.Contains(line, "coverage:") && !strings.Contains(line, "[no test files]") {
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 passing package lines omitted", omitted)
kept = append(kept, "")
copy(kept[insertAt+1:], kept[insertAt:])
kept[insertAt] = summary
return strings.Join(kept, "\n"), omitted
}
+133
View File
@@ -0,0 +1,133 @@
package tools
import (
"fmt"
"os"
"strings"
"testing"
)
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)
}
}
+9
View File
@@ -115,6 +115,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 +162,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 +181,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
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)
}
+23 -6
View File
@@ -472,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
@@ -1155,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 {
@@ -1163,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 {
@@ -2466,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
@@ -2507,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:
@@ -2993,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))
}
@@ -4848,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()
}
@@ -5395,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,
+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
+18
View File
@@ -635,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"),
})
@@ -828,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 {
+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