|
| 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 | +}); |
0 commit comments