Skip to content

fix(cli): surface input schema issues and undeclared API response bodies - #43

Merged
robinbraemer merged 2 commits into
mainfrom
fix/generated-input-errors
Aug 14, 2026
Merged

fix(cli): surface input schema issues and undeclared API response bodies#43
robinbraemer merged 2 commits into
mainfrom
fix/generated-input-errors

Conversation

@robinbraemer

Copy link
Copy Markdown
Member

Summary

Two error-reporting fixes for generated public API commands.

1. Input schema decode failures now name the offending fields

Before, any input that failed the generated Effect Schema decode rendered only:

error:
  type: input_error
  code: AKUA_INPUT_INVALID
  message: Input for workspaces.listMembers does not match the public API contract.

mapGeneratedFailure discarded the Schema.SchemaError, and nothing told the user about the {"path":{...},"query":{...},"headers":{...},"body":{...}} input envelope.

Now:

error:
  type: input_error
  code: AKUA_INPUT_INVALID
  message: Input for workspaces.listMembers does not match the public API contract: path.id: Missing key.
  next_steps[1]{command,description}:
    echo '{"path":{"id":"<id>"}}' | akua workspaces list-members --input -,"Pass a JSON envelope whose keys mirror the OpenAPI parameter locations: {\"path\":{...},\"query\":{...},\"headers\":{...},\"body\":{...}}."
  • GeneratedCommandFailure carries structured issues (path segments + message) produced by Effect's SchemaIssue.makeFormatterStandardSchemaV1() formatter, plus the command name and a runnable --input example derived from the generated command registry's required parameters.
  • The generated executor now wraps each envelope-section decode with a SchemaIssue.Pointer (atEnvelopeKey) so issue paths are prefixed with the envelope key (path.id, body.secret, ...), and decodes an absent path section as {} so required path params surface as precise Missing key issues instead of a bare Expected object. Parse options do not enable reportInput, so request values stay redacted from errors.
  • Invalid JSON input now reports the JSON syntax error instead of the generic contract message.

2. Undeclared API statuses no longer drop the server's error body

When the API returns a status not declared in the OpenAPI contract (real case: 501 from GET /workspaces/{id}/members), the client fails with an HttpClientError and the CLI printed only AKUA_API_ERROR: The public API rejected the request. with no server detail.

Now the failure path reads the undecoded response body (truncated to 2000 chars), exposes it in the error response field, and — when a message can be safely extracted from the body JSON (errors[0].message or top-level message, validated via Schema) — uses it as the error message.

Notes

Testing

  • bun test — 153 pass, 0 fail (6 new tests covering issue details, excess-property naming, rendered next steps, undeclared-status JSON body extraction, non-JSON truncation, and rendering)
  • bun run build — tsc --noEmit + bundle clean
  • bun run generate:check — regenerated executor is in sync
  • Manual: bun src/bin/akua.ts workspaces list-members output shown above

Rationale: Generated commands collapsed every non-response failure into a
generic "does not match the public API contract" message, hiding the Effect
Schema issue paths (e.g. a missing path.id) and the JSON input envelope
contract; undeclared API statuses (e.g. 501) dropped the server's error body
entirely. Input failures now carry structured issue paths (the generated
executor prefixes each envelope-section decode with its key via
SchemaIssue.Pointer and decodes absent path sections as {} so required
fields are named), render an actionable message plus a runnable --input
example derived from the command registry, and undeclared-status failures
retain the raw response body (truncated to 2000 chars) plus an extracted
server message.

Tested: bun test (153 pass), bun run build, bun run generate:check, and
manual "bun src/bin/akua.ts workspaces list-members" naming path.id with a
runnable next step.
Rationale: Review of the input/undeclared-status error work surfaced nine
confirmed gaps. Input examples now follow the operation's real body
contract: generate-commands.ts projects requestBody presence/requirement
plus placeholder examples for required top-level fields (one-level $ref
resolution, enum-first/typed placeholders) into the registry, replacing the
HTTP-method heuristic that suggested guaranteed-to-fail envelopes. Mode
detection treats an unknown isTTY (undefined on piped stdout in Node/Bun)
as non-interactive so piped consumers get structured output in any
environment, not only when agent/CI env vars leak in. Undeclared-status
bodies are decoded against ApiErrorResponse first (structured AKUA_API_<n>
codes), message extraction runs before truncation and prefers
errors[0].message with a top-level-message fallback, raw bodies render
wrapped ({raw}) and newline-flattened so agent output stays line-oriented
and the JSON response field stays object-typed. Stream failures reuse the
same effectful classification; HttpClientErrors after a 2xx/3xx are
transport, not api. Unknown internal failures classify as a new internal
reason instead of masquerading as user input errors, and the failure
classifier is single-sourced (describeGeneratedFailure enriches the mapped
result instead of mirroring its conditions). The generator's optional-body
decoder template is deduplicated.

Tested: bun test (160 pass) in both the normal shell and
env -u CLAUDECODE -u CLAUDE_CODE -u CI -u AGENT; bun run build;
bun run generate:check; manual proofs that the suggested examples pass
input decode for workspaces.listMembers, orderDrafts.create (bodyless
POST), and workspaces.addMember (required body), and that an undeclared
501 carrying an ApiErrorResponse envelope renders AKUA_API_9001 with the
server message.

Not-tested: operations whose OpenAPI paths embed ":action" suffixes (for
example /offers/{id}:archive) fail URL compilation inside the upstream
HttpApiClient before any request; reproduced on main, pre-existing and out
of scope here.
@robinbraemer

Copy link
Copy Markdown
Member Author

Pushed e1bd78d addressing all findings from the adversarial review:

  1. Body-aware input examplesgenerate-commands.ts now projects requestBody presence/requirement and placeholder examples for required top-level fields (one-level $ref resolution; enum-first and type-matched placeholders) into the command registry as a body field. inputExampleFor uses that instead of the HTTP-method heuristic, so bodyless POSTs no longer suggest a body key and required-body ops get a decode-passing skeleton.
  2. Mode detection for piped stdout — Node/Bun report isTTY as undefined (not false) for pipes; mode.ts now treats anything short of a confirmed TTY as non-interactive and ConsoleLive normalizes the value. The structured-output tests now pass in a clean shell (env -u CLAUDECODE -u CLAUDE_CODE -u CI -u AGENT bun test), not just when agent/CI env leaks into the child.
  3. Message extraction before truncation — the full response text is read once; responseMessage is extracted from the untruncated body and only the stored responseBody is capped at 2000 chars.
  4. Structured undeclared-status envelopes — undeclared-status bodies are decoded against Schema.fromJsonString(ApiErrorResponse) first, populating apiError (so a 501 with a real error envelope renders AKUA_API_9001 + structured response), with the loose message extraction as fallback.
  5. Stream failures share the classifier — the stream path now routes causes through the same effectful describeGeneratedFailure; an HttpClientError after a 2xx/3xx (e.g. a dropped SSE stream at status 200) classifies as transport, not api.
  6. errors[0].message first, top-level message fallback{"errors":[],"message":"..."} now extracts the top-level message.
  7. Normalized raw response rendering — raw bodies render as { raw: <newline-flattened string> }, keeping agent output line-oriented and the JSON response field object-typed.
  8. internal reason — unknown internal failures (executor/registry drift) classify as internal_error / AKUA_CLI_INTERNAL (exit 1) instead of masquerading as input errors with a misleading example.
  9. Single-sourced classificationdescribeGeneratedFailure now classifies via mapGeneratedFailure once and enriches only when reason === "api" with no decoded apiError and a readable response; the negated mirror is gone.
  10. Generator deduprenderPartDecoder builds the decode expression once; the optional-body branch only prepends the undefined guard. Regenerated.

All gates: bun test 160/160 in both normal and scrubbed env, bun run build, bun run generate:check, plus manual proofs (input examples pass decode for workspaces.listMembers, orderDrafts.create, workspaces.addMember; simulated undeclared 501 renders AKUA_API_9001).

Pre-existing issue found while proving, out of scope here: ops whose paths embed :action suffixes (e.g. /offers/{id}:archive) fail URL compilation inside upstream HttpApiClient (Missing path parameter: archive) before any request — reproduced on main.

@robinbraemer
robinbraemer merged commit 9576a47 into main Aug 14, 2026
7 checks passed
@robinbraemer
robinbraemer deleted the fix/generated-input-errors branch August 14, 2026 21:23
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