Skip to content

fix: make flag failures legible, and help honest about flag types (COR-13656) - #23

Closed
Bradenream wants to merge 1 commit into
masterfrom
braden/flag-errors/COR-13656
Closed

fix: make flag failures legible, and help honest about flag types (COR-13656)#23
Bradenream wants to merge 1 commit into
masterfrom
braden/flag-errors/COR-13656

Conversation

@Bradenream

Copy link
Copy Markdown
Contributor

Split out of #21 so the contested part can be argued separately. This half changes no parsing behaviour at all — every value accepted before is accepted now, every value rejected before is still rejected, and the request body is byte-identical to master across plain text, quoted JSON, null, objects, arrays, empty and numeric input. Only the messages change.

1. Rejection messages name the problem, not the first character

vf agent update --llm 'gpt-4'
  invalid value for --llm: error unmarshalling json response body:
  invalid character 'g' looking for beginning of value

There is no response body — the request was never sent. Worse, valid JSON of the wrong shape got the same treatment: --llm '[1,2]' parses perfectly, and the CLI insisted it was not valid JSON, sending the reader to re-check syntax that was never wrong. The decoder's own explanation — the only thing that knows which Go type was expected — was discarded.

Now shape mismatch is named separately from syntax error, an example of the right shape is derived from the destination, and the decoder's message is surfaced minus its misleading prefix:

invalid value for --llm: the value is valid JSON but not the shape this flag expects
  you passed: [1,2]
  expected shape: --llm '{"key":"value"}'
  decoder reported: cannot unmarshal array into Go value of type map[string]json.RawMessage
  pass null to clear the field: --llm null

The example is derived rather than fixed because a hardcoded one told array-valued flags to pass an object — following the CLI's own advice reproduced the identical error. A test now takes the suggestion and feeds it back in.

For the nullable string fields this is the entire fix from the caller's side:

invalid value for --instructions: expected a JSON value
  you passed: You are a support agent.
  the value is not valid JSON
  expected shape: --instructions '"your text here"'
  decoder reported: invalid character 'Y' looking for beginning of value

Following that advice works. Previously nothing said the value had to be JSON-quoted at all.

2. Flag errors reach the same renderer as everything else

They never did — they returned raw from the generated RunE and printed via a bare Fprintln, so a mistyped flag got one unstructured line while an API 401 got a structured envelope. Backwards, since mistyped flags are far more common. flagutil cannot import output (output imports flagutil), so failures carry a typed FlagValueError up to Execute, which renders it. What gets echoed is bounded in both modes, and counted in runes.

3. Help shows the flag's type, not a word from its prose

before after
agent update --instructions Name --instructions string
transcript search --version-param environmentAlias --version-param string

pflag reads the first back-quoted word in a usage string as the value placeholder, and our descriptions come from the spec where backticks are emphasis. 12 flags. The worst is --version-param, whose only legal values are draft and published. Fixed where descriptions enter cobra, so a 13th is covered automatically. --usage is byte-identical — it was already correct.

4. Agent-mode errors print exactly once

Every printer in internal/output writes to stderr and returns the error, and main.go printed whatever Execute returned. AgentModeError's own doc comment promised a single print, but main.go is generated and printed unconditionally, so the contract was never kept. stderr is now a single parseable JSON document for all three agent-mode error shapes.

Pretty output is deliberately untouched: there the trailing line is the readable summary that follows a raw API body, not a duplicate.

Footprint and verification

12 insertions, 7 deletions across five generated files. Everything else is new non-generated files, so there is very little for speakeasy run to merge.

  • 35 tests: 20 fail against master, 15 pass on both. The 15 are the invariants — including that --instructions null still sends a real JSON null.
  • Parsing verified identical to master on 7 input shapes.
  • Full suite shows the same pre-existing failures as master.

Relationship to #21

#21 is closed in favour of this plus #24. The raw-text fallback — the part @effervescentia requested changes on — is isolated there so this can land on its own.

…R-13656)

Split out of #21 so the contested part can be argued separately. Nothing here
changes what the CLI accepts: every value parsed before parses now, every value
rejected before is still rejected, and the request body is byte-identical to
master across plain text, quoted JSON, null, objects, arrays, empty and numeric
input. Only the messages change.

