Compare commits

..
Author SHA1 Message Date
Gnanam 03455ad915 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 18:25:42 +05:30
Gnanam 0e7a1fc218 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.
2026-07-02 18:13:30 +05:30
28 changed files with 72 additions and 1043 deletions

No files matched your search

-76
View File
@@ -2,7 +2,6 @@ package agent
import (
"encoding/json"
"fmt"
"strconv"
"strings"
@@ -285,67 +284,12 @@ 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 + " " +
@@ -356,15 +300,6 @@ const providerEmptyStopPrefix = "The model provider returned an empty response (
// 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
}
@@ -417,7 +352,6 @@ 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
@@ -483,18 +417,8 @@ 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++
+11 -116
View File
@@ -9,23 +9,11 @@ import (
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// 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.
// emptyTurn is a stream that produces no visible text and no tool calls.
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{
@@ -57,9 +45,9 @@ func countUserMessagesContaining(messages []zeroruntime.Message, needle string)
func TestRunStopsAfterConsecutiveEmptyTurns(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
reasoningOnlyTurn(),
reasoningOnlyTurn(),
reasoningOnlyTurn(),
emptyTurn(),
emptyTurn(),
emptyTurn(),
// A 4th turn exists but must never be requested.
textTurn("should never reach here"),
},
@@ -89,10 +77,10 @@ func TestRunStopsAfterConsecutiveEmptyTurns(t *testing.T) {
func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
reasoningOnlyTurn(),
reasoningOnlyTurn(),
emptyTurn(),
emptyTurn(),
textTurn("here is real progress"), // resets the counter and is the final answer
reasoningOnlyTurn(),
emptyTurn(),
},
}
@@ -121,11 +109,11 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
reasoningOnlyTurn(),
reasoningOnlyTurn(),
emptyTurn(),
emptyTurn(),
toolTurn("call-1", "read_file", `{"path":"notes.txt"}`), // resets counter
reasoningOnlyTurn(),
reasoningOnlyTurn(),
emptyTurn(),
emptyTurn(),
textTurn("done"),
},
}
@@ -467,96 +455,3 @@ 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)
}
}
+1 -52
View File
@@ -25,20 +25,7 @@ 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.
// 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 maxStreamStallRetries = 2
const (
toolResultMetaControl = "control"
@@ -337,35 +324,6 @@ 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)
@@ -412,15 +370,6 @@ 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
+1 -76
View File
@@ -37,35 +37,6 @@ 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
}
@@ -894,7 +865,7 @@ func TestRunRejectsLocalWebFetchBeforePermissionRequest(t *testing.T) {
}
}
func TestRunAdvertisesPromptedWebSearchInAutoMode(t *testing.T) {
func TestRunAdvertisesAllowedWebSearchInAutoMode(t *testing.T) {
t.Setenv("ZERO_WEBSEARCH_BASE_URL", "https://search.example/api")
registry := tools.NewRegistry()
for _, tool := range tools.CoreNetworkTools() {
@@ -925,52 +896,6 @@ func TestRunAdvertisesPromptedWebSearchInAutoMode(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,22 +55,6 @@ 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
+28 -34
View File
@@ -22,7 +22,6 @@ 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"
@@ -54,33 +53,32 @@ 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
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
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
}
type mcpToolRuntime interface {
@@ -136,9 +134,8 @@ func defaultAppDeps() appDeps {
OAuthLoginKey: loginKey,
})
},
probeProviderHealth: providerhealth.Probe,
discoverProviderModels: defaultDiscoverProviderModels,
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
probeProviderHealth: providerhealth.Probe,
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
newSessionStore: func() *sessions.Store {
return sessions.NewStore(sessions.StoreOptions{})
},
@@ -443,9 +440,6 @@ 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
}
-5
View File
@@ -81,9 +81,6 @@ 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))
}
@@ -468,11 +465,9 @@ 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
@@ -1,148 +0,0 @@
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
@@ -1,151 +0,0 @@
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())
}
}
+1 -10
View File
@@ -246,17 +246,8 @@ 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 != "" {
// 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.
dst.Sandbox.Network = network
}
if src.Sandbox.BlockUnixSockets {
dst.Sandbox.BlockUnixSockets = true
-39
View File
@@ -1273,45 +1273,6 @@ 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{}})
+1 -8
View File
@@ -370,14 +370,7 @@ func TestFullFlowStoresTokens(t *testing.T) {
redirect := parsed.Query().Get("redirect_uri")
go func() {
cbURL := redirect + "?code=auth-code&state=" + url.QueryEscape(state)
for i := 0; i < 20; i++ {
resp, err := http.Get(cbURL)
if err == nil {
resp.Body.Close()
return
}
time.Sleep(50 * time.Millisecond)
}
_, _ = http.Get(cbURL)
}()
return nil
}
-3
View File
@@ -134,9 +134,6 @@ 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
+1 -4
View File
@@ -34,9 +34,6 @@ var expectedCatalogIDs = []string{
"xiaomi-mimo",
"bankr",
"zai",
"kilocode",
"opencode",
"opencode-go",
"atomic-chat",
"chatgpt-proxy",
"custom-openai-compatible",
@@ -267,7 +264,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", "kilocode", "opencode", "opencode-go", "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", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
}
for transport, wantIDs := range cases {
+2 -14
View File
@@ -276,16 +276,6 @@ 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,
@@ -293,12 +283,10 @@ func (provider *Provider) emitChunk(
events chan<- zeroruntime.StreamEvent,
) {
for _, choice := range chunk.Choices {
// 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 != "" {
if choice.Delta.ReasoningContent != "" {
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventReasoning,
Content: reasoning,
Content: choice.Delta.ReasoningContent,
})
}
if choice.Delta.Content != "" {
@@ -227,33 +227,6 @@ 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."}}]}`)
+1 -9
View File
@@ -76,16 +76,8 @@ type streamChoice struct {
}
type streamDelta struct {
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).
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ToolCalls []streamToolCallDelta `json:"tool_calls"`
}
+1 -55
View File
@@ -82,53 +82,6 @@ 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
@@ -364,14 +317,7 @@ func ScanSSEDataWithContext(
idle := time.NewTimer(idleTimeout)
defer idle.Stop()
idleC = idle.C
// 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))
resetIdle = reset(idle, 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,86 +371,3 @@ 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)
}
}
+8 -11
View File
@@ -9,25 +9,22 @@ import (
"time"
)
// 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) {
// TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy verifies that the
// sandbox network policy gates sandboxed shell egress, not first-party
// in-process web tools.
func TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy(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: PermissionPrompt,
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionAllow,
})
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)
if d.Action != ActionAllow || d.Block != nil {
t.Fatalf("web_search must be allowed by the sandbox gate, got %#v", d)
}
allowed := engine.Evaluate(context.Background(), Request{
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt, PermissionGranted: true,
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionAllow, 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 runtime.GOOS != "windows" && pathExists("/tmp") && !writeRootsContain(profile.FileSystem.WriteRoots, "/tmp") {
if pathExists("/tmp") && !writeRootsContain(profile.FileSystem.WriteRoots, "/tmp") {
t.Fatalf("write roots = %#v, want /tmp", profile.FileSystem.WriteRoots)
}
}
+1 -2
View File
@@ -3,7 +3,6 @@ package sandbox
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -129,7 +128,7 @@ func TestNewScopeNormalizesAndValidatesExtraRoots(t *testing.T) {
if !stringSliceContains(roots, normalizeWorkspaceRootBestEffort(extra)) {
t.Fatalf("Roots()=%v want extra root %q", roots, normalizeWorkspaceRootBestEffort(extra))
}
if runtime.GOOS != "windows" && pathExists("/tmp") && !stringSliceContains(roots, normalizeWorkspaceRootBestEffort("/tmp")) {
if pathExists("/tmp") && !stringSliceContains(roots, normalizeWorkspaceRootBestEffort("/tmp")) {
t.Fatalf("Roots()=%v want default /tmp write root", roots)
}
}
-19
View File
@@ -79,25 +79,6 @@ 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 != PermissionPrompt || !safety.AdvertiseInAuto {
t.Fatalf("web_search safety = %#v, want prompt and advertised in auto", safety)
if safety := search.Safety(); safety.Permission != PermissionAllow || safety.AdvertiseInAuto {
t.Fatalf("web_search safety = %#v, want allow without prompt-advertise override", safety)
}
}
+7 -9
View File
@@ -81,14 +81,13 @@ func newWebSearchToolWithBackend(backend searchBackend) Tool {
Required: []string{"query"},
AdditionalProperties: false,
},
// 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.
// 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.
safety: Safety{
SideEffect: SideEffectNetwork,
Permission: PermissionPrompt,
Reason: "Sends model-provided search query text to the configured web search backend.",
AdvertiseInAuto: true,
SideEffect: SideEffectNetwork,
Permission: PermissionAllow,
Reason: "Performs a web search over the network.",
},
},
backend: backend,
@@ -96,8 +95,7 @@ func newWebSearchToolWithBackend(backend searchBackend) Tool {
}
// RunWithSandbox follows the normal web_search path. The sandbox network policy
// gates sandboxed shell egress; this in-process hosted search tool is guarded by
// the permission flow plus backend and result-domain safeguards.
// gates sandboxed shell egress, not this in-process hosted search tool.
func (tool webSearchTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
return tool.Run(ctx, args)
}
+5 -40
View File
@@ -116,52 +116,17 @@ func TestWebSearchRegisteredInCoreNetworkTools(t *testing.T) {
}
}
func TestWebSearchSafetyPromptsForHostedSearchButAdvertisesInAuto(t *testing.T) {
func TestWebSearchSafetyAllowsHostedSearchWithoutPrompt(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 != PermissionPrompt {
t.Fatalf("permission = %s, want prompt", safety.Permission)
if safety.Permission != PermissionAllow {
t.Fatalf("permission = %s, want allow", safety.Permission)
}
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")
if safety.AdvertiseInAuto {
t.Fatal("web_search should not need the prompt-tool auto advertisement override")
}
}
-23
View File
@@ -250,29 +250,6 @@ 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,12 +21,6 @@ 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
@@ -126,7 +120,6 @@ 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 {
@@ -139,9 +132,6 @@ 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)
}