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