import { describe, expect, test } from 'bun:test' describe('workflow authoring graph validation', () => { test('rejects multiple main roots', async () => { const { llmNode, buildGraph } = await import('./workflow-builders.ts') const { validateWorkflowGraph } = await import('./workflows.ts') const a = llmNode({ name: 'A', prompt: 'A' }) const b = llmNode({ name: 'B', prompt: 'B' }) const graph = buildGraph([a, b], [], { validate: false }) const result = validateWorkflowGraph(graph, { version: 'v3', strictAuthoring: true }) expect(result.errors.map(error => error.code)).toContain('invalid_root_count') }) test('rejects multiple default outgoing edges from the same source', async () => { const { llmNode, buildGraph } = await import('./workflow-builders.ts') const { validateWorkflowGraph } = await import('./workflows.ts') const root = llmNode({ name: 'Root', prompt: 'root' }) const left = llmNode({ name: 'Left', prompt: 'left' }) const right = llmNode({ name: 'Right', prompt: 'right' }) const graph = buildGraph([root, left, right], [[root, left], [root, right]], { validate: false }) const result = validateWorkflowGraph(graph, { version: 'v3', strictAuthoring: true }) expect(result.errors.map(error => error.code)).toContain('default_fan_out') }) test('rejects stop nodes outside condition branch ends', async () => { const { llmNode, stopNode, buildGraph } = await import('./workflow-builders.ts') const { validateWorkflowGraph } = await import('./workflows.ts') const root = llmNode({ name: 'Root', prompt: 'root' }) const stop = stopNode({ name: 'Stop' }) const graph = buildGraph([root, stop], [[root, stop]], { validate: false }) const result = validateWorkflowGraph(graph, { version: 'v3', strictAuthoring: true }) expect(result.errors.map(error => error.code)).toContain('stop_not_at_condition_branch_end') }) test('allows stop nodes after a condition-scoped branch chain', async () => { const { conditionNode, llmNode, stopNode, buildGraph } = await import('./workflow-builders.ts') const { validateWorkflowGraph } = await import('./workflows.ts') const condition = conditionNode({ name: 'Route', cases: [{ name: 'End', condition: '{{input://kind}}|equals|end' }], includeDefault: false, }) const branchStep = llmNode({ name: 'Branch Step', prompt: 'branch' }) const stop = stopNode({ name: 'Stop' }) const graph = buildGraph( [condition, branchStep, stop], [[condition, branchStep, { branch: 0 }], [branchStep, stop]], { validate: false }, ) const result = validateWorkflowGraph(graph, { version: 'v3', strictAuthoring: true }) expect(result.errors.map(error => error.code)).not.toContain('stop_not_at_condition_branch_end') expect(result.valid).toBe(true) }) test('rejects cycles in strict authoring graphs', async () => { const { llmNode, buildGraph } = await import('./workflow-builders.ts') const { validateWorkflowGraph } = await import('./workflows.ts') const a = llmNode({ name: 'A', prompt: 'A' }) const b = llmNode({ name: 'B', prompt: 'B' }) const graph = buildGraph([a, b], [[a, b], [b, a]], { validate: false }) const result = validateWorkflowGraph(graph, { version: 'v3', strictAuthoring: true }) expect(result.errors.map(error => error.code)).toContain('cycle_detected') }) test('rejects stop nodes as the strict authoring root', async () => { const { stopNode, buildGraph } = await import('./workflow-builders.ts') const { validateWorkflowGraph } = await import('./workflows.ts') const stop = stopNode({ name: 'Stop' }) const graph = buildGraph([stop], [], { validate: false }) const result = validateWorkflowGraph(graph, { version: 'v3', strictAuthoring: true }) expect(result.errors.map(error => error.code)).toContain('stop_cannot_be_root') }) test('rejects nodes unreachable from the strict authoring root', async () => { const { llmNode, buildGraph } = await import('./workflow-builders.ts') const { validateWorkflowGraph } = await import('./workflows.ts') const root = llmNode({ name: 'Root', prompt: 'root' }) const child = llmNode({ name: 'Child', prompt: 'child' }) const orphanA = llmNode({ name: 'Orphan A', prompt: 'orphan a' }) const orphanB = llmNode({ name: 'Orphan B', prompt: 'orphan b' }) const graph = buildGraph( [root, child, orphanA, orphanB], [[root, child], [orphanA, orphanB], [orphanB, orphanA]], { validate: false }, ) const result = validateWorkflowGraph(graph, { version: 'v3', strictAuthoring: true }) expect(result.errors.map(error => error.code)).toContain('unreachable_node') }) test('rejects mixed condition and non-condition fan-in', async () => { const { conditionNode, llmNode, buildGraph } = await import('./workflow-builders.ts') const { validateWorkflowGraph } = await import('./workflows.ts') const condition = conditionNode({ name: 'Route', cases: [{ name: 'A', condition: '{{input://kind}}|equals|a' }], }) const branch = llmNode({ name: 'Branch', prompt: 'branch' }) const defaultSource = llmNode({ name: 'Default Source', prompt: 'default' }) const merge = llmNode({ name: 'Merge', prompt: 'merge' }) const graph = buildGraph( [condition, branch, defaultSource, merge], [[condition, branch, { branch: 0 }], [branch, merge], [defaultSource, merge]], { validate: false }, ) const result = validateWorkflowGraph(graph, { version: 'v3', strictAuthoring: true }) expect(result.errors.map(error => error.code)).toContain('arbitrary_fan_in') }) test('allows connection fan-in from sibling branches of the same condition', async () => { const { conditionNode, llmNode, buildGraph } = await import('./workflow-builders.ts') const { validateWorkflowGraph } = await import('./workflows.ts') const condition = conditionNode({ name: 'Route', cases: [{ name: 'A', condition: '{{input://kind}}|equals|a' }], }) const caseNode = llmNode({ name: 'Case', prompt: 'case' }) const defaultNode = llmNode({ name: 'Default', prompt: 'default' }) const merge = llmNode({ name: 'Merge', prompt: 'merge' }) const graph = buildGraph( [condition, caseNode, defaultNode, merge], [ [condition, caseNode, { branch: 0 }], [condition, defaultNode, { branch: 'default' }], [caseNode, merge], [defaultNode, merge], ], { validate: false }, ) const result = validateWorkflowGraph(graph, { version: 'v3', strictAuthoring: true }) expect(result.errors.map(error => error.code)).not.toContain('condition_connect_scope_mismatch') expect(result.valid).toBe(true) }) test('rejects connection fan-in across different condition scopes', async () => { const { conditionNode, llmNode, stopNode, buildGraph } = await import('./workflow-builders.ts') const { validateWorkflowGraph } = await import('./workflows.ts') const outer = conditionNode({ name: 'Outer Route', cases: [{ name: 'Outer A', condition: '{{input://kind}}|equals|a' }], }) const outerBranch = llmNode({ name: 'Outer Branch', prompt: 'outer' }) const nested = conditionNode({ name: 'Nested Route', cases: [{ name: 'Nested A', condition: '{{input://kind}}|equals|nested' }], }) const nestedBranch = llmNode({ name: 'Nested Branch', prompt: 'nested' }) const nestedStop = stopNode({ name: 'Stop Nested Default' }) const merge = llmNode({ name: 'Merge', prompt: 'merge' }) const graph = buildGraph( [outer, outerBranch, nested, nestedBranch, nestedStop, merge], [ [outer, outerBranch, { branch: 0 }], [outer, nested, { branch: 'default' }], [nested, nestedBranch, { branch: 0 }], [nested, nestedStop, { branch: 'default' }], [outerBranch, merge], [nestedBranch, merge], ], { validate: false }, ) const result = validateWorkflowGraph(graph, { version: 'v3', strictAuthoring: true }) expect(result.errors).toContainEqual(expect.objectContaining({ code: 'condition_connect_scope_mismatch', nodeId: merge.id, })) }) test('returns validation errors instead of throwing for malformed edges in strict authoring mode', async () => { const { llmNode } = await import('./workflow-builders.ts') const { validateWorkflowGraph } = await import('./workflows.ts') const node = llmNode({ name: 'Root', prompt: 'root' }) const edge = { id: 'edge-without-source', target: { id: node.id, portId: node.ports!.find(port => port.dir === 'in')!.id }, } const result = validateWorkflowGraph({ nodes: [node], edges: [edge as any] }, { version: 'v3', strictAuthoring: true }) expect(result.valid).toBe(false) expect(result.errors.map(error => error.code)).toContain('edge_source_missing') }) test('returns validation errors instead of throwing for null edge entries in strict authoring mode', async () => { const { llmNode } = await import('./workflow-builders.ts') const { validateWorkflowGraph } = await import('./workflows.ts') const node = llmNode({ name: 'Root', prompt: 'root' }) const result = validateWorkflowGraph({ nodes: [node], edges: [null] as any }, { version: 'v3', strictAuthoring: true }) expect(result.valid).toBe(false) expect(result.errors).toContainEqual(expect.objectContaining({ code: 'invalid_edge', path: 'edges[0]', })) }) test('returns validation errors instead of throwing for undefined edge entries in strict authoring mode', async () => { const { llmNode } = await import('./workflow-builders.ts') const { validateWorkflowGraph } = await import('./workflows.ts') const node = llmNode({ name: 'Root', prompt: 'root' }) const result = validateWorkflowGraph({ nodes: [node], edges: [undefined] as any }, { version: 'v3', strictAuthoring: true }) expect(result.valid).toBe(false) expect(result.errors).toContainEqual(expect.objectContaining({ code: 'invalid_edge', path: 'edges[0]', })) }) }) describe('buildGraph strict authoring integration', () => { test('buildGraph creates deterministic edge ids for a valid linear graph', async () => { const { llmNode, buildGraph } = await import('./workflow-builders.ts') const first = llmNode({ name: 'First', prompt: 'first' }) const second = llmNode({ name: 'Second', prompt: 'second' }) const graph = buildGraph([first, second], [[first, second]]) const sourcePortId = first.ports!.find(port => port.dir === 'out' && port.type === 'default')!.id const targetPortId = second.ports!.find(port => port.dir === 'in' && port.type === 'default')!.id expect(graph.edges[0]!.id).toBe(`from_${first.id}_${sourcePortId}_to_${second.id}_${targetPortId}`) }) test('buildGraph validates by default', async () => { const { llmNode, buildGraph } = await import('./workflow-builders.ts') const root = llmNode({ name: 'Root', prompt: 'root' }) const left = llmNode({ name: 'Left', prompt: 'left' }) const right = llmNode({ name: 'Right', prompt: 'right' }) expect(() => buildGraph([root, left, right], [[root, left], [root, right]])).toThrow('default_fan_out') }) test('buildGraph can skip local validation for migration tooling', async () => { const { llmNode, buildGraph } = await import('./workflow-builders.ts') const a = llmNode({ name: 'A', prompt: 'A' }) const b = llmNode({ name: 'B', prompt: 'B' }) expect(buildGraph([a, b], [], { validate: false })).toEqual({ nodes: [a, b], edges: [] }) }) test('runWorkflow rejects invalid v3 graphs locally before request submission', async () => { const { llmNode, buildGraph } = await import('./workflow-builders.ts') const { runWorkflow } = await import('./workflows.ts') const a = llmNode({ name: 'A', prompt: 'A' }) const b = llmNode({ name: 'B', prompt: 'B' }) const graph = buildGraph([a, b], [], { validate: false }) let error: unknown try { await runWorkflow({ promptVersionId: 'version-id', graph, definition: {}, inputs: {}, version: 'v3', strictAuthoring: true, }) } catch (err) { error = err } expect(error).toBeInstanceOf(Error) expect((error as Error).message).toContain('invalid_root_count') }) }) describe('condition and map authoring shape', () => { test('accepts one edge from each condition branch port', async () => { const { conditionNode, llmNode, buildGraph } = await import('./workflow-builders.ts') const condition = conditionNode({ name: 'Route', cases: [{ name: 'A', condition: '{{input://kind}}|equals|a' }], }) const caseNode = llmNode({ name: 'Case', prompt: 'case' }) const defaultNode = llmNode({ name: 'Default', prompt: 'default' }) expect(() => buildGraph( [condition, caseNode, defaultNode], [[condition, caseNode, { branch: 0 }], [condition, defaultNode, { branch: 'default' }]], )).not.toThrow() }) test('rejects a condition branch port without an outgoing edge', async () => { const { conditionNode, llmNode, buildGraph } = await import('./workflow-builders.ts') const { validateWorkflowGraph } = await import('./workflows.ts') const condition = conditionNode({ name: 'Route', cases: [{ name: 'A', condition: '{{input://kind}}|equals|a' }], }) const caseNode = llmNode({ name: 'Case', prompt: 'case' }) const graph = buildGraph( [condition, caseNode], [[condition, caseNode, { branch: 0 }]], { validate: false }, ) const result = validateWorkflowGraph(graph, { version: 'v3', strictAuthoring: true }) expect(result.errors.map(error => error.code)).toContain('condition_branch_edge_count') }) test('rejects condition input branch ports that are missing from serialized ports', async () => { const { conditionNode } = await import('./workflow-builders.ts') const { validateWorkflowGraph } = await import('./workflows.ts') const condition = conditionNode({ name: 'Route', cases: [{ name: 'A', condition: '{{input://kind}}|equals|a' }], }) const input = condition.input as { cases: Array<{ portId: string }> } const casePortId = input.cases[0]!.portId condition.ports = condition.ports?.filter(port => port.id !== casePortId) const result = validateWorkflowGraph({ nodes: [condition], edges: [] }, { version: 'v3', strictAuthoring: true }) expect(result.errors.map(error => error.code)).toContain('condition_branch_port_missing') }) test('rejects condition serialized branch ports that are absent from input cases', async () => { const { conditionNode } = await import('./workflow-builders.ts') const { validateWorkflowGraph } = await import('./workflows.ts') const condition = conditionNode({ name: 'Route', cases: [{ name: 'A', condition: '{{input://kind}}|equals|a' }], includeDefault: false, }) const input = condition.input as { cases: Array<{ portId?: string }> } input.cases[0]!.portId = undefined const result = validateWorkflowGraph({ nodes: [condition], edges: [] }, { version: 'v3', strictAuthoring: true }) expect(result.errors.map(error => error.code)).toContain('condition_branch_port_orphaned') }) test('rejects duplicate condition input branch port ids', async () => { const { conditionNode } = await import('./workflow-builders.ts') const { validateWorkflowGraph } = await import('./workflows.ts') const condition = conditionNode({ name: 'Route', cases: [ { name: 'A', condition: '{{input://kind}}|equals|a' }, { name: 'B', condition: '{{input://kind}}|equals|b' }, ], includeDefault: false, }) const input = condition.input as { cases: Array<{ portId: string }> } input.cases[1]!.portId = input.cases[0]!.portId condition.ports = [ condition.ports!.find(port => port.dir === 'in')!, condition.ports!.find(port => port.id === input.cases[0]!.portId)!, ] const result = validateWorkflowGraph({ nodes: [condition], edges: [] }, { version: 'v3', strictAuthoring: true }) expect(result.errors.map(error => error.code)).toContain('condition_branch_port_duplicate') }) test('rejects a map with more than one subgraph root', async () => { const { mapNode, llmNode, buildGraph } = await import('./workflow-builders.ts') const map = mapNode({ name: 'Map', over: '{{input://items}}', mapVariable: 'item' }) const a = llmNode({ name: 'A', prompt: 'A' }) const b = llmNode({ name: 'B', prompt: 'B' }) expect(() => buildGraph( [map, a, b], [[map, a, 'subgraph'], [map, b, 'subgraph']], )).toThrow('map_subgraph_root_count') }) test('preserves missing subgraph edge code for a map with no subgraph root', async () => { const { mapNode, buildGraph } = await import('./workflow-builders.ts') const { validateWorkflowGraph } = await import('./workflows.ts') const map = mapNode({ name: 'Map', over: '{{input://items}}', mapVariable: 'item' }) const graph = buildGraph([map], [], { validate: false }) const result = validateWorkflowGraph(graph, { version: 'v3', strictAuthoring: true }) expect(result.errors.map(error => error.code)).toContain('map_missing_subgraph_edge') }) test('preserves missing map subgraph edge code outside strict authoring mode', async () => { const { mapNode, buildGraph } = await import('./workflow-builders.ts') const { validateWorkflowGraph } = await import('./workflows.ts') const map = mapNode({ name: 'Map', over: '{{input://items}}', mapVariable: 'item' }) const graph = buildGraph([map], [], { validate: false }) const result = validateWorkflowGraph(graph, { version: 'v3', strictAuthoring: false }) expect(result.errors.map(error => error.code)).toContain('map_missing_subgraph_edge') }) })