Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d6e789a
feat(cli): fail commands on a declared terminal failure state
AshGodfrey Oct 22, 2026
dadc371
fix(cli): classify output.outcome on paginated pages and link streamE…
AshGodfrey Oct 22, 2026
8ddeda4
refactor(cli): share the response-path walker and decode stream frame…
AshGodfrey Oct 22, 2026
5778e83
fix(cli): verify stream outcome states against the event enum and acc…
AshGodfrey Oct 22, 2026
7ee6314
refactor(cli): inherit operation outcome tables into intents, decode …
AshGodfrey Oct 22, 2026
f2a3e94
fix(cli): accept string failure detail, index segments in the artifac…
AshGodfrey Oct 22, 2026
7184697
Merge remote-tracking branch 'origin/main' into ash/cli-terminal-states
AshGodfrey Oct 22, 2026
7a7fa85
test(cli): refresh release snapshots for the outcome-failure README p…
AshGodfrey Oct 22, 2026
acbb4b5
fix(cli): intersect allOf and ref-sibling enums when verifying outcom…
AshGodfrey Oct 22, 2026
f563ced
feat(cli): decode and link continuation recipes in x-speakeasy-cli-co…
TristanSpeakEasy Oct 23, 2026
7422ac5
feat(cli): continue cut-off responses with a declared continuation re…
TristanSpeakEasy Oct 23, 2026
c9a4a6a
fix(cli): close continuation gaps for pending stream drops, mixed res…
TristanSpeakEasy Oct 23, 2026
d9eb6d2
fix(cli): tighten continuation linking and runtime edge cases
TristanSpeakEasy Oct 23, 2026
a8092ec
fix(cli): skip null enum members in async state pointers
TristanSpeakEasy Oct 26, 2026
9fa7b4e
fix(cli): classify output.outcome for --output-file and --output-b64
TristanSpeakEasy Oct 26, 2026
fd88b45
fix(cli): keep $ref sibling constraints when walking stream paths
TristanSpeakEasy Oct 26, 2026
5209510
Merge remote-tracking branch 'origin/main' into ash/cli-terminal-states
TristanSpeakEasy Oct 27, 2026
3b301c7
fix(cli): apply oneOf/anyOf semantics when verifying stream outcome s…
TristanSpeakEasy Oct 27, 2026
1cc6c8e
Merge remote-tracking branch 'origin/ash/cli-terminal-states' into tr…
TristanSpeakEasy Oct 27, 2026
2d84963
Merge remote-tracking branch 'origin/main' into ash/cli-terminal-states
AshGodfrey Oct 29, 2026
17ff538
Merge remote-tracking branch 'origin/main' into ash/cli-terminal-states
AshGodfrey Oct 29, 2026
7851e9b
Merge remote-tracking branch 'origin/ash/cli-terminal-states' into tr…
AshGodfrey Oct 29, 2026
9566f90
Merge remote-tracking branch 'origin/main' into tristan/cli-continuation
AshGodfrey Oct 29, 2026
f36ad20
Merge remote-tracking branch 'origin/main' into tristan/cli-continuation
TristanSpeakEasy Nov 1, 2026
5c43e3f
fix(cli): explain why an operation entry's continue state needs its o…
TristanSpeakEasy Nov 2, 2026
f0aee71
fix(cli): tighten continuation target and aggregation path checks
TristanSpeakEasy Nov 3, 2026
61654c0
fix(cli): reject a readOnly arm as a continuation target
TristanSpeakEasy Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changesets/1792717600-4c1d2e7a.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
id: 1792717600-4c1d2e7a
features:
- cliCommands
targets:
- cli
type: feat
bump: minor
description: declare a continuation recipe so a command re-sends its request with resume values from the response while output.outcome classifies the response state as continue, combining the responses (merge arrays, sum numbers) or streaming every response as one output, with --auto-continue and --max-continuations flags and CLI_CONTINUATION_LIMIT when the hop limit is reached
author: TristanSpeakEasy
date: "2026-10-23"
232 changes: 217 additions & 15 deletions internal/extensions/cli_commands.go

