Skip to content

Commit 24f50bb

Browse files
committed
test(#15705): pin the flow input contract on list_actions
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
1 parent f2a6f7c commit 24f50bb

2 files changed

Lines changed: 268 additions & 0 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `list_actions` publishes a FLOW action's input names (#15705).
5+
*
6+
* The MCP `list_actions` tool description promises each action's "input
7+
* parameters", and `summarizeActionParams` delivered them by iterating
8+
* `action.params` — the script-action declaration. A flow-typed action almost
9+
* never declares `params`: its input contract is the target flow's `isInput`
10+
* variables, which is what `seedDeclaredVariables` binds the caller's bag into.
11+
* So every flow action listed with no `params` key at all, and an agent could
12+
* see the action, could invoke it, and had no way to learn a single input name
13+
* — the reported reproduction passed `due_date` where the flow declares
14+
* `dueDate` because guessing was the only move available.
15+
*
16+
* Pinned here:
17+
*
18+
* 1. the inputs are published, in declaration order, with the collecting
19+
* screen field's `label` / `type` / `required` / `options` folded in;
20+
* 2. a variable that is NOT `isInput` stays private — the listing publishes an
21+
* input contract, not the flow's internals;
22+
* 3. an author's own `action.params` still WINS, so this can only fill a
23+
* silence and no existing listing changes shape;
24+
* 4. every absence is inert: no flow, a flow with no variables, a non-flow
25+
* action — all answer exactly as they did before.
26+
*
27+
* `required` is read off the screen field alone. A flow variable has no
28+
* `required` key, and inferring one from "declares no `defaultValue`" would
29+
* invent a contract the author never wrote — the same reason the executor's
30+
* satisfaction verdict one package over reads `required` from the field spec.
31+
*/
32+
33+
import { describe, it, expect } from 'vitest';
34+
35+
import {
36+
summarizeAction,
37+
summarizeActionParams,
38+
summarizeFlowInputParams,
39+
} from './action-execution.js';
40+
41+
const NO_DEPS: any = {};
42+
43+
/** The card's specimen: `schedule_followup`, `type: 'flow'`, no declared params. */
44+
const FLOW_ACTION = {
45+
name: 'schedule_followup',
46+
label: 'Schedule Follow-up',
47+
type: 'flow',
48+
target: 'schedule_followup',
49+
ai: { exposed: true },
50+
locations: ['record_header'],
51+
};
52+
53+
/**
54+
* The target flow. `internal_cursor` is deliberately not an input, and
55+
* `activityType` is collected on a SECOND screen — a wizard's later step is
56+
* still part of the input contract.
57+
*/
58+
const FLOW = {
59+
name: 'schedule_followup',
60+
type: 'screen',
61+
variables: [
62+
{ name: 'subject', type: 'text', isInput: true },
63+
{ name: 'dueDate', type: 'text', isInput: true },
64+
{ name: 'activityType', type: 'text', isInput: true },
65+
{ name: 'internal_cursor', type: 'number', isInput: false },
66+
],
67+
nodes: [
68+
{ id: 'start', type: 'start' },
69+
{
70+
id: 'screen_1', type: 'screen',
71+
config: {
72+
fields: [
73+
{ name: 'subject', label: 'Subject', type: 'text', required: true },
74+
{ name: 'dueDate', label: 'Due date', type: 'date', required: true },
75+
],
76+
},
77+
},
78+
{
79+
id: 'screen_2', type: 'screen',
80+
config: {
81+
fields: [
82+
{
83+
name: 'activityType', label: 'Activity type', type: 'select',
84+
options: [{ value: 'call', label: 'Call' }, { value: 'email', label: 'Email' }],
85+
},
86+
],
87+
},
88+
},
89+
{ id: 'end', type: 'end' },
90+
],
91+
};
92+
93+
describe('summarizeFlowInputParams (#15705)', () => {
94+
it('publishes every isInput variable, in declaration order, enriched from its screen field', () => {
95+
expect(summarizeFlowInputParams(NO_DEPS, FLOW)).toEqual([
96+
{ name: 'subject', type: 'string', required: true, description: 'Subject' },
97+
{ name: 'dueDate', type: 'string', required: true, description: 'Due date' },
98+
{
99+
name: 'activityType', type: 'string', required: false,
100+
description: 'Activity type', enum: ['call', 'email'],
101+
},
102+
]);
103+
});
104+
105+
it('keeps a non-input variable private', () => {
106+
const names = summarizeFlowInputParams(NO_DEPS, FLOW).map((p) => p.name);
107+
expect(names).not.toContain('internal_cursor');
108+
});
109+
110+
it('lists an input no screen collects — without the screen-only enrichments', () => {
111+
const flow = { ...FLOW, variables: [{ name: 'silent', type: 'number', isInput: true }] };
112+
expect(summarizeFlowInputParams(NO_DEPS, flow)).toEqual([
113+
{ name: 'silent', type: 'number', required: false },
114+
]);
115+
});
116+
117+
it('is inert on every absence', () => {
118+
expect(summarizeFlowInputParams(NO_DEPS, undefined)).toEqual([]);
119+
expect(summarizeFlowInputParams(NO_DEPS, {})).toEqual([]);
120+
expect(summarizeFlowInputParams(NO_DEPS, { variables: [] })).toEqual([]);
121+
expect(summarizeFlowInputParams(NO_DEPS, { variables: [{ name: 'x', isInput: false }] })).toEqual([]);
122+
});
123+
});
124+
125+
describe('summarizeActionParams / summarizeAction fall back to the flow (#15705)', () => {
126+
it('CONTROL — the reported shape: no flow resolved, no params key, exactly as before', () => {
127+
expect(summarizeActionParams(NO_DEPS, FLOW_ACTION, undefined)).toEqual([]);
128+
expect(summarizeAction(NO_DEPS, FLOW_ACTION, undefined, 'crm_lead')).not.toHaveProperty('params');
129+
});
130+
131+
it('surfaces the flow inputs once the flow is resolved', () => {
132+
const summary = summarizeAction(NO_DEPS, FLOW_ACTION, undefined, 'crm_lead', FLOW);
133+
expect(summary.params.map((p: any) => p.name)).toEqual(['subject', 'dueDate', 'activityType']);
134+
// The rest of the summary is untouched by this change.
135+
expect(summary).toMatchObject({ name: 'schedule_followup', type: 'flow', requiresRecord: true });
136+
});
137+
138+
it("CONTROL — an author's own declared params still win outright", () => {
139+
const declaring = {
140+
...FLOW_ACTION,
141+
params: [{ name: 'only_this', type: 'text', required: true, label: 'Only this' }],
142+
};
143+
const params = summarizeActionParams(NO_DEPS, declaring, undefined, FLOW);
144+
expect(params.map((p: any) => p.name)).toEqual(['only_this']);
145+
});
146+
147+
it('CONTROL — a non-flow action handed a flow is unchanged (the caller resolves none)', () => {
148+
const script = { name: 'close_case', type: 'script', target: 'closeCase', locations: ['record_header'] };
149+
expect(summarizeActionParams(NO_DEPS, script, undefined)).toEqual([]);
150+
expect(summarizeAction(NO_DEPS, script, undefined, 'crm_case')).not.toHaveProperty('params');
151+
});
152+
});
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The WIRE half of #15705: `list_actions` asks the automation service for the
5+
* flow behind a flow action, so the input names actually reach the agent.
6+
*
7+
* `summarizeActionParams`' own pins (`action-flow-input-params.test.ts`) prove
8+
* the projection. They cannot prove anybody performs it: the bridge held only
9+
* `Boolean(automationService)` and passed the summary no flow at all, so the
10+
* projection would have sat unreachable behind a green unit suite — the exact
11+
* "declared but nothing calls it" shape. This file pins the call.
12+
*
13+
* `getFlow` is OPTIONAL on `IAutomationService`, so the degradation is pinned
14+
* too: a service that does not implement it, and a flow the registry does not
15+
* hold, both answer exactly as the listing answered before this change.
16+
*/
17+
18+
import { describe, it, expect, vi } from 'vitest';
19+
20+
import { HttpDispatcher } from './http-dispatcher.js';
21+
22+
/** The card's specimen, as an object-embedded declaration. */
23+
const FLOW_ACTION = {
24+
name: 'schedule_followup',
25+
label: 'Schedule Follow-up',
26+
objectName: 'crm_lead',
27+
type: 'flow',
28+
target: 'schedule_followup',
29+
locations: ['record_header'],
30+
ai: { exposed: true, description: 'Schedule a follow-up task for this lead.' },
31+
};
32+
33+
const FLOW = {
34+
name: 'schedule_followup',
35+
type: 'screen',
36+
variables: [
37+
{ name: 'subject', type: 'text', isInput: true },
38+
{ name: 'dueDate', type: 'text', isInput: true },
39+
],
40+
nodes: [
41+
{ id: 'start', type: 'start' },
42+
{
43+
id: 'screen_1', type: 'screen',
44+
config: {
45+
fields: [
46+
{ name: 'subject', label: 'Subject', type: 'text', required: true },
47+
{ name: 'dueDate', label: 'Due date', type: 'date', required: true },
48+
],
49+
},
50+
},
51+
],
52+
};
53+
54+
function makeBridge(automation: any) {
55+
const object = { name: 'crm_lead', label: 'Lead', fields: {}, actions: [FLOW_ACTION] };
56+
const ql: any = {
57+
executeAction: vi.fn(),
58+
registry: { getObject: () => object },
59+
find: vi.fn(async () => []),
60+
insert: vi.fn(), update: vi.fn(), delete: vi.fn(),
61+
};
62+
const metadata: any = {
63+
listObjects: vi.fn(async () => [object]),
64+
getObject: vi.fn(async () => object),
65+
};
66+
const kernel: any = {
67+
context: {
68+
getService: (n: string) =>
69+
n === 'objectql' || n === 'data' ? ql : n === 'metadata' ? metadata : n === 'automation' ? automation : null,
70+
},
71+
};
72+
const dispatcher = new HttpDispatcher(kernel);
73+
const ctx: any = {
74+
request: {}, environmentId: 'platform',
75+
executionContext: { userId: 'u1', systemPermissions: [] },
76+
};
77+
return (dispatcher as any).buildMcpBridge(ctx);
78+
}
79+
80+
async function listed(automation: any) {
81+
const actions = await makeBridge(automation).listActions();
82+
return actions.find((a: any) => a.name === 'schedule_followup');
83+
}
84+
85+
describe('list_actions surfaces a flow action’s inputs (#15705)', () => {
86+
it('asks getFlow for the action’s target and publishes the flow’s inputs as params', async () => {
87+
const getFlow = vi.fn(async () => FLOW);
88+
const summary = await listed({ execute: vi.fn(), getFlow });
89+
expect(getFlow).toHaveBeenCalledWith('schedule_followup');
90+
expect(summary.params).toEqual([
91+
{ name: 'subject', type: 'string', required: true, description: 'Subject' },
92+
{ name: 'dueDate', type: 'string', required: true, description: 'Due date' },
93+
]);
94+
});
95+
96+
it('CONTROL — the reported shape: a service with no getFlow lists exactly as before', async () => {
97+
const summary = await listed({ execute: vi.fn() });
98+
expect(summary).toBeDefined();
99+
expect(summary).not.toHaveProperty('params');
100+
expect(summary).toMatchObject({ name: 'schedule_followup', type: 'flow', requiresRecord: true });
101+
});
102+
103+
it('CONTROL — a target the registry does not hold degrades to the same shape', async () => {
104+
const summary = await listed({ execute: vi.fn(), getFlow: vi.fn(async () => null) });
105+
expect(summary).not.toHaveProperty('params');
106+
});
107+
108+
it('CONTROL — a getFlow that THROWS does not fail the listing', async () => {
109+
const summary = await listed({
110+
execute: vi.fn(),
111+
getFlow: vi.fn(async () => { throw new Error('registry unavailable'); }),
112+
});
113+
expect(summary).toBeDefined();
114+
expect(summary).not.toHaveProperty('params');
115+
});
116+
});

0 commit comments

Comments
 (0)