Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
af50bbb
feat(settings): add SummaryTimeoutSeconds field and SummaryTimeoutVal…
peyton-alt Apr 16, 2026
decc67a
feat(claudecode): add ClaudeError typed error
peyton-alt Apr 16, 2026
a589890
feat(claudecode): classify envelope and stderr errors into ClaudeError
peyton-alt Apr 16, 2026
92449c4
feat: classify Claude CLI errors and surface actionable user messages
peyton-alt Apr 16, 2026
ea21288
fix(claudecode): include ExitCode in ClaudeError.Error() empty-messag…
peyton-alt Apr 16, 2026
071b089
fix(claudecode): preserve envelope on is_error:true with null result
peyton-alt Apr 16, 2026
59ffbee
fix(claudecode): apply auth-phrase heuristic to envelope text when ap…
peyton-alt Apr 16, 2026
4ad8c1b
test(summarize): pin *ClaudeError preservation through Generate and G…
peyton-alt Apr 16, 2026
a07871c
fix(explain): stop advertising an unwired timeout setting to users
peyton-alt Apr 16, 2026
16369da
docs(claudecode, explain): drop rot-prone comments
peyton-alt Apr 16, 2026
ac6c851
fix(explain): fall back to HTTP status or exit code when ClaudeError.…
peyton-alt Apr 16, 2026
88aaa1c
Merge remote-tracking branch 'origin/main' into feat/explain-summary-…
peyton-alt Apr 16, 2026
cba24a3
test: audit and trim redundant tests from review-fix commits
peyton-alt Apr 17, 2026
8532cfa
fix(claudecode): preserve is_error envelope with null result in array…
peyton-alt Apr 17, 2026
7d189d7
fix(explain): stop losing typed errors when ctx is done independently
peyton-alt Apr 17, 2026
6401dca
fix(explain): handle negative ExitCode and all-zero ClaudeError sensibly
peyton-alt Apr 17, 2026
a28fbbe
fix(explain): make timeout message provider-neutral + pin negative as…
peyton-alt Apr 17, 2026
0bc148e
fix(explain,claudecode): 4 round-3 review findings
peyton-alt Apr 17, 2026
432eda7
Merge branch 'main' into feat/explain-summary-observability
gtrrz-victor Apr 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions cmd/entire/cli/agent/claudecode/claude_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package claudecode

import (
"context"
"errors"
"os/exec"
"testing"
)
Expand All @@ -26,6 +27,7 @@ func TestProtectedDirs(t *testing.T) {
}

func TestGenerateText_ArrayResponse(t *testing.T) {
t.Parallel()
ag := &ClaudeCodeAgent{
CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
response := `[{"type":"system","subtype":"init"},{"type":"assistant","message":"Working on it"},{"type":"result","result":"final generated text"}]`
Expand All @@ -42,3 +44,55 @@ func TestGenerateText_ArrayResponse(t *testing.T) {
t.Fatalf("GenerateText() = %q, want %q", result, "final generated text")
}
}

func TestGenerateText_EnvelopeErrorReturnsClaudeError(t *testing.T) {
t.Parallel()
ag := &ClaudeCodeAgent{
CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
response := `{"type":"result","subtype":"success","is_error":true,"api_error_status":401,"result":"Auth required"}`
return exec.CommandContext(ctx, "sh", "-c", "printf '%s' '"+response+"'")
},
}
_, err := ag.GenerateText(context.Background(), "prompt", "")
var ce *ClaudeError
if !errors.As(err, &ce) {
t.Fatalf("err = %v; want *ClaudeError", err)
}
if ce.Kind != ClaudeErrorAuth {
t.Fatalf("Kind = %v; want %v", ce.Kind, ClaudeErrorAuth)
}
}

func TestGenerateText_CLIMissing(t *testing.T) {
t.Parallel()
ag := &ClaudeCodeAgent{
CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
return exec.CommandContext(ctx, "/nonexistent/binary/that/does/not/exist")
},
}
_, err := ag.GenerateText(context.Background(), "prompt", "")
var ce *ClaudeError
if !errors.As(err, &ce) {
t.Fatalf("err = %v; want *ClaudeError", err)
}
if ce.Kind != ClaudeErrorCLIMissing {
t.Fatalf("Kind = %v; want %v", ce.Kind, ClaudeErrorCLIMissing)
}
}

func TestGenerateText_StderrAuthFallback(t *testing.T) {
t.Parallel()
ag := &ClaudeCodeAgent{
CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
return exec.CommandContext(ctx, "sh", "-c", "printf 'Invalid API key' 1>&2; exit 2")
},
}
_, err := ag.GenerateText(context.Background(), "prompt", "")
var ce *ClaudeError
if !errors.As(err, &ce) {
t.Fatalf("err = %v; want *ClaudeError", err)
}
if ce.Kind != ClaudeErrorAuth {
t.Fatalf("Kind = %v; want %v", ce.Kind, ClaudeErrorAuth)
}
}
116 changes: 116 additions & 0 deletions cmd/entire/cli/agent/claudecode/error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package claudecode

import (
"fmt"
"strings"
)

