@@ -142,3 +142,124 @@ export function validateScreenInputs(
142142export function declaredScreenFieldNames ( fields : readonly ScreenFieldSpec [ ] ) : string [ ] {
143143 return fields . map ( ( f ) => f ?. name ) . filter ( ( n ) : n is string => typeof n === 'string' && n . length > 0 ) ;
144144}
145+
146+ /**
147+ * A screen field, reduced to the three keys a satisfaction verdict turns on.
148+ * Structurally a {@link ScreenFieldSpec} subset, so the node executor can hand
149+ * its parsed `config.fields` straight in.
150+ */
151+ export interface ScreenFieldContract {
152+ name : string ;
153+ required ?: boolean ;
154+ visibleWhen ?: string ;
155+ }
156+
157+ /** Why a screen was (or was not) satisfied without showing it — see {@link judgeHeadlessScreen}. */
158+ export interface HeadlessScreenVerdict {
159+ /** `true` ⇒ the run may continue past this screen without suspending. */
160+ satisfied : boolean ;
161+ /** Declared field names whose value this run's CALLER supplied (provenance-checked). */
162+ supplied : string [ ] ;
163+ /** Required fields with no usable bound value — the reason a candidate was refused. */
164+ missing : string [ ] ;
165+ }
166+
167+ const NOTHING_SUPPLIED : HeadlessScreenVerdict = { satisfied : false , supplied : [ ] , missing : [ ] } ;
168+
169+ /**
170+ * Whether a screen field's value in `context.params` came from the run's
171+ * CALLER rather than from the subject record the dispatcher seeded.
172+ *
173+ * This distinction is the whole safety story of {@link judgeHeadlessScreen},
174+ * because the params bag a flow action reaches the engine with is NOT the
175+ * caller's bag: `seedFlowActionParams` (`@objectstack/runtime`) returns
176+ * `{ ...record, recordId, <objectName>Id, ...params }`, so every column of the
177+ * subject row is in there whether the caller named it or not. Reading "the key
178+ * is in `params`" as "the caller supplied it" would let an INTERACTIVE console
179+ * run — which supplies nothing — skip a screen whose field happens to share a
180+ * name with a column of the record it was launched from.
181+ *
182+ * Two legs, either of which proves caller provenance:
183+ *
184+ * - the record has no such key at all ⇒ the record leg cannot be the source;
185+ * - the record HAS the key but `params` holds a different value ⇒ the
186+ * caller's bag overwrote it. `{ ...record }` copies the record's own value
187+ * by reference/primitive, so a run that supplied nothing is `Object.is`-equal
188+ * here, always. Equality is therefore "indistinguishable", not "caller-set".
189+ *
190+ * The ambiguous case (same key, same value) resolves to NOT caller-supplied,
191+ * which costs a headless run a pause it might have been allowed to skip and
192+ * costs an interactive run nothing. That asymmetry is deliberate: every
193+ * uncertainty in this module must land on today's behaviour.
194+ */
195+ function callerSupplied (
196+ name : string ,
197+ context : { params ?: Record < string , unknown > ; record ?: Record < string , unknown > } | undefined ,
198+ ) : boolean {
199+ const params = context ?. params ;
200+ if ( ! params || params [ name ] === undefined ) return false ;
201+ const record = context ?. record ;
202+ if ( ! record || ! Object . prototype . hasOwnProperty . call ( record , name ) ) return true ;
203+ return ! Object . is ( params [ name ] , record [ name ] ) ;
204+ }
205+
206+ /**
207+ * Can this screen be treated as already answered, and the run continued,
208+ * without suspending to show it? (#15705)
209+ *
210+ * The defect this answers: an `ai.exposed` action whose target is a screen
211+ * flow could be STARTED over MCP and never finished. `run_action` seeds the
212+ * flow's `isInput` variables from the caller's `params` — correctly — and the
213+ * screen node then suspended anyway, because the only inputs to that decision
214+ * were "does the node declare fields" and the author's `waitForInput` flag.
215+ * The MCP tool set has no resume verb, so the run parked forever.
216+ *
217+ * ⛔ NOT a general "skip screens" switch. Three conditions must ALL hold, and
218+ * the verdict is `false` the moment any of them is unproven:
219+ *
220+ * 1. **The caller supplied at least one of THIS screen's declared fields**
221+ * ({@link callerSupplied}). Without this leg a screen whose fields are all
222+ * optional would be vacuously "satisfied" and would stop rendering for
223+ * everyone — the loudest way to break the interactive path. A run that
224+ * named none of this screen's fields is not driving it, so it pauses.
225+ * 2. **Every `required` field has a usable value bound** in the live flow
226+ * variables — judged by {@link validateScreenInputs}, the same function
227+ * the resume door enforces the same contract with, so "present" cannot
228+ * drift into two meanings (an empty string is absent on both).
229+ * 3. Only caller-supplied names enter the bag, so a required field bound
230+ * from the record, from a prior node or from a declared `defaultValue`
231+ * does NOT count as answered. Optional fields are free to come from
232+ * anywhere — they constrain nothing.
233+ *
234+ * **`visibleWhen` is enforced here, the OPPOSITE of the resume door**, and the
235+ * asymmetry is the point rather than an oversight. On resume, an unevaluable
236+ * predicate must not fire `required`: the client is the authority on what the
237+ * user was shown, and demanding a hidden field dead-ends a run at Submit
238+ * (#3528). Here the server has no client and no collected values, so it cannot
239+ * evaluate the predicate either — but refusing costs nothing except a pause,
240+ * which is exactly what this screen does today. So a conditional required field
241+ * the caller did not name keeps the screen interactive.
242+ */
243+ export function judgeHeadlessScreen (
244+ fields : readonly ScreenFieldContract [ ] ,
245+ variables : ReadonlyMap < string , unknown > ,
246+ context : { params ?: Record < string , unknown > ; record ?: Record < string , unknown > } | undefined ,
247+ ) : HeadlessScreenVerdict {
248+ const declared = fields . filter ( ( f ) => typeof f ?. name === 'string' && f . name . length > 0 ) ;
249+ if ( declared . length === 0 ) return NOTHING_SUPPLIED ;
250+
251+ const supplied : string [ ] = [ ] ;
252+ const bag : Record < string , unknown > = { } ;
253+ for ( const field of declared ) {
254+ if ( ! callerSupplied ( field . name , context ) ) continue ;
255+ supplied . push ( field . name ) ;
256+ bag [ field . name ] = variables . get ( field . name ) ;
257+ }
258+ // Condition 1 — nobody drove this screen, so it stays interactive.
259+ if ( supplied . length === 0 ) return NOTHING_SUPPLIED ;
260+
261+ // Condition 2/3 — `unknown_field` cannot fire: every bag key is a declared
262+ // field by construction, so every issue returned here is a missing `required`.
263+ const issues = validateScreenInputs ( declared , bag , ( ) => true ) ;
264+ return { satisfied : issues . length === 0 , supplied, missing : issues . map ( ( i ) => i . field ) } ;
265+ }
0 commit comments