diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 0d06d82f4..417231f32 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -404,7 +404,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. @@ -500,7 +501,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} } } diff --git a/internal/agent/loop_test.go b/internal/agent/loop_test.go index 7233f2f08..29e5975de 100644 --- a/internal/agent/loop_test.go +++ b/internal/agent/loop_test.go @@ -4193,6 +4193,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. diff --git a/internal/cli/exec.go b/internal/cli/exec.go index d47964c11..d18127ff4 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -778,7 +778,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) diff --git a/internal/cli/exec_spec.go b/internal/cli/exec_spec.go index fc22eed35..cd7b873ce 100644 --- a/internal/cli/exec_spec.go +++ b/internal/cli/exec_spec.go @@ -198,7 +198,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 == "" { diff --git a/internal/providers/openai/provider.go b/internal/providers/openai/provider.go index f6752e546..c5937f542 100644 --- a/internal/providers/openai/provider.go +++ b/internal/providers/openai/provider.go @@ -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), }) } diff --git a/internal/providers/openai/provider_test.go b/internal/providers/openai/provider_test.go index a59e1a634..dd3c897b0 100644 --- a/internal/providers/openai/provider_test.go +++ b/internal/providers/openai/provider_test.go @@ -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":`) diff --git a/internal/sessions/error_payload.go b/internal/sessions/error_payload.go new file mode 100644 index 000000000..efb61898e --- /dev/null +++ b/internal/sessions/error_payload.go @@ -0,0 +1,46 @@ +package sessions + +import ( + "errors" + + "github.com/Gitlawb/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. Cause is cut to +// maxErrorCauseBytes (on a rune boundary) with a truncation marker appended. +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 { + if len(cause) <= maxErrorCauseBytes { + return cause + } + return truncateUTF8(cause, maxErrorCauseBytes) + errorCauseTruncationMarker +} diff --git a/internal/sessions/error_payload_test.go b/internal/sessions/error_payload_test.go new file mode 100644 index 000000000..4f0b106c9 --- /dev/null +++ b/internal/sessions/error_payload_test.go @@ -0,0 +1,159 @@ +package sessions + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + "unicode/utf8" + + "github.com/Gitlawb/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) { + // "é" is two bytes; an odd cap boundary must not split it. + cause := strings.Repeat("é", maxErrorCauseBytes) + payload := ErrorEventPayload(&zeroruntime.StreamError{Message: "m", StatusCode: 500, Cause: 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:]) + } + body := strings.TrimSuffix(got, errorCauseTruncationMarker) + if len(body) > maxErrorCauseBytes { + t.Fatalf("bounded cause body is %d bytes, want <= %d", len(body), 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))) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 3c69814df..dfaed5b33 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -5999,7 +5999,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)} } diff --git a/internal/zeroruntime/helpers.go b/internal/zeroruntime/helpers.go index 3169a5926..d6e36dac5 100644 --- a/internal/zeroruntime/helpers.go +++ b/internal/zeroruntime/helpers.go @@ -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() diff --git a/internal/zeroruntime/types.go b/internal/zeroruntime/types.go index 3f0924569..e3d37ac36 100644 --- a/internal/zeroruntime/types.go +++ b/internal/zeroruntime/types.go @@ -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