feat(cli): fail commands on a declared terminal failure state (output.outcome)
Why
Some APIs report that a request failed inside a successful HTTP response: a 200 whose body carries a terminal status such as failed or cancelled, often with an errors[] array describing why. Interactions, jobs and agent runs commonly work this way, in both the discrete JSON response and the completion event of an SSE stream.
A generated CLI only knows the HTTP outcome, so it exits 0 and prints the payload. Scripts and agents that rely on the exit code treat the failure as success. The async recipe already lets a manifest classify poll states (states: {failed: failure, ...}), and the runtime exits 1 with CLI_ASYNC_FAILED on a failure state, but there was no way to declare the same table for a command that returns the terminal state directly or streams it.
What changed
x-speakeasy-cli-commands gains output.outcome on commands and on operation entries:
output:
outcome:
statePointer: $.status # state in the JSON response
streamStatePointer: $.data.interaction.status # state inside streamed events
states:
queued: pending
in_progress: pending
completed: success
incomplete: success
failed: failure
cancelled: failure
errorPointer: $.errors # optional failure detail (object or array)
streamErrorPointer: $.data.interaction.errors
errorMessageField: message # default
- Extension decoder and linker: at least one state pointer is required;
statesuses the async classifications and must name at least onefailure.statePointermust select a closed string enum in everyapplication/json2xx response and the table must classify exactly that enum, so no API state can reach the runtime unclassified.streamStatePointeris checked like a stream projection (a string in at least one event shape); when that leaf is a closed enum the table is held to it too (exact match for a stream-only table, every member classified whenstatePointeralready pinned the table), a leaf without an enum warns that the table cannot be verified, and an open enum is rejected. Error pointers must address an object or an array wherever they resolve;errorPointermay exist in only some response variants (a failed union arm) and is rejected only when it resolves nowhere.asyncandoutput.outcomeon the same command is an error, sinceasync.statesalready owns the terminal classification. A single-route intent that declares no table of its own inherits its operation entry's, so a state classified for the operation command also fails the intent; async intents never inherit. - Runtime: the table travels on a
speakeasy_outcomeannotation. For discrete responses the body is peeked before rendering; the payload is rendered as before (JSON, pretty, table,--jq) and then, if the state classifies asfailure, the command returns aCLI_OUTCOME_FAILEDerror (runtime_error, exit 1) whose message names the state and the first failure-detail message. Artifact commands skip the media write on failure. For streams the completion event is classified as it arrives; every event is still rendered and stream metadata still reported, then the same error is returned. The agent-mode envelope carrieserror_reason: CLI_OUTCOME_FAILED,state, anderror(the detail at the error pointer). - Only
failurechanges behaviour.pending,successandhandoffstates, unknown states from a newer API, events without the state, and streams that end without a completion event all keep today's exit 0. Undeclared commands are byte-for-byte unchanged. - The response-path walker now treats a null union arm or a
nullentry in a type list as absence. This also applies toasyncstate and handle pointers: a nullable leaf generates, and the async runtime reports a protocol error only if a null value actually arrives. CLI_OUTCOME_FAILEDjoins the runtime reason list (hint validation,normalizeCLIReason, agent README).- Review fixture:
renderdeclares a discrete outcome table andsaya stream one, so the review CLI exercises both annotations. - Streams decode each event's wire frame only when something consumes it (an outcome table, or the wire re-encode for JSON output); projected and table streams keep their previous per-event cost.
Testing
go test ./internal/extensions/: new decode/link tests cover both pointers, operation-level declarations, and every rejection (missing pointer, no failure class, bad classification, unknown key, state not in the enum, enum member unclassified, non-enum pointer, stream pointer on a discrete operation, stream pointer that resolves nowhere, error pointer that is not an object/array, error bindings without their state pointer, async conflict, planned command).- Inheritance: a bare intent picks up its operation entry's table; an async intent does not. In the primary fixture
outcome-checknow declares no table and still fails onfailed/cancelled. - Pagination: without
--all, a first page in a failure state exits 1 and a pending first page exits 0 (the server now serves a configurable first-page status). - Artifacts: the generated artifact harness gains a case where the artifact command's response carries its declared failure state, asserting exit 1, empty stdout, and no file written (runs against the review
rendercommand). - Stream states: a misspelled stream state and an unclassified stream enum member are rejected; a stream leaf without an enum produces the unverifiable warning.
errorPointerresolving only on oneoneOfarm is accepted, resolving on no arm is rejected. - Primary CLI (
TARGET=primary make build-cli, newoutcome_additional_test.go): a CLI-only overlay adds a discrete interaction lookup and an SSE interaction stream with astatusenum. Tests run the generated operation command and the intent command throughcompleted,incomplete,in_progress,failed(with and without detail) andcancelledin JSON, pretty and agent mode, asserting exit code, rendered payload, stderr message and envelope fields; stream tests assert the projected text, NDJSON events, exit code, and that a delta-only stream with no completion event still exits 0. TARGET=review make build-cliregeneratedzSDKs/sdk-cli.make lint, CLI template type-check,go generate ./....
Public-safety check
- This change contains no credentials, customer documents, private repository URLs, private filesystem paths, or unredacted private logs.
- Title, body, comments, and commit messages name no customers or customer-derived identifiers, private paths or trackers, or workflow provenance, and are understandable without private context (
.claude/skills/public-repo-communication/SKILL.md). - Generated fixtures and review SDK changes are public-safe.
- I reviewed
git diff --check.
Summary by cubic
Lets generated CLIs exit 1 when a successful HTTP response declares a terminal failure state, for discrete JSON responses, streamed SSE events, and paginated pages.
Previously the CLI only knew the HTTP outcome, so it printed the payload and exited 0 even when the body declared failed or cancelled. Commands can now declare output.outcome: a states table using the async classifications, a pointer to the state, and optional failure detail. When a declared failure state appears, the payload is still rendered, then the command exits 1 with reason CLI_OUTCOME_FAILED, naming the state and the first detail message. Only failure changes behavior; pending, success, handoff, unknown states, and undeclared commands keep exit 0.
statePointeraddresses the JSON state andstreamStatePointerthe state in streamed events, with optional error pointers for detail; a detail array of plain strings contributes its first entry to the message.- The linker pins the JSON state pointer to a closed string enum the table classifies completely, intersecting
allOfand ref-sibling enums when verifying; a stream state enum is classified exactly when stream-only, and an enum-less leaf warns while an open enum is rejected. output.outcomebesideasyncon a command is an error; single-route intents without their own table inherit the operation entry's (async intents do not).- Artifact commands skip the media write on failure; the review CLI declares a discrete table on
renderand a stream table onsay.