Skip to content

fix(driver): result subtype "success" with is_error:true is reported as phase success — unrecognized model yields a zero-work "successful" phase #973

Description

@admarble

Finding

The claude-code driver classifies a phase as successful on resultMessage.subtype === "success" (src/lib/workflow/drivers/claude-code.ts:230) — but the SDK emits subtype: "success" with is_error: true when the turn ends on an API error. The SDK's own docs on SDKResultMessage state it explicitly: "subtype 'success' carries the final assistant text in result — or, with is_error true, the error text when the turn ended on an API error" (sdk.d.ts:4608). The driver never reads is_error, so an API-errored turn is reported as a successful phase with zero work done.

Reproduction (deterministic)

Probed 2026-08-28 on Claude Code 2.1.251:

$ claude -p "Reply with exactly: OK" --model nonexistent-model-xyz --output-format json
[claude-code:unrecognized_model] {"model":"nonexistent-model-xyz","query_source":"sdk"}
{... "subtype":"success", "is_error":true, "api_error_status":404,
 "terminal_reason":"api_error", "num_turns":1, "total_cost_usd":0,
 "modelUsage":{},
 "result":"There's an issue with the selected model (nonexistent-model-xyz). It may not exist or you may not have access to it. ..."}
$ echo $?
0

Three properties worth noting: no fallback model is substituted (good — nothing runs on the wrong model), the process exits 0, and the only failure signals are is_error/terminal_reason/api_error_status inside the result message the driver already holds.

Why it matters now

#914 made per-phase model strings reachable from settings.run.phases.<phase>.model and --models — free strings passed verbatim to the SDK (claude-code.ts:144, deliberately, per the AC-3 key-presence test). A typo'd or roster-stale model name therefore produces a phase that reports success, writes no commits, and posts no comment — the failure surfaces only downstream as confusing state (e.g. an "exec succeeded" with an empty diff entering QA). The same silent-success path presumably covers any other API-error termination the SDK folds into a success-subtype result. This is also a prerequisite for the model-roles indirection work: loud dispatch-time failure on a bad model string is what makes a thin role map safe without its own validation layer.

Suggested fix

In the subtype === "success" branch, treat is_error: true as a phase failure: success: false, error from the result text (which carries the API error message), preserving sessionId/resume handling. terminal_reason/api_error_status belong in the structured error context (#732 pattern).

Acceptance Criteria

  • AC-1: a result message with subtype: "success" and is_error: true yields success: false with the result text as the error, verified by a driver unit test using the reproduction shape above (api_error_status: 404, empty modelUsage)
  • AC-2: a genuine success (is_error: false or absent) is unaffected, verified by the existing success-path tests staying green
  • AC-3: the structured error context for this path carries terminal_reason and api_error_status when present, verified by a unit test asserting both fields

Environment

Claude Code 2.1.251 / @anthropic-ai/claude-agent-sdk (repo-pinned), macOS. Found during the model-string contract spike for the model-roles design (vid-gen dogfood follow-up, 2026-08-28).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingneeds-improvementAC met but improvements recommendedplannedImplementation plan approved

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions