Skip to content

Commit 1f029a7

Browse files
fix(runtime): MCP run_action answers a declared action with no handler as /actions does, 404 RESOURCE_NOT_FOUND (#31408)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XSaaQXQ6J3byqVhvgRU927w)_ domain:server#6 · os-project-manager · session_01FEkQ5pVLKxR5eAhTYetnwR Fixes #31330 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:一个声明了但没有 handler 的动作("ghost" 动作),MCP bridge 以前抛一个不带 code 的 `Error('No handler registered …')`。现在改为抛出 `/api/v1/actions` 自己回答的那个路由未命中:同一句 `Action 'X' on object 'Y' not found`,`404`,`RESOURCE_NOT_FOUND`。 - 为什么改:自 #31271 起,`run_action` 通过 `/actions` 的同一个 reader(`actionsDoorAnswerOf`)作答,不带 code 的抛错被当成 `400 VALIDATION_ERROR`,等于告诉 agent 它的输入有错。而 REST 对同一个动作回 `404`。 - 风险与回滚:只改 runtime 里一处抛错(`action-execution.ts`)。只有 MCP 的 bridge 能走到这里,REST 及其它门的回答不变(runtime 全量绿,`/actions` 的 pin 都没改)。MCP 为这个未命中写的 `action_invocation` 行也随之变成 `404` / `RESOURCE_NOT_FOUND`,与 REST 写的那行一致。回滚即 revert。patch。 - 先看:`packages/runtime/src/action-execution.ts` 中 `!dispatch.dispatched` 那段;新测试 `packages/runtime/src/mcp-run-action-ghost-routing-miss.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run src/mcp-run-action-ghost-routing-miss.test.ts`,预期 6 条全绿。 - 需要你做的:无,由席位落地。 ## What changes - **`@objectstack/runtime` `action-execution.ts`.** When the MCP bridge (`invokeBusinessAction` → `dispatchAdmittedBusinessAction`) finds no handler for a declared script action, it now throws the routing miss as REST `/actions` answers it from its routing-miss branch (`domains/actions.ts`). The sentence is `Action 'X' on object 'Y' not found`, the status is `404`, and the code is `standardErrorCodeForHttpStatus(404)`, which is `RESOURCE_NOT_FOUND`. It used to throw `Error('No handler registered for action 'X' on 'Y'')` with no code. - **Sentence.** The order said to keep the producer's sentence unless `/actions` serves a different one. `/actions` does serve a different one, so the throw now uses the door's sentence, and the two doors answer the same bytes. No code reads the old sentence; the only readers were three test pins, which move here. | Ghost action (declared, no handler) | Before (main f4b4c8a) | After | | --- | --- | --- | | MCP `run_action` | `400 VALIDATION_ERROR`, `No handler registered for action 'X' on 'Y'` | `404 RESOURCE_NOT_FOUND`, `Action 'X' on object 'Y' not found` | | MCP `action_invocation` row | `failed`, `500`, `INTERNAL_ERROR` | `failed`, `404`, `RESOURCE_NOT_FOUND` | | REST `POST /api/v1/actions` (the measured target) | `404 RESOURCE_NOT_FOUND`, same sentence | unchanged | | CONTROL: action with a handler | runs on both doors | unchanged | | CONTROL: handler's coded refusal `RECORD_LOCKED` / 409 | code and status kept on both doors | unchanged | ## Tests - New `packages/runtime/src/mcp-run-action-ghost-routing-miss.test.ts`, 6 rows. One kernel serves both doors (`handleActions` and `buildMcpBridge().runAction`), so each pin is the agreement between them: - REST answers `404` / `RESOURCE_NOT_FOUND` / the routed-object sentence (the measured target). - The bridge's throw has the same code, status and sentence as the REST body. - `actionsDoorAnswerOf(thrown)` equals the REST body (code, status, message, no `declaredCode`). - Each door writes one `failed` invocation row with `404` / `RESOURCE_NOT_FOUND`. - CONTROL: with a handler bound, the action runs on both doors. - CONTROL: a handler's coded refusal keeps its code and status on both doors. - `mcp-list-actions-handler-probe.test.ts`: the two `rejects.toThrow(/No handler registered/)` pins now assert `{ code: 'RESOURCE_NOT_FOUND', status: 404 }`. - `packages/cli/test/package-install-local-handlers.integration.test.ts`: the ghost `run_action` pin moves from `VALIDATION_ERROR` / 400 to the exact envelope `{ code: 'RESOURCE_NOT_FOUND', status: 404, message: "Action 'ghost_task' on object 'tasks_app_task' not found" }`, measured over a real `os start` stack. - `resume_run` is not pinned: it continues a paused run through `automation.resume` and never reaches the script dispatch, so it cannot hit the miss. ## Acceptance notes - **Who reads the throw.** Only `domains/mcp.ts`'s bridge `runAction` calls `invokeBusinessAction`; REST `/actions` answers its own miss before any throw. No REST or other door's answer moves. `service-settings`' own "No handler registered … in 'ns'" sentence is a different producer and is unchanged. - **Past-tense comments left as they are.** `domains/mcp.ts` (~961), `app-artifact-handlers.ts` (~25) and `cloud-connection/src/marketplace-install-local-plugin.ts` (~5673) mention the old sentence as a measured history ("was listed … and refused there with …"). They are still true as history, and two of the files are outside this claim's surface. - **Clause-②: no.** A ghost action's `run_action` answer moves from an uncoded 400 to the coded miss `/actions` already serves. No new export, type or protocol member. ## Verification All on head `f841c43e` (base `f4b4c8ae`). <details><summary>Red first, fix, suites</summary> - Red first on base `f4b4c8ae`, the new runtime file: Tests 3 failed | 3 passed (6). The bridge threw `code: undefined`, `status: undefined`; `actionsDoorAnswerOf` gave `VALIDATION_ERROR` / 400; the MCP row recorded `500` / `INTERNAL_ERROR`. The REST pin and both CONTROLs were green. - With the fix: the new file and the probe file, Tests 12 passed (12). The runtime suite and the cli file below ran on `ce565b30`; `f841c43e` changes only those two runtime test files (a module-top import, one unused fake member), and both were rerun green on it. - `pnpm --filter @objectstack/runtime test`: Test Files 556 passed (556), Tests 8963 passed | 19 skipped; lock VERDICT command-exit 0. - `packages/cli/test/package-install-local-handlers.integration.test.ts` (after `--filter '@objectstack/cli...' build`): Test Files 1 passed (1), Tests 17 passed (17); lock VERDICT command-exit 0. - `typecheck` for `@objectstack/runtime` (on `f841c43e`) and `@objectstack/cli`: exit 0 (test layers held: runtime 27 / 186 / 66, cli 3 / 28 / 6). - Lint, as the root script runs it, scoped to the touched packages (`eslint packages/runtime/src packages/cli/test --no-inline-config`): exit 0. </details> <details><summary>Ablation (ablation-replace, restored to HEAD)</summary> - `action-execution.ts`: the `code` / `status` on the miss throw spread only under a condition that is never true (`String('ABL').length < 0`), so the throw goes back to uncoded. Mutation landed (anchor 1 to 0, blob bcee193 to eb08314860a9). The new file plus the probe file: Tests 5 failed | 7 passed (12). The 3 new parity rows and the 2 probe rows went red; the REST pin and the CONTROLs stayed green. Restored: blob == HEAD (bcee193), `git diff HEAD` empty. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `f841c43e`: 67 commands, each run with stdin from `/dev/null`, all exit 0. The first loop, on `ce565b30`, read two reds from the new test file, both fixed in `f841c43e`: `check:engine-double-contract` (an unused `findOne` on the engine fake, now dropped) and `check:test-source-alias` (the new file and the edited probe file now pay the `@objectstack/metadata` load at module top). `check:dual-build-cjs-loads` exited 3 on that first loop (PREREQUISITE NOT MET) and 0 on the full rerun. `dispatch-gates --ran`: 67 derived families accounted for, 67 run, 0 NOT-MEASURED. Not run locally (CI): `test:repo`, dogfood shards, type-check lanes, the rest of the cli integration tier. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5pVLKxR5eAhTYetnwR --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e6a0b72 commit 1f029a7

5 files changed

Lines changed: 216 additions & 15 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): MCP `run_action` answers a declared action with no handler as `POST /api/v1/actions` does, `404 RESOURCE_NOT_FOUND`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A declared `script` action with no handler bound anywhere (for example, an install-local package whose handlers never bound) got two different answers. `POST /api/v1/actions/:object/:action` answers it from its routing-miss branch: `404`, `RESOURCE_NOT_FOUND`, `Action 'X' on object 'Y' not found`. MCP `run_action` answered `400 VALIDATION_ERROR`, `No handler registered for action 'X' on 'Y'`, which told the agent its input was bad.
11+
12+
The MCP bridge threw that miss as an uncoded error. It now throws the routing miss as `/actions` answers it, with the same sentence, `404` and `RESOURCE_NOT_FOUND`, so `run_action` gives the same answer. The `action_invocation` row MCP writes for the miss moves the same way: it recorded `500` / `INTERNAL_ERROR`, and now records `404` / `RESOURCE_NOT_FOUND`, as the REST row does. A declared action with a handler, and a handler's own coded refusal, answer as before. No REST answer changes.