1. Rejection messages name the problem instead of the first character.

     vf agent update --llm 'gpt-4'
       invalid value for --llm: error unmarshalling json response body:
       invalid character 'g' looking for beginning of value

   There is no response body — the request was never sent. Worse, valid JSON of
   the wrong shape got the same treatment: --llm '[1,2]' parses perfectly, and
   the CLI insisted it was not valid JSON, sending the reader to re-check syntax
   that was never wrong. And the decoder's own explanation, the only thing that
   knows which Go type was expected, was discarded entirely.

   Now: shape mismatch is named separately from syntax error, an example of the
   right shape is derived from the destination, and the decoder's message is
   surfaced minus its misleading prefix. The example is derived rather than
   fixed because a hardcoded one told array-valued flags to pass an object —
   following the CLI's own advice reproduced the identical error.

   For the nullable string fields this is the whole fix from the caller's side:

     invalid value for --instructions: expected a JSON value
       you passed: You are a support agent.
       the value is not valid JSON
       expected shape: --instructions '"your text here"'
       decoder reported: invalid character 'Y' looking for beginning of value
       pass null to clear the field: --instructions null

   Following that advice works. Previously nothing said the value had to be
   JSON-quoted at all.

2. Flag errors reach the same renderer as everything else.

   They never did: they returned raw from the generated RunE and printed via a
   bare Fprintln, so a mistyped flag got one unstructured line while an API 401
   got a structured envelope — backwards, since mistyped flags are far more
   common. flagutil cannot import output (output imports flagutil), so failures
   carry a typed FlagValueError up to Execute, which renders it. What gets
   echoed is bounded, in both modes, and counted in runes.

3. Help shows the flag's type, not a word from its prose.

   pflag reads the first back-quoted word in a usage string as the value
   placeholder. Descriptions come from the OpenAPI spec, where backticks are
   emphasis, so --instructions rendered as "--instructions Name" for a flag that
   wants markdown. 12 flags; the worst is transcript search's --version-param,
   whose only legal values are draft and published, labelled with an unrelated
   parameter name. Fixed where descriptions enter cobra, so a 13th is covered
   automatically. --usage output is byte-identical; it was already correct.

4. Agent-mode errors print exactly once.

   Every printer in internal/output writes to stderr and also returns the error,
   and main.go printed whatever Execute returned. AgentModeError's own doc
   comment promised a single print, but main.go is generated and printed
   unconditionally, so the contract was never kept. stderr is now a single
   parseable JSON document for all three agent-mode error shapes. Pretty output
   is untouched — there the trailing line is the readable summary after a raw
   API body, not a duplicate.

Footprint on generated files is 12 insertions and 7 deletions across five
files; everything else is new non-generated files.

Verified: 35 tests, 20 failing against master and 15 passing on both — the 15
are the invariants, including that --instructions null still sends a real JSON
null. Full suite shows the same pre-existing failures as master.
Copilot AI lite review requested due to automatic review settings September 1, 2026 17:18
@linear-code

linear-code Bot commented Sep 1, 2026

Copy link
Copy Markdown

COR-13656

Copilot AI 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.

🟡 Changes recommended

One of the new JSON-flag hints (“pass null to clear the field”) is emitted unconditionally and can be misleading for required/non-nullable JSON flags, and truncateForError can avoid allocating a full []rune for very large inputs.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR improves the CLI’s UX and agent-friendliness around flag parsing failures by introducing richer, typed flag-value errors, routing them through the standard output/error renderer (including agent-mode JSON envelopes), fixing help placeholders impacted by backticks in OpenAPI descriptions, and preventing duplicate stderr printing for already-rendered errors.

Changes:

  • Introduces flagutil.FlagValueError + JSON-shape-aware diagnostics for JSON-typed flags, including bounded value echoing.
  • Routes flag-value errors through cli.Execute() so agent mode renders them via the same structured envelope path as other CLI-level errors.
  • Prevents duplicate stderr output in agent mode by returning a wrapped “already reported” error and updating cmd/vf/main.go to skip printing empty messages; also neutralizes backticks in flag descriptions to keep help type placeholders accurate.