Large diffs are not rendered by default.

318 changes: 318 additions & 0 deletions internal/extensions/cli_commands_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,310 @@ func (d *cliManifestDecoder) linkManifest(manifest *CLICommandManifest) error {
}
}
inheritOperationOutcomes(manifest)
inheritOperationContinuations(manifest)
return d.linkContinuations(manifest)
}

// inheritOperationContinuations gives a single-route, non-async intent
// without its own continuation the recipe its operation entry declared, as
// output.outcome is inherited. Bindings are copied because hint flags are
// resolved against each command's own flags.
func inheritOperationContinuations(manifest *CLICommandManifest) {
recipes := map[string]*CLICommandContinuation{}
for i := range manifest.Operations {
if op := &manifest.Operations[i]; op.Continuation != nil {
recipes[op.OperationID] = op.Continuation
}
}
if len(recipes) == 0 {
return
}
for i := range manifest.Commands {
cmd := &manifest.Commands[i]
if cmd.Source.Type != CLICommandSourceOperation || len(cmd.Source.Routes) != 1 || cmd.Async != nil || cmd.Continuation != nil {
continue
}
recipe, ok := recipes[cmd.Source.Routes[0].OperationID]
if !ok {
continue
}
copied := *recipe
copied.Bind = append([]CLICommandContinuationBinding(nil), recipe.Bind...)
cmd.Continuation = &copied
}
}

func (d *cliManifestDecoder) linkContinuations(manifest *CLICommandManifest) error {
asyncCreates := map[string]string{}
for i := range manifest.Commands {
cmd := &manifest.Commands[i]
if cmd.Async != nil && len(cmd.Source.Routes) > 0 {
asyncCreates[cmd.Source.Routes[0].OperationID] = strings.Join(cmd.Path, " ")
}
}
for i := range manifest.Operations {
op := &manifest.Operations[i]
if op.Continuation == nil {
continue
}
var outcome *CLICommandOutcome
if op.Output != nil {
outcome = op.Output.Outcome
}
if err := d.linkContinuation(fmt.Sprintf("operation %q", op.OperationID), op.Continuation, op.OperationID, outcome, op.Flags, asyncCreates); err != nil {
return err
}
}
for i := range manifest.Commands {
cmd := &manifest.Commands[i]
var outcome *CLICommandOutcome
if cmd.Output != nil {
outcome = cmd.Output.Outcome
}
owner := fmt.Sprintf("command %q", strings.Join(cmd.Path, " "))
if cmd.Continuation == nil || cmd.Source.Type != CLICommandSourceOperation {
if err := rejectUnusedContinueStates(owner, outcome, "add continuation (on the command or its operation entry)"); err != nil {
return err
}
continue
}
if err := d.linkContinuation(owner, cmd.Continuation, cmd.Source.Routes[0].OperationID, outcome, cmd.Flags, asyncCreates); err != nil {
return err
}
}
for i := range manifest.Operations {
op := &manifest.Operations[i]
if op.Continuation != nil || op.Output == nil {

@AshGodfrey AshGodfrey Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This pass rejects an operation entry whose outcome has a continue state whenever the recipe lives on the command instead of the operation entry, but the error text says that layout is allowed.

Repro (fails with operation "ComposeWidget" classifies output.outcome state "more_required" as continue but declares no continuation recipe):

version: 1
commands:
  compose:
    op: ComposeWidget
    args:
      prompt: {to: $.prompt, variadic: true}
    flags:
      resume-token: {to: $.resumeToken}
    continuation:
      bind:
        - from: $.resumeToken
          to: $.resumeToken
operations:
  ComposeWidget:
    output:
      outcome:
        statePointer: $.status
        states: {in_progress: pending, more_required: continue, budget_reached: success, completed: success, failed: failure}

Suggest skipping operations here that a single-route command with its own recipe targets (the command pass already validates those), or only rejecting when no linked recipe covers the operation. A test for this layout would be good too.

Verified by running this manifest through decodeCLIWithSpec with cliContinuationTestSpec on this branch; it fails with the error above.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

thanks for the repro! I had a look and I think rejecting this layout is actually right, it's the error text that was wrong. the operation entry's outcome also gets attached to the generated operation command (opcmd.go.stmpl sets speakeasy_outcome from it), not just to compose. so if we skipped the check here, the raw operation command would carry more_required: continue with no recipe and exit 0 on an incomplete response, which is the case this pass exists to stop

so in 5c43e3f I've kept the rejection but changed the message for operation entries to explain that and point at the two layouts that do work: put continuation: on the operation entry (single-route intents inherit it, which is what validContinuationManifest covers), or move output.outcome onto the intent. I also added your manifest as a test case asserting the new message

let me know if you'd rather go the other way and have the operation command pick up the recipe too, but that gets ambiguous once two intents target the same op so I'd prefer not to

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You're right, the operation entry's outcome also lands on the generated operation command, so rejecting this layout is correct. The new message and test cover it. Thanks.

continue
}
if err := rejectUnusedContinueStates(fmt.Sprintf("operation %q", op.OperationID), op.Output.Outcome, "an operation entry's outcome also applies to the generated operation command, so a recipe on an intent does not cover it; move continuation to the operation entry (single-route intents inherit it), move output.outcome to the intent,"); err != nil {
return err
}
}
return nil
}

