Compare commits

...
Author SHA1 Message Date
jatmn f317a79a02 fix(sessions): use upstream module path for error diagnostics 2026-11-09 08:24:41 -08:00
Vasanthdev2004 90143736a8 fix(sessions): bound the error cause as it reads back, and pin the call sites
A provider error body with invalid UTF-8 was cut to the 8 KiB limit and
then grew on its way into events.jsonl, because JSON turns each invalid
byte into a three-byte U+FFFD: a cause of invalid bytes read back at up
to three times the limit. The cause is now made valid before it is
bounded, and a store round-trip test checks the value that reads back.

Nothing pinned that exec, the spec draft run and the TUI record the
structured payload rather than only the message. Each now has a test
that drives a provider error into the session log and reads back the
status code and the cause, and each fails if its call site records only
the message.

Reported by CodeRabbit on the merge of main.
2026-11-09 08:12:53 -08:00
Vasanthdev2004 c8144df06d fix(sessions): keep a truncated error cause within its cap
The truncation marker was appended after cutting the cause to 8 KiB, so
a capped cause came out up to 15 bytes over. The cut now leaves room for
the marker. The bound test checks the whole persisted value and gains a
one-byte-over case; both fail without the fix (8207 bytes).

Reported by CodeRabbit in review.
2026-11-09 08:12:53 -08:00
svector-anu dd763d9921 fix(providers): stop persisting a bucketed 500 and bound error cause
A streamed error arrives on a 200 response, so the local 500 default used
for classification is not an observed status; report StatusCode only when
the payload code maps to a known status. Cap the persisted cause at 8 KiB
(rune-safe, with a truncation marker) since provider bodies read up to 64 KiB.
2026-11-09 08:12:53 -08:00
svector-anu f4df51cc48 fix(agent): keep StreamError through image-rejection recovery
The 400 image-rejection branch returned a plain fmt.Errorf, so errors.As
could not recover StatusCode/Cause and the session error event persisted
only the message. Wrap the StreamError with %w; the user-facing message is
unchanged.
2026-11-09 08:12:53 -08:00
svector-anu 197026c5e9 test(sessions): assert error event round-trips statusCode/cause through Store 2026-11-09 08:12:28 -08:00
svector-anu fa1dbced03 fix(zeroruntime): gofmt struct alignment in CollectedStream 2026-11-09 08:12:28 -08:00
svector-anuandClaude Sonnet 5 88716c4fb1 Address CodeRabbit review: upstream-unreachable metadata + test coverage
- emitHTTPError's UpstreamUnreachable branch was dropping StatusCode/
  Cause (only the humanized Error string was set), unlike the
  classifiedError branch right below it. A proxy connectivity failure
  (e.g. a local Ollama daemon serving a "-cloud" model) lost its
  response metadata even though response.StatusCode was available.
  Now sets both, same as the main path.
- TestStreamCompletionStreamedErrorCarriesStatusCodeAndCause now
  includes a secret-shaped value in the SSE error message and asserts
  it's absent from Cause, matching the redaction check already done
  for the HTTP-error path.
- TestStreamCompletionHumanizesUpstreamUnreachableGatewayError now
  asserts StatusCode/Cause on the upstream-unreachable path (the fix
  above).
- New TestRunReturnsStreamErrorWithStatusCodeAndCause in
  internal/agent covers the collector-to-agent propagation boundary
  end-to-end: a provider emitting StreamEventError with StatusCode/
  Cause set results in Run returning an error errors.As can still
  recover those fields from.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BLkz6WZYMD3AoiMHQLGvmB
