feat(cli): fail commands on a declared terminal failure state (output.outcome)

AS
13h ago
+2138-95

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; states uses the async classifications and must name at least one failure. statePointer must select a closed string enum in every application/json 2xx response and the table must classify exactly that enum, so no API state can reach the runtime unclassified. streamStatePointer is 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 when statePointer already 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; errorPointer may exist in only some response variants (a failed union arm) and is rejected only when it resolves nowhere. async and output.outcome on the same command is an error, since async.states already 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_outcome annotation. 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 as failure, the command returns a CLI_OUTCOME_FAILED error (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 carries error_reason: CLI_OUTCOME_FAILED, state, and error (the detail at the error pointer).
  • Only failure changes behaviour. pending, success and handoff states, 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 null entry in a type list as absence. This also applies to async state and handle pointers: a nullable leaf generates, and the async runtime reports a protocol error only if a null value actually arrives.
  • CLI_OUTCOME_FAILED joins the runtime reason list (hint validation, normalizeCLIReason, agent README).
  • Review fixture: render declares a discrete outcome table and say a 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-check now declares no table and still fails on failed/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 render command).
  • Stream states: a misspelled stream state and an unclassified stream enum member are rejected; a stream leaf without an enum produces the unverifiable warning. errorPointer resolving only on one oneOf arm is accepted, resolving on no arm is rejected.
  • Primary CLI (TARGET=primary make build-cli, new outcome_additional_test.go): a CLI-only overlay adds a discrete interaction lookup and an SSE interaction stream with a status enum. Tests run the generated operation command and the intent command through completed, incomplete, in_progress, failed (with and without detail) and cancelled in 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-cli regenerated zSDKs/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.

  • statePointer addresses the JSON state and streamStatePointer the 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 allOf and 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.outcome beside async on 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 render and a stream table on say.