Compare commits

...
Author SHA1 Message Date
Gnanam 132788fd36 fix(providerio): detect a mid-stream dead connection at a tighter gap timeout
Live capture of the reported "stuck for 33 minutes": the model call
streamed ~119KB, then the socket went byte-frozen forever while the TUI
showed a dead "Working · thinking" spinner. The only bound on that shape
was the full 5-minute idle window — per attempt — so one turn could blind-
wait 15+ minutes across its stall retries before surfacing anything.

Split the watchdog window by stream phase:

- BEFORE the first payload the full idle window still applies (slow cloud
  proxies legitimately withhold output until the upstream model produces
  its first token — the reason the idle default is 5m).
- AFTER any payload has arrived the backend has proven it streams and
  heartbeats, so the re-arm window tightens to the gap timeout (default
  2m, ZERO_STREAM_GAP_TIMEOUT, clamped to the idle window, disabled with
  it). A byte-frozen socket mid-stream is a dead connection; waiting the
  full idle for it just multiplies the user's blind wait per retry.
  Keep-alives reset the gap like they reset idle, so a slow-but-alive
  stream is never cut — this only fires on true byte silence.

Raise the in-turn stall retries from 2 to 3 (4 attempts total): Codex
ships stream_max_retries=5 for the same failure class, and opencode's
unbounded session retry has looped forever on it — 4 hard-capped attempts
with the faster detection bounds a typical mid-stream death at roughly
six minutes to recovery-or-honest-error instead of fifteen-plus blind.

All providers benefit (openai/anthropic/gemini share the SSE scanner).
Tests: gap applies only after the first payload (frozen-after-first-byte
aborts at the tightened window; silent-from-the-start still gets the full
idle), resolver default/clamp/env/off semantics, and the existing idle,
content-stall, and cancel watchdog tests unchanged.
2026-07-02 20:16:55 +05:30
Gnanam ae43d92d2a feat(agent): carry wire-level diagnostics in the provider-empty stop answer
Append the last empty attempt's response shape (finish_reason,
output/reasoning token counts) to the provider-empty stop message. This
failure class previously took a live traffic capture to diagnose — the
sessions recorded only the generic guard text, with nothing about what the
backend actually returned. Now the session log itself says e.g.
"[last attempt: finish_reason=stop, output_tokens=0, reasoning_tokens=0]",
which distinguishes an instantly-empty relay response from a truncated or
filtered one at a glance.
2026-07-02 19:58:56 +05:30
Gnanam 48318dfb57 fix(openai): parse the reasoning delta field alongside reasoning_content
Reasoning/thinking deltas arrive under different keys depending on the
backend dialect: DeepSeek-style servers emit `reasoning_content` (already
handled), while ollama's OpenAI-compat endpoint, OpenRouter, and most
gateways emit `reasoning`. Zero only parsed the former, so on those
backends every thinking token was silently dropped: live capture against
ollama glm-5.2:cloud showed 28-227 reasoning deltas per turn discarded
even in successful runs. The model looked frozen for minutes behind a dead
"thinking" spinner, the discarded thinking was never replayed into the
next turn's context, and a reasoning-only turn was indistinguishable from
a dead provider (it now feeds CollectedStream.ReasoningEmitted, which the
agent loop uses to tell backend faults from behavioral empty turns).

Emit StreamEventReasoning from whichever key is present; no backend sends
both. The regression test replays the exact delta shape captured live from
ollama.
2026-07-02 19:56:07 +05:30
Gnanam 92ab65aa5f fix(agent): retry provider-empty streams and name the fault when they persist
A backend can answer a completion request with HTTP 200 and an SSE stream
that carries nothing at all — no text, no tool calls, no reasoning.
Observed live on the ollama cloud relay under load (200 in ~1s, straight
to [DONE]; healthy calls take 11s+): three such responses arrived within
two seconds, the no-output guard struck out, and the run died with the
generic "Agent stopped after 3 turns with no output" — no error, no hint
the PROVIDER failed. On the reporting machine 577 of 1034 sessions (56%)
ended exactly this way, and the same silent handling produced multi-minute
"Working · thinking" hangs in the TUI plan flow.

Distinguish that PROVIDER-empty shape from a behavioral empty turn (the
model streamed reasoning but committed no answer — its trace proves the
backend worked) via a new CollectedStream.ReasoningEmitted signal:

- providerEmptyStream turns are re-issued in-turn with backoff (bounded by
  maxEmptyStreamRetries, mirroring the stall-retry pattern) before they may
  count as a no-output strike — the condition is transient backend state
  and usually recovers. Nothing from the empty stream was forwarded or
  committed to history, so the retry is clean. A user-visible notice
  ("provider returned an empty response — retrying n/m") rides the
  reasoning channel like the stall/reconnect notices.
- When every guard strike was provider-empty, the stop answer names the
  backend fault and what to do about it, instead of reading as the agent
  giving up. Mixed or behavioral strikes keep the existing message, and
  IsNoProgressStop recognizes the new answer so session titling and resume
  filtering treat it like the other guard stops.

