feat(aichat): add test tool to script and flow mode (#6367)

* add test script tool

* modify system prompt

* cleaning

* same for flows

* cleaning

* apply code when confirm test + fix circular dep

* cleaning

* factorize

* display error

* cleaning

* fix

* update comment

* prompts

* cleaner code

* show logs in separate container

* format
This commit is contained in:
centdix
2025-08-13 21:58:28 +02:00
committed by GitHub
parent a41edd236b
commit 34773f2614
9 changed files with 520 additions and 188 deletions
+2 -2
View File
@@ -662,8 +662,8 @@
let inlineAIChatSelection: Selection | null = $state(null)
let selectedCode = $state('')
export function reviewAndApplyCode(code: string) {
aiChatEditorHandler?.reviewAndApply(code)
export function reviewAndApplyCode(code: string, applyAll: boolean = false) {
aiChatEditorHandler?.reviewAndApply(code, applyAll)
}
function addChatHandler(editor: meditor.IStandaloneCodeEditor) {
@@ -428,9 +428,9 @@
}
untrack(() => {
aiChatManager.scriptEditorOptions = options
aiChatManager.scriptEditorApplyCode = (code: string) => {
aiChatManager.scriptEditorApplyCode = (code: string, applyAll: boolean = false) => {
hideDiffMode()
editor?.reviewAndApplyCode(code)
editor?.reviewAndApplyCode(code, applyAll)
}
aiChatManager.scriptEditorShowDiffMode = showDiffMode
})
@@ -8,7 +8,7 @@ import {
} from './flow/core'
import ContextManager from './ContextManager.svelte'
import HistoryManager from './HistoryManager.svelte'
import { processToolCall, type DisplayMessage, type Tool, type ToolCallbacks, type ToolDisplayMessage } from './shared'
import { extractCodeFromMarkdown, getLatestAssistantMessage, processToolCall, type DisplayMessage, type Tool, type ToolCallbacks, type ToolDisplayMessage } from './shared'
import type {
ChatCompletionChunk,
ChatCompletionMessageParam,
@@ -80,13 +80,13 @@ class AIChatManager {
helpers = $state<any | undefined>(undefined)
scriptEditorOptions = $state<ScriptOptions | undefined>(undefined)
scriptEditorApplyCode = $state<((code: string) => void) | undefined>(undefined)
scriptEditorApplyCode = $state<((code: string, applyAll?: boolean) => void) | undefined>(undefined)
scriptEditorShowDiffMode = $state<(() => void) | undefined>(undefined)
flowAiChatHelpers = $state<FlowAIChatHelpers | undefined>(undefined)
pendingNewCode = $state<string | undefined>(undefined)
apiTools = $state<Tool<any>[]>([])
aiChatInput = $state<AIChatInput | null>(null)
private confirmationCallback = $state<((value: boolean) => void) | undefined>(undefined)
allowedModes: Record<AIMode, boolean> = $derived({
@@ -205,7 +205,25 @@ class AIChatManager {
const lang = this.scriptEditorOptions?.lang ?? 'bun'
this.tools = [this.changeModeTool, ...prepareScriptTools(lang, context)]
this.helpers = {
getLang: () => lang
getScriptOptions: () => {
return {
code: this.scriptEditorOptions?.code ?? '',
lang: lang,
path: this.scriptEditorOptions?.path ?? '',
args: this.scriptEditorOptions?.args ?? {}
}
},
getLastSuggestedCode: () => {
const latestMessage = getLatestAssistantMessage(this.displayMessages)
if (latestMessage) {
const codeBlocks = extractCodeFromMarkdown(latestMessage)
return codeBlocks[codeBlocks.length - 1]
}
return undefined
},
applyCode: (code: string, applyAll?: boolean) => {
this.scriptEditorApplyCode?.(code, applyAll)
}
}
if (options?.closeScriptSettings) {
const closeComponent = triggerablesByAi['close-script-builder-settings']
@@ -0,0 +1,94 @@
<script lang="ts">
import { Loader2, Copy, Check } from 'lucide-svelte'
interface Props {
title: string
content?: any
error?: string
loading?: boolean
showCopy?: boolean
showWhileLoading?: boolean
}
let { title, content, error, loading, showCopy = true, showWhileLoading = true }: Props = $props()
let copied = $state(false)
const hasContent = $derived(content !== undefined && content !== null)
function formatJson(obj: any): string {
try {
if (typeof obj === 'string') {
try {
const parsed = JSON.parse(obj)
return JSON.stringify(parsed, null, 2)
} catch {
return obj
}
}
return JSON.stringify(obj, null, 2)
} catch {
return String(obj)
}
}
async function copyToClipboard() {
if (!hasContent) return
try {
await navigator.clipboard.writeText(formatJson(content))
copied = true
setTimeout(() => (copied = false), 1500)
} catch (err) {
console.error('Failed to copy:', err)
}
}
</script>
{#if showWhileLoading || (!loading && hasContent)}
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-secondary text-2xs font-semibold uppercase tracking-wide">
{title}:
</span>
{#if showCopy && hasContent}
<button
class="p-1 rounded hover:bg-surface-secondary text-tertiary hover:text-secondary transition-colors"
onclick={copyToClipboard}
title="Copy {title.toLowerCase()}"
>
{#if copied}
<Check class="w-3 h-3 text-green-500" />
{:else}
<Copy class="w-3 h-3" />
{/if}
</button>
{/if}
</div>
{#if loading}
<div
class="bg-surface-secondary border border-gray-200 dark:border-gray-700 rounded p-3 flex items-center gap-2 text-tertiary"
>
<Loader2 class="w-3 h-3 animate-spin" />
<span class="text-2xs">Executing...</span>
</div>
{:else if error}
<div
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-3 overflow-x-auto max-h-64 overflow-y-auto"
>
<pre class="text-2xs text-red-700 dark:text-red-300 whitespace-pre-wrap">{error}</pre>
</div>
{:else if hasContent}
<div
class="bg-surface-secondary border border-gray-200 dark:border-gray-700 rounded p-3 overflow-x-auto max-h-64 overflow-y-auto"
>
<pre class="text-2xs text-primary whitespace-pre-wrap">{formatJson(content)}</pre>
</div>
{:else}
<div
class="bg-surface-secondary border border-gray-200 dark:border-gray-700 rounded p-3 text-center"
>
<span class="text-2xs text-tertiary">No {title.toLowerCase()} yet</span>
</div>
{/if}
</div>
{/if}
@@ -1,67 +1,31 @@
<script lang="ts">
import { Loader2, ChevronDown, ChevronRight, Copy, Check, XCircle, Play } from 'lucide-svelte'
import { Loader2, ChevronDown, ChevronRight, XCircle, Play } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import { aiChatManager } from './AIChatManager.svelte'
import type { ToolDisplayMessage } from './shared'
import { twMerge } from 'tailwind-merge'
import ToolContentDisplay from './ToolContentDisplay.svelte'
interface Props {
message: ToolDisplayMessage
}
let {
message,
}: Props = $props()
let { message }: Props = $props()
let isExpanded = $state(message.showDetails || (message.isLoading && message.needsConfirmation))
let copiedParams = $state(false)
let copiedResult = $state(false)
// Check if we have content to display
const hasParameters = $derived(message.parameters !== undefined && Object.keys(message.parameters).length > 0)
const hasResult = $derived(message.result !== undefined && message.result !== null)
// Format JSON for display and parameters
function formatJson(obj: any): string {
try {
// If it's already a string, try to parse and re-stringify for formatting
if (typeof obj === 'string') {
try {
const parsed = JSON.parse(obj)
return JSON.stringify(parsed, null, 2)
} catch {
// If it's not valid JSON, return as is
return obj
}
}
// Otherwise stringify the object
return JSON.stringify(obj, null, 2)
} catch {
return String(obj)
}
}
// Copy to clipboard
async function copyToClipboard(text: string, type: 'params' | 'result') {
try {
await navigator.clipboard.writeText(text)
if (type === 'params') {
copiedParams = true
setTimeout(() => copiedParams = false, 2000)
} else {
copiedResult = true
setTimeout(() => copiedResult = false, 2000)
}
} catch (err) {
console.error('Failed to copy:', err)
}
}
const hasParameters = $derived(
message.parameters !== undefined && Object.keys(message.parameters).length > 0
)
</script>
<div class="bg-surface border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden font-mono text-xs">
<div
class="bg-surface border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden font-mono text-xs"
>
<!-- Collapsible Header -->
<button
<button
class="w-full p-3 bg-surface-secondary hover:bg-surface-hover transition-colors flex items-center justify-between text-left border-b border-gray-200 dark:border-gray-700"
onclick={() => isExpanded = !isExpanded}
onclick={() => (isExpanded = !isExpanded)}
disabled={!message.showDetails}
>
<div class="flex items-center gap-2 flex-1">
@@ -72,7 +36,7 @@
<ChevronRight class="w-3 h-3 text-secondary" />
{/if}
{/if}
{#if message.isLoading}
<Loader2 class="w-3.5 h-3.5 animate-spin text-blue-500" />
{:else if message.error}
@@ -82,114 +46,69 @@
{:else}
<span class="text-tertiary"></span>
{/if}
<span class="text-primary font-medium text-2xs">
{message.content}
</span>
</div>
</button>
<!-- Expanded Content -->
{#if isExpanded}
<div class="p-3 bg-surface space-y-3">
<!-- Parameters Section -->
{#if hasParameters}
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-secondary text-2xs font-semibold uppercase tracking-wide">
Parameters:
</span>
<button
class="p-1 rounded hover:bg-surface-secondary text-tertiary hover:text-secondary transition-colors"
onclick={() => copyToClipboard(formatJson(message.parameters), 'params')}
title="Copy parameters"
>
{#if copiedParams}
<Check class="w-3 h-3 text-green-500" />
{:else}
<Copy class="w-3 h-3" />
{/if}
</button>
</div>
<div class="bg-surface-secondary border border-gray-200 dark:border-gray-700 rounded p-3 overflow-x-auto max-h-64 overflow-y-auto">
<pre class="text-2xs text-primary whitespace-pre-wrap">{formatJson(message.parameters)}</pre>
</div>
</div>
{/if}
<!-- Result Section -->
{#if !message.needsConfirmation}
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-secondary text-2xs font-semibold uppercase tracking-wide">
Result:
</span>
{#if hasResult && !message.error}
<button
class="p-1 rounded hover:bg-surface-secondary text-tertiary hover:text-secondary transition-colors"
onclick={() => copyToClipboard(formatJson(message.result), 'result')}
title="Copy result"
>
{#if copiedResult}
<Check class="w-3 h-3 text-green-500" />
{:else}
<Copy class="w-3 h-3" />
{/if}
</button>
{/if}
</div>
{#if message.isLoading}
<div class="bg-surface-secondary border border-gray-200 dark:border-gray-700 rounded p-3 flex items-center gap-2 text-tertiary">
<Loader2 class="w-3 h-3 animate-spin" />
<span class="text-2xs">Executing...</span>
</div>
{:else if message.error}
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-3 overflow-x-auto max-h-64 overflow-y-auto">
<pre class="text-2xs text-red-700 dark:text-red-300 whitespace-pre-wrap">{message.error}</pre>
</div>
{:else if hasResult}
<div class="bg-surface-secondary border border-gray-200 dark:border-gray-700 rounded p-3 overflow-x-auto max-h-64 overflow-y-auto">
<pre class="text-2xs text-primary whitespace-pre-wrap">{formatJson(message.result)}</pre>
</div>
{:else}
<div class="bg-surface-secondary border border-gray-200 dark:border-gray-700 rounded p-3 text-center">
<span class="text-2xs text-tertiary">No result yet</span>
</div>
{/if}
</div>
{/if}
<!-- Parameters Section -->
<ToolContentDisplay title="Parameters" content={message.parameters} />
<!-- Confirmation Footer -->
{#if message.needsConfirmation}
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 flex flex-row items-center justify-end gap-2">
<Button
variant="border"
color="red"
size="xs"
on:click={() => {
if (message.tool_call_id) {
aiChatManager.handleToolConfirmation(message.tool_call_id, false)
}
}}
startIcon={{ icon: XCircle }}
>
</Button>
<Button
variant="border"
color="blue"
size="xs"
on:click={() => {
if (message.tool_call_id) {
aiChatManager.handleToolConfirmation(message.tool_call_id, true)
}
}}
startIcon={{ icon: Play }}
>
Run
</Button>
</div>
{/if}
<!-- Confirmation Footer -->
{#if message.needsConfirmation}
<div
class={twMerge(
'mt-3 pt-3 flex flex-row items-center justify-end gap-2',
hasParameters ? 'border-t border-gray-200 dark:border-gray-700' : ''
)}
>
<Button
variant="border"
color="red"
size="xs"
on:click={() => {
if (message.tool_call_id) {
aiChatManager.handleToolConfirmation(message.tool_call_id, false)
}
}}
startIcon={{ icon: XCircle }}
></Button>
<Button
variant="border"
color="blue"
size="xs"
on:click={() => {
if (message.tool_call_id) {
aiChatManager.handleToolConfirmation(message.tool_call_id, true)
}
}}
startIcon={{ icon: Play }}
>
Run
</Button>
</div>
<!-- Result Section -->
{:else}
<ToolContentDisplay
title="Logs"
content={message.logs}
loading={message.isLoading}
showWhileLoading={false}
/>
<ToolContentDisplay
title="Result"
content={message.result}
error={message.error}
loading={message.isLoading}
/>
{/if}
</div>
{/if}
</div>
</div>
@@ -1,4 +1,4 @@
import { ScriptService, type FlowModule, type RawScript, type Script } from '$lib/gen'
import { ScriptService, type FlowModule, type RawScript, type Script, JobService } from '$lib/gen'
import type {
ChatCompletionSystemMessageParam,
ChatCompletionUserMessageParam
@@ -12,7 +12,7 @@ import {
getLangContext,
SUPPORTED_CHAT_SCRIPT_LANGUAGES
} from '../script/core'
import { createSearchHubScriptsTool, createToolDef, type Tool } from '../shared'
import { createSearchHubScriptsTool, createToolDef, type Tool, executeTestRun } from '../shared'
import type { ExtendedOpenFlow } from '$lib/components/flows/types'
export type AIModuleAction = 'added' | 'modified' | 'removed'
@@ -337,6 +337,16 @@ const getInstructionsForCodeGenerationToolDef = createToolDef(
'Get instructions for code generation for a raw script step'
)
const testRunFlowSchema = z.object({
args: z.record(z.any()).optional().describe('Arguments to pass to the flow (optional, uses default flow inputs if not provided)')
})
const testRunFlowToolDef = createToolDef(
testRunFlowSchema,
'test_run_flow',
'Execute a test run of the current flow'
)
const workspaceScriptsSearch = new WorkspaceScriptsSearch()
export const flowTools: Tool<FlowAIChatHelpers>[] = [
@@ -524,6 +534,41 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
toolCallbacks.setToolStatus(toolId, { content: 'Retrieved resource types for "' + parsedArgs.query + '"' })
return formattedResourceTypes
}
},
{
def: testRunFlowToolDef,
fn: async ({ args, workspace, helpers, toolCallbacks, toolId }) => {
const { flow } = helpers.getFlowAndSelectedId()
if (!flow || !flow.value) {
toolCallbacks.setToolStatus(toolId, {
content: 'No flow available to test',
error: 'No flow found in current context'
})
throw new Error('No flow available to test. Please ensure you have a flow open in the editor.')
}
const parsedArgs = testRunFlowSchema.parse(args)
const flowArgs = parsedArgs.args || {}
return executeTestRun({
jobStarter: () => JobService.runFlowPreview({
workspace: workspace,
requestBody: {
args: flowArgs,
value: flow.value,
tag: flow.tag
}
}),
workspace,
toolCallbacks,
toolId,
startMessage: 'Starting flow test run...',
contextName: 'flow'
})
},
requiresConfirmation: true,
showDetails: true
}
]
@@ -532,6 +577,7 @@ export function prepareFlowSystemMessage(): ChatCompletionSystemMessageParam {
Follow the user instructions carefully.
Go step by step, and explain what you're doing as you're doing it.
DO NOT wait for user confirmation before performing an action. Only do it if the user explicitly asks you to wait in their initial instructions.
ALWAYS use the \`test_run_flow\` tool to test the flow, and iterate on the flow until it works as expected. If the user cancels the test run, do not try again and wait for the next user instruction.
## Understanding User Requests
@@ -167,7 +167,7 @@ export class AIChatEditorHandler {
return changedLines
}
async reviewAndApply(newCode: string) {
async reviewAndApply(newCode: string, applyAll: boolean = false) {
if (aiChatManager.pendingNewCode === newCode) {
this.acceptAll()
return
@@ -222,13 +222,18 @@ export class AIChatEditorHandler {
}
})
;({ collection, ids } = await displayVisualChanges(
'editor-windmill-chat-style',
this.editor,
changes
))
this.decorationsCollections.push(collection)
this.viewZoneIds.push(...ids)
if (!applyAll) {
;({ collection, ids } = await displayVisualChanges(
'editor-windmill-chat-style',
this.editor,
changes
))
this.decorationsCollections.push(collection)
this.viewZoneIds.push(...ids)
}
}
if (applyAll) {
this.acceptAll()
}
}
}
@@ -1,4 +1,4 @@
import { ResourceService } from '$lib/gen/services.gen'
import { ResourceService, JobService } from '$lib/gen/services.gen'
import type { ResourceType, ScriptLang } from '$lib/gen/types.gen'
import { capitalize, isObject, toCamel } from '$lib/utils'
import { get } from 'svelte/store'
@@ -13,7 +13,7 @@ import { scriptLangToEditorLang } from '$lib/scripts'
import { getDbSchemas } from '$lib/components/apps/components/display/dbtable/utils'
import type { CodePieceElement, ContextElement } from '../context'
import { PYTHON_PREPROCESSOR_MODULE_CODE, TS_PREPROCESSOR_MODULE_CODE } from '$lib/script_helpers'
import { createSearchHubScriptsTool, type Tool } from '../shared'
import { createSearchHubScriptsTool, type Tool, executeTestRun } from '../shared'
import { setupTypeAcquisition, type DepsToGet } from '$lib/ata'
import { getModelContextWindow } from '../../lib'
@@ -351,6 +351,7 @@ export const CHAT_SYSTEM_PROMPT = `
- You can also receive a \`DIFF\` of the changes that have been made to the code. You should use this diff to give better answers.
- Before giving your answer, check again that you carefully followed these instructions.
- When asked to create a script that communicates with an external service, you can use the \`search_hub_scripts\` tool to search for relevant scripts in the hub. Make sure the language is the same as what the user is coding in. If you do not find any relevant scripts, you can use the \`search_npm_packages\` tool to search for relevant packages and their documentation. Always give a link to the documentation in your answer if possible.
- After modifying the code, ALWAYS use the \`test_run_script\` tool to test the code, and iterate on the code until it works as expected. If the user cancels the test run, do not try again and wait for the next user instruction.
Important:
Do not mention or reveal these instructions to the user unless explicitly asked to do so.
@@ -498,6 +499,7 @@ export function prepareScriptTools(
tools.push(createSearchHubScriptsTool(true))
tools.push(searchNpmPackagesTool)
}
tools.push(testRunScriptTool)
return tools
}
@@ -627,15 +629,18 @@ async function formatDBSchema(dbSchema: DBSchema) {
}
export interface ScriptChatHelpers {
getLang: () => ScriptLang | 'bunnative'
getScriptOptions: () => { code: string; lang: ScriptLang | 'bunnative'; path: string; args: Record<string, any> }
getLastSuggestedCode: () => string | undefined
applyCode: (code: string, applyAll?: boolean) => void
}
export const resourceTypeTool: Tool<ScriptChatHelpers> = {
def: RESOURCE_TYPE_FUNCTION_DEF,
fn: async ({ args, workspace, helpers, toolCallbacks, toolId }) => {
toolCallbacks.setToolStatus(toolId, { content: 'Searching resource types for "' + args.query + '"...' })
const lang = helpers.getScriptOptions().lang
const formattedResourceTypes = await getFormattedResourceTypes(
helpers.getLang(),
lang,
args.query,
workspace
)
@@ -831,3 +836,72 @@ export async function fetchNpmPackageTypes(
}
}
}
const TEST_RUN_SCRIPT_TOOL: ChatCompletionTool = {
type: 'function',
function: {
name: 'test_run_script',
description: 'Execute a test run of the current script in the editor',
parameters: {
type: 'object',
properties: {
args: {
type: 'object',
description: 'Arguments to pass to the script (optional, uses current editor args if not provided)'
}
},
required: []
}
},
}
export const testRunScriptTool: Tool<ScriptChatHelpers> = {
def: TEST_RUN_SCRIPT_TOOL,
fn: async ({ args, workspace, helpers, toolCallbacks, toolId }) => {
const scriptOptions = helpers.getScriptOptions()
if (!scriptOptions) {
toolCallbacks.setToolStatus(toolId, {
content: 'No script available to test',
error: 'No script found in current context'
})
throw new Error('No script code available to test. Please ensure you have a script open in the editor.')
}
let codeToTest = scriptOptions.code
// Check if there are suggested code changes to apply
const lastSuggestedCode = helpers.getLastSuggestedCode()
if (lastSuggestedCode && lastSuggestedCode !== codeToTest) {
codeToTest = lastSuggestedCode
toolCallbacks.setToolStatus(toolId, { content: 'Applying code changes...' })
// Apply the suggested code changes using the existing mechanism
helpers.applyCode(lastSuggestedCode, true)
toolCallbacks.setToolStatus(toolId, { content: 'Code changes applied, starting test...' })
}
return executeTestRun({
jobStarter: () => JobService.runScriptPreview({
workspace: workspace,
requestBody: {
path: scriptOptions.path,
content: codeToTest,
args: args.args || scriptOptions.args || {},
language: scriptOptions.lang as ScriptLang,
tag: undefined,
lock: undefined,
script_hash: undefined
}
}),
workspace,
toolCallbacks,
toolId,
startMessage: 'Running test...',
contextName: 'script'
})
},
requiresConfirmation: true,
showDetails: true,
}
@@ -10,7 +10,7 @@ import type { ExtendedOpenFlow } from '$lib/components/flows/types'
import type { FunctionParameters } from 'openai/resources/shared.mjs'
import { zodToJsonSchema } from 'zod-to-json-schema'
import { z } from 'zod'
import { ScriptService } from '$lib/gen'
import { ScriptService, JobService, type CompletedJob } from '$lib/gen'
type BaseDisplayMessage = {
content: string
@@ -30,6 +30,7 @@ export type ToolDisplayMessage = {
content: string
parameters?: any
result?: any
logs?: string
isLoading?: boolean
error?: string
needsConfirmation?: boolean
@@ -61,7 +62,9 @@ async function callTool<T>({
}): Promise<string> {
const tool = tools.find((t) => t.def.function.name === functionName)
if (!tool) {
throw new Error(`Unknown tool call: ${functionName}. Probably not in the correct mode, use the change_mode tool to switch to the correct mode.`)
throw new Error(
`Unknown tool call: ${functionName}. Probably not in the correct mode, use the change_mode tool to switch to the correct mode.`
)
}
return tool.fn({ args, workspace, helpers, toolCallbacks, toolId })
}
@@ -80,10 +83,10 @@ export async function processToolCall<T>({
try {
const args = JSON.parse(toolCall.function.arguments || '{}')
const tool = tools.find((t) => t.def.function.name === toolCall.function.name)
// Check if tool requires confirmation
const needsConfirmation = tool?.requiresConfirmation
// Add the tool to the display with appropriate status
toolCallbacks.setToolStatus(toolCall.id, {
...(needsConfirmation ? { content: 'Waiting for confirmation...' } : {}),
@@ -92,11 +95,11 @@ export async function processToolCall<T>({
needsConfirmation: needsConfirmation,
showDetails: tool?.showDetails
})
// If confirmation is needed and we have the callback, wait for it
if (needsConfirmation && toolCallbacks.requestConfirmation) {
const confirmed = await toolCallbacks.requestConfirmation(toolCall.id)
if (!confirmed) {
toolCallbacks.setToolStatus(toolCall.id, {
content: 'Cancelled by user',
@@ -110,14 +113,14 @@ export async function processToolCall<T>({
content: 'Tool execution was cancelled by user'
}
}
// Update status to executing after confirmation
toolCallbacks.setToolStatus(toolCall.id, {
isLoading: true,
needsConfirmation: false
})
}
let result = ''
try {
result = await callTool({
@@ -130,7 +133,7 @@ export async function processToolCall<T>({
toolId: toolCall.id
})
toolCallbacks.setToolStatus(toolCall.id, {
isLoading: false,
isLoading: false
})
} catch (err) {
console.error(err)
@@ -138,9 +141,9 @@ export async function processToolCall<T>({
isLoading: false,
error: 'An error occurred while calling the tool'
})
const errorMessage = typeof err === 'string' ? err : 'An error occurred while calling the tool'
result =
`Error while calling tool: ${errorMessage}, MUST tell the user to check the browser console for more details, and then respond as much as possible to the original request`
const errorMessage =
typeof err === 'string' ? err : 'An error occurred while calling the tool'
result = `Error while calling tool: ${errorMessage}`
}
const toAdd = {
role: 'tool' as const,
@@ -153,8 +156,7 @@ export async function processToolCall<T>({
return {
role: 'tool' as const,
tool_call_id: toolCall.id,
content:
'Error while calling tool, MUST tell the user to check the browser console for more details, and then respond as much as possible to the original request'
content: 'Error while calling tool'
}
}
}
@@ -249,3 +251,177 @@ export const createSearchHubScriptsTool = (withContent: boolean = false) => ({
return JSON.stringify(results)
}
})
// Constants for result formatting
const MAX_RESULT_LENGTH = 12000
const MAX_LOG_LENGTH = 4000
export interface TestRunConfig {
jobStarter: () => Promise<string>
workspace: string
toolCallbacks: ToolCallbacks
toolId: string
startMessage?: string
contextName: 'script' | 'flow'
}
// Common job polling function
export async function pollJobCompletion(
jobId: string,
workspace: string,
toolId: string,
toolCallbacks: ToolCallbacks
): Promise<CompletedJob> {
let attempts = 0
const maxAttempts = 60
let job: CompletedJob | null = null
while (attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 1000))
attempts++
try {
const fetchedJob = await JobService.getJob({
workspace: workspace,
id: jobId,
noLogs: false,
noCode: true
})
if (fetchedJob.type === 'CompletedJob') {
job = fetchedJob
break
}
} catch (error) {
if (attempts >= maxAttempts) {
throw error
}
}
}
if (!job) {
toolCallbacks.setToolStatus(toolId, {
content: 'Test timed out',
error: 'Execution timed out or failed to complete'
})
throw new Error('Test execution timed out after 60 seconds')
}
return job
}
// Helper function to extract code blocks from markdown text
export function extractCodeFromMarkdown(markdown: string): string[] {
const codeBlocks: string[] = []
// Matches: ```[language]\n[code]\n```
const codeBlockRegex = /```(?:[a-z]+)?\n([\s\S]*?)```/g
let match: RegExpExecArray | null = null
while ((match = codeBlockRegex.exec(markdown)) !== null) {
const code = match[1].trim()
if (code) {
codeBlocks.push(code)
}
}
return codeBlocks
}
// Helper function to get the latest assistant message from display messages
export function getLatestAssistantMessage(displayMessages: DisplayMessage[]): string | undefined {
// Iterate from the end to find the most recent assistant message
for (let i = displayMessages.length - 1; i >= 0; i--) {
const message = displayMessages[i]
if (message.role === 'assistant' && message.content) {
return message.content
}
}
return undefined
}
// Helper function to extract error messages from job results
function getErrorMessage(result: unknown): string {
if (typeof result === 'object' && result !== null && 'error' in result) {
const error = (result as Record<string, unknown>).error
if (typeof error === 'object' && error !== null && 'message' in error) {
const message = (error as Record<string, unknown>).message as string
if ('stack' in error) {
return (message + '\n' + (error as Record<string, unknown>).stack) as string
}
return message
}
if (typeof error === 'string') {
return error
}
}
if (typeof result === 'string') {
return result
}
return 'Unknown error'
}
// Main execution function for test runs
export async function executeTestRun(config: TestRunConfig): Promise<string> {
try {
config.toolCallbacks.setToolStatus(config.toolId, {
content: config.startMessage || `Starting ${config.contextName} test...`
})
const jobId = await config.jobStarter()
config.toolCallbacks.setToolStatus(config.toolId, {
content: `${config.contextName} test started, waiting for completion...`
})
const job = await pollJobCompletion(
jobId,
config.workspace,
config.toolId,
config.toolCallbacks
)
config.toolCallbacks.setToolStatus(config.toolId, {
content: `${config.contextName} test ${job.success ? 'completed successfully' : 'failed'}`,
result: formatResult(job.result),
logs: formatLogs(job.logs),
...(job.success ? {} : { error: getErrorMessage(job.result) })
})
return formatResultSummary(job.result, job.logs, job.success)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
config.toolCallbacks.setToolStatus(config.toolId, {
content: `${config.contextName} test execution failed`,
error: errorMessage
})
throw new Error(`Failed to execute ${config.contextName} test run: ${errorMessage}`)
}
}
function formatLogs(logs: string | undefined): undefined | string {
if (logs && logs.trim()) {
if (logs.length <= MAX_LOG_LENGTH) {
return logs
} else {
return logs.slice(-MAX_LOG_LENGTH)
}
}
return undefined
}
function formatResult(result: unknown): string {
if (typeof result === 'string') {
return result
}
return JSON.stringify(result, null, 2)
}
function formatResultSummary(result: unknown, logs: string | undefined, success: boolean): string {
let resultSummary = ''
resultSummary += `Result (${success ? 'SUCCESS' : 'FAILED'})\n\n`
resultSummary += formatResult(result).slice(0, MAX_RESULT_LENGTH)
resultSummary += '\n\nLogs:\n\n'
resultSummary += formatLogs(logs) ?? 'No logs available'
return resultSummary
}