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
70 changed files with 6209 additions and 946 deletions

No files matched your search

+42 -9
View File
@@ -253,6 +253,7 @@ func TestPartitionToolsActiveExcludesDisabledDeferredFromReminderAndExposed(t *t
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{
@@ -321,13 +322,21 @@ 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)
if len(exposed) != 2 {
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)
@@ -340,6 +349,9 @@ func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
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
@@ -408,6 +420,9 @@ func TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testing.T) {
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)
@@ -542,6 +557,9 @@ func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testin
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)
@@ -701,7 +719,7 @@ func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
// 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 TestDisabledToolSearchStaysHiddenAndRejectedWhenActive(t *testing.T) {
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"})
@@ -712,24 +730,39 @@ func TestDisabledToolSearchStaysHiddenAndRejectedWhenActive(t *testing.T) {
DisabledTools: []string{"tool_search"},
}
exposed, _ := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
// 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 {
if def.Name == "tool_search" {
t.Fatalf("tool_search must stay hidden when explicitly disabled, got %#v", 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: "tool_search", Arguments: `{"query":"select:mcp__srv__alpha"}`},
ToolCall{ID: "c1", Name: "mcp__srv__alpha", Arguments: `{}`},
PermissionModeAuto,
options,
)
if abortErr != nil {
t.Fatalf("unexpected abort error: %v", abortErr)
}
if result.Status != tools.StatusError || result.DenialReason != DenialFiltered {
t.Fatalf("a disabled tool_search call must be filtered out, got status=%s denial=%v output=%q", result.Status, result.DenialReason, result.Output)
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)
}
}
+70 -23
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.
@@ -235,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)
@@ -249,6 +260,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
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,
@@ -300,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
@@ -476,6 +516,11 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
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
}
@@ -858,15 +903,6 @@ func permissionActionFromSandbox(action sandbox.Action) PermissionAction {
}
}
// toolDefinitions returns the full-schema tool list for a turn. It now delegates
// to partitionTools with no loaded set; with deferral inactive (the only state
// the bare toolDefinitions is used in) the exposed slice is byte-identical to the
// pre-deferral output. Retained so existing callers/tests keep their signature.
func toolDefinitions(registry *tools.Registry, permissionMode PermissionMode, options Options) []zeroruntime.ToolDefinition {
exposed, _ := partitionTools(registry, permissionMode, options, map[string]bool{})
return exposed
}
// 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
@@ -891,7 +927,20 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
}
}
active := options.DeferThreshold > 0 && eligible >= options.DeferThreshold
// 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))
@@ -927,20 +976,18 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
exposedNames[name] = true
}
// On the ACTIVE path, tool_search is the gateway to the allowlisted deferred
// tools, 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 unless an explicit DisabledTools entry
// turns the loader itself off. This never runs on the inactive path, so the
// 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] && !containsToolName(options.DisabledTools, tools.ToolSearchToolName) {
if loader, ok := registry.Get(tools.ToolSearchToolName); ok {
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: loader.Name(),
Description: loader.Description(),
Parameters: schemaToRuntimeMap(loader.Parameters()),
})
}
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 {
+12
View File
@@ -58,6 +58,11 @@ type ToolResult struct {
// 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.
@@ -176,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 {
+15
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":
@@ -378,6 +380,7 @@ func runInteractiveTUIWithSkin(stderr io.Writer, deps appDeps, skin string, perm
PermissionMode: permissionMode,
Skin: skin,
ThemeDark: true,
Notify: resolved.Notify,
})
}
@@ -405,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
}
@@ -456,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
@@ -499,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
@@ -524,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
}
+132 -9
View File
@@ -14,10 +14,12 @@ 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"
@@ -58,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
@@ -79,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 {
@@ -129,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
@@ -142,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())
}
@@ -178,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 {
@@ -204,7 +237,16 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// 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.
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, options.enabledTools, options.disabledTools)
// 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())
@@ -227,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
}
@@ -243,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
@@ -320,7 +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: resolved.Tools.DeferThreshold,
DeferThreshold: effectiveDeferThreshold,
SessionID: preparedSession.Session.SessionID,
CallingSessionID: options.callingSessionID,
CallingToolUseID: options.callingToolUseID,
@@ -328,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,
@@ -375,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
}
@@ -807,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])
}
}
+18
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"
)
@@ -51,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)
}
}
}
+34
View File
@@ -8,6 +8,7 @@ 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"
)
@@ -66,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 {
@@ -79,6 +94,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
MaxTurns: cfg.MaxTurns,
MCP: cfg.MCP,
Sandbox: cfg.Sandbox,
Notify: cfg.Notify,
Tools: cfg.Tools,
}, nil
}
@@ -127,6 +143,12 @@ 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
@@ -151,6 +173,12 @@ 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
@@ -459,6 +487,12 @@ 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
+36
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()
+10
View File
@@ -58,6 +58,11 @@ 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
@@ -94,6 +99,7 @@ type FileConfig struct {
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"`
}
@@ -112,6 +118,7 @@ type Overrides struct {
MaxTurns int
MCP MCPConfig
Sandbox SandboxConfig
Notify NotifyConfig
Tools ToolsConfig
}
@@ -122,6 +129,7 @@ type ResolvedConfig struct {
MaxTurns int
MCP MCPConfig
Sandbox SandboxConfig
Notify NotifyConfig
Tools ToolsConfig
}
@@ -147,6 +155,7 @@ 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"`
@@ -161,6 +170,7 @@ 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{}
+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 {
+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)
}
}
-6
View File
@@ -95,12 +95,6 @@ func (tool toolSearchTool) RunWithOptions(_ context.Context, args map[string]any
}
}
// deferredTools returns the registry's deferred-eligible tools sorted by name so
// keyword ranking and listings are deterministic.
func (tool toolSearchTool) deferredTools() []Tool {
return tool.visibleDeferredTools(nil, nil)
}
// 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
+4 -2
View File
@@ -11,8 +11,10 @@ type Permission string
type Status string
const (
// SideEffectNone marks tools that neither read nor mutate state (e.g.
// tool_search, which only reports already-registered tool schemas).
// 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"
+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))
}
}
}