Skip to content

Commit b31ebfe

Browse files
os-warrenclaude
andauthored
fix(automation): a screen the caller already answered no longer parks the run, and list_actions publishes a flow action's inputs (#15787)
* wip(#15705): headless screen satisfaction + flow input params Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * test(#15705): pin headless screen satisfaction and its interactive controls Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * 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 * docs(#15705): document headless screen satisfaction + flow input params Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * fix(automation): the dispatcher's row-id seeds are not the caller speaking Both dispatch doors put the launched row's id into params under names that are not record columns — recordId and the camelCase object-id alias on the trigger door (which sets no context.record at all), plus the action's declared recordIdParam on the actions door. A screen field named like one of them read as caller-supplied on a run that supplied nothing, so an interactive console launch could skip the screen. Refuse the two derivable names outright and the third by value (identical to the row id). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * docs(#15705): correct three claims the console-door finding falsified The trigger door seeds recordId and the camelCase object-id alias into params and sets no context.record, so 'a console run supplies none of the screen's fields' was not true as written; and a record-change trigger sets params to the SAME object as record, so it pauses on the identity leg rather than on absent params. Also states the durable-resume gap: a JSON-rehydrated context loses the identity a non-scalar column's comparison relies on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * fix(automation): recover the seeded row id from the bag, not just record.id A non-default recordIdField makes the seeded row id a column other than id, so an action's declared recordIdParam gave that value a third key the record does not carry: not a column, not record.id, not a derivable name. It read as caller-supplied and the screen was SKIPPED, not paused, on a launch that supplied nothing. The dispatcher seeds the same row id under every id key it knows, so params.recordId (and the camelCase alias) recover it without knowing the action-level name. Also states the accepted cost: a screen field named recordId or the object-id alias is always collected interactively. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * test(#15705): make each row-id candidate separately load-bearing The token pin is satisfied by any of the three row-id candidates, so on its own it pinned the leg but not its parts: dropping params.recordId alone left all 21 green. Three fixtures now give each candidate a case only it can answer, by letting a record column shadow the other seed keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * docs(#15705): state the accepted cost precisely, in the reviewer's wording The sentence was imprecise both ways: overstated, because an OPTIONAL row-id valued field is not collected interactively — it continues with the value bound, it simply does not count toward the caller-named condition; and understated, because values equal to a caller-overridden recordId or alias, or to a shadowing recordId column, are refused too. Replaced in both shipped places with one wording, identical in each. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 984f1da commit b31ebfe

9 files changed

Lines changed: 1148 additions & 7 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
"@objectstack/runtime": minor
4+
---
5+
6+
A screen flow can now be completed by a headless caller, and `list_actions` publishes its input names.
7+
8+
An `ai.exposed` action whose target is a **screen flow** could be started over MCP and never finished. `run_action` seeded the flow's `isInput` variables from the caller's `params` — correctly — and the screen node suspended anyway, because the only inputs to that decision were "does the node declare fields" and the author's `waitForInput` flag. The MCP tool set has no verb to resume a parked run, so `ai.exposed` meant "the agent can invoke this", not "the agent can complete this". The fallback an agent took instead — re-implementing the flow's tail with `create_record` + `update_record` — bypasses whatever business rules the flow encapsulated.
9+
10+
Two independent halves:
11+
12+
- **A screen the caller already answered no longer pauses.** When the caller named at least one of the screen's own fields and every `required` one has a value from that caller, there is nothing left to collect and the run continues. Optional fields may come from anywhere (including a declared `defaultValue`).
13+
- **`list_actions` publishes a flow action's inputs.** A `type: 'flow'` action's contract is its target flow's `isInput` variables, not `action.params`; those are now surfaced in declaration order with the `label`, `type`, `required` and select `options` of the screen field that collects each one. An action that declares its own `params[]` keeps them — the flow is read only where the action declared nothing.
14+
15+
**Interactive runs are unchanged.** A console launch carries the record it was launched from and that record's id — never a value for the screen's own fields — so the form renders exactly as before. That covers both shapes a launch actually supplies: a subject-record column named like one of the screen's fields, and a field named like one of the row-id keys the dispatch doors seed (`recordId`, the camelCase `<object>Id` alias, an action's declared `recordIdParam`), none of which counts as the caller answering the screen.
16+
17+
**Accepted cost, precisely:** a field is never treated as caller-supplied when it is named `recordId` or `<object>Id`, or when its value equals what the bag carries under `recordId`, `<object>Id`, or `record.id` (normally the launched row's id); a required such field is therefore always collected interactively, an optional one simply does not count as answering the screen. Two screens never take the new path, because they declare nothing to satisfy and must not be answered vacuously: a message-only screen (no fields), and any screen whose author wrote `waitForInput: true`. `waitForInput: false` remains the wrong tool for the headless case — it skips the form for interactive users too.
18+
19+
⚠️ One known gap, on the trigger-record leg only: a run continued from the **durable** suspended-run store judges against a JSON copy of its context, so a later wizard screen whose field collides with a **non-scalar** column (an array or object) of the trigger record can read as caller-supplied and be skipped. Scalar columns are unaffected, as is any run that has not been through a pause.
20+
21+
⚠️ This does **not** make every screen flow completable over MCP. A call that omits the inputs still parks, and nothing on that surface can resume it; that half is a resume verb and is not this change.

content/docs/automation/flows.mdx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,72 @@ producer), and `'service'` when the decision to continue belongs to your service
734734
rather than to whoever holds the run id. Declaring neither leaves your pauses
735735
refused on the generic route, and the engine warns about it at registration.
736736

