mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d9a68b666 | ||
|
|
ad152e0049 | ||
|
|
b4b0418daf | ||
|
|
8e266797a8 | ||
|
|
d37de9214b | ||
|
|
588635487f | ||
|
|
845df6c7d2 | ||
|
|
15eb16f736 |
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -585,6 +585,9 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
baseProbeProviderHealth := deps.probeProviderHealth
|
||||
deps.probeProviderHealth = func(ctx context.Context, options providerhealth.Options) providerhealth.Result {
|
||||
options.Profile = applyStoredProviderKeyAt(options.Profile, userConfigPath)
|
||||
if options.OAuthResolver == nil {
|
||||
options.OAuthResolver, _ = oauthLoginForProfile(options.Profile)
|
||||
}
|
||||
return baseProbeProviderHealth(ctx, options)
|
||||
}
|
||||
return deps
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
@@ -109,6 +112,65 @@ func TestRunDoctorConnectivityProbesProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoctorConnectivityUsesOAuthLogin(t *testing.T) {
|
||||
tokenPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("ZERO_OAUTH_STORAGE", "file")
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokenPath)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: tokenPath})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("xai"), oauth.Token{
|
||||
AccessToken: "oauth-doctor-token",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed OAuth token: %v", err)
|
||||
}
|
||||
|
||||
var gotAuth string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
profile := config.ProviderProfile{
|
||||
Name: "xai",
|
||||
CatalogID: "xai",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: server.URL + "/v1",
|
||||
Model: "grok-4",
|
||||
}
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"doctor", "--connectivity"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return t.TempDir(), nil
|
||||
},
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{
|
||||
Provider: profile,
|
||||
Providers: []config.ProviderProfile{profile},
|
||||
ActiveProvider: profile.Name,
|
||||
}, nil
|
||||
},
|
||||
probeProviderHealth: providerhealth.Probe,
|
||||
now: fixedCLITime("2026-07-29T20:00:00Z"),
|
||||
})
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("doctor exit = %d, want success; stderr=%q\n%s", exitCode, stderr.String(), stdout.String())
|
||||
}
|
||||
if gotAuth != "Bearer oauth-doctor-token" {
|
||||
t.Fatalf("Authorization = %q, want OAuth bearer", gotAuth)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "[pass] provider.connectivity") {
|
||||
t.Fatalf("doctor did not report passing connectivity:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoctorConnectivityReportsProviderHealthFailure(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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))
|
||||
|
||||
@@ -46,12 +46,13 @@ const (
|
||||
const defaultTimeout = 5 * time.Second
|
||||
|
||||
type Options struct {
|
||||
Profile config.ProviderProfile
|
||||
Connectivity bool
|
||||
HTTPClient *http.Client
|
||||
Resolver Resolver
|
||||
Timeout time.Duration
|
||||
UserAgent string
|
||||
Profile config.ProviderProfile
|
||||
Connectivity bool
|
||||
HTTPClient *http.Client
|
||||
Resolver Resolver
|
||||
OAuthResolver providerio.TokenResolver
|
||||
Timeout time.Duration
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
type Resolver interface {
|
||||
@@ -207,11 +208,26 @@ func Probe(ctx context.Context, options Options) Result {
|
||||
"providerKind": metadata.ProviderKind,
|
||||
}, profile))
|
||||
|
||||
if credentialRequired(profile, metadata.ProviderKind) && !hasCredential(profile) {
|
||||
oauthConfigured := options.OAuthResolver != nil
|
||||
if oauthConfigured {
|
||||
var resolveErr error
|
||||
profile, oauthConfigured, resolveErr = profileWithOAuthCredential(ctx, profile, options.OAuthResolver, false)
|
||||
if resolveErr != nil {
|
||||
result.add(check("provider.auth", "Provider auth", StatusFail, CategoryAuth, "Provider OAuth credential could not be resolved: "+resolveErr.Error(), map[string]any{
|
||||
"oauthLogin": true,
|
||||
}, profile))
|
||||
return result.finalize()
|
||||
}
|
||||
}
|
||||
if credentialRequired(profile, metadata.ProviderKind) && !hasCredential(profile) && !oauthConfigured {
|
||||
result.add(check("provider.auth", "Provider auth", StatusFail, CategoryAuth, fmt.Sprintf("Provider %s requires API credentials.", providerName(profile)), credentialDetails(profile), profile))
|
||||
return result.finalize()
|
||||
}
|
||||
if hasCredential(profile) {
|
||||
if oauthConfigured {
|
||||
result.add(check("provider.auth", "Provider auth", StatusPass, CategoryAuth, fmt.Sprintf("Provider %s has OAuth credentials configured.", providerName(profile)), map[string]any{
|
||||
"oauthLogin": true,
|
||||
}, profile))
|
||||
} else if hasCredential(profile) {
|
||||
result.add(check("provider.auth", "Provider auth", StatusPass, CategoryAuth, fmt.Sprintf("Provider %s has credentials configured.", providerName(profile)), credentialDetails(profile), profile))
|
||||
} else {
|
||||
result.add(check("provider.auth", "Provider auth", StatusPass, CategoryAuth, fmt.Sprintf("Provider %s does not require API credentials.", providerName(profile)), credentialDetails(profile), profile))
|
||||
@@ -286,9 +302,35 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
|
||||
if err != nil {
|
||||
return classifyTransportError(err, profile)
|
||||
}
|
||||
defer func() {
|
||||
_ = response.Body.Close()
|
||||
}()
|
||||
defer func(body io.ReadCloser) {
|
||||
_ = body.Close()
|
||||
}(response.Body)
|
||||
if response.StatusCode == http.StatusUnauthorized && options.OAuthResolver != nil {
|
||||
refreshed, ok, refreshErr := profileWithOAuthCredential(requestCtx, options.Profile, options.OAuthResolver, true)
|
||||
if refreshErr != nil {
|
||||
return check("provider.connectivity", "Provider connectivity", StatusFail, CategoryAuth, "Provider OAuth credential refresh failed: "+refreshErr.Error(), map[string]any{
|
||||
"statusCode": response.StatusCode,
|
||||
}, profile)
|
||||
}
|
||||
if ok {
|
||||
_ = response.Body.Close()
|
||||
profile = refreshed
|
||||
request, allowLoopbackOrPrivate, err = healthRequest(requestCtx, profile, kind, options)
|
||||
if err != nil {
|
||||
return check("provider.connectivity", "Provider connectivity", StatusFail, CategoryConfig, err.Error(), nil, profile)
|
||||
}
|
||||
if options.HTTPClient == nil {
|
||||
client = newConnectivityClient(timeout, options.Resolver, sensitiveAuthHeaderNames(profile, kind), allowLoopbackOrPrivate)
|
||||
}
|
||||
response, err = client.Do(request)
|
||||
if err != nil {
|
||||
return classifyTransportError(err, profile)
|
||||
}
|
||||
defer func(body io.ReadCloser) {
|
||||
_ = body.Close()
|
||||
}(response.Body)
|
||||
}
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, 64*1024))
|
||||
if err != nil {
|
||||
@@ -320,6 +362,23 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
|
||||
}, profile)
|
||||
}
|
||||
|
||||
func profileWithOAuthCredential(ctx context.Context, profile config.ProviderProfile, resolver providerio.TokenResolver, forceRefresh bool) (config.ProviderProfile, bool, error) {
|
||||
if resolver == nil {
|
||||
return profile, false, nil
|
||||
}
|
||||
header, value, ok, err := resolver(ctx, forceRefresh)
|
||||
if err != nil || !ok {
|
||||
return profile, false, err
|
||||
}
|
||||
if strings.TrimSpace(header) == "" {
|
||||
header = "Authorization"
|
||||
}
|
||||
profile.AuthHeader = header
|
||||
profile.AuthScheme = ""
|
||||
profile.AuthHeaderValue = value
|
||||
return profile, true, nil
|
||||
}
|
||||
|
||||
func healthRequest(ctx context.Context, profile config.ProviderProfile, kind config.ProviderKind, options Options) (*http.Request, bool, error) {
|
||||
baseURL, err := resolvedBaseURL(profile, kind)
|
||||
if err != nil {
|
||||
@@ -748,6 +807,9 @@ func redact(message string, profile config.ProviderProfile) string {
|
||||
|
||||
func providerSecrets(profile config.ProviderProfile) []string {
|
||||
secrets := []string{profile.APIKey, profile.AuthHeaderValue}
|
||||
if fields := strings.Fields(profile.AuthHeaderValue); len(fields) == 2 {
|
||||
secrets = append(secrets, fields[1])
|
||||
}
|
||||
for _, value := range profile.CustomHeaders {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
secrets = append(secrets, value)
|
||||
|
||||
@@ -180,6 +180,182 @@ func TestProbeResolvedKindConnectivityAuthErrorRedactsSecret(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeConnectivityUsesOAuthCredential(t *testing.T) {
|
||||
var gotAuth string
|
||||
resolverCalled := false
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Body: io.NopCloser(strings.NewReader(`{"data":[]}`)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})}
|
||||
|
||||
result := Probe(context.Background(), Options{
|
||||
Profile: config.ProviderProfile{
|
||||
Name: "xai",
|
||||
CatalogID: "xai",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://api.example.com/v1",
|
||||
Model: "grok-4",
|
||||
},
|
||||
Connectivity: true,
|
||||
HTTPClient: client,
|
||||
Resolver: staticResolver{addr: netip.MustParseAddr("93.184.216.34")},
|
||||
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
|
||||
resolverCalled = true
|
||||
return "Authorization", "Bearer oauth-test-token", true, nil
|
||||
},
|
||||
})
|
||||
|
||||
if result.Status != StatusPass {
|
||||
t.Fatalf("Status = %q, want pass: %#v", result.Status, result.Checks)
|
||||
}
|
||||
if !resolverCalled {
|
||||
t.Fatal("OAuth resolver was not called")
|
||||
}
|
||||
if gotAuth != "Bearer oauth-test-token" {
|
||||
t.Fatalf("Authorization = %q, want OAuth bearer", gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeConnectivityOAuthCredentialsAreRedacted(t *testing.T) {
|
||||
const (
|
||||
staticKey = "STATICKEYVALUE1234567890abcdef"
|
||||
oauthToken = "hf_VASANTHSENTINEL0011223344"
|
||||
)
|
||||
var authHeaders []string
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
authHeaders = append(authHeaders, r.Header.Get("Authorization"))
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Status: "401 Unauthorized",
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"invalid credentials ` + staticKey + ` and ` + oauthToken + `"}}`)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})}
|
||||
|
||||
result := Probe(context.Background(), Options{
|
||||
Profile: config.ProviderProfile{
|
||||
Name: "xai",
|
||||
CatalogID: "xai",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://api.example.com/v1",
|
||||
APIKey: staticKey,
|
||||
Model: "grok-4",
|
||||
},
|
||||
Connectivity: true,
|
||||
HTTPClient: client,
|
||||
Resolver: staticResolver{addr: netip.MustParseAddr("93.184.216.34")},
|
||||
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
|
||||
return "Authorization", "Bearer " + oauthToken, true, nil
|
||||
},
|
||||
})
|
||||
|
||||
if len(authHeaders) != 2 {
|
||||
t.Fatalf("Authorization headers = %#v, want initial request and one refresh retry", authHeaders)
|
||||
}
|
||||
for _, header := range authHeaders {
|
||||
if header != "Bearer "+oauthToken {
|
||||
t.Fatalf("Authorization = %q, want OAuth bearer", header)
|
||||
}
|
||||
}
|
||||
check := result.Check("provider.connectivity")
|
||||
if check == nil || check.Category != CategoryAuth {
|
||||
t.Fatalf("provider.connectivity = %#v, want auth failure", check)
|
||||
}
|
||||
if strings.Contains(check.Message, staticKey) {
|
||||
t.Fatalf("static API key leaked in message: %q", check.Message)
|
||||
}
|
||||
if strings.Contains(check.Message, oauthToken) {
|
||||
t.Fatalf("OAuth token leaked in message: %q", check.Message)
|
||||
}
|
||||
if strings.Count(check.Message, "[REDACTED]") != 2 {
|
||||
t.Fatalf("expected both credentials to be redacted, got %q", check.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeWithoutConnectivityRejectsUnavailableOAuthCredential(t *testing.T) {
|
||||
resolverCalled := false
|
||||
result := Probe(context.Background(), Options{
|
||||
Profile: config.ProviderProfile{
|
||||
Name: "xai",
|
||||
CatalogID: "xai",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://api.example.com/v1",
|
||||
Model: "grok-4",
|
||||
},
|
||||
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
|
||||
resolverCalled = true
|
||||
return "", "", false, nil
|
||||
},
|
||||
})
|
||||
|
||||
if !resolverCalled {
|
||||
t.Fatal("OAuth resolver was not called")
|
||||
}
|
||||
if result.Status != StatusFail {
|
||||
t.Fatalf("Status = %q, want fail: %#v", result.Status, result.Checks)
|
||||
}
|
||||
check := result.Check("provider.auth")
|
||||
if check == nil || check.Status != StatusFail || check.Category != CategoryAuth {
|
||||
t.Fatalf("provider.auth = %#v, want auth failure", check)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeConnectivityRefreshesOAuthCredentialAfterUnauthorized(t *testing.T) {
|
||||
var authHeaders []string
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
authHeaders = append(authHeaders, r.Header.Get("Authorization"))
|
||||
status := http.StatusOK
|
||||
body := `{"data":[]}`
|
||||
if len(authHeaders) == 1 {
|
||||
status = http.StatusUnauthorized
|
||||
body = `{"error":{"message":"expired"}}`
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Status: http.StatusText(status),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})}
|
||||
|
||||
var refreshFlags []bool
|
||||
result := Probe(context.Background(), Options{
|
||||
Profile: config.ProviderProfile{
|
||||
Name: "xai",
|
||||
CatalogID: "xai",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://api.example.com/v1",
|
||||
Model: "grok-4",
|
||||
},
|
||||
Connectivity: true,
|
||||
HTTPClient: client,
|
||||
Resolver: staticResolver{addr: netip.MustParseAddr("93.184.216.34")},
|
||||
OAuthResolver: func(_ context.Context, forceRefresh bool) (string, string, bool, error) {
|
||||
refreshFlags = append(refreshFlags, forceRefresh)
|
||||
token := "stale-token"
|
||||
if forceRefresh {
|
||||
token = "fresh-token"
|
||||
}
|
||||
return "Authorization", "Bearer " + token, true, nil
|
||||
},
|
||||
})
|
||||
|
||||
if result.Status != StatusPass {
|
||||
t.Fatalf("Status = %q, want pass after refresh: %#v", result.Status, result.Checks)
|
||||
}
|
||||
if len(refreshFlags) != 2 || refreshFlags[0] || !refreshFlags[1] {
|
||||
t.Fatalf("OAuth refresh flags = %#v, want [false true]", refreshFlags)
|
||||
}
|
||||
if len(authHeaders) != 2 || authHeaders[0] != "Bearer stale-token" || authHeaders[1] != "Bearer fresh-token" {
|
||||
t.Fatalf("Authorization headers = %#v, want stale then fresh bearer", authHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeConnectivityClassifiesTimeout(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, context.DeadlineExceeded
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestUpdateTitle(t *testing.T) {
|
||||
t.Fatalf("title not trimmed/stored: %q", updated.Title)
|
||||
}
|
||||
if updated.UpdatedAt != before.UpdatedAt {
|
||||
t.Fatalf("UpdatedAt must not change on retitle: before=%q after=%q", before.UpdatedAt, updated.UpdatedAt)
|
||||
t.Fatalf("UpdatedAt must not change on rename: before=%q after=%q", before.UpdatedAt, updated.UpdatedAt)
|
||||
}
|
||||
if updated.EventCount != before.EventCount {
|
||||
t.Fatalf("EventCount changed: before=%d after=%d", before.EventCount, updated.EventCount)
|
||||
@@ -67,7 +67,7 @@ func TestUpdateTitle(t *testing.T) {
|
||||
|
||||
// An unchanged title is a no-op (still succeeds).
|
||||
if _, err := store.UpdateTitle(session.SessionID, "Clean Generated Title"); err != nil {
|
||||
t.Fatalf("no-op retitle should succeed: %v", err)
|
||||
t.Fatalf("no-op rename should succeed: %v", err)
|
||||
}
|
||||
|
||||
// An invalid session id is rejected.
|
||||
@@ -76,6 +76,30 @@ func TestUpdateTitle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTitleIfCurrent(t *testing.T) {
|
||||
store := newTitleTestStore(t)
|
||||
session, err := store.Create(CreateInput{Title: "Automatic title"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
updated, applied, err := store.UpdateTitleIfCurrent(session.SessionID, "Automatic title", "Generated title")
|
||||
if err != nil || !applied || updated.Title != "Generated title" {
|
||||
t.Fatalf("matching update = (%#v, %v, %v)", updated, applied, err)
|
||||
}
|
||||
|
||||
if _, err := store.UpdateTitle(session.SessionID, "Manual title"); err != nil {
|
||||
t.Fatalf("manual rename: %v", err)
|
||||
}
|
||||
updated, applied, err = store.UpdateTitleIfCurrent(session.SessionID, "Generated title", "Late generated title")
|
||||
if err != nil {
|
||||
t.Fatalf("stale update: %v", err)
|
||||
}
|
||||
if applied || updated.Title != "Manual title" {
|
||||
t.Fatalf("stale update should preserve manual title: (%#v, %v)", updated, applied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateModel(t *testing.T) {
|
||||
store := newTitleTestStore(t)
|
||||
session, err := store.Create(CreateInput{ModelID: "model-a"})
|
||||
|
||||
@@ -719,7 +719,7 @@ func (store *Store) appendPreparedEventsLocked(sessionID string, inputs []prepar
|
||||
// serialized under the same per-session lock as AppendEvent and re-reads the
|
||||
// latest metadata under that lock before rewriting, so a concurrent append can't
|
||||
// clobber the new title (nor the title clobber a concurrent append's event
|
||||
// count/timestamp). UpdatedAt is deliberately left untouched: a retitle is not
|
||||
// count/timestamp). UpdatedAt is deliberately left untouched: a rename is not
|
||||
// activity, so it must not reorder the session in the resumable list. A blank
|
||||
// title is rejected so a failed model generation can never erase a useful
|
||||
// first-message title, and an unchanged title is a no-op (no rewrite/fsync).
|
||||
@@ -751,6 +751,40 @@ func (store *Store) UpdateTitle(sessionID string, title string) (Metadata, error
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// UpdateTitleIfCurrent replaces a session title only when it still matches
|
||||
// expected. It lets background automatic naming avoid overwriting a newer manual
|
||||
// rename while keeping the check and write under the same per-session lock.
|
||||
func (store *Store) UpdateTitleIfCurrent(sessionID string, expected string, title string) (Metadata, bool, error) {
|
||||
if !ValidSessionID(sessionID) {
|
||||
return Metadata{}, false, fmt.Errorf("invalid zero session id %q", sessionID)
|
||||
}
|
||||
trimmed := strings.TrimSpace(title)
|
||||
if trimmed == "" {
|
||||
return Metadata{}, false, fmt.Errorf("zero session title is required")
|
||||
}
|
||||
unlock, err := store.lockSession(sessionID)
|
||||
if err != nil {
|
||||
return Metadata{}, false, err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
session, err := store.readMetadata(sessionID)
|
||||
if err != nil {
|
||||
return Metadata{}, false, err
|
||||
}
|
||||
if session.Title != strings.TrimSpace(expected) {
|
||||
return session, false, nil
|
||||
}
|
||||
if session.Title == trimmed {
|
||||
return session, true, nil
|
||||
}
|
||||
session.Title = trimmed
|
||||
if err := store.writeMetadata(session); err != nil {
|
||||
return Metadata{}, false, err
|
||||
}
|
||||
return session, true, nil
|
||||
}
|
||||
|
||||
// UpdateModel replaces a session's selected model without changing its activity
|
||||
// timestamp or event counters. An empty model clears the session override.
|
||||
func (store *Store) UpdateModel(sessionID string, modelID string) (Metadata, error) {
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 " +
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const commandReducerMinPassingLines = 12
|
||||
|
||||
type commandOutputReducer struct {
|
||||
kind string
|
||||
label string
|
||||
reduce func(string) (string, int)
|
||||
}
|
||||
|
||||
// reduceCommandOutput removes repetitive success-only lines from recognized
|
||||
// simple commands. Unsupported or compound shell input passes through exactly,
|
||||
// and every changed result retains the pre-reduction output as an artifact.
|
||||
func reduceCommandOutput(toolName string, args map[string]any, result Result) Result {
|
||||
if toolName != ExecCommandToolName && toolName != "bash" {
|
||||
return result
|
||||
}
|
||||
command, err := commandTextForReducer(toolName, args)
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
reducer, ok := commandOutputReducerFor(command)
|
||||
if !ok {
|
||||
return result
|
||||
}
|
||||
|
||||
reduced, omitted := reducer.reduce(result.Output)
|
||||
if omitted < commandReducerMinPassingLines || len(reduced) >= len(result.Output) {
|
||||
return result
|
||||
}
|
||||
originalBytes := len(result.Output)
|
||||
originalTokens := estimateOutputTokens(result.Output)
|
||||
spillPath := spillTruncatedOutput(toolName, result.Output)
|
||||
if spillPath == "" {
|
||||
return result
|
||||
}
|
||||
|
||||
notice := fmt.Sprintf("[zero] compacted %d %s; exact output saved to %s", omitted, reducer.label, spillPath)
|
||||
result.Output = strings.TrimRight(reduced, "\n") + "\n" + notice
|
||||
result.Truncated = true
|
||||
if result.Meta == nil {
|
||||
result.Meta = map[string]string{}
|
||||
}
|
||||
result.Meta["command_output_reduced"] = "true"
|
||||
result.Meta["command_output_reducer"] = reducer.kind
|
||||
result.Meta["command_output_omitted_lines"] = strconv.Itoa(omitted)
|
||||
result.Meta["command_output_original_bytes"] = strconv.Itoa(originalBytes)
|
||||
result.Meta["command_output_original_tokens"] = strconv.Itoa(originalTokens)
|
||||
result.Meta["command_output_retained_tokens"] = strconv.Itoa(estimateOutputTokens(result.Output))
|
||||
result.Meta["spill_path"] = spillPath
|
||||
result.Meta["truncation_reason"] = "command_aware_reduction"
|
||||
return result
|
||||
}
|
||||
|
||||
func commandTextForReducer(toolName string, args map[string]any) (string, error) {
|
||||
if toolName == "bash" {
|
||||
return bashCommandArg(args)
|
||||
}
|
||||
return execCommandArg(args)
|
||||
}
|
||||
|
||||
func simpleCommandFields(command string) ([]string, bool) {
|
||||
command = strings.TrimSpace(command)
|
||||
if separator := strings.LastIndexAny(command, " \t"); separator >= 0 && command[separator+1:] == "2>&1" {
|
||||
command = strings.TrimSpace(command[:separator])
|
||||
}
|
||||
if command == "" || strings.ContainsAny(command, "|;&<>\n\r`") || strings.Contains(command, "$(") {
|
||||
return nil, false
|
||||
}
|
||||
fields := strings.Fields(command)
|
||||
if len(fields) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return fields, true
|
||||
}
|
||||
|
||||
func commandOutputReducerFor(command string) (commandOutputReducer, bool) {
|
||||
fields, ok := simpleCommandFields(command)
|
||||
if !ok {
|
||||
return commandOutputReducer{}, false
|
||||
}
|
||||
if len(fields) >= 2 && fields[0] == "go" && fields[1] == "test" {
|
||||
return commandOutputReducer{kind: "go_test", label: "passing package lines", reduce: reduceGoTestPassingPackages}, true
|
||||
}
|
||||
if len(fields) >= 2 && fields[0] == "cargo" {
|
||||
switch fields[1] {
|
||||
case "test":
|
||||
return commandOutputReducer{kind: "cargo_test", label: "passing Rust test lines", reduce: reduceCargoTestPassingLines}, true
|
||||
case "build", "check":
|
||||
return commandOutputReducer{kind: "cargo_build", label: "successful Rust build progress lines", reduce: reduceCargoBuildProgressLines}, true
|
||||
}
|
||||
}
|
||||
if isPytestCommand(fields) {
|
||||
return commandOutputReducer{kind: "pytest", label: "passing pytest progress lines", reduce: reducePytestPassingLines}, true
|
||||
}
|
||||
if isJavaScriptTestCommand(fields) {
|
||||
return commandOutputReducer{kind: "javascript_test", label: "passing JavaScript test-file lines", reduce: reduceJavaScriptTestPassingLines}, true
|
||||
}
|
||||
return commandOutputReducer{}, false
|
||||
}
|
||||
|
||||
func isPytestCommand(fields []string) bool {
|
||||
if fields[0] == "pytest" || fields[0] == "py.test" {
|
||||
return true
|
||||
}
|
||||
return len(fields) >= 3 && (fields[0] == "python" || fields[0] == "python3") && fields[1] == "-m" && fields[2] == "pytest"
|
||||
}
|
||||
|
||||
func isJavaScriptTestCommand(fields []string) bool {
|
||||
switch fields[0] {
|
||||
case "jest", "vitest":
|
||||
return true
|
||||
case "npx":
|
||||
return len(fields) >= 2 && (fields[1] == "jest" || fields[1] == "vitest")
|
||||
case "npm", "pnpm", "yarn":
|
||||
if len(fields) >= 2 && fields[1] == "test" {
|
||||
return true
|
||||
}
|
||||
if len(fields) >= 3 && fields[1] == "run" && fields[2] == "test" {
|
||||
return true
|
||||
}
|
||||
return len(fields) >= 3 && fields[1] == "exec" && (fields[2] == "jest" || fields[2] == "vitest")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func reduceGoTestPassingPackages(output string) (string, int) {
|
||||
return reduceRepeatedOutputLines(output, "passing package lines", func(line string) bool {
|
||||
packageSuccess := strings.HasPrefix(line, "ok \t") || strings.HasPrefix(line, "? \t") ||
|
||||
strings.HasPrefix(line, "ok ") || strings.HasPrefix(line, "? ")
|
||||
return packageSuccess && !strings.Contains(line, "coverage:") && !strings.Contains(line, "[no test files]")
|
||||
})
|
||||
}
|
||||
|
||||
func reduceCargoTestPassingLines(output string) (string, int) {
|
||||
if !strings.Contains(output, "test result:") {
|
||||
return output, 0
|
||||
}
|
||||
return reduceRepeatedOutputLines(output, "passing Rust test lines", func(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
return strings.HasPrefix(trimmed, "test ") && strings.HasSuffix(trimmed, " ... ok")
|
||||
})
|
||||
}
|
||||
|
||||
func reduceCargoBuildProgressLines(output string) (string, int) {
|
||||
if !outputHasCargoFinishedSummary(output) {
|
||||
return output, 0
|
||||
}
|
||||
return reduceRepeatedOutputLines(output, "successful Rust build progress lines", func(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
return strings.HasPrefix(trimmed, "Checking ") || strings.HasPrefix(trimmed, "Compiling ")
|
||||
})
|
||||
}
|
||||
|
||||
func reducePytestPassingLines(output string) (string, int) {
|
||||
if !outputHasPytestSummary(output) {
|
||||
return output, 0
|
||||
}
|
||||
return reduceRepeatedOutputLines(output, "passing pytest progress lines", isPassingPytestProgressLine)
|
||||
}
|
||||
|
||||
func isPassingPytestProgressLine(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
open := strings.LastIndexByte(trimmed, '[')
|
||||
if open < 0 || !strings.HasSuffix(trimmed, "%]") {
|
||||
return false
|
||||
}
|
||||
progress := strings.TrimSpace(trimmed[:open])
|
||||
fields := strings.Fields(progress)
|
||||
if len(fields) == 0 {
|
||||
return false
|
||||
}
|
||||
marks := fields[len(fields)-1]
|
||||
return marks != "" && strings.Trim(marks, ".") == ""
|
||||
}
|
||||
|
||||
func reduceJavaScriptTestPassingLines(output string) (string, int) {
|
||||
if !outputHasJavaScriptTestSummary(output) {
|
||||
return output, 0
|
||||
}
|
||||
return reduceRepeatedOutputLines(output, "passing JavaScript test-file lines", func(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
return strings.HasPrefix(line, "PASS ") || strings.HasPrefix(trimmed, "✓ ") || strings.HasPrefix(trimmed, "✔ ")
|
||||
})
|
||||
}
|
||||
|
||||
func outputHasCargoFinishedSummary(output string) bool {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "Finished `") && strings.Contains(trimmed, "` profile ") &&
|
||||
strings.Contains(trimmed, " target(s) in ") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func outputHasPytestSummary(output string) bool {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
summary := strings.Trim(strings.TrimSpace(line), "= ")
|
||||
if summary == "short test summary info" || isPytestResultSummary(summary) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isPytestResultSummary(summary string) bool {
|
||||
fields := strings.Fields(summary)
|
||||
if len(fields) < 4 {
|
||||
return false
|
||||
}
|
||||
first := strings.Trim(fields[0], ",;:()")
|
||||
if _, err := strconv.Atoi(first); err != nil || !summaryHasCountedStatus(summary, "passed") {
|
||||
return false
|
||||
}
|
||||
for index := 1; index+1 < len(fields); index++ {
|
||||
if fields[index] != "in" {
|
||||
continue
|
||||
}
|
||||
duration := strings.TrimSuffix(strings.Trim(fields[index+1], ",;:()"), "s")
|
||||
if _, err := strconv.ParseFloat(duration, 64); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func outputHasJavaScriptTestSummary(output string) bool {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "Test Suites:") && summaryHasCountedStatus(trimmed, "passed") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "Test Files ") && summaryHasCountedStatus(trimmed, "passed") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func summaryHasCountedStatus(summary string, status string) bool {
|
||||
fields := strings.Fields(summary)
|
||||
for index := 1; index < len(fields); index++ {
|
||||
if strings.Trim(fields[index], ",;:()") != status {
|
||||
continue
|
||||
}
|
||||
count := strings.Trim(fields[index-1], ",;:()")
|
||||
if _, err := strconv.Atoi(count); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func reduceRepeatedOutputLines(output string, label string, omit func(string) bool) (string, int) {
|
||||
lines := strings.Split(output, "\n")
|
||||
kept := make([]string, 0, len(lines))
|
||||
omitted := 0
|
||||
for _, line := range lines {
|
||||
if omit(line) {
|
||||
omitted++
|
||||
continue
|
||||
}
|
||||
kept = append(kept, line)
|
||||
}
|
||||
if omitted == 0 {
|
||||
return output, 0
|
||||
}
|
||||
|
||||
insertAt := len(kept)
|
||||
for index, line := range kept {
|
||||
if strings.HasPrefix(line, "exit_code:") || strings.HasPrefix(line, "session_id:") {
|
||||
insertAt = index
|
||||
break
|
||||
}
|
||||
}
|
||||
summary := fmt.Sprintf("[zero] %d %s omitted", omitted, label)
|
||||
kept = append(kept, "")
|
||||
copy(kept[insertAt+1:], kept[insertAt:])
|
||||
kept[insertAt] = summary
|
||||
return strings.Join(kept, "\n"), omitted
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type commandReducerCorpusCase struct {
|
||||
name string
|
||||
command string
|
||||
output string
|
||||
want string
|
||||
}
|
||||
|
||||
func commandReducerCorpus() []commandReducerCorpusCase {
|
||||
goLines := []string{"output:"}
|
||||
cargoTestLines := []string{"output:", "running 24 tests"}
|
||||
cargoCheckLines := []string{"output:"}
|
||||
pytestLines := []string{"output:"}
|
||||
vitestLines := []string{"output:", " RUN v3.2.4 /workspace"}
|
||||
jestLines := []string{"output:"}
|
||||
for index := 0; index < 24; index++ {
|
||||
goLines = append(goLines, fmt.Sprintf("ok \texample.test/pkg%d\t0.01s", index))
|
||||
cargoTestLines = append(cargoTestLines, fmt.Sprintf("test module::case_%02d ... ok", index))
|
||||
cargoCheckLines = append(cargoCheckLines, fmt.Sprintf(" Checking dependency_%02d v1.0.%d", index, index))
|
||||
pytestLines = append(pytestLines, fmt.Sprintf("tests/test_module_%02d.py ........ [%3d%%]", index, (index+1)*100/24))
|
||||
vitestLines = append(vitestLines, fmt.Sprintf(" ✓ src/module_%02d.test.ts (8 tests) 12ms", index))
|
||||
jestLines = append(jestLines, fmt.Sprintf("PASS src/module_%02d.test.ts", index))
|
||||
}
|
||||
goLines = append(goLines, "exit_code: 0")
|
||||
cargoTestLines = append(cargoTestLines,
|
||||
"test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s",
|
||||
"exit_code: 0",
|
||||
)
|
||||
cargoCheckLines = append(cargoCheckLines,
|
||||
" Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.41s",
|
||||
"exit_code: 0",
|
||||
)
|
||||
pytestLines = append(pytestLines,
|
||||
"============================= 192 passed in 3.21s =============================",
|
||||
"exit_code: 0",
|
||||
)
|
||||
vitestLines = append(vitestLines,
|
||||
" Test Files 24 passed (24)",
|
||||
" Tests 192 passed (192)",
|
||||
" Duration 3.21s",
|
||||
"exit_code: 0",
|
||||
)
|
||||
jestLines = append(jestLines,
|
||||
"Test Suites: 24 passed, 24 total",
|
||||
"Tests: 192 passed, 192 total",
|
||||
"Time: 3.21 s",
|
||||
"exit_code: 0",
|
||||
)
|
||||
return []commandReducerCorpusCase{
|
||||
{name: "go_test", command: "go test ./...", output: strings.Join(goLines, "\n"), want: "exit_code: 0"},
|
||||
{name: "cargo_test", command: "cargo test --workspace", output: strings.Join(cargoTestLines, "\n"), want: "24 passed"},
|
||||
{name: "cargo_check", command: "cargo check --workspace", output: strings.Join(cargoCheckLines, "\n"), want: "Finished `dev` profile"},
|
||||
{name: "pytest", command: "pytest -q", output: strings.Join(pytestLines, "\n"), want: "192 passed"},
|
||||
{name: "npm_vitest", command: "npm test", output: strings.Join(vitestLines, "\n"), want: "192 passed"},
|
||||
{name: "pnpm_vitest", command: "pnpm test", output: strings.Join(vitestLines, "\n"), want: "192 passed"},
|
||||
{name: "yarn_jest", command: "yarn test", output: strings.Join(jestLines, "\n"), want: "192 passed"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandOutputReductionCorpusMetrics(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
totalRawTokens := 0
|
||||
totalModelTokens := 0
|
||||
for _, testCase := range commandReducerCorpus() {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": testCase.command}, Result{
|
||||
Status: StatusOK,
|
||||
Output: testCase.output,
|
||||
})
|
||||
if !strings.Contains(result.Output, testCase.want) {
|
||||
t.Fatalf("decisive summary %q was lost: %q", testCase.want, result.Output)
|
||||
}
|
||||
if spillPath := result.Meta["spill_path"]; spillPath != "" {
|
||||
spilled, err := os.ReadFile(spillPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read raw artifact: %v", err)
|
||||
}
|
||||
if string(spilled) != testCase.output {
|
||||
t.Fatal("raw artifact differs from original output")
|
||||
}
|
||||
}
|
||||
rawTokens := estimateOutputTokens(testCase.output)
|
||||
modelTokens := estimateOutputTokens(result.Output)
|
||||
totalRawTokens += rawTokens
|
||||
totalModelTokens += modelTokens
|
||||
reduction := 100 * (rawTokens - modelTokens) / rawTokens
|
||||
t.Logf("raw_bytes=%d model_bytes=%d raw_tokens=%d model_tokens=%d reduction_pct=%d reduced=%t",
|
||||
len(testCase.output), len(result.Output), rawTokens, modelTokens, reduction, result.Meta["command_output_reduced"] == "true")
|
||||
})
|
||||
}
|
||||
if totalRawTokens == 0 {
|
||||
t.Fatal("empty reducer corpus")
|
||||
}
|
||||
if totalModelTokens >= totalRawTokens {
|
||||
t.Fatalf("aggregate reducer output did not save tokens: raw=%d model=%d", totalRawTokens, totalModelTokens)
|
||||
}
|
||||
t.Logf("aggregate raw_tokens=%d model_tokens=%d reduction_pct=%d",
|
||||
totalRawTokens, totalModelTokens, 100*(totalRawTokens-totalModelTokens)/totalRawTokens)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type commandReducerFixtureTool struct {
|
||||
name string
|
||||
output string
|
||||
}
|
||||
|
||||
func (tool commandReducerFixtureTool) Name() string { return tool.name }
|
||||
func (tool commandReducerFixtureTool) Description() string {
|
||||
return "returns command output for reducer integration tests"
|
||||
}
|
||||
func (tool commandReducerFixtureTool) Parameters() Schema {
|
||||
return Schema{Type: "object", AdditionalProperties: true}
|
||||
}
|
||||
func (tool commandReducerFixtureTool) Safety() Safety {
|
||||
return Safety{SideEffect: SideEffectRead, Permission: PermissionAllow, Reason: "test output"}
|
||||
}
|
||||
func (tool commandReducerFixtureTool) Run(context.Context, map[string]any) Result {
|
||||
return Result{Status: StatusOK, Output: tool.output}
|
||||
}
|
||||
|
||||
func TestReduceCommandOutputCompactsPassingGoPackagesAndKeepsRawArtifact(t *testing.T) {
|
||||
var lines []string
|
||||
lines = append(lines, "output:")
|
||||
for index := 0; index < commandReducerMinPassingLines+3; index++ {
|
||||
lines = append(lines, fmt.Sprintf("ok \texample.test/pkg%d\t0.01s", index))
|
||||
}
|
||||
lines = append(lines, "exit_code: 0")
|
||||
original := strings.Join(lines, "\n")
|
||||
|
||||
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": "go test ./..."}, Result{
|
||||
Status: StatusOK,
|
||||
Output: original,
|
||||
})
|
||||
|
||||
if result.Output == original || !strings.Contains(result.Output, "passing package lines omitted") {
|
||||
t.Fatalf("expected compact result, got %q", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "exit_code: 0") {
|
||||
t.Fatalf("exit status was lost: %q", result.Output)
|
||||
}
|
||||
spillPath := result.Meta["spill_path"]
|
||||
spilled, err := os.ReadFile(spillPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read raw artifact: %v", err)
|
||||
}
|
||||
if string(spilled) != original {
|
||||
t.Fatalf("raw artifact differs from original output")
|
||||
}
|
||||
|
||||
budgeted := applySelfManagedOutputBudget(
|
||||
NewExecCommandTool(t.TempDir(), newExecSessionManager()),
|
||||
ExecCommandToolName,
|
||||
map[string]any{"cmd": "go test ./..."},
|
||||
result,
|
||||
)
|
||||
if budgeted.Meta["spill_path"] != spillPath {
|
||||
t.Fatalf("budget boundary replaced the exact raw artifact: got %q want %q", budgeted.Meta["spill_path"], spillPath)
|
||||
}
|
||||
spilled, err = os.ReadFile(budgeted.Meta["spill_path"])
|
||||
if err != nil || string(spilled) != original {
|
||||
t.Fatalf("budgeted raw artifact was not preserved: err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceCommandOutputFailsOpenForCompoundAndFailedOutput(t *testing.T) {
|
||||
failure := "output:\n--- FAIL: TestThing\npanic: broken\nFAIL\texample.test/pkg\nexit_code: 1"
|
||||
for _, command := range []string{"go test ./... | tee test.log", "go test ./... && echo done"} {
|
||||
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": command}, Result{Status: StatusError, Output: failure})
|
||||
if result.Output != failure || result.Meta != nil {
|
||||
t.Fatalf("compound command %q should pass through, got %#v", command, result)
|
||||
}
|
||||
}
|
||||
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": "go test ./..."}, Result{Status: StatusError, Output: failure})
|
||||
if result.Output != failure {
|
||||
t.Fatalf("failure evidence should pass through when there is no repetitive success bulk: %q", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceGoTestPassingPackagesPreservesCoverageResults(t *testing.T) {
|
||||
output := strings.Join([]string{
|
||||
"ok \texample.test/a\t0.01s\tcoverage: 82.4% of statements",
|
||||
"? \texample.test/b\t[no test files]",
|
||||
"ok \texample.test/c\t0.01s",
|
||||
}, "\n")
|
||||
|
||||
reduced, omitted := reduceGoTestPassingPackages(output)
|
||||
if !strings.Contains(reduced, "coverage: 82.4% of statements") {
|
||||
t.Fatalf("coverage result was removed: %q", reduced)
|
||||
}
|
||||
if !strings.Contains(reduced, "[no test files]") {
|
||||
t.Fatalf("no-test-files result was removed: %q", reduced)
|
||||
}
|
||||
if omitted != 1 {
|
||||
t.Fatalf("omitted=%d, want only the plain passing line omitted", omitted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceGoTestPassingPackagesPreservesIndentedTestLogs(t *testing.T) {
|
||||
output := strings.Join([]string{
|
||||
" ok user-provided test log",
|
||||
" ? another test log",
|
||||
"ok \texample.test/pkg\t0.01s",
|
||||
}, "\n")
|
||||
|
||||
reduced, omitted := reduceGoTestPassingPackages(output)
|
||||
if !strings.Contains(reduced, " ok user-provided test log") ||
|
||||
!strings.Contains(reduced, " ? another test log") {
|
||||
t.Fatalf("indented test log was removed: %q", reduced)
|
||||
}
|
||||
if omitted != 1 {
|
||||
t.Fatalf("omitted=%d, want only the package result omitted", omitted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfManagedBudgetPreservesRawSpillWhenReducedOutputAlsoTruncates(t *testing.T) {
|
||||
raw := "raw output\n" + strings.Repeat("ok \texample.test/raw\t0.01s\n", 1000)
|
||||
rawSpill := spillTruncatedOutput(ExecCommandToolName, raw)
|
||||
if rawSpill == "" {
|
||||
t.Fatal("failed to create raw spill")
|
||||
}
|
||||
reduced := strings.Repeat("reduced but still large\n", 1000)
|
||||
result := applySelfManagedOutputBudget(
|
||||
NewExecCommandTool(t.TempDir(), newExecSessionManager()),
|
||||
ExecCommandToolName,
|
||||
map[string]any{"cmd": "go test ./...", "max_output_tokens": 100},
|
||||
Result{
|
||||
Status: StatusOK,
|
||||
Output: reduced,
|
||||
Truncated: true,
|
||||
Meta: map[string]string{
|
||||
"spill_path": rawSpill,
|
||||
"truncation_reason": "command_aware_reduction",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if result.Meta["spill_path"] != rawSpill {
|
||||
t.Fatalf("budget boundary replaced raw spill: got %q want %q", result.Meta["spill_path"], rawSpill)
|
||||
}
|
||||
spilled, err := os.ReadFile(result.Meta["spill_path"])
|
||||
if err != nil || string(spilled) != raw {
|
||||
t.Fatalf("raw spill was not preserved: err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceCommandOutputCompactsRecognizedTestAndBuildNoise(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
for _, testCase := range commandReducerCorpus() {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": testCase.command}, Result{
|
||||
Status: StatusOK,
|
||||
Output: testCase.output,
|
||||
})
|
||||
if result.Meta["command_output_reduced"] != "true" {
|
||||
t.Fatalf("recognized command output was not reduced: %#v", result)
|
||||
}
|
||||
if !strings.Contains(result.Output, testCase.want) || !strings.Contains(result.Output, "exit_code: 0") {
|
||||
t.Fatalf("decisive summary or exit status was lost: %q", result.Output)
|
||||
}
|
||||
spillPath := result.Meta["spill_path"]
|
||||
spilled, err := os.ReadFile(spillPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read raw artifact: %v", err)
|
||||
}
|
||||
if string(spilled) != testCase.output {
|
||||
t.Fatal("raw artifact differs from original output")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceCommandOutputPreservesFailureEvidence(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
passing := make([]string, 0, commandReducerMinPassingLines+2)
|
||||
for index := 0; index < commandReducerMinPassingLines+2; index++ {
|
||||
passing = append(passing, fmt.Sprintf("test module::case_%02d ... ok", index))
|
||||
}
|
||||
original := strings.Join(append(passing,
|
||||
"test module::broken ... FAILED",
|
||||
"failures:",
|
||||
"---- module::broken stdout ----",
|
||||
"assertion failed: expected zero",
|
||||
"test result: FAILED. 14 passed; 1 failed; finished in 0.08s",
|
||||
"exit_code: 101",
|
||||
), "\n")
|
||||
|
||||
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": "cargo test --workspace"}, Result{
|
||||
Status: StatusError,
|
||||
Output: original,
|
||||
})
|
||||
for _, evidence := range []string{"module::broken ... FAILED", "assertion failed: expected zero", "14 passed; 1 failed", "exit_code: 101"} {
|
||||
if !strings.Contains(result.Output, evidence) {
|
||||
t.Fatalf("failure evidence %q was lost: %q", evidence, result.Output)
|
||||
}
|
||||
}
|
||||
if result.Meta["command_output_reduced"] != "true" {
|
||||
t.Fatalf("repetitive passing output was not reduced: %#v", result)
|
||||
}
|
||||
spillPath := result.Meta["spill_path"]
|
||||
if spillPath == "" {
|
||||
t.Fatal("reduced failure output did not retain a raw artifact")
|
||||
}
|
||||
spilled, err := os.ReadFile(spillPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read raw artifact: %v", err)
|
||||
}
|
||||
if string(spilled) != original {
|
||||
t.Fatal("raw artifact differs from original output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceCommandOutputPassesThroughUnsupportedAndCompoundCommands(t *testing.T) {
|
||||
outputs := make(map[string]string)
|
||||
for _, fixture := range commandReducerCorpus() {
|
||||
outputs[fixture.name] = fixture.output
|
||||
}
|
||||
testCases := []struct {
|
||||
command string
|
||||
output string
|
||||
}{
|
||||
{command: "npm test | tee test.log", output: outputs["npm_vitest"]},
|
||||
{command: "cargo test && echo done", output: outputs["cargo_test"]},
|
||||
{command: "pytest; notify-send done", output: outputs["pytest"]},
|
||||
{command: "cargo test 1>test.log", output: outputs["cargo_test"]},
|
||||
{command: "cargo test 2>>errors.log", output: outputs["cargo_test"]},
|
||||
{command: "git status --short", output: outputs["npm_vitest"]},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": testCase.command}, Result{Status: StatusOK, Output: testCase.output})
|
||||
if result.Output != testCase.output || result.Meta != nil {
|
||||
t.Fatalf("command %q should pass through exactly, got %#v", testCase.command, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceCommandOutputAllowsTrailingStderrMerge(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
fixture := commandReducerCorpus()[1]
|
||||
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": fixture.command + " 2>&1"}, Result{
|
||||
Status: StatusOK,
|
||||
Output: fixture.output,
|
||||
})
|
||||
if result.Meta["command_output_reducer"] != "cargo_test" {
|
||||
t.Fatalf("trailing stderr merge prevented reduction: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceCommandOutputRequiresAuthoritativeRunnerSummary(t *testing.T) {
|
||||
lookalikes := map[string]string{
|
||||
"cargo test": "test user::message ... ok",
|
||||
"cargo check": "Checking a custom deployment state",
|
||||
"pytest": "application log ........ [100%]",
|
||||
"npm test": "PASS user-provided log message",
|
||||
}
|
||||
for command, line := range lookalikes {
|
||||
original := strings.Repeat(line+"\n", commandReducerMinPassingLines+2) + "exit_code: 0"
|
||||
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": command}, Result{Status: StatusOK, Output: original})
|
||||
if result.Output != original || result.Meta != nil {
|
||||
t.Fatalf("summary-free output for %q should pass through, got %#v", command, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceCommandOutputRejectsLookalikeRunnerSummaries(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
testCases := []struct {
|
||||
command string
|
||||
line string
|
||||
summary string
|
||||
}{
|
||||
{command: "cargo check", line: "Compiling dependency v1.0.0", summary: "Finished generating bindings"},
|
||||
{command: "pytest", line: "tests/test_module.py ........ [100%]", summary: "application log: 14 passed validation in staging"},
|
||||
{command: "npm test", line: "PASS src/example.test.ts", summary: "Test Files discovered: 14"},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
original := strings.Repeat(testCase.line+"\n", commandReducerMinPassingLines+2) + testCase.summary + "\nexit_code: 1"
|
||||
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": testCase.command}, Result{Status: StatusError, Output: original})
|
||||
if result.Output != original || result.Meta != nil {
|
||||
t.Fatalf("lookalike summary for %q should pass through exactly, got %#v", testCase.command, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryAppliesCommandReducerToExecAndBashTools(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
fixture := commandReducerCorpus()[1]
|
||||
for _, toolName := range []string{ExecCommandToolName, "bash"} {
|
||||
t.Run(toolName, func(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
registry.Register(commandReducerFixtureTool{name: toolName, output: fixture.output})
|
||||
args := map[string]any{"cmd": fixture.command}
|
||||
if toolName == "bash" {
|
||||
args = map[string]any{"command": fixture.command}
|
||||
}
|
||||
result := registry.Run(context.Background(), toolName, args)
|
||||
if result.Status != StatusOK || result.Meta["command_output_reducer"] != "cargo_test" {
|
||||
t.Fatalf("registry result was not reduced: %#v", result)
|
||||
}
|
||||
if !strings.Contains(result.Output, fixture.want) {
|
||||
t.Fatalf("registry reduction lost summary: %q", result.Output)
|
||||
}
|
||||
spilled, err := os.ReadFile(result.Meta["spill_path"])
|
||||
if err != nil || string(spilled) != fixture.output {
|
||||
t.Fatalf("registry raw artifact mismatch: err=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -75,6 +75,9 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
|
||||
// stale view. Only read current bytes when there is a baseline to compare,
|
||||
// so a first-touch create/overwrite stays a single write with no extra read.
|
||||
if existed {
|
||||
if options.FileTracker != nil && !options.FileTracker.SeenWhole(absolutePath) {
|
||||
return errorResult(fileUnseenMessage(relativePath))
|
||||
}
|
||||
if _, tracked := options.FileTracker.Version(absolutePath); tracked {
|
||||
// Fail CLOSED: if the tracked file can't be re-read to verify it, refuse
|
||||
// the overwrite rather than clobbering a file whose current state is
|
||||
@@ -107,6 +110,7 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
|
||||
if err := os.WriteFile(absolutePath, []byte(content), 0o644); err != nil {
|
||||
return errorResult("Error writing file " + relativePath + ": " + err.Error())
|
||||
}
|
||||
modelKnownContent := content
|
||||
// Optional format-on-write (ZERO_FORMAT_ON_WRITE). Must run BEFORE the
|
||||
// FileTracker baseline: recording pre-format content would make the very
|
||||
// next edit look like an external modification and trip the conflict guard.
|
||||
@@ -115,6 +119,9 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
|
||||
// session compares against what is now on disk.
|
||||
newInfo, _ := os.Stat(absolutePath)
|
||||
options.FileTracker.Record(absolutePath, []byte(content), newInfo)
|
||||
if content == modelKnownContent {
|
||||
options.FileTracker.RecordSeenRange(absolutePath, 1, lineCount(content), lineCount(content))
|
||||
}
|
||||
if !existed {
|
||||
options.FileTracker.RecordCreated(absolutePath)
|
||||
}
|
||||
|
||||
+1
-1
@@ -206,7 +206,7 @@ func (m model) leaveBTW() (model, tea.Cmd) {
|
||||
func btwCommandUnavailable(command parsedCommand) bool {
|
||||
arg := strings.ToLower(strings.TrimSpace(command.text))
|
||||
switch command.kind {
|
||||
case commandNew, commandResume, commandRetitle, commandSpec, commandLoop, commandGoal,
|
||||
case commandNew, commandResume, commandRename, commandSpec, commandLoop, commandGoal,
|
||||
commandRewind, commandCompact, commandSTTModel, commandMCP:
|
||||
return true
|
||||
case commandModel:
|
||||
|
||||
@@ -58,7 +58,8 @@ func pasteFromClipboardCmd() tea.Cmd {
|
||||
// right-click paste (clipboardReadMsg) so a bracketed paste and a right-click
|
||||
// paste behave identically. Surfaces with no editable text field (a permission/
|
||||
// spec prompt, the MCP manager, an open picker, the detailed transcript) swallow
|
||||
// the paste; empty content is a no-op.
|
||||
// the paste. The session rename editor accepts text only; empty content is a
|
||||
// no-op there rather than an image probe.
|
||||
func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
|
||||
// A paste is a deliberate action, same as a keypress or click — it means
|
||||
// the user moved on to something else, so it disarms a stale Esc
|
||||
@@ -75,6 +76,14 @@ func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
|
||||
// toggle instead of going solid.
|
||||
m.lastCharTime = m.now()
|
||||
m.composerCursorVisible = true
|
||||
if m.renamePrompt != nil {
|
||||
if content == "" {
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.input, cmd = m.input.Update(tea.PasteMsg{Content: sanitizeComposerInput(content)})
|
||||
return m, cmd
|
||||
}
|
||||
if content == "" {
|
||||
// Empty text clipboard — the user may have pasted a screenshot.
|
||||
// Probe the OS clipboard for image content asynchronously.
|
||||
|
||||
@@ -27,7 +27,7 @@ const (
|
||||
commandPlan
|
||||
commandSearch
|
||||
commandResume
|
||||
commandRetitle
|
||||
commandRename
|
||||
commandSpec
|
||||
commandInit
|
||||
commandCompact
|
||||
@@ -226,11 +226,11 @@ var commandDefinitions = []commandDefinition{
|
||||
kind: commandResume,
|
||||
},
|
||||
{
|
||||
name: "/retitle",
|
||||
usage: "/retitle",
|
||||
name: "/rename",
|
||||
usage: "/rename [title]",
|
||||
group: commandGroupSession,
|
||||
description: "Generate concise titles for resumable sessions.",
|
||||
kind: commandRetitle,
|
||||
description: "Rename the current session (no arg opens an editor).",
|
||||
kind: commandRename,
|
||||
},
|
||||
{
|
||||
name: "/spec",
|
||||
|
||||
@@ -281,7 +281,7 @@ func (m model) composerPositionAtMouse(msg tea.MouseMsg) (int, bool) {
|
||||
|
||||
func (m model) composerMouseSelectionBlocked() bool {
|
||||
return m.transcriptDetailed || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
|
||||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive()
|
||||
m.mcpManager != nil || m.picker != nil || m.renamePrompt != nil || m.suggestionsActive()
|
||||
}
|
||||
|
||||
func (m model) composerPositionAtVisualCell(x int, y int, width int) (int, bool) {
|
||||
|
||||
+92
-51
@@ -102,6 +102,7 @@ type model struct {
|
||||
doctorInFlight bool
|
||||
doctorFrame int
|
||||
activeSession sessions.Metadata
|
||||
pendingSessionTitle string
|
||||
sessionEvents []sessions.Event
|
||||
btw btwState
|
||||
// btwRunIDSeq is the highest run ID issued by any completed or abandoned BTW
|
||||
@@ -112,14 +113,8 @@ type model struct {
|
||||
// already been attempted this process, so a finished turn re-fires the title
|
||||
// generator at most once per session (even before its async result lands).
|
||||
// Lazily initialized.
|
||||
titledSessions map[string]bool
|
||||
// retitle* drive the sequential /retitle backfill: queued session ids still
|
||||
// awaiting a title, whether a backfill is running, and its progress counters.
|
||||
retitleQueue []string
|
||||
retitleActive bool
|
||||
retitleTotal int
|
||||
retitleDone int
|
||||
retitleOK int
|
||||
titledSessions map[string]bool
|
||||
renamePrompt *sessionRenamePrompt
|
||||
usageTracker *usage.Tracker
|
||||
sessionCompactor SessionCompactor
|
||||
prService *PrService
|
||||
@@ -221,6 +216,9 @@ type model struct {
|
||||
// renders the live elapsed time from it so a long or stalled turn never looks
|
||||
// like a frozen terminal (for ANY provider, not just slow ones). Zero = idle.
|
||||
turnStartedAt time.Time
|
||||
// turnTimer is shared with the agent command so both the live status and the
|
||||
// settled "worked for" duration exclude time blocked on a user permission.
|
||||
turnTimer *activeTurnTimer
|
||||
// lastCharTime tracks when the last non-Enter key was received, for paste detection.
|
||||
lastCharTime time.Time
|
||||
// lastKeyTime tracks every keypress timestamp for burst calculation.
|
||||
@@ -474,8 +472,14 @@ type model struct {
|
||||
// pins), this is maintained by recordRecentModel on every successful
|
||||
// switch and persisted via config.SetRecentModels.
|
||||
recentModels []config.RecentModelEntry
|
||||
recapsEnabled bool // post-turn "※ recap:" line (config: recaps on|off)
|
||||
recappedRuns map[int]bool // per-run guard so a recap fires at most once per turn
|
||||
recapsEnabled bool // idle orientation note (config: recaps on|off)
|
||||
recapSeq int
|
||||
recapRunning bool
|
||||
recapCancel context.CancelFunc
|
||||
recapTimerCancel context.CancelFunc
|
||||
recapIdleArmed bool
|
||||
recapIdleRunID int
|
||||
idleRecap string
|
||||
modelPickerLoading bool
|
||||
modelPickerLoadingProviderID string
|
||||
modelPickerLoadError string
|
||||
@@ -1069,7 +1073,7 @@ func (m *model) stopPRWatcher() {
|
||||
func (m model) noBlockingModal() bool {
|
||||
return m.pendingPermission == nil && m.pendingAskUser == nil && m.pendingSpecReview == nil &&
|
||||
m.providerWizard == nil && m.mcpAddWizard == nil && m.mcpManager == nil && m.picker == nil &&
|
||||
m.sttKeyPrompt == nil
|
||||
m.sttKeyPrompt == nil && m.renamePrompt == nil
|
||||
}
|
||||
|
||||
func (m model) quit() (tea.Model, tea.Cmd) {
|
||||
@@ -1157,6 +1161,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.printInFlight = false
|
||||
return m.drainFlushQueue()
|
||||
}
|
||||
var recapActivityCmd tea.Cmd
|
||||
switch msg.(type) {
|
||||
case tea.KeyPressMsg, tea.PasteMsg, tea.MouseMsg:
|
||||
m, recapActivityCmd = m.resetIdleRecapAfterActivity()
|
||||
}
|
||||
next, cmd := m.updateModel(msg)
|
||||
nm, ok := next.(model)
|
||||
if !ok {
|
||||
@@ -1165,7 +1174,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
nm = nm.syncChatScroll()
|
||||
nm, mouseCmd := nm.syncMouseCapture()
|
||||
nm, flushCmd := nm.settleTranscript()
|
||||
return nm, batchCommands(cmd, mouseCmd, flushCmd)
|
||||
return nm, batchCommands(cmd, mouseCmd, flushCmd, recapActivityCmd)
|
||||
}
|
||||
|
||||
func batchCommands(cmds ...tea.Cmd) tea.Cmd {
|
||||
@@ -1352,6 +1361,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.sttKeyPrompt != nil {
|
||||
return m.handleSTTKeyPromptKey(msg)
|
||||
}
|
||||
if m.renamePrompt != nil {
|
||||
return m.handleSessionRenameKey(msg)
|
||||
}
|
||||
m.transcriptSelection = transcriptSelectionState{}
|
||||
m.composerSelection = composerSelectionState{}
|
||||
m.clearMouseSelection()
|
||||
@@ -2465,15 +2477,15 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var titleCmd, recapCmd tea.Cmd
|
||||
if msg.err == nil {
|
||||
m, titleCmd = m.maybeAutoTitleActiveSession()
|
||||
// Post-turn recap (gated on the recaps preference): one short sentence
|
||||
// summarizing the turn's final answer, shown as a "※ recap:" footnote.
|
||||
// Arm an idle recap only after a successful answer. The timer performs
|
||||
// no provider work unless the session stays untouched long enough.
|
||||
var finalAnswer string
|
||||
for _, row := range msg.rows {
|
||||
if row.kind == rowAssistant && row.final {
|
||||
finalAnswer = row.text
|
||||
}
|
||||
}
|
||||
m, recapCmd = m.maybeRecapTurn(msg.runID, finalAnswer)
|
||||
m, recapCmd = m.maybeScheduleIdleRecap(msg.runID, finalAnswer)
|
||||
}
|
||||
// End-of-turn git sweep: catch file mutations the tool stream couldn't
|
||||
// report (bash scaffolding, subagent edits) so the FILES sidebar is
|
||||
@@ -2506,6 +2518,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return next, tea.Batch(pendingClearCmd, titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd, goalCmd)
|
||||
case sessionTitleGeneratedMsg:
|
||||
return m.handleSessionTitleGenerated(msg)
|
||||
case recapIdleMsg:
|
||||
return m.handleRecapIdle(msg)
|
||||
case recapGeneratedMsg:
|
||||
return m.handleRecapGenerated(msg)
|
||||
case compactResultMsg:
|
||||
@@ -2948,6 +2962,12 @@ func (m model) pinnedTitleBar(width int) string {
|
||||
|
||||
func (m model) footerView(width int) string {
|
||||
var footer strings.Builder
|
||||
if m.renamePrompt != nil {
|
||||
footer.WriteString(m.sessionRenamePromptView(width))
|
||||
footer.WriteString("\n")
|
||||
footer.WriteString(m.statusLine(width))
|
||||
return footer.String()
|
||||
}
|
||||
// While an ask-user questionnaire is active it REPLACES the composer box (the
|
||||
// text box becomes the questionnaire): render the tabbed prompt + status line and
|
||||
// skip the plan panel / idle hints / composer for a focused modal.
|
||||
@@ -2986,6 +3006,8 @@ func (m model) footerView(width int) string {
|
||||
// so the footer height is unchanged.
|
||||
if copyStatus := strings.TrimSpace(m.copyStatus); copyStatus != "" {
|
||||
footer.WriteString(rightAlignedLine(zeroTheme.ink.Render(copyStatus), width))
|
||||
} else if recap := strings.TrimSpace(m.idleRecap); recap != "" {
|
||||
footer.WriteString(fitStyledLine(" "+zeroTheme.faint.Render("※ "+recap), width))
|
||||
} else if left, right := m.composerIdleHint(), m.jumpToBottomHint(); left != "" || right != "" {
|
||||
footer.WriteString(fitStyledLine(joinHeaderLine(" "+left, right, width), width))
|
||||
}
|
||||
@@ -3036,9 +3058,18 @@ func (m model) composerIdleHint() string {
|
||||
case tierNarrow:
|
||||
hint = "? shortcuts"
|
||||
case tierMedium:
|
||||
hint = fmt.Sprintf("? shortcuts · Ctrl+X cmds · %s sidebar", sidebarKey)
|
||||
parts := []string{"? shortcuts", "Ctrl+X cmds"}
|
||||
if m.sidebarAvailable() {
|
||||
parts = append(parts, sidebarKey+" sidebar")
|
||||
}
|
||||
hint = strings.Join(parts, " · ")
|
||||
default:
|
||||
hint = fmt.Sprintf("? shortcuts · Ctrl+X cmds · %s sidebar · %s detail · %s copy · Shift+Tab mode", sidebarKey, detailKey, mouseKey)
|
||||
parts := []string{"? shortcuts", "Ctrl+X cmds"}
|
||||
if m.sidebarAvailable() {
|
||||
parts = append(parts, sidebarKey+" sidebar")
|
||||
}
|
||||
parts = append(parts, detailKey+" detail", mouseKey+" copy", "Shift+Tab mode")
|
||||
hint = strings.Join(parts, " · ")
|
||||
}
|
||||
return zeroTheme.faint.Render(hint)
|
||||
}
|
||||
@@ -3502,7 +3533,7 @@ func (m model) workingStatusLine() string {
|
||||
// (reasoning, waiting on the model, or running a tool).
|
||||
line += zeroTheme.faint.Render(" · " + m.workingActivity())
|
||||
if !m.turnStartedAt.IsZero() {
|
||||
line += zeroTheme.faint.Render(" · " + formatWorkingElapsed(m.now().Sub(m.turnStartedAt)))
|
||||
line += zeroTheme.faint.Render(" · " + formatWorkingElapsed(m.activeTurnElapsed(m.turnStartedAt)))
|
||||
}
|
||||
// Live token estimate so the working line visibly climbs as the model reasons
|
||||
// and writes, instead of a static figure. Shown from the start of the turn (at
|
||||
@@ -3587,7 +3618,7 @@ const quietWorkingHint = 8 * time.Second
|
||||
// the ticking number was the only signal, and it looks identical whether real
|
||||
// (if slow) content is coming or nothing ever will.
|
||||
func (m model) quietGenerationHint() string {
|
||||
if m.activeRunID == 0 {
|
||||
if m.activeRunID == 0 || m.pendingPermission != nil {
|
||||
return ""
|
||||
}
|
||||
last := m.lastStreamActivity
|
||||
@@ -4136,6 +4167,10 @@ func (m model) resolvePermissionWithReason(decision permissionDecision, reason s
|
||||
})
|
||||
}
|
||||
m.pendingPermission = nil
|
||||
// Time spent at the prompt is user wait, not provider silence. Restart the
|
||||
// quiet-generation clock so resuming a long-blocked run does not immediately
|
||||
// claim the model has been inactive for the entire approval interval.
|
||||
m.lastStreamActivity = m.now()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -4520,21 +4555,11 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
}
|
||||
return m, nil
|
||||
case commandRetitle:
|
||||
if m.pending {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendError,
|
||||
text: "Cannot retitle sessions while a run is active.",
|
||||
})
|
||||
return m, nil
|
||||
case commandRename:
|
||||
if title := strings.TrimSpace(command.text); title != "" {
|
||||
return m.renameActiveSession(title), nil
|
||||
}
|
||||
text := ""
|
||||
var retitleCmd tea.Cmd
|
||||
m, retitleCmd, text = m.startSessionRetitle()
|
||||
if text != "" {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
}
|
||||
return m, retitleCmd
|
||||
return m.openSessionRenamePrompt(), nil
|
||||
case commandSpec:
|
||||
return m.handleSpecCommand(command.text)
|
||||
case commandInit:
|
||||
@@ -4838,6 +4863,7 @@ func (m model) launchPrompt(prompt string) (model, tea.Cmd) {
|
||||
// (normal prompt + spec draft/impl) keeps these in sync — a missing
|
||||
// turnStartedAt previously dropped the elapsed timer on spec-mode runs.
|
||||
func (m model) beginRun(cancel context.CancelFunc) model {
|
||||
m = m.cancelIdleRecap()
|
||||
if m.prepareRunCompletionWarning != nil {
|
||||
m.prepareRunCompletionWarning()
|
||||
}
|
||||
@@ -4859,6 +4885,7 @@ func (m model) beginRun(cancel context.CancelFunc) model {
|
||||
// suppressed by a stale preference.
|
||||
m.sidebarHidden = false
|
||||
m.turnStartedAt = m.now()
|
||||
m.turnTimer = newActiveTurnTimer(m.turnStartedAt)
|
||||
m.lastStreamActivity = m.turnStartedAt
|
||||
m.turnStreamedRunes = 0
|
||||
m.spinnerTicking = true
|
||||
@@ -5039,9 +5066,21 @@ func selfCorrectAutonomyForMode(mode agent.PermissionMode) string {
|
||||
func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt string, images []zeroruntime.ImageBlock, runOptions tuiAgentRunOptions) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
started := m.now()
|
||||
// firstTokenAt is stamped when the first token (reasoning or text) streams,
|
||||
// so the turn can report time-to-first-token alongside total wall time.
|
||||
var firstTokenAt time.Time
|
||||
if m.turnTimer != nil {
|
||||
m.turnTimer.start(started)
|
||||
}
|
||||
// firstTokenElapsed is stamped from the pause-aware turn timer when the
|
||||
// first reasoning or text token streams, so TTFT and total elapsed use
|
||||
// the same clock.
|
||||
var firstTokenElapsed time.Duration
|
||||
firstTokenSeen := false
|
||||
stampFirstToken := func(at time.Time) {
|
||||
if firstTokenSeen {
|
||||
return
|
||||
}
|
||||
firstTokenSeen = true
|
||||
firstTokenElapsed = m.activeTurnElapsedAt(started, at)
|
||||
}
|
||||
toolCalls := 0
|
||||
rows := []transcriptRow{}
|
||||
usageEvents := []zeroruntime.Usage{}
|
||||
@@ -5157,11 +5196,10 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
|
||||
onText := options.OnText
|
||||
options.OnText = func(delta string) {
|
||||
if firstTokenAt.IsZero() {
|
||||
firstTokenAt = m.now()
|
||||
}
|
||||
now := m.now()
|
||||
stampFirstToken(now)
|
||||
if strings.TrimSpace(reasoningText) != "" {
|
||||
flushReasoning(m.now())
|
||||
flushReasoning(now)
|
||||
}
|
||||
m.sendAgentText(runID, delta)
|
||||
if onText != nil {
|
||||
@@ -5178,6 +5216,12 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
}
|
||||
onPermissionRequest := options.OnPermissionRequest
|
||||
options.OnPermissionRequest = func(ctx context.Context, request agent.PermissionRequest) (agent.PermissionDecision, error) {
|
||||
if m.turnTimer != nil {
|
||||
m.turnTimer.pause(m.now())
|
||||
defer func() {
|
||||
m.turnTimer.resume(m.now())
|
||||
}()
|
||||
}
|
||||
if onPermissionRequest != nil {
|
||||
return onPermissionRequest(ctx, request)
|
||||
}
|
||||
@@ -5255,8 +5299,8 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
onReasoning := options.OnReasoning
|
||||
options.OnReasoning = func(delta string) {
|
||||
now := m.now()
|
||||
if firstTokenAt.IsZero() && strings.TrimSpace(delta) != "" {
|
||||
firstTokenAt = now
|
||||
if strings.TrimSpace(delta) != "" {
|
||||
stampFirstToken(now)
|
||||
}
|
||||
if strings.TrimSpace(reasoningText) == "" && strings.TrimSpace(delta) != "" {
|
||||
reasoningStarted = now
|
||||
@@ -5367,6 +5411,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
tool: result.Name,
|
||||
status: result.Status,
|
||||
detail: toolResultDetail(result),
|
||||
meta: result.Meta,
|
||||
runID: runID,
|
||||
changedFiles: result.ChangedFiles,
|
||||
changeSummaries: result.ChangeSummaries,
|
||||
@@ -5481,7 +5526,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
Type: sessions.EventError,
|
||||
Payload: map[string]any{"message": err.Error()},
|
||||
})
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.activeTurnElapsed(started)}
|
||||
}
|
||||
if runOptions.specDraft {
|
||||
if result.StopReason != agent.StopReasonSpecReviewRequired || specReview == nil || specReview.SpecID == "" || specReview.SpecFilePath == "" {
|
||||
@@ -5491,17 +5536,13 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
Type: sessions.EventError,
|
||||
Payload: map[string]any{"message": err.Error()},
|
||||
})
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.activeTurnElapsed(started)}
|
||||
}
|
||||
flushReasoning(m.now())
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, specReview: specReview, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, specReview: specReview, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.activeTurnElapsed(started)}
|
||||
}
|
||||
flushReasoning(m.now())
|
||||
elapsed := m.now().Sub(started)
|
||||
ttft := time.Duration(0)
|
||||
if !firstTokenAt.IsZero() {
|
||||
ttft = firstTokenAt.Sub(started)
|
||||
}
|
||||
elapsed := m.activeTurnElapsed(started)
|
||||
rows = append(rows, transcriptRow{
|
||||
kind: rowAssistant,
|
||||
text: result.FinalAnswer,
|
||||
@@ -5519,7 +5560,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
"content": result.FinalAnswer,
|
||||
},
|
||||
})
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: elapsed, ttft: ttft}
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: elapsed, ttft: firstTokenElapsed}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -886,8 +886,9 @@ func TestResumeCommandListsRecentSessions(t *testing.T) {
|
||||
if next.picker == nil || next.picker.kind != pickerSession {
|
||||
t.Fatalf("expected /resume to open the session picker, got picker=%#v", next.picker)
|
||||
}
|
||||
// Every row carries the session title (in the Label, after the timestamp) and
|
||||
// resolves to / shows the session id (Value + Meta), for both sessions.
|
||||
// Every row carries the session title after the timestamp and resolves by its
|
||||
// hidden session id. Raw ids are implementation detail and must not consume
|
||||
// the visible title width.
|
||||
findByID := func(id string) (pickerItem, bool) {
|
||||
for _, item := range next.picker.items {
|
||||
if item.Value == id {
|
||||
@@ -904,17 +905,52 @@ func TestResumeCommandListsRecentSessions(t *testing.T) {
|
||||
if !strings.Contains(item.Label, want.title) {
|
||||
t.Fatalf("picker Label %q should contain the title %q", item.Label, want.title)
|
||||
}
|
||||
if !strings.Contains(item.Meta, want.id) {
|
||||
t.Fatalf("picker %q Meta should show the id %q, got %q", want.title, want.id, item.Meta)
|
||||
if item.Meta != "" {
|
||||
t.Fatalf("picker %q should not expose raw session id metadata, got %q", want.title, item.Meta)
|
||||
}
|
||||
}
|
||||
// The picker overlay renders the titles and ids.
|
||||
// The picker overlay renders clean title rows plus a position indicator.
|
||||
view := viewString(next.View())
|
||||
for _, want := range []string{"Resume a session", "Newer", "Older", first.SessionID, second.SessionID} {
|
||||
for _, want := range []string{"Resume a session", "Newer", "Older", "1 / 2"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("session picker view missing %q:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
for _, id := range []string{first.SessionID, second.SessionID} {
|
||||
if strings.Contains(view, id) {
|
||||
t.Fatalf("session picker view should not expose raw id %q:\n%s", id, view)
|
||||
}
|
||||
}
|
||||
|
||||
// IDs remain searchable even though they are hidden from normal rows.
|
||||
searchByID := *next.picker
|
||||
searchByID.query = first.SessionID
|
||||
searchByID.applyQuery()
|
||||
if len(searchByID.items) != 1 || searchByID.items[0].Value != first.SessionID {
|
||||
t.Fatalf("hidden session id should remain searchable, got %#v", searchByID.items)
|
||||
}
|
||||
|
||||
// An empty result set reports an unambiguous zero position.
|
||||
noResults := next
|
||||
noResults.picker.query = "__missing_session__"
|
||||
noResults.picker.applyQuery()
|
||||
if view := viewString(noResults.View()); !strings.Contains(view, "0 / 0") {
|
||||
t.Fatalf("empty session search should show 0 / 0:\n%s", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionPickerLabelAlignsTitles(t *testing.T) {
|
||||
today := sessionPickerLabel("20:47:50", "Today title")
|
||||
older := sessionPickerLabel("Jul 24 10:47", "Older title")
|
||||
|
||||
todayColumn := strings.Index(today, "Today title")
|
||||
olderColumn := strings.Index(older, "Older title")
|
||||
if todayColumn < 0 || olderColumn < 0 {
|
||||
t.Fatalf("sessionPickerLabel omitted a title: today=%q older=%q", today, older)
|
||||
}
|
||||
if todayColumn != olderColumn {
|
||||
t.Fatalf("title columns differ: today=%d (%q), older=%d (%q)", todayColumn, today, olderColumn, older)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumePickerSelectionHydratesSession(t *testing.T) {
|
||||
@@ -2501,9 +2537,36 @@ func TestComposerIdleHintAndJumpCue(t *testing.T) {
|
||||
m.width, m.height = 100, 30
|
||||
m.transcript = append(m.transcript, transcriptRow{kind: rowAssistant, text: "hi", final: true})
|
||||
|
||||
// Idle, empty composer, managed mode -> the discoverability hint shows.
|
||||
if h := plainRender(t, m.composerIdleHint()); !strings.Contains(h, "shortcuts") {
|
||||
t.Fatalf("expected idle hint, got %q", h)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
width int
|
||||
}{
|
||||
{name: "medium", width: 80},
|
||||
{name: "full", width: 100},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
idle := m
|
||||
idle.width = test.width
|
||||
|
||||
// Idle, empty composer, managed mode -> the discoverability hint shows.
|
||||
hint := plainRender(t, idle.composerIdleHint())
|
||||
if !strings.Contains(hint, "shortcuts") {
|
||||
t.Fatalf("expected idle hint, got %q", hint)
|
||||
}
|
||||
if strings.Contains(hint, "sidebar") {
|
||||
t.Fatalf("empty sidebar should not be advertised, got %q", hint)
|
||||
}
|
||||
|
||||
withSidebar := idle
|
||||
withSidebar.plan.steps = []planStep{{content: "inspect footer", status: "in_progress"}}
|
||||
if hint := plainRender(t, withSidebar.composerIdleHint()); !strings.Contains(hint, "Ctrl+B sidebar") {
|
||||
t.Fatalf("available sidebar should be advertised, got %q", hint)
|
||||
}
|
||||
withSidebar.sidebarHidden = true
|
||||
if hint := plainRender(t, withSidebar.composerIdleHint()); !strings.Contains(hint, "Ctrl+B sidebar") {
|
||||
t.Fatalf("collapsed sidebar should keep its restore shortcut, got %q", hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Hidden during a run.
|
||||
busy := m
|
||||
|
||||
+162
-41
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1477,13 +1477,13 @@ func renderToolResultCard(row transcriptRow, width int, rc rowContext, opts card
|
||||
collapsedFooter = collapsedToolFooter(row.detail)
|
||||
}
|
||||
if collapsedFooter != "" && !row.expanded {
|
||||
head := toolCardHead(name, headTarget, headArg, "", row.detail, row.text, false, nameStyle, rc.auto[key], width, opts)
|
||||
head := toolCardHead(name, headTarget, headArg, toolResultBudgetTag(row.meta), row.detail, row.text, false, nameStyle, rc.auto[key], width, opts)
|
||||
return toolCard(head, glyph, nil, collapsedFooter, borderStyle, width)
|
||||
}
|
||||
bodyOpts := opts
|
||||
bodyOpts.expanded = row.expanded
|
||||
body := toolCardBody(name, rc.hints[key], rc.args[key], row.detail, width, bodyOpts, failed)
|
||||
head := toolCardHead(name, headTarget, headArg, body.headTag, row.detail, row.text, false, nameStyle, rc.auto[key], width, opts)
|
||||
head := toolCardHead(name, headTarget, headArg, joinToolHeadTags(body.headTag, toolResultBudgetTag(row.meta)), row.detail, row.text, false, nameStyle, rc.auto[key], width, opts)
|
||||
footer := body.footer
|
||||
if collapsedFooter != "" && row.expanded && footer == "" {
|
||||
footer = "▾ collapse"
|
||||
@@ -1491,6 +1491,49 @@ func renderToolResultCard(row transcriptRow, width int, rc rowContext, opts card
|
||||
return toolCard(head, glyph, body.lines, footer, borderStyle, width)
|
||||
}
|
||||
|
||||
func joinToolHeadTags(tags ...string) string {
|
||||
var present []string
|
||||
for _, tag := range tags {
|
||||
if tag = strings.TrimSpace(tag); tag != "" {
|
||||
present = append(present, tag)
|
||||
}
|
||||
}
|
||||
return strings.Join(present, " · ")
|
||||
}
|
||||
|
||||
func toolResultBudgetTag(meta map[string]string) string {
|
||||
if len(meta) == 0 {
|
||||
return ""
|
||||
}
|
||||
reduced := meta["command_output_reduced"] == "true" || meta["truncated"] == "true"
|
||||
if !reduced {
|
||||
return ""
|
||||
}
|
||||
original, originalErr := strconv.Atoi(meta["output_budget_estimated_original_tokens"])
|
||||
retained, retainedErr := strconv.Atoi(meta["output_budget_estimated_retained_tokens"])
|
||||
if commandOriginal, err := strconv.Atoi(meta["command_output_original_tokens"]); err == nil {
|
||||
original, originalErr = commandOriginal, nil
|
||||
}
|
||||
if commandRetained, err := strconv.Atoi(meta["command_output_retained_tokens"]); err == nil {
|
||||
retained, retainedErr = commandRetained, nil
|
||||
}
|
||||
tag := "compact output"
|
||||
if originalErr == nil && retainedErr == nil && original > retained {
|
||||
tag = fmt.Sprintf("compact %s→%s tok", compactCount(original), compactCount(retained))
|
||||
}
|
||||
if strings.TrimSpace(meta["spill_path"]) != "" {
|
||||
tag += " · raw saved"
|
||||
}
|
||||
return tag
|
||||
}
|
||||
|
||||
func compactCount(value int) string {
|
||||
if value < 1000 {
|
||||
return strconv.Itoa(value)
|
||||
}
|
||||
return fmt.Sprintf("%.1fk", float64(value)/1000)
|
||||
}
|
||||
|
||||
// confirmationVerbPattern matches a single-line success confirmation that only
|
||||
// restates the action + target: a leading verb ("Created", "Overwrote",
|
||||
// "Successfully created directory", "Wrote", "Deleted", …) optionally followed
|
||||
|
||||
+44
-7
@@ -29,8 +29,13 @@ func (m model) ensureActiveSession(prompt string) (model, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(m.pendingSessionTitle)
|
||||
manuallyNamed := title != ""
|
||||
if !manuallyNamed {
|
||||
title = tuiSessionTitle(prompt)
|
||||
}
|
||||
session, err := m.sessionStore.Create(sessions.CreateInput{
|
||||
Title: tuiSessionTitle(prompt),
|
||||
Title: title,
|
||||
Cwd: m.cwd,
|
||||
ModelID: m.modelName,
|
||||
Provider: m.providerName,
|
||||
@@ -39,7 +44,14 @@ func (m model) ensureActiveSession(prompt string) (model, error) {
|
||||
return m, err
|
||||
}
|
||||
m.activeSession = session
|
||||
m.pendingSessionTitle = ""
|
||||
m.sessionEvents = []sessions.Event{}
|
||||
if manuallyNamed {
|
||||
if m.titledSessions == nil {
|
||||
m.titledSessions = map[string]bool{}
|
||||
}
|
||||
m.titledSessions[session.SessionID] = true
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -55,6 +67,7 @@ func (m model) startNewSession() model {
|
||||
previousID := m.activeSession.SessionID
|
||||
|
||||
m.activeSession = sessions.Metadata{}
|
||||
m.pendingSessionTitle = ""
|
||||
m.sessionEvents = nil
|
||||
|
||||
// Reset the per-session usage + compaction display so the new session starts
|
||||
@@ -217,6 +230,7 @@ func (m model) handleResumeCommand(args string) (model, string) {
|
||||
// the already-active session, whose loops belong to it, not a "previous" one.
|
||||
previousID := m.activeSession.SessionID
|
||||
m.activeSession = *session
|
||||
m.pendingSessionTitle = ""
|
||||
m.sessionEvents = append([]sessions.Event{}, events...)
|
||||
if m.providerName == "" {
|
||||
m.providerName = session.Provider
|
||||
@@ -392,17 +406,16 @@ func (m model) newSessionPicker() *commandPicker {
|
||||
if !m.sessionHasResumableContent(meta.SessionID) {
|
||||
continue
|
||||
}
|
||||
// Lead with the timestamp so same-titled sessions (e.g. the same first
|
||||
// prompt run several times) are visually distinct; the id (right, faint)
|
||||
// stays for reference and is what selection actually resolves.
|
||||
// Lead with a fixed-width timestamp so titles form one scannable column.
|
||||
// The raw id remains the selection/search value but stays out of the row:
|
||||
// rendering it consumed half the picker and truncated the useful title.
|
||||
label := displayValue(meta.Title, "untitled")
|
||||
if when := sessionWhen(meta.UpdatedAt, now); when != "" {
|
||||
label = when + " " + label
|
||||
label = sessionPickerLabel(when, label)
|
||||
}
|
||||
items = append(items, pickerItem{
|
||||
Label: label,
|
||||
Value: meta.SessionID,
|
||||
Meta: meta.SessionID,
|
||||
})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
@@ -417,6 +430,12 @@ func (m model) newSessionPicker() *commandPicker {
|
||||
}
|
||||
}
|
||||
|
||||
const sessionPickerTimeWidth = len("Jan 02 15:04")
|
||||
|
||||
func sessionPickerLabel(when, title string) string {
|
||||
return fmt.Sprintf("%-*s %s", sessionPickerTimeWidth, when, title)
|
||||
}
|
||||
|
||||
// sessionHasResumableContent reports whether a session has anything worth
|
||||
// resuming: a tool call/result, or a non-user message with real content (not the
|
||||
// no-output guardrail stop). Empty/failed runs return false and are hidden from
|
||||
@@ -479,7 +498,7 @@ func (m model) sessionHasResumableContent(sessionID string) bool {
|
||||
// anything worth resuming: a tool call/result, or a non-user message with real
|
||||
// content (not the no-output guardrail stop). It is the pure core of
|
||||
// sessionHasResumableContent so callers that already hold the events (e.g. the
|
||||
// /retitle scan) don't re-read them.
|
||||
// session picker refresh) don't re-read them.
|
||||
func eventsHaveResumableContent(events []sessions.Event) bool {
|
||||
for _, event := range events {
|
||||
switch event.Type {
|
||||
@@ -616,6 +635,7 @@ func transcriptRowsFromSessionEvents(events []sessions.Event) []transcriptRow {
|
||||
tool: name,
|
||||
status: status,
|
||||
detail: output,
|
||||
meta: payloadStringMap(payload, "meta"),
|
||||
changedFiles: payloadStringSlice(payload, "changedFiles"),
|
||||
changeSummaries: payloadExecutionChanges(payload, "changeSummaries"),
|
||||
})
|
||||
@@ -809,6 +829,23 @@ func payloadStringSlice(payload map[string]any, key string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
func payloadStringMap(payload map[string]any, key string) map[string]string {
|
||||
value, ok := payloadMap(payload, key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(value))
|
||||
for name, raw := range value {
|
||||
if text, ok := raw.(string); ok {
|
||||
out[name] = text
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func payloadExecutionChanges(payload map[string]any, key string) []execution.Change {
|
||||
value, ok := payload[key]
|
||||
if !ok {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
type sessionRenamePrompt struct{}
|
||||
|
||||
func (m model) openSessionRenamePrompt() model {
|
||||
if m.sessionStore == nil {
|
||||
return m.appendSessionRenameError("session storage is unavailable")
|
||||
}
|
||||
m.clearComposer()
|
||||
title := m.activeSession.Title
|
||||
if m.activeSession.SessionID == "" {
|
||||
title = m.pendingSessionTitle
|
||||
}
|
||||
m.input.SetValue(title)
|
||||
m.input.CursorEnd()
|
||||
m.renamePrompt = &sessionRenamePrompt{}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) handleSessionRenameKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case keyIs(msg, tea.KeyEsc), keyCtrl(msg, 'c'):
|
||||
m.renamePrompt = nil
|
||||
m.clearComposer()
|
||||
return m, nil
|
||||
case keyIs(msg, tea.KeyEnter):
|
||||
title := m.input.Value()
|
||||
m.renamePrompt = nil
|
||||
m.clearComposer()
|
||||
return m.renameActiveSession(title), nil
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) renameActiveSession(title string) model {
|
||||
title = cutRunes(strings.TrimSpace(title), tuiSessionTitleLimit)
|
||||
if title == "" {
|
||||
return m.appendSessionRenameError("session name cannot be empty")
|
||||
}
|
||||
if m.sessionStore == nil {
|
||||
return m.appendSessionRenameError("session storage is unavailable")
|
||||
}
|
||||
if m.activeSession.SessionID == "" {
|
||||
m.pendingSessionTitle = title
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendSystem,
|
||||
text: "Session renamed to " + title + ".",
|
||||
})
|
||||
return m
|
||||
}
|
||||
updated, err := m.sessionStore.UpdateTitle(m.activeSession.SessionID, title)
|
||||
if err != nil {
|
||||
return m.appendSessionRenameError(err.Error())
|
||||
}
|
||||
m.activeSession.Title = updated.Title
|
||||
if m.titledSessions == nil {
|
||||
m.titledSessions = map[string]bool{}
|
||||
}
|
||||
m.titledSessions[updated.SessionID] = true
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendSystem,
|
||||
text: "Session renamed to " + updated.Title + ".",
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) appendSessionRenameError(detail string) model {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendError,
|
||||
text: fmt.Sprintf("Could not rename session: %s.", strings.TrimSuffix(strings.TrimSpace(detail), ".")),
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) sessionRenamePromptView(width int) string {
|
||||
if width <= 0 {
|
||||
width = defaultStartupWidth
|
||||
}
|
||||
input := m.input
|
||||
input.Prompt = "> "
|
||||
input.Placeholder = "Type a name"
|
||||
innerWidth := maxInt(1, width-4)
|
||||
line := renderComposerInput(
|
||||
input,
|
||||
composerState{text: input.Value(), cursor: input.Position()},
|
||||
innerWidth,
|
||||
m.composerCursorVisible,
|
||||
composerSelectionState{},
|
||||
)
|
||||
lines := append(strings.Split(line, "\n"),
|
||||
zeroTheme.line.Render(strings.Repeat("─", innerWidth)),
|
||||
zeroTheme.faint.Render("Enter save Esc cancel"),
|
||||
)
|
||||
return styledBlockFillTitle(width, "Rename session", lines, zeroTheme.lineStrong, lipgloss.NewStyle())
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
func renameTestModel(t *testing.T) (model, *sessions.Store, sessions.Metadata) {
|
||||
t.Helper()
|
||||
store := testSessionStore(t)
|
||||
session, err := store.Create(sessions.CreateInput{Title: "Current session name"})
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
m := newModel(context.Background(), Options{SessionStore: store})
|
||||
m.activeSession = session
|
||||
return m, store, session
|
||||
}
|
||||
|
||||
func TestRenameCommandRenamesCurrentSessionWithoutAgentRun(t *testing.T) {
|
||||
m, store, session := renameTestModel(t)
|
||||
m.input.SetValue("/rename Better session name ")
|
||||
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if cmd != nil {
|
||||
t.Fatal("/rename must not start an agent or background command")
|
||||
}
|
||||
if next.activeSession.Title != "Better session name" {
|
||||
t.Fatalf("active title = %q", next.activeSession.Title)
|
||||
}
|
||||
stored, err := store.Get(session.SessionID)
|
||||
if err != nil || stored == nil {
|
||||
t.Fatalf("get renamed session: %v", err)
|
||||
}
|
||||
if stored.Title != "Better session name" {
|
||||
t.Fatalf("stored title = %q", stored.Title)
|
||||
}
|
||||
if !next.titledSessions[session.SessionID] {
|
||||
t.Fatal("manual rename should suppress later automatic naming")
|
||||
}
|
||||
if !transcriptContains(next.transcript, "Session renamed to Better session name.") {
|
||||
t.Fatalf("missing rename confirmation: %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameCommandCapsSessionTitle(t *testing.T) {
|
||||
m, store, session := renameTestModel(t)
|
||||
longTitle := strings.Repeat("界", tuiSessionTitleLimit+20)
|
||||
m.input.SetValue("/rename " + longTitle)
|
||||
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if cmd != nil {
|
||||
t.Fatal("/rename must not start an agent or background command")
|
||||
}
|
||||
want := cutRunes(longTitle, tuiSessionTitleLimit)
|
||||
if next.activeSession.Title != want {
|
||||
t.Fatalf("active title has %d runes, want %d", len([]rune(next.activeSession.Title)), tuiSessionTitleLimit)
|
||||
}
|
||||
stored, err := store.Get(session.SessionID)
|
||||
if err != nil || stored == nil || stored.Title != want {
|
||||
t.Fatalf("stored capped title = %#v, err=%v", stored, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameEditorBordersWrappedTitleLines(t *testing.T) {
|
||||
m, _, _ := renameTestModel(t)
|
||||
m.input.SetValue(strings.Repeat("x", tuiSessionTitleLimit))
|
||||
m.input.CursorEnd()
|
||||
m.renamePrompt = &sessionRenamePrompt{}
|
||||
|
||||
view := plainRender(t, m.sessionRenamePromptView(80))
|
||||
lines := strings.Split(view, "\n")
|
||||
if len(lines) < 6 {
|
||||
t.Fatalf("expected wrapped rename editor, got:\n%s", view)
|
||||
}
|
||||
for _, line := range lines[1 : len(lines)-1] {
|
||||
if !strings.HasPrefix(line, "│ ") || !strings.HasSuffix(line, " │") {
|
||||
t.Fatalf("wrapped editor row lost its border: %q\n%s", line, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBareRenameOpensPrefilledEditorAndSaves(t *testing.T) {
|
||||
m, store, session := renameTestModel(t)
|
||||
m.input.SetValue("/rename")
|
||||
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if cmd != nil || next.renamePrompt == nil {
|
||||
t.Fatalf("bare /rename should open editor without a command: prompt=%#v cmd=%v", next.renamePrompt, cmd)
|
||||
}
|
||||
if next.input.Value() != "Current session name" {
|
||||
t.Fatalf("editor value = %q", next.input.Value())
|
||||
}
|
||||
view := plainRender(t, next.View())
|
||||
for _, want := range []string{"Rename session", "Current session name", "Enter save", "Esc cancel"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("rename editor missing %q:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
|
||||
next.input.SetValue("Edited in prompt")
|
||||
updated, cmd = next.Update(testKey(tea.KeyEnter))
|
||||
next = updated.(model)
|
||||
if cmd != nil || next.renamePrompt != nil {
|
||||
t.Fatalf("saving should close editor without a command: prompt=%#v cmd=%v", next.renamePrompt, cmd)
|
||||
}
|
||||
stored, err := store.Get(session.SessionID)
|
||||
if err != nil || stored == nil || stored.Title != "Edited in prompt" {
|
||||
t.Fatalf("stored session after editor save = %#v, err=%v", stored, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameNamesFreshSessionBeforeFirstPrompt(t *testing.T) {
|
||||
store := testSessionStore(t)
|
||||
m := newModel(context.Background(), Options{SessionStore: store})
|
||||
m.input.SetValue("/rename Fresh session name")
|
||||
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if cmd != nil {
|
||||
t.Fatal("fresh /rename must stay local")
|
||||
}
|
||||
if next.activeSession.SessionID != "" || next.pendingSessionTitle != "Fresh session name" {
|
||||
t.Fatalf("fresh rename state = active:%q pending:%q", next.activeSession.SessionID, next.pendingSessionTitle)
|
||||
}
|
||||
|
||||
next, err := next.ensureActiveSession("first real prompt")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure active session: %v", err)
|
||||
}
|
||||
if next.activeSession.Title != "Fresh session name" || next.pendingSessionTitle != "" {
|
||||
t.Fatalf("created session title = %q, pending=%q", next.activeSession.Title, next.pendingSessionTitle)
|
||||
}
|
||||
if !next.titledSessions[next.activeSession.SessionID] {
|
||||
t.Fatal("a manually named fresh session must skip automatic naming")
|
||||
}
|
||||
stored, err := store.Get(next.activeSession.SessionID)
|
||||
if err != nil || stored == nil || stored.Title != "Fresh session name" {
|
||||
t.Fatalf("stored fresh session = %#v, err=%v", stored, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameEditorRejectsBlankAndEscCancels(t *testing.T) {
|
||||
m, store, session := renameTestModel(t)
|
||||
m = m.openSessionRenamePrompt()
|
||||
m.input.SetValue(" ")
|
||||
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if next.renamePrompt != nil {
|
||||
t.Fatal("blank submit should close the editor")
|
||||
}
|
||||
if !transcriptContains(next.transcript, "session name cannot be empty") {
|
||||
t.Fatalf("missing blank-name error: %#v", next.transcript)
|
||||
}
|
||||
stored, _ := store.Get(session.SessionID)
|
||||
if stored == nil || stored.Title != "Current session name" {
|
||||
t.Fatalf("blank rename changed title: %#v", stored)
|
||||
}
|
||||
|
||||
next = next.openSessionRenamePrompt()
|
||||
next.input.SetValue("Do not save")
|
||||
updated, _ = next.Update(testKey(tea.KeyEsc))
|
||||
next = updated.(model)
|
||||
if next.renamePrompt != nil || next.input.Value() != "" {
|
||||
t.Fatalf("Esc should close and clear editor: prompt=%#v input=%q", next.renamePrompt, next.input.Value())
|
||||
}
|
||||
stored, _ = store.Get(session.SessionID)
|
||||
if stored == nil || stored.Title != "Current session name" {
|
||||
t.Fatalf("cancelled rename changed title: %#v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetitleCommandIsRemoved(t *testing.T) {
|
||||
if command, ok := resolveCommand("/retitle"); ok {
|
||||
t.Fatalf("/retitle should not resolve, got %#v", command)
|
||||
}
|
||||
command, ok := resolveCommand("/rename")
|
||||
if !ok || command.kind != commandRename {
|
||||
t.Fatalf("/rename should resolve, got ok=%v command=%#v", ok, command)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -19,9 +20,10 @@ import (
|
||||
)
|
||||
|
||||
type scriptedProvider struct {
|
||||
scripts [][]zeroruntime.StreamEvent
|
||||
requests []zeroruntime.CompletionRequest
|
||||
calls int
|
||||
scripts [][]zeroruntime.StreamEvent
|
||||
requests []zeroruntime.CompletionRequest
|
||||
beforeCall func(int)
|
||||
calls int
|
||||
}
|
||||
|
||||
func (provider *scriptedProvider) StreamCompletion(
|
||||
@@ -39,6 +41,9 @@ func (provider *scriptedProvider) StreamCompletion(
|
||||
if index >= len(provider.scripts) {
|
||||
index = len(provider.scripts) - 1
|
||||
}
|
||||
if provider.beforeCall != nil {
|
||||
provider.beforeCall(index)
|
||||
}
|
||||
ch := make(chan zeroruntime.StreamEvent, len(provider.scripts[index]))
|
||||
for _, event := range provider.scripts[index] {
|
||||
ch <- event
|
||||
@@ -358,6 +363,92 @@ func TestPromptSubmitPersistsPermissionSessionEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionWaitDoesNotCountTowardTurnElapsed(t *testing.T) {
|
||||
store := testSessionStore(t)
|
||||
root := t.TempDir()
|
||||
base := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC)
|
||||
var clockNanos atomic.Int64
|
||||
clockNanos.Store(base.UnixNano())
|
||||
provider := &scriptedProvider{scripts: [][]zeroruntime.StreamEvent{
|
||||
writeFileToolScript("call_write", "notes.txt", "hello"),
|
||||
textScript("write blocked"),
|
||||
}}
|
||||
provider.beforeCall = func(index int) {
|
||||
switch index {
|
||||
case 0:
|
||||
clockNanos.Store(base.Add(2 * time.Second).UnixNano())
|
||||
case 1:
|
||||
clockNanos.Store(base.Add(95 * time.Second).UnixNano())
|
||||
}
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
runtimeMessageCh := make(chan tea.Msg, 8)
|
||||
m := newPermissionTestModel(root, provider, registry, store, nil, runtimeMessageCh)
|
||||
|
||||
m.now = func() time.Time {
|
||||
return time.Unix(0, clockNanos.Load()).UTC()
|
||||
}
|
||||
m.input.SetValue("write notes")
|
||||
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if cmd == nil {
|
||||
t.Fatal("expected prompt submit to start an agent run")
|
||||
}
|
||||
|
||||
finalCh := make(chan tea.Msg, 1)
|
||||
go func() {
|
||||
finalCh <- execCmd(cmd)
|
||||
}()
|
||||
|
||||
for received := 0; received < 4; {
|
||||
runtimeMsg := receiveRuntimeMessage(t, runtimeMessageCh)
|
||||
updated, _ = next.Update(runtimeMsg)
|
||||
next = updated.(model)
|
||||
switch runtimeMsg.(type) {
|
||||
case toolCallStreamStartMsg, toolCallStreamDeltaMsg:
|
||||
continue
|
||||
case permissionRequestMsg:
|
||||
clockNanos.Store(base.Add(92 * time.Second).UnixNano())
|
||||
if status := plainRender(t, next.workingStatusLine()); !strings.Contains(status, "· 2s ·") || strings.Contains(status, "still generating") {
|
||||
t.Fatalf("live elapsed advanced during permission wait: %q", status)
|
||||
}
|
||||
updated, _ = next.Update(testKeyText("d"))
|
||||
next = updated.(model)
|
||||
if hint := next.quietGenerationHint(); hint != "" {
|
||||
t.Fatalf("permission wait leaked into quiet-generation age: %q", hint)
|
||||
}
|
||||
}
|
||||
received++
|
||||
}
|
||||
|
||||
finalMsg := receiveFinalMessage(t, finalCh)
|
||||
response, ok := finalMsg.(agentResponseMsg)
|
||||
if !ok {
|
||||
t.Fatalf("expected agent response, got %T", finalMsg)
|
||||
}
|
||||
if response.turnElapsed != 5*time.Second {
|
||||
t.Fatalf("turn elapsed = %s, want 5s of active work", response.turnElapsed)
|
||||
}
|
||||
if response.ttft != 5*time.Second {
|
||||
t.Fatalf("ttft = %s, want 5s with permission wait excluded", response.ttft)
|
||||
}
|
||||
if response.ttft > response.turnElapsed {
|
||||
t.Fatalf("ttft = %s exceeds turn elapsed = %s", response.ttft, response.turnElapsed)
|
||||
}
|
||||
updated, _ = next.Update(finalMsg)
|
||||
next = updated.(model)
|
||||
|
||||
answer, ok := findTranscriptRow(next.transcript, rowAssistant)
|
||||
if !ok {
|
||||
t.Fatalf("expected final assistant row, got %#v", next.transcript)
|
||||
}
|
||||
if answer.turnElapsed != 5*time.Second {
|
||||
t.Fatalf("assistant turn elapsed = %s, want 5s", answer.turnElapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionPromptAllowWritesFileAndRecordsDecision(t *testing.T) {
|
||||
store := testSessionStore(t)
|
||||
root := t.TempDir()
|
||||
|
||||
@@ -3,7 +3,6 @@ package tui
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -42,12 +41,11 @@ const sessionTitleSystemPrompt = "You write a short, specific title for a coding
|
||||
var errSessionTitleNoContent = errors.New("session has no content to title")
|
||||
|
||||
// sessionTitleGeneratedMsg carries the outcome of a background title generation
|
||||
// back to the Update loop. backfill distinguishes a /retitle queue step (which
|
||||
// advances the queue and updates a status row) from a silent auto-title.
|
||||
// back to the Update loop.
|
||||
type sessionTitleGeneratedMsg struct {
|
||||
sessionID string
|
||||
title string
|
||||
backfill bool
|
||||
applied bool
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -168,32 +166,25 @@ func generateSessionTitle(ctx context.Context, provider zeroruntime.Provider, di
|
||||
}
|
||||
|
||||
// generateSessionTitleCmd builds the background command that generates and
|
||||
// persists a title for sessionID. When precomputedDigest is empty the command
|
||||
// reads the session's events itself (the backfill path), keeping that I/O off the
|
||||
// Update goroutine; the auto-title path passes the in-memory digest directly.
|
||||
func (m model) generateSessionTitleCmd(sessionID string, precomputedDigest string, backfill bool) tea.Cmd {
|
||||
// persists a title for sessionID if its original automatic title is still
|
||||
// current. The compare-and-update keeps a manual /rename authoritative when it
|
||||
// races a slow provider response.
|
||||
func (m model) generateSessionTitleCmd(sessionID string, digest string) tea.Cmd {
|
||||
provider := m.provider
|
||||
store := m.sessionStore
|
||||
originalTitle := m.activeSession.Title
|
||||
return func() tea.Msg {
|
||||
digest := precomputedDigest
|
||||
if strings.TrimSpace(digest) == "" {
|
||||
events, err := store.ReadEvents(sessionID)
|
||||
if err != nil {
|
||||
return sessionTitleGeneratedMsg{sessionID: sessionID, backfill: backfill, err: err}
|
||||
}
|
||||
digest = sessionTitleDigest(events)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sessionTitleTimeout)
|
||||
defer cancel()
|
||||
title, err := generateSessionTitle(ctx, provider, digest)
|
||||
if err != nil {
|
||||
return sessionTitleGeneratedMsg{sessionID: sessionID, backfill: backfill, err: err}
|
||||
return sessionTitleGeneratedMsg{sessionID: sessionID, err: err}
|
||||
}
|
||||
updated, err := store.UpdateTitle(sessionID, title)
|
||||
updated, applied, err := store.UpdateTitleIfCurrent(sessionID, originalTitle, title)
|
||||
if err != nil {
|
||||
return sessionTitleGeneratedMsg{sessionID: sessionID, title: title, backfill: backfill, err: err}
|
||||
return sessionTitleGeneratedMsg{sessionID: sessionID, title: title, err: err}
|
||||
}
|
||||
return sessionTitleGeneratedMsg{sessionID: sessionID, title: updated.Title, backfill: backfill}
|
||||
return sessionTitleGeneratedMsg{sessionID: sessionID, title: updated.Title, applied: applied}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,87 +247,24 @@ func (m model) maybeAutoTitleActiveSession() (model, tea.Cmd) {
|
||||
m.titledSessions = map[string]bool{}
|
||||
}
|
||||
m.titledSessions[sessionID] = true
|
||||
return m, m.generateSessionTitleCmd(sessionID, digest, false)
|
||||
return m, m.generateSessionTitleCmd(sessionID, digest)
|
||||
}
|
||||
|
||||
// startSessionRetitle scans resumable sessions for ones still carrying their
|
||||
// default first-message title and queues a model-generated title for each,
|
||||
// firing them one at a time. It returns a status line for the transcript.
|
||||
func (m model) startSessionRetitle() (model, tea.Cmd, string) {
|
||||
if m.provider == nil {
|
||||
return m, nil, "Cannot retitle sessions: no active provider is configured."
|
||||
}
|
||||
if m.retitleActive {
|
||||
return m, nil, fmt.Sprintf("Already generating titles (%d/%d). Let it finish first.", m.retitleDone, m.retitleTotal)
|
||||
}
|
||||
list, err := m.sessionStore.ListResumable()
|
||||
if err != nil {
|
||||
return m, nil, "Sessions\nFailed to list sessions: " + err.Error()
|
||||
}
|
||||
candidates := make([]string, 0, len(list))
|
||||
for _, session := range list {
|
||||
events, err := m.sessionStore.ReadEvents(session.SessionID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !eventsHaveResumableContent(events) {
|
||||
continue // empty/failed run — nothing worth titling
|
||||
}
|
||||
if !sessionTitleIsAuto(session.Title, events) {
|
||||
continue // already has a model-generated title
|
||||
}
|
||||
candidates = append(candidates, session.SessionID)
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return m, nil, "All resumable sessions already have a generated title."
|
||||
}
|
||||
if m.titledSessions == nil {
|
||||
m.titledSessions = map[string]bool{}
|
||||
}
|
||||
for _, id := range candidates {
|
||||
m.titledSessions[id] = true
|
||||
}
|
||||
m.retitleQueue = append([]string(nil), candidates[1:]...)
|
||||
m.retitleActive = true
|
||||
m.retitleTotal = len(candidates)
|
||||
m.retitleDone = 0
|
||||
m.retitleOK = 0
|
||||
cmd := m.generateSessionTitleCmd(candidates[0], "", true)
|
||||
return m, cmd, fmt.Sprintf("Generating titles for %d session(s)… this runs in the background.", len(candidates))
|
||||
}
|
||||
|
||||
// handleSessionTitleGenerated applies a finished title and, for the /retitle
|
||||
// backfill, advances the sequential queue and reports completion.
|
||||
// handleSessionTitleGenerated applies a finished automatic title to the active
|
||||
// model. The store update has already been skipped when a manual rename won.
|
||||
func (m model) handleSessionTitleGenerated(msg sessionTitleGeneratedMsg) (model, tea.Cmd) {
|
||||
titleOK := msg.err == nil && msg.title != ""
|
||||
titleOK := msg.err == nil && msg.title != "" && msg.applied
|
||||
if titleOK {
|
||||
if msg.sessionID == m.activeSession.SessionID {
|
||||
m.activeSession.Title = msg.title
|
||||
}
|
||||
} else {
|
||||
} else if msg.err != nil || msg.title == "" {
|
||||
// titledSessions is marked optimistically when the cmd is scheduled (so a
|
||||
// second turn can't double-fire a title for the same session while the
|
||||
// first is in flight). A FAILED generation — provider error, empty title,
|
||||
// or store write error — must not leave that gate set forever, so release
|
||||
// it here; a later turn or /retitle can then retry. Success keeps the gate.
|
||||
// it here so a later turn can retry. Success keeps the gate.
|
||||
delete(m.titledSessions, msg.sessionID)
|
||||
}
|
||||
if !msg.backfill {
|
||||
// Auto-title is silent: on failure the first-message title simply stays
|
||||
// (and the retry gate above was released).
|
||||
return m, nil
|
||||
}
|
||||
m.retitleDone++
|
||||
if titleOK {
|
||||
m.retitleOK++
|
||||
}
|
||||
if len(m.retitleQueue) > 0 {
|
||||
next := m.retitleQueue[0]
|
||||
m.retitleQueue = m.retitleQueue[1:]
|
||||
return m, m.generateSessionTitleCmd(next, "", true)
|
||||
}
|
||||
m.retitleActive = false
|
||||
summary := fmt.Sprintf("Generated titles for %d of %d session(s). Open /resume to see them.", m.retitleOK, m.retitleTotal)
|
||||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, tool: "sessions", text: summary})
|
||||
return m, nil
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
@@ -32,7 +30,7 @@ func appendSessionMessage(t *testing.T, store *sessions.Store, id, role, content
|
||||
}
|
||||
|
||||
// createAutoTitledSession creates a real, resumable session whose Title is the
|
||||
// default first-message title (so it is a retitle/auto-title candidate).
|
||||
// default first-message title (so it is an auto-title candidate).
|
||||
func createAutoTitledSession(t *testing.T, store *sessions.Store, prompt, answer string) sessions.Metadata {
|
||||
t.Helper()
|
||||
session, err := store.Create(sessions.CreateInput{Title: tuiSessionTitle(prompt)})
|
||||
@@ -179,7 +177,7 @@ func TestAutoTitleGeneratesTitleForActiveSession(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected sessionTitleGeneratedMsg, got %#v", msg)
|
||||
}
|
||||
if result.err != nil || result.backfill {
|
||||
if result.err != nil || !result.applied {
|
||||
t.Fatalf("unexpected auto-title result: %#v", result)
|
||||
}
|
||||
if result.title != "Add Fetch Call To Client" {
|
||||
@@ -268,82 +266,52 @@ func TestAutoTitleFailureReleasesRetryGate(t *testing.T) {
|
||||
// A successful generation keeps the session gated (one-shot, no re-fire).
|
||||
ok := newModel(context.Background(), Options{})
|
||||
ok.titledSessions = map[string]bool{id: true}
|
||||
ok, _ = ok.handleSessionTitleGenerated(sessionTitleGeneratedMsg{sessionID: id, title: "Real Title"})
|
||||
ok, _ = ok.handleSessionTitleGenerated(sessionTitleGeneratedMsg{sessionID: id, title: "Real Title", applied: true})
|
||||
if !ok.titledSessions[id] {
|
||||
t.Fatal("a successful title generation must keep the session gated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetitleBackfillTitlesOnlyAutoTitledSessions(t *testing.T) {
|
||||
func TestManualRenameWinsOverInFlightAutoTitle(t *testing.T) {
|
||||
store := testSessionStore(t)
|
||||
first := createAutoTitledSession(t, store, "build the resume picker", "Working on it.")
|
||||
second := createAutoTitledSession(t, store, "fix the dst bug", "Fixed.")
|
||||
|
||||
// An empty/failed run: user prompt + the no-output guardrail stop. Skipped.
|
||||
empty, err := store.Create(sessions.CreateInput{Title: tuiSessionTitle("do a thing")})
|
||||
if err != nil {
|
||||
t.Fatalf("create empty: %v", err)
|
||||
}
|
||||
appendSessionMessage(t, store, empty.SessionID, "user", "do a thing")
|
||||
appendSessionMessage(t, store, empty.SessionID, "assistant",
|
||||
"Agent stopped after 3 turns with no output (no visible text and no tool calls) to avoid consuming tokens without making progress.")
|
||||
|
||||
// A session that already has a distinct (hand/model) title. Skipped.
|
||||
named, err := store.Create(sessions.CreateInput{Title: "Hand Named Session"})
|
||||
if err != nil {
|
||||
t.Fatalf("create named: %v", err)
|
||||
}
|
||||
appendSessionMessage(t, store, named.SessionID, "user", "something")
|
||||
appendSessionMessage(t, store, named.SessionID, "assistant", "ok")
|
||||
session := createAutoTitledSession(t, store, "build the resume picker", "Working on it.")
|
||||
|
||||
m := newModel(context.Background(), Options{
|
||||
SessionStore: store,
|
||||
Provider: titleProvider("Generated Backfill Title"),
|
||||
Provider: titleProvider("Generated Automatic Title"),
|
||||
})
|
||||
m.input.SetValue("/retitle")
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
m = updated.(model)
|
||||
if !m.retitleActive {
|
||||
t.Fatal("expected a backfill to be active")
|
||||
m.activeSession = session
|
||||
events, err := store.ReadEvents(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("read events: %v", err)
|
||||
}
|
||||
if m.retitleTotal != 2 {
|
||||
t.Fatalf("retitle total = %d, want 2 (only auto-titled real sessions)", m.retitleTotal)
|
||||
m.sessionEvents = events
|
||||
|
||||
m, cmd := m.maybeAutoTitleActiveSession()
|
||||
if cmd == nil {
|
||||
t.Fatal("expected an automatic title command")
|
||||
}
|
||||
if !transcriptContains(m.transcript, "Generating titles for 2 session") {
|
||||
t.Fatalf("expected a kickoff status row, got %#v", m.transcript)
|
||||
if _, err := store.UpdateTitle(session.SessionID, "Manual Name"); err != nil {
|
||||
t.Fatalf("manual rename: %v", err)
|
||||
}
|
||||
|
||||
// Drain the sequential queue.
|
||||
guard := 0
|
||||
for cmd != nil {
|
||||
guard++
|
||||
if guard > 10 {
|
||||
t.Fatal("retitle queue did not drain")
|
||||
}
|
||||
msg := execCmd(cmd)
|
||||
updated, cmd = m.Update(msg)
|
||||
m = updated.(model)
|
||||
result, ok := execCmd(cmd).(sessionTitleGeneratedMsg)
|
||||
if !ok {
|
||||
t.Fatal("expected a title result")
|
||||
}
|
||||
if m.retitleActive {
|
||||
t.Fatal("backfill should be finished after the queue drains")
|
||||
if result.err != nil || result.applied {
|
||||
t.Fatalf("late automatic title should be skipped, got %#v", result)
|
||||
}
|
||||
if !transcriptContains(m.transcript, "Generated titles for 2 of 2") {
|
||||
t.Fatalf("expected a completion status row, got %#v", m.transcript)
|
||||
got, err := store.Get(session.SessionID)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("get session: %v", err)
|
||||
}
|
||||
if got.Title != "Manual Name" {
|
||||
t.Fatalf("late automatic title overwrote manual name: %q", got.Title)
|
||||
}
|
||||
|
||||
for _, id := range []string{first.SessionID, second.SessionID} {
|
||||
got, err := store.Get(id)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("get %s: %v", id, err)
|
||||
}
|
||||
if got.Title != "Generated Backfill Title" {
|
||||
t.Fatalf("session %s title = %q, want generated", id, got.Title)
|
||||
}
|
||||
}
|
||||
if got, _ := store.Get(named.SessionID); got == nil || got.Title != "Hand Named Session" {
|
||||
t.Fatalf("a named session must keep its title, got %#v", got)
|
||||
}
|
||||
if got, _ := store.Get(empty.SessionID); got == nil || got.Title != tuiSessionTitle("do a thing") {
|
||||
t.Fatalf("an empty session must be skipped and keep its title, got %#v", got)
|
||||
next, _ := m.handleSessionTitleGenerated(result)
|
||||
if !next.titledSessions[session.SessionID] {
|
||||
t.Fatal("a stale automatic title result must keep the retry gate set")
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func (m model) sidebarToggleAllowed() bool {
|
||||
return false
|
||||
}
|
||||
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
|
||||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
|
||||
m.mcpManager != nil || m.picker != nil || m.renamePrompt != nil || m.suggestionsActive() {
|
||||
return false
|
||||
}
|
||||
// Home/welcome screen: stay single-column until there's real conversation.
|
||||
@@ -113,7 +113,7 @@ func (m model) sidebarAvailable() bool {
|
||||
// suggestionsActive() guard, so clicks still go to the palette and not to the
|
||||
// sidebar rows underneath it.
|
||||
if m.setup.visible || m.helpOverlay || m.leaderHelpOverlay || m.providerWizard != nil || m.mcpAddWizard != nil ||
|
||||
m.mcpManager != nil || m.picker != nil {
|
||||
m.mcpManager != nil || m.picker != nil || m.renamePrompt != nil {
|
||||
return false
|
||||
}
|
||||
// Home/welcome screen: stay single-column until there's real conversation, so
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1125,7 +1125,8 @@ func (m model) transcriptHitTestSource() (header string, items []transcriptBodyI
|
||||
// transcriptHitTestBlocked reports whether mouse hit-testing must be skipped
|
||||
// outright — a modal/overlay is up, or there's no alt-screen viewport at all.
|
||||
func (m model) transcriptHitTestBlocked() bool {
|
||||
return !m.altScreen || m.height <= 0 || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive()
|
||||
return !m.altScreen || m.height <= 0 || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
|
||||
m.mcpManager != nil || m.picker != nil || m.renamePrompt != nil || m.suggestionsActive()
|
||||
}
|
||||
|
||||
// transcriptHitTestLayout computes the frame/window/layout mouse hit-testing needs,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// activeTurnTimer measures run time while excluding intervals where execution
|
||||
// is paused waiting for a user decision. It is shared by the Bubble Tea model
|
||||
// and the agent command, so access must remain safe across their goroutines.
|
||||
type activeTurnTimer struct {
|
||||
mu sync.Mutex
|
||||
startedAt time.Time
|
||||
pausedAt time.Time
|
||||
pausedFor time.Duration
|
||||
}
|
||||
|
||||
func newActiveTurnTimer(startedAt time.Time) *activeTurnTimer {
|
||||
return &activeTurnTimer{startedAt: startedAt}
|
||||
}
|
||||
|
||||
func (timer *activeTurnTimer) start(startedAt time.Time) {
|
||||
if timer == nil {
|
||||
return
|
||||
}
|
||||
timer.mu.Lock()
|
||||
defer timer.mu.Unlock()
|
||||
timer.startedAt = startedAt
|
||||
timer.pausedAt = time.Time{}
|
||||
timer.pausedFor = 0
|
||||
}
|
||||
|
||||
func (timer *activeTurnTimer) pause(at time.Time) {
|
||||
if timer == nil {
|
||||
return
|
||||
}
|
||||
timer.mu.Lock()
|
||||
defer timer.mu.Unlock()
|
||||
if timer.startedAt.IsZero() || !timer.pausedAt.IsZero() {
|
||||
return
|
||||
}
|
||||
timer.pausedAt = at
|
||||
}
|
||||
|
||||
func (timer *activeTurnTimer) resume(at time.Time) {
|
||||
if timer == nil {
|
||||
return
|
||||
}
|
||||
timer.mu.Lock()
|
||||
defer timer.mu.Unlock()
|
||||
if timer.pausedAt.IsZero() {
|
||||
return
|
||||
}
|
||||
if at.After(timer.pausedAt) {
|
||||
timer.pausedFor += at.Sub(timer.pausedAt)
|
||||
}
|
||||
timer.pausedAt = time.Time{}
|
||||
}
|
||||
|
||||
func (timer *activeTurnTimer) elapsed(at time.Time) time.Duration {
|
||||
if timer == nil {
|
||||
return 0
|
||||
}
|
||||
timer.mu.Lock()
|
||||
defer timer.mu.Unlock()
|
||||
if timer.startedAt.IsZero() {
|
||||
return 0
|
||||
}
|
||||
end := at
|
||||
if !timer.pausedAt.IsZero() && timer.pausedAt.Before(end) {
|
||||
end = timer.pausedAt
|
||||
}
|
||||
elapsed := end.Sub(timer.startedAt) - timer.pausedFor
|
||||
if elapsed < 0 {
|
||||
return 0
|
||||
}
|
||||
return elapsed
|
||||
}
|
||||
|
||||
func (m model) activeTurnElapsed(fallbackStartedAt time.Time) time.Duration {
|
||||
return m.activeTurnElapsedAt(fallbackStartedAt, m.now())
|
||||
}
|
||||
|
||||
func (m model) activeTurnElapsedAt(fallbackStartedAt, at time.Time) time.Duration {
|
||||
if m.turnTimer != nil {
|
||||
return m.turnTimer.elapsed(at)
|
||||
}
|
||||
elapsed := at.Sub(fallbackStartedAt)
|
||||
if elapsed < 0 {
|
||||
return 0
|
||||
}
|
||||
return elapsed
|
||||
}
|
||||
+10
-1
@@ -813,7 +813,16 @@ func (m model) pickerOverlay(width int) string {
|
||||
// Hints live in the footer (a separator + faint keys), matching the /model
|
||||
// picker and the other bordered boxes.
|
||||
lines = append(lines, zeroTheme.line.Render(strings.Repeat("─", innerWidth)))
|
||||
lines = append(lines, zeroTheme.faint.Render("↑/↓ move Enter select Esc close"))
|
||||
footer := zeroTheme.faint.Render("↑/↓ move Enter select Esc close")
|
||||
if m.picker.kind == pickerSession {
|
||||
position := 0
|
||||
if len(m.picker.items) > 0 {
|
||||
position = clampInt(m.picker.selected, 0, len(m.picker.items)-1) + 1
|
||||
}
|
||||
count := zeroTheme.faint.Render(fmt.Sprintf("%d / %d", position, len(m.picker.items)))
|
||||
footer = joinHeaderLine(footer, count, innerWidth)
|
||||
}
|
||||
lines = append(lines, footer)
|
||||
return centerRenderedBlock(styledBlockFillTitle(overlayWidth, title, lines, zeroTheme.lineStrong, lipgloss.NewStyle()), width)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user