feat(cli): continue cut-off responses with a declared continuation recipe

TR
7h ago
+2511-224

Why

Some APIs stop a response early and expect the client to send the same request again with a resume value from the response to get the rest. Each response reports a state such as more_required and carries a token, and only returns its own part of the output (its own steps, its own usage). Generated CLIs currently print the first part and exit 0, so users and scripts get partial output with no indication that more was available.

Stacked on #134, which adds output.outcome state tables. This PR adds a continue state class and a continuation: recipe that drives the loop.

What changed

  • New continuation: recipe on x-speakeasy-cli-commands commands and operation entries. Single-route intent commands inherit it from their operation entry, the same way output.outcome is inherited.

    operations:
      continuationCompose:
        output:
          outcome:
            statePointer: $.status
            states: {in_progress: pending, more_required: continue, completed: success, failed: failure}
        continuation:
          bind:
            - from: $.resumeToken          # read from the response that stopped
              to: $.resumeToken            # written into the next request body
            - from: $.id
              to: $.previousId
              optional: true
          merge: $.steps                   # arrays concatenated across hops
          sum: [$.usage.inputUnits, $.usage.outputUnits]
          # maxHops: 32, default: true
    

    streamFrom reads bindings from streamed events instead. Operations with both JSON and streaming responses declare both.

  • Generation-time validation:

    • The outcome table must classify at least one state as continue, with a state pointer for every response kind the operation has.
    • from and streamFrom must resolve to strings, and to must be a writable string in every request-body variant (including properties declared beside a union's oneOf/anyOf).
    • A continue state on a command or operation entry without a continuation recipe is rejected, since that command would otherwise exit 0 on a response that says it is incomplete.
    • merge targets must be arrays and sum targets numbers.
    • Continuation is rejected on async create operations and on paginated operations.
  • Runtime:

    • The CLI re-sends the already-built request with the resume values written in, and does not re-read flags or stdin.
    • Discrete results are rendered once, combined according to merge and sum (summed exactly, so totals beyond int64 stay correct); every other field comes from the last response. Streamed events from every hop go to one output, through one stream projector.
    • Operations with a response transform read the transformed result on every hop, as the single-request path does.
    • A JSON answer from an operation that can also stream continues as a discrete response.
  • Flags: --auto-continue (default from the recipe) and --max-continuations (default 32, or the recipe's maxHops).

  • Stop conditions:

    • Hop limit reached: CLI_CONTINUATION_LIMIT (exit 1). The envelope carries hops, continuation (the resume values), and a hint naming the command's own flags.
    • Failure state after continuing: CLI_OUTCOME_FAILED, also carrying hops and continuation.
    • Transport or API error on a later hop: keeps its normal classification and adds the last good resume values.
    • continue state without its required resume value: CLI_PROTOCOL (exit 1). The output is printed first.
    • Stream ending without a final state, or in a pending state, while holding resume values: CLI_CONNECTION, with the resume values. Cancellation carries the same hop context.
    • Success state that still carries a resume value: prints a "To continue, re-run with ..." note on stderr in human output.
  • Shared streaming refactor: StreamResult now drains one response at a time so a hop driver can chain the next stream. CLIs without a recipe behave the same; the review fixture diff in zSDKs/sdk-cli is this refactor plus the new reason constant. The continuation runtime file is only generated when a recipe is declared.

Review notes

  • output/continuation.go.stmpl holds the loop. The parts worth the closest look:
    • setContinuationBodyValue: writes a resume value into the typed request through a JSON round trip of the request-body field.
    • streamContinuation.next: decides whether a stream ended cleanly, dropped, or should continue.
    • The JSON-variant handoff from streamResult into discrete.
  • Every hop goes through the operation's normal SDK call, so retries, timeouts and auth behave as they do for a single request.
  • The continue state class lives in the output.outcome state table from #134 and is accepted only by commands that declare a continuation. Async commands still reject it.
  • Out of scope: pagination combined with continuation (rejected at generation time), and multi-route dispatch commands (they cannot declare a recipe).

Testing

  • go test ./internal/extensions/: passed. Covers decode and link, the nested to target, a to shared by union members, inheritance, and about 26 rejection cases.
  • 24 runtime scenarios in continuation_additional_test.go.stmpl, served by in-test HTTP servers that record each hop's request body:
    • single, three-hop and combined results, including sums beyond int64
    • optional bindings and resume values supplied as flags
    • the resumable-success note and --auto-continue=false
    • failure after continuing, a missing token in human and JSON output, the hop limit, and --max-continuations 0
    • a server error on hop 2, and dry run
    • streaming across three hops, plus JSON stream output
    • stream drops with no state, a pending state, and no checkpoint
    • a JSON answer, and a stream continued by a JSON answer
    • a streamed failure and a streamed hop limit
  • TARGET=primary make test-cli (2250 tests) and TARGET=review make test-cli (759 tests): passed.
  • make check-templates (all targets, since typeDefs.d.ts changed), go generate ./..., npm run format, go mod tidy -diff, the CLI snapshot tests, and staticcheck on the generated CLI: passed.
  • CI on the latest commit, including Go lint and the generator tests: passed.

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.