fix: harden global flow test fallback

This commit is contained in:
centdix
2026-06-01 14:17:10 +02:00
parent cdb50796fb
commit 97254ef33a
5 changed files with 152 additions and 14 deletions
@@ -431,6 +431,16 @@ export class AIChatManager {
}
}
private getGlobalHelpers = (): GlobalToolHelpers => {
const flowHelpers = this.flowAiChatHelpers
return {
...(this.isSessionChat ? { sessionId: this.sessionId } : {}),
...(flowHelpers
? { testActiveFlow: (args?: Record<string, any>) => 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<string, any>) =>
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()
}
}
}
@@ -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', () => {
@@ -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(),
@@ -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'
})
}
@@ -1113,11 +1113,13 @@ function flowStepArgsForModule(moduleId: string, args: Record<string, any>): 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(