feat(cli): continue cut-off responses with a declared continuation recipe
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 onx-speakeasy-cli-commandscommands and operation entries. Single-route intent commands inherit it from their operation entry, the same wayoutput.outcomeis 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: truestreamFromreads 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. fromandstreamFrommust resolve to strings, andtomust be a writable string in every request-body variant (including properties declared beside a union'soneOf/anyOf).- A
continuestate 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. mergetargets must be arrays andsumtargets numbers.- Continuation is rejected on async create operations and on paginated operations.
- The outcome table must classify at least one state as
-
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
mergeandsum(summed exactly, so totals beyondint64stay 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'smaxHops). -
Stop conditions:
- Hop limit reached:
CLI_CONTINUATION_LIMIT(exit 1). The envelope carrieshops,continuation(the resume values), and a hint naming the command's own flags. - Failure state after continuing:
CLI_OUTCOME_FAILED, also carryinghopsandcontinuation. - Transport or API error on a later hop: keeps its normal classification and adds the last good resume values.
continuestate without its required resume value:CLI_PROTOCOL(exit 1). The output is printed first.- Stream ending without a final state, or in a
pendingstate, 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.
- Hop limit reached:
-
Shared streaming refactor:
StreamResultnow 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 inzSDKs/sdk-cliis this refactor plus the new reason constant. The continuation runtime file is only generated when a recipe is declared.
Review notes
output/continuation.go.stmplholds 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
streamResultintodiscrete.
- Every hop goes through the operation's normal SDK call, so retries, timeouts and auth behave as they do for a single request.
- The
continuestate class lives in theoutput.outcomestate 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 nestedtotarget, atoshared 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
- single, three-hop and combined results, including sums beyond
TARGET=primary make test-cli(2250 tests) andTARGET=review make test-cli(759 tests): passed.make check-templates(all targets, sincetypeDefs.d.tschanged),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.