2026-11-09 08:12:28 -08:00
svector-anuandClaude Sonnet 5 91a25079ce Persist status code and cause on provider error events
Session events of type "error" only ever stored the top-level message
string, e.g. {"type":"error","payload":{"message":"provider error:
provider returned error"}}. The HTTP status code and upstream cause
were discarded, leaving no way to determine why a provider call
failed from the CLI or its logs.

- zeroruntime.StreamEvent gains StatusCode/Cause fields, set at the
  provider's two response-driven error sites (HTTP error response,
  streamed error payload).
- zeroruntime.CollectedStream carries them through as
  ErrorStatusCode/ErrorCause.
- agent.Run now returns a *zeroruntime.StreamError (implements error)
  instead of a flat errors.New(...), so the detail survives to the
  call site instead of being flattened into a string.
- New sessions.ErrorEventPayload(err) builds the EventError payload,
  adding "statusCode"/"cause" via errors.As when present (unwrapping
  wrapped errors too). All three session-event call sites
  (exec.go, exec_spec.go, tui/model.go) now use it.

Cause is built from the existing provider.redact(...) helper — the
same scrubbing already applied to the classified Error string — so
nothing new is ever persisted unredacted.

Scoped to the OpenAI-compatible provider (matches the issue's repro:
provider=gitlawb-opengateway, model=tencent/hy3). Anthropic and
Gemini have the identical classifiedError/redact pattern at their own
StreamEventError sites and can get the same two-field addition as a
fast follow.

Fixes issue 1 of #674. Issues 2 (turn resume) and 3 (duplicate
terminal detection) are separate, not addressed here per the
maintainer's split.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BLkz6WZYMD3AoiMHQLGvmB
2026-11-09 08:12:20 -08:00
13 changed files with 692 additions and 17 deletions

No files matched your search

+3 -2
View File
@@ -413,7 +413,8 @@ 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)) {
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)
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)
}
// REACTIVE compaction: the streamed error may also be a context limit
// (some providers surface it mid-stream). Compact and retry once.
@@ -542,7 +543,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
if collected.Error != "" {
result.Messages = copyMessages(messages)
return result, errors.New(collected.Error)
return result, &zeroruntime.StreamError{Message: collected.Error, StatusCode: collected.ErrorStatusCode, Cause: collected.ErrorCause}
}
}
+80
View File
@@ -4205,6 +4205,86 @@ 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.
+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, map[string]any{"message": err.Error()})
sessionRecorder.append(sessions.EventError, sessions.ErrorEventPayload(err))
if options.outputFormat == execOutputStreamJSON {
writer.errorEvent("provider_error", err.Error(), false)
writer.runEnd("error", exitProvider)
+135
View File
@@ -0,0 +1,135 @@
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, map[string]any{"message": err.Error()})
sessionRecorder.append(sessions.EventError, sessions.ErrorEventPayload(err))
return writeExecSpecDraftProviderError(&writer, run.options.outputFormat, err.Error())
}
if result.StopReason != agent.StopReasonSpecReviewRequired || draftInfo.SpecID == "" || draftInfo.SpecFilePath == "" {
+21 -8
View File
@@ -298,26 +298,32 @@ 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 = code
statusCode, observedStatus = code, code
}
case float64:
if code, ok := openAIStreamErrorStatusByCode[strconv.Itoa(int(c))]; ok {
statusCode = code
statusCode, observedStatus = code, code
}
case int:
if code, ok := openAIStreamErrorStatusByCode[strconv.Itoa(c)]; ok {
statusCode = code
statusCode, observedStatus = code, code
}
}
}
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.classifiedError(statusCode, chunk.Error.Message),
Type: zeroruntime.StreamEventError,
Error: provider.classifiedError(statusCode, chunk.Error.Message),
StatusCode: observedStatus,
Cause: provider.redact(chunk.Error.Message),
})
state.done = true
return false
@@ -404,12 +410,19 @@ 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)})
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.redact(humanized),
StatusCode: response.StatusCode,
Cause: provider.redact(message),
})
return
}
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.classifiedError(response.StatusCode, message),
Type: zeroruntime.StreamEventError,
Error: provider.classifiedError(response.StatusCode, message),
StatusCode: response.StatusCode,
Cause: provider.redact(message),
})
}
@@ -500,6 +500,35 @@ 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
@@ -526,6 +555,15 @@ 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) {
@@ -584,6 +622,50 @@ 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
@@ -0,0 +1,55 @@
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
@@ -0,0 +1,213 @@
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: map[string]any{"message": err.Error()},
Payload: sessions.ErrorEventPayload(err),
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, usageModelIDs: usageModelIDs, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.activeTurnElapsed(started)}
}
@@ -0,0 +1,64 @@
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)
}
}
+11 -4
View File
@@ -8,10 +8,15 @@ import (
// CollectedStream is the non-streaming summary of provider events.
type CollectedStream struct {
Text string
ToolCalls []ToolCall
Usage Usage
Error string
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
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).
@@ -153,6 +158,8 @@ 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,6 +212,17 @@ 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
@@ -223,6 +234,20 @@ 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