// rejectUnusedContinueStates refuses a continue classification that no
// continuation recipe acts on: the command would otherwise exit 0 on a
// response that says it is incomplete.
func rejectUnusedContinueStates(owner string, outcome *CLICommandOutcome, remedy string) error {
if outcome == nil {
return nil
}
var states []string
for state, class := range outcome.States {
if class == "continue" {
states = append(states, state)
}
}
if len(states) == 0 {
return nil
}
sort.Strings(states)
return fmt.Errorf("%s classifies output.outcome state %q as continue but declares no continuation recipe; %s or classify the state as pending, success, or failure", owner, states[0], remedy)
}

func (d *cliManifestDecoder) linkContinuation(owner string, recipe *CLICommandContinuation, opID string, outcome *CLICommandOutcome, flags []CLICommandInput, asyncCreates map[string]string) error {
opInfo, ok := d.schemaIndex.operations[opID]
if !ok {
return fmt.Errorf("%s continuation: operation %q does not exist in the document%s", owner, opID, cliDidYouMean(opID, d.schemaIndex.opIDs))
}
recipe.OperationID = opID
if asyncCommand, ok := asyncCreates[opID]; ok {
return fmt.Errorf("%s declares continuation on operation %q, which async command %q also uses as its create operation; continuation and async cannot share an operation in v1", owner, opID, asyncCommand)
}
if outcome == nil {
return fmt.Errorf("%s declares continuation but no output.outcome; declare a states table with at least one continue state on the command or its operation entry", owner)
}
hasContinue := false
for _, class := range outcome.States {
hasContinue = hasContinue || class == "continue"
}
if !hasContinue {
return fmt.Errorf("%s continuation requires output.outcome.states to classify at least one state as continue", owner)
}
hasJSON, hasStream := len(opInfo.jsonResponses) > 0, opInfo.streamMediaSeen
if !hasJSON && !hasStream {
return fmt.Errorf("%s continuation: operation %q has no application/json or streaming 2xx response to read resume values from", owner, opID)
}
if hasJSON && outcome.StatePointer == "" {
return fmt.Errorf("%s continuation: operation %q returns application/json, so output.outcome must declare statePointer", owner, opID)
}
if hasStream && outcome.StreamStatePointer == "" {
return fmt.Errorf("%s continuation: operation %q streams its response, so output.outcome must declare streamStatePointer", owner, opID)
}
if opInfo.bodySchema == nil {
return fmt.Errorf("%s continuation: operation %q has no application/json request body to carry resume values", owner, opID)
}

for i := range recipe.Bind {
binding := &recipe.Bind[i]
if binding.From != "" {
if !hasJSON {
return fmt.Errorf("%s continuation.bind.from %s: operation %q has no application/json 2xx response; use streamFrom for streamed events", owner, binding.From, opID)
}
for _, response := range opInfo.jsonResponses {
if _, err := d.linkAsyncResponsePointer(binding.From, response.schema, false); err != nil {
return fmt.Errorf("%s continuation.bind.from %s in operation %q response %s: %w", owner, binding.From, opID, response.code, err)
}
}
} else if hasJSON && !binding.Optional {
return fmt.Errorf("%s continuation.bind entry for %s requires from: operation %q returns application/json", owner, binding.To, opID)
}
if len(binding.StreamFrom) > 0 {
if !hasStream {
return fmt.Errorf("%s continuation.bind.streamFrom: operation %q has no streaming 2xx response", owner, opID)
}
for _, path := range binding.StreamFrom {
if err := d.linkStreamSelection(owner, "continuation.bind.streamFrom", path, opInfo); err != nil {
return err
}
}
} else if hasStream && !binding.Optional {
return fmt.Errorf("%s continuation.bind entry for %s requires streamFrom: operation %q streams its response", owner, binding.To, opID)
}
if err := d.linkContinuationTarget(owner, opID, binding, opInfo.bodySchema); err != nil {
return err
}
binding.Flag = ""
for _, flag := range flags {
if flag.Bind != nil && flag.Bind.In == "body" && flag.Bind.Pointer == binding.ToPointer {
binding.Flag = flag.Name
break
}
}
}

if (len(recipe.Merge) > 0 || len(recipe.Sum) > 0) && !hasJSON {
return fmt.Errorf("%s continuation.merge and continuation.sum apply to application/json responses, and operation %q has none", owner, opID)
}
for _, path := range recipe.MergeFrom {
if err := d.linkContinuationResultPath(owner, "continuation.merge", path, opID, opInfo, func(kind string) bool { return kind == "array" }, "an array"); err != nil {
return err
}
}
for _, path := range recipe.SumFrom {
if err := d.linkContinuationResultPath(owner, "continuation.sum", path, opID, opInfo, func(kind string) bool { return kind == "int" || kind == "float" }, "a number"); err != nil {
return err
}
}
return nil
}