Tests: provider-empty strikes retry 3× each and stop with the provider
message (recognized by IsNoProgressStop, distinct from the generic
marker); a transient empty recovers on the in-turn retry with no extra
turn counted; mixed provider/behavioral strikes keep the generic message;
the existing counter-reset and guard tests now drive BEHAVIORAL empties
(reasoning-only turns) and are byte-identical in outcome.
2026-07-02 19:54:31 +05:30
Gautam ManchandaniandGautam Manchandani 960db9660e fix(tools): require permission before web_search requests (#382)
* fix(tools): gate web_search behind network permission

* chore(tools): clarify web_search permission reason

---------

Co-authored-by: Gautam Manchandani <[email protected]>
2026-07-02 22:14:58 +08:00
Ashwinhegde19andVasanthdev2004 b1ccb6d9c1 feat(providers): add KiloCode and OpenCode provider support (#388)
* feat(providers): add KiloCode and OpenCode provider support

Add KiloCode (Kilo Gateway), OpenCode Zen, and OpenCode Go as catalog
entries using the existing openAICompat transport. No new runtime code
needed — both gateways expose standard OpenAI-compatible /chat/completions.

Fixes #383

* fix(providers): swap OpenCode Zen default model to deepseek-v4-flash

gpt-5.5 is served via /responses on Zen, not /chat/completions.
deepseek-v4-flash works with openAICompat transport.

Co-Authored-By: Vasanthdev2004
2026-07-02 22:10:54 +08:00
Vasanth T 0bc8074c97 feat(providers): add zero providers models to discover a provider's models (#386)
`zero providers` had no way to list the models a provider actually serves, so a
custom OpenAI-/Anthropic-compatible endpoint (e.g. a self-hosted proxy serving a
dozen models) forced users to hand-write a config object per model. Live model
discovery already existed for the TUI picker but had no headless CLI surface.

Add `zero providers models [name] [--json]`: it probes the provider's live
model-listing endpoint (an OpenAI-compatible /v1/models or the Anthropic /models)
via providermodeldiscovery.Discover, using the profile's base URL and resolved
credential (inline -> stored -> env). This uses the raw discovery primitive with
no curated-catalog merge or coding-model filtering, so a custom provider's full
model list is returned. Configure the provider once, run `zero providers models`
to see what it serves, and use any id with `zero exec --model <id>` (Zero passes
unknown model ids through to the provider) -- no per-model config needed.

Wired through a new appDeps.discoverProviderModels hook (default:
providermodeldiscovery.Discover) so the command is unit-tested hermetically.

Fixes #381.
2026-07-02 22:10:08 +08:00
KRATOS 72eed06b4f fix(config): unbrick first-run setup — default google/anthropic models, enter setup on fixable config errors (#385)
* fix(config): default missing google/anthropic models from the catalog

A hand-written provider entry like {name: "google", provider_kind:
"google", apiKey: ...} with no model bricked Zero: config resolution
failed with "provider google requires model", and since zero config and
bare zero setup both resolve up front, the only commands that could have
fixed the config died with the same error (reported in the wild — the
one working escape, zero setup google --model <m>, is undiscoverable
from that message).

The openai kind has always defaulted a missing model
(modelregistry.DefaultModelID); google and anthropic were missed. Fall
back to their provider-catalog default models (gemini-2.5-pro /
claude-sonnet-4.5) the same way, behind the same gate: provider-command
configs (normalizeProvidersWithoutModelDefaults) still surface exactly
what the external command returned. An explicitly configured model
always wins, and profiles with a catalogID already got this via
applyCatalogDescriptor — this covers the catalog-less hand-written case.

The residual requires-model error (openai-compatible customs, which
have no catalog default) now carries an actionable hint: add "model" in
config.json or re-run zero setup with --model.

Tests: google/anthropic defaulting (red-green: reverting the google
fallback reproduces the exact reported error), explicit model wins,
provider-command gate still errors with the hint, and validation treats
official-kind model-less profiles as clean while still flagging customs.

* fix(cli): enter setup on a requires-model config and hint zero auth login

Two residual first-run gaps after the catalog model-defaulting fix:

- An active provider whose model has NO catalog default to fall back on
  (custom openai-/anthropic-compatible endpoints) still bricked bare
  `zero` and `zero setup` — the exact commands that could have repaired
  the config. Tag that resolve failure with ErrProviderRequiresModel and
  extend the interactive TUI's fixable-error fallback (previously only
  ErrNoActiveProvider) to drop into the setup wizard for it. The sentinel
  is attached via a message-preserving wrapper (multi-target Unwrap), so
  headless commands (zero config / zero exec) print the exact actionable
  text unchanged and every other resolve error stays fatal.

- `zero login` / `zero logout` don't exist (it's `zero auth login`);
  first-run users try them and got only "unknown command" (reported in
  the wild). The unknown-command error now answers: did you mean
  "zero auth login"? Unrelated unknown commands get no suggestion.

Tests: the sentinel comes from a REAL Resolve over a real config file
(message-prefix asserted, no key leak); the TUI fallback is red-green
(reverting the condition reproduces the exact brick with the old error);
the existing no-active-provider fallback and other-errors-stay-fatal
tests still pass; login/logout hint with a no-false-positive check.
2026-07-02 22:09:25 +08:00
Kunj SHAH 33dc7ae2cc fix(tools): CRLF line ending mismatch in edit_file tool on Windows (#378)
* fix(tools): CRLF line ending mismatch in edit_file tool on Windows

* fix: resolve gofmt error, add robust CRLF guard, and fix Windows sandbox and OAuth test flakiness
2026-07-02 22:07:26 +08:00
Kunj SHAH 292e1edea5 security(config): project config should not override sandbox.network to allow (#379)
* security(config): project config should not override sandbox.network to allow

* fix(config): use NormalizeNetworkMode in project config merge, per coderabbit review
2026-07-02 21:01:35 +08:00
30 changed files with 1306 additions and 82 deletions

No files matched your search

+76
View File
@@ -2,6 +2,7 @@ package agent
import (
"encoding/json"
"fmt"
"strconv"
"strings"
@@ -284,12 +285,67 @@ const (
noOutputStopSuffix = "to avoid consuming tokens without making progress."
)
// providerEmptyStream reports that a cleanly-completed turn carried NOTHING —
// no visible text, no tool calls (real or dropped), and no reasoning signal.
// That shape is a transport/backend fault (e.g. the ollama cloud relay
// answering 200 with an instantly-empty SSE under load), not model behavior:
// a model that is generating always leaves SOME trace (text, a tool call
// attempt, or reasoning deltas).
func providerEmptyStream(collected zeroruntime.CollectedStream) bool {
return strings.TrimSpace(collected.Text) == "" &&
len(collected.ToolCalls) == 0 &&
collected.DroppedToolCalls == 0 &&
len(collected.ReasoningBlocks) == 0 &&
!collected.ReasoningEmitted
}
// allEmptyTurnsProviderEmpty reports that every strike toward the no-output
// guard was a provider-empty stream, so the stop message should name the
// backend fault rather than generic agent no-progress.
func (state *guardState) allEmptyTurnsProviderEmpty() bool {
return state.emptyTurns > 0 && state.providerEmptyTurns == state.emptyTurns
}
// noOutputStopAnswer is the final answer returned when the no-output guard
// stops the run. The turn count is interpolated at the call site.
func noOutputStopAnswer(turns int) string {
return noOutputStopPrefix + strconv.Itoa(turns) + " turns " + noOutputStopMarker + " " + noOutputStopSuffix
}
// providerEmptyStopAnswer is the final answer when every guard strike was a
// provider-empty stream: the backend repeatedly answered with contentless
// completions (already retried with backoff inside each turn). It tells the
// user the truth — the provider failed, not the agent — and what to do next.
// detail carries wire-level diagnostics from the last empty attempt
// (finish reason, token counts) so the condition is debuggable straight from
// the session log — this exact failure class previously took a live traffic
// capture to diagnose because nothing about the response shape was recorded.
func providerEmptyStopAnswer(turns int, detail string) string {
answer := providerEmptyStopPrefix +
strconv.Itoa(turns) + " consecutive attempts, each already retried with backoff. " +
"The backend is likely rate-limiting or degraded right now — try again shortly, or switch providers/models (zero providers use <name>)."
if detail != "" {
answer += " [last attempt: " + detail + "]"
}
return answer
}
// emptyStreamDetail renders the wire-level shape of an empty attempt for
// providerEmptyStopAnswer.
func emptyStreamDetail(collected zeroruntime.CollectedStream) string {
finish := collected.FinishReason
if finish == "" {
finish = "stop"
}
return fmt.Sprintf("finish_reason=%s, output_tokens=%d, reasoning_tokens=%d",
finish, collected.Usage.OutputTokens, collected.Usage.ReasoningTokens)
}
// providerEmptyStopPrefix is the stable head of providerEmptyStopAnswer, used
// by IsNoProgressStop so session titling / resume filtering treat the
// provider-empty stop like the other guard answers (not real content).
const providerEmptyStopPrefix = "The model provider returned an empty response (no text, no tool calls, no reasoning) on "
// IsNoProgressStop reports whether content IS the no-output guardrail stop answer
// (a run that produced no visible text and no tool calls). It matches the EXACT
// structure noOutputStopAnswer emits — prefix + "<int> turns " + marker + " " +
@@ -300,6 +356,15 @@ func noOutputStopAnswer(turns int) string {
// and skip its title generation.
func IsNoProgressStop(content string) bool {
trimmed := strings.TrimSpace(content)
// The provider-empty variant: stable prefix + a bare integer count.
if rest, ok := strings.CutPrefix(trimmed, providerEmptyStopPrefix); ok {
if sep := strings.Index(rest, " consecutive attempts"); sep > 0 {
if _, err := strconv.Atoi(rest[:sep]); err == nil {
return true
}
}
return false
}
if !strings.HasPrefix(trimmed, noOutputStopPrefix) {
return false
}
@@ -352,6 +417,7 @@ func toolOnlyProgressReminder(turns int) string {
// purely from tool-call names and per-turn output, matching what the loop holds.
type guardState struct {
emptyTurns int
providerEmptyTurns int
totalToolCalls int
toolCallsSincePlanUpdate int
planEverCalled bool
@@ -417,8 +483,18 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
if hasToolCalls || hasVisibleText {
state.emptyTurns = 0
state.providerEmptyTurns = 0
} else {
state.emptyTurns++
// Track separately whether the empty turn was a PROVIDER-empty stream
// (nothing at all came back — not even reasoning) vs a behavioral empty
// (the model thought/streamed but committed no answer). When every
// strike was provider-empty, the stop message names the backend fault
// instead of blaming the agent — the difference between the user
// retrying/switching providers and filing a "the agent gives up" bug.
if providerEmptyStream(collected) {
state.providerEmptyTurns++
}
}
if hasToolCalls && !hasVisibleText {
state.toolOnlyTurns++
+116 -11
View File
@@ -9,11 +9,23 @@ import (
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// emptyTurn is a stream that produces no visible text and no tool calls.
// emptyTurn is a PROVIDER-empty stream: it completes cleanly carrying nothing
// at all (the ollama-cloud 200+[DONE] shape). The loop retries these in-turn
// before counting a no-output strike.
func emptyTurn() []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{{Type: zeroruntime.StreamEventDone}}
}
// reasoningOnlyTurn is a BEHAVIORAL empty turn: the model streamed reasoning
// (so the provider clearly worked) but committed no text and no tool calls.
// These are NOT retried in-turn; they count directly toward the no-output guard.
func reasoningOnlyTurn() []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventReasoning, Content: "thinking…"},
{Type: zeroruntime.StreamEventDone},
}
}
// textTurn produces a turn with visible assistant text.
func textTurn(content string) []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
@@ -45,9 +57,9 @@ func countUserMessagesContaining(messages []zeroruntime.Message, needle string)
func TestRunStopsAfterConsecutiveEmptyTurns(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
emptyTurn(),
emptyTurn(),
emptyTurn(),
reasoningOnlyTurn(),
reasoningOnlyTurn(),
reasoningOnlyTurn(),
// A 4th turn exists but must never be requested.
textTurn("should never reach here"),
},
@@ -77,10 +89,10 @@ func TestRunStopsAfterConsecutiveEmptyTurns(t *testing.T) {
func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
emptyTurn(),
emptyTurn(),
reasoningOnlyTurn(),
reasoningOnlyTurn(),
textTurn("here is real progress"), // resets the counter and is the final answer
emptyTurn(),
reasoningOnlyTurn(),
},
}
@@ -109,11 +121,11 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
emptyTurn(),
emptyTurn(),
reasoningOnlyTurn(),
reasoningOnlyTurn(),
toolTurn("call-1", "read_file", `{"path":"notes.txt"}`), // resets counter
emptyTurn(),
emptyTurn(),
reasoningOnlyTurn(),
reasoningOnlyTurn(),
textTurn("done"),
},
}
@@ -455,3 +467,96 @@ func TestRunInjectsToolFailureHintWithSchema(t *testing.T) {
t.Fatalf("expected a tool-failure hint on the 3rd turn, messages: %+v", provider.requests[2].Messages)
}
}
// A provider-empty stream (clean completion carrying nothing at all) is
// retried in-turn with backoff before it may count as a no-output strike; when
// the backend keeps answering empty, the run stops with a message that names
// the PROVIDER fault — not the generic "agent made no progress" text. This is
// the exact live failure observed on the ollama cloud relay (HTTP 200 in ~1s,
// SSE straight to [DONE]), which silently killed 56% of sessions on the
// reporting machine.
func TestRunRetriesProviderEmptyStreamsThenStopsWithProviderMessage(t *testing.T) {
turns := make([][]zeroruntime.StreamEvent, 0, 9)
for i := 0; i < 9; i++ { // 3 strikes × (1 initial + 2 in-turn retries)
turns = append(turns, emptyTurn())
}
provider := &mockProvider{turns: turns}
result, err := Run(context.Background(), "go", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 12,
})
if err != nil {
t.Fatal(err)
}
wantRequests := maxEmptyTurns * (1 + maxEmptyStreamRetries)
if len(provider.requests) != wantRequests {
t.Fatalf("expected %d requests (%d strikes × %d attempts), got %d",
wantRequests, maxEmptyTurns, 1+maxEmptyStreamRetries, len(provider.requests))
}
if !strings.Contains(result.FinalAnswer, "provider returned an empty response") {
t.Fatalf("expected the provider-empty stop message, got %q", result.FinalAnswer)
}
if strings.Contains(result.FinalAnswer, noOutputStopMarker) {
t.Fatalf("provider fault must not be reported as generic agent no-progress: %q", result.FinalAnswer)
}
if !IsNoProgressStop(result.FinalAnswer) {
t.Fatal("the provider-empty stop answer must be recognized by IsNoProgressStop (titling/resume filters)")
}
// Wire-level diagnostics must land in the message (and therefore the
// session log) so this failure class is debuggable without a traffic capture.
if !strings.Contains(result.FinalAnswer, "output_tokens=0") || !strings.Contains(result.FinalAnswer, "finish_reason=") {
t.Fatalf("stop answer must carry last-attempt diagnostics, got %q", result.FinalAnswer)
}
}
// A transient empty response recovers on the in-turn retry: no strike, no stop,
// the retried turn's real output is the answer.
func TestRunRecoversWhenEmptyStreamRetrySucceeds(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
emptyTurn(), // initial attempt: backend hiccup
textTurn("recovered"), // in-turn retry gets the real answer
},
}
result, err := Run(context.Background(), "go", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 12,
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) != 2 {
t.Fatalf("expected 2 requests (initial + one retry), got %d", len(provider.requests))
}
if result.FinalAnswer != "recovered" {
t.Fatalf("expected the retried turn's text as final answer, got %q", result.FinalAnswer)
}
if result.Turns != 1 {
t.Fatalf("an in-turn retry is the SAME turn, want 1 turn, got %d", result.Turns)
}
}
// Mixed strikes (provider-empty + behavioral reasoning-only) must NOT claim a
// provider fault — the generic no-output message stays.
func TestRunMixedEmptyTurnsKeepGenericStopMessage(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
reasoningOnlyTurn(), // strike 1: behavioral
emptyTurn(), emptyTurn(), emptyTurn(), // strike 2: provider-empty ×(1+2 retries)
reasoningOnlyTurn(), // strike 3: behavioral
},
}
result, err := Run(context.Background(), "go", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 12,
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(result.FinalAnswer, noOutputStopMarker) {
t.Fatalf("mixed strikes must use the generic no-output message, got %q", result.FinalAnswer)
}
}
+52 -1
View File
@@ -25,7 +25,20 @@ const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not
// 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
// maxStreamStallRetries: 3 in-turn re-issues (4 attempts total) approaches
// Codex's stream_max_retries=5 posture while staying hard-capped (opencode's
// unbounded session retry has produced infinite loops on this same failure).
// Affordable now that a mid-stream death is detected at the gap timeout
// (~2m) instead of the full 5m idle window.
const maxStreamStallRetries = 3
// maxEmptyStreamRetries caps in-turn re-issues of a request whose stream
// completed cleanly but carried nothing at all (providerEmptyStream). Observed
// live on the ollama cloud relay under load: HTTP 200 in ~1s with an SSE that
// goes straight to [DONE]. The condition is transient backend state, so a
// short backoff-retry usually recovers; without it three such responses
// arrived inside two seconds and killed the run via the no-output guard.
const maxEmptyStreamRetries = 2
const (
toolResultMetaControl = "control"
@@ -324,6 +337,35 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, forwardingOpts)
}
// Provider-empty retry: the stream completed CLEANLY but carried nothing
// — no text, no tool calls, no reasoning. That is not model behavior (a
// generating model always leaves some trace); it is a backend answering
// with a contentless completion (seen on the ollama cloud relay under
// load: 200 + instant [DONE]). Transient, so re-issue with backoff before
// letting it count as a no-output strike. Nothing from the empty stream
// was forwarded or committed to history, so the retry is clean.
for attempt := 1; attempt <= maxEmptyStreamRetries &&
collected.Error == "" && !forwardedVisibleText &&
providerEmptyStream(collected); attempt++ {
if notify := emptyRetryNoticeFor(options); notify != nil {
notify(attempt, maxEmptyStreamRetries)
}
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)
@@ -370,6 +412,15 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// A truly-empty turn (no text, no tool calls, no dropped calls) is
// counted toward the runaway cap so we stop before burning maxTurns.
if guards.observeTurn(collected) {
// Every strike being a provider-empty stream means the BACKEND
// failed repeatedly (each attempt already retried with backoff
// above) — say so, instead of the generic no-progress message
// that reads as the agent giving up.
if guards.allEmptyTurnsProviderEmpty() {
result.FinalAnswer = providerEmptyStopAnswer(result.Turns, emptyStreamDetail(collected))
result.Messages = copyMessages(messages)
return result, nil
}
result.FinalAnswer = noOutputStopAnswer(result.Turns)
result.Messages = copyMessages(messages)
return result, nil
+76 -1
View File
@@ -37,6 +37,35 @@ func (provider *mockProvider) StreamCompletion(ctx context.Context, request zero
return ch, nil
}
type recordingWebSearchTool struct {
calls []map[string]any
}
func (tool *recordingWebSearchTool) Name() string { return "web_search" }
func (tool *recordingWebSearchTool) Description() string { return "test web search tool" }
func (tool *recordingWebSearchTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{
"query": {Type: "string"},
},
Required: []string{"query"},
AdditionalProperties: false,
}
}
func (tool *recordingWebSearchTool) Safety() tools.Safety {
return tools.Safety{
SideEffect: tools.SideEffectNetwork,
Permission: tools.PermissionPrompt,
Reason: "Sends model-provided search query text to the configured web search backend.",
AdvertiseInAuto: true,
}
}
func (tool *recordingWebSearchTool) Run(_ context.Context, args map[string]any) tools.Result {
tool.calls = append(tool.calls, cloneArgs(args))
return tools.Result{Status: tools.StatusOK, Output: "1. T — https://x.test"}
}
type sandboxDeniedRetryTool struct {
calls []map[string]any
}
@@ -865,7 +894,7 @@ func TestRunRejectsLocalWebFetchBeforePermissionRequest(t *testing.T) {
}
}
func TestRunAdvertisesAllowedWebSearchInAutoMode(t *testing.T) {
func TestRunAdvertisesPromptedWebSearchInAutoMode(t *testing.T) {
t.Setenv("ZERO_WEBSEARCH_BASE_URL", "https://search.example/api")
registry := tools.NewRegistry()
for _, tool := range tools.CoreNetworkTools() {
@@ -896,6 +925,52 @@ func TestRunAdvertisesAllowedWebSearchInAutoMode(t *testing.T) {
}
}
func TestRunRequestsPermissionBeforeWebSearchExecution(t *testing.T) {
search := &recordingWebSearchTool{}
registry := tools.NewRegistry()
registry.Register(search)
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "web_search"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"query":"private workspace detail"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var requests []PermissionRequest
result, err := Run(context.Background(), "search", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requests = append(requests, request)
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "network not approved"}, nil
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer after denied tool call, got %q", result.FinalAnswer)
}
if len(requests) != 1 {
t.Fatalf("expected one permission request, got %#v", requests)
}
if requests[0].ToolName != "web_search" || requests[0].Permission != string(tools.PermissionPrompt) || requests[0].SideEffect != string(tools.SideEffectNetwork) {
t.Fatalf("unexpected permission request: %#v", requests[0])
}
if len(search.calls) != 0 {
t.Fatalf("web_search backend must not run when permission is denied, got calls %#v", search.calls)
}
}
func TestRunFiltersAdvertisedTools(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
+16
View File
@@ -55,6 +55,22 @@ func stallRetryNoticeFor(options Options) reconnectNotifier {
}
}
// emptyRetryNoticeFor builds a notifier for the loop's provider-empty retry
// (a stream that completed cleanly but carried NOTHING — no text, no tool
// calls, no reasoning). Distinct wording from the stall notice because nothing
// timed out: the backend answered instantly with an empty completion (observed
// on the ollama cloud relay under load, and possible on any gateway having a
// bad moment). Surfaced through OnReasoning, the non-content channel. Nil when
// there is no reasoning sink (the retry still happens silently).
func emptyRetryNoticeFor(options Options) reconnectNotifier {
if options.OnReasoning == nil {
return nil
}
return func(attempt, max int) {
options.OnReasoning(fmt.Sprintf("\n[provider returned an empty response — 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
+53 -33
View File
@@ -22,6 +22,7 @@ import (
"github.com/Gitlawb/zero/internal/observability"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/provideronboarding"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/redaction"
@@ -53,32 +54,33 @@ type appDeps struct {
// 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)
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
newSessionStore func() *sessions.Store
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
skillsDir func() string
pluginsDir func() string
toolsDir func() string
newMCPStore func() (*mcp.PermissionStore, error)
newMCPTokenStore func() (*mcp.TokenStore, error)
newSandboxStore func() (*sandbox.GrantStore, error)
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
detectVerifyPlan func(string) (verify.Plan, error)
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
now func() time.Time
exportActiveProvider func(providerName string)
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
newSessionStore func() *sessions.Store
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
skillsDir func() string
pluginsDir func() string
toolsDir func() string
newMCPStore func() (*mcp.PermissionStore, error)
newMCPTokenStore func() (*mcp.TokenStore, error)
newSandboxStore func() (*sandbox.GrantStore, error)
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
detectVerifyPlan func(string) (verify.Plan, error)
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
now func() time.Time
}
type mcpToolRuntime interface {
@@ -134,8 +136,9 @@ func defaultAppDeps() appDeps {
OAuthLoginKey: loginKey,
})
},
probeProviderHealth: providerhealth.Probe,
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
probeProviderHealth: providerhealth.Probe,
discoverProviderModels: defaultDiscoverProviderModels,
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
newSessionStore: func() *sessions.Store {
return sessions.NewStore(sessions.StoreOptions{})
},
@@ -402,6 +405,14 @@ 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
}
@@ -432,6 +443,9 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.probeProviderHealth == nil {
deps.probeProviderHealth = defaults.probeProviderHealth
}
if deps.discoverProviderModels == nil {
deps.discoverProviderModels = defaults.discoverProviderModels
}
if deps.detectLocalRuntimes == nil {
deps.detectLocalRuntimes = defaults.detectLocalRuntimes
}
@@ -530,11 +544,17 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
if err != nil {
// A resolve failure caused solely by a missing/unresolvable active provider
// is not fatal for the interactive TUI: drop into the setup wizard with an
// empty config so the user can onboard, instead of exiting with an error.
// Any other error (malformed JSON, directory conflict, etc.) is still fatal.
if !errors.Is(err, config.ErrNoActiveProvider) {
// 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{}
+72
View File
@@ -1476,3 +1476,75 @@ func TestRunThemeFlagRejectsBadValue(t *testing.T) {
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())
}
}
+5
View File
@@ -81,6 +81,9 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if command == "detect" {
return runProvidersDetect(args, stdout, stderr, deps)
}
if command == "models" {
return runProvidersModels(args, stdout, stderr, deps)
}
if command != "list" && command != "current" && command != "catalog" {
return writeExecUsageError(stderr, fmt.Sprintf("unknown providers command %q", command))
}
@@ -465,9 +468,11 @@ func writeProvidersHelp(w io.Writer) error {
zero providers use <name> [flags]
zero providers setup <catalog-id> [flags]
zero providers detect [flags]
zero providers models [name] [flags]
Inspects resolved provider profiles and provider catalog descriptors without printing secrets.
Detect probes for running local runtimes (Ollama, LM Studio) and prints adopt commands plus per-provider next steps.
Models probes a provider's live model-listing endpoint (e.g. an OpenAI-compatible /v1/models) and lists the models it serves — including custom OpenAI-/Anthropic-compatible endpoints — so a self-hosted provider needs no per-model config.
Flags:
--json Print JSON summary
+148
View File
@@ -0,0 +1,148 @@
package cli
import (
"context"
"fmt"
"io"
"os"
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
)
type providerModelsOptions struct {
name string
json bool
}
// runProvidersModels lists the models a saved provider actually serves by probing
// its live model-discovery endpoint (e.g. an OpenAI-compatible `/v1/models`). It
// works for custom OpenAI-/Anthropic-compatible providers too: discovery runs off
// the profile's base URL + credentials, so a self-hosted endpoint serving a dozen
// models no longer needs a config object per model — configure the provider once,
// then run any listed model with `zero exec --model <id>` (Zero passes unknown
// model ids through to the provider).
func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseProviderModelsArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeProvidersHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
if exitCode != exitSuccess {
return exitCode
}
profile, err := selectProviderForCheck(resolved, options.name)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
ctx, stop := signalContext()
defer stop()
models, err := deps.discoverProviderModels(ctx, discoveryCredentialProfile(profile))
if err != nil {
return writeAppError(stderr, err.Error(), exitProvider)
}
if options.json {
items := make([]map[string]any, 0, len(models))
for _, model := range models {
entry := map[string]any{"id": model.ID}
if description := strings.TrimSpace(model.Description); description != "" {
entry["description"] = description
}
items = append(items, entry)
}
payload := map[string]any{
"provider": profile.Name,
"count": len(items),
"models": items,
}
if err := writePrettyJSON(stdout, payload); err != nil {
return exitCrash
}
return exitSuccess
}
name := strings.TrimSpace(profile.Name)
if name == "" {
name = "provider"
}
if _, err := fmt.Fprintf(stdout, "Provider models (%s)\n", name); err != nil {
return exitCrash
}
for _, model := range models {
line := strings.TrimSpace(model.ID)
if description := strings.TrimSpace(model.Description); description != "" {
line += " — " + description
}
if _, err := fmt.Fprintln(stdout, line); err != nil {
return exitCrash
}
}
suffix := "s"
if len(models) == 1 {
suffix = ""
}
if _, err := fmt.Fprintf(stdout, "%d model%s discovered\n", len(models), suffix); err != nil {
return exitCrash
}
if len(models) > 0 {
if _, err := fmt.Fprintf(stdout, "next: zero exec %q --model %s\n", "hello", setupCommandArg(models[0].ID)); err != nil {
return exitCrash
}
}
return exitSuccess
}
// discoveryCredentialProfile resolves the profile's API key the same way the
// runtime does — inline, then the stored credential, then the configured env var —
// so a `providers models` probe authenticates exactly like a real request. Mirrors
// discoveredModelContextWindow's credential resolution.
func discoveryCredentialProfile(profile config.ProviderProfile) config.ProviderProfile {
authed := profile
if strings.TrimSpace(authed.APIKey) == "" {
if store, err := config.ProviderKeyStore(); err == nil {
authed = config.ApplyStoredAPIKey(authed, store)
}
}
if strings.TrimSpace(authed.APIKey) == "" && strings.TrimSpace(authed.APIKeyEnv) != "" {
authed.APIKey = strings.TrimSpace(os.Getenv(authed.APIKeyEnv))
}
return authed
}
// defaultDiscoverProviderModels is the production discovery hook: a live probe of
// the provider's model-listing endpoint with no curated-catalog merge or
// coding-model filtering, so a custom provider's full model list is returned.
func defaultDiscoverProviderModels(ctx context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return providermodeldiscovery.Discover(ctx, profile, providermodeldiscovery.Options{})
}
func parseProviderModelsArgs(args []string) (providerModelsOptions, bool, error) {
options := providerModelsOptions{}
for index := 0; index < len(args); index++ {
arg := args[index]
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--json":
options.json = true
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
default:
if options.name != "" {
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
}
options.name = arg
}
}
return options, false, nil
}
+151
View File
@@ -0,0 +1,151 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
)
func TestRunProvidersModelsListsDiscoveredModels(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
var probed config.ProviderProfile
deps.discoverProviderModels = func(_ context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
probed = profile
return []providermodeldiscovery.Model{
{ID: "team/model-a", Description: "first"},
{ID: "team/model-b"},
}, nil
}
exitCode := runWithDeps([]string{"providers", "models"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
// No name given → the active provider is probed, with its resolved credential.
if probed.Name != "work" {
t.Fatalf("probed provider = %q, want active provider work", probed.Name)
}
out := stdout.String()
for _, want := range []string{"Provider models (work)", "team/model-a — first", "team/model-b", "2 models discovered", "--model team/model-a"} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q:\n%s", want, out)
}
}
}
func TestRunProvidersModelsJSON(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return []providermodeldiscovery.Model{{ID: "m1", Description: "d1"}, {ID: "m2"}}, nil
}
exitCode := runWithDeps([]string{"providers", "models", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
var payload struct {
Provider string `json:"provider"`
Count int `json:"count"`
Models []struct {
ID string `json:"id"`
Description string `json:"description"`
} `json:"models"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode json: %v\n%s", err, stdout.String())
}
if payload.Provider != "work" || payload.Count != 2 || len(payload.Models) != 2 {
t.Fatalf("payload = %#v", payload)
}
if payload.Models[0].ID != "m1" || payload.Models[0].Description != "d1" {
t.Fatalf("model[0] = %#v", payload.Models[0])
}
// A model with no description omits the field rather than emitting an empty string.
if payload.Models[1].ID != "m2" || payload.Models[1].Description != "" {
t.Fatalf("model[1] = %#v", payload.Models[1])
}
}
func TestRunProvidersModelsSelectsNamedProvider(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
custom := config.ProviderProfile{
Name: "self-hosted",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://models.internal/v1",
APIKey: "sk-internal",
Model: "house-model",
}
deps.resolveConfig = func(string, config.Overrides) (config.ResolvedConfig, error) {
work := config.ProviderProfile{Name: "work", ProviderKind: config.ProviderKindOpenAI, BaseURL: config.OpenAIBaseURL, APIKey: "sk", Model: "gpt-4.1"}
return config.ResolvedConfig{ActiveProvider: "work", Provider: work, Providers: []config.ProviderProfile{work, custom}}, nil
}
var probed config.ProviderProfile
deps.discoverProviderModels = func(_ context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
probed = profile
return []providermodeldiscovery.Model{{ID: "house-model"}}, nil
}
exitCode := runWithDeps([]string{"providers", "models", "self-hosted"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
if probed.Name != "self-hosted" || probed.BaseURL != "https://models.internal/v1" {
t.Fatalf("probed = %#v, want the named custom provider", probed)
}
}
func TestRunProvidersModelsUnknownProviderFails(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
called := false
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
called = true
return nil, nil
}
exitCode := runWithDeps([]string{"providers", "models", "ghost"}, &stdout, &stderr, deps)
if exitCode == exitSuccess {
t.Fatalf("exit = %d, want non-zero for unknown provider", exitCode)
}
if called {
t.Fatal("discovery must not run for an unknown provider")
}
if !strings.Contains(stderr.String(), `provider "ghost" not found`) {
t.Fatalf("stderr = %q, want provider-not-found", stderr.String())
}
}
func TestRunProvidersModelsSurfacesDiscoveryError(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return nil, errors.New("models endpoint returned 404")
}
exitCode := runWithDeps([]string{"providers", "models"}, &stdout, &stderr, deps)
if exitCode != exitProvider {
t.Fatalf("exit = %d, want exitProvider %d", exitCode, exitProvider)
}
if !strings.Contains(stderr.String(), "models endpoint returned 404") {
t.Fatalf("stderr = %q, want the discovery error", stderr.String())
}
}
+60 -5
View File
@@ -20,6 +20,26 @@ import (
// 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.
@@ -226,8 +246,17 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
// config: a cloned repo's .zero/config.json must not be able to grant
// itself write access outside the workspace. Global config and CLI flags
// are the only config sources for write roots.
//
// Sandbox.Network from project config may only TIGHTEN (→ "deny"), never
// WEAKEN (→ "allow"). A malicious repo must not be able to open network
// access for shell commands. Matches the AdditionalWriteRoots posture.
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
dst.Sandbox.Network = network
// Normalize: anything that isn't literally "allow" counts as deny.
if sandbox.NormalizeNetworkMode(sandbox.NetworkMode(network)) != sandbox.NetworkAllow {
dst.Sandbox.Network = network
}
// Silently ignore "allow" from project config — not an error, just
// a privilege the project scope does not have.
}
if src.Sandbox.BlockUnixSockets {
dst.Sandbox.BlockUnixSockets = true
@@ -376,6 +405,18 @@ 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 == "" {
@@ -737,11 +778,16 @@ func unionStrings(base []string, extra []string) []string {
}
type normalizeOptions struct {
defaultOpenAIModel bool
// 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
}
func normalizeProviders(providers []ProviderProfile, activeName string, envMaps ...map[string]string) ([]ProviderProfile, ProviderProfile, error) {
return normalizeProvidersWithOptions(providers, activeName, normalizeOptions{defaultOpenAIModel: true}, envMaps...)
return normalizeProvidersWithOptions(providers, activeName, normalizeOptions{defaultModels: true}, envMaps...)
}
func normalizeProvidersWithoutModelDefaults(providers []ProviderProfile, activeName string, envMaps ...map[string]string) ([]ProviderProfile, ProviderProfile, error) {
@@ -791,7 +837,10 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
}
if active.Model == "" {
return nil, ProviderProfile{}, providerError(active, "provider %s requires model", active.Name)
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 normalized, active, nil
@@ -838,7 +887,7 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
case ProviderKindOpenAI:
if profile.BaseURL == "" || isOfficialOpenAIBaseURL(profile.BaseURL) {
profile.BaseURL = OpenAIBaseURL
if options.defaultOpenAIModel && profile.Model == "" {
if options.defaultModels && profile.Model == "" {
profile.Model = modelregistry.DefaultModelID
}
return profile, nil
@@ -855,6 +904,9 @@ 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)
@@ -869,6 +921,9 @@ 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)
+133
View File
@@ -1273,6 +1273,45 @@ func TestResolveSandboxBlockUnixSocketsFromFile(t *testing.T) {
}
}
func TestResolveSandboxNetworkProjectConfigCannotWeaken(t *testing.T) {
// 1. Project config tries to set network to "allow" (should be ignored).
userPath := writeConfig(t, `{}`)
projectPath := writeConfig(t, `{
"sandbox": {"network": "allow"}
}`)
resolved, err := Resolve(ResolveOptions{
UserConfigPath: userPath,
ProjectConfigPath: projectPath,
Env: map[string]string{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Sandbox.Network == "allow" {
t.Fatal("resolved.Sandbox.Network = allow, want empty/deny to prevent project config from weakening sandbox")
}
// 2. User config sets network to "allow", project config sets it to "deny" (tightening is allowed).
userPath2 := writeConfig(t, `{
"sandbox": {"network": "allow"}
}`)
projectPath2 := writeConfig(t, `{
"sandbox": {"network": "deny"}
}`)
resolved2, err := Resolve(ResolveOptions{
UserConfigPath: userPath2,
ProjectConfigPath: projectPath2,
Env: map[string]string{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved2.Sandbox.Network != "deny" {
t.Fatalf("resolved.Sandbox.Network = %q, want deny (project config allowed to tighten)", resolved2.Sandbox.Network)
}
}
func TestResolveNotifyValid(t *testing.T) {
path := writeConfig(t, `{"notify":{"mode":"both","focusMode":"always"}}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
@@ -1555,3 +1594,97 @@ 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())
}
}
+28 -1
View File
@@ -66,10 +66,13 @@ 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": "anthropic"}
{"name": "main", "provider_kind": "openai-compatible", "baseURL": "https://gateway.example/v1"}
]
}`)
@@ -80,6 +83,30 @@ 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) {
+8 -1
View File
@@ -370,7 +370,14 @@ func TestFullFlowStoresTokens(t *testing.T) {
redirect := parsed.Query().Get("redirect_uri")
go func() {
cbURL := redirect + "?code=auth-code&state=" + url.QueryEscape(state)
_, _ = http.Get(cbURL)
for i := 0; i < 20; i++ {
resp, err := http.Get(cbURL)
if err == nil {
resp.Body.Close()
return
}
time.Sleep(50 * time.Millisecond)
}
}()
return nil
}
+3
View File
@@ -134,6 +134,9 @@ var descriptors = []Descriptor{
openAICompat("xiaomi-mimo", "Xiaomi MiMo", "https://api.mimo.xiaomi.com/openai/v1", "mimo-vl", []string{"MIMO_API_KEY", "XIAOMI_API_KEY"}, "xiaomi mimo"),
openAICompat("bankr", "Bankr", "https://api.bankr.bot/v1", "bankr-large", []string{"BANKR_API_KEY"}),
openAICompat("zai", "Z.ai", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY", "ZHIPU_API_KEY"}),
openAICompat("kilocode", "KiloCode", "https://api.kilo.ai/api/gateway", "anthropic/claude-sonnet-4.6", []string{"KILO_API_KEY"}, "kilo", "kilo gateway"),
openAICompat("opencode", "OpenCode Zen", "https://opencode.ai/zen/v1", "deepseek-v4-flash", []string{"OPENCODE_API_KEY"}, "opencode zen"),
openAICompat("opencode-go", "OpenCode Go", "https://opencode.ai/zen/go/v1", "deepseek-v4-pro", []string{"OPENCODE_API_KEY"}, "opencode go"),
openAICompat("atomic-chat", "Atomic Chat", "https://api.atomic.chat/v1", "gpt-4.1", []string{"ATOMIC_CHAT_API_KEY"}),
// ChatGPT subscription via a local OAuth proxy. A ChatGPT (Plus/Pro) OAuth
// token only works against ChatGPT's own backend (which is Cloudflare-gated to
+4 -1
View File
@@ -34,6 +34,9 @@ var expectedCatalogIDs = []string{
"xiaomi-mimo",
"bankr",
"zai",
"kilocode",
"opencode",
"opencode-go",
"atomic-chat",
"chatgpt-proxy",
"custom-openai-compatible",
@@ -264,7 +267,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
TransportBedrock: {"bedrock"},
TransportVertex: {"vertex"},
TransportAnthropicCompat: {"minimax", "custom-anthropic-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
}
for transport, wantIDs := range cases {
+14 -2
View File
@@ -276,6 +276,16 @@ func (provider *Provider) emitPayload(ctx context.Context, data string, state *t
return true
}
// firstNonEmptyString returns the first argument that is not empty.
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func (provider *Provider) emitChunk(
ctx context.Context,
chunk streamChunk,
@@ -283,10 +293,12 @@ func (provider *Provider) emitChunk(
events chan<- zeroruntime.StreamEvent,
) {
for _, choice := range chunk.Choices {
if choice.Delta.ReasoningContent != "" {
// Two dialects for the same thing (see streamDelta): prefer
// `reasoning_content`, fall back to `reasoning`. No backend sends both.
if reasoning := firstNonEmptyString(choice.Delta.ReasoningContent, choice.Delta.Reasoning); reasoning != "" {
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventReasoning,
Content: choice.Delta.ReasoningContent,
Content: reasoning,
})
}
if choice.Delta.Content != "" {
@@ -227,6 +227,33 @@ func TestStreamCompletionEmitsReasoningContentDeltas(t *testing.T) {
}
}
// Ollama's OpenAI-compat endpoint, OpenRouter, and most gateways stream
// thinking under `reasoning` (not DeepSeek's `reasoning_content`). Dropping it
// made every thinking token on those backends vanish: the model looked frozen
// for minutes and reasoning-only turns were indistinguishable from a dead
// provider. The exact shape below is a live capture from ollama glm-5.2:cloud.
func TestStreamCompletionEmitsOllamaStyleReasoningDeltas(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":"The user"},"finish_reason":null}]}`)
writeSSE(w, `{"choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":" wants hello"},"finish_reason":null}]}`)
writeSSE(w, `{"choices":[{"index":0,"delta":{"role":"assistant","content":"hello"},"finish_reason":"stop"}]}`)
writeSSE(w, `[DONE]`)
})
events := collectProviderEvents(t, provider)
reasoning := eventsOfType(events, zeroruntime.StreamEventReasoning)
if len(reasoning) != 2 {
t.Fatalf("reasoning events = %#v, want two reasoning deltas (ollama `reasoning` field regression)", reasoning)
}
if reasoning[0].Content != "The user" || reasoning[1].Content != " wants hello" {
t.Fatalf("unexpected reasoning events: %#v", reasoning)
}
text := eventsOfType(events, zeroruntime.StreamEventText)
if len(text) != 1 || text[0].Content != "hello" {
t.Fatalf("content after thinking must still stream as text, got %#v", text)
}
}
func TestStreamCompletionEmitsReasoningBeforeRegularContent(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"delta":{"reasoning_content":"Thinking. ","content":"Answer."}}]}`)
+9 -1
View File
@@ -76,8 +76,16 @@ type streamChoice struct {
}
type streamDelta struct {
Content string `json:"content"`
Content string `json:"content"`
// Reasoning/thinking deltas arrive under DIFFERENT keys depending on the
// backend dialect: DeepSeek-style servers emit `reasoning_content`, while
// ollama's OpenAI-compat endpoint, OpenRouter, and most gateways emit
// `reasoning`. Parse both — dropping `reasoning` made every thinking token
// on those backends silently vanish (the model looked frozen for minutes,
// its thinking was never replayed into the next turn's context, and a
// reasoning-only turn was indistinguishable from a dead provider).
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ToolCalls []streamToolCallDelta `json:"tool_calls"`
}
+55 -1
View File
@@ -82,6 +82,53 @@ func ContentStallTimeout(idleTimeout time.Duration) time.Duration {
return idleTimeout + extra
}
// DefaultStreamGapTimeout bounds the byte-silent gap AFTER a stream has
// started delivering payloads. Once ANY payload (data or keep-alive) has
// arrived, the backend has proven it heartbeats/streams — so a subsequent gap
// with zero bytes is almost always a dead connection (live capture of the
// reported "stuck for 33 minutes": ~119KB streamed, then the socket went
// byte-frozen forever). Detecting that at 2 minutes instead of the 5-minute
// idle default turns a ~15-minute blind wait (3 × 5m attempts) into a ~6-minute
// bounded recovery, without touching the pre-first-byte window that slow cloud
// proxies legitimately need (they may withhold output until the upstream model
// produces its first token). Override with ZERO_STREAM_GAP_TIMEOUT. For
// comparison: Codex ships the same 5m idle with 5 stream retries; opencode
// suffers this exact freeze (idle HTTPS socket in "working" state) with no
// mid-stream gap detection at all.
const DefaultStreamGapTimeout = 2 * time.Minute
// streamGapTimeoutEnv overrides the post-first-payload gap timeout. Accepts the
// same forms as ZERO_STREAM_IDLE_TIMEOUT; "0"/"off" disables the tightened gap
// (the plain idle timeout then applies for the whole stream).
const streamGapTimeoutEnv = "ZERO_STREAM_GAP_TIMEOUT"
// ResolveStreamGapTimeout selects the effective post-first-payload gap timeout
// for a stream whose idle timeout resolved to idleTimeout. It is clamped to
// never EXCEED the idle timeout (a gap larger than idle would be dead config),
// and disabled entirely when the idle watchdog is disabled.
func ResolveStreamGapTimeout(idleTimeout time.Duration) time.Duration {
if idleTimeout <= 0 {
return 0
}
gap := DefaultStreamGapTimeout
if raw := strings.TrimSpace(os.Getenv(streamGapTimeoutEnv)); raw != "" {
switch strings.ToLower(raw) {
case "0", "off", "none", "disabled":
return idleTimeout // no tightened gap: idle applies throughout
default:
if d, err := time.ParseDuration(raw); err == nil && d > 0 {
gap = d
} else if secs, err := strconv.Atoi(raw); err == nil && secs > 0 {
gap = time.Duration(secs) * time.Second
}
}
}
if gap > idleTimeout {
return idleTimeout
}
return gap
}
// streamIdleTimeoutEnv is the global override for the stream idle timeout. It
// accepts a Go duration ("5m", "300s", "90s") or a bare number of seconds
// ("300"). A value of "0", "off", "none", or "disabled" turns the watchdog off
@@ -317,7 +364,14 @@ func ScanSSEDataWithContext(
idle := time.NewTimer(idleTimeout)
defer idle.Stop()
idleC = idle.C
resetIdle = reset(idle, idleTimeout)
// The timer was armed above with the FULL idle window, which governs
// until the first payload (slow cloud proxies may withhold output until
// the upstream model produces its first token). resetIdle only runs when
// a payload HAS arrived — the backend has proven it streams/heartbeats —
// so every re-arm uses the tighter gap window: a byte-frozen socket
// mid-stream is a dead connection, and waiting the full idle for it just
// multiplies the user's blind wait per retry.
resetIdle = reset(idle, ResolveStreamGapTimeout(idleTimeout))
// Content watchdog: only real data lines reset it (keep-alives do not), so a
// stream that heartbeats without producing output is bounded instead of
@@ -371,3 +371,86 @@ func TestHTTPClientReturnsStallHardenedSharedClient(t *testing.T) {
t.Fatal("an explicit client must be returned unchanged")
}
}
// Once a stream has delivered its first payload, a byte-silent gap is bounded
// by the TIGHTER gap timeout, not the full idle window — a mid-stream frozen
// socket (live-captured "stuck" shape: bytes flowed, then nothing forever)
// must be detected fast so retries aren't multiplied by five-minute waits.
func TestScanSSETightensGapAfterFirstPayload(t *testing.T) {
t.Setenv("ZERO_STREAM_GAP_TIMEOUT", "80ms")
pr, pw := io.Pipe()
defer func() { _ = pw.Close() }()
go func() {
_, _ = io.WriteString(pw, "data: first\n\n")
// then: byte-frozen forever
}()
start := time.Now()
done := make(chan error, 1)
go func() {
done <- ScanSSEDataWithContext(context.Background(), func() {}, pr, 2*time.Second, func(string) bool { return true })
}()
select {
case err := <-done:
if !errors.Is(err, ErrStreamIdle) {
t.Fatalf("err = %v, want ErrStreamIdle", err)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("gap abort took %s — the tightened window (80ms) did not apply after the first payload", elapsed)
}
case <-time.After(5 * time.Second):
t.Fatal("stream not aborted")
}
}
// BEFORE the first payload the FULL idle window applies: slow cloud proxies may
// withhold output until the upstream model emits its first token, so the gap
// timeout must NOT govern the wait for the first byte.
func TestScanSSEKeepsFullIdleBeforeFirstPayload(t *testing.T) {
t.Setenv("ZERO_STREAM_GAP_TIMEOUT", "30ms")
pr, pw := io.Pipe()
defer func() { _ = pw.Close() }()
// never write anything
start := time.Now()
done := make(chan error, 1)
go func() {
done <- ScanSSEDataWithContext(context.Background(), func() {}, pr, 300*time.Millisecond, func(string) bool { return true })
}()
select {
case err := <-done:
if !errors.Is(err, ErrStreamIdle) {
t.Fatalf("err = %v, want ErrStreamIdle", err)
}
if elapsed := time.Since(start); elapsed < 250*time.Millisecond {
t.Fatalf("aborted after %s — the 30ms gap timeout must not apply before the first payload", elapsed)
}
case <-time.After(5 * time.Second):
t.Fatal("stream not aborted")
}
}
func TestResolveStreamGapTimeout(t *testing.T) {
t.Setenv("ZERO_STREAM_GAP_TIMEOUT", "")
if got := ResolveStreamGapTimeout(5 * time.Minute); got != DefaultStreamGapTimeout {
t.Fatalf("default gap = %s, want %s", got, DefaultStreamGapTimeout)
}
// Clamped: the gap never exceeds the idle window.
if got := ResolveStreamGapTimeout(time.Minute); got != time.Minute {
t.Fatalf("gap = %s, want clamp to the 1m idle", got)
}
// Disabled idle disables the gap too.
if got := ResolveStreamGapTimeout(0); got != 0 {
t.Fatalf("gap with idle off = %s, want 0", got)
}
// Env override wins; "off" falls back to the idle window for the whole stream.
t.Setenv("ZERO_STREAM_GAP_TIMEOUT", "45s")
if got := ResolveStreamGapTimeout(5 * time.Minute); got != 45*time.Second {
t.Fatalf("env gap = %s, want 45s", got)
}
t.Setenv("ZERO_STREAM_GAP_TIMEOUT", "off")
if got := ResolveStreamGapTimeout(5 * time.Minute); got != 5*time.Minute {
t.Fatalf("off gap = %s, want the idle window", got)
}
}
+11 -8
View File
@@ -9,22 +9,25 @@ import (
"time"
)
// TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy verifies that the
// sandbox network policy gates sandboxed shell egress, not first-party
// in-process web tools.
func TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy(t *testing.T) {
// TestEvaluatePromptsForNetworkToolsButExemptsThemFromShellNetworkPolicy verifies
// that first-party in-process network tools are governed by their permission
// metadata, not by the sandboxed shell egress policy.
func TestEvaluatePromptsForNetworkToolsButExemptsThemFromShellNetworkPolicy(t *testing.T) {
base := Policy{Mode: ModeEnforce, Network: NetworkDeny}
engine := NewEngine(EngineOptions{Policy: base})
d := engine.Evaluate(context.Background(), Request{
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionAllow,
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt,
})
if d.Action != ActionAllow || d.Block != nil {
t.Fatalf("web_search must be allowed by the sandbox gate, got %#v", d)
if d.Action != ActionPrompt || d.Block != nil {
t.Fatalf("web_search must prompt before permission is granted, got %#v", d)
}
if d.Reason == ReasonNetworkBlocked {
t.Fatalf("web_search prompt should come from tool permission, not shell network policy: %#v", d)
}
allowed := engine.Evaluate(context.Background(), Request{
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionAllow, PermissionGranted: true,
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt, PermissionGranted: true,
})
if allowed.Action != ActionAllow || allowed.Block != nil {
t.Fatalf("granted web_search must be allowed under deny, got %#v", allowed)
+1 -1
View File
@@ -72,7 +72,7 @@ func TestPermissionProfileFromPolicyIncludesDefaultTempWriteRoots(t *testing.T)
if !writeRootsContain(profile.FileSystem.WriteRoots, tmpdir) {
t.Fatalf("write roots = %#v, want temp root %q", profile.FileSystem.WriteRoots, tmpdir)
}
if pathExists("/tmp") && !writeRootsContain(profile.FileSystem.WriteRoots, "/tmp") {
if runtime.GOOS != "windows" && pathExists("/tmp") && !writeRootsContain(profile.FileSystem.WriteRoots, "/tmp") {
t.Fatalf("write roots = %#v, want /tmp", profile.FileSystem.WriteRoots)
}
}
+2 -1
View File
@@ -3,6 +3,7 @@ package sandbox
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -128,7 +129,7 @@ func TestNewScopeNormalizesAndValidatesExtraRoots(t *testing.T) {
if !stringSliceContains(roots, normalizeWorkspaceRootBestEffort(extra)) {
t.Fatalf("Roots()=%v want extra root %q", roots, normalizeWorkspaceRootBestEffort(extra))
}
if pathExists("/tmp") && !stringSliceContains(roots, normalizeWorkspaceRootBestEffort("/tmp")) {
if runtime.GOOS != "windows" && pathExists("/tmp") && !stringSliceContains(roots, normalizeWorkspaceRootBestEffort("/tmp")) {
t.Fatalf("Roots()=%v want default /tmp write root", roots)
}
}
+19
View File
@@ -79,6 +79,25 @@ func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any,
content := string(contentBytes)
occurrences := strings.Count(content, oldString)
// CRLF fallback: read_file normalizes \r\n → \n before presenting content to
// the model, so the model's old_string will use \n line endings. When the raw
// file uses \r\n (common on Windows), the \n-based old_string won't match.
// Detect this and transparently normalize: if the direct match fails, translate
// old_string's line endings to \r\n and search again. If found, use the CRLF-translated
// old_string and new_string for the replacement to preserve the file's existing EOL style
// without rewriting EOLs in unrelated parts of the file.
if occurrences == 0 && strings.Contains(content, "\r\n") && !strings.Contains(oldString, "\r\n") {
crlfOldString := strings.ReplaceAll(oldString, "\n", "\r\n")
normalizedOccurrences := strings.Count(content, crlfOldString)
if normalizedOccurrences > 0 {
occurrences = normalizedOccurrences
oldString = crlfOldString
if !strings.Contains(newString, "\r\n") {
newString = strings.ReplaceAll(newString, "\n", "\r\n")
}
}
}
if occurrences == 0 {
return errorResult("Error: Could not find the exact string to replace in " + relativePath + ". The old_string must match the file byte-for-byte.")
}
+2 -2
View File
@@ -104,8 +104,8 @@ func TestCoreNetworkToolsExposeSafetyMetadata(t *testing.T) {
if property, ok := search.Parameters().Properties["query"]; !ok || property.Type != "string" {
t.Fatalf("web_search must expose a string query property, got %#v", search.Parameters().Properties["query"])
}
if safety := search.Safety(); safety.Permission != PermissionAllow || safety.AdvertiseInAuto {
t.Fatalf("web_search safety = %#v, want allow without prompt-advertise override", safety)
if safety := search.Safety(); safety.Permission != PermissionPrompt || !safety.AdvertiseInAuto {
t.Fatalf("web_search safety = %#v, want prompt and advertised in auto", safety)
}
}
+9 -7
View File
@@ -81,13 +81,14 @@ func newWebSearchToolWithBackend(backend searchBackend) Tool {
Required: []string{"query"},
AdditionalProperties: false,
},
// Hosted search is a read-only discovery action. It remains marked as
// network for metadata, but sandbox network policy is the shell egress
// boundary; web_search keeps its own result-domain filtering safeguards.
// Hosted search sends model-provided query text to a configured network
// backend. Keep it visible in auto mode, but guard execution through the
// normal permission flow like web_fetch.
safety: Safety{
SideEffect: SideEffectNetwork,
Permission: PermissionAllow,
Reason: "Performs a web search over the network.",
SideEffect: SideEffectNetwork,
Permission: PermissionPrompt,
Reason: "Sends model-provided search query text to the configured web search backend.",
AdvertiseInAuto: true,
},
},
backend: backend,
@@ -95,7 +96,8 @@ func newWebSearchToolWithBackend(backend searchBackend) Tool {
}
// RunWithSandbox follows the normal web_search path. The sandbox network policy
// gates sandboxed shell egress, not this in-process hosted search tool.
// gates sandboxed shell egress; this in-process hosted search tool is guarded by
// the permission flow plus backend and result-domain safeguards.
func (tool webSearchTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
return tool.Run(ctx, args)
}
+40 -5
View File
@@ -116,17 +116,52 @@ func TestWebSearchRegisteredInCoreNetworkTools(t *testing.T) {
}
}
func TestWebSearchSafetyAllowsHostedSearchWithoutPrompt(t *testing.T) {
func TestWebSearchSafetyPromptsForHostedSearchButAdvertisesInAuto(t *testing.T) {
tool := newWebSearchToolWithBackend(&fakeSearchBackend{})
safety := tool.Safety()
if safety.SideEffect != SideEffectNetwork {
t.Fatalf("side effect = %s, want network", safety.SideEffect)
}
if safety.Permission != PermissionAllow {
t.Fatalf("permission = %s, want allow", safety.Permission)
if safety.Permission != PermissionPrompt {
t.Fatalf("permission = %s, want prompt", safety.Permission)
}
if safety.AdvertiseInAuto {
t.Fatal("web_search should not need the prompt-tool auto advertisement override")
if !safety.AdvertiseInAuto {
t.Fatal("web_search should be advertised in auto mode while still requiring permission")
}
}
func TestWebSearchRegistryRequiresPermissionBeforeBackendCall(t *testing.T) {
backend := &fakeSearchBackend{results: []searchResult{{Title: "T", URL: "https://x.test"}}}
registry := NewRegistry()
registry.Register(newWebSearchToolWithBackend(backend))
res := registry.Run(context.Background(), "web_search", map[string]any{"query": "private workspace detail"})
if res.Status != StatusError {
t.Fatalf("expected permission error, got %s: %s", res.Status, res.Output)
}
if !strings.Contains(res.Output, "Permission required for web_search") {
t.Fatalf("expected permission-required output, got %q", res.Output)
}
if backend.gotQuery != "" {
t.Fatalf("backend must not be called before permission, got query %q", backend.gotQuery)
}
}
func TestWebSearchRegistryRunsAfterPermissionGranted(t *testing.T) {
backend := &fakeSearchBackend{results: []searchResult{{Title: "T", URL: "https://x.test"}}}
registry := NewRegistry()
registry.Register(newWebSearchToolWithBackend(backend))
res := registry.RunWithOptions(context.Background(), "web_search", map[string]any{"query": "go errors"}, RunOptions{
PermissionGranted: true,
})
if res.Status != StatusOK {
t.Fatalf("expected ok, got %s: %s", res.Status, res.Output)
}
if backend.gotQuery != "go errors" {
t.Fatalf("backend query = %q, want %q", backend.gotQuery, "go errors")
}
}
+23
View File
@@ -250,6 +250,29 @@ func TestEditFileToolReplacesExactStrings(t *testing.T) {
}
}
func TestEditFileToolReplacesCRLF(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "code.go")
writeTestFile(t, path, "const a = 1\r\nconst b = 2\r\n")
result := NewEditFileTool(root).Run(context.Background(), map[string]any{
"path": "code.go",
"old_string": "const a = 1\nconst b = 2",
"new_string": "const a = 42\nconst b = 24",
})
if result.Status != StatusOK {
t.Fatalf("expected edit ok, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(content) != "const a = 42\r\nconst b = 24\r\n" {
t.Fatalf("unexpected edited content: %q", string(content))
}
}
func TestEditFileToolEmitsUnifiedDiff(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "code.go"), "const a = 1\nconst b = 2\n")
+10
View File
@@ -21,6 +21,12 @@ type CollectedStream struct {
// thinking blocks) that must be replayed on the next turn. Empty for providers
// or runs without extended thinking.
ReasoningBlocks []ReasoningBlock
// ReasoningEmitted reports that the stream carried ANY reasoning signal
// (live reasoning deltas or preserved blocks). It distinguishes "the model
// thought but produced no answer" (a behavioral empty turn) from "the
// provider returned a contentless stream" (a transport/backend fault worth
// retrying) — the two need different handling in the agent loop.
ReasoningEmitted bool
}
// Truncated reports whether the response ended for a non-normal reason (the
@@ -120,6 +126,7 @@ func CollectStreamWithOptions(ctx context.Context, events <-chan StreamEvent, op
// accumulate them regardless of type so they survive for replay.
if len(event.ReasoningBlocks) > 0 {
collected.ReasoningBlocks = append(collected.ReasoningBlocks, event.ReasoningBlocks...)
collected.ReasoningEmitted = true
}
switch event.Type {
@@ -132,6 +139,9 @@ func CollectStreamWithOptions(ctx context.Context, events <-chan StreamEvent, op
options.OnText(event.Content)
}
case StreamEventReasoning:
if event.Content != "" {
collected.ReasoningEmitted = true
}
if options.OnReasoning != nil {
options.OnReasoning(event.Content)
}