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 <noreply@anthropic.com>
This commit is contained in:
1 parent
454d202109
commit
21d7149756
5 files changed
+109
-25
No files matched your search
@@ -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<string, Value> {
|
||||
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);
|
||||
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',
|
||||
{
|
||||
const inner: NodeContext = {
|
||||
...ctx,
|
||||
variables,
|
||||
states: {},
|
||||
stateChanged: (machineId, from, to) => {
|
||||
if (machineId in ctx.shared.states) ctx.stateChanged(machineId, from, to);
|
||||
},
|
||||
},
|
||||
depth + 1,
|
||||
);
|
||||
};
|
||||
try {
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user