From cc84330a4f42f658e00856722bb8d0c7271a2c43 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Wed, 22 Jul 2026 14:40:34 +0200 Subject: [PATCH] fix(copilot): resolve inline_script placeholders in global flow writes Co-Authored-By: Claude Fable 5 --- .../copilot/chat/flow/editableFlowJson.ts | 42 +++++++++- .../copilot/chat/flow/helperUtils.ts | 2 +- .../copilot/chat/flow/inlineScriptsUtils.ts | 2 +- .../copilot/chat/global/core.test.ts | 81 ++++++++++++++++++- .../components/copilot/chat/global/core.ts | 65 +++++++++++++-- 5 files changed, 178 insertions(+), 14 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/flow/editableFlowJson.ts b/frontend/src/lib/components/copilot/chat/flow/editableFlowJson.ts index 2f1cb5a699..3d5ef5f3d0 100644 --- a/frontend/src/lib/components/copilot/chat/flow/editableFlowJson.ts +++ b/frontend/src/lib/components/copilot/chat/flow/editableFlowJson.ts @@ -2,8 +2,14 @@ import { z } from 'zod' import type { FlowModule, FlowValue } from '$lib/gen' import { collectAllFlowModuleIdsFromModules } from '$lib/components/flows/flowTree' import { SPECIAL_MODULE_IDS } from '../shared' -import type { InlineScriptSession } from './inlineScriptsUtils' -import { validateFlowGroups, validateFlowNotes, type FlowGroup, type FlowNote } from './helperUtils' +import { findUnresolvedInlineScriptRefs, type InlineScriptSession } from './inlineScriptsUtils' +import { + replaceNewInlineScriptRefsWithEmptyCode, + validateFlowGroups, + validateFlowNotes, + type FlowGroup, + type FlowNote +} from './helperUtils' import { flowModuleSchema, flowModulesSchema } from './openFlowZod.gen' /** @@ -384,7 +390,7 @@ export function buildEditableFlowJson( } } -function restoreSpecialRawscriptModule( +export function restoreSpecialRawscriptModule( module: FlowModule | null, session: InlineScriptSession ): FlowModule | null { @@ -420,3 +426,33 @@ export function applyEditableFlowJsonToFlow( notes: editable.notes ?? undefined } } + +/** + * Post-restore normalization for draft flow writes. A placeholder that survived + * restore either names its own module — a new inline body, blanked in place so + * the caller's empty-body warning sends the model to the set-module-code tool — + * or names nothing, in which case the write is rejected so a literal + * `inline_script.*` string is never persisted as runnable content. + */ +export function finalizeUnresolvedInlineScripts(value: { + modules: FlowModule[] + preprocessor_module?: FlowModule | null + failure_module?: FlowModule | null +}): void { + const specials = [value.preprocessor_module, value.failure_module].filter( + (module): module is FlowModule => module != null + ) + const blanked = new Set() + replaceNewInlineScriptRefsWithEmptyCode(value.modules, blanked) + replaceNewInlineScriptRefsWithEmptyCode(specials, blanked) + const unresolved = [ + ...findUnresolvedInlineScriptRefs(value.modules), + ...findUnresolvedInlineScriptRefs(specials) + ] + if (unresolved.length > 0) { + const refs = unresolved.map((id) => `"inline_script.${id}"`).join(', ') + throw new Error( + `Unresolved inline script reference(s): ${refs}. A rawscript "content" placeholder must be "inline_script." (a new body, filled afterwards with the set-module-code tool) or reference an existing rawscript module to keep its current body. Nothing was saved.` + ) + } +} diff --git a/frontend/src/lib/components/copilot/chat/flow/helperUtils.ts b/frontend/src/lib/components/copilot/chat/flow/helperUtils.ts index c426cb8e4e..ba5d98fdfb 100644 --- a/frontend/src/lib/components/copilot/chat/flow/helperUtils.ts +++ b/frontend/src/lib/components/copilot/chat/flow/helperUtils.ts @@ -344,7 +344,7 @@ function assertResolvedInlineScripts( } } -function replaceNewInlineScriptRefsWithEmptyCode( +export function replaceNewInlineScriptRefsWithEmptyCode( modules: FlowModule[], emptyInlineScriptModuleIds: Set ): void { diff --git a/frontend/src/lib/components/copilot/chat/flow/inlineScriptsUtils.ts b/frontend/src/lib/components/copilot/chat/flow/inlineScriptsUtils.ts index e63965e52a..41d8e06b4a 100644 --- a/frontend/src/lib/components/copilot/chat/flow/inlineScriptsUtils.ts +++ b/frontend/src/lib/components/copilot/chat/flow/inlineScriptsUtils.ts @@ -223,7 +223,7 @@ function restoreInlineScriptReferences( }) } -function findUnresolvedInlineScriptRefs(modules: FlowModule[]): string[] { +export function findUnresolvedInlineScriptRefs(modules: FlowModule[]): string[] { const unresolvedRefs: string[] = [] function checkModule(module: FlowModule) { diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index ac6c1cb2e9..3ba69284d9 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -2986,12 +2986,91 @@ describe('global AI tools', () => { expect(result.message).toContain('"write_to_pg"') expect(result.message).not.toContain('"call_api"') - // The existing module's body must survive the patch round-trip. + // The placeholder is blanked, never persisted as literal content, and the + // existing module's body survives the patch round-trip. + await expect( + callGlobalTool('read_flow_module_code', { path, module_id: 'write_to_pg' }) + ).resolves.toBe('') await expect( callGlobalTool('read_flow_module_code', { path, module_id: 'call_api' }) ).resolves.toBe('export async function main() { return 1 }') }) + it('rejects a patch whose inline_script placeholder references no module', async () => { + const path = 'f/flows/patch-bad-ref' + await callGlobalTool('write_flow', { + path, + modules: JSON.stringify([ + { + id: 'call_api', + value: { + type: 'rawscript', + language: 'bun', + content: 'export async function main() { return 1 }', + input_transforms: {} + } + } + ]) + }) + + await expect( + callGlobalTool('patch_flow_json', { + path, + old_string: '"input_transforms":{}}}]', + new_string: + '"input_transforms":{}}},{"id":"write_to_pg","value":{"type":"rawscript","language":"postgresql","content":"inline_script.call_apy","input_transforms":{}}}]' + }) + ).rejects.toThrow(/Unresolved inline script reference/) + + // The rejected patch must not have touched the draft. + await expect( + callGlobalTool('read_flow_module_code', { path, module_id: 'call_api' }) + ).resolves.toBe('export async function main() { return 1 }') + }) + + it('write_flow resolves placeholders to existing module bodies on overwrite', async () => { + const path = 'f/flows/overwrite-keep-bodies' + const code = 'export async function main() { return 1 }' + await callGlobalTool('write_flow', { + path, + modules: JSON.stringify([ + { + id: 'call_api', + value: { type: 'rawscript', language: 'bun', content: code, input_transforms: {} } + } + ]) + }) + + const result = JSON.parse( + await callGlobalTool('write_flow', { + path, + summary: 'Reordered', + modules: JSON.stringify([ + { + id: 'call_api', + value: { + type: 'rawscript', + language: 'bun', + content: 'inline_script.call_api', + input_transforms: {} + } + }, + { + id: 'notify', + value: { type: 'rawscript', language: 'bun', content: '', input_transforms: {} } + } + ]) + }) + ) + expect(result.success).toBe(true) + expect(result.message).toContain('"notify"') + expect(result.message).not.toContain('"call_api"') + + await expect( + callGlobalTool('read_flow_module_code', { path, module_id: 'call_api' }) + ).resolves.toBe(code) + }) + it('writes flows with flow-mode arguments and reads compact flow value', async () => { const writeResult = JSON.parse( await callGlobalTool('write_flow', { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index e53ba18944..8596512536 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -26,6 +26,7 @@ import type { CreateResource, CreateVariable, Flow, + FlowModule, FlowValue, Job, ListableApp, @@ -57,9 +58,14 @@ import { applyEditableFlowJsonToFlow, buildEditableFlowJson, type EditableFlowJson, + finalizeUnresolvedInlineScripts, + restoreSpecialRawscriptModule, validateEditableFlowJson } from '../flow/editableFlowJson' -import { createInlineScriptSession } from '../flow/inlineScriptsUtils' +import { + createInlineScriptSession, + findUnresolvedInlineScriptRefs +} from '../flow/inlineScriptsUtils' import { searchNpmPackagesTool } from '../script/core' import { getDatatableSdkReference, @@ -1946,10 +1952,11 @@ function getFlowInstructions(): string { - \`read_flow_module_code(path, module_id)\` — returns the raw inline script content for one module. - \`set_flow_module_code(path, module_id, code)\` — overwrites that module's inline script content; saves to the draft. - Use \`patch_flow_json\` for *structural* edits: module ids, paths, input_transforms, branch arrangement, summaries, preprocessor/failure swaps, schema/groups/notes. Use \`set_flow_module_code\` for changes inside a specific rawscript body. -- When **adding a new rawscript module** with \`patch_flow_json\`, set its \`content\` to the placeholder \`"inline_script."\` (matching the compact view) — never real code — then **immediately fill the body** with \`set_flow_module_code(path, module_id, code)\`. Until you do, the module's saved content is the literal placeholder string and the flow cannot run; the tool result lists the module ids still awaiting code. +- When **adding a new rawscript module** with \`patch_flow_json\`, set its \`content\` to the placeholder \`"inline_script."\` (matching the compact view) — never real code — then **immediately fill the body** with \`set_flow_module_code(path, module_id, code)\`. The module is saved with an empty body until you do; the tool result lists the module ids still awaiting code. A placeholder that names anything other than the module's own id or an existing rawscript module is rejected. - \`write_flow\` is for full overwrites / create-from-scratch. Its \`modules\`, \`preprocessor_module\`, and \`failure_module\` arguments are **non-compact** flow modules, but you do NOT have to inline the actual rawscript code: - For a short one-liner body, inline it directly in \`content\`. - For multi-line code, or any body containing quotes, create the rawscript module with **empty content** (\`"content": ""\`) and fill each body afterwards with \`set_flow_module_code(path, module_id, code)\`. Embedding large code in the \`modules\` JSON string forces triple-nested escaping and frequently corrupts the JSON — keep code out of the flow structure. \`write_flow\` reports which modules still have empty bodies so you know what to fill. + - When overwriting an **existing** flow, set \`"content": "inline_script."\` on any rawscript module whose code you are not changing — the placeholder resolves to that module's current body, so you never re-send (or re-read) unchanged code. Placeholders that match no existing rawscript module and are not the module's own id are rejected. # Windmill flow authoring reference @@ -2849,16 +2856,17 @@ export const globalTools: Tool<{}>[] = [ groups: parseOptionalJsonArg(parsed.groups, 'groups'), notes: parseOptionalJsonArg(parsed.notes, 'notes') }) + const resolved = await resolveWriteFlowInlineScripts(parsed.path, editable, ctx.workspace) const result = await writeFlowDraft( { path: parsed.path, summary: parsed.summary, description: parsed.description, - flow: editableFlowToDraftValue(editable) + flow: editableFlowToDraftValue(resolved) }, ctx ) - return appendEmptyInlineScriptWarning(result, editable) + return appendEmptyInlineScriptWarning(result, resolved) } }, { @@ -4326,6 +4334,46 @@ async function loadFlowDraftValue( } } +/** + * `write_flow` accepts `inline_script.` placeholders so the model can + * overwrite a flow without re-sending (or even reading) unchanged rawscript + * bodies. Resolve them against the current draft/deployed flow: an id with a + * stored body keeps it, a new module's own-id placeholder becomes an empty body + * (to fill via set_flow_module_code), and anything else rejects the write. + */ +async function resolveWriteFlowInlineScripts( + path: string, + editable: EditableFlowJson, + workspace: string +): Promise { + const specials = [editable.preprocessor_module, editable.failure_module].filter( + (module): module is FlowModule => module != null + ) + const hasPlaceholders = + findUnresolvedInlineScriptRefs(editable.modules).length > 0 || + findUnresolvedInlineScriptRefs(specials).length > 0 + if (!hasPlaceholders) { + return editable + } + + const session = createInlineScriptSession() + if ( + (await getGlobalDraft(workspace, 'flow', path)) || + (await FlowService.existsFlowByPath({ workspace, path })) + ) { + const base = await loadFlowDraftValue(path, workspace) + buildEditableFlowJson(flowDraftAsEditableInput(base.flow), session) + } + const resolved: EditableFlowJson = { + ...editable, + modules: session.restoreInlineScriptReferences(editable.modules), + preprocessor_module: restoreSpecialRawscriptModule(editable.preprocessor_module, session), + failure_module: restoreSpecialRawscriptModule(editable.failure_module, session) + } + finalizeUnresolvedInlineScripts(resolved) + return resolved +} + async function patchFlowJson( args: { path: string; old_string: string; new_string: string; replace_all: boolean }, ctx: WriteDraftCtx @@ -4358,6 +4406,7 @@ async function patchFlowJson( const patchedEditable = validateEditableFlowJson(parsedValue) const newFlowValue = applyEditableFlowJsonToFlow(base.flow.value, patchedEditable, session) + finalizeUnresolvedInlineScripts(newFlowValue) const result = await writeFlowDraft( { @@ -4373,10 +4422,10 @@ async function patchFlowJson( ctx ) // A patch that adds a new rawscript module carries an `inline_script.` - // placeholder with no session entry, so the restore above keeps the literal - // placeholder as the saved content. Check the restored value (patchedEditable - // still holds placeholders for every rawscript) and tell the model which - // bodies to fill via set_flow_module_code. + // placeholder with no session entry; finalize blanks it (or rejects a typo'd + // ref). Check the restored value (patchedEditable still holds placeholders + // for every rawscript) and tell the model which bodies to fill via + // set_flow_module_code. return appendEmptyInlineScriptWarning(result, { ...patchedEditable, modules: newFlowValue.modules,