File summaries
File Description
test/flag-errors.test.ts Adds end-to-end tests for flag error messaging, bounded echoing, agent-mode envelopes, and help placeholder correctness.
test/errors-teach.test.ts Tightens assertions that agent-mode stderr is only JSON (no trailing duplicate prose).
internal/output/reported.go Adds an internal wrapper error type that suppresses second-printing by making Error() empty while preserving Unwrap().
internal/output/output.go Returns AlreadyReported(err) from output.Error to prevent main from printing the same error again.
internal/output/agentmode.go Returns AlreadyReported(...) from AgentModeError to avoid duplicate printing via main.go.
internal/flagutil/metadata.go Normalizes descriptions via flagDescription before registering flags to avoid pflag placeholder hijacking.
internal/flagutil/description.go Replaces backticks with apostrophes so pflag doesn’t treat prose emphasis as a value placeholder.
internal/flagutil/flagerror.go Introduces FlagValueError with bounded, rune-counted value display for error messages.
internal/flagutil/jsonerror.go Adds JSON syntax-vs-shape-aware error reporting and derived “expected shape” examples.
internal/cli/root.go Wraps command execution with flag-value error rendering for agent mode.
internal/cli/flagerrors.go Renders *FlagValueError via output.AgentModeError in agent mode while leaving human mode unchanged.
cmd/vf/main.go Skips printing returned errors with an empty message to prevent duplicate stderr output.
Review details

Files not reviewed (5)

  • cmd/vf/main.go: Generated file
  • internal/cli/root.go: Generated file
  • internal/flagutil/metadata.go: Generated file
  • internal/output/agentmode.go: Generated file
  • internal/output/output.go: Generated file
  • Files reviewed: 7/12 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +67 to +75
func truncateForError(s string) string {
const max = 120
s = strings.ReplaceAll(s, "\n", " ")
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max]) + "… (" + fmt.Sprint(len(runes)) + " chars)"
}
Comment on lines +48 to +56
if example := jsonShapeExample(t); example != "" {
hints = append(hints, fmt.Sprintf("expected shape: --%s '%s'", m.FlagName, example))
}
if detail := unmarshalDetail(cause); detail != "" {
hints = append(hints, detail)
}
hints = append(hints, "pass null to clear the field: --"+m.FlagName+" null")

return &FlagValueError{Flag: m.FlagName, Value: val, Expected: expected, Hints: hints, Cause: cause}

@effervescentia effervescentia left a comment

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.

I tried running yarn codegen locally and it failed with this error

Generating SDK for cli...
                         
commercial license requires a validated license token: attach one with licensetoken.WithToken (or --license-token / SPEAKEASY_LICENSE_TOKEN); authenticated state asserting a commercial license is not proof by itself
                                                                           
To get help, send the following reproduction command to the Speakeasy team:
                                                                              
    speakeasy repro voiceflow_engineering_01a0628d-8b94-7353-8a54-4311bcf5883b
                                                                              
                               
Step Failed: Generating Cli SDK
                               
                     
Step Failed: Workflow
                     
                                  
Step Failed: Target: voiceflow-sdk


failed to generate "cli"
Failed to run with Speakeasy version 1.796.3: failed to run with version 1.796.3: exit status 1

Comment thread test/errors-teach.test.ts
Comment on lines +171 to +179
const cases: Array<[name: string, args: string[]]> = [
['CLI-level error (AgentModeError)', ['configure']],
['preflight error (no token)', ['workspace', 'list']],
['flag-value error', ['agent', 'update', '--project-id', 'p', '--environment-alias', 'main',
'--dry-run', '--token', 'vfp_x', '--llm', 'not json']],
];

for (const [name, args] of cases) {
it(`${name}: stderr is nothing but the JSON envelope`, async () => {

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.

🟢 use it.each() instead here as the idiomatic way to run the same test across multiple inputs

https://vitest.dev/api/test.html#test-each

Bradenream commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Merge activity

  • Sep 4, 3:21 PM UTC: The merge label 'merge' was detected. This PR will be added to the Graphite merge queue once it meets the requirements.
  • Sep 4, 3:21 PM UTC: Bradenream added this pull request to the Graphite merge queue.
  • Sep 4, 3:21 PM UTC: CI is running for this pull request on a draft pull request (#28) due to your merge queue CI optimization settings.
  • Sep 4, 3:22 PM UTC: Merged by the Graphite merge queue via draft PR: #28.

@graphite-app graphite-app Bot closed this Sep 4, 2026
@graphite-app graphite-app Bot removed the merge label Sep 4, 2026
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.

3 participants