mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 17:56:14 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
132788fd36 | ||
|
|
ae43d92d2a | ||
|
|
48318dfb57 | ||
|
|
92ab65aa5f | ||
|
|
960db9660e | ||
|
|
b1ccb6d9c1 | ||
|
|
0bc8074c97 | ||
|
|
72eed06b4f | ||
|
|
33dc7ae2cc | ||
|
|
292e1edea5 |
No files matched your search
@@ -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++
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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{}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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."}}]}`)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user