// ClaudeErrorKind classifies a typed Claude CLI error so callers can
// produce actionable user-facing messages without parsing strings.
type ClaudeErrorKind string

const (
// ClaudeErrorAuth indicates an authentication or authorization failure
// (HTTP 401/403 in the CLI envelope, or recognized stderr substring).
ClaudeErrorAuth ClaudeErrorKind = "auth"
// ClaudeErrorRateLimit indicates the request was rejected for rate-limit
// or quota reasons (HTTP 429).
ClaudeErrorRateLimit ClaudeErrorKind = "rate_limit"
// ClaudeErrorConfig indicates a client-side request error other than
// auth or rate-limit (e.g., HTTP 4xx for invalid model or malformed args).
ClaudeErrorConfig ClaudeErrorKind = "config"
// ClaudeErrorCLIMissing indicates the claude binary was not found on PATH.
ClaudeErrorCLIMissing ClaudeErrorKind = "cli_missing"
// ClaudeErrorUnknown is the catch-all for failures we cannot classify.
ClaudeErrorUnknown ClaudeErrorKind = "unknown"
)

// ClaudeError is a typed error returned by ClaudeCodeAgent's text generation
// methods. APIStatus and ExitCode use zero to mean "not applicable."
type ClaudeError struct {
Kind ClaudeErrorKind
Message string // user-safe text extracted from the CLI envelope or stderr
APIStatus int
ExitCode int
Cause error
}

func (e *ClaudeError) Error() string {
if e.Message == "" {
if e.ExitCode != 0 {
return fmt.Sprintf("claude CLI error (kind=%s, exit=%d)", e.Kind, e.ExitCode)
}
return fmt.Sprintf("claude CLI error (kind=%s)", e.Kind)
}
return fmt.Sprintf("claude CLI error (kind=%s): %s", e.Kind, e.Message)
}

func (e *ClaudeError) Unwrap() error { return e.Cause }

const stderrMessageMaxLen = 500

// authStderrPhrases is intentionally small. The primary auth-detection path
// is the structured envelope (classifyEnvelopeError); these phrases are a
// best-effort fallback for crashes that exit non-zero before the envelope
// is produced.
var authStderrPhrases = []string{
"invalid api key",
"not logged in",
}

// classifyEnvelopeError converts a Claude CLI is_error:true envelope into a
// typed ClaudeError. The result text is treated as user-safe (the CLI
// produces it for human consumption).
func classifyEnvelopeError(resultText string, apiStatus *int, exitCode int) *ClaudeError {
e := &ClaudeError{
Message: resultText,
ExitCode: exitCode,
}
if apiStatus != nil {
e.APIStatus = *apiStatus
}
switch {
case e.APIStatus == 401, e.APIStatus == 403:
e.Kind = ClaudeErrorAuth
case e.APIStatus == 429:
e.Kind = ClaudeErrorRateLimit
case e.APIStatus >= 400 && e.APIStatus < 500:
e.Kind = ClaudeErrorConfig
case e.APIStatus == 0 && hasAuthPhrase(resultText):
// No structured status (older CLI builds / internal errors) — fall
// back to the same phrase heuristic the stderr path uses so users
// still get auth-specific guidance.
e.Kind = ClaudeErrorAuth
default:
e.Kind = ClaudeErrorUnknown
}
return e
}

// classifyStderrError is a fallback classifier used when the subprocess exited
// non-zero without producing a parseable envelope. It only attempts to
// recognize a small, stable set of auth phrases; everything else becomes
// ClaudeErrorUnknown with the (truncated) stderr as the message.
func classifyStderrError(stderr string, exitCode int) *ClaudeError {
msg := strings.TrimSpace(stderr)
if len(msg) > stderrMessageMaxLen {
msg = msg[:stderrMessageMaxLen]
}
e := &ClaudeError{Message: msg, ExitCode: exitCode}
if hasAuthPhrase(msg) {
e.Kind = ClaudeErrorAuth
return e
}
e.Kind = ClaudeErrorUnknown
return e
}

func hasAuthPhrase(s string) bool {
lower := strings.ToLower(s)
for _, phrase := range authStderrPhrases {
if strings.Contains(lower, phrase) {
return true
}
}
return false
}
153 changes: 153 additions & 0 deletions cmd/entire/cli/agent/claudecode/error_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package claudecode

import (
"errors"
"fmt"
"strings"
"testing"
)

func TestClaudeError_ErrorIncludesKindAndMessage(t *testing.T) {
t.Parallel()
e := &ClaudeError{Kind: ClaudeErrorAuth, Message: "Invalid API key"}
s := e.Error()
if !strings.Contains(s, "auth") {
t.Errorf("Error() = %q; want to contain kind 'auth'", s)
}
if !strings.Contains(s, "Invalid API key") {
t.Errorf("Error() = %q; want to contain message", s)
}
}

func TestClaudeError_UnwrapReturnsCause(t *testing.T) {
t.Parallel()
cause := errors.New("underlying")
e := &ClaudeError{Kind: ClaudeErrorUnknown, Cause: cause}
if got := errors.Unwrap(e); !errors.Is(got, cause) {
t.Errorf("Unwrap() = %v; want %v", got, cause)
}
}

