Compare commits

...
Author SHA1 Message Date
Gnanam 6174b20226 zeroline: remove the ZERO boot splash — launch straight to the empty state
The animated ZERO-wordmark boot splash showed for ~2s before the home; per the
request it's gone. Dropped the splash render branch + the m.booted flag/bootFrames
gating, and removed the now-dead RenderBoot + wordmark + newCanvasStyles (its last
users) and the --boot snapshot flag. The frame ticker stays (spinners); showSplash
stays (drives the empty state + placeholder). startup.go (default shell) untouched.
build/vet/test/windows/deadcode green.
2026-06-09 20:44:06 +05:30
Gnanam 4388807eee zeroline: home/empty state now matches the spec (chrome + centered 0)
The home was a separate full-screen Zen page (giant ZERO wordmark, no bars),
which doesn't match the spec: there the empty state lives INSIDE the chat layout
(title bar + status bar + composer) with a small 0 mark + tagline + model hint +
chips centered in the stream. Reworked to that: RenderChat renders an emptyState
in its body when there are no rows/stream; added zeroMark (small 0) + ChatData
.Chips/.ChipIndex; routed the live home + snapshot --page home through RenderChat;
removed the now-unused RenderHome + HomeData. emptyState is frame-exact (centers +
crops to h rows). Boot splash keeps the ZERO wordmark. build/vet/test/windows/
deadcode green.
2026-06-09 20:36:07 +05:30
Gnanam 28d55e3fd8 zeroline: remove the abandoned colorizeDiff/diffLine colorizer
The old unified-diff colorizer (colorizeDiff free func + method, diffLine,
diffMaxLines) was replaced by the bordered diff card (diffBody) in Phase 4 and has
no production caller (deadcode-confirmed); only its own tests referenced it.
Removed it + the tests that exclusively covered it (TestColorizeDiff*) + the
forceColor helper and its lipgloss/termenv imports. Kept looksLikeDiff (used by
the card) + TestLooksLikeDiff. Diff card (diffBody) + all snapshots unchanged;
build/vet/test/goldens green.
2026-06-09 20:12:17 +05:30
Gnanam 16554508f5 zeroline: remove the abandoned glamour markdown renderer
FIX 1 made assistant prose plain (renderSay), so the glamour markdown path is
dead: delete markdown.go (renderMarkdown/markdownRenderer/themeStyleConfig/sp/hex/
boolp + the renderer cache) and styles.renderAssistantMarkdown/renderAssistantPlain
in render.go (provably unreachable from main per deadcode). Removed the tests that
exclusively covered them (TestRenderMarkdown*, TestMarkdownRenderer*,
TestAssistantMarkdownClips*) + the now-orphaned glamour import. glamour is no
longer imported anywhere (can be go-mod-tidied separately). Kept looksLikeDiff +
its test and the plain/streaming assistant tests. build/vet/test/goldens green.
2026-06-09 20:09:11 +05:30
Gnanam 84ff761544 zeroline: remove provably-dead helpers orphaned by the redesign
staticcheck U1000-confirmed unused (no production OR test references): styles.block
(old bg-space caret, replaced by the ▌ glyph), styles.renderAssistant (dispatcher
— assistant is now plain renderSay), styles.renderCodeBlock + styles.codeLine (old
tool-output block, replaced by tool cards), resultSummary + plural + countNonEmpty
+ countBodyLines (old one-line result summaries, replaced by cards). No test
touched; U1000 clean afterward; build/vet/test/goldens green.
2026-06-09 20:03:17 +05:30
Gnanam a503b3a0ba zeroline: color the resolved-permission line by outcome (.perm-resolved)
The recorded permission line was always amber ⚠ regardless of decision; per the
spec it now reflects the outcome — allow/always green ✓, deny red ✗, pending ⚠
(parsed from the action word in the row text). +regression test.
2026-06-09 19:56:36 +05:30
Gnanam df6194544b zeroline: fix read_file card double line-numbering
The read_file tool already emits a 'File: … (N lines)' header and numbers each
line ('N | …'), but the read card re-numbered them — producing a doubled gutter
like '3   1 | import random' (seen in the live TUI). New readBody drops the
redundant File: header (the card head shows the path) and keeps the tool's own
numbering; numberedBody stays for the fallback/write_file body. Snapshot --tool
read now uses the real read_file output format. +regression test.
2026-06-09 19:51:34 +05:30
Gnanam 27b377186a zeroline: polish to match the spec — plain assistant prose, bordered chips, clean composer
FIX 1: assistant prose (.blk-say) renders as plain MUTED text (renderSay), no
panel/background — was routed through glamour (near-ink on a code block bg). Only
the final answer (.blk-final) keeps the accent rail + ink.
FIX 2: each home suggestion is its own bordered rounded box (.chip: panel bg,
accent ❯ + label); the selected chip uses a thick accent border (terminal analog
of the spec's accent hover border, visible without color). Was plain stacked rows.
FIX 3: composer shows the spec placeholder per surface (home 'describe a task for
zero…', chat 'message zero…') via a dynamic placeholder; the home composer box now
renders the accent ❯ prompt too. No %% artifact existed on this branch.
TDD: backtick-survival (plain say), chipBox bordered+selected, home separate
boxes, placeholder clean. No golden files exist (regenerate step is a no-op).
2026-06-09 19:39:09 +05:30
Gnanam 31cab0d836 zeroline: fix Phase 6-9 adversarial-review findings
- High: the sessions-drawer modal block no longer swallows Ctrl+C or a pending
  permission's keyboard (a/A/d) — both now take precedence over the drawer.
- High: ctrl+s on the home page no longer opens a drawer nothing renders (which
  froze input); it's a no-op until the chat page.
- Medium: composer clips the input + bounds input.Width (zeroline) so a long line
  can't push the run/stop button off the frame; drawer id/when row clipped to the
  panel width (long raw-timestamp fallback no longer overflows).
- Nit: stop button gets the spec panel background.
- Tests: Ctrl+C-through-drawer, no-drawer-on-home, composer-button-not-clipped.
Deferred (noted): TEXT|JSON toggle hidden at very narrow widths; drawer Enter-to-
resume action.
2026-06-09 18:41:30 +05:30
Gnanam c8bdbad5a1 zeroline: gofmt pre-existing test files in touched packages (keep gofmt -l clean) 2026-06-09 18:21:16 +05:30
Gnanam 5b685861f1 zeroline: complete --snapshot flag coverage (Phase 9)
Add --tool diff|read|bash|grep (single tool-card snapshot) and --frame N
(spinner frame); fix the --variant mapping to a direct theme index (0 ZERO …)
to match the flag help after the default change. Tests cover every snapshot
state (home/chat/json/sessions/perm/stream/tool).
2026-06-09 18:20:15 +05:30
Gnanam 9b1d02510c zeroline: sessions drawer slide-over (Phase 8)
ctrl+s opens a right-side sessions panel (Esc/ctrl+s closes, ↑/↓ navigate); it
dims the body and composites a bordered panel (header + session rows: accent id,
faint timestamp, title, model · turns; selected row gets an accent ▌ rail) on the
right, staying exactly h rows / w cells. Backed by the real sessions.Store
(store.List, most-recent first) with the sample list as a fallback. Snapshot
gains --sessions. Drawer is a top-level modal in Update (intercepts keys).
2026-06-09 18:17:15 +05:30
Gnanam d69396323d zeroline: TEXT/JSON view toggle (Phase 7)
ctrl+j toggles a jsonMode (model + ChatData.JSONMode), reflected by the title-bar
segmented toggle. In JSON mode RenderChat renders the run as a synthetic stream of
syntax-colored JSON events (.jsonwrap): a faint '$ zero run -p … --format json'
header + one event line per row (keys blue, strings ink, numbers amber, type
accent, punctuation faint), frame-exact. Snapshot gains --json. Pure view toggle;
the agent is unaffected.
2026-06-09 18:08:39 +05:30
Gnanam 30a9a060a5 zeroline: clear textinput prompt (composer owns ❯) + spec placeholder
The composer renders the ❯ prompt itself, so the textinput's own '❯ ' prompt
would double it in the live TUI; clear it for the zeroline skin and set the
spec placeholder 'describe a task for zero…'.
2026-06-09 18:03:59 +05:30
Gnanam 65139c7b04 zeroline: composer with accent prompt + run/stop affordance (Phase 6)
cmdRegion now renders an accent ❯ prompt + input with a right-aligned affordance:
lime 'run ↵' normally, red '■ stop' while the agent is working. Perm-mode key
hint updated to a/A/d. Snapshot chat input de-doubled (composer owns the prompt).
2026-06-09 18:03:26 +05:30
Gnanam 8c7c7e75ba zeroline: default the --snapshot theme to ZERO (variant 0) 2026-06-09 17:58:54 +05:30
Gnanam 1ded5ec253 zeroline: fix Phase 4.4 adversarial-review findings
- High: running-tool detection tested the dead 'toolcall' kind (rows are now
  'tool'), so a mid-run tool card stacked a spurious 'thinking…' line under it —
  now checks Kind=='tool' && Running.
- Medium: clip the tool-card head so a long (MCP) tool name/target can't wrap the
  head into extra rows.
- diffBody +N/-M stat now skips unified-diff ---/+++/@@ header lines; removed a
  dead sign variable.
- regression tests: running-tool suppresses thinking, long-name no head overflow,
  diff stat excludes headers.
Deferred (noted): real file line numbers, diff-head path/NEW tag, real bash exit
code (needs tools plumbing), Windows drive-letter grep paths, empty-id edge.
2026-06-09 17:57:26 +05:30
Gnanam f7f2c0c64a zeroline: permission card polish + clickable buttons (Phase 5)
Card: PERMISSION badge (amber fill) + risk on the top row, amber border, lime
allow button, keys a/A/d (capital A = always). Mouse: enable WithMouseCellMotion
in run.go + a tea.MouseMsg handler that hit-tests PermLayout and resolves the
permission on a left-click. Also fix grepBody to detab before clipping (a tab
counted as 1 but rendered wider, overflowing the card) — caught by the review.
Tests: PERMISSION render, mouse-click resolves allow, grep-tab no-overflow.
2026-06-09 17:54:23 +05:30
Gnanam 337bdd1a16 tools: edit_file emits a unified diff as its result (Phase 4.2)
edit_file's Output is now the focused old→new UnifiedDiff (the success summary
moves to Display.Summary), so the zeroline diff card — and the default skin —
show the actual change. apply_patch already carries a diff.
2026-06-09 17:44:32 +05:30
Gnanam 20a48e6af5 zeroline: bordered tool cards for diff/read/bash/grep (Phase 4.4)
Merge each tool call + its result into a single 'tool' card row (zerolineRows);
render a rounded ╭╮╰╯ card: head (accent icon + bold name + muted target +
spinner→✓/✗; accent border running, red border on error) + a per-tool body —
diff (+N/-M stat, numbered signed colored lines), read (numbered), bash ($ cmd +
output + exit foot), grep (path:line blue + count). Snapshot --page chat updated
to show the cards. (edit_file→UnifiedDiff result wiring is the next step.)
2026-06-09 17:41:38 +05:30
Gnanam 64f78875a9 zeroline: fix Phase 3 adversarial-review findings
- renderSay tone --muted (s.dim, was s.mute=faint); streaming + say now preserve
  line structure and re-clip long unbroken tokens (new wrapBlock helper).
- doneLine: spec glyph ■ (was ●), faint meta (s.mute), clipped to frame width
  (was a hardcoded 84 → spurious ellipsis on wide / backstop-only on narrow).
- system/deny notes render one styled line per input line (was firstLine-only,
  which silently truncated multi-line resume-session summaries); sys tone faint.
- regression tests: multi-line note, streaming newlines, long-token re-clip.
2026-06-09 17:31:07 +05:30
Gnanam 75342a4a31 tools: add UnifiedDiff + DiffStat (LCS line diff) for the zeroline diff card (Phase 4 pt 1)
Pure, dep-free LCS line diff emitting +/-/space-prefixed lines the zeroline
renderer parses; DiffStat counts +N/-M for the diff-card head. edit_file/
apply_patch wiring + the diff/read/bash/grep tool cards follow.
2026-06-09 17:25:18 +05:30
Gnanam eee8036c52 zeroline: redesign transcript blocks to the ZERO spec (Phase 3)
user → accent ❯ + ink (drops the 'you' label); assistant prose keeps markdown;
new final (accent rail + ink), done (●/red + faint meta), and sys/deny note
blocks; streaming renders muted say text + an accent ▌ caret (drops the ✦ zero
label and the bg-space caret).
2026-06-09 17:21:00 +05:30
Gnanam 0e1a5dde02 zeroline: redesign home/empty state to the ZERO spec (Phase 2)
ZERO wordmark (accent) + spec tagline + 'running zero against <model>' hint +
suggestion chips (accent ❯ + label, selected chip carries an accent ▌ rail).
Adds HomeData.Chips/ChipIndex + DefaultChips(); drops the recent-sessions list
and header stripe from home (sessions move to the drawer in Phase 8).
2026-06-09 17:15:28 +05:30
Gnanam aa3b610109 zeroline: redesign title + status bars to the ZERO spec (Phase 1 chrome)
Title bar: lime 0 brand mark + zero name + model menu label + cwd (hidden when
tight); TEXT|JSON segmented toggle + git branch on the right. Status bar: accent
dot + run mode, tok/s, tokens, ctx gauge, cost. Each bar stays exactly one row
and full width (bar() spacer). Run mode moves from the title to the status bar.
2026-06-09 17:05:50 +05:30
Gnanam 318e6937d5 zeroline: add ZERO design palette as the default theme (exact spec hex) 2026-06-09 16:56:16 +05:30
KRATOS 58e209d021 Sound / completion notifier: terminal bell + OSC-9 on turn completion & awaiting-input (#149)
* notify: dep-free terminal bell / OSC-9 completion notifier

* config: notify.mode + notify.focusMode (validated, default off)

* exec: --notify/--no-notify + completion notify on stderr

* tui: focus reporting + completion/awaiting-input notifications

* notify: fire on exec --use-spec completion; headless ignores focus; gate focus-reporting on enabled

* notify: validate --notify value, skip notify on empty ask_user, deterministic resolver tests

Addresses #149 review (CodeRabbit + @Vasanthdev2004):
- parseExecArgs rejects an invalid --notify value (only off/bell/notify/both),
  so a CLI typo errors early instead of silently running with no notification.
- TUI OnAskUser fires AwaitingInput only when len(request.Questions) > 0 — a
  request with no questions auto-resolves without prompting, so the bell/desktop
  notification was a false positive.
- notify resolver tests pass Env: map[string]string{} so Resolve does not fall
  back to os.Getenv (deterministic, not ambient-env dependent).
2026-06-09 14:34:24 +05:30
KRATOS 2ea36cf138 Deferred (lazy) tool loading: tool_search + auto threshold for large MCP tool sets (#148)
* Deferred (lazy) tool loading: tool_search + auto threshold for large MCP tool sets

Adds deferred tool loading so a large MCP tool set no longer sends every tool's full JSON schema each turn. When the number of visible MCP tools crosses a threshold (config tools.deferThreshold, default 10; 0 disables), their schemas are withheld and advertised compactly in a per-turn system-reminder; the model pulls a tool's full schema on demand via a new tool_search tool. Strictly additive: below the threshold behaviour is byte-identical to today. No new module dependencies.

- Eligibility: MCP tools opt in via an optional Deferred() bool interface (tools.IsDeferred); built-ins stay eager. No change to the core Tool interface.
- Formatting (internal/tools/deferred.go): compact one-liners wrapped in a <system-reminder> by BuildDeferredReminder; server label via MCPServerName().
- tool_search (internal/tools/tool_search.go): SideEffectNone/PermissionAllow/AdvertiseInAuto; select:Name1,Name2 (exact) or keyword (ranked); returns full schemas and signals the loop via Meta["load_tools"]. Operator-filter-aware (never lists tools hidden by --enabled-tools/--disabled-tools).
- Agent loop: partitionTools is the single source of truth; eligible counts only ToolVisible deferred tools; Meta["load_tools"] lifts to ToolResult.LoadedTools and unions into a per-run loaded-set advertised next turn. Reminder appended only to the per-turn request copy (never persisted), including both reactive-compaction retry paths. tool_search stays reachable when deferral is active (exempt from the EnabledTools allowlist).
- Config + wiring: tools.deferThreshold (default 10; 0 disables; negative rejected); tool_search registered and DeferThreshold threaded in exec and TUI, gated on the same visible-deferred count the loop uses.

Recreated on top of current main; resolves the SideEffectNone clash with #146 (escalate_model). Supersedes #147.

* deferred-tools: activate deferral only when tool_search is runnable; drop dead deferredTools()

Addresses #148 review:
- partitionTools gates 'active' on a registered, non-disabled, mode-advertised
  tool_search (mirrors the executeToolCall dispatch gate). When the loader is
  unavailable (explicitly disabled, missing, or not advertised e.g. spec-draft),
  fall back to the eager/inactive path instead of hiding deferred tools behind a
  loader the model can never call (the dead-end).
- Flip TestDisabledToolSearch... to assert the eager fallback; register a real
  tool_search in the active-path tests that previously relied on activation
  without a usable loader.
- Remove unused toolSearchTool.deferredTools().

* deferred-tools: disable deferral in exec when tool_search disabled; drop unused toolDefinitions shim

Further #148 review:
- exec computes one effective DeferThreshold, forced to 0 when tool_search is in
  --disabled-tools, and feeds it to both registerToolSearchIfEligible and
  agent.Options.DeferThreshold — so the registration gate and the loop partition
  gate agree, and a disabled loader never enables deferral.
- Remove the unused toolDefinitions shim (golangci-lint unused; no callers).
2026-06-09 12:20:22 +05:30
Anandan b83367e9d3 Add spec mode review flow (#145) 2026-06-08 21:07:57 +05:30
KRATOSandGnanam 69810d5ab0 Mid-run model escalation: escalate_model tool + --allow-escalation (#146)
* modelregistry: add UpgradeTargetID and Registry.UpgradeTarget escalation lookup

* modelregistry: seed escalation chains in catalog decoration

* modelregistry: verification gate for escalation upgrade map

* agent: add ModelSwitcher option and ToolResult.RequestedModel field

* agent: lift escalate_to_model meta into ToolResult.RequestedModel

* agent: perform at-most-one mid-run model switch in the turn loop

* tools: add SideEffectNone for control-only tools

* tools: add escalate_model control tool (metadata + args)

* cli: parse --allow-escalation exec flag

* cli: document --allow-escalation in exec help

* cli: register escalate_model tool under --allow-escalation

* cli: wire model switcher for exec escalation

* cli: guard byte-identical exec run without escalation flag

* cli: attribute post-switch usage to the escalated model

* tools: accept an explicit empty escalate_model reason

reason is an optional, informational-only arg, but the previous arg
parsing rejected an explicit reason:"" (non-empty-string check), failing
the whole escalation. Switch to stringArgWithEmpty so an absent OR empty
reason is accepted while a non-string value is still rejected. Add a test
covering both the empty and absent forms.

* agent: test (nil,nil) escalation switcher keeps original provider

When a ModelSwitcher returns (nil, nil) — no error, no replacement — the
loop's `else if newProvider != nil` guard already keeps the active
provider, so the run continues on the original model. Add an agent-layer
test that proves this (it panics if the nil guard is dropped), closing the
gap the adversarial review flagged.

Also note the deferred compaction limitation near the switch: the
compactor's context-window budget is fixed at run start and not updated on
a mid-run switch (needs a ModelSwitcher contract change to fix).

* cli: gate usage model attribution on the escalation flag and harden tests

FIX A (back-compat): the OnUsage closure wrote a "model" key into every
persisted EventUsage payload unconditionally, diverging a non-escalation
run from the byte-identical origin/main baseline. The model can only
change mid-run under --allow-escalation, so include the "model" key only
then. A new flag-OFF test asserts the persisted usage payload carries no
"model" key (mutation-proven).

Test hardening for the CLI escalation boundary:
- Wire test: each newProvider build returns a DISTINCT provider with its
  own turn counter; assert the source provider served exactly the
  escalation turn and the escalated provider served exactly the
  post-switch answer. Fails if `provider = newProvider` is dropped.
- Mixed usage attribution: emit usage pre- and post-switch and assert the
  first attributes to the original model, the second to the escalated
  target (the loop's ordering guarantee).
- Switch-error path: a failed rebuild is non-fatal and post-error usage
  stays attributed to the original model (currentModel unchanged).
- Top-tier decline end-to-end: a flag-on run that escalates while already
  top-tier performs no switch (newProvider built exactly once) and
  succeeds.

* modelregistry: validate UpgradeTargetID resolves at registry construction

* cli: update currentModel only when the switched provider is non-nil

* sandbox: make 'none' a first-class side effect (control-only tools)

---------

Co-authored-by: Gnanam <[email protected]>
2026-06-08 20:50:32 +05:30
79 changed files with 8882 additions and 924 deletions

No files matched your search

+768
View File
@@ -0,0 +1,768 @@
package agent
import (
"context"
"errors"
"reflect"
"sort"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestOptionsDeferThresholdFieldExists(t *testing.T) {
options := Options{DeferThreshold: 10}
if options.DeferThreshold != 10 {
t.Fatalf("expected DeferThreshold 10, got %d", options.DeferThreshold)
}
}
func TestToolResultLoadedToolsField(t *testing.T) {
result := ToolResult{LoadedTools: []string{"Alpha", "Beta"}}
if len(result.LoadedTools) != 2 || result.LoadedTools[0] != "Alpha" || result.LoadedTools[1] != "Beta" {
t.Fatalf("expected LoadedTools [Alpha Beta], got %#v", result.LoadedTools)
}
// Default zero value is nil for an ordinary result.
if (ToolResult{}).LoadedTools != nil {
t.Fatalf("expected nil LoadedTools by default")
}
}
// loadSignalTool returns Meta["load_tools"] like tool_search does, so we can
// assert executeToolCall lifts it into ToolResult.LoadedTools.
type loadSignalTool struct{ value string }
func (t loadSignalTool) Name() string { return "load_signal" }
func (t loadSignalTool) Description() string { return "emits a load_tools signal" }
func (t loadSignalTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (t loadSignalTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (t loadSignalTool) Run(_ context.Context, _ map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func (t loadSignalTool) RunWithOptions(_ context.Context, _ map[string]any, _ tools.RunOptions) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "ok", Meta: map[string]string{"load_tools": t.value}}
}
func TestExecuteToolCallLiftsLoadTools(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(loadSignalTool{value: " Alpha , Beta ,, "})
result, abortErr := executeToolCall(
context.Background(),
registry,
ToolCall{ID: "c1", Name: "load_signal", Arguments: ""},
PermissionModeAuto,
Options{},
)
if abortErr != nil {
t.Fatalf("unexpected abort error: %v", abortErr)
}
want := []string{"Alpha", "Beta"}
if len(result.LoadedTools) != len(want) {
t.Fatalf("expected LoadedTools %#v, got %#v", want, result.LoadedTools)
}
for i := range want {
if result.LoadedTools[i] != want[i] {
t.Fatalf("expected LoadedTools %#v, got %#v", want, result.LoadedTools)
}
}
}
func TestExecuteToolCallNoLoadToolsMetaLeavesNil(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(secretEmittingTool{output: "plain"})
result, abortErr := executeToolCall(
context.Background(),
registry,
ToolCall{ID: "c1", Name: "leak", Arguments: ""},
PermissionModeAuto,
Options{},
)
if abortErr != nil {
t.Fatalf("unexpected abort error: %v", abortErr)
}
if result.LoadedTools != nil {
t.Fatalf("expected nil LoadedTools for a tool with no load_tools meta, got %#v", result.LoadedTools)
}
}
// fakeDeferredTool is deferred-eligible (implements Deferred() bool) like an MCP
// tool wrapper, so partitionTools counts and (when active) hides it.
type fakeDeferredTool struct {
name string
desc string
}
func (t fakeDeferredTool) Name() string { return t.name }
func (t fakeDeferredTool) Description() string { return t.desc }
func (t fakeDeferredTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (t fakeDeferredTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (t fakeDeferredTool) Run(_ context.Context, _ map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func (t fakeDeferredTool) Deferred() bool { return true }
// fakeToolSearchTool stands in for component D's tool_search (a non-deferred
// builtin) so the inactive path can assert it is dropped.
type fakeToolSearchTool struct{}
func (fakeToolSearchTool) Name() string { return "tool_search" }
func (fakeToolSearchTool) Description() string { return "load deferred tool schemas" }
func (fakeToolSearchTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (fakeToolSearchTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectNone, Permission: tools.PermissionAllow, AdvertiseInAuto: true}
}
func (fakeToolSearchTool) Run(_ context.Context, _ map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func TestPartitionToolsInactiveIsByteIdenticalAndDropsToolSearch(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
registry.Register(fakeDeferredTool{name: "mcp__srv__a", desc: "tool a"})
registry.Register(fakeToolSearchTool{})
options := Options{DeferThreshold: 0} // 0 => deferral disabled => inactive path.
// DeferThreshold 0 => deferral disabled => inactive path.
exposed, reminder := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
if reminder != "" {
t.Fatalf("expected empty reminder on inactive path, got %q", reminder)
}
// Strong byte-identity assertion: build the reference the LEGACY way (the old
// toolDefinitions construction — every visible/advertised tool's full schema,
// EXCEPT tool_search, alpha-sorted by name) and require an exact DeepEqual. This
// pins that the inactive partition produces the pre-deferral output verbatim,
// not merely the same set of names.
reference := legacyToolDefinitions(registry, PermissionModeAuto, options)
if !reflect.DeepEqual(exposed, reference) {
t.Fatalf("inactive partition not byte-identical to legacy toolDefinitions:\n got %#v\nwant %#v", exposed, reference)
}
// Belt-and-suspenders: tool_search is dropped, the deferred tool keeps its full
// schema, and only the expected names are present.
for _, def := range exposed {
if def.Name == "tool_search" {
t.Fatalf("tool_search must be dropped on inactive path, got %#v", exposed)
}
}
wantNames := map[string]bool{"read_file": true, "mcp__srv__a": true}
if len(exposed) != len(wantNames) {
t.Fatalf("expected %d exposed tools, got %d: %#v", len(wantNames), len(exposed), exposed)
}
for _, def := range exposed {
if !wantNames[def.Name] {
t.Fatalf("unexpected exposed tool %q", def.Name)
}
if def.Name == "mcp__srv__a" && def.Parameters["type"] != "object" {
t.Fatalf("expected full schema for deferred tool on inactive path, got %#v", def.Parameters)
}
}
}
// legacyToolDefinitions reconstructs the PRE-deferral tool-list builder: every
// tool that is visible (passes the operator filters) and advertised for the mode,
// EXCEPT tool_search, rendered with its full schema and alpha-sorted by name. It
// is the byte-identity reference for the inactive partition path.
func legacyToolDefinitions(registry *tools.Registry, permissionMode PermissionMode, options Options) []zeroruntime.ToolDefinition {
definitions := make([]zeroruntime.ToolDefinition, 0)
for _, tool := range registry.All() {
if !ToolVisible(tool, permissionMode, options.EnabledTools, options.DisabledTools) {
continue
}
if tool.Name() == tools.ToolSearchToolName {
continue
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: tool.Name(),
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
})
}
sort.Slice(definitions, func(left, right int) bool {
return definitions[left].Name < definitions[right].Name
})
return definitions
}
// Below-threshold-but-eligible (count < threshold) is also inactive.
func TestPartitionToolsBelowThresholdInactive(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(fakeDeferredTool{name: "mcp__srv__a", desc: "a"})
registry.Register(fakeDeferredTool{name: "mcp__srv__b", desc: "b"})
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 10}, map[string]bool{})
if reminder != "" {
t.Fatalf("expected empty reminder below threshold, got %q", reminder)
}
if len(exposed) != 2 {
t.Fatalf("expected both deferred tools exposed below threshold, got %#v", exposed)
}
}
// FIX 6(a): a deferred tool removed by DisabledTools must NOT count toward the
// eligible total. With N deferred registered and threshold == N, disabling one
// drops the surviving eligible count to N-1 < threshold, so the partition takes
// the INACTIVE path (empty reminder, all VISIBLE tools exposed with full schemas,
// the disabled one filtered out entirely).
func TestPartitionToolsDisabledDeferredDropsBelowThresholdInactive(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{
DeferThreshold: 2,
DisabledTools: []string{"mcp__srv__beta"},
}, map[string]bool{})
// Eligible drops to 1 (< threshold 2) => inactive: empty reminder.
if reminder != "" {
t.Fatalf("expected inactive path (empty reminder) when a disable drops eligible below threshold, got %q", reminder)
}
// Only the surviving visible tool is exposed, with its FULL schema; the disabled
// tool is filtered out entirely.
if len(exposed) != 1 || exposed[0].Name != "mcp__srv__alpha" {
t.Fatalf("expected only mcp__srv__alpha exposed on inactive path, got %#v", exposed)
}
if exposed[0].Parameters["type"] != "object" {
t.Fatalf("surviving deferred tool must keep its full schema on inactive path, got %#v", exposed[0].Parameters)
}
}
// FIX 6(b): with deferral ACTIVE, a DisabledTools-hidden deferred tool must never
// appear in the reminder NOR be exposed — it is filtered out before the partition
// even considers it.
func TestPartitionToolsActiveExcludesDisabledDeferredFromReminderAndExposed(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
registry.Register(fakeDeferredTool{name: "mcp__srv__gamma", desc: "gamma"})
registry.Register(fakeToolSearchTool{}) // usable loader => deferral can activate
// 3 deferred, disable beta => 2 surviving eligible, threshold 2 => active.
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{
DeferThreshold: 2,
DisabledTools: []string{"mcp__srv__beta"},
}, map[string]bool{})
if reminder == "" {
t.Fatalf("expected active path with a non-empty reminder for the unloaded tools")
}
if strings.Contains(reminder, "mcp__srv__beta") {
t.Fatalf("disabled deferred tool must not appear in the reminder, got %q", reminder)
}
for _, def := range exposed {
if def.Name == "mcp__srv__beta" {
t.Fatalf("disabled deferred tool must not be exposed, got %#v", exposed)
}
}
// The two surviving deferred tools are hidden (unloaded) and named in the reminder.
if !strings.Contains(reminder, "mcp__srv__alpha") || !strings.Contains(reminder, "mcp__srv__gamma") {
t.Fatalf("reminder must list the surviving unloaded deferred tools, got %q", reminder)
}
}
func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root)) // non-deferred builtin
registry.Register(fakeToolSearchTool{}) // non-deferred, must stay exposed
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
loaded := map[string]bool{"mcp__srv__alpha": true}
// 2 eligible deferred tools, threshold 2 => active.
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, loaded)
exposedNames := map[string]bool{}
for _, def := range exposed {
exposedNames[def.Name] = true
}
if !exposedNames["read_file"] {
t.Fatalf("expected builtin read_file exposed, got %#v", exposed)
}
if !exposedNames["tool_search"] {
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
}
if !exposedNames["mcp__srv__alpha"] {
t.Fatalf("expected loaded deferred tool exposed, got %#v", exposed)
}
if exposedNames["mcp__srv__beta"] {
t.Fatalf("unloaded deferred tool must be hidden from exposed, got %#v", exposed)
}
if reminder == "" {
t.Fatalf("expected a non-empty reminder for the hidden tool")
}
if !strings.Contains(reminder, "mcp__srv__beta") {
t.Fatalf("expected reminder to list the hidden tool, got %q", reminder)
}
if strings.Contains(reminder, "mcp__srv__alpha") {
t.Fatalf("loaded tool must not appear in the reminder, got %q", reminder)
}
}
func TestPartitionToolsActiveNothingHiddenEmptyReminder(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
registry.Register(fakeToolSearchTool{}) // usable loader => deferral can activate
loaded := map[string]bool{"mcp__srv__alpha": true, "mcp__srv__beta": true}
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, loaded)
exposedNames := map[string]bool{}
for _, def := range exposed {
exposedNames[def.Name] = true
}
if !exposedNames["mcp__srv__alpha"] || !exposedNames["mcp__srv__beta"] {
t.Fatalf("expected both loaded deferred tools exposed, got %#v", exposed)
}
if !exposedNames["tool_search"] {
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
}
// BuildDeferredReminder returns "" for no hidden lines.
if reminder != "" {
t.Fatalf("expected empty reminder when nothing is hidden, got %q", reminder)
}
}
func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
registry := tools.NewRegistry()
// load_signal asks the loop to load mcp__srv__alpha next turn.
registry.Register(loadSignalTool{value: "mcp__srv__alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
// A real, usable tool_search must be registered for deferral to activate
// (otherwise the loop falls back to eager so it never strands the loader).
registry.Register(tools.NewToolSearchTool(registry))
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{ // turn 1: call load_signal
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "load_signal"},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventDone},
},
{ // turn 2: final answer
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
}}
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
DeferThreshold: 2, // 2 deferred tools => active
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) != 2 {
t.Fatalf("expected two turns, got %d", len(provider.requests))
}
// Turn 1: alpha is hidden (not advertised) and the reminder is a trailing user msg.
turn1Tools := map[string]bool{}
for _, def := range provider.requests[0].Tools {
turn1Tools[def.Name] = true
}
if turn1Tools["mcp__srv__alpha"] {
t.Fatalf("turn 1 must not advertise the not-yet-loaded deferred tool")
}
last1 := provider.requests[0].Messages[len(provider.requests[0].Messages)-1]
if last1.Role != zeroruntime.MessageRoleUser || !strings.Contains(last1.Content, "tool_search") {
t.Fatalf("turn 1 request must end with the deferred-tools reminder, got role=%s content=%q", last1.Role, last1.Content)
}
// Turn 2: alpha is now loaded and advertised with a full schema.
turn2Tools := map[string]bool{}
for _, def := range provider.requests[1].Tools {
turn2Tools[def.Name] = true
}
if !turn2Tools["mcp__srv__alpha"] {
t.Fatalf("turn 2 must advertise the loaded deferred tool, got %#v", provider.requests[1].Tools)
}
if turn2Tools["mcp__srv__beta"] {
t.Fatalf("beta was never loaded; it must stay hidden in turn 2")
}
// The reminder must NOT persist into the returned message history.
for _, m := range result.Messages {
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
}
}
}
// TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder drives a mid-run
// context-limit error that triggers reactive compaction+retry while deferral is
// ACTIVE and a deferred tool is already loaded. The retried turn must NOT be
// degraded to the empty-loaded/no-reminder state: it must still advertise the
// loaded tool's FULL schema and carry the <system-reminder> for the hidden tool.
func TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testing.T) {
registry := tools.NewRegistry()
// load_signal asks the loop to load mcp__srv__alpha next turn.
registry.Register(loadSignalTool{value: "mcp__srv__alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
// A real, usable tool_search must be registered for deferral to activate
// (otherwise the loop falls back to eager so it never strands the loader).
registry.Register(tools.NewToolSearchTool(registry))
// Request indices (mockProvider plays one turn per request, in order):
// 0: turn 1 — calls load_signal (loads mcp__srv__alpha for later turns)
// 1: turn 2 — emits a context-limit error MID-stream -> reactive recover
// 2: the summarize call inside Compact (must return non-empty text)
// 3: the RETRY request rebuilt after compaction — asserted below
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{ // turn 1: call load_signal
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "load_signal"},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventDone},
},
{ // turn 2: mid-stream context-limit error -> reactive compaction
{Type: zeroruntime.StreamEventError, Error: "prompt is too long: 250000 tokens > 200000 maximum"},
},
{ // summarize call inside Compact
{Type: zeroruntime.StreamEventText, Content: "SUMMARY"},
{Type: zeroruntime.StreamEventDone},
},
{ // retry of turn 2 after compaction: final answer
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
}}
// A large user prompt so the elided middle is big enough that reactive
// compaction actually shrinks the history (recover only retries when it can
// shrink). ContextWindow is set high enough that PROACTIVE compaction (which
// fires at 0.8 * window at the top of a turn) does NOT trigger first — so the
// only summarize call is the reactive one, keeping the request sequence
// predictable: turn1, errored turn2, summarize, retry.
bigPrompt := strings.Repeat("work on this task. ", 2000)
result, err := Run(context.Background(), bigPrompt, provider, Options{
Registry: registry,
DeferThreshold: 2, // 2 deferred tools => active
ContextWindow: 1_000_000,
CompactionPreserveLast: 2,
})
if err != nil {
t.Fatalf("run returned error: %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer from the retried turn, got %q", result.FinalAnswer)
}
// 4 provider calls: turn1, errored turn2, summarize, retry.
if len(provider.requests) != 4 {
t.Fatalf("expected 4 provider requests (turn1, errored turn2, summarize, retry), got %d", len(provider.requests))
}
retry := provider.requests[3]
// The retry must advertise the loaded deferred tool with its FULL schema —
// NOT re-hide it as the empty-loaded partition would.
var alpha *zeroruntime.ToolDefinition
for i := range retry.Tools {
if retry.Tools[i].Name == "mcp__srv__alpha" {
alpha = &retry.Tools[i]
}
if retry.Tools[i].Name == "mcp__srv__beta" {
t.Fatalf("retry must keep the never-loaded deferred tool hidden, got %#v", retry.Tools)
}
}
if alpha == nil {
t.Fatalf("retry must still advertise the loaded deferred tool mcp__srv__alpha, got %#v", retry.Tools)
}
if alpha.Parameters["type"] != "object" {
t.Fatalf("retry must advertise the loaded tool's FULL schema, got %#v", alpha.Parameters)
}
// The retry must carry the deferred-tools reminder as its trailing user
// message (the hidden tool is mcp__srv__beta) — not the no-reminder state.
last := retry.Messages[len(retry.Messages)-1]
if last.Role != zeroruntime.MessageRoleUser || !strings.Contains(last.Content, "tool_search") {
t.Fatalf("retry request must end with the deferred-tools reminder, got role=%s content=%q", last.Role, last.Content)
}
if !strings.Contains(last.Content, "mcp__srv__beta") {
t.Fatalf("retry reminder must list the still-hidden tool mcp__srv__beta, got %q", last.Content)
}
if strings.Contains(last.Content, "mcp__srv__alpha") {
t.Fatalf("loaded tool must not appear in the retry reminder, got %q", last.Content)
}
// The reminder must NOT persist into the returned message history, even on
// the reactive-retry path.
for _, m := range result.Messages {
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
}
}
}
// connectErrorProvider returns a connect-time error (StreamCompletion itself
// returns a non-nil error) on the request at index errAtRequest, exercising the
// FIRST reactive-recovery block in the loop (loop.go:99). Every other request
// streams the corresponding turn's events. It mirrors mockProvider's recording so
// the rebuilt retry request can be asserted.
type connectErrorProvider struct {
turns [][]zeroruntime.StreamEvent
errAtRequest int
errText string
requests []zeroruntime.CompletionRequest
}
func (provider *connectErrorProvider) StreamCompletion(_ context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
provider.requests = append(provider.requests, request)
index := len(provider.requests) - 1
if index == provider.errAtRequest {
return nil, errors.New(provider.errText)
}
events := []zeroruntime.StreamEvent{{Type: zeroruntime.StreamEventDone}}
if index < len(provider.turns) {
events = provider.turns[index]
}
ch := make(chan zeroruntime.StreamEvent, len(events))
for _, event := range events {
ch <- event
}
close(ch)
return ch, nil
}
// TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder mirrors
// TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder but drives the
// CONNECT-TIME error path: StreamCompletion itself returns a non-nil
// context-limit error (rather than a mid-stream StreamEventError). With deferral
// ACTIVE and a deferred tool already loaded, the rebuilt retry request must still
// advertise the loaded tool's FULL schema AND carry the deferred-tools reminder —
// i.e. the first reactive block reuses exposed/reminder, not the empty-loaded
// partition.
func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(loadSignalTool{value: "mcp__srv__alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
// A real, usable tool_search must be registered for deferral to activate
// (otherwise the loop falls back to eager so it never strands the loader).
registry.Register(tools.NewToolSearchTool(registry))
// Request indices:
// 0: turn 1 — calls load_signal (loads mcp__srv__alpha for later turns)
// 1: turn 2 — StreamCompletion returns a connect-time context-limit error
// 2: the summarize call inside Compact (must return non-empty text)
// 3: the RETRY request rebuilt after compaction — asserted below
provider := &connectErrorProvider{
errAtRequest: 1,
errText: "prompt is too long: 250000 tokens > 200000 maximum",
turns: [][]zeroruntime.StreamEvent{
{ // turn 1: call load_signal
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "load_signal"},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventDone},
},
nil, // index 1: replaced by the connect-time error
{ // index 2: summarize call inside Compact
{Type: zeroruntime.StreamEventText, Content: "SUMMARY"},
{Type: zeroruntime.StreamEventDone},
},
{ // index 3: retry of turn 2 after compaction: final answer
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
bigPrompt := strings.Repeat("work on this task. ", 2000)
result, err := Run(context.Background(), bigPrompt, provider, Options{
Registry: registry,
DeferThreshold: 2, // 2 deferred tools => active
ContextWindow: 1_000_000,
CompactionPreserveLast: 2,
})
if err != nil {
t.Fatalf("run returned error: %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer from the retried turn, got %q", result.FinalAnswer)
}
// 4 provider calls: turn1, errored-connect turn2, summarize, retry.
if len(provider.requests) != 4 {
t.Fatalf("expected 4 provider requests (turn1, errored turn2, summarize, retry), got %d", len(provider.requests))
}
retry := provider.requests[3]
// The retry must advertise the loaded deferred tool with its FULL schema and
// keep the never-loaded one hidden.
var alpha *zeroruntime.ToolDefinition
for i := range retry.Tools {
if retry.Tools[i].Name == "mcp__srv__alpha" {
alpha = &retry.Tools[i]
}
if retry.Tools[i].Name == "mcp__srv__beta" {
t.Fatalf("retry must keep the never-loaded deferred tool hidden, got %#v", retry.Tools)
}
}
if alpha == nil {
t.Fatalf("retry must still advertise the loaded deferred tool mcp__srv__alpha, got %#v", retry.Tools)
}
if alpha.Parameters["type"] != "object" {
t.Fatalf("retry must advertise the loaded tool's FULL schema, got %#v", alpha.Parameters)
}
// The retry must carry the deferred-tools reminder as its trailing user message.
last := retry.Messages[len(retry.Messages)-1]
if last.Role != zeroruntime.MessageRoleUser || !strings.Contains(last.Content, "tool_search") {
t.Fatalf("retry request must end with the deferred-tools reminder, got role=%s content=%q", last.Role, last.Content)
}
if !strings.Contains(last.Content, "mcp__srv__beta") {
t.Fatalf("retry reminder must list the still-hidden tool mcp__srv__beta, got %q", last.Content)
}
if strings.Contains(last.Content, "mcp__srv__alpha") {
t.Fatalf("loaded tool must not appear in the retry reminder, got %q", last.Content)
}
// The reminder must NOT persist into the returned message history.
for _, m := range result.Messages {
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
}
}
}
// TestAllowlistedDeferredToolsKeepToolSearchReachable is the FIX 1+2 dead-end
// regression: N deferred tools are registered alongside tool_search, the operator
// allowlists ONLY the N deferred names (NOT tool_search), and DeferThreshold == N.
// Deferral must ACTIVATE, the partition must STILL expose tool_search (the gateway
// to the allowlisted tools), and a tool_search call must be DISPATCH-ALLOWED — so
// the reminder never points the model at an unreachable tool.
func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
registry := tools.NewRegistry()
deferredNames := []string{"mcp__srv__alpha", "mcp__srv__beta"}
for _, name := range deferredNames {
registry.Register(fakeDeferredTool{name: name, desc: name + " tool"})
}
// The REAL tool_search (so partitionTools can look it up by name and dispatch
// can run it through registry.RunWithOptions).
registry.Register(tools.NewToolSearchTool(registry))
options := Options{
DeferThreshold: len(deferredNames), // threshold == N => active
// Allowlist the deferred tools but NOT tool_search.
EnabledTools: append([]string{}, deferredNames...),
}
exposed, reminder := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
// Deferral active => non-empty reminder advertising the hidden deferred tools.
if reminder == "" {
t.Fatalf("expected deferral active (non-empty reminder) at threshold N, got empty")
}
if !strings.Contains(reminder, "tool_search") {
t.Fatalf("reminder must instruct the model to call tool_search, got %q", reminder)
}
// FIX 2(a): tool_search is exposed even though the allowlist omits it.
exposedNames := map[string]bool{}
for _, def := range exposed {
exposedNames[def.Name] = true
}
if !exposedNames["tool_search"] {
t.Fatalf("tool_search must be exposed on the active path despite the allowlist omitting it, got %#v", exposed)
}
// The allowlisted-but-unloaded deferred tools are hidden (not exposed).
for _, name := range deferredNames {
if exposedNames[name] {
t.Fatalf("unloaded deferred tool %q must be hidden when active, got %#v", name, exposed)
}
}
// FIX 2(b): a tool_search call must be DISPATCH-ALLOWED (not rejected by the
// allowlist that omits it). It runs through the registry and reports a load.
result, abortErr := executeToolCall(
context.Background(),
registry,
ToolCall{ID: "c1", Name: "tool_search", Arguments: `{"query":"select:mcp__srv__alpha"}`},
PermissionModeAuto,
options,
)
if abortErr != nil {
t.Fatalf("unexpected abort error: %v", abortErr)
}
if result.Status != tools.StatusOK {
t.Fatalf("tool_search call was rejected: status=%s output=%q", result.Status, result.Output)
}
if result.DenialReason == DenialFiltered {
t.Fatalf("tool_search must not be denied by the allowlist, got DenialFiltered: %q", result.Output)
}
if len(result.LoadedTools) != 1 || result.LoadedTools[0] != "mcp__srv__alpha" {
t.Fatalf("tool_search call must load mcp__srv__alpha, got LoadedTools=%#v", result.LoadedTools)
}
}
// TestDisabledToolSearchStaysHiddenAndRejectedWhenActive verifies an explicit
// DisabledTools entry for tool_search is STILL honored on the active path: the
// loader is not exposed and a call to it is rejected (FIX 2 exempts the allowlist
// only, never the denylist).
func TestDisabledToolSearchFallsBackToEager(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
registry.Register(tools.NewToolSearchTool(registry))
options := Options{
DeferThreshold: 2,
DisabledTools: []string{"tool_search"},
}
// tool_search is explicitly disabled, so it can never run. Deferral must NOT
// activate — otherwise the loop would hide the deferred tools behind a loader
// the dispatch gate rejects (an inescapable dead-end). Expect the eager /
// inactive fallback: every deferred tool exposed with its full schema, no
// tool_search definition, and an empty reminder.
exposed, reminder := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
if reminder != "" {
t.Fatalf("inactive fallback must emit no reminder, got %q", reminder)
}
exposedNames := make(map[string]bool, len(exposed))
for _, def := range exposed {
exposedNames[def.Name] = true
}
if exposedNames["tool_search"] {
t.Fatalf("tool_search must not be advertised when disabled, got %#v", exposed)
}
if !exposedNames["mcp__srv__alpha"] || !exposedNames["mcp__srv__beta"] {
t.Fatalf("deferred tools must be exposed eagerly when deferral is inactive, got %#v", exposed)
}
// The deferred tools themselves stay directly callable (not stranded behind a
// disabled loader).
result, abortErr := executeToolCall(
context.Background(),
registry,
ToolCall{ID: "c1", Name: "mcp__srv__alpha", Arguments: `{}`},
PermissionModeAuto,
options,
)
if abortErr != nil {
t.Fatalf("unexpected abort error: %v", abortErr)
}
if result.Status != tools.StatusOK {
t.Fatalf("deferred tool must be callable under eager fallback, got status=%s output=%q", result.Status, result.Output)
}
}
+318
View File
@@ -0,0 +1,318 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// TestOptionsModelSwitcherFieldExists asserts the escalation hook is an
// assignable field on Options with the agreed signature, and that nil is its
// zero value (escalation disabled by default).
func TestOptionsModelSwitcherFieldExists(t *testing.T) {
var options Options
if options.ModelSwitcher != nil {
t.Fatalf("expected ModelSwitcher to default to nil, got non-nil")
}
options.ModelSwitcher = func(_ context.Context, modelID string) (Provider, error) {
return &mockProvider{}, nil
}
provider, err := options.ModelSwitcher(context.Background(), "claude-sonnet-4.5")
if err != nil {
t.Fatal(err)
}
if _, ok := provider.(*mockProvider); !ok {
t.Fatalf("expected ModelSwitcher to return a Provider, got %T", provider)
}
}
// TestToolResultRequestedModelFieldExists asserts the loop-level escalation
// signal field is present on ToolResult and empty for a normal result.
func TestToolResultRequestedModelFieldExists(t *testing.T) {
var result ToolResult
if result.RequestedModel != "" {
t.Fatalf("expected RequestedModel to default to empty, got %q", result.RequestedModel)
}
result.RequestedModel = "claude-opus-4.1"
if result.RequestedModel != "claude-opus-4.1" {
t.Fatalf("expected RequestedModel to round-trip, got %q", result.RequestedModel)
}
// Keep the zeroruntime import load-bearing so the file compiles standalone.
_ = zeroruntime.MessageRoleAssistant
}
// escalatingTool is a registered fake tool that returns the escalation signal
// in result Meta (mirroring the real escalate_model tool's contract), used to
// drive the loop-level switch in tests without depending on the tools package.
type escalatingTool struct {
target string
}
func (t escalatingTool) Name() string { return "escalate" }
func (t escalatingTool) Description() string { return "requests a model switch for testing" }
func (t escalatingTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (t escalatingTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (t escalatingTool) Run(_ context.Context, _ map[string]any) tools.Result {
meta := map[string]string{}
if t.target != "" {
meta["escalate_to_model"] = t.target
}
return tools.Result{Status: tools.StatusOK, Output: "escalating", Meta: meta}
}
// TestExecuteToolCallLiftsEscalationMeta verifies executeToolCall promotes the
// tool's Meta["escalate_to_model"] into ToolResult.RequestedModel.
func TestExecuteToolCallLiftsEscalationMeta(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
result, abortErr := executeToolCall(
context.Background(),
registry,
ToolCall{ID: "c1", Name: "escalate"},
PermissionModeAuto,
Options{},
)
if abortErr != nil {
t.Fatal(abortErr)
}
if result.RequestedModel != "claude-opus-4.1" {
t.Fatalf("expected RequestedModel lifted from meta, got %q", result.RequestedModel)
}
}
// TestExecuteToolCallNoEscalationMetaLeavesRequestedModelEmpty verifies a normal
// tool result (no escalate_to_model meta) leaves RequestedModel empty.
func TestExecuteToolCallNoEscalationMetaLeavesRequestedModelEmpty(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: ""})
result, abortErr := executeToolCall(
context.Background(),
registry,
ToolCall{ID: "c1", Name: "escalate"},
PermissionModeAuto,
Options{},
)
if abortErr != nil {
t.Fatal(abortErr)
}
if result.RequestedModel != "" {
t.Fatalf("expected empty RequestedModel without escalation meta, got %q", result.RequestedModel)
}
}
// escalateThenAnswerTurns builds a two-turn provider script: turn 1 calls the
// escalate tool, turn 2 returns a final answer. Reused by the switch tests.
func escalateThenAnswerTurns(answer string) [][]zeroruntime.StreamEvent {
return [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "escalate"},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: answer},
{Type: zeroruntime.StreamEventDone},
},
}
}
// TestRunSwitchesProviderOnEscalationRequest verifies that when a tool requests
// a model and a ModelSwitcher is wired, the loop swaps the active provider (the
// NEXT turn streams from the new provider) and updates options.Model.
func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
firstProvider := &mockProvider{turns: escalateThenAnswerTurns("done")}
secondProvider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventText, Content: "answered on the upgraded model"},
{Type: zeroruntime.StreamEventDone},
},
}}
var switchedTo string
switchCount := 0
result, err := Run(context.Background(), "go", firstProvider, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
ModelSwitcher: func(_ context.Context, modelID string) (Provider, error) {
switchCount++
switchedTo = modelID
return secondProvider, nil
},
})
if err != nil {
t.Fatal(err)
}
if switchCount != 1 {
t.Fatalf("expected exactly one switch, got %d", switchCount)
}
if switchedTo != "claude-opus-4.1" {
t.Fatalf("expected switch to claude-opus-4.1, got %q", switchedTo)
}
// The first provider only handled the escalation turn (1 request); the second
// provider handled the answer turn (1 request) — proving the swap took effect.
if len(firstProvider.requests) != 1 {
t.Fatalf("expected first provider to handle exactly the escalation turn, got %d requests", len(firstProvider.requests))
}
if len(secondProvider.requests) != 1 {
t.Fatalf("expected second provider to handle the post-switch turn, got %d requests", len(secondProvider.requests))
}
if result.FinalAnswer != "answered on the upgraded model" {
t.Fatalf("expected final answer from the swapped provider, got %q", result.FinalAnswer)
}
}
// TestRunIgnoresEscalationWhenSwitcherNil verifies that without a ModelSwitcher
// the escalation request is ignored: the original provider serves every turn.
func TestRunIgnoresEscalationWhenSwitcherNil(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
provider := &mockProvider{turns: escalateThenAnswerTurns("done on original")}
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
// ModelSwitcher intentionally nil.
})
if err != nil {
t.Fatal(err)
}
// Both turns (escalate + answer) must run on the single original provider.
if len(provider.requests) != 2 {
t.Fatalf("expected the original provider to serve both turns, got %d requests", len(provider.requests))
}
if result.FinalAnswer != "done on original" {
t.Fatalf("expected final answer from the original provider, got %q", result.FinalAnswer)
}
}
// TestRunEscalationSwitcherErrorIsNonFatal verifies a ModelSwitcher error does
// not abort the run: the loop continues on the current provider and a note is
// recorded in the transcript.
func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
provider := &mockProvider{turns: escalateThenAnswerTurns("recovered")}
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
return nil, errors.New("provider build blew up")
},
})
if err != nil {
t.Fatalf("expected escalation error to be non-fatal, got %v", err)
}
// The run continues on the same provider through the answer turn.
if len(provider.requests) != 2 {
t.Fatalf("expected the run to continue on the original provider, got %d requests", len(provider.requests))
}
if result.FinalAnswer != "recovered" {
t.Fatalf("expected final answer after non-fatal switch error, got %q", result.FinalAnswer)
}
// A brief note about the failed switch must reach the model on the next turn.
var sawNote bool
for _, m := range provider.requests[1].Messages {
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(strings.ToLower(m.Content), "could not switch") {
sawNote = true
}
}
if !sawNote {
t.Fatalf("expected a non-fatal switch-failure note on the next turn, messages: %+v", provider.requests[1].Messages)
}
}
// TestRunEscalationSwitcherNilProviderKeepsOriginal verifies that when a
// ModelSwitcher returns (nil, nil) — no error, but also no replacement provider —
// the loop does NOT null out the active provider. The run must continue on the
// ORIGINAL provider and return a result rather than panic on a nil provider.
func TestRunEscalationSwitcherNilProviderKeepsOriginal(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
provider := &mockProvider{turns: escalateThenAnswerTurns("answered on original")}
switchCount := 0
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
switchCount++
// No error, but no provider either: the loop must NOT swap to nil.
return nil, nil
},
})
if err != nil {
t.Fatalf("expected (nil,nil) switch to be non-fatal, got %v", err)
}
if switchCount != 1 {
t.Fatalf("expected exactly one switch attempt, got %d", switchCount)
}
// Both turns (escalate + answer) must run on the single original provider; if
// the loop nil'd out the provider on a (nil,nil) return, the second turn would
// panic or never run.
if len(provider.requests) != 2 {
t.Fatalf("expected the original provider to serve both turns, got %d requests", len(provider.requests))
}
if result.FinalAnswer != "answered on original" {
t.Fatalf("expected final answer from the original provider, got %q", result.FinalAnswer)
}
}
// TestRunSwitchesAtMostOncePerTurn verifies that when a single turn carries two
// escalation requests, only the first triggers a switch.
func TestRunSwitchesAtMostOncePerTurn(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
firstProvider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "escalate"},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c2", ToolName: "escalate"},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c2"},
{Type: zeroruntime.StreamEventDone},
},
}}
secondProvider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
}}
switchCount := 0
if _, err := Run(context.Background(), "go", firstProvider, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
switchCount++
return secondProvider, nil
},
}); err != nil {
t.Fatal(err)
}
if switchCount != 1 {
t.Fatalf("expected at most one switch per turn, got %d", switchCount)
}
}
+209 -8
View File
@@ -33,6 +33,12 @@ const abortedToolResultNotice = "aborted: run halted by the repeated-failure gua
const droppedToolCallNotice = "Your previous tool call was malformed (it was missing a tool name) and was not executed. " +
"Re-issue the tool call with a valid tool name and JSON arguments, or reply with your final answer."
// escalationFailedNoticePrefix introduces the brief, user-role note recorded
// when a requested mid-run model switch could not be performed (the
// ModelSwitcher returned an error). The run continues on the current model;
// escalation is best-effort, never fatal.
const escalationFailedNoticePrefix = "Note: could not switch to the requested model"
// The system prompt (core coding-craft instructions + workspace context + safety
// confirmation policy) is assembled in system_prompt.go via buildSystemPrompt.
@@ -61,6 +67,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
guards := newGuardState()
compactor := newCompactionState(options)
// loaded tracks deferred-eligible tools the model has pulled via tool_search
// during THIS run. It is consulted by partitionTools each turn to expose a
// loaded tool's full schema; it lives only for the run (v1 within-run scope).
loaded := map[string]bool{}
result := Result{Messages: copyMessages(messages)}
for turn := 0; turn < maxTurns; turn++ {
result.Turns = turn + 1
@@ -70,9 +81,19 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// request. A no-op when ContextWindow == 0 (compaction disabled).
messages = compactor.maybeCompact(ctx, provider, messages)
exposed, reminder := partitionTools(registry, permissionMode, options, loaded)
request := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: toolDefinitions(registry, permissionMode, options),
Tools: exposed,
}
if reminder != "" {
// Append to the per-turn request copy only — NEVER to persistent
// messages — so the reminder refreshes each turn and never accumulates
// in the saved transcript.
request.Messages = append(request.Messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: reminder,
})
}
// Report the per-category context budget for this turn so a surface can
@@ -91,9 +112,23 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, retryErr
}
// Rebuild from the compacted messages but reuse the SAME active-mode
// partition (exposed) and reminder computed for this turn: they depend
// on registry+loaded, not on the messages, so they stay valid after
// compaction. Using the bare toolDefinitions here would route through an
// empty-loaded partition, re-hiding every already-loaded deferred tool
// and dropping the reminder when deferral is active.
request = zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: toolDefinitions(registry, permissionMode, options),
Tools: exposed,
}
if reminder != "" {
// Append to the per-turn retry copy only — NEVER to persistent
// messages — matching the main path's reminder-not-persisted invariant.
request.Messages = append(request.Messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: reminder,
})
}
stream, err = provider.StreamCompletion(ctx, request)
}
@@ -117,9 +152,22 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, retryErr
}
// Reuse the SAME active-mode partition (exposed) and reminder from this
// turn rather than the bare toolDefinitions: exposed/reminder depend on
// registry+loaded (not the messages), so they stay valid after compaction.
// Routing through an empty-loaded partition here would re-hide every
// already-loaded deferred tool and drop the reminder when deferral is active.
retryRequest := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: toolDefinitions(registry, permissionMode, options),
Tools: exposed,
}
if reminder != "" {
// Append to the per-turn retry copy only — NEVER to persistent
// messages — matching the main path's reminder-not-persisted invariant.
retryRequest.Messages = append(retryRequest.Messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: reminder,
})
}
retryStream, retryStreamErr := provider.StreamCompletion(ctx, retryRequest)
if retryStreamErr != nil {
@@ -193,6 +241,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
guards.observeTurn(collected)
failureHint := ""
// turnRequestedModel records the FIRST mid-run escalation target requested
// during this turn's tool batch. The actual provider switch happens once,
// after the batch, so every tool_result is recorded first and at most one
// switch occurs per turn.
turnRequestedModel := ""
for index, call := range collected.ToolCalls {
if options.OnToolCall != nil {
options.OnToolCall(call)
@@ -201,6 +254,15 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
if options.OnToolResult != nil {
options.OnToolResult(toolResult)
}
// Union the deferred tools this result asked to load into the per-run
// set BEFORE any abort/stop/guard branch, so a load that coincides with
// a turn-ending result is still recorded for the next turn's partition.
for _, name := range toolResult.LoadedTools {
loaded[name] = true
}
if turnRequestedModel == "" && toolResult.RequestedModel != "" {
turnRequestedModel = toolResult.RequestedModel
}
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleTool,
Content: toolResult.Output,
@@ -252,6 +314,32 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
}
// Mid-run model escalation: if a tool asked to switch models this turn and
// a switcher is wired, rebuild the provider on the new model for the rest
// of the run. At most one switch per turn (first request wins). A switcher
// error is NON-FATAL — record a brief note and continue on the current
// model. nil switcher ⇒ requests are ignored entirely (escalation off).
if turnRequestedModel != "" && options.ModelSwitcher != nil {
newProvider, switchErr := options.ModelSwitcher(ctx, turnRequestedModel)
if switchErr != nil {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: escalationFailedNoticePrefix + " (" + turnRequestedModel + "): " + switchErr.Error() + ". Continuing on " + options.Model + ".",
})
} else if newProvider != nil {
// Reassign the local provider so the next turn's StreamCompletion and
// compaction use it; update options.Model so subsequent RunOptions.Model,
// context-window sizing, and usage attribution follow the new model.
provider = newProvider
options.Model = turnRequestedModel
// KNOWN LIMITATION (deferred): the compactor's context-window budget
// is fixed at run start from options.ContextWindow and is NOT updated
// here, so a switch to a model with a different window keeps compacting
// against the original budget. Fixing it needs a ModelSwitcher contract
// change (return the new window) — out of scope for this change.
}
}
// A turn can mix valid tool calls with a dropped (nameless) one. The valid
// calls executed above; surface the dropped call too so it is never
// silently ignored just because the turn also did real work. This is
@@ -315,7 +403,13 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}, nil
}
}
if !ToolAllowedByFilters(call.Name, options.EnabledTools, options.DisabledTools) {
// 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
// reminder points the model at a tool the dispatch gate rejects (an inescapable
// dead-end). The allowlist is exempted; an explicit DisabledTools entry for
// tool_search is still honored (only the allowlist is exempted, not the denylist).
toolSearchAllowed := call.Name == tools.ToolSearchToolName && !containsToolName(options.DisabledTools, tools.ToolSearchToolName)
if !toolSearchAllowed && !ToolAllowedByFilters(call.Name, options.EnabledTools, options.DisabledTools) {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
@@ -395,6 +489,10 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
ReasoningEffort: options.ReasoningEffort,
Depth: options.Depth,
Cwd: options.Cwd,
// Forward the run's operator tool filters so a filter-aware tool
// (tool_search) never discloses or loads an operator-hidden deferred tool.
EnabledTools: options.EnabledTools,
DisabledTools: options.DisabledTools,
// The sandbox decision (if any) is returned synchronously on the Result and
// used here for permission event building.
})
@@ -417,6 +515,12 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
Display: result.Display,
LoadedTools: loadedToolsFromResult(result.Meta),
// A tool may signal a mid-run model escalation by carrying the target id
// in Meta["escalate_to_model"]. Lift it into the typed loop-level field;
// the Run turn loop performs the actual provider switch. Empty for every
// ordinary tool result.
RequestedModel: result.Meta["escalate_to_model"],
}, nil
}
@@ -799,24 +903,102 @@ func permissionActionFromSandbox(action sandbox.Action) PermissionAction {
}
}
func toolDefinitions(registry *tools.Registry, permissionMode PermissionMode, options Options) []zeroruntime.ToolDefinition {
// partitionTools builds the per-turn advertised tool list and an optional
// deferred-tools reminder. INACTIVE (DeferThreshold <= 0 or the eligible count is
// below it): every visible tool is exposed with its full schema EXCEPT tool_search
// (dropped so it is never advertised when it cannot help), and the reminder is
// empty — byte-identical to the pre-deferral output. ACTIVE: a deferred-eligible
// tool is exposed only when loaded[name]; otherwise it is hidden and its compact
// line goes into the reminder. Non-deferred tools (including tool_search) are
// always exposed. The exposed slice is alpha-sorted by name, matching the legacy
// order so the inactive path is stable.
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
registeredTools := registry.All()
definitions := make([]zeroruntime.ToolDefinition, 0, len(registeredTools))
visible := make([]tools.Tool, 0, len(registeredTools))
eligible := 0
for _, tool := range registeredTools {
if !ToolVisible(tool, permissionMode, options.EnabledTools, options.DisabledTools) {
continue
}
visible = append(visible, tool)
if tools.IsDeferred(tool) {
eligible++
}
}
// Deferral may activate only when tool_search is actually runnable; otherwise
// the loop would hide deferred tools behind a loader the dispatch gate rejects
// — an inescapable dead-end. "Runnable" mirrors executeToolCall's gate:
// registered, not in DisabledTools, and advertised in the current permission
// mode (e.g. not spec-draft, where tool_search is not advertised). The
// EnabledTools allowlist is intentionally NOT checked here — tool_search is
// exempt from the allowlist at dispatch, so an allowlist that omits it must
// not disable deferral.
loader, loaderFound := registry.Get(tools.ToolSearchToolName)
loaderUsable := loaderFound &&
!containsToolName(options.DisabledTools, tools.ToolSearchToolName) &&
ToolAdvertised(loader, permissionMode)
active := options.DeferThreshold > 0 && eligible >= options.DeferThreshold && loaderUsable
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
exposedNames := make(map[string]bool, len(visible))
var hiddenLines []string
for _, tool := range visible {
name := tool.Name()
deferred := tools.IsDeferred(tool)
if !active {
// Inactive: byte-identical to legacy, but tool_search is never advertised.
if name == tools.ToolSearchToolName {
continue
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: name,
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
})
exposedNames[name] = true
continue
}
// Active path.
if deferred && !loaded[name] {
hiddenLines = append(hiddenLines, tools.DeferredLine(tool))
continue
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: tool.Name(),
Name: name,
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
})
exposedNames[name] = true
}
// On the ACTIVE path tool_search is guaranteed runnable (active implies
// loaderUsable), so it must ALWAYS be reachable — even when a non-empty
// EnabledTools allowlist omits it (the operator allowlisted the deferred
// tools, not the loader). Expose its full definition whenever it is not
// already in the exposed set. This never runs on the inactive path, so the
// byte-identical below-threshold output is preserved.
if active && !exposedNames[tools.ToolSearchToolName] {
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: loader.Name(),
Description: loader.Description(),
Parameters: schemaToRuntimeMap(loader.Parameters()),
})
}
sort.Slice(definitions, func(left int, right int) bool {
return definitions[left].Name < definitions[right].Name
})
return definitions
reminder := ""
if active {
reminder = tools.BuildDeferredReminder(hiddenLines)
}
return definitions, reminder
}
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
@@ -937,6 +1119,25 @@ func stopReasonFromToolResult(result ToolResult) StopReason {
return ""
}
// loadedToolsFromResult extracts the deferred-tool names a tool (tool_search)
// asked the loop to expose next turn from Meta["load_tools"] (comma-separated).
// It trims each name and drops empties; returns nil when the key is absent or
// yields no names, so an ordinary result keeps a nil LoadedTools. Mirrors the
// Meta-driven control signal read by stopReasonFromToolResult.
func loadedToolsFromResult(meta map[string]string) []string {
raw := meta["load_tools"]
if strings.TrimSpace(raw) == "" {
return nil
}
var names []string
for _, part := range strings.Split(raw, ",") {
if trimmed := strings.TrimSpace(part); trimmed != "" {
names = append(names, trimmed)
}
}
return names
}
// appendAbortedToolResults adds a placeholder tool-result message for each of
// the given (unexecuted) tool calls, so every advertised tool_use keeps a
// matching tool_result when the loop halts a turn before all calls have run.
+22
View File
@@ -54,6 +54,15 @@ type ToolResult struct {
// DenialReason categorizes why a tool call was blocked (empty when it ran).
// It lets a surface distinguish the cause precisely instead of parsing Output.
DenialReason DenialCategory
// LoadedTools carries the deferred-tool names a tool_search call asked the
// loop to expose next turn (lifted from Meta["load_tools"]). nil for every
// ordinary tool result; only tool_search populates it.
LoadedTools []string
// RequestedModel is the model id a tool asked the loop to switch to for the
// rest of the run (lifted from the tool's Meta["escalate_to_model"]). Empty
// for every normal tool result; the Run loop performs the switch when it is
// set and Options.ModelSwitcher is wired.
RequestedModel string
}
// DenialCategory classifies why a tool call was blocked before it executed.
@@ -125,6 +134,12 @@ type AskUserResponse struct {
type Options struct {
MaxTurns int
// DeferThreshold activates deferred MCP-tool loading: when the number of
// deferred-eligible visible tools is >= this value (and it is > 0), their
// full schemas are withheld and advertised as compact lines via tool_search.
// 0 (or below the eligible count) keeps every tool eager — byte-identical to
// the pre-deferral behavior.
DeferThreshold int
// Specialist/sub-agent metadata is carried through exec now and consumed by
// the specialist runtime in later slices.
SessionID string
@@ -166,6 +181,13 @@ type Options struct {
// budget of the request about to be sent, so a surface (TUI/CLI) can show
// context utilization. Opt-in like the other callbacks; nil is a no-op.
OnContext func(ContextBreakdown)
// ModelSwitcher, when set, lets a tool escalate the run to a stronger model
// mid-run: the loop calls it with the requested model id and, on success,
// swaps the active provider and updates Options.Model for the rest of the
// run. nil DISABLES escalation entirely (the loop ignores any switch
// request), so every existing caller is unaffected. A returned error is
// non-fatal: the run continues on the current model.
ModelSwitcher func(ctx context.Context, modelID string) (Provider, error)
}
type Result struct {
+37 -10
View File
@@ -181,6 +181,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
return runSearch(args[1:], stdout, stderr, deps)
case "sessions", "session":
return runSessions(args[1:], stdout, stderr, deps)
case "spec":
return runSpec(args[1:], stdout, stderr, deps)
case "specialists", "specialist":
return runSpecialists(args[1:], stdout, stderr, deps)
case "plugins", "plugin":
@@ -326,6 +328,27 @@ func runInteractiveTUIWithSkin(stderr io.Writer, deps appDeps, skin string, perm
return writeAppError(stderr, err.Error(), 1)
}
defer closeMCPRuntime(stderr, mcpRuntime)
// Ask (not Auto) is the interactive default: in Auto, ToolAdvertised exposes
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
// apply_patch) would never be offered to the model — the TUI could neither edit
// files nor run shell. Ask advertises them and routes each through the existing
// OnPermissionRequest flow; shift+tab lets the user switch modes live. An
// explicit --skip-permissions-unsafe launch overrides this to unsafe (the only
// way to reach unsafe, since shift+tab deliberately cycles auto↔ask only).
//
// Resolve the effective mode BEFORE the deferral gate below so the registration
// count uses the SAME permission mode the agent loop's partition will use; an
// empty mode here would mis-gate prompt-advertised deferred tools.
if permissionMode == "" {
permissionMode = agent.PermissionModeAsk
}
// Activate deferred MCP-tool loading for the interactive run only when the
// VISIBLE deferred-eligible count meets the resolved threshold, matching exec.
// The registry is complete (core + specialist + MCP) here, so the count is
// accurate; below threshold this is a no-op and the surface is unchanged. The
// interactive surface applies no operator tool filters, so enabled/disabled are
// nil — matching the AgentOptions below.
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
sandboxStore, err := deps.newSandboxStore()
if err != nil {
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
@@ -336,16 +359,6 @@ func runInteractiveTUIWithSkin(stderr io.Writer, deps appDeps, skin string, perm
Store: sandboxStore,
Backend: deps.selectSandboxBackend(sandbox.BackendOptions{}),
})
// Ask (not Auto) is the interactive default: in Auto, ToolAdvertised exposes
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
// apply_patch) would never be offered to the model — the TUI could neither edit
// files nor run shell. Ask advertises them and routes each through the existing
// OnPermissionRequest flow; shift+tab lets the user switch modes live. An
// explicit --skip-permissions-unsafe launch overrides this to unsafe (the only
// way to reach unsafe, since shift+tab deliberately cycles auto↔ask only).
if permissionMode == "" {
permissionMode = agent.PermissionModeAsk
}
return deps.runTUI(context.Background(), tui.Options{
Cwd: workspaceRoot,
ProviderName: resolved.Provider.Name,
@@ -362,10 +375,12 @@ func runInteractiveTUIWithSkin(stderr io.Writer, deps appDeps, skin string, perm
PermissionMode: permissionMode,
Autonomy: string(sandbox.AutonomyLow),
Sandbox: sandboxEngine,
DeferThreshold: resolved.Tools.DeferThreshold,
},
PermissionMode: permissionMode,
Skin: skin,
ThemeDark: true,
Notify: resolved.Notify,
})
}
@@ -393,6 +408,9 @@ func registerSpecialistTools(registry *tools.Registry, workspaceRoot string) (*s
}
func shouldRegisterExecSpecialistTools(options execOptions) bool {
if options.useSpec {
return false
}
if strings.EqualFold(strings.TrimSpace(options.tag), specialist.SessionTagSpecialist) {
return false
}
@@ -444,6 +462,7 @@ Commands:
search Search persisted local Zero session events
find Alias for search
sessions Inspect local Zero session lineage
spec Review and approve saved spec-mode drafts
specialist Manage local Zero specialist profiles
plugins Inspect local Zero plugin manifests
skills Inspect local Zero skills
@@ -487,6 +506,10 @@ Flags:
--image <path> Attach a local image (repeatable; vision models only)
--mode <name> Apply a preset (smart, deep, fast, large, precise); explicit flags override it
-m, --model <model> Select the model for provider setup
--use-spec Draft a spec first and stop for review
--spec-model <model> Override the draft model when --use-spec is set
--spec-reasoning-effort <effort>
Override draft reasoning effort when --use-spec is set
--max-turns <number> Override the maximum agent loop turns
--auto <low|medium|high> Set exec autonomy; high enables unsafe tools
--enabled-tools <tools> Only expose these comma or space separated tools
@@ -512,6 +535,10 @@ Flags:
--session-title <text> Set the created session title
--init-session-id <id> Create a new exec session with this id
--skip-permissions-unsafe Allow prompt-gated tools without approval
--allow-escalation Let the agent escalate to a stronger model mid-run via escalate_model
--notify <off|bell|notify|both>
Override notification mode for this run
--no-notify Disable notifications for this run
`)
return err
}
+221
View File
@@ -0,0 +1,221 @@
package cli
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tui"
)
// cliFakeDeferredTool is deferred-eligible (implements Deferred() bool), mirroring
// an MCP registry tool, so it counts toward the deferral threshold.
type cliFakeDeferredTool struct {
name string
}
func (t cliFakeDeferredTool) Name() string { return t.name }
func (t cliFakeDeferredTool) Description() string { return "fake deferred tool" }
func (t cliFakeDeferredTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
func (t cliFakeDeferredTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectNetwork, Permission: tools.PermissionAllow}
}
func (t cliFakeDeferredTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func (t cliFakeDeferredTool) Deferred() bool { return true }
func registryHasToolSearch(registry *tools.Registry) bool {
_, ok := registry.Get("tool_search")
return ok
}
func TestRegisterToolSearchIfEligibleRegistersAtThreshold(t *testing.T) {
registry := tools.NewRegistry()
for i := 0; i < 3; i++ {
registry.Register(cliFakeDeferredTool{name: "mcp_srv_t" + string(rune('a'+i))})
}
registerToolSearchIfEligible(registry, 3, agent.PermissionModeAuto, nil, nil)
if !registryHasToolSearch(registry) {
t.Fatal("expected tool_search registered when eligible count == threshold")
}
}
func TestRegisterToolSearchIfEligibleSkipsBelowThreshold(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(cliFakeDeferredTool{name: "mcp_srv_ta"})
registry.Register(cliFakeDeferredTool{name: "mcp_srv_tb"})
// A plain (non-deferred) MCP-named tool must NOT count toward eligibility.
registry.Register(cliFakeMCPRegistryTool{})
registerToolSearchIfEligible(registry, 3, agent.PermissionModeAuto, nil, nil)
if registryHasToolSearch(registry) {
t.Fatal("expected no tool_search when eligible count (2) < threshold (3)")
}
}
func TestRegisterToolSearchIfEligibleSkipsWhenThresholdZero(t *testing.T) {
registry := tools.NewRegistry()
for i := 0; i < 5; i++ {
registry.Register(cliFakeDeferredTool{name: "mcp_srv_t" + string(rune('a'+i))})
}
registerToolSearchIfEligible(registry, 0, agent.PermissionModeAuto, nil, nil)
if registryHasToolSearch(registry) {
t.Fatal("expected no tool_search when threshold is 0 (disabled)")
}
}
func TestDeferredEligibleCountIgnoresCoreTools(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)
}
}
// FIX 1: a deferred tool the operator hid via --disabled-tools must NOT count
// toward the visible-deferred total, so registration agrees with the loop's
// activation gate. Two deferred + threshold 2 normally registers tool_search;
// disabling one drops the visible count to 1 (< 2) so it must NOT register.
func TestRegisterToolSearchSkipsWhenDisabledDropsVisibleBelowThreshold(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(cliFakeDeferredTool{name: "mcp_srv_ta"})
registry.Register(cliFakeDeferredTool{name: "mcp_srv_tb"})
registerToolSearchIfEligible(registry, 2, agent.PermissionModeAuto, nil, []string{"mcp_srv_tb"})
if registryHasToolSearch(registry) {
t.Fatal("expected no tool_search: a disabled deferred tool must not count toward the visible-deferred total")
}
}
// FIX 3: validateExecToolFilters must treat tool_search as always-valid even
// though it is not registered yet at validation time (it is registered later only
// when deferral activates). Listing it in --enabled-tools/--disabled-tools must
// not raise "Unknown tool".
func TestValidateExecToolFiltersAllowsToolSearch(t *testing.T) {
registry := newCoreRegistry(t.TempDir())
// tool_search is NOT registered in this registry — it would be added later.
if _, present := registry.Get(tools.ToolSearchToolName); present {
t.Fatalf("precondition: tool_search must not be registered yet")
}
if err := validateExecToolFilters(execOptions{enabledTools: []string{tools.ToolSearchToolName}}, registry); err != nil {
t.Fatalf("--enabled-tools tool_search must validate, got error: %v", err)
}
if err := validateExecToolFilters(execOptions{disabledTools: []string{tools.ToolSearchToolName}}, registry); err != nil {
t.Fatalf("--disabled-tools tool_search must validate, got error: %v", err)
}
// A genuinely unknown tool still errors.
if err := validateExecToolFilters(execOptions{enabledTools: []string{"definitely_not_a_tool"}}, registry); err == nil {
t.Fatal("expected an Unknown tool error for an unregistered, non-tool_search name")
}
}
// TestRunExecListToolsAdvertisesMCPToolsWithoutToolSearch verifies that
// `exec --list-tools` lists the core + MCP tools WITHOUT tool_search. This is
// independent of the deferral threshold: --list-tools short-circuits and returns
// before resolveConfig and registerToolSearchIfEligible ever run (see exec.go),
// so tool_search is never registered on this path. The threshold gate itself is
// exercised by TestRegisterToolSearchIfEligible{RegistersAtThreshold,
// SkipsBelowThreshold,SkipsWhenThresholdZero} and the end-to-end at-threshold
// path in TestTUIRunThreadsDeferThresholdAndRegistersToolSearch.
func TestRunExecListToolsAdvertisesMCPToolsWithoutToolSearch(t *testing.T) {
cwd := t.TempDir()
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--list-tools"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
},
resolveMCPConfig: func(string) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, nil
},
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
registerMCPTools: func(_ context.Context, registry *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
registry.Register(cliFakeMCPRegistryTool{})
return closeFunc(func() error { return nil }), nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
out := stdout.String()
if !strings.Contains(out, "mcp_docs_lookup") {
t.Fatalf("expected MCP tool advertised by --list-tools, got %q", out)
}
// --list-tools never registers tool_search (it returns before the threshold
// gate runs); the threshold logic is verified by the unit/E2E tests named above.
if strings.Contains(out, "tool_search") {
t.Fatalf("expected NO tool_search from --list-tools, got %q", out)
}
}
func TestTUIRunThreadsDeferThresholdAndRegistersToolSearch(t *testing.T) {
cwd := t.TempDir()
var captured agent.Options
var capturedRegistry *tools.Registry
// Empty args route runWithDeps to the interactive TUI (runInteractiveTUIWithSkin).
// There is no "--tui" flag; that arg would hit the unknown-command path.
exitCode := runWithDeps([]string{}, io.Discard, io.Discard, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{
Provider: config.ProviderProfile{
Name: "p",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "http://127.0.0.1/v1",
Model: "m",
},
Tools: config.ToolsConfig{DeferThreshold: 2},
}, nil
},
resolveMCPConfig: func(string) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, nil
},
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
registerMCPTools: func(_ context.Context, registry *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
registry.Register(cliFakeDeferredTool{name: "mcp_docs_ta"})
registry.Register(cliFakeDeferredTool{name: "mcp_docs_tb"})
return closeFunc(func() error { return nil }), nil
},
runTUI: func(_ context.Context, options tui.Options) int {
captured = options.AgentOptions
capturedRegistry = options.Registry
return exitSuccess
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit %d, got %d", exitSuccess, exitCode)
}
if captured.DeferThreshold != 2 {
t.Fatalf("AgentOptions.DeferThreshold = %d, want 2", captured.DeferThreshold)
}
if capturedRegistry == nil {
t.Fatal("expected registry passed to TUI")
}
if _, ok := capturedRegistry.Get("tool_search"); !ok {
t.Fatal("expected tool_search registered for TUI run at/above threshold")
}
}
+180 -7
View File
@@ -14,11 +14,14 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/imageinput"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/specialist"
"github.com/Gitlawb/zero/internal/specmode"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/worktrees"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -57,6 +60,9 @@ type execOptions struct {
// selection") and TestRunExecAcceptsLegacyModelProfileFlags.
modelProfile string
reasoningEffort string
useSpec bool
specModel string
specReasoningEffort string
maxTurns int
cwd string
inputFormat execInputFormat
@@ -78,6 +84,16 @@ type execOptions struct {
worktreeName string
worktreeDir string
skipPermissionsUnsafe bool
// allowEscalation opts the run into mid-run model escalation: it registers
// the escalate_model tool and wires agent.Options.ModelSwitcher. Off by
// default — a run without the flag is byte-identical to before (no tool, nil
// switcher).
allowEscalation bool
// notifyMode overrides config.Notify.Mode for this run. Mutually exclusive
// with noNotify.
notifyMode string
// noNotify forces ModeOff for this run. Mutually exclusive with notifyMode.
noNotify bool
}
type execUsageError struct {
@@ -128,6 +144,14 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
registry := newCoreRegistry(workspaceRoot)
// Register the escalate_model tool only when the run opted into mid-run
// escalation. It is registered before --list-tools formatting and
// validateExecToolFilters so the tool is both listable and filter-validatable.
// The no-arg constructor builds its own default registry internally (and
// degrades to an inert tool if the catalog fails), so no registry is threaded.
if options.allowEscalation {
registry.Register(tools.NewEscalateModelTool())
}
var specialistRuntime *specialist.Runtime
if shouldRegisterExecSpecialistTools(options) {
var err error
@@ -141,11 +165,17 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
}
if options.useSpec {
permissionMode = agent.PermissionModeSpecDraft
}
mcpRuntime, err := registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options))
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
}
defer closeMCPRuntime(stderr, mcpRuntime)
if options.useSpec {
specmode.RegisterDraftTools(registry, workspaceRoot, deps.now)
}
if err := validateExecToolFilters(options, registry); err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
}
@@ -177,8 +207,12 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
modelRegistry, _ := modelregistry.DefaultRegistry()
overrides := config.Overrides{}
if options.model != "" {
resolvedModel, notice := resolveSelectedModel(modelRegistry, options.model)
modelOverride := options.model
if options.useSpec && options.specModel != "" {
modelOverride = options.specModel
}
if modelOverride != "" {
resolvedModel, notice := resolveSelectedModel(modelRegistry, modelOverride)
overrides.Provider.Model = resolvedModel
if notice != "" {
if _, err := fmt.Fprintln(stderr, notice); err != nil {
@@ -196,6 +230,23 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if !config.HasProviderProfile(resolved.Provider) {
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", "No provider configured. Set OPENAI_MODEL/OPENAI_API_KEY or add .zero/config.json.")
}
// Activate deferred MCP-tool loading for this run only when the VISIBLE
// deferred-eligible count meets the resolved threshold; below threshold this
// is a no-op and tool advertising stays byte-identical. The registry is
// already complete (core + MCP) at this point, so the count is accurate. The
// permission mode and operator tool filters MUST match the values passed to
// agent.Run below so this registration gate counts the same population the
// loop's partition gate counts.
// tool_search is the only way to reach a hidden deferred tool, so if the
// operator explicitly disables it, deferral must not activate at all —
// otherwise a positive threshold would hide tools behind a loader the run
// rejects (a dead-end). Force the effective threshold to 0 so this
// registration gate and agent.Run's partition gate agree the run is inactive.
effectiveDeferThreshold := resolved.Tools.DeferThreshold
if toolListContains(options.disabledTools, tools.ToolSearchToolName) {
effectiveDeferThreshold = 0
}
registerToolSearchIfEligible(registry, effectiveDeferThreshold, permissionMode, options.enabledTools, options.disabledTools)
images, err := resolveExecImages(options.imagePaths, workspaceRoot)
if err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
@@ -218,12 +269,16 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
runReasoningEffort := options.reasoningEffort
if options.useSpec && options.specReasoningEffort != "" {
runReasoningEffort = options.specReasoningEffort
}
// Evaluate the --reasoning-effort advisory against the EFFECTIVE resolved
// model (resolved.Provider.Model), not the override. Without an explicit
// --model the override model is empty, so checking it here would silently
// skip the advisory even though the run uses a concrete effective model.
if options.reasoningEffort != "" {
if notice := reasoningEffortNotice(modelRegistry, resolved.Provider.Model, options.reasoningEffort); notice != "" {
if runReasoningEffort != "" {
if notice := reasoningEffortNotice(modelRegistry, resolved.Provider.Model, runReasoningEffort); notice != "" {
if _, err := fmt.Fprintln(stderr, notice); err != nil {
return exitCrash
}
@@ -234,10 +289,63 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
}
// currentModel tracks the model in force for usage attribution. It starts at
// the resolved model and is reassigned by the model switcher on a mid-run
// escalation so post-switch turns are attributed to the escalated model.
currentModel := resolved.Provider.Model
var modelSwitcher func(context.Context, string) (agent.Provider, error)
if options.allowEscalation {
modelSwitcher = func(_ context.Context, modelID string) (agent.Provider, error) {
switchedProfile := resolved.Provider
switchedProfile.Model = modelID
switchedProvider, err := deps.newProvider(switchedProfile)
if err != nil {
return nil, err
}
// Mirror the agent loop's switch guard (it only reassigns the provider
// when newProvider != nil). Updating currentModel only on a non-nil
// provider keeps usage attribution consistent with whether the loop
// actually switched — a (nil, nil) return leaves both untouched.
if switchedProvider != nil {
currentModel = modelID
}
return switchedProvider, nil
}
}
runMetadata, err := resolveExecRunMetadata(resolved.Provider)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
}
// Notify on completion via stderr (never stdout, which may carry stream-json).
// Headless has no terminal-focus signal, so focus gating does not apply here:
// always emit when a mode is configured (focusMode is a TUI-only concept).
notifier := notify.New(stderr, notify.Config{
Mode: notify.Mode(strings.TrimSpace(execNotifyMode(options, resolved))),
FocusMode: notify.FocusAlways,
})
if options.useSpec {
return runExecSpecDraft(execSpecDraftRun{
options: options,
stdout: stdout,
stderr: stderr,
deps: deps,
workspaceRoot: workspaceRoot,
registry: registry,
modelRegistry: modelRegistry,
resolved: resolved,
runMetadata: runMetadata,
provider: provider,
sandboxEngine: sandboxEngine,
prompt: prompt,
sessionTitle: sessionTitle,
images: images,
reasoningEffort: runReasoningEffort,
specPermissionMode: permissionMode,
notifier: notifier,
})
}
preparedSession := sessions.PreparedExec{}
agentPrompt := prompt
@@ -311,6 +419,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
MaxTurns: resolved.MaxTurns,
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
DeferThreshold: effectiveDeferThreshold,
SessionID: preparedSession.Session.SessionID,
CallingSessionID: options.callingSessionID,
CallingToolUseID: options.callingToolUseID,
@@ -318,7 +427,8 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
Depth: options.depth,
SessionTitle: sessionTitle,
Model: resolved.Provider.Model,
ReasoningEffort: options.reasoningEffort,
ModelSwitcher: modelSwitcher,
ReasoningEffort: runReasoningEffort,
Cwd: workspaceRoot,
Images: images,
Registry: registry,
@@ -365,13 +475,23 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
},
OnUsage: func(usage agent.Usage) {
writer.usage(usage)
sessionRecorder.append(sessions.EventUsage, map[string]any{
payload := map[string]any{
"promptTokens": usage.EffectiveInputTokens(),
"completionTokens": usage.EffectiveOutputTokens(),
"totalTokens": usage.TotalTokens(),
})
}
// Attribute usage to a specific model ONLY when escalation is enabled:
// the model in force can change mid-run only under --allow-escalation, so
// the "model" key is meaningful exclusively then. Omitting it otherwise
// keeps a non-escalation run's persisted usage payload byte-identical to
// before this feature (the origin/main guarantee).
if options.allowEscalation {
payload["model"] = currentModel
}
sessionRecorder.append(sessions.EventUsage, payload)
},
})
notifier.Notify(notify.Completion, notify.DefaultMessage(notify.Completion))
if writer.err != nil {
return exitCrash
}
@@ -414,6 +534,46 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
return exitSuccess
}
// deferredEligibleCount returns the number of registered tools that are
// deferred-eligible (MCP tools) AND visible to the model for THIS run — i.e. they
// pass the same agent.ToolVisible gate (permission-mode advertising + operator
// allow/deny filters) that the agent loop's partitionTools applies when it
// decides whether deferral activates. Counting the SAME visible-deferred
// population here keeps registration and activation in agreement: tool_search is
// registered iff the partition will actually go active. Built-ins never implement
// the Deferred interface, so they never count.
func deferredEligibleCount(registry *tools.Registry, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) int {
count := 0
for _, tool := range registry.All() {
if !tools.IsDeferred(tool) {
continue
}
if !agent.ToolVisible(tool, permissionMode, enabledTools, disabledTools) {
continue
}
count++
}
return count
}
// registerToolSearchIfEligible registers the tool_search tool only when deferral
// is active for this run: the visible-deferred count (the same population the
// agent loop's partition counts) meets the (positive) threshold. Below threshold
// or with a zero/negative threshold, tool_search is never registered, so the
// agent loop's partition stays inactive and tool advertising is byte-identical to
// today. The permissionMode + enabled/disabled filters MUST match the values the
// run passes to agent.Run so the registration gate and the activation gate count
// the same tools.
func registerToolSearchIfEligible(registry *tools.Registry, deferThreshold int, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) {
if deferThreshold <= 0 {
return
}
if deferredEligibleCount(registry, permissionMode, enabledTools, disabledTools) < deferThreshold {
return
}
registry.Register(tools.NewToolSearchTool(registry))
}
func buildExecSandboxEngine(workspaceRoot string, resolved config.ResolvedConfig, deps appDeps) (*sandbox.Engine, error) {
store, err := deps.newSandboxStore()
if err != nil {
@@ -757,3 +917,16 @@ func sessionPermissionEventType(event agent.PermissionEvent) sessions.EventType
}
return sessions.EventPermission
}
// execNotifyMode resolves the effective notification mode for a run:
// --no-notify forces "off", --notify <mode> overrides config, otherwise the
// config value is used.
func execNotifyMode(options execOptions, resolved config.ResolvedConfig) string {
if options.noNotify {
return string(notify.ModeOff)
}
if options.notifyMode != "" {
return options.notifyMode
}
return resolved.Notify.Mode
}
+68
View File
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/specialist"
)
func parseExecArgs(args []string) (execOptions, bool, error) {
@@ -23,6 +24,23 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
options.skipPermissionsUnsafe = true
case arg == "--list-tools":
options.listTools = true
case arg == "--allow-escalation":
options.allowEscalation = true
case arg == "--no-notify":
options.noNotify = true
case arg == "--notify":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.notifyMode = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--notify="):
value, err := requiredInlineFlagValue(arg, "--notify")
if err != nil {
return options, false, err
}
options.notifyMode = value
case arg == "--auto":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
@@ -112,6 +130,34 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
index = next
case strings.HasPrefix(arg, "--reasoning-effort="):
options.reasoningEffort = strings.TrimSpace(strings.TrimPrefix(arg, "--reasoning-effort="))
case arg == "--use-spec":
options.useSpec = true
case arg == "--spec-model":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.specModel = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--spec-model="):
value, err := requiredInlineFlagValue(arg, "--spec-model")
if err != nil {
return options, false, err
}
options.specModel = value
case arg == "--spec-reasoning-effort":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.specReasoningEffort = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--spec-reasoning-effort="):
value, err := requiredInlineFlagValue(arg, "--spec-reasoning-effort")
if err != nil {
return options, false, err
}
options.specReasoningEffort = value
case arg == "--max-turns":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
@@ -314,9 +360,31 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
}
}
if options.noNotify && options.notifyMode != "" {
return options, false, execUsageError{"Use either --notify or --no-notify, not both."}
}
if options.notifyMode != "" {
switch options.notifyMode {
case "off", "bell", "notify", "both":
default:
return options, false, execUsageError{fmt.Sprintf("invalid --notify %q. Expected off, bell, notify, or both.", options.notifyMode)}
}
}
if (options.resume != "" || options.resumeLatest) && options.fork != "" {
return options, false, execUsageError{"Use either --resume or --fork, not both."}
}
if options.useSpec && (options.resume != "" || options.resumeLatest || options.fork != "") {
return options, false, execUsageError{"--use-spec cannot be combined with --resume or --fork."}
}
if options.useSpec && strings.EqualFold(strings.TrimSpace(options.tag), specialist.SessionTagSpecialist) {
return options, false, execUsageError{"--use-spec cannot be used inside a specialist child session."}
}
if !options.useSpec && options.specModel != "" {
return options, false, execUsageError{"--spec-model requires --use-spec."}
}
if !options.useSpec && options.specReasoningEffort != "" {
return options, false, execUsageError{"--spec-reasoning-effort requires --use-spec."}
}
if options.initSessionID != "" && (options.resume != "" || options.resumeLatest) {
return options, false, execUsageError{"Use --init-session-id only when creating or forking a session."}
}
+295
View File
@@ -0,0 +1,295 @@
package cli
import (
"context"
"errors"
"fmt"
"io"
"strings"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/specmode"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type execSpecDraftRun struct {
options execOptions
stdout io.Writer
stderr io.Writer
deps appDeps
workspaceRoot string
registry *tools.Registry
modelRegistry modelregistry.Registry
resolved config.ResolvedConfig
runMetadata execRunMetadata
provider zeroruntime.Provider
sandboxEngine *sandbox.Engine
prompt string
sessionTitle string
images []zeroruntime.ImageBlock
reasoningEffort string
specPermissionMode agent.PermissionMode
notifier *notify.Notifier
}
type execSpecDraftInfo struct {
SpecID string
SpecTitle string
SpecFilePath string
RelativePath string
DraftSessionID string
}
func runExecSpecDraft(run execSpecDraftRun) int {
store := run.deps.newSessionStore()
draftSession, err := store.Create(sessions.CreateInput{
SessionID: run.options.initSessionID,
SessionKind: sessions.SessionKindSpecDraft,
Title: run.sessionTitle,
Cwd: run.workspaceRoot,
ModelID: run.resolved.Provider.Model,
Provider: run.runMetadata.Provider,
SpecDraftModelID: run.resolved.Provider.Model,
SpecDraftReasoning: run.reasoningEffort,
})
if err != nil {
return writeExecFormatUsageError(run.stdout, run.stderr, run.options.outputFormat, err.Error())
}
runID, err := streamjson.CreateRunID(run.deps.now())
if err != nil {
return writeAppError(run.stderr, "failed to create run id: "+err.Error(), exitCrash)
}
writer := execEventWriter{
stdout: run.stdout,
stderr: run.stderr,
format: run.options.outputFormat,
runID: runID,
sessionID: draftSession.SessionID,
streamedText: &strings.Builder{},
}
writer.runStart(run.workspaceRoot, run.runMetadata, run.specPermissionMode)
if writer.err != nil {
return exitCrash
}
preparedSession := sessions.PreparedExec{
Mode: sessions.ModeNew,
Session: draftSession,
Store: store,
}
sessionRecorder := execSessionRecorder{prepared: preparedSession}
sessionRecorder.append(sessions.EventMessage, map[string]any{
"role": "user",
"content": run.prompt,
})
var draftInfo execSpecDraftInfo
runCtx, stopSignals := signalContext()
defer stopSignals()
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
MaxTurns: run.resolved.MaxTurns,
ContextWindow: modelContextWindow(run.modelRegistry, run.resolved.Provider.Model),
SessionID: draftSession.SessionID,
SessionTitle: run.sessionTitle,
Model: run.resolved.Provider.Model,
ReasoningEffort: run.reasoningEffort,
Cwd: run.workspaceRoot,
SystemPrompt: specmode.DraftSystemPrompt,
Images: run.images,
Registry: run.registry,
PermissionMode: agent.PermissionModeSpecDraft,
Autonomy: "low",
Sandbox: run.sandboxEngine,
EnabledTools: run.options.enabledTools,
DisabledTools: run.options.disabledTools,
OnText: writer.text,
OnToolCall: func(call agent.ToolCall) {
writer.toolCall(call, run.registry)
sessionRecorder.append(sessions.EventToolCall, map[string]any{
"id": call.ID,
"name": call.Name,
"arguments": call.Arguments,
})
if checkpoint, ok := sessionRecorder.captureCheckpoint(run.workspaceRoot, call); ok {
writer.checkpoint(checkpoint)
}
},
OnPermission: func(event agent.PermissionEvent) {
writer.permission(event)
sessionRecorder.append(sessionPermissionEventType(event), event)
},
OnToolResult: func(result agent.ToolResult) {
writer.toolResult(result)
if info, ok := execSpecDraftInfoFromToolResult(result); ok {
draftInfo = info
}
payload := map[string]any{
"toolCallId": result.ToolCallID,
"name": result.Name,
"status": string(result.Status),
"output": result.Output,
}
if len(result.Meta) > 0 {
payload["meta"] = result.Meta
}
if result.Redacted {
payload["redacted"] = true
}
if len(result.ChangedFiles) > 0 {
payload["changedFiles"] = result.ChangedFiles
}
sessionRecorder.append(sessions.EventToolResult, payload)
},
OnUsage: func(usage agent.Usage) {
writer.usage(usage)
sessionRecorder.append(sessions.EventUsage, map[string]any{
"promptTokens": usage.EffectiveInputTokens(),
"completionTokens": usage.EffectiveOutputTokens(),
"totalTokens": usage.TotalTokens(),
})
},
})
run.notifier.Notify(notify.Completion, notify.DefaultMessage(notify.Completion))
if writer.err != nil {
return exitCrash
}
if err != nil {
if errors.Is(err, context.Canceled) || runCtx.Err() != nil {
sessionRecorder.append(sessions.EventError, map[string]any{"message": "interrupted"})
if run.options.outputFormat == execOutputStreamJSON {
writer.errorEvent("interrupted", "run cancelled by signal", false)
writer.runEnd("interrupted", exitInterrupted)
if writer.err != nil {
return exitCrash
}
} else {
fmt.Fprintln(run.stderr, "Interrupted.")
}
return exitInterrupted
}
sessionRecorder.append(sessions.EventError, map[string]any{"message": err.Error()})
return writeExecSpecDraftProviderError(&writer, run.options.outputFormat, err.Error())
}
if result.StopReason != agent.StopReasonSpecReviewRequired || draftInfo.SpecID == "" || draftInfo.SpecFilePath == "" {
message := "spec draft ended without submit_spec"
sessionRecorder.append(sessions.EventError, map[string]any{"message": message})
return writeExecSpecDraftProviderError(&writer, run.options.outputFormat, message)
}
draftInfo.DraftSessionID = draftSession.SessionID
if _, _, err := store.RecordSpec(draftSession.SessionID, sessions.RecordSpecInput{
SpecID: draftInfo.SpecID,
SpecFilePath: draftInfo.SpecFilePath,
SpecStatus: sessions.SpecStatusDraft,
SpecDraftModelID: run.resolved.Provider.Model,
SpecDraftReasoning: run.reasoningEffort,
}); err != nil {
return writeAppError(run.stderr, err.Error(), exitCrash)
}
if emittedTerminal := writer.specReviewRequired(draftInfo); !emittedTerminal {
writer.runEnd(string(agent.StopReasonSpecReviewRequired), exitSuccess)
}
if writer.err != nil {
return exitCrash
}
return exitSuccess
}
func execSpecDraftInfoFromToolResult(result agent.ToolResult) (execSpecDraftInfo, bool) {
if result.Name != specmode.SubmitToolName || result.Meta["control"] != specmode.ControlSpecReviewRequired {
return execSpecDraftInfo{}, false
}
return execSpecDraftInfo{
SpecID: strings.TrimSpace(result.Meta["specId"]),
SpecTitle: strings.TrimSpace(result.Meta["specTitle"]),
SpecFilePath: strings.TrimSpace(result.Meta["specFilePath"]),
RelativePath: strings.TrimSpace(result.Meta["relativePath"]),
}, true
}
func writeExecSpecDraftProviderError(writer *execEventWriter, format execOutputFormat, message string) int {
if format == execOutputStreamJSON {
writer.errorEvent("provider_error", message, false)
writer.runEnd("error", exitProvider)
if writer.err != nil {
return exitCrash
}
return exitProvider
}
if format == execOutputJSON {
writer.errorEvent("provider_error", message, false)
writer.writeJSON(map[string]any{"type": "done", "exit_code": exitProvider})
if writer.err != nil {
return exitCrash
}
return exitProvider
}
writer.errorEvent("provider_error", message, false)
if writer.err != nil {
return exitCrash
}
return exitProvider
}
func (writer *execEventWriter) specReviewRequired(info execSpecDraftInfo) bool {
summary := formatExecSpecDraftSummary(info)
switch writer.format {
case execOutputJSON:
writer.writeJSON(map[string]any{
"type": string(agent.StopReasonSpecReviewRequired),
"status": string(sessions.SpecStatusDraft),
"stop_reason": string(agent.StopReasonSpecReviewRequired),
"spec_id": info.SpecID,
"spec_title": info.SpecTitle,
"spec_file_path": info.SpecFilePath,
"relative_path": info.RelativePath,
"draft_session_id": info.DraftSessionID,
})
writer.writeJSON(map[string]any{"type": "done", "exit_code": exitSuccess})
return true
case execOutputStreamJSON:
writer.writeStreamJSON(streamjson.Event{
Type: streamjson.EventFinal,
RunID: writer.runID,
Text: summary,
Meta: map[string]string{
"status": string(sessions.SpecStatusDraft),
"stopReason": string(agent.StopReasonSpecReviewRequired),
"specId": info.SpecID,
"specTitle": info.SpecTitle,
"specFilePath": info.SpecFilePath,
"relativePath": info.RelativePath,
"draftSessionId": info.DraftSessionID,
},
})
return false
default:
if writer.streamedText.Len() > 0 && !strings.HasSuffix(writer.streamedText.String(), "\n") {
writer.writeStdout("\n")
}
writer.writeStdout(summary + "\n")
return true
}
}
func formatExecSpecDraftSummary(info execSpecDraftInfo) string {
path := strings.TrimSpace(info.RelativePath)
if path == "" {
path = info.SpecFilePath
}
lines := []string{
"Spec draft saved for review.",
" spec: " + redact(info.SpecID),
" path: " + redact(path),
" draft session: " + redact(info.DraftSessionID),
"Next: zero spec show " + redact(info.SpecID) + "; zero spec approve " + redact(info.SpecID),
}
return strings.Join(lines, "\n")
}
+204
View File
@@ -0,0 +1,204 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/specmode"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestRunExecUseSpecCreatesDraftSession(t *testing.T) {
workspaceRoot := t.TempDir()
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: fixedCLISpecTime})
provider := &submitSpecExecProvider{}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec",
"--cwd", workspaceRoot,
"--use-spec",
"--spec-model", "draft-model",
"--spec-reasoning-effort", "high",
"-o", "json",
"plan the review flow",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return workspaceRoot, nil
},
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "default-model"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
return config.ResolvedConfig{
ActiveProvider: "test",
Provider: config.ProviderProfile{
Name: "test",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "http://127.0.0.1/v1",
Model: model,
},
MaxTurns: 3,
}, nil
},
resolveMCPConfig: func(string) (config.MCPConfig, error) {
return config.MCPConfig{}, nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return provider, nil
},
newSessionStore: func() *sessions.Store {
return store
},
newSandboxStore: func() (*sandbox.GrantStore, error) {
return sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: t.TempDir() + "/grants.json"})
},
now: fixedCLISpecTime,
})
if exitCode != exitSuccess {
t.Fatalf("exit = %d stderr=%s stdout=%s", exitCode, stderr.String(), stdout.String())
}
events := decodeJSONLines(t, stdout.String())
if !slicesContains(jsonEventTypes(events), string(agent.StopReasonSpecReviewRequired)) {
t.Fatalf("missing spec_review_required event in %v", jsonEventTypes(events))
}
items, err := store.List()
if err != nil {
t.Fatal(err)
}
if len(items) != 1 {
t.Fatalf("session count = %d, want 1: %#v", len(items), items)
}
draft := items[0]
if draft.SessionKind != sessions.SessionKindSpecDraft || draft.SpecStatus != sessions.SpecStatusDraft {
t.Fatalf("unexpected draft session: %#v", draft)
}
if draft.SpecID == "" || !strings.Contains(filepath.ToSlash(draft.SpecFilePath), ".zero/specs/") {
t.Fatalf("draft spec metadata missing: %#v", draft)
}
if draft.SpecDraftModelID != "draft-model" || draft.ModelID != "draft-model" || draft.SpecDraftReasoning != "high" {
t.Fatalf("draft model metadata = %#v", draft)
}
if len(provider.requests) != 1 {
t.Fatalf("provider request count = %d, want 1", len(provider.requests))
}
if !provider.requestIncludesTool(specmode.SubmitToolName) {
t.Fatalf("provider tools missing submit_spec: %#v", provider.requests[0].Tools)
}
}
func TestParseExecUseSpecRejectsResume(t *testing.T) {
_, _, err := parseExecArgs([]string{"--use-spec", "--resume", "zero_1", "plan"})
if err == nil || !strings.Contains(err.Error(), "cannot be combined") {
t.Fatalf("expected --use-spec/--resume validation, got %v", err)
}
}
func TestParseExecSpecOverridesRequireUseSpec(t *testing.T) {
for _, tc := range []struct {
name string
args []string
want string
}{
{
name: "spec model",
args: []string{"--spec-model", "gpt-5", "plan"},
want: "--spec-model requires --use-spec",
},
{
name: "spec reasoning effort",
args: []string{"--spec-reasoning-effort", "high", "plan"},
want: "--spec-reasoning-effort requires --use-spec",
},
} {
t.Run(tc.name, func(t *testing.T) {
_, _, err := parseExecArgs(tc.args)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("expected %q validation, got %v", tc.want, err)
}
})
}
}
func TestRunExecUseSpecRejectsSpecialistTag(t *testing.T) {
exitCode, _, stderr := runExecWithEcho(t, []string{"exec", "--use-spec", "--tag", "specialist", "plan"})
if exitCode != exitUsage {
t.Fatalf("exit = %d, want %d stderr=%s", exitCode, exitUsage, stderr)
}
if !strings.Contains(stderr, "specialist child session") {
t.Fatalf("expected --use-spec specialist tag validation, got %q", stderr)
}
}
func TestRunExecUseSpecRejectsFiltersThatHideSubmitSpec(t *testing.T) {
for _, args := range [][]string{
{"exec", "--use-spec", "--disabled-tools", specmode.SubmitToolName, "plan"},
{"exec", "--use-spec", "--enabled-tools", "read_file", "plan"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
exitCode, _, stderr := runExecWithEcho(t, args)
if exitCode != exitUsage {
t.Fatalf("exit = %d, want %d stderr=%s", exitCode, exitUsage, stderr)
}
if !strings.Contains(stderr, "submit_spec") {
t.Fatalf("expected submit_spec validation, got %q", stderr)
}
})
}
}
type submitSpecExecProvider struct {
requests []zeroruntime.CompletionRequest
}
func (provider *submitSpecExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
provider.requests = append(provider.requests, request)
arguments, _ := json.Marshal(map[string]string{
"title": "Review Flow",
"plan": "# Goal\n\nAdd the review flow.",
})
ch := make(chan zeroruntime.StreamEvent, 4)
select {
case <-ctx.Done():
close(ch)
return ch, ctx.Err()
case ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: specmode.SubmitToolName}:
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: string(arguments)}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
func (provider *submitSpecExecProvider) requestIncludesTool(name string) bool {
if len(provider.requests) == 0 {
return false
}
for _, tool := range provider.requests[0].Tools {
if tool.Name == name {
return true
}
}
return false
}
func slicesContains(values []string, value string) bool {
for _, candidate := range values {
if candidate == value {
return true
}
}
return false
}
+742
View File
@@ -1135,3 +1135,745 @@ func TestRunExecInvalidAutoValidatedWithSkipPermissions(t *testing.T) {
t.Fatalf("expected autonomy validation error, got %q", got)
}
}
func TestParseExecAllowEscalationFlag(t *testing.T) {
t.Run("absent defaults to false", func(t *testing.T) {
options, help, err := parseExecArgs([]string{"hello"})
if err != nil {
t.Fatalf("parseExecArgs returned error: %v", err)
}
if help {
t.Fatal("help = true, want false")
}
if options.allowEscalation {
t.Fatal("allowEscalation = true, want false by default")
}
})
t.Run("flag sets true", func(t *testing.T) {
options, _, err := parseExecArgs([]string{"--allow-escalation", "hello"})
if err != nil {
t.Fatalf("parseExecArgs returned error: %v", err)
}
if !options.allowEscalation {
t.Fatal("allowEscalation = false, want true")
}
if strings.Join(options.promptParts, " ") != "hello" {
t.Fatalf("promptParts = %#v, want [hello]", options.promptParts)
}
})
}
func TestRunExecHelpDocumentsAllowEscalation(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"exec", "--help"}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}
if !strings.Contains(stdout.String(), "--allow-escalation") {
t.Fatalf("expected exec help to document --allow-escalation, got %q", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
}
func TestRunExecRegistersEscalateModelOnlyWithFlag(t *testing.T) {
for _, tc := range []struct {
name string
args []string
wantTool bool
}{
{name: "absent", args: []string{"exec", "--list-tools"}, wantTool: false},
{name: "present", args: []string{"exec", "--allow-escalation", "--list-tools"}, wantTool: true},
} {
t.Run(tc.name, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps(tc.args, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return t.TempDir(), nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
hasTool := strings.Contains(stdout.String(), " escalate_model ")
if hasTool != tc.wantTool {
t.Fatalf("escalate_model visibility = %v, want %v; output:\n%s", hasTool, tc.wantTool, stdout.String())
}
})
}
}
// escalatingExecProvider drives an exec run through a mid-run switch without a
// live model. Each instance tracks how many turns IT served (turns) so a test
// can assert exactly which provider handled which turn across a switch.
//
// escalateOnce controls behavior: when true, the FIRST turn this instance serves
// emits an escalate_model tool call (later turns answer); when false, every turn
// emits a final text answer. Pointing escalateOnce at distinct instances lets a
// test prove the post-switch turn lands on the SECOND (escalated) provider.
type escalatingExecProvider struct {
// turns counts how many StreamCompletion calls THIS instance handled.
turns int
// escalateOnce makes this instance's first served turn request an escalation.
escalateOnce bool
}
func (provider *escalatingExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
turn := provider.turns
provider.turns++
ch := make(chan zeroruntime.StreamEvent, 4)
if provider.escalateOnce && turn == 0 {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_escalate", ToolName: "escalate_model"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_escalate", ArgumentsFragment: "{}"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_escalate"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
func TestRunExecWiresModelSwitcherUnderFlag(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
cwd := t.TempDir()
var providerModels []string
// Each newProvider build returns a DISTINCT provider instance with its OWN
// turn counter, mirroring the agent-layer firstProvider/secondProvider split.
// The first build (escalation source) must handle exactly the escalation turn;
// the second build (escalation target) must handle exactly the post-switch
// answer turn. Sharing a single counter would hide a dropped `provider =
// newProvider` in loop.go — here it is mutation-proven by the per-instance
// turn assertions below.
var builtProviders []*escalatingExecProvider
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--allow-escalation", "--model", "claude-haiku-4.5", "escalate please"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "claude-haiku-4.5"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
cfg := execResolvedConfig()
// The escalation chain (haiku -> sonnet) is anthropic; declare the
// provider kind to match so provider-metadata resolution accepts it.
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
cfg.Provider.Model = model
cfg.MaxTurns = 3
return cfg, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
providerModels = append(providerModels, profile.Model)
// First build escalates; every later build answers. Each instance owns
// its turn counter so we can assert exactly-one-turn per provider.
provider := &escalatingExecProvider{escalateOnce: len(builtProviders) == 0}
builtProviders = append(builtProviders, provider)
return provider, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
// Initial provider build + one rebuild for the escalation target.
if len(providerModels) != 2 {
t.Fatalf("newProvider called for models %#v, want exactly 2 builds (initial + escalated)", providerModels)
}
if providerModels[0] != "claude-haiku-4.5" {
t.Fatalf("initial provider model = %q, want claude-haiku-4.5", providerModels[0])
}
target, ok := mustUpgradeTarget(t, "claude-haiku-4.5")
if !ok {
t.Skip("registry has no upgrade target for claude-haiku-4.5")
}
if providerModels[1] != target {
t.Fatalf("escalated provider model = %q, want %q", providerModels[1], target)
}
if len(builtProviders) != 2 {
t.Fatalf("built %d providers, want 2", len(builtProviders))
}
// The original (escalation-source) provider handled ONLY the escalation turn;
// the escalated (target) provider handled ONLY the post-switch answer turn.
// This FAILS if loop.go drops `provider = newProvider` (the second provider
// would then handle zero turns and the first would handle both).
if builtProviders[0].turns != 1 {
t.Fatalf("first (source) provider handled %d turns, want exactly 1 (the escalation turn)", builtProviders[0].turns)
}
if builtProviders[1].turns != 1 {
t.Fatalf("second (escalated) provider handled %d turns, want exactly 1 (the post-switch answer turn)", builtProviders[1].turns)
}
}
func mustUpgradeTarget(t *testing.T, id string) (string, bool) {
t.Helper()
registry, err := modelregistry.DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry: %v", err)
}
entry, ok := registry.UpgradeTarget(id)
return entry.ID, ok
}
func TestRunExecNoSwitcherWithoutFlag(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
cwd := t.TempDir()
var providerModels []string
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--model", "claude-haiku-4.5", "escalate please"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "claude-haiku-4.5"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
cfg := execResolvedConfig()
// The escalation chain (haiku -> sonnet) is anthropic; declare the
// provider kind to match so provider-metadata resolution accepts it.
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
cfg.Provider.Model = model
cfg.MaxTurns = 3
return cfg, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
providerModels = append(providerModels, profile.Model)
return &escalatingExecProvider{escalateOnce: true}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
if len(providerModels) != 1 {
t.Fatalf("newProvider called for %#v, want exactly 1 build (no switcher wired)", providerModels)
}
if providerModels[0] != "claude-haiku-4.5" {
t.Fatalf("provider model = %q, want claude-haiku-4.5", providerModels[0])
}
}
func TestRunExecAttributesUsageToEscalatedModel(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
cwd := t.TempDir()
target, ok := mustUpgradeTarget(t, "claude-haiku-4.5")
if !ok {
t.Skip("registry has no upgrade target for claude-haiku-4.5")
}
// The escalation turn (turn 0, pre-switch) emits usage with distinct tokens so
// it can be told apart from the post-switch usage; the first build escalates,
// the second answers.
builds := 0
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec",
"--allow-escalation",
"--model", "claude-haiku-4.5",
"--init-session-id", "escalation_run",
"escalate please",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "claude-haiku-4.5"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
cfg := execResolvedConfig()
// The escalation chain (haiku -> sonnet) is anthropic; declare the
// provider kind to match so provider-metadata resolution accepts it.
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
cfg.Provider.Model = model
cfg.MaxTurns = 3
return cfg, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
// First build = escalation source (escalate + usage 3/4 pre-switch);
// second build = escalation target (answer + usage 5/7 post-switch).
escalate := builds == 0
builds++
return &usageEmittingEscalatingProvider{escalate: escalate}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(dataHome, "zero", "sessions")})
events, err := store.ReadEvents("escalation_run")
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
// Collect every usage model attribution IN ORDER. The pre-switch usage must
// attribute to the ORIGINAL model and the post-switch usage to the escalated
// target — proving the loop's ordering guarantee (usage is attributed to the
// model that produced it, not retroactively reassigned after the switch).
var usageModels []string
for _, event := range events {
if event.Type != sessions.EventUsage {
continue
}
var payload map[string]any
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatalf("unmarshal usage payload: %v", err)
}
model, _ := payload["model"].(string)
usageModels = append(usageModels, model)
}
if len(usageModels) != 2 {
t.Fatalf("recorded usage model attributions = %#v, want exactly 2 (pre- and post-switch)", usageModels)
}
if usageModels[0] != "claude-haiku-4.5" {
t.Fatalf("first (pre-switch) usage model = %q, want claude-haiku-4.5", usageModels[0])
}
if usageModels[1] != target {
t.Fatalf("second (post-switch) usage model = %q, want escalated target %q", usageModels[1], target)
}
}
func TestRunExecNilSwitchProviderKeepsOriginalAttribution(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
cwd := t.TempDir()
if _, ok := mustUpgradeTarget(t, "claude-haiku-4.5"); !ok {
t.Skip("registry has no upgrade target for claude-haiku-4.5")
}
// The escalation rebuild returns (nil, nil): the loop must NOT swap the
// provider, and the CLI switcher must leave currentModel unchanged so usage
// after the declined switch stays attributed to the ORIGINAL model. Without
// the switchedProvider != nil guard, currentModel would advance to the target
// even though no switch happened, misattributing the post-turn usage.
builds := 0
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec",
"--allow-escalation",
"--model", "claude-haiku-4.5",
"--init-session-id", "nil_switch_run",
"escalate please",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "claude-haiku-4.5"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
cfg := execResolvedConfig()
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
cfg.Provider.Model = model
cfg.MaxTurns = 3
return cfg, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
builds++
// First build = the original (haiku). The escalation rebuild returns
// (nil, nil), so the loop keeps the original provider for every turn.
if builds == 1 {
return &escalateThenAnswerProvider{}, nil
}
return nil, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(dataHome, "zero", "sessions")})
events, err := store.ReadEvents("nil_switch_run")
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
sawUsage := false
for _, event := range events {
if event.Type != sessions.EventUsage {
continue
}
var payload map[string]any
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatalf("unmarshal usage payload: %v", err)
}
sawUsage = true
if model, _ := payload["model"].(string); model != "claude-haiku-4.5" {
t.Fatalf("usage attributed to %q after a (nil,nil) switch, want original claude-haiku-4.5", model)
}
}
if !sawUsage {
t.Fatal("expected at least one usage event")
}
}
// escalateThenAnswerProvider escalates on its first turn and answers afterward,
// so a single instance can serve an entire run when no provider swap occurs
// (e.g. a (nil,nil) switcher). It emits usage on every turn for attribution.
type escalateThenAnswerProvider struct {
turns int
}
func (provider *escalateThenAnswerProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
turn := provider.turns
provider.turns++
ch := make(chan zeroruntime.StreamEvent, 6)
if turn == 0 {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_escalate", ToolName: "escalate_model"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_escalate", ArgumentsFragment: "{}"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_escalate"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 3, OutputTokens: 4}}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 5, OutputTokens: 7}}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
// usageEmittingEscalatingProvider emits a usage event on every turn it serves so
// usage attribution can be traced across a mid-run switch. When escalate is set,
// its turn requests an escalation (and emits pre-switch usage tokens 3/4);
// otherwise it answers (post-switch usage tokens 5/7).
type usageEmittingEscalatingProvider struct {
escalate bool
}
func (provider *usageEmittingEscalatingProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
ch := make(chan zeroruntime.StreamEvent, 6)
if provider.escalate {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_escalate", ToolName: "escalate_model"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_escalate", ArgumentsFragment: "{}"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_escalate"}
// Pre-switch usage: still attributed to the ORIGINAL model.
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 3, OutputTokens: 4}}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
// Post-switch usage: attributed to the escalated model.
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 5, OutputTokens: 7}}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
// usageEmittingEchoProvider answers in a single turn and emits a usage event. It
// has no escalation behavior, so it exercises a plain (flag-OFF) run that still
// records usage.
type usageEmittingEchoProvider struct{}
func (usageEmittingEchoProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
ch := make(chan zeroruntime.StreamEvent, 3)
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 5, OutputTokens: 7}}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
// TestRunExecUsageOmitsModelKeyWithoutEscalationFlag verifies the back-compat
// guarantee: a run WITHOUT --allow-escalation persists EventUsage payloads that
// carry NO "model" key (byte-identical to before the escalation feature), since
// the model can never change mid-run when escalation is off. The flag-ON,
// post-escalation case is covered by TestRunExecAttributesUsageToEscalatedModel.
func TestRunExecUsageOmitsModelKeyWithoutEscalationFlag(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
cwd := t.TempDir()
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec",
"--model", "claude-haiku-4.5",
"--init-session-id", "no_escalation_run",
"hello",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "claude-haiku-4.5"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
cfg := execResolvedConfig()
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
cfg.Provider.Model = model
cfg.MaxTurns = 3
return cfg, nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return usageEmittingEchoProvider{}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(dataHome, "zero", "sessions")})
events, err := store.ReadEvents("no_escalation_run")
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
var sawUsage bool
for _, event := range events {
if event.Type != sessions.EventUsage {
continue
}
sawUsage = true
var payload map[string]any
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatalf("unmarshal usage payload: %v", err)
}
if _, ok := payload["model"]; ok {
t.Fatalf("flag-OFF usage payload must NOT carry a model key, got %#v", payload)
}
}
if !sawUsage {
t.Fatal("expected at least one usage event to be recorded")
}
}
// usageThenAnswerProvider serves a two-turn run on ONE instance: turn 0 requests
// an escalation and emits usage, turn 1 answers and emits usage. It is used by
// the switch-error test where no second provider is ever built, so a single
// instance must serve both turns on the original model.
type usageThenAnswerProvider struct {
turns int
}
func (provider *usageThenAnswerProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
turn := provider.turns
provider.turns++
ch := make(chan zeroruntime.StreamEvent, 6)
if turn == 0 {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_escalate", ToolName: "escalate_model"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_escalate", ArgumentsFragment: "{}"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_escalate"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 3, OutputTokens: 4}}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 5, OutputTokens: 7}}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
// TestRunExecSwitcherErrorKeepsOriginalModelAttribution verifies that when the
// ModelSwitcher fails on the rebuild (deps.newProvider errors for the escalation
// target), the run is NON-FATAL and stays on the original provider — and every
// usage event, including the one AFTER the failed switch, stays attributed to the
// ORIGINAL model (currentModel is never reassigned on a failed switch).
func TestRunExecSwitcherErrorKeepsOriginalModelAttribution(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
cwd := t.TempDir()
if _, ok := mustUpgradeTarget(t, "claude-haiku-4.5"); !ok {
t.Skip("registry has no upgrade target for claude-haiku-4.5")
}
builds := 0
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec",
"--allow-escalation",
"--model", "claude-haiku-4.5",
"--init-session-id", "switch_error_run",
"escalate please",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "claude-haiku-4.5"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
cfg := execResolvedConfig()
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
cfg.Provider.Model = model
cfg.MaxTurns = 3
return cfg, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
builds++
// The first build (original model) succeeds; the rebuild on escalation
// FAILS, so the switcher returns an error and the run continues on the
// original provider.
if builds >= 2 {
return nil, errors.New("provider rebuild failed")
}
return &usageThenAnswerProvider{}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected non-fatal switch error, exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
if builds != 2 {
t.Fatalf("newProvider builds = %d, want 2 (initial + one failed rebuild attempt)", builds)
}
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(dataHome, "zero", "sessions")})
events, err := store.ReadEvents("switch_error_run")
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
var usageModels []string
for _, event := range events {
if event.Type != sessions.EventUsage {
continue
}
var payload map[string]any
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatalf("unmarshal usage payload: %v", err)
}
model, _ := payload["model"].(string)
usageModels = append(usageModels, model)
}
if len(usageModels) != 2 {
t.Fatalf("recorded usage attributions = %#v, want 2", usageModels)
}
// Both usages (pre- AND post-failed-switch) stay on the ORIGINAL model.
for i, model := range usageModels {
if model != "claude-haiku-4.5" {
t.Fatalf("usage[%d] model = %q, want claude-haiku-4.5 (currentModel must not change on a failed switch)", i, model)
}
}
}
// TestRunExecTopTierDeclineNoSwitch verifies an end-to-end flag-ON run where the
// agent calls escalate_model while ALREADY on a top-tier model (claude-opus-4.1):
// the tool returns the informational no-meta result, NO switch happens
// (newProvider is called exactly once), and the run succeeds.
func TestParseExecNotifyFlag(t *testing.T) {
opts, _, err := parseExecArgs([]string{"--notify", "both", "hello"})
if err != nil {
t.Fatalf("parse: %v", err)
}
if opts.notifyMode != "both" || opts.noNotify {
t.Fatalf("got mode=%q noNotify=%v", opts.notifyMode, opts.noNotify)
}
}
func TestParseExecNoNotifyFlag(t *testing.T) {
opts, _, err := parseExecArgs([]string{"--no-notify", "hello"})
if err != nil {
t.Fatalf("parse: %v", err)
}
if !opts.noNotify {
t.Fatal("expected noNotify=true")
}
}
func TestParseExecNotifyConflict(t *testing.T) {
if _, _, err := parseExecArgs([]string{"--notify", "bell", "--no-notify", "hi"}); err == nil {
t.Fatal("expected error for --notify with --no-notify")
}
}
func TestParseExecNotifyInvalidValue(t *testing.T) {
for _, arg := range [][]string{{"--notify", "buzz", "hi"}, {"--notify=loud", "hi"}} {
if _, _, err := parseExecArgs(arg); err == nil {
t.Fatalf("expected error for invalid notify value in %v", arg)
}
}
// Valid values still parse.
for _, mode := range []string{"off", "bell", "notify", "both"} {
if _, _, err := parseExecArgs([]string{"--notify", mode, "hi"}); err != nil {
t.Fatalf("valid --notify %s rejected: %v", mode, err)
}
}
}
func TestExecNotifyModeResolution(t *testing.T) {
resolved := config.ResolvedConfig{Notify: config.NotifyConfig{Mode: "bell"}}
if got := execNotifyMode(execOptions{}, resolved); got != "bell" {
t.Fatalf("config passthrough got %q", got)
}
if got := execNotifyMode(execOptions{notifyMode: "both"}, resolved); got != "both" {
t.Fatalf("flag override got %q", got)
}
if got := execNotifyMode(execOptions{noNotify: true}, resolved); got != "off" {
t.Fatalf("--no-notify got %q", got)
}
}
func TestRunExecTopTierDeclineNoSwitch(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
cwd := t.TempDir()
// Sanity: claude-opus-4.1 must genuinely be top-tier (no upgrade target), or
// the scenario is meaningless.
if _, ok := mustUpgradeTarget(t, "claude-opus-4.1"); ok {
t.Skip("registry unexpectedly has an upgrade target for claude-opus-4.1")
}
var providerModels []string
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec",
"--allow-escalation",
"--model", "claude-opus-4.1",
"escalate please",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "claude-opus-4.1"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
cfg := execResolvedConfig()
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
cfg.Provider.Model = model
cfg.MaxTurns = 3
return cfg, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
providerModels = append(providerModels, profile.Model)
return &escalatingExecProvider{escalateOnce: true}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
// The escalate_model call resolved to a no-target informational result, so the
// loop never requested a switch and newProvider was built exactly once.
if len(providerModels) != 1 {
t.Fatalf("newProvider built for %#v, want exactly 1 (top-tier decline performs no switch)", providerModels)
}
if providerModels[0] != "claude-opus-4.1" {
t.Fatalf("provider model = %q, want claude-opus-4.1", providerModels[0])
}
}
+23 -1
View File
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/specmode"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -34,7 +35,11 @@ func validateExecToolFilters(options execOptions, registry *tools.Registry) erro
if !toolNamePattern.MatchString(name) {
return execUsageError{fmt.Sprintf("Invalid tool name %q.", name)}
}
if _, ok := registry.Get(name); !ok {
// tool_search is registered later (only when deferral activates), so it is
// not in the registry yet at validation time. Treat it as always-valid so an
// operator can list it in --enabled-tools / --disabled-tools without a
// spurious "Unknown tool" error.
if _, ok := registry.Get(name); !ok && name != tools.ToolSearchToolName {
return execUsageError{fmt.Sprintf("Unknown tool: %s", name)}
}
}
@@ -47,9 +52,26 @@ func validateExecToolFilters(options execOptions, registry *tools.Registry) erro
return execUsageError{fmt.Sprintf("Tool cannot be both enabled and disabled: %s", name)}
}
}
if options.useSpec {
if disabled[specmode.SubmitToolName] {
return execUsageError{"--use-spec requires submit_spec; remove it from --disabled-tools."}
}
if len(options.enabledTools) > 0 && !toolListContains(options.enabledTools, specmode.SubmitToolName) {
return execUsageError{"--use-spec requires submit_spec; include it in --enabled-tools."}
}
}
return nil
}
func toolListContains(names []string, want string) bool {
for _, name := range names {
if name == want {
return true
}
}
return false
}
func resolveExecPermissionMode(options execOptions) (agent.PermissionMode, error) {
// Validate --auto first, regardless of --skip-permissions-unsafe, so an
// invalid autonomy value is always rejected. (Previously the unsafe path
+2
View File
@@ -390,6 +390,8 @@ func streamJSONSideEffect(name string, registry *tools.Registry) string {
return "shell"
case tools.SideEffectNetwork:
return "network"
case tools.SideEffectNone:
return "none"
default:
return "unknown"
}
+18
View File
@@ -0,0 +1,18 @@
package cli
import (
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
// escalate_model is a control-only tool (SideEffectNone). The stream-json tool
// call must report sideEffect "none", not "unknown", so automation sees the
// promised value.
func TestStreamJSONSideEffectReportsNoneForControlTool(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewEscalateModelTool())
if got := streamJSONSideEffect("escalate_model", registry); got != "none" {
t.Fatalf("streamJSONSideEffect(escalate_model) = %q, want none", got)
}
}
+366
View File
@@ -0,0 +1,366 @@
package cli
import (
"fmt"
"io"
"strings"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/specmode"
)
type specCommandOptions struct {
json bool
comment string
commentProvided bool
reason string
reasonProvided bool
}
type specCommandResult struct {
Status string `json:"status"`
SpecID string `json:"specId,omitempty"`
SpecFilePath string `json:"specFilePath,omitempty"`
DraftSessionID string `json:"draftSessionId,omitempty"`
ImplementationSessionID string `json:"implementationSessionId,omitempty"`
Message string `json:"message,omitempty"`
Next string `json:"next,omitempty"`
}
func runSpec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
command, target, options, help, err := parseSpecArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeSpecHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
store := deps.newSessionStore()
draft, err := resolveSpecReviewTarget(store, target)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
switch command {
case "show":
return runSpecShow(store, draft, options, stdout, stderr)
case "approve":
return runSpecApprove(store, draft, options, stdout, stderr)
case "reject":
return runSpecReject(store, draft, options, stdout, stderr)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown spec command %q", command))
}
}
func parseSpecArgs(args []string) (string, string, specCommandOptions, bool, error) {
options := specCommandOptions{}
if len(args) == 0 {
return "", "", options, false, execUsageError{"spec command required. Use `zero spec show <spec>`."}
}
command := ""
target := ""
for index := 0; index < len(args); index++ {
arg := args[index]
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return command, target, options, true, nil
case arg == "--json":
options.json = true
case arg == "--comment":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return command, target, options, false, err
}
options.comment = value
options.commentProvided = true
index = next
case strings.HasPrefix(arg, "--comment="):
value, err := requiredInlineFlagValue(arg, "--comment")
if err != nil {
return command, target, options, false, err
}
options.comment = value
options.commentProvided = true
case arg == "--reason":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return command, target, options, false, err
}
options.reason = value
options.reasonProvided = true
index = next
case strings.HasPrefix(arg, "--reason="):
value, err := requiredInlineFlagValue(arg, "--reason")
if err != nil {
return command, target, options, false, err
}
options.reason = value
options.reasonProvided = true
case strings.HasPrefix(arg, "-"):
return command, target, options, false, execUsageError{fmt.Sprintf("unknown spec flag %q", arg)}
default:
if command == "" {
command = arg
continue
}
if target == "" {
target = arg
continue
}
return command, target, options, false, execUsageError{fmt.Sprintf("unexpected spec argument %q", arg)}
}
}
command = strings.TrimSpace(command)
switch command {
case "show", "approve", "reject":
default:
return command, target, options, false, execUsageError{fmt.Sprintf("unknown spec command %q", command)}
}
if strings.TrimSpace(target) == "" {
return command, target, options, false, execUsageError{fmt.Sprintf("zero spec %s requires a spec id or draft session id", command)}
}
if options.commentProvided && command != "approve" {
return command, target, options, false, execUsageError{"--comment is only valid for zero spec approve"}
}
if options.reasonProvided && command != "reject" {
return command, target, options, false, execUsageError{"--reason is only valid for zero spec reject"}
}
return command, strings.TrimSpace(target), options, false, nil
}
func resolveSpecReviewTarget(store *sessions.Store, target string) (sessions.Metadata, error) {
target = strings.TrimSpace(target)
if target == "" {
return sessions.Metadata{}, fmt.Errorf("spec id or draft session id is required")
}
if sessions.ValidSessionID(target) {
session, err := store.Get(target)
if err != nil {
return sessions.Metadata{}, err
}
if session != nil {
if session.SpecID == "" || session.SpecFilePath == "" {
return sessions.Metadata{}, fmt.Errorf("zero session has no recorded spec: %s", redact(target))
}
return *session, nil
}
}
items, err := store.List()
if err != nil {
return sessions.Metadata{}, err
}
matches := []sessions.Metadata{}
draftMatches := []sessions.Metadata{}
for _, item := range items {
if item.SpecID == target && item.SpecFilePath != "" {
matches = append(matches, item)
if item.SessionKind == sessions.SessionKindSpecDraft {
draftMatches = append(draftMatches, item)
}
}
}
if len(draftMatches) == 1 {
return draftMatches[0], nil
}
if len(draftMatches) > 1 {
return sessions.Metadata{}, fmt.Errorf("zero spec id is ambiguous: %s; use the draft session id", redact(target))
}
if len(matches) == 0 {
return sessions.Metadata{}, fmt.Errorf("zero spec not found: %s", redact(target))
}
if len(matches) > 1 {
return sessions.Metadata{}, fmt.Errorf("zero spec id is ambiguous: %s; use the draft session id", redact(target))
}
return matches[0], nil
}
func runSpecShow(store *sessions.Store, draft sessions.Metadata, options specCommandOptions, stdout io.Writer, stderr io.Writer) int {
body, path, err := specmode.LoadSpecFile(draft.Cwd, draft.SpecFilePath)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if options.json {
payload := map[string]any{
"specId": draft.SpecID,
"specFilePath": path,
"draftSessionId": draft.SessionID,
"specStatus": draft.SpecStatus,
"content": body,
}
if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, body); err != nil {
return exitCrash
}
return exitSuccess
}
func runSpecApprove(store *sessions.Store, draft sessions.Metadata, options specCommandOptions, stdout io.Writer, stderr io.Writer) int {
if draft.SessionKind != sessions.SessionKindSpecDraft {
return writeExecUsageError(stderr, "zero spec approve requires a spec-draft session")
}
if draft.SpecStatus == sessions.SpecStatusRejected {
return writeExecUsageError(stderr, "zero spec approve cannot approve a rejected spec")
}
if draft.SpecStatus == sessions.SpecStatusApproved && draft.SpecImplSessionID != "" {
return writeSpecResult(stdout, options, specCommandResult{
Status: string(sessions.SpecStatusApproved),
SpecID: draft.SpecID,
SpecFilePath: draft.SpecFilePath,
DraftSessionID: draft.SessionID,
ImplementationSessionID: draft.SpecImplSessionID,
Message: "Spec already approved.",
Next: "zero exec --resume " + draft.SpecImplSessionID + ` "Start implementation"`,
})
}
body, path, err := specmode.LoadSpecFile(draft.Cwd, draft.SpecFilePath)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
prompt := specmode.ImplementationPrompt(body, path, draft.SessionID, options.comment)
impl, _, err := store.EnsureSpecImplementation(sessions.EnsureSpecImplementationInput{
Title: specImplementationTitle(draft),
Cwd: draft.Cwd,
ModelID: draft.ModelID,
Provider: draft.Provider,
RootSessionID: firstNonEmptyString(draft.RootSessionID, draft.SessionID),
SpecID: draft.SpecID,
SpecFilePath: path,
SpecDraftModelID: draft.SpecDraftModelID,
SpecDraftReasoning: draft.SpecDraftReasoning,
SpecUserComment: options.comment,
SpecSourceSessionID: draft.SessionID,
Prompt: prompt,
})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
updated, _, err := store.RecordSpec(draft.SessionID, sessions.RecordSpecInput{
SpecID: draft.SpecID,
SpecFilePath: path,
SpecStatus: sessions.SpecStatusApproved,
SpecDraftModelID: draft.SpecDraftModelID,
SpecDraftReasoning: draft.SpecDraftReasoning,
SpecUserComment: options.comment,
SpecImplSessionID: impl.SessionID,
SpecSourceSessionID: draft.SessionID,
})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
return writeSpecResult(stdout, options, specCommandResult{
Status: string(updated.SpecStatus),
SpecID: updated.SpecID,
SpecFilePath: updated.SpecFilePath,
DraftSessionID: updated.SessionID,
ImplementationSessionID: impl.SessionID,
Message: "Spec approved.",
Next: "zero exec --resume " + impl.SessionID + ` "Start implementation"`,
})
}
func runSpecReject(store *sessions.Store, draft sessions.Metadata, options specCommandOptions, stdout io.Writer, stderr io.Writer) int {
if draft.SessionKind != sessions.SessionKindSpecDraft {
return writeExecUsageError(stderr, "zero spec reject requires a spec-draft session")
}
if draft.SpecStatus == sessions.SpecStatusApproved && draft.SpecImplSessionID != "" {
return writeExecUsageError(stderr, "zero spec reject cannot reject an approved spec with an implementation session")
}
updated, _, err := store.RecordSpec(draft.SessionID, sessions.RecordSpecInput{
SpecID: draft.SpecID,
SpecFilePath: draft.SpecFilePath,
SpecStatus: sessions.SpecStatusRejected,
SpecDraftModelID: draft.SpecDraftModelID,
SpecDraftReasoning: draft.SpecDraftReasoning,
SpecRejectReason: options.reason,
SpecSourceSessionID: draft.SessionID,
})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
return writeSpecResult(stdout, options, specCommandResult{
Status: string(updated.SpecStatus),
SpecID: updated.SpecID,
SpecFilePath: updated.SpecFilePath,
DraftSessionID: updated.SessionID,
Message: "Spec rejected.",
Next: "Run zero exec --use-spec again with a revised task.",
})
}
func writeSpecResult(stdout io.Writer, options specCommandOptions, result specCommandResult) int {
if options.json {
if err := writePrettyJSON(stdout, redaction.RedactValue(result, redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
lines := []string{result.Message}
if result.SpecID != "" {
lines = append(lines, " spec: "+redact(result.SpecID))
}
if result.SpecFilePath != "" {
lines = append(lines, " path: "+redact(result.SpecFilePath))
}
if result.DraftSessionID != "" {
lines = append(lines, " draft session: "+redact(result.DraftSessionID))
}
if result.ImplementationSessionID != "" {
lines = append(lines, " implementation session: "+redact(result.ImplementationSessionID))
}
if result.Next != "" {
lines = append(lines, "Next: "+redact(result.Next))
}
if _, err := fmt.Fprintln(stdout, strings.Join(lines, "\n")); err != nil {
return exitCrash
}
return exitSuccess
}
func specImplementationTitle(draft sessions.Metadata) string {
title := strings.TrimSpace(draft.Title)
if title == "" {
title = strings.TrimSpace(draft.SpecID)
}
if title == "" {
return "Spec implementation"
}
return title + " implementation"
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func writeSpecHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero spec show <spec-id|draft-session-id> [--json]
zero spec approve <spec-id|draft-session-id> [--comment <text>] [--json]
zero spec reject <spec-id|draft-session-id> [--reason <text>] [--json]
Commands:
show Print the saved draft spec
approve Mark a draft approved and create a spec implementation session
reject Mark a draft rejected
Flags:
--json Print JSON output
--comment <text> Approval note to include in the implementation prompt
--reason <text> Rejection reason
-h, --help Show this help
`)
return err
}
+201
View File
@@ -0,0 +1,201 @@
package cli
import (
"bytes"
"encoding/json"
"os"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/specmode"
)
func TestRunSpecApproveCreatesImplementationSession(t *testing.T) {
store, deps, draft := seedSpecDraft(t)
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"spec", "approve", draft.SpecID, "--comment", "Keep changes focused.", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d stderr=%s", exitCode, stderr.String())
}
var payload specCommandResult
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode approve json: %v\n%s", err, stdout.String())
}
if payload.Status != string(sessions.SpecStatusApproved) || payload.ImplementationSessionID == "" {
t.Fatalf("unexpected approve payload: %#v", payload)
}
updatedDraft, err := store.Get(draft.SessionID)
if err != nil {
t.Fatal(err)
}
if updatedDraft.SpecStatus != sessions.SpecStatusApproved || updatedDraft.SpecImplSessionID != payload.ImplementationSessionID {
t.Fatalf("draft metadata not updated: %#v", updatedDraft)
}
impl, err := store.Get(payload.ImplementationSessionID)
if err != nil {
t.Fatal(err)
}
if impl == nil || impl.SessionKind != sessions.SessionKindSpecImpl || impl.SpecSourceSessionID != draft.SessionID {
t.Fatalf("unexpected impl session: %#v", impl)
}
events, err := store.ReadEvents(payload.ImplementationSessionID)
if err != nil {
t.Fatal(err)
}
if len(events) != 1 || events[0].Type != sessions.EventMessage {
t.Fatalf("implementation events = %#v", events)
}
if !strings.Contains(string(events[0].Payload), "Keep changes focused.") || !strings.Contains(string(events[0].Payload), "# Goal") {
t.Fatalf("implementation prompt missing approved spec context: %s", string(events[0].Payload))
}
}
func TestRunSpecApproveIsIdempotent(t *testing.T) {
store, deps, draft := seedSpecDraft(t)
var first bytes.Buffer
if exitCode := runWithDeps([]string{"spec", "approve", draft.SessionID, "--json"}, &first, &bytes.Buffer{}, deps); exitCode != exitSuccess {
t.Fatalf("first approve exit = %d", exitCode)
}
var firstPayload specCommandResult
if err := json.Unmarshal(first.Bytes(), &firstPayload); err != nil {
t.Fatal(err)
}
var second bytes.Buffer
if exitCode := runWithDeps([]string{"spec", "approve", draft.SpecID, "--json"}, &second, &bytes.Buffer{}, deps); exitCode != exitSuccess {
t.Fatalf("second approve exit = %d", exitCode)
}
var secondPayload specCommandResult
if err := json.Unmarshal(second.Bytes(), &secondPayload); err != nil {
t.Fatal(err)
}
if secondPayload.ImplementationSessionID != firstPayload.ImplementationSessionID {
t.Fatalf("approve created a second implementation session: first=%q second=%q", firstPayload.ImplementationSessionID, secondPayload.ImplementationSessionID)
}
items, err := store.List()
if err != nil {
t.Fatal(err)
}
implCount := 0
for _, item := range items {
if item.SessionKind == sessions.SessionKindSpecImpl {
implCount++
}
}
if implCount != 1 {
t.Fatalf("implementation session count = %d, want 1", implCount)
}
}
func TestRunSpecRejectMarksDraftRejected(t *testing.T) {
store, deps, draft := seedSpecDraft(t)
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"spec", "reject", draft.SpecID, "--reason=Needs narrower scope"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d stderr=%s", exitCode, stderr.String())
}
updated, err := store.Get(draft.SessionID)
if err != nil {
t.Fatal(err)
}
if updated.SpecStatus != sessions.SpecStatusRejected || updated.SpecRejectReason != "Needs narrower scope" {
t.Fatalf("draft metadata not rejected: %#v", updated)
}
if !strings.Contains(stdout.String(), "Spec rejected.") {
t.Fatalf("stdout = %q", stdout.String())
}
}
func TestParseSpecRejectsCommandSpecificFlags(t *testing.T) {
for _, tc := range []struct {
name string
args []string
want string
}{
{
name: "comment on show",
args: []string{"show", "draft", "--comment", "ship it"},
want: "--comment is only valid for zero spec approve",
},
{
name: "reason on approve",
args: []string{"approve", "draft", "--reason", "too broad"},
want: "--reason is only valid for zero spec reject",
},
} {
t.Run(tc.name, func(t *testing.T) {
_, _, _, _, err := parseSpecArgs(tc.args)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("expected %q validation, got %v", tc.want, err)
}
})
}
}
func TestRunSpecShowPrintsSavedDraft(t *testing.T) {
_, deps, draft := seedSpecDraft(t)
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"spec", "show", draft.SessionID}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "# Goal") || !strings.Contains(stdout.String(), "Add review flow.") {
t.Fatalf("stdout = %q", stdout.String())
}
}
func seedSpecDraft(t *testing.T) (*sessions.Store, appDeps, sessions.Metadata) {
t.Helper()
workspaceRoot := t.TempDir()
saved, err := specmode.SaveDraft(specmode.SaveOptions{
WorkspaceRoot: workspaceRoot,
Title: "Review Flow",
Plan: "# Goal\n\nAdd review flow.",
Now: fixedCLISpecTime,
})
if err != nil {
t.Fatal(err)
}
storeRoot := t.TempDir()
store := sessions.NewStore(sessions.StoreOptions{RootDir: storeRoot, Now: fixedCLISpecTime})
draft, err := store.Create(sessions.CreateInput{
SessionKind: sessions.SessionKindSpecDraft,
Title: "Review flow",
Cwd: workspaceRoot,
ModelID: "test-model",
Provider: "test",
SpecID: saved.ID,
SpecFilePath: saved.Path,
SpecStatus: sessions.SpecStatusDraft,
SpecDraftModelID: "test-model",
SpecDraftReasoning: "high",
})
if err != nil {
t.Fatal(err)
}
deps := appDeps{
newSessionStore: func() *sessions.Store {
return store
},
}
t.Cleanup(func() {
_ = os.RemoveAll(storeRoot)
})
return store, deps, draft
}
func fixedCLISpecTime() time.Time {
return time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
}
+51 -18
View File
@@ -21,11 +21,14 @@ func runZeroline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
fs.SetOutput(stderr)
snapshot := fs.Bool("snapshot", false, "render a single frame to stdout and exit (no TTY)")
page := fs.String("page", "home", "snapshot page: home|chat")
variant := fs.Int("variant", 1, "color theme 1-5 (1 Phosphor, 2 Cyan, 3 Sage, 4 Violet, 5 Mono)")
variant := fs.Int("variant", 0, "color theme 0-5 (0 ZERO, 1 Phosphor, 2 Cyan, 3 Sage, 4 Violet, 5 Mono)")
light := fs.Bool("light", false, "use the light variant for the snapshot")
perm := fs.Bool("perm", false, "show the centered permission modal in the chat snapshot")
boot := fs.Int("boot", -1, "render the boot splash at the given animation frame")
stream := fs.Bool("stream", false, "show a streaming assistant response in the chat snapshot")
jsonMode := fs.Bool("json", false, "render the chat snapshot in JSON mode (TEXT/JSON toggle)")
sessionsDrawer := fs.Bool("sessions", false, "show the sessions drawer in the chat snapshot")
tool := fs.String("tool", "", "show a single tool card in the chat snapshot: diff|read|bash|grep")
frameN := fs.Int("frame", 0, "animation frame for the chat snapshot (spinner)")
width := fs.Int("width", 100, "snapshot width")
height := fs.Int("height", 30, "snapshot height")
skipUnsafe := fs.Bool("skip-permissions-unsafe", false, "launch in unsafe permission mode (enables the ! shell escape)")
@@ -34,16 +37,10 @@ func runZeroline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
}
if *snapshot {
v := *variant - 1
v := *variant // theme index directly (0 ZERO, 1 Phosphor, …)
if v < 0 || v >= len(zeroline.Themes) {
v = 0
}
if *boot >= 0 {
if _, err := fmt.Fprintln(stdout, zeroline.RenderBoot(v, !*light, *boot, *width, *height)); err != nil {
return 1
}
return 0
}
hdr := zeroline.Header{Cwd: "~/src/zero", Branch: "main", Model: "claude-sonnet-4.5", Provider: "anthropic"}
var frame string
if *page == "chat" {
@@ -51,19 +48,29 @@ func runZeroline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
Variant: v, Dark: !*light, Width: *width, Height: *height, Header: hdr,
Rows: []zeroline.Row{
{Kind: "user", Text: "refactor internal/agent/loop.go to extract tool execution"},
{Kind: "toolcall", Tool: "list_directory", Detail: "internal/agent"},
{Kind: "toolresult", Tool: "list_directory", Status: "ok", Detail: "Contents of internal/agent:\n\nloop.go\ntypes.go\nloop_test.go"},
{Kind: "toolcall", Tool: "read_file", Detail: "internal/agent/loop.go"},
{Kind: "toolresult", Tool: "read_file", Status: "ok", Detail: "File: internal/agent/loop.go (164 lines)\n\n118 | func (l *Loop) run(ctx context.Context) error {"},
{Kind: "toolcall", Tool: "edit_file", Detail: "exec.go (new) · loop.go"},
{Kind: "toolresult", Tool: "edit_file", Status: "ok", Detail: "--- a/internal/agent/loop.go\n+++ b/internal/agent/exec.go\n@@ -141,6 +141,3 @@\n-\tswitch t := call.Tool.(type) {\n-\tcase ReadFileTool: out, err = l.readFile(ctx, t)\n+\tout, err := l.exec.Dispatch(call)"},
{Kind: "tool", Tool: "grep", Text: "internal/agent", Status: "ok", Detail: "internal/agent/loop.go:88:\tfor i, call := range reply.ToolCalls {\ninternal/agent/loop.go:90:\t\tgo func() {"},
{Kind: "tool", Tool: "read_file", Text: "internal/agent/loop.go", Status: "ok", Detail: "func (l *Loop) run(ctx context.Context) error {\n\tfor step := 0; step < l.MaxSteps; step++ {\n\t\treply, _, err := l.model.Complete(ctx, msgs, l.tools)"},
{Kind: "tool", Tool: "edit_file", Text: "internal/agent/exec.go", Status: "ok", Detail: " func (l *Loop) run(ctx context.Context) error {\n-\tswitch t := call.Tool.(type) {\n-\tcase ReadFileTool: out, err = l.readFile(ctx, t)\n+\tout, err := l.exec.Dispatch(call)"},
{Kind: "tool", Tool: "bash", Text: "go test ./...", Status: "ok", Detail: "ok github.com/zero-dev/zero/internal/agent\t0.061s\nok github.com/zero-dev/zero/internal/tools\t0.140s"},
{Kind: "assistant", Text: "Done. Extracted a `ToolExecutor`:\n\n```go\nfunc (e *ToolExecutor) Dispatch(c Call) (Out, error) {\n\treturn e.route(c)\n}\n```\n\nThe switch in loop.go now delegates to one call. Tests pass."},
{Kind: "final", Text: "Extracted tool dispatch into a ToolExecutor; loop.go now delegates. go test ./... is green."},
{Kind: "done", Text: "4 tools · 1,284 tok · $0.04", Status: "ok"},
},
Input: "❯ ",
Input: "add a test for the new ToolExecutor",
Spin: *frameN,
}
if *tool != "" {
cd.Rows = toolSnapshotRows(*tool)
}
if *perm {
cd.Perm = &zeroline.Perm{Tool: "edit_file", Risk: "medium", Reason: "writes internal/agent/exec.go and loop.go", Summary: "write"}
}
if *jsonMode {
cd.JSONMode = true
}
if *sessionsDrawer {
cd.Drawer = &zeroline.Drawer{Sessions: zeroline.DefaultSessions()}
}
if *stream {
cd.Rows = cd.Rows[:len(cd.Rows)-1] // drop the final assistant row
cd.Working = true
@@ -72,9 +79,11 @@ func runZeroline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
}
frame = zeroline.RenderChat(cd)
} else {
frame = zeroline.RenderHome(zeroline.HomeData{
frame = zeroline.RenderChat(zeroline.ChatData{
Variant: v, Dark: !*light, Width: *width, Height: *height, Header: hdr,
Input: "❯ message zero — / commands · @ files · ! bash",
Input: "describe a task for zero…",
Chips: zeroline.DefaultChips(),
ChipIndex: 0,
})
}
if _, err := fmt.Fprintln(stdout, frame); err != nil {
@@ -89,3 +98,27 @@ func runZeroline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
}
return runInteractiveTUIWithSkin(stderr, deps, "zeroline", permissionMode)
}
// toolSnapshotRows builds a chat focused on a single tool card type, for
// `zeroline --snapshot --tool <kind>`.
func toolSnapshotRows(kind string) []zeroline.Row {
user := zeroline.Row{Kind: "user", Text: "show me the " + kind + " tool card"}
var tool zeroline.Row
switch kind {
case "diff":
tool = zeroline.Row{Kind: "tool", Tool: "edit_file", Text: "internal/cli/root.go", Status: "ok",
Detail: " \tfs.IntVar(&o.MaxSteps, \"max-steps\", 50, \"agent step cap\")\n+\tfs.BoolVar(&o.ShowVersion, \"version\", false, \"print version and exit\")\n \n+\tif o.ShowVersion {\n+\t\tfmt.Fprintln(o.Stdout, \"zero \"+buildinfo.Version)\n+\t}"}
case "read":
tool = zeroline.Row{Kind: "tool", Tool: "read_file", Text: "internal/agent/loop.go", Status: "ok",
Detail: "File: internal/agent/loop.go (164 lines)\n\n 1 | func (l *Loop) Run(ctx context.Context, task string) error {\n 2 | \tl.emit(Event{Type: EventStart})\n 3 | \tmsgs := l.seed(task)\n 4 | \tfor step := 0; step < l.MaxSteps; step++ {\n 5 | \t\treply, usage, err := l.model.Complete(ctx, msgs, l.tools)"}
case "bash":
tool = zeroline.Row{Kind: "tool", Tool: "bash", Text: "go test ./internal/cli/...", Status: "ok",
Detail: "ok \tgithub.com/zero-dev/zero/internal/cli\t0.214s"}
case "grep":
tool = zeroline.Row{Kind: "tool", Tool: "grep", Text: "internal/cli", Status: "ok",
Detail: "internal/cli/root.go:41:fs := flag.NewFlagSet(\"zero\", flag.ContinueOnError)\ninternal/cli/root.go:47:fs.StringVar(&o.Model, \"model\", env(\"ZERO_MODEL\"), \"model id\")"}
default:
tool = zeroline.Row{Kind: "tool", Tool: kind, Text: "(unknown tool kind: " + kind + ")", Status: "error", Detail: "supported: diff|read|bash|grep"}
}
return []zeroline.Row{user, tool, {Kind: "done", Text: "1 tool · 214 tok", Status: "ok"}}
}
+48
View File
@@ -0,0 +1,48 @@
package cli
import (
"bytes"
"strings"
"testing"
)
func TestZerolineSnapshotToolFlag(t *testing.T) {
cases := map[string][]string{
"diff": {"edit_file", "ShowVersion"},
"read": {"read_file", "Loop"},
"bash": {"bash", "exit 0"},
"grep": {"grep", "matches"},
}
for kind, wants := range cases {
var out, errb bytes.Buffer
code := runZeroline([]string{"--snapshot", "--page", "chat", "--tool", kind, "--width", "100", "--height", "20"}, &out, &errb, appDeps{})
if code != 0 {
t.Fatalf("--tool %s: exit %d, stderr=%s", kind, code, errb.String())
}
for _, w := range wants {
if !strings.Contains(out.String(), w) {
t.Errorf("--tool %s snapshot missing %q", kind, w)
}
}
}
}
func TestZerolineSnapshotModes(t *testing.T) {
for _, args := range [][]string{
{"--snapshot", "--page", "home"},
{"--snapshot", "--page", "chat"},
{"--snapshot", "--page", "chat", "--json"},
{"--snapshot", "--page", "chat", "--sessions"},
{"--snapshot", "--page", "chat", "--perm"},
{"--snapshot", "--page", "chat", "--stream", "--frame", "3"},
} {
var out, errb bytes.Buffer
full := append(args, "--width", "100", "--height", "24")
if code := runZeroline(full, &out, &errb, appDeps{}); code != 0 {
t.Fatalf("%v: exit %d, stderr=%s", args, code, errb.String())
}
if strings.TrimSpace(out.String()) == "" {
t.Errorf("%v produced empty output", args)
}
}
}
+56
View File
@@ -8,12 +8,15 @@ import (
"strings"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/sandbox"
)
const defaultMaxTurns = 12
const defaultDeferThreshold = 10
func Resolve(options ResolveOptions) (ResolvedConfig, error) {
cfg := FileConfig{
MaxTurns: defaultMaxTurns,
@@ -48,6 +51,13 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
applyOverrides(&cfg, options.Overrides)
if !cfg.Tools.deferThresholdSet && cfg.Tools.DeferThreshold == 0 {
cfg.Tools.DeferThreshold = defaultDeferThreshold
}
if cfg.Tools.DeferThreshold < 0 {
return ResolvedConfig{}, fmt.Errorf("invalid tools.deferThreshold %d: must be >= 0", cfg.Tools.DeferThreshold)
}
if maxAutonomy := strings.TrimSpace(cfg.Sandbox.MaxAutonomy); maxAutonomy != "" {
// Fail loud on an invalid ceiling. An unvalidated typo (e.g. "moderate")
// would otherwise survive Resolve untouched, reach the sandbox bridge,
@@ -57,6 +67,20 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
return ResolvedConfig{}, fmt.Errorf("invalid sandbox.maxAutonomy %q: expected low, medium, or high", maxAutonomy)
}
}
if mode := strings.TrimSpace(cfg.Notify.Mode); mode != "" {
switch notify.Mode(mode) {
case notify.ModeOff, notify.ModeBell, notify.ModeNotify, notify.ModeBoth:
default:
return ResolvedConfig{}, fmt.Errorf("invalid notify.mode %q: expected off, bell, notify, or both", mode)
}
}
if focusMode := strings.TrimSpace(cfg.Notify.FocusMode); focusMode != "" {
switch notify.FocusMode(focusMode) {
case notify.FocusUnfocused, notify.FocusAlways, notify.FocusFocused:
default:
return ResolvedConfig{}, fmt.Errorf("invalid notify.focusMode %q: expected unfocused, always, or focused", focusMode)
}
}
providers, active, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider, options.Env)
if err != nil {
@@ -70,6 +94,8 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
MaxTurns: cfg.MaxTurns,
MCP: cfg.MCP,
Sandbox: cfg.Sandbox,
Notify: cfg.Notify,
Tools: cfg.Tools,
}, nil
}
@@ -117,6 +143,16 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
if maxAutonomy := strings.TrimSpace(src.Sandbox.MaxAutonomy); maxAutonomy != "" {
dst.Sandbox.MaxAutonomy = maxAutonomy
}
if mode := strings.TrimSpace(src.Notify.Mode); mode != "" {
dst.Notify.Mode = mode
}
if focusMode := strings.TrimSpace(src.Notify.FocusMode); focusMode != "" {
dst.Notify.FocusMode = focusMode
}
if src.Tools.deferThresholdSet {
dst.Tools.DeferThreshold = src.Tools.DeferThreshold
dst.Tools.deferThresholdSet = true
}
}
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
@@ -137,6 +173,16 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
if maxAutonomy := strings.TrimSpace(src.Sandbox.MaxAutonomy); maxAutonomy != "" {
dst.Sandbox.MaxAutonomy = maxAutonomy
}
if mode := strings.TrimSpace(src.Notify.Mode); mode != "" {
dst.Notify.Mode = mode
}
if focusMode := strings.TrimSpace(src.Notify.FocusMode); focusMode != "" {
dst.Notify.FocusMode = focusMode
}
if src.Tools.deferThresholdSet {
dst.Tools.DeferThreshold = src.Tools.DeferThreshold
dst.Tools.deferThresholdSet = true
}
return nil
}
@@ -441,6 +487,16 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
if maxAutonomy := strings.TrimSpace(overrides.Sandbox.MaxAutonomy); maxAutonomy != "" {
cfg.Sandbox.MaxAutonomy = maxAutonomy
}
if mode := strings.TrimSpace(overrides.Notify.Mode); mode != "" {
cfg.Notify.Mode = mode
}
if focusMode := strings.TrimSpace(overrides.Notify.FocusMode); focusMode != "" {
cfg.Notify.FocusMode = focusMode
}
if overrides.Tools.deferThresholdSet || overrides.Tools.DeferThreshold != 0 {
cfg.Tools.DeferThreshold = overrides.Tools.DeferThreshold
cfg.Tools.deferThresholdSet = true
}
for _, provider := range overrides.Providers {
mergeProvider(cfg, provider)
}
+186
View File
@@ -1284,6 +1284,42 @@ func TestResolveSandboxMaxAutonomyValidResolves(t *testing.T) {
}
}
func TestResolveNotifyValid(t *testing.T) {
path := writeConfig(t, `{"notify":{"mode":"both","focusMode":"always"}}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved.Notify.Mode != "both" || resolved.Notify.FocusMode != "always" {
t.Fatalf("got %+v", resolved.Notify)
}
}
func TestResolveNotifyInvalidMode(t *testing.T) {
path := writeConfig(t, `{"notify":{"mode":"buzz"}}`)
if _, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}}); err == nil {
t.Fatal("expected error for invalid notify.mode")
}
}
func TestResolveNotifyInvalidFocusMode(t *testing.T) {
path := writeConfig(t, `{"notify":{"focusMode":"sideways"}}`)
if _, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}}); err == nil {
t.Fatal("expected error for invalid notify.focusMode")
}
}
func TestResolveNotifyDefaultEmpty(t *testing.T) {
path := writeConfig(t, `{}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved.Notify.Mode != "" || resolved.Notify.FocusMode != "" {
t.Fatalf("unset notify should be empty, got %+v", resolved.Notify)
}
}
func writeConfig(t *testing.T, body string) string {
t.Helper()
@@ -1305,3 +1341,153 @@ func providerByName(t *testing.T, providers []ProviderProfile, name string) Prov
t.Fatalf("provider %q not found in %#v", name, providers)
return ProviderProfile{}
}
func TestResolveDefaultsDeferThresholdWhenUnset(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "p",
"providers": [{"name": "p", "provider": "openai", "apiKey": "sk", "model": "m"}]
}`)
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Tools.DeferThreshold != defaultDeferThreshold {
t.Fatalf("Tools.DeferThreshold = %d, want %d", resolved.Tools.DeferThreshold, defaultDeferThreshold)
}
}
func TestResolveDeferThresholdFromFile(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "p",
"providers": [{"name": "p", "provider": "openai", "apiKey": "sk", "model": "m"}],
"tools": {"deferThreshold": 4}
}`)
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Tools.DeferThreshold != 4 {
t.Fatalf("Tools.DeferThreshold = %d, want 4", resolved.Tools.DeferThreshold)
}
}
func TestResolveDeferThresholdZeroDisablesViaFile(t *testing.T) {
// 0 is a valid, meaningful value (disabled). It must survive Resolve and NOT
// be re-defaulted to 10, so a user can explicitly disable deferral.
path := writeConfig(t, `{
"activeProvider": "p",
"providers": [{"name": "p", "provider": "openai", "apiKey": "sk", "model": "m"}],
"tools": {"deferThreshold": 0}
}`)
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Tools.DeferThreshold != 0 {
t.Fatalf("Tools.DeferThreshold = %d, want 0 (explicit disable preserved)", resolved.Tools.DeferThreshold)
}
}
func TestResolveDeferThresholdOverrideWins(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "p",
"providers": [{"name": "p", "provider": "openai", "apiKey": "sk", "model": "m"}],
"tools": {"deferThreshold": 4}
}`)
resolved, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{},
Overrides: Overrides{Tools: ToolsConfig{DeferThreshold: 20}},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Tools.DeferThreshold != 20 {
t.Fatalf("Tools.DeferThreshold = %d, want 20 (override wins)", resolved.Tools.DeferThreshold)
}
}
func TestResolveToolsOverrideDisablesDeferralOverNonZeroBase(t *testing.T) {
// A programmatic Override built via ToolsOverride(0) carries the presence flag,
// so it must override an explicit non-zero base threshold down to 0 (disabled).
// A bare ToolsConfig{DeferThreshold: 0} could not do this — it is indistinguishable
// from "unset" — which is exactly the trap ToolsOverride exists to avoid.
path := writeConfig(t, `{
"activeProvider": "p",
"providers": [{"name": "p", "provider": "openai", "apiKey": "sk", "model": "m"}],
"tools": {"deferThreshold": 4}
}`)
resolved, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{},
Overrides: Overrides{Tools: ToolsOverride(0)},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Tools.DeferThreshold != 0 {
t.Fatalf("Tools.DeferThreshold = %d, want 0 (ToolsOverride(0) disables deferral)", resolved.Tools.DeferThreshold)
}
}
func TestResolveToolsOverrideSetsNonZeroOverNonZeroBase(t *testing.T) {
// ToolsOverride(7) over an explicit non-zero base must win with the new value.
path := writeConfig(t, `{
"activeProvider": "p",
"providers": [{"name": "p", "provider": "openai", "apiKey": "sk", "model": "m"}],
"tools": {"deferThreshold": 4}
}`)
resolved, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{},
Overrides: Overrides{Tools: ToolsOverride(7)},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Tools.DeferThreshold != 7 {
t.Fatalf("Tools.DeferThreshold = %d, want 7 (ToolsOverride(7) wins)", resolved.Tools.DeferThreshold)
}
}
func TestApplyOverridesToolsOverrideZeroOverridesNonZero(t *testing.T) {
// Unit-level check on applyOverrides directly: ToolsOverride(0) must flip an
// explicit non-zero base to 0 (deferThresholdSet honored), while a bare
// non-zero ToolsConfig still overrides via the != 0 branch.
cfg := FileConfig{Tools: ToolsOverride(4)}
applyOverrides(&cfg, Overrides{Tools: ToolsOverride(0)})
if cfg.Tools.DeferThreshold != 0 {
t.Fatalf("applyOverrides ToolsOverride(0): DeferThreshold = %d, want 0", cfg.Tools.DeferThreshold)
}
if !cfg.Tools.deferThresholdSet {
t.Fatal("applyOverrides ToolsOverride(0): deferThresholdSet = false, want true")
}
cfg = FileConfig{Tools: ToolsOverride(4)}
applyOverrides(&cfg, Overrides{Tools: ToolsConfig{DeferThreshold: 5}})
if cfg.Tools.DeferThreshold != 5 {
t.Fatalf("applyOverrides bare non-zero: DeferThreshold = %d, want 5", cfg.Tools.DeferThreshold)
}
}
func TestResolveRejectsNegativeDeferThreshold(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "p",
"providers": [{"name": "p", "provider": "openai", "apiKey": "sk", "model": "m"}],
"tools": {"deferThreshold": -1}
}`)
_, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err == nil {
t.Fatal("Resolve() error = nil for negative deferThreshold, want failure")
}
if !strings.Contains(err.Error(), "tools.deferThreshold") {
t.Fatalf("Resolve() error = %v, want tools.deferThreshold rejection", err)
}
}
+45
View File
@@ -58,12 +58,49 @@ type SandboxConfig struct {
MaxAutonomy string `json:"maxAutonomy,omitempty"`
}
type NotifyConfig struct {
Mode string `json:"mode,omitempty"`
FocusMode string `json:"focusMode,omitempty"`
}
type ToolsConfig struct {
DeferThreshold int `json:"deferThreshold,omitempty"`
deferThresholdSet bool
}
// ToolsOverride builds a ToolsConfig that explicitly overrides the deferred-tool
// threshold (including to 0, which disables deferral). Use this for programmatic
// Overrides — a bare ToolsConfig{DeferThreshold: 0} is indistinguishable from
// "unset" and will not override.
func ToolsOverride(deferThreshold int) ToolsConfig {
return ToolsConfig{DeferThreshold: deferThreshold, deferThresholdSet: true}
}
func (cfg *ToolsConfig) UnmarshalJSON(data []byte) error {
type rawTools struct {
DeferThreshold *int `json:"deferThreshold"`
}
var raw rawTools
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
cfg.DeferThreshold = 0
cfg.deferThresholdSet = false
if raw.DeferThreshold != nil {
cfg.DeferThreshold = *raw.DeferThreshold
cfg.deferThresholdSet = true
}
return nil
}
type FileConfig struct {
ActiveProvider string `json:"activeProvider,omitempty"`
Providers []ProviderProfile `json:"providers,omitempty"`
MaxTurns int `json:"maxTurns,omitempty"`
MCP MCPConfig `json:"mcp,omitempty"`
Sandbox SandboxConfig `json:"sandbox,omitempty"`
Notify NotifyConfig `json:"notify,omitempty"`
Tools ToolsConfig `json:"tools,omitempty"`
}
type ResolveOptions struct {
@@ -81,6 +118,8 @@ type Overrides struct {
MaxTurns int
MCP MCPConfig
Sandbox SandboxConfig
Notify NotifyConfig
Tools ToolsConfig
}
type ResolvedConfig struct {
@@ -90,6 +129,8 @@ type ResolvedConfig struct {
MaxTurns int
MCP MCPConfig
Sandbox SandboxConfig
Notify NotifyConfig
Tools ToolsConfig
}
type MCPConfig struct {
@@ -114,6 +155,8 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
MaxTurns int `json:"maxTurns"`
MCP MCPConfig `json:"mcp"`
Sandbox SandboxConfig `json:"sandbox"`
Notify NotifyConfig `json:"notify"`
Tools ToolsConfig `json:"tools"`
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
}
@@ -127,6 +170,8 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
cfg.MaxTurns = raw.MaxTurns
cfg.MCP = raw.MCP
cfg.Sandbox = raw.Sandbox
cfg.Notify = raw.Notify
cfg.Tools = raw.Tools
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
cfg.MCP.Servers = map[string]MCPServerConfig{}
}
+45
View File
@@ -0,0 +1,45 @@
package config
import (
"encoding/json"
"testing"
)
func TestToolsConfigJSONRoundTrip(t *testing.T) {
var cfg FileConfig
if err := json.Unmarshal([]byte(`{"tools":{"deferThreshold":25}}`), &cfg); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if cfg.Tools.DeferThreshold != 25 {
t.Fatalf("Tools.DeferThreshold = %d, want 25", cfg.Tools.DeferThreshold)
}
encoded, err := json.Marshal(ToolsConfig{DeferThreshold: 7})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if string(encoded) != `{"deferThreshold":7}` {
t.Fatalf("Marshal() = %s, want {\"deferThreshold\":7}", encoded)
}
// omitempty: a zero value must not emit the field.
emptyEncoded, err := json.Marshal(ToolsConfig{})
if err != nil {
t.Fatalf("Marshal(empty) error = %v", err)
}
if string(emptyEncoded) != `{}` {
t.Fatalf("Marshal(empty) = %s, want {}", emptyEncoded)
}
}
func TestToolsConfigPresentOnOverridesAndResolved(t *testing.T) {
// Compile-time guard that Overrides and ResolvedConfig carry the field too.
overrides := Overrides{Tools: ToolsConfig{DeferThreshold: 3}}
resolved := ResolvedConfig{Tools: ToolsConfig{DeferThreshold: 4}}
if overrides.Tools.DeferThreshold != 3 {
t.Fatalf("Overrides.Tools.DeferThreshold = %d, want 3", overrides.Tools.DeferThreshold)
}
if resolved.Tools.DeferThreshold != 4 {
t.Fatalf("ResolvedConfig.Tools.DeferThreshold = %d, want 4", resolved.Tools.DeferThreshold)
}
}
+16
View File
@@ -156,6 +156,22 @@ func (tool registryTool) Safety() tools.Safety {
return tool.safety
}
// Deferred marks every MCP tool as deferred-eligible: when many MCP tools are
// registered the agent loop may withhold their full schema and advertise them
// via tool_search. Built-in tools do not implement this interface and stay
// eager.
func (tool registryTool) Deferred() bool {
return true
}
// MCPServerName reports the tool's originating MCP server name so the deferred-
// tools reminder labels it correctly, even when the sanitized server token in the
// synthesized tool name contains an underscore (which the name-only parser would
// truncate). It returns the true configured server name, not the sanitized token.
func (tool registryTool) MCPServerName() string {
return tool.server.Name
}
func (tool registryTool) Run(ctx context.Context, args map[string]any) tools.Result {
result, err := tool.client.CallTool(ctx, tool.remote.Name, args)
if err != nil {
+66 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"time"
@@ -171,11 +172,75 @@ func TestRegisterToolsPreservesPriorRegistryStateOnFailure(t *testing.T) {
}
}
func TestRegistryToolIsDeferredEligible(t *testing.T) {
client := &fakeToolClient{}
tool := newRegistryTool(
Server{Name: "docs", Type: "stdio"},
RemoteTool{Name: "lookup", Description: "Lookup documentation"},
client,
RegisterOptions{},
)
// Sanity: the wrapper synthesizes the expected sanitized name so the test
// exercises the real production path, not a hand-built struct.
if tool.Name() != "mcp_docs_lookup" {
t.Fatalf("registryTool.Name() = %q, want mcp_docs_lookup", tool.Name())
}
if !tool.Deferred() {
t.Fatal("registryTool.Deferred() = false, want true (all MCP tools are deferred-eligible)")
}
// The exported helper in the tools package must agree via the optional
// interface, since the agent loop partitions tools through tools.IsDeferred.
if !tools.IsDeferred(tool) {
t.Fatal("tools.IsDeferred(registryTool) = false, want true")
}
}
// TestRegistryToolReportsMCPServerName verifies the registryTool reports its true
// configured server name (not the sanitized tool-name token) so the deferred-tools
// reminder labels a multi-token server correctly via tools.DeferredLine.
func TestRegistryToolReportsMCPServerName(t *testing.T) {
client := &fakeToolClient{}
// A server name that sanitizes to a token containing an underscore ("git_hub"):
// the name-only parser would truncate the label to "git".
tool := newRegistryTool(
Server{Name: "git hub", Type: "stdio"},
RemoteTool{Name: "create_issue", Description: "Create a GitHub issue."},
client,
RegisterOptions{},
)
if tool.MCPServerName() != "git hub" {
t.Fatalf("MCPServerName() = %q, want %q", tool.MCPServerName(), "git hub")
}
// DeferredLine must prefer the reported server name over the name-derived token.
line := tools.DeferredLine(tool)
if !strings.Contains(line, "server: git hub") {
t.Fatalf("DeferredLine = %q, want it to label server as %q via MCPServerName()", line, "git hub")
}
// The truncated token-only label ("git") must NOT be the server segment.
if strings.Contains(line, "server: git |") {
t.Fatalf("DeferredLine = %q, mislabeled multi-token server with the truncated token", line)
}
}
// TestRegistryToolNameRoundTripsToServerToken pins the synthesized name format
// for a single-token server so the fallback label parser (mcpServerFromToolName,
// exercised in the tools package) recovers the right server token.
func TestRegistryToolNameRoundTripsToServerToken(t *testing.T) {
if name := registryToolName("docs", "lookup"); name != "mcp_docs_lookup" {
t.Fatalf("registryToolName(docs, lookup) = %q, want mcp_docs_lookup", name)
}
}
type fakePreexistingTool struct {
name string
}
func (t *fakePreexistingTool) Name() string { return t.name }
func (t *fakePreexistingTool) Name() string { return t.name }
func (t *fakePreexistingTool) Description() string { return "preexisting tool" }
func (t *fakePreexistingTool) Parameters() tools.Schema { return tools.Schema{} }
func (t *fakePreexistingTool) Safety() tools.Safety { return tools.Safety{} }
+19
View File
@@ -51,10 +51,12 @@ func decorateModelDepth(entries []ModelEntry) {
defaultEffort ReasoningEffort
patterns []string
deprecation *DeprecationRule
upgradeTarget string
}{
"claude-sonnet-4.5": {
defaultEffort: ReasoningEffortMedium,
patterns: []string{`(?i)^sonnet[^a-z0-9]*4[.\s]?5$`},
upgradeTarget: "claude-opus-4.1",
},
"claude-opus-4.1": {
defaultEffort: ReasoningEffortHigh,
@@ -63,6 +65,7 @@ func decorateModelDepth(entries []ModelEntry) {
"claude-haiku-4.5": {
defaultEffort: ReasoningEffortLow,
patterns: []string{`(?i)^haiku[^a-z0-9]*4[.\s]?5$`},
upgradeTarget: "claude-sonnet-4.5",
},
"gemini-2.5-pro": {
defaultEffort: ReasoningEffortMedium,
@@ -70,6 +73,19 @@ func decorateModelDepth(entries []ModelEntry) {
},
"gemini-2.5-flash": {
defaultEffort: ReasoningEffortLow,
upgradeTarget: "gemini-2.5-pro",
},
"gemini-2.5-flash-lite": {
upgradeTarget: "gemini-2.5-flash",
},
"gpt-4.1-mini": {
upgradeTarget: "gpt-4.1",
},
"gpt-4.1-nano": {
upgradeTarget: "gpt-4.1-mini",
},
"gpt-4o-mini": {
upgradeTarget: "gpt-4o",
},
"gpt-4-turbo": {
deprecation: &DeprecationRule{
@@ -100,6 +116,9 @@ func decorateModelDepth(entries []ModelEntry) {
if extra.deprecation != nil {
entries[index].Deprecation = extra.deprecation.Clone()
}
if extra.upgradeTarget != "" {
entries[index].UpgradeTargetID = extra.upgradeTarget
}
}
}
+43
View File
@@ -216,6 +216,49 @@ func reasoningEffortAllowedIn(efforts []ReasoningEffort, want ReasoningEffort) b
return false
}
func TestDefaultRegistryUpgradeTargets(t *testing.T) {
registry, err := DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry returned error: %v", err)
}
// Seeded escalation chains across the concrete catalog ids.
chains := map[string]string{
"claude-haiku-4.5": "claude-sonnet-4.5",
"claude-sonnet-4.5": "claude-opus-4.1",
"gpt-4.1-mini": "gpt-4.1",
"gpt-4.1-nano": "gpt-4.1-mini",
"gpt-4o-mini": "gpt-4o",
"gemini-2.5-flash-lite": "gemini-2.5-flash",
"gemini-2.5-flash": "gemini-2.5-pro",
}
for source, want := range chains {
target, ok := registry.UpgradeTarget(source)
if !ok {
t.Fatalf("UpgradeTarget(%q) returned no target, want %q", source, want)
}
if target.ID != want {
t.Fatalf("UpgradeTarget(%q) = %q, want %q", source, target.ID, want)
}
}
// Top-tier models declare no escalation target.
for _, topTier := range []string{"claude-opus-4.1", "gpt-4.1", "gemini-2.5-pro"} {
if target, ok := registry.UpgradeTarget(topTier); ok {
t.Fatalf("top-tier model %q should have no upgrade target, got %q", topTier, target.ID)
}
}
// The seeded field is present on the decorated entry, not only via the method.
haiku, err := registry.Require("claude-haiku-4.5")
if err != nil {
t.Fatalf("Require(claude-haiku-4.5) returned error: %v", err)
}
if haiku.UpgradeTargetID != "claude-sonnet-4.5" {
t.Fatalf("claude-haiku-4.5 UpgradeTargetID = %q, want claude-sonnet-4.5", haiku.UpgradeTargetID)
}
}
func containsModelID(models []ModelEntry, id string) bool {
for _, model := range models {
if model.ID == id {
+17 -1
View File
@@ -96,7 +96,11 @@ type ModelEntry struct {
MatchPatterns []string
// Deprecation, when set, redirects this model to a replacement.
Deprecation *DeprecationRule
Description string
// UpgradeTargetID, when set, names the stronger model this one escalates to
// during mid-run escalation. Empty means no escalation target (e.g. top-tier
// models). Resolved and availability-checked by Registry.UpgradeTarget.
UpgradeTargetID string
Description string
}
// DeprecationRule describes how a deprecated model is phased out and what to use
@@ -342,6 +346,18 @@ func NewRegistry(entries []ModelEntry) (Registry, error) {
return Registry{}, fmt.Errorf("model %q deprecation fallback %q does not resolve to a known model", entry.ID, fallbackID)
}
}
// A non-empty UpgradeTargetID must resolve to a known model. Otherwise
// UpgradeTarget would silently disable escalation at runtime (a catalog typo)
// instead of failing loudly here — mirroring the deprecation fallback check.
for _, entry := range registry.models {
targetID := strings.TrimSpace(entry.UpgradeTargetID)
if targetID == "" {
continue
}
if _, ok := registry.Get(targetID); !ok {
return Registry{}, fmt.Errorf("model %q upgrade target %q does not resolve to a known model", entry.ID, targetID)
}
}
return registry, nil
}
+28
View File
@@ -0,0 +1,28 @@
package modelregistry
import "strings"
// UpgradeTarget resolves the stronger model that id escalates to during mid-run
// escalation. It resolves id to a concrete entry, reads its UpgradeTargetID,
// resolves THAT to a concrete entry, and returns it only when the target is
// available and not deprecated. It returns (_, false) when id is unknown, has no
// configured target, or the target resolves to an unavailable/deprecated model.
// Pure and registry-only: applies no deprecation fallback redirection.
func (registry Registry) UpgradeTarget(id string) (ModelEntry, bool) {
source, ok := registry.Resolve(id)
if !ok {
return ModelEntry{}, false
}
targetID := strings.TrimSpace(source.UpgradeTargetID)
if targetID == "" {
return ModelEntry{}, false
}
target, ok := registry.Resolve(targetID)
if !ok {
return ModelEntry{}, false
}
if target.Status == ModelStatusDeprecated {
return ModelEntry{}, false
}
return target, true
}
+99
View File
@@ -0,0 +1,99 @@
package modelregistry
import (
"strings"
"testing"
)
// upgradeEntry builds a minimal valid active entry with an upgrade target set.
func upgradeEntry(id, alias, upgradeTargetID string) ModelEntry {
entry := mkEntry(id, alias)
entry.UpgradeTargetID = upgradeTargetID
return entry
}
func upgradeTestRegistry(t *testing.T) Registry {
t.Helper()
haiku := upgradeEntry("claude-haiku-4-5", "haiku-4.5", "claude-sonnet-4-5")
sonnet := upgradeEntry("claude-sonnet-4-5", "sonnet-4.5", "claude-opus-4-1")
opus := mkEntry("claude-opus-4-1", "opus-4.1") // top-tier: no UpgradeTargetID
// A model whose target points at a deprecated entry must not escalate.
legacy := upgradeEntry("legacy-mini", "legacy-m", "legacy-retired")
retired := mkEntry("legacy-retired", "legacy-r")
retired.Status = ModelStatusDeprecated
retired.Deprecation = &DeprecationRule{FallbackID: "claude-opus-4-1", WarningMsg: "retired"}
reg, err := NewRegistry([]ModelEntry{haiku, sonnet, opus, legacy, retired})
if err != nil {
t.Fatalf("NewRegistry returned error: %v", err)
}
return reg
}
func TestUpgradeTargetResolvesConcreteEntry(t *testing.T) {
reg := upgradeTestRegistry(t)
target, ok := reg.UpgradeTarget("claude-haiku-4-5")
if !ok {
t.Fatal("haiku should escalate to its upgrade target")
}
if target.ID != "claude-sonnet-4-5" {
t.Fatalf("UpgradeTarget(haiku) = %q, want claude-sonnet-4-5", target.ID)
}
// Resolution should also work through an alias for the source model.
if target, ok := reg.UpgradeTarget("sonnet-4.5"); !ok || target.ID != "claude-opus-4-1" {
t.Fatalf("UpgradeTarget(sonnet alias) = %q/%v, want claude-opus-4-1/true", target.ID, ok)
}
}
func TestUpgradeTargetTopTierHasNone(t *testing.T) {
reg := upgradeTestRegistry(t)
if target, ok := reg.UpgradeTarget("claude-opus-4-1"); ok {
t.Fatalf("top-tier model should have no upgrade target, got %q", target.ID)
}
}
func TestUpgradeTargetUnknownSource(t *testing.T) {
reg := upgradeTestRegistry(t)
if _, ok := reg.UpgradeTarget("not-a-real-model"); ok {
t.Fatal("unknown source model should not yield an upgrade target")
}
}
func TestUpgradeTargetDeprecatedTargetRejected(t *testing.T) {
reg := upgradeTestRegistry(t)
if target, ok := reg.UpgradeTarget("legacy-mini"); ok {
t.Fatalf("deprecated target should be rejected, got %q", target.ID)
}
}
func TestNewRegistryRejectsDanglingUpgradeTarget(t *testing.T) {
// A non-empty UpgradeTargetID that does not resolve to a known model is a
// catalog typo; NewRegistry must fail loudly rather than silently disabling
// escalation at runtime (mirrors the deprecation-fallback validation).
dangling := upgradeEntry("dangling-mini", "dangling-m", "does-not-exist")
_, err := NewRegistry([]ModelEntry{dangling})
if err == nil {
t.Fatal("NewRegistry should reject an unresolvable upgrade target")
}
if !strings.Contains(err.Error(), "upgrade target") {
t.Fatalf("error %q should mention the upgrade target", err.Error())
}
}
func TestUpgradeTargetReturnsIndependentCopy(t *testing.T) {
reg := upgradeTestRegistry(t)
target, ok := reg.UpgradeTarget("claude-haiku-4-5")
if !ok {
t.Fatal("haiku should escalate")
}
target.Aliases = append(target.Aliases, "mutated")
again, _ := reg.UpgradeTarget("claude-haiku-4-5")
for _, alias := range again.Aliases {
if alias == "mutated" {
t.Fatal("UpgradeTarget must return a defensive copy, not a shared entry")
}
}
}
+144
View File
@@ -0,0 +1,144 @@
// Package notify emits dep-free terminal notifications (BEL and/or OSC-9
// desktop notifications) when Zero finishes a turn or needs user input.
package notify
import (
"io"
"strings"
"sync"
)
// Mode selects the notification mechanism.
type Mode string
const (
ModeOff Mode = "off"
ModeBell Mode = "bell"
ModeNotify Mode = "notify" // OSC-9 desktop notification
ModeBoth Mode = "both"
)
// FocusMode gates emission on terminal focus (a TUI concept).
type FocusMode string
const (
FocusUnfocused FocusMode = "unfocused" // default: only when terminal is NOT focused
FocusAlways FocusMode = "always"
FocusFocused FocusMode = "focused"
)
// Event is the moment that triggered a notification.
type Event int
const (
Completion Event = iota
AwaitingInput
)
// Config is the resolved notifier policy. Zero value (Mode=="") is silent.
type Config struct {
Mode Mode
FocusMode FocusMode
}
const maxMessageLen = 120
// Notifier emits notifications to w according to cfg. Safe for concurrent use.
type Notifier struct {
w io.Writer
cfg Config // immutable after New; reads outside the lock are safe
mu sync.Mutex
focused bool
}
// New returns a Notifier. focused defaults to false so a headless caller (no
// focus signal) still emits under the default "unfocused" focus mode; an
// interactive caller should call SetFocused(true) at launch.
func New(w io.Writer, cfg Config) *Notifier {
return &Notifier{w: w, cfg: cfg}
}
// SetFocused records the terminal focus state (TUI FocusMsg/BlurMsg).
func (n *Notifier) SetFocused(focused bool) {
n.mu.Lock()
n.focused = focused
n.mu.Unlock()
}
// Notify emits a notification for event if policy allows. message is the OSC-9
// body (ignored for bell). Write errors are intentionally ignored — a failed
// notification must never disrupt the run.
func (n *Notifier) Notify(event Event, message string) {
if n.w == nil || n.cfg.Mode == ModeOff || n.cfg.Mode == "" {
return
}
seq := sequence(n.cfg.Mode, message)
if seq == "" {
return
}
n.mu.Lock()
defer n.mu.Unlock()
if !shouldEmit(n.cfg, event, n.focused) {
return
}
_, _ = io.WriteString(n.w, seq)
}
// DefaultMessage is the generic OSC-9 body for an event (no prompt content).
func DefaultMessage(event Event) string {
if event == AwaitingInput {
return "Zero: needs input"
}
return "Zero: ready"
}
func shouldEmit(cfg Config, _ Event, focused bool) bool {
if cfg.Mode == ModeOff || cfg.Mode == "" {
return false
}
switch cfg.FocusMode {
case FocusAlways:
return true
case FocusFocused:
return focused
default: // FocusUnfocused, "", or unknown
return !focused
}
}
func sequence(mode Mode, message string) string {
switch mode {
case ModeBell:
return "\x07"
case ModeNotify:
return "\x1b]9;" + sanitizeMessage(message) + "\x07"
case ModeBoth:
return "\x07\x1b]9;" + sanitizeMessage(message) + "\x07"
default:
return ""
}
}
// Enabled reports whether mode will ever emit a notification.
func Enabled(mode Mode) bool {
return mode != "" && mode != ModeOff
}
// sanitizeMessage drops control bytes (so the message can't break the escape or
// inject terminal control) and clamps to maxMessageLen runes.
func sanitizeMessage(s string) string {
var b strings.Builder
count := 0
for _, r := range s {
if r == 0x1b || r == 0x07 || r < 0x20 || r == 0x7f {
continue
}
b.WriteRune(r)
count++
if count >= maxMessageLen {
break
}
}
return b.String()
}
+128
View File
@@ -0,0 +1,128 @@
package notify
import (
"bytes"
"strings"
"sync"
"testing"
)
func TestShouldEmit(t *testing.T) {
cases := []struct {
name string
cfg Config
event Event
focused bool
want bool
}{
{"off never", Config{Mode: ModeOff, FocusMode: FocusAlways}, Completion, false, false},
{"empty mode never", Config{}, Completion, false, false},
{"always when focused", Config{Mode: ModeBell, FocusMode: FocusAlways}, Completion, true, true},
{"always when unfocused", Config{Mode: ModeBell, FocusMode: FocusAlways}, Completion, false, true},
{"unfocused emits when unfocused", Config{Mode: ModeBell, FocusMode: FocusUnfocused}, Completion, false, true},
{"unfocused silent when focused", Config{Mode: ModeBell, FocusMode: FocusUnfocused}, Completion, true, false},
{"empty focusmode == unfocused", Config{Mode: ModeBell}, Completion, true, false},
{"focused emits when focused", Config{Mode: ModeBell, FocusMode: FocusFocused}, AwaitingInput, true, true},
{"focused silent when unfocused", Config{Mode: ModeBell, FocusMode: FocusFocused}, AwaitingInput, false, false},
{"awaiting-input also eligible", Config{Mode: ModeBoth, FocusMode: FocusAlways}, AwaitingInput, true, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := shouldEmit(tc.cfg, tc.event, tc.focused); got != tc.want {
t.Fatalf("shouldEmit=%v want %v", got, tc.want)
}
})
}
}
func TestSequence(t *testing.T) {
if got := sequence(ModeBell, "hi"); got != "\x07" {
t.Fatalf("bell seq=%q", got)
}
if got := sequence(ModeNotify, "hi"); got != "\x1b]9;hi\x07" {
t.Fatalf("notify seq=%q", got)
}
if got := sequence(ModeBoth, "hi"); got != "\x07\x1b]9;hi\x07" {
t.Fatalf("both seq=%q", got)
}
if got := sequence(ModeOff, "hi"); got != "" {
t.Fatalf("off seq=%q", got)
}
}
func TestSanitizeMessage(t *testing.T) {
if got := sanitizeMessage("ok\x1b]0;evil\x07more\nx"); got != "ok]0;evilmorex" {
t.Fatalf("sanitize=%q", got)
}
long := strings.Repeat("a", 500)
if got := sanitizeMessage(long); len([]rune(got)) != maxMessageLen {
t.Fatalf("clamp len=%d want %d", len([]rune(got)), maxMessageLen)
}
}
func TestNotifyWritesAndRespectsPolicy(t *testing.T) {
var buf bytes.Buffer
n := New(&buf, Config{Mode: ModeBoth, FocusMode: FocusAlways})
n.Notify(Completion, "Zero: ready")
if buf.String() != "\x07\x1b]9;Zero: ready\x07" {
t.Fatalf("emitted=%q", buf.String())
}
buf.Reset()
off := New(&buf, Config{Mode: ModeOff})
off.Notify(Completion, "x")
if buf.Len() != 0 {
t.Fatalf("off should emit nothing, got %q", buf.String())
}
buf.Reset()
uf := New(&buf, Config{Mode: ModeBell, FocusMode: FocusUnfocused})
uf.SetFocused(true)
uf.Notify(Completion, "x")
if buf.Len() != 0 {
t.Fatalf("focused+unfocused-mode should be silent, got %q", buf.String())
}
uf.SetFocused(false)
uf.Notify(Completion, "x")
if buf.String() != "\x07" {
t.Fatalf("unfocused should bell, got %q", buf.String())
}
}
func TestNotifierDefaultFocusFalse(t *testing.T) {
var buf bytes.Buffer
n := New(&buf, Config{Mode: ModeBell, FocusMode: FocusUnfocused})
n.Notify(Completion, "x")
if buf.String() != "\x07" {
t.Fatalf("default-focus headless should bell, got %q", buf.String())
}
}
func TestNotifyRaceSafe(t *testing.T) {
n := New(&bytes.Buffer{}, Config{Mode: ModeBell, FocusMode: FocusAlways})
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(2)
go func() { defer wg.Done(); n.SetFocused(true) }()
go func() { defer wg.Done(); n.Notify(Completion, "x") }()
}
wg.Wait()
}
func TestDefaultMessage(t *testing.T) {
if DefaultMessage(Completion) != "Zero: ready" {
t.Fatal("completion message")
}
if DefaultMessage(AwaitingInput) != "Zero: needs input" {
t.Fatal("awaiting message")
}
}
func TestEnabled(t *testing.T) {
if Enabled(ModeOff) || Enabled("") {
t.Fatal("off/empty should be disabled")
}
if !Enabled(ModeBell) || !Enabled(ModeNotify) || !Enabled(ModeBoth) {
t.Fatal("bell/notify/both should be enabled")
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ func NormalizePermission(value Permission) Permission {
func NormalizeSideEffect(value SideEffect) SideEffect {
normalized := SideEffect(strings.ToLower(strings.TrimSpace(string(value))))
switch normalized {
case SideEffectRead, SideEffectWrite, SideEffectShell, SideEffectNetwork, SideEffectOutOfWorkspace:
case SideEffectRead, SideEffectWrite, SideEffectShell, SideEffectNetwork, SideEffectOutOfWorkspace, SideEffectNone:
return normalized
default:
return SideEffectOutOfWorkspace
+10
View File
@@ -30,3 +30,13 @@ func TestAutonomyAllowedNormalizesBothOperands(t *testing.T) {
})
}
}
func TestNormalizeSideEffectPreservesNone(t *testing.T) {
if got := NormalizeSideEffect(SideEffectNone); got != SideEffectNone {
t.Fatalf("NormalizeSideEffect(none) = %q, want none (must not collapse to out_of_workspace)", got)
}
// An unrecognized value still fails closed to out_of_workspace.
if got := NormalizeSideEffect(SideEffect("bogus")); got != SideEffectOutOfWorkspace {
t.Fatalf("NormalizeSideEffect(bogus) = %q, want out_of_workspace", got)
}
}
+3
View File
@@ -83,6 +83,9 @@ func Classify(request Request) Risk {
add("network", RiskHigh)
case SideEffectOutOfWorkspace:
add("out_of_workspace", RiskCritical)
case SideEffectNone:
// Control-only tool (e.g. escalate_model): no read/write/shell/network
// effect, so it contributes no side-effect risk category and stays low.
}
// The bash tool accepts the command under any of these aliases; resolve the
+10
View File
@@ -219,3 +219,13 @@ func TestClassifyRmLongFlagRootQuotedAndSeparator(t *testing.T) {
}
}
}
func TestClassifyNoneSideEffectIsLowRisk(t *testing.T) {
risk := Classify(Request{ToolName: "escalate_model", SideEffect: SideEffectNone})
if risk.Level != RiskLow {
t.Fatalf("none side-effect risk level = %s, want low", risk.Level)
}
if HasRiskCategory(risk, "out_of_workspace") {
t.Fatalf("control-only tool must not classify as out_of_workspace: %#v", risk)
}
}
+4
View File
@@ -22,6 +22,10 @@ const (
SideEffectShell SideEffect = "shell"
SideEffectNetwork SideEffect = "network"
SideEffectOutOfWorkspace SideEffect = "out_of_workspace"
// SideEffectNone marks a control-only tool that performs no read/write/
// shell/network effect (e.g. escalate_model). It must be recognized so it is
// not normalized to out_of_workspace and falsely classified as critical.
SideEffectNone SideEffect = "none"
)
const (
+150
View File
@@ -0,0 +1,150 @@
package sessions
import (
"encoding/json"
"fmt"
"strings"
)
type EnsureSpecImplementationInput struct {
Title string
Cwd string
ModelID string
Provider string
SpecID string
SpecFilePath string
SpecDraftModelID string
SpecDraftReasoning string
SpecUserComment string
SpecSourceSessionID string
RootSessionID string
Prompt string
}
func (store *Store) FindSpecImplementation(specID string, sourceSessionID string) (*Metadata, error) {
specID = strings.TrimSpace(specID)
sourceSessionID = strings.TrimSpace(sourceSessionID)
if specID == "" || sourceSessionID == "" {
return nil, nil
}
if !ValidSessionID(sourceSessionID) {
return nil, fmt.Errorf("invalid zero session id %q", sourceSessionID)
}
items, err := store.List()
if err != nil {
return nil, err
}
for _, item := range items {
if item.SessionKind == SessionKindSpecImpl && item.SpecID == specID && item.SpecSourceSessionID == sourceSessionID {
matched := item
return &matched, nil
}
}
return nil, nil
}
func (store *Store) EnsureSpecImplementation(input EnsureSpecImplementationInput) (Metadata, []Event, error) {
specID := strings.TrimSpace(input.SpecID)
sourceSessionID := strings.TrimSpace(input.SpecSourceSessionID)
prompt := input.Prompt
if specID == "" {
return Metadata{}, nil, fmt.Errorf("zero spec id is required")
}
if sourceSessionID == "" {
return Metadata{}, nil, fmt.Errorf("zero spec source session id is required")
}
if !ValidSessionID(sourceSessionID) {
return Metadata{}, nil, fmt.Errorf("invalid zero session id %q", sourceSessionID)
}
if strings.TrimSpace(prompt) == "" {
return Metadata{}, nil, fmt.Errorf("zero spec implementation prompt is required")
}
source, err := store.Get(sourceSessionID)
if err != nil {
return Metadata{}, nil, err
}
if source == nil {
return Metadata{}, nil, fmt.Errorf("zero session not found: %s", sourceSessionID)
}
unlock, err := store.lockSession(sourceSessionID)
if err != nil {
return Metadata{}, nil, err
}
defer unlock()
impl, err := store.FindSpecImplementation(specID, sourceSessionID)
if err != nil {
return Metadata{}, nil, err
}
if impl == nil {
created, err := store.Create(CreateInput{
SessionKind: SessionKindSpecImpl,
Title: input.Title,
Cwd: input.Cwd,
ModelID: input.ModelID,
Provider: input.Provider,
ParentSessionID: sourceSessionID,
RootSessionID: firstNonEmpty(input.RootSessionID, sourceSessionID),
SpecID: specID,
SpecFilePath: input.SpecFilePath,
SpecStatus: SpecStatusApproved,
SpecDraftModelID: input.SpecDraftModelID,
SpecDraftReasoning: input.SpecDraftReasoning,
SpecUserComment: input.SpecUserComment,
SpecSourceSessionID: sourceSessionID,
})
if err != nil {
return Metadata{}, nil, err
}
impl = &created
}
events, err := store.ReadEvents(impl.SessionID)
if err != nil {
return Metadata{}, nil, err
}
if !eventsContainUserPrompt(events, prompt) {
if _, err := store.AppendEvent(impl.SessionID, AppendEventInput{
Type: EventMessage,
Payload: map[string]any{
"role": "user",
"content": prompt,
},
}); err != nil {
return Metadata{}, nil, err
}
events, err = store.ReadEvents(impl.SessionID)
if err != nil {
return Metadata{}, nil, err
}
}
loaded, err := store.Get(impl.SessionID)
if err != nil {
return Metadata{}, nil, err
}
if loaded == nil {
return Metadata{}, nil, fmt.Errorf("zero session not found: %s", impl.SessionID)
}
return *loaded, events, nil
}
func eventsContainUserPrompt(events []Event, prompt string) bool {
for _, event := range events {
if event.Type != EventMessage {
continue
}
var payload struct {
Role string `json:"role"`
Content string `json:"content"`
}
if err := json.Unmarshal(event.Payload, &payload); err != nil {
continue
}
if payload.Role == "user" && payload.Content == prompt {
return true
}
}
return false
}
+64
View File
@@ -500,6 +500,70 @@ func TestRecordSpecUpdatesMetadataAndAppendsEvents(t *testing.T) {
}
}
func TestEnsureSpecImplementationReusesExistingPromptSession(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: sequenceClock([]time.Time{
time.Date(2026, 6, 8, 11, 0, 0, 0, time.UTC),
time.Date(2026, 6, 8, 11, 0, 1, 0, time.UTC),
time.Date(2026, 6, 8, 11, 0, 2, 0, time.UTC),
time.Date(2026, 6, 8, 11, 0, 3, 0, time.UTC),
})})
draft, err := store.Create(CreateInput{
SessionID: "draft",
SessionKind: SessionKindSpecDraft,
Title: "Draft spec",
Cwd: "/repo",
ModelID: "gpt-5",
Provider: "openai",
SpecID: "2026-06-08-spec",
SpecStatus: SpecStatusDraft,
})
if err != nil {
t.Fatalf("Create draft returned error: %v", err)
}
input := EnsureSpecImplementationInput{
Title: "Draft spec implementation",
Cwd: draft.Cwd,
ModelID: draft.ModelID,
Provider: draft.Provider,
SpecID: draft.SpecID,
SpecFilePath: "/repo/.zero/specs/2026-06-08-spec.md",
SpecDraftModelID: "gpt-5",
SpecDraftReasoning: "high",
SpecUserComment: "ship it",
SpecSourceSessionID: draft.SessionID,
Prompt: "Implement the approved spec.",
}
first, firstEvents, err := store.EnsureSpecImplementation(input)
if err != nil {
t.Fatalf("EnsureSpecImplementation first returned error: %v", err)
}
second, secondEvents, err := store.EnsureSpecImplementation(input)
if err != nil {
t.Fatalf("EnsureSpecImplementation second returned error: %v", err)
}
if second.SessionID != first.SessionID {
t.Fatalf("implementation session was not reused: first=%s second=%s", first.SessionID, second.SessionID)
}
if len(firstEvents) != 1 || len(secondEvents) != 1 || second.EventCount != 1 {
t.Fatalf("expected one implementation prompt event, first=%#v second=%#v metadata=%#v", firstEvents, secondEvents, second)
}
items, err := store.List()
if err != nil {
t.Fatal(err)
}
implCount := 0
for _, item := range items {
if item.SessionKind == SessionKindSpecImpl {
implCount++
}
}
if implCount != 1 {
t.Fatalf("implementation session count = %d, want 1", implCount)
}
}
func fixedClock(value string) func() time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
+78
View File
@@ -0,0 +1,78 @@
package specmode
import (
"fmt"
"os"
"path/filepath"
"strings"
)
func LoadSpecFile(workspaceRoot string, specFilePath string) (string, string, error) {
path, err := ResolveSpecFilePath(workspaceRoot, specFilePath)
if err != nil {
return "", "", err
}
info, err := os.Lstat(path)
if err != nil {
return "", "", fmt.Errorf("stat spec file: %w", err)
}
if !info.Mode().IsRegular() {
return "", "", fmt.Errorf("spec file is not a regular file: %s", path)
}
data, err := os.ReadFile(path)
if err != nil {
return "", "", fmt.Errorf("read spec file: %w", err)
}
body := strings.TrimRight(string(data), "\n")
if strings.TrimSpace(body) == "" {
return "", "", fmt.Errorf("spec file is empty: %s", path)
}
return body, path, nil
}
func ResolveSpecFilePath(workspaceRoot string, specFilePath string) (string, error) {
root := strings.TrimSpace(workspaceRoot)
if root == "" {
return "", fmt.Errorf("workspace root is required")
}
absoluteRoot, err := filepath.Abs(root)
if err != nil {
return "", fmt.Errorf("resolve workspace root: %w", err)
}
path := strings.TrimSpace(specFilePath)
if path == "" {
return "", fmt.Errorf("spec file path is required")
}
if !filepath.IsAbs(path) {
path = filepath.Join(absoluteRoot, filepath.FromSlash(path))
}
path = filepath.Clean(path)
specDir := filepath.Join(absoluteRoot, filepath.FromSlash(SpecDirName))
if err := ensureSpecPathContained(specDir, path); err != nil {
return "", err
}
return path, nil
}
func ImplementationPrompt(specBody string, specFilePath string, draftSessionID string, userComment string) string {
body := strings.TrimSpace(specBody)
lines := []string{
"Implement the following approved spec:",
"",
}
if comment := strings.TrimSpace(userComment); comment != "" {
lines = append(lines, "User note: "+comment, "")
}
lines = append(lines, body, "")
if path := strings.TrimSpace(specFilePath); path != "" {
lines = append(lines, "Spec file: "+path)
}
if sessionID := strings.TrimSpace(draftSessionID); sessionID != "" {
lines = append(lines, "Planning session: "+sessionID)
}
lines = append(lines,
"",
"If you need details that are not in the spec, inspect the workspace again. The spec is the approved source of truth for scope.",
)
return strings.Join(lines, "\n")
}
+42
View File
@@ -125,6 +125,48 @@ func TestSubmitToolRejectsInvalidArgs(t *testing.T) {
}
}
func TestLoadSpecFileRejectsPathsOutsideSpecDirectory(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(root, "notes.md")
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
t.Fatal(err)
}
_, _, err := LoadSpecFile(root, outside)
if err == nil {
t.Fatal("expected LoadSpecFile to reject a path outside .zero/specs")
}
}
func TestLoadSpecFileRejectsNonRegularFiles(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, filepath.FromSlash(".zero/specs/not-a-file.md"))
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
_, _, err := LoadSpecFile(root, dir)
if err == nil || !strings.Contains(err.Error(), "not a regular file") {
t.Fatalf("expected non-regular file error, got %v", err)
}
}
func TestImplementationPromptIncludesReviewContext(t *testing.T) {
prompt := ImplementationPrompt("# Goal\n\nShip it.", "/repo/.zero/specs/plan.md", "zero_1", "Keep tests focused.")
for _, want := range []string{
"Implement the following approved spec:",
"User note: Keep tests focused.",
"# Goal\n\nShip it.",
"Spec file: /repo/.zero/specs/plan.md",
"Planning session: zero_1",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("prompt missing %q: %s", want, prompt)
}
}
}
func fixedSpecTime(value string) func() time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
+209
View File
@@ -0,0 +1,209 @@
package tools
import (
"fmt"
"regexp"
"sort"
"strings"
)
const (
defaultShortenMax = 200
maxSchemaHintParams = 4
maxSchemaHintLen = 360
)
var (
headingPrefix = regexp.MustCompile(`^#{1,6}\s+(.+)$`)
genericHeading = regexp.MustCompile(`(?i)^(overview|description|summary)$`)
collapseWhitespace = regexp.MustCompile(`\s+`)
)
// normalizeDescriptionLine trims a line and unwraps a leading markdown heading.
func normalizeDescriptionLine(line string) string {
trimmed := strings.TrimSpace(line)
if m := headingPrefix.FindStringSubmatch(trimmed); m != nil {
return strings.TrimSpace(m[1])
}
return trimmed
}
func isGenericDescriptionHeading(line string) bool {
return genericHeading.MatchString(line)
}
// truncateDescription clips desc to at most max runes, preferring a word
// boundary and appending a single-rune ellipsis when it had to cut.
func truncateDescription(desc string, max int) string {
runes := []rune(desc)
if max <= 0 || len(runes) <= max {
return desc
}
cut := string(runes[:max-1])
if idx := strings.LastIndexByte(cut, ' '); idx > 0 {
cut = cut[:idx]
}
return strings.TrimRight(cut, " ") + "…"
}
// shortenDescription reduces desc to a single meaningful line, collapses
// whitespace, and truncates to at most max runes with an ellipsis.
func shortenDescription(desc string, max int) string {
if desc == "" {
return ""
}
if max <= 0 {
max = defaultShortenMax
}
var lines []string
for _, raw := range strings.Split(desc, "\n") {
if line := normalizeDescriptionLine(raw); line != "" {
lines = append(lines, collapseWhitespace.ReplaceAllString(line, " "))
}
}
if len(lines) == 0 {
return ""
}
meaningful := lines[0]
if isGenericDescriptionHeading(meaningful) && len(lines) > 1 {
meaningful = meaningful + " — " + lines[1]
}
return truncateDescription(meaningful, max)
}
// formatInputSchemaHint renders a one-line summary of a tool's input schema,
// e.g. "inputs (* required): a (string)*, b (number); +N more". Property names
// are sorted for deterministic output (Schema.Properties is a map). Returns
// "(none)" when the schema declares no properties. At most maxSchemaHintParams
// params are shown; the rest are summarized as "; +N more".
func formatInputSchemaHint(schema Schema) string {
if len(schema.Properties) == 0 {
return "(none)"
}
names := make([]string, 0, len(schema.Properties))
for name := range schema.Properties {
names = append(names, name)
}
sort.Strings(names)
required := make(map[string]bool, len(schema.Required))
for _, name := range schema.Required {
required[name] = true
}
shown := names
if len(shown) > maxSchemaHintParams {
shown = shown[:maxSchemaHintParams]
}
parts := make([]string, 0, len(shown))
for _, name := range shown {
prop := schema.Properties[name]
marker := ""
if required[name] {
marker = "*"
}
typePart := ""
if t := strings.TrimSpace(prop.Type); t != "" {
typePart = " (" + t + ")"
}
parts = append(parts, name+typePart+marker)
}
more := ""
if len(names) > maxSchemaHintParams {
more = fmt.Sprintf("; +%d more", len(names)-maxSchemaHintParams)
}
hint := "inputs (* required): " + strings.Join(parts, ", ") + more
return shortenDescription(hint, maxSchemaHintLen)
}
// mcpToolNamePrefix mirrors the prefix used by mcp.registryToolName.
const mcpToolNamePrefix = "mcp_"
// mcpServerFromToolName extracts the server token from a synthesized MCP tool
// name produced by mcp.registryToolName ("mcp_<server>_<tool>"). It returns ""
// for non-MCP names and for names that lack both a server and a tool segment.
func mcpServerFromToolName(name string) string {
rest, ok := strings.CutPrefix(name, mcpToolNamePrefix)
if !ok {
return ""
}
sep := strings.IndexByte(rest, '_')
if sep <= 0 || sep == len(rest)-1 {
// No server token, or nothing after the server token (no tool part).
return ""
}
return rest[:sep]
}
// formatDeferredToolLine renders a single compact advertisement line for a
// deferred tool: "name: <short-desc> | server: <X> | <input-hint>". The
// "server: <X>" segment is omitted when server is empty (non-MCP tools).
func formatDeferredToolLine(name, description, server string, schema Schema) string {
desc := shortenDescription(description, defaultShortenMax)
if desc == "" {
desc = "No description provided"
}
parts := []string{name + ": " + desc}
if server != "" {
parts = append(parts, "server: "+server)
}
parts = append(parts, formatInputSchemaHint(schema))
return strings.Join(parts, " | ")
}
// mcpServerNamed is an optional interface a deferred MCP tool implements to
// report its true (un-sanitized-token) server name for the reminder label. When
// a tool provides it, DeferredLine prefers it over the name-derived token, which
// would mislabel a server whose sanitized name itself contains an underscore
// (e.g. "git_hub" → "git"). It affects the cosmetic reminder label only; tool
// resolution never depends on this.
type mcpServerNamed interface {
MCPServerName() string
}
// DeferredLine renders the compact advertisement line for a single deferred
// tool, deriving the MCP server label from the tool's reported server name when
// available, falling back to the token parsed from the tool's name. It is the
// exported entry point the agent loop uses to build each line of the
// deferred-tools reminder, so callers in other packages never touch the
// unexported formatters.
func DeferredLine(t Tool) string {
server := mcpServerFromToolName(t.Name())
if named, ok := t.(mcpServerNamed); ok {
if reported := strings.TrimSpace(named.MCPServerName()); reported != "" {
server = reported
}
}
return formatDeferredToolLine(t.Name(), t.Description(), server, t.Parameters())
}
const (
systemReminderStart = "<system-reminder>"
systemReminderEnd = "</system-reminder>"
)
// BuildDeferredReminder wraps the given deferred-tool advertisement lines in a
// <system-reminder> block instructing the model to load a tool's full schema
// via tool_search (using a `select:Name1,Name2` exact query or keywords)
// before calling it. Returns "" when there are no lines.
func BuildDeferredReminder(lines []string) string {
if len(lines) == 0 {
return ""
}
var b strings.Builder
b.WriteString(systemReminderStart)
b.WriteString("\n")
b.WriteString("The tools listed below are available in this environment, but their full schemas are omitted from the current tool list to save context.\n")
b.WriteString(`Call tool_search with query "select:Name1,Name2" (the exact names before each colon) or with keywords to load a tool's full schema before calling it. Calling an omitted tool directly will fail with an input validation error.` + "\n")
b.WriteString("Do not call tool_search for tools already present in the current tool list, and do not guess or invent tool names.\n")
b.WriteString("\n")
b.WriteString("Deferred tools:\n")
b.WriteString(strings.Join(lines, "\n"))
b.WriteString("\n")
b.WriteString(systemReminderEnd)
return b.String()
}
+340
View File
@@ -0,0 +1,340 @@
package tools
import (
"context"
"strings"
"testing"
)
func TestShortenDescription(t *testing.T) {
tests := []struct {
name string
desc string
max int
want string
}{
{name: "empty", desc: "", max: 200, want: ""},
{name: "single short line", desc: "Reads a file from disk.", max: 200, want: "Reads a file from disk."},
{
name: "takes first non-empty line",
desc: "\n\nReads a file.\nSecond line ignored.",
max: 200,
want: "Reads a file.",
},
{
name: "strips markdown heading",
desc: "## Read tool\nbody",
max: 200,
want: "Read tool",
},
{
name: "generic heading joins next line",
desc: "Overview\nFetches a URL over HTTP.",
max: 200,
want: "Overview — Fetches a URL over HTTP.",
},
{
name: "collapses internal whitespace",
desc: "Runs a\tshell command",
max: 200,
want: "Runs a shell command",
},
{
name: "truncates on word boundary with ellipsis",
desc: "alpha beta gamma delta epsilon zeta",
max: 20,
want: "alpha beta gamma…",
},
{
name: "truncates mid-word when no boundary",
desc: "supercalifragilistic",
max: 10,
want: "supercali…",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := shortenDescription(tc.desc, tc.max)
if got != tc.want {
t.Fatalf("shortenDescription(%q, %d) = %q, want %q", tc.desc, tc.max, got, tc.want)
}
if len([]rune(got)) > tc.max && tc.max > 0 {
t.Fatalf("result %q exceeds max %d runes", got, tc.max)
}
if strings.Contains(got, "\n") {
t.Fatalf("result %q contains a newline", got)
}
})
}
}
func TestFormatInputSchemaHint(t *testing.T) {
tests := []struct {
name string
schema Schema
want string
}{
{
name: "no properties",
schema: Schema{Type: "object"},
want: "(none)",
},
{
name: "required marked and sorted",
schema: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"a": {Type: "string"},
"b": {Type: "number"},
},
Required: []string{"a"},
},
want: "inputs (* required): a (string)*, b (number)",
},
{
name: "untyped property omits type parens",
schema: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"q": {},
},
Required: []string{"q"},
},
want: "inputs (* required): q*",
},
{
name: "caps at four and reports remainder",
schema: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"a": {Type: "string"},
"b": {Type: "string"},
"c": {Type: "string"},
"d": {Type: "string"},
"e": {Type: "string"},
"f": {Type: "string"},
},
Required: []string{"a"},
},
want: "inputs (* required): a (string)*, b (string), c (string), d (string); +2 more",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := formatInputSchemaHint(tc.schema); got != tc.want {
t.Fatalf("formatInputSchemaHint() = %q, want %q", got, tc.want)
}
})
}
}
func TestMCPServerFromToolName(t *testing.T) {
tests := []struct {
name string
toolName string
want string
}{
{name: "standard mcp name", toolName: "mcp_github_create_issue", want: "github"},
{name: "single tool segment", toolName: "mcp_weather_forecast", want: "weather"},
{name: "server with no tool part", toolName: "mcp_github_tool", want: "github"},
{name: "non-mcp builtin", toolName: "read_file", want: ""},
{name: "tool_search itself", toolName: "tool_search", want: ""},
{name: "prefix only no server", toolName: "mcp_", want: ""},
{name: "prefix and server but no tool", toolName: "mcp_github", want: ""},
{name: "empty", toolName: "", want: ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := mcpServerFromToolName(tc.toolName); got != tc.want {
t.Fatalf("mcpServerFromToolName(%q) = %q, want %q", tc.toolName, got, tc.want)
}
})
}
}
func TestFormatDeferredToolLine(t *testing.T) {
schema := Schema{
Type: "object",
Properties: map[string]PropertySchema{
"url": {Type: "string"},
},
Required: []string{"url"},
}
t.Run("with server", func(t *testing.T) {
got := formatDeferredToolLine("mcp_github_fetch", "Fetches a URL.", "github", schema)
want := "mcp_github_fetch: Fetches a URL. | server: github | inputs (* required): url (string)*"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
})
t.Run("omits server when empty", func(t *testing.T) {
got := formatDeferredToolLine("read_file", "Reads a file.", "", schema)
want := "read_file: Reads a file. | inputs (* required): url (string)*"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
if strings.Contains(got, "server:") {
t.Fatalf("server part leaked into %q", got)
}
})
t.Run("empty schema shows none", func(t *testing.T) {
got := formatDeferredToolLine("ping", "Pings.", "", Schema{Type: "object"})
want := "ping: Pings. | (none)"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
})
t.Run("blank description gets placeholder", func(t *testing.T) {
got := formatDeferredToolLine("noop", "", "", Schema{Type: "object"})
want := "noop: No description provided | (none)"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
})
t.Run("long description truncated", func(t *testing.T) {
long := strings.Repeat("word ", 80) // 400 chars
got := formatDeferredToolLine("big", long, "", Schema{Type: "object"})
if !strings.Contains(got, "…") {
t.Fatalf("expected truncation ellipsis in %q", got)
}
descPart := strings.SplitN(strings.TrimPrefix(got, "big: "), " | ", 2)[0]
if len([]rune(descPart)) > defaultShortenMax {
t.Fatalf("description part %d runes exceeds %d", len([]rune(descPart)), defaultShortenMax)
}
})
}
func TestBuildDeferredReminder(t *testing.T) {
t.Run("no lines returns empty", func(t *testing.T) {
if got := BuildDeferredReminder(nil); got != "" {
t.Fatalf("BuildDeferredReminder(nil) = %q, want empty", got)
}
if got := BuildDeferredReminder([]string{}); got != "" {
t.Fatalf("BuildDeferredReminder([]) = %q, want empty", got)
}
})
t.Run("wraps lines in system-reminder", func(t *testing.T) {
lines := []string{
"mcp_github_fetch: Fetches a URL. | server: github | inputs (* required): url (string)*",
"mcp_weather_now: Current weather. | server: weather | (none)",
}
got := BuildDeferredReminder(lines)
if !strings.HasPrefix(got, "<system-reminder>") {
t.Fatalf("missing opening tag: %q", got)
}
if !strings.HasSuffix(got, "</system-reminder>") {
t.Fatalf("missing closing tag: %q", got)
}
if !strings.Contains(got, "tool_search") {
t.Fatalf("reminder must name the tool_search tool: %q", got)
}
if !strings.Contains(got, `select:`) {
t.Fatalf("reminder must explain select: query form: %q", got)
}
for _, line := range lines {
if !strings.Contains(got, line) {
t.Fatalf("reminder missing line %q in %q", line, got)
}
}
// Lines are newline-joined, not concatenated.
if !strings.Contains(got, lines[0]+"\n"+lines[1]) {
t.Fatalf("lines not newline-joined in %q", got)
}
})
}
func TestDeferredLineUsesToolFieldsAndServer(t *testing.T) {
tool := fakeDeferredTool{
baseTool: baseTool{
name: "mcp_docs_lookup",
description: "Look up documentation for a symbol.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{"symbol": {Type: "string"}},
Required: []string{"symbol"},
},
},
deferred: true,
}
got := DeferredLine(tool)
for _, want := range []string{"mcp_docs_lookup: Look up documentation", "server: docs", "inputs (* required): symbol (string)*"} {
if !strings.Contains(got, want) {
t.Fatalf("DeferredLine = %q, missing %q", got, want)
}
}
}
// serverNamedTool implements the optional mcpServerNamed interface so DeferredLine
// can prefer the reported server name over the name-derived token.
type serverNamedTool struct {
baseTool
server string
}
func (t serverNamedTool) Run(context.Context, map[string]any) Result { return okResult("ok") }
func (t serverNamedTool) Deferred() bool { return true }
func (t serverNamedTool) MCPServerName() string { return t.server }
func TestDeferredLinePrefersMCPServerName(t *testing.T) {
// The tool name sanitizes to a server token containing an underscore
// ("git_hub"): mcpServerFromToolName would truncate the label to "git", so the
// reported server name must win.
tool := serverNamedTool{
baseTool: baseTool{
name: "mcp_git_hub_create_issue",
description: "Create a GitHub issue.",
parameters: Schema{Type: "object"},
},
server: "git hub",
}
got := DeferredLine(tool)
if !strings.Contains(got, "server: git hub") {
t.Fatalf("DeferredLine = %q, want server label from MCPServerName() (%q)", got, "git hub")
}
if strings.Contains(got, "server: git |") {
t.Fatalf("DeferredLine = %q, used truncated name-derived token instead of MCPServerName()", got)
}
}
// A blank/whitespace MCPServerName() must fall back to the name-derived token so a
// tool that reports nothing useful is still labeled from its synthesized name.
func TestDeferredLineFallsBackWhenMCPServerNameBlank(t *testing.T) {
tool := serverNamedTool{
baseTool: baseTool{
name: "mcp_docs_lookup",
description: "Look up docs.",
parameters: Schema{Type: "object"},
},
server: " ",
}
got := DeferredLine(tool)
if !strings.Contains(got, "server: docs") {
t.Fatalf("DeferredLine = %q, want fallback server token %q", got, "docs")
}
}
// TestMCPServerFromToolNameRoundTrip pins the fallback parser against the name
// format produced for a single-token MCP server ("mcp_<server>_<tool>"), the same
// format mcp.registryToolName synthesizes.
func TestMCPServerFromToolNameRoundTrip(t *testing.T) {
cases := map[string]string{
"mcp_docs_lookup": "docs",
"mcp_weather_forecast": "weather",
"mcp_github_create_issue": "github",
}
for name, wantServer := range cases {
if got := mcpServerFromToolName(name); got != wantServer {
t.Fatalf("mcpServerFromToolName(%q) = %q, want %q", name, got, wantServer)
}
}
}
+4 -2
View File
@@ -91,8 +91,10 @@ func (tool editFileTool) Run(_ context.Context, args map[string]any) Result {
suffix = "s"
}
summary := fmt.Sprintf("Successfully edited %s (replaced %d occurrence%s).", relativePath, replacedCount, suffix)
result := okResult(summary)
// Emit the focused change as a unified diff so renderers can show a diff view;
// the diff is also clearer confirmation to the model than a prose summary.
result := okResult(UnifiedDiff(relativePath, oldString, newString))
result.ChangedFiles = []string{relativePath}
result.Display = Display{Summary: fmt.Sprintf("Edited %s", relativePath), Kind: "diff"}
result.Display = Display{Summary: summary, Kind: "diff"}
return result
}
+98
View File
@@ -0,0 +1,98 @@
package tools
import (
"context"
"fmt"
"github.com/Gitlawb/zero/internal/modelregistry"
)
// escalateToModelMetaKey is the result-metadata key the agent loop reads to
// perform a mid-run model switch. The tool itself stays pure: it validates the
// upgrade target and signals it here; the loop owns the provider swap.
const escalateToModelMetaKey = "escalate_to_model"
// escalateModelTool lets the agent escalate the current model to its configured
// stronger upgrade target for the rest of the run. It is a control action: it
// performs no read/write/shell/network effect and only signals the target via
// result metadata. The agent loop wires the actual provider switch.
type escalateModelTool struct {
baseTool
registry modelregistry.Registry
}
// NewEscalateModelTool builds the tool with the default model registry. If the
// default registry cannot be built the tool degrades gracefully: every run then
// reports "no escalation available" rather than failing.
func NewEscalateModelTool() Tool {
registry, err := modelregistry.DefaultRegistry()
if err != nil {
registry = modelregistry.Registry{}
}
return newEscalateModelTool(registry)
}
// newEscalateModelTool builds the tool with an explicit registry (used by the
// exec wiring and tests so escalation resolves against the same catalog the run
// uses).
func newEscalateModelTool(registry modelregistry.Registry) escalateModelTool {
return escalateModelTool{
baseTool: baseTool{
name: "escalate_model",
description: "Escalate the current model to its stronger upgrade target for the rest of this run. Use when the task is harder than expected. No effect if already on the strongest available model.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"reason": {
Type: "string",
Description: "Optional short note on why a stronger model is needed (recorded for transcript context only).",
},
},
Required: []string{},
AdditionalProperties: false,
},
safety: Safety{
SideEffect: SideEffectNone,
Permission: PermissionAllow,
Reason: "Requests a mid-run switch to a stronger model; the agent loop performs the switch.",
AdvertiseInAuto: true,
},
},
registry: registry,
}
}
// Run satisfies the Tool interface for callers that do not supply RunOptions; it
// has no current model to escalate from, so it reports no escalation. The real
// path is RunWithOptions, which the registry invokes when RunOptions are present.
func (tool escalateModelTool) Run(ctx context.Context, args map[string]any) Result {
return tool.RunWithOptions(ctx, args, RunOptions{})
}
// RunWithOptions reads the current model from options.Model, looks up its upgrade
// target, and either signals the target via Meta or returns an informational
// result. Status is OK either way (a "no escalation" outcome is not an error).
func (tool escalateModelTool) RunWithOptions(_ context.Context, args map[string]any, options RunOptions) Result {
// reason is optional and used only for transcript context; reject a non-string
// value but accept an absent OR explicitly empty string (allowEmpty) — reason
// is informational, so an empty reason must never fail the escalation.
if _, err := stringArgWithEmpty(args, "reason", "", false, true); err != nil {
return errorResult("Error: Invalid arguments for escalate_model: " + err.Error())
}
current := options.Model
target, ok := tool.registry.UpgradeTarget(current)
if !ok {
label := current
if label == "" {
label = "current model"
}
return okResult(fmt.Sprintf("Already using the strongest available model (%s); no escalation performed.", label))
}
return Result{
Status: StatusOK,
Output: fmt.Sprintf("Escalating from %s to %s for the rest of this run.", current, target.ID),
Meta: map[string]string{escalateToModelMetaKey: target.ID},
}
}
+199
View File
@@ -0,0 +1,199 @@
package tools
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/modelregistry"
)
func TestEscalateModelToolMetadata(t *testing.T) {
tool := NewEscalateModelTool()
if tool.Name() != "escalate_model" {
t.Fatalf("name = %q, want escalate_model", tool.Name())
}
if tool.Description() == "" {
t.Fatal("description must not be empty")
}
safety := tool.Safety()
if safety.SideEffect != SideEffectNone {
t.Fatalf("side effect = %q, want none", safety.SideEffect)
}
if safety.Permission != PermissionAllow {
t.Fatalf("permission = %q, want allow", safety.Permission)
}
if !safety.AdvertiseInAuto {
t.Fatal("escalate_model must advertise in auto")
}
schema := tool.Parameters()
if schema.Type != "object" {
t.Fatalf("schema type = %q, want object", schema.Type)
}
if schema.AdditionalProperties {
t.Fatal("schema must disallow additional properties")
}
if len(schema.Required) != 0 {
t.Fatalf("escalate_model must have no required args, got %v", schema.Required)
}
if _, ok := schema.Properties["reason"]; !ok {
t.Fatal("schema must advertise an optional reason property")
}
}
// A non-string reason argument is a soft error, not a panic.
func TestEscalateModelToolRejectsNonStringReason(t *testing.T) {
tool := NewEscalateModelTool()
res := tool.Run(context.Background(), map[string]any{"reason": 42})
if res.Status != StatusError {
t.Fatalf("status = %q, want error for non-string reason", res.Status)
}
}
// Resolving against the real catalog: a known mid-tier model escalates to its
// configured stronger target and signals it via Meta["escalate_to_model"].
func TestEscalateModelRunSignalsTarget(t *testing.T) {
registry, err := modelregistry.DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry: %v", err)
}
want, ok := registry.UpgradeTarget("claude-haiku-4.5")
if !ok {
t.Skip("catalog has no upgrade target seeded for claude-haiku-4.5")
}
tool := newEscalateModelTool(registry)
res := tool.RunWithOptions(context.Background(), map[string]any{"reason": "task is harder than expected"}, RunOptions{Model: "claude-haiku-4.5"})
if res.Status != StatusOK {
t.Fatalf("status = %q, want ok", res.Status)
}
if got := res.Meta["escalate_to_model"]; got != want.ID {
t.Fatalf("meta escalate_to_model = %q, want %q", got, want.ID)
}
if !strings.Contains(res.Output, "claude-haiku-4.5") || !strings.Contains(res.Output, want.ID) {
t.Fatalf("output should name both models, got %q", res.Output)
}
if !strings.Contains(res.Output, "Escalating") {
t.Fatalf("output should announce escalation, got %q", res.Output)
}
}
// An explicitly empty reason (and an absent reason) is accepted: reason is
// informational only, so it must never fail the escalation. Both forms still
// return the escalation result with the target signalled via Meta.
func TestEscalateModelRunAcceptsEmptyReason(t *testing.T) {
registry, err := modelregistry.DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry: %v", err)
}
target, ok := registry.UpgradeTarget("claude-haiku-4.5")
if !ok {
t.Skip("catalog has no upgrade target seeded for claude-haiku-4.5")
}
tool := newEscalateModelTool(registry)
cases := []struct {
name string
args map[string]any
}{
{name: "explicit empty reason", args: map[string]any{"reason": ""}},
{name: "absent reason", args: map[string]any{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
res := tool.RunWithOptions(context.Background(), tc.args, RunOptions{Model: "claude-haiku-4.5"})
if res.Status != StatusOK {
t.Fatalf("status = %q output = %q, want ok (empty reason must not error)", res.Status, res.Output)
}
if got := res.Meta["escalate_to_model"]; got != target.ID {
t.Fatalf("meta escalate_to_model = %q, want %q", got, target.ID)
}
})
}
}
// A top-tier model (no upgrade target), an unknown model, and an empty model all
// produce an informational, no-meta, OK result — the loop must not switch.
func TestEscalateModelRunNoTarget(t *testing.T) {
registry, err := modelregistry.DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry: %v", err)
}
tool := newEscalateModelTool(registry)
cases := []struct {
name string
model string
wantInOut string
}{
{name: "top-tier", model: "claude-opus-4.1", wantInOut: "claude-opus-4.1"},
{name: "unknown", model: "not-a-real-model", wantInOut: "not-a-real-model"},
{name: "empty", model: "", wantInOut: "current model"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
res := tool.RunWithOptions(context.Background(), map[string]any{}, RunOptions{Model: tc.model})
if res.Status != StatusOK {
t.Fatalf("status = %q, want ok", res.Status)
}
if _, ok := res.Meta["escalate_to_model"]; ok {
t.Fatalf("no-target result must carry no escalate_to_model meta, got %v", res.Meta)
}
if !strings.Contains(res.Output, "no escalation performed") {
t.Fatalf("output should be informational, got %q", res.Output)
}
if !strings.Contains(res.Output, tc.wantInOut) {
t.Fatalf("output should name %q, got %q", tc.wantInOut, res.Output)
}
})
}
}
// Sanity: the empty-args path (no reason) is valid and does not switch when on a
// top-tier model.
func TestEscalateModelRunDefaultsToNoEscalation(t *testing.T) {
tool := newEscalateModelTool(modelregistry.Registry{})
res := tool.RunWithOptions(context.Background(), map[string]any{}, RunOptions{Model: "claude-opus-4.1"})
if res.Status != StatusOK {
t.Fatalf("status = %q, want ok", res.Status)
}
if len(res.Meta) != 0 {
t.Fatalf("empty registry must never signal a target, got meta %v", res.Meta)
}
}
// End-to-end through the Registry: RunWithOptions must route to the tool's
// optionsAwareTool branch (threading options.Model) and, because the tool is
// PermissionAllow, run without a permission grant.
func TestEscalateModelThroughRegistryDispatch(t *testing.T) {
modelReg, err := modelregistry.DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry: %v", err)
}
target, ok := modelReg.UpgradeTarget("claude-haiku-4.5")
if !ok {
t.Skip("catalog has no upgrade target seeded for claude-haiku-4.5")
}
reg := NewRegistry()
reg.Register(newEscalateModelTool(modelReg))
res := reg.RunWithOptions(context.Background(), "escalate_model", map[string]any{}, RunOptions{
Model: "claude-haiku-4.5",
})
if res.Status != StatusOK {
t.Fatalf("status = %q output = %q", res.Status, res.Output)
}
if got := res.Meta["escalate_to_model"]; got != target.ID {
t.Fatalf("meta escalate_to_model = %q, want %q", got, target.ID)
}
if !strings.Contains(res.Output, target.ID) {
t.Fatalf("output should name target %q, got %q", target.ID, res.Output)
}
}
+22
View File
@@ -23,6 +23,13 @@ type RunOptions struct {
ReasoningEffort string
Depth int
Cwd string
// 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:
// denied if listed in DisabledTools; if EnabledTools is non-empty, allowed
// only when listed in it.
EnabledTools []string
DisabledTools []string
}
type sandboxAwareTool interface {
@@ -33,10 +40,25 @@ type optionsAwareTool interface {
RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result
}
// deferredTool is an optional interface a tool implements to mark itself
// deferred-eligible: when many such tools are registered, the agent loop may
// withhold their full schema and advertise them via tool_search instead.
type deferredTool interface {
Deferred() bool
}
func NewRegistry() *Registry {
return &Registry{tools: make(map[string]Tool)}
}
// IsDeferred reports whether a tool opts into deferred loading. A tool is
// deferred-eligible only if it implements deferredTool and its Deferred()
// method returns true; tools that do not implement the interface are eager.
func IsDeferred(t Tool) bool {
d, ok := t.(deferredTool)
return ok && d.Deferred()
}
func (registry *Registry) Register(tool Tool) {
registry.tools[tool.Name()] = tool
}
+44
View File
@@ -329,3 +329,47 @@ func TestRunWithOptionsLeavesCleanOutputUnchanged(t *testing.T) {
t.Fatalf("clean output altered: redacted=%v output=%q", res.Redacted, res.Output)
}
}
// fakeDeferredTool implements the optional Deferred() interface and reports
// itself as deferred-eligible; fakePlainTool does not implement it.
type fakeDeferredTool struct {
baseTool
deferred bool
}
func (tool fakeDeferredTool) Run(context.Context, map[string]any) Result {
return okResult("ok")
}
func (tool fakeDeferredTool) Deferred() bool { return tool.deferred }
type fakePlainTool struct {
baseTool
}
func (tool fakePlainTool) Run(context.Context, map[string]any) Result {
return okResult("ok")
}
func TestIsDeferredReportsOptionalInterface(t *testing.T) {
eligible := fakeDeferredTool{
baseTool: baseTool{name: "eligible", description: "deferred-eligible tool"},
deferred: true,
}
if !IsDeferred(eligible) {
t.Fatal("IsDeferred(eligible) = false, want true for a tool whose Deferred() returns true")
}
notEligible := fakeDeferredTool{
baseTool: baseTool{name: "not_eligible", description: "implements interface but opts out"},
deferred: false,
}
if IsDeferred(notEligible) {
t.Fatal("IsDeferred(notEligible) = true, want false when Deferred() returns false")
}
plain := fakePlainTool{baseTool: baseTool{name: "plain", description: "no deferred interface"}}
if IsDeferred(plain) {
t.Fatal("IsDeferred(plain) = true, want false for a tool that does not implement deferredTool")
}
}
+253
View File
@@ -0,0 +1,253 @@
package tools
import (
"context"
"encoding/json"
"sort"
"strconv"
"strings"
)
// ToolSearchToolName is the canonical registry name of the deferred-tool loader.
// It is exported so the agent loop, CLI filter validation, and the partition can
// reference the loader without re-stating the literal (keeping all the gates that
// must treat it specially in agreement).
const ToolSearchToolName = "tool_search"
// toolSearchMaxKeywordMatches caps how many deferred tools a bare-keyword query
// loads in one call, keeping the returned schemas bounded.
const toolSearchMaxKeywordMatches = 10
// toolSearchTool lets the model pull a deferred tool's full schema on demand.
// It holds the live registry (like escalate_model holds the model registry) so
// it can resolve names against the currently registered deferred-eligible tools.
type toolSearchTool struct {
baseTool
registry *Registry
}
// NewToolSearchTool builds the tool_search tool over the given registry. The
// tool is informational/no-side-effect and is advertised even in auto mode so
// the model can always discover withheld tools.
func NewToolSearchTool(registry *Registry) Tool {
return toolSearchTool{
baseTool: baseTool{
name: ToolSearchToolName,
description: "Search for and load deferred tools by exact name or keyword. Use query \"select:Name1,Name2\" to load specific tools, or keywords to find matching tools. Loading a tool returns its full schema so you can call it on the next turn.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"query": {
Type: "string",
Description: "Either \"select:Name1,Name2\" for exact tool names, or space-separated keywords to match tool names and descriptions.",
},
},
Required: []string{"query"},
AdditionalProperties: false,
},
safety: Safety{
SideEffect: SideEffectNone,
Permission: PermissionAllow,
Reason: "Lists and loads already-registered tool schemas; performs no side effects.",
AdvertiseInAuto: true,
},
},
registry: registry,
}
}
// Run satisfies the Tool interface; actual dispatch goes through RunWithOptions.
func (tool toolSearchTool) Run(ctx context.Context, args map[string]any) Result {
return tool.RunWithOptions(ctx, args, RunOptions{})
}
func (tool toolSearchTool) RunWithOptions(_ context.Context, args map[string]any, options RunOptions) Result {
query, err := stringArg(args, "query", "", true)
if err != nil {
return errorResult("Error: Invalid arguments for tool_search: " + err.Error())
}
query = strings.TrimSpace(query)
// Honor the run's operator filters so an operator-hidden deferred tool is
// invisible to tool_search: it never resolves via select:, never ranks for a
// keyword query, and is omitted from the no-match listing.
deferred := tool.visibleDeferredTools(options.EnabledTools, options.DisabledTools)
var matches []Tool
if rest, ok := strings.CutPrefix(query, "select:"); ok {
matches = tool.resolveExact(rest, deferred)
} else {
matches = tool.rankByKeyword(query, deferred)
}
if len(matches) == 0 {
return okResult(tool.noMatchMessage(query, deferred))
}
names := make([]string, 0, len(matches))
for _, match := range matches {
names = append(names, match.Name())
}
return Result{
Status: StatusOK,
Output: renderLoadedTools(matches),
Meta: map[string]string{"load_tools": strings.Join(names, ",")},
}
}
// visibleDeferredTools returns the registry's deferred-eligible tools that pass
// the operator allow/deny filters, sorted by name so keyword ranking and
// listings stay deterministic. A nil/empty filter pair admits every deferred
// tool (the pre-filter behavior). The allow/deny semantics mirror the agent's
// ToolAllowedByFilters: denied if in disabled; if enabled is non-empty, the tool
// must be listed in it.
func (tool toolSearchTool) visibleDeferredTools(enabled []string, disabled []string) []Tool {
var deferred []Tool
if tool.registry != nil {
for _, candidate := range tool.registry.All() {
if !IsDeferred(candidate) {
continue
}
if !toolAllowedByFilters(candidate.Name(), enabled, disabled) {
continue
}
deferred = append(deferred, candidate)
}
}
sort.Slice(deferred, func(left, right int) bool {
return deferred[left].Name() < deferred[right].Name()
})
return deferred
}
// toolAllowedByFilters mirrors agent.ToolAllowedByFilters (kept here to avoid an
// import cycle: the agent package imports tools, not the other way around). A
// name is denied when it appears in disabled; when enabled is non-empty, the
// name must appear in it to be allowed.
func toolAllowedByFilters(name string, enabled []string, disabled []string) bool {
if len(enabled) > 0 && !containsName(enabled, name) {
return false
}
return !containsName(disabled, name)
}
func containsName(names []string, name string) bool {
for _, candidate := range names {
if candidate == name {
return true
}
}
return false
}
// resolveExact maps a comma-separated name list (the part after "select:") to
// the matching deferred tools, preserving the model's order and skipping blanks
// and unknown names.
func (tool toolSearchTool) resolveExact(list string, deferred []Tool) []Tool {
byName := make(map[string]Tool, len(deferred))
for _, candidate := range deferred {
byName[candidate.Name()] = candidate
}
var matches []Tool
seen := make(map[string]bool)
for _, raw := range strings.Split(list, ",") {
name := strings.TrimSpace(raw)
if name == "" || seen[name] {
continue
}
if candidate, ok := byName[name]; ok {
seen[name] = true
matches = append(matches, candidate)
}
}
return matches
}
// rankByKeyword scores deferred tools by case-insensitive substring match on the
// name (weighted higher) then the description, and returns the top matches.
func (tool toolSearchTool) rankByKeyword(query string, deferred []Tool) []Tool {
keywords := strings.Fields(strings.ToLower(query))
if len(keywords) == 0 {
return nil
}
type scored struct {
tool Tool
score int
order int
}
var ranked []scored
for index, candidate := range deferred {
name := strings.ToLower(candidate.Name())
desc := strings.ToLower(candidate.Description())
score := 0
for _, keyword := range keywords {
if strings.Contains(name, keyword) {
score += 2
}
if strings.Contains(desc, keyword) {
score++
}
}
if score > 0 {
ranked = append(ranked, scored{tool: candidate, score: score, order: index})
}
}
sort.SliceStable(ranked, func(left, right int) bool {
if ranked[left].score != ranked[right].score {
return ranked[left].score > ranked[right].score
}
return ranked[left].order < ranked[right].order
})
if len(ranked) > toolSearchMaxKeywordMatches {
ranked = ranked[:toolSearchMaxKeywordMatches]
}
matches := make([]Tool, 0, len(ranked))
for _, entry := range ranked {
matches = append(matches, entry.tool)
}
return matches
}
// noMatchMessage reports that nothing loaded and names the available deferred
// tools so the model can retry with a valid select: query.
func (tool toolSearchTool) noMatchMessage(query string, deferred []Tool) string {
if len(deferred) == 0 {
return "No deferred tools are available to load."
}
names := make([]string, 0, len(deferred))
for _, candidate := range deferred {
names = append(names, candidate.Name())
}
return "No tools matched \"" + query + "\". Available tools: " + strings.Join(names, ", ") +
`. Retry with query "select:Name1,Name2" using exact names.`
}
// renderLoadedTools lists each loaded tool's name, full description, and full
// input schema (pretty-printed JSON) so the model has the complete spec inline.
func renderLoadedTools(matches []Tool) string {
var builder strings.Builder
builder.WriteString("Loaded ")
if len(matches) == 1 {
builder.WriteString("1 tool")
} else {
builder.WriteString(strconv.Itoa(len(matches)))
builder.WriteString(" tools")
}
builder.WriteString(". Full schemas follow; call them on the next turn.\n")
for _, match := range matches {
builder.WriteString("\n## ")
builder.WriteString(match.Name())
builder.WriteString("\n")
builder.WriteString(match.Description())
builder.WriteString("\n")
schemaJSON, err := json.MarshalIndent(match.Parameters(), "", " ")
if err != nil {
builder.WriteString("(schema unavailable)\n")
continue
}
builder.WriteString("input schema:\n")
builder.Write(schemaJSON)
builder.WriteString("\n")
}
return builder.String()
}
+299
View File
@@ -0,0 +1,299 @@
package tools
import (
"context"
"strings"
"testing"
)
// searchFakeTool is a minimal deferred-eligible tool for tool_search tests:
// it implements Tool plus the optional Deferred() bool that IsDeferred checks.
type searchFakeTool struct {
name string
description string
parameters Schema
}
func (t searchFakeTool) Name() string { return t.name }
func (t searchFakeTool) Description() string { return t.description }
func (t searchFakeTool) Parameters() Schema { return t.parameters }
func (t searchFakeTool) Safety() Safety {
return Safety{SideEffect: SideEffectRead, Permission: PermissionAllow}
}
func (t searchFakeTool) Run(context.Context, map[string]any) Result {
return Result{Status: StatusOK}
}
func (t searchFakeTool) Deferred() bool { return true }
func newDeferredFixtureRegistry() *Registry {
reg := NewRegistry()
reg.Register(searchFakeTool{
name: "weather_lookup",
description: "Look up the current weather for a city.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"city": {Type: "string", Description: "City name to look up."},
},
Required: []string{"city"},
AdditionalProperties: false,
},
})
reg.Register(searchFakeTool{
name: "stock_quote",
description: "Fetch a stock quote for a ticker symbol.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{"ticker": {Type: "string"}},
Required: []string{"ticker"},
AdditionalProperties: false,
},
})
return reg
}
func TestToolSearchExposesExpectedSafetyAndSchema(t *testing.T) {
tool := NewToolSearchTool(NewRegistry())
if tool.Name() != "tool_search" {
t.Fatalf("name = %q, want tool_search", tool.Name())
}
if tool.Description() == "" {
t.Fatal("tool_search must have a description")
}
safety := tool.Safety()
if safety.SideEffect != SideEffectNone {
t.Fatalf("side effect = %s, want none", safety.SideEffect)
}
if safety.Permission != PermissionAllow {
t.Fatalf("permission = %s, want allow", safety.Permission)
}
if !safety.AdvertiseInAuto {
t.Fatal("tool_search must be AdvertiseInAuto")
}
schema := tool.Parameters()
if schema.Type != "object" || schema.AdditionalProperties {
t.Fatalf("unexpected schema header: %#v", schema)
}
queryProp, ok := schema.Properties["query"]
if !ok {
t.Fatal("schema missing query property")
}
if queryProp.Type != "string" {
t.Fatalf("query type = %s, want string", queryProp.Type)
}
if len(schema.Required) != 1 || schema.Required[0] != "query" {
t.Fatalf("required = %#v, want [query]", schema.Required)
}
}
// tool_search must run through the registry's optionsAwareTool dispatch and be
// allowed without a permission grant (SideEffectNone + PermissionAllow).
func TestToolSearchRunsThroughRegistryWithoutPermission(t *testing.T) {
reg := newDeferredFixtureRegistry()
reg.Register(NewToolSearchTool(reg))
result := reg.Run(context.Background(), "tool_search", map[string]any{
"query": "select:weather_lookup",
})
if result.Status != StatusOK {
t.Fatalf("status = %s, want ok; output=%q", result.Status, result.Output)
}
if result.Meta["load_tools"] != "weather_lookup" {
t.Fatalf("Meta[load_tools] = %q, want weather_lookup", result.Meta["load_tools"])
}
}
func TestToolSearchMissingQueryArgIsError(t *testing.T) {
tool := NewToolSearchTool(NewRegistry()).(optionsAwareTool)
result := tool.RunWithOptions(context.Background(), map[string]any{}, RunOptions{})
if result.Status != StatusError {
t.Fatalf("status = %s, want error for missing query", result.Status)
}
if !strings.Contains(result.Output, "query is required") {
t.Fatalf("unexpected error output: %q", result.Output)
}
}
func TestToolSearchUnknownQueryReturnsNoMeta(t *testing.T) {
reg := newDeferredFixtureRegistry()
tool := NewToolSearchTool(reg).(optionsAwareTool)
for _, query := range []string{"select:does_not_exist", "select:", "zzznomatch"} {
result := tool.RunWithOptions(context.Background(),
map[string]any{"query": query}, RunOptions{})
if result.Status != StatusOK {
t.Fatalf("query %q: status = %s, want ok (informational)", query, result.Status)
}
if _, present := result.Meta["load_tools"]; present {
t.Fatalf("query %q: must NOT set load_tools, got %q", query, result.Meta["load_tools"])
}
// Informational message should name the available tools so the model can retry.
if !strings.Contains(result.Output, "weather_lookup") || !strings.Contains(result.Output, "stock_quote") {
t.Fatalf("query %q: message must name available tools, got %q", query, result.Output)
}
}
}
func TestToolSearchEmptyRegistryReportsNothingAvailable(t *testing.T) {
tool := NewToolSearchTool(NewRegistry()).(optionsAwareTool)
result := tool.RunWithOptions(context.Background(),
map[string]any{"query": "select:anything"}, RunOptions{})
if result.Status != StatusOK {
t.Fatalf("status = %s, want ok", result.Status)
}
if _, present := result.Meta["load_tools"]; present {
t.Fatalf("empty registry must not set load_tools, got %q", result.Meta["load_tools"])
}
if !strings.Contains(result.Output, "No deferred tools are available") {
t.Fatalf("unexpected message: %q", result.Output)
}
}
func TestToolSearchKeywordRanksByNameThenDescription(t *testing.T) {
reg := NewRegistry()
// name match should outrank a description-only match.
reg.Register(searchFakeTool{
name: "weather_lookup",
description: "Look up the current weather for a city.",
parameters: Schema{Type: "object", AdditionalProperties: false},
})
reg.Register(searchFakeTool{
name: "forecast_report",
description: "Generates a multi-day weather outlook.",
parameters: Schema{Type: "object", AdditionalProperties: false},
})
tool := NewToolSearchTool(reg).(optionsAwareTool)
result := tool.RunWithOptions(context.Background(),
map[string]any{"query": "weather"}, RunOptions{})
if result.Status != StatusOK {
t.Fatalf("status = %s, want ok", result.Status)
}
loaded := result.Meta["load_tools"]
// Both match "weather"; the name match (weather_lookup) must come first.
if loaded != "weather_lookup,forecast_report" {
t.Fatalf("load_tools = %q, want weather_lookup,forecast_report (name match ranked first)", loaded)
}
}
func TestToolSearchKeywordExcludesNonMatches(t *testing.T) {
reg := newDeferredFixtureRegistry()
tool := NewToolSearchTool(reg).(optionsAwareTool)
result := tool.RunWithOptions(context.Background(),
map[string]any{"query": "stock"}, RunOptions{})
if got := result.Meta["load_tools"]; got != "stock_quote" {
t.Fatalf("load_tools = %q, want only stock_quote", got)
}
if strings.Contains(result.Output, "weather_lookup") {
t.Fatalf("non-matching tool weather_lookup leaked into output: %q", result.Output)
}
}
func TestToolSearchSelectLoadsExactNames(t *testing.T) {
reg := newDeferredFixtureRegistry()
tool := NewToolSearchTool(reg)
optioned, ok := tool.(optionsAwareTool)
if !ok {
t.Fatalf("tool_search must implement optionsAwareTool")
}
result := optioned.RunWithOptions(context.Background(),
map[string]any{"query": "select:weather_lookup,stock_quote"}, RunOptions{})
if result.Status != StatusOK {
t.Fatalf("status = %s, want ok; output=%q", result.Status, result.Output)
}
if got := result.Meta["load_tools"]; got != "weather_lookup,stock_quote" {
t.Fatalf("Meta[load_tools] = %q, want %q", got, "weather_lookup,stock_quote")
}
// Output must carry the FULL description and full schema (not a compact line).
if !strings.Contains(result.Output, "Look up the current weather for a city.") {
t.Fatalf("output missing full description: %q", result.Output)
}
if !strings.Contains(result.Output, "weather_lookup") || !strings.Contains(result.Output, "\"city\"") {
t.Fatalf("output missing full schema for weather_lookup: %q", result.Output)
}
if !strings.Contains(result.Output, "stock_quote") || !strings.Contains(result.Output, "\"ticker\"") {
t.Fatalf("output missing full schema for stock_quote: %q", result.Output)
}
}
// A deferred tool the operator hid via DisabledTools must be invisible to
// tool_search: it never resolves via select:, it cannot be the operator-hidden
// candidate, and it must not appear in the no-match listing.
func TestToolSearchExcludesDisabledDeferredTool(t *testing.T) {
reg := newDeferredFixtureRegistry()
tool := NewToolSearchTool(reg).(optionsAwareTool)
disabled := RunOptions{DisabledTools: []string{"stock_quote"}}
// select: a hidden tool must NOT resolve (no load_tools, informational message).
// Query "select:stock_quote_x" avoids echoing the disabled name verbatim so the
// listing-omission assertion below is not confused by the query echo.
selectResult := tool.RunWithOptions(context.Background(),
map[string]any{"query": "select:stock_quote_x"}, disabled)
if _, present := selectResult.Meta["load_tools"]; present {
t.Fatalf("disabled tool must not resolve via select:, got load_tools=%q", selectResult.Meta["load_tools"])
}
// The available-tools listing (the segment after "Available tools: ") must omit
// the disabled tool while still naming the allowed one.
listing := selectResult.Output
if idx := strings.Index(listing, "Available tools: "); idx >= 0 {
listing = listing[idx:]
}
if strings.Contains(listing, "stock_quote") {
t.Fatalf("disabled tool leaked into no-match listing: %q", selectResult.Output)
}
if !strings.Contains(listing, "weather_lookup") {
t.Fatalf("no-match listing must still name the allowed tool, got %q", selectResult.Output)
}
// keyword: a hidden tool must NOT rank.
keywordResult := tool.RunWithOptions(context.Background(),
map[string]any{"query": "stock"}, disabled)
if got := keywordResult.Meta["load_tools"]; got != "" {
t.Fatalf("disabled tool must not rank for a keyword query, got load_tools=%q", got)
}
if strings.Contains(keywordResult.Output, "stock_quote") {
t.Fatalf("disabled tool leaked into keyword output: %q", keywordResult.Output)
}
}
// With a non-empty EnabledTools allowlist, a deferred tool absent from it is
// invisible to tool_search even though it is registered and deferred-eligible.
func TestToolSearchHonorsEnabledAllowlist(t *testing.T) {
reg := newDeferredFixtureRegistry()
tool := NewToolSearchTool(reg).(optionsAwareTool)
// Allowlist admits only weather_lookup; stock_quote is excluded.
allow := RunOptions{EnabledTools: []string{"weather_lookup"}}
// The excluded tool must not resolve, even by exact name.
selectResult := tool.RunWithOptions(context.Background(),
map[string]any{"query": "select:weather_lookup,stock_quote"}, allow)
if got := selectResult.Meta["load_tools"]; got != "weather_lookup" {
t.Fatalf("allowlist must load only weather_lookup, got load_tools=%q", got)
}
if strings.Contains(selectResult.Output, "ticker") {
t.Fatalf("excluded tool's schema leaked into output: %q", selectResult.Output)
}
// A keyword for the excluded tool finds nothing and the listing omits it.
keywordResult := tool.RunWithOptions(context.Background(),
map[string]any{"query": "stock"}, allow)
if _, present := keywordResult.Meta["load_tools"]; present {
t.Fatalf("excluded tool must not rank under the allowlist, got %q", keywordResult.Meta["load_tools"])
}
if strings.Contains(keywordResult.Output, "stock_quote") {
t.Fatalf("excluded tool leaked into keyword no-match listing: %q", keywordResult.Output)
}
}
+5
View File
@@ -11,6 +11,11 @@ type Permission string
type Status string
const (
// SideEffectNone marks a control-only tool that neither reads nor mutates
// state — no read/write/shell/network/out-of-workspace effect. Examples:
// tool_search (only reports already-registered tool schemas) and
// escalate_model (only requests a loop-level model switch).
SideEffectNone SideEffect = "none"
SideEffectRead SideEffect = "read"
SideEffectWrite SideEffect = "write"
SideEffectShell SideEffect = "shell"
+19
View File
@@ -0,0 +1,19 @@
package tools
import "testing"
// SideEffectNone marks a control-only tool (no read/write/shell/network/
// out-of-workspace effect). escalate_model is its first consumer.
func TestSideEffectNoneConstant(t *testing.T) {
if SideEffectNone != "none" {
t.Fatalf("SideEffectNone = %q, want %q", SideEffectNone, "none")
}
// It must be distinct from every existing side-effect value.
for _, other := range []SideEffect{
SideEffectRead, SideEffectWrite, SideEffectShell, SideEffectNetwork, SideEffectOutOfWorkspace,
} {
if SideEffectNone == other {
t.Fatalf("SideEffectNone collides with existing side effect %q", other)
}
}
}
+80
View File
@@ -0,0 +1,80 @@
package tools
import "strings"
// UnifiedDiff returns a line-oriented diff between before and after. Each output
// line is prefixed with '+' (added), '-' (removed), or ' ' (unchanged context),
// computed from the longest common subsequence of lines. A created file
// (before == "") yields all-'+' lines. path is accepted for callers that want to
// associate the diff with a file; the body is what the zeroline renderer parses.
func UnifiedDiff(path, before, after string) string {
a := diffLines(before)
b := diffLines(after)
m, n := len(a), len(b)
// lcs[i][j] = length of the LCS of a[i:] and b[j:].
lcs := make([][]int, m+1)
for i := range lcs {
lcs[i] = make([]int, n+1)
}
for i := m - 1; i >= 0; i-- {
for j := n - 1; j >= 0; j-- {
if a[i] == b[j] {
lcs[i][j] = lcs[i+1][j+1] + 1
} else if lcs[i+1][j] >= lcs[i][j+1] {
lcs[i][j] = lcs[i+1][j]
} else {
lcs[i][j] = lcs[i][j+1]
}
}
}
var out []string
i, j := 0, 0
for i < m && j < n {
switch {
case a[i] == b[j]:
out = append(out, " "+a[i])
i++
j++
case lcs[i+1][j] >= lcs[i][j+1]:
out = append(out, "-"+a[i])
i++
default:
out = append(out, "+"+b[j])
j++
}
}
for ; i < m; i++ {
out = append(out, "-"+a[i])
}
for ; j < n; j++ {
out = append(out, "+"+b[j])
}
return strings.Join(out, "\n")
}
// DiffStat counts the added and removed lines in a UnifiedDiff body.
func DiffStat(diff string) (add, del int) {
if diff == "" {
return 0, 0
}
for _, ln := range strings.Split(diff, "\n") {
switch {
case strings.HasPrefix(ln, "+"):
add++
case strings.HasPrefix(ln, "-"):
del++
}
}
return add, del
}
// diffLines splits content into lines, dropping a single trailing newline so a
// file that ends in "\n" doesn't produce a spurious trailing empty line.
func diffLines(s string) []string {
if s == "" {
return nil
}
return strings.Split(strings.TrimSuffix(s, "\n"), "\n")
}
+54
View File
@@ -0,0 +1,54 @@
package tools
import (
"strings"
"testing"
)
func TestUnifiedDiffNewFile(t *testing.T) {
got := UnifiedDiff("hello.txt", "", "hi\nthere")
want := "+hi\n+there"
if got != want {
t.Fatalf("new-file diff = %q, want %q", got, want)
}
}
func TestUnifiedDiffEdit(t *testing.T) {
before := "line1\nline2\nline3"
after := "line1\nline2-changed\nline3"
got := UnifiedDiff("f", before, after)
for _, want := range []string{" line1", "-line2", "+line2-changed", " line3"} {
if !strings.Contains(got, want) {
t.Fatalf("edit diff missing %q in:\n%s", want, got)
}
}
}
func TestUnifiedDiffInsertion(t *testing.T) {
got := UnifiedDiff("f", "a\nb", "a\nx\nb")
want := " a\n+x\n b"
if got != want {
t.Fatalf("insertion diff = %q, want %q", got, want)
}
}
func TestUnifiedDiffDeletion(t *testing.T) {
got := UnifiedDiff("f", "a\nb\nc", "a\nc")
want := " a\n-b\n c"
if got != want {
t.Fatalf("deletion diff = %q, want %q", got, want)
}
}
func TestUnifiedDiffEmpty(t *testing.T) {
if got := UnifiedDiff("f", "", ""); got != "" {
t.Fatalf("empty diff = %q, want empty", got)
}
}
func TestDiffStat(t *testing.T) {
add, del := DiffStat(" a\n+x\n+y\n-b\n c")
if add != 2 || del != 1 {
t.Fatalf("DiffStat = (+%d -%d), want (+2 -1)", add, del)
}
}
+1 -2
View File
@@ -218,7 +218,6 @@ func TestSuggestionOverlayRendersInDefaultSkin(t *testing.T) {
func TestSuggestionOverlayRendersInZerolineSkin(t *testing.T) {
m := newModel(context.Background(), Options{Skin: "zeroline", ThemeDark: true})
m.width, m.height = 100, 30
m.booted = true
m.showSplash = false
m = typeRunes(t, m, "/mo")
@@ -241,7 +240,7 @@ func TestFileSuggestionsMatchesAndSkipsVCSDirs(t *testing.T) {
}
}
mustWrite("cmd/server/main.go")
mustWrite(".git/config") // hidden VCS dir: must be skipped
mustWrite(".git/config") // hidden VCS dir: must be skipped
mustWrite("node_modules/dep/index.js") // dependency dir: must be skipped
got := suggestionTokens(fileSuggestions(root, "main"))
+8
View File
@@ -25,6 +25,7 @@ const (
commandPlan
commandSearch
commandResume
commandSpec
commandCompact
commandRewind
commandEffort
@@ -146,6 +147,13 @@ var commandDefinitions = []commandDefinition{
description: "List recent sessions or show resume guidance.",
kind: commandResume,
},
{
name: "/spec",
usage: "/spec <task>",
group: commandGroupSession,
description: "Draft an implementation spec for review before editing.",
kind: commandSpec,
},
{
name: "/compact",
usage: "/compact [status]",
+4 -5
View File
@@ -16,10 +16,10 @@ func TestModelSupportsVisionTUI(t *testing.T) {
modelName string
want bool
}{
{modelName: "gpt-4.1", want: true}, // vision model in the catalog
{modelName: "claude-sonnet-4.5", want: true}, // vision model in the catalog
{modelName: "claude-haiku-3.5", want: true}, // has vision capability
{modelName: "totally-unknown-custom", want: false}, // not in catalog -> can't confirm
{modelName: "gpt-4.1", want: true}, // vision model in the catalog
{modelName: "claude-sonnet-4.5", want: true}, // vision model in the catalog
{modelName: "claude-haiku-3.5", want: true}, // has vision capability
{modelName: "totally-unknown-custom", want: false}, // not in catalog -> can't confirm
{modelName: "", want: false},
}
for _, tc := range cases {
@@ -152,7 +152,6 @@ func TestZerolineViewShowsImageChips(t *testing.T) {
m.width = 100
m.height = 30
m.showSplash = false
m.booted = true
m.pendingImageLabels = []string{"photo.png"}
view := m.zerolineView()
+152 -8
View File
@@ -14,10 +14,12 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/usage"
"github.com/Gitlawb/zero/internal/zeroline"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -41,6 +43,7 @@ type model struct {
usageTracker *usage.Tracker
runtimeMessageSink func(tea.Msg)
agentOptions agent.Options
notifier *notify.Notifier
permissionMode agent.PermissionMode
reasoningEffort modelregistry.ReasoningEffort
responseStyle string
@@ -68,6 +71,7 @@ type model struct {
flushRunIDs map[int]struct{}
pendingPermission *pendingPermissionPrompt
pendingAskUser *pendingAskUserPrompt
pendingSpecReview *pendingSpecReviewPrompt
width int
height int
now func() time.Time
@@ -76,7 +80,10 @@ type model struct {
themeVariant int // zeroline color theme (0-4)
themeDark bool // zeroline light/dark
frame int // animation frame counter (zeroline spinner)
booted bool // zeroline boot splash finished
jsonMode bool // zeroline TEXT/JSON view toggle (ctrl+j)
drawerOpen bool // zeroline sessions drawer (ctrl+s)
drawerIndex int // selected session row in the drawer
drawerSessions []zeroline.Session
streamingText string // live assistant text for the current segment
streamStartFrame int // frame the current stream segment began (tok/s)
@@ -119,6 +126,7 @@ type agentResponseMsg struct {
usageEvents []zeroruntime.Usage
usageModelID string
sessionEvents []pendingSessionEvent
specReview *pendingSpecReviewPrompt
err error
}
@@ -165,6 +173,21 @@ type pendingAskUserPrompt struct {
answers []string
}
type pendingSpecReviewPrompt struct {
SpecID string
SpecTitle string
SpecFilePath string
RelativePath string
DraftSessionID string
}
type tuiAgentRunOptions struct {
registry *tools.Registry
permissionMode agent.PermissionMode
systemPrompt string
specDraft bool
}
func newModel(ctx context.Context, options Options) model {
if ctx == nil {
ctx = context.Background()
@@ -206,11 +229,17 @@ func newModel(ctx context.Context, options Options) model {
input.Prompt = "zero > "
input.Placeholder = "Ask Zero to inspect, edit, explain, or run a command..."
if options.Skin == "zeroline" {
input.Prompt = "❯ "
input.Placeholder = "message zero — / commands · @ files · ! bash"
input.Prompt = "" // the zeroline composer renders its own ❯ prompt
input.Placeholder = "describe a task for zero…"
}
input.Focus()
notifier := notify.New(os.Stderr, notify.Config{
Mode: notify.Mode(strings.TrimSpace(options.Notify.Mode)),
FocusMode: notify.FocusMode(strings.TrimSpace(options.Notify.FocusMode)),
})
notifier.SetFocused(true)
return model{
skin: options.Skin,
themeVariant: options.ThemeVariant,
@@ -236,6 +265,7 @@ func newModel(ctx context.Context, options Options) model {
input: input,
showSplash: true,
now: time.Now,
notifier: notifier,
}
}
@@ -249,6 +279,24 @@ func (m model) Init() tea.Cmd {
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
// The sessions drawer is a top-level modal: it intercepts close/navigate
// keys and swallows the rest. Ctrl+C (quit/cancel) and a pending permission
// always take precedence, so the drawer never traps the user.
if m.skin == "zeroline" && m.drawerOpen && m.pendingPermission == nil && msg.Type != tea.KeyCtrlC {
switch msg.String() {
case "esc", "ctrl+s", "q":
m.drawerOpen = false
case "up", "k":
if m.drawerIndex > 0 {
m.drawerIndex--
}
case "down", "j":
if m.drawerIndex < len(m.drawerSessions)-1 {
m.drawerIndex++
}
}
return m, nil
}
switch msg.Type {
case tea.KeyCtrlC:
// cancelRun records the in-flight run into flushRunIDs and writes the
@@ -278,6 +326,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.pendingAskUser != nil {
return m.resolveAskUser(true)
}
if m.pendingSpecReview != nil {
return m.cancelSpecReview()
}
// An open picker cancels first; then an active suggestion overlay is
// dismissed. Neither cancels the run or clears the input.
if m.picker != nil {
@@ -301,6 +352,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.pendingAskUser != nil {
return m.submitAskUserAnswer()
}
if m.pendingSpecReview != nil {
return m, nil
}
if m.picker != nil {
return m.choosePicker()
}
@@ -316,7 +370,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// nextPermissionMode), but only when nothing modal is up: a permission
// prompt, ask_user questionnaire, or open picker all take precedence
// and let the key fall through to their own handlers below.
if m.pendingPermission == nil && m.pendingAskUser == nil && m.picker == nil {
if m.pendingPermission == nil && m.pendingAskUser == nil && m.pendingSpecReview == nil && m.picker == nil {
m.permissionMode = nextPermissionMode(m.permissionMode)
return m, nil
}
@@ -351,6 +405,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.input, cmd = m.input.Update(msg)
return m, cmd
}
if m.pendingSpecReview != nil {
return m.handleSpecReviewKey(msg)
}
if m.pendingPermission != nil {
return m.handlePermissionKey(msg)
}
@@ -361,7 +418,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
if m.skin == "zeroline" {
m.booted = true // any key dismisses the boot splash
if nm, handled := m.handleZerolineKeys(msg); handled {
return nm, nil
}
@@ -372,14 +428,38 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.input, cmd = m.input.Update(msg)
m.recomputeSuggestions()
return m, cmd
case tea.MouseMsg:
// Left-click resolves the zeroline permission card by hit-testing its
// buttons; the geometry is computed the same way the renderer lays them out.
if m.skin == "zeroline" && m.pendingPermission != nil &&
msg.Action == tea.MouseActionRelease && msg.Button == tea.MouseButtonLeft {
if geo := zeroline.PermLayout(m.width, m.height); geo.Active {
switch geo.Hit(msg.X, msg.Y) {
case "allow":
return m.resolvePermission(permissionDecisionAllow)
case "always":
return m.resolvePermission(permissionDecisionAlwaysAllow)
case "deny":
return m.resolvePermission(permissionDecisionDeny)
}
}
}
return m, nil
case tea.FocusMsg:
if m.notifier != nil {
m.notifier.SetFocused(true)
}
return m, nil
case tea.BlurMsg:
if m.notifier != nil {
m.notifier.SetFocused(false)
}
return m, nil
case zerolineTickMsg:
if m.skin != "zeroline" {
return m, nil
}
m.frame++
if m.frame >= bootFrames {
m.booted = true
}
return m, zerolineTick()
case agentTextMsg:
if msg.runID != m.activeRunID {
@@ -394,6 +474,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
// Bound the input width for the zeroline composer so a long line scrolls
// horizontally instead of growing past the frame and pushing the run/stop
// button off the right edge. Leave room for the ❯ prompt + the button.
if m.skin == "zeroline" && m.width > 16 {
m.input.Width = m.width - 12
}
return m, nil
case permissionRequestMsg:
if msg.runID != m.activeRunID {
@@ -487,6 +573,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
text: msg.err.Error(),
})
}
if msg.specReview != nil {
m = m.activateSpecReview(*msg.specReview)
}
if m.notifier != nil {
m.notifier.Notify(notify.Completion, notify.DefaultMessage(notify.Completion))
}
return m, nil
case agentRowMsg:
if msg.runID != m.activeRunID {
@@ -545,6 +637,11 @@ func (m model) transcriptView() string {
}
builder.WriteString("\n")
}
if m.pendingSpecReview != nil {
builder.WriteString("\n")
builder.WriteString(renderFocusedSpecReviewPrompt(*m.pendingSpecReview, width))
builder.WriteString("\n")
}
builder.WriteString("\n")
if chips := renderImageChips(m.pendingImageLabels); chips != "" {
@@ -578,6 +675,9 @@ func startsTurn(kind rowKind) bool {
}
func (m model) handlePermissionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.String() == "A" { // capital A = always (spec: [a] allow, [A] always, [d] deny)
return m.resolvePermission(permissionDecisionAlwaysAllow)
}
switch strings.ToLower(msg.String()) {
case "a":
return m.resolvePermission(permissionDecisionAllow)
@@ -804,6 +904,9 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
}
return m, nil
case commandSpec:
m.showSplash = false
return m.handleSpecCommand(command.text)
case commandCompact:
m.showSplash = false
text := ""
@@ -988,14 +1091,28 @@ func (m *model) cancelRun() {
}
func (m model) runAgent(runID int, runCtx context.Context, prompt string, images []zeroruntime.ImageBlock) tea.Cmd {
return m.runAgentWithOptions(runID, runCtx, prompt, images, tuiAgentRunOptions{})
}
func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt string, images []zeroruntime.ImageBlock, runOptions tuiAgentRunOptions) tea.Cmd {
return func() tea.Msg {
rows := []transcriptRow{}
usageEvents := []zeroruntime.Usage{}
sessionEvents := []pendingSessionEvent{}
usageModelID := m.modelName
var specReview *pendingSpecReviewPrompt
options := m.agentOptions
options.Registry = m.registry
if runOptions.registry != nil {
options.Registry = runOptions.registry
}
options.PermissionMode = m.permissionMode
if runOptions.permissionMode != "" {
options.PermissionMode = runOptions.permissionMode
}
if runOptions.systemPrompt != "" {
options.SystemPrompt = runOptions.systemPrompt
}
options.SessionID = m.activeSession.SessionID
options.Model = m.modelName
options.ReasoningEffort = string(m.reasoningEffort)
@@ -1024,6 +1141,9 @@ func (m model) runAgent(runID int, runCtx context.Context, prompt string, images
if m.runtimeMessageSink == nil {
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission prompt unavailable"}, nil
}
if m.notifier != nil {
m.notifier.Notify(notify.AwaitingInput, notify.DefaultMessage(notify.AwaitingInput))
}
decisionCh := make(chan agent.PermissionDecision, 1)
m.sendPermissionRequest(runID, request, func(decision agent.PermissionDecision) {
select {
@@ -1055,6 +1175,11 @@ func (m model) runAgent(runID int, runCtx context.Context, prompt string, images
// No interactive surface: let the loop degrade gracefully.
return agent.AskUserResponse{}, fmt.Errorf("ask_user prompt unavailable")
}
// Only notify when there is actually something to answer — a request
// with no questions auto-resolves without ever prompting the user.
if m.notifier != nil && len(request.Questions) > 0 {
m.notifier.Notify(notify.AwaitingInput, notify.DefaultMessage(notify.AwaitingInput))
}
answerCh := make(chan []string, 1)
m.sendAskUserRequest(runID, request, func(answers []string) {
select {
@@ -1121,6 +1246,11 @@ func (m model) runAgent(runID int, runCtx context.Context, prompt string, images
onToolResult := options.OnToolResult
options.OnToolResult = func(result agent.ToolResult) {
if runOptions.specDraft {
if info, ok := tuiSpecReviewFromToolResult(result, m.activeSession.SessionID); ok {
specReview = &info
}
}
row := transcriptRow{
kind: rowToolResult,
id: result.ToolCallID,
@@ -1140,6 +1270,9 @@ func (m model) runAgent(runID int, runCtx context.Context, prompt string, images
if result.Redacted {
toolPayload["redacted"] = true
}
if len(result.Meta) > 0 {
toolPayload["meta"] = result.Meta
}
if len(result.ChangedFiles) > 0 {
toolPayload["changedFiles"] = result.ChangedFiles
}
@@ -1190,6 +1323,17 @@ func (m model) runAgent(runID int, runCtx context.Context, prompt string, images
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err}
}
if runOptions.specDraft {
if result.StopReason != agent.StopReasonSpecReviewRequired || specReview == nil || specReview.SpecID == "" || specReview.SpecFilePath == "" {
err := fmt.Errorf("spec draft ended without submit_spec")
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: sessions.EventError,
Payload: map[string]any{"message": err.Error()},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err}
}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, specReview: specReview}
}
rows = append(rows, transcriptRow{kind: rowAssistant, text: result.FinalAnswer})
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: sessions.EventMessage,
+33 -1
View File
@@ -1,6 +1,7 @@
package tui
import (
"bytes"
"context"
"errors"
"path/filepath"
@@ -12,6 +13,7 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
@@ -55,6 +57,7 @@ func TestParseCommand(t *testing.T) {
{input: "/find needle", kind: commandSearch, text: "needle"},
{input: "/resume", kind: commandResume},
{input: "/sessions", kind: commandResume},
{input: "/spec add review flow", kind: commandSpec, text: "add review flow"},
{input: "/compact", kind: commandCompact},
{input: "/effort high", kind: commandEffort, text: "high"},
{input: "/style concise", kind: commandStyle, text: "concise"},
@@ -86,7 +89,7 @@ func TestCommandRegistryResolvesAliasesAndFormatsHelp(t *testing.T) {
}
help := strings.Join(formatCommandHelpLines(), "\n")
for _, want := range []string{"/model", "/context", "/debug", "/permissions", "model"} {
for _, want := range []string{"/model", "/context", "/debug", "/permissions", "/spec", "model"} {
assertContains(t, help, want)
}
}
@@ -1293,3 +1296,32 @@ func TestNextPermissionModeFoldsUnsafeToAsk(t *testing.T) {
t.Fatalf("Unsafe -> %s, want Ask", got)
}
}
func TestModelNotifierFocusAndCompletion(t *testing.T) {
var buf bytes.Buffer
m := model{notifier: notify.New(&buf, notify.Config{Mode: notify.ModeBell, FocusMode: notify.FocusUnfocused})}
m.notifier.SetFocused(true)
// Focused → completion under unfocused-mode is silent.
m.notifier.Notify(notify.Completion, notify.DefaultMessage(notify.Completion))
if buf.Len() != 0 {
t.Fatalf("focused should be silent, got %q", buf.String())
}
// BlurMsg flips focus; completion now bells.
updated, _ := m.Update(tea.BlurMsg{})
m = updated.(model)
m.notifier.Notify(notify.Completion, notify.DefaultMessage(notify.Completion))
if buf.String() != "\x07" {
t.Fatalf("unfocused should bell, got %q", buf.String())
}
// FocusMsg flips back.
updated, _ = m.Update(tea.FocusMsg{})
m = updated.(model)
buf.Reset()
m.notifier.Notify(notify.Completion, "x")
if buf.Len() != 0 {
t.Fatalf("refocused should be silent, got %q", buf.String())
}
}
+3
View File
@@ -37,4 +37,7 @@ type Options struct {
Skin string
ThemeVariant int // zeroline color theme index (0-4)
ThemeDark bool // zeroline light/dark mode
// Notify configures completion / awaiting-input notifications.
Notify config.NotifyConfig
}
-1
View File
@@ -187,7 +187,6 @@ func TestPickerRendersInBothSkins(t *testing.T) {
// Zeroline skin.
z := newModel(context.Background(), Options{Skin: "zeroline", ThemeDark: true, ModelName: "claude-sonnet-4.5"})
z.width, z.height = 100, 30
z.booted = true
z.showSplash = false
z.input.SetValue("/model")
updatedZ, _ := z.Update(tea.KeyMsg{Type: tea.KeyEnter})
+11 -5
View File
@@ -3,8 +3,11 @@ package tui
import (
"context"
"os"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/Gitlawb/zero/internal/notify"
)
// Run starts the Zero Bubble Tea shell and returns a process-style exit code.
@@ -25,11 +28,14 @@ func Run(ctx context.Context, options Options) int {
tea.WithInput(os.Stdin),
tea.WithOutput(os.Stdout),
}
// NOTE: we intentionally do NOT enable mouse capture. Mouse cell-motion
// reporting routes clicks/drags to the program, which breaks the terminal's
// native text selection + copy and surprises users who expect normal
// click/select/copy/paste. The permission modal is fully keyboard-driven
// (a/y/d/Esc), so capturing the mouse buys little and costs core UX.
if notify.Enabled(notify.Mode(strings.TrimSpace(options.Notify.Mode))) {
programOpts = append(programOpts, tea.WithReportFocus())
}
// Enable mouse cell-motion so the permission card's buttons are clickable.
// Tradeoff: this routes clicks/drags to the program and can interfere with the
// terminal's native text selection (most terminals let users hold Shift/Option
// to force native selection). The card stays fully keyboard-driven (a/A/d/Esc).
programOpts = append(programOpts, tea.WithMouseCellMotion())
program = tea.NewProgram(newModel(ctx, options), programOpts...)
if _, err := program.Run(); err != nil {
+296
View File
@@ -0,0 +1,296 @@
package tui
import (
"context"
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/specmode"
"github.com/Gitlawb/zero/internal/tools"
)
func (m model) handleSpecCommand(task string) (tea.Model, tea.Cmd) {
task = strings.TrimSpace(task)
if task == "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Usage: /spec <task>"})
return m, nil
}
if m.pending {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "Cannot start spec mode while a run is active."})
return m, nil
}
if m.pendingSpecReview != nil {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Review the pending spec before drafting another one."})
return m, nil
}
if m.provider == nil {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendAssistant, text: "No provider configured."})
return m, nil
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: "/spec " + task})
var err error
m, err = m.createSpecDraftSession(task)
if err != nil {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "session create error: " + err.Error()})
return m, nil
}
m, err = m.appendSessionEvent(sessions.EventMessage, map[string]any{
"role": "user",
"content": task,
})
if err != nil {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "session record error: " + err.Error()})
}
turnImages := m.pendingImages
if len(turnImages) > 0 && !modelSupportsVisionTUI(m.modelName) {
name := m.modelName
if name == "" {
name = "the active model"
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendSystem,
text: fmt.Sprintf("Model %s does not support image input; ignoring %d image(s).", name, len(turnImages)),
})
turnImages = nil
}
m.pendingImages = nil
m.pendingImageLabels = nil
specRegistry := cloneToolRegistry(m.registry)
specmode.RegisterDraftTools(specRegistry, m.cwd, m.now)
runCtx, cancel := context.WithCancel(m.ctx)
m.runID++
m.activeRunID = m.runID
m.runCancel = cancel
m.pending = true
return m, m.runAgentWithOptions(m.activeRunID, runCtx, task, turnImages, tuiAgentRunOptions{
registry: specRegistry,
permissionMode: agent.PermissionModeSpecDraft,
systemPrompt: specmode.DraftSystemPrompt,
specDraft: true,
})
}
func (m model) createSpecDraftSession(task string) (model, error) {
session, err := m.sessionStore.Create(sessions.CreateInput{
SessionKind: sessions.SessionKindSpecDraft,
Title: tuiSessionTitle(task),
Cwd: m.cwd,
ModelID: m.modelName,
Provider: m.providerName,
SpecDraftModelID: m.modelName,
SpecDraftReasoning: string(m.reasoningEffort),
})
if err != nil {
return m, err
}
m.activeSession = session
m.sessionEvents = []sessions.Event{}
return m, nil
}
func tuiSpecReviewFromToolResult(result agent.ToolResult, draftSessionID string) (pendingSpecReviewPrompt, bool) {
if result.Name != specmode.SubmitToolName || result.Meta["control"] != specmode.ControlSpecReviewRequired {
return pendingSpecReviewPrompt{}, false
}
return pendingSpecReviewPrompt{
SpecID: strings.TrimSpace(result.Meta["specId"]),
SpecTitle: strings.TrimSpace(result.Meta["specTitle"]),
SpecFilePath: strings.TrimSpace(result.Meta["specFilePath"]),
RelativePath: strings.TrimSpace(result.Meta["relativePath"]),
DraftSessionID: strings.TrimSpace(draftSessionID),
}, true
}
func (m model) activateSpecReview(review pendingSpecReviewPrompt) model {
updated, event, err := m.sessionStore.RecordSpec(review.DraftSessionID, sessions.RecordSpecInput{
SpecID: review.SpecID,
SpecFilePath: review.SpecFilePath,
SpecStatus: sessions.SpecStatusDraft,
SpecDraftModelID: m.modelName,
SpecDraftReasoning: string(m.reasoningEffort),
})
if err != nil {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "spec record error: " + err.Error()})
return m
}
if m.activeSession.SessionID == updated.SessionID {
m.activeSession = updated
m.sessionEvents = append(m.sessionEvents, event)
}
m.pendingSpecReview = &review
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: specReviewSummary(review)})
return m
}
func (m model) handleSpecReviewKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch strings.ToLower(msg.String()) {
case "a":
return m.approveSpecReview()
case "r":
return m.rejectSpecReview("")
case "e":
review := m.pendingSpecReview
if review == nil {
return m, nil
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendSystem,
text: "Edit the saved spec file, then press a to approve or r to reject.\npath: " + reviewDisplayPath(*review),
})
return m, nil
case "c":
return m.cancelSpecReview()
default:
return m, nil
}
}
func (m model) approveSpecReview() (tea.Model, tea.Cmd) {
review := m.pendingSpecReview
if review == nil {
return m, nil
}
if m.provider == nil {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendAssistant, text: "No provider configured."})
return m, nil
}
body, path, err := specmode.LoadSpecFile(m.cwd, review.SpecFilePath)
if err != nil {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "spec read error: " + err.Error()})
return m, nil
}
prompt := specmode.ImplementationPrompt(body, path, review.DraftSessionID, "")
impl, events, err := m.sessionStore.EnsureSpecImplementation(sessions.EnsureSpecImplementationInput{
Title: specImplementationTitle(*review),
Cwd: m.cwd,
ModelID: m.modelName,
Provider: m.providerName,
RootSessionID: review.DraftSessionID,
SpecID: review.SpecID,
SpecFilePath: path,
SpecDraftModelID: m.modelName,
SpecDraftReasoning: string(m.reasoningEffort),
SpecSourceSessionID: review.DraftSessionID,
Prompt: prompt,
})
if err != nil {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "session create error: " + err.Error()})
return m, nil
}
if _, _, err := m.sessionStore.RecordSpec(review.DraftSessionID, sessions.RecordSpecInput{
SpecID: review.SpecID,
SpecFilePath: path,
SpecStatus: sessions.SpecStatusApproved,
SpecDraftModelID: m.modelName,
SpecDraftReasoning: string(m.reasoningEffort),
SpecImplSessionID: impl.SessionID,
}); err != nil {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "spec approve error: " + err.Error()})
return m, nil
}
m.pendingSpecReview = nil
m.activeSession = impl
m.sessionEvents = append([]sessions.Event{}, events...)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Spec approved. Starting implementation session " + impl.SessionID + "."})
runCtx, cancel := context.WithCancel(m.ctx)
m.runID++
m.activeRunID = m.runID
m.runCancel = cancel
m.pending = true
return m, m.runAgent(m.activeRunID, runCtx, prompt, nil)
}
func (m model) rejectSpecReview(reason string) (tea.Model, tea.Cmd) {
review := m.pendingSpecReview
if review == nil {
return m, nil
}
updated, event, err := m.sessionStore.RecordSpec(review.DraftSessionID, sessions.RecordSpecInput{
SpecID: review.SpecID,
SpecFilePath: review.SpecFilePath,
SpecStatus: sessions.SpecStatusRejected,
SpecDraftModelID: m.modelName,
SpecDraftReasoning: string(m.reasoningEffort),
SpecRejectReason: reason,
})
if err != nil {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "spec reject error: " + err.Error()})
return m, nil
}
if m.activeSession.SessionID == updated.SessionID {
m.activeSession = updated
m.sessionEvents = append(m.sessionEvents, event)
}
m.pendingSpecReview = nil
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Spec rejected. Use /spec <task> to draft again."})
return m, nil
}
func (m model) cancelSpecReview() (tea.Model, tea.Cmd) {
m.pendingSpecReview = nil
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Spec review canceled. The draft remains saved."})
return m, nil
}
func cloneToolRegistry(registry *tools.Registry) *tools.Registry {
clone := tools.NewRegistry()
if registry == nil {
return clone
}
for _, tool := range registry.All() {
clone.Register(tool)
}
return clone
}
func renderFocusedSpecReviewPrompt(review pendingSpecReviewPrompt, width int) string {
lines := []string{
zeroTheme.zero.Render("◇ spec review"),
"path: " + reviewDisplayPath(review),
"[a] approve [r] reject [e] edit file [esc] cancel",
}
return borderedBlock(width, lines)
}
func specReviewSummary(review pendingSpecReviewPrompt) string {
return renderCommandOutput(commandOutput{
Title: "Spec draft ready",
Status: commandStatusOK,
Sections: []commandSection{{
Title: "Review",
Lines: []string{
"spec: " + review.SpecID,
"path: " + reviewDisplayPath(review),
"keys: a approve, r reject, e edit file, esc cancel",
},
}},
})
}
func reviewDisplayPath(review pendingSpecReviewPrompt) string {
if strings.TrimSpace(review.RelativePath) != "" {
return review.RelativePath
}
return review.SpecFilePath
}
func specImplementationTitle(review pendingSpecReviewPrompt) string {
title := strings.TrimSpace(review.SpecTitle)
if title == "" {
title = strings.TrimSpace(review.SpecID)
}
if title == "" {
return "Spec implementation"
}
if len(title) > tuiSessionTitleLimit {
title = title[:tuiSessionTitleLimit]
}
return title + " implementation"
}
+154
View File
@@ -0,0 +1,154 @@
package tui
import (
"context"
"encoding/json"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/specmode"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestSpecCommandCreatesDraftReview(t *testing.T) {
store := testSessionStore(t)
provider := &scriptedProvider{scripts: [][]zeroruntime.StreamEvent{
submitSpecScript("call-1", "Review Flow", "# Goal\n\nAdd review flow."),
}}
m := newSpecModeTestModel(t.TempDir(), provider, store)
m.input.SetValue("/spec add review flow")
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd == nil {
t.Fatal("expected /spec to start a draft run")
}
updated, _ = next.Update(cmd())
next = updated.(model)
if next.pendingSpecReview == nil {
t.Fatalf("expected pending spec review, got %#v", next)
}
if next.activeSession.SessionKind != sessions.SessionKindSpecDraft || next.activeSession.SpecStatus != sessions.SpecStatusDraft {
t.Fatalf("unexpected active spec session: %#v", next.activeSession)
}
if !strings.Contains(next.pendingSpecReview.RelativePath, ".zero/specs/") {
t.Fatalf("spec path not recorded: %#v", next.pendingSpecReview)
}
if !providerRequestIncludesTool(provider.requests[0], specmode.SubmitToolName) {
t.Fatalf("submit_spec was not advertised: %#v", provider.requests[0].Tools)
}
if providerRequestIncludesTool(provider.requests[0], "write_file") {
t.Fatalf("spec draft must not advertise write_file: %#v", provider.requests[0].Tools)
}
}
func TestSpecApproveStartsImplementationSession(t *testing.T) {
store := testSessionStore(t)
provider := &scriptedProvider{scripts: [][]zeroruntime.StreamEvent{
submitSpecScript("call-1", "Review Flow", "# Goal\n\nAdd review flow."),
textScript("implemented from approved spec"),
}}
m := newSpecModeTestModel(t.TempDir(), provider, store)
m.input.SetValue("/spec add review flow")
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
updated, _ = next.Update(cmd())
next = updated.(model)
review := next.pendingSpecReview
if review == nil {
t.Fatal("expected pending review before approval")
}
updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")})
next = updated.(model)
if cmd == nil {
t.Fatal("expected approval to start implementation run")
}
if next.pendingSpecReview != nil {
t.Fatal("expected pending review to clear on approval")
}
if next.activeSession.SessionKind != sessions.SessionKindSpecImpl {
t.Fatalf("expected active implementation session, got %#v", next.activeSession)
}
updated, _ = next.Update(cmd())
next = updated.(model)
if !transcriptContains(next.transcript, "implemented from approved spec") {
t.Fatalf("implementation answer missing from transcript: %#v", next.transcript)
}
draft, err := store.Get(review.DraftSessionID)
if err != nil {
t.Fatal(err)
}
if draft == nil || draft.SpecStatus != sessions.SpecStatusApproved || draft.SpecImplSessionID != next.activeSession.SessionID {
t.Fatalf("draft metadata not approved: %#v", draft)
}
if len(provider.requests) != 2 {
t.Fatalf("provider request count = %d, want 2", len(provider.requests))
}
last := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
if !strings.Contains(last.Content, "Implement the following approved spec") || !strings.Contains(last.Content, "# Goal") {
t.Fatalf("implementation prompt missing spec body: %#v", last)
}
}
func TestSpecReviewBlocksShiftTabModeCycle(t *testing.T) {
m := newModel(context.Background(), Options{PermissionMode: agent.PermissionModeAuto})
m.pendingSpecReview = &pendingSpecReviewPrompt{SpecID: "spec", SpecFilePath: ".zero/specs/spec.md"}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
next := updated.(model)
if next.permissionMode != agent.PermissionModeAuto {
t.Fatalf("expected permission mode unchanged during spec review, got %q", next.permissionMode)
}
if next.pendingSpecReview == nil {
t.Fatal("expected spec review to remain pending")
}
}
func newSpecModeTestModel(root string, provider zeroruntime.Provider, store *sessions.Store) model {
registry := tools.NewRegistry()
for _, tool := range tools.CoreTools(root) {
registry.Register(tool)
}
return newModel(context.Background(), Options{
Cwd: root,
ProviderName: "openai",
ModelName: "gpt-4.1",
Provider: provider,
Registry: registry,
SessionStore: store,
PermissionMode: agent.PermissionModeAsk,
})
}
func submitSpecScript(callID string, title string, plan string) []zeroruntime.StreamEvent {
args, _ := json.Marshal(map[string]string{
"title": title,
"plan": plan,
})
return []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: callID, ToolName: specmode.SubmitToolName},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: callID, ArgumentsFragment: string(args)},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: callID},
{Type: zeroruntime.StreamEventDone},
}
}
func providerRequestIncludesTool(request zeroruntime.CompletionRequest, name string) bool {
for _, tool := range request.Tools {
if tool.Name == name {
return true
}
}
return false
}
+104 -33
View File
@@ -1,6 +1,7 @@
package tui
import (
"fmt"
"strings"
"time"
@@ -13,10 +14,6 @@ import (
// agent events so "working…" spins smoothly between row updates.
type zerolineTickMsg time.Time
// bootFrames is how many ~120ms ticks the boot splash plays before the home
// page reveals (~1.9s), matching the mockup's splash timing.
const bootFrames = 16
func zerolineTick() tea.Cmd {
return tea.Tick(120*time.Millisecond, func(t time.Time) tea.Msg { return zerolineTickMsg(t) })
}
@@ -32,6 +29,17 @@ func (m model) handleZerolineKeys(msg tea.KeyMsg) (model, bool) {
case "ctrl+t":
m.themeVariant = (m.themeVariant + 1) % len(zeroline.Themes)
return m, true
case "ctrl+j":
m.jsonMode = !m.jsonMode // TEXT/JSON view toggle
return m, true
case "ctrl+s":
if m.showSplash {
return m, true // the drawer only renders on the chat page; ignore on home
}
m.drawerOpen = true // sessions drawer (Esc/ctrl+s closes; handled in Update)
m.drawerSessions = m.loadDrawerSessions()
m.drawerIndex = 0
return m, true
}
if strings.TrimSpace(m.input.Value()) == "" {
if k := msg.String(); len(k) == 1 && k >= "1" && k <= "5" {
@@ -42,6 +50,72 @@ func (m model) handleZerolineKeys(msg tea.KeyMsg) (model, bool) {
return m, false
}
// drawerData returns the open sessions drawer (nil when closed).
func (m model) drawerData() *zeroline.Drawer {
if !m.drawerOpen {
return nil
}
return &zeroline.Drawer{Sessions: m.drawerSessions, Selected: m.drawerIndex}
}
// loadDrawerSessions lists real sessions from the store (most-recent first),
// falling back to the sample list when the store is empty or unavailable.
func (m model) loadDrawerSessions() []zeroline.Session {
if m.sessionStore == nil {
return zeroline.DefaultSessions()
}
items, err := m.sessionStore.List()
if err != nil || len(items) == 0 {
return zeroline.DefaultSessions()
}
out := make([]zeroline.Session, 0, len(items))
for _, it := range items {
title := strings.TrimSpace(it.Title)
if title == "" {
title = "(untitled)"
}
id := it.SessionID
if len(id) > 6 {
id = id[:6]
}
out = append(out, zeroline.Session{
ID: id,
When: m.relativeWhen(it.UpdatedAt),
Title: title,
Model: it.ModelID,
Turns: it.EventCount,
})
if len(out) >= 12 {
break
}
}
return out
}
// relativeWhen formats an RFC3339 timestamp as a short relative age (falls back
// to the raw string if it can't be parsed).
func (m model) relativeWhen(s string) string {
t, err := time.Parse(time.RFC3339, strings.TrimSpace(s))
if err != nil {
return s
}
now := time.Now
if m.now != nil {
now = m.now
}
d := now().Sub(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
default:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
}
// zerolineHeader builds the zeroline header, including the live cost and
// cumulative token total from the usage tracker (with the unpriced fallback) so
// the top/bottom bars actually reflect consumption instead of always showing $0.
@@ -72,32 +146,20 @@ func (m model) zerolineView() string {
height = 30
}
// Boot splash reveals on launch, then the home page.
if !m.booted && m.showSplash {
return zeroline.RenderBoot(m.themeVariant, m.themeDark, m.frame, width, height)
// Spec composer placeholder per surface (m is a value receiver, so this only
// affects this render). The composer renders the ❯ prompt + accent caret.
if m.showSplash {
m.input.Placeholder = "describe a task for zero…"
} else {
m.input.Placeholder = "message zero…"
}
header := m.zerolineHeader()
// Home until the first turn is submitted.
if m.showSplash {
return zeroline.RenderHome(zeroline.HomeData{
Variant: m.themeVariant,
Dark: m.themeDark,
Width: width,
Height: height,
Header: header,
Input: m.input.View(),
Suggestions: m.zerolineSuggestions(),
SelectedIdx: m.suggestionIdx,
Picker: m.zerolinePicker(),
})
}
rows := m.zerolineRows()
running := false
for _, r := range rows {
if r.Kind == "toolcall" && r.Running {
if r.Kind == "tool" && r.Running {
running = true
}
}
@@ -119,6 +181,10 @@ func (m model) zerolineView() string {
Stream: m.streamingText,
TokS: m.streamTokS(),
Spin: m.frame,
JSONMode: m.jsonMode,
Drawer: m.drawerData(),
Chips: zeroline.DefaultChips(), // shown only on the empty state (no rows)
ChipIndex: -1,
Perm: m.zerolinePerm(),
AskUser: askUser,
Input: m.input.View(),
@@ -204,11 +270,12 @@ func (m model) streamTokS() int {
}
func (m model) zerolineRows() []zeroline.Row {
// a tool call is "running" until a result row with the same id arrives
resultIDs := make(map[string]bool)
// Merge each tool call with its matching result (by id) into a single "tool"
// card row: running until the result arrives, then carrying its status + body.
results := make(map[string]transcriptRow)
for _, r := range m.transcript {
if r.kind == rowToolResult && r.id != "" {
resultIDs[r.id] = true
results[r.id] = r
}
}
rows := make([]zeroline.Row, 0, len(m.transcript))
@@ -219,14 +286,18 @@ func (m model) zerolineRows() []zeroline.Row {
case rowAssistant:
rows = append(rows, zeroline.Row{Kind: "assistant", Text: r.text})
case rowToolCall:
rows = append(rows, zeroline.Row{
Kind: "toolcall",
Tool: r.tool,
Detail: r.detail,
Running: !(r.id != "" && resultIDs[r.id]),
})
res, done := results[r.id]
row := zeroline.Row{Kind: "tool", Tool: r.tool, Text: r.detail, Running: !(r.id != "" && done)}
if done {
row.Status = string(res.status)
row.Detail = res.detail
}
rows = append(rows, row)
case rowToolResult:
rows = append(rows, zeroline.Row{Kind: "toolresult", Tool: r.tool, Status: string(r.status), Detail: r.detail})
if r.id != "" {
continue // already merged into its tool call above
}
rows = append(rows, zeroline.Row{Kind: "tool", Tool: r.tool, Status: string(r.status), Detail: r.detail})
case rowPermission:
rows = append(rows, zeroline.Row{Kind: "permission", Text: r.text})
case rowAskUser:
+106 -4
View File
@@ -9,6 +9,7 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroline"
)
func newZerolineModel() model {
@@ -20,7 +21,6 @@ func newZerolineModel() model {
ModelName: "claude-sonnet-4.5",
})
m.width, m.height = 100, 30
m.booted = true // skip the boot splash animation; these tests cover home/chat
return m
}
@@ -28,7 +28,7 @@ func TestZerolineHomeThenChat(t *testing.T) {
m := newZerolineModel()
// Home is shown until the first turn (showSplash true).
if !strings.Contains(m.View(), "Own your agent") {
if !strings.Contains(m.View(), "std-lib-first") {
t.Fatal("expected Zen home before first turn")
}
@@ -41,7 +41,7 @@ func TestZerolineHomeThenChat(t *testing.T) {
{kind: rowToolResult, id: "t1", tool: "grep", status: tools.StatusOK, detail: "3 matches"},
}
chat := m.View()
for _, want := range []string{"WORKING", "you", "grep", "claude-sonnet-4.5", "thinking"} {
for _, want := range []string{"WORKING", "❯", "grep", "claude-sonnet-4.5", "thinking"} {
if !strings.Contains(chat, want) {
t.Errorf("chat view missing %q", want)
}
@@ -91,13 +91,115 @@ func TestZerolinePermissionRender(t *testing.T) {
request: agent.PermissionRequest{ToolName: "edit_file", SideEffect: "write"},
}
out := m.View()
for _, want := range []string{"BLOCKED", "permission required", "edit_file", "allow", "deny"} {
for _, want := range []string{"BLOCKED", "PERMISSION", "edit_file", "allow", "deny"} {
if !strings.Contains(out, want) {
t.Errorf("permission view missing %q", want)
}
}
}
func TestZerolinePermissionMouseClick(t *testing.T) {
m := newZerolineModel()
m.showSplash = false
m.pending = true
var got []permissionDecision
m.pendingPermission = &pendingPermissionPrompt{
request: agent.PermissionRequest{ToolName: "edit_file", SideEffect: "write"},
decide: func(d agent.PermissionDecision) { got = append(got, permissionDecision(d.Action)) },
}
geo := zeroline.PermLayout(m.width, m.height)
if !geo.Active {
t.Fatal("expected active permission geometry at 100x30")
}
click := tea.MouseMsg{X: geo.Allow.X + geo.Allow.W/2, Y: geo.Allow.Y, Action: tea.MouseActionRelease, Button: tea.MouseButtonLeft}
updated, _ := m.Update(click)
next := updated.(model)
if len(got) != 1 || got[0] != permissionDecisionAllow {
t.Fatalf("mouse click on allow should resolve allow, got %#v", got)
}
if next.pendingPermission != nil {
t.Fatal("permission should clear after a resolving click")
}
}
func TestZerolineRunningToolSuppressesThinking(t *testing.T) {
m := newZerolineModel()
m.showSplash = false
m.pending = true
// A tool call with no matching result → a running card; the separate
// "thinking…" line must be suppressed (not stacked under the running card).
m.transcript = []transcriptRow{
{kind: rowToolCall, id: "t1", tool: "bash", text: "tool call: bash", detail: "go test ./..."},
}
if out := m.View(); strings.Contains(out, "thinking") {
t.Error("a running tool card should suppress the separate 'thinking' line")
}
}
func TestZerolineDrawerOpenClose(t *testing.T) {
m := newZerolineModel()
m.showSplash = false
// ctrl+s opens the drawer and renders the panel
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
m2 := updated.(model)
if !m2.drawerOpen {
t.Fatal("ctrl+s should open the sessions drawer")
}
if !strings.Contains(m2.View(), "sessions") {
t.Error("open drawer should render the panel")
}
// esc closes it
updated2, _ := m2.Update(tea.KeyMsg{Type: tea.KeyEsc})
if updated2.(model).drawerOpen {
t.Fatal("esc should close the sessions drawer")
}
}
func TestZerolineDrawerCtrlCNotSwallowed(t *testing.T) {
m := newZerolineModel()
m.showSplash = false
upd, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
m2 := upd.(model)
if !m2.drawerOpen {
t.Fatal("ctrl+s should open the drawer")
}
if _, cmd := m2.Update(tea.KeyMsg{Type: tea.KeyCtrlC}); cmd == nil {
t.Error("Ctrl+C must not be swallowed by the drawer (it should quit)")
}
}
func TestZerolineDrawerNotOpenedOnHome(t *testing.T) {
m := newZerolineModel()
m.showSplash = true // home page
upd, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
if upd.(model).drawerOpen {
t.Error("ctrl+s on the home page must not open the drawer (nothing renders it there)")
}
}
func TestZerolineComposerPlaceholderClean(t *testing.T) {
m := newZerolineModel()
m.input.SetValue("")
m.showSplash = true
home := m.View()
if strings.Contains(home, "%") {
t.Errorf("home composer must have no %% artifact: %q", home)
}
if !strings.Contains(home, "describe a task for zero") {
t.Error("home placeholder text missing")
}
m.showSplash = false
chat := m.View()
if strings.Contains(chat, "%") {
t.Errorf("chat composer must have no %% artifact: %q", chat)
}
if !strings.Contains(chat, "message zero") {
t.Error("chat placeholder text missing")
}
}
func TestZerolineThemeKeys(t *testing.T) {
m := newZerolineModel()
// digit selects theme when input empty
+62
View File
@@ -0,0 +1,62 @@
package zeroline
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
)
func TestTitleBarSegmentsAndWidth(t *testing.T) {
s := newStyles(Resolve(0, true), 0, true)
h := Header{Cwd: "~/src/shop", Branch: "main", Dirty: true, Model: "claude-sonnet-4-5"}
out := s.topBar("normal", h, 110, false)
if lipgloss.Height(out) != 1 {
t.Fatalf("title bar must be one row, got %d", lipgloss.Height(out))
}
if lipgloss.Width(out) != 110 {
t.Fatalf("title bar width = %d, want 110 (full width)", lipgloss.Width(out))
}
plain := stripANSI(out)
for _, want := range []string{"zero", "model", "claude-sonnet-4-5", "~/src/shop", "TEXT", "JSON", "main"} {
if !strings.Contains(plain, want) {
t.Errorf("title bar missing %q in %q", want, plain)
}
}
}
func TestTitleBarHidesCwdWhenTight(t *testing.T) {
s := newStyles(Resolve(0, true), 0, true)
h := Header{Cwd: "~/src/shop", Branch: "main", Model: "m"}
out := s.topBar("normal", h, 48, false)
if lipgloss.Width(out) != 48 {
t.Fatalf("tight title bar width = %d, want 48", lipgloss.Width(out))
}
if strings.Contains(stripANSI(out), "~/src/shop") {
t.Errorf("cwd must be hidden at tight width, got %q", stripANSI(out))
}
}
func TestStatusBarSegmentsAndMode(t *testing.T) {
s := newStyles(Resolve(0, true), 0, true)
h := Header{CtxPct: 42, Cost: 0.04, TotalTokens: 1284}
out := s.botBar("work", h, 0, 12, 110)
if lipgloss.Height(out) != 1 {
t.Fatalf("status bar must be one row, got %d", lipgloss.Height(out))
}
if lipgloss.Width(out) != 110 {
t.Fatalf("status bar width = %d, want 110 (full width)", lipgloss.Width(out))
}
plain := stripANSI(out)
for _, want := range []string{"WORKING", "tok/s", "tok ", "ctx", "$"} {
if !strings.Contains(plain, want) {
t.Errorf("status bar missing %q in %q", want, plain)
}
}
if got := stripANSI(s.botBar("done", h, 0, 0, 110)); !strings.Contains(got, "DONE") {
t.Errorf("status bar should show DONE for run=done, got %q", got)
}
if got := stripANSI(s.botBar("blocked", h, 0, 0, 110)); !strings.Contains(got, "BLOCKED") {
t.Errorf("status bar should show BLOCKED for run=blocked, got %q", got)
}
}
+38
View File
@@ -0,0 +1,38 @@
package zeroline
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
)
func TestDrawerRendersSessions(t *testing.T) {
d := chatWith([]Row{{Kind: "user", Text: "hi"}, {Kind: "assistant", Text: "hello"}})
d.Width, d.Height = 110, 22
d.Drawer = &Drawer{Sessions: DefaultSessions(), Selected: 0}
out := RenderChat(d)
if h := lipgloss.Height(out); h != 22 {
t.Fatalf("drawer frame height = %d, want 22 (frame-exact)", h)
}
for _, line := range strings.Split(out, "\n") {
if lipgloss.Width(line) > 110 {
t.Fatalf("drawer line exceeds width 110: %d", lipgloss.Width(line))
}
}
plain := stripANSI(out)
for _, want := range []string{"sessions", "resume / fork / export", "0a91f3", "Add streaming SSE", "14 turns"} {
if !strings.Contains(plain, want) {
t.Errorf("drawer missing %q", want)
}
}
}
func TestDrawerSelectionRail(t *testing.T) {
s := newStyles(Resolve(0, true), 0, true)
panel := strings.Join(s.drawerPanel(&Drawer{Sessions: DefaultSessions(), Selected: 1}, 40, 20), "\n")
// the selected (2nd) row carries the accent rail
if !strings.Contains(stripANSI(panel), "▌") {
t.Error("selected session row should show the accent rail")
}
}
+40
View File
@@ -0,0 +1,40 @@
package zeroline
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
)
// The empty state renders INSIDE the chat chrome (title bar + status bar +
// composer), with the 0 mark + tagline + model hint + chips centered in the body.
func TestEmptyStateShowsChromeAndContent(t *testing.T) {
d := ChatData{
Variant: 0, Dark: true, Width: 90, Height: 30,
Header: Header{Model: "claude-sonnet-4-5", Cwd: "~/code/zero"},
Chips: []string{"Add a --version flag", "Why is go vet failing?", "Create hello.txt"},
}
out := RenderChat(d)
if h := lipgloss.Height(out); h != 30 {
t.Fatalf("empty-state height = %d, want 30 (frame-exact)", h)
}
for _, line := range strings.Split(out, "\n") {
if lipgloss.Width(line) > 90 {
t.Fatalf("line exceeds width 90: %d (%q)", lipgloss.Width(line), stripANSI(line))
}
}
plain := stripANSI(out)
for _, want := range []string{
"zero", // title bar brand
"std-lib-first", "running", "against", "claude-sonnet-4-5", // empty-state hint
"Add a --version flag", "Why is go vet failing?", "Create hello.txt", // chips
"tok", // status bar present (chrome, not a bare Zen page)
} {
if !strings.Contains(plain, want) {
t.Errorf("empty state missing %q", want)
}
}
}
// Chip selection/border behavior is covered by TestChipBoxBorderedAndSelected.
+63
View File
@@ -0,0 +1,63 @@
package zeroline
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
)
func TestJSONModeRendersEvents(t *testing.T) {
d := chatWith([]Row{
{Kind: "user", Text: "add a --version flag"},
{Kind: "tool", Tool: "edit_file", Text: "cli/root.go", Status: "ok", Detail: "+x"},
{Kind: "final", Text: "Done."},
{Kind: "done", Text: "1 tool", Status: "ok"},
})
d.JSONMode = true
out := stripANSI(RenderChat(d))
for _, want := range []string{
`$ `, `zero run`, `--format json`,
`"type"`, `"user"`, `"tool_use"`, `"edit_file"`, `"result"`, `"done"`,
} {
if !strings.Contains(out, want) {
t.Errorf("JSON view missing %q", want)
}
}
// the transcript card border must NOT appear in JSON mode
if strings.Contains(out, "╭") {
t.Error("JSON mode should not render tool-card borders")
}
}
func TestJSONModeTitleToggle(t *testing.T) {
s := newStyles(Resolve(0, true), 0, true)
h := Header{Model: "m"}
// In text mode the toggle highlights TEXT; in JSON mode it highlights JSON.
// Both labels are always present; assert the bar still renders one full row.
for _, jm := range []bool{false, true} {
out := s.topBar("normal", h, 100, jm)
if lipgloss.Height(out) != 1 || lipgloss.Width(out) != 100 {
t.Fatalf("title bar not 1x100 (jsonMode=%v): %dx%d", jm, lipgloss.Width(out), lipgloss.Height(out))
}
plain := stripANSI(out)
if !strings.Contains(plain, "TEXT") || !strings.Contains(plain, "JSON") {
t.Errorf("toggle missing TEXT/JSON (jsonMode=%v): %q", jm, plain)
}
}
}
func TestJSONModeFrameExact(t *testing.T) {
d := chatWith([]Row{{Kind: "user", Text: "hi"}, {Kind: "assistant", Text: "ok"}})
d.JSONMode = true
d.Width, d.Height = 100, 20
out := RenderChat(d)
if h := lipgloss.Height(out); h != 20 {
t.Fatalf("JSON frame height = %d, want 20", h)
}
for _, line := range strings.Split(out, "\n") {
if lipgloss.Width(line) > 100 {
t.Fatalf("JSON line exceeds width 100: %d", lipgloss.Width(line))
}
}
}
-169
View File
@@ -1,169 +0,0 @@
package zeroline
import (
"strings"
"sync"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/ansi"
gstyles "github.com/charmbracelet/glamour/styles"
"github.com/charmbracelet/lipgloss"
)
// mdKey identifies a cached glamour renderer. A renderer bakes in the theme
// colors AND the word-wrap width, so any change to variant, mode or width needs
// a fresh one.
type mdKey struct {
variant int
dark bool
width int
}
// mdOutKey caches the RENDERED output per message so glamour runs once per
// (message, theme, width) instead of every frame — RenderChat is called on every
// spinner tick and keystroke, so re-rendering all assistant markdown each time
// would make the TUI sluggish on a long transcript.
type mdOutKey struct {
text string
variant int
dark bool
width int
}
const mdOutCacheMax = 512 // bounded so a resize drag (many widths) can't grow it forever
const mdCacheMax = 256 // ditto for the renderer cache: a resize mints a width key per frame
var (
mdMu sync.Mutex
mdCache = map[mdKey]*glamour.TermRenderer{}
mdOutput = map[mdOutKey]string{}
)
// renderMarkdown renders CommonMark to styled terminal text using glamour, which
// also syntax-highlights fenced code blocks via chroma — markdown + code
// highlighting in a single pass. The output is themed to the active palette and
// sits on the canvas background so no "card" reappears against the full-bleed
// surface. Trailing blank lines glamour appends are trimmed so spacing stays
// tight with the surrounding transcript.
func renderMarkdown(text string, p Pal, variant int, dark bool, width int) string {
if width < 1 {
width = 1
}
key := mdOutKey{text, variant, dark, width}
mdMu.Lock()
if cached, ok := mdOutput[key]; ok {
mdMu.Unlock()
return cached
}
mdMu.Unlock()
// markdownRenderer takes mdMu itself, so it must be called WITHOUT the lock
// held (sync.Mutex is not reentrant).
r := markdownRenderer(p, variant, dark, width)
if r == nil {
return text
}
out, err := r.Render(text)
if err != nil {
return text
}
out = strings.Trim(out, "\n")
mdMu.Lock()
if len(mdOutput) >= mdOutCacheMax {
mdOutput = map[mdOutKey]string{} // simple bounded reset; cheap to repopulate
}
mdOutput[key] = out
mdMu.Unlock()
return out
}
// markdownRenderer returns a glamour renderer for the given palette + width,
// building and caching one on first use. Cache misses build a renderer keyed by
// variant/mode/width so width changes (resizes) get a fresh, correctly wrapped
// renderer.
func markdownRenderer(p Pal, variant int, dark bool, width int) *glamour.TermRenderer {
if width < 1 {
width = 1
}
key := mdKey{variant, dark, width}
mdMu.Lock()
defer mdMu.Unlock()
if r, ok := mdCache[key]; ok {
return r
}
r, err := glamour.NewTermRenderer(
glamour.WithStyles(themeStyleConfig(p, dark)),
glamour.WithWordWrap(width),
)
if err != nil {
return nil
}
if len(mdCache) >= mdCacheMax {
mdCache = map[mdKey]*glamour.TermRenderer{} // simple bounded reset; cheap to repopulate
}
mdCache[key] = r
return r
}
func sp(s string) *string { return &s }
// hex returns the lipgloss color as a "#rrggbb" string suitable for glamour /
// chroma color fields.
func hex(c lipgloss.Color) string { return string(c) }
// themeStyleConfig derives a glamour StyleConfig from the theme palette. It
// starts from glamour's stock dark/light style (so code-block chroma and list
// structure stay sensible) and overrides the load-bearing colors with the
// theme: headings -> Accent, links -> Accent2, inline code -> readable Fg on the
// panel bg, list bullets -> Mute, body text -> Fg on the theme Bg. The document
// background is set to the theme Bg to match the full-bleed canvas.
func themeStyleConfig(p Pal, dark bool) ansi.StyleConfig {
var cfg ansi.StyleConfig
if dark {
cfg = gstyles.DarkStyleConfig
} else {
cfg = gstyles.LightStyleConfig
}
bg := hex(p.Bg)
// Body text + full-bleed background, with no extra block margin so the
// transcript's own indent governs alignment.
cfg.Document.Color = sp(hex(p.Fg))
cfg.Document.BackgroundColor = sp(bg)
cfg.Document.BlockPrefix = ""
cfg.Document.BlockSuffix = ""
zero := uint(0)
cfg.Document.Margin = &zero
// Headings -> Accent, bold, on the theme bg (drop glamour's H1 color block).
cfg.Heading.Color = sp(hex(p.Accent))
cfg.Heading.BackgroundColor = sp(bg)
cfg.H1.Color = sp(hex(p.Accent))
cfg.H1.BackgroundColor = sp(bg)
cfg.H1.Bold = boolp(true)
// Links + link text -> Accent2.
cfg.Link.Color = sp(hex(p.Accent2))
cfg.LinkText.Color = sp(hex(p.Accent2))
// Inline code -> readable Fg on the panel background.
cfg.Code.Color = sp(hex(p.Fg))
cfg.Code.BackgroundColor = sp(hex(p.Panel))
// List bullets / enumeration -> Mute.
cfg.Item.Color = sp(hex(p.Mute))
cfg.Enumeration.Color = sp(hex(p.Mute))
// Block quotes + emphasis pick up theme dim/accent.
cfg.BlockQuote.Color = sp(hex(p.Mute))
cfg.Emph.Color = sp(hex(p.Dim))
cfg.Strong.Color = sp(hex(p.Fg))
return cfg
}
func boolp(b bool) *bool { return &b }
+9 -145
View File
@@ -3,150 +3,12 @@ package zeroline
import (
"strings"
"testing"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
)
// forceColor makes lipgloss emit ANSI escapes regardless of the test TTY so we
// can assert that styled output is actually colored.
func forceColor(t *testing.T) {
t.Helper()
prev := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.TrueColor)
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
}
func TestRenderMarkdownStripsSyntax(t *testing.T) {
p := Resolve(0, true)
out := renderMarkdown("**bold** and `code`", p, 0, true, 60)
if strings.TrimSpace(out) == "" {
t.Fatal("renderMarkdown returned empty output")
}
if strings.Contains(out, "**") {
t.Errorf("markdown markers leaked into output: %q", stripANSI(out))
}
// the words themselves must survive the render
plain := stripANSI(out)
for _, w := range []string{"bold", "code"} {
if !strings.Contains(plain, w) {
t.Errorf("rendered markdown missing %q: %q", w, plain)
}
}
// no trailing blank lines (we Trim them so transcript spacing stays tight)
if strings.HasSuffix(out, "\n") || strings.HasPrefix(out, "\n") {
t.Errorf("renderMarkdown left surrounding blank lines: %q", out)
}
}
func TestRenderMarkdownHighlightsCodeFence(t *testing.T) {
p := Resolve(1, true)
src := "Here is code:\n\n```go\nfunc main() {}\n```\n"
out := renderMarkdown(src, p, 1, true, 70)
plain := stripANSI(out)
if !strings.Contains(plain, "func main()") {
t.Errorf("fenced code body missing from render: %q", plain)
}
if strings.Contains(plain, "```") {
t.Errorf("code fence markers leaked into output: %q", plain)
}
}
func TestMarkdownRendererCachedPerKey(t *testing.T) {
p := Resolve(2, true)
r1 := markdownRenderer(p, 2, true, 80)
r2 := markdownRenderer(p, 2, true, 80)
if r1 != r2 {
t.Error("expected same cached renderer for identical key")
}
r3 := markdownRenderer(p, 2, true, 81) // width change -> new renderer
if r1 == r3 {
t.Error("width change should produce a distinct renderer")
}
r4 := markdownRenderer(p, 3, true, 80) // variant change -> new renderer
if r1 == r4 {
t.Error("variant change should produce a distinct renderer")
}
}
func TestMarkdownRendererCacheBounded(t *testing.T) {
// A resize drag mints a fresh width key on every frame, so the renderer cache
// (the expensive goldmark+chroma objects) must be bounded just like the output
// cache — otherwise it grows without limit.
p := Resolve(0, true)
mdMu.Lock()
mdCache = map[mdKey]*glamour.TermRenderer{}
mdMu.Unlock()
for w := 1; w <= mdCacheMax*3; w++ {
markdownRenderer(p, 0, true, w)
}
mdMu.Lock()
n := len(mdCache)
mdMu.Unlock()
if n > mdCacheMax {
t.Errorf("mdCache grew past bound: %d entries, want <= %d", n, mdCacheMax)
}
}
func TestColorizeDiffColorsAddsAndDels(t *testing.T) {
forceColor(t)
p := Resolve(0, true)
diff := "@@ -1,2 +1,2 @@\n context line\n-removed line\n+added line\n"
out := colorizeDiff(diff, p)
// content survives
plain := stripANSI(out)
for _, w := range []string{"added line", "removed line", "context line"} {
if !strings.Contains(plain, w) {
t.Errorf("diff output missing %q: %q", w, plain)
}
}
// a subtle left gutter is present
if !strings.Contains(plain, "│") {
t.Errorf("diff output missing left gutter: %q", plain)
}
// adds and dels are colored DISTINCTLY: the addition is rendered in the
// theme Green and the deletion in the theme Red. The diff line keeps its
// leading +/- marker, so we assert against the marked text.
green := lipgloss.NewStyle().Foreground(p.Green).Render("+added line")
red := lipgloss.NewStyle().Foreground(p.Red).Render("-removed line")
if !strings.Contains(out, green) {
t.Errorf("added line not colored with theme Green")
}
if !strings.Contains(out, red) {
t.Errorf("removed line not colored with theme Red")
}
if string(p.Green) == string(p.Red) {
t.Fatal("theme 0 unexpectedly uses the same color for green/red")
}
}
func TestColorizeDiffCapsLongDiffs(t *testing.T) {
p := Resolve(0, true)
var b strings.Builder
b.WriteString("@@ -1,100 +1,100 @@\n")
for i := 0; i < 100; i++ {
b.WriteString("+line\n")
}
out := stripANSI(colorizeDiff(b.String(), p))
if !strings.Contains(out, "more lines") {
t.Errorf("long diff not capped with a footer: %q", out)
}
// the cap means far fewer than 101 rendered lines
if n := strings.Count(out, "\n") + 1; n > diffMaxLines+2 {
t.Errorf("diff not capped: %d lines rendered", n)
}
}
func TestLooksLikeDiff(t *testing.T) {
cases := map[string]bool{
"@@ -1 +1 @@\n-a\n+b": true,
"--- a\n+++ b\n-x\n+y": true,
"@@ -1 +1 @@\n-a\n+b": true,
"--- a\n+++ b\n-x\n+y": true,
"+just an addition": true,
"plain prose with no diff": false,
"wrote 12 lines to file.go": false,
@@ -158,7 +20,9 @@ func TestLooksLikeDiff(t *testing.T) {
}
}
func TestAssistantRowRendersMarkdown(t *testing.T) {
func TestAssistantRowRendersPlainMuted(t *testing.T) {
// .blk-say is plain MUTED prose (no markdown formatting, no panel): markers are
// preserved verbatim rather than rendered through glamour.
d := ChatData{
Variant: 0, Dark: true, Width: 100, Height: 40,
Rows: []Row{
@@ -166,11 +30,11 @@ func TestAssistantRowRendersMarkdown(t *testing.T) {
},
}
out := stripANSI(RenderChat(d))
if strings.Contains(out, "**bold**") {
t.Errorf("completed assistant markdown not rendered (raw markers present): %q", out)
if !strings.Contains(out, "**bold**") {
t.Errorf("assistant prose should render plainly (keep markers): %q", out)
}
if !strings.Contains(out, "bold") {
t.Errorf("assistant text missing from render: %q", out)
if !strings.Contains(out, "- one") || !strings.Contains(out, "- two") {
t.Errorf("assistant list lines missing: %q", out)
}
}
+105
View File
@@ -0,0 +1,105 @@
package zeroline
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
)
// FIX 1: .blk-say is plain MUTED prose with no panel/background; only .blk-final
// keeps the accent rail + ink. Glamour formatting would drop the backticks of
// inline code, so their survival proves the prose is rendered plainly.
func TestAssistantSayIsPlainMuted(t *testing.T) {
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "assistant", Text: "use the `flag` package here"}})))
if !strings.Contains(out, "`flag`") {
t.Errorf("assistant prose must render plainly (keep backticks), got: %q", out)
}
final := stripANSI(RenderChat(chatWith([]Row{{Kind: "final", Text: "All set."}})))
if !strings.Contains(final, "│ All set.") {
t.Errorf("final block must keep the accent rail + ink text, got: %q", final)
}
}
// FIX 2: each home suggestion is its own bordered rounded box; the selected one
// uses the accent (rendered as a thick border so it's visible without color).
func TestChipBoxBorderedAndSelected(t *testing.T) {
s := newStyles(Resolve(0, true), 0, true)
un := stripANSI(s.chipBox("hello world", false, 50))
sel := stripANSI(s.chipBox("hello world", true, 50))
if !strings.Contains(un, "╭") || !strings.Contains(un, "╰") {
t.Errorf("unselected chip must be a rounded box, got: %q", un)
}
if !strings.Contains(un, "❯") || !strings.Contains(un, "hello world") {
t.Errorf("chip must carry the accent arrow + label, got: %q", un)
}
if lipgloss.Width(strings.Split(un, "\n")[0]) != 50 {
t.Errorf("chip box must be exactly w wide, got %d", lipgloss.Width(strings.Split(un, "\n")[0]))
}
if !strings.Contains(sel, "┏") {
t.Errorf("selected chip must use the accent (thick) border, got: %q", sel)
}
if strings.Contains(un, "┏") {
t.Errorf("unselected chip must be rounded, not thick, got: %q", un)
}
}
// The read_file tool already numbers its lines ("N | …") under a "File: … (N
// lines)" header, so the read card must NOT add a second number column or repeat
// the header.
func TestReadCardNoDoubleNumbering(t *testing.T) {
s := newStyles(Resolve(0, true), 0, true)
detail := "File: x.py (132 lines)\n\n 1 | import random\n 2 | import time"
body := s.toolBody(Row{Kind: "tool", Tool: "read_file", Detail: detail}, 80)
joined := stripANSI(strings.Join(body, "\n"))
if strings.Contains(joined, "File:") {
t.Errorf("read card should drop the redundant File: header: %q", joined)
}
if len(body) == 0 || stripANSI(body[0]) != " 1 | import random" {
t.Errorf("read card should show the tool's own numbering once, got: %q", joined)
}
}
// Resolved-permission line is colored by outcome (.perm-resolved): allow/always
// green ✓, deny red ✗, pending amber ⚠.
func TestPermissionLineColoredByOutcome(t *testing.T) {
allow := stripANSI(RenderChat(chatWith([]Row{{Kind: "permission", Text: "permission: write_file allow risk:medium"}})))
if !strings.Contains(allow, "✓") || strings.Contains(allow, "⚠") {
t.Errorf("allowed permission should show ✓ (not ⚠): %q", allow)
}
deny := stripANSI(RenderChat(chatWith([]Row{{Kind: "permission", Text: "permission: bash deny risk:high"}})))
if !strings.Contains(deny, "✗") {
t.Errorf("denied permission should show ✗: %q", deny)
}
pend := stripANSI(RenderChat(chatWith([]Row{{Kind: "permission", Text: "permission: bash prompt risk:high"}})))
if !strings.Contains(pend, "⚠") {
t.Errorf("pending permission should show ⚠: %q", pend)
}
}
func TestHomeChipsAreSeparateBorderedBoxes(t *testing.T) {
d := ChatData{
Variant: 0, Dark: true, Width: 100, Height: 34, Header: Header{Model: "m"},
Chips: []string{"alpha", "beta", "gamma"}, ChipIndex: 1,
}
out := RenderChat(d)
if h := lipgloss.Height(out); h != 34 {
t.Fatalf("empty-state height = %d, want 34 (frame-exact)", h)
}
for _, line := range strings.Split(out, "\n") {
if lipgloss.Width(line) > 100 {
t.Fatalf("home line exceeds width 100: %d", lipgloss.Width(line))
}
}
plain := stripANSI(out)
for _, c := range []string{"alpha", "beta", "gamma"} {
if !strings.Contains(plain, "❯ "+c) {
t.Errorf("chip %q missing arrow+label", c)
}
}
// selected chip (index 1) renders the accent thick border
if !strings.Contains(plain, "┏") {
t.Error("home should show the selected chip's accent border")
}
}
File diff suppressed because it is too large. Load diff
+22 -65
View File
@@ -8,8 +8,11 @@ import (
)
func TestThemesCount(t *testing.T) {
if len(Themes) != 5 {
t.Fatalf("expected 5 themes, got %d", len(Themes))
if len(Themes) != 6 {
t.Fatalf("expected 6 themes (ZERO + 5 legacy), got %d", len(Themes))
}
if Themes[0].Name != "ZERO" {
t.Fatalf("Themes[0] = %q, want ZERO (the default)", Themes[0].Name)
}
for i, th := range Themes {
if th.Name == "" || th.Dark.Bg == "" || th.Light.Bg == "" {
@@ -18,15 +21,15 @@ func TestThemesCount(t *testing.T) {
}
}
func TestRenderHomeAllThemes(t *testing.T) {
func TestEmptyStateAllThemes(t *testing.T) {
for v := 0; v < len(Themes); v++ {
out := RenderHome(HomeData{
out := RenderChat(ChatData{
Variant: v, Dark: true, Width: 100, Height: 28,
Header: Header{Cwd: "~/src/zero", Branch: "main", Model: "claude-sonnet-4.5", Provider: "anthropic"},
Input: "❯ message zero",
Chips: DefaultChips(),
})
if !strings.Contains(out, "Own your agent") {
t.Errorf("theme %d: home missing tagline", v)
if !strings.Contains(stripANSI(out), "std-lib-first") {
t.Errorf("theme %d: empty state missing tagline", v)
}
}
}
@@ -37,13 +40,12 @@ func TestRenderChatLiveData(t *testing.T) {
Header: Header{Cwd: "~/src/zero", Branch: "main", Model: "claude-sonnet-4.5", Provider: "anthropic"},
Rows: []Row{
{Kind: "user", Text: "refactor the loop"},
{Kind: "toolcall", Tool: "grep", Detail: "pattern: case"},
{Kind: "toolresult", Tool: "grep", Status: "ok", Detail: "3 matches"},
{Kind: "tool", Tool: "grep", Text: "pattern: case", Status: "ok", Detail: "internal/x.go:3:case"},
{Kind: "assistant", Text: "Here is the plan."},
},
}
out := RenderChat(d)
for _, want := range []string{"DONE", "you", "grep", "✦ zero", "claude-sonnet-4.5"} {
for _, want := range []string{"DONE", "❯", "grep", "claude-sonnet-4.5"} {
if !strings.Contains(out, want) {
t.Errorf("chat render missing %q", want)
}
@@ -67,7 +69,7 @@ func TestRenderChatLiveData(t *testing.T) {
d.Working = false
d.Perm = &Perm{Tool: "edit_file", Risk: "medium", Reason: "writes a file"}
p := RenderChat(d)
for _, want := range []string{"BLOCKED", "permission required", "edit_file", "allow", "always", "deny"} {
for _, want := range []string{"BLOCKED", "PERMISSION", "edit_file", "allow", "always", "deny"} {
if !strings.Contains(p, want) {
t.Errorf("permission modal missing %q", want)
}
@@ -236,69 +238,24 @@ func TestPermLayoutMatchesRenderClamped(t *testing.T) {
}
}
func TestToolResultRenderingCollapsesAndShows(t *testing.T) {
func TestToolCardsShowContent(t *testing.T) {
d := ChatData{
Variant: 0, Dark: true, Width: 100, Height: 40,
Rows: []Row{
{Kind: "toolcall", Tool: "read_file", Detail: "README.md"},
{Kind: "toolresult", Tool: "read_file", Status: "ok", Detail: "File: README.md (217 lines)\n\n 1 | THE-RAW-FILE-CONTENT-SHOULD-NOT-APPEAR"},
{Kind: "toolcall", Tool: "list_directory", Detail: "."},
{Kind: "toolresult", Tool: "list_directory", Status: "ok", Detail: "Contents of .:\n\na\nb\nc"},
{Kind: "toolcall", Tool: "edit_file", Detail: "x.go"},
{Kind: "toolresult", Tool: "edit_file", Status: "ok", Detail: "@@ -1 +1 @@\n-old\n+NEWCODE"},
{Kind: "toolcall", Tool: "bash", Detail: "go test"},
{Kind: "toolresult", Tool: "bash", Status: "error", Detail: "exit 1: BUILD-FAILED-HERE"},
{Kind: "tool", Tool: "read_file", Text: "README.md", Status: "ok", Detail: "line one\nline two\nline three"},
{Kind: "tool", Tool: "edit_file", Text: "x.go", Status: "ok", Detail: "@@ -1 +1 @@\n-old\n+NEWCODE"},
{Kind: "tool", Tool: "bash", Text: "go test", Status: "error", Detail: "exit 1: BUILD-FAILED-HERE"},
},
}
out := stripANSI(RenderChat(d))
// file read collapses to a count, never dumps content
if !strings.Contains(out, "217 lines") {
t.Error("read_file should summarize to a line count")
if !strings.Contains(out, "line one") {
t.Error("read card should show file content")
}
if strings.Contains(out, "THE-RAW-FILE-CONTENT-SHOULD-NOT-APPEAR") {
t.Error("read_file dumped raw file content (should be collapsed)")
}
// listing collapses to entry count
if !strings.Contains(out, "3 entries") {
t.Error("list_directory should summarize to an entry count")
}
// diff body is shown for edits
if !strings.Contains(out, "NEWCODE") {
t.Error("edit_file diff body should be shown")
t.Error("edit card should show the diff body")
}
// errors are surfaced
if !strings.Contains(out, "BUILD-FAILED-HERE") {
t.Error("error output should be shown")
}
}
func TestAssistantMarkdownClipsLongLines(t *testing.T) {
// glamour does not hard-break unbreakable tokens, so a very long URL or fenced
// code line would otherwise emit a line far wider than the frame, blowing out
// the fixed-height/full-bleed layout. Each emitted line must fit the budget.
s := newStyles(Resolve(0, true), 0, true)
tw := 60
longURL := "See https://example.com/" + strings.Repeat("verylongpath/", 40) + "end"
out := s.renderAssistantMarkdown(longURL, tw)
if len(out) == 0 {
t.Fatal("renderAssistantMarkdown returned no lines")
}
for i, ln := range out {
if w := lipgloss.Width(ln); w > tw {
t.Errorf("line %d width %d exceeds budget %d: %q", i, w, tw, stripANSI(ln))
}
}
}
func TestAssistantMarkdownClipsLongFencedCode(t *testing.T) {
s := newStyles(Resolve(0, true), 0, true)
tw := 50
src := "```\n" + strings.Repeat("x", 300) + "\n```"
out := s.renderAssistantMarkdown(src, tw)
for i, ln := range out {
if w := lipgloss.Width(ln); w > tw {
t.Errorf("line %d width %d exceeds budget %d: %q", i, w, tw, stripANSI(ln))
}
if !strings.Contains(out, "BUILD-FAILED-HERE") || !strings.Contains(out, "✗") {
t.Error("bash error card should surface the error output + ✗ status")
}
}
+29 -2
View File
@@ -11,6 +11,10 @@ import "github.com/charmbracelet/lipgloss"
type Pal struct {
Bg, Panel, Panel2, Fg, Dim, Mute, Line, Line2 lipgloss.Color
Accent, Accent2, Green, Amber, Red, Sel lipgloss.Color
// Extended fields for the ZERO design: a third panel level, two extra
// dim/text levels (faint, faintest), and a blue accent. Legacy themes derive
// these in mkPal so new render code always has a non-empty value.
Panel3, Faint, Faintest, Blue lipgloss.Color
}
// Theme is a named color identity with a dark and a light variant.
@@ -23,12 +27,35 @@ type Theme struct {
func mkPal(bg, panel, panel2, fg, dim, mute, line, line2, accent, accent2, green, amber, red, sel string) Pal {
c := func(s string) lipgloss.Color { return lipgloss.Color(s) }
return Pal{c(bg), c(panel), c(panel2), c(fg), c(dim), c(mute), c(line), c(line2),
c(accent), c(accent2), c(green), c(amber), c(red), c(sel)}
p := Pal{
Bg: c(bg), Panel: c(panel), Panel2: c(panel2), Fg: c(fg), Dim: c(dim), Mute: c(mute),
Line: c(line), Line2: c(line2), Accent: c(accent), Accent2: c(accent2),
Green: c(green), Amber: c(amber), Red: c(red), Sel: c(sel),
}
// Derive the extended fields for legacy themes from existing colors.
p.Panel3 = p.Panel2
p.Faint = p.Mute
p.Faintest = p.Line2
p.Blue = p.Accent2
return p
}
// zeroPal is the ZERO design palette (exact hex from the spec). Dark-only design.
var zeroPal = Pal{
Bg: "#070708", Panel: "#0e0e10", Panel2: "#121215", Panel3: "#17171b",
Line: "#242429", Line2: "#2e2e34",
Fg: "#ececee", Dim: "#8b8b93", Mute: "#5b5b63", Faint: "#5b5b63", Faintest: "#3a3a40",
Accent: "#caff3f", Accent2: "#caff3f", Green: "#5dd1a4", Amber: "#ffc25c", Red: "#ff7a7a",
Blue: "#7db4ff", Sel: "#19220a",
}
// Themes is the ordered list of the 5 selectable color identities (keys 1-5).
var Themes = []Theme{
{
Name: "ZERO", Swt: "#caff3f",
Dark: zeroPal,
Light: zeroPal, // dark-only design
},
{
Name: "Phosphor", Swt: "#ffb000",
Dark: mkPal("#040804", "#0a0f0a", "#0e150e", "#dfe8d6", "#8aa07f", "#566b50", "#16241a", "#22382a", "#ffb000", "#36ff7a", "#36ff7a", "#ffb000", "#ff6b6b", "#171f12"),
+51
View File
@@ -0,0 +1,51 @@
package zeroline
import "testing"
// TestZeroThemeIsDefault asserts the new ZERO palette is Themes[0] (the Resolve
// default) with the exact hex values from the design spec.
func TestZeroThemeIsDefault(t *testing.T) {
if got := ThemeName(0); got != "ZERO" {
t.Fatalf("default theme name = %q, want ZERO", got)
}
p := Resolve(0, true)
got := map[string]string{
"Bg": string(p.Bg), "Panel": string(p.Panel), "Panel2": string(p.Panel2),
"Panel3": string(p.Panel3), "Line": string(p.Line), "Line2": string(p.Line2),
"Fg": string(p.Fg), "Dim": string(p.Dim), "Faint": string(p.Faint),
"Faintest": string(p.Faintest), "Accent": string(p.Accent), "Green": string(p.Green),
"Red": string(p.Red), "Amber": string(p.Amber), "Blue": string(p.Blue),
}
want := map[string]string{
"Bg": "#070708", "Panel": "#0e0e10", "Panel2": "#121215", "Panel3": "#17171b",
"Line": "#242429", "Line2": "#2e2e34", "Fg": "#ececee", "Dim": "#8b8b93",
"Faint": "#5b5b63", "Faintest": "#3a3a40", "Accent": "#caff3f", "Green": "#5dd1a4",
"Red": "#ff7a7a", "Amber": "#ffc25c", "Blue": "#7db4ff",
}
for k, w := range want {
if got[k] != w {
t.Errorf("ZERO.%s = %q, want %q", k, got[k], w)
}
}
}
// TestLegacyThemesPreserved asserts the 5 original themes remain reachable after
// ZERO is prepended, and that the extended Pal fields are populated for them so
// new render code never emits an empty color.
func TestLegacyThemesPreserved(t *testing.T) {
if len(Themes) != 6 {
t.Fatalf("len(Themes) = %d, want 6 (ZERO + 5 legacy)", len(Themes))
}
if got := ThemeName(1); got != "Phosphor" {
t.Fatalf("Themes[1] = %q, want Phosphor (legacy preserved)", got)
}
p := Resolve(1, true)
for name, v := range map[string]string{
"Panel3": string(p.Panel3), "Faint": string(p.Faint),
"Faintest": string(p.Faintest), "Blue": string(p.Blue),
} {
if v == "" {
t.Errorf("legacy theme missing extended field %s", name)
}
}
}
+2 -2
View File
@@ -13,8 +13,8 @@ func TestHumanTokens(t *testing.T) {
{1000, "1k"}, // .0 trimmed
{1234, "1.2k"},
{1500, "1.5k"},
{999999, "1000k"}, // just under 1M: rounds up but stays in k
{1000000, "1M"}, // .0 trimmed at the M boundary
{999999, "1000k"}, // just under 1M: rounds up but stays in k
{1000000, "1M"}, // .0 trimmed at the M boundary
{1500000, "1.5M"},
{5000000, "5M"},
{12345678, "12.3M"}, // large counts read in M, not thousands of k
+108
View File
@@ -0,0 +1,108 @@
package zeroline
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
)
func TestToolCardDiff(t *testing.T) {
diff := "+added line\n-removed line\n unchanged"
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "tool", Tool: "edit_file", Text: "internal/cli/root.go", Detail: diff, Status: "ok"}})))
if !strings.Contains(out, "╭") || !strings.Contains(out, "╰") {
t.Error("tool card missing rounded border")
}
for _, w := range []string{"edit_file", "internal/cli/root.go", "✓", "added line", "removed line"} {
if !strings.Contains(out, w) {
t.Errorf("diff card missing %q", w)
}
}
}
func TestToolCardBash(t *testing.T) {
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "tool", Tool: "bash", Text: "go test ./...", Detail: "ok pkg 0.2s", Status: "ok"}})))
for _, w := range []string{"bash", "$ go test", "exit 0"} {
if !strings.Contains(out, w) {
t.Errorf("bash card missing %q in %q", w, out)
}
}
}
func TestToolCardBashError(t *testing.T) {
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "tool", Tool: "bash", Text: "go vet", Detail: "vet: bad", Status: "error"}})))
if !strings.Contains(out, "✗") || !strings.Contains(out, "exit 1") {
t.Errorf("error bash card missing ✗/exit 1: %q", out)
}
}
func TestToolCardRunningSpinner(t *testing.T) {
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "tool", Tool: "grep", Text: "pattern", Running: true}})))
found := false
for _, f := range spinFrames {
if strings.Contains(out, f) {
found = true
break
}
}
if !found {
t.Errorf("running card missing spinner: %q", out)
}
}
func TestToolCardGrep(t *testing.T) {
body := "internal/a.go:41:foo match\ninternal/b.go:12:bar match"
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "tool", Tool: "grep", Text: ".", Detail: body, Status: "ok"}})))
for _, w := range []string{"a.go", "foo match", "2 matches"} {
if !strings.Contains(out, w) {
t.Errorf("grep card missing %q in %q", w, out)
}
}
}
func TestToolCardGrepTabsNoOverflow(t *testing.T) {
body := "a.go:1:if\tx\t{\treturn\tnil}\nb.go:2:foo\tbar\tbaz"
d := chatWith([]Row{{Kind: "tool", Tool: "grep", Text: ".", Detail: body, Status: "ok"}})
d.Width, d.Height = 60, 16
out := RenderChat(d)
for _, line := range strings.Split(out, "\n") {
if lipgloss.Width(line) > 60 {
t.Fatalf("grep line with tabs overflows width 60: %d (%q)", lipgloss.Width(line), stripANSI(line))
}
}
}
func TestToolCardLongNameNoHeadOverflow(t *testing.T) {
long := "mcp__some_very_long_server__some_very_long_tool_name_indeed"
d := chatWith([]Row{{Kind: "tool", Tool: long, Text: "and/a/long/target/path/to/some/file.go", Status: "ok", Detail: "x"}})
d.Width, d.Height = 70, 14
out := RenderChat(d)
for _, line := range strings.Split(out, "\n") {
if lipgloss.Width(line) > 70 {
t.Fatalf("long tool name overflows card head: %d (%q)", lipgloss.Width(line), stripANSI(line))
}
}
}
func TestDiffBodyStatSkipsHeaders(t *testing.T) {
s := newStyles(Resolve(0, true), 0, true)
diff := "--- a/f\n+++ b/f\n@@ -1 +1 @@\n-old\n+new"
out := stripANSI(strings.Join(s.diffBody(diff, 60), "\n"))
if !strings.Contains(out, "+1") || !strings.Contains(out, "-1") {
t.Fatalf("stat should be +1/-1 with diff headers excluded, got: %q", out)
}
}
func TestToolCardFrameExact(t *testing.T) {
d := chatWith([]Row{{Kind: "tool", Tool: "edit_file", Text: "f.go", Detail: "+x\n-y\n z", Status: "ok"}})
d.Width, d.Height = 100, 26
out := RenderChat(d)
if h := lipgloss.Height(out); h != 26 {
t.Fatalf("chat height = %d, want 26 (frame-exact)", h)
}
for _, line := range strings.Split(out, "\n") {
if lipgloss.Width(line) > 100 {
t.Fatalf("tool-card line exceeds width 100: %d", lipgloss.Width(line))
}
}
}
+150
View File
@@ -0,0 +1,150 @@
package zeroline
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
)
func chatWith(rows []Row) ChatData {
return ChatData{Variant: 0, Dark: true, Width: 90, Height: 24, Header: Header{Model: "m"}, Rows: rows}
}
func TestTranscriptBlocks(t *testing.T) {
// user: accent ❯ + ink, no "you" label
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "user", Text: "refactor the loop"}})))
if !strings.Contains(out, "❯ refactor the loop") {
t.Errorf("user block missing ❯ + text")
}
if strings.Contains(out, "you ") {
t.Errorf("user block should not show the 'you' label")
}
// assistant say: muted text, no "✦ zero" label
out = stripANSI(RenderChat(chatWith([]Row{{Kind: "assistant", Text: "Here is the plan."}})))
if !strings.Contains(out, "Here is the plan.") {
t.Errorf("say text missing")
}
if strings.Contains(out, "✦ zero") {
t.Errorf("say block should not show the zero label")
}
// final: accent rail + ink text
out = stripANSI(RenderChat(chatWith([]Row{{Kind: "final", Text: "All done."}})))
if !strings.Contains(out, "│") || !strings.Contains(out, "All done.") {
t.Errorf("final block missing rail/text: %q", out)
}
// done: ■ + faint meta
out = stripANSI(RenderChat(chatWith([]Row{{Kind: "done", Text: "12 tools · 1,284 tok · $0.04", Status: "ok"}})))
if !strings.Contains(out, "■") || !strings.Contains(out, "12 tools") {
t.Errorf("done block missing dot/meta: %q", out)
}
// notes: sys + deny
if out = stripANSI(RenderChat(chatWith([]Row{{Kind: "system", Text: "compacted older turns"}}))); !strings.Contains(out, "compacted older turns") {
t.Errorf("sys note missing")
}
if out = stripANSI(RenderChat(chatWith([]Row{{Kind: "error", Text: "denied: bash"}}))); !strings.Contains(out, "denied: bash") {
t.Errorf("deny note missing")
}
}
func TestStreamingCaretOnlyMidStream(t *testing.T) {
// Streaming → accent caret ▌ trails the say text.
d := chatWith(nil)
d.Stream = "partial answer"
d.Working = true
stream := stripANSI(RenderChat(d))
if !strings.Contains(stream, "partial answer") || !strings.Contains(stream, "▌") {
t.Errorf("streaming should show text + caret: %q", stream)
}
// Not streaming → no caret.
still := stripANSI(RenderChat(chatWith([]Row{{Kind: "assistant", Text: "settled answer"}})))
if strings.Contains(still, "▌") {
t.Errorf("non-streaming transcript must not show the caret")
}
}
func TestMultiLineNotePreserved(t *testing.T) {
// A multi-line system note (e.g. a resume-session summary) must keep all lines.
out := stripANSI(RenderChat(chatWith([]Row{{Kind: "system", Text: "Resumed session\nid: abc123\nmodel: opus"}})))
for _, want := range []string{"Resumed session", "id: abc123", "model: opus"} {
if !strings.Contains(out, want) {
t.Errorf("multi-line note dropped %q", want)
}
}
}
func TestStreamingPreservesNewlines(t *testing.T) {
d := chatWith(nil)
d.Stream = "first paragraph\n\nsecond paragraph"
d.Working = true
out := stripANSI(RenderChat(d))
if !strings.Contains(out, "first paragraph") || !strings.Contains(out, "second paragraph") {
t.Errorf("streaming flattened line structure: %q", out)
}
}
func TestLongTokenReclipped(t *testing.T) {
// wrap() does not hard-break a single long word; the block helpers must re-clip.
s := newStyles(Resolve(0, true), 0, true)
for _, line := range s.renderFinal(strings.Repeat("X", 300), 74, false) {
if lipgloss.Width(line) > 76 { // rail "│ " (2) + 74
t.Fatalf("renderFinal line overflows budget: %d cells", lipgloss.Width(line))
}
}
for _, line := range s.renderSay(strings.Repeat("Y", 300), 74, false) {
if lipgloss.Width(line) > 74 {
t.Fatalf("renderSay line overflows budget: %d cells", lipgloss.Width(line))
}
}
}
func TestComposerRunStopAffordance(t *testing.T) {
idle := stripANSI(RenderChat(chatWith([]Row{{Kind: "user", Text: "hi"}})))
if !strings.Contains(idle, "run ↵") {
t.Errorf("idle composer should show 'run ↵', got: %q", idle)
}
d := chatWith([]Row{{Kind: "user", Text: "hi"}})
d.Working = true
working := stripANSI(RenderChat(d))
if !strings.Contains(working, "■ stop") || strings.Contains(working, "run ↵") {
t.Errorf("working composer should show '■ stop' not 'run ↵', got: %q", working)
}
}
func TestComposerButtonNotClippedByLongInput(t *testing.T) {
d := chatWith([]Row{{Kind: "user", Text: "hi"}})
d.Input = strings.Repeat("x", 200)
d.Width, d.Height = 50, 12
out := stripANSI(RenderChat(d))
if !strings.Contains(out, "run ↵") {
t.Errorf("run button must survive a long input (was clipped): %q", out)
}
for _, line := range strings.Split(out, "\n") {
if lipgloss.Width(line) > 50 {
t.Fatalf("composer line overflows width 50: %d", lipgloss.Width(line))
}
}
}
func TestTranscriptFrameExact(t *testing.T) {
d := chatWith([]Row{
{Kind: "user", Text: "do the thing"},
{Kind: "assistant", Text: "Working on it."},
{Kind: "final", Text: "Finished."},
{Kind: "done", Text: "2 tools · 100 tok", Status: "ok"},
})
d.Width, d.Height = 100, 22
out := RenderChat(d)
if h := lipgloss.Height(out); h != 22 {
t.Fatalf("chat height = %d, want 22 (frame-exact)", h)
}
for _, line := range strings.Split(out, "\n") {
if lipgloss.Width(line) > 100 {
t.Fatalf("chat line exceeds width 100: %d", lipgloss.Width(line))
}
}
}