import { describe, expect, it } from 'vitest' import { describePromptError } from '../main/acp/prompt-error' import { buildUnsupportedCodexAcpVersionMessage } from './codex-runtime' import { isClaudeCliCompatibilityError } from './claude-runtime' import { buildActiveModelIncompatibleMessage, buildConfiguredModelUnavailableMessage, CLAUDE_CLI_INCOMPATIBLE_MESSAGE, CLAUDE_EXECUTABLE_MISSING_MESSAGE, CODEX_BRIDGE_UNSUPPORTED_MESSAGE, IMAGE_REPLAY_UNSUPPORTED_MESSAGE, NO_ACTIVE_PROVIDER_MESSAGE, PROVIDER_RESOURCE_NOT_FOUND_PREFIX, RESUME_MODEL_INCOMPATIBLE_MESSAGE, RESUME_RECONNECT_FAILED_MESSAGE, RESUME_TIMED_OUT_MESSAGE, RESUME_UNSUPPORTED_MESSAGE, RESUME_WORKSPACE_MISSING_MESSAGE, VISION_EVIDENCE_INVALID_MESSAGE, VISION_MODEL_NOT_CONFIGURED_MESSAGE, isClaudeApiConnectionFailure, isReportableRunFailure, visionRunFailureMessage } from './run-error-classification' // This module is only the SECONDARY, text-based tier: it recognizes the app's OWN crafted strings so // their report button is hidden even without the structural `providerError` flag. Ordinary provider // errors are suppressed by that flag (set at the ACP layer), NOT here — so at this text tier they read // as reportable, and this suite asserts exactly that boundary. describe('isReportableRunFailure (text tier)', () => { it('only recognizes app-authored Claude version guidance, not arbitrary CLI errors', () => { expect( isClaudeCliCompatibilityError( `Error invoking remote method 'acp:create-session': Error: ${CLAUDE_CLI_INCOMPATIBLE_MESSAGE}` ) ).toBe(true) const stderr = "Internal error: unknown option '--managed-settings'" expect(isClaudeCliCompatibilityError(stderr)).toBe(false) expect(isReportableRunFailure(stderr)).toBe(true) }) it('reports an empty or whitespace-only failure (nothing explains it)', () => { expect(isReportableRunFailure(undefined)).toBe(true) expect(isReportableRunFailure(null)).toBe(true) expect(isReportableRunFailure('')).toBe(true) expect(isReportableRunFailure(' ')).toBe(true) }) it('reports an opaque / internal ACP-layer failure', () => { expect(isReportableRunFailure('Agent session could not be created.')).toBe(true) expect(isReportableRunFailure('Agent session did not return a workspace.')).toBe(true) expect(isReportableRunFailure('Permission response failed')).toBe(true) expect(isReportableRunFailure('Run failed: connection reset')).toBe(true) // An unrecognized resume cause keeps the generic fallback and stays reportable. expect(isReportableRunFailure('Agent session resume failed: something odd')).toBe(true) }) it('does not report an app-crafted, actionable failure (exact-match set)', () => { for (const message of [ RESUME_WORKSPACE_MISSING_MESSAGE, RESUME_TIMED_OUT_MESSAGE, RESUME_UNSUPPORTED_MESSAGE, RESUME_RECONNECT_FAILED_MESSAGE, RESUME_MODEL_INCOMPATIBLE_MESSAGE, CLAUDE_CLI_INCOMPATIBLE_MESSAGE, IMAGE_REPLAY_UNSUPPORTED_MESSAGE ]) { expect(isReportableRunFailure(message)).toBe(false) } }) it('does NOT recognize ordinary provider error TEXT at this tier — the structural flag suppresses those', () => { // These come from the model/provider and are hidden by `providerError`/`errorReportable=false`, set // structurally at the ACP layer. The text tier must NOT try to recognize them (that heuristic was // fragile and swallowed genuine app errors), so here — text only — they read as reportable. for (const providerText of [ 'HTTP 401 Unauthorized', '{"error":{"type":"authentication_error"}}', 'Invalid API key', '429 Too Many Requests', 'insufficient_quota', 'Internal error: Overloaded: service is busy', '503 Service Unavailable' ]) { expect(isReportableRunFailure(providerText)).toBe(true) } }) it('recognizes the Claude Code unreachable-API wrapper without the structural flag', () => { // createSession failRun and persisted pre-flag sessions have no providerError tag. The distinctive // Claude wrapper is recognized so Report stays hidden; nearby "connect" wording is not. const live = 'Internal error: API Error: Unable to connect to API (ConnectionRefused)' const wrapped = `Error invoking remote method 'acp:send-prompt': Error: ${live}` expect(isClaudeApiConnectionFailure(live)).toBe(true) expect(isClaudeApiConnectionFailure(wrapped)).toBe(true) expect(isReportableRunFailure(live)).toBe(false) expect(isReportableRunFailure(wrapped)).toBe(false) expect( isReportableRunFailure('Internal error: unable to connect to API (ConnectionRefused)') ).toBe(true) expect( isReportableRunFailure('Internal error: Unable to connect to workspace (ConnectionRefused)') ).toBe(true) expect(isReportableRunFailure('Run failed: connection reset')).toBe(true) }) it('recognizes the Claude Code mid-response connection interruption without broad text guessing', () => { const live = 'Internal error: API Error: Connection closed mid-response. The response above may be incomplete.' const wrapped = `Error invoking remote method 'acp:send-prompt': Error: ${live}` expect(isReportableRunFailure(live)).toBe(false) expect(isReportableRunFailure(wrapped)).toBe(false) expect(isReportableRunFailure('Connection closed mid-response')).toBe(true) }) it('recognizes the actionable provider connection reminder without the structural flag', () => { expect( isReportableRunFailure( 'Could not connect to the model provider for model "test-model". Check the base URL in Settings → Model and your proxy, VPN, or firewall, then retry. Connection detail: ConnectionRefused.' ) ).toBe(false) }) it('does not swallow an ordinary app error that merely mentions a provider word', () => { // The text tier only matches the app's own exact strings, so an internal failure that happens to // contain a provider-ish word or number is never mistaken for an expected failure. expect(isReportableRunFailure('EACCES: permission denied opening workspace')).toBe(true) expect(isReportableRunFailure('Failed to parse rate limit configuration')).toBe(true) expect(isReportableRunFailure('Record 503 could not be decoded')).toBe(true) expect(isReportableRunFailure('Billing planner initialization failed')).toBe(true) expect(isReportableRunFailure('Scheduler crashed while overloaded with tasks')).toBe(true) }) it('recognizes a request-size overflow (its own recovery path) as expected', () => { expect(isReportableRunFailure('Request too large (max 32MB)')).toBe(false) expect(isReportableRunFailure('maximum context length exceeded')).toBe(false) }) it('recognizes app-owned Vision failures through the Electron invoke wrapper', () => { const wrapped = `Error invoking remote method 'acp:send-prompt': Error: ${VISION_EVIDENCE_INVALID_MESSAGE}` expect(visionRunFailureMessage(wrapped)).toBe(VISION_EVIDENCE_INVALID_MESSAGE) expect(isReportableRunFailure(wrapped)).toBe(false) }) it('maps the persisted legacy Vision configuration error to the current recovery message', () => { const legacy = 'Configure a Vision model in Settings > Model before sending images to this model.' const wrapped = `Error invoking remote method 'acp:send-prompt': Error: ${legacy}` expect(visionRunFailureMessage(legacy)).toBe(VISION_MODEL_NOT_CONFIGURED_MESSAGE) expect(visionRunFailureMessage(wrapped)).toBe(VISION_MODEL_NOT_CONFIGURED_MESSAGE) expect(isReportableRunFailure(legacy)).toBe(false) }) it('recognizes the provider resource-not-found message built by describePromptError', () => { // Drift guard: feed describePromptError's REAL output through the classifier so the shared prefix // constant and the produced message can never diverge silently. This one IS text-recognized because // it is the app's OWN reworded string (a model-config problem the user fixes in Settings). const error = Object.assign( new Error( 'Internal error: Not Found: {"error":{"message":"model xyz not found","type":"resource_not_found"}}' ), { code: -32603, data: { errorName: 'APIError' }, name: 'RequestError' } ) const produced = describePromptError(error, { model: 'xyz' }) expect(produced.startsWith(PROVIDER_RESOURCE_NOT_FOUND_PREFIX)).toBe(true) expect(isReportableRunFailure(produced)).toBe(false) }) it('does not report app-authored agent-setup guidance (createSession spawn failures)', () => { // These are thrown by settings/service.ts:resolveActiveAgentBackend when a conversation fails to // START — wrong-config the user fixes in Settings → Model, not app bugs, so no report button. expect(isReportableRunFailure(NO_ACTIVE_PROVIDER_MESSAGE)).toBe(false) expect(isReportableRunFailure(CODEX_BRIDGE_UNSUPPORTED_MESSAGE)).toBe(false) expect(isReportableRunFailure(CLAUDE_EXECUTABLE_MISSING_MESSAGE)).toBe(false) expect( isReportableRunFailure(buildConfiguredModelUnavailableMessage('old-model', 'Provider')) ).toBe(false) }) it('does not report an unsupported Codex ACP version', () => { const message = buildUnsupportedCodexAcpVersionMessage('1.1.4') const wrapped = `Error invoking remote method 'acp:create-session': Error: ${message}` const resumeWrapped = `Agent session resume failed: ${message}` expect(isReportableRunFailure(message)).toBe(false) expect(isReportableRunFailure(wrapped)).toBe(false) expect(isReportableRunFailure(resumeWrapped)).toBe(false) }) it('recognizes the framework-specific model-incompat message built by service.ts (prefix)', () => { // Drift guard: the classifier matches ACTIVE_MODEL_INCOMPATIBLE_PREFIX, and service.ts builds its // throw from the SAME builder, so the framework name can vary and both stay in sync. This is the // createSession wording ("…compatible with Codex."), distinct from the reworded resume constant. const codex = buildActiveModelIncompatibleMessage('Codex') const claude = buildActiveModelIncompatibleMessage('Claude Code') expect(codex).not.toBe(RESUME_MODEL_INCOMPATIBLE_MESSAGE) expect(isReportableRunFailure(codex)).toBe(false) expect(isReportableRunFailure(claude)).toBe(false) }) })