mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f317a79a02 | ||
|
|
90143736a8 | ||
|
|
c8144df06d | ||
|
|
dd763d9921 | ||
|
|
f4df51cc48 | ||
|
|
197026c5e9 | ||
|
|
fa1dbced03 | ||
|
|
88716c4fb1 | ||
|
|
91a25079ce |
No files matched your search
@@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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":`)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user