func (d *cliManifestDecoder) linkContinuationResultPath(owner, field, path, opID string, opInfo *cliOperationInfo, accept func(string) bool, expected string) error {
for _, response := range opInfo.jsonResponses {
views, err := d.walkResponsePath(path, response.schema)
if err != nil {
return fmt.Errorf("%s %s %s in operation %q response %s: %w", owner, field, path, opID, response.code, err)
}
facts, err := d.responseLeafFacts(views)
if err != nil {
return fmt.Errorf("%s %s %s in operation %q response %s: %w", owner, field, path, opID, response.code, err)
}
for _, propertyFacts := range facts {
if len(propertyFacts.kinds) != 1 || !accept(propertyFacts.kinds[0]) {
return fmt.Errorf("%s %s %s in operation %q response %s must select %s in every response variant", owner, field, path, opID, response.code, expected)
}
}
}
return nil
}

// linkContinuationTarget requires the bound request-body path to be a
// writable string property in every request-body variant, so a resume value
// always lands in a field the API accepts.
func (d *cliManifestDecoder) linkContinuationTarget(owner, opID string, binding *CLICommandContinuationBinding, bodySchema any) error {
for i, raw := range d.continuationTargetVariants(bodySchema) {
if err := d.linkContinuationTargetPath(owner, opID, binding, raw, 0, i+1); err != nil {
return err
}
}
return nil
}

