Skip to content

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

Open
TristanSpeakEasy wants to merge 23 commits into
mainfrom
tristan/cli-continuation
Open

TristanSpeakEasy wants to merge 23 commits into
mainfrom
tristan/cli-continuation

Conversation

@TristanSpeakEasy

@TristanSpeakEasy TristanSpeakEasy commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

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 feat(cli): fail commands on a declared terminal failure state (output.outcome) #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.

AshGodfrey and others added 12 commits October 22, 2026 14:02
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, in both the discrete JSON
response and the completion event of an SSE stream. A generated CLI only
knew the HTTP outcome, so it printed the payload and exited 0.

x-speakeasy-cli-commands gains output.outcome on commands and operation
entries: statePointer (state in the JSON response), streamStatePointer
(state inside streamed events), a states table using the async
classifications, and optional errorPointer / streamErrorPointer /
errorMessageField for the failure detail. The decoder requires a state
pointer and at least one failure classification; the linker requires the
JSON state pointer to select a closed string enum that the table
classifies completely, checks the stream pointer like a stream
projection, and rejects an outcome table beside an async recipe.

At runtime the table travels on a speakeasy_outcome annotation. Discrete
responses are rendered as before and then, when the state classifies as
failure, the command returns CLI_OUTCOME_FAILED (runtime_error, exit 1)
naming the state and the first failure-detail message; artifact commands
skip the media write. Streams render every event and report metadata
before returning the same error. Only "failure" changes behaviour; other
classifications, unknown states, events without the state, and streams
with no completion event keep exit 0. Undeclared commands are unchanged.

The review CLI declares a discrete table on render and a stream table on
say; the primary CLI overlay adds a CLI-only interaction lookup and
stream with tests across JSON, pretty and agent mode.
…rrorPointer

Paginated commands rendered pages through PaginatedResult without ever
consulting the declared statePointer, so --all exited 0 on a page whose
body carried a failure state. Each page is now classified after it is
rendered and the command fails on the first page in a failure state.

The linker also walks streamErrorPointer through the streaming event
shapes, requiring an object or array in at least one of them, so a path
that resolves nowhere is rejected at generation time instead of silently
dropping the failure detail.
…s once

The JSON state and error pointers now go through one response-path walker
and accept nullable leaves: a null union arm or a "null" entry in a type
list is treated as absence rather than rejected. Each streamed event is
decoded once and the frame is shared between outcome classification and
the wire rendering. Narrative comments are dropped from the outcome
templates so they no longer land in generated CLIs.
…ept failure detail in any response variant

The linker now collects the closed enum a streamStatePointer resolves to
and holds the states table to it: a stream-only table must name exactly
those members, and when statePointer already pinned the table to the JSON
enum every stream member must still be classified. A leaf without a closed
enum warns that the table cannot be verified; an open enum is rejected.

errorPointer no longer has to resolve in every response variant. Failure
detail that only exists on the failed arm of a union is accepted, and the
pointer is rejected only when it resolves in no variant of any JSON 2xx
response, with a did-you-mean from the properties it passed.

The review fixture's streamed status gains an enum so the regenerated
review CLI exercises the stream-states check.

Co-Authored-By: Claude <noreply@anthropic.com>
…stream frames on demand, and cover the artifact and single-page outcome paths

A single-route intent without its own output.outcome now inherits the
table its operation entry declared, so a state classified for the
operation command also fails the intent; async intents keep async.states
as their only classification. The primary fixture's outcome-check
command drops its duplicate table and relies on the inheritance.

Streams decode an event's wire frame only when an outcome table needs
it or the wire re-encode will use it, restoring the previous per-event
cost for projected and table output. The unused StateSegments field
leaves the outcome model and the unreachable string arm leaves the
failure-detail reader.

The generated artifact harness gains a case where the response carries
the command's declared failure state and asserts nothing is written,
and the paginated test now serves a configurable first-page status so a
failed first page is shown to exit 1 without --all.

Co-Authored-By: Claude <noreply@anthropic.com>
…t outcome fixture, and document the outcome envelope fields

