__tests__/flows-engine.test.js
Rama main · réplica de lectura del repositorio original.
/**
* FLOWS FOR THE ORGANS — the four engine additions.
*
* Herald and the master send single open-ended walks, and every failure this week had the same shape:
* one walk handed a big goal, drifting a defensible page at a time, then inventing when it could not
* tell what had happened. The browser already has a workflow engine — steps as data, outputs threaded
* into the next goal, filter/branch/collect/for-each, a journalled run that resumes — and only the
* Automation canvas used it. These four additions are what an organ needs to drive it:
*
* 1. a run can be handed its INPUT (a reply needs its thread URL and its approved text)
* 2. a step carries its own BUDGET (per-job since v199; the engine never set it)
* 3. a step's OUTCOME is data (posted / unconfirmed / blocked, not a paragraph of prose)
* 4. a `verify` step (the act gate's own confirmation, placed as a step)
*
* The whole point of the engine is that the scheduler is pure: runAgent and runVerify are injected,
* so all of this is tested without a browser.
*/
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'gb-flows-'));
process.env.PROFILE_DIR = TMP;
const wf = await import('../src/workflows.js');
afterAll(() => { try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* gone */ } });
const ROLE_NAMES = ['general', 'herald.facebook.groups.engage'];
/* The reply flow, in miniature: open the post, send, verify, decide. */
const replyFlow = () => ({
name: 'reply to a thread',
nodes: [
{ id: 'start', type: 'trigger', trigger: { type: 'manual' } },
{ id: 'send', type: 'agent', role: 'general', profile: 'facebook', outKey: 'send',
goal: 'Open {{input.threadUrl}} and post this comment: {{input.text}}', maxPages: 3, maxSteps: 15 },
{ id: 'check', type: 'verify', profile: 'facebook', outKey: 'check',
url: '{{input.threadUrl}}', text: '{{input.text}}' },
],
edges: [{ from: 'start', to: 'send' }, { from: 'send', to: 'check' }],
});
describe('a run can be handed its input', () => {
it('seeds {{input.*}} into every goal', async () => {
const goals = [];
const run = await wf.drive(replyFlow(), {
runAgent: async ({ goal }) => { goals.push(goal); return { outcome: 'posted' }; },
runVerify: async () => ({ found: true }),
input: { threadUrl: 'https://x/1', text: 'Own the database first.' },
now: () => 't', runId: 'r-input',
});
expect(run.status).toBe('done');
expect(goals[0]).toBe('Open https://x/1 and post this comment: Own the database first.');
});
/* Every workflow that exists today runs without one, and must be untouched. */
it('a run with no input is exactly as it was', async () => {
const goals = [];
const run = await wf.drive(replyFlow(), {
runAgent: async ({ goal }) => { goals.push(goal); return {}; },
runVerify: async () => ({ found: false }),
now: () => 't', runId: 'r-noinput',
});
expect(run.status).toBe('done');
expect(goals[0]).toBe('Open and post this comment: '); // the templates fill with nothing
});
/* A restart mid-run must not lose what the run was about. */
it('the input is kept on the run, so a resume still knows the thread', async () => {
const run = await wf.drive(replyFlow(), {
runAgent: async () => ({ outcome: 'posted' }), runVerify: async () => ({ found: true }),
input: { threadUrl: 'https://x/2', text: 'hello' }, now: () => 't', runId: 'r-keep',
});
expect(run.input).toEqual({ threadUrl: 'https://x/2', text: 'hello' });
const goals = [];
await wf.drive(replyFlow(), {
runAgent: async ({ goal }) => { goals.push(goal); return {}; }, runVerify: async () => ({ found: true }),
resume: { ...run, steps: run.steps.slice(0, 1) }, now: () => 't', runId: 'r-keep',
});
expect(goals[0]).toContain('https://x/2');
});
});
describe('a step carries its own budget', () => {
it('stores the page and step budget on an agent node', () => {
const saved = wf.save({ ...replyFlow(), name: 'budgeted flow' }, ROLE_NAMES);
const send = saved.nodes.find((n) => n.id === 'send');
expect(send.maxPages).toBe(3);
expect(send.maxSteps).toBe(15);
});
/* A budget is a promise about cost: a caller must not buy an unbounded walk by typing a big one. */
it('clamps them, and leaves them off when they are not set', () => {
const f = replyFlow();
f.nodes[1].maxPages = 9999; f.nodes[1].maxSteps = 9999;
const send = wf.save({ ...f, name: 'clamped flow' }, ROLE_NAMES).nodes.find((n) => n.id === 'send');
expect(send.maxPages).toBe(200);
expect(send.maxSteps).toBe(300);
const plain = replyFlow(); delete plain.nodes[1].maxPages; delete plain.nodes[1].maxSteps;
const n = wf.save({ ...plain, name: 'default flow' }, ROLE_NAMES).nodes.find((x) => x.id === 'send');
expect('maxPages' in n).toBe(false); // absent = the browser's own default, as before
expect('maxSteps' in n).toBe(false);
});
it('and the runner hands them to the job', () => {
const src = fs.readFileSync(new URL('../src/server.js', import.meta.url), 'utf8');
expect(src).toMatch(/maxSteps: Number\(node\.maxSteps\) \|\| 0, maxPages: Number\(node\.maxPages\) \|\| 0 \}\);/);
});
});
describe("what happened, as one word", () => {
/* Read off the job's own steps — the same kinds the act gate writes. */
it('reads the outcome from the walk itself', () => {
expect(wf.outcomeOf([{ kind: 'tool' }, { kind: 'acted' }])).toBe('posted');
expect(wf.outcomeOf([{ kind: 'unconfirmed' }])).toBe('unconfirmed');
expect(wf.outcomeOf([{ kind: 'blocked' }])).toBe('blocked');
expect(wf.outcomeOf([{ kind: 'look' }, { kind: 'read' }])).toBe('none');
expect(wf.outcomeOf()).toBe('none');
});
/* An act that landed outranks an earlier refusal: the walk got there in the end. */
it('posted wins over a refusal earlier in the same walk', () => {
expect(wf.outcomeOf([{ kind: 'blocked' }, { kind: 'unconfirmed' }, { kind: 'acted' }])).toBe('posted');
});
it('a branch can test it, which is the whole point', async () => {
const flow = {
name: 'branch on the outcome',
nodes: [
{ id: 'start', type: 'trigger', trigger: { type: 'manual' } },
{ id: 'send', type: 'agent', role: 'general', outKey: 'send', goal: 'send it' },
{ id: 'gate', type: 'branch', inKey: 'send.outcome', op: 'eq', value: 'posted' },
{ id: 'yes', type: 'store', key: 'said', value: 'it posted' },
{ id: 'no', type: 'store', key: 'said', value: 'a person must look' },
],
edges: [{ from: 'start', to: 'send' }, { from: 'send', to: 'gate' },
{ from: 'gate', to: 'yes', fromPort: 'true' }, { from: 'gate', to: 'no', fromPort: 'false' }],
};
const runWith = (outcome) => wf.drive(flow, { runAgent: async () => ({ outcome }), now: () => 't', runId: 'r-' + outcome });
const posted = await runWith('posted');
expect(posted.steps.find((s) => s.node_id === 'yes').status).toBe('done');
expect(posted.steps.find((s) => s.node_id === 'no').status).toBe('skipped');
const unconfirmed = await runWith('unconfirmed');
expect(unconfirmed.steps.find((s) => s.node_id === 'no').status).toBe('done');
expect(unconfirmed.steps.find((s) => s.node_id === 'yes').status).toBe('skipped');
});
it('and the runner puts the report, note and url beside the records', () => {
const src = fs.readFileSync(new URL('../src/server.js', import.meta.url), 'utf8');
expect(src).toMatch(/outcome: workflows\.outcomeOf\(v\.steps\)/);
expect(src).toMatch(/report: v\.report \? String\(v\.report\)\.slice\(0, 4000\) : undefined/);
});
});
describe('a verify step answers whether the words are on the page', () => {
it('is a real node type, validated on the two things it needs', () => {
expect(wf.NODE_TYPES).toContain('verify');
const errs = wf.validate({ name: 'bad verify', nodes: [
{ id: 'start', type: 'trigger' }, { id: 'v', type: 'verify', label: 'check' },
] }, ROLE_NAMES);
expect(errs.join(' ')).toMatch(/needs a url to open/);
expect(errs.join(' ')).toMatch(/needs the text to look for/);
});
it('templates its url and its text from the run, and hands back data', async () => {
let seen = null;
const run = await wf.drive(replyFlow(), {
runAgent: async () => ({ outcome: 'unconfirmed' }),
runVerify: async (a) => { seen = a; return { found: true }; },
input: { threadUrl: 'https://x/9', text: 'the exact words' }, now: () => 't', runId: 'r-verify',
});
expect(seen.url).toBe('https://x/9');
expect(seen.text).toBe('the exact words');
expect(run.steps.find((s) => s.node_id === 'check').output).toMatchObject({ found: true, url: 'https://x/9' });
});
/* The point of the step: the walk said unconfirmed, the page says otherwise, and the flow believes
the page. That is the false "replied" this fixes, decided without asking a model. */
it('the page outranks the walk, and no model is asked', async () => {
const flow = { ...replyFlow(), name: 'trust the page' };
flow.nodes.push({ id: 'gate', type: 'branch', inKey: 'check.found', op: 'eq', value: 'true' });
flow.nodes.push({ id: 'ok', type: 'store', key: 'verdict', value: 'posted' });
flow.edges.push({ from: 'check', to: 'gate' }, { from: 'gate', to: 'ok', fromPort: 'true' });
const run = await wf.drive(flow, {
runAgent: async () => ({ outcome: 'unconfirmed' }),
runVerify: async () => ({ found: true }),
input: { threadUrl: 'https://x/1', text: 'words' }, now: () => 't', runId: 'r-trust',
});
expect(run.steps.find((s) => s.node_id === 'ok').status).toBe('done');
});
it('a browser that cannot verify fails the step rather than guessing', async () => {
const run = await wf.drive(replyFlow(), {
runAgent: async () => ({}), input: { threadUrl: 'https://x/1', text: 'w' }, now: () => 't', runId: 'r-noverify',
});
expect(run.status).toBe('error');
expect(run.steps.find((s) => s.node_id === 'check').error).toMatch(/cannot run a verify step/);
});
it('and it resumes like every other step', async () => {
const done = { node_id: 'check', type: 'verify', status: 'done', output: { found: true, url: 'https://x/1' } };
const context = {};
expect(wf.seedFromStep({ id: 'check', type: 'verify', outKey: 'check' }, done, context, {})).toBe(true);
expect(context.check).toMatchObject({ found: true });
/* A step whose output cannot rebuild the context must re-run rather than be trusted. */
expect(wf.seedFromStep({ id: 'check', type: 'verify' }, { output: null }, {}, {})).toBe(false);
});
});
describe('the verify step in the browser is the act gate\'s own check', () => {
const src = fs.readFileSync(new URL('../src/server.js', import.meta.url), 'utf8');
/*
* It used to reuse confirmPosted, on the reasoning that both ask "is this text on the page". They
* do not. confirmPosted asks whether the comment the agent just typed actually POSTED, so it
* demands a long distinctive string and returns null below 40 alphanumerics rather than guess —
* correct for that, fatal here, because makeRunVerify reads `found === true` and a short proof
* ("PLN", a page heading) therefore came back as "not on the page" every single time. No flow
* could ever be verified and the library stayed empty through seven builds. See verify-text.test.js.
*/
it('asks pageHasText, NOT confirmPosted — a short proof must be answerable', () => {
expect(src).toMatch(/const found = await agent\.pageHasText\(s\.page, String\(text\)\);/);
const fn = src.slice(src.indexOf('function makeRunVerify'), src.indexOf('function makeRunAgent'));
expect(fn).not.toMatch(/await agent[.]confirmPosted/);
});
it('opens the page and nothing else — no model, no act, nothing typed', () => {
const fn = src.slice(src.indexOf('function makeRunVerify'), src.indexOf('function makeRunAgent'));
expect(fn).toMatch(/waitUntil: 'domcontentloaded'/);
expect(fn).not.toMatch(/agent\.run\(|jobs\.create\(|typeInto|autoAct/);
});
it('refuses anything that is not an http url', () => {
expect(src).toMatch(/a verify step needs an http\(s\) url/);
});
});