Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 d550f4a311 test(cli): stop TestRunCommandsDoNotLaunchTUI leaking real checkouts
The test runs every top-level command through runWithDeps with only the
TUI launcher injected, so each command gets the production dependencies.
Bare `worktrees` and `worktree` default to `worktrees prepare`, so every
run added two locked checkouts of the repository the tests ran in, under
the developer's real per-user state directory, and nothing released them.

The test now isolates the per-user roots, runs from a temporary
directory, and stubs `prepareWorktree`. It also asserts that both
spellings reached the stub for that directory, so dropping either
injection fails it.
2026-11-11 12:16:16 +05:30
18 changed files with 56 additions and 699 deletions

No files matched your search

+2 -2
View File
@@ -12,7 +12,7 @@
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.11+" src="https://img.shields.io/badge/Go-1.26.11+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<a href="https://vercel.com/open-source-program"><img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge-2026.svg"></a>
@@ -168,7 +168,7 @@ irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | i
### From source
Source builds require Go 1.26.11+.
Source builds require Go 1.26.10+.
```bash
git clone https://github.com/Twigpine/zero.git
+2 -2
View File
@@ -12,7 +12,7 @@
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.11+" src="https://img.shields.io/badge/Go-1.26.11+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<a href="https://vercel.com/open-source-program"><img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge-2026.svg"></a>
@@ -146,7 +146,7 @@ irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | i
### 从源码构建
源码构建需要 Go 1.26.11+。
源码构建需要 Go 1.26.10+。
```bash
git clone https://github.com/Twigpine/zero.git
+2 -2
View File
@@ -190,7 +190,7 @@ Every release so far declares the module path `github.com/Gitlawb/zero`, so
fails with "module declares its path as" until a release is tagged with the new
module path.
This builds from source, so it needs Go 1.26.11+ and it does not go through the
This builds from source, so it needs Go 1.26.10+ and it does not go through the
release archives. Two consequences worth knowing before you pick it:
- On Linux, Bubblewrap is still required for native sandboxing. Zero resolves
@@ -218,7 +218,7 @@ Build a local binary:
go build -o zero ./cmd/zero
```
Source builds require Go 1.26.11+.
Source builds require Go 1.26.10+.
### Sandbox Helpers For Source Builds
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/Twigpine/zero
go 1.26.11
go 1.26.10
require (
charm.land/bubbles/v2 v2.1.1
+2 -3
View File
@@ -413,8 +413,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// collected and a non-nil stop error when the run must end now.
recoverStreamError := func(collected zeroruntime.CollectedStream) (zeroruntime.CollectedStream, error) {
if isImageRejectionError(errors.New(collected.Error)) {
cause := &zeroruntime.StreamError{Message: collected.Error, StatusCode: collected.ErrorStatusCode, Cause: collected.ErrorCause}
return collected, fmt.Errorf("model %s rejected the image: %w. The model may not support image input — try switching to a vision-capable model (claude, gpt-4o, gemini)", options.Model, cause)
return collected, fmt.Errorf("model %s rejected the image: %s. The model may not support image input — try switching to a vision-capable model (claude, gpt-4o, gemini)", options.Model, collected.Error)
}
// REACTIVE compaction: the streamed error may also be a context limit
// (some providers surface it mid-stream). Compact and retry once.
@@ -543,7 +542,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
if collected.Error != "" {
result.Messages = copyMessages(messages)
return result, &zeroruntime.StreamError{Message: collected.Error, StatusCode: collected.ErrorStatusCode, Cause: collected.ErrorCause}
return result, errors.New(collected.Error)
}
}
-80
View File
@@ -4205,86 +4205,6 @@ func TestRunNilTraceForwardsUsage(t *testing.T) {
}
}
// TestRunReturnsStreamErrorWithStatusCodeAndCause covers the collector-to-agent
// propagation boundary: a provider stream that terminates with a StreamEventError
// carrying StatusCode/Cause must have Run return an error that errors.As can
// still recover those fields from — the detail a session's "error" event needs
// to actually diagnose why a provider call failed (#674).
func TestRunReturnsStreamErrorWithStatusCodeAndCause(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{
Type: zeroruntime.StreamEventError,
Error: "provider error: provider returned error",
StatusCode: 500,
Cause: "upstream returned an empty completion",
},
}}}
_, err := Run(context.Background(), "hi", provider, Options{
SessionID: "stream-error-session",
Cwd: t.TempDir(),
ProviderName: "test-provider",
Model: "test-model",
})
if err == nil {
t.Fatal("Run: expected an error, got nil")
}
var streamErr *zeroruntime.StreamError
if !errors.As(err, &streamErr) {
t.Fatalf("Run error %v (%T) does not unwrap to *zeroruntime.StreamError", err, err)
}
if streamErr.StatusCode != 500 {
t.Fatalf("StatusCode = %d, want 500", streamErr.StatusCode)
}
if streamErr.Cause != "upstream returned an empty completion" {
t.Fatalf("Cause = %q, want %q", streamErr.Cause, "upstream returned an empty completion")
}
if err.Error() != "provider error: provider returned error" {
t.Fatalf("Error() = %q, want the flattened message unchanged", err.Error())
}
}
// TestRunImageRejectionKeepsStreamErrorStatusCodeAndCause covers the 400
// image-rejection recovery path: the friendly message is kept, but the returned
// error must still unwrap to the *StreamError so StatusCode and Cause reach the
// session's "error" event (#674).
func TestRunImageRejectionKeepsStreamErrorStatusCodeAndCause(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{
Type: zeroruntime.StreamEventError,
Error: "provider error 400: image input is not supported",
StatusCode: 400,
Cause: "this model does not support image input",
},
}}}
_, err := Run(context.Background(), "hi", provider, Options{
SessionID: "image-rejection-session",
Cwd: t.TempDir(),
ProviderName: "test-provider",
Model: "test-model",
})
if err == nil {
t.Fatal("Run: expected an error, got nil")
}
want := "model test-model rejected the image: provider error 400: image input is not supported. The model may not support image input — try switching to a vision-capable model (claude, gpt-4o, gemini)"
if err.Error() != want {
t.Fatalf("Error() = %q, want the friendly message unchanged: %q", err.Error(), want)
}
var streamErr *zeroruntime.StreamError
if !errors.As(err, &streamErr) {
t.Fatalf("Run error %v (%T) does not unwrap to *zeroruntime.StreamError", err, err)
}
if streamErr.StatusCode != 400 {
t.Fatalf("StatusCode = %d, want 400", streamErr.StatusCode)
}
if streamErr.Cause != "this model does not support image input" {
t.Fatalf("Cause = %q, want the upstream cause", streamErr.Cause)
}
}
// TestRunSuppressesAdvisoryHooksInPlanMode: plan mode promises a read-only
// turn for advisory hooks (sessionStart/sessionEnd/afterTool), which execute
// configured host commands outside the advertised-tool and sandbox gates.
+32
View File
@@ -22,6 +22,7 @@ import (
"github.com/Twigpine/zero/internal/tui"
"github.com/Twigpine/zero/internal/update"
"github.com/Twigpine/zero/internal/workspacetrust"
"github.com/Twigpine/zero/internal/worktrees"
"github.com/Twigpine/zero/internal/zeroruntime"
)
@@ -1236,6 +1237,20 @@ func TestRunNoArgsReportsConfigErrorsWithoutLaunchingTUI(t *testing.T) {
}
func TestRunCommandsDoNotLaunchTUI(t *testing.T) {
// THESE COMMANDS RUN FOR REAL. runWithDeps fills every dependency left out
// here with the production one, and bare `worktrees` and `worktree` default
// to `prepare`. With the real working directory and state roots, each run
// of this test registered two locked worktrees of the repository it ran in,
// under the developer's real per-user state directory, and nothing ever
// released them.
isolateCLIUserState(t)
cwd := t.TempDir()
cwdInfo, err := os.Stat(cwd)
if err != nil {
t.Fatalf("stat working directory: %v", err)
}
var prepareCalls []worktrees.Options
for _, args := range [][]string{
{"--help"},
{"-h"},
@@ -1272,6 +1287,11 @@ func TestRunCommandsDoNotLaunchTUI(t *testing.T) {
launchCalled := false
_ = runWithDeps(args, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
prepareWorktree: func(_ context.Context, options worktrees.Options) (worktrees.Result, error) {
prepareCalls = append(prepareCalls, options)
return worktrees.Result{}, errors.New("worktree preparation is stubbed in this test")
},
runTUI: func(ctx context.Context, options tui.Options) int {
launchCalled = true
return 0
@@ -1283,6 +1303,18 @@ func TestRunCommandsDoNotLaunchTUI(t *testing.T) {
}
})
}
// Both spellings reached prepare, and only through the stub, for the
// temporary directory rather than the repository the test runs in.
if len(prepareCalls) != 2 {
t.Fatalf("prepareWorktree stub called %d times, want 2 (worktrees, worktree)", len(prepareCalls))
}
for _, options := range prepareCalls {
info, err := os.Stat(options.Cwd)
if err != nil || !os.SameFile(info, cwdInfo) {
t.Errorf("prepare ran for %q, want the test's temporary directory %q (stat err %v)", options.Cwd, cwd, err)
}
}
}
func TestRunSetupNoArgsForcesSetupTUI(t *testing.T) {
+1 -1
View File
@@ -799,7 +799,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
return exitInterrupted
}
sessionRecorder.append(sessions.EventError, sessions.ErrorEventPayload(err))
sessionRecorder.append(sessions.EventError, map[string]any{"message": err.Error()})
if options.outputFormat == execOutputStreamJSON {
writer.errorEvent("provider_error", err.Error(), false)
writer.runEnd("error", exitProvider)
-135
View File
@@ -1,135 +0,0 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// streamErrorExecProvider fails every request with one provider error event.
type streamErrorExecProvider struct {
event zeroruntime.StreamEvent
}
func (provider streamErrorExecProvider) StreamCompletion(ctx context.Context, _ zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
ch := make(chan zeroruntime.StreamEvent, 1)
ch <- provider.event
close(ch)
return ch, nil
}
func providerErrorExecDeps(t *testing.T, store *sessions.Store, workspaceRoot string) appDeps {
t.Helper()
return appDeps{
getwd: func() (string, error) { return workspaceRoot, nil },
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{
ActiveProvider: "test",
Provider: config.ProviderProfile{
Name: "test",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "http://127.0.0.1/v1",
Model: "test-model",
},
MaxTurns: 3,
}, nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return streamErrorExecProvider{event: zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: "provider request error: model not found",
StatusCode: 404,
Cause: "upstream said: no such model",
}}, nil
},
newSessionStore: func() *sessions.Store { return store },
newSandboxStore: func() (*sandbox.GrantStore, error) {
return sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: t.TempDir() + "/grants.json"})
},
}
}
// recordedProviderError returns the payload of the one error event the run
// recorded, failing unless there is exactly one session with exactly one.
func recordedProviderError(t *testing.T, store *sessions.Store) map[string]any {
t.Helper()
items, err := store.List()
if err != nil || len(items) != 1 {
t.Fatalf("sessions = %d, %v; want one", len(items), err)
}
events, err := store.ReadEvents(items[0].SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
var found []map[string]any
for _, event := range events {
if event.Type != sessions.EventError {
continue
}
var payload map[string]any
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatalf("unmarshal error payload: %v", err)
}
found = append(found, payload)
}
if len(found) != 1 {
t.Fatalf("recorded %d error events, want 1: %#v", len(found), found)
}
return found[0]
}
func assertProviderErrorDetail(t *testing.T, payload map[string]any) {
t.Helper()
if got, want := payload["message"], "provider request error: model not found"; got != want {
t.Errorf("message = %#v, want %q", got, want)
}
if got, want := payload["statusCode"], float64(404); got != want {
t.Errorf("statusCode = %#v, want %v: the run recorded only the flattened message", got, want)
}
if got, want := payload["cause"], "upstream said: no such model"; got != want {
t.Errorf("cause = %#v, want %q: the run recorded only the flattened message", got, want)
}
}
// A provider error reaches the exec session log with its HTTP status and the
// upstream cause, not only the flattened message (#674).
func TestRunExecRecordsTheProviderErrorStatusAndCause(t *testing.T) {
isolateCLIUserState(t)
workspaceRoot := t.TempDir()
// exec records through the default store, which isolateCLIUserState has
// moved under the test's temp root; this opens the same one.
store := sessions.NewStore(sessions.StoreOptions{})
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--cwd", workspaceRoot, "--init-session-id", "provider_error_run", "hello"}, &stdout, &stderr,
providerErrorExecDeps(t, store, workspaceRoot))
if exitCode != exitProvider {
t.Fatalf("exit = %d, want %d; stderr=%s", exitCode, exitProvider, stderr.String())
}
assertProviderErrorDetail(t, recordedProviderError(t, store))
}
// The spec draft run records its provider error through its own call site, so
// it is checked on its own.
func TestRunExecUseSpecRecordsTheProviderErrorStatusAndCause(t *testing.T) {
isolateCLIUserState(t)
workspaceRoot := t.TempDir()
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--cwd", workspaceRoot, "--use-spec", "-o", "json", "plan the review flow"}, &stdout, &stderr,
providerErrorExecDeps(t, store, workspaceRoot))
if exitCode != exitProvider {
t.Fatalf("exit = %d, want %d; stderr=%s stdout=%s", exitCode, exitProvider, stderr.String(), stdout.String())
}
assertProviderErrorDetail(t, recordedProviderError(t, store))
}
+1 -1
View File
@@ -201,7 +201,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
}
return exitInterrupted
}
sessionRecorder.append(sessions.EventError, sessions.ErrorEventPayload(err))
sessionRecorder.append(sessions.EventError, map[string]any{"message": err.Error()})
return writeExecSpecDraftProviderError(&writer, run.options.outputFormat, err.Error())
}
if result.StopReason != agent.StopReasonSpecReviewRequired || draftInfo.SpecID == "" || draftInfo.SpecFilePath == "" {
+8 -21
View File
@@ -298,32 +298,26 @@ func (provider *Provider) emitPayload(ctx context.Context, data string, state *t
if chunk.Error != nil {
state.flushContent(ctx, events)
state.closeOpen(ctx, events)
// statusCode buckets the error for classification only. The HTTP response
// was 200, so observedStatus stays 0 unless the payload's code maps to a
// known status; a bucket default must not be persisted as a real status.
statusCode := http.StatusInternalServerError
observedStatus := 0
if chunk.Error.Code != nil {
switch c := chunk.Error.Code.(type) {
case string:
if code, ok := openAIStreamErrorStatusByCode[c]; ok {
statusCode, observedStatus = code, code
statusCode = code
}
case float64:
if code, ok := openAIStreamErrorStatusByCode[strconv.Itoa(int(c))]; ok {
statusCode, observedStatus = code, code
statusCode = code
}
case int:
if code, ok := openAIStreamErrorStatusByCode[strconv.Itoa(c)]; ok {
statusCode, observedStatus = code, code
statusCode = code
}
}
}
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.classifiedError(statusCode, chunk.Error.Message),
StatusCode: observedStatus,
Cause: provider.redact(chunk.Error.Message),
Type: zeroruntime.StreamEventError,
Error: provider.classifiedError(statusCode, chunk.Error.Message),
})
state.done = true
return false
@@ -410,19 +404,12 @@ func (provider *Provider) emitHTTPError(ctx context.Context, response *http.Resp
// localhost but returns a gateway error when it cannot reach its own backend.
// Surface that as a clear connectivity message instead of the raw proxied body.
if humanized, ok := providerio.UpstreamUnreachable(message); ok {
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.redact(humanized),
StatusCode: response.StatusCode,
Cause: provider.redact(message),
})
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact(humanized)})
return
}
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.classifiedError(response.StatusCode, message),
StatusCode: response.StatusCode,
Cause: provider.redact(message),
Type: zeroruntime.StreamEventError,
Error: provider.classifiedError(response.StatusCode, message),
})
}
@@ -500,35 +500,6 @@ func TestStreamCompletionClassifiesHTTPErrorsAndRedactsToken(t *testing.T) {
}
}
// TestStreamCompletionHTTPErrorCarriesStatusCodeAndCause verifies the StreamEvent
// for an HTTP-level provider failure carries the raw status code and a redacted
// cause separately from the classified, user-facing Error string — the detail
// a session's "error" event needs to actually diagnose why a provider call
// failed instead of only storing the flattened message (#674).
func TestStreamCompletionHTTPErrorCarriesStatusCodeAndCause(t *testing.T) {
provider := newTestProviderWithKey(t, "sk-secret", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":{"message":"upstream saw Bearer sk-secret"}}`, http.StatusInternalServerError)
})
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{})
if err != nil {
t.Fatalf("StreamCompletion returned setup error: %v", err)
}
events := readAll(stream)
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
t.Fatalf("events = %#v, want one error", events)
}
event := events[0]
if event.StatusCode != http.StatusInternalServerError {
t.Fatalf("StatusCode = %d, want %d", event.StatusCode, http.StatusInternalServerError)
}
if !strings.Contains(event.Cause, "upstream saw Bearer") {
t.Fatalf("Cause = %q, want it to contain the upstream detail", event.Cause)
}
if strings.Contains(event.Cause, "sk-secret") {
t.Fatalf("Cause leaked token: %q", event.Cause)
}
}
func TestStreamCompletionHumanizesUpstreamUnreachableGatewayError(t *testing.T) {
t.Parallel()
// A local Ollama daemon serving a "-cloud" model answers on localhost but
@@ -555,15 +526,6 @@ func TestStreamCompletionHumanizesUpstreamUnreachableGatewayError(t *testing.T)
t.Fatalf("error = %q, want it to contain %q", got, want)
}
}
// The humanized "upstream unreachable" message must not drop the response
// metadata a session error event needs — StatusCode/Cause must still reflect
// the real HTTP response, matching the classified-error path below it (#674).
if events[0].StatusCode != http.StatusBadGateway {
t.Fatalf("StatusCode = %d, want %d", events[0].StatusCode, http.StatusBadGateway)
}
if !strings.Contains(events[0].Cause, "TLS handshake timeout") {
t.Fatalf("Cause = %q, want it to contain the raw upstream detail", events[0].Cause)
}
}
func TestStreamCompletionEmitsStreamErrorObject(t *testing.T) {
@@ -622,50 +584,6 @@ func TestStreamCompletionClassifiesStreamErrorCode(t *testing.T) {
}
}
// TestStreamCompletionStreamedErrorCarriesStatusCodeAndCause mirrors
// TestStreamCompletionHTTPErrorCarriesStatusCodeAndCause for the other error
// path: an error arriving inside a 200 OK SSE payload's "code" field rather
// than the HTTP status (#674).
func TestStreamCompletionStreamedErrorCarriesStatusCodeAndCause(t *testing.T) {
provider := newTestProviderWithKey(t, "sk-secret", func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"error":{"message":"rate limited sk-secret, see Bearer token docs","code":"429"}}`)
})
events := collectProviderEvents(t, provider)
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
t.Fatalf("events = %#v, want one error", events)
}
event := events[0]
if event.StatusCode != http.StatusTooManyRequests {
t.Fatalf("StatusCode = %d, want %d", event.StatusCode, http.StatusTooManyRequests)
}
if !strings.Contains(event.Cause, "rate limited") {
t.Fatalf("Cause = %q, want it to contain the upstream detail", event.Cause)
}
if strings.Contains(event.Cause, "sk-secret") {
t.Fatalf("Cause leaked token: %q", event.Cause)
}
}
// An error inside a 200 OK SSE payload whose code is not a known status was
// never an observed HTTP status, so none must be reported (#674 review).
func TestStreamCompletionStreamedErrorWithUnknownCodeReportsNoStatus(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"error":{"message":"model overloaded","code":"server_error"}}`)
})
events := collectProviderEvents(t, provider)
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
t.Fatalf("events = %#v, want one error", events)
}
if events[0].StatusCode != 0 {
t.Fatalf("StatusCode = %d, want 0 (no HTTP status was observed)", events[0].StatusCode)
}
if !strings.Contains(events[0].Cause, "model overloaded") {
t.Fatalf("Cause = %q, want the upstream detail", events[0].Cause)
}
}
func TestStreamCompletionEmitsErrorForMalformedJSON(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":`)
-55
View File
@@ -1,55 +0,0 @@
package sessions
import (
"errors"
"strings"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// maxErrorCauseBytes bounds the persisted cause. Provider error bodies are read
// up to 64 KiB, which is far more than a diagnostic needs and would otherwise be
// copied whole into every error event in events.jsonl.
const maxErrorCauseBytes = 8 << 10
const errorCauseTruncationMarker = "… [truncated]"
// ErrorEventPayload builds the payload for an EventError session event. It
// always includes the flattened message (unchanged, for backward
// compatibility with existing consumers of events.jsonl), and additionally
// persists the HTTP status code and upstream cause when err is (or wraps) a
// *zeroruntime.StreamError — the structured error a failed provider stream
// returns. Without this, a provider error left no way to diagnose *why* a
// call failed from the CLI or its logs — only the generic top-level message
// was ever recorded (#674). Cause has already been redacted for secrets by
// the provider before reaching here (see zeroruntime.StreamEvent.Cause) — it
// is never re-scrubbed or stored raw at this layer. Invalid UTF-8 in the cause
// is replaced with U+FFFD, and a longer cause is cut on a rune boundary and
// marked; the marked result, marker included, stays within maxErrorCauseBytes
// as it reads back from the store.
func ErrorEventPayload(err error) map[string]any {
payload := map[string]any{"message": err.Error()}
var streamErr *zeroruntime.StreamError
if errors.As(err, &streamErr) {
if streamErr.StatusCode != 0 {
payload["statusCode"] = streamErr.StatusCode
}
if streamErr.Cause != "" {
payload["cause"] = boundErrorCause(streamErr.Cause)
}
}
return payload
}
func boundErrorCause(cause string) string {
// Made valid first, so the bound applies to what the store keeps. The
// payload is written as JSON, which turns every invalid byte into a
// three-byte U+FFFD, so a body of invalid bytes cut to the limit here came
// back from events.jsonl at up to three times it.
cause = strings.ToValidUTF8(cause, "\uFFFD")
if len(cause) <= maxErrorCauseBytes {
return cause
}
// The marker counts toward the bound, so the cut leaves room for it.
return truncateUTF8(cause, maxErrorCauseBytes-len(errorCauseTruncationMarker)) + errorCauseTruncationMarker
}
-213
View File
@@ -1,213 +0,0 @@
package sessions
import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"unicode/utf8"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestErrorEventPayloadPlainError(t *testing.T) {
payload := ErrorEventPayload(errors.New("provider error: provider returned error"))
if got, want := payload["message"], "provider error: provider returned error"; got != want {
t.Fatalf("message = %v, want %v", got, want)
}
if _, ok := payload["statusCode"]; ok {
t.Fatalf("statusCode should be absent for a plain error, got %v", payload["statusCode"])
}
if _, ok := payload["cause"]; ok {
t.Fatalf("cause should be absent for a plain error, got %v", payload["cause"])
}
}
func TestErrorEventPayloadStreamError(t *testing.T) {
err := &zeroruntime.StreamError{
Message: "provider error: provider returned error",
StatusCode: 500,
Cause: "upstream returned an empty completion",
}
payload := ErrorEventPayload(err)
if got, want := payload["message"], "provider error: provider returned error"; got != want {
t.Fatalf("message = %v, want %v", got, want)
}
if got, want := payload["statusCode"], 500; got != want {
t.Fatalf("statusCode = %v, want %v", got, want)
}
if got, want := payload["cause"], "upstream returned an empty completion"; got != want {
t.Fatalf("cause = %v, want %v", got, want)
}
}
func TestErrorEventPayloadWrappedStreamError(t *testing.T) {
// A StreamError wrapped by another layer (e.g. fmt.Errorf("...: %w", err))
// must still surface its status/cause via errors.As, not just a direct type
// assertion — this is the shape #674's real repro chain produces.
inner := &zeroruntime.StreamError{Message: "rate limit error: too many requests", StatusCode: 429, Cause: "retry after 30s"}
wrapped := fmt.Errorf("turn failed: %w", inner)
payload := ErrorEventPayload(wrapped)
if got, want := payload["statusCode"], 429; got != want {
t.Fatalf("statusCode = %v, want %v", got, want)
}
if got, want := payload["cause"], "retry after 30s"; got != want {
t.Fatalf("cause = %v, want %v", got, want)
}
if got, want := payload["message"], "turn failed: rate limit error: too many requests"; got != want {
t.Fatalf("message = %v, want %v", got, want)
}
}
func TestErrorEventPayloadZeroStatusCodeOmitted(t *testing.T) {
// A transport-level failure (e.g. context cancellation) never got an HTTP
// response, so StatusCode is legitimately 0 — that must stay omitted rather
// than be persisted as a misleading "statusCode": 0.
err := &zeroruntime.StreamError{Message: "context deadline exceeded"}
payload := ErrorEventPayload(err)
if _, ok := payload["statusCode"]; ok {
t.Fatalf("statusCode should be omitted when zero, got %v", payload["statusCode"])
}
if _, ok := payload["cause"]; ok {
t.Fatalf("cause should be omitted when empty, got %v", payload["cause"])
}
}
func TestStoreAppendEventPersistsErrorEventStatusCodeAndCause(t *testing.T) {
// Closes the seam between ErrorEventPayload (unit tested above) and Run
// returning a *StreamError (tested in internal/agent): this asserts the
// two actually meet at the Store, i.e. a *StreamError survives a real
// AppendEvent/ReadEvents round trip with statusCode/cause intact.
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-08-10T12:00:00Z")})
session, err := store.Create(CreateInput{SessionID: "error_join"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
streamErr := &zeroruntime.StreamError{
Message: "provider error: provider returned error",
StatusCode: 503,
Cause: "upstream returned an empty completion",
}
if _, err := store.AppendEvent(session.SessionID, AppendEventInput{
Type: EventError,
Payload: ErrorEventPayload(streamErr),
}); err != nil {
t.Fatalf("AppendEvent returned error: %v", err)
}
events, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if len(events) != 1 || events[0].Type != EventError {
t.Fatalf("expected a single error event, got %#v", events)
}
var payload map[string]any
if err := json.Unmarshal(events[0].Payload, &payload); err != nil {
t.Fatalf("failed to unmarshal persisted payload: %v", err)
}
if got, want := payload["message"], "provider error: provider returned error"; got != want {
t.Fatalf("persisted message = %v, want %v", got, want)
}
if got, want := payload["statusCode"], float64(503); got != want {
t.Fatalf("persisted statusCode = %v, want %v", got, want)
}
if got, want := payload["cause"], "upstream returned an empty completion"; got != want {
t.Fatalf("persisted cause = %v, want %v", got, want)
}
}
func TestErrorEventPayloadBoundsCause(t *testing.T) {
for _, tc := range []struct {
name string
cause string
}{
// "é" is two bytes; an odd cap boundary must not split it.
{"two-byte runes", strings.Repeat("é", maxErrorCauseBytes)},
{"one byte over the cap", strings.Repeat("a", maxErrorCauseBytes+1)},
} {
t.Run(tc.name, func(t *testing.T) {
payload := ErrorEventPayload(&zeroruntime.StreamError{Message: "m", StatusCode: 500, Cause: tc.cause})
got, ok := payload["cause"].(string)
if !ok {
t.Fatalf("cause = %#v, want string", payload["cause"])
}
if !strings.HasSuffix(got, errorCauseTruncationMarker) {
t.Fatalf("cause missing truncation marker: ...%q", got[len(got)-20:])
}
// The marker counts: the persisted cause as a whole stays within the cap.
if len(got) > maxErrorCauseBytes {
t.Fatalf("bounded cause is %d bytes with its marker, want <= %d", len(got), maxErrorCauseBytes)
}
if !utf8.ValidString(got) {
t.Fatal("bounded cause is not valid UTF-8")
}
})
}
}
func TestErrorEventPayloadKeepsCauseAtCap(t *testing.T) {
cause := strings.Repeat("a", maxErrorCauseBytes)
payload := ErrorEventPayload(&zeroruntime.StreamError{Message: "m", Cause: cause})
if got := payload["cause"]; got != cause {
t.Fatalf("cause at exactly the cap was altered (len %d)", len(got.(string)))
}
}
// The bound holds for the cause as it reads back from events.jsonl, not only
// for the string handed to AppendEvent. JSON turns each invalid byte into a
// three-byte U+FFFD, so an error body of invalid bytes cut to the limit came
// back at up to three times it.
func TestAnInvalidUTF8CauseStaysWithinTheBoundAfterTheStoreRoundTrip(t *testing.T) {
for _, tc := range []struct {
name string
cause string
}{
{"invalid bytes between ASCII", strings.Repeat("\xffa", maxErrorCauseBytes)},
{"a run of invalid bytes", strings.Repeat("\xfe", maxErrorCauseBytes) + "tail"},
{"short and invalid", "status \xff\xfe body"},
} {
t.Run(tc.name, func(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-11-09T12:00:00Z")})
session, err := store.Create(CreateInput{SessionID: "invalid_cause"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
streamErr := &zeroruntime.StreamError{Message: "provider error", StatusCode: 502, Cause: tc.cause}
if _, err := store.AppendEvent(session.SessionID, AppendEventInput{Type: EventError, Payload: ErrorEventPayload(streamErr)}); err != nil {
t.Fatalf("AppendEvent returned error: %v", err)
}
events, err := store.ReadEvents(session.SessionID)
if err != nil || len(events) != 1 {
t.Fatalf("ReadEvents = %d events, %v", len(events), err)
}
var payload map[string]any
if err := json.Unmarshal(events[0].Payload, &payload); err != nil {
t.Fatalf("unmarshal persisted payload: %v", err)
}
got, ok := payload["cause"].(string)
if !ok {
t.Fatalf("persisted cause = %#v, want a string", payload["cause"])
}
if len(got) > maxErrorCauseBytes {
t.Fatalf("persisted cause reads back at %d bytes, want <= %d", len(got), maxErrorCauseBytes)
}
if !utf8.ValidString(got) {
t.Fatal("persisted cause is not valid UTF-8")
}
})
}
}
+1 -1
View File
@@ -6351,7 +6351,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
flushReasoning(m.now())
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: sessions.EventError,
Payload: sessions.ErrorEventPayload(err),
Payload: map[string]any{"message": err.Error()},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, usageModelIDs: usageModelIDs, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.activeTurnElapsed(started)}
}
@@ -1,64 +0,0 @@
package tui
import (
"context"
"encoding/json"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// A provider error reaches the TUI session log with its HTTP status and the
// upstream cause, not only the flattened message (#674).
func TestPromptSubmitRecordsTheProviderErrorStatusAndCause(t *testing.T) {
store := testSessionStore(t)
provider := &fakeProvider{events: []zeroruntime.StreamEvent{{
Type: zeroruntime.StreamEventError,
Error: "provider request error: model not found",
StatusCode: 404,
Cause: "upstream said: no such model",
}}}
m := newModel(context.Background(), Options{
Cwd: isolatedCwd(t),
ProviderName: "openai",
ModelName: "gpt-4.1",
Provider: provider,
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.input.SetValue("inspect repo")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd == nil {
t.Fatal("expected prompt submit to start an agent run")
}
updated, _ = next.Update(execCmd(cmd))
_ = updated.(model)
list, err := store.List()
if err != nil || len(list) != 1 {
t.Fatalf("sessions = %d, %v; want one", len(list), err)
}
events, err := store.ReadEvents(list[0].SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if got := countSessionEvents(events, sessions.EventError); got != 1 {
t.Fatalf("recorded %d error events, want 1: %#v", got, eventTypes(events))
}
var payload map[string]any
if err := json.Unmarshal(nthSessionEvent(t, events, sessions.EventError, 1).Payload, &payload); err != nil {
t.Fatalf("unmarshal error payload: %v", err)
}
if got, want := payload["statusCode"], float64(404); got != want {
t.Errorf("statusCode = %#v, want %v: the run recorded only the flattened message", got, want)
}
if got, want := payload["cause"], "upstream said: no such model"; got != want {
t.Errorf("cause = %#v, want %q: the run recorded only the flattened message", got, want)
}
}
+4 -11
View File
@@ -8,15 +8,10 @@ import (
// CollectedStream is the non-streaming summary of provider events.
type CollectedStream struct {
Text string
ToolCalls []ToolCall
Usage Usage
Error string
// ErrorStatusCode and ErrorCause mirror StreamEvent.StatusCode/Cause for a
// StreamEventError — see those fields for what each carries. Both are zero
// value when Error came from a context cancellation or other non-HTTP source.
ErrorStatusCode int
ErrorCause string
Text string
ToolCalls []ToolCall
Usage Usage
Error string
DroppedToolCalls int // malformed tool calls the provider could not dispatch
// FinishReason is the provider's normalized terminal stop reason when the
// response did not end normally (FinishReasonLength / FinishReasonContentFilter).
@@ -158,8 +153,6 @@ func CollectStreamWithOptions(ctx context.Context, events <-chan StreamEvent, op
usageSeen = true
case StreamEventError:
collected.Error = event.Error
collected.ErrorStatusCode = event.StatusCode
collected.ErrorCause = event.Cause
return finish()
case StreamEventDone:
return finish()
-25
View File
@@ -212,17 +212,6 @@ type StreamEvent struct {
// available. Stateful turn sessions use it to chain a later compatible
// request; ordinary providers and consumers leave it empty.
ResponseID string
// StatusCode is the HTTP status code behind Error, when the failure came from
// an HTTP response (0 for transport-level/context errors that never received
// one). Providers set it alongside Error on a StreamEventError so the status
// survives past the flattened error string (#674).
StatusCode int
// Cause is the raw, secret-redacted upstream detail behind Error — the
// provider's own error message or response body — kept separate from Error's
// curated/classified wording so both are preserved rather than one
// overwriting the other. Always passed through the same redaction already
// applied to Error (see provider.redact); never stored unredacted.
Cause string
// FinishReason carries the provider's normalized terminal stop reason when a
// response did not end normally (e.g. FinishReasonLength when the output hit
// the token cap, or FinishReasonContentFilter when it was filtered). It is
@@ -234,20 +223,6 @@ type StreamEvent struct {
ReasoningBlocks []ReasoningBlock
}
// StreamError is the terminal error returned for a failed provider stream. It
// implements error via Message, so existing `err.Error()` call sites are
// unaffected by this type; callers that need the underlying HTTP status code or
// upstream cause recover them with errors.As instead of re-parsing the message
// string. This is what lets a session's "error" event persist real diagnostic
// detail instead of only the flattened message (#674).
type StreamError struct {
Message string
StatusCode int
Cause string
}
func (e *StreamError) Error() string { return e.Message }
// CompletionRequest groups provider input messages and available tools.
type CompletionRequest struct {
Messages []Message