737+
### A screen the caller already answered does not pause
738+
739+
A `screen` node exists to collect input. When the run's **caller already supplied
740+
it**, there is nothing left to collect, so the run continues past the screen
741+
instead of suspending.
742+
743+
That is what makes a screen flow reachable from a headless invoker. An
744+
`ai.exposed` action whose target is a screen flow is started with `params`; those
745+
bind to the flow's `isInput` variables, and before this the screen suspended
746+
anyway — returning a form to a caller with no way to submit one, so the run
747+
parked forever.
748+
749+
The screen is treated as answered only when **all** of these hold:
750+
751+
- the caller named **at least one of this screen's own fields** — a run that
752+
named none of them is not driving the screen, and it renders as always. The
753+
row id a launch carries does **not** count as naming a field: both dispatch
754+
doors seed it into the params bag under `recordId` and the camelCase
755+
`<object>Id` alias (and under an action's declared `recordIdParam`), and none
756+
of those is the caller supplying a screen value.
757+
758+
**Accepted cost, precisely:** a field is never treated as caller-supplied when it is named `recordId` or `<object>Id`, or when its value equals what the bag carries under `recordId`, `<object>Id`, or `record.id` (normally the launched row's id); a required such field is therefore always collected interactively, an optional one simply does not count as answering the screen.
759+
760+
Name a field you want a headless caller to fill something else;
761+
- every `required` field has a value (an empty or blank string is no value),
762+
supplied by that caller. A value that came from the trigger record, from an
763+
earlier node or from a declared `defaultValue` does not answer a `required`
764+
field; optional fields may come from anywhere;
765+
- the field has no `visibleWhen` the caller left unanswered. The server cannot
766+
evaluate that predicate — it has no rendered form and no collected values — so
767+
a conditional `required` field the caller did not name keeps the screen
768+
interactive. (The resume door makes the opposite call for the opposite reason:
769+
there, enforcing a hidden field's `required` would dead-end a run at Submit.)
770+
771+
Two screens never take this path, because they declare nothing to satisfy and a
772+
bag must not answer them vacuously: a **message-only** screen (no `fields`), and
773+
any screen whose author wrote `waitForInput: true` — that flag is an explicit
774+
"show this", and a confirmation step is not something a params bag may skip.
775+
776+
**Interactive runs are unaffected.** A console launch carries the record it was
777+
launched from and that record's id — never a value for the screen's own fields —
778+
so the form renders exactly as before, including when the subject record carries
779+
a column named like one of the fields and including when a field is named like
780+
one of the seeded id keys. ⛔ `waitForInput: false` remains the wrong tool for
781+
the headless case: it skips the form for interactive users too.
782+
783+
⚠️ One known gap, on the trigger-record leg only: a run continued from the
784+
**durable** suspended-run store judges against a JSON copy of its context, so a
785+
later wizard screen whose field collides with a **non-scalar** column (an array
786+
or object) of the trigger record can read as caller-supplied. Scalar columns are
787+
unaffected, as is any run that has not been through a pause.
788+
789+
⚠️ This does **not** make every screen flow completable from a headless caller. A
790+
call that omits the inputs still parks, and the MCP tool set has no verb to
791+
resume a parked run.
792+
793+
### `list_actions` publishes a flow action's inputs
794+
795+
An action's `params` in the MCP action listing come from its declared
796+
`params[]`. A `type: 'flow'` action rarely declares any — its input contract is
797+
the **target flow's `isInput` variables**, which is what the caller's `params`
798+
bag binds into. Those are published instead, in declaration order, carrying the
799+
`label`, `type`, `required` and select `options` of the screen field that
800+
collects each one. An action that declares its own `params[]` keeps them: the
801+
flow is read only when the action itself declares nothing.
802+
737803
### Parallel approvals — one aggregating node, not two pauses
738804

739805
"Finance **and** legal must both sign off, concurrently" is **one `approval`

packages/runtime/src/action-execution.ts

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -981,7 +981,7 @@ export function actionLooksDestructive(_deps: ActionExecutionDeps, action: any):
981981
return Boolean(action?.mode === 'delete' || action?.variant === 'danger');
982982
}
983983

984-
export function summarizeAction(deps: ActionExecutionDeps, action: any, obj: any, objectName: string): any {
984+
export function summarizeAction(deps: ActionExecutionDeps, action: any, obj: any, objectName: string, flow?: any): any {
985985
// [#15079] `operation` before `type`, on the LISTING face. A declarative
986986
// update always requires a current record — that is contract point 7, and
987987
// the executor refuses without one — so the answer cannot be left to
@@ -999,7 +999,7 @@ export function summarizeAction(deps: ActionExecutionDeps, action: any, obj: any
999999
const description =
10001000
(typeof action?.ai?.description === 'string' ? action.ai.description : undefined) ??
10011001
(typeof action?.label === 'string' ? action.label : undefined);
1002-
const params = summarizeActionParams(deps, action, obj);
1002+
const params = summarizeActionParams(deps, action, obj, flow);
10031003
return {
10041004
name: action.name,
10051005
objectName,
@@ -1029,7 +1029,7 @@ export function jsonTypeOf(_deps: ActionExecutionDeps, t: string | undefined): '
10291029
}
10301030
}
10311031

1032-
export function summarizeActionParams(deps: ActionExecutionDeps, action: any, obj: any): any[] {
1032+
export function summarizeActionParams(deps: ActionExecutionDeps, action: any, obj: any, flow?: any): any[] {
10331033
const fields: Record<string, any> = obj?.fields ?? {};
10341034
const out: any[] = [];
10351035
for (const p of (Array.isArray(action?.params) ? action.params : [])) {
@@ -1055,9 +1055,89 @@ export function summarizeActionParams(deps: ActionExecutionDeps, action: any, ob
10551055
...(enumVals.length > 0 ? { enum: enumVals } : {}),
10561056
});
10571057
}
1058+
// [#15705] A FLOW action's input contract is its flow's `isInput`
1059+
// variables, not `action.params` — a flow-typed action almost never
1060+
// declares `params`, so this listing answered with no `params` key at all
1061+
// while the MCP `list_actions` tool description promised "its input
1062+
// parameters". An agent could see the action, could invoke it, and had no
1063+
// way to learn a single input name.
1064+
//
1065+
// Second, never first: a declaration the AUTHOR wrote on the action wins
1066+
// outright, so this can only fill a silence. `flow` is optional and the
1067+
// caller resolves it (`domains/mcp.ts` asks the automation service's
1068+
// `getFlow`), which keeps this function pure and leaves every existing
1069+
// 3-argument call site — and every non-flow action — byte-identical.
1070+
if (out.length === 0) out.push(...summarizeFlowInputParams(deps, flow));
1071+
return out;
1072+
}
1073+
1074+
/**
1075+
* A screen flow's input contract, projected onto the same param shape
1076+
* {@link summarizeActionParams} emits for a declared param (#15705).
1077+
*
1078+
* The flow's `isInput` variables ARE the contract — they are what
1079+
* `seedDeclaredVariables` binds from the caller's `params`, so their names are
1080+
* exactly the keys an invoker must send. The variable declaration carries only
1081+
* `name` / `type` / `defaultValue`, so everything an agent needs beyond the
1082+
* name (`label`, `required`, select `options`) is read off the screen node
1083+
* that collects the variable — the same field spec a paused run surfaces.
1084+
*
1085+
* `required` comes from the screen field alone: a flow variable has no
1086+
* `required` key, and inferring one from "declares no `defaultValue`" would
1087+
* invent a contract the author never wrote. A variable no screen collects is
1088+
* still listed — it is a real input, and omitting it would hide the very names
1089+
* this exists to publish — just without the screen-only enrichments.
1090+
*/
1091+
export function summarizeFlowInputParams(deps: ActionExecutionDeps, flow: any): any[] {
1092+
const variables: any[] = Array.isArray(flow?.variables) ? flow.variables : [];
1093+
if (variables.length === 0) return [];
1094+
const screenFields = collectScreenFieldSpecs(flow);
1095+
const out: any[] = [];
1096+
for (const v of variables) {
1097+
const name: unknown = v?.name;
1098+
if (v?.isInput !== true || typeof name !== 'string' || !name) continue;
1099+
const field = screenFields.get(name);
1100+
const type = jsonTypeOf(deps, field?.type ?? v?.type);
1101+
const description = typeof field?.label === 'string' && field.label ? field.label : undefined;
1102+
const enumVals = Array.isArray(field?.options)
1103+
? field.options
1104+
.map((o: any) => (typeof o === 'string' ? o : o?.value))
1105+
.filter((x: any): x is string => typeof x === 'string')
1106+
: [];
1107+
out.push({
1108+
name,
1109+
type,
1110+
required: field?.required === true,
1111+
...(description ? { description } : {}),
1112+
...(enumVals.length > 0 ? { enum: enumVals } : {}),
1113+
});
1114+
}
10581115
return out;
10591116
}
10601117

1118+
/**
1119+
* Every screen field a flow declares, by field name, first declaration
1120+
* winning. Walks ALL `screen` nodes rather than just the first: a multi-step
1121+
* wizard collects its inputs across several screens, and a contract that
1122+
* stopped at screen one would publish a subset while looking complete.
1123+
*
1124+
* Object-form screens contribute nothing by construction — their `fields` is
1125+
* empty because the client renders the object's own form — so they are simply
1126+
* skipped rather than special-cased.
1127+
*/
1128+
function collectScreenFieldSpecs(flow: any): Map<string, any> {
1129+
const byName = new Map<string, any>();
1130+
for (const node of Array.isArray(flow?.nodes) ? flow.nodes : []) {
1131+
if (node?.type !== 'screen') continue;
1132+
for (const field of Array.isArray(node?.config?.fields) ? node.config.fields : []) {
1133+
const name: unknown = field?.name;
1134+
if (typeof name !== 'string' || !name || byName.has(name)) continue;
1135+
byName.set(name, field);
1136+
}
1137+
}
1138+
return byName;
1139+
}
1140+
10611141
/**
10621142
* Resolve an action's declared `params[]` to their effective value-shape
10631143
* inputs (ADR-0104 D2). A field-backed param inherits type/multiple/

0 commit comments

Comments
 (0)