fix: make flag values, errors, and help legible to humans and agents (COR-13656) - #21
fix: make flag values, errors, and help legible to humans and agents (COR-13656)#21Bradenream wants to merge 4 commits into
Conversation
…OR-13656)
Three defects in the same place — the moment a flag value is typed — found
while watching an unattended coding agent configure a real agent. Together
they account for most of the 42 minutes it spent after the quickstart.
1. JSON flags whose destination is a string now accept raw text.
vf agent update --instructions 'You are a support agent for Acme.'
invalid value for --instructions: error unmarshalling json response
body: invalid character 'Y' looking for beginning of value
The field holds markdown. It is FlagKindJSON because the spec marks it
nullable, and JSON is the only encoding that tells a real null apart from
the string "null" — that part is correct and is preserved. But nothing
told the caller to wrap prose in JSON quotes, and the error named the
first letter of the sentence. The fix retries an unparseable value as a
JSON string when, and only when, the destination ultimately holds a Go
string. 22 flags qualify.
The discriminator has to unwrap OptionalNullable[T], which is defined as
map[bool]*T — indistinguishable from map[string]any by Kind alone.
Requiring a bool key admits OptionalNullable[string] and rejects every
ordinary map. Unit-tested against all 13 shapes that occur, including
map[string]string and OptionalNullable[[]string].
The trade, stated plainly: for those 22 flags, malformed JSON that used
to fail now succeeds as literal text. Right for markdown authors, wrong
for someone who meant JSON and typo'd. Structs, maps and slices are
excluded, so mistyped objects still fail loudly.
2. Flag errors now teach.
They never reached output.Error: they return raw from the generated RunE
and main.go prints them with a bare Fprintln. So a mistyped flag gave one
unstructured line while an API 401 gave a structured envelope with hints
— backwards, since mistyped flags are the more common failure. flagutil
cannot import output (output already imports flagutil), so failures carry
a typed FlagValueError up to Execute, which renders it.
3. Help now shows the flag's type instead of a word from its prose.
pflag reads the first back-quoted word in a usage string as the value
placeholder. Our descriptions come from the OpenAPI spec, where backticks
are emphasis, so --instructions rendered as "--instructions Name" for a
flag that wants markdown. 12 flags affected; the worst is transcript
search's --version-param, whose only legal values are draft and published,
labelled with an unrelated parameter name. Fixed at the one point
descriptions enter cobra, so a 13th flag is covered automatically.
An OpenAPI overlay was assessed as an alternative for 1 and 3 and rejected:
it would pin 12 descriptions verbatim so upstream rewording is silently
overwritten, and fixing 1 that way means deleting nullable, trading away the
ability to clear a field. Persisted edits were confirmed durable first — all
four surviving edits came through the v0.234.0 regeneration intact.
Footprint on generated files is 4 insertions and 3 deletions across two
files; all real logic lives in new non-generated files.
Verified: 13 new tests, 9 of which fail against master and 4 of which pass
on both (the invariants: null still clears, quoted JSON unchanged, object
flags still reject). Full suite shows the same 11 pre-existing failures as
master — live integration tests with no credentials — and 13 more passes.
--usage output is byte-identical, as it was already correct.
Every error printer in internal/output writes the message to stderr and also
returns the error, and cmd/vf/main.go printed whatever Execute returned. So a
single failure reached stderr twice:
CLAUDECODE=1 vf configure
{ "error": "…", "error_type": "configure_blocked", "hints": [ … ] }
the 'configure' command is interactive and cannot be used in agent mode
AgentModeError's own doc comment promised the opposite — "outputs structured
JSON exactly once to stderr… Callers must NOT print the error again" — but
main.go is generated and printed unconditionally, so the contract was never
actually kept by anything.
This matters because in agent mode the envelope IS the product. A trailing
non-JSON line after a JSON object is exactly what breaks a consumer that does
the obvious thing, and being driven by agents is what this CLI is for. All
three agent-mode error shapes were affected: AgentModeError (configure, auth),
output.Error (API and preflight failures), and the flag-value errors added in
the previous commit.
Errors that have been printed are now marked, and main.go skips printing a
marked error. The marker renders as an empty message, which is what actually
suppresses the second print — main.go imports only internal/cli and so cannot
call a predicate in internal/output. That cost is stated in the file: anything
that formats one of these with %v renders nothing, which is contained because
exactly one place formats them and it is guarded.
Pretty output is deliberately untouched. There the trailing line is the
readable one-line summary that follows a raw API body, so suppressing it would
remove the most useful part of the message rather than a duplicate. Verified
byte-identical to master across four error classes.
One deliberate behavior change beyond agent mode: --output-format json now
emits pure JSON on stderr for errors, where it previously appended the same
prose line. If you asked for JSON you should get JSON, and everything in that
line is already in the envelope as message, hints and docs_url.
Footprint on generated files is 8 insertions and 4 deletions across three
files. Verified: 6 new tests, all 6 failing against master; stderr now parses
as a single JSON document for all three error shapes; exit codes unchanged at
1. Full suite shows the same 11 pre-existing failures as master.
…o (COR-13656)
Adversarial review of the two previous commits raised 23 findings; 12 were
refuted by independent verification and 11 survived. Three were blocking, and
all three were defects I introduced. Each is fixed here with a test that fails
against the pre-review branch.
1. The suggested example was one the CLI itself rejects.
rawtext.go hardcoded '{"key":"value"}' for every structured destination, so
on the array-valued flags it told the caller to pass a value the same binary
refuses:
vf agent update --playbooks notjson
objects and arrays must be valid JSON, e.g. --playbooks '{"key":"value"}'
vf agent update --playbooks '{"key":"value"}' # do exactly what it said
objects and arrays must be valid JSON, e.g. --playbooks '{"key":"value"}'
Identical error, nothing new to try — a loop, for the agents this work is
aimed at. Worse than master, which at least said "cannot unmarshal object
into []json.RawMessage" and so named the array. The example is now derived
from the destination kind, and an unrecognized shape produces no example at
all: a wrong example is worse than none. The regression test takes the
CLI's own suggestion and feeds it back in.
2. Valid JSON of the wrong shape was reported as invalid JSON.
--llm '[1,2]' parses perfectly; it is simply not an object. The message said
"expected a JSON value" and hinted "must be valid JSON", sending the reader
to re-check syntax that was never wrong. Master said "cannot unmarshal array
into Go value of type map[string]json.RawMessage" — uglier, and correct.
That detail was being swallowed: FlagValueError.Cause was stored and never
rendered on any path. Shape mismatch is now named separately, and the
decoder's own message is surfaced, minus its "response body" prefix, which
is wrong for a request the CLI has not sent.
3. Agent mode bypassed the truncation written for exactly this.
renderFlagValueError echoed the raw value; truncateForError was unexported
so internal/cli could not reach it. Measured on the same input: master 118
bytes, human 311, agent 5,329 for a 5,000-char value and 400,331 for a
400KB one — unbounded and linear in input. It also reproduced a credential
embedded in a malformed config blob that master never echoed. Now behind an
exported DisplayValue, tested in both modes.
Also fixed, non-blocking:
4. truncateForError sliced bytes, so a multi-byte rune was cut in half and the
reported length was wrong — 200 Japanese characters reported as 600. Now
counts and cuts in runes.
5. A comment in flagerrors.go described the double-print that the previous
commit removed. Rewritten.
Residual, deliberately kept: up to 120 characters of the value are still
echoed, where master echoed none. The echo is what reveals shell-quoting
mistakes, the most common flag error, and the value is the caller's own argv —
already in process args and shell history. Bounded, and documented in the file.
Not fixed here, filed separately: --agent-mode / --agent-mode=false is inert
because InitAgentMode latches before flag parsing. Verified present on master,
so not a regression from this work.
Verified: 20 tests in flag-ergonomics, 6 of the 7 new ones failing against the
pre-review branch (the 7th guards human mode, which already had the cap). Full
suite 66 passing with the same 11 pre-existing failures as master. Non-flag
human output still byte-identical to master.
There was a problem hiding this comment.
🟡 Changes recommended
The raw-text retry failure path reports the wrong underlying error (hiding the actual retry failure), and a new test helper does not fully isolate agent-detection env vars, risking flaky mode-dependent assertions.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Improves CLI ergonomics at the “flag value parsing” boundary by (1) accepting raw prose for certain JSON-typed string destinations, (2) surfacing actionable, structured flag parsing errors (especially in agent mode), (3) preventing backticks in OpenAPI-derived descriptions from breaking help placeholders, and (4) ensuring agent-mode errors are emitted exactly once.
Changes:
- Add raw-text fallback for JSON flags whose destination unwraps to a Go
string, while keeping structured JSON flags strict and improving mismatch diagnostics. - Introduce a typed
FlagValueErrorand render it via the existinginternal/outputmachinery for consistent agent-mode JSON envelopes. - Neutralize backticks in flag descriptions so help placeholders show the flag’s real type, and suppress duplicate stderr printing via
AlreadyReported.
File summaries
| File | Description |
|---|---|
| test/flag-ergonomics.test.ts | New integration-style tests covering raw-text fallback, actionable errors, help placeholder behavior, and bounded echoing. |
| test/errors-teach.test.ts | Tightens agent-mode assertions to require pure JSON stderr and single-print behavior. |
| internal/output/reported.go | Adds AlreadyReported wrapper to prevent stderr duplication while preserving Unwrap. |
| internal/output/output.go | Returns AlreadyReported(err) after printing error JSON to avoid double-printing from main.go. |
| internal/output/agentmode.go | Wraps returned errors with AlreadyReported so agent-mode envelopes aren’t duplicated. |
| internal/flagutil/rawtext.go | Implements string-destination raw-text retry + improved structured-flag error shaping/hints. |
| internal/flagutil/metadata.go | Routes JSON unmarshal failures through raw-text retry / typed errors; neutralizes description backticks at registration. |
| internal/flagutil/flagerror.go | Introduces FlagValueError with bounded, rune-aware value truncation for safe echoing. |
| internal/flagutil/description.go | Replaces backticks in descriptions to prevent pflag placeholder hijacking. |
| internal/cli/root.go | Wraps execution with flag-value error rendering for agent mode. |
| internal/cli/flagerrors.go | Converts FlagValueError into output.AgentModeError envelope in agent mode. |
| cmd/vf/main.go | Skips printing errors with an empty message to prevent double 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
Suppressed comments (1)
internal/flagutil/rawtext.go:107
- Same issue as above: the non-pointer retry path reports the original parse error (cause) instead of the retry error, so the user/dev never sees why the quoted retry failed.
target := reflect.New(fieldType)
if err := utils.UnmarshalJsonFromString(string(quoted), target.Interface(), m.Annotations); err != nil {
return rawTextRetryFailed(m, val, cause)
}
- 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.
| if isPtr { | ||
| holder := reflect.New(reflect.PtrTo(fieldType)) | ||
| holder.Elem().Set(reflect.New(fieldType)) | ||
| if err := utils.UnmarshalJsonFromString(string(quoted), holder.Interface(), m.Annotations); err != nil { | ||
| return rawTextRetryFailed(m, val, cause) | ||
| } | ||
| field.Set(holder.Elem()) | ||
| return nil | ||
| } |
There was a problem hiding this comment.
Confirmed and fixed in c37b670.
The retry error was shadowed and cause passed in its place, so a message whose only job is explaining why the quoted retry failed instead printed the original parse error — "this text is not JSON". That is the precondition for reaching this path at all, so it was true, expected, and useless for the one case the diagnostic exists for.
rawTextRetryFailed now takes the retry error and reports that; the original is kept as the wrapped Cause so the full sequence is still recoverable.
| function run(args: string[], opts: { agentMode?: boolean } = {}) { | ||
| const env: Record<string, string | undefined> = opts.agentMode | ||
| ? { CLAUDECODE: '1' } | ||
| : { CLAUDECODE: undefined, CLAUDE_CODE: undefined, CURSOR_AGENT: undefined }; | ||
| return execa({ reject: false, timeout: 20_000, stdin: 'ignore', env, extendEnv: true })(VF, args); | ||
| } |
There was a problem hiding this comment.
Confirmed and fixed in c37b670 — and it was not theoretical.
internal/output/agentmode.go treats twelve variables as "an agent is driving this". The helper cleared three. I reproduced it: with GITHUB_COPILOT=1 in the environment, the old helper failed 3 of 20 tests. The new one passes 20/20 with GITHUB_COPILOT, CODEX and AMAZON_Q all set.
The list is duplicated from Go into TypeScript and can drift, so tests that depend on which renderer ran now assert the mode explicitly. A thirteenth variable upstream fails loudly naming the cause, rather than quietly testing the wrong renderer.
effervescentia
left a comment
There was a problem hiding this comment.
changes to the type of a field (here accepting a string for Markup fields) should happen at the API level, not by modifying the generated flags downstream in the CLI
…OR-13656) Both from Copilot's review of this PR. Both verified before fixing, and both real. 1. rawTextRetryFailed reported the wrong error. The retry's own error was shadowed and discarded, and the original parse error was passed in its place. So a message whose entire purpose is to explain why the quoted retry failed instead printed "this text is not JSON" — true, expected, and useless, since not-JSON is the precondition for reaching that path at all. This path only fires when stringLikeJSONTarget and the SDK unmarshaler disagree, which is a CLI bug, so the diagnostic is the only thing a reporter has to go on. The retry error is now what gets reported; the original is kept as the wrapped Cause. 2. The human-mode test helper cleared 3 of 12 agent-detection variables. internal/output/agentmode.go treats twelve environment variables as "an agent is driving this", including GITHUB_COPILOT and CODEX. The helper cleared CLAUDECODE, CLAUDE_CODE and CURSOR_AGENT only, so on any machine with one of the other nine set, human-mode tests ran against the agent renderer. Demonstrated rather than assumed: with GITHUB_COPILOT=1 in the environment, the old helper failed 3 of 20 tests. The new one passes 20/20 with GITHUB_COPILOT, CODEX and AMAZON_Q all set. The list is duplicated from Go into TypeScript and can drift, so tests that depend on which renderer ran now assert the mode explicitly. A thirteenth variable appearing upstream fails loudly with a message naming the cause, instead of quietly testing the wrong renderer.
|
Splitting this rather than arguing it — @effervescentia's objection is isolated to one of the four changes, so the other three should not wait on it.
On the objection itself, for the record here as well as on #24: I could not reproduce the premise.
So there is nothing to change at the API level: the spec is already correct. What is awkward is the codegen mapping from "nullable string" to a JSON-encoded flag. If you would still rather not carry that in the CLI, I am happy to drop #24 — #23 stands on its own and carries most of the value. Closing this in favour of the two. |
…OR-13656) Split out of #21 as the contested half, stacked on the errors-only PR so it can be judged on its own. Fields marked `nullable: true` generate as FlagKindJSON, because JSON is the only encoding that expresses a real null distinctly from the string "null". Correct, and the reason --instructions null clears the field. The cost is that prose has to arrive JSON-quoted, and the workaround is quotes inside quotes — exactly what shell users and coding agents get wrong: vf agent update --instructions '"You are a support agent for Acme."' When a JSON flag fails to parse and its destination ultimately holds a Go string, the value is re-encoded as a JSON string and retried. 22 of 82 JSON flags qualify. Nullability is untouched: the null branch runs earlier. On the review objection — that this accepts a string for Markup fields, and so belongs at the API level rather than downstream in the generated CLI. Both halves are testable, and both are now pinned by tests rather than argued: No field's type changes. The retry only fires where the spec already declares `type: string`; the request body is byte-identical to what the JSON-quoted form produced, and to master. This is how the CLI reads a flag, not what the API accepts. Verified across plain text, quoted JSON, null, objects, arrays, empty and numeric input. Markup is unreachable. Markup is a union struct, and every field holding one holds it as a struct or a slice of them; stringLikeJSONTarget admits neither. --url on mcp-server create is []components.Markup and still rejects raw text. There is also no property in .speakeasy/out.openapi.yaml that references Markup at all. There is correspondingly nothing to fix upstream: the spec is already right. What is awkward is the mapping from "nullable string" to a JSON-encoded flag, which is codegen, not API design. The discriminator has to unwrap OptionalNullable[T], which is map[bool]*T and so indistinguishable from map[string]any by Kind alone. Requiring a bool key admits OptionalNullable[string] and rejects every ordinary map. Unit-tested against all 13 shapes that occur, including map[string]string and OptionalNullable[[]string]. The trade, stated plainly: for those 22 flags, malformed JSON that used to error is now stored as literal text. Right for markdown authors, wrong for someone who meant JSON and typo'd. Structs, maps and slices are excluded, so a mistyped object still fails loudly. One test from the errors-only PR is updated rather than kept: it asserted that --instructions rejects prose and explains how to quote it. That rejection is what this change removes, so the test now pins the behaviour that replaced it. Verified: 7 new tests plus the updated one; full suite shows the same pre-existing failures as master.
Why
An unattended coding agent, given only an access token and the README, built a working support agent and held a real conversation — but it took ~45 minutes. About 3 of those were the quickstart. The other ~42 were spent fighting the CLI itself.
This fixes four defects from that session. All of them live at the same moment: when a flag value is typed.
What changes
1. Flags that hold prose now accept prose
The field holds markdown. It is
FlagKindJSONbecause the spec marks itnullable, and JSON is the only encoding that distinguishes a realnullfrom the string"null"— that is correct and is preserved. But nothing told the caller to wrap prose in JSON quotes, and the error named the first letter of their sentence.An unparseable value is now retried JSON-quoted, but only when the destination unwraps to a Go string — 22 of 82 JSON flags. Structs, maps and slices still fail loudly, because prose is meaningless there.
The discriminator has to unwrap
OptionalNullable[T], which ismap[bool]*Tand so indistinguishable frommap[string]anybyKindalone. Requiring a bool key admitsOptionalNullable[string]and rejects every ordinary map. Unit-tested against all 13 shapes that occur, including the near-missesmap[string]stringandOptionalNullable[[]string].The trade, stated plainly: for those 22 flags, malformed JSON that used to error is now stored as literal text. Right for markdown authors, wrong for someone who meant JSON and typo'd.
2. Rejection messages are actionable
Flag errors never reached
output.Error— they returned raw from the generatedRunEand printed via a bareFprintln. A mistyped flag got one unstructured line while an API 401 got a structured envelope. Backwards, since mistyped flags are the more common failure.flagutilcannot importoutput(outputalready importsflagutil), so failures carry a typedFlagValueErrorup toExecute, which renders it.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. Our descriptions come from the OpenAPI spec, where backticks are emphasis:
agent update--instructions Name--instructions stringtranscript search--version-param environmentAlias--version-param string12 flags affected. The worst is
--version-param, whose only legal values aredraftandpublished, labelled with an unrelated parameter name. Fixed at the single point descriptions enter cobra, so a 13th flag is covered automatically.--usageoutput is byte-identical — it was already correct.4. Agent-mode errors print exactly once
Every printer in
internal/outputwrites to stderr and returns the error, andmain.goprinted whateverExecutereturned. So one failure reached stderr twice.AgentModeError's own doc comment promised the opposite — butmain.gois 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 after a raw API body, so suppressing it would remove the most useful part rather than a duplicate. Verified byte-identical to master.
Design notes
Footprint on generated files is 12 insertions and 7 deletions across five files. All real logic lives in six new non-generated files. That was the binding constraint — regenerations land regularly (
v0.234.0was one), so every touched line of generated code is carried forever.An OpenAPI overlay was assessed for items 1 and 3, and rejected. It would pin 12 descriptions verbatim so upstream rewording is silently overwritten and a 13th flag is missed; and fixing item 1 that way means deleting
nullable, trading away the ability to clear a field. Persisted-edit durability was confirmed first — all four pre-existing edits survived thev0.234.0regeneration intact.Adversarial review
A 53-agent hostile review ran against the built binaries, with every finding independently verified by two skeptics instructed to refute it. 23 findings, 12 refuted, 11 survived, 3 blocking. All three blocking findings were real defects in my own code, and all three are fixed in
a70fe3a:The suggested example was one the CLI rejects. An object example was hardcoded for every structured flag, so on array-valued flags the hint told the caller to pass a value the same binary refuses — following it exactly reproduced the identical error. A loop, for the audience this PR exists for. The example is now derived from the destination kind, and an unrecognized shape yields no example at all.
Valid JSON of the wrong shape was reported as invalid JSON.
--llm '[1,2]'parses fine. The decoder's real explanation was stored inCauseand never rendered on any path.Agent mode bypassed the truncation written for exactly this. Measured: master 118 B, human 311 B, agent 5,329 B for a 5,000-char value and 400,331 B for a 400 KB one. It also echoed a credential from a malformed blob that master never echoed.
Plus two non-blocking: byte-slicing split multi-byte runes (200 Japanese characters reported as 600), and a stale comment.
Residual, deliberately kept: up to 120 characters of the value are still echoed where master echoed none. The echo is what reveals shell-quoting mistakes, and the value is the caller's own argv — already in process args and shell history. Bounded and documented.
Verification
--instructions nullstill sends a real null, quoted JSON is unchanged, object flags still reject.go build,go vet,gofmtclean. Non-flag human output byte-identical to master.Found but not fixed
--agent-mode/--agent-mode=falseis inert:InitAgentModelatches atroot.go:222before flags parse, so the post-parse call returns early. Only env vars work. Verified present on master — not a regression from this work, and worth its own ticket.