diff --git a/apps/backend/internal/agent/runtime/dynamic/engine_test.go b/apps/backend/internal/agent/runtime/dynamic/engine_test.go index 644b8b1d22..99c2377c6c 100644 --- a/apps/backend/internal/agent/runtime/dynamic/engine_test.go +++ b/apps/backend/internal/agent/runtime/dynamic/engine_test.go @@ -93,6 +93,42 @@ func TestEngineAppliesCandidateErrorActions(t *testing.T) { } } +// Contract coverage: Codex's complete usage-limit envelope must reach the +// existing hard-error policy without adding a provider branch to the engine. +func TestEngineRoutesCodexUsageLimitThroughHardPolicy(t *testing.T) { + failure := routingerr.Classify(routingerr.Input{ + Phase: routingerr.PhasePromptSend, + ProviderID: "codex-acp", + Stderr: `{"code":-32603,"message":"Internal error","data":{"codexErrorInfo":"usageLimitExceeded","message":"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at Sep 1st, 2026 3:14 PM."}}`, + }) + if failure.Code != routingerr.CodeQuotaLimited || failure.Class != routingerr.ClassHard { + t.Fatalf("classification = %+v, want hard quota_limited", failure) + } + + profile := Profile{ + ID: "dynamic-1", Version: 1, + Candidates: []Candidate{ + {ID: "codex", Enabled: true, BindingKey: "codex", Policies: routingpolicy.DefaultDocument()}, + {ID: "fallback", Enabled: true, BindingKey: "fallback", Policies: routingpolicy.DefaultDocument()}, + }, + } + engine := NewEngine() + initial, err := engine.Select("session-1", profile, 0, "") + if err != nil { + t.Fatalf("Select: %v", err) + } + + decision, err := engine.ApplyFailure( + "session-1", profile, initial.Generation, initial.ExecutionProfileID, failure, + ) + if err != nil { + t.Fatalf("ApplyFailure: %v", err) + } + if decision.ExecutionProfileID != "fallback" || decision.Reason != "policy_skip" { + t.Fatalf("decision = %#v, want fallback selected by hard policy", decision) + } +} + func TestEngineRetrySameKeepsTheFailedCandidate(t *testing.T) { profile := Profile{ ID: "dynamic-1", Version: 1, diff --git a/apps/backend/internal/agent/runtime/routingerr/classify_test.go b/apps/backend/internal/agent/runtime/routingerr/classify_test.go index 6583ce9673..b6c348458a 100644 --- a/apps/backend/internal/agent/runtime/routingerr/classify_test.go +++ b/apps/backend/internal/agent/runtime/routingerr/classify_test.go @@ -59,7 +59,12 @@ func TestClassify_ProviderRules(t *testing.T) { {"claude auth", "claude-acp", "you are not authenticated", CodeAuthRequired}, {"claude model", "claude-acp", "model claude-foo not found here", CodeModelUnavailable}, {"codex quota", "codex-acp", "insufficient_quota for project", CodeQuotaLimited}, - {"codex usage limit", "codex-acp", `{"codexErrorInfo":"usageLimitExceeded"}`, CodeQuotaLimited}, + { + "codex usage limit", + "codex-acp", + `{"code":-32603,"message":"Internal error","data":{"codexErrorInfo":"usageLimitExceeded","message":"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at Sep 1st, 2026 3:14 PM."}}`, + CodeQuotaLimited, + }, {"codex rate", "codex-acp", "rate_limit_exceeded", CodeRateLimited}, {"codex apikey", "codex-acp", "invalid api key provided", CodeMissingCredentials}, {"opencode auth", "opencode-acp", "Unauthorized request", CodeAuthRequired}, diff --git a/apps/backend/internal/orchestrator/dynamic_evidence_test.go b/apps/backend/internal/orchestrator/dynamic_evidence_test.go index ca2314e22e..8ed2e33563 100644 --- a/apps/backend/internal/orchestrator/dynamic_evidence_test.go +++ b/apps/backend/internal/orchestrator/dynamic_evidence_test.go @@ -6,29 +6,41 @@ import ( "github.com/kandev/kandev/internal/orchestrator/watcher" ) +// Contract coverage: a recognized provider failure still cannot override the +// dynamic route's pre-result and effect-safety gate. func TestDynamicPreResultRequiresExplicitKnownEvidence(t *testing.T) { - if dynamicPreResultSafe(watcher.AgentEventData{DynamicRouteAttempt: true}) { - t.Fatal("unknown dynamic attempt was treated as pre-result safe") - } - if !dynamicPreResultSafe(watcher.AgentEventData{ + usageLimitFailure := watcher.AgentEventData{ + AgentID: "codex-acp", + ErrorMessage: `{"code":-32603,"message":"Internal error","data":{"codexErrorInfo":"usageLimitExceeded","message":"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at Sep 1st, 2026 3:14 PM."}}`, DynamicRouteAttempt: true, EvidenceKnown: true, - }) { - t.Fatal("known no-output/no-effect attempt was not pre-result safe") } - if dynamicPreResultSafe(watcher.AgentEventData{ - DynamicRouteAttempt: true, - EvidenceKnown: true, - OutputObserved: true, - }) { - t.Fatal("output-producing attempt was treated as pre-result safe") + if !dynamicPreResultSafe(usageLimitFailure) { + t.Fatal("pre-result usage-limit failure was not safe to route") } - if dynamicPreResultSafe(watcher.AgentEventData{ - DynamicRouteAttempt: true, - EvidenceKnown: true, - EffectObserved: true, - }) { - t.Fatal("effect-producing attempt was treated as pre-result safe") + + unsafeCases := []struct { + name string + data watcher.AgentEventData + }{ + {name: "unknown evidence", data: watcher.AgentEventData{DynamicRouteAttempt: true}}, + {name: "assistant output", data: func() watcher.AgentEventData { + data := usageLimitFailure + data.OutputObserved = true + return data + }()}, + {name: "tool effect", data: func() watcher.AgentEventData { + data := usageLimitFailure + data.EffectObserved = true + return data + }()}, + } + for _, test := range unsafeCases { + t.Run(test.name, func(t *testing.T) { + if dynamicPreResultSafe(test.data) { + t.Fatalf("case %q was incorrectly treated as pre-result safe", test.name) + } + }) } }