Failure detail arrays of plain strings now contribute their first entry to
the CLI_OUTCOME_FAILED message. The generated artifact failure fixture builds
array elements for numeric pointer segments and Go-quotes the asserted state.
The agent README names the state and error fields the outcome envelope adds.
The states map is built on a null prototype so any enum member name survives
serialization. perms.go and the review CLI are regenerated.
…cipe

Commands whose operation declares x-speakeasy-cli-commands continuation
re-send the request with resume values taken from the response while
output.outcome classifies its state as continue. Discrete responses are
combined (merge arrays, sum numbers); streamed responses are written as
one output across hops. Adds --auto-continue, --max-continuations and the
CLI_CONTINUATION_LIMIT reason.
…ponses and missing tokens

A stream that ends in a pending state while holding resume values is
reported as a connection error, a JSON answer from an operation that can
also stream continues as a discrete response, and a continue state without
its required resume value is a CLI_PROTOCOL error in every output mode.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 18 files

Shadow auto-approve: would not auto-approve because issues were found.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread internal/extensions/cli_commands_link.go
Comment thread templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl Outdated
Comment thread templates/templates/cli/auxiliary/internal/output/continuation.go.stmpl Outdated
Comment thread templates/templates/cli/opcmd.go.stmpl
Comment thread internal/extensions/cli_commands.go
Reject a continue outcome state that no continuation recipe acts on,
accept continuation targets declared beside a request-body union, sum
merged numbers exactly, keep hop context on cancellation, and read
transformed responses the way the single-request path does.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 8 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would require human review. Adds an opt-in continuation recipe that makes generated CLIs re-send requests with resume values and combine or stream results, with new flags, error reasons, and a streaming refactor. The auto-retry default/hop limit and combined-output contract need human sign-off.

View guided diff | Turn on auto-fix | Re-trigger cubic

@TristanSpeakEasy
TristanSpeakEasy marked this pull request as ready for review October 25, 2026 23:07
@TristanSpeakEasy
TristanSpeakEasy requested a review from a team as a code owner October 25, 2026 23:07
@TristanSpeakEasy
TristanSpeakEasy requested review from 2ynn and removed request for a team October 25, 2026 23:07
A nullable async.statePointer leaf now passes the response-path type
check, so its null enum member must be skipped the same way the
output.outcome linker skips it instead of failing as a non-string
member. Addresses review feedback on the async state enum loop.
Operations with a binary 2xx response wrote --output-file and
--output-b64 output before output.Result ran, so a JSON response in a
declared failure state was saved or printed and the command exited 0.
Both paths now classify the response, write it as before, and then
return CLI_OUTCOME_FAILED. The primary CLI fixture gains a mixed
JSON/binary operation covering both flags. Addresses review feedback
on the output-file and output-b64 early returns.
The stream path walker followed a $ref straight to its component when
segments remained, dropping sibling keywords such as properties. A
narrowed nested state was then checked against the referenced
component's full enum. The walker now rewrites a sibling-constrained
reference as an implicit allOf, sharing the rewrite resolveObjectSchema
already used, and guards the rewrite against reference cycles.
Addresses review feedback on $ref siblings in the stream walker.
# Conflicts:
#	internal/extensions/cli_commands_test.go
#	zSDKs/sdk-cli/.speakeasy/gen.lock
…tates

A oneOf or anyOf beside a closed enum was dropped whenever one of its
arms was not closed, so the leaf fell back to the unverifiable warning
and a misspelled states table was accepted. The union now filters the
members the other keywords already pin instead of discarding them.

oneOf arms were also combined as an ordinary union, so a member that
matches more than one arm, which oneOf rejects, still had to be
classified. When every arm is closed, oneOf now keeps only members that
match exactly one arm; with an arm that is not closed it requires a
match, keeping every possible member in the table.
TristanSpeakEasy and others added 4 commits October 27, 2026 13:23
…istan/cli-continuation

# Conflicts:
#	internal/extensions/cli_commands_test.go
#	templates/templates/cli/auxiliary/internal/output/streaming.go.stmpl
#	zSDKs/sdk-cli/.speakeasy/gen.lock
#	zSDKs/sdk-cli/internal/output/streaming.go
# Conflicts:
#	zSDKs/sdk-cli/.speakeasy/gen.lock
Base automatically changed from ash/cli-terminal-states to main October 29, 2026 21:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants