Skip to content

feat(agent-bff): expose the action execute endpoint with result normalization#1752

Open
nbouliol wants to merge 6 commits into
mainfrom
feature/prd-674-expose-the-action-execute-endpoint-with-result-normalization
Open

feat(agent-bff): expose the action execute endpoint with result normalization#1752
nbouliol wants to merge 6 commits into
mainfrom
feature/prd-674-expose-the-action-execute-endpoint-with-result-normalization

Conversation

@nbouliol

@nbouliol nbouliol commented Jul 10, 2026

Copy link
Copy Markdown
Member

What

Exposes POST /v1/:collection/actions/:action/execute on the BFF, stacked on the action-form PR (#1748). It rejects unknown submitted fields, runs the Smart Action, and normalizes the agent result into one flat wrapper — success / error / webhook / redirect — returning a structured 501 for File results, which are unimplemented in v1.

Fixes PRD-674

Base branch is feature/prd-673-expose-the-action-form-endpoint (stacked). Review after #1748 merges; rebase onto main at that point.

How

agent-client (2 additive, backward-compatible changes — a version bump ships with this, so rollback is no longer "redeploy agent-bff alone"):

  • ActionFormValidationError now carries html; toActionError forwards it from the native action Error payload ({ error, html }). Without this the html is dropped before reaching the BFF.
  • setFields throws a typed UnknownActionFieldError (same message as before) so callers route an unknown field to a client error instead of a generic 500. Existing consumers (mcp-server, workflow-executor) catch generically and are unaffected.

agent-bff:

  • action-execute-mapper.ts — pure mapper: the agent 200 payload is discriminated positively (webhook / redirect / success); any unrecognized body (a streamed File) falls through to a 501 unsupported_action_result. HttpRequester does not expose response headers, so Content-Disposition cannot be read — the fall-through is the only viable File detection.
  • agent-action-client.ts — a single loadAction method (form and execute load the same agent-client Action object); Action extends ActionForm.
  • action-routes-middleware.ts — one middleware matching both /form and /execute with shared body parsing and action resolution. execute() gets its own try/catch (it cannot use the generic callAgent, which would mislabel the action-Error 400 as a transport 502): UnknownActionFieldError → 400 invalid_request, ActionFormValidationError → 400 { type: "error", ..., html }, ActionRequiresApprovalError → 403 action_requires_approval, transport 5xx → agent_unavailable.
  • bff-local-errors.ts — new actionRequiresApproval (403).

Result shapes

Agent result HTTP BFF body
Success 200 { type: "success", message, invalidated: [...], html }
Error (native) 400 { type: "error", status: 400, message, html }
Webhook 200 { type: "webhook", url, method, headers, body }
Redirect 200 { type: "redirect", path }
File 501 { error: { type: "unsupported_action_result", status: 501 } }

Out of scope / follow-ups

  • Approval-gated actions are not wired (no createApprovalRequest); they surface as 403 action_requires_approval (with roleIdsAllowedToApprove). Full wiring depends on the auth foundation (PRD-637) providing a per-mode Forest-server token + rendering context, and would add an approvalRequested wrapper shape. To confirm with the spec author.
  • action_not_allowed (403) still has no local trigger — every non-exposed action maps to 404, mirroring the collection/relation routes (existing TODO).

Tests

  • agent-client: html carry on the error path, typed UnknownActionFieldError from setFields.
  • agent-bff: pure execute mapper (all shapes + 501), the /execute route (missing recordIds, unknown field, action Error + html, approval, File, unknown action, transport 5xx vs action-Error 400, success/webhook/redirect), and the shared loadAction.
  • No regression in setFields consumers: mcp-server (24) and workflow-executor adapter (63) green.

🤖 Generated with Claude Code

Note

Expose action execute endpoint in agent-bff with result normalization

  • Adds a POST /agent/v1/:collection/actions/:action/execute route in action-routes-middleware.ts that calls setFields then execute on the action, dispatching to handleExecute alongside the existing handleForm handler.
  • Introduces action-execute-mapper.ts to normalize raw execute results into typed BFF responses: webhook, redirect, or success (with invalidated/html); unrecognized shapes return a structured 501 unsupported_action_result.
  • Maps execution errors to structured HTTP responses: UnknownActionFieldError → 400 invalid_request, ActionFormValidationError → 400 with optional html, ActionRequiresApprovalError → 403 action_requires_approval with optional roleIdsAllowedToApprove.
  • Renames loadActionForm to loadAction in agent-action-client.ts and extends the returned Action interface with setFields and execute.
  • In agent-client, setFields now throws UnknownActionFieldError instead of a generic Error for unknown fields, and ActionFormValidationError now carries an optional html property forwarded from 400/422 responses.

Macroscope summarized 6763260.

@linear-code

linear-code Bot commented Jul 10, 2026

Copy link
Copy Markdown

PRD-674

@qltysh

qltysh Bot commented Jul 10, 2026

Copy link
Copy Markdown

6 new issues

Tool Category Rule Count
qlty Structure Function with many returns (count = 4): mapActionExecuteResult 2
qlty Structure Function with high complexity (count = 13): mapActionExecuteResult 2
qlty Structure Function with many parameters (count = 7): handleForm 2

Comment thread packages/agent-bff/src/action/action-execute-mapper.ts Outdated
Comment thread packages/agent-bff/src/action/action-routes-middleware.ts Outdated
Base automatically changed from feature/prd-673-expose-the-action-form-endpoint to main July 15, 2026 08:14
nbouliol and others added 3 commits July 15, 2026 10:19
…eld error

Carry the native action Error result's html on ActionFormValidationError and
throw a typed UnknownActionFieldError from the strict setFields, so consumers
can render error markup and route unknown fields to a client error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lization

Serve POST /v1/:collection/actions/:action/execute: reject unknown submitted
fields via setFields, run the action, and normalize the agent result into a
flat wrapper (success/error/webhook/redirect), returning 501 for File results.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Validate value shapes (not just key presence) when normalizing the execute
result so malformed payloads fall through to 501 instead of a null-field 200,
and keep an empty roleIdsAllowedToApprove array in the approval 403 details.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nbouliol
nbouliol force-pushed the feature/prd-674-expose-the-action-execute-endpoint-with-result-normalization branch from 911b16e to c4f48d5 Compare July 15, 2026 08:20
@ShohanRahman

Copy link
Copy Markdown
Contributor

Review — key points

Multi-perspective review (correctness, tests, error-handling, types, comments, simplification). No Critical/blocking bugs. Verified the mapper's wire-shape discrimination (success/redirectTo/webhook/refresh) matches the agent serialization in agent/src/routes/modification/action/action.ts:122-140, and the handleExecute error routing is sound.

Missing test coverage (worth adding)

  1. setFields catch → mapAgentError fallback is untestedaction-routes-middleware.ts:99-104. Tests only reject setFields with UnknownActionFieldError (→400); no test drives a non-unknown-field rejection (e.g. an AgentHttpError from the change-hook) through the mapAgentError branch. The path is correct (setFieldValue throws raw AgentHttpError, never ActionFormValidationError), but the branch is uncovered.
  2. ActionRequiresApprovalError without roleIdsAllowedToApproveaction-routes-middleware.ts:114-121. The details-omitted branch (403 body with no roleIdsAllowedToApprove) has zero coverage; tests only pass [7,9]/[].
  3. Non-array refresh.relationships fallbackaction-execute-mapper.ts:70,77. The : [] guard that malformed payloads exist to exercise is never tested (refresh: {} or relationships: 'x').
  4. html-absent on the error pathaction-routes-middleware.ts:125. error.html ?? null is only tested with html present; the null-default on the 400 error body is uncovered (the success-path default is covered).

Advisory — worth a conscious decision before merge

  • Inconsistent error-body shape for action-validation failuresaction-routes-middleware.ts:123-128. This branch hand-builds a flat { type: 'error', status, message, html }, bypassing the { error: { type, status, message, details? } } convention used by every other BFF error (including actionRequiresApproval a few lines above). A client parsing body.error.type uniformly breaks only for this case. It's deliberate (asserted at test :499) — flagging for error-contract consistency.
  • No logging on the 501 fall-throughaction-execute-mapper.ts:83. Any unrecognized payload becomes a generic 501 with no record of the raw body; a Warn log would help diagnose a File result or a new agent result type reaching this path.
  • Comment at action-execute-mapper.ts:37 slightly misstates the reason shape-sniffing is needed: the BFF's own execute() is typed Promise<unknown> (agent-action-client.ts:26), which is the real driver — not agent-client's typing.

🤖 Generated with Claude Code

… fall-through

Add the setFields agent-error fallback, approval-without-roles, error-body
html-absent, and non-array refresh.relationships cases; log the unrecognized
payload keys when execute maps to 501; correct the mapper rationale comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nbouliol

Copy link
Copy Markdown
Member Author

Addressed in 308d861.

Test coverage (1–4): all four branches now covered — setFields AgentHttpErrormapAgentError (422), approval without roleIdsAllowedToApprove (details omitted), non-array/absent refresh.relationships[], and html-absent → null on the error body.

501 fall-through: added a Warn logging the payload keys (in the middleware, keeping the mapper pure).

Mapper comment: corrected — the real driver is Action.execute(): Promise<unknown> at the BFF boundary, not agent-client's typing.

Kept — flat error body shape ({ type: 'error', status, message, html }): intentional, not an oversight. This branch is not a BFF error envelope — it's the normalized action-result wrapper mandated by the PRD (API Contract: success/error/webhook/redirect share one flat { type, ... } shape; File → 501). A client reads body.type to discriminate the action outcome, distinct from transport/BFF errors which keep the { error: { type, status, ... } } envelope. Aligning it to the envelope would break the documented contract, so it's left as-is and asserted in the tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@qltysh

qltysh Bot commented Jul 16, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

⬆️ Merging this pull request will increase total coverage on main by 0.01%.

Modified Files with Diff Coverage (6)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/agent-client/src/errors.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-client/src/domains/action.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-client/src/index.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/action/action-routes-middleware.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/http/bff-local-errors.ts100.0%
New Coverage rating: A
packages/agent-bff/src/action/action-execute-mapper.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

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.

2 participants