Compare commits

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

Split the watchdog window by stream phase:

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

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

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

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

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

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

Tests: provider-empty strikes retry 3× each and stop with the provider
message (recognized by IsNoProgressStop, distinct from the generic
marker); a transient empty recovers on the in-turn retry with no extra
turn counted; mixed provider/behavioral strikes keep the generic message;
the existing counter-reset and guard tests now drive BEHAVIORAL empties
(reasoning-only turns) and are byte-identical in outcome.
2026-07-02 19:54:31 +05:30
25 changed files with 478 additions and 868 deletions

No files matched your search

-21
View File
@@ -51,27 +51,6 @@ The npm package installs a small wrapper plus the matching Zero binary for your
platform from GitHub Releases. It supports Linux, macOS, and Windows on x64 and
arm64.
### Bun
Bun does not run dependency lifecycle scripts by default, so the `postinstall`
that fetches the Zero binary is skipped and the first run fails with
`No native binary found next to the npm wrapper`.
Install with Bun, then fetch the binary in one of two ways:
```bash
# Option A: run the installer manually
bun add @gitlawb/zero
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
# Option B: allow the postinstall to run on install
# add to your package.json: "trustedDependencies": ["@gitlawb/zero"]
bun add @gitlawb/zero
```
For global installs (`bun add -g @gitlawb/zero`), use Option A against the
global install path, or prefer the install scripts below.
### Install scripts
Linux/macOS:
+1 -19
View File
@@ -54,26 +54,8 @@ const nativePath = join(packageRoot, zeroBinaryName());
const localControlHelpers = localControlHelperManifest(packageRoot);
if (!existsSync(nativePath)) {
const postinstallScript = join(packageRoot, 'scripts', 'postinstall.mjs');
const ranByBun = process.execPath.includes('bun') || !!process.versions?.bun;
console.error(
'[zero] No native binary found next to the npm wrapper.\n' +
'The platform binary is fetched at install time by a postinstall script,\n' +
'which did not run (or was skipped) for this install.\n' +
'\n' +
'Fix it now by running the installer manually:\n' +
` node "${postinstallScript}"\n` +
'\n' +
(ranByBun
? 'You installed with Bun, which does not run dependency lifecycle scripts\n' +
'by default. To let it run on future installs, add zero to your\n' +
'project\'s trustedDependencies:\n' +
' "trustedDependencies": ["@gitlawb/zero"]\n' +
'then reinstall.\n' +
'\n'
: '') +
'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
'(go run ./cmd/zero, requires Go 1.25+).',
'[zero] No native binary found next to the npm wrapper. Reinstall the zero package or run `go run ./cmd/zero-release build` from the repository.'
);
process.exit(1);
}
-32
View File
@@ -24,38 +24,6 @@ Requirements:
- Node.js 18+
- network access to npm and GitHub Releases
## Bun
Bun is "default-secure" and does not run lifecycle scripts of installed
dependencies (only the installing project's own scripts), so the `postinstall`
that fetches the Zero binary is silently skipped. The first run then fails with
`No native binary found next to the npm wrapper`.
To install with Bun, either run the installer manually after installing:
```bash
bun add @gitlawb/zero
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
```
Or allow the postinstall to run by adding the package to your project's
`trustedDependencies` before installing:
```json
{
"trustedDependencies": ["@gitlawb/zero"]
}
```
```bash
bun add @gitlawb/zero
```
For global installs (`bun add -g @gitlawb/zero`), run the installer manually
against the global install path, or use the install scripts below.
Reference: <https://bun.sh/docs/pm/lifecycle>
## Linux And macOS Script
Install the latest release:
+76
View File
@@ -2,6 +2,7 @@ package agent
import (
"encoding/json"
"fmt"
"strconv"
"strings"
@@ -284,12 +285,67 @@ const (
noOutputStopSuffix = "to avoid consuming tokens without making progress."
)
// providerEmptyStream reports that a cleanly-completed turn carried NOTHING —
// no visible text, no tool calls (real or dropped), and no reasoning signal.
// That shape is a transport/backend fault (e.g. the ollama cloud relay
// answering 200 with an instantly-empty SSE under load), not model behavior:
// a model that is generating always leaves SOME trace (text, a tool call
// attempt, or reasoning deltas).
func providerEmptyStream(collected zeroruntime.CollectedStream) bool {
return strings.TrimSpace(collected.Text) == "" &&
len(collected.ToolCalls) == 0 &&
collected.DroppedToolCalls == 0 &&
len(collected.ReasoningBlocks) == 0 &&
!collected.ReasoningEmitted
}
// allEmptyTurnsProviderEmpty reports that every strike toward the no-output
// guard was a provider-empty stream, so the stop message should name the
// backend fault rather than generic agent no-progress.
func (state *guardState) allEmptyTurnsProviderEmpty() bool {
return state.emptyTurns > 0 && state.providerEmptyTurns == state.emptyTurns
}
// noOutputStopAnswer is the final answer returned when the no-output guard
// stops the run. The turn count is interpolated at the call site.
func noOutputStopAnswer(turns int) string {
return noOutputStopPrefix + strconv.Itoa(turns) + " turns " + noOutputStopMarker + " " + noOutputStopSuffix
}
// providerEmptyStopAnswer is the final answer when every guard strike was a
// provider-empty stream: the backend repeatedly answered with contentless
// completions (already retried with backoff inside each turn). It tells the
// user the truth — the provider failed, not the agent — and what to do next.
// detail carries wire-level diagnostics from the last empty attempt
// (finish reason, token counts) so the condition is debuggable straight from
// the session log — this exact failure class previously took a live traffic
// capture to diagnose because nothing about the response shape was recorded.
func providerEmptyStopAnswer(turns int, detail string) string {
answer := providerEmptyStopPrefix +
strconv.Itoa(turns) + " consecutive attempts, each already retried with backoff. " +
"The backend is likely rate-limiting or degraded right now — try again shortly, or switch providers/models (zero providers use <name>)."
if detail != "" {
answer += " [last attempt: " + detail + "]"
}
return answer
}
// emptyStreamDetail renders the wire-level shape of an empty attempt for
// providerEmptyStopAnswer.
func emptyStreamDetail(collected zeroruntime.CollectedStream) string {
finish := collected.FinishReason
if finish == "" {
finish = "stop"
}
return fmt.Sprintf("finish_reason=%s, output_tokens=%d, reasoning_tokens=%d",
finish, collected.Usage.OutputTokens, collected.Usage.ReasoningTokens)
}
// providerEmptyStopPrefix is the stable head of providerEmptyStopAnswer, used
// by IsNoProgressStop so session titling / resume filtering treat the
// provider-empty stop like the other guard answers (not real content).
const providerEmptyStopPrefix = "The model provider returned an empty response (no text, no tool calls, no reasoning) on "
// IsNoProgressStop reports whether content IS the no-output guardrail stop answer
// (a run that produced no visible text and no tool calls). It matches the EXACT
// structure noOutputStopAnswer emits — prefix + "<int> turns " + marker + " " +
@@ -300,6 +356,15 @@ func noOutputStopAnswer(turns int) string {
// and skip its title generation.
func IsNoProgressStop(content string) bool {
trimmed := strings.TrimSpace(content)
// The provider-empty variant: stable prefix + a bare integer count.
if rest, ok := strings.CutPrefix(trimmed, providerEmptyStopPrefix); ok {
if sep := strings.Index(rest, " consecutive attempts"); sep > 0 {
if _, err := strconv.Atoi(rest[:sep]); err == nil {
return true
}
}
return false
}
if !strings.HasPrefix(trimmed, noOutputStopPrefix) {
return false
}
@@ -352,6 +417,7 @@ func toolOnlyProgressReminder(turns int) string {
// purely from tool-call names and per-turn output, matching what the loop holds.
type guardState struct {
emptyTurns int
providerEmptyTurns int
totalToolCalls int
toolCallsSincePlanUpdate int
planEverCalled bool
@@ -417,8 +483,18 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
if hasToolCalls || hasVisibleText {
state.emptyTurns = 0
state.providerEmptyTurns = 0
} else {
state.emptyTurns++
// Track separately whether the empty turn was a PROVIDER-empty stream
// (nothing at all came back — not even reasoning) vs a behavioral empty
// (the model thought/streamed but committed no answer). When every
// strike was provider-empty, the stop message names the backend fault
// instead of blaming the agent — the difference between the user
// retrying/switching providers and filing a "the agent gives up" bug.
if providerEmptyStream(collected) {
state.providerEmptyTurns++
}
}
if hasToolCalls && !hasVisibleText {
state.toolOnlyTurns++
+116 -11
View File
@@ -9,11 +9,23 @@ import (
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// emptyTurn is a stream that produces no visible text and no tool calls.
// emptyTurn is a PROVIDER-empty stream: it completes cleanly carrying nothing
// at all (the ollama-cloud 200+[DONE] shape). The loop retries these in-turn
// before counting a no-output strike.
func emptyTurn() []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{{Type: zeroruntime.StreamEventDone}}
}
// reasoningOnlyTurn is a BEHAVIORAL empty turn: the model streamed reasoning
// (so the provider clearly worked) but committed no text and no tool calls.
// These are NOT retried in-turn; they count directly toward the no-output guard.
func reasoningOnlyTurn() []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventReasoning, Content: "thinking…"},
{Type: zeroruntime.StreamEventDone},
}
}
// textTurn produces a turn with visible assistant text.
func textTurn(content string) []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
@@ -45,9 +57,9 @@ func countUserMessagesContaining(messages []zeroruntime.Message, needle string)
func TestRunStopsAfterConsecutiveEmptyTurns(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
emptyTurn(),
emptyTurn(),
emptyTurn(),
reasoningOnlyTurn(),
reasoningOnlyTurn(),
reasoningOnlyTurn(),
// A 4th turn exists but must never be requested.
textTurn("should never reach here"),
},
@@ -77,10 +89,10 @@ func TestRunStopsAfterConsecutiveEmptyTurns(t *testing.T) {
func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
emptyTurn(),
emptyTurn(),
reasoningOnlyTurn(),
reasoningOnlyTurn(),
textTurn("here is real progress"), // resets the counter and is the final answer
emptyTurn(),
reasoningOnlyTurn(),
},
}
@@ -109,11 +121,11 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
emptyTurn(),
emptyTurn(),
reasoningOnlyTurn(),
reasoningOnlyTurn(),
toolTurn("call-1", "read_file", `{"path":"notes.txt"}`), // resets counter
emptyTurn(),
emptyTurn(),
reasoningOnlyTurn(),
reasoningOnlyTurn(),
textTurn("done"),
},
}
@@ -455,3 +467,96 @@ func TestRunInjectsToolFailureHintWithSchema(t *testing.T) {
t.Fatalf("expected a tool-failure hint on the 3rd turn, messages: %+v", provider.requests[2].Messages)
}
}
// A provider-empty stream (clean completion carrying nothing at all) is
// retried in-turn with backoff before it may count as a no-output strike; when
// the backend keeps answering empty, the run stops with a message that names
// the PROVIDER fault — not the generic "agent made no progress" text. This is
// the exact live failure observed on the ollama cloud relay (HTTP 200 in ~1s,
// SSE straight to [DONE]), which silently killed 56% of sessions on the
// reporting machine.
func TestRunRetriesProviderEmptyStreamsThenStopsWithProviderMessage(t *testing.T) {
turns := make([][]zeroruntime.StreamEvent, 0, 9)
for i := 0; i < 9; i++ { // 3 strikes × (1 initial + 2 in-turn retries)
turns = append(turns, emptyTurn())
}
provider := &mockProvider{turns: turns}
result, err := Run(context.Background(), "go", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 12,
})
if err != nil {
t.Fatal(err)
}
wantRequests := maxEmptyTurns * (1 + maxEmptyStreamRetries)
if len(provider.requests) != wantRequests {
t.Fatalf("expected %d requests (%d strikes × %d attempts), got %d",
wantRequests, maxEmptyTurns, 1+maxEmptyStreamRetries, len(provider.requests))
}
if !strings.Contains(result.FinalAnswer, "provider returned an empty response") {
t.Fatalf("expected the provider-empty stop message, got %q", result.FinalAnswer)
}
if strings.Contains(result.FinalAnswer, noOutputStopMarker) {
t.Fatalf("provider fault must not be reported as generic agent no-progress: %q", result.FinalAnswer)
}
if !IsNoProgressStop(result.FinalAnswer) {
t.Fatal("the provider-empty stop answer must be recognized by IsNoProgressStop (titling/resume filters)")
}
// Wire-level diagnostics must land in the message (and therefore the
// session log) so this failure class is debuggable without a traffic capture.
if !strings.Contains(result.FinalAnswer, "output_tokens=0") || !strings.Contains(result.FinalAnswer, "finish_reason=") {
t.Fatalf("stop answer must carry last-attempt diagnostics, got %q", result.FinalAnswer)
}
}
// A transient empty response recovers on the in-turn retry: no strike, no stop,
// the retried turn's real output is the answer.
func TestRunRecoversWhenEmptyStreamRetrySucceeds(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
emptyTurn(), // initial attempt: backend hiccup
textTurn("recovered"), // in-turn retry gets the real answer
},
}
result, err := Run(context.Background(), "go", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 12,
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) != 2 {
t.Fatalf("expected 2 requests (initial + one retry), got %d", len(provider.requests))
}
if result.FinalAnswer != "recovered" {
t.Fatalf("expected the retried turn's text as final answer, got %q", result.FinalAnswer)
}
if result.Turns != 1 {
t.Fatalf("an in-turn retry is the SAME turn, want 1 turn, got %d", result.Turns)
}
}
// Mixed strikes (provider-empty + behavioral reasoning-only) must NOT claim a
// provider fault — the generic no-output message stays.
func TestRunMixedEmptyTurnsKeepGenericStopMessage(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
reasoningOnlyTurn(), // strike 1: behavioral
emptyTurn(), emptyTurn(), emptyTurn(), // strike 2: provider-empty ×(1+2 retries)
reasoningOnlyTurn(), // strike 3: behavioral
},
}
result, err := Run(context.Background(), "go", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 12,
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(result.FinalAnswer, noOutputStopMarker) {
t.Fatalf("mixed strikes must use the generic no-output message, got %q", result.FinalAnswer)
}
}
+52 -13
View File
@@ -25,7 +25,20 @@ const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not
// WITH NO OUTPUT yet is re-issued on a fresh connection before giving up. Only
// the no-output case is retried (a partial turn would duplicate), so this is a
// safe recovery for a stalled/dead pooled connection.
const maxStreamStallRetries = 2
// maxStreamStallRetries: 3 in-turn re-issues (4 attempts total) approaches
// Codex's stream_max_retries=5 posture while staying hard-capped (opencode's
// unbounded session retry has produced infinite loops on this same failure).
// Affordable now that a mid-stream death is detected at the gap timeout
// (~2m) instead of the full 5m idle window.
const maxStreamStallRetries = 3
// maxEmptyStreamRetries caps in-turn re-issues of a request whose stream
// completed cleanly but carried nothing at all (providerEmptyStream). Observed
// live on the ollama cloud relay under load: HTTP 200 in ~1s with an SSE that
// goes straight to [DONE]. The condition is transient backend state, so a
// short backoff-retry usually recovers; without it three such responses
// arrived inside two seconds and killed the run via the no-output guard.
const maxEmptyStreamRetries = 2
const (
toolResultMetaControl = "control"
@@ -324,6 +337,35 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, forwardingOpts)
}
// Provider-empty retry: the stream completed CLEANLY but carried nothing
// — no text, no tool calls, no reasoning. That is not model behavior (a
// generating model always leaves some trace); it is a backend answering
// with a contentless completion (seen on the ollama cloud relay under
// load: 200 + instant [DONE]). Transient, so re-issue with backoff before
// letting it count as a no-output strike. Nothing from the empty stream
// was forwarded or committed to history, so the retry is clean.
for attempt := 1; attempt <= maxEmptyStreamRetries &&
collected.Error == "" && !forwardedVisibleText &&
providerEmptyStream(collected); attempt++ {
if notify := emptyRetryNoticeFor(options); notify != nil {
notify(attempt, maxEmptyStreamRetries)
}
if err := sleepWithContext(ctx, backoffFor(attempt)); err != nil {
result.Messages = copyMessages(messages)
return result, err
}
retryRequest := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
}
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
if retryErr != nil {
result.Messages = copyMessages(messages)
return result, retryErr
}
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, forwardingOpts)
}
if collected.Error != "" {
// Route a reissued stream's non-stall error through the SAME recovery as
// the initial stream (image-rejection wrapping / context-limit compaction)
@@ -370,6 +412,15 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// A truly-empty turn (no text, no tool calls, no dropped calls) is
// counted toward the runaway cap so we stop before burning maxTurns.
if guards.observeTurn(collected) {
// Every strike being a provider-empty stream means the BACKEND
// failed repeatedly (each attempt already retried with backoff
// above) — say so, instead of the generic no-progress message
// that reads as the agent giving up.
if guards.allEmptyTurnsProviderEmpty() {
result.FinalAnswer = providerEmptyStopAnswer(result.Turns, emptyStreamDetail(collected))
result.Messages = copyMessages(messages)
return result, nil
}
result.FinalAnswer = noOutputStopAnswer(result.Turns)
result.Messages = copyMessages(messages)
return result, nil
@@ -2159,18 +2210,6 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
}
}
// A command that explicitly requests additional sandbox permissions
// (sandbox_permissions: with_additional_permissions) is an ELEVATION the user
// must consent to, even when the base command's sandbox decision was Allow.
// shouldRequestPermission blocks on OnPermissionRequest for exactly this case,
// so the event MUST be a prompt — otherwise it carried Action=allow while the
// loop waited on a decision, and a UI that renders only prompts (the TUI)
// dropped it, deadlocking the run. Keep this consistent with
// shouldRequestPermission's own condition.
if action == PermissionActionAllow && !permissionGranted && shellCommandAdditionalPermissionsRequested(args) {
action = PermissionActionPrompt
}
if safety.Permission == tools.PermissionAllow && action == PermissionActionAllow && !grantMatched && block == nil {
return PermissionEvent{}, false
}
@@ -1,70 +0,0 @@
package agent
import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
// promptShellTool is a minimal shell-style tool whose Safety requires a prompt,
// standing in for bash so buildPermissionEvent can be exercised directly.
type promptShellTool struct{}
func (promptShellTool) Name() string { return "bash" }
func (promptShellTool) Description() string { return "run a shell command" }
func (promptShellTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (promptShellTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs a shell command"}
}
func (promptShellTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK}
}
// A command whose sandbox decision is Allow but that explicitly requests
// additional sandbox permissions is an ELEVATION: it must be surfaced as a
// prompt, never Action=allow. Previously it carried allow while the loop still
// blocked on OnPermissionRequest, and the TUI (which renders only prompts)
// dropped it → the run deadlocked forever. This is the exact gpt-5.5
// folder-creation shape captured in the reported hang.
func TestBuildPermissionEventPromptsForAdditionalPermissions(t *testing.T) {
call := ToolCall{ID: "call_1", Name: "bash", Arguments: `{}`}
args := map[string]any{
"command": "mkdir foo && cd foo && go mod init foo",
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
}
// Base command allowed by the sandbox…
decision := &sandbox.Decision{Action: sandbox.ActionAllow}
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
if !ok {
t.Fatal("buildPermissionEvent returned ok=false; the elevation request must produce an event")
}
if event.Action != PermissionActionPrompt {
t.Fatalf("Action = %q, want prompt — an additional-permissions elevation must ask the user, not auto-allow", event.Action)
}
// Sanity: shouldRequestPermission agrees the loop blocks on this.
if !shouldRequestPermission(promptShellTool{}, args, false, decision) {
t.Fatal("shouldRequestPermission must be true for an additional-permissions request")
}
}
// Without the additional-permissions flag, a sandbox-allowed command is NOT a
// prompt (shouldRequestPermission is false; it just runs) — the override must
// not over-reach and prompt for ordinary allowed commands.
func TestBuildPermissionEventKeepsAllowForOrdinaryAllowedCommand(t *testing.T) {
call := ToolCall{ID: "call_2", Name: "bash", Arguments: `{}`}
args := map[string]any{"command": "ls"}
decision := &sandbox.Decision{Action: sandbox.ActionAllow}
event, _ := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
if event.Action == PermissionActionPrompt {
t.Fatalf("Action = prompt for an ordinary allowed command; want allow (no spurious prompt)")
}
if shouldRequestPermission(promptShellTool{}, args, false, decision) {
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
}
}
+16
View File
@@ -55,6 +55,22 @@ func stallRetryNoticeFor(options Options) reconnectNotifier {
}
}
// emptyRetryNoticeFor builds a notifier for the loop's provider-empty retry
// (a stream that completed cleanly but carried NOTHING — no text, no tool
// calls, no reasoning). Distinct wording from the stall notice because nothing
// timed out: the backend answered instantly with an empty completion (observed
// on the ollama cloud relay under load, and possible on any gateway having a
// bad moment). Surfaced through OnReasoning, the non-content channel. Nil when
// there is no reasoning sink (the retry still happens silently).
func emptyRetryNoticeFor(options Options) reconnectNotifier {
if options.OnReasoning == nil {
return nil
}
return func(attempt, max int) {
options.OnReasoning(fmt.Sprintf("\n[provider returned an empty response — retrying %d/%d…]\n", attempt, max))
}
}
// streamWithReconnect issues request via provider.StreamCompletion and, on a
// transient disconnect error, retries the connect up to maxStreamReconnects
// times with exponential backoff. It returns the live stream on success, or the
+1 -2
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"io"
"runtime"
"strconv"
"strings"
@@ -66,7 +65,7 @@ func runDoctor(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
report := doctor.Run(doctor.Options{
Now: deps.now,
Runtime: runtime.Version(),
Runtime: "go",
UserConfig: userConfig,
ProjectConfig: projectConfig,
Provider: provider,
+6 -47
View File
@@ -57,11 +57,6 @@ type Options struct {
// LSP-server checks. Nil means exec.LookPath; tests inject a stub so the
// checks are deterministic regardless of the host's installed tooling.
LookupExecutable func(string) (string, error)
// Executable resolves the running Zero binary's path (nil => os.Executable)
// and StatExecutable stats it (nil => os.Stat). Injected so the native-binary
// runtime check is testable without depending on a real on-disk layout.
Executable func() (string, error)
StatExecutable func(string) (os.FileInfo, error)
}
func Run(options Options) Report {
@@ -70,7 +65,7 @@ func Run(options Options) Report {
now = time.Now
}
checks := []Check{
runtimeCheck(options),
runtimeCheck(options.Runtime),
configFilesCheck(options.UserConfig, options.ProjectConfig),
configValidationCheck(options.UserConfig, options.ProjectConfig),
}
@@ -119,48 +114,12 @@ func Format(report Report) string {
return strings.Join(lines, "\n")
}
// runtimeCheck verifies the running Zero native binary is actually present and
// runnable, and reports its resolved path — instead of the former hardcoded
// always-pass, which claimed "Go runtime is available" while checking nothing
// (#405). doctor executes INSIDE the native binary, so this only fails if
// os.Executable can't resolve or the running file has vanished; the missing-
// binary-at-install case is caught earlier by the npm wrapper, which refuses to
// launch without the platform binary. Surfacing the binary PATH is the useful
// part: it lets a user see which binary answered (e.g. an npm-installed one vs a
// stale/shadowing copy on PATH) rather than a meaningless green.
func runtimeCheck(options Options) Check {
runtimeVersion := strings.TrimSpace(options.Runtime)
if runtimeVersion == "" {
runtimeVersion = "go"
func runtimeCheck(runtime string) Check {
runtime = strings.TrimSpace(runtime)
if runtime == "" {
runtime = "go"
}
details := map[string]any{"runtime": runtimeVersion}
executable := options.Executable
if executable == nil {
executable = os.Executable
}
path, err := executable()
if err != nil {
return check("runtime.go", "Zero native binary", StatusFail,
"Could not resolve the running Zero binary: "+err.Error(), details)
}
details["binaryPath"] = path
stat := options.StatExecutable
if stat == nil {
stat = os.Stat
}
info, err := stat(path)
if err != nil {
return check("runtime.go", "Zero native binary", StatusFail,
fmt.Sprintf("The running Zero binary is not accessible at %s: %s. Reinstall the zero package, or build from source: https://github.com/Gitlawb/zero", path, err.Error()), details)
}
if info.IsDir() {
return check("runtime.go", "Zero native binary", StatusFail,
fmt.Sprintf("The resolved Zero binary path %s is a directory, not an executable.", path), details)
}
return check("runtime.go", "Zero native binary", StatusPass,
fmt.Sprintf("Zero native binary is present and runnable at %s (%s).", path, runtimeVersion), details)
return check("runtime.go", "Go runtime", StatusPass, fmt.Sprintf("Zero Go runtime is available (%s).", runtime), map[string]any{"runtime": runtime})
}
func configFilesCheck(userPath string, projectPath string) Check {
-88
View File
@@ -1,88 +0,0 @@
package doctor
import (
"errors"
"io/fs"
"os"
"strings"
"testing"
"time"
)
// fakeFileInfo is a minimal os.FileInfo for the runtime-check stat injection.
type fakeFileInfo struct{ dir bool }
func (f fakeFileInfo) Name() string { return "zero" }
func (f fakeFileInfo) Size() int64 { return 1 }
func (f fakeFileInfo) Mode() fs.FileMode { return 0o755 }
func (f fakeFileInfo) ModTime() time.Time { return time.Unix(0, 0) }
func (f fakeFileInfo) IsDir() bool { return f.dir }
func (f fakeFileInfo) Sys() any { return nil }
func runtimeCheckFor(t *testing.T, exe func() (string, error), stat func(string) (os.FileInfo, error)) Check {
t.Helper()
return runtimeCheck(Options{Runtime: "go1.26", Executable: exe, StatExecutable: stat})
}
// A present, runnable binary passes AND reports its resolved path — the check is
// no longer a hardcoded green; it names which binary answered.
func TestRuntimeCheckPassesAndReportsBinaryPath(t *testing.T) {
c := runtimeCheckFor(t,
func() (string, error) { return "/opt/homebrew/lib/node_modules/@gitlawb/zero/zero", nil },
func(string) (os.FileInfo, error) { return fakeFileInfo{}, nil })
if c.Status != StatusPass {
t.Fatalf("status = %s, want pass", c.Status)
}
if c.Details["binaryPath"] != "/opt/homebrew/lib/node_modules/@gitlawb/zero/zero" {
t.Fatalf("binaryPath = %v, want the resolved path in details", c.Details["binaryPath"])
}
if !strings.Contains(c.Message, "/opt/homebrew/lib/node_modules/@gitlawb/zero/zero") {
t.Fatalf("message should surface the binary path, got %q", c.Message)
}
}
// The vanished-binary case FAILS loudly instead of the old unconditional pass —
// this is the #405 regression: a broken runtime no longer gets a green check.
func TestRuntimeCheckFailsWhenBinaryMissing(t *testing.T) {
c := runtimeCheckFor(t,
func() (string, error) { return "/path/to/zero", nil },
func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
if c.Status != StatusFail {
t.Fatalf("#405: a missing/inaccessible binary must FAIL, got %s (%q)", c.Status, c.Message)
}
if !strings.Contains(c.Message, "/path/to/zero") {
t.Fatalf("failure message should name the path, got %q", c.Message)
}
}
// os.Executable resolution failure fails loudly too.
func TestRuntimeCheckFailsWhenExecutableUnresolvable(t *testing.T) {
c := runtimeCheckFor(t,
func() (string, error) { return "", errors.New("no executable path") },
func(string) (os.FileInfo, error) { return fakeFileInfo{}, nil })
if c.Status != StatusFail {
t.Fatalf("unresolvable executable must FAIL, got %s", c.Status)
}
}
// A path that resolves to a directory is not a runnable binary → FAIL.
func TestRuntimeCheckFailsWhenPathIsDirectory(t *testing.T) {
c := runtimeCheckFor(t,
func() (string, error) { return "/some/dir", nil },
func(string) (os.FileInfo, error) { return fakeFileInfo{dir: true}, nil })
if c.Status != StatusFail {
t.Fatalf("a directory path must FAIL, got %s", c.Status)
}
}
// Default injection (nil Executable/StatExecutable) resolves the real running
// test binary — proving the production default path works and passes.
func TestRuntimeCheckDefaultsToRealBinary(t *testing.T) {
c := runtimeCheck(Options{})
if c.Status != StatusPass {
t.Fatalf("default runtime check should pass for the running test binary, got %s (%q)", c.Status, c.Message)
}
if strings.TrimSpace(c.Details["binaryPath"].(string)) == "" {
t.Fatal("default runtime check should report the running binary path")
}
}
-127
View File
@@ -11,7 +11,6 @@ import (
"os"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -212,85 +211,6 @@ func TestHTTPClientListsAndCallsTools(t *testing.T) {
}
}
func TestHTTPClientFollowsSameOriginRedirect(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
testServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.URL.Path == "/mcp" {
http.Redirect(response, request, "/redirected", http.StatusTemporaryRedirect)
return
}
if request.Method != http.MethodPost || request.URL.Path != "/redirected" {
t.Errorf("request = %s %s, want POST /redirected", request.Method, request.URL.Path)
http.Error(response, "bad request", http.StatusNotFound)
return
}
if got := request.Header.Get("X-Api-Key"); got != "secret" {
t.Errorf("X-Api-Key = %q, want secret", got)
http.Error(response, "missing auth", http.StatusUnauthorized)
return
}
message := readHTTPRPCMessage(t, request)
switch message.Method {
case "initialize":
writeHTTPRPCResponse(t, response, message.ID, map[string]any{"protocolVersion": "2024-11-05"})
case "notifications/initialized":
response.WriteHeader(http.StatusAccepted)
default:
writeHTTPRPCResponse(t, response, message.ID, map[string]any{})
}
}))
defer testServer.Close()
client, err := Connect(ctx, Server{
Name: "docs",
Type: ServerTypeHTTP,
URL: testServer.URL + "/mcp",
Headers: map[string]string{"X-Api-Key": "secret"},
})
if err != nil {
t.Fatalf("Connect() error = %v", err)
}
if err := client.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
}
func TestHTTPClientRejectsCrossOriginRedirectBeforeSendingHeaders(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var targetHits int32
target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
atomic.AddInt32(&targetHits, 1)
if got := request.Header.Get("X-Api-Key"); got != "" {
t.Errorf("redirect target received X-Api-Key = %q", got)
}
response.WriteHeader(http.StatusOK)
}))
defer target.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
http.Redirect(response, request, target.URL+"/mcp", http.StatusTemporaryRedirect)
}))
defer redirector.Close()
_, err := Connect(ctx, Server{
Name: "docs",
Type: ServerTypeHTTP,
URL: redirector.URL + "/mcp",
Headers: map[string]string{"X-Api-Key": "secret"},
})
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
t.Fatalf("Connect() error = %v, want cross-origin redirect error", err)
}
if got := atomic.LoadInt32(&targetHits); got != 0 {
t.Fatalf("redirect target hits = %d, want 0", got)
}
}
func TestSSEClientListsAndCallsToolsFromRemoteStream(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -420,53 +340,6 @@ func TestSSEClientListsAndCallsToolsFromRemoteStream(t *testing.T) {
}
}
func TestSSEClientRejectsCrossOriginEndpointBeforeSendingHeaders(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var targetHits int32
target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
atomic.AddInt32(&targetHits, 1)
if got := request.Header.Get("X-Api-Key"); got != "" {
t.Errorf("SSE endpoint target received X-Api-Key = %q", got)
}
response.WriteHeader(http.StatusAccepted)
}))
defer target.Close()
stream := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if got := request.Header.Get("X-Api-Key"); got != "secret" {
t.Errorf("X-Api-Key = %q, want secret on configured SSE server", got)
http.Error(response, "missing auth", http.StatusUnauthorized)
return
}
if request.Method != http.MethodGet || request.URL.Path != "/sse" {
t.Errorf("request = %s %s, want GET /sse", request.Method, request.URL.Path)
http.Error(response, "bad request", http.StatusNotFound)
return
}
response.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprintf(response, "event: endpoint\ndata: %s/messages\n\n", target.URL)
if flusher, ok := response.(http.Flusher); ok {
flusher.Flush()
}
}))
defer stream.Close()
_, err := Connect(ctx, Server{
Name: "docs",
Type: ServerTypeSSE,
URL: stream.URL + "/sse",
Headers: map[string]string{"X-Api-Key": "secret"},
})
if err == nil || !strings.Contains(err.Error(), "endpoint origin") {
t.Fatalf("Connect() error = %v, want cross-origin endpoint error", err)
}
if got := atomic.LoadInt32(&targetHits); got != 0 {
t.Fatalf("SSE endpoint target hits = %d, want 0", got)
}
}
func TestHTTPClientReportsNonOKStatus(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
+4 -8
View File
@@ -733,11 +733,7 @@ func resolveSSEEndpointURL(baseValue string, endpointValue string) (string, erro
if err != nil {
return "", fmt.Errorf("parse MCP SSE endpoint URL: %w", err)
}
resolvedURL := baseURL.ResolveReference(endpointURL)
if !sameMCPOrigin(baseURL, resolvedURL) {
return "", fmt.Errorf("MCP SSE endpoint origin %s differs from configured server origin %s", mcpOrigin(resolvedURL), mcpOrigin(baseURL))
}
return resolvedURL.String(), nil
return baseURL.ResolveReference(endpointURL).String(), nil
}
func rpcResponseKey(id any) string {
@@ -877,10 +873,10 @@ func (source *storeTokenSource) Refresh(ctx context.Context) (string, error) {
// oauthHTTPClient returns an HTTP client whose transport attaches OAuth bearer
// tokens and refreshes them on 401 for OAuth-configured servers. Servers that do
// not declare OAuth use the default transport with the same MCP redirect guard.
// not declare OAuth get the default client unchanged.
func oauthHTTPClient(server Server) (*http.Client, error) {
if !strings.EqualFold(strings.TrimSpace(server.Auth), ServerAuthOAuth) {
return mcpHTTPClient(server, nil), nil
return http.DefaultClient, nil
}
store, err := NewTokenStore(TokenStoreOptions{})
if err != nil {
@@ -892,5 +888,5 @@ func oauthHTTPClient(server Server) (*http.Client, error) {
httpClient: http.DefaultClient,
now: time.Now,
}
return mcpHTTPClient(server, newOAuthRoundTripper(http.DefaultTransport, source, server.Name)), nil
return &http.Client{Transport: newOAuthRoundTripper(http.DefaultTransport, source, server.Name)}, nil
}
-136
View File
@@ -4,7 +4,6 @@ import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
@@ -150,141 +149,6 @@ func TestOAuthRoundTripperDoesNotRetryAfterSuccessfulNon401(t *testing.T) {
}
}
func TestOAuthRoundTripperRejectsCrossOriginRedirectBeforeBearerLeak(t *testing.T) {
source := &fakeTokenSource{}
source.access.Store("access-secret")
source.refreshFunc = func() (string, error) { return "access-fresh", nil }
var targetHits int32
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&targetHits, 1)
if got := r.Header.Get("Authorization"); got != "" {
t.Errorf("redirect target received Authorization = %q", got)
}
w.WriteHeader(http.StatusOK)
}))
defer target.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+"/mcp", http.StatusTemporaryRedirect)
}))
defer redirector.Close()
client := mcpHTTPClient(
Server{Name: "oauth-docs", Type: ServerTypeHTTP},
newOAuthRoundTripper(http.DefaultTransport, source, "oauth-docs"),
)
resp, err := client.Get(redirector.URL + "/mcp")
if resp != nil {
_ = resp.Body.Close()
}
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
t.Fatalf("Get() error = %v, want cross-origin redirect error", err)
}
if got := atomic.LoadInt32(&targetHits); got != 0 {
t.Fatalf("redirect target hits = %d, want 0", got)
}
}
func TestSameMCPOriginNormalizesDefaultPorts(t *testing.T) {
for _, tc := range []struct {
name string
left string
right string
want bool
}{
{
name: "https default port",
left: "https://example.test/mcp",
right: "https://EXAMPLE.test:443/other",
want: true,
},
{
name: "http default port",
left: "http://example.test/mcp",
right: "http://example.test:80/other",
want: true,
},
{
name: "different scheme",
left: "https://example.test/mcp",
right: "http://example.test:443/other",
want: false,
},
{
name: "different port",
left: "https://example.test/mcp",
right: "https://example.test:8443/other",
want: false,
},
} {
t.Run(tc.name, func(t *testing.T) {
left, err := url.Parse(tc.left)
if err != nil {
t.Fatal(err)
}
right, err := url.Parse(tc.right)
if err != nil {
t.Fatal(err)
}
if got := sameMCPOrigin(left, right); got != tc.want {
t.Fatalf("sameMCPOrigin(%q, %q) = %v, want %v", tc.left, tc.right, got, tc.want)
}
})
}
}
func TestResolveSSEEndpointURLRestrictsOrigin(t *testing.T) {
for _, tc := range []struct {
name string
base string
endpoint string
want string
wantErr string
}{
{
name: "relative endpoint",
base: "https://example.test/sse",
endpoint: "/messages",
want: "https://example.test/messages",
},
{
name: "absolute same origin",
base: "https://example.test/sse",
endpoint: "https://example.test/messages",
want: "https://example.test/messages",
},
{
name: "default port equivalent",
base: "https://example.test/sse",
endpoint: "https://example.test:443/messages",
want: "https://example.test:443/messages",
},
{
name: "cross origin",
base: "https://example.test/sse",
endpoint: "https://evil.test/messages",
wantErr: "differs from configured server origin",
},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := resolveSSEEndpointURL(tc.base, tc.endpoint)
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("resolveSSEEndpointURL() error = %v, want %q", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("resolveSSEEndpointURL() error = %v", err)
}
if got != tc.want {
t.Fatalf("resolveSSEEndpointURL() = %q, want %q", got, tc.want)
}
})
}
}
func TestNonOAuthServerIsUnaffected(t *testing.T) {
// Regression: a server without auth must not get a bearer header or any
// OAuth round tripper, and must connect normally.
-74
View File
@@ -1,74 +0,0 @@
package mcp
import (
"fmt"
"net"
"net/http"
"net/url"
"strings"
)
const maxMCPRedirects = 10
func mcpHTTPClient(server Server, transport http.RoundTripper) *http.Client {
return &http.Client{
Transport: transport,
CheckRedirect: checkMCPRedirect(server),
}
}
func checkMCPRedirect(server Server) func(*http.Request, []*http.Request) error {
return func(request *http.Request, via []*http.Request) error {
if len(via) >= maxMCPRedirects {
return fmt.Errorf("MCP %s server %s stopped after %d redirects", server.Type, server.Name, maxMCPRedirects)
}
if len(via) == 0 {
return nil
}
if !sameMCPOrigin(via[0].URL, request.URL) {
return fmt.Errorf("MCP %s server %s refused cross-origin redirect to %s", server.Type, server.Name, mcpOrigin(request.URL))
}
return nil
}
}
func sameMCPOrigin(left *url.URL, right *url.URL) bool {
if left == nil || right == nil {
return false
}
return strings.EqualFold(left.Scheme, right.Scheme) &&
strings.EqualFold(left.Hostname(), right.Hostname()) &&
effectiveMCPPort(left) == effectiveMCPPort(right)
}
func effectiveMCPPort(value *url.URL) string {
if value == nil {
return ""
}
if port := value.Port(); port != "" {
return port
}
switch strings.ToLower(value.Scheme) {
case "http":
return "80"
case "https":
return "443"
default:
return ""
}
}
func mcpOrigin(value *url.URL) string {
if value == nil {
return "<nil>"
}
host := value.Hostname()
port := effectiveMCPPort(value)
if port != "" {
host = net.JoinHostPort(host, port)
}
if value.Scheme == "" {
return host
}
return strings.ToLower(value.Scheme) + "://" + host
}
+14 -2
View File
@@ -276,6 +276,16 @@ func (provider *Provider) emitPayload(ctx context.Context, data string, state *t
return true
}
// firstNonEmptyString returns the first argument that is not empty.
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func (provider *Provider) emitChunk(
ctx context.Context,
chunk streamChunk,
@@ -283,10 +293,12 @@ func (provider *Provider) emitChunk(
events chan<- zeroruntime.StreamEvent,
) {
for _, choice := range chunk.Choices {
if choice.Delta.ReasoningContent != "" {
// Two dialects for the same thing (see streamDelta): prefer
// `reasoning_content`, fall back to `reasoning`. No backend sends both.
if reasoning := firstNonEmptyString(choice.Delta.ReasoningContent, choice.Delta.Reasoning); reasoning != "" {
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventReasoning,
Content: choice.Delta.ReasoningContent,
Content: reasoning,
})
}
if choice.Delta.Content != "" {
@@ -227,6 +227,33 @@ func TestStreamCompletionEmitsReasoningContentDeltas(t *testing.T) {
}
}
// Ollama's OpenAI-compat endpoint, OpenRouter, and most gateways stream
// thinking under `reasoning` (not DeepSeek's `reasoning_content`). Dropping it
// made every thinking token on those backends vanish: the model looked frozen
// for minutes and reasoning-only turns were indistinguishable from a dead
// provider. The exact shape below is a live capture from ollama glm-5.2:cloud.
func TestStreamCompletionEmitsOllamaStyleReasoningDeltas(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":"The user"},"finish_reason":null}]}`)
writeSSE(w, `{"choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":" wants hello"},"finish_reason":null}]}`)
writeSSE(w, `{"choices":[{"index":0,"delta":{"role":"assistant","content":"hello"},"finish_reason":"stop"}]}`)
writeSSE(w, `[DONE]`)
})
events := collectProviderEvents(t, provider)
reasoning := eventsOfType(events, zeroruntime.StreamEventReasoning)
if len(reasoning) != 2 {
t.Fatalf("reasoning events = %#v, want two reasoning deltas (ollama `reasoning` field regression)", reasoning)
}
if reasoning[0].Content != "The user" || reasoning[1].Content != " wants hello" {
t.Fatalf("unexpected reasoning events: %#v", reasoning)
}
text := eventsOfType(events, zeroruntime.StreamEventText)
if len(text) != 1 || text[0].Content != "hello" {
t.Fatalf("content after thinking must still stream as text, got %#v", text)
}
}
func TestStreamCompletionEmitsReasoningBeforeRegularContent(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"delta":{"reasoning_content":"Thinking. ","content":"Answer."}}]}`)
+9 -1
View File
@@ -76,8 +76,16 @@ type streamChoice struct {
}
type streamDelta struct {
Content string `json:"content"`
Content string `json:"content"`
// Reasoning/thinking deltas arrive under DIFFERENT keys depending on the
// backend dialect: DeepSeek-style servers emit `reasoning_content`, while
// ollama's OpenAI-compat endpoint, OpenRouter, and most gateways emit
// `reasoning`. Parse both — dropping `reasoning` made every thinking token
// on those backends silently vanish (the model looked frozen for minutes,
// its thinking was never replayed into the next turn's context, and a
// reasoning-only turn was indistinguishable from a dead provider).
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ToolCalls []streamToolCallDelta `json:"tool_calls"`
}
+55 -1
View File
@@ -82,6 +82,53 @@ func ContentStallTimeout(idleTimeout time.Duration) time.Duration {
return idleTimeout + extra
}
// DefaultStreamGapTimeout bounds the byte-silent gap AFTER a stream has
// started delivering payloads. Once ANY payload (data or keep-alive) has
// arrived, the backend has proven it heartbeats/streams — so a subsequent gap
// with zero bytes is almost always a dead connection (live capture of the
// reported "stuck for 33 minutes": ~119KB streamed, then the socket went
// byte-frozen forever). Detecting that at 2 minutes instead of the 5-minute
// idle default turns a ~15-minute blind wait (3 × 5m attempts) into a ~6-minute
// bounded recovery, without touching the pre-first-byte window that slow cloud
// proxies legitimately need (they may withhold output until the upstream model
// produces its first token). Override with ZERO_STREAM_GAP_TIMEOUT. For
// comparison: Codex ships the same 5m idle with 5 stream retries; opencode
// suffers this exact freeze (idle HTTPS socket in "working" state) with no
// mid-stream gap detection at all.
const DefaultStreamGapTimeout = 2 * time.Minute
// streamGapTimeoutEnv overrides the post-first-payload gap timeout. Accepts the
// same forms as ZERO_STREAM_IDLE_TIMEOUT; "0"/"off" disables the tightened gap
// (the plain idle timeout then applies for the whole stream).
const streamGapTimeoutEnv = "ZERO_STREAM_GAP_TIMEOUT"
// ResolveStreamGapTimeout selects the effective post-first-payload gap timeout
// for a stream whose idle timeout resolved to idleTimeout. It is clamped to
// never EXCEED the idle timeout (a gap larger than idle would be dead config),
// and disabled entirely when the idle watchdog is disabled.
func ResolveStreamGapTimeout(idleTimeout time.Duration) time.Duration {
if idleTimeout <= 0 {
return 0
}
gap := DefaultStreamGapTimeout
if raw := strings.TrimSpace(os.Getenv(streamGapTimeoutEnv)); raw != "" {
switch strings.ToLower(raw) {
case "0", "off", "none", "disabled":
return idleTimeout // no tightened gap: idle applies throughout
default:
if d, err := time.ParseDuration(raw); err == nil && d > 0 {
gap = d
} else if secs, err := strconv.Atoi(raw); err == nil && secs > 0 {
gap = time.Duration(secs) * time.Second
}
}
}
if gap > idleTimeout {
return idleTimeout
}
return gap
}
// streamIdleTimeoutEnv is the global override for the stream idle timeout. It
// accepts a Go duration ("5m", "300s", "90s") or a bare number of seconds
// ("300"). A value of "0", "off", "none", or "disabled" turns the watchdog off
@@ -317,7 +364,14 @@ func ScanSSEDataWithContext(
idle := time.NewTimer(idleTimeout)
defer idle.Stop()
idleC = idle.C
resetIdle = reset(idle, idleTimeout)
// The timer was armed above with the FULL idle window, which governs
// until the first payload (slow cloud proxies may withhold output until
// the upstream model produces its first token). resetIdle only runs when
// a payload HAS arrived — the backend has proven it streams/heartbeats —
// so every re-arm uses the tighter gap window: a byte-frozen socket
// mid-stream is a dead connection, and waiting the full idle for it just
// multiplies the user's blind wait per retry.
resetIdle = reset(idle, ResolveStreamGapTimeout(idleTimeout))
// Content watchdog: only real data lines reset it (keep-alives do not), so a
// stream that heartbeats without producing output is bounded instead of
@@ -371,3 +371,86 @@ func TestHTTPClientReturnsStallHardenedSharedClient(t *testing.T) {
t.Fatal("an explicit client must be returned unchanged")
}
}
// Once a stream has delivered its first payload, a byte-silent gap is bounded
// by the TIGHTER gap timeout, not the full idle window — a mid-stream frozen
// socket (live-captured "stuck" shape: bytes flowed, then nothing forever)
// must be detected fast so retries aren't multiplied by five-minute waits.
func TestScanSSETightensGapAfterFirstPayload(t *testing.T) {
t.Setenv("ZERO_STREAM_GAP_TIMEOUT", "80ms")
pr, pw := io.Pipe()
defer func() { _ = pw.Close() }()
go func() {
_, _ = io.WriteString(pw, "data: first\n\n")
// then: byte-frozen forever
}()
start := time.Now()
done := make(chan error, 1)
go func() {
done <- ScanSSEDataWithContext(context.Background(), func() {}, pr, 2*time.Second, func(string) bool { return true })
}()
select {
case err := <-done:
if !errors.Is(err, ErrStreamIdle) {
t.Fatalf("err = %v, want ErrStreamIdle", err)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("gap abort took %s — the tightened window (80ms) did not apply after the first payload", elapsed)
}
case <-time.After(5 * time.Second):
t.Fatal("stream not aborted")
}
}
// BEFORE the first payload the FULL idle window applies: slow cloud proxies may
// withhold output until the upstream model emits its first token, so the gap
// timeout must NOT govern the wait for the first byte.
func TestScanSSEKeepsFullIdleBeforeFirstPayload(t *testing.T) {
t.Setenv("ZERO_STREAM_GAP_TIMEOUT", "30ms")
pr, pw := io.Pipe()
defer func() { _ = pw.Close() }()
// never write anything
start := time.Now()
done := make(chan error, 1)
go func() {
done <- ScanSSEDataWithContext(context.Background(), func() {}, pr, 300*time.Millisecond, func(string) bool { return true })
}()
select {
case err := <-done:
if !errors.Is(err, ErrStreamIdle) {
t.Fatalf("err = %v, want ErrStreamIdle", err)
}
if elapsed := time.Since(start); elapsed < 250*time.Millisecond {
t.Fatalf("aborted after %s — the 30ms gap timeout must not apply before the first payload", elapsed)
}
case <-time.After(5 * time.Second):
t.Fatal("stream not aborted")
}
}
func TestResolveStreamGapTimeout(t *testing.T) {
t.Setenv("ZERO_STREAM_GAP_TIMEOUT", "")
if got := ResolveStreamGapTimeout(5 * time.Minute); got != DefaultStreamGapTimeout {
t.Fatalf("default gap = %s, want %s", got, DefaultStreamGapTimeout)
}
// Clamped: the gap never exceeds the idle window.
if got := ResolveStreamGapTimeout(time.Minute); got != time.Minute {
t.Fatalf("gap = %s, want clamp to the 1m idle", got)
}
// Disabled idle disables the gap too.
if got := ResolveStreamGapTimeout(0); got != 0 {
t.Fatalf("gap with idle off = %s, want 0", got)
}
// Env override wins; "off" falls back to the idle window for the whole stream.
t.Setenv("ZERO_STREAM_GAP_TIMEOUT", "45s")
if got := ResolveStreamGapTimeout(5 * time.Minute); got != 45*time.Second {
t.Fatalf("env gap = %s, want 45s", got)
}
t.Setenv("ZERO_STREAM_GAP_TIMEOUT", "off")
if got := ResolveStreamGapTimeout(5 * time.Minute); got != 5*time.Minute {
t.Fatalf("off gap = %s, want the idle window", got)
}
}
+5 -41
View File
@@ -1669,38 +1669,17 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// when the loop is already running or there is nothing to animate.
return m, m.ensureSpinnerTick()
case permissionRequestMsg:
// The agent goroutine that raised this request is BLOCKED waiting on the
// decision callback, so every branch below must resolve it exactly once —
// or store it in pendingPermission, which resolves it on the user's reply.
// Dropping a request without resolving parks the run forever (the reported
// "stuck for 33 minutes" deadlock): the agent waits on a decision channel
// nothing will ever signal, with no visible prompt and no network activity.
if msg.runID != m.activeRunID {
// A superseded/stale run: unblock its parked goroutine now rather than
// relying on that run's context cancel to fire first.
if msg.decide != nil {
msg.decide(agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "run superseded"})
}
return m, nil
}
if msg.request.Action != agent.PermissionActionPrompt {
// Not a user-facing prompt (e.g. a sandbox-allowed command that still
// blocked because it requested additional permissions). The UI has
// nothing to ask, so resolve immediately and FAIL CLOSED — never
// silently grant access that was never surfaced to the user. (The agent
// now marks such elevation requests as prompts, so in practice this is a
// defensive backstop; matches the ACP handler's fail-closed contract.)
if msg.decide != nil {
msg.decide(autoResolvedPermissionDecision(msg.request.Action))
}
return m, nil
}
promptRow := permissionTranscriptRow(permissionEventFromRequest(msg.request))
promptRow.runID = msg.runID
m.transcript = appendTranscriptRow(m.transcript, promptRow)
m.pendingPermission = &pendingPermissionPrompt{
request: msg.request,
decide: msg.decide,
if msg.request.Action == agent.PermissionActionPrompt {
m.pendingPermission = &pendingPermissionPrompt{
request: msg.request,
decide: msg.decide,
}
}
return m, nil
case askUserRequestMsg:
@@ -4546,21 +4525,6 @@ func (m model) sendPermissionRequest(runID int, request agent.PermissionRequest,
m.runtimeMessageSink(permissionRequestMsg{runID: runID, request: request, decide: decide})
}
// autoResolvedPermissionDecision resolves a permission request the TUI cannot
// turn into a user prompt (Action != prompt). The agent is blocked awaiting a
// decision, so one must ALWAYS be produced. Only an explicit Cancel is honored
// as such; every other non-prompt action — including allow — is DENIED, so the
// UI never silently grants access it did not surface for approval.
func autoResolvedPermissionDecision(action agent.PermissionAction) agent.PermissionDecision {
if action == agent.PermissionActionCancel {
return agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "run cancelled"}
}
return agent.PermissionDecision{
Action: agent.PermissionDecisionDeny,
Reason: "permission request could not be surfaced for approval",
}
}
func (m model) sendAskUserRequest(runID int, request agent.AskUserRequest, answer func([]string)) {
if m.runtimeMessageSink == nil {
return
-57
View File
@@ -1,57 +0,0 @@
package tui
import "testing"
// The model picker must title each row with the model NAME, not the models.dev
// marketing description. Regression: Ollama Cloud models resolved full-sentence
// descriptions from the remote catalog, so rows showed sentences and several
// models sharing one description looked like exact duplicates (issue: /model
// list rendered descriptions as titles).
func TestModelPickerDisplayNamePrefersModelName(t *testing.T) {
sentence := "DeepSeek chat model for instruction following, coding, and analysis"
cases := []struct {
id string
description string
want string
}{
{"deepseek-v3.2", sentence, "Deepseek V3.2"},
{"deepseek-v3.2-thinking", sentence, "Deepseek V3.2 Thinking"},
{"glm-5.2", "GLM flagship model", "GLM 5.2"},
{"qwen3-coder:480b", "Qwen coding agent model for repository tasks", "Qwen3 Coder 480b"},
{"anthropic/claude-sonnet-4.6", "some blurb", "Claude Sonnet 4.6"},
}
for _, tc := range cases {
if got := modelPickerDisplayName(tc.id, tc.description); got != tc.want {
t.Errorf("modelPickerDisplayName(%q, %q) = %q, want %q", tc.id, tc.description, got, tc.want)
}
}
}
// Two distinct model ids that share the same catalog description must render as
// distinct rows (the "duplicate-looking rows" symptom), never as the shared
// sentence.
func TestModelPickerDisplayNameDistinctForSharedDescription(t *testing.T) {
shared := "Mistral coding agent model for repository tasks and software engineering"
a := modelPickerDisplayName("codestral-2", shared)
b := modelPickerDisplayName("devstral-2", shared)
if a == b {
t.Fatalf("shared description collapsed rows to the same title: %q", a)
}
if a == shared || b == shared {
t.Fatalf("row title used the description sentence: a=%q b=%q", a, b)
}
}
// With no id to name the row, a non-generic description is still an acceptable
// fallback; a generic placeholder (or nothing) falls back to "model".
func TestModelPickerDisplayNameFallsBackToDescriptionOnlyWithoutID(t *testing.T) {
if got := modelPickerDisplayName("", "Friendly Name"); got != "Friendly Name" {
t.Fatalf("empty id should fall back to the description, got %q", got)
}
if got := modelPickerDisplayName("", "catalog default"); got != "model" {
t.Fatalf("generic description with no id should yield %q, got %q", "model", got)
}
if got := modelPickerDisplayName("", ""); got != "model" {
t.Fatalf("empty id and description should yield %q, got %q", "model", got)
}
}
-109
View File
@@ -1,109 +0,0 @@
package tui
import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/agent"
)
func permissionModel(t *testing.T) model {
t.Helper()
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 7
m.width = 96
return m
}
// The deadlock: a permission request whose Action is NOT prompt (e.g. a
// sandbox-allowed command that still blocked to request additional permissions)
// must be resolved immediately — the agent goroutine is parked awaiting a
// decision. Before the fix the handler dropped it, and the run hung forever
// (the reported "stuck for 33 minutes"). Fail closed: a non-prompt allow denies.
func TestPermissionRequestNonPromptResolvesFailClosed(t *testing.T) {
m := permissionModel(t)
var got *agent.PermissionDecision
decide := func(d agent.PermissionDecision) { got = &d }
updated, _ := m.Update(permissionRequestMsg{
runID: 7,
request: agent.PermissionRequest{ToolCallID: "c1", ToolName: "bash", Action: agent.PermissionActionAllow},
decide: decide,
})
next := updated.(model)
if got == nil {
t.Fatal("DEADLOCK: decide was never called for a non-prompt request — the agent goroutine would park forever")
}
if got.Action != agent.PermissionDecisionDeny {
t.Fatalf("decision = %q, want deny (fail closed — never silently grant an unsurfaced permission)", got.Action)
}
if next.pendingPermission != nil {
t.Fatal("a non-prompt request must not open a pending prompt")
}
}
// A superseded/stale run's request (runID mismatch) must also be resolved
// (cancelled) so its parked goroutine unblocks instead of depending on
// context-cancel ordering.
func TestPermissionRequestStaleRunResolvesCancel(t *testing.T) {
m := permissionModel(t) // activeRunID = 7
var got *agent.PermissionDecision
decide := func(d agent.PermissionDecision) { got = &d }
m.Update(permissionRequestMsg{
runID: 3, // != activeRunID
request: agent.PermissionRequest{ToolCallID: "c1", ToolName: "bash", Action: agent.PermissionActionPrompt},
decide: decide,
})
if got == nil {
t.Fatal("stale-run request was dropped without resolving — its agent goroutine would park")
}
if got.Action != agent.PermissionDecisionCancel {
t.Fatalf("decision = %q, want cancel for a superseded run", got.Action)
}
}
// An explicit-cancel action is honored as a cancel (not denied).
func TestPermissionRequestCancelActionResolvesCancel(t *testing.T) {
m := permissionModel(t)
var got *agent.PermissionDecision
decide := func(d agent.PermissionDecision) { got = &d }
m.Update(permissionRequestMsg{
runID: 7,
request: agent.PermissionRequest{ToolCallID: "c1", ToolName: "bash", Action: agent.PermissionActionCancel},
decide: decide,
})
if got == nil || got.Action != agent.PermissionDecisionCancel {
t.Fatalf("cancel action must resolve as cancel, got %#v", got)
}
}
// A genuine prompt request stores the pending prompt and does NOT resolve yet —
// the decision comes from the user's reply. (Regression guard: the fix must not
// accidentally auto-resolve real prompts.)
func TestPermissionRequestPromptDefersToUser(t *testing.T) {
m := permissionModel(t)
resolved := false
decide := func(agent.PermissionDecision) { resolved = true }
updated, _ := m.Update(permissionRequestMsg{
runID: 7,
request: agent.PermissionRequest{
ToolCallID: "c1", ToolName: "bash", Action: agent.PermissionActionPrompt,
Reason: "runs a shell command", AvailableDecisions: []agent.PermissionDecisionAction{agent.PermissionDecisionAllow},
},
decide: decide,
})
next := updated.(model)
if resolved {
t.Fatal("a real prompt must not be auto-resolved; it awaits the user")
}
if next.pendingPermission == nil {
t.Fatal("a prompt request must open a pending permission prompt")
}
}
+3 -9
View File
@@ -452,17 +452,11 @@ func registryModelPickerMeta(entry modelregistry.ModelEntry) string {
}
func modelPickerDisplayName(id string, description string) string {
// The row title is the model NAME, not its marketing blurb. Catalog/discovery
// descriptions (from models.dev) are full sentences, and several models share
// the same one — using them as titles showed sentence-long rows that looked like
// exact duplicates and hid the actual id (which only survived in the meta line).
// Prefer the (prettified) id; fall back to a non-generic description only when
// there is no id to name the row.
if description = strings.TrimSpace(description); description != "" && !providerWizardGenericModelDescription(description) {
return description
}
id = strings.TrimSpace(id)
if id == "" {
if description = strings.TrimSpace(description); description != "" && !providerWizardGenericModelDescription(description) {
return description
}
return "model"
}
name := id
+10
View File
@@ -21,6 +21,12 @@ type CollectedStream struct {
// thinking blocks) that must be replayed on the next turn. Empty for providers
// or runs without extended thinking.
ReasoningBlocks []ReasoningBlock
// ReasoningEmitted reports that the stream carried ANY reasoning signal
// (live reasoning deltas or preserved blocks). It distinguishes "the model
// thought but produced no answer" (a behavioral empty turn) from "the
// provider returned a contentless stream" (a transport/backend fault worth
// retrying) — the two need different handling in the agent loop.
ReasoningEmitted bool
}
// Truncated reports whether the response ended for a non-normal reason (the
@@ -120,6 +126,7 @@ func CollectStreamWithOptions(ctx context.Context, events <-chan StreamEvent, op
// accumulate them regardless of type so they survive for replay.
if len(event.ReasoningBlocks) > 0 {
collected.ReasoningBlocks = append(collected.ReasoningBlocks, event.ReasoningBlocks...)
collected.ReasoningEmitted = true
}
switch event.Type {
@@ -132,6 +139,9 @@ func CollectStreamWithOptions(ctx context.Context, events <-chan StreamEvent, op
options.OnText(event.Content)
}
case StreamEventReasoning:
if event.Content != "" {
collected.ReasoningEmitted = true
}
if options.OnReasoning != nil {
options.OnReasoning(event.Content)
}