func (d *cliManifestDecoder) linkContinuationTargetPath(owner, opID string, binding *CLICommandContinuationBinding, schema any, depth, bodyVariant int) error {
name := binding.ToPath[depth]
for _, raw := range d.continuationTargetVariants(schema) {
variant, err := d.resolveObjectSchema(raw, nil)
if err != nil {
return fmt.Errorf("%s continuation.bind.to %s: request body variant %d of operation %q: %w", owner, binding.To, bodyVariant, opID, err)
}
prop, exists := variant.properties[name]
if !exists {
return fmt.Errorf("%s continuation.bind.to %s: property %q is absent from request body variant %s of operation %q%s", owner, binding.To, name, cliSchemaLabel(variant), opID, cliDidYouMean(name, variant.propOrder))
}
if depth < len(binding.ToPath)-1 {
if err := d.linkContinuationTargetPath(owner, opID, binding, prop, depth+1, bodyVariant); err != nil {
return err
}
continue
}
facts, err := d.propertyFacts(prop, nil)
if err != nil {
return fmt.Errorf("%s continuation.bind.to %s in request body variant %s of operation %q: %w", owner, binding.To, cliSchemaLabel(variant), opID, err)
}
readOnly := facts.readOnly
if leaf := d.continuationTargetLeaf(prop); leaf != nil {
facts, err = d.propertyFacts(leaf, nil)
if err != nil {
return fmt.Errorf("%s continuation.bind.to %s in request body variant %s of operation %q: %w", owner, binding.To, cliSchemaLabel(variant), opID, err)
}
readOnly = readOnly || facts.readOnly
}
if readOnly {
return fmt.Errorf("%s continuation.bind.to %s names readOnly property %q in request body variant %s of operation %q", owner, binding.To, name, cliSchemaLabel(variant), opID)
}
Comment thread
TristanSpeakEasy marked this conversation as resolved.
Resolved
if len(facts.kinds) != 1 || facts.kinds[0] != "string" {
return fmt.Errorf("%s continuation.bind.to %s must name a string property in request body variant %s of operation %q", owner, binding.To, cliSchemaLabel(variant), opID)
}
}
return nil
}

// continuationTargetLeaf drops null from a target's type list or union, so a
// nullable string accepts a resume value; it returns nil when nothing changes.
func (d *cliManifestDecoder) continuationTargetLeaf(prop any) any {
leaf := prop
if variants := d.continuationTargetVariants(prop); len(variants) == 1 {
leaf = variants[0]
}
if leafMap, ok := leaf.(map[string]any); ok {
if stripped := cliWithoutNullType(leafMap); stripped != nil {
leaf = stripped
}
}
if reflect.DeepEqual(leaf, prop) {
return nil
}
return leaf
}

// continuationTargetVariants expands an object union into its members, each
// conjoined with the keywords the union declares beside oneOf/anyOf. A
// null-only member is skipped: there is no object to write into.
func (d *cliManifestDecoder) continuationTargetVariants(schema any) []any {
schemaMap, _ := schema.(map[string]any)
unionMap, _ := d.unionBodySchema(schemaMap)
members := cliUnionMembers(unionMap)
if len(members) == 0 {
return []any{schema}
}
shared := cliUnionSharedSchema(unionMap)
variants := make([]any, 0, len(members))
for _, member := range members {
memberMap, _ := member.(map[string]any)
if types := cliSchemaTypes(memberMap); len(types) == 1 && types[0] == "null" {
continue
}
if shared != nil {
variants = append(variants, cliConjoinSchemas(memberMap, shared))
} else {
variants = append(variants, member)
}
}
return variants
}

// inheritOperationOutcomes gives a single-route intent without its own
// output.outcome the table its operation entry declared, so a failure state
// classified for the operation command also fails the intent. Async intents
Expand Down Expand Up @@ -3734,6 +4035,23 @@ func (d *cliManifestDecoder) resolveObjectSchema(schema any, seen []string) (*cl
return out, nil
}

// cliUnionSharedSchema returns the keywords declared beside a union's
// oneOf/anyOf, which apply to every member; nil when there are none.
func cliUnionSharedSchema(unionMap map[string]any) map[string]any {
shared := map[string]any{}
for key, value := range unionMap {
switch key {
case "oneOf", "anyOf", "discriminator", "description", "title", "example", "examples":
continue
}
shared[key] = value
}
if len(shared) == 0 {
return nil
}
return shared
}

func cliConjoinSchemas(a, b map[string]any) map[string]any {
switch {
case a == nil:
Expand Down
Loading
Loading