Compare commits

..
Author SHA1 Message Date
Gnanam 57ef38ebcb fix(providerio): surface ErrStreamStalled in callers + widen flaky test margins
Address review:
- All four provider stream handlers (openai, codex, anthropic, gemini) now treat
  ErrStreamStalled in the same branch as ErrStreamIdle, via a new
  providerio.StreamTimeoutMessage helper that gives the stall a distinct, accurate
  message (it reports the content window and does NOT claim the upstream stopped
  sending data — keep-alives were still arriving). Previously a stalled stream fell
  through to the generic provider-error path.
- Widen the content-watchdog test timing margins (idle 100ms, data/keepalive every
  30ms) so CI/GC jitter can't trip the idle watchdog and mask the content path.
- Add a StreamTimeoutMessage unit test.
2026-06-30 22:13:55 +05:30
Gnanam 31e57915c9 fix(providerio): bound heartbeat-but-no-output streams (content-stall watchdog)
A stalled-but-alive upstream hung the agent indefinitely: SSE keep-alives reset the
idle watchdog (intentionally — a heartbeating upstream isn't 'dead', and aborting it
killed healthy long requests), so a stream that heartbeats while producing nothing
never tripped the idle timeout and there was no other ceiling. Observed on both
chatgpt/gpt-5.x (Codex) and ollama minimax-m3 — different provider code, same shared
providerio SSE loop — so the fix belongs here, once, for every provider.

Add a second 'content' watchdog alongside the idle one: keep-alives still reset idle
(no regression for heartbeating connections), but only REAL data lines reset the
content watchdog. If no data arrives for streamContentStallFactor (2) × the idle
timeout, abort with ErrStreamStalled instead of hanging. A slow-but-producing stream
(data between heartbeats) keeps resetting content and is never killed.

Contained to internal/providers/providerio — no provider/caller changes.
2026-06-30 22:03:24 +05:30
115 changed files with 599 additions and 8450 deletions

No files matched your search

+7 -97
View File
@@ -54,10 +54,8 @@ jobs:
# is published under the pushed git tag. If they disagree, install.sh/install.ps1
# and the npm postinstall all build a download URL that 404s. Fail fast so a
# forgotten version bump can never publish mis-versioned, un-installable assets.
# Matches on the ref (not event_name) because release-please dispatches this
# workflow at the tag ref — its GITHUB_TOKEN-created tag cannot fire `push`.
- name: Check tag matches package.json version
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
shell: bash
run: |
pkg_version="$(node -p "require('./package.json').version")"
@@ -80,15 +78,13 @@ jobs:
path: dist/release/*
if-no-files-found: error
# On a `v*` tag ref (pushed manually, or dispatched at the tag by the
# release-please workflow), publish the packaged archive + .sha256 as GitHub
# Release assets so the documented `scripts/install.sh` / `install.ps1` path
# (which reads /releases/latest) works. Uses the preinstalled `gh` (no new
# action dependency); each matrix OS creates the release if absent
# (best-effort — release-please normally creates it first) then uploads its
# own assets with --clobber. Tags come from merging the release-please PR.
# On a `v*` tag push, publish the packaged archive + .sha256 as GitHub Release
# assets so the documented `scripts/install.sh` / `install.ps1` path (which reads
# /releases/latest) works. Uses the preinstalled `gh` (no new action dependency);
# each matrix OS creates the release if absent (best-effort) then uploads its own
# assets with --clobber. NOTE: maintainers must push the tag — this never tags.
- name: Publish to GitHub Release
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
@@ -96,89 +92,3 @@ jobs:
tag="${GITHUB_REF_NAME}"
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "$tag" --generate-notes 2>/dev/null || true
gh release upload "$tag" dist/release/* --repo "$GITHUB_REPOSITORY" --clobber
# Publishes the npm wrapper (@gitlawb/zero) once every platform's release
# assets are live — the package's postinstall downloads its binary from the
# GitHub Release, so publishing before the assets exist would ship a package
# that fails to install.
#
# Auth is npm OIDC trusted publishing: no NPM_TOKEN secret. The package's
# npmjs.com settings must list this repo + workflow file as a trusted
# publisher, and provenance is then generated automatically. Note npm only
# allows configuring a trusted publisher on an EXISTING package, so the very
# first @gitlawb/zero publish is a one-time manual `npm publish` bootstrap.
publish-npm:
name: Publish npm package
needs: package
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
runs-on: ubuntu-latest
# Manual approval gate: this environment carries a required-reviewer rule
# (Settings -> Environments -> npm-publish), so the job pauses until a
# maintainer approves it in the Actions UI before anything reaches npm.
environment:
name: npm-publish
url: https://www.npmjs.com/package/@gitlawb/zero
permissions:
contents: read
id-token: write # OIDC token exchange for npm trusted publishing + provenance
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
# No registry-url here on purpose: setup-node's registry-url writes an
# .npmrc line referencing $NODE_AUTH_TOKEN, and npm hard-fails on the
# unset env var. Trusted publishing needs no token at all.
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24
# OIDC trusted publishing needs npm >= 11.5.1. Use the npm bundled with
# the pinned Node toolchain (Node 24 ships >= 11.5.1) rather than pulling
# npm@latest from the registry inside the publish path — an unpinned
# install there would be avoidable supply-chain exposure. Fail fast if a
# future runner/toolchain change ever regresses the bundled npm.
- name: Check npm supports trusted publishing
run: |
npm --version | node -e '
const version = require("fs").readFileSync(0, "utf8").trim();
const [major, minor, patch] = version.split(".").map(Number);
const ok =
major > 11 ||
(major === 11 && (minor > 5 || (minor === 5 && patch >= 1)));
if (!ok) {
console.error(
`::error::bundled npm ${version} is too old for OIDC trusted ` +
`publishing (needs >= 11.5.1) — bump node-version in this job.`,
);
process.exit(1);
}
console.log(`npm ${version} supports trusted publishing.`);
'
# Probe the exact URLs postinstall will build (internal/release/release.go
# asset-name scheme) as an anonymous client. This also blocks publishing
# while the repo is still private — private release assets 404 without
# auth, exactly as they would for an installing user.
- name: Verify release assets are downloadable
shell: bash
run: |
version="$(node -p "require('./package.json').version")"
base="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${version}"
for asset in \
"zero-v${version}-linux-x64.tar.gz" \
"zero-v${version}-linux-arm64.tar.gz" \
"zero-v${version}-macos-x64.tar.gz" \
"zero-v${version}-macos-arm64.tar.gz" \
"zero-v${version}-windows-x64.zip"; do
for url in "${base}/${asset}" "${base}/${asset}.sha256"; do
if ! curl -fsSLI -o /dev/null "$url"; then
echo "::error::${url} is not publicly downloadable — npm postinstall would fail. Is the repo public and are all assets uploaded?" >&2
exit 1
fi
done
done
echo "all release assets for v${version} are publicly downloadable."
- name: Publish to npm
run: npm publish --access public
-35
View File
@@ -1,35 +0,0 @@
name: Release Please
on:
push:
branches:
- main
permissions:
contents: write # push the release-PR branch, create the tag + GitHub Release
pull-requests: write # open/update the release PR
issues: write # create/apply release-tracking labels on the release PR
actions: write # dispatch release-artifacts.yml once a release is cut
jobs:
release-please:
name: Release PR / tag
runs-on: ubuntu-latest
steps:
- name: Run release-please
id: release
uses: googleapis/release-please-action@a02a34c4d625f9be7cb89156071d8567266a2445 # v4.2.0
# Tags created with the default GITHUB_TOKEN never fire `on: push: tags`
# workflows (GitHub suppresses workflows triggered by workflow-created
# events), so release-artifacts.yml would not run for a release-please
# tag. workflow_dispatch invoked through the API is exempt from that
# suppression — dispatch the artifacts build at the new tag explicitly.
- name: Build and publish release artifacts
if: ${{ steps.release.outputs.release_created }}
env:
GH_TOKEN: ${{ github.token }}
run: |
gh workflow run release-artifacts.yml \
--repo "$GITHUB_REPOSITORY" \
--ref "${{ steps.release.outputs.tag_name }}"
-1
View File
@@ -1 +0,0 @@
{".":"0.1.0"}
+1 -21
View File
@@ -6,32 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## 0.1.0 (2026-07-02)
### Features
* publish zero to npm via release-please ([#367](https://github.com/Gitlawb/zero/issues/367)) ([8eccc26](https://github.com/Gitlawb/zero/commit/8eccc2669887bc38d35bc16a315c888e4d9ec43a))
* **tui:** FILES sidebar panel with click-to-select and file drill-in ([#365](https://github.com/Gitlawb/zero/issues/365)) ([142c548](https://github.com/Gitlawb/zero/commit/142c548c89a8652ce300e64ddf1228ee36df7606))
### Bug Fixes
* **auth:** propagate credentials to every provider-build surface and pin children to the live provider ([#366](https://github.com/Gitlawb/zero/issues/366)) ([6e0a665](https://github.com/Gitlawb/zero/commit/6e0a665118fe0e09c4b07d482dd18f86045acd2b))
## [Unreleased]
### Added
- `SECURITY.md` with a private vulnerability-reporting path, `CODE_OF_CONDUCT.md`, this changelog, and
GitHub issue/PR templates.
- Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move
and applies on select (Esc reverts).
- Ten built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, and
`solarized-light` — selectable via `/theme <name>`, `--theme <name>`, or `ZERO_THEME`. Every palette
is contrast-audited to WCAG AA. The built-in light theme was reworked for legibility.
- `--theme <name>` flag for the TUI, accepting `auto` or any registered theme (previously only the
`ZERO_THEME` env var existed).
- `--theme {auto|dark|light}` flag for the TUI (previously only the `ZERO_THEME` env var existed).
- "Accessibility / Appearance" section in the README documenting `NO_COLOR`, `ZERO_THEME`, `/theme`,
and `ZERO_NO_FADE`.
+3 -3
View File
@@ -254,9 +254,9 @@ zero update check for newer releases
| Control | Effect |
|---|---|
| `NO_COLOR=<anything>` | disables color output |
| `ZERO_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `solarized-light`) |
| `--theme <name>` | selects the TUI theme from the CLI (same names) |
| `/theme` | opens the theme picker inside the TUI (live preview; `/theme <name>` switches directly) |
| `ZERO_THEME=auto|dark|light` | selects the startup theme |
| `--theme auto|dark|light` | selects the TUI theme from the CLI |
| `/theme auto|dark|light` | switches theme inside the TUI |
| `ZERO_NO_FADE=1` | disables streaming fade animation |
Meaning does not rely on color alone; diffs, permissions, and statuses also use
-19
View File
@@ -280,25 +280,6 @@ func isContextLimitError(message string) bool {
return false
}
// isStreamTimeoutError reports whether a streamed error is a provider stream
// idle/stall timeout (providerio surfaces these as "idle timeout after …" /
// "no output for …" / "stream stalled"). Such a turn produced nothing, so when
// no content was forwarded yet it can be safely re-issued on a fresh connection
// (see the stall-retry in the agent loop). Substring + case-insensitive to
// tolerate the prefixed "provider stream error: …" wrapping.
func isStreamTimeoutError(message string) bool {
lowered := strings.ToLower(strings.TrimSpace(message))
if lowered == "" {
return false
}
for _, needle := range []string{"idle timeout after", "no output for", "stream stalled"} {
if strings.Contains(lowered, needle) {
return true
}
}
return false
}
// compactionState carries the per-run state the agent loop needs to compact a
// conversation safely. It is created once per Run and is a no-op whenever
// options.ContextWindow <= 0.
-99
View File
@@ -1,99 +0,0 @@
package agent
import "testing"
// decodeToolArguments must (a) decode a normal single-object payload unchanged,
// (b) tolerate a weak model packing multiple concatenated top-level JSON objects
// into one arguments string by decoding the FIRST object and ignoring the rest
// (the minimax-m3 "invalid character '{' after top-level value" failure), and
// (c) still reject a genuinely malformed/truncated first object.
func TestDecodeToolArguments(t *testing.T) {
t.Run("single object decodes normally", func(t *testing.T) {
var a map[string]any
if err := decodeToolArguments(`{"path":"x"}`, &a); err != nil {
t.Fatalf("err = %v, want nil", err)
}
if a["path"] != "x" {
t.Fatalf("decoded %v, want path=x", a)
}
})
t.Run("concatenated objects decode the first, ignore trailing", func(t *testing.T) {
var b map[string]any
if err := decodeToolArguments(`{"path":"first"}{"path":"second"}`, &b); err != nil {
t.Fatalf("multi-object should decode the first, got err = %v", err)
}
if b["path"] != "first" {
t.Fatalf("decoded %v, want the first object (path=first)", b)
}
})
t.Run("distinct tools' args concatenated (real minimax case): first object used", func(t *testing.T) {
var c map[string]any
if err := decodeToolArguments(`{"plan":[1,2]}{"cmd":"go version"}`, &c); err != nil {
t.Fatalf("err = %v, want nil", err)
}
if _, ok := c["plan"]; !ok || c["cmd"] != nil {
t.Fatalf("decoded %v, want only the first object's keys", c)
}
})
t.Run("truncated first object still errors", func(t *testing.T) {
var d map[string]any
if err := decodeToolArguments(`{"path":`, &d); err == nil {
t.Fatal("truncated JSON must still error")
}
})
t.Run("non-JSON trailing garbage still errors (not masked)", func(t *testing.T) {
var g map[string]any
if err := decodeToolArguments(`{"x":1}xyz`, &g); err == nil {
t.Fatal("a valid first object followed by non-JSON garbage must still error")
}
})
t.Run("recoverableToolArguments: only whole-JSON trailing is tolerated", func(t *testing.T) {
cases := []struct {
in string
ok bool
first string
}{
{`{"a":1}`, true, `{"a":1}`},
{`{"a":1}{"b":2}`, true, `{"a":1}`},
{`{"a":1} {"b":2} `, true, `{"a":1}`},
{`{"a":1}xyz`, false, ""},
{`{"a":`, false, ""},
{` `, false, ""},
}
for _, c := range cases {
first, ok := recoverableToolArguments(c.in)
if ok != c.ok || (ok && first != c.first) {
t.Fatalf("recoverableToolArguments(%q) = (%q,%v), want (%q,%v)", c.in, first, ok, c.first, c.ok)
}
}
})
t.Run("historySafeToolCalls recovers the first object, not {}", func(t *testing.T) {
out := historySafeToolCalls([]ToolCall{
{ID: "c1", Name: "read_file", Arguments: `{"path":"README.md"}{"path":"AGENTS.md"}`}, // concatenated -> first
{ID: "c2", Name: "read_file", Arguments: `{"path":"x"}xyz`}, // garbage -> {}
{ID: "c3", Name: "read_file", Arguments: `{"path":"ok"}`}, // valid -> unchanged
})
if out[0].Arguments != `{"path":"README.md"}` {
t.Fatalf("concatenated args should record the first object, got %q", out[0].Arguments)
}
if out[1].Arguments != "{}" {
t.Fatalf("garbage-trailing args should record {}, got %q", out[1].Arguments)
}
if out[2].Arguments != `{"path":"ok"}` {
t.Fatalf("valid args should be unchanged, got %q", out[2].Arguments)
}
})
t.Run("whitespace-only errors (caller guards empty)", func(t *testing.T) {
var e map[string]any
if err := decodeToolArguments(" ", &e); err == nil {
t.Fatal("whitespace-only must error")
}
})
}
+36 -166
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"sort"
"strings"
"sync"
@@ -21,12 +20,6 @@ import (
const maxTurnsAnswer = "Agent reached maximum number of turns without a final answer."
const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not call tools. Give a concise final answer now: summarize what you completed, what you found, and any remaining blockers."
// maxStreamStallRetries bounds how many times a turn that timed out (idle/stall)
// WITH NO OUTPUT yet is re-issued on a fresh connection before giving up. Only
// the no-output case is retried (a partial turn would duplicate), so this is a
// safe recovery for a stalled/dead pooled connection.
const maxStreamStallRetries = 2
const (
toolResultMetaControl = "control"
toolResultControlSpecReview = "spec_review_required"
@@ -210,74 +203,55 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
}
// forwardedVisibleText flags forwarded final PROSE (OnText) specifically —
// the only output whose re-stream on a stall retry would visibly DUPLICATE
// answer text the user already read. Transient working indicators
// (reasoning previews, tool-call "writing…" previews) are deliberately NOT
// counted: a turn that streamed only those then stalled — the common
// gpt-5.x / ollama "froze mid-write_file" case — is safe to re-issue, since
// the TUI resets the tool-call preview on the next tool-call-start and a
// stalled turn is never appended to `messages` (it returns on the error
// before the append), so the retry re-sends clean context with no
// conversation-state duplication.
forwardedVisibleText := false
forwardingOpts := zeroruntime.CollectOptions{OnUsage: options.OnUsage}
if options.OnText != nil {
forwardingOpts.OnText = func(s string) { forwardedVisibleText = true; options.OnText(s) }
}
if options.OnReasoning != nil {
forwardingOpts.OnReasoning = func(s string) { options.OnReasoning(s) }
}
if options.OnToolCallStart != nil {
forwardingOpts.OnToolCallStart = func(id, name string) { options.OnToolCallStart(id, name) }
}
if options.OnToolCallDelta != nil {
forwardingOpts.OnToolCallDelta = func(id, fragment string) { options.OnToolCallDelta(id, fragment) }
}
// recoverStreamError applies the same non-stall recovery the initial stream
// gets to ANY collected error — including one from a reissued (stall-retry)
// stream: an image rejection gets the friendly wrapping, and a context limit
// gets one compaction + reactive reissue (omitting visible callbacks, since
// any pre-error output was already forwarded). It returns the possibly-updated
// collected and a non-nil stop error when the run must end now.
recoverStreamError := func(collected zeroruntime.CollectedStream) (zeroruntime.CollectedStream, error) {
if isImageRejectionError(errors.New(collected.Error)) {
return collected, fmt.Errorf("model %s rejected the image: %s. The model may not support image input — try switching to a vision-capable model (claude, gpt-4o, gemini)", options.Model, collected.Error)
collected := zeroruntime.CollectStreamWithOptions(ctx, stream, zeroruntime.CollectOptions{
OnText: options.OnText,
OnReasoning: options.OnReasoning,
OnUsage: options.OnUsage,
OnToolCallStart: options.OnToolCallStart,
OnToolCallDelta: options.OnToolCallDelta,
})
if collected.Error != "" {
collectedErr := errors.New(collected.Error)
if isImageRejectionError(collectedErr) {
result.Messages = copyMessages(messages)
return result, fmt.Errorf("model %s rejected the image: %s. The model may not support image input — try switching to a vision-capable model (claude, gpt-4o, gemini)", options.Model, collected.Error)
}
// REACTIVE compaction: the streamed error may also be a context limit
// (some providers surface it mid-stream). Compact and retry once.
// REACTIVE compaction: the streamed error may also be a context
// limit (some providers surface it mid-stream). Compact and retry
// the same turn once before giving up.
if compacted, retried, retryErr := compactor.recover(ctx, provider, messages, request.Tools, collected.Error); retried {
messages = compacted
if retryErr != nil {
return collected, retryErr
result.Messages = copyMessages(messages)
return result, retryErr
}
// Reuse the SAME active-mode partition (exposed) from this turn rather
// than the bare toolDefinitions: exposed depends on registry+loaded (not
// the messages), so it stays valid after compaction.
// Reuse the SAME active-mode partition (exposed) from this
// turn rather than the bare toolDefinitions: exposed depends on
// registry+loaded (not the messages), so they stay valid after compaction.
// Routing through an empty-loaded partition here would re-hide every
// already-loaded deferred tool.
retryRequest := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
}
// Pre-content reconnect of a mid-stream disconnect: route the connect
// through the reconnect helper too (AUDIT-L1).
retryStream, retryStreamErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
if retryStreamErr != nil {
return collected, retryStreamErr
result.Messages = copyMessages(messages)
return result, retryStreamErr
}
// Omit OnText on the reactive retry: when the original error
// surfaced MID-stream, partial text was already forwarded to the
// user. Re-streaming the retried response on top of it would
// duplicate output. OnUsage IS kept so token telemetry/budgeting
// still counts the successful retry. The retried text is captured
// in collected.Text and becomes the turn's assistant message.
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, zeroruntime.CollectOptions{
OnUsage: options.OnUsage,
})
}
return collected, nil
}
collected := zeroruntime.CollectStreamWithOptions(ctx, stream, forwardingOpts)
if collected.Error != "" {
updated, stop := recoverStreamError(collected)
collected = updated
if stop != nil {
result.Messages = copyMessages(messages)
return result, stop
}
}
// Check ctx first: on cancellation helpers.go sets collected.Error to
// ctx.Err().Error(), so returning errors.New(collected.Error) would lose
@@ -286,58 +260,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, ctx.Err()
}
// A stream idle/stall timeout is safely re-issued when the turn committed NO
// answer text — no forwarded visible prose (forwardedVisibleText) and no
// collected final text (collected.Text). This covers two cases:
// 1. Nothing streamed at all before the connection died (the original
// macOS stale-pooled-connection hang past the response-header timeout).
// 2. The model streamed transient reasoning and began a tool call (e.g. a
// large write_file) then froze mid-arguments — the common gpt-5.x /
// ollama heartbeat-pause stall. That partial tool call is NEVER executed
// (a turn with collected.Error returns before dispatch below) and NEVER
// appended to `messages`, so a retry re-issues clean context; the only
// re-render is transient previews, not duplicated answer text. This is
// why the gate no longer excludes collected.ToolCalls: an incomplete
// tool call from a timed-out stream is discard-and-retry, not output.
// A turn that forwarded real prose is NOT retried (it would duplicate visible
// answer text) and falls through to the error return below. Capped +
// exponential backoff, with a user-visible notice per attempt.
for attempt := 1; attempt <= maxStreamStallRetries &&
isStreamTimeoutError(collected.Error) && !forwardedVisibleText &&
collected.Text == ""; attempt++ {
if notify := stallRetryNoticeFor(options); notify != nil {
notify(attempt, maxStreamStallRetries)
}
if err := sleepWithContext(ctx, backoffFor(attempt)); err != nil {
result.Messages = copyMessages(messages)
return result, err
}
retryRequest := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
}
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
if retryErr != nil {
result.Messages = copyMessages(messages)
return result, retryErr
}
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, forwardingOpts)
}
if collected.Error != "" {
// Route a reissued stream's non-stall error through the SAME recovery as
// the initial stream (image-rejection wrapping / context-limit compaction)
// rather than returning it raw.
updated, stop := recoverStreamError(collected)
collected = updated
if stop != nil {
result.Messages = copyMessages(messages)
return result, stop
}
if collected.Error != "" {
result.Messages = copyMessages(messages)
return result, errors.New(collected.Error)
}
result.Messages = copyMessages(messages)
return result, errors.New(collected.Error)
}
// Carry the turn's terminal stop reason so a final answer cut off at the
@@ -690,20 +615,8 @@ func historySafeToolCalls(calls []ToolCall) []ToolCall {
for i, call := range calls {
safe[i] = call
args := strings.TrimSpace(call.Arguments)
switch {
case args == "":
if args == "" || !json.Valid([]byte(args)) {
safe[i].Arguments = "{}"
case json.Valid([]byte(args)):
// already a single valid JSON value — keep as-is
default:
// Recover the first object for the concatenated case so the replayed
// transcript matches what executeToolCall actually ran; otherwise (real
// corruption) fall back to an empty object.
if first, ok := recoverableToolArguments(args); ok {
safe[i].Arguments = first
} else {
safe[i].Arguments = "{}"
}
}
}
return safe
@@ -728,53 +641,10 @@ func toolSchemaJSON(registry *tools.Registry, name string) string {
// is a non-nil ABORT error only when the call demands the whole run stop (a
// canceled/timed-out ask_user prompt) rather than continuing — every ordinary
// success or tool error returns a nil abort error.
// recoverableToolArguments inspects a tool call's raw JSON arguments. It returns
// the raw text of the FIRST JSON value when the payload is one value optionally
// followed by additional WHOLE JSON values (the weak-model concatenated case, e.g.
// `{A}{B}`). ok is false when the payload is genuinely malformed: a bad/truncated
// first value, OR trailing NON-JSON garbage after a valid first value (e.g.
// `{"x":1}xyz`) — that case must still error, not be silently accepted. Sharing
// this between dispatch and replay-history keeps them consistent: only a cleanly
// recoverable first object is ever used.
func recoverableToolArguments(arguments string) (first string, ok bool) {
dec := json.NewDecoder(strings.NewReader(arguments))
var head json.RawMessage
if err := dec.Decode(&head); err != nil {
return "", false
}
// The remainder must be only whole JSON values (or nothing); any decode error
// other than EOF means trailing garbage — reject so corruption still surfaces.
for {
var rest json.RawMessage
if err := dec.Decode(&rest); err != nil {
if errors.Is(err, io.EOF) {
return strings.TrimSpace(string(head)), true
}
return "", false
}
}
}
// decodeToolArguments decodes a tool call's JSON arguments into v. It tolerates a
// weaker model that packs MULTIPLE concatenated top-level JSON objects into one
// arguments string (`{A}{B}`) — the cause of "invalid character '{' after top-level
// value" failures with small models like minimax-m3 — by decoding the FIRST object
// and ignoring the trailing WHOLE JSON values, so the primary intended call runs.
// A genuinely malformed payload (bad/truncated first object, or non-JSON trailing
// garbage) still returns the real parse error; a standard single-object payload
// decodes exactly as before.
func decodeToolArguments(arguments string, v any) error {
if first, ok := recoverableToolArguments(arguments); ok {
return json.Unmarshal([]byte(first), v)
}
// Not cleanly recoverable — surface the genuine parse error.
return json.Unmarshal([]byte(arguments), v)
}
func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCall, permissionMode PermissionMode, options Options) (ToolResult, error) {
args := map[string]any{}
if call.Arguments != "" {
if err := decodeToolArguments(call.Arguments, &args); err != nil {
if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
+1 -51
View File
@@ -1053,10 +1053,7 @@ func TestRunSanitizesMalformedToolCallArgumentsBeforeRetry(t *testing.T) {
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
// Genuinely malformed (truncated) args: still error -> sanitize -> retry.
// (Concatenated multi-object args are now tolerated; see
// TestRunRecoversFirstObjectFromConcatenatedToolArgs.)
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"README.md"`},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"README.md"}{"path":"AGENTS.md"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
@@ -1106,53 +1103,6 @@ func TestRunSanitizesMalformedToolCallArgumentsBeforeRetry(t *testing.T) {
}
}
// A weak model (e.g. minimax-m3) that packs MULTIPLE concatenated JSON objects into
// one tool-call slot must RECOVER the first object and run the call, not fail with
// "invalid character '{' after top-level value". The trailing object(s) are dropped
// (the model re-issues them on a later turn if still needed).
func TestRunRecoversFirstObjectFromConcatenatedToolArgs(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hello readme"), 0o644); err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"README.md"}{"path":"AGENTS.md"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "read", provider, Options{Registry: registry, MaxTurns: 2})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want done", result.FinalAnswer)
}
var toolResult string
for _, message := range provider.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleTool {
toolResult = message.Content
}
}
if strings.Contains(toolResult, "Failed to parse arguments") {
t.Fatalf("concatenated args should recover the first object, not fail to parse: %q", toolResult)
}
if !strings.Contains(toolResult, "hello readme") {
t.Fatalf("expected README.md contents (the first object's path), got %q", toolResult)
}
}
func TestRunDefersSelfCorrectFeedbackUntilAfterToolBatch(t *testing.T) {
// A single assistant turn with two tool calls where the first mutates and
// self-correct fails must keep both tool_results contiguous, with the feedback
-15
View File
@@ -40,21 +40,6 @@ func reconnectNoticeFor(options Options) reconnectNotifier {
}
}
// stallRetryNoticeFor builds a notifier for the loop's content-stall retry (a
// stream that connected and produced only transient output, then went silent).
// Distinct wording from reconnectNoticeFor's "connection lost" because the
// connection was fine — the model stalled. Surfaced through OnReasoning, the
// non-content channel, so it never folds into the answer text. Nil when there
// is no reasoning sink (the retry still happens silently).
func stallRetryNoticeFor(options Options) reconnectNotifier {
if options.OnReasoning == nil {
return nil
}
return func(attempt, max int) {
options.OnReasoning(fmt.Sprintf("\n[no output — model stalled; retrying %d/%d…]\n", attempt, max))
}
}
// streamWithReconnect issues request via provider.StreamCompletion and, on a
// transient disconnect error, retries the connect up to maxStreamReconnects
// times with exponential backoff. It returns the live stream on success, or the
-168
View File
@@ -1,168 +0,0 @@
package agent
import (
"context"
"sync/atomic"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// stallProvider connects successfully (HTTP 200) but the stream emits a stall/idle
// timeout error for the first stallBefore calls, then succeeds with "done". The
// pre-stall emission simulates different partial-output shapes:
// - partialText: forwarded PROSE (StreamEventText) — must NOT be retried
// (re-issuing would duplicate visible answer text).
// - reasoningText: transient reasoning preview — IS retried.
// - partialToolCall: a tool call started (+ arg delta) but never ended, the
// "froze mid-write_file" case — IS retried (the incomplete call is never
// executed or committed).
type stallProvider struct {
calls int32
stallBefore int32
partialText string
reasoningText string
partialToolCall string
}
func (p *stallProvider) StreamCompletion(_ context.Context, _ zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
n := atomic.AddInt32(&p.calls, 1)
ch := make(chan zeroruntime.StreamEvent, 5)
if n <= p.stallBefore {
if p.partialText != "" {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.partialText}
}
if p.reasoningText != "" {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventReasoning, Content: p.reasoningText}
}
if p.partialToolCall != "" {
// A tool call that starts and streams a partial argument fragment but
// never gets StreamEventToolCallEnd, then the stream errors — exactly
// the gpt-5.x "began the big write_file then froze" shape.
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "tc_1", ToolName: p.partialToolCall}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "tc_1", ArgumentsFragment: `{"path":"x.html","content":"<!doctype`}
}
ch <- zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: "provider stream error: no output for 6m (the model produced nothing)",
}
close(ch)
return ch, nil
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
// A no-output stall is re-issued on a fresh connection and recovers — this is the
// macOS stale-pooled-connection hang turned into an automatic recovery.
func TestRunRetriesStreamStallWithNoOutput(t *testing.T) {
p := &stallProvider{stallBefore: 1}
result, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
if err != nil {
t.Fatalf("a no-output stall should retry to success, got %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
}
if got := atomic.LoadInt32(&p.calls); got != 2 {
t.Fatalf("want 2 calls (1 stall + 1 retry), got %d", got)
}
}
// A stall AFTER partial output must NOT be retried — re-issuing would duplicate
// the already-streamed text. It surfaces the error instead.
func TestRunDoesNotRetryStallAfterPartialOutput(t *testing.T) {
p := &stallProvider{stallBefore: 1, partialText: "partial"}
_, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
if err == nil {
t.Fatal("a stall after partial output must NOT be retried; want an error")
}
if got := atomic.LoadInt32(&p.calls); got != 1 {
t.Fatalf("partial-then-stall must not retry, got %d calls", got)
}
}
// A stall after only transient REASONING was forwarded (no prose text, no
// completed tool call) IS retried: reasoning is a "thinking" preview, not
// committed answer text, so re-streaming it on a clearly-signalled retry is
// acceptable and recovers the common "reasoned then froze" stall. Only
// forwarded final prose (OnText) blocks the retry (collected.Text is empty
// here, so this exercises the transient-vs-prose gate specifically).
func TestRunRetriesStallAfterOnlyReasoning(t *testing.T) {
p := &stallProvider{stallBefore: 1, reasoningText: "thinking hard"}
result, err := Run(context.Background(), "go", p, Options{
Registry: tools.NewRegistry(),
OnReasoning: func(string) {},
})
if err != nil {
t.Fatalf("a stall after only reasoning should retry to success, got %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
}
if got := atomic.LoadInt32(&p.calls); got != 2 {
t.Fatalf("want 2 calls (1 stall + 1 retry), got %d", got)
}
}
// A stall mid-way through an INCOMPLETE tool call (started + partial args, never
// ended) IS retried — the exact gpt-5.x / ollama "began the big write_file then
// froze" case. The incomplete call is never executed (a turn returns on the
// stall error before dispatch) and never committed to history, so re-issuing is
// safe and recovers to success. This is the primary scenario this change fixes.
func TestRunRetriesStallAfterIncompleteToolCall(t *testing.T) {
starts := 0
p := &stallProvider{stallBefore: 1, reasoningText: "planning", partialToolCall: "write_file"}
result, err := Run(context.Background(), "go", p, Options{
Registry: tools.NewRegistry(),
OnReasoning: func(string) {},
OnToolCallStart: func(string, string) { starts++ },
OnToolCallDelta: func(string, string) {},
})
if err != nil {
t.Fatalf("a stall mid-incomplete-tool-call should retry to success, got %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
}
if got := atomic.LoadInt32(&p.calls); got != 2 {
t.Fatalf("want 2 calls (1 stall + 1 retry), got %d", got)
}
if starts != 1 {
t.Fatalf("the incomplete tool call should have been forwarded once before the stall, got %d starts", starts)
}
}
// A persistent stall surfaces an error after exhausting the capped retries.
func TestRunGivesUpAfterMaxStallRetries(t *testing.T) {
p := &stallProvider{stallBefore: 99}
_, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
if err == nil {
t.Fatal("a persistent stall must surface an error after exhausting retries")
}
if got := atomic.LoadInt32(&p.calls); got != int32(1+maxStreamStallRetries) {
t.Fatalf("want %d calls (1 + %d retries), got %d", 1+maxStreamStallRetries, maxStreamStallRetries, got)
}
}
func TestIsStreamTimeoutError(t *testing.T) {
timeouts := []string{
"provider stream error: no output for 10m (the model produced nothing)",
"provider stream error: idle timeout after 5m0s (upstream stopped sending data)",
"stream stalled (upstream kept the connection alive but produced no output)",
}
for _, m := range timeouts {
if !isStreamTimeoutError(m) {
t.Fatalf("want timeout-classified: %q", m)
}
}
notTimeouts := []string{"", "context length exceeded", "rate limit error: slow down", "model not found"}
for _, m := range notTimeouts {
if isStreamTimeoutError(m) {
t.Fatalf("must NOT classify as timeout: %q", m)
}
}
}
+2 -6
View File
@@ -455,15 +455,11 @@ func TestHarnessTimeoutCancelsBlockedAgent(t *testing.T) {
// The timeout is longer than materialization of the small fixture even on
// slower Windows CI runners, so the agent is reached before it blocks until
// the deadline fires. 10s wasn't generous enough in practice — it failed
// on an otherwise-unrelated PR merge (materialization alone consumed the
// full budget on a contended Windows runner, so the agent was never
// reached); widened for headroom, since the assertions below still bound
// worst-case test time via that same deadline, not this constant.
// the deadline fires.
report := harness.Run(context.Background(), suitePath, suite, BenchmarkInput{
TaskID: "document-stream-json-verify-events",
WorkRoot: t.TempDir(),
Timeout: 30 * time.Second,
Timeout: 10 * time.Second,
})
if !agentReached {
+2 -5
View File
@@ -43,11 +43,8 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
conn := acp.NewConn(deps.stdin, stdout)
acp.NewAgent(conn, acp.Deps{
ResolveConfig: deps.resolveConfig,
// deps.newProvider is wrapped in fillAppDeps to apply the stored API key,
// so ACP is authenticated for apiKeyStored profiles like every other
// surface — no ACP-specific credential handling needed.
NewProvider: deps.newProvider,
RunAgent: agent.Run,
NewProvider: deps.newProvider,
RunAgent: agent.Run,
// Build the SCOPED registry + sandbox engine per workspace, exactly like the
// exec surface, so ACP shell/file tools are confined — never run unconfined.
BuildWorkspace: func(workspaceRoot string, resolved config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
+29 -103
View File
@@ -43,17 +43,12 @@ import (
var version = "dev"
type appDeps struct {
getwd func() (string, error)
stdin io.Reader
userConfigPath func() (string, error)
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
// exportActiveProvider pins spawned children to the run's provider (production:
// config.SetActiveProviderEnv, set in defaultAppDeps — deliberately NOT filled
// by fillAppDeps, so tests never mutate the process environment unless they
// inject it). nil ⇒ no export.
exportActiveProvider func(providerName string)
getwd func() (string, error)
stdin io.Reader
userConfigPath func() (string, error)
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
newSessionStore func() *sessions.Store
@@ -104,10 +99,9 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int {
func defaultAppDeps() appDeps {
return appDeps{
getwd: os.Getwd,
stdin: os.Stdin,
userConfigPath: config.DefaultUserConfigPath,
exportActiveProvider: config.SetActiveProviderEnv,
getwd: os.Getwd,
stdin: os.Stdin,
userConfigPath: config.DefaultUserConfigPath,
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
options, err := config.DefaultResolveOptions(workspaceRoot)
if err != nil {
@@ -124,14 +118,9 @@ func defaultAppDeps() appDeps {
return config.ResolveMCP(options)
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
// Resolve the OAuth login ONCE: the bearer resolver and the login key it
// bound must describe the same login (the key is passed on to the Codex
// account-header resolver so it never re-selects independently).
resolver, loginKey := oauthLoginForProfile(profile)
return providers.New(profile, providers.Options{
UserAgent: userAgent(),
OAuthResolver: resolver,
OAuthLoginKey: loginKey,
OAuthResolver: oauthResolverForProfile(profile),
})
},
probeProviderHealth: providerhealth.Probe,
@@ -228,9 +217,9 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
// --theme <name> selects the TUI palette non-interactively (auto or any registered
// theme; populates tui.Options.Theme, which resolveThemeMode prefers over
// ZERO_THEME). Re-split --add-dir afterward so it may appear on either side of --theme.
// --theme {auto|dark|light} selects the TUI palette non-interactively (populates
// tui.Options.Theme, which resolveThemeMode prefers over ZERO_THEME). Re-split
// --add-dir afterward so it may appear on either side of --theme.
theme, args, err := splitLeadingThemeFlag(args)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
@@ -402,14 +391,6 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
if _, err := fmt.Fprintf(stderr, "unknown command %q\n", args[0]); err != nil {
return 1
}
// First-run users reach for `zero login`/`zero logout` (reported in the
// wild); point them at the real command instead of a bare usage pointer.
switch strings.ToLower(args[0]) {
case "login", "logout":
if _, err := fmt.Fprintf(stderr, "did you mean %q?\n", "zero auth "+strings.ToLower(args[0])); err != nil {
return 1
}
}
if _, err := fmt.Fprintln(stderr, "Run zero --help for usage."); err != nil {
return 1
}
@@ -512,17 +493,6 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.now == nil {
deps.now = defaults.now
}
// Wrap newProvider ONCE, after all defaults are filled, so every surface that
// builds the runtime provider gets the credential-store key applied. The
// resolver is pure and leaves apiKeyStored profiles keyless; an unwrapped
// build sends unauthenticated requests. config.ApplyStoredAPIKey is a no-op
// when the key is already set or the profile isn't stored-key, so this is
// safe and idempotent for every profile kind and every injected newProvider.
baseNewProvider := deps.newProvider
userConfigPath := deps.userConfigPath
deps.newProvider = func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return baseNewProvider(applyStoredProviderKeyAt(profile, userConfigPath))
}
return deps
}
@@ -538,21 +508,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
if err != nil {
// A resolve failure the setup wizard can FIX is not fatal for the
// interactive TUI: drop into the wizard with an empty config so the user
// can onboard/repair, instead of exiting with an error they can only fix
// by hand-editing config.json. That covers a missing/unresolvable active
// provider and an active provider without a model (custom endpoints have
// no catalog default) — previously the second shape bricked bare `zero`
// and `zero setup`, the exact commands that could have fixed it. Any
// other error (malformed JSON, directory conflict, etc.) is still fatal,
// and headless commands (zero config / zero exec) still fail with the
// actionable message.
if !errors.Is(err, config.ErrNoActiveProvider) && !errors.Is(err, config.ErrProviderRequiresModel) {
return writeAppError(stderr, err.Error(), 1)
}
resolved = config.ResolvedConfig{}
forceSetup = true
return writeAppError(stderr, err.Error(), 1)
}
userConfigPath, err := deps.userConfigPath()
if err != nil {
@@ -685,7 +641,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
return deps.runTUI(context.Background(), tui.Options{
Cwd: workspaceRoot,
Theme: theme,
SavedTheme: resolved.Preferences.Theme,
UserConfigPath: userConfigPath,
DoctorUserConfigPath: doctorUserConfigPath,
ProjectConfigPath: projectConfigPath,
@@ -764,50 +719,20 @@ func tuiSandboxSetupCommand(backend sandbox.Backend, deps appDeps) func(context.
}
}
// buildProvider constructs the run's provider at STARTUP — it is called only from
// the two launch paths (interactive TUI and headless exec), never from mid-run
// rebuilds (escalation, wizard, ACP go through deps.newProvider directly).
func buildProvider(resolved config.ResolvedConfig, deps appDeps) (zeroruntime.Provider, error) {
if !config.HasProviderProfile(resolved.Provider) {
return nil, nil
}
// deps.newProvider is wrapped in fillAppDeps to apply the stored key, so this
// (and every other newProvider caller) needs no per-site key handling.
provider, err := deps.newProvider(resolved.Provider)
if err != nil {
return nil, err
}
// Pin spawned children (sub-agents / swarm members inherit the environment) to
// THIS run's provider from launch, not only after an in-session switch. Without
// the launch-time export a child re-resolves config.json at spawn time, so a
// provider switch persisted by ANOTHER zero process mid-session would silently
// move new children onto a different provider (and different credentials) than
// the parent is running. Runtime switches (/model, /provider, wizard,
// onboarding) re-export on commit, keeping the pin current. Injected via deps
// (nil in tests) because it mutates the process environment.
if deps.exportActiveProvider != nil {
deps.exportActiveProvider(resolved.Provider.Name)
}
return provider, nil
}
// applyStoredProviderKeyAt loads the API key from the encrypted credential store
// when the resolver left it empty (the resolver is pure — no I/O — so an
// apiKeyStored profile reaches this layer keyless). No-op for inline/env-key
// providers and when nothing is stored. This is applied once, centrally, by the
// deps.newProvider wrapper in fillAppDeps so EVERY surface that builds a runtime
// provider (headless exec, the ACP builder, exec's mid-run escalation switcher,
// and any future caller) is covered without a per-site invariant to forget.
func applyStoredProviderKeyAt(profile config.ProviderProfile, userConfigPath func() (string, error)) config.ProviderProfile {
if userConfigPath == nil {
return profile
}
if path, perr := userConfigPath(); perr == nil {
// Load the API key from the encrypted credential store when it isn't already
// resolved from inline config or an env var. This keeps the resolver pure (no
// I/O) and is a no-op for env/inline-key providers and when nothing is stored.
profile := resolved.Provider
if path, perr := deps.userConfigPath(); perr == nil {
if store, err := config.ProviderKeyStoreAt(filepath.Dir(path)); err == nil {
profile = config.ApplyStoredAPIKey(profile, store)
}
}
return profile
return deps.newProvider(profile)
}
func newCoreRegistry(workspaceRoot string) *tools.Registry {
@@ -1108,10 +1033,9 @@ func splitLeadingAddDirFlags(args []string) ([]string, []string, error) {
return addDirs, args, nil
}
// splitLeadingThemeFlag strips a leading --theme <auto|theme-name> (space or =form)
// from the root argument list and validates it against the registered themes. The
// last occurrence wins. A value outside the allowed set is a loud error rather than
// a silent fallback.
// splitLeadingThemeFlag strips a leading --theme {auto|dark|light} (space or =form)
// from the root argument list and validates it. The last occurrence wins. A value
// outside the allowed set is a loud error rather than a silent fallback.
func splitLeadingThemeFlag(args []string) (string, []string, error) {
theme := ""
for len(args) > 0 {
@@ -1119,7 +1043,7 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
switch {
case args[0] == "--theme":
if len(args) < 2 {
return "", nil, errors.New("--theme requires a value (auto or a theme name; try --theme auto)")
return "", nil, errors.New("--theme requires a value (auto, dark, or light)")
}
value = strings.TrimSpace(args[1])
args = args[2:]
@@ -1129,10 +1053,12 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
default:
return theme, args, nil
}
if !tui.ValidThemeArg(value) {
return "", nil, fmt.Errorf("--theme must be auto or a registered theme name (got %q)", value)
switch strings.ToLower(value) {
case "auto", "dark", "light":
theme = strings.ToLower(value)
default:
return "", nil, fmt.Errorf("--theme must be auto, dark, or light (got %q)", value)
}
theme = strings.ToLower(value)
}
return theme, args, nil
}
+1 -148
View File
@@ -5,7 +5,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
@@ -158,80 +157,6 @@ func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *tes
assertAgentOptions(t, launchedOptions, 12, agent.PermissionModeAsk)
}
func TestRunNoArgsEntersSetupWhenResolveReportsNoActiveProvider(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
setCLIUserConfigRoot(t)
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
var launchedOptions tui.Options
launched := false
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "ghost")
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
t.Fatal("newProvider should not be called without a resolved provider")
return nil, nil
},
userConfigPath: func() (string, error) {
return userConfigPath, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return noopMCPRuntime{}, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
launched = true
launchedOptions = options
return 0
},
})
if exitCode != 0 {
t.Fatalf("exit code = %d, want 0 (setup TUI launched), stderr=%q", exitCode, stderr.String())
}
if !launched {
t.Fatal("TUI was not launched; expected fallback into setup instead of a fatal error")
}
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
}
if launchedOptions.Provider != nil {
t.Fatalf("Provider = %#v, want nil for no-provider fallback", launchedOptions.Provider)
}
}
func TestRunNoArgsFailsWhenResolveErrorIsNotProviderRelated(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
setCLIUserConfigRoot(t)
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, fmt.Errorf("invalid config JSON")
},
runTUI: func(ctx context.Context, options tui.Options) int {
t.Fatal("TUI must not launch on a non-provider resolve error")
return 0
},
})
if exitCode == 0 {
t.Fatal("exit code = 0, want non-zero for fatal config error")
}
if !strings.Contains(stderr.String(), "invalid config JSON") {
t.Fatalf("stderr = %q, want the underlying config error", stderr.String())
}
}
func TestRunNoArgsLaunchesTUIWithMCPState(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -1472,79 +1397,7 @@ func TestRunThemeFlagRejectsBadValue(t *testing.T) {
if launched {
t.Fatal("the TUI must not launch on a bad --theme value")
}
if !strings.Contains(stderr.String(), "--theme must be auto or a registered theme name") {
if !strings.Contains(stderr.String(), "--theme must be auto, dark, or light") {
t.Fatalf("expected a clear --theme error, got %q", stderr.String())
}
}
// A custom endpoint saved without a model previously bricked bare `zero` and
// `zero setup` — the exact commands that could have fixed it (the resolve
// error escaped before the wizard could open). The interactive TUI now treats
// the requires-model failure as "needs onboarding", same as a missing active
// provider. The error comes from a REAL Resolve over a real config file, so
// this exercises the production sentinel wrapping, not a hand-built error.
func TestRunNoArgsEntersSetupWhenActiveProviderMissesModel(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
setCLIUserConfigRoot(t)
brokenConfig := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(brokenConfig, []byte(`{
"activeProvider": "gw",
"providers": [{"name": "gw", "provider_kind": "openai-compatible", "baseURL": "https://gw.example/v1", "apiKey": "sk-x"}]
}`), 0o600); err != nil {
t.Fatal(err)
}
launched := false
var launchedOptions tui.Options
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.Resolve(config.ResolveOptions{UserConfigPath: brokenConfig, Env: map[string]string{}})
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
t.Fatal("newProvider must not be called without a resolved provider")
return nil, nil
},
userConfigPath: func() (string, error) { return filepath.Join(t.TempDir(), "zero", "config.json"), nil },
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return noopMCPRuntime{}, nil
},
runTUI: func(_ context.Context, options tui.Options) int {
launched = true
launchedOptions = options
return 0
},
})
if exitCode != 0 {
t.Fatalf("exit code = %d, want 0 (setup TUI launched), stderr=%q", exitCode, stderr.String())
}
if !launched {
t.Fatal("TUI was not launched; a fixable requires-model config must fall into setup, not exit fatally")
}
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
}
}
// `zero login`/`zero logout` don't exist (it's `zero auth login`); first-run
// users try them (reported in the wild), so the unknown-command error points at
// the real command.
func TestRunUnknownLoginSuggestsAuthLogin(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"login"}, &stdout, &stderr, appDeps{})
if exitCode != 2 {
t.Fatalf("exit = %d, want 2 (unknown command)", exitCode)
}
if !strings.Contains(stderr.String(), `"zero auth login"`) {
t.Fatalf("stderr = %q, want a zero auth login suggestion", stderr.String())
}
// An unrelated unknown command gets no misleading suggestion.
stderr.Reset()
runWithDeps([]string{"frobnicate"}, &stdout, &stderr, appDeps{})
if strings.Contains(stderr.String(), "did you mean") {
t.Fatalf("stderr = %q, unrelated commands must not get the auth hint", stderr.String())
}
}
-3
View File
@@ -283,9 +283,6 @@ func newAuthManager(deps appDeps, out io.Writer) (*oauth.Manager, error) {
// carries no token material. A real browser launch is intentionally not
// performed (the sandbox/headless contexts make printing the safer default).
OpenBrowser: func(string) error { return nil },
// `zero auth login <preset>` (e.g. xai) should resolve the baked-in preset
// without the operator exporting ZERO_OAUTH_ALLOW_PRESETS first.
AllowPresets: true,
})
}
-180
View File
@@ -1,180 +0,0 @@
package cli
import (
"bytes"
"errors"
"os"
"path/filepath"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// seedStoredProviderKey writes key into a credential store rooted beside the
// returned config path, and returns deps fields wired at that path — the same
// layout applyStoredProviderKey resolves through in production.
func seedStoredProviderKey(t *testing.T, providerName, key string) (userConfigPath string) {
t.Helper()
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") // never touch the real OS keychain in tests
dir := t.TempDir()
store, err := config.ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("ProviderKeyStoreAt: %v", err)
}
if err := store.Set(providerName, key); err != nil {
t.Fatalf("seed stored key: %v", err)
}
return filepath.Join(dir, "config.json")
}
// applyStoredProviderKeyAt must fill APIKey from the credential store for an
// apiKeyStored profile — the single central helper the newProvider wrapper uses.
func TestApplyStoredProviderKeyAtFillsFromCredstore(t *testing.T) {
configPath := seedStoredProviderKey(t, "echo", "sk-stored-test")
ucp := func() (string, error) { return configPath, nil }
stored := applyStoredProviderKeyAt(config.ProviderProfile{Name: "echo", APIKeyStored: true}, ucp)
if stored.APIKey != "sk-stored-test" {
t.Fatalf("APIKey = %q, want the stored key", stored.APIKey)
}
// A profile that did NOT opt into stored-key auth must be left unchanged —
// a stale store entry must not silently reactivate credentials.
plain := applyStoredProviderKeyAt(config.ProviderProfile{Name: "echo"}, ucp)
if plain.APIKey != "" {
t.Fatalf("non-stored profile APIKey = %q, want empty", plain.APIKey)
}
}
// fillAppDeps wraps newProvider so EVERY surface that builds a runtime provider
// (buildProvider, the ACP builder — which is just deps.newProvider — and exec's
// escalation switcher) gets the stored key applied. Passing the raw resolved
// profile previously sent unauthenticated requests for apiKeyStored profiles,
// the default onboarding outcome. Testing the wrap covers all those surfaces.
func TestFillAppDepsWrapsNewProviderWithStoredKey(t *testing.T) {
configPath := seedStoredProviderKey(t, "echo", "sk-stored-wrap")
var captured config.ProviderProfile
deps := fillAppDeps(appDeps{
userConfigPath: func() (string, error) { return configPath, nil },
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
captured = profile
return nil, nil // only the captured profile matters here
},
})
if _, err := deps.newProvider(config.ProviderProfile{Name: "echo", APIKeyStored: true}); err != nil {
t.Fatalf("wrapped newProvider: %v", err)
}
if captured.APIKey != "sk-stored-wrap" {
t.Fatalf("wrapped newProvider built with APIKey = %q, want the stored key (unauthenticated regression)", captured.APIKey)
}
}
// buildProvider (the TUI/exec STARTUP construction site) must export
// ZERO_PROVIDER so children spawned at any point in the run are pinned to the
// parent's provider from launch — not only after an in-session switch. Without
// this, a provider switch persisted by another zero process mid-session moves
// new children onto a different provider (and credentials) than the parent.
func TestBuildProviderExportsActiveProviderEnv(t *testing.T) {
t.Setenv(config.ActiveProviderEnv, "stale-from-elsewhere")
// The exporter is injected (defaultAppDeps wires config.SetActiveProviderEnv;
// fillAppDeps deliberately leaves it nil so ordinary tests never mutate the
// process env). Inject the real one here to assert the actual env effect.
deps := appDeps{
exportActiveProvider: config.SetActiveProviderEnv,
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return nil, nil
},
}
resolved := config.ResolvedConfig{Provider: config.ProviderProfile{Name: "pinned", ProviderKind: config.ProviderKindAnthropic, Model: "m"}}
if _, err := buildProvider(resolved, deps); err != nil {
t.Fatalf("buildProvider: %v", err)
}
if got := os.Getenv(config.ActiveProviderEnv); got != "pinned" {
t.Fatalf("%s = %q after startup build, want %q (children would spawn on the stale provider)", config.ActiveProviderEnv, got, "pinned")
}
// A FAILED build must not move the pin: the process never committed to the
// new provider, so children must keep resolving whatever was in effect.
t.Setenv(config.ActiveProviderEnv, "still-current")
failing := appDeps{
exportActiveProvider: config.SetActiveProviderEnv,
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return nil, errors.New("boom")
},
}
if _, err := buildProvider(resolved, failing); err == nil {
t.Fatal("expected build error")
}
if got := os.Getenv(config.ActiveProviderEnv); got != "still-current" {
t.Fatalf("%s = %q after failed build, want it untouched", config.ActiveProviderEnv, got)
}
// No provider profile at all → no provider, no pin change.
if _, err := buildProvider(config.ResolvedConfig{}, deps); err != nil {
t.Fatalf("buildProvider(empty): %v", err)
}
if got := os.Getenv(config.ActiveProviderEnv); got != "still-current" {
t.Fatalf("%s = %q after profile-less build, want it untouched", config.ActiveProviderEnv, got)
}
}
// A mid-run --allow-escalation model switch previously rebuilt the provider
// from the PURE resolved profile (the stored key lives only in buildProvider's
// local copy), so the escalated provider was keyless and the next completion
// call 401'd mid-run. Every profile handed to newProvider on this path must
// carry the stored key.
func TestRunExecEscalationSwitchKeepsStoredKey(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")
}
configPath := seedStoredProviderKey(t, "echo", "sk-stored-escalation")
var builtProfiles []config.ProviderProfile
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec",
"--allow-escalation",
"--model", "claude-haiku-4.5",
"--init-session-id", "escalation_stored_key_run",
"escalate please",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
userConfigPath: func() (string, error) { return configPath, 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.Provider.APIKeyStored = true // key lives ONLY in the credstore
cfg.MaxTurns = 3
return cfg, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
builtProfiles = append(builtProfiles, profile)
return &usageEmittingEscalatingProvider{escalate: len(builtProfiles) == 1}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want success; stderr=%s", exitCode, stderr.String())
}
if len(builtProfiles) < 2 {
t.Fatalf("want at least 2 provider builds (initial + escalated), got %d", len(builtProfiles))
}
for i, profile := range builtProfiles {
if profile.APIKey != "sk-stored-escalation" {
t.Fatalf("provider build %d APIKey = %q, want the stored key (escalated build went out keyless)", i, profile.APIKey)
}
}
}
-3
View File
@@ -365,9 +365,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
var modelSwitcher func(context.Context, string) (agent.Provider, error)
if options.allowEscalation {
modelSwitcher = func(_ context.Context, modelID string) (agent.Provider, error) {
// deps.newProvider is wrapped (fillAppDeps) to apply the stored key, so
// the escalated provider is authenticated even though resolved.Provider
// is the pure profile — no per-site key handling here.
switchedProfile := resolved.Provider
switchedProfile.Model = modelID
switchedProvider, err := deps.newProvider(switchedProfile)
-86
View File
@@ -24,7 +24,6 @@ func TestProviderHasOAuthLogin(t *testing.T) {
func TestSetupRequiredRecognizesOAuthLogin(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json")
t.Setenv("ZERO_OAUTH_STORAGE", "file") // an inherited "keyring" would ignore the temp path and hit the OS keychain
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
@@ -46,88 +45,3 @@ func TestSetupRequiredRecognizesOAuthLogin(t *testing.T) {
t.Fatal("a keyless provider with no OAuth login must require onboarding")
}
}
// A profile renamed away from its catalog ID must still get a runtime OAuth
// resolver when the login is stored under the catalog name — and a CASE-VARIANT
// profile name must not drop the catalog-ID candidate (the store is
// case-sensitive; a case-insensitive dedupe would swallow it).
func TestOAuthResolverForProfileFallsBackToCatalogID(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json")
t.Setenv("ZERO_OAUTH_STORAGE", "file") // an inherited "keyring" would ignore the temp path and hit the OS keychain
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("seed token: %v", err)
}
// A renamed profile resolves the catalog-ID login, and the returned key MUST be
// that login's key — the same key the Codex account-header resolver reads from,
// so bearer and account can never come from different logins.
if resolver, key := oauthLoginForProfile(config.ProviderProfile{Name: "codex", CatalogID: "chatgpt"}); resolver == nil {
t.Fatal("renamed profile with a catalog-ID login must get a resolver (unauthenticated-children regression)")
} else if key != oauth.ProviderKey("chatgpt") {
t.Fatalf("bound key = %q, want the chatgpt login key (bearer/account must share it)", key)
}
// Name differs from catalog ID only in CASE: the exact-case store key is
// "provider:chatgpt", so "chatgpt" must survive as a distinct candidate.
if resolver, key := oauthLoginForProfile(config.ProviderProfile{Name: "ChatGPT", CatalogID: "chatgpt"}); resolver == nil {
t.Fatal("case-variant profile name must still resolve the catalog-ID login (case-insensitive dedupe regression)")
} else if key != oauth.ProviderKey("chatgpt") {
t.Fatalf("case-variant bound key = %q, want the chatgpt login key", key)
}
if resolver, _ := oauthLoginForProfile(config.ProviderProfile{Name: "chatgpt", CatalogID: "chatgpt"}); resolver == nil {
t.Fatal("catalog-named profile must get a resolver")
}
if resolver, key := oauthLoginForProfile(config.ProviderProfile{Name: "openai", CatalogID: "openai"}); resolver != nil || key != "" {
t.Fatalf("a profile with no stored login must get no resolver and an empty key, got key=%q", key)
}
}
// The catalog-ID fallback must NOT reach a sibling profile's OAuth login when
// this profile has its own key — withBearer would erase the key and silently
// bill the model call under the sibling's account (a real cross-profile
// override the fallback introduced). A profile WITH its own resolved key gets no
// resolver even when a catalog-shared login exists.
func TestOAuthResolverForProfileDoesNotOverrideExplicitKey(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json")
t.Setenv("ZERO_OAUTH_STORAGE", "file") // an inherited "keyring" would ignore the temp path and hit the OS keychain
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
// A personal login stored under the catalog name.
if err := store.Save(oauth.ProviderKey("anthropic"), oauth.Token{AccessToken: "personal-tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("seed token: %v", err)
}
// Work profile: same catalog, its OWN resolved API key, deliberately NOT the
// personal OAuth account. It must keep using its key (no resolver attached).
work := config.ProviderProfile{Name: "anthropic-work", CatalogID: "anthropic", APIKey: "sk-work-key"}
if resolver, key := oauthLoginForProfile(work); resolver != nil || key != "" {
t.Fatalf("a profile with its own API key must not get a catalog-shared OAuth resolver (cross-profile override), got key=%q", key)
}
// A raw auth-header credential must be protected the same way.
header := config.ProviderProfile{Name: "anthropic-hdr", CatalogID: "anthropic", AuthHeaderValue: "Bearer byo"}
if resolver, _ := oauthLoginForProfile(header); resolver != nil {
t.Fatal("a profile with its own auth header must not get a catalog-shared OAuth resolver")
}
// A same-NAME login must not override an explicit key either (the name
// candidate is gated on the profile being keyless, not just the catalog one).
if err := store.Save(oauth.ProviderKey("anthropic-work"), oauth.Token{AccessToken: "name-tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("seed name token: %v", err)
}
if resolver, _ := oauthLoginForProfile(work); resolver != nil {
t.Fatal("a profile with its own API key must not get an OAuth resolver even from a same-name login")
}
// Sanity: a keyless sibling on the same catalog DOES resolve the shared login.
keyless := config.ProviderProfile{Name: "anthropic-oauth", CatalogID: "anthropic"}
if resolver, key := oauthLoginForProfile(keyless); resolver == nil {
t.Fatal("a keyless profile should still resolve the catalog-shared login")
} else if key != oauth.ProviderKey("anthropic") {
t.Fatalf("keyless sibling bound key = %q, want the catalog login key", key)
}
}
+26 -32
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/Gitlawb/zero/internal/config"
@@ -11,48 +12,42 @@ import (
"github.com/Gitlawb/zero/internal/providers/providerio"
)
// oauthLoginForProfile resolves the user's OAuth login for a provider ONCE and
// returns both a TokenResolver that authenticates model calls with it and the
// credential-store key it bound to. It returns (nil, "") when no login exists —
// keeping API-key users free of any per-request store lookups, since the resolver
// is only attached when a login is present at construction time.
//
// The returned key is the single source of truth for "which login is this
// provider using": callers pass it to providers.Options.OAuthLoginKey so the
// Codex chatgpt-account-id header reads its account from the exact login that
// issued the bearer token, instead of doing a second, independent lookup that
// could select a different login (a backend-rejected mismatch).
//
// Candidate login names (profile name, then a catalog-ID fallback, both gated on
// the profile having no own configured credential) come from the shared
// ProviderProfile.OAuthLoginCandidates so the runtime resolver, the Codex account
// resolver, and the onboarding presence check never diverge.
func oauthLoginForProfile(profile config.ProviderProfile) (providerio.TokenResolver, string) {
candidates := profile.OAuthLoginCandidates()
if len(candidates) == 0 {
return nil, ""
// providerOAuthName resolves the OAuth login name for a provider profile. It is
// the name a user logs in under (`zero auth login <name>`) whose token then
// authenticates this provider's model calls. For now it is the profile name;
// the provider presets map well-known kinds (openai/anthropic) to canonical
// names on top of this.
func providerOAuthName(profile config.ProviderProfile) string {
return strings.TrimSpace(profile.Name)
}
// oauthResolverForProfile returns a TokenResolver that authenticates a provider's
// model calls with the user's OAuth login, or nil when no login exists for this
// provider. Returning nil for the no-login case keeps API-key users free of any
// per-request store lookups: the resolver is only attached once a login is
// present at construction time.
func oauthResolverForProfile(profile config.ProviderProfile) providerio.TokenResolver {
name := providerOAuthName(profile)
if name == "" {
return nil
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return nil, ""
return nil
}
_, key, ok := oauth.FirstStored(store, candidates)
if !ok {
// No login under any candidate (or unreadable/invalid keys) → API-key
// auth, no resolver.
return nil, ""
key := oauth.ProviderKey(name)
if _, ok, loadErr := store.Load(key); loadErr != nil || !ok {
// No login (or an unreadable/invalid key) → API-key auth, no resolver.
return nil
}
manager, err := oauth.NewManager(oauth.ManagerOptions{
Store: store,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
// Refreshing a token the user logged into (possibly a preset provider like
// xAI) re-resolves that provider's OAuth config, which needs the preset.
AllowPresets: true,
})
if err != nil {
return nil, ""
return nil
}
resolver := func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
return func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
var token string
var rerr error
if forceRefresh {
@@ -69,5 +64,4 @@ func oauthLoginForProfile(profile config.ProviderProfile) (providerio.TokenResol
}
return "Authorization", "Bearer " + token, true, nil
}
return resolver, key
}
+1 -1
View File
@@ -488,7 +488,7 @@ func validateProviderRuntimeReady(profile config.ProviderProfile) error {
}
func providerProfileHasCredential(profile config.ProviderProfile) bool {
return profile.HasConfiguredCredential()
return strings.TrimSpace(profile.APIKey) != "" || profile.APIKeyStored || strings.TrimSpace(profile.AuthHeaderValue) != ""
}
func providerKindForDescriptor(descriptor providercatalog.Descriptor) config.ProviderKind {
+3 -7
View File
@@ -314,14 +314,10 @@ func setupRequired(resolved config.ResolvedConfig) bool {
}
// providerHasOAuthLogin reports whether a stored OAuth login exists for the
// provider. It uses the same ProviderProfile.OAuthLoginCandidates the runtime
// resolver does, so the "authenticated in the UI" gate and the "authenticated at
// runtime" resolver can never diverge. This is only consulted for a profile with
// no usable API-key credential (setupRequired returns early otherwise), where
// the candidate set is permissive (profile name + catalog ID).
// provider, keyed by its profile name or catalog id.
func providerHasOAuthLogin(profile config.ProviderProfile, oauthLogins map[string]bool) bool {
for _, name := range profile.OAuthLoginCandidates() {
if oauthLogins[name] {
for _, name := range []string{strings.TrimSpace(profile.Name), strings.TrimSpace(profile.CatalogID)} {
if name != "" && oauthLogins[name] {
return true
}
}
+1 -3
View File
@@ -8,8 +8,6 @@ import (
"runtime"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
)
func TestRunSpecialistListShowAndPath(t *testing.T) {
@@ -328,7 +326,7 @@ func setSpecialistConfigRoot(t *testing.T) string {
default:
t.Setenv("XDG_CONFIG_HOME", root)
}
configRoot, err := config.UserConfigDir()
configRoot, err := os.UserConfigDir()
if err != nil {
t.Fatalf("UserConfigDir() error = %v", err)
}
@@ -1,49 +0,0 @@
package config
import (
"os"
"testing"
)
// SetActiveProviderEnv must export the provider name so a child process (whose
// resolution reads the inherited environment via the nil-map -> os.Getenv path in
// applyEnv) resolves the SAME active provider. A blank name CLEARS it.
func TestSetActiveProviderEnvRoundTrips(t *testing.T) {
t.Setenv(ActiveProviderEnv, "") // register cleanup; restores the original after
if err := os.Unsetenv(ActiveProviderEnv); err != nil {
t.Fatalf("unset: %v", err)
}
SetActiveProviderEnv("") // blank with nothing set stays unset
if got := os.Getenv(ActiveProviderEnv); got != "" {
t.Fatalf("empty name set the env to %q, want unset", got)
}
// Regression: switching from a named provider back to an unnamed/default profile
// must CLEAR a previously exported value, not leave it stale for children.
if err := os.Setenv(ActiveProviderEnv, "stale-provider"); err != nil {
t.Fatalf("seed stale: %v", err)
}
SetActiveProviderEnv("") // blank must unset the stale value
if got := os.Getenv(ActiveProviderEnv); got != "" {
t.Fatalf("blank name left stale env %q, want cleared", got)
}
cfgStale := &FileConfig{}
applyEnv(cfgStale, nil)
if cfgStale.ActiveProvider != "" {
t.Fatalf("applyEnv still resolved stale provider %q after clear", cfgStale.ActiveProvider)
}
SetActiveProviderEnv("ollama-cloud")
if got := os.Getenv(ActiveProviderEnv); got != "ollama-cloud" {
t.Fatalf("env = %q, want ollama-cloud", got)
}
// applyEnv with a nil map is the exact path a child uses: it falls back to
// os.Getenv, so the inherited value selects the active provider.
cfg := &FileConfig{}
applyEnv(cfg, nil)
if cfg.ActiveProvider != "ollama-cloud" {
t.Fatalf("applyEnv ActiveProvider = %q, want ollama-cloud (inherited env not read)", cfg.ActiveProvider)
}
}
-57
View File
@@ -155,63 +155,6 @@ func MigratePlaintextProviderKeys(path string, store APIKeySetter) (int, error)
return migrated, nil
}
// HasConfiguredCredential reports whether the profile is set up to authenticate
// with its own API key (inline, stored, or a raw auth header). It is the single
// definition of "this profile is key-authed", shared by OAuthLoginCandidates and
// the cli/tui setup surfaces so they never disagree about whether a profile is
// keyed. APIKeyEnv is deliberately NOT included: an env var may be unset at
// runtime while the profile actually relies on an OAuth login, so an
// env-configured-but-empty profile must still be allowed to resolve a token.
func (profile ProviderProfile) HasConfiguredCredential() bool {
return strings.TrimSpace(profile.APIKey) != "" ||
profile.APIKeyStored ||
strings.TrimSpace(profile.AuthHeaderValue) != ""
}
// OAuthLoginCandidates returns the login names to try, in order, when resolving
// this profile's OAuth token — used by the runtime bearer resolver, the Codex
// account-header resolver, and the onboarding presence check so all three agree.
//
// It returns NO candidates for a profile that carries its own configured
// credential: attaching an OAuth resolver to such a profile makes withBearer
// erase the key the instant a token resolves, silently routing/billing the call
// through the OAuth account instead of the intended key (the cross-profile
// override the review caught — and, via the profile name, a same-name login
// overriding an explicit key). A stored-key profile that reaches this keyless
// (e.g. a transiently unreadable keyring) is likewise treated as key-auth, so it
// fails with a clear missing-credential error rather than silently borrowing an
// unrelated OAuth login. APIKeyEnv is intentionally not part of that gate (an
// env var may be unset while the profile relies on OAuth).
//
// For a keyless profile the profile name comes first (a login under your own
// profile name is unambiguously yours), then the catalog ID as a FALLBACK (so a
// profile renamed away from its catalog ID — e.g. {name:"codex",
// catalogID:"chatgpt"} — still finds a `zero auth login chatgpt` token).
// Candidates are trimmed, blank-skipped, and de-duplicated CASE-SENSITIVELY — the
// OAuth token store is a case-sensitive map, so "ChatGPT" and "chatgpt" are
// distinct keys and both must survive.
func (profile ProviderProfile) OAuthLoginCandidates() []string {
if profile.HasConfiguredCredential() {
return nil
}
var names []string
add := func(s string) {
s = strings.TrimSpace(s)
if s == "" {
return
}
for _, existing := range names {
if existing == s { // case-sensitive: the store lookup is case-sensitive
return
}
}
names = append(names, s)
}
add(profile.Name)
add(profile.CatalogID)
return names
}
// ApplyStoredAPIKey fills profile.APIKey from the credential store when it is not
// already resolved — an inline config key or a resolved APIKeyEnv always wins, so
// this only supplies a key for providers whose secret lives in the store. A nil
-66
View File
@@ -172,69 +172,3 @@ func TestProviderProfileAPIKeyStoredRoundTrips(t *testing.T) {
t.Fatalf("no inline key expected, got %q", p.APIKey)
}
}
// OAuthLoginCandidates always offers the profile name, adds the catalog ID as a
// fallback ONLY when the profile has no effective own credential, and dedupes
// case-sensitively (the OAuth store is a case-sensitive map).
func TestOAuthLoginCandidates(t *testing.T) {
cases := []struct {
name string
profile ProviderProfile
want []string
}{
{
name: "renamed keyless profile falls back to catalog id",
profile: ProviderProfile{Name: "codex", CatalogID: "chatgpt"},
want: []string{"codex", "chatgpt"},
},
{
name: "case-variant name keeps distinct catalog-id candidate",
profile: ProviderProfile{Name: "ChatGPT", CatalogID: "chatgpt"},
want: []string{"ChatGPT", "chatgpt"},
},
{
name: "exact-duplicate name and catalog id collapse",
profile: ProviderProfile{Name: "chatgpt", CatalogID: "chatgpt"},
want: []string{"chatgpt"},
},
{
// A configured key must block ALL candidates (name included): a login
// under the profile's own name would otherwise erase the key too.
name: "own inline key blocks every candidate",
profile: ProviderProfile{Name: "anthropic-work", CatalogID: "anthropic", APIKey: "sk-work"},
want: nil,
},
{
name: "own auth header blocks every candidate",
profile: ProviderProfile{Name: "acme", CatalogID: "anthropic", AuthHeaderValue: "Bearer x"},
want: nil,
},
{
// APIKeyStored means key-auth even if the key isn't loaded here (e.g. a
// transiently unreadable keyring): don't silently borrow an OAuth login.
name: "stored-key profile blocks every candidate",
profile: ProviderProfile{Name: "openai-stored", CatalogID: "openai", APIKeyStored: true},
want: nil,
},
{
// APIKeyEnv is NOT a configured credential for gating: the env var may be
// unset while the profile relies on an OAuth login, so candidates stand.
name: "env-only profile still yields candidates",
profile: ProviderProfile{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
want: []string{"xai"},
},
{
name: "empty profile yields no candidates",
profile: ProviderProfile{},
want: nil,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := c.profile.OAuthLoginCandidates()
if strings.Join(got, ",") != strings.Join(c.want, ",") {
t.Fatalf("OAuthLoginCandidates() = %#v, want %#v", got, c.want)
}
})
}
}
-56
View File
@@ -1,56 +0,0 @@
package config
import (
"os"
"testing"
)
// SetMaxTurnsEnv must export the per-run turn budget so a child process (whose
// resolution reads the inherited environment via the nil-map -> os.Getenv path in
// applyEnv) runs with the SAME budget the user set via /turns. n <= 0 is a no-op,
// and a non-numeric/zero env value must not clobber the configured budget.
func TestSetMaxTurnsEnvRoundTrips(t *testing.T) {
t.Setenv(MaxTurnsEnv, "") // register cleanup; restores the original after
if err := os.Unsetenv(MaxTurnsEnv); err != nil {
t.Fatalf("unset: %v", err)
}
SetMaxTurnsEnv(0) // non-positive must not set anything
if got := os.Getenv(MaxTurnsEnv); got != "" {
t.Fatalf("n<=0 set the env to %q, want unset", got)
}
SetMaxTurnsEnv(150)
if got := os.Getenv(MaxTurnsEnv); got != "150" {
t.Fatalf("env = %q, want 150", got)
}
// applyEnv with a nil map is the exact path a child uses: it falls back to
// os.Getenv, so the inherited value sets the budget.
cfg := &FileConfig{MaxTurns: defaultMaxTurns}
applyEnv(cfg, nil)
if cfg.MaxTurns != 150 {
t.Fatalf("applyEnv MaxTurns = %d, want 150 (inherited env not read)", cfg.MaxTurns)
}
// A garbage env value must leave the configured budget untouched.
if err := os.Setenv(MaxTurnsEnv, "not-a-number"); err != nil {
t.Fatalf("set: %v", err)
}
cfg2 := &FileConfig{MaxTurns: defaultMaxTurns}
applyEnv(cfg2, nil)
if cfg2.MaxTurns != defaultMaxTurns {
t.Fatalf("garbage env clobbered MaxTurns to %d, want default %d", cfg2.MaxTurns, defaultMaxTurns)
}
// An over-ceiling env value (e.g. a raw shell export that bypasses the /turns
// clamp) must be clamped to MaxTurnsCeiling at the read site.
if err := os.Setenv(MaxTurnsEnv, "999999"); err != nil {
t.Fatalf("set: %v", err)
}
cfg3 := &FileConfig{MaxTurns: defaultMaxTurns}
applyEnv(cfg3, nil)
if cfg3.MaxTurns != MaxTurnsCeiling {
t.Fatalf("applyEnv MaxTurns = %d, want clamped to ceiling %d", cfg3.MaxTurns, MaxTurnsCeiling)
}
}
+1 -21
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
@@ -34,32 +33,13 @@ func DefaultResolveOptions(workspaceRoot string) (ResolveOptions, error) {
}
func DefaultUserConfigPath() (string, error) {
userConfigDir, err := UserConfigDir()
userConfigDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("resolve user config directory: %w", err)
}
return filepath.Join(userConfigDir, "zero", "config.json"), nil
}
// UserConfigDir returns the base directory Zero stores per-user config under.
// It mirrors os.UserConfigDir everywhere except macOS: there Go defaults to
// ~/Library/Application Support, but Zero deliberately uses ~/.config (XDG-style,
// matching Linux and Claude Code) so a single config path works cross-platform.
// $XDG_CONFIG_HOME still wins on macOS when set.
func UserConfigDir() (string, error) {
if runtime.GOOS == "darwin" {
if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" {
return xdg, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(home, ".config"), nil
}
return os.UserConfigDir()
}
func existingConfigFile(path string) (string, error) {
info, err := os.Stat(path)
if err != nil {
+1 -19
View File
@@ -105,7 +105,7 @@ func setUserConfigRoot(t *testing.T) string {
t.Setenv("XDG_CONFIG_HOME", root)
}
configRoot, err := UserConfigDir()
configRoot, err := os.UserConfigDir()
if err != nil {
t.Fatalf("UserConfigDir() error = %v", err)
}
@@ -130,21 +130,3 @@ func mkdirAll(t *testing.T, path string) {
t.Fatalf("create directory %s: %v", path, err)
}
}
func TestDefaultUserConfigPathUsesXDGConfigOnMacOS(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("macOS-specific config path behavior")
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", "")
path, err := DefaultUserConfigPath()
if err != nil {
t.Fatalf("DefaultUserConfigPath() error = %v", err)
}
want := filepath.Join(home, ".config", "zero", "config.json")
if path != want {
t.Fatalf("DefaultUserConfigPath() = %q, want %q", path, want)
}
}
+8 -114
View File
@@ -2,11 +2,9 @@ package config
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"github.com/Gitlawb/zero/internal/modelregistry"
@@ -15,40 +13,7 @@ import (
"github.com/Gitlawb/zero/internal/sandbox"
)
// ErrNoActiveProvider marks a resolve failure caused solely by a missing or
// unresolvable active provider. The interactive TUI treats this as "needs
// onboarding" (drop into the setup wizard) rather than a fatal config error.
var ErrNoActiveProvider = errors.New("no active provider configured")
// ErrProviderRequiresModel marks a resolve failure caused solely by the active
// provider missing a model with no catalog default to fall back on (custom
// openai-/anthropic-compatible endpoints — Zero cannot guess a gateway's model).
// Like ErrNoActiveProvider, the interactive TUI treats it as "needs onboarding"
// and drops into the setup wizard so the user can fix it; headless commands
// (zero config, zero exec) still fail with the actionable message.
var ErrProviderRequiresModel = errors.New("provider requires model")
// setupFixableError tags an error with a sentinel for errors.Is WITHOUT
// changing its message: the sentinel's own text must not prefix what the user
// sees (the wrapped message is already complete and actionable).
type setupFixableError struct {
err error
sentinel error
}
func (e *setupFixableError) Error() string { return e.err.Error() }
func (e *setupFixableError) Unwrap() []error { return []error{e.err, e.sentinel} }
// defaultMaxTurns is the per-run tool-turn budget when none is configured. 30 was
// too low for real multi-step agentic work (agents ran out mid-task before reaching
// later steps); 50 matches the old "deep" preset. Raise per-session with /turns.
const defaultMaxTurns = 50
// MaxTurnsCeiling caps the per-run tool-turn budget so a stray env value or typo
// can't set an absurd ceiling. Shared between applyEnv (read site) and the /turns
// command (write site) so the bound holds even if the env is set by a raw shell.
const MaxTurnsCeiling = 500
const defaultMaxTurns = 30
// defaultDeferThreshold is the number of deferred-eligible (MCP) tools at which
// Zero collapses their full JSON schemas into compact `tool_search` reminder
@@ -221,9 +186,6 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
if src.Preferences.Recaps != nil {
dst.Preferences.Recaps = src.Preferences.Recaps
}
if strings.TrimSpace(src.Preferences.Theme) != "" {
dst.Preferences.Theme = strings.TrimSpace(src.Preferences.Theme)
}
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
}
@@ -396,18 +358,6 @@ func effectiveProviderKind(profile ProviderProfile) ProviderKind {
return ""
}
// catalogDefaultModel returns the default model of a catalog provider ("" when
// the id is unknown), used to fill a missing profile.Model for the official-API
// kinds so a hand-written profile without a model resolves instead of bricking
// every command that resolves config up front (zero config, bare zero setup).
func catalogDefaultModel(catalogID string) string {
descriptor, ok := providercatalog.Get(catalogID)
if !ok {
return ""
}
return strings.TrimSpace(descriptor.DefaultModel)
}
func catalogDescriptorForProfile(profile ProviderProfile) (providercatalog.Descriptor, bool) {
catalogID := providercatalog.NormalizeID(profile.CatalogID)
if catalogID == "" {
@@ -481,53 +431,11 @@ func mergeProfile(base ProviderProfile, next ProviderProfile) ProviderProfile {
return base
}
// ActiveProviderEnv selects the active provider profile by name (read in applyEnv).
const ActiveProviderEnv = "ZERO_PROVIDER"
// SetActiveProviderEnv exports the active provider name to the process environment
// so a spawned child process (which inherits the environment) resolves the SAME
// provider profile — and therefore the same credentials (env key / stored key /
// OAuth) — as its parent. Without this a sub-agent re-resolves config.json's
// default provider and can land on one whose credentials don't match the parent's
// live selection, failing auth the instant it spawns. A blank name CLEARS the
// variable: switching back to an unnamed/default profile must not keep exporting a
// stale provider to children.
func SetActiveProviderEnv(name string) {
name = strings.TrimSpace(name)
if name == "" {
_ = os.Unsetenv(ActiveProviderEnv)
return
}
_ = os.Setenv(ActiveProviderEnv, name)
}
// MaxTurnsEnv overrides the per-run tool-turn budget by name (read in applyEnv).
const MaxTurnsEnv = "ZERO_MAX_TURNS"
// SetMaxTurnsEnv exports the per-run tool-turn budget to the process environment so
// a spawned child (sub-agent / swarm member, which inherits the environment) runs
// with the SAME budget the user set via /turns. Without it a child re-resolves
// config.json's default and a large delegated task can exhaust its turns mid-run
// (exit 4 / max-turns). No-op for n <= 0.
func SetMaxTurnsEnv(n int) {
if n > 0 {
_ = os.Setenv(MaxTurnsEnv, strconv.Itoa(n))
}
}
func applyEnv(cfg *FileConfig, env map[string]string) {
activeProvider := strings.TrimSpace(envValue(env, ActiveProviderEnv))
activeProvider := strings.TrimSpace(envValue(env, "ZERO_PROVIDER"))
if activeProvider != "" {
cfg.ActiveProvider = activeProvider
}
if maxTurns := strings.TrimSpace(envValue(env, MaxTurnsEnv)); maxTurns != "" {
if n, err := strconv.Atoi(maxTurns); err == nil && n > 0 {
if n > MaxTurnsCeiling {
n = MaxTurnsCeiling
}
cfg.MaxTurns = n
}
}
applyProviderEnv(cfg, ProviderKindOpenAI, envProfile{
Name: string(ProviderKindOpenAI),
@@ -769,16 +677,11 @@ func unionStrings(base []string, extra []string) []string {
}
type normalizeOptions struct {
// defaultModels fills a missing profile.Model for the official-API provider
// kinds (openai from the model registry; anthropic/google from their catalog
// descriptors). Off for provider-command configs (command.go), which must
// surface exactly what the external command returned rather than inventing
// a model behind its back.
defaultModels bool
defaultOpenAIModel bool
}
func normalizeProviders(providers []ProviderProfile, activeName string, envMaps ...map[string]string) ([]ProviderProfile, ProviderProfile, error) {
return normalizeProvidersWithOptions(providers, activeName, normalizeOptions{defaultModels: true}, envMaps...)
return normalizeProvidersWithOptions(providers, activeName, normalizeOptions{defaultOpenAIModel: true}, envMaps...)
}
func normalizeProvidersWithoutModelDefaults(providers []ProviderProfile, activeName string, envMaps ...map[string]string) ([]ProviderProfile, ProviderProfile, error) {
@@ -793,7 +696,7 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
}
if len(providers) == 0 {
if activeName != "" {
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
return nil, ProviderProfile{}, fmt.Errorf("active provider %q not found", activeName)
}
return []ProviderProfile{}, ProviderProfile{}, nil
}
@@ -825,13 +728,10 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
}
if !activeFound {
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
return nil, ProviderProfile{}, fmt.Errorf("active provider %q not found", activeName)
}
if active.Model == "" {
return nil, ProviderProfile{}, &setupFixableError{
err: providerError(active, "provider %s requires model — add \"model\" to its entry in config.json, or re-run: zero setup <catalog-id> --model <model>", active.Name),
sentinel: ErrProviderRequiresModel,
}
return nil, ProviderProfile{}, providerError(active, "provider %s requires model", active.Name)
}
return normalized, active, nil
@@ -878,7 +778,7 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
case ProviderKindOpenAI:
if profile.BaseURL == "" || isOfficialOpenAIBaseURL(profile.BaseURL) {
profile.BaseURL = OpenAIBaseURL
if options.defaultModels && profile.Model == "" {
if options.defaultOpenAIModel && profile.Model == "" {
profile.Model = modelregistry.DefaultModelID
}
return profile, nil
@@ -895,9 +795,6 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
case ProviderKindAnthropic:
if profile.BaseURL == "" || isOfficialAnthropicBaseURL(profile.BaseURL) {
profile.BaseURL = AnthropicBaseURL
if options.defaultModels && profile.Model == "" {
profile.Model = catalogDefaultModel("anthropic")
}
return profile, nil
}
return ProviderProfile{}, providerError(profile, "anthropic provider %s requires official baseURL %s", profile.Name, AnthropicBaseURL)
@@ -912,9 +809,6 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
case ProviderKindGoogle:
if profile.BaseURL == "" || isOfficialGoogleBaseURL(profile.BaseURL) {
profile.BaseURL = GoogleBaseURL
if options.defaultModels && profile.Model == "" {
profile.Model = catalogDefaultModel("google")
}
return profile, nil
}
return ProviderProfile{}, providerError(profile, "google provider %s requires official baseURL %s", profile.Name, GoogleBaseURL)
+2 -100
View File
@@ -1,7 +1,6 @@
package config
import (
"errors"
"os"
"path/filepath"
"reflect"
@@ -884,8 +883,8 @@ func TestResolveAllowsNoConfiguredProviders(t *testing.T) {
if HasProviderProfile(resolved.Provider) {
t.Fatalf("Provider = %#v, want zero value", resolved.Provider)
}
if resolved.MaxTurns != defaultMaxTurns {
t.Fatalf("MaxTurns = %d, want default %d", resolved.MaxTurns, defaultMaxTurns)
if resolved.MaxTurns != 30 {
t.Fatalf("MaxTurns = %d, want 30", resolved.MaxTurns)
}
}
@@ -899,9 +898,6 @@ func TestResolveRejectsActiveProviderWithoutConfiguredProfiles(t *testing.T) {
if !strings.Contains(err.Error(), `active provider "ghost" not found`) {
t.Fatalf("error = %q, want active provider missing message", err.Error())
}
if !errors.Is(err, ErrNoActiveProvider) {
t.Fatalf("error = %v, want errors.Is(err, ErrNoActiveProvider)", err)
}
if resolved.ActiveProvider != "" {
t.Fatalf("ActiveProvider = %q, want empty", resolved.ActiveProvider)
}
@@ -1555,97 +1551,3 @@ func TestResolveMaxTurnsZeroFallsBackToDefault(t *testing.T) {
t.Fatalf("MaxTurns = %d, want default %d", resolved.MaxTurns, defaultMaxTurns)
}
}
// The reported brick: a hand-written google profile with an apiKey but no
// model made EVERY resolving command fail ("provider google requires model"),
// including zero config and bare zero setup — the only commands that could
// have fixed it. Official-API kinds now fall back to their catalog default
// model, exactly like the openai kind always has.
func TestResolveDefaultsGoogleModelFromCatalog(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "google",
"providers": [{"name": "google", "provider_kind": "google", "apiKey": "AIza-x"}]
}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v, want model defaulted from the google catalog", err)
}
if resolved.Provider.Model != "gemini-2.5-pro" {
t.Fatalf("Model = %q, want the google catalog default gemini-2.5-pro", resolved.Provider.Model)
}
if resolved.Provider.BaseURL != GoogleBaseURL {
t.Fatalf("BaseURL = %q, want %q", resolved.Provider.BaseURL, GoogleBaseURL)
}
}
func TestResolveDefaultsAnthropicModelFromCatalog(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "anthropic",
"providers": [{"name": "anthropic", "provider_kind": "anthropic", "apiKey": "sk-ant-x"}]
}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v, want model defaulted from the anthropic catalog", err)
}
if resolved.Provider.Model != "claude-sonnet-4.5" {
t.Fatalf("Model = %q, want the anthropic catalog default claude-sonnet-4.5", resolved.Provider.Model)
}
}
// An explicitly configured model must always win over the catalog default.
func TestResolveKeepsExplicitGoogleModel(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "google",
"providers": [{"name": "google", "provider_kind": "google", "apiKey": "AIza-x", "model": "gemini-2.5-flash"}]
}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.Model != "gemini-2.5-flash" {
t.Fatalf("Model = %q, want the explicitly configured gemini-2.5-flash", resolved.Provider.Model)
}
}
// Provider-command configs must NOT get invented models: the without-defaults
// path surfaces exactly what the external command returned.
func TestNormalizeWithoutModelDefaultsStillRequiresGoogleModel(t *testing.T) {
_, _, err := normalizeProvidersWithoutModelDefaults(
[]ProviderProfile{{Name: "google", ProviderKind: ProviderKindGoogle, APIKey: "AIza-x"}},
"google", map[string]string{})
if err == nil {
t.Fatal("provider-command path must still require an explicit model")
}
if !strings.Contains(err.Error(), "requires model") {
t.Fatalf("error = %q, want requires-model", err.Error())
}
// The residual error must tell the user how to fix it.
if !strings.Contains(err.Error(), "config.json") {
t.Fatalf("error = %q, want an actionable config.json hint", err.Error())
}
}
// The residual requires-model failure (custom endpoint, no catalog default)
// must be tagged setup-fixable WITHOUT changing its message: the interactive
// TUI matches the sentinel to drop into the wizard, while headless commands
// print the exact actionable text.
func TestResolveRequiresModelErrorIsSetupFixable(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "gw",
"providers": [{"name": "gw", "provider_kind": "openai-compatible", "baseURL": "https://gw.example/v1", "apiKey": "sk-x"}]
}`)
_, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err == nil {
t.Fatal("expected requires-model error for a custom endpoint without model")
}
if !errors.Is(err, ErrProviderRequiresModel) {
t.Fatalf("error = %v, want errors.Is(err, ErrProviderRequiresModel)", err)
}
// The sentinel must NOT prefix the user-facing message.
if !strings.HasPrefix(err.Error(), "provider gw requires model") {
t.Fatalf("message = %q, want it to start with the provider error, not the sentinel text", err.Error())
}
if strings.Contains(err.Error(), "sk-x") {
t.Fatalf("error leaked API key: %q", err.Error())
}
}
-4
View File
@@ -97,10 +97,6 @@ type ToolsConfig struct {
type PreferencesConfig struct {
FavoriteModels []string `json:"favoriteModels,omitempty"`
// Theme is the persisted TUI palette preference — "auto" or a registered theme
// name (e.g. "dracula"). Applied at startup below the --theme flag and
// ZERO_THEME, so a /theme choice survives restart. Empty = unset (defaults auto).
Theme string `json:"theme,omitempty"`
// Recaps is a tri-state: nil (unset) defaults to ON; an explicit false means
// the user turned post-turn recaps off. A *bool is its own tri-state, so no
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
+1 -28
View File
@@ -66,13 +66,10 @@ func TestValidateFileRedactsSecretInIssue(t *testing.T) {
}
func TestValidateFileMissingModelWarns(t *testing.T) {
// An openai-compatible CUSTOM endpoint has no catalog default to fall back
// on, so a missing model is still a real issue — and the message must tell
// the user how to fix it.
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "provider_kind": "openai-compatible", "baseURL": "https://gateway.example/v1"}
{"name": "main", "provider_kind": "anthropic"}
]
}`)
@@ -83,30 +80,6 @@ func TestValidateFileMissingModelWarns(t *testing.T) {
if !strings.Contains(issues[0].Message, "requires model") {
t.Fatalf("expected requires-model issue, got %#v", issues)
}
if !strings.Contains(issues[0].Message, "config.json") {
t.Fatalf("requires-model issue should carry an actionable hint, got %#v", issues)
}
}
func TestValidateFileDefaultsOfficialKindModels(t *testing.T) {
// Official-API kinds (anthropic/google) fall back to their catalog default
// model, so a hand-written model-less profile validates clean instead of
// bricking zero config / bare zero setup — the only commands that could
// have fixed it (the reported google case).
path := writeValidateFixture(t, `{
"activeProvider": "google",
"providers": [
{"name": "google", "provider_kind": "google", "apiKey": "AIza-x"},
{"name": "anthropic", "provider_kind": "anthropic", "apiKey": "sk-ant-x"}
]
}`)
_, issues := ValidateFile(path)
for _, issue := range issues {
if strings.Contains(issue.Message, "requires model") {
t.Fatalf("official-kind profiles must default their model, got issue %#v", issue)
}
}
}
func TestValidateFileValidConfigHasNoIssues(t *testing.T) {
-22
View File
@@ -154,28 +154,6 @@ func SetRecapsEnabled(path string, enabled bool) (FileConfig, error) {
return cfg, nil
}
// SetTheme persists the TUI theme preference, mirroring SetFavoriteModels
// (read-modify-atomic-write). A blank theme clears the stored preference.
func SetTheme(path string, theme string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
cfg := FileConfig{}
if data, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
} else if !os.IsNotExist(err) {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg.Preferences.Theme = strings.TrimSpace(theme)
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
func normalizeFavoriteModels(models []string) []string {
seen := map[string]bool{}
favorites := make([]string, 0, len(models))
-33
View File
@@ -272,39 +272,6 @@ func TestSetFavoriteModelsPersistsUserPreferences(t *testing.T) {
}
}
func TestSetThemePersistsUserPreference(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "openai",
Providers: []ProviderProfile{
{Name: "openai", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"},
},
}, 0o600)
cfg, err := SetTheme(path, " dracula ")
if err != nil {
t.Fatalf("SetTheme() error = %v", err)
}
if cfg.Preferences.Theme != "dracula" {
t.Fatalf("Theme = %q, want dracula (trimmed)", cfg.Preferences.Theme)
}
persisted := readConfigFixture(t, path)
if persisted.Preferences.Theme != "dracula" {
t.Fatalf("persisted Theme = %q, want dracula", persisted.Preferences.Theme)
}
if persisted.ActiveProvider != "openai" || len(persisted.Providers) != 1 {
t.Fatalf("provider config was not preserved by SetTheme: %#v", persisted)
}
// A blank value clears the stored preference.
if cfg, err = SetTheme(path, ""); err != nil {
t.Fatalf("SetTheme(\"\") error = %v", err)
}
if cfg.Preferences.Theme != "" {
t.Fatalf("SetTheme(\"\") should clear the theme, got %q", cfg.Preferences.Theme)
}
}
func TestRecapsPreferenceRoundTrips(t *testing.T) {
// Default (unset) is ON.
if !(PreferencesConfig{}).RecapsEnabled() {
+21 -39
View File
@@ -1,11 +1,11 @@
package mcp
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
@@ -393,43 +393,28 @@ func TestConnectRejectsUnsupportedTransport(t *testing.T) {
}
func TestClientRequestWaitsForMatchingResponseID(t *testing.T) {
// Pipes, not a pre-filled bytes.Buffer: the fake server responds only AFTER
// reading the outgoing request, matching production ordering. With responses
// pre-loaded, the client's reader goroutine could consume the matching
// response — and hit EOF — before request() registered its pending id,
// flaking as "request() error = EOF" on fast runners.
serverReader, clientWriter := io.Pipe() // client → server
clientReader, serverWriter := io.Pipe() // server → client
t.Cleanup(func() {
_ = clientWriter.Close()
_ = serverWriter.Close()
})
serverErr := make(chan error, 1)
go func() {
requests := newMessageReader(serverReader)
request, err := requests.read()
if err != nil {
serverErr <- err
return
}
responses := newMessageWriter(serverWriter)
for _, message := range []rpcMessage{
{Method: "notifications/progress"},
{ID: 99, Error: &rpcError{Code: -32000, Message: "wrong response"}},
{ID: request.ID, Result: mustRaw(map[string]any{"value": "matched"})},
} {
if err := responses.write(message); err != nil {
serverErr <- err
return
}
}
serverErr <- nil
}()
var incoming bytes.Buffer
incomingWriter := newMessageWriter(&incoming)
if err := incomingWriter.write(rpcMessage{Method: "notifications/progress"}); err != nil {
t.Fatal(err)
}
if err := incomingWriter.write(rpcMessage{
ID: 99,
Error: &rpcError{Code: -32000, Message: "wrong response"},
}); err != nil {
t.Fatal(err)
}
if err := incomingWriter.write(rpcMessage{
ID: 1,
Result: mustRaw(map[string]any{"value": "matched"}),
}); err != nil {
t.Fatal(err)
}
var outgoing bytes.Buffer
client := &Client{
reader: newMessageReader(clientReader),
writer: newMessageWriter(clientWriter),
reader: newMessageReader(&incoming),
writer: newMessageWriter(&outgoing),
nextID: 1,
}
var result struct {
@@ -441,9 +426,6 @@ func TestClientRequestWaitsForMatchingResponseID(t *testing.T) {
if result.Value != "matched" {
t.Fatalf("result.Value = %q, want matched response", result.Value)
}
if err := <-serverErr; err != nil {
t.Fatalf("fake server error: %v", err)
}
}
type errorCloser struct {
+5 -16
View File
@@ -44,17 +44,10 @@ type Manager struct {
// ManagerOptions configures a Manager.
type ManagerOptions struct {
Store *Store
Registry *Registry
HTTPClient *http.Client
Env map[string]string
// AllowPresets opts this manager into the baked-in OAuth presets without the
// operator exporting ZERO_OAUTH_ALLOW_PRESETS — used by the interactive wizard
// and CLI login (and the runtime token refresh) for a provider the user chose
// to sign into, whose preset client identity is public (e.g. xAI). It layers the
// flag onto Env (or the process environment when Env is nil), preserving any
// ZERO_OAUTH_<NAME>_* overrides. Leave false for hermetic tests.
AllowPresets bool
Store *Store
Registry *Registry
HTTPClient *http.Client
Env map[string]string
Now func() time.Time
RefreshBuffer time.Duration
Out io.Writer
@@ -90,13 +83,9 @@ func NewManager(opts ManagerOptions) (*Manager, error) {
if open == nil {
open = func(string) error { return nil }
}
env := opts.Env
if opts.AllowPresets {
env = envWithPresetsAllowed(env)
}
return &Manager{
store: opts.Store, registry: registry, client: client,
env: env, now: now, buffer: buffer, out: out, openBrowser: open,
env: opts.Env, now: now, buffer: buffer, out: out, openBrowser: open,
}, nil
}
-24
View File
@@ -52,30 +52,6 @@ func managerFor(t *testing.T, env map[string]string, openBrowser func(string) er
return m
}
// AllowPresets layers the opt-in onto the manager's env so a login/refresh for a
// preset provider resolves; without it (and no Env) the env stays nil so callers
// and tests remain hermetic.
func TestNewManagerAllowPresetsForcesOptIn(t *testing.T) {
store, err := NewStore(StoreOptions{FilePath: filepath.Join(t.TempDir(), "tok.json")})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
m, err := NewManager(ManagerOptions{Store: store, AllowPresets: true})
if err != nil {
t.Fatalf("NewManager: %v", err)
}
if m.env["ZERO_OAUTH_ALLOW_PRESETS"] != "1" {
t.Fatalf("AllowPresets should force the opt-in; got env[%q]=%q", "ZERO_OAUTH_ALLOW_PRESETS", m.env["ZERO_OAUTH_ALLOW_PRESETS"])
}
m2, err := NewManager(ManagerOptions{Store: store})
if err != nil {
t.Fatalf("NewManager: %v", err)
}
if m2.env != nil {
t.Fatalf("env should stay nil without AllowPresets, got %v", m2.env)
}
}
func TestManagerLoginLoopback(t *testing.T) {
fp := newFakeProvider(t, `{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
env := map[string]string{
+1 -28
View File
@@ -1,33 +1,6 @@
package oauth
import (
"os"
"strings"
)
// envWithPresetsAllowed returns an env map that opts into the baked-in presets
// (as if ZERO_OAUTH_ALLOW_PRESETS=1 were exported) so an interactive wizard/CLI
// login for a well-known public client (e.g. xAI) can use the preset without the
// operator setting the flag themselves. It copies base — or snapshots the process
// environment when base is nil — because envValue treats a non-nil map as hermetic
// (a missing key does NOT fall back to os.Getenv), so a partial map would silently
// drop the operator's ZERO_OAUTH_<NAME>_* overrides. The flag is then forced on.
func envWithPresetsAllowed(base map[string]string) map[string]string {
env := make(map[string]string, len(base)+1)
if base == nil {
for _, kv := range os.Environ() {
if eq := strings.IndexByte(kv, '='); eq > 0 {
env[kv[:eq]] = kv[eq+1:]
}
}
} else {
for k, v := range base {
env[k] = v
}
}
env["ZERO_OAUTH_ALLOW_PRESETS"] = "1"
return env
}
import "strings"
// providerPreset is a baked-in default OAuth configuration for a well-known
// provider. Every field is overridable per provider via ZERO_OAUTH_<NAME>_*
-40
View File
@@ -46,46 +46,6 @@ func TestResolveConfigUsesXAIPreset(t *testing.T) {
}
}
// envWithPresetsAllowed lets a caller (the wizard / CLI login / runtime refresh)
// opt into the preset without exporting ZERO_OAUTH_ALLOW_PRESETS: an otherwise-inert
// xAI config now resolves from the baked-in preset.
func TestEnvWithPresetsAllowedEnablesPreset(t *testing.T) {
r := NewRegistry()
// Baseline: a hermetic empty map keeps the preset inert.
if _, _, err := r.ResolveConfig("xai", map[string]string{}); err == nil {
t.Fatal("xai should not resolve without the opt-in")
}
// The helper forces the opt-in so the preset resolves.
cfg, _, err := r.ResolveConfig("xai", envWithPresetsAllowed(map[string]string{}))
if err != nil {
t.Fatalf("envWithPresetsAllowed should enable the xai preset: %v", err)
}
if cfg.ClientID != "b1a00492-073a-47ea-816f-4c329264a828" {
t.Fatalf("client_id = %q", cfg.ClientID)
}
}
// The helper copies the base map (never mutating it) and keeps ZERO_OAUTH_<NAME>_*
// overrides — critical because envValue treats a non-nil map as hermetic, so a
// partial map would silently drop them.
func TestEnvWithPresetsAllowedPreservesOverrides(t *testing.T) {
base := map[string]string{"ZERO_OAUTH_XAI_CLIENT_ID": "custom-id"}
env := envWithPresetsAllowed(base)
if env["ZERO_OAUTH_ALLOW_PRESETS"] != "1" {
t.Fatalf("opt-in flag not set: %v", env)
}
if _, mutated := base["ZERO_OAUTH_ALLOW_PRESETS"]; mutated {
t.Fatal("envWithPresetsAllowed mutated the caller's base map")
}
cfg, _, err := NewRegistry().ResolveConfig("xai", env)
if err != nil {
t.Fatalf("ResolveConfig with override env: %v", err)
}
if cfg.ClientID != "custom-id" {
t.Fatalf("override dropped through the helper: client_id = %q", cfg.ClientID)
}
}
func TestResolveConfigEnvOverridesPreset(t *testing.T) {
r := NewRegistry()
env := map[string]string{
-20
View File
@@ -41,26 +41,6 @@ func ValidateKey(key string) error {
// ProviderKey builds the store key for a provider login.
func ProviderKey(name string) string { return KeyPrefixProvider + name }
// FirstStored returns the token and its ProviderKey for the FIRST candidate name
// that has a token in the store, with ok=false when none do. Callers pass
// ProviderProfile.OAuthLoginCandidates() so that everything derived from a login
// — the bearer token AND any header claim like chatgpt-account-id — comes from
// the SAME login; selecting independently per consumer could otherwise pair a
// bearer from one login with an account header from another. A load error on a
// candidate is treated as a miss (skip to the next), never a hard failure.
func FirstStored(store *Store, candidates []string) (Token, string, bool) {
if store == nil {
return Token{}, "", false
}
for _, name := range candidates {
key := ProviderKey(name)
if token, ok, err := store.Load(key); err == nil && ok {
return token, key, true
}
}
return Token{}, "", false
}
// Status is a redaction-safe summary of a stored token (no secret material).
type Status struct {
Key string `json:"key"`
@@ -85,104 +85,6 @@ func Discover(ctx context.Context, profile config.ProviderProfile, options Optio
}
}
// DiscoverOllamaContextWindow asks a local Ollama daemon for a model's context
// length via its native /api/show endpoint. The generic /v1/models probe
// (parseModelsResponse) only ever returns id/description — OpenAI-compatible
// listings don't carry context-window metadata, and a custom/local model tag
// (e.g. a user's own Ollama pull, including a ":cloud"-tagged model proxied
// through the local daemon) has no curated-catalog entry to borrow one from
// either, so modelContextWindow has nothing to show for it without this. Only
// meaningful for the local Ollama provider (baseURL like
// http://localhost:11434/v1) — Ollama Cloud's hosted API is a different
// service and isn't assumed to expose the same endpoint.
func DiscoverOllamaContextWindow(ctx context.Context, baseURL string, model string, options Options) (int, error) {
model = strings.TrimSpace(model)
if model == "" {
return 0, fmt.Errorf("model name is required")
}
endpoint, err := ollamaShowEndpoint(baseURL)
if err != nil {
return 0, err
}
payload, err := json.Marshal(struct {
Model string `json:"model"`
}{Model: model})
if err != nil {
return 0, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(payload)))
if err != nil {
return 0, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
client := options.HTTPClient
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
response, err := client.Do(request)
if err != nil {
return 0, err
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return 0, err
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return 0, fmt.Errorf("ollama show endpoint returned %s: %s", response.Status, strings.TrimSpace(string(body)))
}
return parseOllamaShowContextWindow(body)
}
// ollamaShowEndpoint derives the native Ollama API root from the
// OpenAI-compatible base URL Zero stores for the provider (".../v1") and
// appends /api/show.
func ollamaShowEndpoint(baseURL string) (string, error) {
baseURL = strings.TrimSpace(baseURL)
if baseURL == "" {
return "", fmt.Errorf("provider base URL is required for ollama model discovery")
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("invalid provider base URL %q", baseURL)
}
path := strings.TrimRight(parsed.Path, "/")
path = strings.TrimSuffix(path, "/v1")
parsed.Path = strings.TrimRight(path, "/") + "/api/show"
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
}
// parseOllamaShowContextWindow extracts the context length from an
// /api/show response. Ollama reports it under model_info with an
// architecture-prefixed key (e.g. "llama.context_length",
// "qwen2.context_length") — the prefix varies by model family, so this scans
// for any key ending in ".context_length" rather than hardcoding one.
func parseOllamaShowContextWindow(body []byte) (int, error) {
// model_info mixes value types (strings like "general.architecture", numbers
// like "*.context_length"), so decode values generically and only try to
// read a number out of the one key we actually care about.
var payload struct {
ModelInfo map[string]any `json:"model_info"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return 0, fmt.Errorf("decode ollama show response: %w", err)
}
for key, value := range payload.ModelInfo {
if !strings.HasSuffix(key, ".context_length") {
continue
}
if window, ok := value.(float64); ok && window > 0 {
return int(window), nil
}
}
return 0, fmt.Errorf("ollama show response did not report a context length")
}
func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, options Options) ([]Model, error) {
endpoint, err := modelsEndpoint(profile.BaseURL)
if err != nil {
@@ -2,7 +2,6 @@ package providermodeldiscovery
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -268,61 +267,3 @@ func modelIDs(models []Model) []string {
}
return ids
}
// TestDiscoverOllamaContextWindowFetchesFromNativeShowEndpoint: the generic
// /v1/models probe never carries context-window metadata (parseModelsResponse
// only extracts id/description), so a custom/local Ollama model tag with no
// curated-catalog match has no other source for it. This exercises the
// Ollama-native /api/show fallback that fills that gap.
func TestDiscoverOllamaContextWindowFetchesFromNativeShowEndpoint(t *testing.T) {
var gotPath, gotMethod, gotBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotMethod = r.Method
body, _ := io.ReadAll(r.Body)
gotBody = string(body)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"model_info": {
"general.architecture": "qwen2",
"qwen2.context_length": 131072
}
}`))
}))
defer server.Close()
window, err := DiscoverOllamaContextWindow(context.Background(), server.URL+"/v1", "kimi-k2.7-code:cloud", Options{HTTPClient: server.Client()})
if err != nil {
t.Fatalf("DiscoverOllamaContextWindow returned error: %v", err)
}
if window != 131072 {
t.Fatalf("context window = %d, want 131072", window)
}
if gotPath != "/api/show" {
t.Fatalf("requested path = %q, want /api/show (not under /v1)", gotPath)
}
if gotMethod != http.MethodPost {
t.Fatalf("method = %q, want POST", gotMethod)
}
if !strings.Contains(gotBody, `"kimi-k2.7-code:cloud"`) {
t.Fatalf("request body = %q, want it to name the model", gotBody)
}
}
func TestDiscoverOllamaContextWindowRequiresModelName(t *testing.T) {
if _, err := DiscoverOllamaContextWindow(context.Background(), "http://localhost:11434/v1", "", Options{}); err == nil {
t.Fatal("expected an error for an empty model name")
}
}
func TestDiscoverOllamaContextWindowErrorsWhenShowOmitsContextLength(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"model_info": {"general.architecture": "qwen2"}}`))
}))
defer server.Close()
if _, err := DiscoverOllamaContextWindow(context.Background(), server.URL+"/v1", "some-model", Options{HTTPClient: server.Client()}); err == nil {
t.Fatal("expected an error when no *.context_length key is present")
}
}
+13 -31
View File
@@ -26,13 +26,6 @@ type Options struct {
// OAuth bearer token (preferred over the API key). nil => API-key auth only.
// Applied to the OpenAI and Anthropic providers.
OAuthResolver providerio.TokenResolver
// OAuthLoginKey is the credential-store key OAuthResolver bound to (empty when
// there is no OAuth login). It is the SAME selection the bearer resolver made,
// resolved ONCE by the caller so the Codex chatgpt-account-id header reads its
// account from the exact login that issued the bearer token — never a second,
// independent lookup that could pick a different login (a backend-rejected
// mismatch).
OAuthLoginKey string
}
// New creates a runtime provider for a resolved provider profile.
@@ -258,22 +251,14 @@ func isCodexCatalog(profile config.ProviderProfile, _ resolvedProfile) bool {
// newCodexProvider builds a Codex-flavored openai provider for the chatgpt
// catalog. The Codex headers (`originator`, `chatgpt-account-id`) are
// injected by the CodexProvider wrapper. The `chatgpt-account-id` is read from
// the stored OAuth token's Account field on every request so a refresh that
// rotates the token (and its account claim) takes effect immediately — a static
// AccountID captured at construction would go stale on the first refresh.
//
// The *login* the header reads from is options.OAuthLoginKey — the SAME key the
// bearer resolver bound, resolved ONCE by the caller. The bearer resolver
// refreshes the token in place under that key; reading the account from the same
// key keeps header and token on one login for the provider's life. Resolving the
// key independently here (a second oauth.FirstStored) could, after a transient
// per-candidate load error or a mid-session `zero auth login`, pick a different
// login than the bearer — a mismatch the backend rejects.
// injected by the CodexProvider wrapper. The `chatgpt-account-id` is
// resolved dynamically from the stored OAuth token's Account field on
// every request so a refresh that rotates the token (and its account
// claim) takes effect immediately — a static AccountID captured at
// construction would go stale on the first refresh.
func newCodexProvider(profile config.ProviderProfile, resolved resolvedProfile, options Options) (zeroruntime.Provider, error) {
accountKey := options.OAuthLoginKey
resolver := openai.CodexAccountResolver(func(ctx context.Context) (string, bool, error) {
account := codexAccountForKey(accountKey)
account := codexAccountFromStore(profile.Name)
if account == "" {
return "", false, nil
}
@@ -303,20 +288,17 @@ func newCodexProvider(profile config.ProviderProfile, resolved resolvedProfile,
})
}
// codexAccountForKey reads the chatgpt_account_id from the token currently
// stored under key. Called per-request (via the resolver) so a refresh that
// rotates the token — saved back in place under the same key — takes effect
// immediately without restarting the agent. Returns "" when key is empty (no
// OAuth login), or the token is absent or carries no account claim.
func codexAccountForKey(key string) string {
if key == "" {
return ""
}
// codexAccountFromStore reads the chatgpt_account_id from the stored OAuth
// token for the given provider name. It is called per-request (via the
// resolver) so a refresh that rotates the token takes effect immediately
// without restarting the agent. Returns "" when no token is stored (the
// Codex provider then omits the header and the user sees a clear 401).
func codexAccountFromStore(providerName string) string {
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return ""
}
token, ok, err := store.Load(key)
token, ok, err := store.Load(oauth.ProviderKey(providerName))
if err != nil || !ok {
return ""
}
+12 -64
View File
@@ -269,7 +269,6 @@ func TestNewRoutesChatGPTCatalogToCodexProvider(t *testing.T) {
// OAuth token and the "want empty chatgpt-account-id" assertion fails locally
// (it still passes in CI, where no login is stored). Mirrors the isolation in
// TestNewRoutesChatGPTCatalogWithStoredAccountID.
t.Setenv("ZERO_OAUTH_STORAGE", "file")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", t.TempDir()+"/tokens.json")
transport := &captureTransport{
@@ -323,10 +322,11 @@ func TestNewRoutesChatGPTCatalogToCodexProvider(t *testing.T) {
func TestNewRoutesChatGPTCatalogWithStoredAccountID(t *testing.T) {
// The factory reads the stored OAuth token's Account field for the
// chatgpt-account-id header, from the login key the CALLER supplies in
// Options.OAuthLoginKey (the same key the bearer resolver bound). Seed a
// token in an isolated temp store, then pass that key.
store, err := newOAuthStoreForTest(t)
// chatgpt-account-id header. Seed a token in a temp store and point
// ZERO_OAUTH_TOKENS_PATH at it so the factory picks it up.
dir := t.TempDir()
t.Setenv("ZERO_OAUTH_TOKENS_PATH", dir+"/tokens.json")
store, err := newOAuthStoreForTest()
if err != nil {
t.Fatalf("NewStore: %v", err)
}
@@ -344,9 +344,8 @@ func TestNewRoutesChatGPTCatalogWithStoredAccountID(t *testing.T) {
CatalogID: "chatgpt",
Model: "gpt-5",
}, Options{
HTTPClient: &http.Client{Transport: transport},
UserAgent: "zero-factory-test",
OAuthLoginKey: oauth.ProviderKey("chatgpt"),
HTTPClient: &http.Client{Transport: transport},
UserAgent: "zero-factory-test",
})
if err != nil {
t.Fatalf("New() error = %v", err)
@@ -408,61 +407,10 @@ func (transport *captureTransport) body() io.Reader {
return strings.NewReader(transport.requestBody)
}
// newOAuthStoreForTest pins the OAuth token store to a plain temp FILE and
// returns a Store on it. Pinning ZERO_OAUTH_STORAGE matters as much as the
// path: an inherited "keyring" value would send NewStore to the OS keychain
// and ignore ZERO_OAUTH_TOKENS_PATH entirely, making the test read/write the
// developer's real logins. Exists so the chatgpt factory tests can seed a
// token without copying the path-handling dance from internal/cli.
func newOAuthStoreForTest(t *testing.T) (*oauth.Store, error) {
t.Helper()
t.Setenv("ZERO_OAUTH_STORAGE", "file")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", t.TempDir()+"/tokens.json")
// newOAuthStoreForTest builds a Store pointed at the current
// ZERO_OAUTH_TOKENS_PATH (set by the caller). It exists so the chatgpt
// factory tests can seed a token without copying the path-handling dance
// from internal/cli.
func newOAuthStoreForTest() (*oauth.Store, error) {
return oauth.NewStore(oauth.StoreOptions{})
}
// codexAccountForKey reads the chatgpt-account-id from the token stored under a
// FIXED key — the key the caller (cli.oauthLoginForProfile) already bound for the
// bearer token, passed through providers.Options.OAuthLoginKey. The account is
// therefore always read from the same login that issued the bearer (no second,
// independent selection). It re-reads per call, so an in-place token refresh (new
// account claim under the SAME key) is picked up; an empty key (no OAuth login)
// or a missing/account-less token yields "".
func TestCodexAccountForKey(t *testing.T) {
store, err := newOAuthStoreForTest(t)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "tok-1", Account: "acc-catalog-7"}); err != nil {
t.Fatalf("Save chatgpt: %v", err)
}
// A different login WITHOUT an account claim, to prove the read stays on the
// requested key and never crosses to another login's account.
if err := store.Save(oauth.ProviderKey("codex"), oauth.Token{AccessToken: "tok-codex"}); err != nil {
t.Fatalf("Save codex: %v", err)
}
if got := codexAccountForKey(oauth.ProviderKey("chatgpt")); got != "acc-catalog-7" {
t.Fatalf("account = %q, want acc-catalog-7", got)
}
// The bound key's token has no account → "", NOT the other login's account.
if got := codexAccountForKey(oauth.ProviderKey("codex")); got != "" {
t.Fatalf("account = %q, want empty (must not cross to another login's account)", got)
}
// Empty key (no OAuth login) → header omitted.
if got := codexAccountForKey(""); got != "" {
t.Fatalf("account for empty key = %q, want empty", got)
}
// Unknown key → "".
if got := codexAccountForKey(oauth.ProviderKey("nope")); got != "" {
t.Fatalf("account for unknown key = %q, want empty", got)
}
// An in-place refresh under the bound key IS reflected per request.
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "tok-refreshed", Account: "acc-rotated"}); err != nil {
t.Fatalf("refresh chatgpt: %v", err)
}
if got := codexAccountForKey(oauth.ProviderKey("chatgpt")); got != "acc-rotated" {
t.Fatalf("account = %q, want acc-rotated (in-place refresh must be picked up)", got)
}
}
+1 -66
View File
@@ -342,7 +342,7 @@ func (provider *Provider) geminiRequest(request zeroruntime.CompletionRequest) (
declarations = append(declarations, geminiFunctionDeclaration{
Name: tool.Name,
Description: tool.Description,
Parameters: sanitizeGeminiSchema(tool.Parameters),
Parameters: tool.Parameters,
})
}
mapped.Tools = []geminiToolGroup{{FunctionDeclarations: declarations}}
@@ -350,71 +350,6 @@ func (provider *Provider) geminiRequest(request zeroruntime.CompletionRequest) (
return mapped, nil
}
// geminiSchemaFields is the subset of JSON Schema keywords Google's
// functionDeclarations[].parameters accepts (the Generative AI Schema type).
// Anything outside it — most notably OpenAI's `additionalProperties`, which Zero
// emits on every tool's parameters and which Gemini 400s on ("Unknown name
// \"additionalProperties\" … Cannot find field") — must be dropped before the
// request goes out. Kept as an allowlist rather than a denylist so a schema
// keyword Zero (or an MCP server) adds later can't silently leak an unsupported
// field into the Gemini payload.
var geminiSchemaFields = map[string]bool{
"type": true, "format": true, "title": true, "description": true,
"nullable": true, "enum": true, "items": true, "properties": true,
"required": true, "anyOf": true, "propertyOrdering": true, "default": true,
"minimum": true, "maximum": true, "minItems": true, "maxItems": true,
"minLength": true, "maxLength": true, "minProperties": true,
"maxProperties": true, "pattern": true, "example": true,
}
// sanitizeGeminiSchema returns a copy of a JSON-Schema map keeping only the
// keywords Gemini supports (geminiSchemaFields), recursing through the nested
// schemas under `properties`, `items`, and `anyOf`. Returns nil for a nil input
// so a parameterless tool stays parameterless.
func sanitizeGeminiSchema(schema map[string]any) map[string]any {
if schema == nil {
return nil
}
out := make(map[string]any, len(schema))
for key, value := range schema {
if !geminiSchemaFields[key] {
continue
}
switch key {
case "properties":
if props, ok := value.(map[string]any); ok {
cleaned := make(map[string]any, len(props))
for name, sub := range props {
if subMap, ok := sub.(map[string]any); ok {
cleaned[name] = sanitizeGeminiSchema(subMap)
} else {
cleaned[name] = sub
}
}
value = cleaned
}
case "items":
if subMap, ok := value.(map[string]any); ok {
value = sanitizeGeminiSchema(subMap)
}
case "anyOf":
if variants, ok := value.([]any); ok {
cleaned := make([]any, len(variants))
for i, variant := range variants {
if subMap, ok := variant.(map[string]any); ok {
cleaned[i] = sanitizeGeminiSchema(subMap)
} else {
cleaned[i] = variant
}
}
value = cleaned
}
}
out[key] = value
}
return out
}
func mapMessages(messages []zeroruntime.Message) (*geminiContent, []geminiContent, error) {
systemParts := []geminiPart{}
contents := []geminiContent{}
-127
View File
@@ -3,7 +3,6 @@ package gemini
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
@@ -505,129 +504,3 @@ func eventsOfType(events []zeroruntime.StreamEvent, eventType zeroruntime.Stream
}
return matching
}
// TestSanitizeGeminiSchemaStripsUnsupportedFields: the sanitizer keeps only
// Gemini-supported keywords and recurses into properties/items/anyOf, so no
// OpenAI-ism (additionalProperties, $schema, patternProperties) survives at any
// depth while legitimate fields (type/description/enum/required/default) stay.
func TestSanitizeGeminiSchemaStripsUnsupportedFields(t *testing.T) {
in := map[string]any{
"type": "object",
"additionalProperties": false,
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": []string{"path"},
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "a path",
"default": ".",
},
"nested": map[string]any{
"type": "object",
"additionalProperties": false, // must be stripped at depth too
"properties": map[string]any{
"tags": map[string]any{
"type": "array",
"items": map[string]any{"type": "string", "additionalProperties": true},
},
},
},
},
}
out := sanitizeGeminiSchema(in)
assertNoAdditionalProps(t, out, "root")
if out["additionalProperties"] != nil {
t.Fatal("top-level additionalProperties must be stripped")
}
if out["$schema"] != nil {
t.Fatal("$schema must be stripped")
}
// Legitimate fields survive.
if out["type"] != "object" {
t.Fatalf("type should survive, got %v", out["type"])
}
props := out["properties"].(map[string]any)
pathProp := props["path"].(map[string]any)
if pathProp["description"] != "a path" || pathProp["default"] != "." {
t.Fatalf("legitimate property fields must survive: %#v", pathProp)
}
// nil input stays nil (parameterless tool).
if sanitizeGeminiSchema(nil) != nil {
t.Fatal("nil schema should map to nil")
}
}
// assertNoAdditionalProps fails if additionalProperties appears anywhere in the
// (recursively walked) schema.
func assertNoAdditionalProps(t *testing.T, node any, path string) {
t.Helper()
switch v := node.(type) {
case map[string]any:
if _, ok := v["additionalProperties"]; ok {
t.Fatalf("additionalProperties leaked at %s", path)
}
for k, sub := range v {
assertNoAdditionalProps(t, sub, path+"."+k)
}
case []any:
for i, sub := range v {
assertNoAdditionalProps(t, sub, fmt.Sprintf("%s[%d]", path, i))
}
}
}
// TestGeminiRequestOmitsAdditionalPropertiesInToolSchema: end-to-end, the tool
// parameters Zero emits (schemaToRuntimeMap always writes additionalProperties)
// must not reach Gemini — otherwise every functionDeclaration is 400-rejected
// ("Unknown name additionalProperties"), which broke all tool-using exec calls
// against Google (issue #373).
func TestGeminiRequestOmitsAdditionalPropertiesInToolSchema(t *testing.T) {
var gotBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSE(w, `{"usageMetadata":{"promptTokenCount":1}}`)
}))
defer server.Close()
provider, err := New(Options{APIKey: "k", BaseURL: server.URL + "/", Model: "models/gemini-2.5-flash"})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
Tools: []zeroruntime.ToolDefinition{{
Name: "grep",
Description: "search",
Parameters: map[string]any{ // exactly what schemaToRuntimeMap produces
"type": "object",
"additionalProperties": false,
"required": []string{"pattern"},
"properties": map[string]any{
"pattern": map[string]any{"type": "string", "description": "regex"},
},
},
}},
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
tools := gotBody["tools"].([]any)
decls := tools[0].(map[string]any)["functionDeclarations"].([]any)
params := decls[0].(map[string]any)["parameters"].(map[string]any)
if _, ok := params["additionalProperties"]; ok {
t.Fatalf("additionalProperties must not be sent to Gemini: %#v", params)
}
// The tool is still usable: its real schema survived.
if params["type"] != "object" {
t.Fatalf("tool parameters type should survive, got %v", params["type"])
}
props := params["properties"].(map[string]any)
if _, ok := props["pattern"]; !ok {
t.Fatalf("tool property must survive sanitization: %#v", props)
}
}
+4 -5
View File
@@ -102,11 +102,10 @@ func New(options Options) (*Provider, error) {
endpoint = baseURL + "/chat/completions"
}
// Route through providerio.HTTPClient so a missing client gets the shared,
// stall-hardened transport (bounded response-header wait + shorter idle-conn
// reuse) rather than the raw http.DefaultClient, which hangs on a reused-dead
// pooled connection (the macOS-only "both providers stall" bug).
httpClient := providerio.HTTPClient(options.HTTPClient)
httpClient := options.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}
maxTokens := options.MaxTokens
if maxTokens < 0 {
+14 -81
View File
@@ -6,12 +6,10 @@ import (
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"os"
"regexp"
"runtime"
"strconv"
"strings"
"time"
@@ -27,9 +25,9 @@ var ErrStreamIdle = errors.New("idle timeout (upstream stopped sending data)")
// ErrStreamStalled reports that a streaming upstream kept the connection alive
// (SSE keep-alives reset the idle watchdog, so it never fired) but produced no
// actual output for ContentStallTimeout(idle). Without this an upstream that
// heartbeats-but-stalls — observed on chatgpt/gpt-5.x and ollama reasoning
// models — would hang the agent indefinitely.
// actual output for streamContentStallFactor × the idle timeout. Without this an
// upstream that heartbeats-but-stalls — observed on chatgpt/gpt-5.x and ollama
// reasoning models — would hang the agent indefinitely.
var ErrStreamStalled = errors.New("stream stalled (upstream kept the connection alive but produced no output)")
// StreamTimeoutMessage returns the human-readable detail for a stream-timeout
@@ -40,7 +38,7 @@ var ErrStreamStalled = errors.New("stream stalled (upstream kept the connection
// stuck or very slow.
func StreamTimeoutMessage(err error, idleTimeout time.Duration) string {
if errors.Is(err, ErrStreamStalled) {
return fmt.Sprintf("no output for %s (the model kept the connection alive but produced nothing — it may be stuck; try a faster model or lower reasoning effort)", ContentStallTimeout(idleTimeout))
return fmt.Sprintf("no output for %s (the model kept the connection alive but produced nothing — it may be stuck; try a faster model or lower reasoning effort)", idleTimeout*streamContentStallFactor)
}
return fmt.Sprintf("idle timeout after %s (upstream stopped sending data)", idleTimeout)
}
@@ -55,32 +53,13 @@ func StreamTimeoutMessage(err error, idleTimeout time.Duration) string {
// Override globally with ZERO_STREAM_IDLE_TIMEOUT.
const DefaultStreamIdleTimeout = 5 * time.Minute
// ContentStallTimeout bounds a heartbeat-but-no-output stream: keep-alives reset
// the idle watchdog (a heartbeating upstream is not "dead"), but if NO real data
// line arrives for this long the stream is aborted (ErrStreamStalled). Scaled
// above idleTimeout (so a slow-but-producing request that emits data between
// heartbeats is never killed — the watchdog only ever fires when NOTHING real
// arrives), but only 1.2× rather than the old 2×: a genuine heartbeat-pause
// stall on chatgpt/gpt-5.x rarely recovers, so a ~10-minute dead wait (at the 5m
// idle default) was a terrible UX for a doomed turn. At the default idle this is
// 6 minutes; it still scales with ZERO_STREAM_IDLE_TIMEOUT. A returned value <= 0
// (idle watchdog disabled) leaves the content watchdog off too.
func ContentStallTimeout(idleTimeout time.Duration) time.Duration {
if idleTimeout <= 0 {
return 0
}
// 1.2× computed as idle + idle/5 (not idle*6/5), plus a clamp: a
// pathologically large ZERO_STREAM_IDLE_TIMEOUT could make idle*6 overflow
// int64 and wrap to a NEGATIVE duration, which would arm the content timer
// to fire immediately and abort every stream. Clamping to the max duration
// on overflow just means "effectively no content watchdog" — the sane
// result for an absurd idle timeout.
extra := idleTimeout / 5
if idleTimeout > math.MaxInt64-extra {
return math.MaxInt64
}
return idleTimeout + extra
}
// streamContentStallFactor bounds a heartbeat-but-no-output stream. Keep-alives
// reset the idle watchdog (a heartbeating upstream is not "dead"), but if NO real
// data line arrives for streamContentStallFactor × the idle timeout, the stream is
// treated as stalled and aborted (ErrStreamStalled). It is > 1 so a slow-but-
// producing request — one that emits data between heartbeats — is never killed; the
// content watchdog only catches a stream that heartbeats while producing nothing.
const streamContentStallFactor = 2
// streamIdleTimeoutEnv is the global override for the stream idle timeout. It
// accepts a Go duration ("5m", "300s", "90s") or a bare number of seconds
@@ -126,58 +105,12 @@ func NormalizeBaseURL(baseURL string, defaultBaseURL string, label string) (stri
return baseURL, nil
}
// sharedHTTPClient is the process-wide client used when a provider supplies none.
// It tunes the default transport to defeat the stale-pooled-connection hang: Go
// keeps idle keep-alive connections in a pool, and a later request can reuse one
// the server/NAT has silently dropped. Because the model call is a POST (non-
// idempotent), Go will NOT auto-retry it on a fresh connection — so it blocks
// forever waiting for a response that never arrives. This is provider-agnostic
// (every provider shares the default transport), which is why it reproduced on
// BOTH chatgpt and ollama, and it surfaces on macOS far more than Linux because
// macOS keeps dead pooled connections around longer.
//
// - ResponseHeaderTimeout bounds the gap between finishing the request and the
// first response header, so a reused-dead connection fails fast (then the
// reconnect path re-dials) instead of hanging. It does NOT bound streaming
// after the 200 header arrives, so slow first tokens / long reasoning are
// unaffected (the stream-idle + content watchdogs cover that).
// - IdleConnTimeout is shortened so a connection that went idle across a pause
// is closed (and re-dialed fresh) rather than reused after it has gone stale.
// - DisableKeepAlives on darwin only: ResponseHeaderTimeout/IdleConnTimeout
// above catch a reused connection that's fully dead (never responds) or
// idle past the timeout, but not one that's alive-but-severely-degraded —
// e.g. reused shortly after a prior request on the same host (well within
// 30s, common across quick retries of the same turn), where it still
// delivers real bytes, just at a crippled rate, resetting Zero's stream
// idle/content-stall watchdogs (which only fire on true silence) without
// ever recovering. That degraded-not-dead case is indistinguishable from
// genuine backend slowness from inside the stream, so the only reliable
// fix is removing pooling from the equation entirely on the one platform
// where this class of bug has actually reproduced. A fresh TCP+TLS
// handshake per request costs low tens of milliseconds — negligible next
// to the minutes-long stalls this avoids — and this doesn't touch
// Linux/Windows, where the underlying OS doesn't keep dead/degraded
// pooled connections around as long.
var sharedHTTPClient = func() *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
// 120s, not 60s: a slow cloud proxy (e.g. ollama `*:cloud`) can withhold its
// 200 response header until the upstream model emits a first token, so a 60s cap
// risked aborting a legitimately-slow-but-alive request. 120s still bounds a
// truly dead reused connection (which never responds) while tolerating slow
// header delivery; slow first tokens after the header are covered by the idle +
// content-stall watchdogs.
transport.ResponseHeaderTimeout = 120 * time.Second
transport.IdleConnTimeout = 30 * time.Second
transport.DisableKeepAlives = runtime.GOOS == "darwin"
return &http.Client{Transport: transport}
}()
// HTTPClient returns the configured client or the shared, stall-hardened default.
// HTTPClient returns the configured client or the process default.
func HTTPClient(client *http.Client) *http.Client {
if client != nil {
return client
}
return sharedHTTPClient
return http.DefaultClient
}
// SendEvent writes a provider event without blocking cancellation cleanup.
@@ -322,7 +255,7 @@ func ScanSSEDataWithContext(
// Content watchdog: only real data lines reset it (keep-alives do not), so a
// stream that heartbeats without producing output is bounded instead of
// hanging forever.
contentTimeout := ContentStallTimeout(idleTimeout)
contentTimeout := idleTimeout * streamContentStallFactor
content := time.NewTimer(contentTimeout)
defer content.Stop()
contentC = content.C
@@ -4,9 +4,6 @@ import (
"context"
"errors"
"io"
"math"
"net/http"
"runtime"
"strings"
"testing"
"time"
@@ -205,36 +202,11 @@ func TestUpstreamUnreachable(t *testing.T) {
}
}
// ContentStallTimeout is 1.2× the idle timeout, disabled (0) when idle is
// disabled, and must never overflow to a negative duration for an absurdly
// large idle timeout (which would arm the content timer to fire immediately
// and abort every stream).
func TestContentStallTimeout(t *testing.T) {
cases := []struct {
idle time.Duration
want time.Duration
}{
{5 * time.Minute, 6 * time.Minute},
{30 * time.Second, 36 * time.Second},
{100 * time.Millisecond, 120 * time.Millisecond},
{0, 0},
{-1, 0},
}
for _, c := range cases {
if got := ContentStallTimeout(c.idle); got != c.want {
t.Fatalf("ContentStallTimeout(%v) = %v, want %v", c.idle, got, c.want)
}
}
// Overflow guard: idle*6 would wrap negative; the clamp keeps it positive.
if got := ContentStallTimeout(math.MaxInt64); got <= 0 {
t.Fatalf("ContentStallTimeout(MaxInt64) = %v, must stay positive (no overflow wrap)", got)
}
}
// A heartbeating-but-output-less upstream must not hang forever: SSE keep-alives
// feed the idle watchdog (so it never fires), but the content watchdog
// (ContentStallTimeout(idle)) aborts with ErrStreamStalled when no real data
// line arrives. This is the gpt-5.x / ollama "still generating forever" hang.
// (idleTimeout × streamContentStallFactor) aborts with ErrStreamStalled when no
// real data line arrives. This is the gpt-5.x / ollama "still generating forever"
// hang.
func TestScanSSEDataWithContextAbortsOnContentStall(t *testing.T) {
pr, pw := io.Pipe()
defer func() { _ = pw.Close() }()
@@ -262,8 +234,7 @@ func TestScanSSEDataWithContextAbortsOnContentStall(t *testing.T) {
done := make(chan error, 1)
go func() {
// idle 100ms → content stall at 120ms (ContentStallTimeout = idle*1.2).
// Keep-alives reset idle but not content.
// idle 100ms → content stall at 200ms. Keep-alives reset idle but not content.
done <- ScanSSEDataWithContext(context.Background(), cancel, pr, 100*time.Millisecond, func(string) bool { return true })
}()
@@ -286,7 +257,7 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
pr, pw := io.Pipe()
go func() {
// One data line every 30ms for ~300ms (well past the 120ms content window,
// One data line every 30ms for ~300ms (well past the 200ms content window,
// with comfortable slack under the 100ms idle timeout), so the content
// watchdog keeps resetting, then close cleanly.
for i := 0; i < 10; i++ {
@@ -318,56 +289,18 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
}
// StreamTimeoutMessage must give a stalled stream a distinct, accurate detail —
// it reports the content window (ContentStallTimeout) and must NOT claim the
// upstream "stopped sending data" (keep-alives were still arriving).
// it reports the content window (idle × factor) and must NOT claim the upstream
// "stopped sending data" (keep-alives were still arriving).
func TestStreamTimeoutMessage(t *testing.T) {
idle := 5 * time.Minute
if msg := StreamTimeoutMessage(ErrStreamIdle, idle); !strings.Contains(msg, "idle timeout after 5m") {
t.Fatalf("idle message = %q, want it to mention the 5m idle timeout", msg)
}
stalled := StreamTimeoutMessage(ErrStreamStalled, idle)
if !strings.Contains(stalled, "no output for 6m") {
t.Fatalf("stalled message = %q, want it to report the 6m content window (idle 5m × 1.2)", stalled)
if !strings.Contains(stalled, "no output for 10m") {
t.Fatalf("stalled message = %q, want it to report the 10m content window", stalled)
}
if strings.Contains(stalled, "stopped sending data") {
t.Fatalf("stalled message must not claim the upstream stopped sending data: %q", stalled)
}
}
// HTTPClient(nil) must return the shared, stall-hardened transport (bounded
// response-header wait + shorter idle-conn reuse) that defeats the macOS stale-
// pooled-connection hang; an explicit client is returned untouched.
func TestHTTPClientReturnsStallHardenedSharedClient(t *testing.T) {
got := HTTPClient(nil)
if got == nil {
t.Fatal("HTTPClient(nil) returned nil")
}
tr, ok := got.Transport.(*http.Transport)
if !ok {
t.Fatalf("transport type = %T, want *http.Transport", got.Transport)
}
if tr.ResponseHeaderTimeout != 120*time.Second {
t.Fatalf("ResponseHeaderTimeout = %v, want 120s", tr.ResponseHeaderTimeout)
}
if tr.IdleConnTimeout != 30*time.Second {
t.Fatalf("IdleConnTimeout = %v, want 30s", tr.IdleConnTimeout)
}
// DisableKeepAlives is the stronger mitigation scoped to darwin only: the
// two timeouts above catch a reused connection that's fully dead or idle
// too long, but not one that's alive-and-degraded (still delivers real
// bytes, just at a crippled rate) — which resets Zero's stream watchdogs
// without ever recovering. Removing pooling from the equation entirely is
// only worth its reconnect cost on the platform this class of bug has
// actually reproduced on.
wantDisableKeepAlives := runtime.GOOS == "darwin"
if tr.DisableKeepAlives != wantDisableKeepAlives {
t.Fatalf("DisableKeepAlives = %v, want %v (GOOS=%s)", tr.DisableKeepAlives, wantDisableKeepAlives, runtime.GOOS)
}
if HTTPClient(nil) != got {
t.Fatal("HTTPClient(nil) must return a shared instance so the conn pool is reused")
}
custom := &http.Client{}
if HTTPClient(custom) != custom {
t.Fatal("an explicit client must be returned unchanged")
}
}
+5 -39
View File
@@ -204,11 +204,7 @@ type ChildRunResult struct {
Events []streamjson.Event
Stderr string
ExitCode int
// Signal is a human-readable description (e.g. "signal: killed") when the child
// was terminated by a signal rather than exiting normally; empty otherwise. It
// turns an opaque exit -1 into an actionable reason (SIGKILL ~ out of memory).
Signal string
Started bool
Started bool
}
func (executor Executor) Run(ctx context.Context, params TaskParameters, options TaskRunOptions) (ExecResult, error) {
@@ -570,35 +566,17 @@ func (executor Executor) runBuiltArgs(ctx context.Context, built BuildArgsResult
exitCode := run.exitCodeOr(-1)
summary := SummarizeStream(run.Events, exitCode)
executor.recordSpecialistStop(accounting, summary, "error", summary.ExitCode, err, false)
// Carry the child session id even on a post-start failure so a caller (the
// swarm launcher -> FailWithSession) can still make the failed member
// drillable; the session exists once the child has started.
return ExecResult{SessionID: built.SessionID}, err
return ExecResult{}, err
}
summary := SummarizeStream(run.Events, run.ExitCode)
rolledUp := executor.rollUpSpecialistUsage(accounting, summary)
executor.recordSpecialistStop(accounting, summary, summary.Status, summary.ExitCode, nil, rolledUp)
return ExecResult{
Result: BuildFinalResult(run.Events, run.Stderr, run.ExitCode, run.Signal),
Result: BuildFinalResult(run.Events, run.Stderr, run.ExitCode),
SessionID: built.SessionID,
}, nil
}
// availableSpecialistList renders the registered specialist names for a corrective
// "not found" error, so a model that guessed a wrong name learns the real options.
func availableSpecialistList(result LoadResult) string {
names := make([]string, 0, len(result.Specialists))
for _, manifest := range result.Specialists {
if n := strings.TrimSpace(manifest.Metadata.Name); n != "" {
names = append(names, n)
}
}
if len(names) == 0 {
return "(none registered)"
}
return strings.Join(names, ", ")
}
func (executor Executor) loadManifest(name string) (Manifest, error) {
name = strings.TrimSpace(name)
if name == "" {
@@ -614,12 +592,7 @@ func (executor Executor) loadManifest(name string) (Manifest, error) {
}
manifest, ok := Find(result, name)
if !ok {
// Corrective error: a model that invents a specialist name (e.g.
// "validator-runner", "file-writer") otherwise gets an opaque "not found"
// and keeps retrying made-up names. List the ACTUALLY-registered ones so it
// self-corrects — no hardcoded names, since a custom registry may not have
// worker/explorer/code-review.
return Manifest{}, fmt.Errorf("specialist %q not found. Available: %s. Pick one of these whose tools fit the task, or omit the name to use the default", name, availableSpecialistList(result))
return Manifest{}, fmt.Errorf("specialist %q not found", name)
}
return manifest, nil
}
@@ -810,22 +783,15 @@ func runChildProcess(ctx context.Context, binaryPath string, args []string, prog
}
exitCode := 0
started := true
signalDesc := ""
if err := command.Wait(); err != nil {
var exitErr *osexec.ExitError
if errors.As(err, &exitErr) {
exitCode = exitErr.ExitCode()
if exitCode < 0 && exitErr.ProcessState != nil {
// ExitCode() is -1 when the child was terminated by a signal rather
// than exiting. ProcessState.String() is the portable description
// (e.g. "signal: killed") — capture it so the failure isn't opaque.
signalDesc = exitErr.ProcessState.String()
}
} else {
return ChildRunResult{Events: events, Stderr: stderr.String(), ExitCode: -1, Started: started}, fmt.Errorf("run specialist child: %w", err)
}
}
return ChildRunResult{Events: events, Stderr: stderr.String(), ExitCode: exitCode, Signal: signalDesc, Started: started}, nil
return ChildRunResult{Events: events, Stderr: stderr.String(), ExitCode: exitCode, Started: started}, nil
}
func (run ChildRunResult) exitCodeOr(defaultExitCode int) int {
@@ -1,32 +0,0 @@
package specialist
import (
"strings"
"testing"
)
// A model that invents a specialist name (e.g. "validator-runner", "file-writer")
// must get a corrective error listing the real specialists, so it self-corrects to a
// capable one instead of looping on made-up names that spawn doomed sub-agents.
func TestLoadManifestUnknownNameListsAvailable(t *testing.T) {
executor := Executor{
Load: func(LoadOptions) (LoadResult, error) {
return LoadResult{Specialists: []Manifest{
{Metadata: Metadata{Name: "worker"}},
{Metadata: Metadata{Name: "explorer"}},
{Metadata: Metadata{Name: "code-review"}},
}}, nil
},
}
_, err := executor.loadManifest("validator-runner")
if err == nil {
t.Fatal("an unknown specialist name must error")
}
msg := err.Error()
for _, want := range []string{"validator-runner", "not found", "worker", "explorer", "code-review"} {
if !strings.Contains(msg, want) {
t.Fatalf("error %q missing %q (must steer the model to a real specialist)", msg, want)
}
}
}
+1 -2
View File
@@ -11,7 +11,6 @@ import (
"strings"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
)
@@ -121,7 +120,7 @@ var knownToolNames = map[string]bool{
}
func DefaultPaths(workspaceRoot string) (Paths, error) {
userConfigDir, err := config.UserConfigDir()
userConfigDir, err := os.UserConfigDir()
if err != nil {
return Paths{}, fmt.Errorf("resolve user config directory: %w", err)
}
-6
View File
@@ -260,12 +260,6 @@ func formatTaskOutputSummary(task background.Task, summary StreamResult, rawLine
if !task.CompletedAt.IsZero() {
fmt.Fprintf(&builder, "completed_at: %s\n", task.CompletedAt.Format(time.RFC3339))
fmt.Fprintf(&builder, "exit_code: %d\n", task.ExitCode)
if task.ExitCode < 0 {
// A negative exit code means the background child was terminated by a
// signal rather than exiting (the int-only launcher can't carry the
// signal name). Give the same actionable hint the foreground path does.
builder.WriteString("note: terminated by a signal — killed before it finished. Likely causes: the OS out-of-memory killer (common when many sub-agents run in parallel — try fewer), a timeout, or cancellation.\n")
}
}
if summary.Text != "" {
+1 -7
View File
@@ -124,16 +124,10 @@ func TestOutputToolBlocksUntilTaskCompletes(t *testing.T) {
tool := NewOutputTool(manager)
tool.PollInterval = time.Millisecond
// timeout is a ceiling, not a target: the test still returns as soon as the
// 5ms write lands (PollInterval keeps it responsive), it just tolerates a
// slow/contended CI runner without failing. A 100ms budget against a 5ms
// write only left 20x margin, which Windows CI runners (coarser default
// timer granularity, heavier scheduling contention than Linux/macOS) blew
// through and failed the test on an otherwise-unrelated PR merge.
result := tool.Run(context.Background(), map[string]any{
"task_id": "child_task",
"block": true,
"timeout": 2000,
"timeout": 100,
})
if result.Status != tools.StatusOK || !strings.Contains(result.Output, "output:\nfinished") {
+2 -15
View File
@@ -134,17 +134,12 @@ func SummarizeStream(events []streamjson.Event, processExitCode int) StreamResul
return result
}
func BuildFinalResult(events []streamjson.Event, stderrOutput string, processExitCode int, signalDesc string) tools.Result {
func BuildFinalResult(events []streamjson.Event, stderrOutput string, processExitCode int) tools.Result {
summary := SummarizeStream(events, processExitCode)
hasErrors := len(summary.Errors) > 0 || summary.ExitCode != 0
if summary.Status != "" && summary.Status != "success" && summary.Status != "ok" {
hasErrors = true
}
// A captured kill signal must always surface, even if a late run_end reported a
// clean exit just before the child was killed during teardown.
if strings.TrimSpace(signalDesc) != "" {
hasErrors = true
}
if !hasErrors {
output := summary.Text
if summary.SessionID != "" {
@@ -154,15 +149,7 @@ func BuildFinalResult(events []streamjson.Event, stderrOutput string, processExi
}
var builder strings.Builder
if signal := strings.TrimSpace(signalDesc); signal != "" {
// The child was terminated by a signal (exit code -1) rather than exiting.
// Surface the signal instead of an opaque "exit -1", and list the common
// causes evenhandedly — this branch also covers timeouts and cancellations,
// so don't assert OOM.
fmt.Fprintf(&builder, "Subagent terminated by a signal (%s) — it was killed before it finished. Common causes: an out-of-memory kill, a timeout, or cancellation; check the signal to tell which. If you were running many sub-agents in parallel, reduce the concurrency.\n", signal)
} else {
fmt.Fprintf(&builder, "Subagent failed (exit %d)\n", summary.ExitCode)
}
fmt.Fprintf(&builder, "Subagent failed (exit %d)\n", summary.ExitCode)
if len(summary.Errors) > 0 {
fmt.Fprintf(&builder, "errors: %s\n", strings.Join(summary.Errors, "; "))
}
+3 -30
View File
@@ -25,7 +25,7 @@ func TestParseStreamAndBuildFinalResultSuccess(t *testing.T) {
if summary.SessionID != "child" || summary.Text != "done" || summary.ExitCode != 0 || len(summary.Tools) != 1 || summary.Tools[0] != "grep" {
t.Fatalf("unexpected summary: %#v", summary)
}
result := BuildFinalResult(events, "", 0, "")
result := BuildFinalResult(events, "", 0)
if result.Status != tools.StatusOK || result.Output != "session_id: child\ndone" {
t.Fatalf("unexpected final result: %#v", result)
}
@@ -41,7 +41,7 @@ func TestBuildFinalResultUsesTextDeltasWhenFinalMissing(t *testing.T) {
if err != nil {
t.Fatalf("ParseStream returned error: %v", err)
}
result := BuildFinalResult(events, "", 0, "")
result := BuildFinalResult(events, "", 0)
if result.Status != tools.StatusOK || result.Output != "session_id: child\nhello world" {
t.Fatalf("unexpected final result: %#v", result)
}
@@ -82,7 +82,7 @@ func TestBuildFinalResultErrorIncludesDiagnostics(t *testing.T) {
if err != nil {
t.Fatalf("ParseStream returned error: %v", err)
}
result := BuildFinalResult(events, "stderr text", 0, "")
result := BuildFinalResult(events, "stderr text", 0)
if result.Status != tools.StatusError {
t.Fatalf("Status = %s, want error", result.Status)
}
@@ -93,33 +93,6 @@ func TestBuildFinalResultErrorIncludesDiagnostics(t *testing.T) {
}
}
// A child terminated by a signal (exit code -1) must surface the signal + an
// actionable hint, not an opaque "Subagent failed (exit -1)".
func TestBuildFinalResultSurfacesKillSignal(t *testing.T) {
events, err := ParseStream(strings.NewReader(strings.Join([]string{
`{"schemaVersion":2,"type":"run_start","runId":"run_1","sessionId":"child"}`,
`{"schemaVersion":2,"type":"tool_call","runId":"run_1","id":"call_1","name":"read_file"}`,
"",
}, "\n")))
if err != nil {
t.Fatalf("ParseStream returned error: %v", err)
}
result := BuildFinalResult(events, "", -1, "signal: killed")
if result.Status != tools.StatusError {
t.Fatalf("Status = %s, want error", result.Status)
}
// Surfaces the signal + lists causes (OOM/timeout/cancellation) without asserting
// OOM as the sole cause.
for _, want := range []string{"terminated by a signal", "signal: killed", "out-of-memory", "timeout", "cancellation"} {
if !strings.Contains(result.Output, want) {
t.Fatalf("kill output missing %q:\n%s", want, result.Output)
}
}
if strings.Contains(result.Output, "Subagent failed (exit") {
t.Fatalf("signaled child should not show the opaque exit line:\n%s", result.Output)
}
}
func TestParseStreamRejectsInvalidLines(t *testing.T) {
_, err := ParseStream(strings.NewReader(`{"schemaVersion":2,"runId":"run_1"}` + "\n"))
if err == nil || !strings.Contains(err.Error(), "type is required") {
-6
View File
@@ -166,12 +166,6 @@ func (c *Coordinator) Fail(id, errMsg string) error {
return c.finish(id, StatusFailed, "", errMsg, "")
}
// FailWithSession marks a task failed but keeps its child session id, so a member
// that ran and failed (e.g. exited non-zero / hit max-turns) is still drillable.
func (c *Coordinator) FailWithSession(id, errMsg, sessionID string) error {
return c.finish(id, StatusFailed, "", errMsg, sessionID)
}
func (c *Coordinator) finish(id string, status TaskStatus, result, errMsg, sessionID string) error {
c.mu.Lock()
defer c.mu.Unlock()
+1 -14
View File
@@ -2,10 +2,8 @@ package swarm
import (
"context"
"errors"
"github.com/Gitlawb/zero/internal/specialist"
"github.com/Gitlawb/zero/internal/tools"
)
// NewSpecialistLauncher adapts internal/specialist.Executor into a
@@ -56,18 +54,7 @@ func NewSpecialistLauncher(executor specialist.Executor) MemberLauncher {
MemberAutonomy: true,
})
if err != nil {
// Preserve the child session id on a post-start failure too (exec.go
// returns it on the error path) so lifecycle's FailWithSession can keep
// the failed member drillable, not just the StatusError case below.
return MemberResult{SessionID: res.SessionID}, err
}
if res.Result.Status == tools.StatusError {
// The child ran but its task FAILED (e.g. non-zero exit / max-turns).
// Surface it as a member failure so the swarm marks it [failed], not
// [done] — otherwise the orchestrator (which can't see the AGENTS panel)
// trusts incomplete work. Keep the session id so the failed member is
// still drillable, and carry its report as the failure message.
return MemberResult{SessionID: res.SessionID}, errors.New(res.Result.Output)
return MemberResult{}, err
}
return MemberResult{Result: res.Result.Output, SessionID: res.SessionID}, nil
}}
@@ -67,50 +67,3 @@ func TestSpecialistLauncherRunsUnregisteredSwarmAgent(t *testing.T) {
t.Fatalf("member args missing agent type: %#v", gotArgs)
}
}
// A member whose child exits non-zero (e.g. exit 4 / max-turns) must be reported as
// a FAILURE — otherwise the swarm marks it [done] and the orchestrator trusts
// incomplete work. The failed member keeps its session id (drill-in) and the child
// report rides along as the failure message.
func TestSpecialistLauncherMarksNonZeroExitAsFailed(t *testing.T) {
four := 4
executor := specialist.Executor{
BinaryPath: "/usr/local/bin/zero",
NewSessionID: func() (string, error) { return "member_task", nil },
Load: func(specialist.LoadOptions) (specialist.LoadResult, error) {
return specialist.LoadResult{}, nil
},
RunChild: func(ctx context.Context, binaryPath string, args []string, progress func(streamjson.Event)) (specialist.ChildRunResult, error) {
return specialist.ChildRunResult{
Events: []streamjson.Event{
{Type: streamjson.EventRunStart, SessionID: "member_task"},
{Type: streamjson.EventFinal, Text: "i could not finish the objective"},
{Type: streamjson.EventRunEnd, Status: "error", ExitCode: &four},
},
ExitCode: 4,
}, nil
},
}
handle, err := NewSpecialistLauncher(executor).Launch(context.Background(), MemberSpec{
ID: "m1",
TaskID: "m1",
AgentType: "subagent",
Team: "probe",
Task: "a task too big for the budget",
SystemPrompt: "You are a subagent spawned to complete a specific task.",
})
if err != nil {
t.Fatalf("launch: %v", err)
}
res, err := handle.Wait()
if err == nil {
t.Fatal("a member that exited non-zero must be reported as FAILED, got nil error")
}
if !strings.Contains(err.Error(), "exit 4") {
t.Fatalf("failure should carry the child report (exit 4), got %q", err.Error())
}
if res.SessionID != "member_task" {
t.Fatalf("a failed member must keep its session id for drill-in, got %q", res.SessionID)
}
}
+1 -3
View File
@@ -71,9 +71,7 @@ func (s *Swarm) watch(t *Team, m *Member, spec MemberSpec) {
}
// fall through to record the original error if relaunch fails
}
// res.SessionID is preserved by the handle even on error, so a member that
// ran then failed stays drillable; a pure launch error carries an empty id.
_ = s.coord.FailWithSession(m.TaskID, memberError(err), res.SessionID)
_ = s.coord.Fail(m.TaskID, memberError(err))
} else {
_ = s.coord.CompleteWithSession(m.TaskID, res.Result, res.SessionID)
}
+54 -144
View File
@@ -28,17 +28,8 @@ const (
completedSessionRetention = 30 * time.Second
maxExecSessions = 64
recentExecOutputBytes = 4096
// maxExecOutputBufferBytes caps the undrained output an unpolled session can
// accumulate. Without a cap, a long-lived background session nobody polls
// again (e.g. a dev server left running after its initiating run was
// cancelled) grows this buffer forever as long as the process keeps writing
// output, with no ceiling — this previously ran a session's memory into the
// tens of gigabytes over several hours and got the whole zero process
// OOM-killed by the OS.
maxExecOutputBufferBytes = 2 * 1024 * 1024
execSessionStopTimeout = 3 * time.Second
execSessionEvictedMessage = "[zero] session evicted: too many background terminals\n"
execOutputBufferTruncatedMessage = "[zero] output buffer truncated: undrained output exceeded 2MiB, oldest output dropped"
execSessionStopTimeout = 3 * time.Second
execSessionEvictedMessage = "[zero] session evicted: too many background terminals\n"
)
type execSessionManager struct {
@@ -242,10 +233,6 @@ type ExecSessionSnapshot struct {
Status string
ExitCode *int
RecentOutput string
// OutputTruncated reflects the buffer's last-known truncation state (see
// execOutputBuffer.peekTruncated), so a session listing (/ps) still shows
// this even if the session is reaped before ever being polled again.
OutputTruncated bool
}
type ExecSessionController interface {
@@ -330,26 +317,24 @@ func (session *execSession) snapshot() ExecSessionSnapshot {
status = "exited"
}
return ExecSessionSnapshot{
ID: session.id,
Command: session.commandText,
Cwd: session.cwd,
RelativeCwd: session.relativeCwd,
StartedAt: startedAt,
LastUsedAt: lastUsedAt,
TTY: session.tty,
Status: status,
ExitCode: copiedExit,
RecentOutput: session.output.recentString(),
OutputTruncated: session.output.peekTruncated(),
ID: session.id,
Command: session.commandText,
Cwd: session.cwd,
RelativeCwd: session.relativeCwd,
StartedAt: startedAt,
LastUsedAt: lastUsedAt,
TTY: session.tty,
Status: status,
ExitCode: copiedExit,
RecentOutput: session.output.recentString(),
}
}
type execOutputBuffer struct {
mu sync.Mutex
data []byte
recent []byte
truncated bool
notify chan struct{}
mu sync.Mutex
data []byte
recent []byte
notify chan struct{}
}
func newExecOutputBuffer() *execOutputBuffer {
@@ -359,10 +344,6 @@ func newExecOutputBuffer() *execOutputBuffer {
func (buffer *execOutputBuffer) Write(p []byte) (int, error) {
buffer.mu.Lock()
buffer.data = append(buffer.data, p...)
if len(buffer.data) > maxExecOutputBufferBytes {
buffer.data = buffer.data[len(buffer.data)-maxExecOutputBufferBytes:]
buffer.truncated = true
}
buffer.recent = append(buffer.recent, p...)
if len(buffer.recent) > recentExecOutputBytes {
buffer.recent = buffer.recent[len(buffer.recent)-recentExecOutputBytes:]
@@ -392,34 +373,6 @@ func (buffer *execOutputBuffer) drainString() string {
return out
}
// consumeTruncated reports whether the buffer has dropped output to stay
// within maxExecOutputBufferBytes since the last call, resetting the flag.
// Kept as an out-of-band signal rather than text embedded in drainString's
// result: an earlier version prefixed a notice directly into the drained
// string, but that notice always sat ~maxExecOutputBufferBytes (2MiB) before
// the end of the combined output, far outside the byte-budget head/tail
// window truncateExecOutput keeps afterward (at most 400KB even at the
// tool's maximum max_output_tokens) — so the notice was reliably swallowed
// or chopped by that second, unrelated truncation pass. Surfacing it as a
// separate bool lets the caller report it regardless of where in the byte
// stream the overflow happened.
func (buffer *execOutputBuffer) consumeTruncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
truncated := buffer.truncated
buffer.truncated = false
return truncated
}
// peekTruncated reports the buffer's last-known truncation state without
// clearing it, for status views (e.g. /ps) that shouldn't consume the signal
// a subsequent poll is still meant to report via consumeTruncated.
func (buffer *execOutputBuffer) peekTruncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return buffer.truncated
}
type execCommandTool struct {
baseTool
workspaceRoot string
@@ -532,28 +485,25 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
if err != nil {
return errorResult("Error starting exec_command: " + err.Error())
}
output, outputTruncated := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
output := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
if ctx != nil && ctx.Err() != nil && !session.doneClosed() {
session.terminate()
more, moreTruncated := session.collect(context.Background(), time.Second)
output += more
outputTruncated = outputTruncated || moreTruncated
output += session.collect(context.Background(), time.Second)
}
exitCode, exited := session.exitStatus()
if exited {
tool.manager.remove(session.id)
}
return execToolResult(execToolResultInput{
commandText: commandText,
output: output,
outputBufferTruncated: outputTruncated,
sessionID: session.id,
exitCode: exitCode,
exited: exited,
relativeCwd: relativeCwd,
tty: session.tty,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
commandText: commandText,
output: output,
sessionID: session.id,
exitCode: exitCode,
exited: exited,
relativeCwd: relativeCwd,
tty: session.tty,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
})
}
@@ -608,49 +558,26 @@ func (tool execCommandTool) startSession(commandText string, absoluteCwd string,
return session, nil
}
// collect drains the session's output buffer, returning the accumulated text
// and whether the buffer ever had to drop output to stay within
// maxExecOutputBufferBytes since the last collect call.
func (session *execSession) collect(ctx context.Context, wait time.Duration) (string, bool) {
func (session *execSession) collect(ctx context.Context, wait time.Duration) string {
if ctx == nil {
ctx = context.Background()
}
deadline := time.Now().Add(wait)
var builder strings.Builder
truncated := false
finish := func() (string, bool) {
if session.output.consumeTruncated() {
truncated = true
}
return builder.String(), truncated
}
for {
if chunk := session.output.drainString(); chunk != "" {
builder.WriteString(chunk)
if session.output.consumeTruncated() {
truncated = true
}
// A background process that keeps writing output faster than this
// loop can drain it (a chatty dev server, a watch-mode rebuild loop,
// a crash-restart spew) would otherwise never let drainString return
// empty, so the deadline check below — only reached on an empty
// drain — would never fire. That turns `wait` into an unbounded
// block regardless of what the caller asked for. Check it here too,
// so continuous output can never starve the timeout.
if time.Now().After(deadline) {
return finish()
}
continue
}
if session.doneClosed() {
if chunk := session.output.drainString(); chunk != "" {
builder.WriteString(chunk)
}
return finish()
return builder.String()
}
remaining := time.Until(deadline)
if remaining <= 0 {
return finish()
return builder.String()
}
timer := time.NewTimer(remaining)
select {
@@ -675,9 +602,9 @@ func (session *execSession) collect(ctx context.Context, wait time.Duration) (st
default:
}
}
return finish()
return builder.String()
case <-timer.C:
return finish()
return builder.String()
}
}
}
@@ -774,23 +701,22 @@ func (tool writeStdinTool) RunWithOptions(ctx context.Context, args map[string]a
}
}
}
output, outputTruncated := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
output := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
exitCode, exited := session.exitStatus()
if exited {
tool.manager.remove(session.id)
}
return execToolResult(execToolResultInput{
commandText: session.commandText,
output: output,
outputBufferTruncated: outputTruncated,
sessionID: session.id,
exitCode: exitCode,
exited: exited,
relativeCwd: session.relativeCwd,
tty: session.tty,
interrupted: interrupted,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
commandText: session.commandText,
output: output,
sessionID: session.id,
exitCode: exitCode,
exited: exited,
relativeCwd: session.relativeCwd,
tty: session.tty,
interrupted: interrupted,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
})
}
@@ -815,22 +741,16 @@ func shouldInterruptExecSession(chars string, tty bool) bool {
}
type execToolResultInput struct {
commandText string
output string
// outputBufferTruncated is true when the session's undrained output buffer
// had to drop bytes to stay within maxExecOutputBufferBytes since it was
// last collected — data that is gone for good, unlike the head/tail
// truncation below (which the caller can recover by polling again or
// raising max_output_tokens).
outputBufferTruncated bool
sessionID int
exitCode int
exited bool
relativeCwd string
tty bool
interrupted bool
plan zeroSandbox.CommandPlan
maxOutputTokens int
commandText string
output string
sessionID int
exitCode int
exited bool
relativeCwd string
tty bool
interrupted bool
plan zeroSandbox.CommandPlan
maxOutputTokens int
}
func execToolResult(input execToolResultInput) Result {
@@ -851,26 +771,16 @@ func execToolResult(input execToolResultInput) Result {
} else {
meta["session_id"] = strconv.Itoa(input.sessionID)
}
if input.outputBufferTruncated {
meta["output_buffer_truncated"] = "true"
}
status := StatusOK
if input.exited && ((input.exitCode != 0 && !input.interrupted) || meta[SandboxLikelyDeniedMeta] == "true") {
status = StatusError
}
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
if input.outputBufferTruncated {
// Appended after truncateExecOutput's own head/tail slicing, not
// embedded in the text that goes through it — a marker inside that
// text can land in the discarded middle or get chopped at a small
// max_output_tokens budget. Appending here guarantees it survives.
body += "\n" + execOutputBufferTruncatedMessage
}
return Result{
Status: status,
Output: body,
Truncated: truncated || input.outputBufferTruncated,
Truncated: truncated,
Meta: meta,
Display: Display{
Summary: execDisplaySummary(input.commandText, input.sessionID, input.exited, input.exitCode),
-146
View File
@@ -363,152 +363,6 @@ func TestStartExecProcessFallsBackAfterPTYStartMutation(t *testing.T) {
}
}
// TestExecOutputBufferCapsUndrainedData: a session nobody polls must not grow
// its undrained buffer without bound — a long-lived background process that
// keeps writing while unpolled previously ran a session's memory into the
// tens of gigabytes and got the whole zero process OOM-killed by the OS.
func TestExecOutputBufferCapsUndrainedData(t *testing.T) {
buffer := newExecOutputBuffer()
chunk := strings.Repeat("x", 1024)
writes := (maxExecOutputBufferBytes / len(chunk)) + 10 // comfortably over the cap
for i := 0; i < writes; i++ {
if _, err := buffer.Write([]byte(chunk)); err != nil {
t.Fatalf("Write: %v", err)
}
}
buffer.mu.Lock()
dataLen := len(buffer.data)
buffer.mu.Unlock()
if dataLen > maxExecOutputBufferBytes {
t.Fatalf("undrained buffer grew to %d bytes, want <= %d", dataLen, maxExecOutputBufferBytes)
}
out := buffer.drainString()
// drainString itself carries no truncation marker: a marker embedded in
// the drained text would always sit ~maxExecOutputBufferBytes before the
// end of the string, past any realistic head/tail truncation window a
// caller applies afterward (see execToolResult), so it's reliably lost.
// The signal lives out of band on consumeTruncated/peekTruncated instead.
if !strings.HasSuffix(out, chunk) {
t.Fatal("drained output should keep the most recent bytes, not the oldest")
}
if len(out) > maxExecOutputBufferBytes {
t.Fatalf("drained output = %d bytes, want <= %d (no marker text mixed in)", len(out), maxExecOutputBufferBytes)
}
if !buffer.peekTruncated() {
t.Fatal("peekTruncated should report the overflow without clearing it")
}
if !buffer.peekTruncated() {
t.Fatal("a second peekTruncated call should still report it — peek must not consume")
}
if !buffer.consumeTruncated() {
t.Fatal("consumeTruncated should report the overflow")
}
if buffer.consumeTruncated() {
t.Fatal("consumeTruncated should reset after being read once")
}
if buffer.peekTruncated() {
t.Fatal("peekTruncated should reflect the reset state after consumeTruncated")
}
// A second drain with no intervening writes must be empty.
if got := buffer.drainString(); got != "" {
t.Fatalf("drainString after a full drain = %q, want empty", got)
}
}
// TestExecToolResultSurfacesBufferTruncationOutsideByteBudget: an earlier
// version embedded the truncation notice directly in the drained text, which
// a review found sits ~maxExecOutputBufferBytes before the end of the
// string — far past the head/tail window truncateExecOutput keeps even at
// the tool's smallest allowed max_output_tokens, so the notice was reliably
// swallowed or chopped. The notice must survive regardless of how small the
// byte budget is, and it must not count against that budget.
func TestExecToolResultSurfacesBufferTruncationOutsideByteBudget(t *testing.T) {
hugeOutput := strings.Repeat("y", maxExecOutputBufferBytes+1024)
result := execToolResult(execToolResultInput{
commandText: "cmd",
output: hugeOutput,
outputBufferTruncated: true,
sessionID: 1,
exited: false,
maxOutputTokens: 1, // the schema's own declared minimum
})
if !strings.Contains(result.Output, execOutputBufferTruncatedMessage) {
t.Fatalf("result output should contain the buffer-truncation notice even at the smallest max_output_tokens, got %q", result.Output[:min(len(result.Output), 200)])
}
if result.Meta["output_buffer_truncated"] != "true" {
t.Fatalf("result meta should flag output_buffer_truncated, got %#v", result.Meta)
}
if !result.Truncated {
t.Fatal("result should report Truncated when the buffer dropped output")
}
}
// TestCollectRespectsDeadlineUnderContinuousOutput asserts collect() returns
// close to its requested deadline even while output keeps arriving. Before
// the corresponding fix, the deadline was only checked in the branch reached
// when drainString returns empty — a background process producing output
// fast/continuously enough to keep the buffer perpetually non-empty could
// theoretically starve that check indefinitely. This synthetic writer
// (8 goroutines, tight loop) did not reliably reproduce that starvation under
// Go's scheduler in practice — the reader still won the race often enough —
// so this test doesn't prove the old code could hang; it just pins down the
// intended behavior (bounded by wait) going forward.
func TestCollectRespectsDeadlineUnderContinuousOutput(t *testing.T) {
session := &execSession{
id: 1000,
output: newExecOutputBuffer(),
done: make(chan struct{}),
}
stop := make(chan struct{})
var wg sync.WaitGroup
// Several concurrent writers, each pushing a decent-sized chunk in a tight
// loop: a single slow writer lets the reader win the race often enough
// (the runtime schedules a gap between writes) to mask the bug. Enough
// parallel writers keep the buffer non-empty essentially continuously,
// closer to a real chatty process whose PTY reads land in bursts.
const writers = 8
chunk := []byte(strings.Repeat("x", 256))
wg.Add(writers)
for i := 0; i < writers; i++ {
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
session.output.Write(chunk)
}
}
}()
}
t.Cleanup(func() {
close(stop)
wg.Wait()
})
const wait = 200 * time.Millisecond
start := time.Now()
_, _ = session.collect(context.Background(), wait)
elapsed := time.Since(start)
// Generous slack over `wait` for scheduling jitter under a continuously
// writing goroutine — this must stay a small multiple of wait, not
// "however long the writer keeps going" (which is what the bug produced:
// this test would hang past the 30s test timeout without the fix).
if elapsed > 3*wait {
t.Fatalf("collect took %v under continuous output, want close to the %v deadline", elapsed, wait)
}
}
// resilientTempDir is like t.TempDir() but tolerates the Windows handle-release
// lag: a SIGKILL'd child process that had the dir as its cwd may not have
// released it the instant it is reaped, so the immediate RemoveAll t.TempDir()
-3
View File
@@ -146,9 +146,6 @@ func formatBackgroundTerminalRow(session tools.ExecSessionSnapshot, now time.Tim
}
preview := compactCommandOutputText(session.RecentOutput)
prefix := fmt.Sprintf("%d · %s · %s · %s", session.ID, session.Status, age, cwd)
if session.OutputTruncated {
prefix += " · output truncated"
}
if preview != "" {
return prefix + " · " + command + " · " + preview
}
-7
View File
@@ -56,13 +56,6 @@ func pasteFromClipboardCmd() tea.Cmd {
// spec prompt, the MCP manager, an open picker, the detailed transcript) swallow
// the paste; empty content is a no-op.
func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
// A paste is a deliberate action, same as a keypress or click — it means
// the user moved on to something else, so it disarms a stale Esc
// cancel-confirmation. tea.PasteMsg/clipboardReadMsg aren't
// tea.KeyPressMsg, so they don't go through that generic keypress hook;
// do it once here, before any early return, so both the bracketed-paste
// and right-click-paste paths are covered uniformly.
m = m.disarmCancelConfirmation()
if content == "" {
// Empty text clipboard — the user may have pasted a screenshot.
// Probe the OS clipboard for image content asynchronously.
+7 -23
View File
@@ -441,8 +441,6 @@ func (m model) handleModelCommand(args string) (model, string) {
m.providerProfile = nextProfile
m.provider = nextProvider
m.providerName = displayValue(nextProfile.Name, string(metadata.ProviderKind))
// Keep sub-agent child processes on the same provider we just switched to.
config.SetActiveProviderEnv(nextProfile.Name)
m.modelName = target.modelID
resetEffort := false
if m.reasoningEffort != "" && !reasoningEffortAllowed(target.reasoningEfforts, m.reasoningEffort) {
@@ -490,16 +488,16 @@ func (m model) handleModelCommand(args string) (model, string) {
// picker calls this when a model from a non-active provider is chosen, so the
// picker can list every saved provider and switch across them (like a unified
// provider+model selector). The key is loaded from the encrypted store / env.
func (m model) switchProviderModel(providerName, modelID string) (model, string, tea.Cmd) {
func (m model) switchProviderModel(providerName, modelID string) (model, string) {
if m.pending {
return m, "Model\nCannot switch providers while a run is active.", nil
return m, "Model\nCannot switch providers while a run is active."
}
if m.newProvider == nil {
return m, "Model\nProvider rebuild is not available for this TUI session.", nil
return m, "Model\nProvider rebuild is not available for this TUI session."
}
target, ok := m.savedProviderByName(providerName)
if !ok {
return m, "Model\nunknown provider " + strconv.Quote(providerName), nil
return m, "Model\nunknown provider " + strconv.Quote(providerName)
}
target = m.profileWithCredential(target)
target.Model = strings.TrimSpace(modelID)
@@ -508,35 +506,21 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string,
// was deleted/unreadable the marker can still be set, and building a keyless
// provider would only fail later with a 401. Local/no-auth providers need no key.
if strings.TrimSpace(target.APIKey) == "" && strings.TrimSpace(target.AuthHeaderValue) == "" && !(hasDescriptor && descriptor.Local) {
return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`.", nil
return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`."
}
next, err := m.newProvider(target)
if err != nil {
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}}), nil
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}})
}
m.provider = next
m.providerProfile = target
m.providerName = target.Name
m.modelName = target.Model
// Keep sub-agent child processes on the same provider we just switched to.
config.SetActiveProviderEnv(target.Name)
if strings.TrimSpace(m.userConfigPath) != "" {
_, _ = config.SetActiveProvider(m.userConfigPath, target.Name)
_, _ = config.SetProviderModel(m.userConfigPath, target.Name, target.Model)
}
// Warm discovery for the provider we just switched to, same as Init() does
// for the provider active at launch — otherwise the context-usage gauge has
// no window for this provider until /model happens to be opened separately.
var cmds []tea.Cmd
if hasDescriptor {
if cmd := m.modelPickerProviderDiscoveryCmd(descriptor, target); cmd != nil {
cmds = append(cmds, cmd)
}
if cmd := m.ollamaContextWindowDiscoveryCmd(descriptor, target.BaseURL, target.Model); cmd != nil {
cmds = append(cmds, cmd)
}
}
return m, fmt.Sprintf("Model\nSwitched to %s · %s", target.Name, target.Model), tea.Batch(cmds...)
return m, fmt.Sprintf("Model\nSwitched to %s · %s", target.Name, target.Model)
}
// profileWithCredential fills a profile's APIKey for provider construction the same
+1 -1
View File
@@ -467,7 +467,7 @@ func providerNextActionLines(profile config.ProviderProfile, snapshot zerocomman
}
func providerProfileHasCredential(profile config.ProviderProfile) bool {
return profile.HasConfiguredCredential()
return strings.TrimSpace(profile.APIKey) != "" || profile.APIKeyStored || strings.TrimSpace(profile.AuthHeaderValue) != ""
}
func providerCredentialRequired(profile config.ProviderProfile, providerKind string) bool {
+2 -10
View File
@@ -40,7 +40,6 @@ const (
commandImage
commandAddDir
commandSelfCorrect
commandTurns
commandUnknown
)
@@ -242,13 +241,6 @@ var commandDefinitions = []commandDefinition{
description: "Show or set post-edit self-correction depth (LSP-only default; on/tests/full add the project test plan; off/lsp disable tests, LSP-only).",
kind: commandSelfCorrect,
},
{
name: "/turns",
usage: "/turns [n]",
group: commandGroupSession,
description: "Show or set the per-run tool-turn budget for this session (raise it for long multi-step tasks).",
kind: commandTurns,
},
{
name: "/help",
usage: "/help",
@@ -281,9 +273,9 @@ var commandDefinitions = []commandDefinition{
},
{
name: "/theme",
usage: "/theme [list|auto|name]",
usage: "/theme [auto|dark|light]",
group: commandGroupSession,
description: "Pick a color theme (no arg opens the picker; auto detects the terminal background).",
description: "Switch or show the color theme (auto-detects terminal background).",
kind: commandTheme,
},
{
-26
View File
@@ -27,29 +27,3 @@ func TestContextWindowSegment(t *testing.T) {
t.Fatalf("gauge = %q, want 161K/200K · 81%%", got)
}
}
// TestStatusLineDoesNotDuplicateTokenFigureNextToGauge: the gauge's "used"
// figure and the plain usage segment's token count both read the exact same
// latestUsageTokens value — once the gauge is showing it, the usage segment
// must fall back to cost-only, or the same number renders twice side by
// side in the footer (e.g. "◔ 161K/200K · 81% │ 161K tok").
func TestStatusLineDoesNotDuplicateTokenFigureNextToGauge(t *testing.T) {
m := newModel(t.Context(), Options{ModelName: "claude-sonnet-4.5"})
if _, err := m.usageTracker.Record(usage.RecordInput{
ModelID: "claude-sonnet-4.5",
Usage: zeroruntime.Usage{InputTokens: 160_000, OutputTokens: 1000},
}); err != nil {
t.Fatalf("Record: %v", err)
}
status := plainRender(t, m.statusLine(110))
if !strings.Contains(status, "161K/200K") {
t.Fatalf("status line = %q, expected the context gauge to render", status)
}
if strings.Count(status, "161K") != 1 {
t.Fatalf("status line = %q, the 161K token figure must appear exactly once, not duplicated next to the gauge", status)
}
if strings.Contains(status, "tok") {
t.Fatalf("status line = %q, the plain token segment should be suppressed once the gauge shows the same figure", status)
}
}
-251
View File
@@ -1,251 +0,0 @@
// file_view.go is the drill-in view for a touched file, opened from the FILES
// sidebar section (files_panel.go). It reuses the subchat pattern: while
// active, the chat column's body swaps to this file's content — the sidebar,
// composer, and scroll engine keep working unchanged (transcriptBodyItems is
// the single source the viewport, renderer, and hit-testers all read, so
// swapping there keeps every consumer consistent). Two modes:
//
// diff (default) — the file's edit cards from this session, full-depth
// full — the file as it stands on disk, syntax highlighted, with
// gutter markers on the lines this session's diffs added
//
// d/f switch modes (with an empty composer), Esc returns to the chat at the
// scroll position it was left at.
package tui
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)
// fileViewMaxLines caps the full-file mode so a giant generated file can't
// freeze a render; the tail collapses into a "… N more lines" trailer.
const fileViewMaxLines = 4000
const (
fileViewDiff = iota
fileViewFull
)
// fileViewState manages the drill-in view for a touched file. When active, the
// transcript body swaps to the file's diff/content instead of the chat rows.
type fileViewState struct {
active bool
path string // workspace-relative, as carried by changedFiles
mode int // fileViewDiff | fileViewFull
// parentScrollOffset preserves the chat scroll position so closing the view
// returns to the same spot (mirrors subchatState).
parentScrollOffset int
}
// openFileView activates the drill-in for path in diff mode. Opening from an
// already-open view (clicking another FILES row) keeps the original saved chat
// scroll position rather than saving the file view's own offset as "parent".
// Re-opening the file that is ALREADY being viewed is a no-op: a stray
// re-click must not bounce the user from full mode back to diff or reset
// their scroll position.
func (m model) openFileView(path string) model {
if m.fileView.active && m.fileView.path == path {
return m
}
if !m.fileView.active {
m.fileView.parentScrollOffset = m.chatScrollOffset
}
m.fileView.active = true
m.fileView.path = path
m.fileView.mode = fileViewDiff
// A file only the git sweep knows about (bash/subagent mutation) has no edit
// cards to stack — open straight on the full file instead of a placeholder.
if len(m.fileViewResultRows()) == 0 {
m.fileView.mode = fileViewFull
}
m.chatScrollOffset = 0
m = m.clearHover() // bodyY numbering differs between the file body and the chat
return m
}
// exitFileView deactivates the view and restores the chat scroll position.
func (m model) exitFileView() model {
if !m.fileView.active {
return m
}
m.chatScrollOffset = m.fileView.parentScrollOffset
m.fileView = fileViewState{}
m = m.clearHover()
return m
}
// setFileViewMode switches diff/full, resetting the scroll to the bottom-anchored
// start since the two bodies have unrelated heights.
func (m model) setFileViewMode(mode int) model {
if !m.fileView.active || m.fileView.mode == mode {
return m
}
m.fileView.mode = mode
m.chatScrollOffset = 0
return m
}
// fileViewNavBar renders the single-line header shown in place of the pinned
// title bar while the view is active: the path plus the key hints. One line
// exactly, so every scrollableTranscriptFrame computed against it agrees with
// the title bar's geometry.
func (m model) fileViewNavBar(width int) string {
mode := "diff"
other := "f full"
if m.fileView.mode == fileViewFull {
mode = "full"
other = "d diff"
}
left := zeroTheme.accent.Render("← "+truncatePathLeft(m.fileView.path, maxInt(8, width/2))) +
zeroTheme.faint.Render(" · "+mode)
right := zeroTheme.faint.Render(other + " · esc back")
return fitStyledLine(joinHeaderLine(left, right, width), width)
}
// fileViewBodyItems builds the body items the transcript machinery renders
// while the view is active — one pre-rendered block, so scrolling and height
// accounting flow through the exact same path as chat rows.
func (m model) fileViewBodyItems(width int) []transcriptBodyItem {
var block string
if m.fileView.mode == fileViewFull {
block = m.renderFileViewFull(width)
} else {
block = m.renderFileViewDiff(width)
}
return []transcriptBodyItem{transcriptBlockBodyItem(transcriptBodyItemRow, -1, block)}
}
// fileViewResultRows returns the transcript's tool-result rows that touched the
// viewed file, in chronological order.
func (m model) fileViewResultRows() []transcriptRow {
var rows []transcriptRow
for _, row := range m.transcript {
if row.kind != rowToolResult {
continue
}
for _, p := range row.changedFiles {
if p == m.fileView.path {
rows = append(rows, row)
break
}
}
}
return rows
}
// renderFileViewDiff renders the session's edits to the file as its full-depth
// tool cards (bodyCap 0, the detailed-view depth), stacked chronologically —
// the same cards the chat shows, so the diffs read identically in both places.
func (m model) renderFileViewDiff(width int) string {
rows := m.fileViewResultRows()
if len(rows) == 0 {
return zeroTheme.faint.Render("No recorded edits for this file in this session.")
}
rc := buildRowContext(m.transcript)
opts := cardRenderOptions{bodyCap: 0, cwd: m.cwd}
var b strings.Builder
for i, row := range rows {
if i > 0 {
b.WriteString("\n\n")
}
if len(rows) > 1 {
b.WriteString(zeroTheme.faint.Render(fmt.Sprintf("edit %d of %d", i+1, len(rows))))
b.WriteString("\n")
}
b.WriteString(m.renderRowModeUncached(row, width, rc, opts))
}
return b.String()
}
// renderFileViewFull renders the file as it currently stands on disk, syntax
// highlighted, with a line-number gutter and an accent ▎ marker on the lines
// this session's diffs added (matched by exact text — an approximation that
// tolerates later drift; a stale marker just doesn't highlight).
func (m model) renderFileViewFull(width int) string {
target := m.fileView.path
if !filepath.IsAbs(target) {
target = filepath.Join(m.cwd, target)
}
// Stream the read and stop at the cap: os.ReadFile would load a multi-GB
// file wholesale before any truncation, which is the exact render freeze
// fileViewMaxLines exists to prevent.
file, err := os.Open(target)
if err != nil {
return zeroTheme.faint.Render("Could not read file: " + err.Error())
}
defer file.Close()
var lines []string
truncated := false
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
for scanner.Scan() {
if len(lines) == fileViewMaxLines {
truncated = true
break
}
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
if len(lines) == 0 {
return zeroTheme.faint.Render("Could not read file: " + err.Error())
}
truncated = true // e.g. a single over-long line mid-file: show what we have
}
changed := m.fileViewChangedLines()
gutterW := len(fmt.Sprintf("%d", len(lines)))
textBudget := maxInt(8, width-gutterW-3) // gutter + space + marker column
// Highlight with an effectively-infinite measure so the highlighter never
// wraps — output lines stay 1:1 with file lines and the gutter numbering
// can't desync. Each line is then truncated to the column budget below.
display, ok := highlightCodeForPath(lines, m.fileView.path, 1<<20, nil)
if !ok || len(display) != len(lines) {
display = lines // no lexer for this path: render plain
}
var b strings.Builder
for i, line := range display {
line = fitStyledLine(line, textBudget)
if i > 0 {
b.WriteString("\n")
}
marker := " "
if changed[strings.TrimSpace(lines[i])] {
marker = zeroTheme.accent.Render("▎")
}
b.WriteString(zeroTheme.faintest.Render(fmt.Sprintf("%*d ", gutterW, i+1)))
b.WriteString(marker)
b.WriteString(line)
}
if truncated {
// No exact remaining-line count: computing one would require reading the
// rest of the file, defeating the bounded read above.
b.WriteString("\n")
b.WriteString(zeroTheme.faint.Render(fmt.Sprintf("… more lines (file truncated at %d for display)", len(lines))))
}
return b.String()
}
// fileViewChangedLines collects the trimmed text of every line the session's
// diffs ADDED to the viewed file, for the full-mode gutter markers. Very short
// lines ("}", "return", ")") are skipped: text-matching them would mark every
// unrelated occurrence across the file, which misleads far more than a missing
// marker on a brace line ever could.
func (m model) fileViewChangedLines() map[string]bool {
changed := map[string]bool{}
for _, row := range m.fileViewResultRows() {
for _, line := range strings.Split(row.detail, "\n") {
if !strings.HasPrefix(line, "+") || strings.HasPrefix(line, "+++") {
continue
}
if text := strings.TrimSpace(strings.TrimPrefix(line, "+")); len(text) >= 4 {
changed[text] = true
}
}
}
return changed
}
-288
View File
@@ -1,288 +0,0 @@
package tui
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
)
// TestFileViewOpenExitRestoresScroll: opening saves the chat scroll position,
// resets it for the file body, and Esc restores it; switching files while open
// keeps the ORIGINAL saved position (not the file view's own).
func TestFileViewOpenExitRestoresScroll(t *testing.T) {
m := filesPanelTestModel()
m.chatScrollOffset = 12
m = m.openFileView("web/app.js")
if !m.fileView.active || m.fileView.mode != fileViewDiff {
t.Fatalf("open should activate in diff mode: %+v", m.fileView)
}
if m.chatScrollOffset != 0 || m.fileView.parentScrollOffset != 12 {
t.Fatalf("open should reset scroll and save the parent offset: offset=%d saved=%d", m.chatScrollOffset, m.fileView.parentScrollOffset)
}
m.chatScrollOffset = 5 // scrolled within the file body
m = m.openFileView("internal/tui/sidebar.go")
if m.fileView.parentScrollOffset != 12 {
t.Fatalf("switching files must keep the original parent offset, got %d", m.fileView.parentScrollOffset)
}
m = m.exitFileView()
if m.fileView.active || m.chatScrollOffset != 12 {
t.Fatalf("exit should restore the chat scroll: active=%v offset=%d", m.fileView.active, m.chatScrollOffset)
}
}
// TestFileViewEscAndModeKeys: Esc exits the view via the model's key handler;
// d/f switch modes while the composer is empty and never while typing.
func TestFileViewEscAndModeKeys(t *testing.T) {
m := filesPanelTestModel()
m = m.openFileView("web/app.js")
updated, _ := m.Update(tea.KeyPressMsg{Code: 'f', Text: "f"})
m = updated.(model)
if m.fileView.mode != fileViewFull {
t.Fatal("f should switch to full mode")
}
updated, _ = m.Update(tea.KeyPressMsg{Code: 'd', Text: "d"})
m = updated.(model)
if m.fileView.mode != fileViewDiff {
t.Fatal("d should switch back to diff mode")
}
// With text in the composer, d/f type as normal characters.
m.input.SetValue("say")
updated, _ = m.Update(tea.KeyPressMsg{Code: 'f', Text: "f"})
m = updated.(model)
if m.fileView.mode != fileViewDiff {
t.Fatal("f while typing must not hijack the composer")
}
m.input.SetValue("")
updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
m = updated.(model)
if m.fileView.active {
t.Fatal("Esc should exit the file view")
}
}
// TestFileViewDiffBody: diff mode stacks the file's edit cards chronologically
// with "edit N of M" labels; a file with no recorded edits shows the quiet
// placeholder.
func TestFileViewDiffBody(t *testing.T) {
m := filesPanelTestModel()
m = m.openFileView("internal/tui/sidebar.go")
body := plainRender(t, m.renderFileViewDiff(78))
if !strings.Contains(body, "edit 1 of 2") || !strings.Contains(body, "edit 2 of 2") {
t.Fatalf("expected chronological edit labels:\n%s", body)
}
if !strings.Contains(body, "added one") || !strings.Contains(body, "three") {
t.Errorf("expected both diffs' content:\n%s", body)
}
m.fileView.path = "never/touched.go"
if got := plainRender(t, m.renderFileViewDiff(78)); !strings.Contains(got, "No recorded edits") {
t.Errorf("untouched file should show the placeholder, got:\n%s", got)
}
}
// TestFileViewFullBody: full mode shows the on-disk content with line numbers
// and marks session-added lines with the gutter marker; a missing file degrades
// to a readable error line.
func TestFileViewFullBody(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.js"), []byte("let a = 1\nlet untouched = 0\n"), 0o644); err != nil {
t.Fatal(err)
}
m := filesPanelTestModel()
m.cwd = dir
m.transcript = append(m.transcript, transcriptRow{
kind: rowToolResult, tool: "write_file", id: "w9", status: tools.StatusOK,
detail: "+let a = 1",
changedFiles: []string{"app.js"},
})
m = m.openFileView("app.js")
m = m.setFileViewMode(fileViewFull)
body := m.renderFileViewFull(78)
plain := plainRender(t, body)
if !strings.Contains(plain, "1 ") || !strings.Contains(plain, "let untouched = 0") {
t.Fatalf("full view should show numbered file content:\n%s", plain)
}
lines := strings.Split(plain, "\n")
if len(lines) != 2 {
t.Fatalf("one rendered line per file line, got %d:\n%s", len(lines), plain)
}
if !strings.Contains(lines[0], "▎") {
t.Errorf("session-added line should carry the gutter marker: %q", lines[0])
}
if strings.Contains(lines[1], "▎") {
t.Errorf("untouched line must not carry the marker: %q", lines[1])
}
m.fileView.path = "gone.js"
if got := plainRender(t, m.renderFileViewFull(78)); !strings.Contains(got, "Could not read file") {
t.Errorf("missing file should degrade to an error line, got:\n%s", got)
}
}
// TestFileViewSwapsTranscriptBody: while active, transcriptBodyItems returns
// the file body (a single block) instead of the chat rows, and the pinned
// title bar swaps to the one-line nav bar — the geometry every frame consumer
// relies on.
func TestFileViewSwapsTranscriptBody(t *testing.T) {
m := filesPanelTestModel()
m = m.openFileView("internal/tui/sidebar.go")
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
if len(items) != 1 {
t.Fatalf("file view should swap the body to a single block item, got %d items", len(items))
}
nav := plainRender(t, m.pinnedTitleBar(m.chatColumnWidth()))
if !strings.Contains(nav, "sidebar.go") || !strings.Contains(nav, "esc back") {
t.Fatalf("nav bar should show the path and key hints: %q", nav)
}
if lines := len(viewLines(m.fileViewNavBar(m.chatColumnWidth()))); lines != 1 {
t.Fatalf("nav bar must be exactly one line (title-bar geometry), got %d", lines)
}
// The whole view renders without panicking in both modes and shows the nav.
if view := plainRender(t, m.transcriptView()); !strings.Contains(view, "esc back") {
t.Fatal("transcript view should carry the file nav bar")
}
}
// TestSubchatEntryClosesFileView: drilling into an AGENTS row while a file view
// is open closes the file view first (the subchat owns the single-column view).
func TestSubchatEntryClosesFileView(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
if _, err := store.Create(sessions.CreateInput{SessionID: "sess-1"}); err != nil {
t.Fatal(err)
}
m := filesPanelTestModel()
m.sessionStore = store
m.swarmSessionMap = map[string]string{"subagent-1": "sess-1"}
m.transcript = append(m.transcript,
transcriptRow{kind: rowToolCall, tool: "swarm_spawn", detail: "build it", runID: 1},
transcriptRow{kind: rowToolResult, tool: "swarm_spawn", detail: "Spawned subagent as task subagent-1 on team default.", runID: 1},
)
m.activeRunID = 1
m = m.openFileView("web/app.js")
width := sidebarWidth(m.width)
agents := m.sidebarAgentSelectables(width)
if len(agents) == 0 {
t.Fatal("expected a clickable agent row")
}
click := testMouseClick(tea.MouseLeft, m.chatColumnWidth()+3, agents[0].lineOffset)
updated, _, handled := m.handleTranscriptSelectionMouse(click)
if !handled {
t.Fatal("agent row click should be handled")
}
if updated.fileView.active {
t.Fatal("entering the subchat should close the file view")
}
if !updated.subchat.active {
t.Fatal("subchat should be active")
}
}
// TestChangedFilesRehydration: a persisted tool-result payload's changedFiles
// restores onto the rehydrated transcript row, so the FILES panel survives
// /resume.
func TestChangedFilesRehydration(t *testing.T) {
events := []sessions.Event{{
Type: sessions.EventToolResult,
Payload: json.RawMessage(`{"toolCallId":"t1","name":"edit_file","status":"ok","output":"+x","changedFiles":["pkg/a.go","pkg/b.go"]}`),
}}
rows := transcriptRowsFromSessionEvents(events)
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
got := rows[0].changedFiles
if len(got) != 2 || got[0] != "pkg/a.go" || got[1] != "pkg/b.go" {
t.Fatalf("changedFiles not rehydrated: %v", got)
}
}
// TestOpenFileViewSamePathIsNoOp: re-clicking the FILES row of the file already
// being viewed must not clobber the user's mode or scroll — the old
// unconditional openFileView bounced full mode back to diff and reset scroll.
func TestOpenFileViewSamePathIsNoOp(t *testing.T) {
m := filesPanelTestModel()
m = m.openFileView("web/app.js")
m = m.setFileViewMode(fileViewFull)
m.chatScrollOffset = 7 // scrolled within the file body
m = m.openFileView("web/app.js")
if m.fileView.mode != fileViewFull {
t.Fatal("re-opening the same file must keep full mode")
}
if m.chatScrollOffset != 7 {
t.Fatalf("re-opening the same file must keep the scroll, got %d", m.chatScrollOffset)
}
// A DIFFERENT file still switches (and resets to diff mode as documented).
m = m.openFileView("internal/tui/sidebar.go")
if m.fileView.path != "internal/tui/sidebar.go" || m.fileView.mode != fileViewDiff {
t.Fatalf("opening another file should switch views: %+v", m.fileView)
}
}
// TestFileViewFullBodyTruncatesLongFile: the full view stops reading at
// fileViewMaxLines (bounded read — a giant file must not be loaded wholesale)
// and appends the truncation trailer.
func TestFileViewFullBodyTruncatesLongFile(t *testing.T) {
dir := t.TempDir()
var b strings.Builder
for i := 0; i < fileViewMaxLines+50; i++ {
fmt.Fprintf(&b, "line %d\n", i)
}
if err := os.WriteFile(filepath.Join(dir, "big.txt"), []byte(b.String()), 0o644); err != nil {
t.Fatal(err)
}
m := filesPanelTestModel()
m.cwd = dir
m.gitTouched = []gitSweepFile{{path: "big.txt"}}
m = m.openFileView("big.txt")
plain := plainRender(t, m.renderFileViewFull(80))
lines := strings.Split(plain, "\n")
if len(lines) != fileViewMaxLines+1 { // capped content + the trailer line
t.Fatalf("rendered %d lines, want %d content lines + 1 trailer", len(lines), fileViewMaxLines)
}
if !strings.Contains(lines[len(lines)-1], "truncated") {
t.Fatalf("expected the truncation trailer, got %q", lines[len(lines)-1])
}
}
// TestFileViewKeysDeferToBlockingModal: with a permission prompt up, Esc and
// the d/f shortcuts belong to the prompt — the drill-in must not swallow them
// (Esc exiting the view instead of reaching the prompt's deny handling).
func TestFileViewKeysDeferToBlockingModal(t *testing.T) {
m := filesPanelTestModel()
m = m.openFileView("web/app.js")
m.pendingPermission = &pendingPermissionPrompt{
request: agent.PermissionRequest{ToolName: "write_file"},
decide: func(agent.PermissionDecision) {},
}
updated, _ := m.Update(tea.KeyPressMsg{Code: 'f', Text: "f"})
m = updated.(model)
if m.fileView.mode != fileViewDiff {
t.Fatal("f with a permission prompt up must not switch file-view modes")
}
updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
m = updated.(model)
if !m.fileView.active {
t.Fatal("Esc with a permission prompt up must not exit the file view")
}
}
-237
View File
@@ -1,237 +0,0 @@
// files_git_sweep.go fills the FILES sidebar's blind spot: mutations that
// bypass the file tools entirely — bash/exec_command scaffolding (npm create,
// go generate, heredoc writes) and subagents editing the shared workspace. None
// of those produce a changedFiles-carrying tool result, so the transcript-derived
// roster (files_panel.go) never sees them. The sweep asks git instead: a
// baseline `git status --porcelain` snapshot is taken at startup (Init), and a
// re-run after each command tool result / turn end reports any NEWLY dirty
// paths, which merge into the roster with a diffstat from `git diff --numstat`.
// Pre-existing dirty state stays in the baseline and never shows; a non-git
// workspace fails the first command and the sweep silently stays off.
package tui
import (
"context"
"strconv"
"strings"
"time"
tea "charm.land/bubbletea/v2"
)
// gitSweepTimeout bounds each background git invocation so a hung index lock
// can never stall message delivery (the command runs off the update goroutine).
const gitSweepTimeout = 3 * time.Second
// gitSweepFile is one workspace file git reports as dirty: workspace-relative
// path (git's own output convention, matching changedFiles), whether it is new
// (untracked/added), and the tracked diffstat when known (0/0 for untracked).
type gitSweepFile struct {
path string
created bool
adds int
dels int
}
// gitSweepMsg carries one sweep's result. baseline marks the startup snapshot
// (recorded as "pre-existing, never show") vs a live re-check (merged into the
// roster). ok is false when git failed (not a repo, no git binary) — the
// handler then marks the sweep unavailable rather than retrying every turn.
type gitSweepMsg struct {
baseline bool
ok bool
files []gitSweepFile
}
// gitSweepCmd runs the status+numstat pair off the update goroutine. cwd is the
// workspace root the TUI resolves paths against.
func gitSweepCmd(parent context.Context, cwd string, baseline bool) tea.Cmd {
return func() tea.Msg {
if parent == nil {
parent = context.Background()
}
ctx, cancel := context.WithTimeout(parent, gitSweepTimeout)
defer cancel()
// --untracked-files=all enumerates files inside new directories; plain
// porcelain collapses them to "dir/", which is useless as a FILES row.
status, err := defaultPRCommandRunner(ctx, cwd, "git", "status", "--porcelain", "--untracked-files=all")
if err != nil {
return gitSweepMsg{baseline: baseline, ok: false}
}
files := parseGitPorcelain(status)
// Diffstat for tracked modifications; untracked files have no diff to
// stat. -z makes the output machine-parseable: NUL-terminated records, no
// path quoting, and renames as explicit preimage/postimage fields instead
// of the brace-mangled "src/{old => new}" a plain --numstat emits (which
// would never match a porcelain path). Best-effort: a failure (e.g. unborn
// HEAD in a fresh repo) keeps the file list with zero counts rather than
// dropping the sweep.
if numstat, err := defaultPRCommandRunner(ctx, cwd, "git", "diff", "HEAD", "--numstat", "-z"); err == nil {
stats := parseGitNumstat(numstat)
for i := range files {
if counts, ok := stats[files[i].path]; ok {
files[i].adds, files[i].dels = counts[0], counts[1]
}
}
}
return gitSweepMsg{baseline: baseline, ok: true, files: files}
}
}
// parseGitPorcelain parses `git status --porcelain` v1 output into sweep files.
// Format: two status columns, a space, then the path ("XY path"); renames show
// "old -> new" (keep the new side); untracked entries are "?? path".
func parseGitPorcelain(out string) []gitSweepFile {
var files []gitSweepFile
for _, line := range strings.Split(out, "\n") {
if len(line) < 4 {
continue
}
code, path := line[:2], strings.TrimSpace(line[3:])
if to, _, found := cutRename(path); found {
path = to
}
path = unquoteGitPath(path)
if path == "" {
continue
}
files = append(files, gitSweepFile{
path: path,
created: code == "??" || strings.Contains(code, "A"),
})
}
return files
}
// cutRename splits a porcelain rename value "old -> new", returning the new
// path. found is false for ordinary (non-rename) paths.
func cutRename(path string) (string, string, bool) {
if idx := strings.Index(path, " -> "); idx >= 0 {
return path[idx+4:], path[:idx], true
}
return "", "", false
}
// unquoteGitPath strips the quotes git wraps around paths with special
// characters ("web/my file.js"). Escapes inside are left as-is — such a path
// still identifies the file well enough for a sidebar row.
func unquoteGitPath(path string) string {
if len(path) >= 2 && strings.HasPrefix(path, `"`) && strings.HasSuffix(path, `"`) {
return path[1 : len(path)-1]
}
return path
}
// parseGitNumstat parses `git diff --numstat -z` output into path → [added,
// deleted]. -z records are NUL-terminated "added\tdeleted\tpath"; a rename/copy
// instead ends the counts record with an EMPTY path ("added\tdeleted\t") and
// appends two more NUL-terminated fields, preimage then postimage — the stats
// key is the postimage (the path porcelain/changedFiles report). Paths are
// never quoted or brace-mangled in -z mode. Binary files report "-" counts and
// are skipped.
func parseGitNumstat(out string) map[string][2]int {
stats := map[string][2]int{}
fields := strings.Split(out, "\x00")
for i := 0; i < len(fields); i++ {
parts := strings.SplitN(fields[i], "\t", 3)
if len(parts) != 3 {
continue
}
adds, errA := strconv.Atoi(parts[0])
dels, errD := strconv.Atoi(parts[1])
if errA != nil || errD != nil {
continue
}
path := parts[2]
if path == "" {
// Rename/copy record: the next two fields are preimage, postimage.
if i+2 >= len(fields) {
break // truncated output — drop the partial record
}
path = fields[i+2]
i += 2
}
if path != "" {
stats[path] = [2]int{adds, dels}
}
}
return stats
}
// handleGitSweepMsg folds a sweep result into the model: the baseline snapshot
// records what was already dirty before this TUI session (those paths never
// show), a live sweep upserts newly dirty paths into gitTouched in first-seen
// order. A failed sweep marks git unavailable so no further sweeps are issued.
func (m model) handleGitSweepMsg(msg gitSweepMsg) model {
m.gitSweepInFlight = false
if !msg.ok {
m.gitSweepUnavailable = true
if m.gitFileBaseline == nil {
m.gitFileBaseline = map[string]bool{}
}
return m
}
if msg.baseline {
baseline := make(map[string]bool, len(msg.files))
for _, f := range msg.files {
baseline[f.path] = true
}
m.gitFileBaseline = baseline
return m
}
for _, f := range msg.files {
if m.gitFileBaseline[f.path] {
continue
}
found := false
for i := range m.gitTouched {
if m.gitTouched[i].path == f.path {
m.gitTouched[i] = f
found = true
break
}
}
if !found {
// Copy-on-append: model copies share the backing array, so an in-place
// append from two update branches could alias. Rebuilding is cheap at
// sidebar scale.
m.gitTouched = append(append([]gitSweepFile(nil), m.gitTouched...), f)
}
}
return m
}
// maybeGitSweep issues a live sweep when one is useful and none is running:
// the baseline exists (Init's snapshot answered), git works here, and the
// workspace is known. Returns the (possibly nil) command to batch.
func (m model) maybeGitSweep() (model, tea.Cmd) {
if m.gitSweepInFlight || m.gitSweepUnavailable || m.gitFileBaseline == nil || strings.TrimSpace(m.cwd) == "" {
return m, nil
}
m.gitSweepInFlight = true
return m, gitSweepCmd(m.ctx, m.cwd, false)
}
// gitTouchedFiles adapts the sweep results to the roster's touchedFile shape,
// for merging under the transcript-derived entries (files_panel.go), newest
// detection first — gitTouched upserts in first-seen order, so iterating it in
// reverse keeps the merged FILES tail consistent with the roster's
// newest-first ordering. No transcript row backs them (lastRowIndex -1), so
// selecting one skips the scroll/tint and the drill-in opens on the full file.
func (m model) gitTouchedFiles() []touchedFile {
if len(m.gitTouched) == 0 {
return nil
}
files := make([]touchedFile, 0, len(m.gitTouched))
for i := len(m.gitTouched) - 1; i >= 0; i-- {
f := m.gitTouched[i]
files = append(files, touchedFile{
path: f.path,
created: f.created,
adds: f.adds,
dels: f.dels,
lastRowIndex: -1,
})
}
return files
}
-243
View File
@@ -1,243 +0,0 @@
package tui
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestParseGitPorcelain(t *testing.T) {
out := " M web/app.js\n?? web/new.css\nA docs/added.md\nR old.go -> pkg/new.go\n?? \"web/my file.js\"\n\n"
files := parseGitPorcelain(out)
if len(files) != 5 {
t.Fatalf("expected 5 entries, got %d: %+v", len(files), files)
}
byPath := map[string]gitSweepFile{}
for _, f := range files {
byPath[f.path] = f
}
if f := byPath["web/app.js"]; f.created {
t.Error("modified file must not read as created")
}
if f := byPath["web/new.css"]; !f.created {
t.Error("untracked file should read as created")
}
if f := byPath["docs/added.md"]; !f.created {
t.Error("index-added file should read as created")
}
if _, ok := byPath["pkg/new.go"]; !ok {
t.Errorf("rename should keep the new path: %+v", files)
}
if _, ok := byPath["web/my file.js"]; !ok {
t.Errorf("quoted path should unquote: %+v", files)
}
}
// TestParseGitNumstat feeds the exact `git diff --numstat -z` record shapes:
// plain "added\tdeleted\tpath\0", binary "-\t-\tpath\0", and the rename form
// "added\tdeleted\t\0preimage\0postimage\0" — the stats must key the postimage
// (the path porcelain/changedFiles report), which the old newline parser lost
// (plain --numstat brace-mangles renames, so they never matched a roster path).
func TestParseGitNumstat(t *testing.T) {
out := "12\t3\tweb/app.js\x00" +
"-\t-\tassets/logo.png\x00" +
"4\t0\t\x00old.go\x00pkg/new.go\x00" +
"1\t1\tweb/my file.js\x00" // -z paths are never quoted
stats := parseGitNumstat(out)
if got := stats["web/app.js"]; got != [2]int{12, 3} {
t.Errorf("web/app.js = %v, want [12 3]", got)
}
if _, ok := stats["assets/logo.png"]; ok {
t.Error("binary '-' entries must be skipped")
}
if got := stats["pkg/new.go"]; got != [2]int{4, 0} {
t.Errorf("renamed file must key the postimage path = %v, want [4 0]", got)
}
if _, ok := stats["old.go"]; ok {
t.Error("the rename preimage must not appear as its own entry")
}
if got := stats["web/my file.js"]; got != [2]int{1, 1} {
t.Errorf("path with spaces = %v, want [1 1] (no quoting in -z mode)", got)
}
// Truncated rename record (missing postimage) must not panic or mis-key.
if got := parseGitNumstat("4\t0\t\x00old.go"); len(got) != 0 {
t.Errorf("truncated rename record should be dropped, got %v", got)
}
}
// TestGitSweepMergeAndBaseline: the baseline snapshot hides pre-existing dirty
// paths; live sweeps upsert only new ones; a failed sweep latches unavailable.
func TestGitSweepMergeAndBaseline(t *testing.T) {
m := model{}
m = m.handleGitSweepMsg(gitSweepMsg{baseline: true, ok: true, files: []gitSweepFile{{path: "dirty-before.go"}}})
if !m.gitFileBaseline["dirty-before.go"] {
t.Fatal("baseline should record pre-existing dirty paths")
}
m = m.handleGitSweepMsg(gitSweepMsg{ok: true, files: []gitSweepFile{
{path: "dirty-before.go", adds: 9},
{path: "kanban/board.tsx", created: true, adds: 120},
}})
if len(m.gitTouched) != 1 || m.gitTouched[0].path != "kanban/board.tsx" {
t.Fatalf("only newly dirty paths should merge: %+v", m.gitTouched)
}
// Re-sweep updates stats in place, no duplicate.
m = m.handleGitSweepMsg(gitSweepMsg{ok: true, files: []gitSweepFile{{path: "kanban/board.tsx", created: true, adds: 150, dels: 2}}})
if len(m.gitTouched) != 1 || m.gitTouched[0].adds != 150 {
t.Fatalf("re-sweep should upsert stats: %+v", m.gitTouched)
}
failed := model{}
failed = failed.handleGitSweepMsg(gitSweepMsg{baseline: true, ok: false})
if !failed.gitSweepUnavailable {
t.Fatal("a failed sweep should latch unavailable")
}
if _, cmd := failed.maybeGitSweep(); cmd != nil {
t.Fatal("no further sweeps once unavailable")
}
}
// TestMaybeGitSweepGating: no sweep before the baseline answers, none while one
// is in flight, and the in-flight flag sets when one is issued.
func TestMaybeGitSweepGating(t *testing.T) {
m := model{cwd: "/tmp"}
if _, cmd := m.maybeGitSweep(); cmd != nil {
t.Fatal("no baseline yet: sweep must not run")
}
m.gitFileBaseline = map[string]bool{}
next, cmd := m.maybeGitSweep()
if cmd == nil || !next.gitSweepInFlight {
t.Fatal("with a baseline, a sweep should be issued and marked in flight")
}
if _, again := next.maybeGitSweep(); again != nil {
t.Fatal("single-flight: no second sweep while one runs")
}
}
// TestTouchedFilesMergesGitSweep: git-discovered files append below the
// transcript-derived entries, deduped by path (a tool-result entry wins).
func TestTouchedFilesMergesGitSweep(t *testing.T) {
m := filesPanelTestModel()
m.gitTouched = []gitSweepFile{
{path: "kanban/board.tsx", created: true, adds: 120},
{path: "web/app.js", adds: 999}, // duplicate of a transcript entry
}
files := m.touchedFiles()
var kanban *touchedFile
appCount := 0
for i := range files {
if files[i].path == "kanban/board.tsx" {
kanban = &files[i]
}
if files[i].path == "web/app.js" {
appCount++
if files[i].adds == 999 {
t.Error("transcript-derived entry should win over the git duplicate")
}
}
}
if kanban == nil || !kanban.created || kanban.lastRowIndex != -1 {
t.Fatalf("git-only file should merge with created badge and no row anchor: %+v", files)
}
if appCount != 1 {
t.Fatalf("web/app.js should appear exactly once, got %d", appCount)
}
}
// TestOpenFileViewGitOnlyFallsBackToFull: a file with no edit cards (git sweep
// discovery) opens straight on the full-file view instead of an empty diff.
func TestOpenFileViewGitOnlyFallsBackToFull(t *testing.T) {
m := filesPanelTestModel()
m.gitTouched = []gitSweepFile{{path: "kanban/board.tsx", created: true}}
m = m.openFileView("kanban/board.tsx")
if m.fileView.mode != fileViewFull {
t.Fatal("git-only file should open in full mode")
}
m = m.exitFileView()
m = m.openFileView("web/app.js")
if m.fileView.mode != fileViewDiff {
t.Fatal("a file with edit cards still opens in diff mode")
}
}
// TestGitSweepCmdAgainstRealRepo: end-to-end against a real git repo — the
// baseline sees pre-existing dirt, a post-mutation sweep reports the new file
// as created and the modified file with its numstat.
func TestGitSweepCmdAgainstRealRepo(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
dir := t.TempDir()
run := func(args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
write := func(name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
run("init", "-q")
write("tracked.txt", "one\ntwo\n")
run("add", ".")
run("commit", "-q", "-m", "seed")
write("pre-existing.txt", "dirt\n") // dirty BEFORE the TUI "opens"
baseline := gitSweepCmd(nil, dir, true)().(gitSweepMsg)
if !baseline.ok || len(baseline.files) != 1 || baseline.files[0].path != "pre-existing.txt" {
t.Fatalf("baseline should see only the pre-existing dirt: %+v", baseline)
}
// The "agent" now scaffolds via the shell.
write("scaffolded.txt", "hello\n")
write("tracked.txt", "one\ntwo\nthree\nfour\n")
sweep := gitSweepCmd(nil, dir, false)().(gitSweepMsg)
if !sweep.ok {
t.Fatal("sweep failed")
}
byPath := map[string]gitSweepFile{}
for _, f := range sweep.files {
byPath[f.path] = f
}
if f, ok := byPath["scaffolded.txt"]; !ok || !f.created {
t.Fatalf("scaffolded file should report created: %+v", sweep.files)
}
if f := byPath["tracked.txt"]; f.adds != 2 || f.dels != 0 {
t.Fatalf("tracked.txt numstat = +%d −%d, want +2 −0", f.adds, f.dels)
}
// A rename WITH edits: porcelain reports the new path, and the -z numstat
// rename record must resolve to the same (postimage) path so the diffstat
// actually attaches — the plain --numstat form brace-mangles the path and
// the counts were silently lost.
run("add", ".")
run("commit", "-q", "-m", "second")
run("mv", "tracked.txt", "renamed.txt")
write("renamed.txt", "one\ntwo\nthree\nfour\nfive\n")
renameSweep := gitSweepCmd(nil, dir, false)().(gitSweepMsg)
if !renameSweep.ok {
t.Fatal("rename sweep failed")
}
renamed := map[string]gitSweepFile{}
for _, f := range renameSweep.files {
renamed[f.path] = f
}
if f, ok := renamed["renamed.txt"]; !ok || f.adds != 1 {
t.Fatalf("renamed file should carry its numstat under the new path, got %+v", renameSweep.files)
}
// Not-a-repo → ok=false (the sweep latches off).
if msg := gitSweepCmd(nil, t.TempDir(), false)().(gitSweepMsg); msg.ok {
t.Fatal("a non-repo should report ok=false")
}
}
-416
View File
@@ -1,416 +0,0 @@
// files_panel.go renders the FILES section of the right context sidebar: the
// workspace files this session has touched, newest first, with an A/M badge and
// a +added/−removed diffstat per file, plus a pulsing row for the file whose
// write is streaming right now. Like the swarm roster (sidebar.go), the touched
// set is not separate model state — it is recovered on demand from the
// transcript's tool-result rows (their changedFiles), so it survives resume for
// free and can never drift from what the chat shows.
//
// Interaction (see handleTranscriptSelectionMouse): the first click on a row
// SELECTS the file — its edit cards tint in the chat and the transcript scrolls
// to the most recent one; a second click (or a click while the drill-in is
// already open) opens the file view (file_view.go). Esc clears the selection.
package tui
import (
"fmt"
"sort"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/tools"
)
// maxSidebarFiles caps the FILES rows so the section stays a glanceable set,
// not a scrolling log; older files collapse into a "+N more" trailer.
const maxSidebarFiles = 6
// touchedFile is one workspace file the session has mutated, aggregated across
// every tool-result row that touched it.
type touchedFile struct {
path string
adds int // total added lines across its diffs
dels int // total removed lines across its diffs
edits int // number of mutations
created bool // first touch created the file (write_file "Created …")
failed bool // the latest touch errored
lastRowIndex int // transcript index of the most recent result touching it
}
// touchedFiles recovers the session's touched-file roster from the transcript,
// most recently touched first. Recomputed on demand (the value-receiver model
// can't persist a registry from View), mirroring swarmSpawnedAgents; the scan is
// a single pass over the transcript per render, same order as the sidebar's
// other sections.
func (m model) touchedFiles() []touchedFile {
var files []touchedFile
index := map[string]int{}
for i, row := range m.transcript {
if row.kind != rowToolResult || len(row.changedFiles) == 0 {
continue
}
adds, dels := planDiffStat(row.detail)
// A multi-file result (apply_patch spanning several files) must not charge
// the WHOLE patch's totals to every file it touched — split the diff by its
// per-file headers and attribute each file its own counts.
var perFile map[string][2]int
if len(row.changedFiles) > 1 {
perFile = perFileDiffStats(row.detail)
}
for _, path := range row.changedFiles {
if path == "" {
continue
}
at, seen := index[path]
if !seen {
index[path] = len(files)
files = append(files, touchedFile{
path: path,
created: resultRowCreatedFile(row),
lastRowIndex: i,
})
at = len(files) - 1
}
fileAdds, fileDels := adds, dels
if perFile != nil {
// Headers were found: use this file's own counts. A path the split
// couldn't attribute shows 0/0 rather than the inflated patch totals.
counts := perFile[path]
fileAdds, fileDels = counts[0], counts[1]
}
files[at].adds += fileAdds
files[at].dels += fileDels
files[at].edits++
files[at].failed = row.status == tools.StatusError
files[at].lastRowIndex = i
}
}
// Most recently touched first: the file being worked on now belongs at the
// top, like the sidebar's ACTIVITY feed. Sorted by last touch (not reversed
// first-seen order): a file touched early and touched again last must list
// first. Stable, so same-row files keep their changedFiles order.
sort.SliceStable(files, func(i, j int) bool {
return files[i].lastRowIndex > files[j].lastRowIndex
})
// Merge the git sweep's discoveries (bash/subagent mutations that carry no
// changedFiles — files_git_sweep.go) below the transcript-derived entries,
// skipping paths a tool result already reported.
for _, f := range m.gitTouchedFiles() {
if _, seen := index[f.path]; seen {
continue
}
files = append(files, f)
}
return files
}
// perFileDiffStats splits a (possibly multi-file) unified diff into per-path
// +added/−removed counts, keyed by the workspace-relative path from each
// section's "+++ b/…" header (the "--- a/…" old side when the new side is
// /dev/null, i.e. a deletion). Returns nil when the detail carries no file
// headers at all (write_file/edit_file previews may be bare hunks) — the caller
// then falls back to whole-detail totals, which are correct for a single file.
func perFileDiffStats(detail string) map[string][2]int {
stats := map[string][2]int{}
current, oldPath := "", ""
normalize := func(line string, prefix string) string {
path := strings.TrimSpace(line[4:])
if tab := strings.IndexByte(path, '\t'); tab >= 0 {
path = path[:tab] // unified-diff convention: optional timestamp after a tab
}
path = unquoteGitPath(path)
if path == "/dev/null" {
return ""
}
return strings.TrimPrefix(path, prefix)
}
for _, line := range strings.Split(detail, "\n") {
switch {
case strings.HasPrefix(line, "--- "):
oldPath = normalize(line, "a/")
case strings.HasPrefix(line, "+++ "):
current = normalize(line, "b/")
if current == "" {
current = oldPath // deletion: charge the removed lines to the old path
}
case current == "":
// Lines before the first header (or in an unattributable section).
case strings.HasPrefix(line, "+"):
counts := stats[current]
counts[0]++
stats[current] = counts
case strings.HasPrefix(line, "-"):
counts := stats[current]
counts[1]++
stats[current] = counts
}
}
if len(stats) == 0 {
return nil
}
return stats
}
// resultRowCreatedFile reports whether a tool-result row reads as a file
// creation ("tool result: write_file ok Created x (…)"), so the FILES row can
// badge it A rather than M. Heuristic on the confirmation text write_file
// already emits; a miss just shows M, never breaks anything.
func resultRowCreatedFile(row transcriptRow) bool {
return strings.Contains(row.text, " Created ")
}
// liveEditingPath is the path of the file whose mutating tool call is streaming
// its arguments RIGHT NOW (the same live write streamingToolCallView previews),
// or "" when no mutation is in flight. It renders as a pulsing row pinned atop
// the FILES section.
func (m model) liveEditingPath() string {
if m.streamCallDecoder == nil {
return ""
}
switch m.streamCallName {
case "write_file", "edit_file", "apply_patch":
return m.streamCallDecoder.path
}
return ""
}
// fileHit marks a rendered FILES row (by its ABSOLUTE index inside the rendered
// sidebar) that is clickable, carrying the file it refers to.
type fileHit struct {
lineOffset int
path string
}
// sidebarFilesHeader renders the FILES section header with the touched count.
func (m model) sidebarFilesHeader(width int) string {
n := len(m.touchedFiles())
if n == 0 {
return sidebarHeader("FILES", width)
}
return sidebarHeaderWithCount("FILES", fmt.Sprintf("%d", n), zeroTheme.muted, width)
}
// sidebarFileLines renders the FILES section body: the live "writing" pulse row
// first, then the touched files newest-first, capped at maxSidebarFiles with a
// "+N more" trailer. Returns nil when the session has touched nothing (the
// caller shows a placeholder). The paired hits slice records each clickable
// row's index WITHIN the returned lines.
func (m model) sidebarFileLines(width int) ([]string, []fileHit) {
files := m.touchedFiles()
live := m.liveEditingPath()
if len(files) == 0 && live == "" {
return nil, nil
}
room := maxInt(4, width-3)
var lines []string
var hits []fileHit
// The file being written this instant: an accent pulse, not yet clickable
// (its result row — the diff — doesn't exist until the call completes).
if live != "" {
lines = append(lines, " "+zeroTheme.accent.Render("●")+" "+
zeroTheme.ink.Render(truncatePathLeft(live, room)))
}
// Filter the live row out FIRST so the "+N more" trailer counts only rows
// that could actually render — counting the skipped live entry would inflate
// the overflow by one.
visible := files[:0:0]
for _, f := range files {
if f.path != live {
visible = append(visible, f)
}
}
shown := 0
for _, f := range visible {
if shown >= maxSidebarFiles {
lines = append(lines, " "+zeroTheme.faint.Render(fmt.Sprintf("+%d more", len(visible)-shown)))
break
}
shown++
hits = append(hits, fileHit{lineOffset: len(lines), path: f.path})
lines = append(lines, m.renderFileRow(f, room))
}
return lines, hits
}
// renderFileRow renders one touched file: status badge, left-truncated path,
// and the +/− diffstat, with the selected file carrying an accent marker so the
// selection reads in the sidebar as well as in the chat tint.
func (m model) renderFileRow(f touchedFile, room int) string {
badge := zeroTheme.muted.Render("M")
switch {
case f.failed:
badge = zeroTheme.red.Render("✗")
case f.created:
badge = zeroTheme.green.Render("A")
}
// Reserve the diffstat's width so the path truncates around it.
stat := ""
if f.adds > 0 || f.dels > 0 {
stat = fmt.Sprintf(" +%d −%d", f.adds, f.dels)
}
pathRoom := maxInt(4, room-len(stat))
pathStyle := zeroTheme.muted
lead := " "
if m.selectedFile == f.path {
lead = zeroTheme.accent.Render("▸")
pathStyle = zeroTheme.ink
}
line := lead + badge + " " + pathStyle.Render(truncatePathLeft(f.path, pathRoom))
if stat != "" {
line += zeroTheme.faintest.Render(stat)
}
return line
}
// sidebarFileSelectables returns the clickable FILES rows with their ABSOLUTE
// index inside the rendered sidebar. The FILES section renders after PLAN in
// renderContextSidebar: AGENTS header + body, blank + PLAN header + body, then
// blank + FILES header, then the file rows. The offset accounting mirrors
// sidebarPlanSelectables exactly, extended one section down.
func (m model) sidebarFileSelectables(width int) []fileHit {
lines, hits := m.sidebarFileLines(width)
if len(lines) == 0 {
return nil
}
agentBody := len(m.sidebarAgentLines(width))
if agentBody == 0 {
agentBody = 1 // the "no agents spawned" placeholder occupies one line
}
planBody := len(m.sidebarPlanLines(width))
if planBody == 0 {
planBody = 1 // the "no active plan" placeholder occupies one line
}
base := 1 + agentBody + 2 + planBody + 2 // sections above + (blank + FILES header)
for i := range hits {
hits[i].lineOffset += base
}
return hits
}
// fileRowAtMouse maps a left-click in the context sidebar to a touched file,
// mirroring planStepAtMouse's column/x gate. Rows truncated away by the
// sidebar's height budget (or colliding with the token floor line) never match.
func (m model) fileRowAtMouse(msg tea.MouseMsg) (string, bool) {
if !m.sidebarActive() {
return "", false
}
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return "", false
}
sidebarW := sidebarWidth(m.width)
if sidebarW <= 0 {
return "", false
}
x0 := m.chatColumnWidth() + 3 // " │ " divider between the columns
x, y := mouseX(msg), mouseY(msg)
if x < x0 || x >= x0+sidebarW {
return "", false
}
for _, hit := range m.sidebarFileSelectables(sidebarW) {
if hit.lineOffset == y && hit.lineOffset < m.height-1 && hit.path != "" {
return hit.path, true
}
}
return "", false
}
// rowTouchesSelectedFile reports whether a transcript row's mutation touched
// the currently selected file, so its card renders with the selection tint.
func (m model) rowTouchesSelectedFile(row transcriptRow) bool {
if m.selectedFile == "" || row.kind != rowToolResult {
return false
}
for _, path := range row.changedFiles {
if path == m.selectedFile {
return true
}
}
return false
}
// lastRowIndexForFile returns the transcript index of the most recent
// tool-result row that touched path, or -1.
func (m model) lastRowIndexForFile(path string) int {
for i := len(m.transcript) - 1; i >= 0; i-- {
row := m.transcript[i]
if row.kind != rowToolResult {
continue
}
for _, p := range row.changedFiles {
if p == path {
return i
}
}
}
return -1
}
// selectFile marks path as the selected file and scrolls the transcript so its
// most recent edit card is in view; the card tint comes from the renderers
// reading selectedFile (rowTouchesSelectedFile).
func (m model) selectFile(path string) model {
m.selectedFile = path
if offset, ok := m.scrollOffsetForTranscriptRow(m.lastRowIndexForFile(path)); ok {
m.chatScrollOffset = offset
if offset == 0 {
m.chatBodyLines = 0
}
}
return m
}
// scrollOffsetForTranscriptRow computes the chatScrollOffset that places the
// given transcript row's first rendered line at the top of the viewport (with
// one line of breathing room), using the same layout metrics the scroll engine
// itself uses. ok is false outside alt-screen or when the row isn't rendered
// (e.g. skipped/collapsed).
func (m model) scrollOffsetForTranscriptRow(rowIndex int) (int, bool) {
if rowIndex < 0 || !m.altScreen || m.height <= 0 {
return 0, false
}
width := m.chatColumnWidth()
items := m.transcriptBodyItems(width, "")
metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
startY := -1
for _, span := range metrics.spans {
if span.kind == transcriptBodyItemRow && span.rowIndex == rowIndex {
startY = span.startY
break
}
}
if startY < 0 {
return 0, false
}
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
viewport := transcriptViewportForLayout(metrics, frame, m.chatScrollOffset)
// The offset counts lines below the fold: window.start == total-height-offset,
// so placing startY at the window top (minus one context line) means
// offset = total - height - (startY - 1), clamped to the scroll range.
offset := metrics.totalLines() - frame.bodyRect.height - (startY - 1)
return clampInt(offset, 0, viewport.maxOffset()), true
}
// truncatePathLeft fits a path into room cells by dropping leading components
// ("internal/tui/sidebar.go" → "…/tui/sidebar.go"), keeping the filename — the
// part that identifies the file — intact for as long as possible.
func truncatePathLeft(path string, room int) string {
if room <= 0 {
return ""
}
if len(path) <= room {
return path
}
const ellipsis = "…/"
parts := strings.Split(path, "/")
for start := 1; start < len(parts); start++ {
candidate := ellipsis + strings.Join(parts[start:], "/")
if len(candidate) <= room {
return candidate
}
}
// Even "…/name" overflows: fall back to a right-truncated basename.
return truncateRunes(ellipsis+parts[len(parts)-1], room)
}
-317
View File
@@ -1,317 +0,0 @@
package tui
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/tools"
)
// filesPanelTestModel is sidebarTestModel plus a couple of touched files: one
// created, one edited twice (the second touch most recent).
func filesPanelTestModel() model {
m := sidebarTestModel()
m.transcript = append(m.transcript,
transcriptRow{kind: rowToolResult, tool: "write_file", id: "f1", status: tools.StatusOK,
text: "tool result: write_file ok Created web/app.js (10 lines).",
detail: "+let a = 1\n+let b = 2",
changedFiles: []string{"web/app.js"}},
transcriptRow{kind: rowToolResult, tool: "edit_file", id: "f2", status: tools.StatusOK,
text: "tool result: edit_file ok Edited internal/tui/sidebar.go.",
detail: "+added one\n-removed one",
changedFiles: []string{"internal/tui/sidebar.go"}},
transcriptRow{kind: rowToolResult, tool: "edit_file", id: "f3", status: tools.StatusOK,
text: "tool result: edit_file ok Edited internal/tui/sidebar.go.",
detail: "+two\n+three\n-gone",
changedFiles: []string{"internal/tui/sidebar.go"}},
)
return m
}
// TestTouchedFilesAggregates: the roster is recovered from tool-result rows,
// newest-touch first, with per-file diffstat totals, edit counts, and the
// created badge from write_file's confirmation text.
func TestTouchedFilesAggregates(t *testing.T) {
m := filesPanelTestModel()
files := m.touchedFiles()
if len(files) != 2 {
t.Fatalf("expected 2 touched files, got %d: %+v", len(files), files)
}
// sidebar.go was touched last, so it lists first.
if files[0].path != "internal/tui/sidebar.go" {
t.Fatalf("most recently touched file should list first, got %q", files[0].path)
}
if files[0].adds != 3 || files[0].dels != 2 || files[0].edits != 2 {
t.Errorf("sidebar.go diffstat = +%d −%d over %d edits, want +3 −2 over 2", files[0].adds, files[0].dels, files[0].edits)
}
if files[0].created {
t.Error("edited file must not read as created")
}
if !files[1].created {
t.Error("write_file 'Created …' result should mark the file created")
}
if files[0].lastRowIndex != len(m.transcript)-1 {
t.Errorf("lastRowIndex = %d, want the final transcript row", files[0].lastRowIndex)
}
}
// TestSidebarFileLinesRenderAndOverflow: rows carry the badge + left-truncated
// path + diffstat; beyond maxSidebarFiles the tail collapses into "+N more";
// hits index only real file rows.
func TestSidebarFileLinesRenderAndOverflow(t *testing.T) {
m := filesPanelTestModel()
lines, hits := m.sidebarFileLines(34)
joined := plainRender(t, strings.Join(lines, "\n"))
if !strings.Contains(joined, "sidebar.go") || !strings.Contains(joined, "app.js") {
t.Fatalf("file rows missing paths:\n%s", joined)
}
if !strings.Contains(joined, "+3 −2") {
t.Errorf("expected aggregated diffstat +3 −2:\n%s", joined)
}
if !strings.Contains(joined, "A") || !strings.Contains(joined, "M") {
t.Errorf("expected A and M badges:\n%s", joined)
}
if len(hits) != 2 {
t.Fatalf("expected 2 clickable hits, got %d", len(hits))
}
if hits[0].path != "internal/tui/sidebar.go" {
t.Errorf("first hit should be the newest file, got %q", hits[0].path)
}
// Overflow: 8 distinct files -> maxSidebarFiles rows + a "+2 more" trailer.
over := sidebarTestModel()
for _, name := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} {
over.transcript = append(over.transcript, transcriptRow{
kind: rowToolResult, tool: "edit_file", id: name, status: tools.StatusOK,
changedFiles: []string{name + ".go"},
})
}
overLines, overHits := over.sidebarFileLines(34)
if len(overHits) != maxSidebarFiles {
t.Fatalf("expected %d clickable rows, got %d", maxSidebarFiles, len(overHits))
}
if got := plainRender(t, overLines[len(overLines)-1]); !strings.Contains(got, "+2 more") {
t.Errorf("expected '+2 more' trailer, got %q", got)
}
}
// TestSidebarFilesLivePulseRow: while a mutating tool call streams its args,
// the file it is writing shows as a pinned (unclickable) pulse row.
func TestSidebarFilesLivePulseRow(t *testing.T) {
m := filesPanelTestModel()
m.streamCallName = "write_file"
m.streamCallDecoder = newStreamingDecoder()
m.streamCallDecoder.feed(`{"path":"web/new.css","content":"body{}`)
lines, hits := m.sidebarFileLines(34)
if got := plainRender(t, lines[0]); !strings.Contains(got, "web/new.css") {
t.Fatalf("live write should pin its path atop FILES, got %q", got)
}
for _, hit := range hits {
if hit.path == "web/new.css" {
t.Error("the live row must not be clickable (no result exists yet)")
}
}
// A non-mutating streaming call shows no pulse row.
m.streamCallName = "read_file"
if m.liveEditingPath() != "" {
t.Error("read_file must not read as a live edit")
}
}
// TestSidebarFileSelectablesMatchRenderedRows: the hit offsets computed by
// sidebarFileSelectables must land exactly on the rendered FILES rows in
// renderContextSidebar's output — the same invariant the mouse hit-test relies
// on. Asserted against the real render, not a re-derivation of the math.
func TestSidebarFileSelectablesMatchRenderedRows(t *testing.T) {
m := filesPanelTestModel()
width := sidebarWidth(m.width)
rendered := m.renderContextSidebar(width, m.height)
hits := m.sidebarFileSelectables(width)
if len(hits) == 0 {
t.Fatal("expected clickable FILES rows")
}
for _, hit := range hits {
if hit.lineOffset >= len(rendered) {
t.Fatalf("hit offset %d beyond rendered sidebar (%d lines)", hit.lineOffset, len(rendered))
}
line := plainRender(t, rendered[hit.lineOffset])
base := hit.path[strings.LastIndex(hit.path, "/")+1:]
if !strings.Contains(line, base) {
t.Errorf("hit for %q points at line %d which reads %q", hit.path, hit.lineOffset, line)
}
}
}
// TestFileRowClickSelectsThenOpens: the first sidebar click on a file selects
// it (tint + scroll state, no view swap); the second click on the same file
// opens the drill-in; clicking another file while the view is open switches it.
func TestFileRowClickSelectsThenOpens(t *testing.T) {
m := filesPanelTestModel()
width := sidebarWidth(m.width)
hits := m.sidebarFileSelectables(width)
if len(hits) < 2 {
t.Fatal("expected two clickable FILES rows")
}
x := m.chatColumnWidth() + 3
click := func(hit fileHit) tea.MouseMsg { return testMouseClick(tea.MouseLeft, x, hit.lineOffset) }
m1, _, handled := m.handleTranscriptSelectionMouse(click(hits[0]))
if !handled {
t.Fatal("FILES row click should be handled")
}
if m1.selectedFile != hits[0].path || m1.fileView.active {
t.Fatalf("first click should select (not open): selected=%q active=%v", m1.selectedFile, m1.fileView.active)
}
m2, _, _ := m1.handleTranscriptSelectionMouse(click(hits[0]))
if !m2.fileView.active || m2.fileView.path != hits[0].path {
t.Fatalf("second click should open the file view: %+v", m2.fileView)
}
m3, _, _ := m2.handleTranscriptSelectionMouse(click(hits[1]))
if !m3.fileView.active || m3.fileView.path != hits[1].path {
t.Fatalf("clicking another file with the view open should switch it: %+v", m3.fileView)
}
if m3.selectedFile != hits[1].path {
t.Errorf("switching the view should move the selection too, got %q", m3.selectedFile)
}
}
// TestRowTouchesSelectedFileTint: only tool-result rows whose changedFiles
// carry the selected path tint, and the render cache key differs across
// selection states so the tint can't be served stale.
func TestRowTouchesSelectedFileTint(t *testing.T) {
m := filesPanelTestModel()
row := m.transcript[len(m.transcript)-1]
if m.rowTouchesSelectedFile(row) {
t.Fatal("nothing selected: no row should tint")
}
m.selectedFile = "internal/tui/sidebar.go"
if !m.rowTouchesSelectedFile(row) {
t.Fatal("selected file's edit row should tint")
}
other := m.transcript[len(m.transcript)-3] // web/app.js
if m.rowTouchesSelectedFile(other) {
t.Fatal("an unrelated file's row must not tint")
}
rc := buildRowContext(m.transcript)
opts := cardRenderOptions{bodyCap: cardBodyMaxLines, cwd: m.cwd}
selectedKey, _ := m.renderRowCacheKey(row, 80, rc, opts, false)
m.selectedFile = ""
plainKey, _ := m.renderRowCacheKey(row, 80, rc, opts, false)
if selectedKey == plainKey {
t.Error("render cache key must change with the selection state")
}
}
// TestEscClearsFileSelectionThenNothing: Esc drops an active file selection
// (before any run-level action) and is consumed doing so.
func TestEscClearsFileSelectionThenNothing(t *testing.T) {
m := filesPanelTestModel()
m.selectedFile = "web/app.js"
updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
if got := updated.(model).selectedFile; got != "" {
t.Fatalf("Esc should clear the file selection, still %q", got)
}
}
func TestTruncatePathLeft(t *testing.T) {
if got := truncatePathLeft("internal/tui/sidebar.go", 40); got != "internal/tui/sidebar.go" {
t.Errorf("short path should pass through, got %q", got)
}
if got := truncatePathLeft("internal/tui/sidebar.go", 18); got != "…/tui/sidebar.go" {
t.Errorf("want …/tui/sidebar.go, got %q", got)
}
if got := truncatePathLeft("internal/tui/sidebar.go", 14); got != "…/sidebar.go" {
t.Errorf("want …/sidebar.go, got %q", got)
}
}
// TestTouchedFilesPerFileDiffStat: a multi-file result (apply_patch spanning
// several files) must charge each file its OWN +/- counts from the per-file
// diff sections — not the whole patch's totals to every file it touched.
func TestTouchedFilesPerFileDiffStat(t *testing.T) {
m := sidebarTestModel()
patch := "diff --git a/web/one.js b/web/one.js\n" +
"--- a/web/one.js\n+++ b/web/one.js\n@@ -1,1 +1,2 @@\n+added one\n+added two\n" +
"diff --git a/web/two.js b/web/two.js\n" +
"--- a/web/two.js\n+++ b/web/two.js\n@@ -1,2 +1,1 @@\n-gone one\n-gone two\n-gone three\n"
m.transcript = append(m.transcript, transcriptRow{
kind: rowToolResult, tool: "apply_patch", id: "p1", status: tools.StatusOK,
detail: patch,
changedFiles: []string{"web/one.js", "web/two.js"},
})
byPath := map[string]touchedFile{}
for _, f := range m.touchedFiles() {
byPath[f.path] = f
}
if f := byPath["web/one.js"]; f.adds != 2 || f.dels != 0 {
t.Errorf("web/one.js = +%d −%d, want its own +2 −0 (not the patch totals)", f.adds, f.dels)
}
if f := byPath["web/two.js"]; f.adds != 0 || f.dels != 3 {
t.Errorf("web/two.js = +%d −%d, want its own +0 −3 (not the patch totals)", f.adds, f.dels)
}
}
// TestTouchedFilesRetouchOrdersByLastTouch: touch a, then b, then a again — a
// must list first. The old "reverse first-seen order" put b first because a's
// slot kept its original position.
func TestTouchedFilesRetouchOrdersByLastTouch(t *testing.T) {
m := sidebarTestModel()
for _, step := range []struct{ id, path string }{
{"r1", "a.go"}, {"r2", "b.go"}, {"r3", "a.go"},
} {
m.transcript = append(m.transcript, transcriptRow{
kind: rowToolResult, tool: "edit_file", id: step.id, status: tools.StatusOK,
changedFiles: []string{step.path},
})
}
files := m.touchedFiles()
if len(files) != 2 || files[0].path != "a.go" {
t.Fatalf("the re-touched file must list first, got %+v", files)
}
}
// TestSidebarFileLinesOverflowExcludesLiveRow: when the live-writing file is
// also a touched file, it renders as the pulse row and is skipped from the
// list — the "+N more" trailer must not count that skipped entry.
func TestSidebarFileLinesOverflowExcludesLiveRow(t *testing.T) {
m := sidebarTestModel()
for _, name := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} {
m.transcript = append(m.transcript, transcriptRow{
kind: rowToolResult, tool: "edit_file", id: name, status: tools.StatusOK,
changedFiles: []string{name + ".go"},
})
}
// h.go (a touched file) is ALSO the live write: 7 listable files remain,
// 6 render, so the trailer is +1 — not +2 (which counted the skipped live row).
m.streamCallName = "edit_file"
m.streamCallDecoder = newStreamingDecoder()
m.streamCallDecoder.feed(`{"path":"h.go","content":"x`)
lines, _ := m.sidebarFileLines(34)
trailer := plainRender(t, lines[len(lines)-1])
if !strings.Contains(trailer, "+1 more") {
t.Fatalf("overflow must exclude the live row from the count, got %q", trailer)
}
}
// TestSidebarHasContentForLiveWrite: the sidebar counts an in-flight first
// write as content, so the FILES pulse is visible before any result row lands.
func TestSidebarHasContentForLiveWrite(t *testing.T) {
m := sidebarTestModel()
m.plan.steps = nil // drop the helper's seeded plan: no agents/plan/files now
if m.sidebarHasContent() {
t.Fatal("session without agents/plan/files should have no sidebar content")
}
m.streamCallName = "write_file"
m.streamCallDecoder = newStreamingDecoder()
m.streamCallDecoder.feed(`{"path":"web/new.css","content":"body{}`)
if !m.sidebarHasContent() {
t.Fatal("a live in-flight write must count as sidebar content")
}
}
-4
View File
@@ -139,10 +139,6 @@ func (m model) drainFlushQueue() (model, tea.Cmd) {
// surface ended.
func (m *model) resetFlushFrontier(divider string) {
m.flushed = 0
// Renumbers every row's bodyY from the top (used by /clear, /resume, /rewind,
// /compact); a transcript-hover target's bodyY would otherwise risk
// coincidentally matching an unrelated row in the rebuilt transcript.
m.hover = hoverTarget{}
if divider != "" && m.flushedAny {
m.flushQueue = append(m.flushQueue, zeroTheme.faint.Render(divider))
}
-2
View File
@@ -122,8 +122,6 @@ func TestEscCancellationLeavesVisibleMarker(t *testing.T) {
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if !transcriptContains(next.transcript, "Run cancelled.") {
t.Fatalf("expected visible cancellation marker, got %#v", next.transcript)
}
-124
View File
@@ -1,124 +0,0 @@
package tui
import tea "charm.land/bubbletea/v2"
// hoverKind discriminates which clickable surface a hoverTarget refers to.
type hoverKind int
const (
hoverNone hoverKind = iota
// hoverTranscript: a specialist card or collapse/expand toggle header in the
// main transcript body (or the subchat child view), identified by bodyY.
hoverTranscript
// hoverSidebarAgent: a clickable AGENTS sidebar row, identified by sessionID.
hoverSidebarAgent
// hoverPlanStep: a plan step row in the sidebar's PLAN section, identified by
// step index.
hoverPlanStep
// hoverFileRow: a touched-file row in the sidebar's FILES section, identified
// by path.
hoverFileRow
)
// hoverTarget identifies the single clickable row (if any) currently under the
// mouse cursor with no button pressed. Exactly one of bodyY/sessionID/stepIndex is
// meaningful, discriminated by kind.
//
// The sidebar fields deliberately store a STABLE IDENTITY (sessionID, stepIndex),
// not a raw line offset: the AGENTS/PLAN section sizes can change between when a
// hover is detected and when the sidebar next renders (a swarm member's linger
// window elapsing, a plan step completing) with no mouse motion in between to
// re-resolve it. A cached raw offset would then silently point at whatever
// unrelated row now occupies that slot. hoveredSidebarLineOffset (sidebar.go)
// re-resolves the CURRENT line offset for this identity fresh on every render, so
// a row that's gone simply doesn't highlight rather than a wrong one lighting up.
type hoverTarget struct {
kind hoverKind
bodyY int // hoverTranscript
sessionID string // hoverSidebarAgent
stepIndex int // hoverPlanStep
filePath string // hoverFileRow
}
// mouseHover reports whether msg is a plain cursor-movement event with NO button
// pressed — a hover, not a drag. Requires AllMotion mouse reporting (see View's
// wantsMouseCapture branch); CellMotion only reports motion while a button is held,
// so this predicate would never match under the old mouse mode.
func mouseHover(msg tea.MouseMsg) bool {
if _, ok := msg.(tea.MouseMotionMsg); !ok {
return false
}
return mouseEvent(msg).Button == tea.MouseNone
}
// updateHoverTarget resolves what's under the cursor for a hover motion, using
// the SAME hit-testers and priority order as the click handlers in
// handleTranscriptSelectionMouse's press case: a sidebar row (agent, then plan
// step) takes priority since it's outside the chat column, then a clickable
// transcript line. Clears the hover when nothing clickable is under the cursor.
func (m model) updateHoverTarget(msg tea.MouseMsg) model {
if hit, ok := m.sidebarLineAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverSidebarAgent, sessionID: hit.sessionID})
}
if stepIndex, ok := m.planStepAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverPlanStep, stepIndex: stepIndex})
}
if path, ok := m.fileRowAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverFileRow, filePath: path})
}
if line, ok := m.transcriptLineAtMouse(msg); ok {
// A permission option reuses its OWN existing keyboard-cursor highlight
// (see hoverPermissionOption) rather than the m.hover mechanism, so there's
// nothing further to set here.
if line.permOption {
return m.hoverPermissionOption(line.permChoice).withHover(hoverTarget{})
}
// Only a card or a collapse/expand toggle header is "clickable" here; plain
// selectable text (e.g. a user/assistant message) isn't, so hovering over
// it must not light up as if it were.
if line.specialistCard || line.toggle {
return m.withHover(hoverTarget{kind: hoverTranscript, bodyY: line.bodyY})
}
}
return m.withHover(hoverTarget{})
}
// hoverPermissionOption moves the pending permission prompt's cursor to the
// hovered option, reusing its EXISTING keyboard-navigation highlight (the same
// one Tab/Shift+Tab/arrow keys move) instead of new styling — the popup already
// renders whichever option .cursor points at differently. A no-op when no
// permission prompt is pending or the choice isn't found (defensive; every
// permOption line's choice always comes from permissionOptions(request) in the
// first place).
func (m model) hoverPermissionOption(choice permissionDecision) model {
if m.pendingPermission == nil {
return m
}
for index, option := range permissionOptions(m.pendingPermission.request) {
if option.choice == choice {
m.pendingPermission.cursor = index
return m
}
}
return m
}
func (m model) withHover(t hoverTarget) model {
m.hover = t
return m
}
// clearHover drops any hover highlight. Called wherever the underlying content a
// hover target refers to may no longer mean the same thing on the next render — a
// wheel-scroll (the cursor's mapping to a bodyY is only recomputed on the NEXT
// motion event; without clearing, a stale bodyY could coincidentally match a
// DIFFERENT row after the viewport shifts, highlighting the wrong thing) or a
// subchat toggle (bodyY numbering is entirely different between the parent
// transcript and a child session).
func (m model) clearHover() model {
if m.hover.kind == hoverNone {
return m
}
m.hover = hoverTarget{}
return m
}
-284
View File
@@ -1,284 +0,0 @@
package tui
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/agent"
)
func TestMouseHoverPredicate(t *testing.T) {
if !mouseHover(testMouseMotion(tea.MouseNone, 5, 5)) {
t.Error("a motion event with no button pressed must be a hover")
}
if mouseHover(testMouseMotion(tea.MouseLeft, 5, 5)) {
t.Error("a motion event WITH a button pressed is a drag, not a hover")
}
if mouseHover(testMouseClick(tea.MouseLeft, 5, 5)) {
t.Error("a click is not a hover")
}
if mouseHover(testMouseWheel(tea.MouseWheelDown, 5, 5)) {
t.Error("a wheel event is not a hover")
}
}
func TestUpdateHoverTargetOnSpecialistCard(t *testing.T) {
m := specialistClickTestModel(t, "child-sess-1")
x, y, ok := specialistCardClickPoint(m, 2)
if !ok {
t.Fatal("expected a specialist card selectable line in the layout")
}
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x, y))
m = updated.(model)
if m.hover.kind != hoverTranscript {
t.Fatalf("hover.kind = %v, want hoverTranscript over a specialist card", m.hover.kind)
}
}
func TestUpdateHoverTargetOnToggleRow(t *testing.T) {
m := mouseTestModel()
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowReasoning, text: "private thought"})
width := m.chatColumnWidth()
body, selectable := m.transcriptBody(width, "")
start, _, top := m.transcriptViewportStart(body, width)
var target transcriptSelectableLine
for _, line := range selectable {
if line.toggle {
target = line
break
}
}
if !target.toggle {
t.Fatalf("expected reasoning header to be clickable, selectable=%#v", selectable)
}
updated, _ := m.Update(testMouseMotion(tea.MouseNone, target.textStart, top+target.bodyY-start))
m = updated.(model)
if m.hover.kind != hoverTranscript {
t.Fatalf("hover.kind = %v, want hoverTranscript over a toggle header", m.hover.kind)
}
if m.hover.bodyY != target.bodyY {
t.Fatalf("hover.bodyY = %d, want %d", m.hover.bodyY, target.bodyY)
}
}
func TestUpdateHoverTargetOnPlainTextIsNone(t *testing.T) {
m := mouseTestModel()
m.transcript = appendRow(m.transcript, rowUser, "hello world")
textY := firstTranscriptTextMouseY(t, m)
updated, _ := m.Update(testMouseMotion(tea.MouseNone, 3, textY))
m = updated.(model)
if m.hover.kind != hoverNone {
t.Fatalf("hover.kind = %v, want hoverNone over plain (non-clickable) text", m.hover.kind)
}
}
func TestUpdateHoverTargetOnSidebarAgentRow(t *testing.T) {
// Only a SWARM member row (a session mapped via swarmSessionMap) is clickable
// in the sidebar — a Task-delegation specialist row is not (sidebarAgentRows
// only records hits from the swarm loop). swarmSidebarTestModel builds exactly
// that: real conversation + a mapped swarm member session.
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
if !m.sidebarActive() {
t.Fatal("sanity check failed: sidebar should be active with a swarm member present on a 100-col terminal")
}
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected at least one clickable sidebar agent row")
}
x0 := m.chatColumnWidth() + 3
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x0+1, hits[0].lineOffset))
m = updated.(model)
if m.hover.kind != hoverSidebarAgent {
t.Fatalf("hover.kind = %v, want hoverSidebarAgent", m.hover.kind)
}
if m.hover.sessionID != hits[0].sessionID {
t.Fatalf("hover.sessionID = %q, want %q", m.hover.sessionID, hits[0].sessionID)
}
// The bug this guards: hoverTarget must store the STABLE identity (sessionID),
// not a raw line offset — resolve it back to a line offset the same way the
// render path does (hoveredSidebarLineOffset) and confirm it lands on the
// SAME row that was actually hovered, not some other row at that raw index.
resolved, ok := m.hoveredSidebarLineOffset(sidebarWidth(m.width))
if !ok || resolved != hits[0].lineOffset {
t.Fatalf("hoveredSidebarLineOffset() = (%d, %v), want (%d, true)", resolved, ok, hits[0].lineOffset)
}
}
func TestUpdateHoverTargetOnPlanStep(t *testing.T) {
m := runningPlanModel(t, 3)
m.altScreen = true
m.height = 40
// sidebarActive requires real conversation; runningPlanModel only sets m.plan,
// leaving the transcript at its default (welcome-only, i.e. "empty").
m.transcript = appendRow(m.transcript, rowUser, "do something")
if !m.sidebarActive() {
t.Fatal("sanity check failed: sidebar should be active with a plan present on a 100-col terminal")
}
hits := m.sidebarPlanSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected at least one clickable plan step row")
}
x0 := m.chatColumnWidth() + 3
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x0+1, hits[0].lineOffset))
m = updated.(model)
if m.hover.kind != hoverPlanStep {
t.Fatalf("hover.kind = %v, want hoverPlanStep", m.hover.kind)
}
if m.hover.stepIndex != hits[0].stepIndex {
t.Fatalf("hover.stepIndex = %d, want %d", m.hover.stepIndex, hits[0].stepIndex)
}
// The bug this guards: the render path must resolve stepIndex back to the
// CORRECT current lineOffset (hits[0].lineOffset, which is >= base=4, never a
// raw small index like 0/1/2) — not treat stepIndex itself as a line offset
// into the rendered sidebar (which would land on the AGENTS header/body).
resolved, ok := m.hoveredSidebarLineOffset(sidebarWidth(m.width))
if !ok || resolved != hits[0].lineOffset {
t.Fatalf("hoveredSidebarLineOffset() = (%d, %v), want (%d, true)", resolved, ok, hits[0].lineOffset)
}
}
func TestUpdateHoverTargetOnPermissionOptionMovesCursor(t *testing.T) {
m := mouseTestModel()
m.pending = true // the permission prompt only renders into the transcript body while m.pending
m.pendingPermission = &pendingPermissionPrompt{
request: agent.PermissionRequest{ToolName: "write_file"},
cursor: 0,
}
width := m.chatColumnWidth()
body, selectable := m.transcriptBody(width, "")
start, _, top := m.transcriptViewportStart(body, width)
var target transcriptSelectableLine
found := false
for _, line := range selectable {
if line.permOption && line.permChoice != permissionOptions(m.pendingPermission.request)[0].choice {
target = line
found = true
break
}
}
if !found {
t.Fatal("expected at least 2 permission options to test a non-default hover target")
}
updated, _ := m.Update(testMouseMotion(tea.MouseNone, target.textStart, top+target.bodyY-start))
m = updated.(model)
// Permission-option hover reuses the existing cursor highlight, not m.hover.
if m.hover.kind != hoverNone {
t.Fatalf("hover.kind = %v, want hoverNone (permission options use pendingPermission.cursor)", m.hover.kind)
}
wantIndex := -1
for index, option := range permissionOptions(m.pendingPermission.request) {
if option.choice == target.permChoice {
wantIndex = index
break
}
}
if m.pendingPermission.cursor != wantIndex {
t.Fatalf("pendingPermission.cursor = %d, want %d (the hovered option)", m.pendingPermission.cursor, wantIndex)
}
}
func TestHoverChangesTranscriptRenderOutput(t *testing.T) {
m := specialistClickTestModel(t, "child-sess-1")
width := m.chatColumnWidth()
without := m.transcriptBodyLayout(width, "").String()
x, y, ok := specialistCardClickPoint(m, 2)
if !ok {
t.Fatal("expected a specialist card selectable line in the layout")
}
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x, y))
m = updated.(model)
if m.hover.kind != hoverTranscript {
t.Fatal("sanity check failed: hover should be set over the specialist card")
}
with := m.transcriptBodyLayout(width, "").String()
if without == with {
t.Fatal("rendering with a hover target set should differ from without (the highlight should show)")
}
}
func TestHoverChangesSidebarRenderOutput(t *testing.T) {
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
width := m.chatColumnWidth()
height := m.height
without := strings.Join(m.renderContextSidebar(sidebarWidth(m.width), height), "\n")
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected at least one clickable sidebar agent row")
}
updated, _ := m.Update(testMouseMotion(tea.MouseNone, width+3+1, hits[0].lineOffset))
m = updated.(model)
if m.hover.kind != hoverSidebarAgent {
t.Fatal("sanity check failed: hover should be set over the sidebar agent row")
}
with := strings.Join(m.renderContextSidebar(sidebarWidth(m.width), height), "\n")
if without == with {
t.Fatal("sidebar rendering with a hover target set should differ from without (the highlight should show)")
}
}
func TestHoverClearsOnWheelScroll(t *testing.T) {
m := mouseTestModel()
for i := 0; i < 80; i++ {
m.transcript = appendRow(m.transcript, rowUser, "line content")
}
m.hover = hoverTarget{kind: hoverTranscript, bodyY: 5}
updated, _ := m.Update(testMouseWheel(tea.MouseWheelUp, 0, 3))
m = updated.(model)
if m.hover.kind != hoverNone {
t.Fatalf("hover should clear on a wheel scroll, got %#v", m.hover)
}
}
func TestHoverClearsOnSubchatExit(t *testing.T) {
m := mouseTestModel()
m.subchat.active = true
m.subchat.childSessionID = "child-1"
m.subchat.childRows = appendRow(nil, rowUser, "child text")
m.hover = hoverTarget{kind: hoverTranscript, bodyY: 5}
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEsc}))
m = updated.(model)
if m.subchat.active {
t.Fatal("Esc should exit the subchat view")
}
if m.hover.kind != hoverNone {
t.Fatalf("hover should clear on subchat exit (bodyY numbering differs), got %#v", m.hover)
}
}
// A sidebar row can disappear between when it was hovered and the next render
// (a swarm member's linger window elapsing) with no mouse motion in between to
// re-target the hover. hoveredSidebarLineOffset must not paint a highlight on
// whatever unrelated row now occupies that identity's old slot — it must find
// nothing and skip painting entirely.
func TestHoveredSidebarLineOffsetSelfHealsWhenRowDisappears(t *testing.T) {
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected at least one clickable sidebar agent row")
}
m.hover = hoverTarget{kind: hoverSidebarAgent, sessionID: hits[0].sessionID}
// Simulate the member disappearing (a fresh model with no swarm member at all,
// same identity no longer present) without any mouse motion clearing m.hover.
m2 := swarmSidebarTestModel(t, nil)
m2.hover = m.hover
if _, ok := m2.hoveredSidebarLineOffset(sidebarWidth(m2.width)); ok {
t.Fatal("hoveredSidebarLineOffset should find nothing once the identity no longer resolves, not fall back to a stale/coincidental row")
}
}
-59
View File
@@ -1,59 +0,0 @@
package tui
import (
"context"
"reflect"
"testing"
tea "charm.land/bubbletea/v2"
)
// TestInitRequestsWindowSize: Bubble Tea documents an initial WindowSizeMsg as
// delivered automatically on program start, but that's the terminal
// proactively pushing a size — if it's ever missed (a slow/unusual terminal,
// a multiplexer, a startup race), nothing else asks again, m.height stays 0
// forever, and transcriptView's `if m.altScreen && m.height > 0` gate falls
// back to the unpadded, non-fullscreen render path for the whole session (the
// alt-screen viewport never gets filled below the actual content). Init must
// explicitly request it too, the same way it already explicitly requests the
// background color rather than relying solely on an unprompted push.
func TestInitRequestsWindowSize(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4"})
cmd := m.Init()
if cmd == nil {
t.Fatal("Init() must return a non-nil command")
}
want := reflect.ValueOf(tea.RequestWindowSize).Pointer()
if !batchContainsCmd(t, cmd, want) {
t.Error("Init() must request the window size (tea.RequestWindowSize), not rely solely on the terminal's unprompted initial WindowSizeMsg")
}
}
// batchContainsCmd reports whether cmd (Init()'s single top-level
// tea.Batch(...)) contains a sub-command matching want, compared by pointer
// (the same technique TestThemeAutoReProbesBackground uses for
// tea.RequestBackgroundColor — reliable for named top-level functions like
// tea.RequestWindowSize, not closures). Invokes cmd itself once to unpack the
// tea.BatchMsg, but only pointer-compares the sub-commands — never calls
// them, since Init()'s batch also carries real closures (e.g. provider model
// discovery) that make live network calls and must not fire in a unit test.
func batchContainsCmd(t *testing.T, cmd tea.Cmd, want uintptr) bool {
t.Helper()
if cmd == nil {
return false
}
if reflect.ValueOf(cmd).Pointer() == want {
return true
}
batch, ok := cmd().(tea.BatchMsg)
if !ok {
return false
}
for _, sub := range batch {
if sub != nil && reflect.ValueOf(sub).Pointer() == want {
return true
}
}
return false
}
+1 -1
View File
@@ -31,7 +31,7 @@ var keybindingGroups = []keybindingGroup{
bindings: []keybinding{
{"Enter", "send the message"},
{"Alt+Enter", "insert a newline (multi-line compose)"},
{"Esc (×2)", "cancel the run / dismiss a popup / clear the input"},
{"Esc", "cancel the run / dismiss a popup / clear the input"},
{"Ctrl+C", "cancel the run, then quit"},
{"?", "show this help (on an empty input)"},
},
+113 -424
View File
@@ -25,7 +25,6 @@ import (
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/streamjson"
@@ -41,58 +40,41 @@ const chatWheelScrollLines = 5
const ctrlCExitConfirmDuration = 3 * time.Second
const ctrlCExitConfirmText = "Press Ctrl+C again to exit"
// escCancelConfirmDuration/escCancelConfirmText guard Esc cancelling a running
// turn, mirroring ctrlCExitConfirmDuration/ctrlCExitConfirmText's pattern (same
// window, same amber footer treatment) so the two "stop it" keybinds feel
// consistent. Without this, a single stray Esc — e.g. meant to dismiss a
// suggestion overlay that had already closed — silently threw away an
// in-progress run with no chance to reconsider.
const escCancelConfirmDuration = 3 * time.Second
const escCancelConfirmText = "Press Esc again to cancel"
// dragEdgeScrollInterval/dragEdgeScrollStep drive the smooth-glide auto-scroll
// while a drag holds past the transcript edge (see edgeScrollDelta). A small step
// on a short, steady cadence reads as a smooth continuous scroll; the wheel-scroll
// tick size (chatWheelScrollLines) would jump too far per step for that.
const dragEdgeScrollInterval = 70 * time.Millisecond
const dragEdgeScrollStep = 1
type model struct {
ctx context.Context
cwd string
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
userConfigPath string
doctorUserConfigPath string
projectConfigPath string
gitBranch string
providerName string
modelName string
providerProfile config.ProviderProfile
savedProviders []config.ProviderProfile
provider zeroruntime.Provider
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
discoverOllamaContextWindow func(ctx context.Context, baseURL string, model string) (int, error)
registry *tools.Registry
sessionStore *sessions.Store
sandboxStore *sandbox.GrantStore
mcpConfig config.MCPConfig
mcpPermissionStore *internalmcp.PermissionStore
mcpTokenStore *internalmcp.TokenStore
mcpCommand func(context.Context, []string) MCPCommandResult
sandboxSetupCommand func(context.Context) SandboxSetupCommandResult
mcpViewStateCache MCPViewState
mcpViewStateReady bool
mcpCommandSeq int
mcpCommandCancel context.CancelFunc
sandboxSetupSeq int
sandboxSetupInFlight bool
doctorCommandSeq int
doctorInFlight bool
doctorFrame int
activeSession sessions.Metadata
sessionEvents []sessions.Event
ctx context.Context
cwd string
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
userConfigPath string
doctorUserConfigPath string
projectConfigPath string
gitBranch string
providerName string
modelName string
providerProfile config.ProviderProfile
savedProviders []config.ProviderProfile
provider zeroruntime.Provider
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
registry *tools.Registry
sessionStore *sessions.Store
sandboxStore *sandbox.GrantStore
mcpConfig config.MCPConfig
mcpPermissionStore *internalmcp.PermissionStore
mcpTokenStore *internalmcp.TokenStore
mcpCommand func(context.Context, []string) MCPCommandResult
sandboxSetupCommand func(context.Context) SandboxSetupCommandResult
mcpViewStateCache MCPViewState
mcpViewStateReady bool
mcpCommandSeq int
mcpCommandCancel context.CancelFunc
sandboxSetupSeq int
sandboxSetupInFlight bool
doctorCommandSeq int
doctorInFlight bool
doctorFrame int
activeSession sessions.Metadata
sessionEvents []sessions.Event
// titledSessions records session ids for which a model-generated title has
// already been attempted this process, so a finished turn re-fires the title
// generator at most once per session (even before its async result lands).
@@ -211,22 +193,6 @@ type model struct {
// sidebar; when set, the chat reflows to full width. Distinct from the
// availability conditions in sidebarAvailable (geometry / mode / overlays).
sidebarHidden bool
// selectedFile is the touched file selected by clicking its FILES sidebar
// row: its edit cards tint in the chat (rowTouchesSelectedFile) and a second
// click opens the drill-in file view. "" when nothing is selected; Esc clears.
selectedFile string
// fileView is the drill-in view for a touched file (file_view.go): while
// active the chat column's body shows the file's diff/content instead of the
// transcript, mirroring the subchat drill-in.
fileView fileViewState
// Git-sweep state (files_git_sweep.go): the startup snapshot of already-dirty
// paths (nil until Init's sweep answers), the newly dirty files discovered by
// live sweeps (bash/subagent mutations that carry no changedFiles), the
// single-flight guard, and the "not a git repo / no git" latch.
gitFileBaseline map[string]bool
gitTouched []gitSweepFile
gitSweepInFlight bool
gitSweepUnavailable bool
// swarmDoneAt records when each swarm member was first seen finished (done/
// failed) in a swarm_status report, so the sidebar can linger it briefly with a
// fading ✓ before dropping it (a smooth exit, not an abrupt pop). Stamped in the
@@ -314,39 +280,10 @@ type model struct {
// (clickable suggestions, right-click paste, transcript select) pause while on.
mouseReleased bool
transcriptSelection transcriptSelectionState
// hover identifies the single clickable row (if any) currently under the
// mouse cursor with no button pressed, so it renders in a distinct style —
// the visual cue that it's clickable. Requires AllMotion mouse reporting
// (see wantsMouseCapture) since idle cursor movement carries no button.
hover hoverTarget
copyStatus string
copyStatusSeq int
exitConfirmActive bool
exitConfirmSeq int
// cancelConfirmActive/cancelConfirmSeq mirror exitConfirmActive/exitConfirmSeq
// (same seq-gated tea.Tick pattern) but guard a DIFFERENT action: Esc
// cancelling a running turn. The two are deliberately separate state (not a
// shared flag) since they're different actions with different consequences
// (quit the app vs. cancel the current run) that are armed by different
// keys — Ctrl+C and Esc respectively.
cancelConfirmActive bool
cancelConfirmSeq int
// edgeScrollDelta drives the smooth-glide auto-scroll while a drag holds past
// the transcript's top/bottom edge: 0 when idle, else the signed per-tick step
// (matches transcriptEdgeScrollDelta's sign convention). A self-scheduling
// tea.Tick chain (see dragEdgeScrollTickCmd) keeps stepping it at a fixed small
// increment regardless of whether new raw mouse-motion events arrive — a
// terminal only reports motion on actual cursor movement, so without a timer
// the scroll would stop dead the instant the physical mouse holds still, even
// while parked past the edge. edgeScrollSeq invalidates a stale in-flight tick
// (mirroring exitConfirmSeq/copyStatusSeq) whenever the drag moves back into
// the body, releases, or the chain is otherwise stopped.
edgeScrollDelta int
edgeScrollSeq int
// edgeScrollMouseX is the column the tick chain keeps extending the selection
// at — captured from the triggering drag since a timer tick carries no mouse
// position of its own.
edgeScrollMouseX int
copyStatus string
copyStatusSeq int
exitConfirmActive bool
exitConfirmSeq int
// picker, when non-nil, is an open interactive selector overlay (/model,
// /effort with no argument). It captures ↑/↓/Enter/Esc and applies
@@ -365,12 +302,6 @@ type model struct {
// catalog descriptor ID), so /model shows each provider's real current models —
// the same list the provider-setup wizard discovers — not the static catalog.
modelPickerLiveByProvider map[string][]providermodeldiscovery.Model
// ollamaContextWindowByModel holds context-window sizes fetched from a local
// Ollama daemon's native /api/show endpoint (keyed by model name), for
// custom/local models that have no curated-catalog entry and whose
// OpenAI-compatible /v1/models listing doesn't carry that metadata at all —
// see modelContextWindow.
ollamaContextWindowByModel map[string]int
// pendingImages holds image attachments staged by /image for the next user
// turn; pendingImageLabels are their display names (base(path)) for the chip
@@ -399,21 +330,6 @@ type exitConfirmExpiredMsg struct {
seq int
}
// cancelConfirmExpiredMsg mirrors exitConfirmExpiredMsg for the Esc
// cancel-a-run confirmation (see cancelConfirmActive).
type cancelConfirmExpiredMsg struct {
seq int
}
// dragEdgeScrollTickMsg advances the smooth-glide auto-scroll one step (see
// edgeScrollDelta). seq must match m.edgeScrollSeq or the tick is stale (the
// drag moved back into the body, released, or was otherwise stopped since this
// tick was scheduled) and is silently dropped — the self-scheduling chain simply
// doesn't reschedule itself, so it terminates rather than ticking forever.
type dragEdgeScrollTickMsg struct {
seq int
}
// toolCallStreamStartMsg / toolCallStreamDeltaMsg carry a tool call's live
// argument stream from the agent goroutine to the update loop, so a file being
// written renders as it streams (see streamingToolCallView).
@@ -635,7 +551,7 @@ func newModel(ctx context.Context, options Options) model {
}
}
userConfigDir, _ := config.UserConfigDir()
userConfigDir, _ := os.UserConfigDir()
loadedUserCommands := usercommands.Load(usercommands.DefaultPaths(cwd, userConfigDir))
registry := options.Registry
@@ -692,57 +608,56 @@ func newModel(ctx context.Context, options Options) model {
notifier.SetFocused(true)
m := model{
ctx: ctx,
cwd: cwd,
swarmDoneAt: map[string]time.Time{},
userCommands: loadedUserCommands,
composerCursorVisible: true,
userConfigPath: options.UserConfigPath,
doctorUserConfigPath: doctorUserConfigPath,
projectConfigPath: options.ProjectConfigPath,
savedProviders: options.SavedProviders,
gitBranch: gitBranch(cwd),
providerName: options.ProviderName,
modelName: options.ModelName,
providerProfile: options.ProviderProfile,
favoriteModels: favoriteModelSet(options.FavoriteModels),
recapsEnabled: options.RecapsEnabled,
provider: options.Provider,
newProvider: options.NewProvider,
probeProviderHealth: options.ProbeProviderHealth,
discoverProviderModels: options.DiscoverProviderModels,
discoverOllamaContextWindow: options.DiscoverOllamaContextWindow,
registry: registry,
sessionStore: sessionStore,
sandboxStore: sandboxStore,
mcpConfig: options.MCPConfig,
mcpPermissionStore: options.MCPPermissionStore,
mcpTokenStore: options.MCPTokenStore,
mcpCommand: options.MCPCommand,
sandboxSetupCommand: options.SandboxSetupCommand,
agentOptions: options.AgentOptions,
sessionCompactor: options.SessionCompactor,
runtimeMessageSink: options.RuntimeMessageSink,
permissionMode: permissionMode,
reasoningEffort: options.ReasoningEffort,
responseStyle: defaultedResponseStyle(options.ResponseStyle),
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
hasDarkBg: true,
userAgent: options.UserAgent,
usageTracker: usageTracker,
transcript: initialTranscript(),
transcriptBodyHeights: newTranscriptBodyHeightCache(defaultTranscriptBodyHeightCacheMaxEntries),
prService: prService,
prState: prService.GetState(),
input: input,
spinner: runSpinner,
now: time.Now,
notifier: notifier,
altScreen: options.AltScreen,
liveUsageCounts: map[int]int{},
swarmSessionMap: map[string]string{},
setup: newSetupState(options.Setup),
setupSave: options.Setup.Save,
ctx: ctx,
cwd: cwd,
swarmDoneAt: map[string]time.Time{},
userCommands: loadedUserCommands,
composerCursorVisible: true,
userConfigPath: options.UserConfigPath,
doctorUserConfigPath: doctorUserConfigPath,
projectConfigPath: options.ProjectConfigPath,
savedProviders: options.SavedProviders,
gitBranch: gitBranch(cwd),
providerName: options.ProviderName,
modelName: options.ModelName,
providerProfile: options.ProviderProfile,
favoriteModels: favoriteModelSet(options.FavoriteModels),
recapsEnabled: options.RecapsEnabled,
provider: options.Provider,
newProvider: options.NewProvider,
probeProviderHealth: options.ProbeProviderHealth,
discoverProviderModels: options.DiscoverProviderModels,
registry: registry,
sessionStore: sessionStore,
sandboxStore: sandboxStore,
mcpConfig: options.MCPConfig,
mcpPermissionStore: options.MCPPermissionStore,
mcpTokenStore: options.MCPTokenStore,
mcpCommand: options.MCPCommand,
sandboxSetupCommand: options.SandboxSetupCommand,
agentOptions: options.AgentOptions,
sessionCompactor: options.SessionCompactor,
runtimeMessageSink: options.RuntimeMessageSink,
permissionMode: permissionMode,
reasoningEffort: options.ReasoningEffort,
responseStyle: defaultedResponseStyle(options.ResponseStyle),
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME")),
hasDarkBg: true,
userAgent: options.UserAgent,
usageTracker: usageTracker,
transcript: initialTranscript(),
transcriptBodyHeights: newTranscriptBodyHeightCache(defaultTranscriptBodyHeightCacheMaxEntries),
prService: prService,
prState: prService.GetState(),
input: input,
spinner: runSpinner,
now: time.Now,
notifier: notifier,
altScreen: options.AltScreen,
liveUsageCounts: map[int]int{},
swarmSessionMap: map[string]string{},
setup: newSetupState(options.Setup),
setupSave: options.Setup.Save,
}
// Apply an explicit theme immediately; auto stays on the dark default until
// Init's terminal background probe resolves it (see Init / BackgroundColorMsg).
@@ -807,24 +722,6 @@ func composerBlinkCmd() tea.Cmd {
func (m model) Init() tea.Cmd {
cmds := []tea.Cmd{textinput.Blink, composerBlinkCmd()}
// Bubble Tea documents an initial WindowSizeMsg as delivered automatically
// on program start, so m.height/m.width are normally set before the first
// render. But that's the terminal proactively pushing a size — if it's
// ever missed (a slow/unusual terminal, a multiplexer, a startup race),
// nothing else ever asks again: m.height stays its zero value forever,
// `if m.altScreen && m.height > 0` (transcriptView) falls back to the
// unpadded, non-fullscreen render path for the rest of the session, and
// the alt-screen viewport never gets filled below the actual content.
// Explicitly requesting it here means Zero doesn't depend solely on the
// terminal's unprompted push — mirrors the RequestBackgroundColor request
// below for the same reason.
cmds = append(cmds, tea.RequestWindowSize)
// Baseline git snapshot for the FILES sidebar sweep: whatever is already
// dirty when the TUI opens is pre-existing state, not this session's work
// (files_git_sweep.go). Async; a non-git workspace just disables the sweep.
if strings.TrimSpace(m.cwd) != "" {
cmds = append(cmds, gitSweepCmd(m.ctx, m.cwd, true))
}
// In auto mode, ask the terminal for its background color; the reply arrives
// as tea.BackgroundColorMsg and selects light vs dark (see updateModel).
if m.themeMode == themeAuto {
@@ -839,13 +736,6 @@ func (m model) Init() tea.Cmd {
if cmd := m.modelPickerProviderDiscoveryCmd(descriptor, m.providerProfile); cmd != nil {
cmds = append(cmds, cmd)
}
// The generic discovery above has no source for a local Ollama model's
// context window (see ollamaContextWindowDiscoveryCmd); probe its
// native /api/show separately so the gauge works for custom/local
// Ollama models too, not just ones in the curated catalog.
if cmd := m.ollamaContextWindowDiscoveryCmd(descriptor, m.providerProfile.BaseURL, m.modelName); cmd != nil {
cmds = append(cmds, cmd)
}
}
if m.prService != nil && m.runtimeMessageSink != nil {
service := m.prService
@@ -922,14 +812,6 @@ func (m model) disarmExitConfirmation() model {
return m
}
func (m model) disarmCancelConfirmation() model {
if m.cancelConfirmActive {
m.cancelConfirmActive = false
m.cancelConfirmSeq++
}
return m
}
// Update routes every message through updateModel, then advances the flush
// frontier for inline rendering. Alt-screen runs keep rows in the managed view
// instead of printing into terminal scrollback (see flush.go).
@@ -1008,17 +890,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.exitConfirmActive = false
}
return m, nil
case cancelConfirmExpiredMsg:
if msg.seq == m.cancelConfirmSeq {
m.cancelConfirmActive = false
}
return m, nil
case dragEdgeScrollTickMsg:
if msg.seq != m.edgeScrollSeq || m.edgeScrollDelta == 0 || !m.transcriptSelection.active {
return m, nil // stale, or the chain was stopped since this tick was scheduled
}
m = m.dragToEdgeScroll(m.edgeScrollDelta, m.edgeScrollMouseX)
return m, dragEdgeScrollTickCmd(m.edgeScrollSeq)
case providerWizardOAuthMsg:
return m.applyProviderWizardOAuth(msg)
case providerWizardDeviceCodeMsg:
@@ -1060,13 +931,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if !keyCtrl(msg, 'c') {
m = m.disarmExitConfirmation()
}
// Mirrors the exit-confirmation reset above: any key that isn't itself
// the confirming Esc means the user moved on to something else, so a
// later, unrelated Esc must arm a fresh confirmation rather than
// silently cancel off a stale press from seconds ago.
if !keyIs(msg, tea.KeyEsc) {
m = m.disarmCancelConfirmation()
}
// The `?` help overlay is modal: `?`, Esc, q, or Enter close it; every
// other key is swallowed so nothing types into the hidden composer.
if m.helpOverlay {
@@ -1080,14 +944,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handleCtrlC()
case keyCtrl(msg, 'o'):
return m.toggleDetailedTranscript(), nil
case m.fileView.active && m.noBlockingModal() && m.composerValue() == "" && (keyText(msg) == "d" || keyText(msg) == "f"):
// Mode toggle for the file drill-in, only while the composer is empty
// (so mid-sentence typing is never hijacked) and no modal is up (so a
// permission prompt / ask-user / wizard keeps its own key handling).
if keyText(msg) == "f" {
return m.setFileViewMode(fileViewFull), nil
}
return m.setFileViewMode(fileViewDiff), nil
case keyCtrl(msg, 'e'):
// Release/recapture the mouse so the user can drag-select and copy text
// natively (mouse capture otherwise intercepts terminal selection).
@@ -1097,29 +953,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m.appendSystemNotice("Mouse interaction re-enabled."), nil
case keyIs(msg, tea.KeyEsc):
// Esc is heavily overloaded below (subchat exit, MCP cancel, ask-user,
// permission deny, wizard/picker/suggestions dismiss, ...) before ever
// reaching the run-cancel fallback. Capture whether this press really
// is the confirming second Esc BEFORE any of those branches can fire,
// then disarm unconditionally: an Esc that gets consumed by one of
// them wasn't a confirm, so it must not leave cancelConfirmActive
// armed for some later, unrelated Esc to silently act on.
wasConfirmingCancel := m.pending && m.cancelConfirmActive
m = m.disarmCancelConfirmation()
// Subchat view exits on Esc (returns to main chat).
if m.subchat.active {
m.chatScrollOffset = m.subchat.exit()
m = m.clearHover() // bodyY numbering differs between subchat and the parent transcript
return m, nil
}
// File drill-in exits on Esc (returns to the chat at its saved scroll
// position); the file stays selected so a second Esc clears that. Only
// with no blocking modal up: Esc on a permission prompt / ask-user /
// wizard must reach THAT surface's deny/cancel handling below, not
// silently close the drill-in behind it.
if m.fileView.active && m.noBlockingModal() {
return m.exitFileView(), nil
}
if m.mcpCommandCancel != nil {
m.cancelMCPCommand()
if m.mcpAddWizard != nil {
@@ -1165,45 +1003,20 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.picker.kind == pickerModel {
m.clearModelPickerLoadState()
}
if m.picker.kind == pickerTheme {
// A live theme preview was applied while navigating; restore the
// committed palette since Esc dismisses without choosing.
m.restoreCommittedTheme()
}
m.picker = nil
return m, nil
}
if m.suggestionsActive() {
return m.dismissSuggestions(), nil
}
// A selected FILES row clears before anything run-related: the
// selection is a passive highlight, so Esc dropping it is cheap and
// expected (mirrors how editors clear selection on Esc).
if m.selectedFile != "" {
m.selectedFile = ""
return m, nil
}
if m.hasQueuedMessage() {
return m.clearQueuedMessage(), nil
}
m.clearComposer()
m.clearSuggestions()
if m.pending {
if wasConfirmingCancel {
m.clearComposer()
m.cancelRun()
return m, nil
}
// First Esc only arms the confirmation — preserve whatever
// draft the user has typed, since nothing has actually been
// cancelled yet and they may not press Esc again.
m.cancelConfirmActive = true
m.cancelConfirmSeq++
seq := m.cancelConfirmSeq
return m, tea.Tick(escCancelConfirmDuration, func(time.Time) tea.Msg {
return cancelConfirmExpiredMsg{seq: seq}
})
m.cancelRun()
}
m.clearComposer()
return m, nil
case keyIs(msg, tea.KeyEnter):
if m.transcriptDetailed {
@@ -1324,9 +1137,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.picker.deleteQueryRune()
// Editing the filter changes which row is highlighted; keep the
// theme preview in sync with it (no-op for other pickers).
m.previewSelectedTheme()
return m, nil
}
// On an empty composer, Backspace removes the last attachment chip
@@ -1364,17 +1174,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.transcriptDetailed {
return m, nil
}
// A stationary mouse over a bodyY-keyed transcript hover target would
// otherwise stay lit at the scrolled-away bodyY until the next real
// motion event (see clearHover) — same reasoning as the wheel-scroll
// cases in mouse.go.
m = m.clearHover()
return m.scrollChat(m.chatPageScrollLines()), nil
case keyIs(msg, tea.KeyPgDown):
if m.transcriptDetailed {
return m, nil
}
m = m.clearHover()
return m.scrollChat(-m.chatPageScrollLines()), nil
case keyIs(msg, tea.KeyDown):
if m.transcriptDetailed {
@@ -1399,7 +1203,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(1)
m.picker.move(1)
return m, nil
}
if m.suggestionsActive() {
@@ -1416,7 +1220,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// ArrowUp exits subchat view (returns to main chat).
if m.subchat.active {
m.chatScrollOffset = m.subchat.exit()
m = m.clearHover() // bodyY numbering differs between subchat and the parent transcript
return m, nil
}
if m.transcriptDetailed {
@@ -1441,7 +1244,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(-1)
m.picker.move(-1)
return m, nil
}
if m.suggestionsActive() {
@@ -1500,9 +1303,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if keyPrintable(msg) {
m.picker.appendQuery(keyRunes(msg))
// Filtering changes the highlighted row; keep the theme preview in
// sync with it (no-op for other pickers).
m.previewSelectedTheme()
}
return m, nil
}
@@ -1645,10 +1445,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
// A resize re-wraps content at a new width, shifting every row's bodyY;
// a stale transcript-hover target could coincidentally land on an
// unrelated clickable row (same reasoning as clearHover's other callers).
m = m.clearHover()
// Reset the streaming-text fade state. A width change can re-wrap
// the in-progress text into a different number of visual lines,
// which invalidates the per-line age mapping. The next delta
@@ -1759,7 +1555,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.clearStreamingToolCall() // active run finished — drop any lingering "writing" block
m.pending = false
m = m.disarmCancelConfirmation() // the run finished on its own — nothing left to confirm cancelling
// Fully reset the fade state at stream end. The next render
// emits the final row in solid ink (no settling animation), and
// the pending streamingFadeTickMsg that lands after this point
@@ -1857,13 +1652,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m, recapCmd = m.maybeRecapTurn(msg.runID, finalAnswer)
}
// End-of-turn git sweep: catch file mutations the tool stream couldn't
// report (bash scaffolding, subagent edits) so the FILES sidebar is
// complete once the turn settles.
var sweepCmd tea.Cmd
m, sweepCmd = m.maybeGitSweep()
next, queuedCmd := m.launchQueuedMessageIfReady()
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd)
return next, tea.Batch(titleCmd, recapCmd, queuedCmd)
case sessionTitleGeneratedMsg:
return m.handleSessionTitleGenerated(msg)
case recapGeneratedMsg:
@@ -2014,14 +1804,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.transcript = collapseRepeatedStatusCard(m.transcript, msg.row)
m.transcript = appendTranscriptRow(m.transcript, msg.row)
m = m.captureStepWork(msg.row)
// A finished command tool may have mutated files git can see but no
// changedFiles reports (npm create, heredoc writes, subagent edits) —
// re-sweep so the FILES sidebar picks them up mid-turn.
if msg.row.kind == rowToolResult && isPlanCommandTool(msg.row.tool) {
var sweep tea.Cmd
m, sweep = m.maybeGitSweep()
return m, sweep
}
return m, nil
case swarmSessionsMsg:
// Merge completed swarm members' session ids so their AGENTS sidebar rows
@@ -2052,8 +1834,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
case prStateMsg:
m.prState = msg.state
return m, nil
case gitSweepMsg:
return m.handleGitSweepMsg(msg), nil
case prWatcherStartedMsg:
if msg.stop == nil {
return m, nil
@@ -2076,14 +1856,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.applySetupOAuthDeviceCode(msg)
case modelPickerModelsDiscoveredMsg:
return m.applyModelPickerModelsDiscovered(msg), nil
case ollamaContextWindowDiscoveredMsg:
if msg.err == nil && msg.contextWindow > 0 {
if m.ollamaContextWindowByModel == nil {
m.ollamaContextWindowByModel = map[string]int{}
}
m.ollamaContextWindowByModel[msg.modelName] = msg.contextWindow
}
return m, nil
case mcpCommandResultMsg:
return m.applyMCPCommandResultMessage(msg), nil
}
@@ -2107,27 +1879,9 @@ func (m model) View() tea.View {
view := tea.NewView(content)
view.AltScreen = m.altScreen
// Paint the whole frame with the active theme's surface. Zero never paints the
// terminal's own canvas, so without this a theme's text falls on the terminal
// background — fine when they share polarity, but a light theme's dark text on a
// dark terminal (or vice versa) is invisible, and a color theme never shows its
// real surface. Painting the panel makes every theme self-contained and legible
// on any terminal, and fills the transparent popup interiors (e.g. the /theme
// picker) too. Alt-screen only, so inline output never leaves a painted
// background behind in the user's scrollback after exit.
if m.altScreen {
view.BackgroundColor = zeroTheme.bgPanel
}
view.ReportFocus = m.notifier != nil
if m.wantsMouseCapture() {
// AllMotion (not CellMotion) is required for hover highlighting: it
// reports cursor movement even with no button pressed. CellMotion only
// reports motion while a button is held (drag) — see bubbletea's
// MouseMode docs. AllMotion has marginally worse terminal compatibility
// but is well supported by the terminals this app targets; the existing
// 15ms mouse-event throttle (mouseEventThrottleInterval) already bounds
// the redraw rate from the extra motion events.
view.MouseMode = tea.MouseModeAllMotion
view.MouseMode = tea.MouseModeCellMotion
}
return view
}
@@ -2258,12 +2012,6 @@ func (m model) pinnedTitleBar(width int) string {
if !m.altScreen || m.height <= 0 {
return ""
}
// The file drill-in replaces the title bar with its nav line (path + key
// hints). Both are exactly one line, and every frame computation routes
// through here, so the swap never desyncs the viewport geometry.
if m.fileView.active {
return m.fileViewNavBar(width)
}
return m.titleBar(width)
}
@@ -2282,14 +2030,10 @@ func (m model) footerView(width int) string {
// while the transcript scrolls underneath (a streaming turn no longer pushes
// the plan off-screen). Budgeted to at most a third of the screen height; a
// taller plan collapses to a one-line summary so the composer always stays
// on screen. Skipped in the subchat drill-in: m.plan belongs to the PARENT
// run, not the subagent/swarm child session being viewed there, so pinning it
// above that composer would show unrelated state.
if !m.subchat.active {
if plan := m.renderPinnedPlanPanel(width, m.pinnedPlanMaxHeight()); plan != "" {
footer.WriteString(plan)
footer.WriteString("\n")
}
// on screen.
if plan := m.renderPinnedPlanPanel(width, m.pinnedPlanMaxHeight()); plan != "" {
footer.WriteString(plan)
footer.WriteString("\n")
}
// The row above the composer: transient copy feedback takes priority; otherwise
// a faint idle affordance — discoverable key hints on the left, a jump-to-bottom
@@ -2301,18 +2045,15 @@ func (m model) footerView(width int) string {
footer.WriteString(fitStyledLine(joinHeaderLine(" "+left, right, width), width))
}
footer.WriteString("\n")
// A message typed while a run is active is queued for the next turn; show its
// preview directly ABOVE the input box (not below), so it reads as "waiting to
// send" sitting on top of what you're currently typing.
if queued := renderQueuedMessagePreview(m.queuedMessage, width); queued != "" {
footer.WriteString(queued)
footer.WriteString("\n")
}
footer.WriteString(m.composerBox(width))
if hint := m.composerDescriptionHint(width); hint != "" {
footer.WriteString("\n")
footer.WriteString(hint)
}
if queued := renderQueuedMessagePreview(m.queuedMessage, width); queued != "" {
footer.WriteString("\n")
footer.WriteString(queued)
}
footer.WriteString("\n")
footer.WriteString(m.statusLine(width))
return footer.String()
@@ -2888,15 +2629,6 @@ const quietWorkingHint = 8 * time.Second
// quietGenerationHint returns a "still generating…" cue with an advancing
// quiet-timer when the active turn has produced no streamed output for a while,
// else "". The advancing number is itself the liveness signal.
//
// Past half the provider's idle timeout, the cue escalates to name what's
// actually happening and when Zero will act on its own: a heartbeating-but-
// silent stream (observed on chatgpt/gpt-5.x and ollama reasoning models,
// see providerio.ErrStreamStalled) is bounded by the content-stall watchdog at
// providerio.ContentStallTimeout(idle), but until it fires this exact same
// plain "still generating… Xs" text is indistinguishable from a genuine hang —
// the ticking number was the only signal, and it looks identical whether real
// (if slow) content is coming or nothing ever will.
func (m model) quietGenerationHint() string {
if m.activeRunID == 0 {
return ""
@@ -2912,10 +2644,6 @@ func (m model) quietGenerationHint() string {
if quiet < quietWorkingHint {
return ""
}
if idleTimeout := providerio.ResolveStreamIdleTimeout(0); idleTimeout > 0 && quiet >= idleTimeout/2 {
ceiling := providerio.ContentStallTimeout(idleTimeout)
return fmt.Sprintf("still generating… %s — unusually quiet, Zero will auto-recover by ~%s if it doesn't resume", formatWorkingElapsed(quiet), formatWorkingElapsed(ceiling))
}
return "still generating… " + formatWorkingElapsed(quiet)
}
@@ -3471,20 +3199,14 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
}
item, ok := picker.current()
if !ok {
if picker.kind == pickerTheme {
// No selectable row (e.g. the filter matched nothing): undo any live
// preview so the palette matches the committed m.themeMode.
m.restoreCommittedTheme()
}
return m, nil
}
var cmd tea.Cmd
switch picker.kind {
case pickerModel:
text := ""
if owner := strings.TrimSpace(item.OwnerProvider); owner != "" && !strings.EqualFold(owner, strings.TrimSpace(m.providerName)) {
// A model from another saved provider: switch provider + model together.
m, text, cmd = m.switchProviderModel(owner, item.Value)
m, text = m.switchProviderModel(owner, item.Value)
} else {
m, text = m.handleModelCommand(item.Value)
}
@@ -3501,20 +3223,8 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
if text != "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
}
case pickerTheme:
// The hovered palette is already live from the preview; handleThemeCommand
// records the choice (m.themeMode) and re-applies it, and reports the switch.
text := ""
m, text = m.handleThemeCommand(item.Value)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
if m.themeMode == themeAuto {
// Re-probe the terminal background so a committed `auto` re-detects
// light/dark instead of reusing the preview's reading — mirrors the
// text /theme dispatch (M17).
return m, tea.RequestBackgroundColor
}
}
return m, cmd
return m, nil
}
func (m model) chooseSuggestion() (tea.Model, tea.Cmd) {
@@ -3755,26 +3465,7 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
m, text = m.handleSelfCorrectCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandTurns:
// Changing the budget mid-run would mutate the inherited ZERO_MAX_TURNS env
// that sub-agents spawned later in THIS run read, making the run's budget
// inconsistent. Require an idle session (the new budget applies next run).
if m.pending && strings.TrimSpace(command.text) != "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Turns\nFinish or stop the current run before changing the tool-turn budget."})
return m, nil
}
text := ""
m, text = m.handleTurnsCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandTheme:
// Bare `/theme` opens the popup picker (live preview on move, apply on
// Enter), matching /model and /effort. An explicit `/theme auto|dark|light`
// (or `/theme list`) still runs the text handler directly.
if strings.TrimSpace(command.text) == "" {
m.picker = m.newThemePicker()
return m, nil
}
text := ""
m, text = m.handleThemeCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
@@ -4038,8 +3729,7 @@ func (m *model) cancelRun() {
m.pending = false
m.runCancel = nil
m.activeRunID = 0
m.cancelConfirmActive = false // whatever path got here, there's nothing left to confirm cancelling
m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
m.pendingPermission = nil
m.pendingAskUser = nil
// The interim block renders streamingText live; a cancelled run's partial
@@ -4364,14 +4054,13 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
}
}
row := transcriptRow{
kind: rowToolResult,
id: effectiveToolRowID(result.ToolCallID, callSeq[result.ToolCallID]),
text: toolResultRowText(result),
tool: result.Name,
status: result.Status,
detail: toolResultDetail(result),
runID: runID,
changedFiles: result.ChangedFiles,
kind: rowToolResult,
id: effectiveToolRowID(result.ToolCallID, callSeq[result.ToolCallID]),
text: toolResultRowText(result),
tool: result.Name,
status: result.Status,
detail: toolResultDetail(result),
runID: runID,
}
// A Task result is shown by the specialist card, and update_plan by the
// plan panel/sidebar, so skip both redundant transcript rows.
-7
View File
@@ -84,13 +84,6 @@ func (m model) modelContextWindow(modelName string) int {
return window
}
}
// A custom/local Ollama model tag has no curated-catalog entry, and its
// OpenAI-compatible /v1/models listing (the discoveredContextWindow source
// above) doesn't carry context-window metadata at all — see
// ollamaContextWindowDiscoveryCmd, the only other source for this.
if window := m.ollamaContextWindowByModel[trimmed]; window > 0 {
return window
}
// Unknown: report 0 so display gauges show no denominator. Compaction enablement
// applies its own positive fallback via modelregistry.AgentContextWindow.
return 0
+4 -200
View File
@@ -1108,221 +1108,25 @@ func TestPromptSubmitDoesNotStartAnotherRunWhilePending(t *testing.T) {
}
}
func TestEscRequiresSecondPressToCancelPendingRun(t *testing.T) {
func TestEscCancelsPendingRun(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, cmd := m.Update(testKey(tea.KeyEsc))
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if cancelled {
t.Fatal("first Esc should not cancel the pending run")
}
if !next.pending {
t.Fatal("first Esc should leave the run pending")
}
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
if cmd == nil {
t.Fatal("first Esc should schedule confirmation expiry")
}
status := plainRender(t, next.statusLine(80))
if !strings.Contains(status, escCancelConfirmText) {
t.Fatalf("status line = %q, want cancel confirmation", status)
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if !cancelled {
t.Fatal("second Esc should cancel pending run")
t.Fatal("expected Esc to cancel pending run")
}
if next.pending {
t.Fatal("expected second Esc to clear pending state")
t.Fatal("expected Esc to clear pending state")
}
if next.activeRunID != 0 || next.runCancel != nil {
t.Fatalf("expected active run state to clear, got id=%d cancel=%v", next.activeRunID, next.runCancel)
}
if next.cancelConfirmActive {
t.Fatal("cancelling should clear cancel confirmation")
}
}
// TestEscArmingCancelConfirmationPreservesComposerDraft: the first Esc only
// arms the confirmation — nothing has actually been cancelled yet, so it
// must not destroy a draft the user is still typing. Only the confirming
// second Esc (the one that actually cancels the run) clears the composer.
func TestEscArmingCancelConfirmationPreservesComposerDraft(t *testing.T) {
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 1
m.runCancel = func() {}
m.input.SetValue("draft prompt")
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
if next.composerValue() != "draft prompt" {
t.Fatalf("first Esc should preserve the draft, got %q", next.composerValue())
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if next.pending {
t.Fatal("second Esc should cancel the pending run")
}
if next.composerValue() != "" {
t.Fatalf("the confirming second Esc should clear the draft, got %q", next.composerValue())
}
}
// TestPasteDisarmsCancelConfirmation: a paste isn't a keypress, so it never
// went through the generic "any non-Esc key disarms it" hook. Pasting is a
// deliberate action just like typing or clicking — it must disarm a stale
// confirmation too, or a later, unrelated Esc could silently cancel the run.
func TestPasteDisarmsCancelConfirmation(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
updated, _ = next.Update(testPaste("pasted text"))
next = updated.(model)
if next.cancelConfirmActive {
t.Fatal("a paste should disarm the stale cancel confirmation")
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("Esc after a paste should re-arm, not immediately cancel")
}
if !next.cancelConfirmActive {
t.Fatal("Esc after a paste should arm a fresh confirmation")
}
}
func TestEscCancelConfirmationExpires(t *testing.T) {
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 1
m.runCancel = func() {}
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
seq := next.cancelConfirmSeq
updated, _ = next.Update(cancelConfirmExpiredMsg{seq: seq - 1})
next = updated.(model)
if !next.cancelConfirmActive {
t.Fatal("stale expiry should not clear active cancel confirmation")
}
updated, _ = next.Update(cancelConfirmExpiredMsg{seq: seq})
next = updated.(model)
if next.cancelConfirmActive {
t.Fatal("matching expiry should clear cancel confirmation")
}
if !next.pending {
t.Fatal("expiring the confirmation should not cancel the run")
}
}
// TestEscCancelConfirmationDisarmsOnInterveningKey mirrors
// TestCtrlCExitConfirmationDisarmsOnInterveningKey: an intervening key other
// than Esc (typing, Ctrl+O, etc.) means the user moved on to something else,
// so a later, unrelated Esc must arm a fresh confirmation instead of
// silently cancelling off a stale press from seconds ago.
func TestEscCancelConfirmationDisarmsOnInterveningKey(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
seq := next.cancelConfirmSeq
updated, _ = next.Update(testKey('h'))
next = updated.(model)
if next.cancelConfirmActive {
t.Fatal("an intervening non-Esc key should disarm cancel confirmation")
}
if next.cancelConfirmSeq == seq {
t.Fatal("disarming should advance the sequence so a stale expiry tick is ignored")
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("Esc after an intervening key should re-arm, not cancel")
}
if !next.cancelConfirmActive {
t.Fatal("Esc after an intervening key should arm a fresh confirmation")
}
}
// TestEscCancelConfirmationDisarmsWhenStolenByAskUser: a mid-turn ask_user
// prompt lands between the two Esc presses. The user's second Esc denies the
// questionnaire (an earlier branch in the Esc handler), not a confirm, so it
// must not leave cancelConfirmActive armed for a later, unrelated Esc to
// silently cancel the run.
func TestEscCancelConfirmationDisarmsWhenStolenByAskUser(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
request := agent.AskUserRequest{Questions: []agent.AskUserQuestion{{Question: "name?"}}}
next.pendingAskUser = &pendingAskUserPrompt{
request: request,
answer: func([]string) {},
states: newAskUserStates(request.Questions),
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("an Esc consumed by the ask-user prompt must not cancel the run")
}
if next.cancelConfirmActive {
t.Fatal("an Esc consumed by the ask-user prompt must disarm the stale cancel confirmation")
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("the next Esc should arm a fresh confirmation, not immediately cancel")
}
if !next.cancelConfirmActive {
t.Fatal("the next Esc should arm a fresh confirmation")
}
}
func TestAgentResponseCompletesStuckPlan(t *testing.T) {
+4 -43
View File
@@ -14,25 +14,6 @@ type mouseSelectionTarget struct {
Index int
}
// scrollChatExtendingSelection scrolls the chat and, if a drag-selection is in
// progress, extends its cursor to the mouse's current position against the
// POST-scroll viewport — mirroring what a mouseMotion event does. Without this, a
// selection's cursor is only ever updated by actual mouse movement (see
// handleTranscriptSelectionMouse's mouseMotion case): a wheel-scroll event is a
// distinct message type that never reaches that code, so scrolling while
// selecting left the selection frozen at whatever line the mouse last physically
// moved over — capped at the viewport that was visible before the scroll.
func (m model) scrollChatExtendingSelection(delta int, msg tea.MouseMsg) model {
m = m.scrollChat(delta)
if !m.transcriptSelection.active {
return m
}
if line, ok := m.nearestTranscriptLineAtMouse(msg); ok {
m.transcriptSelection.cursor = transcriptSelectionPointForMouse(line, mouseX(msg))
}
return m
}
func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
// While a provider-wizard OAuth login is in flight, ignore all mouse input
// (clicks and wheel) so a stray scroll can't change the selected provider
@@ -40,15 +21,6 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.providerWizard != nil && m.providerWizard.oauthPending {
return m, nil
}
// A click is a deliberate action, same as a keypress — it means the user
// moved on to something else, so it disarms a stale Esc cancel-confirmation
// rather than leaving it armed for some later, unrelated Esc to act on.
// Scoped to actual clicks (not hover/motion/wheel), since motion events fire
// continuously while the mouse merely sits over the terminal and would
// otherwise defeat the confirmation window on every render tick.
if mouseLeftPress(msg) || mouseRightPress(msg) {
m = m.disarmCancelConfirmation()
}
// A right-click pastes the clipboard straight into the focused field — no
// menu. pasteFromClipboardCmd reads the clipboard off the Update goroutine; the
// clipboardReadMsg result is routed by routePaste to wherever input is focused
@@ -112,17 +84,10 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if next, cmd, ok := m.handleTranscriptSelectionMouse(msg); ok {
return next, cmd
}
// A plain hover (cursor moved, no button pressed) never matches the
// press/drag/release cases above, so it falls through here — resolve what's
// under the cursor so it can render with the hover highlight.
if mouseHover(msg) {
return m.updateHoverTarget(msg), nil
}
switch {
case mouseWheelUp(msg):
m.clearMouseSelection()
m = m.clearHover()
if m.providerWizard != nil {
m.providerWizard.move(-1)
return m, nil
@@ -139,7 +104,7 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(-1)
m.picker.move(-1)
return m, nil
}
if m.suggestionsActive() {
@@ -151,10 +116,9 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
return next, nil
}
}
return m.scrollChatExtendingSelection(chatWheelScrollLines, msg), nil
return m.scrollChat(chatWheelScrollLines), nil
case mouseWheelDown(msg):
m.clearMouseSelection()
m = m.clearHover()
if m.providerWizard != nil {
m.providerWizard.move(1)
return m, nil
@@ -171,7 +135,7 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(1)
m.picker.move(1)
return m, nil
}
if m.suggestionsActive() {
@@ -183,7 +147,7 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
return next, nil
}
}
return m.scrollChatExtendingSelection(-chatWheelScrollLines, msg), nil
return m.scrollChat(-chatWheelScrollLines), nil
default:
return m, nil
}
@@ -450,9 +414,6 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
if hit.y == line {
index := start + offset
m.picker.selected = index
// Clicking (or hovering onto) a row live-previews it, like arrow keys,
// so the theme picker repaints before the confirming second click.
m.previewSelectedTheme()
return mouseSelectionTarget{Scope: "picker", Kind: int(m.picker.kind), Value: item.Value, Index: index}, true
}
line++
-239
View File
@@ -407,245 +407,6 @@ func TestTranscriptSelectionReleaseExtendsRangeWithoutMotion(t *testing.T) {
}
}
// A completed selection stays "active" through the async copy-command grace
// window (transcriptCopiedMsg hasn't landed yet), but the drag itself is over.
// A genuine hover motion (no button held) arriving in that window must NOT be
// treated as a drag continuation — it must fall through to hover handling
// instead of silently moving the selection the user just released.
func TestTranscriptHoverAfterReleaseDoesNotMoveSelection(t *testing.T) {
m := withSidebarContent(mouseTestModel())
m.mouseCapture = true
m.transcript = appendRow(m.transcript, rowUser, "hello world")
textY := firstTranscriptTextMouseY(t, m)
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 3, textY))
m = updated.(model)
updated, cmd := m.Update(testMouseRelease(tea.MouseNone, 8, textY))
m = updated.(model)
if cmd == nil {
t.Fatal("sanity check failed: release should return the copy command")
}
if m.transcriptSelection.dragging {
t.Fatal("dragging must be false immediately after release")
}
before := m.selectedTranscriptText()
// A hover (no button) moves further along the same line, in the window
// before transcriptCopiedMsg has landed (m.transcriptSelection.active is
// still true here).
updated, _ = m.Update(testMouseMotion(tea.MouseNone, 10, textY))
m = updated.(model)
if got := m.selectedTranscriptText(); got != before {
t.Fatalf("a post-release hover changed the selection: got %q, want unchanged %q", got, before)
}
}
// Dragging a selection past the top edge of the visible transcript must
// auto-scroll toward older content and extend the selection to follow — the
// classic "drag past the viewport edge keeps scrolling" affordance.
func TestTranscriptSelectionDragPastTopEdgeAutoScrolls(t *testing.T) {
m := mouseTestModel()
m.mouseCapture = true
// This test exercises the animated glide specifically; reducedMotion
// defaults from TTY detection (see defaultReducedMotion), which differs
// between a local dev shell and CI's non-TTY runners — set it explicitly
// so the test is deterministic regardless of environment.
m.reducedMotion = false
for i := 0; i < 80; i++ {
m.transcript = appendRow(m.transcript, rowUser, "line content")
}
textY := topmostVisibleTranscriptMouseY(t, m)
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 0, textY))
m = updated.(model)
if !m.transcriptSelection.active {
t.Fatal("selection should be active after a left click on transcript text")
}
cursorBefore := m.transcriptSelection.cursor.bodyY
scrollBefore := m.chatScrollOffset
frame, _, _ := m.transcriptHitTestLayout()
aboveBody := frame.bodyRect.y - 1 // one row above the visible transcript body
// A left-button drag (not a hover) moved past the top edge: the smooth-glide
// chain must start immediately (first step + a scheduled tick).
updated, cmd := m.Update(testMouseMotion(tea.MouseLeft, 0, aboveBody))
m = updated.(model)
if cmd == nil {
t.Fatal("crossing the top edge should schedule the glide tick chain")
}
if m.edgeScrollDelta <= 0 {
t.Fatalf("edgeScrollDelta = %d, want > 0 (scrolling toward older content)", m.edgeScrollDelta)
}
if m.chatScrollOffset == scrollBefore {
t.Fatal("dragging past the top edge should auto-scroll toward older content")
}
// A single small step may land on a blank spacer row between messages (no
// text there yet) — drive the tick chain forward like a sustained real hold
// would, and the cursor must eventually reach a genuinely earlier line.
for i := 0; i < 10 && m.transcriptSelection.cursor.bodyY >= cursorBefore; i++ {
updated, _ = m.Update(dragEdgeScrollTickMsg{seq: m.edgeScrollSeq})
m = updated.(model)
}
if !m.transcriptSelection.active {
t.Fatal("selection must survive an edge auto-scroll, not be cleared")
}
if m.transcriptSelection.cursor.bodyY >= cursorBefore {
t.Fatalf("selection cursor bodyY = %d, want < %d (it must extend upward to follow the drag)", m.transcriptSelection.cursor.bodyY, cursorBefore)
}
}
// Symmetric to the top-edge case: dragging past the BOTTOM edge scrolls toward
// newer content. Requires scrolling up first so there's somewhere to scroll back
// down to (chatScrollOffset=0 already sits at the bottom).
func TestTranscriptSelectionDragPastBottomEdgeAutoScrolls(t *testing.T) {
m := mouseTestModel()
m.mouseCapture = true
m.reducedMotion = false // deterministic across local/CI TTY-detection differences
for i := 0; i < 80; i++ {
m.transcript = appendRow(m.transcript, rowUser, "line content")
}
m.chatScrollOffset = chatWheelScrollLines * 3 // scrolled up, away from the bottom
textY := topmostVisibleTranscriptMouseY(t, m)
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 0, textY))
m = updated.(model)
if !m.transcriptSelection.active {
t.Fatal("selection should be active after a left click on transcript text")
}
cursorBefore := m.transcriptSelection.cursor.bodyY
scrollBefore := m.chatScrollOffset
frame, _, _ := m.transcriptHitTestLayout()
belowBody := frame.bodyRect.y + frame.bodyRect.height // one row below the visible transcript body
updated, cmd := m.Update(testMouseMotion(tea.MouseLeft, 0, belowBody))
m = updated.(model)
if cmd == nil {
t.Fatal("crossing the bottom edge should schedule the glide tick chain")
}
if m.edgeScrollDelta >= 0 {
t.Fatalf("edgeScrollDelta = %d, want < 0 (scrolling toward newer content)", m.edgeScrollDelta)
}
if m.chatScrollOffset >= scrollBefore {
t.Fatal("dragging past the bottom edge should auto-scroll toward newer content (offset decreases)")
}
for i := 0; i < 10 && m.transcriptSelection.cursor.bodyY <= cursorBefore; i++ {
updated, _ = m.Update(dragEdgeScrollTickMsg{seq: m.edgeScrollSeq})
m = updated.(model)
}
if !m.transcriptSelection.active {
t.Fatal("selection must survive an edge auto-scroll, not be cleared")
}
if m.transcriptSelection.cursor.bodyY <= cursorBefore {
t.Fatalf("selection cursor bodyY = %d, want > %d (it must extend downward to follow the drag)", m.transcriptSelection.cursor.bodyY, cursorBefore)
}
}
// The glide tick chain must self-terminate the instant the drag moves back into
// the visible body — otherwise a stray scheduled tick could keep scrolling after
// the user has already dragged back to the content they wanted selected.
func TestTranscriptSelectionEdgeGlideStopsWhenDragReturnsToBody(t *testing.T) {
m := mouseTestModel()
m.mouseCapture = true
m.reducedMotion = false // deterministic across local/CI TTY-detection differences
for i := 0; i < 80; i++ {
m.transcript = appendRow(m.transcript, rowUser, "line content")
}
textY := topmostVisibleTranscriptMouseY(t, m)
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 0, textY))
m = updated.(model)
frame, _, _ := m.transcriptHitTestLayout()
updated, _ = m.Update(testMouseMotion(tea.MouseLeft, 0, frame.bodyRect.y-1))
m = updated.(model)
if m.edgeScrollDelta == 0 {
t.Fatal("sanity check failed: the glide chain should be running")
}
seqWhileGliding := m.edgeScrollSeq
updated, _ = m.Update(testMouseMotion(tea.MouseLeft, 0, textY))
m = updated.(model)
if m.edgeScrollDelta != 0 {
t.Fatalf("edgeScrollDelta = %d, want 0 (dragging back into the body must stop the glide)", m.edgeScrollDelta)
}
// A tick already in flight from before the drag returned must be a no-op —
// it carries the OLD seq, which no longer matches.
scrollBefore := m.chatScrollOffset
updated, _ = m.Update(dragEdgeScrollTickMsg{seq: seqWhileGliding})
m = updated.(model)
if m.chatScrollOffset != scrollBefore {
t.Fatal("a stale in-flight tick must not scroll after the glide was stopped")
}
}
// A keypress mid-glide clears m.transcriptSelection.active directly (the
// KeyPressMsg handler in model.go), NOT through stopEdgeScroll — so
// edgeScrollDelta is left stale. If a fresh drag past the SAME edge afterward
// doesn't reset it, startEdgeScroll's "already running" fast path is fooled into
// thinking a chain is already active and silently does nothing: no immediate
// step, no scheduled tick. The press handler must reset glide state so every new
// drag starts clean regardless of how the previous one ended.
func TestTranscriptSelectionEdgeGlideRecoversAfterKeypressInterrupt(t *testing.T) {
m := mouseTestModel()
m.mouseCapture = true
m.reducedMotion = false // deterministic across local/CI TTY-detection differences
for i := 0; i < 80; i++ {
m.transcript = appendRow(m.transcript, rowUser, "line content")
}
textY := topmostVisibleTranscriptMouseY(t, m)
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 0, textY))
m = updated.(model)
frame, _, _ := m.transcriptHitTestLayout()
aboveBody := frame.bodyRect.y - 1
updated, _ = m.Update(testMouseMotion(tea.MouseLeft, 0, aboveBody))
m = updated.(model)
if m.edgeScrollDelta == 0 {
t.Fatal("sanity check failed: the glide chain should be running")
}
// A keypress interrupts the drag WITHOUT releasing the mouse — clears
// transcriptSelection.active directly, bypassing stopEdgeScroll.
updated, _ = m.Update(testKey('a'))
m = updated.(model)
if m.transcriptSelection.active {
t.Fatal("sanity check failed: the keypress should have cleared the selection")
}
if m.edgeScrollDelta == 0 {
t.Fatal("sanity check failed: edgeScrollDelta should still be stale here (that's the bug this test guards)")
}
// A brand-new press+drag past the SAME edge must glide normally, not be
// silently swallowed by the stale state from the interrupted drag. The
// viewport shifted from the first glide's step, so the topmost visible text
// line's on-screen Y must be recomputed — reusing the ORIGINAL textY would
// fail for an unrelated reason (it may now land on a blank spacer row).
textY = topmostVisibleTranscriptMouseY(t, m)
updated, _ = m.Update(testMouseClick(tea.MouseLeft, 0, textY))
m = updated.(model)
if !m.transcriptSelection.active {
t.Fatal("sanity check failed: the new press should start a fresh selection")
}
scrollBefore := m.chatScrollOffset
updated, cmd := m.Update(testMouseMotion(tea.MouseLeft, 0, aboveBody))
m = updated.(model)
if cmd == nil {
t.Fatal("the new drag should schedule a fresh glide tick chain, not be fooled by stale state")
}
if m.chatScrollOffset == scrollBefore {
t.Fatal("the new drag should have applied its immediate first scroll step")
}
}
func TestTranscriptSelectionClearsAfterCopy(t *testing.T) {
m := mouseTestModel()
m.transcriptSelection = transcriptSelectionState{active: true}
+4 -9
View File
@@ -44,9 +44,6 @@ func oauthDevicePrepare(name string) (oauth.DeviceAuth, oauth.Config, error) {
Store: store,
HTTPClient: &http.Client{Timeout: 60 * time.Second},
OpenBrowser: browser.OpenURL,
// Device-flow providers (e.g. Hugging Face) rely on the baked-in preset for
// their endpoints; opt in so the device code can be requested.
AllowPresets: true,
})
if err != nil {
return oauth.DeviceAuth{}, oauth.Config{}, err
@@ -65,9 +62,8 @@ func oauthDeviceComplete(name string, cfg oauth.Config, auth oauth.DeviceAuth) e
return err
}
manager, err := oauth.NewManager(oauth.ManagerOptions{
Store: store,
HTTPClient: &http.Client{Timeout: 60 * time.Second},
AllowPresets: true, // preset config is needed to poll/exchange the device token
Store: store,
HTTPClient: &http.Client{Timeout: 60 * time.Second},
})
if err != nil {
return err
@@ -88,9 +84,8 @@ func oauthStoredToken(ctx context.Context, providerID string) string {
return ""
}
manager, err := oauth.NewManager(oauth.ManagerOptions{
Store: store,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
AllowPresets: true, // refreshing a preset-provider token re-resolves its config
Store: store,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
})
if err != nil {
return ""
-9
View File
@@ -755,15 +755,6 @@ func (m model) completeSetup() (tea.Model, tea.Cmd) {
m.providerProfile = result.Provider
m.providerName = result.Provider.Name
m.modelName = result.Provider.Model
// Export ZERO_PROVIDER alongside the committed profile fields (and the
// config setupSave already persisted as active). Unlike command_center's
// switch — which commits everything only after a successful build — setup
// commits config + profile unconditionally here, so the env must match
// them unconditionally too: applyEnv makes ZERO_PROVIDER WIN over config,
// so a stale value from an earlier /model switch left in place would send
// spawned children to the OLD provider even though config now names the
// new one. That is the exact D3 gap this closes.
config.SetActiveProviderEnv(result.Provider.Name)
if m.newProvider != nil {
if provider, providerErr := m.newProvider(result.Provider); providerErr == nil {
m.provider = provider
-40
View File
@@ -3,7 +3,6 @@ package tui
import (
"context"
"errors"
"os"
"strings"
"testing"
@@ -1536,42 +1535,3 @@ func absInt(value int) int {
}
return value
}
// Completing setup switches the live provider, so it must export ZERO_PROVIDER
// exactly like the /model, /provider, and wizard switch paths — a stale value
// from an earlier switch would otherwise win over config in every spawned
// child (applyEnv) and pin specialists/swarm members to the OLD provider's
// credentials.
func TestCompleteSetupExportsActiveProviderEnv(t *testing.T) {
t.Setenv(config.ActiveProviderEnv, "stale-previous-provider")
m := newModel(context.Background(), Options{
Setup: SetupOptions{
Visible: true,
Providers: []SetupProviderOption{
{ID: "openai", Name: "OpenAI", DefaultModel: "gpt-4.1", EnvVar: "OPENAI_API_KEY", RequiresAuth: true},
},
Save: func(selection SetupSelection) (SetupResult, error) {
return SetupResult{
Provider: config.ProviderProfile{
Name: selection.CatalogID,
CatalogID: selection.CatalogID,
Model: selection.Model,
},
}, nil
},
},
})
m.width = 100
m.height = 30
m.setup.stage = setupStageReady
updated, _ := m.completeSetup()
next := updated.(model)
if next.providerName == "" {
t.Fatal("setup completion should have set a provider name")
}
if got := os.Getenv(config.ActiveProviderEnv); got != next.providerName {
t.Fatalf("%s = %q after setup save, want %q (children would spawn on the stale provider)", config.ActiveProviderEnv, got, next.providerName)
}
}
+30 -35
View File
@@ -20,46 +20,41 @@ import (
// Options configures the reusable Zero terminal UI shell.
type Options struct {
Cwd string
UserConfigPath string
DoctorUserConfigPath string
ProjectConfigPath string
ProviderName string
ModelName string
ProviderProfile config.ProviderProfile
SavedProviders []config.ProviderProfile // all configured providers, for the /model multi-provider list
FavoriteModels []string
RecapsEnabled bool
Provider zeroruntime.Provider
NewProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
ProbeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
DiscoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
DiscoverOllamaContextWindow func(ctx context.Context, baseURL string, model string) (int, error)
RuntimeMessageSink func(tea.Msg)
Registry *tools.Registry
SessionStore *sessions.Store
SandboxStore *sandbox.GrantStore
MCPConfig config.MCPConfig
MCPPermissionStore *mcp.PermissionStore
MCPTokenStore *mcp.TokenStore
MCPCommand func(context.Context, []string) MCPCommandResult
SandboxSetupCommand func(context.Context) SandboxSetupCommandResult
UsageTracker *usage.Tracker
SessionCompactor SessionCompactor
PrService *PrService
Cwd string
UserConfigPath string
DoctorUserConfigPath string
ProjectConfigPath string
ProviderName string
ModelName string
ProviderProfile config.ProviderProfile
SavedProviders []config.ProviderProfile // all configured providers, for the /model multi-provider list
FavoriteModels []string
RecapsEnabled bool
Provider zeroruntime.Provider
NewProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
ProbeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
DiscoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
RuntimeMessageSink func(tea.Msg)
Registry *tools.Registry
SessionStore *sessions.Store
SandboxStore *sandbox.GrantStore
MCPConfig config.MCPConfig
MCPPermissionStore *mcp.PermissionStore
MCPTokenStore *mcp.TokenStore
MCPCommand func(context.Context, []string) MCPCommandResult
SandboxSetupCommand func(context.Context) SandboxSetupCommandResult
UsageTracker *usage.Tracker
SessionCompactor SessionCompactor
PrService *PrService
AgentOptions agent.Options
PermissionMode agent.PermissionMode
ReasoningEffort modelregistry.ReasoningEffort
ResponseStyle string
// Theme is the operator's palette preference: "auto" (default), a built-in
// ("dark"/"light"), or a registered color theme. Set from the --theme flag;
// falls back to ZERO_THEME, then the persisted SavedTheme, then auto.
Theme string
// SavedTheme is the theme persisted in user config (Preferences.Theme). Applied
// at startup below --theme and ZERO_THEME, so a /theme choice survives restart.
SavedTheme string
UserAgent string
// Theme is the operator's palette preference: "auto" (default), "dark", or
// "light". Set from the --theme flag; falls back to ZERO_THEME then auto.
Theme string
UserAgent string
// Notify configures completion / awaiting-input notifications.
Notify config.NotifyConfig
-82
View File
@@ -23,7 +23,6 @@ const (
pickerModel pickerKind = iota
pickerEffort
pickerSession
pickerTheme
)
// pickerItem is one selectable row: Label is shown, Value is passed to the
@@ -565,46 +564,6 @@ type modelPickerModelsDiscoveredMsg struct {
err error
}
// ollamaContextWindowDiscoveredMsg carries the result of an async /api/show
// probe against a local Ollama daemon (see ollamaContextWindowDiscoveryCmd).
// A zero contextWindow (or a non-nil err) means the probe found nothing
// usable and the map is left untouched, not zeroed out.
type ollamaContextWindowDiscoveredMsg struct {
modelName string
contextWindow int
err error
}
// ollamaContextWindowDiscoveryCmd probes a local Ollama daemon's native
// /api/show endpoint for modelName's context length. Scoped to the local
// Ollama provider only (catalog ID "ollama", not "ollama-cloud" — a
// different hosted service this endpoint isn't assumed to exist on): the
// generic /v1/models discovery has no source for this metadata for
// custom/local model tags, so without this the context gauge just never
// shows for them (see modelContextWindow).
func (m model) ollamaContextWindowDiscoveryCmd(descriptor providercatalog.Descriptor, baseURL string, modelName string) tea.Cmd {
if descriptor.ID != "ollama" {
return nil
}
modelName = strings.TrimSpace(modelName)
baseURL = strings.TrimSpace(baseURL)
if modelName == "" || baseURL == "" {
return nil
}
discover := m.discoverOllamaContextWindow
if discover == nil {
discover = func(ctx context.Context, baseURL string, model string) (int, error) {
return providermodeldiscovery.DiscoverOllamaContextWindow(ctx, baseURL, model, providermodeldiscovery.Options{})
}
}
return func() tea.Msg {
ctx, cancel := context.WithTimeout(m.ctx, 8*time.Second)
defer cancel()
window, err := discover(ctx, baseURL, modelName)
return ollamaContextWindowDiscoveredMsg{modelName: modelName, contextWindow: window, err: err}
}
}
func (m model) normalizeProfileForProvider(provider providercatalog.Descriptor) config.ProviderProfile {
profile := m.providerProfile
normalizeIdentity := profileMatchesProviderBaseURL(profile, provider) ||
@@ -767,44 +726,3 @@ func (m model) newEffortPicker() *commandPicker {
}
return &commandPicker{kind: pickerEffort, title: "select reasoning effort", items: items, selected: selected}
}
// newThemePicker lists `auto` plus every registered theme as a popup, grouped into
// Dark/Light sections (the registry is ordered dark-then-light so the group header
// changes exactly once), preselecting the active preference. Bare `/theme` opens the
// same overlay /model and /effort use. Moving the cursor live-previews each palette
// (previewSelectedTheme); Enter commits the highlighted theme (choosePicker) and Esc
// restores the previous one (Update's picker-cancel path). Items stay 1:1 with
// themeModes and in the same order, so the popup and /theme state list agree.
func (m model) newThemePicker() *commandPicker {
items := make([]pickerItem, 0, len(themeModes))
selected := 0
// `auto` sits at the top with an empty Group, so it renders header-less above
// the Dark/Light sections.
items = append(items, pickerItem{Label: string(themeAuto), Value: string(themeAuto), Meta: "match terminal"})
for _, entry := range themeRegistry {
group := "Light"
if entry.IsDark {
group = "Dark"
}
items = append(items, pickerItem{Group: group, Label: entry.Label, Value: entry.Name})
if entry.Name == string(m.themeMode) {
selected = len(items) - 1
}
}
// allItems lets the query filter restore rows on Backspace (one-way narrowing
// otherwise, since applyQuery falls back to the current items without it).
return &commandPicker{kind: pickerTheme, title: "select theme", items: items, allItems: append([]pickerItem{}, items...), selected: selected}
}
// pickerMoved advances the open picker's cursor by delta and live-previews the new
// selection where the picker supports it — stepping through the /theme popup
// repaints the UI in the hovered palette. Safe to call with no picker open. Callers
// mutate through m.picker (a pointer) and the global theme, so the value receiver
// is fine.
func (m model) pickerMoved(delta int) {
if m.picker == nil {
return
}
m.picker.move(delta)
m.previewSelectedTheme()
}
+4 -104
View File
@@ -14,7 +14,6 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -640,17 +639,14 @@ func TestEffortPickerOpensForSupportedModel(t *testing.T) {
}
}
func TestThemeCommandOpensPicker(t *testing.T) {
// Bare /theme opens the theme popup (live preview on move, apply on Enter),
// like /model and /effort. Full preview/commit/cancel behavior is covered in
// theme_picker_test.go; here we just pin that the no-arg command opens it.
defer applyTheme(themeDark, true)
func TestThemeCommandOpensNoPicker(t *testing.T) {
// /theme keeps the existing shell-only message; no picker opens.
m := newModel(context.Background(), Options{})
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if m.picker == nil || m.picker.kind != pickerTheme {
t.Fatalf("expected the theme picker to open, got %#v", m.picker)
if m.picker != nil {
t.Fatal("/theme should not open a picker")
}
}
@@ -879,100 +875,4 @@ func TestModelContextWindowResolution(t *testing.T) {
if got := m.modelContextWindow(""); got != 0 {
t.Fatalf("empty model name should yield 0, got %d", got)
}
// A custom/local Ollama model tag has no curated-catalog entry and its
// generic /v1/models listing carries no window either — the only source is
// the Ollama-specific /api/show probe, landed via ollamaContextWindowByModel.
m.ollamaContextWindowByModel = map[string]int{"kimi-k2.7-code:cloud": 131072}
if got := m.modelContextWindow("kimi-k2.7-code:cloud"); got != 131072 {
t.Fatalf("ollama-discovered window should be used for an unregistered model, got %d", got)
}
}
// TestOllamaContextWindowDiscoveryCmdScopedToLocalOllama: the /api/show probe
// only makes sense against a local Ollama daemon (catalog ID "ollama") — a
// different provider like "ollama-cloud" is a distinct hosted service this
// endpoint isn't assumed to exist on, and every other provider already has
// its context window covered by the curated catalog or /v1/models discovery.
func TestOllamaContextWindowDiscoveryCmdScopedToLocalOllama(t *testing.T) {
m := model{ctx: context.Background()}
ollama, ok := providercatalog.Get("ollama")
if !ok {
t.Fatal("expected \"ollama\" to be a registered catalog descriptor")
}
if cmd := m.ollamaContextWindowDiscoveryCmd(ollama, "http://localhost:11434/v1", "kimi-k2.7-code:cloud"); cmd == nil {
t.Fatal("expected a discovery command for the local ollama provider")
}
ollamaCloud, ok := providercatalog.Get("ollama-cloud")
if !ok {
t.Fatal("expected \"ollama-cloud\" to be a registered catalog descriptor")
}
if cmd := m.ollamaContextWindowDiscoveryCmd(ollamaCloud, "https://ollama.com/v1", "qwen3-coder:480b"); cmd != nil {
t.Fatal("ollama-cloud is a different hosted service; must not probe /api/show against it")
}
if cmd := m.ollamaContextWindowDiscoveryCmd(ollama, "", "kimi-k2.7-code:cloud"); cmd != nil {
t.Fatal("an empty base URL should yield no command")
}
if cmd := m.ollamaContextWindowDiscoveryCmd(ollama, "http://localhost:11434/v1", ""); cmd != nil {
t.Fatal("an empty model name should yield no command")
}
}
// TestOllamaContextWindowDiscoveredMsgPopulatesMap verifies the Update()
// handler for the async probe's result actually reaches modelContextWindow's
// fallback source, and that a failed/zero probe leaves the map untouched
// rather than caching a bogus zero.
func TestOllamaContextWindowDiscoveredMsgPopulatesMap(t *testing.T) {
m := newModel(context.Background(), Options{})
updated, cmd := m.Update(ollamaContextWindowDiscoveredMsg{modelName: "kimi-k2.7-code:cloud", contextWindow: 131072})
next := updated.(model)
if cmd != nil {
t.Fatal("applying a discovered window should not schedule further work")
}
if got := next.modelContextWindow("kimi-k2.7-code:cloud"); got != 131072 {
t.Fatalf("modelContextWindow after discovery = %d, want 131072", got)
}
updated, _ = next.Update(ollamaContextWindowDiscoveredMsg{modelName: "other-model", contextWindow: 0, err: errors.New("boom")})
next = updated.(model)
if got := next.modelContextWindow("other-model"); got != 0 {
t.Fatalf("a failed probe must not populate a window, got %d", got)
}
}
// TestSwitchProviderModelWarmsDiscoveryForTheNewProvider: switching providers
// mid-session (e.g. via the /model picker) previously fired no discovery at
// all for the newly active provider — Init() only warms the provider active
// at launch — so the context gauge stayed blank for the rest of the session
// unless /model happened to be reopened separately. Switching to Ollama
// specifically must also warm the /api/show probe, the only source of a
// context window for custom/local Ollama models.
func TestSwitchProviderModelWarmsDiscoveryForTheNewProvider(t *testing.T) {
m := newModel(context.Background(), Options{
ProviderName: "openai",
ModelName: "gpt-5.1",
Provider: &fakeProvider{},
ProviderProfile: config.ProviderProfile{Name: "openai", CatalogID: "openai", Model: "gpt-5.1"},
SavedProviders: []config.ProviderProfile{
{Name: "openai", CatalogID: "openai", Model: "gpt-5.1"},
{Name: "ollama", CatalogID: "ollama", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "http://localhost:11434/v1", Model: "kimi-k2.7-code:cloud"},
},
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return &fakeProvider{}, nil
},
})
next, text, cmd := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud")
if !strings.Contains(text, "Switched to ollama") {
t.Fatalf("switch notice = %q, want it to confirm the switch", text)
}
if next.modelName != "kimi-k2.7-code:cloud" || next.providerName != "ollama" {
t.Fatalf("model/provider not switched: modelName=%q providerName=%q", next.modelName, next.providerName)
}
if cmd == nil {
t.Fatal("switching to ollama should warm both the generic and ollama-specific discovery commands")
}
}
+3 -47
View File
@@ -20,7 +20,6 @@ import (
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/provideroauth"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// providerWizardOAuthMsg carries the result of an in-wizard browser OAuth login.
@@ -167,10 +166,6 @@ func runProviderTokenLogin(name string) error {
Store: store,
HTTPClient: &http.Client{Timeout: 60 * time.Second},
OpenBrowser: browser.OpenURL,
// The user explicitly chose to sign in with this provider's OAuth, so opt
// into its baked-in preset (e.g. xAI's public client_id); without this the
// config never resolves and the browser never opens.
AllowPresets: true,
})
if err != nil {
return err
@@ -888,20 +883,13 @@ func (m model) applyProviderWizard() (model, tea.Cmd) {
modelChoice := wizard.currentModel()
profile := providerWizardProfile(provider, modelChoice.ID, wizard.apiKey, wizard.baseURL, wizard.profileName)
runtimeProfile := providerWizardRuntimeProfile(profile)
// Build and persist into LOCALS first, committing live state only once BOTH
// succeed. A persist failure (read-only config, disk full) must not leave the
// chat running on the new provider while the status line, m.providerProfile,
// and the ZERO_PROVIDER export (which pins spawned children) still point at
// the old one — parent and children would silently run on different providers.
var nextProvider zeroruntime.Provider
if m.newProvider != nil {
built, err := m.newProvider(runtimeProfile)
nextProvider, err := m.newProvider(runtimeProfile)
if err != nil {
wizard.err = redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{profile.APIKey, runtimeProfile.APIKey}})
return m, nil
}
nextProvider = built
m.provider = nextProvider
}
if strings.TrimSpace(m.userConfigPath) != "" {
// Capture flip: move the freshly entered key into the encrypted credential
@@ -911,24 +899,12 @@ func (m model) applyProviderWizard() (model, tea.Cmd) {
profile = config.SecureProviderProfile(profile, m.userConfigPath)
if _, err := config.UpsertProvider(m.userConfigPath, profile, true); err != nil {
wizard.err = redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{secret, profile.APIKey}})
return m, nil // nothing committed to live state yet
return m, nil
}
}
// Both succeeded — commit the live provider, profile, model, and the child
// env export together, so they can never disagree.
if nextProvider != nil {
m.provider = nextProvider
}
m.providerProfile = profile
m.providerName = profile.Name
m.modelName = profile.Model
// Keep sub-agent child processes on the same provider we just switched to —
// same as the /model and /provider switch paths (command_center.go). Without
// this, a ZERO_PROVIDER exported by an earlier switch stays pointing at the
// OLD provider and wins over config in every spawned child (applyEnv), so
// specialists/swarm members run on the wrong provider's credentials.
config.SetActiveProviderEnv(profile.Name)
m.providerWizard = nil
return m, nil
}
@@ -1253,29 +1229,9 @@ func (wizard *providerWizardState) renderProviderStep(width int) []string {
for offset, provider := range wizard.providers[start : start+maxVisible] {
lines = append(lines, wizard.renderSelectableProvider(width, start+offset, provider))
}
// A failed OAuth attempt leaves the wizard on this list (it does not advance),
// so the error must render here or the click looks like a silent no-op. The
// credential step renders its own copy for the ctrl+o path.
if wizard.oauthMode && wizard.oauthErr != "" {
lines = append(lines, "", zeroTheme.red.Render("OAuth login failed: "+wizard.oauthErr))
if hint := providerWizardOAuthErrHint(wizard.currentProvider()); hint != "" {
lines = append(lines, zeroTheme.faint.Render(hint))
}
}
return lines
}
// providerWizardOAuthErrHint returns a provider-specific next step for a failed
// OAuth login. Hugging Face ships no public client_id (unlike xAI), so a login
// can only work once the operator registers an app and supplies its client_id.
func providerWizardOAuthErrHint(provider providercatalog.Descriptor) string {
if strings.EqualFold(provider.ID, "huggingface") {
return "Hugging Face needs your own app: create one at " +
"https://huggingface.co/settings/applications/new, then set ZERO_OAUTH_HUGGINGFACE_CLIENT_ID."
}
return ""
}
func (wizard *providerWizardState) renderSelectableProvider(width int, index int, provider providercatalog.Descriptor) string {
selected := index == wizard.selectedProvider
surface := transparentSurface
@@ -125,36 +125,6 @@ func TestProviderWizardDeviceCodeMsgShowsCodeAndPolls(t *testing.T) {
}
}
// A failed OAuth attempt leaves the wizard on the provider list; the error must be
// rendered there (not just on the credential step) so a click isn't a silent
// no-op, and Hugging Face gets an actionable client_id hint.
func TestProviderStepSurfacesOAuthError(t *testing.T) {
m := mouseTestModel()
m.providerWizard = m.newProviderWizard()
wizard := m.providerWizard
wizard.oauthMode = true
wizard.providers = providerWizardOAuthDescriptors()
found := false
for i, p := range wizard.providers {
if strings.EqualFold(p.ID, "huggingface") {
wizard.selectedProvider = i
found = true
}
}
if !found {
t.Fatal("huggingface should be an OAuth-capable provider")
}
wizard.oauthErr = `oauth: provider "huggingface" is not configured; set ZERO_OAUTH_HUGGINGFACE_CLIENT_ID`
view := strings.Join(wizard.renderProviderStep(72), "\n")
if !strings.Contains(view, "OAuth login failed") {
t.Fatalf("provider step should surface the OAuth error:\n%s", view)
}
if !strings.Contains(view, "huggingface.co/settings/applications/new") {
t.Fatalf("Hugging Face hint should point at app registration:\n%s", view)
}
}
func TestProviderWizardDeviceErrorSurfaced(t *testing.T) {
m := mouseTestModel()
m.providerWizard = m.newProviderWizard()
-101
View File
@@ -1189,104 +1189,3 @@ func containsString(values []string, want string) bool {
}
return false
}
// Applying the wizard switches the live provider, so it must export
// ZERO_PROVIDER exactly like the /model and /provider switch paths — a stale
// value from an earlier switch would otherwise win over config in every
// spawned child (applyEnv) and pin specialists/swarm members to the OLD
// provider's credentials.
func TestApplyProviderWizardExportsActiveProviderEnv(t *testing.T) {
t.Setenv(config.ActiveProviderEnv, "stale-previous-provider")
m := newModel(context.Background(), Options{})
// Isolate the SUCCESS path's persist to a temp config (the default empty
// path would skip persist; a future default must never reach the real user
// config), and stub the build so the full commit sequence runs.
m.userConfigPath = filepath.Join(t.TempDir(), "config.json")
m.newProvider = func(config.ProviderProfile) (zeroruntime.Provider, error) {
return &fakeProvider{}, nil
}
m.providerWizard = &providerWizardState{
step: providerWizardStepModel,
profileName: "acme-wizard",
providers: []providercatalog.Descriptor{{
ID: "openrouter",
Name: "OpenRouter",
Transport: providercatalog.TransportOpenAICompatible,
DefaultBaseURL: "https://openrouter.ai/api/v1",
DefaultModel: "openai/gpt-4.1",
AuthEnvVars: []string{"OPENROUTER_API_KEY"},
RequiresAuth: true,
SupportedAPIFormats: []providercatalog.APIFormat{providercatalog.APIFormatOpenAIChatCompletions},
}},
models: []providerWizardModel{{ID: "openai/gpt-4.1", Description: "GPT-4.1"}},
}
updated, _ := m.applyProviderWizard()
next := updated
if next.providerName == "" {
t.Fatal("wizard apply should have set a provider name")
}
if got := os.Getenv(config.ActiveProviderEnv); got != next.providerName {
t.Fatalf("%s = %q after wizard apply, want %q (children would spawn on the stale provider)", config.ActiveProviderEnv, got, next.providerName)
}
}
// On a config PERSIST failure, applyProviderWizard must leave live state fully
// unchanged — the chat must NOT already be running on the new provider while the
// status line and the ZERO_PROVIDER export (which pins spawned children) still
// point at the old one. Build and persist are staged into locals; nothing is
// committed unless both succeed.
func TestApplyProviderWizardPersistFailureLeavesLiveStateUnchanged(t *testing.T) {
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") // never touch the real OS keychain: apiKey is secured before the persist fails
t.Setenv(config.ActiveProviderEnv, "old-provider")
// A config path whose parent is a regular FILE, so writeConfigFile's MkdirAll
// fails and UpsertProvider returns an error.
blocker := filepath.Join(t.TempDir(), "not-a-dir")
if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil {
t.Fatalf("seed blocker file: %v", err)
}
brokenConfigPath := filepath.Join(blocker, "config.json")
oldProvider := &fakeProvider{}
newProvider := &fakeProvider{}
m := newModel(context.Background(), Options{})
m.provider = oldProvider
m.providerProfile = config.ProviderProfile{Name: "old-provider"}
m.providerName = "old-provider"
m.userConfigPath = brokenConfigPath
m.newProvider = func(config.ProviderProfile) (zeroruntime.Provider, error) { return newProvider, nil }
m.providerWizard = &providerWizardState{
step: providerWizardStepModel,
profileName: "acme-new",
providers: []providercatalog.Descriptor{{
ID: "openrouter",
Name: "OpenRouter",
Transport: providercatalog.TransportOpenAICompatible,
DefaultBaseURL: "https://openrouter.ai/api/v1",
DefaultModel: "openai/gpt-4.1",
AuthEnvVars: []string{"OPENROUTER_API_KEY"},
RequiresAuth: true,
SupportedAPIFormats: []providercatalog.APIFormat{providercatalog.APIFormatOpenAIChatCompletions},
}},
models: []providerWizardModel{{ID: "openai/gpt-4.1", Description: "GPT-4.1"}},
apiKey: "sk-new",
}
updated, _ := m.applyProviderWizard()
next := updated
if next.providerWizard == nil || next.providerWizard.err == "" {
t.Fatal("a persist failure must surface a wizard error and keep the wizard open")
}
if next.provider != oldProvider {
t.Fatal("live provider must NOT be swapped when the persist fails")
}
if next.providerName != "old-provider" {
t.Fatalf("providerName = %q, want it unchanged on persist failure", next.providerName)
}
if got := os.Getenv(config.ActiveProviderEnv); got != "old-provider" {
t.Fatalf("%s = %q, want it unchanged on persist failure (children would diverge from parent)", config.ActiveProviderEnv, got)
}
}
-32
View File
@@ -55,38 +55,6 @@ func TestQueuedPromptPreviewAppearsInView(t *testing.T) {
}
}
// TestQueuedPromptPreviewSitsAboveComposer: the queued-message preview must
// render ABOVE the input box, not below it — a message waiting to send should
// sit on top of what you're currently typing.
func TestQueuedPromptPreviewSitsAboveComposer(t *testing.T) {
m := newQueuedMessageTestModel(t)
m.pending = true
m.activeRunID = 1
m.runID = 1
m.width = 96
m.input.SetValue("summarize the failing test output")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
footer := plainRender(t, next.footerView(96))
lines := strings.Split(footer, "\n")
queuedLine, composerTopLine := -1, -1
for i, ln := range lines {
if queuedLine < 0 && strings.Contains(ln, "queued") {
queuedLine = i
}
if composerTopLine < 0 && strings.Contains(ln, "╭") { // composer box top border
composerTopLine = i
}
}
if queuedLine < 0 || composerTopLine < 0 {
t.Fatalf("expected both a queued line and a composer box; queued=%d composer=%d\n%s", queuedLine, composerTopLine, footer)
}
if queuedLine >= composerTopLine {
t.Fatalf("queued preview (line %d) must sit ABOVE the composer box top border (line %d):\n%s", queuedLine, composerTopLine, footer)
}
}
func TestAgentResponseLaunchesQueuedPrompt(t *testing.T) {
provider := &scriptedProvider{scripts: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "queued answer"},
-3
View File
@@ -164,9 +164,6 @@ func (m model) renderRowCacheKey(row transcriptRow, width int, rc rowContext, op
appendRenderCacheField(&b, strconv.FormatBool(row.final))
appendRenderCacheField(&b, strconv.Itoa(row.turnTools))
appendRenderCacheField(&b, strconv.FormatInt(int64(row.turnElapsed), 10))
// The FILES selection tints this row's card border, so selecting/deselecting
// a file must miss the cache entry rendered under the other state.
appendRenderCacheField(&b, strconv.FormatBool(m.rowTouchesSelectedFile(row)))
key := rcKey(row.runID, row.id)
appendRenderCacheField(&b, rc.hints[key])
+1 -14
View File
@@ -36,7 +36,7 @@ func pickerBusyText(name string) string {
Title: "Busy",
Lines: []string{"Can't change " + label + " while a run is in progress."},
}},
Hints: []string{"press Esc twice to cancel the run, then try again"},
Hints: []string{"press Esc to cancel the run, then try again"},
})
}
@@ -167,10 +167,6 @@ type cardRenderOptions struct {
bodyCap int
cwd string
expanded bool
// fileSelected marks a tool-result card whose mutation touched the file
// selected in the FILES sidebar; the card border tints accent so the
// selection reads in the transcript.
fileSelected bool
}
// flushCardBodyMaxLines is the body cap for cards flushed to scrollback. The
@@ -214,10 +210,6 @@ func (m model) renderRowMode(row transcriptRow, width int, rc rowContext, flush
}
func (m model) renderRowModeUncached(row transcriptRow, width int, rc rowContext, opts cardRenderOptions) string {
// Resolved per-row (not at the opts construction sites) so every render
// path — live, flush, detailed — carries the FILES selection tint; the
// cache key includes the same predicate (renderRowCacheKey).
opts.fileSelected = m.rowTouchesSelectedFile(row)
switch row.kind {
case rowUser:
return renderUserRow(row, width)
@@ -1398,11 +1390,6 @@ func renderToolResultCard(row transcriptRow, width int, rc rowContext, opts card
glyph := zeroTheme.green.Render("•")
nameStyle := zeroTheme.green
borderStyle := zeroTheme.line
if opts.fileSelected {
// The selected FILES row's edit card: accent border, same as the
// sidebar's ▸ marker, so click → highlight reads as one gesture.
borderStyle = zeroTheme.accent
}
if failed {
glyph = zeroTheme.red.Render("•")
nameStyle = zeroTheme.red
+6 -29
View File
@@ -444,13 +444,12 @@ func transcriptRowsFromSessionEvents(events []sessions.Event) []transcriptRow {
}
output := payloadString(payload, "output")
rows = append(rows, transcriptRow{
kind: rowToolResult,
id: effectiveToolRowID(id, callSeq[id]),
text: fmt.Sprintf("tool result: %s %s %s", name, status, truncateTUIOutput(output, tuiToolOutputLimit)),
tool: name,
status: status,
detail: output,
changedFiles: payloadStringSlice(payload, "changedFiles"),
kind: rowToolResult,
id: effectiveToolRowID(id, callSeq[id]),
text: fmt.Sprintf("tool result: %s %s %s", name, status, truncateTUIOutput(output, tuiToolOutputLimit)),
tool: name,
status: status,
detail: output,
})
case sessions.EventError:
if message := payloadString(payload, "message"); message != "" {
@@ -620,28 +619,6 @@ func payloadString(payload map[string]any, key string) string {
}
}
// payloadStringSlice reads a []string persisted into a session payload (JSON
// round-trips it as []any), skipping non-string entries. Nil when absent.
func payloadStringSlice(payload map[string]any, key string) []string {
switch typed := payload[key].(type) {
case []string:
return typed
case []any:
out := make([]string, 0, len(typed))
for _, v := range typed {
if s, ok := v.(string); ok && s != "" {
out = append(out, s)
}
}
if len(out) == 0 {
return nil
}
return out
default:
return nil
}
}
func payloadBool(payload map[string]any, key string) bool {
value := payload[key]
switch typed := value.(type) {
-37
View File
@@ -9,7 +9,6 @@ import (
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/usage"
@@ -257,42 +256,6 @@ func (m model) handleSelfCorrectCommand(args string) (model, string) {
return m, m.selfCorrectText()
}
// maxTurnsCeiling caps the per-session /turns budget so a typo (e.g. /turns 99999)
// can't set an absurd ceiling; real multi-step tasks fit comfortably under it. Shared
// with config so applyEnv enforces the same bound on an inherited ZERO_MAX_TURNS.
const maxTurnsCeiling = config.MaxTurnsCeiling
func (m model) handleTurnsCommand(args string) (model, string) {
args = strings.TrimSpace(args)
if args == "" || strings.EqualFold(args, "status") {
return m, m.turnsText()
}
n, err := strconv.Atoi(args)
if err != nil || n < 1 {
return m, "Turns\nUsage: /turns <n> — set the per-run tool-turn budget for this session (a positive number, e.g. /turns 150)."
}
if n > maxTurnsCeiling {
n = maxTurnsCeiling
}
m.agentOptions.MaxTurns = n
// Propagate the budget to spawned sub-agents / swarm members (which inherit the
// environment) so a delegated task gets the same budget, not config.json's default.
config.SetMaxTurnsEnv(n)
return m, m.turnsText()
}
func (m model) turnsText() string {
return renderCommandOutput(commandOutput{
Title: "Turns",
Status: commandStatusOK,
Sections: []commandSection{{
Title: "State",
Lines: []string{fmt.Sprintf("max tool-turns per run: %d", m.agentOptions.MaxTurns)},
}},
Hints: []string{fmt.Sprintf("/turns <n> sets this session's tool-turn budget (max %d); raise it for long multi-step tasks like delegation/swarm runs", maxTurnsCeiling)},
})
}
func (m model) selfCorrectText() string {
depth := "LSP diagnostics only — fast, scoped to changed files"
if m.selfCorrectTests {
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more