Skip to content

feat(cli): structured error envelope (extra + next_steps on the wire)#21

Merged
vvillait88 merged 3 commits intomainfrom
structured-error-envelope
May 7, 2026
Merged

feat(cli): structured error envelope (extra + next_steps on the wire)#21
vvillait88 merged 3 commits intomainfrom
structured-error-envelope

Conversation

@vvillait88
Copy link
Copy Markdown
Contributor

Summary

CliError carries extra (structured recovery context) and nextSteps (action + suggestion) for deterministic agent recovery, but neither field reached the wire. The bridge to incur's IncurError accepts only {code, message, hint?, retryable?, exitCode?} and incur's renderer surfaces {code, message, retryable, fieldErrors?}. Agents reading --json got code and message but never the structured context they needed (e.g. extra.valid_keys, extra.chain, extra.held_chains, next_steps.action).

This PR wraps cli.serve() so the originating CliError is captured before the bridge erases it, then re-emits the wire envelope through incur's own Formatter.format(...) with extra and next_steps as proper fields. Format negotiation (toon/json/yaml/md/jsonl) and --full-output wrapping stay consistent with the success path.

Before:

{ "code": "no_wallet", "message": "No keystore for base (X).", "retryable": false }

After:

{
  "code": "no_wallet",
  "message": "No keystore for base (X).",
  "retryable": false,
  "extra": { "chain": "base", "name": "X" }
}

Full-output JSON parses incur's actual envelope and swaps just the error body, preserving the real meta.command (e.g. "wallet remove") and meta.duration.

Carve-outs (intentional passthrough — no enrichment)

Path Reason
--mcp stdio mode Loops forever; per-tool-call enrichment isn't reachable, post-serve enrichment would emit a stale envelope after session close
Human-TTY context (no explicit format/full-output) Preserve incur's friendly Error (code): message one-liner; multi-line TOON dumps with extras are an agent need, not a human one
--token-count / --token-limit / --token-offset Token / pagination flags ask incur for sized or sliced rendering; enrichment would defeat the purpose
Non-CliError errors (Parse, Validation, COMMAND_NOT_FOUND) Flow through incur unchanged

Sync-throw bypass (also fixed)

withCliErrors(() => fundEstimate({headers: parseHeaders(...)})) was a non-async arrow. A sync parseHeaders throw bypassed .catch() (the throw happened before the inner promise was returned), reaching incur as a raw Error → code: UNKNOWN. Switched to Promise.resolve().then(fn).catch(...) so both sync and async throws route through the same handler. All 21 non-async call sites are uniformly protected. Verified live: fund-estimate with malformed -H now correctly returns code: invalid_input instead of UNKNOWN.

Doc cleanup

Three pre-existing inaccuracies became false-claims-into-true-claims with this wrap:

  • README.md:73Errors emit { code, message, retryable, hint? } on stderr was wrong on both counts. Now states the real shape on stdout with extra? / next_steps?. The hint? field was never reachable by the renderer.
  • README.md:184/210 — Python and TypeScript code examples read the error envelope from stderr; switched to stdout and corrected the compact-mode shape (err['code'], not err['error']['code']).
  • agent-guide.ts:json_mode — same stderr / hint? mistake; corrected.

Plus a new contract entry: agent-guide now has a top-level error_envelope field with channel / shape / full_output_shape / human_tty_shape. Agents reading agent-guide --json get an explicit wire-shape contract alongside the existing identity_error_recovery and exit_codes blocks.

Pin

incur ^0.4.50.4.5. The wrap depends on incur internals (wire envelope shape, Formatter.format API, IncurError.Options, cli.serve overrides, Mcp.serve, formatHumanError, format flag precedence). Any minor / patch bump could silently shift any of these. Mirrors the viem 2.48.7 exact-pin pattern for load-bearing deps. Upgrade verification protocol captured in the team's incur-upgrade-checklist memory.

Tests

tests/error-envelope.test.ts — 67 tests across 9 describe blocks:

  • Compact JSON error envelope (10) — code+message+retryable, every extras source, next_steps, both-fields-present, omission when absent
  • Compact non-JSON formats (5) — TOON / YAML / MD / JSONL render extras and next_steps via Formatter.format
  • Full-output (3) — JSON parse-and-swap preserves meta.command + meta.duration; YAML synthesizes
  • Format precedence (3) — last-wins both directions, default TOON
  • Non-CliError passthrough (4) — COMMAND_NOT_FOUND, --version, --help, --llms
  • exitCodeForError mapping (27) — every ErrorCode → expected exit code
  • isRetryable (3) — retryable / non-retryable codes, RETRYABLE_CODES set consistency
  • Sync-throw protection (2) — fund-estimate + check with malformed -H
  • Token-flag passthrough (3) — --token-count, --token-limit, --token-offset on errors
  • Human-TTY passthrough (3) — TTY + no flag preserves formatHumanError; TTY + --json enriches; pipe + no flag enriches
  • Wrap-internal correctness (4) — success passthrough, full-output success, pendingError leak prevention, exit-code-1 across multiple error paths

Plus 2 new tests in tests/agent-guide.test.ts locking in the doc fixes.

