From 21d7149756cf3b6ac30b776ef5831396beadaa5e Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 25 Sep 2026 16:13:14 +0200 Subject: [PATCH] Let a nested behavior without a start node run from On start (#230) A behavior built as a plain behavior, with On start and no Behavior start, did nothing when another behavior called it through Run behavior. The node only showed a warning. Such a behavior should run as a call too, without the author having to rebuild its entry. - New callEntries() in nested.ts returns the Behavior start node or, when there is none, every On start node. callProblem uses it too. - GraphInterpreter.call() follows all entries one after the other, in graph order, in the same frame and with the same variables. - Behavior end still returns what reaches it. A call through On start has no inputs, because only the start node declares them. - It is a fallback only. With a Behavior start present, On start nodes stay out of the call. - The node warning now reads "No start or On start node - does nothing" and only shows when neither exists. - Keep and local state machines behave as before. Tests: - ./check.sh: 163 files, 2396 tests, CHECK EXIT 0 (before 2392). - 4 new unit tests in tests/core/nested-behavior.test.ts: the flow after On start runs, the return value arrives, several On start nodes run in graph order, a start node keeps On start out. - e2e 19: ALL PASSED, 35 checks. Only the expected warning text changed, the check sum stays at 1385. - Counter-checks: without the fallback 3 tests fail. With only the first On start exactly the order test fails. Co-Authored-By: Claude Opus 5.5 --- src/core/graph/GraphInterpreter.ts | 53 ++++++++--------- src/core/graph/nested.ts | 12 +++- src/core/graph/nodes/flow/RunBehavior.ts | 9 +-- tests/core/nested-behavior.test.ts | 74 +++++++++++++++++++++++- tools/e2e/19-nested-behavior.mjs | 2 +- 5 files changed, 117 insertions(+), 33 deletions(-) diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index 7118747b..63472004 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -1,6 +1,6 @@ import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph'; import { type GraphLookup, NO_LOOKUP } from './lookup'; -import { BEHAVIOR_END, BEHAVIOR_START, resolveDef, terminalNode } from './nested'; +import { BEHAVIOR_END, callEntries, resolveDef, terminalNode } from './nested'; import type { PropertyBag } from '../model/Properties'; import type { NodeContext } from './NodeContext'; import { @@ -159,46 +159,47 @@ export class GraphInterpreter { } /** - * Runs another behavior from its start node to its end node and returns what the end node was - * handed. The nested graph gets its own frame and a fresh copy of its variable defaults, so it is - * a call and not an inlining: nothing of it survives the return but the values it hands back – - * and the variables it keeps, which wait for the next call from the same site. + * Runs another behavior from its start node (or, lacking one, its `On start` nodes) to its end + * node and returns what the end node was handed. The nested graph gets its own frame and a fresh + * copy of its variable defaults, so it is a call and not an inlining: nothing of it survives the + * return but the values it hands back – and the variables it keeps, which wait for the next call + * from the same site. */ private call(call: CallResult, site: string, ctx: NodeContext, depth: number): Record { const program = this.lookup.behavior(call.behaviorId); if (!program) return {}; if (this.callStack.includes(call.behaviorId)) throw new Error(`Behavior "${program.name}" runs itself`); - const start = terminalNode(program.graph, BEHAVIOR_START); - if (!start) return {}; + const entries = callEntries(program.graph); + if (entries.length === 0) return {}; const sites = [...this.callSites, site].join('/'); const kept = program.kept ?? []; const variables = resumedVariables(program.defaults, this.keptCalls.get(sites), kept); if (kept.length > 0) this.keptCalls.set(sites, variables); const outer = this.frame; this.frame = emptyFrame(program.graph); - this.frame.outputCache.set(start.id, { ...call.inputs }); - this.record(start.id, call.inputs); + for (const entry of entries) { + this.frame.outputCache.set(entry.id, { ...call.inputs }); + this.record(entry.id, call.inputs); + } this.callStack.push(call.behaviorId); this.callSites.push(site); this.calledBehaviors.add(call.behaviorId); + // Fresh states every call, and nothing of the caller's variables or states. What it does to a + // local machine therefore dies with the call and is not reported; a `global` machine is the + // run's, so moving that one is a move like any other. + const inner: NodeContext = { + ...ctx, + variables, + states: {}, + stateChanged: (machineId, from, to) => { + if (machineId in ctx.shared.states) ctx.stateChanged(machineId, from, to); + }, + }; try { - this.trace?.onNode(start.id); - // Fresh states every call, and nothing of the caller's variables or states. What it does to a - // local machine therefore dies with the call and is not reported; a `global` machine is the - // run's, so moving that one is a move like any other. - this.follow( - start, - 'exec', - { - ...ctx, - variables, - states: {}, - stateChanged: (machineId, from, to) => { - if (machineId in ctx.shared.states) ctx.stateChanged(machineId, from, to); - }, - }, - depth + 1, - ); + for (const entry of entries) { + this.trace?.onNode(entry.id); + this.follow(entry, 'exec', inner, depth + 1); + } const end = terminalNode(program.graph, BEHAVIOR_END); return (end && this.frame.outputCache.get(end.id)) ?? {}; } finally { diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index 6c32d38a..964b868f 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -20,6 +20,7 @@ export const BEHAVIOR_END = 'BehaviorEnd'; export const RUN_BEHAVIOR = 'RunBehavior'; export const VARIABLES = 'Variables'; export const PROPERTIES = 'Properties'; +export const ON_START = 'OnStart'; export const BEHAVIOR_PARAM = 'behavior'; export const VARIABLES_PARAM = 'variablesAsset'; @@ -92,6 +93,15 @@ export function terminalNode(graph: Graph, type: string): GraphNode | undefined return graph.nodes.find((n) => n.type === type); } +/** + * Where a call into `graph` begins: its start node, or – for a behavior that has none – every + * `On start` in it, in graph order, as when the behavior starts on its own (#230). + */ +export function callEntries(graph: Graph): GraphNode[] { + const start = terminalNode(graph, BEHAVIOR_START); + return start ? [start] : graph.nodes.filter((n) => n.type === ON_START); +} + export function isTerminalType(type: string): boolean { return type === BEHAVIOR_START || type === BEHAVIOR_END; } @@ -267,7 +277,7 @@ export function callProblem(node: GraphNode, lookup: GraphLookup, ownerId = ''): if (id === ownerId) return 'A behavior cannot run itself'; const program = lookup.behavior(id); if (!program) return 'That behavior is gone'; - if (!terminalNode(program.graph, BEHAVIOR_START)) return 'No start node – does nothing'; + if (callEntries(program.graph).length === 0) return 'No start or On start node – does nothing'; return null; } diff --git a/src/core/graph/nodes/flow/RunBehavior.ts b/src/core/graph/nodes/flow/RunBehavior.ts index 188e4352..f89339ff 100644 --- a/src/core/graph/nodes/flow/RunBehavior.ts +++ b/src/core/graph/nodes/flow/RunBehavior.ts @@ -4,16 +4,17 @@ import { BEHAVIOR_END, BEHAVIOR_PARAM, BEHAVIOR_START, declaredPorts, RUN_BEHAVI import { exec } from '../shared'; /** - * Runs another behavior's graph in place, from its start node to its end node. Its ports are those - * the named behavior declares; the interpreter does the call, because only it can hold a second - * graph's pulse. + * Runs another behavior's graph in place, from its start node (or its `On start` nodes, #230) to + * its end node. Its ports are those the named behavior declares; the interpreter does the call, + * because only it can hold a second graph's pulse. */ export const RunBehavior: NodeDef = { type: RUN_BEHAVIOR, label: 'Run behavior', category: 'flow', description: - 'Runs another behavior as a step of this one, passing values in and taking results out. A ' + + 'Runs another behavior as a step of this one, passing values in and taking results out. It ' + + 'begins at the Behavior start node, or at On start if that behavior has none. A ' + 'variable marked Keep in that behavior carries on from the last call of this node on this ' + 'object; the others start at their defaults every time.', inputs: [exec()], diff --git a/tests/core/nested-behavior.test.ts b/tests/core/nested-behavior.test.ts index 8138c5dc..7b266b2e 100644 --- a/tests/core/nested-behavior.test.ts +++ b/tests/core/nested-behavior.test.ts @@ -198,7 +198,7 @@ describe('a call that cannot do anything says so', () => { call.params[BEHAVIOR_PARAM] = 'gone'; expect(problem()).toBe('That behavior is gone'); call.params[BEHAVIOR_PARAM] = makeBehavior(fixture, 'Empty').id; - expect(problem()).toBe('No start node – does nothing'); + expect(problem()).toBe('No start or On start node – does nothing'); call.params[BEHAVIOR_PARAM] = callee.id; expect(problem()).toBe(null); // Only a call can be dead this way; no other node type may be marked. @@ -245,6 +245,78 @@ describe('a call that cannot do anything says so', () => { }); }); +describe('a called behavior without a start node begins at On start (#230)', () => { + /** A caller whose On start runs `callee` and logs "after" once the call is done. */ + function logCaller(fixture: ProjectFixture, callee: BehaviorAsset) { + const caller = makeBehavior(fixture, 'Caller'); + const edit = graphEditor(fixture, caller); + const onStart = edit.addNode('OnStart'); + const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id }); + edit.wire([ + [onStart, 'exec', call, 'exec'], + [call, 'exec', edit.addNode('Log', { message: 'after' }), 'exec'], + ]); + return { caller, call }; + } + + it('runs the flow behind its On start, and the call is not marked dead', () => { + const fixture = makeProject(); + const callee = makeBehavior(fixture, 'Plain'); + const cEdit = graphEditor(fixture, callee); + cEdit.connect(cEdit.addNode('OnStart'), 'exec', cEdit.addNode('Log', { message: 'sub' }), 'exec'); + const { caller, call } = logCaller(fixture, callee); + + expect(callProblem(call, graphLookup(fixture.project), caller.id)).toBe(null); + expect(runOnStart(fixture, caller).ctx.logs).toEqual(['sub', 'after']); + }); + + it('still hands back what its end node was given', () => { + const fixture = makeProject(); + const callee = makeBehavior(fixture, 'Seven'); + const cEdit = graphEditor(fixture, callee); + const onStart = cEdit.addNode('OnStart'); + const end = cEdit.addNode(BEHAVIOR_END); + fixture.stack.execute(new AddPortCommand(callee.id, end.id, { key: 'out1', kind: 'number' })); + cEdit.wire([ + [onStart, 'exec', end, 'exec'], + [cEdit.addNode('Number', { value: 7 }), 'value', end, 'out1'], + ]); + const caller = makeBehavior(fixture, 'Caller', ['result']); + const edit = graphEditor(fixture, caller); + const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id }); + const set = edit.variables(['result'], { write: true }); + edit.wire([ + [edit.addNode('OnStart'), 'exec', call, 'exec'], + [call, 'exec', set, 'exec'], + [call, 'out1', set, 'result'], + ]); + + expect(runOnStart(fixture, caller, { result: 0 }).ctx.variables.result).toBe(7); + }); + + it('runs every On start, in graph order, as the behavior would on its own', () => { + const fixture = makeProject(); + const callee = makeBehavior(fixture, 'Twice'); + const cEdit = graphEditor(fixture, callee); + cEdit.connect(cEdit.addNode('OnStart'), 'exec', cEdit.addNode('Log', { message: 'one' }), 'exec'); + cEdit.connect(cEdit.addNode('OnStart'), 'exec', cEdit.addNode('Log', { message: 'two' }), 'exec'); + const { caller } = logCaller(fixture, callee); + + expect(runOnStart(fixture, caller).ctx.logs).toEqual(['one', 'two', 'after']); + }); + + it('is only a fallback: with a start node, its On start stays out of the call', () => { + const fixture = makeProject(); + const callee = makeBehavior(fixture, 'Both'); + const cEdit = graphEditor(fixture, callee); + cEdit.connect(cEdit.addNode(BEHAVIOR_START), 'exec', cEdit.addNode('Log', { message: 'start' }), 'exec'); + cEdit.connect(cEdit.addNode('OnStart'), 'exec', cEdit.addNode('Log', { message: 'on start' }), 'exec'); + const { caller } = logCaller(fixture, callee); + + expect(runOnStart(fixture, caller).ctx.logs).toEqual(['start', 'after']); + }); +}); + describe('declared ports', () => { it('renaming one rewrites the edges in this graph and in every calling one', () => { const fixture = makeProject(); diff --git a/tools/e2e/19-nested-behavior.mjs b/tools/e2e/19-nested-behavior.mjs index ce63ea9c..fd2fa946 100644 --- a/tools/e2e/19-nested-behavior.mjs +++ b/tools/e2e/19-nested-behavior.mjs @@ -252,7 +252,7 @@ export async function nestedBehavior(t) { const problem = await evaluate(`__gm.graph.nodeProblem(${JSON.stringify(emptyCall?.id ?? '')})`); check( 'and the node says why it does nothing, instead of failing silently', - problem === 'No start node – does nothing', + problem === 'No start or On start node – does nothing', `problem=${JSON.stringify(problem)}`, ); check(