Files
agent-radar 2ed3415fb7
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
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

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}
}