204 lines · 10.2 KB · TypeScript
RawDownload
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)  })})