‎packages/cli/test/package-install-local-handlers.integration.test.ts‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
* declarations, and bound none of its handlers:
1515
*
1616
* - REST `POST /api/v1/actions/<obj>/complete_task` → 404 RESOURCE_NOT_FOUND
17-
* - MCP `run_action complete_task` → "No handler registered …"
17+
* - MCP `run_action complete_task` → "No handler registered …" (since
18+
* #31330 the same coded 404 the REST door answers)
1819
* - MCP `list_actions` → still lists complete_task
1920
* - a `beforeInsert` body hook → never ran (status null)
2021
*
@@ -388,15 +389,16 @@ describe('#21321: an install-local package runs its script actions and body hook
388389
it('list_actions does not advertise a declared action run_action cannot run', () => {
389390
const p = phases[name]!;
390391
expect(p.ghostRun.isError, 'precondition: ghost_task has no handler anywhere').toBe(true);
391-
// [#31271] `run_action` answers a throw through the `/actions` door's
392-
// reader (`actionsDoorAnswerOf`), so the bridge's uncoded "No handler
393-
// registered" throw arrives as that door's 400 rejection envelope, its
394-
// sentence intact. (`POST /api/v1/actions` answers the same ghost action
395-
// from its own routing-miss branch, a 404, before any throw.)
396-
expect(p.ghostRun.payload?.error, JSON.stringify(p.ghostRun.payload)).toMatchObject({
397-
code: 'VALIDATION_ERROR',
398-
status: 400,
399-
message: expect.stringMatching(/No handler registered/),
392+
// `run_action` answers a throw through the `/actions` door's reader
393+
// (`actionsDoorAnswerOf`), and the bridge throws a ghost action as the
394+
// coded routing miss `POST /api/v1/actions` answers from its own
395+
// routing-miss branch: 404, `RESOURCE_NOT_FOUND`, the same sentence.
396+
// It used to throw it uncoded ("No handler registered …"), which the
397+
// reader answered as the door's `400 VALIDATION_ERROR` rejection.
398+
expect(p.ghostRun.payload?.error, JSON.stringify(p.ghostRun.payload)).toEqual({
399+
code: 'RESOURCE_NOT_FOUND',
400+
status: 404,
401+
message: `Action 'ghost_task' on object '${OBJECT}' not found`,
400402
});
401403
expect(p.listed, 'advertised an action the run door refuses').not.toContain('ghost_task');
402404
});

‎packages/runtime/src/action-execution.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4793,7 +4793,18 @@ async function dispatchAdmittedBusinessAction(deps: ActionExecutionDeps,
47934793
deps, ql, objectName, resolveActionHandlerKeys(action), actionContext,
47944794
);
47954795
if (!dispatch.dispatched) {
4796-
throw new Error(`No handler registered for action '${name}' on '${objectName}'`);
4796+
// No key carried a handler: the routing miss the REST `/actions` door
4797+
// answers from its own branch (`domains/actions.ts`), thrown as that
4798+
// answer — its sentence, `404` and the closed code the status derives
4799+
// (`RESOURCE_NOT_FOUND`) — so `run_action`, which reads every throw
4800+
// through that door's reader (`actionsDoorAnswerOf`), answers it as
4801+
// the door does. Thrown uncoded ("No handler registered …"), the
4802+
// reader answered it as the door's `400 VALIDATION_ERROR` rejection,
4803+
// telling the agent its input was bad, and the invocation row recorded
4804+
// a `500`.
4805+
throw Object.assign(new Error(`Action '${name}' on object '${objectName}' not found`), {
4806+
code: standardErrorCodeForHttpStatus(404), status: 404,
4807+
});
47974808
}
47984809
// [#11519] Same doubled post-success-navigation diagnostic as the REST
47994810
// seam — the defect is a property of the authored action + handler pair,
@@ -5052,7 +5063,8 @@ export async function executeRegisteredAction(_deps: ActionExecutionDeps,
50525063
* MCP `list_actions` used to admit a script action on its declaration alone
50535064
* (`target || body`), so a declaration nothing had bound — measured: an
50545065
* `os package install`ed package, before its bodies were bound — was listed
5055-
* and then refused by `run_action` with "No handler registered".
5066+
* and then refused by `run_action` (then "No handler registered"; now the
5067+
* coded routing miss REST `/actions` answers, `404`).
50565068
*
50575069
* An engine that cannot enumerate its handlers (no `listRegisteredActions`)
50585070
* answers `false` for everything: the listing then cannot vouch for any script

‎packages/runtime/src/mcp-list-actions-handler-probe.test.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
* candidates over the engine's handler Map. The listing used to admit a script
1010
* action on DECLARATION presence (`target || body`), so a declaration nothing
1111
* had bound — measured: an `os package install`ed package — was advertised by
12-
* `list_actions` and refused by `run_action` ("No handler registered"), the
12+
* `list_actions` and refused by `run_action` (then "No handler registered";
13+
* now the coded routing miss REST `/actions` answers, `404`), the
1314
* NEG1 failure of the `ai.mcp-run-action-exposure-gate` checklist item.
1415
*
1516
* The listing now asks `registeredActionHandlerProbe`, which reads the engine's
@@ -21,6 +22,10 @@
2122
*/
2223

2324
import { describe, it, expect, vi } from 'vitest';
25+
// Pay the first transform of `@objectstack/metadata`, which the dispatcher's
26+
// source reaches through a dynamic `import()`, at MODULE LOAD, never inside a
27+
// clocked `it` (`scripts/check-test-source-alias.mjs`).
28+
import '@objectstack/metadata';
2429

2530
import { HttpDispatcher } from './http-dispatcher.js';
2631

@@ -85,7 +90,7 @@ describe('#21321: list_actions advertises a script action only when run_action c
8590
it('THE DEFECT — a declared body action with no registered handler is not advertised, and run_action refuses it', async () => {
8691
const bridge = makeBridge([]);
8792
expect(await listedNames(bridge), 'advertised an action the run door cannot dispatch').not.toContain('complete_task');
88-
await expect(bridge.runAction('complete_task', { recordId: 'r1' })).rejects.toThrow(/No handler registered/);
93+
await expect(bridge.runAction('complete_task', { recordId: 'r1' })).rejects.toMatchObject({ code: 'RESOURCE_NOT_FOUND', status: 404 });
8994
});
9095

9196
it('CONTROL — the same action, bound by name, is advertised and runs', async () => {
@@ -99,7 +104,7 @@ describe('#21321: list_actions advertises a script action only when run_action c
99104
// Registered under a key no candidate derives: the run door misses it, so the listing must too.
100105
const stray = makeBridge([['tasks_app_task', 'reopenTaskV2']]);
101106
expect(await listedNames(stray)).not.toContain('reopen_task');
102-
await expect(stray.runAction('reopen_task', { recordId: 'r1' })).rejects.toThrow(/No handler registered/);
107+
await expect(stray.runAction('reopen_task', { recordId: 'r1' })).rejects.toMatchObject({ code: 'RESOURCE_NOT_FOUND', status: 404 });
103108
});
104109

105110
it('the probe walks the run door’s OBJECT rotation — a handler on the object-less key counts', async () => {
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A declared script action with no handler bound (a "ghost" action): MCP
5+
* `run_action` answers it as the routing miss REST `/api/v1/actions` answers.
6+
*
7+
* Both doors dispatch a script action through `executeRegisteredAction`, and
8+
* both see the same miss (`dispatched: false`). REST answers it from its
9+
* routing-miss branch (`domains/actions.ts`): `404`, the closed code the
10+
* status derives (`RESOURCE_NOT_FOUND`), `Action 'X' on object 'Y' not
11+
* found`. The MCP bridge (`invokeBusinessAction`) threw a plain uncoded
12+
* `Error('No handler registered …')`, which `run_action` answers through the
13+
* `/actions` door's reader (`actionsDoorAnswerOf`, `@objectstack/types`) as
14+
* that door's bare-`Error` arm, `400 VALIDATION_ERROR`: it told an agent its
15+
* input was bad when the action has no route. The bridge now throws the
16+
* coded miss, so the reader gives back the door's own answer.
17+
*
18+
* One kernel serves both doors here, so each pin is the agreement between
19+
* them, not either half alone. The invocation row each door writes for the
20+
* miss is pinned too: the MCP row used to record the uncoded throw as a `500`.
21+
*/
22+
23+
import { describe, it, expect, vi, afterEach } from 'vitest';
24+
import { actionsDoorAnswerOf } from '@objectstack/types';
25+
// Pay the first transform of `@objectstack/metadata`, which the dispatcher's
26+
// source reaches through a dynamic `import()`, at MODULE LOAD, never inside a
27+
// clocked `it` (`scripts/check-test-source-alias.mjs`).
28+
import '@objectstack/metadata';
29+
30+
import { HttpDispatcher } from './http-dispatcher.js';
31+
32+
const OBJECT = 'tasks_app_task';
33+
const ACTION = {
34+
name: 'complete_task', label: 'Complete', objectName: OBJECT, type: 'script',
35+
body: { language: 'js', source: 'return { ok: true };' },
36+
ai: { exposed: true, description: 'Mark a task complete.' },
37+
};
38+
const MISS_SENTENCE = `Action 'complete_task' on object '${OBJECT}' not found`;
39+
40+
function makeKernel(handler?: (ctx: any) => unknown) {
41+
const object = { name: OBJECT, label: 'Task', fields: {}, actions: [ACTION] };
42+
const handlers = new Map<string, (ctx: any) => unknown>();
43+
if (handler) handlers.set(`${OBJECT}:complete_task`, handler);
44+
const ql: any = {
45+
getSchema: (name: string) => (name === OBJECT ? object : undefined),
46+
registry: { getObject: (name: string) => (name === OBJECT ? object : undefined), getItem: () => undefined },
47+
find: vi.fn(async () => [{ id: 'r1' }]),
48+
insert: vi.fn(), update: vi.fn(), delete: vi.fn(),
49+
executeAction: vi.fn(async (obj: string, key: string, ctx: any) => {
50+
const h = handlers.get(`${obj}:${key}`);
51+
if (!h) throw new Error(`Action '${key}' on object '${obj}' not found`);
52+
return h(ctx);
53+
}),
54+
listRegisteredActions: () => [...handlers.keys()].map((k) => ({
55+
objectName: k.slice(0, k.indexOf(':')), actionName: k.slice(k.indexOf(':') + 1),
56+
})),
57+
};
58+
const metadata: any = {
59+
listObjects: vi.fn(async () => [object]),
60+
getObject: vi.fn(async () => object),
61+
load: vi.fn(async () => null),
62+
loadDiagnosed: vi.fn(async () => ({ data: null, degraded: false, errors: [] })),
63+
};
64+
const rows: any[] = [];
65+
const audit = { recordActionInvocation: vi.fn(async (row: any) => { rows.push(row); }) };
66+
const auth: any = { api: { getSession: async () => ({ user: { id: 'u1' } }) } };
67+
const automation = { execute: vi.fn(async () => ({ success: true })) };
68+
const kernel: any = {
69+
context: {
70+
getService: (n: string) =>
71+
n === 'objectql' || n === 'data' ? ql
72+
: n === 'metadata' ? metadata
73+
: n === 'audit' ? audit
74+
: n === 'auth' ? auth
75+
: n === 'automation' ? automation
76+
: null,
77+
},
78+
};
79+
return { dispatcher: new HttpDispatcher(kernel) as any, rows };
80+
}
81+
82+
const ctx = (): any => ({ request: {}, environmentId: 'platform', executionContext: { userId: 'u1', systemPermissions: [] } });
83+
84+
async function restAnswer(dispatcher: any) {
85+
const res = await dispatcher.handleActions(`/${OBJECT}/complete_task/r1`, 'POST', {}, ctx());
86+
return res.response;
87+
}
88+
89+
async function mcpThrow(dispatcher: any): Promise<unknown> {
90+
const bridge = dispatcher.buildMcpBridge(ctx());
91+
try {
92+
await bridge.runAction('complete_task', { objectName: OBJECT, recordId: 'r1' });
93+
} catch (err) {
94+
return err;
95+
}
96+
throw new Error('run_action did not throw');
97+
}
98+
99+
describe('a declared action with no handler: run_action answers the routing miss /actions answers', () => {
100+
afterEach(() => {
101+
vi.restoreAllMocks();
102+
});
103+
104+
it('REST /actions answers 404 RESOURCE_NOT_FOUND, naming the routed object (the measured door answer)', async () => {
105+
const { dispatcher } = makeKernel();
106+
const response = await restAnswer(dispatcher);
107+
expect(response.status).toBe(404);
108+
expect(response.body.error).toEqual({ code: 'RESOURCE_NOT_FOUND', message: MISS_SENTENCE, httpStatus: 404 });
109+
});
110+
111+
it('the MCP bridge throws the same miss coded: code, status and sentence equal the REST body', async () => {
112+
vi.spyOn(console, 'info').mockImplementation(() => {});
113+
const { dispatcher } = makeKernel();
114+
const rest = (await restAnswer(dispatcher)).body.error;
115+
const thrown = await mcpThrow(dispatcher) as { code?: unknown; status?: unknown; message?: unknown };
116+
expect({ code: thrown.code, status: thrown.status, message: thrown.message }).toEqual({
117+
code: 'RESOURCE_NOT_FOUND', status: 404, message: MISS_SENTENCE,
118+
});
119+
expect([thrown.code, thrown.status, thrown.message]).toEqual([rest.code, rest.httpStatus, rest.message]);
120+
});
121+
122+
it('run_action reads it through the /actions reader as that door answers it, never 400 VALIDATION_ERROR', async () => {
123+
vi.spyOn(console, 'info').mockImplementation(() => {});
124+
const { dispatcher } = makeKernel();
125+
const rest = (await restAnswer(dispatcher)).body.error;
126+
const answer = actionsDoorAnswerOf(await mcpThrow(dispatcher));
127+
expect({ code: answer.code, status: answer.status, message: answer.message }).toEqual({
128+
code: 'RESOURCE_NOT_FOUND', status: 404, message: MISS_SENTENCE,
129+
});
130+
expect({ code: answer.code, status: answer.status, message: answer.message })
131+
.toEqual({ code: rest.code, status: rest.httpStatus, message: rest.message });
132+
expect(answer.declaredCode).toBeUndefined();
133+
});
134+
135+
it('each door writes one failed invocation row for the miss, with the same status and code', async () => {
136+
vi.spyOn(console, 'info').mockImplementation(() => {});
137+
const { dispatcher, rows } = makeKernel();
138+
await restAnswer(dispatcher);
139+
await mcpThrow(dispatcher);
140+
expect(rows.map((r) => ({ surface: r.context?.surface, outcome: r.outcome, status: r.status, errorCode: r.errorCode }))).toEqual([
141+
{ surface: 'rest', outcome: 'failed', status: 404, errorCode: 'RESOURCE_NOT_FOUND' },
142+
{ surface: 'mcp', outcome: 'failed', status: 404, errorCode: 'RESOURCE_NOT_FOUND' },
143+
]);
144+
});
145+
146+
it('CONTROL: the same action with a handler bound runs on both doors', async () => {
147+
vi.spyOn(console, 'info').mockImplementation(() => {});
148+
const { dispatcher } = makeKernel(() => ({ done: true }));
149+
const response = await restAnswer(dispatcher);
150+
expect(response.status).toBe(200);
151+
const bridge = dispatcher.buildMcpBridge(ctx());
152+
await expect(bridge.runAction('complete_task', { objectName: OBJECT, recordId: 'r1' }))
153+
.resolves.toMatchObject({ ok: true, result: { done: true } });
154+
});
155+
156+
it('CONTROL: a coded refusal from the handler keeps its code and status on both doors', async () => {
157+
vi.spyOn(console, 'info').mockImplementation(() => {});
158+
vi.spyOn(console, 'error').mockImplementation(() => {});
159+
const refuse = () => {
160+
throw Object.assign(new Error('Record is locked.'), { code: 'RECORD_LOCKED', status: 409 });
161+
};
162+
const { dispatcher } = makeKernel(refuse);
163+
const rest = await restAnswer(dispatcher);
164+
expect(rest.status).toBe(409);
165+
expect(rest.body.error).toMatchObject({ code: 'RECORD_LOCKED', message: 'Record is locked.', httpStatus: 409 });
166+
const answer = actionsDoorAnswerOf(await mcpThrow(dispatcher));
167+
expect({ code: answer.code, status: answer.status, message: answer.message })
168+
.toEqual({ code: 'RECORD_LOCKED', status: 409, message: 'Record is locked.' });
169+
});
170+
});

0 commit comments

Comments
 (0)