import { beforeEach, describe, expect, mock, test } from 'bun:test' import type { GetWorkflowVariablesPayload } from './workflows.ts' const apiRequest = mock(async (_endpoint: string, _options?: RequestInit): Promise => ({ id: 'version-id', promptId: 'prompt-id', status: 'pending', })) const apiRequestRaw = mock(async (_endpoint: string, _options?: { body?: unknown, method?: string }) => new Response(null, { status: 200 })) void mock.module('./common.ts', () => ({ apiRequest, apiRequestRaw, getApiBaseUrl: () => 'https://api.tela.com', getAppBaseUrl: () => 'https://app.tela.com', getAuthenticatedWorkspaceId: () => 'workspace-id', getCanvasUrl: (canvasId: string) => `https://app.tela.com/prompt/${canvasId}/craft`, getProjectUrl: (projectId: string) => `https://app.tela.com/project/${projectId}`, loadApiKey: () => 'test-token', parsePayload: (payload: unknown) => payload, SESSION_PATH: '/tmp/session.test', })) async function expectRejectsWithMessage(promise: Promise, message: string): Promise { let error: unknown try { await promise } catch (err) { error = err } expect(error).toBeInstanceOf(Error) expect((error as Error).message).toContain(message) } function requestBody(callIndex = 0): Record { const [, options] = apiRequest.mock.calls[callIndex]! return JSON.parse(String(options?.body)) as Record } async function invalidTwoRootGraph() { const { buildGraph, llmNode } = await import('./workflow-builders.ts') const first = llmNode({ name: 'First', prompt: 'first' }) const second = llmNode({ name: 'Second', prompt: 'second' }) return buildGraph([first, second], [], { validate: false }) } async function validSingleNodeGraph() { const { buildGraph, llmNode } = await import('./workflow-builders.ts') return buildGraph([llmNode({ name: 'Draft', prompt: 'Write about {{input://topic}}' })], []) } describe('workflow API local validation', () => { beforeEach(() => { apiRequest.mockClear() apiRequestRaw.mockClear() }) test('createWorkflowVersionV3 rejects invalid graphs before server validation', async () => { const { buildWorkflowSpecV3 } = await import('./workflow-spec-builders.ts') const { createWorkflowVersionV3 } = await import('./workflows.ts') await expectRejectsWithMessage(createWorkflowVersionV3({ promptId: 'prompt-id', title: 'Invalid', workflowSpec: buildWorkflowSpecV3({ inputs: [] }), graph: await invalidTwoRootGraph(), }), 'invalid_root_count') expect(apiRequestRaw).not.toHaveBeenCalled() expect(apiRequest).not.toHaveBeenCalled() }) test('updateWorkflowVersionV3 rejects invalid graphs before fetching the existing version', async () => { const { updateWorkflowVersionV3 } = await import('./workflows.ts') await expectRejectsWithMessage(updateWorkflowVersionV3('version-id', { graph: await invalidTwoRootGraph(), }), 'invalid_root_count') expect(apiRequestRaw).not.toHaveBeenCalled() expect(apiRequest).not.toHaveBeenCalled() }) test('runWorkflow rejects invalid graphs as v3 by default before request submission', async () => { const { runWorkflow } = await import('./workflows.ts') await expectRejectsWithMessage(runWorkflow({ promptVersionId: 'version-id', graph: await invalidTwoRootGraph(), definition: {}, inputs: {}, }), 'invalid_root_count') expect(apiRequestRaw).not.toHaveBeenCalled() expect(apiRequest).not.toHaveBeenCalled() }) test('createWorkflowVersion defaults to v3 server validation', async () => { const { buildWorkflowSpecV3 } = await import('./workflow-spec-builders.ts') const { createWorkflowVersion } = await import('./workflows.ts') await createWorkflowVersion({ promptId: 'prompt-id', title: 'Server validated', workflowSpec: buildWorkflowSpecV3({ inputs: [] }), graph: await invalidTwoRootGraph(), strictAuthoring: false, }) expect(apiRequestRaw.mock.calls[0]?.[0]).toBe('/workflow/prompt-id/validate') expect(apiRequest.mock.calls[0]?.[0]).toBe('/prompt-version') }) test('createWorkflowVersionV3 with strictAuthoring false keeps server validation', async () => { const { buildWorkflowSpecV3 } = await import('./workflow-spec-builders.ts') const { createWorkflowVersionV3 } = await import('./workflows.ts') await createWorkflowVersionV3({ promptId: 'prompt-id', title: 'Server validated', workflowSpec: buildWorkflowSpecV3({ inputs: [] }), graph: await invalidTwoRootGraph(), strictAuthoring: false, }) expect(apiRequestRaw.mock.calls[0]?.[0]).toBe('/workflow/prompt-id/validate') expect(apiRequest.mock.calls[0]?.[0]).toBe('/prompt-version') }) test('updateWorkflowVersionV3 with strictAuthoring false keeps server validation', async () => { const { updateWorkflowVersionV3 } = await import('./workflows.ts') await updateWorkflowVersionV3('version-id', { graph: await invalidTwoRootGraph(), strictAuthoring: false, }) expect(apiRequest.mock.calls[0]?.[0]).toBe('/prompt-version/version-id') expect(apiRequestRaw.mock.calls[0]?.[0]).toBe('/workflow/prompt-id/validate') expect(apiRequest.mock.calls[1]?.[0]).toBe('/prompt-version/version-id') }) test('runWorkflow with strictAuthoring false keeps server validation', async () => { const { runWorkflow } = await import('./workflows.ts') await runWorkflow({ promptVersionId: 'version-id', graph: await invalidTwoRootGraph(), definition: {}, inputs: {}, version: 'v3', strictAuthoring: false, }) expect(apiRequest.mock.calls[0]?.[0]).toBe('/prompt-version/version-id') expect(apiRequestRaw.mock.calls[0]?.[0]).toBe('/workflow/prompt-id/validate') expect(apiRequest.mock.calls[1]?.[0]).toBe('/workflow/test') }) test('getWorkflowVariables with strictAuthoring false skips local authoring validation', async () => { const { getWorkflowVariables } = await import('./workflows.ts') const payload = { graph: await invalidTwoRootGraph(), version: 'v3', strictAuthoring: false, } satisfies GetWorkflowVariablesPayload await getWorkflowVariables('prompt-id', payload) expect(apiRequestRaw).not.toHaveBeenCalled() expect(apiRequest.mock.calls[0]?.[0]).toBe('/workflow/prompt-id/variables') expect(requestBody()).toMatchObject({ graph: payload.graph, input: [], }) }) }) describe('workflow API payloads', () => { beforeEach(() => { apiRequest.mockClear() apiRequestRaw.mockClear() }) test('createWorkflowVersionV3 derives variables with stable input ids', async () => { const { createWorkflowVersionV3 } = await import('./workflows.ts') const { buildWorkflowSpecV3 } = await import('./workflow-spec-builders.ts') await createWorkflowVersionV3({ promptId: 'prompt-id', title: 'Stable input', workflowSpec: buildWorkflowSpecV3({ inputs: [{ id: 'input-topic', name: 'topic', type: 'text' }], }), graph: await validSingleNodeGraph(), }) expect(apiRequestRaw.mock.calls[0]?.[0]).toBe('/workflow/prompt-id/validate') expect(requestBody()).toMatchObject({ promptId: 'prompt-id', title: 'Stable input', variables: [{ id: 'input-topic', name: 'topic', type: 'text', required: true }], }) }) test('updateWorkflowVersionV3 keeps explicit null configuration in the patch payload', async () => { const { updateWorkflowVersionV3 } = await import('./workflows.ts') await updateWorkflowVersionV3('version-id', { title: 'Clear configuration', configuration: null, }) expect(apiRequest.mock.calls[0]?.[0]).toBe('/prompt-version/version-id') expect(requestBody()).toMatchObject({ title: 'Clear configuration', configuration: null, isWorkflow: true, }) }) test('runWorkflow includes a caller-supplied workflow run id', async () => { const { runWorkflow } = await import('./workflows.ts') await runWorkflow({ id: 'run-id-1', promptVersionId: 'version-id', definition: {}, inputs: {}, version: 'v3', }) expect(apiRequest.mock.calls[0]?.[0]).toBe('/workflow/test') expect(requestBody()).toMatchObject({ id: 'run-id-1', promptVersionId: 'version-id', }) }) test('getWorkflowUrl always returns the v3 app route', async () => { const { getWorkflowUrl } = await import('./workflows.ts') expect(getWorkflowUrl('prompt-id')).toBe('https://app.tela.com/workflows/prompt-id') }) test('public workflow API no longer exports v2 helpers', async () => { const tela = await import('./index.ts') expect('createWorkflowVersionV2' in tela).toBe(false) expect('updateWorkflowVersionV2' in tela).toBe(false) expect('buildWorkflowSpecV2' in tela).toBe(false) expect('getWorkflowV2Url' in tela).toBe(false) expect('templateNode' in tela).toBe(true) }) }) describe('workflow graph validation', () => { test('allows document-templater in v3 strict UI compatibility', async () => { const { buildGraph, documentTemplaterNode } = await import('./workflow-builders.ts') const { validateWorkflowGraph } = await import('./workflows.ts') const graph = buildGraph([documentTemplaterNode({ name: 'Fill Contract', vaultUrl: 'vault://template.docx', outputFilename: 'contract.docx', })], []) const result = validateWorkflowGraph(graph, { version: 'v3', strictUiCompatibility: true }) expect(result.valid).toBe(true) }) test('default workflow validation uses current action catalog and allows template nodes', async () => { const { buildGraph, templateNode } = await import('./workflow-builders.ts') const { validateWorkflowGraph } = await import('./workflows.ts') const graph = buildGraph([ templateNode({ name: 'Template', templateId: 'template-id' }), ], []) const result = validateWorkflowGraph(graph) expect(result.valid).toBe(true) }) }) describe('workflow node updates', () => { test('updates a node by id without mutating the original graph', async () => { const { buildGraph, llmNode, updateWorkflowNodeInput } = await import('./workflow-builders.ts') const node = llmNode({ name: 'Prompt', prompt: 'old' }) const graph = buildGraph([node], []) const updated = updateWorkflowNodeInput(graph, node.id, { prompt: 'new' }) expect((graph.nodes[0]!.input as any).prompt).toBe('old') expect((updated.nodes[0]!.input as any).prompt).toBe('new') }) test('rejects stop node input updates', async () => { const { buildGraph, stopNode, updateWorkflowNodeInput } = await import('./workflow-builders.ts') const stop = stopNode({ name: 'Stop' }) const graph = buildGraph([stop], [], { validate: false }) expect(() => updateWorkflowNodeInput(graph, stop.id, { anything: true })).toThrow('stop nodes do not have editable input') }) test('syncs condition branch ports from updated cases and default branches', async () => { const { buildGraph, conditionNode, updateWorkflowNodeInput } = await import('./workflow-builders.ts') const condition = conditionNode({ name: 'Route', cases: [ { name: 'A', condition: '{{input://topic}} == "a"' }, { name: 'B', condition: '{{input://topic}} == "b"' }, ], }) const firstPortId = ((condition.input?.cases as Array<{ portId: string }>)[0]!).portId const graph = buildGraph([condition], [], { validate: false }) const updated = updateWorkflowNodeInput(graph, condition.id, { cases: [{ name: 'A', condition: '{{input://topic}} == "a"', type: 'selector', portId: firstPortId }], default: null, }, { strictAuthoring: false }) expect(updated.nodes[0]!.ports).toEqual([ { id: condition.ports![0]!.id, dir: 'in', type: 'default' }, { id: firstPortId, dir: 'out', type: 'branch' }, ]) }) test('preserves existing condition branch ports and creates ports for new branches', async () => { const { buildGraph, conditionNode, updateWorkflowNodeInput } = await import('./workflow-builders.ts') const condition = conditionNode({ name: 'Route', cases: [{ name: 'A', condition: '{{input://topic}} == "a"' }], }) const originalInput = condition.input as { cases: Array<{ portId: string }> default: { portId: string } } const firstPortId = originalInput.cases[0]!.portId const defaultPortId = originalInput.default.portId const graph = buildGraph([condition], [], { validate: false }) const updated = updateWorkflowNodeInput(graph, condition.id, { cases: [ { name: 'A', condition: '{{input://topic}} == "a"', type: 'selector' }, { name: 'B', condition: '{{input://topic}} == "b"', type: 'selector' }, ], default: { name: 'Default', condition: null, steps: [] }, }, { strictAuthoring: false }) const input = updated.nodes[0]!.input as { cases: Array<{ portId: string }> default: { portId: string } } expect(input.cases[0]!.portId).toBe(firstPortId) expect(input.cases[1]!.portId).toBeString() expect(input.cases[1]!.portId).not.toBe(firstPortId) expect(input.default.portId).toBe(defaultPortId) expect(updated.nodes[0]!.ports?.filter(port => port.dir === 'out' && port.type === 'branch').map(port => port.id)) .toEqual([firstPortId, input.cases[1]!.portId, defaultPortId]) }) test('preserves condition case ports by stable case name when cases are reordered without port ids', async () => { const { buildGraph, conditionNode, updateWorkflowNodeInput } = await import('./workflow-builders.ts') const condition = conditionNode({ name: 'Route', cases: [ { name: 'A', condition: '{{input://topic}} == "a"' }, { name: 'B', condition: '{{input://topic}} == "b"' }, ], includeDefault: false, }) const originalInput = condition.input as { cases: Array<{ portId: string }> } const firstPortId = originalInput.cases[0]!.portId const secondPortId = originalInput.cases[1]!.portId const graph = buildGraph([condition], [], { validate: false }) const updated = updateWorkflowNodeInput(graph, condition.id, { cases: [ { name: 'B', condition: '{{input://topic}} == "b"', type: 'selector' }, { name: 'A', condition: '{{input://topic}} == "a"', type: 'selector' }, ], }, { strictAuthoring: false }) const input = updated.nodes[0]!.input as { cases: Array<{ portId: string }> } expect(input.cases.map(workflowCase => workflowCase.portId)).toEqual([secondPortId, firstPortId]) }) test('rejects prepended condition cases without explicit port ids', async () => { const { buildGraph, conditionNode, updateWorkflowNodeInput } = await import('./workflow-builders.ts') const condition = conditionNode({ name: 'Route', cases: [{ name: 'A', condition: '{{input://topic}} == "a"' }], includeDefault: false, }) const graph = buildGraph([condition], [], { validate: false }) expect(() => updateWorkflowNodeInput(graph, condition.id, { cases: [ { name: 'New', condition: '{{input://topic}} == "new"', type: 'selector' }, { name: 'A', condition: '{{input://topic}} == "a"', type: 'selector' }, ], }, { strictAuthoring: false })).toThrow('condition_case_port_id_required') }) test('cleans canvas and template variables to allowed keys', async () => { const { buildGraph, canvasNode, templateNode, updateWorkflowNodeInput } = await import('./workflow-builders.ts') const canvas = canvasNode({ name: 'Canvas', canvasId: 'canvas-id', variables: { keep: '{{input://keep}}', remove: '{{input://remove}}' }, }) const template = templateNode({ name: 'Template', templateId: 'template-id', variables: { keep: '{{input://keep}}', remove: '{{input://remove}}' }, }) const graph = buildGraph([canvas, template], [], { validate: false }) const options = { allowedVariableKeys: ['keep'], strictAuthoring: false } const updatedCanvas = updateWorkflowNodeInput(graph, canvas.id, {}, options) const updatedTemplate = updateWorkflowNodeInput(graph, template.id, {}, options) expect((updatedCanvas.nodes[0]!.input?.variables as Record)).toEqual({ keep: '{{input://keep}}' }) expect((updatedTemplate.nodes[1]!.input?.variables as Record)).toEqual({ keep: '{{input://keep}}' }) }) test('creates condition case port ids when omitted from patches', async () => { const { buildGraph, conditionNode, updateWorkflowNodeInput } = await import('./workflow-builders.ts') const condition = conditionNode({ name: 'Route', cases: [{ name: 'A', condition: '{{input://topic}} == "a"' }], includeDefault: false, }) const graph = buildGraph([condition], [], { validate: false }) const updated = updateWorkflowNodeInput(graph, condition.id, { cases: [{ name: 'A', condition: '{{input://topic}} == "a"', type: 'selector' }], }, { strictAuthoring: false }) const input = updated.nodes[0]!.input as { cases: Array<{ portId: string }> } expect(input.cases[0]!.portId).toBeString() }) test('does not mutate caller-owned nested condition patch objects', async () => { const { buildGraph, conditionNode, updateWorkflowNodeInput } = await import('./workflow-builders.ts') const condition = conditionNode({ name: 'Route', cases: [{ name: 'A', condition: '{{input://topic}} == "a"' }], includeDefault: false, }) const graph = buildGraph([condition], [], { validate: false }) const patch = { cases: [{ name: 'A', condition: '{{input://topic}} == "a"', type: 'selector' }], } updateWorkflowNodeInput(graph, condition.id, patch, { strictAuthoring: false }) expect(patch).toEqual({ cases: [{ name: 'A', condition: '{{input://topic}} == "a"', type: 'selector' }], }) }) test('creates condition default branch port ids when omitted from patches', async () => { const { buildGraph, conditionNode, updateWorkflowNodeInput } = await import('./workflow-builders.ts') const condition = conditionNode({ name: 'Route', cases: [{ name: 'A', condition: '{{input://topic}} == "a"' }], }) const casePortId = ((condition.input?.cases as Array<{ portId: string }>)[0]!).portId const graph = buildGraph([condition], [], { validate: false }) const updated = updateWorkflowNodeInput(graph, condition.id, { cases: [{ name: 'A', condition: '{{input://topic}} == "a"', type: 'selector', portId: casePortId }], default: { name: 'Default', condition: null, steps: [] }, }, { strictAuthoring: false }) const input = updated.nodes[0]!.input as { default: { portId: string } } expect(input.default.portId).toBeString() }) test('passes strict compatibility options through validation', async () => { const { buildGraph, llmNode, updateWorkflowNodeInput } = await import('./workflow-builders.ts') const node = llmNode({ name: 'Prompt', prompt: 'old' }) const graph = buildGraph([node], [], { validate: false }) const updated = updateWorkflowNodeInput(graph, node.id, { prompt: 'new' }, { version: 'v3', strictUiCompatibility: false, strictReferenceCompatibility: false, strictAuthoring: false, }) expect(updated.nodes[0]!.actionId).toBe('llm-completion') }) }) describe('workflow prompt-version state management', () => { beforeEach(() => { apiRequest.mockClear() apiRequest.mockImplementation(async (endpoint: string, options?: RequestInit) => { if (endpoint === '/prompt-version/version-id') { if (!options) { return { id: 'version-id', promptId: 'prompt-id', title: 'Existing', content: 'content', markdownContent: 'markdown', configuration: { model: 'gpt-5', type: 'chat' }, variables: [{ id: 'input-id', name: 'topic', type: 'text', required: true }], promoted: true, draft: false, isWorkflow: true, workflowSpec: JSON.stringify({ inputs: { topic: { id: 'input-id', name: 'topic', type: 'text' } }, steps: [] }), graph: JSON.stringify({ nodes: [], edges: [] }), createdAt: '2026-01-01', updatedAt: '2026-01-01', } } return { id: 'updated-version-id' } } throw new Error(`Unexpected endpoint ${endpoint}`) }) }) test('updateWorkflowVersionPreservingState preserves variables and configuration by default', async () => { const { updateWorkflowVersionPreservingState } = await import('./workflows.ts') await updateWorkflowVersionPreservingState('version-id', { title: 'Renamed' }) const [, options] = apiRequest.mock.calls.at(-1)! const body = JSON.parse(String(options?.body)) expect(body.title).toBe('Renamed') expect(body.variables).toEqual([{ id: 'input-id', name: 'topic', type: 'text', required: true }]) expect(body.configuration).toEqual({ model: 'gpt-5', type: 'chat' }) expect(JSON.parse(body.workflowSpec).inputs.topic.id).toBe('input-id') }) test('updateWorkflowVersionPreservingState can clear nullable fields explicitly', async () => { const { updateWorkflowVersionPreservingState } = await import('./workflows.ts') await updateWorkflowVersionPreservingState('version-id', { content: null, markdownContent: null, configuration: null, }) const [, options] = apiRequest.mock.calls.at(-1)! const body = JSON.parse(String(options?.body)) expect(body.content).toBeNull() expect(body.markdownContent).toBeNull() expect(body.configuration).toBeNull() }) test('updateWorkflowVersionPreservingState derives variables when workflowSpec changes and variables are omitted', async () => { const { updateWorkflowVersionPreservingState } = await import('./workflows.ts') await updateWorkflowVersionPreservingState('version-id', { workflowSpec: { inputs: { newTopic: { id: 'new-input-id', name: 'newTopic', type: 'text', required: false }, }, steps: [], }, }) const [, options] = apiRequest.mock.calls.at(-1)! const body = JSON.parse(String(options?.body)) expect(body.variables).toEqual([{ id: 'new-input-id', name: 'newTopic', type: 'text', required: false }]) }) test('updateWorkflowVersionPreservingState preserves explicit variables when workflowSpec changes', async () => { const { updateWorkflowVersionPreservingState } = await import('./workflows.ts') const variables = [{ id: 'manual-id', name: 'manual', type: 'text' as const, required: true }] await updateWorkflowVersionPreservingState('version-id', { workflowSpec: { inputs: { newTopic: { id: 'new-input-id', name: 'newTopic', type: 'text' }, }, steps: [], }, variables, }) const [, options] = apiRequest.mock.calls.at(-1)! const body = JSON.parse(String(options?.body)) expect(body.variables).toEqual(variables) }) }) describe('patchWorkflowNode', () => { beforeEach(() => { apiRequest.mockClear() apiRequestRaw.mockClear() apiRequest.mockImplementation(async (endpoint: string, options?: RequestInit) => { if (endpoint === '/prompt-version/version-with-node' && !options) { return { id: 'version-with-node', promptId: 'prompt-id', title: 'Existing', content: 'content', markdownContent: 'markdown', configuration: { model: 'gpt-5', type: 'chat' }, variables: [{ id: 'input-id', name: 'topic', type: 'text', required: true }], promoted: true, draft: false, isWorkflow: true, workflowSpec: JSON.stringify({ inputs: { topic: { id: 'input-id', name: 'topic', type: 'text' } }, steps: [], }), graph: JSON.stringify({ nodes: [{ id: 'node-id', name: 'Prompt', actionId: 'llm-completion', input: { prompt: 'old' }, ports: [ { id: 'in', dir: 'in', type: 'default' }, { id: 'out', dir: 'out', type: 'default' }, ], }], edges: [], }), createdAt: '2026-01-01', updatedAt: '2026-01-01', } } if (endpoint === '/prompt-version' && options) return { id: 'new-version-id' } throw new Error(`Unexpected endpoint ${endpoint}`) }) }) test('creates a new version preserving existing state', async () => { const { patchWorkflowNode } = await import('./workflows.ts') await patchWorkflowNode({ promptId: 'prompt-id', versionId: 'version-with-node', nodeName: 'Prompt', patch: { prompt: 'new' }, }) const [, options] = apiRequest.mock.calls.at(-1)! const body = JSON.parse(String(options?.body)) expect(body.variables).toEqual([{ id: 'input-id', name: 'topic', type: 'text', required: true }]) expect(body.content).toBe('content') expect(body.markdownContent).toBe('markdown') expect(JSON.parse(body.graph).nodes[0].input.prompt).toBe('new') }) test('preserves null configuration when creating the patched version', async () => { apiRequest.mockImplementation(async (endpoint: string, options?: RequestInit) => { if (endpoint === '/prompt-version/version-with-node' && !options) { return { id: 'version-with-node', promptId: 'prompt-id', title: 'Existing', content: 'content', markdownContent: 'markdown', configuration: null, variables: [{ id: 'input-id', name: 'topic', type: 'text', required: true }], promoted: true, draft: false, isWorkflow: true, workflowSpec: JSON.stringify({ inputs: { topic: { id: 'input-id', name: 'topic', type: 'text' } }, steps: [], }), graph: JSON.stringify({ nodes: [{ id: 'node-id', name: 'Prompt', actionId: 'llm-completion', input: { prompt: 'old' }, ports: [ { id: 'in', dir: 'in', type: 'default' }, { id: 'out', dir: 'out', type: 'default' }, ], }], edges: [], }), createdAt: '2026-01-01', updatedAt: '2026-01-01', } } if (endpoint === '/prompt-version' && options) return { id: 'new-version-id' } throw new Error(`Unexpected endpoint ${endpoint}`) }) const { patchWorkflowNode } = await import('./workflows.ts') await patchWorkflowNode({ promptId: 'prompt-id', versionId: 'version-with-node', nodeName: 'Prompt', patch: { prompt: 'new' }, }) const [, options] = apiRequest.mock.calls.at(-1)! const body = JSON.parse(String(options?.body)) expect(body.configuration).toBeNull() }) test('passes strict authoring and UI compatibility options when patching v3 graphs', async () => { apiRequest.mockImplementation(async (endpoint: string, options?: RequestInit) => { if (endpoint === '/prompt-version/version-with-node' && !options) { return { id: 'version-with-node', promptId: 'prompt-id', title: 'Existing', content: 'content', markdownContent: 'markdown', configuration: { model: 'gpt-5', type: 'chat' }, variables: [], promoted: true, draft: false, isWorkflow: true, workflowSpec: JSON.stringify({ inputs: {}, steps: [] }), graph: JSON.stringify({ nodes: [ { id: 'prompt-node', name: 'Prompt', actionId: 'llm-completion', input: { prompt: 'old' }, ports: [ { id: 'prompt-in', dir: 'in', type: 'default' }, { id: 'prompt-out', dir: 'out', type: 'default' }, ], }, { id: 'canvas-node', name: 'Canvas', actionId: 'canvas', input: { canvasId: 'canvas-id', variables: {} }, ports: [ { id: 'canvas-in', dir: 'in', type: 'default' }, { id: 'canvas-out', dir: 'out', type: 'default' }, ], }, ], edges: [], }), createdAt: '2026-01-01', updatedAt: '2026-01-01', } } if (endpoint === '/prompt-version' && options) return { id: 'new-version-id' } throw new Error(`Unexpected endpoint ${endpoint}`) }) const { patchWorkflowNode } = await import('./workflows.ts') await patchWorkflowNode({ promptId: 'prompt-id', versionId: 'version-with-node', nodeName: 'Prompt', patch: { prompt: 'new' }, version: 'v3', strictAuthoring: false, strictUiCompatibility: false, }) expect(apiRequestRaw.mock.calls[0]?.[0]).toBe('/workflow/prompt-id/validate') const [, options] = apiRequest.mock.calls.at(-1)! const body = JSON.parse(String(options?.body)) expect(JSON.parse(body.graph).nodes.find((node: { id: string }) => node.id === 'prompt-node').input.prompt).toBe('new') }) test('passes strict reference compatibility options when patching v3 graphs', async () => { const { patchWorkflowNode } = await import('./workflows.ts') await patchWorkflowNode({ promptId: 'prompt-id', versionId: 'version-with-node', nodeName: 'Prompt', patch: { prompt: '{{input://topic?path=items[0]}}' }, strictReferenceCompatibility: false, }) const [, options] = apiRequest.mock.calls.at(-1)! const body = JSON.parse(String(options?.body)) expect(JSON.parse(body.graph).nodes[0].input.prompt).toBe('{{input://topic?path=items[0]}}') }) test('rejects invalid module patches before creating the patched version', async () => { const { patchWorkflowNode } = await import('./workflows.ts') await expectRejectsWithMessage(patchWorkflowNode({ promptId: 'prompt-id', versionId: 'version-with-node', nodeName: 'Prompt', patch: { prompt: '' }, }), 'module_required_field') expect(apiRequest.mock.calls.some(([endpoint, options]) => endpoint === '/prompt-version' && Boolean(options))).toBe(false) }) })