diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index a23a8da905..caa31b94df 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -431,6 +431,16 @@ export class AIChatManager { } } + private getGlobalHelpers = (): GlobalToolHelpers => { + const flowHelpers = this.flowAiChatHelpers + return { + ...(this.isSessionChat ? { sessionId: this.sessionId } : {}), + ...(flowHelpers + ? { testActiveFlow: (args?: Record) => flowHelpers.testFlow(args) } + : {}) + } + } + changeMode( mode: AIMode, pendingPrompt?: string, @@ -517,11 +527,7 @@ export class AIChatManager { previewTools: this.isSessionChat }) this.tools = globalToolsFor({ sessionPreview: this.isSessionChat }) - this.helpers = { - ...(this.isSessionChat ? { sessionId: this.sessionId } : {}), - testActiveFlow: async (args?: Record) => - this.flowAiChatHelpers?.testFlow(args) - } satisfies GlobalToolHelpers + this.helpers = this.getGlobalHelpers() } else if (mode === AIMode.APP) { const customPrompt = getCombinedCustomPrompt(mode) this.systemMessage = prepareAppSystemMessage(customPrompt) @@ -1330,6 +1336,9 @@ export class AIChatManager { setFlowHelpers = (flowHelpers: FlowAIChatHelpers) => { this.flowAiChatHelpers = flowHelpers + if (this.mode === AIMode.GLOBAL) { + this.helpers = this.getGlobalHelpers() + } untrack(() => { if (this.autoAcceptEditsActive) { this.acceptPendingFlowEdits(flowHelpers) @@ -1338,6 +1347,9 @@ export class AIChatManager { return () => { this.flowAiChatHelpers = undefined + if (this.mode === AIMode.GLOBAL) { + this.helpers = this.getGlobalHelpers() + } } } diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index b1c77df84d..1c83f24b4f 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -191,6 +191,37 @@ describe('AIChatManager autonomy mode', () => { expect(jobId).toBe('job-flow-preview') expect(testFlow).toHaveBeenCalledWith({ name: 'Ada' }) }) + + it('does not expose an active flow test helper in global mode before flow helpers mount', () => { + const manager = new AIChatManager() + + manager.changeMode(AIMode.GLOBAL) + + expect(manager.helpers.testActiveFlow).toBeUndefined() + }) + + it('refreshes the active flow test helper when flow helpers mount in global mode', async () => { + const manager = new AIChatManager() + const testFlow = vi.fn(async () => 'job-flow-preview') + + manager.changeMode(AIMode.GLOBAL) + const cleanup = manager.setFlowHelpers( + createFlowHelpers({ + hasPendingChanges: () => false, + acceptAllModuleActions: vi.fn(), + testFlow + }) + ) + + await expect(manager.helpers.testActiveFlow({ name: 'Ada' })).resolves.toBe( + 'job-flow-preview' + ) + expect(testFlow).toHaveBeenCalledWith({ name: 'Ada' }) + + cleanup() + + expect(manager.helpers.testActiveFlow).toBeUndefined() + }) }) describe('AIChatManager persisted autonomy default', () => { 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 08ec244aa2..9521c970fb 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -1490,6 +1490,54 @@ describe('global AI tools', () => { expect(result).toContain('Result (SUCCESS)') }) + it('test_run_flow falls back to preview when the live flow editor test hook returns undefined', async () => { + UserDraft.save( + 'flow', + '', + { + path: 'u/admin/live_flow_fallback', + summary: 'Live flow fallback', + value: { modules: [{ id: 'fallback_step', value: { type: 'identity' } }] }, + schema: {}, + edited_by: '', + edited_at: '', + archived: false, + extra_perms: {} + }, + { workspace: WORKSPACE } + ) + UserDraft.setLiveEditorDraft({ + workspace: WORKSPACE, + itemKind: 'flow', + storagePath: '', + effectivePath: 'u/admin/live_flow_fallback' + }) + const testActiveFlow = vi.fn(async () => undefined) + + await withCompletedTestJob(() => + callGlobalTool( + 'test_run_flow', + { + path: 'u/admin/live_flow_fallback', + args: { name: 'Ada' } + }, + toolCallbacks, + { testActiveFlow } + ) + ) + + expect(testActiveFlow).toHaveBeenCalledWith({ name: 'Ada' }) + expect(FlowService.getFlowByPath).not.toHaveBeenCalled() + expect(JobService.runFlowPreview).toHaveBeenCalledWith({ + workspace: WORKSPACE, + requestBody: { + path: 'u/admin/live_flow_fallback', + value: { modules: [{ id: 'fallback_step', value: { type: 'identity' } }] }, + args: { name: 'Ada' } + } + }) + }) + it('test_run_step previews rawscript steps from the local draft flow', async () => { const content = 'export async function main(name: string) {\n\treturn name.toUpperCase()\n}' await callGlobalTool('write_flow', { @@ -1610,6 +1658,42 @@ describe('global AI tools', () => { }) }) + it('test_run_step lists nested step ids when a step is not found', async () => { + await callGlobalTool('write_flow', { + path: 'f/flows/nested-step-error', + summary: 'Flow with nested step', + modules: JSON.stringify([ + { + id: 'loop_step', + value: { + type: 'forloopflow', + iterator: { type: 'static', value: [1] }, + skip_failures: false, + modules: [ + { + id: 'nested_script_step', + value: { + type: 'rawscript', + language: 'bun', + content: 'export async function main() { return 1 }', + input_transforms: {} + } + } + ] + } + } + ]) + }) + + await expect( + callGlobalTool('test_run_step', { + path: 'f/flows/nested-step-error', + stepId: 'missing_nested_step', + args: {} + }) + ).rejects.toThrow(/Available steps: loop_step, nested_script_step/) + }) + it('asks the user a question and returns the selected answer', async () => { const callbacks: ToolCallbacks = { setToolStatus: vi.fn(), diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index fb4edd990c..b30e30c9b0 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -2640,15 +2640,24 @@ async function testRunFlowByPath( return executeTestRun({ jobStarter: async () => { const jobId = await testActiveFlow(testArgs) - if (!jobId) { - throw new Error('Failed to start test run - active flow editor returned undefined') + if (jobId) { + return jobId } - return jobId + + const flow = await loadFlowDraftValue(args.path, workspace) + return JobService.runFlowPreview({ + workspace, + requestBody: { + path: args.path, + value: flowDraftValueForPreview(flow.flow), + args: testArgs + } + }) }, workspace, toolCallbacks, toolId, - startMessage: `Starting live editor flow test run for "${args.path}"...`, + startMessage: `Starting flow test run for "${args.path}"...`, contextName: 'flow' }) } diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index b5018d2a2c..97089e8db0 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -1113,11 +1113,13 @@ function flowStepArgsForModule(moduleId: string, args: Record): Rec } function getAvailableFlowStepIds(flowValue: FlowValue): string { - return [ - ...(flowValue.modules ?? []).map((module: FlowModule) => module.id), - ...(flowValue.preprocessor_module ? [flowValue.preprocessor_module.id] : []), - ...(flowValue.failure_module ? [flowValue.failure_module.id] : []) - ].join(', ') + return Array.from( + new Set([ + ...extractAllModules(flowValue.modules ?? []).map((module: FlowModule) => module.id), + ...(flowValue.preprocessor_module ? [flowValue.preprocessor_module.id] : []), + ...(flowValue.failure_module ? [flowValue.failure_module.id] : []) + ]) + ).join(', ') } async function loadDeployedScriptForFlowStep(