mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 13:31:26 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
132788fd36 | ||
|
|
ae43d92d2a | ||
|
|
48318dfb57 | ||
|
|
92ab65aa5f |
No files matched your search
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
-13
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+5
-41
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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