Sabotage-verified non-spurious: temporarily clearing the pendingError stash makes 15 wrap-dependent tests fail; restoring makes all 67 pass.

Test plan

  • bun run typecheck clean
  • bun run lint clean
  • bunx vitest run — 434 pass / 2 skipped / 0 fail across 42 test files
  • Live binary smoke across 31+ scenarios — every output format, full-output mode, MCP short-circuit, human-TTY (via script), --version, --help, --llms, COMMAND_NOT_FOUND, and the sync-throw fix
  • Lefthook pre-commit (eslint) + pre-push (typecheck) both pass
  • CI green
  • Tag v0.1.0-rc.19 after merge to trigger npm publish

🤖 Generated with Claude Code

vvillait88 and others added 3 commits May 7, 2026 05:10
CliError already carries `extra: Record<string, unknown>` and
`nextSteps: { action, suggestion? }` for deterministic agent recovery,
but neither field reached the wire. The bridge from CliError to incur's
IncurError accepts only `{code, message, hint?, retryable?, exitCode?}`
and incur's wire renderer surfaces only `{code, message, retryable,
fieldErrors?}`. Result: agents reading `--json` output got `code` and
`message` but never the structured context needed to recover (e.g.
`extra.valid_keys`, `extra.chain`, `extra.held_chains`,
`next_steps.action`).

Wrap incur's `cli.serve()` so the originating CliError is captured
before the bridge erases it, then re-emit the wire envelope through
incur's own `Formatter.format(...)` with `extra` and `next_steps`
spread alongside the existing fields. Format negotiation (toon / json
/ yaml / md / jsonl) and `--full-output` wrapping stay consistent with
the success path. For `--full-output` JSON we parse incur's actual
output and swap just the `error` body, preserving the real
`meta.command` (e.g. "wallet remove") and `meta.duration`.

Carve-outs that pass through verbatim instead of enriching:
- `--mcp` stdio mode loops forever; per-tool-call enrichment isn't
  reachable, and a single post-serve enrichment would emit a stale
  envelope after the session closes
- Human-TTY context (no explicit format) preserves incur's friendly
  `Error (code): message` one-liner; multi-line TOON dumps are an
  agent need, not a human one
- Token / pagination flags (`--token-count`, `--token-limit`,
  `--token-offset`) ask incur for a sized or sliced rendering;
  enrichment would defeat the purpose
- Non-CliError errors (Parse, Validation, COMMAND_NOT_FOUND) flow
  through incur unchanged

Also fixes a sync-throw bypass: `withCliErrors(() =>
fundEstimate({headers: parseHeaders(...)}))` was a non-async arrow,
so a sync `parseHeaders` throw bypassed `.catch()` and reached incur
as a raw Error → `code: UNKNOWN`. Switched to
`Promise.resolve().then(fn).catch(...)` which routes both sync and
async throws through the same handler. All 21 non-async call sites
benefit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three pre-existing inaccuracies the wrap above made false-claims into
true-claims, plus one new contract entry agents now need.

README:
- 'Errors emit { code, message, retryable, hint? } on stderr' was wrong
  on both counts: errors emit on stdout (same channel as success), and
  `hint?` was always discarded by incur's renderer. Now states the real
  shape `{ code, message, retryable, extra?, next_steps? }` on stdout
  with a one-line description of what `extra` and `next_steps` carry.
- Python and TypeScript code examples were reading the error envelope
  from `proc.stderr` / `err.stderr`. Switched to `proc.stdout` /
  `err.stdout` and corrected `err['error']['code']` → `err['code']`
  (compact mode is flat, not envelope-wrapped).
- 'every byte on stdout is parseable JSON, every error on stderr…'
  rewritten to reflect that both success and error envelopes go to
  stdout; stderr is reserved for human-only chrome.

agent-guide:
- `json_mode` repeated the README's stderr / hint? mistake. Fixed to
  reference stdout, `extra`, and `next_steps`.
- Added a top-level `error_envelope` field with `channel`, `shape`,
  `full_output_shape`, and `human_tty_shape`. Agents that read
  agent-guide --json now have an explicit contract for the wire shape
  alongside the existing `identity_error_recovery` and `exit_codes`
  blocks.

Tests:
- Two regression tests in tests/agent-guide.test.ts lock in
  `error_envelope.channel === 'stdout'`, the shape descriptions, and
  that `json_mode` mentions stdout (not stderr) and references
  `extra` + `next_steps` (not `hint?`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The serveCli wrap depends on incur internals: the wire envelope
shape, `Formatter.format` API, `IncurError.Options`, `cli.serve`
overrides, `Mcp.serve`, `formatHumanError` rendering, and the
left-to-right last-wins format-flag precedence in
`extractBuiltinFlags`. Any minor or patch incur bump could silently
shift any of these.

Drop the caret to exact-pin incur at 0.4.5, mirroring the same pattern
viem 2.48.7 uses elsewhere in this repo for load-bearing deps where
internal behavior matters. The full upgrade verification protocol is
captured in the team's incur-upgrade-checklist memory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vvillait88 vvillait88 merged commit a2ab9af into main May 7, 2026
6 checks passed
@vvillait88 vvillait88 deleted the structured-error-envelope branch May 7, 2026 12:17
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.

1 participant