func TestClaudeError_UnwrapNilCause(t *testing.T) {
t.Parallel()
e := &ClaudeError{Kind: ClaudeErrorAuth}
if got := errors.Unwrap(e); got != nil {
t.Errorf("Unwrap() = %v; want nil", got)
}
}

func TestClaudeError_ErrorEmptyMessageFallback(t *testing.T) {
t.Parallel()
e := &ClaudeError{Kind: ClaudeErrorRateLimit}
s := e.Error()
want := "claude CLI error (kind=rate_limit)"
if s != want {
t.Errorf("Error() = %q; want %q", s, want)
}
}

func TestClaudeError_ErrorEmptyMessageIncludesExitCode(t *testing.T) {
t.Parallel()
e := &ClaudeError{Kind: ClaudeErrorUnknown, ExitCode: 137}
s := e.Error()
want := "claude CLI error (kind=unknown, exit=137)"
if s != want {
t.Errorf("Error() = %q; want %q", s, want)
}
}

func TestClaudeError_ErrorsAsIntegration(t *testing.T) {
t.Parallel()
cause := errors.New("timeout")
wrapped := fmt.Errorf("operation failed: %w", &ClaudeError{
Kind: ClaudeErrorCLIMissing,
Message: "claude not found",
Cause: cause,
})

var ce *ClaudeError
if !errors.As(wrapped, &ce) {
t.Fatal("errors.As did not find *ClaudeError in wrapped chain")
}
if ce.Kind != ClaudeErrorCLIMissing {
t.Errorf("Kind = %q; want %q", ce.Kind, ClaudeErrorCLIMissing)
}
if !errors.Is(ce, cause) {
t.Error("errors.Is did not find cause through ClaudeError.Unwrap()")
}
}

func TestClassifyEnvelopeError(t *testing.T) {
t.Parallel()
intPtr := func(n int) *int { return &n }
tests := []struct {
name string
result string
status *int
exitCode int
wantKind ClaudeErrorKind
wantAPI int
wantExit int
}{
{"Auth401", "Authentication required", intPtr(401), 0, ClaudeErrorAuth, 401, 0},
{"Auth403", "forbidden", intPtr(403), 0, ClaudeErrorAuth, 403, 0},
{"RateLimit429", "Too many requests", intPtr(429), 0, ClaudeErrorRateLimit, 429, 0},
{"Config404", "model not found", intPtr(404), 0, ClaudeErrorConfig, 404, 0},
{"Config400", "invalid_request_error", intPtr(400), 0, ClaudeErrorConfig, 400, 0},
{"UnknownNoStatus", "something blew up", nil, 0, ClaudeErrorUnknown, 0, 0},
{"Unknown5xx", "upstream error", intPtr(503), 0, ClaudeErrorUnknown, 503, 0},
{"ExitCodePropagated", "internal error", intPtr(500), 2, ClaudeErrorUnknown, 500, 2},
{"AuthFromResultWhenStatusNil", "Invalid API key provided", nil, 0, ClaudeErrorAuth, 0, 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := classifyEnvelopeError(tc.result, tc.status, tc.exitCode)
if got.Kind != tc.wantKind {
t.Errorf("Kind = %v; want %v", got.Kind, tc.wantKind)
}
if got.Message != tc.result {
t.Errorf("Message = %q; want %q", got.Message, tc.result)
}
if got.APIStatus != tc.wantAPI {
t.Errorf("APIStatus = %d; want %d", got.APIStatus, tc.wantAPI)
}
if got.ExitCode != tc.wantExit {
t.Errorf("ExitCode = %d; want %d", got.ExitCode, tc.wantExit)
}
})
}
}

func TestClassifyStderrError(t *testing.T) {
t.Parallel()
tests := []struct {
name string
stderr string
exitCode int
wantKind ClaudeErrorKind
wantExit int
maxMsgLen int // 0 means no length check
}{
{"AuthFromInvalidKey", "error: Invalid API key", 1, ClaudeErrorAuth, 1, 0},
{"AuthFromNotLoggedIn", "Please run claude login first; you are not logged in", 1, ClaudeErrorAuth, 1, 0},
{"AuthCaseInsensitive", "INVALID API KEY", 1, ClaudeErrorAuth, 1, 0},
{"UnknownPreservesMessage", "segfault", 134, ClaudeErrorUnknown, 134, 0},
{"TruncatesLongStderr", strings.Repeat("x", 800), 1, ClaudeErrorUnknown, 1, 500},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := classifyStderrError(tc.stderr, tc.exitCode)
if got.Kind != tc.wantKind {
t.Errorf("Kind = %v; want %v", got.Kind, tc.wantKind)
}
if got.ExitCode != tc.wantExit {
t.Errorf("ExitCode = %d; want %d", got.ExitCode, tc.wantExit)
}
if tc.maxMsgLen > 0 && len(got.Message) > tc.maxMsgLen {
t.Errorf("len(Message) = %d; want <= %d", len(got.Message), tc.maxMsgLen)
}
})
}
}
Loading
Loading