Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
433 lines
20 KiB
Go
433 lines
20 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
|
|
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
|
"github.com/tutti-os/tutti/packages/agent/store-sqlite/canonical"
|
|
)
|
|
|
|
func TestModelPlanAuthenticationFailureDoesNotOfferProviderLogin(t *testing.T) {
|
|
session := Session{Provider: "claude-code", AgentSessionID: "session-1", ProviderSessionID: "provider-1"}
|
|
event := newTurnActivityEvent(session, EventTurnFailed, "turn-1", SessionStatusFailed, "", "", map[string]any{
|
|
"code": "auth_required", "authImpact": "required", "origin": "provider",
|
|
"error": "relay rejected request: HTTP 401",
|
|
})
|
|
if event.Type != activityshared.EventTurnFailed {
|
|
t.Fatalf("event type = %q", event.Type)
|
|
}
|
|
projection, ok := projectVisibleFailure(canonical.EventSource{
|
|
Provider: "claude-code", ProviderGlobalAuthEligible: false,
|
|
}, event)
|
|
if !ok || projection.payload["code"] != "provider_error" || projection.content != "relay rejected request: HTTP 401" {
|
|
t.Fatalf("projection = %#v ok=%v", projection, ok)
|
|
}
|
|
|
|
nativeProjection, ok := projectVisibleFailure(canonical.EventSource{
|
|
Provider: "claude-code", ProviderGlobalAuthEligible: true,
|
|
}, event)
|
|
if !ok || nativeProjection.payload["code"] != "auth_required" {
|
|
t.Fatalf("provider-native projection = %#v ok=%v", nativeProjection, ok)
|
|
}
|
|
}
|
|
|
|
type testRuntimeTransportFailure struct {
|
|
code string
|
|
}
|
|
|
|
func (testRuntimeTransportFailure) Error() string { return "runtime transport failed" }
|
|
|
|
func (e testRuntimeTransportFailure) RuntimeTransportFailureCode() string { return e.code }
|
|
|
|
func TestACPFailureMetadataPreservesTrustedRuntimeTransportCode(t *testing.T) {
|
|
metadata := acpFailureMetadata(testRuntimeTransportFailure{code: "provider_process_exit_stream_eof"})
|
|
if metadata["errorCode"] != "provider_process_exit_stream_eof" {
|
|
t.Fatalf("errorCode = %#v, want provider_process_exit_stream_eof", metadata["errorCode"])
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodePreservesStructuredTransportReasons(t *testing.T) {
|
|
for detail, want := range map[string]string{
|
|
"egress request failed; error_code=egress_dns_failed": "egress_dns_failed",
|
|
"provider process exit was not confirmed; error_code=provider_process_exit_stream_eof": "provider_process_exit_stream_eof",
|
|
} {
|
|
if got := visibleFailureCode(detail); got != want {
|
|
t.Fatalf("visibleFailureCode(%q) = %q, want %q", detail, got, want)
|
|
}
|
|
if !visibleFailureRetryable(want, detail) {
|
|
t.Fatalf("visibleFailureRetryable(%q) = false, want true", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeClassifiesDeadlineExceededAsRequestTimedOut(t *testing.T) {
|
|
if got := visibleFailureCode("context deadline exceeded"); got != "request_timed_out" {
|
|
t.Fatalf("visibleFailureCode() = %q, want request_timed_out", got)
|
|
}
|
|
}
|
|
|
|
func TestIsAuthenticationRequiredDoesNotInferFromProviderText(t *testing.T) {
|
|
err := errors.New("Gemini API key is missing or not configured")
|
|
if IsAuthenticationRequired(err) {
|
|
t.Fatalf("IsAuthenticationRequired(%q) = true, want false without typed evidence", err)
|
|
}
|
|
}
|
|
|
|
func TestIsAuthenticationRequiredDoesNotHideAccountFailures(t *testing.T) {
|
|
for _, detail := range []string{
|
|
`Kimi Code models endpoint rejected OAuth credentials: error, status code: 402, message: We're unable to verify your membership benefits at this time. Please ensure your membership is active.`,
|
|
`Kimi Code models endpoint rejected the API key: error, status code: 401, message: Your current subscription does not have access to kimi-for-coding-highspeed.`,
|
|
`Kimi Code request rejected OAuth credentials: error, status code: 403, message: You've reached your usage limit for this billing cycle.`,
|
|
`Kimi Code request rejected OAuth credentials: 402 Payment Required`,
|
|
} {
|
|
err := errors.New(detail)
|
|
if IsAuthenticationRequired(err) {
|
|
t.Fatalf("IsAuthenticationRequired(%q) = true, want false", detail)
|
|
}
|
|
if ClassifyAccountFailure(err) == "" {
|
|
t.Fatalf("ClassifyAccountFailure(%q) = empty, want account-state code", detail)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeClassifiesProviderConcurrencyLimit(t *testing.T) {
|
|
detail := `stream disconnected before completion: Concurrency limit exceeded for user, please retry later`
|
|
if got := visibleFailureCode(detail); got != "provider_concurrency_limit" {
|
|
t.Fatalf("visibleFailureCode() = %q, want provider_concurrency_limit", got)
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeClassifiesConfigTimeout(t *testing.T) {
|
|
detail := `agent session ACP effort configuration failed: acp session/set_config_option timed out after 30s`
|
|
if got := visibleFailureCode(detail); got != "provider_config_timeout" {
|
|
t.Fatalf("visibleFailureCode() = %q, want provider_config_timeout", got)
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureContentDescribesStartupConfigTimeout(t *testing.T) {
|
|
got := visibleFailureContent(ProviderCodex, "start", "provider_config_timeout")
|
|
want := "Codex could not apply session settings before startup timed out. Try again in a moment."
|
|
if got != want {
|
|
t.Fatalf("visibleFailureContent() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeClassifiesStreamDisconnected(t *testing.T) {
|
|
detail := `stream disconnected before completion: Transport error: network error: error decoding response body`
|
|
if got := visibleFailureCode(detail); got != "provider_stream_disconnected" {
|
|
t.Fatalf("visibleFailureCode() = %q, want provider_stream_disconnected", got)
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeClassifiesProviderModelNotFound(t *testing.T) {
|
|
detail := `stream disconnected before completion: modelCode:不存在[2026082021072905b55e622c4a42ba]`
|
|
if got := visibleFailureCode(detail); got != "provider_model_not_found" {
|
|
t.Fatalf("visibleFailureCode() = %q, want provider_model_not_found", got)
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeClassifiesConfiguredRouteNotFound(t *testing.T) {
|
|
detail := `unexpected status 404 Not Found: 404 page not found, url: http://127.0.0.1:7794/_tsh/configured-routes/route-1/v1/responses`
|
|
if got := visibleFailureCode(detail); got != "configured_route_not_found" {
|
|
t.Fatalf("visibleFailureCode() = %q, want configured_route_not_found", got)
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeClassifiesClosedProviderStream(t *testing.T) {
|
|
detail := `provider process stream is closed; error_code=provider_process_stream_closed; stream_phase=send; cause=io: read/write on closed pipe`
|
|
if got := visibleFailureCode(detail); got != "provider_process_stream_closed" {
|
|
t.Fatalf("visibleFailureCode() = %q, want provider_process_stream_closed", got)
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeClassifiesProviderEmptyResponse(t *testing.T) {
|
|
detail := "provider_empty_response: ACP agent ended the turn without assistant output or tool activity"
|
|
if got := visibleFailureCode(detail); got != "provider_empty_response" {
|
|
t.Fatalf("visibleFailureCode() = %q, want provider_empty_response", got)
|
|
}
|
|
got := visibleFailureContent("acp:kimi-code", "turn", "provider_empty_response")
|
|
want := "Agent returned no response. Check the provider settings or try again."
|
|
if got != want {
|
|
t.Fatalf("visibleFailureContent() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeClassifiesProviderPlanAndBalanceFailures(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
detail string
|
|
want string
|
|
}{
|
|
{"Membership expired, please renew your plan", FailureCodeSubscriptionRequired},
|
|
{"Your account has insufficient balance", FailureCodeInsufficientCredits},
|
|
{"Account balance is insufficient", FailureCodeInsufficientCredits},
|
|
} {
|
|
if got := visibleFailureCode(tt.detail); got != tt.want {
|
|
t.Fatalf("visibleFailureCode(%q) = %q, want %q", tt.detail, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeDoesNotTreatPatchContextLoginTextAsAuth(t *testing.T) {
|
|
// Test-function text in the stderr tail ("...Login...") must never read as
|
|
// codex auth. The process exited cleanly (code 0) with that apply_patch error
|
|
// only as incidental tail output, so it classifies as an interrupted session —
|
|
// the one thing it must NOT be is auth_required.
|
|
detail := `acp process exited with code 0: process exited: ERROR codex_core::tools::router: error=apply_patch verification failed: Failed to find expected lines in /Users/wwcome/work/tutti-os/tutti/services/tuttid/service/agentstatus/service_test.go:
|
|
func TestServiceLoginRunsProviderLoginCommand(t *testing.T) {
|
|
service := testService(func(name string) (string, error) {`
|
|
if got := visibleFailureCode(detail); got == "auth_required" {
|
|
t.Fatalf("visibleFailureCode() = auth_required, but embedded test text must not read as codex auth")
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeDoesNotTreatMcpServerAuthAsCodexAuth(t *testing.T) {
|
|
// A Notion/Figma MCP server's expired OAuth token crashes codex's MCP client
|
|
// (rmcp) and bubbles up here. It mentions "access token"/"AuthRequired", which
|
|
// trips the auth pattern, but codex itself is still signed in — so this must
|
|
// NOT surface as "Codex needs authentication". The exit is code 0 (a clean
|
|
// shutdown), so it reads as an interrupted session, never auth_required.
|
|
detail := `acp process exited with code 0: process exited: ERROR rmcp::transport::worker: ` +
|
|
`worker quit with fatal: Transport channel closed, when AuthRequired(AuthRequiredError { ` +
|
|
`www_authenticate_header: "Bearer realm=\"OAuth\", ` +
|
|
`resource_metadata=\"https://mcp.notion.com/.well-known/oauth-protected-resource/mcp\", ` +
|
|
`error=\"invalid_token\", error_description=\"Missing or invalid access token\"" })`
|
|
if got := visibleFailureCode(detail); got == "auth_required" {
|
|
t.Fatalf("visibleFailureCode() = auth_required, but MCP server auth must not read as codex auth")
|
|
}
|
|
if got := visibleFailureCode(detail); got != "session_interrupted" {
|
|
t.Fatalf("visibleFailureCode() = %q, want session_interrupted (clean exit-0 MCP failure)", got)
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeClassifiesMcpServerAuthWithoutProcessExit(t *testing.T) {
|
|
detail := `codex MCP server figma startup failed (reauthenticationRequired): ` +
|
|
`rmcp::transport::worker AuthRequired(AuthRequiredError { ` +
|
|
`resource_metadata="https://mcp.figma.com/.well-known/oauth-protected-resource" })`
|
|
if got := visibleFailureCode(detail); got != "mcp_server_auth_required" {
|
|
t.Fatalf("visibleFailureCode() = %q, want mcp_server_auth_required", got)
|
|
}
|
|
if !visibleFailureRetryable("mcp_server_auth_required", detail) {
|
|
t.Fatal("mcp_server_auth_required should be retryable")
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeClassifiesCleanExitAsInterrupted(t *testing.T) {
|
|
// A clean exit (code 0) reaching here means the app-server was stopped
|
|
// externally mid-turn (host quit, or an agent killed its own host) — the
|
|
// session was interrupted, not "Codex request failed".
|
|
for _, detail := range []string{
|
|
"acp process exited with code 0: ",
|
|
"acp process exited with code 0: shutting down",
|
|
} {
|
|
if got := visibleFailureCode(detail); got != "session_interrupted" {
|
|
t.Fatalf("visibleFailureCode(%q) = %q, want session_interrupted", detail, got)
|
|
}
|
|
}
|
|
if !visibleFailureRetryable("session_interrupted", "acp process exited with code 0: ") {
|
|
t.Fatal("session_interrupted should be retryable")
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeClassifiesSignalKillAsInterrupted(t *testing.T) {
|
|
// Signal-terminations (128+N: 137 SIGKILL, 143 SIGTERM, 130 SIGINT) are the
|
|
// process being killed externally, not codex erroring out.
|
|
for _, detail := range []string{
|
|
"acp process exited with code 137: ",
|
|
"acp process exited with code 143: ",
|
|
"acp process exited with code 130: ",
|
|
} {
|
|
if got := visibleFailureCode(detail); got != "session_interrupted" {
|
|
t.Fatalf("visibleFailureCode(%q) = %q, want session_interrupted", detail, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeClassifiesGoSignalExitAsInterrupted(t *testing.T) {
|
|
// Regression: the claude-code sidecar's process wrapper
|
|
// (localProcessConnection in process_transport.go) reports a
|
|
// signal-terminated exit via Go's exec.ExitError.ExitCode(), which
|
|
// returns -1 — not the 128+N convention codex's own app-server uses for
|
|
// the same event. Seen in the field: tuttid's graceful-shutdown path
|
|
// (CloseAllLiveSessions) sends SIGTERM to a live claude-code sidecar
|
|
// mid-turn, and the resulting "exited with code -1" must read as a calm,
|
|
// retryable interruption rather than "Claude Code request failed".
|
|
for _, detail := range []string{
|
|
"claude sdk sidecar exited with code -1",
|
|
"claude sdk sidecar exited with code -1: ",
|
|
} {
|
|
if got := visibleFailureCode(detail); got != "session_interrupted" {
|
|
t.Fatalf("visibleFailureCode(%q) = %q, want session_interrupted", detail, got)
|
|
}
|
|
}
|
|
if !visibleFailureRetryable("session_interrupted", "claude sdk sidecar exited with code -1") {
|
|
t.Fatal("session_interrupted should be retryable")
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeClassifiesUsageLimitAsQuota(t *testing.T) {
|
|
// The most common real codex failure in the field is the ChatGPT usage cap,
|
|
// delivered as plain text (no structured codexErrorInfo). It must read as a
|
|
// quota/rate-limit, not a generic "request failed".
|
|
detail := "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), " +
|
|
"visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again later."
|
|
if got := visibleFailureCode(detail); got != "quota_or_rate_limit" {
|
|
t.Fatalf("visibleFailureCode() = %q, want quota_or_rate_limit", got)
|
|
}
|
|
if got := visibleFailureCode("API Error: 403 Key limit exceeded (total limit)"); got != "quota_or_rate_limit" {
|
|
t.Fatalf("visibleFailureCode() = %q, want quota_or_rate_limit", got)
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeClassifiesInsufficientCredits(t *testing.T) {
|
|
for _, detail := range []string{
|
|
`unexpected status 402 Payment Required: pre-deduct credits failed, url: https://llm-api.tutti.sh/v1/responses`,
|
|
`unexpected status 402 Payment Required: {"error":{"message":"insufficient credits","type":"billing_error","code":"insufficient_credits"}}`,
|
|
`Kimi API request failed: 402 Payment Required: OAuth credentials rejected`,
|
|
`Provider request failed because the account balance is insufficient`,
|
|
`You've hit your usage limit. Insufficient credits. View Tutti plans at https://tutti.sh/profile/plan, or try again later.`,
|
|
} {
|
|
if got := visibleFailureCode(detail); got != "insufficient_credits" {
|
|
t.Fatalf("visibleFailureCode(%q) = %q, want insufficient_credits", detail, got)
|
|
}
|
|
}
|
|
if visibleFailureRetryable("insufficient_credits", "402 Payment Required") {
|
|
t.Fatal("insufficient_credits should not be retryable")
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeClassifiesSubscriptionAndQuotaBeforeAuthWrapper(t *testing.T) {
|
|
tests := map[string]string{
|
|
`Kimi Code models endpoint rejected OAuth credentials: error, status code: 402, message: We're unable to verify your membership benefits at this time. Please ensure your membership is active.`: "subscription_required",
|
|
`Kimi Code models endpoint rejected the API key: error, status code: 401, message: Your current subscription does not have access to kimi-for-coding-highspeed.`: "subscription_required",
|
|
`Kimi Code request rejected OAuth credentials: error, status code: 403, message: You've reached your usage limit for this billing cycle.`: "quota_or_rate_limit",
|
|
}
|
|
for detail, want := range tests {
|
|
if got := visibleFailureCode(detail); got != want {
|
|
t.Fatalf("visibleFailureCode(%q) = %q, want %q", detail, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureContentDescribesProviderInsufficientCredits(t *testing.T) {
|
|
got := visibleFailureContent(ProviderTuttiAgent, "turn", "insufficient_credits")
|
|
want := "Tutti Agent could not continue because the account has insufficient credits or balance."
|
|
if got != want {
|
|
t.Fatalf("visibleFailureContent() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeDoesNotMisclassifyStructuredProviderFailuresAsAuth(t *testing.T) {
|
|
tests := map[string]string{
|
|
`HTTP 403: {"error":{"code":"model_not_allowed","message":"authorization denied for model"}}`: "model_not_allowed",
|
|
`MCP client for codex_apps failed: HTTP 451: {"message":"no_biscuit_no_service"} authentication transport failed`: "plugin_unavailable",
|
|
`HTTP 451: {"message":"no_biscuit_no_service"} authentication transport failed`: "provider_error",
|
|
}
|
|
for detail, want := range tests {
|
|
if got := visibleFailureCode(detail); got != want {
|
|
t.Fatalf("visibleFailureCode(%q) = %q, want %q", detail, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureContentDescribesInterruptedSession(t *testing.T) {
|
|
got := visibleFailureContent(ProviderCodex, "turn", "session_interrupted")
|
|
want := "Codex stopped unexpectedly before it finished responding. Try again."
|
|
if got != want {
|
|
t.Fatalf("visibleFailureContent() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeStillClassifiesCodexOwnAuth(t *testing.T) {
|
|
// Codex's own login failure must still be auth_required (guard against the MCP
|
|
// exclusion being too broad).
|
|
for _, detail := range []string{
|
|
"acp process exited with code 1: process exited: not logged in. Please run /login.",
|
|
"401 Unauthorized: invalid authentication credentials",
|
|
} {
|
|
if got := visibleFailureCode(detail); got != "auth_required" {
|
|
t.Fatalf("visibleFailureCode(%q) = %q, want auth_required", detail, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeClassifiesMissingBinaryAsCliNotFound(t *testing.T) {
|
|
// A run that can't find the CLI binary surfaces as an exec error; this is the
|
|
// real "not installed / not on PATH" failure (the aspirational CODEX_CLI_MISSING
|
|
// never reaches the run pipeline), so it must be distinct from a genuine exit.
|
|
for _, detail := range []string{
|
|
`fork/exec /Users/asdf/.local/bin/codex: no such file or directory`,
|
|
`spawn codex ENOENT`,
|
|
`codex: command not found`,
|
|
} {
|
|
if got := visibleFailureCode(detail); got != "cli_not_found" {
|
|
t.Fatalf("visibleFailureCode(%q) = %q, want cli_not_found", detail, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeDoesNotClassifyMetadataReadAsMissingCLI(t *testing.T) {
|
|
detail := `read claude system prompt: open /run/tsh/managed-agent/session/claude-system-prompt.md: no such file or directory`
|
|
if got := visibleFailureCode(detail); got != "provider_error" {
|
|
t.Fatalf("visibleFailureCode(%q) = %q, want provider_error", detail, got)
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeClassifiesGenuineExitAsProcessExited(t *testing.T) {
|
|
// A non-zero exit that is NOT a missing binary stays process_exited.
|
|
if got := visibleFailureCode("codex process exited with code 1"); got != "process_exited" {
|
|
t.Fatalf("visibleFailureCode() = %q, want process_exited", got)
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeClassifiesExplicitLoginFailureAsAuth(t *testing.T) {
|
|
if got := visibleFailureCode("Please login to continue."); got != "auth_required" {
|
|
t.Fatalf("visibleFailureCode() = %q, want auth_required", got)
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeClassifiesVersionUnsupported(t *testing.T) {
|
|
for _, detail := range []string{
|
|
`codex-acp requires a newer version of codex`,
|
|
`installed codex version is too old`,
|
|
} {
|
|
if got := visibleFailureCode(detail); got != "cli_version_unsupported" {
|
|
t.Fatalf("visibleFailureCode(%q) = %q, want cli_version_unsupported", detail, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeClassifiesNetworkError(t *testing.T) {
|
|
for _, detail := range []string{
|
|
`request failed: getaddrinfo ENOTFOUND api.anthropic.com`,
|
|
`connect ECONNREFUSED 127.0.0.1:443`,
|
|
`Error: socket hang up`,
|
|
} {
|
|
if got := visibleFailureCode(detail); got != "network_error" {
|
|
t.Fatalf("visibleFailureCode(%q) = %q, want network_error", detail, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureCodeStreamDisconnectBeatsNetworkMarker(t *testing.T) {
|
|
// A stream-disconnect detail can also mention "network error"; the more
|
|
// specific stream classification must still win.
|
|
detail := `stream disconnected before completion: Transport error: network error: error decoding response body`
|
|
if got := visibleFailureCode(detail); got != "provider_stream_disconnected" {
|
|
t.Fatalf("visibleFailureCode() = %q, want provider_stream_disconnected", got)
|
|
}
|
|
}
|
|
|
|
func TestVisibleFailureRetryableForNetworkButNotMissingCli(t *testing.T) {
|
|
if !visibleFailureRetryable("network_error", "ECONNRESET") {
|
|
t.Fatal("network_error should be retryable")
|
|
}
|
|
if visibleFailureRetryable("cli_not_found", "ENOENT") {
|
|
t.Fatal("cli_not_found should not be retryable")
|
|
}
|
|
}
|
|
|
|
func reportTestSource() canonical.EventSource {
|
|
return canonical.EventSource{Provider: ProviderClaudeCode}
|
|
}
|