mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-19 16:03:29 +00:00
2424 lines
81 KiB
TypeScript
2424 lines
81 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import { z } from 'zod'
|
|
import type { DisplayMessage, ToolDisplayMessage } from './shared'
|
|
import { openItemPreviewAction, webSearchResultOf } from './shared'
|
|
|
|
vi.mock('monaco-editor', () => ({
|
|
editor: {}
|
|
}))
|
|
|
|
vi.mock('$lib/utils/featureUsage', () => ({
|
|
logFeatureUsage: vi.fn(),
|
|
logHubScriptPick: vi.fn()
|
|
}))
|
|
|
|
const userHolder = vi.hoisted(() => ({
|
|
current: { is_super_admin: true } as { is_super_admin: boolean }
|
|
}))
|
|
|
|
vi.mock('$lib/stores', () => ({
|
|
workspaceStore: { subscribe: () => () => undefined },
|
|
userStore: {
|
|
subscribe: (run: (value: { is_super_admin: boolean }) => void) => {
|
|
run(userHolder.current)
|
|
return () => {}
|
|
}
|
|
}
|
|
}))
|
|
|
|
vi.mock('$lib/components/triggers/email/utils', () => ({
|
|
getEmailAddress: (localPart: string, _wlp: boolean, _wsId: string, domain: string) =>
|
|
`${localPart}@${domain}`
|
|
}))
|
|
|
|
vi.mock('$lib/components/flows/flowTree', () => ({
|
|
findModuleInModules: () => undefined
|
|
}))
|
|
|
|
vi.mock('$lib/gen', () => ({
|
|
ScriptService: {},
|
|
FlowService: {},
|
|
IntegrationService: {},
|
|
JobService: { getJob: vi.fn(), getJobUpdates: vi.fn() },
|
|
ScheduleService: {
|
|
previewSchedule: vi.fn(),
|
|
createSchedule: vi.fn()
|
|
},
|
|
HttpTriggerService: { createHttpTrigger: vi.fn() },
|
|
WebsocketTriggerService: { createWebsocketTrigger: vi.fn() },
|
|
KafkaTriggerService: { createKafkaTrigger: vi.fn() },
|
|
NatsTriggerService: { createNatsTrigger: vi.fn() },
|
|
PostgresTriggerService: { createPostgresTrigger: vi.fn() },
|
|
MqttTriggerService: { createMqttTrigger: vi.fn() },
|
|
SqsTriggerService: { createSqsTrigger: vi.fn() },
|
|
GcpTriggerService: { createGcpTrigger: vi.fn() },
|
|
AzureTriggerService: { createAzureTrigger: vi.fn() },
|
|
AmqpTriggerService: { createAmqpTrigger: vi.fn() },
|
|
EmailTriggerService: { createEmailTrigger: vi.fn() },
|
|
SettingService: { getGlobal: vi.fn() },
|
|
ResourceService: {
|
|
getResourceValue: vi.fn(async ({ path }: { path: string }) => ({ content: `body of ${path}` }))
|
|
}
|
|
}))
|
|
|
|
vi.mock('$lib/utils', () => ({
|
|
emptyString: (value: string | undefined | null) => !value
|
|
}))
|
|
|
|
vi.mock('$lib/scripts', () => ({
|
|
scriptLangToEditorLang: (language: string) => language
|
|
}))
|
|
|
|
vi.mock('$lib/aiStore', () => ({
|
|
getCurrentModel: () => undefined
|
|
}))
|
|
|
|
vi.mock('@leeoniya/ufuzzy', () => ({
|
|
default: class {
|
|
search() {
|
|
return [[], [], []]
|
|
}
|
|
}
|
|
}))
|
|
|
|
// deriveChatJobStatus's scheduled branch calls forLater; stub it deterministically
|
|
// (a real one pulls in stores/db-clock drift) — "later" = >5s in the future.
|
|
vi.mock('$lib/forLater', () => ({
|
|
forLater: (scheduled: string | number | Date) => new Date(scheduled).getTime() > Date.now() + 5000
|
|
}))
|
|
|
|
describe('createToolDef', () => {
|
|
it('builds the create_trigger schema without top-level composition', async () => {
|
|
const { createToolDef } = await import('./shared')
|
|
const { createTriggerToolSchema } = await import('./workspaceToolsZod.gen')
|
|
const toolDef = createToolDef(createTriggerToolSchema, 'create_trigger', 'Create a trigger')
|
|
|
|
const parameters = toolDef.function.parameters as any
|
|
expect(parameters).toBeDefined()
|
|
expect(parameters?.type).toBe('object')
|
|
expect(parameters?.anyOf).toBeUndefined()
|
|
expect(parameters?.oneOf).toBeUndefined()
|
|
expect(parameters?.allOf).toBeUndefined()
|
|
expect(parameters?.properties?.kind?.enum).toContain('http')
|
|
// config stays open-ended; get_trigger_schema serves the per-kind schemas instead.
|
|
expect(parameters?.properties?.config?.anyOf).toBeUndefined()
|
|
expect(JSON.stringify(toolDef).length).toBeLessThan(2000)
|
|
})
|
|
|
|
it('disables strict mode for schemas with optional properties', async () => {
|
|
const { createToolDef } = await import('./shared')
|
|
const toolDef = createToolDef(
|
|
z.object({
|
|
subject: z.string(),
|
|
language: z.string().optional()
|
|
}),
|
|
'get_instructions',
|
|
'Get instructions'
|
|
)
|
|
|
|
const parameters = toolDef.function.parameters as any
|
|
expect(toolDef.function.strict).toBe(false)
|
|
expect(parameters.required).toEqual(['subject'])
|
|
expect(parameters.properties.language.type).toBe('string')
|
|
})
|
|
|
|
it('keeps strict mode for schemas without optional properties', async () => {
|
|
const { createToolDef } = await import('./shared')
|
|
const toolDef = createToolDef(
|
|
z.object({
|
|
question: z.string(),
|
|
choices: z.array(z.string())
|
|
}),
|
|
'askUserQuestion',
|
|
'Ask a question'
|
|
)
|
|
|
|
const parameters = toolDef.function.parameters as any
|
|
expect(toolDef.function.strict).toBe(true)
|
|
expect(parameters.required).toEqual(['question', 'choices'])
|
|
})
|
|
|
|
it('does not expose runnable target fields on workspace mutation tools', async () => {
|
|
const { createWorkspaceMutationTools } = await import('./workspaceTools')
|
|
const { triggerConfigSchemas } = await import('./workspaceToolsZod.gen')
|
|
const [scheduleTool] = createWorkspaceMutationTools()
|
|
|
|
const scheduleParameters = scheduleTool.def.function.parameters as any
|
|
expect(scheduleParameters?.properties?.script_path).toBeUndefined()
|
|
expect(scheduleParameters?.properties?.is_flow).toBeUndefined()
|
|
|
|
// These come from the runnable the chat is editing, so the model must not see them
|
|
// on any kind, including in the schemas get_trigger_schema serves on demand.
|
|
const getTriggerSchema = createWorkspaceMutationTools().find(
|
|
(tool) => tool.def.function.name === 'get_trigger_schema'
|
|
)!
|
|
for (const kind of Object.keys(triggerConfigSchemas)) {
|
|
const served = JSON.parse(await getTriggerSchema.fn({ args: { kind } } as any))
|
|
expect(served.properties?.script_path).toBeUndefined()
|
|
expect(served.properties?.is_flow).toBeUndefined()
|
|
expect(served.properties?.path).toBeUndefined()
|
|
}
|
|
|
|
// Same for the schedule options served on demand. The runnable target still wins
|
|
// on merge, so this is about not advertising a field the model cannot influence.
|
|
const getScheduleSchema = createWorkspaceMutationTools().find(
|
|
(tool) => tool.def.function.name === 'get_schedule_schema'
|
|
)!
|
|
const schedule = JSON.parse(await getScheduleSchema.fn({ args: {} } as any))
|
|
expect(schedule.properties?.script_path).toBeUndefined()
|
|
expect(schedule.properties?.is_flow).toBeUndefined()
|
|
expect(schedule.properties).toHaveProperty('retry')
|
|
})
|
|
})
|
|
|
|
describe('buildContextString', () => {
|
|
it('serializes selected workspace items as references only', async () => {
|
|
const { buildContextString } = await import('./shared')
|
|
|
|
const context = buildContextString([
|
|
{
|
|
type: 'workspace_script',
|
|
path: 'f/scripts/report',
|
|
title: 'f/scripts/report',
|
|
summary: 'Report script'
|
|
},
|
|
{
|
|
type: 'workspace_flow',
|
|
path: 'f/flows/reporting',
|
|
title: 'f/flows/reporting',
|
|
summary: 'Reporting flow'
|
|
},
|
|
{
|
|
type: 'workspace_app',
|
|
path: 'f/apps/dashboard',
|
|
title: 'f/apps/dashboard',
|
|
summary: 'Dashboard raw app'
|
|
}
|
|
])
|
|
|
|
expect(context).toContain('SELECTED WORKSPACE ITEMS:')
|
|
expect(context).toContain('- type: script, path: f/scripts/report')
|
|
expect(context).toContain('- type: flow, path: f/flows/reporting')
|
|
expect(context).toContain('- type: raw_app, path: f/apps/dashboard')
|
|
expect(context).not.toContain('Report script')
|
|
expect(context).not.toContain('Reporting flow')
|
|
expect(context).not.toContain('Dashboard raw app')
|
|
expect(context).not.toContain('Code:')
|
|
expect(context).not.toContain('Value:')
|
|
})
|
|
})
|
|
|
|
describe('processToolCall', () => {
|
|
it('delivers folder instructions once, holding back a change until they are read', async () => {
|
|
const { createToolDef, processToolCall } = await import('./shared')
|
|
const read = vi.fn(async () => 'read ok')
|
|
const write = vi.fn(async () => 'write ok')
|
|
const tools = [
|
|
{ def: createToolDef(z.object({}), 'read_item', 'Read'), planModeSafe: true, fn: read },
|
|
{ def: createToolDef(z.object({}), 'write_item', 'Write'), fn: write }
|
|
]
|
|
const folderInstructions = {
|
|
list: () => [
|
|
{ path: 'f/billing/AGENTS', scope: 'f/billing/' },
|
|
{ path: 'f/billing/eu/AGENTS', scope: 'f/billing/eu/' },
|
|
{ path: 'f/other/AGENTS', scope: 'f/other/' },
|
|
{ path: 'g/ops/AGENTS', scope: 'g/ops/' }
|
|
],
|
|
deliveredBy: new Map<string, { workspace: string; paths: readonly string[] }>()
|
|
}
|
|
const messages: any[] = []
|
|
const call = async (
|
|
id: string,
|
|
name: string,
|
|
path: string | object,
|
|
workspace = 'test-workspace'
|
|
) => {
|
|
const message = await processToolCall({
|
|
tools,
|
|
toolCall: {
|
|
id,
|
|
type: 'function',
|
|
function: {
|
|
name,
|
|
arguments: JSON.stringify(typeof path === 'string' ? { path } : path)
|
|
}
|
|
},
|
|
helpers: {},
|
|
workspace,
|
|
messages,
|
|
toolCallbacks: { setToolStatus: vi.fn(), removeToolStatus: vi.fn(), folderInstructions }
|
|
})
|
|
messages.push(message)
|
|
return message.content as string
|
|
}
|
|
const turn = () => messages.push({ role: 'assistant', content: '' })
|
|
|
|
turn()
|
|
const readResult = await call('c1', 'read_item', 'f/billing/eu/invoice')
|
|
expect(read).toHaveBeenCalledTimes(1)
|
|
expect(readResult.startsWith('read ok')).toBe(true)
|
|
// Outermost first, so the nested folder's instructions read as the specific ones.
|
|
expect(readResult.indexOf('body of f/billing/AGENTS')).toBeLessThan(
|
|
readResult.indexOf('body of f/billing/eu/AGENTS')
|
|
)
|
|
// Same batch: the model has not read c1's result yet, so the write still waits,
|
|
// pointed at that result rather than handed the bodies twice.
|
|
const sameBatch = await call('c2', 'write_item', 'f/billing/eu/invoice')
|
|
expect(write).not.toHaveBeenCalled()
|
|
expect(sameBatch).not.toContain('body of f/billing/AGENTS')
|
|
expect(sameBatch).toContain('earlier result of this same batch')
|
|
|
|
turn()
|
|
expect(await call('c3', 'write_item', 'f/billing/eu/invoice')).toBe('write ok')
|
|
|
|
const held = await call('c4', 'write_item', 'f/other/x')
|
|
expect(write).toHaveBeenCalledTimes(1)
|
|
expect(held).toContain('body of f/other/AGENTS')
|
|
expect(held).not.toContain('body of f/billing/AGENTS')
|
|
|
|
turn()
|
|
expect(await call('c5', 'write_item', 'f/other/x')).toBe('write ok')
|
|
|
|
expect(await call('g1', 'read_item', 'g/ops/runbook')).toContain('body of g/ops/AGENTS')
|
|
|
|
// The same path in another workspace is another resource, delivered afresh.
|
|
expect(await call('w1', 'read_item', 'f/other/x', 'other-workspace')).toContain(
|
|
'body of f/other/AGENTS'
|
|
)
|
|
|
|
// A delivery lost from the conversation (compaction) is made again.
|
|
messages.splice(0)
|
|
turn()
|
|
expect(await call('c6', 'read_item', 'f/billing/y')).toContain('body of f/billing/AGENTS')
|
|
|
|
// A trigger names its target inside its config.
|
|
messages.splice(0)
|
|
turn()
|
|
const nested = await call('t1', 'write_item', { kind: 'http', config: { path: 'f/other/t' } })
|
|
expect(nested).toContain('body of f/other/AGENTS')
|
|
expect(write).toHaveBeenCalledTimes(2)
|
|
|
|
// A call stopped mid-run is answered with a placeholder under its id: no delivery.
|
|
messages.splice(0)
|
|
folderInstructions.deliveredBy.set('stopped', {
|
|
workspace: 'test-workspace',
|
|
paths: ['f/billing/AGENTS']
|
|
})
|
|
messages.push({ role: 'tool', tool_call_id: 'stopped', content: 'Interrupted' })
|
|
turn()
|
|
expect(await call('c7', 'read_item', 'f/billing/y')).toContain('body of f/billing/AGENTS')
|
|
})
|
|
|
|
it('returns pre-confirmation validation errors without asking for confirmation', async () => {
|
|
const { createToolDef, processToolCall } = await import('./shared')
|
|
const error = 'the script needs to be deployed before doing this action'
|
|
const fn = vi.fn()
|
|
const requestConfirmation = vi.fn()
|
|
const setToolStatus = vi.fn()
|
|
|
|
const result = await processToolCall({
|
|
tools: [
|
|
{
|
|
def: createToolDef(z.object({}), 'create_schedule', 'Create schedule'),
|
|
requiresConfirmation: true,
|
|
showDetails: true,
|
|
validateBeforeConfirmation: () => error,
|
|
fn
|
|
}
|
|
],
|
|
toolCall: {
|
|
id: 'call_1',
|
|
type: 'function',
|
|
function: { name: 'create_schedule', arguments: '{}' }
|
|
},
|
|
helpers: {},
|
|
workspace: 'test-workspace',
|
|
toolCallbacks: {
|
|
setToolStatus,
|
|
removeToolStatus: vi.fn(),
|
|
requestConfirmation
|
|
}
|
|
})
|
|
|
|
expect(requestConfirmation).not.toHaveBeenCalled()
|
|
expect(fn).not.toHaveBeenCalled()
|
|
expect(setToolStatus).toHaveBeenCalledWith(
|
|
'call_1',
|
|
expect.objectContaining({
|
|
content: error,
|
|
error,
|
|
isLoading: false,
|
|
isStreamingArguments: false,
|
|
needsConfirmation: false,
|
|
showDetails: true
|
|
})
|
|
)
|
|
expect(result.content).toBe(error)
|
|
})
|
|
|
|
it('surfaces the real error in the tool status when the tool throws', async () => {
|
|
const { createToolDef, processToolCall } = await import('./shared')
|
|
const apiError = Object.assign(new Error('Bad Request'), {
|
|
status: 400,
|
|
body: { error: { message: 'script not found at path f/scripts/missing' } }
|
|
})
|
|
const setToolStatus = vi.fn()
|
|
|
|
const result = await processToolCall({
|
|
tools: [
|
|
{
|
|
def: createToolDef(z.object({}), 'run_script', 'Run script'),
|
|
fn: vi.fn().mockRejectedValue(apiError)
|
|
}
|
|
],
|
|
toolCall: {
|
|
id: 'call_err',
|
|
type: 'function',
|
|
function: { name: 'run_script', arguments: '{}' }
|
|
},
|
|
helpers: {},
|
|
workspace: 'test-workspace',
|
|
toolCallbacks: {
|
|
setToolStatus,
|
|
removeToolStatus: vi.fn()
|
|
}
|
|
})
|
|
|
|
const expectedError = 'script not found at path f/scripts/missing'
|
|
expect(setToolStatus).toHaveBeenLastCalledWith(
|
|
'call_err',
|
|
expect.objectContaining({
|
|
isLoading: false,
|
|
error: expectedError
|
|
})
|
|
)
|
|
expect(result.content).toBe(`Error while calling tool: ${expectedError}`)
|
|
})
|
|
|
|
it('resolves a tool registered earlier in the same batch from the live set', async () => {
|
|
const { createToolDef, processToolCall } = await import('./shared')
|
|
const fn = vi.fn().mockResolvedValue('built')
|
|
const registered = {
|
|
def: createToolDef(z.object({}), 'build_pipeline_node', 'Build node'),
|
|
fn
|
|
}
|
|
const result = await processToolCall({
|
|
tools: [],
|
|
live: () => ({ tools: [registered], helpers: { pipelines: true } }),
|
|
toolCall: {
|
|
id: 'call_live',
|
|
type: 'function',
|
|
function: { name: 'build_pipeline_node', arguments: '{}' }
|
|
},
|
|
helpers: {},
|
|
workspace: 'test-workspace',
|
|
toolCallbacks: { setToolStatus: vi.fn(), removeToolStatus: vi.fn() }
|
|
})
|
|
|
|
expect(result.content).toBe('built')
|
|
expect(fn).toHaveBeenCalledWith(expect.objectContaining({ helpers: { pipelines: true } }))
|
|
})
|
|
|
|
it('continues to confirmation when pre-confirmation validation passes', async () => {
|
|
const { createToolDef, processToolCall } = await import('./shared')
|
|
const fn = vi.fn().mockResolvedValue('ok')
|
|
const requestConfirmation = vi.fn().mockResolvedValue(true)
|
|
const setToolStatus = vi.fn()
|
|
|
|
const result = await processToolCall({
|
|
tools: [
|
|
{
|
|
def: createToolDef(z.object({}), 'create_schedule', 'Create schedule'),
|
|
requiresConfirmation: true,
|
|
confirmationMessage: 'Create schedule',
|
|
showDetails: true,
|
|
autoCollapseDetails: false,
|
|
validateBeforeConfirmation: () => undefined,
|
|
fn
|
|
}
|
|
],
|
|
toolCall: {
|
|
id: 'call_2',
|
|
type: 'function',
|
|
function: { name: 'create_schedule', arguments: '{}' }
|
|
},
|
|
helpers: {},
|
|
workspace: 'test-workspace',
|
|
toolCallbacks: {
|
|
setToolStatus,
|
|
removeToolStatus: vi.fn(),
|
|
requestConfirmation
|
|
}
|
|
})
|
|
|
|
expect(requestConfirmation).toHaveBeenCalledWith('call_2', 'create_schedule')
|
|
expect(fn).toHaveBeenCalled()
|
|
expect(setToolStatus).toHaveBeenCalledWith(
|
|
'call_2',
|
|
expect.objectContaining({
|
|
autoCollapseDetails: false,
|
|
showDetails: true
|
|
})
|
|
)
|
|
expect(setToolStatus).toHaveBeenLastCalledWith(
|
|
'call_2',
|
|
expect.objectContaining({
|
|
isLoading: false,
|
|
isStreamingArguments: false
|
|
})
|
|
)
|
|
expect(result.content).toBe('ok')
|
|
})
|
|
|
|
it('auto-accepts required confirmations when yolo mode is active', async () => {
|
|
const { createToolDef, processToolCall } = await import('./shared')
|
|
const fn = vi.fn().mockResolvedValue('ok')
|
|
const requestConfirmation = vi.fn()
|
|
const setToolStatus = vi.fn()
|
|
|
|
const result = await processToolCall({
|
|
tools: [
|
|
{
|
|
def: createToolDef(z.object({}), 'create_schedule', 'Create schedule'),
|
|
requiresConfirmation: true,
|
|
confirmationMessage: 'Create schedule',
|
|
fn
|
|
}
|
|
],
|
|
toolCall: {
|
|
id: 'call_yolo',
|
|
type: 'function',
|
|
function: { name: 'create_schedule', arguments: '{}' }
|
|
},
|
|
helpers: {},
|
|
workspace: 'test-workspace',
|
|
toolCallbacks: {
|
|
setToolStatus,
|
|
removeToolStatus: vi.fn(),
|
|
requestConfirmation,
|
|
shouldAutoAcceptToolConfirmations: () => true
|
|
}
|
|
})
|
|
|
|
expect(requestConfirmation).not.toHaveBeenCalled()
|
|
expect(fn).toHaveBeenCalled()
|
|
expect(setToolStatus).toHaveBeenCalledWith(
|
|
'call_yolo',
|
|
expect.objectContaining({
|
|
content: 'Create schedule',
|
|
isLoading: true,
|
|
needsConfirmation: false
|
|
})
|
|
)
|
|
expect(result.content).toBe('ok')
|
|
})
|
|
|
|
it('blocks workspace mutation tools for undeployed scripts and flows', async () => {
|
|
const { processToolCall } = await import('./shared')
|
|
const { createWorkspaceMutationTools } = await import('./workspaceTools')
|
|
const setToolStatus = vi.fn()
|
|
const requestConfirmation = vi.fn()
|
|
const workspaceMutationTools = createWorkspaceMutationTools()
|
|
|
|
const scriptResult = await processToolCall({
|
|
tools: workspaceMutationTools,
|
|
toolCall: {
|
|
id: 'call_3',
|
|
type: 'function',
|
|
function: { name: 'create_schedule', arguments: '{}' }
|
|
},
|
|
helpers: {
|
|
getWorkspaceMutationTarget: () => ({ kind: 'script', path: '', deployed: false })
|
|
},
|
|
workspace: 'test-workspace',
|
|
toolCallbacks: {
|
|
setToolStatus,
|
|
removeToolStatus: vi.fn(),
|
|
requestConfirmation
|
|
}
|
|
})
|
|
|
|
expect(scriptResult.content).toBe('the script needs to be deployed before doing this action')
|
|
|
|
const flowResult = await processToolCall({
|
|
tools: workspaceMutationTools,
|
|
toolCall: {
|
|
id: 'call_4',
|
|
type: 'function',
|
|
function: { name: 'create_trigger', arguments: '{}' }
|
|
},
|
|
helpers: {
|
|
getWorkspaceMutationTarget: () => ({ kind: 'flow', path: 'f/flow', deployed: false })
|
|
},
|
|
workspace: 'test-workspace',
|
|
toolCallbacks: {
|
|
setToolStatus,
|
|
removeToolStatus: vi.fn(),
|
|
requestConfirmation
|
|
}
|
|
})
|
|
|
|
expect(flowResult.content).toBe('the flow needs to be deployed before doing this action')
|
|
expect(requestConfirmation).not.toHaveBeenCalled()
|
|
})
|
|
|
|
// create_schedule duplicates the global `advanced` merge and guard, so it needs its
|
|
// own coverage: the fold must reach the request body and a mis-shape must stop the
|
|
// write rather than create a schedule with an inert policy.
|
|
it('folds advanced schedule options into create_schedule and refuses a mis-shaped one', async () => {
|
|
const gen = (await import('$lib/gen')) as any
|
|
const { processToolCall } = await import('./shared')
|
|
const { createWorkspaceMutationTools } = await import('./workspaceTools')
|
|
const workspaceMutationTools = createWorkspaceMutationTools()
|
|
|
|
gen.ScheduleService.previewSchedule.mockReset()
|
|
gen.ScheduleService.createSchedule.mockReset()
|
|
gen.ScheduleService.previewSchedule.mockResolvedValue({})
|
|
gen.ScheduleService.createSchedule.mockResolvedValue('schedule-created')
|
|
|
|
const target = {
|
|
getWorkspaceMutationTarget: () => ({
|
|
kind: 'script' as const,
|
|
path: 'f/scripts/current',
|
|
deployed: true
|
|
})
|
|
}
|
|
const callbacks = () => ({
|
|
setToolStatus: vi.fn(),
|
|
removeToolStatus: vi.fn(),
|
|
requestConfirmation: vi.fn().mockResolvedValue(true)
|
|
})
|
|
|
|
await processToolCall({
|
|
tools: workspaceMutationTools,
|
|
toolCall: {
|
|
id: 'call_adv',
|
|
type: 'function',
|
|
function: {
|
|
name: 'create_schedule',
|
|
arguments: JSON.stringify({
|
|
path: 'f/schedules/adv',
|
|
schedule: '0 0 12 * * *',
|
|
timezone: 'UTC',
|
|
args: null,
|
|
advanced: { tag: 'nightly', retry: { constant: { attempts: 2, seconds: 30 } } }
|
|
})
|
|
}
|
|
},
|
|
helpers: target,
|
|
workspace: 'test-workspace',
|
|
toolCallbacks: callbacks()
|
|
})
|
|
|
|
expect(gen.ScheduleService.createSchedule).toHaveBeenCalledWith({
|
|
workspace: 'test-workspace',
|
|
requestBody: expect.objectContaining({
|
|
tag: 'nightly',
|
|
retry: { constant: { attempts: 2, seconds: 30 } }
|
|
})
|
|
})
|
|
|
|
gen.ScheduleService.createSchedule.mockReset()
|
|
const badStatus = vi.fn()
|
|
await processToolCall({
|
|
tools: workspaceMutationTools,
|
|
toolCall: {
|
|
id: 'call_bad',
|
|
type: 'function',
|
|
function: {
|
|
name: 'create_schedule',
|
|
arguments: JSON.stringify({
|
|
path: 'f/schedules/bad',
|
|
schedule: '0 0 12 * * *',
|
|
timezone: 'UTC',
|
|
args: null,
|
|
advanced: { retry: { constant: { attempts: 2, seconds_typo: 30 } } }
|
|
})
|
|
}
|
|
},
|
|
helpers: target,
|
|
workspace: 'test-workspace',
|
|
toolCallbacks: { ...callbacks(), setToolStatus: badStatus }
|
|
})
|
|
|
|
expect(gen.ScheduleService.createSchedule).not.toHaveBeenCalled()
|
|
expect(JSON.stringify(badStatus.mock.calls)).toContain('get_schedule_schema')
|
|
})
|
|
|
|
it('injects runnable target fields into schedule and trigger requests', async () => {
|
|
const gen = (await import('$lib/gen')) as any
|
|
const { processToolCall } = await import('./shared')
|
|
const { createWorkspaceMutationTools } = await import('./workspaceTools')
|
|
const workspaceMutationTools = createWorkspaceMutationTools()
|
|
|
|
gen.ScheduleService.previewSchedule.mockReset()
|
|
gen.ScheduleService.createSchedule.mockReset()
|
|
gen.HttpTriggerService.createHttpTrigger.mockReset()
|
|
gen.ScheduleService.previewSchedule.mockResolvedValue({})
|
|
gen.ScheduleService.createSchedule.mockResolvedValue('schedule-created')
|
|
gen.HttpTriggerService.createHttpTrigger.mockResolvedValue('trigger-created')
|
|
|
|
const scheduleSetToolStatus = vi.fn()
|
|
const scheduleResult = await processToolCall({
|
|
tools: workspaceMutationTools,
|
|
toolCall: {
|
|
id: 'call_5',
|
|
type: 'function',
|
|
function: {
|
|
name: 'create_schedule',
|
|
arguments: JSON.stringify({
|
|
path: 'f/schedules/current',
|
|
schedule: '0 0 12 * * *',
|
|
timezone: 'UTC',
|
|
args: null
|
|
})
|
|
}
|
|
},
|
|
helpers: {
|
|
getWorkspaceMutationTarget: () => ({
|
|
kind: 'script',
|
|
path: 'f/scripts/current',
|
|
deployed: true
|
|
})
|
|
},
|
|
workspace: 'test-workspace',
|
|
toolCallbacks: {
|
|
setToolStatus: scheduleSetToolStatus,
|
|
removeToolStatus: vi.fn(),
|
|
requestConfirmation: vi.fn().mockResolvedValue(true)
|
|
}
|
|
})
|
|
|
|
expect(gen.ScheduleService.createSchedule).toHaveBeenCalledWith({
|
|
workspace: 'test-workspace',
|
|
requestBody: expect.objectContaining({
|
|
script_path: 'f/scripts/current',
|
|
is_flow: false
|
|
})
|
|
})
|
|
expect(scheduleSetToolStatus).toHaveBeenCalledWith(
|
|
'call_5',
|
|
expect.objectContaining({
|
|
result: expect.objectContaining({
|
|
success: true,
|
|
path: 'f/schedules/current',
|
|
target_path: 'f/scripts/current',
|
|
target_kind: 'script',
|
|
backend_result: 'schedule-created'
|
|
}),
|
|
actions: [
|
|
expect.objectContaining({
|
|
id: 'open-created-schedule:f/schedules/current',
|
|
type: 'open_created_resource',
|
|
label: 'Open schedule',
|
|
resource: 'schedule',
|
|
path: 'f/schedules/current',
|
|
targetKind: 'script'
|
|
})
|
|
]
|
|
})
|
|
)
|
|
expect(JSON.parse(scheduleResult.content as string)).toEqual(
|
|
expect.objectContaining({
|
|
success: true,
|
|
path: 'f/schedules/current',
|
|
target_path: 'f/scripts/current',
|
|
target_kind: 'script',
|
|
backend_result: 'schedule-created'
|
|
})
|
|
)
|
|
|
|
const triggerSetToolStatus = vi.fn()
|
|
const triggerResult = await processToolCall({
|
|
tools: workspaceMutationTools,
|
|
toolCall: {
|
|
id: 'call_6',
|
|
type: 'function',
|
|
function: {
|
|
name: 'create_trigger',
|
|
arguments: JSON.stringify({
|
|
kind: 'http',
|
|
path: 'f/triggers/current',
|
|
config: {
|
|
route_path: 'api/current',
|
|
http_method: 'post',
|
|
authentication_method: 'none',
|
|
is_static_website: false
|
|
}
|
|
})
|
|
}
|
|
},
|
|
helpers: {
|
|
getWorkspaceMutationTarget: () => ({
|
|
kind: 'flow',
|
|
path: 'f/flows/current',
|
|
deployed: true
|
|
})
|
|
},
|
|
workspace: 'test-workspace',
|
|
toolCallbacks: {
|
|
setToolStatus: triggerSetToolStatus,
|
|
removeToolStatus: vi.fn(),
|
|
requestConfirmation: vi.fn().mockResolvedValue(true)
|
|
}
|
|
})
|
|
|
|
expect(gen.HttpTriggerService.createHttpTrigger).toHaveBeenCalledWith({
|
|
workspace: 'test-workspace',
|
|
requestBody: expect.objectContaining({
|
|
script_path: 'f/flows/current',
|
|
is_flow: true
|
|
})
|
|
})
|
|
expect(triggerSetToolStatus).toHaveBeenCalledWith(
|
|
'call_6',
|
|
expect.objectContaining({
|
|
result: expect.objectContaining({
|
|
success: true,
|
|
kind: 'http',
|
|
path: 'f/triggers/current',
|
|
target_path: 'f/flows/current',
|
|
target_kind: 'flow',
|
|
backend_result: 'trigger-created'
|
|
}),
|
|
actions: [
|
|
expect.objectContaining({
|
|
id: 'open-created-trigger:http:f/triggers/current',
|
|
type: 'open_created_resource',
|
|
label: 'Open HTTP trigger',
|
|
resource: 'trigger',
|
|
triggerKind: 'http',
|
|
path: 'f/triggers/current',
|
|
targetKind: 'flow'
|
|
})
|
|
]
|
|
})
|
|
)
|
|
expect(JSON.parse(triggerResult.content as string)).toEqual(
|
|
expect.objectContaining({
|
|
success: true,
|
|
kind: 'http',
|
|
path: 'f/triggers/current',
|
|
target_path: 'f/flows/current',
|
|
target_kind: 'flow',
|
|
backend_result: 'trigger-created'
|
|
})
|
|
)
|
|
})
|
|
|
|
it('email trigger: guides the user to set up email triggering when unconfigured', async () => {
|
|
const gen = (await import('$lib/gen')) as any
|
|
const { processToolCall } = await import('./shared')
|
|
const { createWorkspaceMutationTools } = await import('./workspaceTools')
|
|
const tools = createWorkspaceMutationTools()
|
|
|
|
gen.SettingService.getGlobal.mockReset()
|
|
gen.SettingService.getGlobal.mockResolvedValue(null)
|
|
gen.EmailTriggerService.createEmailTrigger.mockReset()
|
|
|
|
const call = (id: string) =>
|
|
processToolCall({
|
|
tools,
|
|
toolCall: {
|
|
id,
|
|
type: 'function',
|
|
function: {
|
|
name: 'create_trigger',
|
|
arguments: JSON.stringify({
|
|
kind: 'email',
|
|
path: 'f/triggers/email_current',
|
|
config: { local_part: 'orders' }
|
|
})
|
|
}
|
|
},
|
|
helpers: {
|
|
getWorkspaceMutationTarget: () => ({
|
|
kind: 'flow',
|
|
path: 'f/flows/current',
|
|
deployed: true
|
|
})
|
|
},
|
|
workspace: 'test-workspace',
|
|
toolCallbacks: {
|
|
setToolStatus: vi.fn(),
|
|
removeToolStatus: vi.fn(),
|
|
requestConfirmation: vi.fn().mockResolvedValue(true)
|
|
}
|
|
})
|
|
|
|
userHolder.current = { is_super_admin: true }
|
|
const superadminResult = await call('call_email_super')
|
|
expect(gen.EmailTriggerService.createEmailTrigger).not.toHaveBeenCalled()
|
|
expect(superadminResult.content).toContain('not set up')
|
|
expect(superadminResult.content).toContain('As a superadmin')
|
|
|
|
userHolder.current = { is_super_admin: false }
|
|
const memberResult = await call('call_email_member')
|
|
expect(gen.EmailTriggerService.createEmailTrigger).not.toHaveBeenCalled()
|
|
expect(memberResult.content).toContain('Ask an instance superadmin')
|
|
})
|
|
|
|
it('email trigger: creates it and reports the address when email triggering is configured', async () => {
|
|
const gen = (await import('$lib/gen')) as any
|
|
const { processToolCall } = await import('./shared')
|
|
const { createWorkspaceMutationTools } = await import('./workspaceTools')
|
|
const tools = createWorkspaceMutationTools()
|
|
|
|
gen.SettingService.getGlobal.mockReset()
|
|
gen.SettingService.getGlobal.mockResolvedValue('mail.example.com')
|
|
gen.EmailTriggerService.createEmailTrigger.mockReset()
|
|
gen.EmailTriggerService.createEmailTrigger.mockResolvedValue('email-created')
|
|
|
|
const result = await processToolCall({
|
|
tools,
|
|
toolCall: {
|
|
id: 'call_email_ok',
|
|
type: 'function',
|
|
function: {
|
|
name: 'create_trigger',
|
|
arguments: JSON.stringify({
|
|
kind: 'email',
|
|
path: 'f/triggers/email_current',
|
|
config: { local_part: 'orders' }
|
|
})
|
|
}
|
|
},
|
|
helpers: {
|
|
getWorkspaceMutationTarget: () => ({
|
|
kind: 'flow',
|
|
path: 'f/flows/current',
|
|
deployed: true
|
|
})
|
|
},
|
|
workspace: 'test-workspace',
|
|
toolCallbacks: {
|
|
setToolStatus: vi.fn(),
|
|
removeToolStatus: vi.fn(),
|
|
requestConfirmation: vi.fn().mockResolvedValue(true)
|
|
}
|
|
})
|
|
|
|
expect(gen.EmailTriggerService.createEmailTrigger).toHaveBeenCalledWith({
|
|
workspace: 'test-workspace',
|
|
requestBody: expect.objectContaining({
|
|
local_part: 'orders',
|
|
// defaulted before the request is sent; the backend column is NOT NULL
|
|
workspaced_local_part: false,
|
|
script_path: 'f/flows/current',
|
|
is_flow: true
|
|
})
|
|
})
|
|
expect(JSON.parse(result.content as string)).toEqual(
|
|
expect.objectContaining({
|
|
success: true,
|
|
kind: 'email',
|
|
email_address: 'orders@mail.example.com',
|
|
backend_result: 'email-created'
|
|
})
|
|
)
|
|
})
|
|
|
|
it('surfaces workspace mutation tool execution errors to the user', async () => {
|
|
const gen = (await import('$lib/gen')) as any
|
|
const { processToolCall } = await import('./shared')
|
|
const { createWorkspaceMutationTools } = await import('./workspaceTools')
|
|
const workspaceMutationTools = createWorkspaceMutationTools()
|
|
|
|
gen.ScheduleService.previewSchedule.mockReset()
|
|
gen.ScheduleService.createSchedule.mockReset()
|
|
gen.HttpTriggerService.createHttpTrigger.mockReset()
|
|
gen.ScheduleService.previewSchedule.mockRejectedValue(new Error('backend rejected schedule'))
|
|
|
|
const scheduleSetToolStatus = vi.fn()
|
|
const scheduleError = 'Invalid schedule or timezone: backend rejected schedule'
|
|
const scheduleResult = await processToolCall({
|
|
tools: workspaceMutationTools,
|
|
toolCall: {
|
|
id: 'call_7',
|
|
type: 'function',
|
|
function: {
|
|
name: 'create_schedule',
|
|
arguments: JSON.stringify({
|
|
path: 'f/schedules/current',
|
|
schedule: '0 0 12 * * *',
|
|
timezone: 'UTC',
|
|
args: null
|
|
})
|
|
}
|
|
},
|
|
helpers: {
|
|
getWorkspaceMutationTarget: () => ({
|
|
kind: 'script',
|
|
path: 'f/scripts/current',
|
|
deployed: true
|
|
})
|
|
},
|
|
workspace: 'test-workspace',
|
|
toolCallbacks: {
|
|
setToolStatus: scheduleSetToolStatus,
|
|
removeToolStatus: vi.fn(),
|
|
requestConfirmation: vi.fn().mockResolvedValue(true)
|
|
}
|
|
})
|
|
|
|
expect(scheduleSetToolStatus).toHaveBeenCalledWith(
|
|
'call_7',
|
|
expect.objectContaining({
|
|
content: scheduleError,
|
|
error: scheduleError,
|
|
isLoading: false
|
|
})
|
|
)
|
|
expect(scheduleResult.content).toBe(`Error while calling tool: ${scheduleError}`)
|
|
expect(scheduleSetToolStatus).not.toHaveBeenCalledWith(
|
|
'call_7',
|
|
expect.objectContaining({
|
|
error: 'An error occurred while calling the tool'
|
|
})
|
|
)
|
|
|
|
gen.ScheduleService.previewSchedule.mockResolvedValue({})
|
|
gen.HttpTriggerService.createHttpTrigger.mockRejectedValue(
|
|
new Error('backend rejected trigger')
|
|
)
|
|
|
|
const triggerSetToolStatus = vi.fn()
|
|
const triggerError =
|
|
'Failed to create HTTP trigger "f/triggers/current": backend rejected trigger'
|
|
const triggerResult = await processToolCall({
|
|
tools: workspaceMutationTools,
|
|
toolCall: {
|
|
id: 'call_8',
|
|
type: 'function',
|
|
function: {
|
|
name: 'create_trigger',
|
|
arguments: JSON.stringify({
|
|
kind: 'http',
|
|
path: 'f/triggers/current',
|
|
config: {
|
|
route_path: 'api/current',
|
|
http_method: 'post',
|
|
authentication_method: 'none',
|
|
is_static_website: false
|
|
}
|
|
})
|
|
}
|
|
},
|
|
helpers: {
|
|
getWorkspaceMutationTarget: () => ({
|
|
kind: 'flow',
|
|
path: 'f/flows/current',
|
|
deployed: true
|
|
})
|
|
},
|
|
workspace: 'test-workspace',
|
|
toolCallbacks: {
|
|
setToolStatus: triggerSetToolStatus,
|
|
removeToolStatus: vi.fn(),
|
|
requestConfirmation: vi.fn().mockResolvedValue(true)
|
|
}
|
|
})
|
|
|
|
expect(triggerSetToolStatus).toHaveBeenCalledWith(
|
|
'call_8',
|
|
expect.objectContaining({
|
|
content: triggerError,
|
|
error: triggerError,
|
|
isLoading: false
|
|
})
|
|
)
|
|
expect(triggerResult.content).toBe(`Error while calling tool: ${triggerError}`)
|
|
expect(triggerSetToolStatus).not.toHaveBeenCalledWith(
|
|
'call_8',
|
|
expect.objectContaining({
|
|
error: 'An error occurred while calling the tool'
|
|
})
|
|
)
|
|
})
|
|
|
|
// The counter is silently dropped by the backend when the key is malformed, so nothing
|
|
// here fails loudly if a path stops logging or logs the wrong status.
|
|
it('logs one feature-usage outcome per tool call, keyed <tool>:<status>', async () => {
|
|
const { createToolDef, processToolCall } = await import('./shared')
|
|
const { logFeatureUsage } = await import('$lib/utils/featureUsage')
|
|
|
|
const outcomeKeys = async (
|
|
tool: Partial<import('./shared').Tool<any>> = {},
|
|
toolCallbacks: Partial<import('./shared').ToolCallbacks> = {}
|
|
) => {
|
|
vi.mocked(logFeatureUsage).mockClear()
|
|
await runToolCall(
|
|
{
|
|
def: createToolDef(z.object({}), 'run_script', 'Run script'),
|
|
fn: vi.fn().mockResolvedValue('done'),
|
|
...tool
|
|
},
|
|
toolCallbacks
|
|
)
|
|
return vi
|
|
.mocked(logFeatureUsage)
|
|
.mock.calls.map(([feature, kind, opts]) => [feature, kind, opts?.key])
|
|
}
|
|
|
|
expect(await outcomeKeys()).toEqual([['ai_chat', 'tool', 'run_script:ok']])
|
|
expect(await outcomeKeys({ fn: vi.fn().mockRejectedValue(new Error('boom')) })).toEqual([
|
|
['ai_chat', 'tool', 'run_script:error']
|
|
])
|
|
expect(await outcomeKeys({ validateBeforeConfirmation: () => 'not deployed' })).toEqual([
|
|
['ai_chat', 'tool', 'run_script:rejected']
|
|
])
|
|
expect(
|
|
await outcomeKeys(
|
|
{ requiresConfirmation: true },
|
|
{ requestConfirmation: vi.fn().mockResolvedValue(false) }
|
|
)
|
|
).toEqual([['ai_chat', 'tool', 'run_script:declined']])
|
|
// A tool that runs its own consent surface (the run form) declines by settling
|
|
// the card, then returns normally — that must not read as a successful run.
|
|
expect(
|
|
await outcomeKeys({
|
|
fn: vi.fn(async ({ toolCallbacks, toolId }: any) => {
|
|
toolCallbacks.setToolStatus(toolId, { declinedByUser: true })
|
|
return 'cancelled'
|
|
})
|
|
})
|
|
).toEqual([['ai_chat', 'tool', 'run_script:declined']])
|
|
expect(await outcomeKeys({}, { isPlanModeActive: () => true })).toEqual([
|
|
['ai_chat', 'tool', 'run_script:blocked_plan_mode']
|
|
])
|
|
|
|
// A name the model invented resolves to no tool, and must never reach telemetry.
|
|
vi.mocked(logFeatureUsage).mockClear()
|
|
await processToolCall({
|
|
tools: [{ def: createToolDef(z.object({}), 'run_script', 'Run script'), fn: vi.fn() }],
|
|
toolCall: {
|
|
id: 'call_ghost',
|
|
type: 'function',
|
|
function: { name: 'hallucinated_tool', arguments: '{}' }
|
|
},
|
|
helpers: {},
|
|
workspace: 'test-workspace',
|
|
toolCallbacks: { setToolStatus: vi.fn(), removeToolStatus: vi.fn() }
|
|
})
|
|
expect(logFeatureUsage).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
async function runToolCall(
|
|
tool: Partial<import('./shared').Tool<any>> & {
|
|
def: import('./shared').Tool<any>['def']
|
|
fn: import('./shared').Tool<any>['fn']
|
|
},
|
|
toolCallbacks: Partial<import('./shared').ToolCallbacks>,
|
|
args: Record<string, unknown> = {}
|
|
) {
|
|
const { processToolCall } = await import('./shared')
|
|
return processToolCall({
|
|
tools: [tool as import('./shared').Tool<any>],
|
|
toolCall: {
|
|
id: 'call_plan',
|
|
type: 'function',
|
|
function: { name: tool.def.function.name, arguments: JSON.stringify(args) }
|
|
},
|
|
helpers: {},
|
|
workspace: 'test-workspace',
|
|
toolCallbacks: {
|
|
setToolStatus: vi.fn(),
|
|
removeToolStatus: vi.fn(),
|
|
...toolCallbacks
|
|
}
|
|
})
|
|
}
|
|
|
|
describe('processToolCall plan-mode gate', () => {
|
|
it('blocks an untagged tool while plan mode is active', async () => {
|
|
const { createToolDef } = await import('./shared')
|
|
const fn = vi.fn().mockResolvedValue('ran')
|
|
const onToolBlockedByPlanMode = vi.fn()
|
|
|
|
const result = await runToolCall(
|
|
{ def: createToolDef(z.object({}), 'write_script', 'Write script'), fn },
|
|
{ isPlanModeActive: () => true, onToolBlockedByPlanMode }
|
|
)
|
|
|
|
expect(fn).not.toHaveBeenCalled()
|
|
expect(onToolBlockedByPlanMode).toHaveBeenCalledOnce()
|
|
expect(result.content).toContain('plan mode is active')
|
|
})
|
|
|
|
it('blocks a mutating tool before its own validator gets to run', async () => {
|
|
const { createToolDef } = await import('./shared')
|
|
const fn = vi.fn().mockResolvedValue('ran')
|
|
const validateBeforeConfirmation = vi.fn().mockResolvedValue('target is undeployed')
|
|
const onToolBlockedByPlanMode = vi.fn()
|
|
|
|
const result = await runToolCall(
|
|
{
|
|
def: createToolDef(z.object({}), 'write_script', 'Write script'),
|
|
validateBeforeConfirmation,
|
|
fn
|
|
},
|
|
{ isPlanModeActive: () => true, onToolBlockedByPlanMode }
|
|
)
|
|
|
|
expect(validateBeforeConfirmation).not.toHaveBeenCalled()
|
|
expect(onToolBlockedByPlanMode).toHaveBeenCalledOnce()
|
|
expect(result.content).toContain('plan mode is active')
|
|
})
|
|
|
|
it('allows a plan-mode-safe tool while plan mode is active', async () => {
|
|
const { createToolDef } = await import('./shared')
|
|
const fn = vi.fn().mockResolvedValue('ok')
|
|
|
|
const result = await runToolCall(
|
|
{ def: createToolDef(z.object({}), 'read_file', 'Read file'), planModeSafe: true, fn },
|
|
{ isPlanModeActive: () => true }
|
|
)
|
|
|
|
expect(fn).toHaveBeenCalled()
|
|
expect(result.content).toBe('ok')
|
|
})
|
|
|
|
it('runs an untagged tool normally when plan mode is inactive', async () => {
|
|
const { createToolDef } = await import('./shared')
|
|
const fn = vi.fn().mockResolvedValue('ran')
|
|
|
|
const result = await runToolCall(
|
|
{ def: createToolDef(z.object({}), 'write_script', 'Write script'), fn },
|
|
{ isPlanModeActive: () => false }
|
|
)
|
|
|
|
expect(fn).toHaveBeenCalled()
|
|
expect(result.content).toBe('ran')
|
|
})
|
|
|
|
it('does not block an unknown tool name — falls through to the unknown-tool error', async () => {
|
|
const { processToolCall } = await import('./shared')
|
|
const result = await processToolCall({
|
|
tools: [],
|
|
toolCall: {
|
|
id: 'call_unknown',
|
|
type: 'function',
|
|
function: { name: 'made_up_tool', arguments: '{}' }
|
|
},
|
|
helpers: {},
|
|
workspace: 'test-workspace',
|
|
toolCallbacks: {
|
|
setToolStatus: vi.fn(),
|
|
removeToolStatus: vi.fn(),
|
|
isPlanModeActive: () => true
|
|
}
|
|
})
|
|
|
|
expect(result.content).not.toContain('plan mode is active')
|
|
expect(result.content).toContain('Unknown tool call')
|
|
})
|
|
|
|
it('returns the tool cancellationMessage when the user rejects the confirmation', async () => {
|
|
const { createToolDef } = await import('./shared')
|
|
const fn = vi.fn()
|
|
const setToolStatus = vi.fn()
|
|
|
|
const result = await runToolCall(
|
|
{
|
|
def: createToolDef(z.object({ summary: z.string() }), 'exit_plan_mode', 'Exit plan mode'),
|
|
planModeSafe: true,
|
|
requiresConfirmation: true,
|
|
cancellationMessage: 'keep planning',
|
|
fn
|
|
},
|
|
{
|
|
isPlanModeActive: () => true,
|
|
requestConfirmation: vi.fn().mockResolvedValue(false),
|
|
setToolStatus
|
|
}
|
|
)
|
|
|
|
expect(fn).not.toHaveBeenCalled()
|
|
expect(result.content).toBe('keep planning')
|
|
// The one place a decline is recorded: planCardState reads it to tell a plan the
|
|
// user turned down apart from a call that merely errored.
|
|
expect(setToolStatus).toHaveBeenCalledWith(
|
|
'call_plan',
|
|
expect.objectContaining({ declinedByUser: true })
|
|
)
|
|
})
|
|
|
|
it('blocks a mutating tool if plan mode is entered while its confirmation is pending', async () => {
|
|
const { createToolDef } = await import('./shared')
|
|
const fn = vi.fn().mockResolvedValue('ran')
|
|
let planActive = false
|
|
// The user switches into plan mode while the confirmation card is open, then
|
|
// approves it: requestConfirmation flips the posture, then resolves true.
|
|
const requestConfirmation = vi.fn().mockImplementation(async () => {
|
|
planActive = true
|
|
return true
|
|
})
|
|
|
|
const result = await runToolCall(
|
|
{
|
|
def: createToolDef(z.object({}), 'write_script', 'Write script'),
|
|
requiresConfirmation: true,
|
|
fn
|
|
},
|
|
{ isPlanModeActive: () => planActive, requestConfirmation }
|
|
)
|
|
|
|
expect(requestConfirmation).toHaveBeenCalled()
|
|
expect(fn).not.toHaveBeenCalled()
|
|
expect(result.content).toContain('plan mode is active')
|
|
})
|
|
|
|
it('refuses only the arguments a tagged tool names, in its own words', async () => {
|
|
const { createToolDef } = await import('./shared')
|
|
const fn = vi.fn().mockResolvedValue('ran')
|
|
const setToolStatus = vi.fn()
|
|
const tool = {
|
|
def: createToolDef(z.object({ id: z.string() }), 'update_doc', 'Update a doc'),
|
|
planModeSafe: true,
|
|
refuseInPlanMode: ({ args }: { args: any }) =>
|
|
args.id === 'the-plan'
|
|
? { label: 'Not that one', result: 'Write anything but the plan.' }
|
|
: undefined,
|
|
fn
|
|
}
|
|
|
|
const refused = await runToolCall(
|
|
tool,
|
|
{ isPlanModeActive: () => true, setToolStatus },
|
|
{
|
|
id: 'the-plan'
|
|
}
|
|
)
|
|
expect(fn).not.toHaveBeenCalled()
|
|
expect(refused.content).toBe('Write anything but the plan.')
|
|
expect(setToolStatus).toHaveBeenCalledWith(
|
|
'call_plan',
|
|
expect.objectContaining({ content: 'Not that one', blockedByPlanMode: true })
|
|
)
|
|
|
|
const allowed = await runToolCall(tool, { isPlanModeActive: () => true }, { id: 'a-note' })
|
|
expect(fn).toHaveBeenCalled()
|
|
expect(allowed.content).toBe('ran')
|
|
})
|
|
|
|
it('never asks a tool which arguments it refuses once plan mode is over', async () => {
|
|
// The posture is the only thing that makes this hook relevant: consulted outside it, a
|
|
// tool that narrows itself for planning would narrow itself for every other mode too.
|
|
const { createToolDef } = await import('./shared')
|
|
const fn = vi.fn().mockResolvedValue('ran')
|
|
const refuseInPlanMode = vi.fn().mockReturnValue('refused')
|
|
|
|
const result = await runToolCall(
|
|
{
|
|
def: createToolDef(z.object({ id: z.string() }), 'update_doc', 'Update a doc'),
|
|
planModeSafe: true,
|
|
refuseInPlanMode,
|
|
fn
|
|
},
|
|
{ isPlanModeActive: () => false },
|
|
{ id: 'the-plan' }
|
|
)
|
|
|
|
expect(refuseInPlanMode).not.toHaveBeenCalled()
|
|
expect(fn).toHaveBeenCalled()
|
|
expect(result.content).toBe('ran')
|
|
})
|
|
})
|
|
|
|
describe('isActiveUserQuestion', () => {
|
|
function toolMessage(overrides: Partial<ToolDisplayMessage> = {}): ToolDisplayMessage {
|
|
return {
|
|
role: 'tool',
|
|
tool_call_id: 'call_q',
|
|
content: 'asking a question',
|
|
isLoading: true,
|
|
userQuestion: { question: 'Pick one', choices: ['a', 'b'] },
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
it('is true for a loading tool message with an unanswered question', async () => {
|
|
const { isActiveUserQuestion } = await import('./shared')
|
|
expect(isActiveUserQuestion(toolMessage())).toBe(true)
|
|
})
|
|
|
|
it('is false once choices have been selected', async () => {
|
|
const { isActiveUserQuestion } = await import('./shared')
|
|
expect(
|
|
isActiveUserQuestion(
|
|
toolMessage({
|
|
userQuestion: { question: 'Pick one', choices: ['a', 'b'], selectedChoices: ['a'] }
|
|
})
|
|
)
|
|
).toBe(false)
|
|
})
|
|
|
|
it('is false once a legacy scalar selectedChoice is present', async () => {
|
|
const { isActiveUserQuestion } = await import('./shared')
|
|
expect(
|
|
isActiveUserQuestion(
|
|
toolMessage({
|
|
userQuestion: { question: 'Pick one', choices: ['a', 'b'], selectedChoice: 'a' }
|
|
})
|
|
)
|
|
).toBe(false)
|
|
})
|
|
|
|
it('stays active when selectedChoices is present but empty', async () => {
|
|
const { isActiveUserQuestion } = await import('./shared')
|
|
expect(
|
|
isActiveUserQuestion(
|
|
toolMessage({
|
|
userQuestion: { question: 'Pick one', choices: ['a', 'b'], selectedChoices: [] }
|
|
})
|
|
)
|
|
).toBe(true)
|
|
})
|
|
|
|
it('is false when the question was canceled', async () => {
|
|
const { isActiveUserQuestion } = await import('./shared')
|
|
expect(
|
|
isActiveUserQuestion(
|
|
toolMessage({ userQuestion: { question: 'Pick one', choices: ['a', 'b'], canceled: true } })
|
|
)
|
|
).toBe(false)
|
|
})
|
|
|
|
it('is false when the tool errored', async () => {
|
|
const { isActiveUserQuestion } = await import('./shared')
|
|
expect(isActiveUserQuestion(toolMessage({ error: 'boom' }))).toBe(false)
|
|
})
|
|
|
|
it('is false when the tool is no longer loading', async () => {
|
|
const { isActiveUserQuestion } = await import('./shared')
|
|
expect(isActiveUserQuestion(toolMessage({ isLoading: false }))).toBe(false)
|
|
})
|
|
|
|
it('is false for a tool message without a question', async () => {
|
|
const { isActiveUserQuestion } = await import('./shared')
|
|
expect(isActiveUserQuestion(toolMessage({ userQuestion: undefined }))).toBe(false)
|
|
})
|
|
|
|
it('is false for non-tool messages and undefined', async () => {
|
|
const { isActiveUserQuestion } = await import('./shared')
|
|
const userMessage: DisplayMessage = { role: 'user', index: 0, content: 'hi' }
|
|
const assistantMessage: DisplayMessage = { role: 'assistant', content: 'hi' }
|
|
expect(isActiveUserQuestion(undefined)).toBe(false)
|
|
expect(isActiveUserQuestion(userMessage)).toBe(false)
|
|
expect(isActiveUserQuestion(assistantMessage)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('isActiveRunForm', () => {
|
|
const runForm = { path: 'f/a/b', schema: {}, args: { name: 'ada' } }
|
|
function toolMessage(overrides: Partial<ToolDisplayMessage> = {}): ToolDisplayMessage {
|
|
return {
|
|
role: 'tool',
|
|
tool_call_id: 'call_r',
|
|
content: 'waiting for arguments',
|
|
isLoading: true,
|
|
runForm,
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
// Either flag unmounts the card, so the loop must stop waiting on it.
|
|
it('is false once submitted or cancelled', async () => {
|
|
const { isActiveRunForm } = await import('./shared')
|
|
expect(isActiveRunForm(toolMessage())).toBe(true)
|
|
expect(isActiveRunForm(toolMessage({ runForm: { ...runForm, submitted: true } }))).toBe(false)
|
|
expect(isActiveRunForm(toolMessage({ runForm: { ...runForm, canceled: true } }))).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('pendingUserAction', () => {
|
|
const toolMessage = (overrides: Partial<ToolDisplayMessage> = {}): ToolDisplayMessage => ({
|
|
role: 'tool',
|
|
tool_call_id: 'call_p',
|
|
content: 'running',
|
|
isLoading: true,
|
|
...overrides
|
|
})
|
|
|
|
const question = toolMessage({ userQuestion: { question: 'Pick one', choices: ['a'] } })
|
|
|
|
it('distinguishes an unanswered question from a staged confirmation', async () => {
|
|
const { pendingUserAction } = await import('./shared')
|
|
expect(pendingUserAction([question])).toBe('question')
|
|
expect(pendingUserAction([toolMessage({ needsConfirmation: true })])).toBe('confirmation')
|
|
})
|
|
|
|
// A run form asks for arguments rather than a yes/no, but it blocks the loop the
|
|
// same way, so the chat reports it as a confirmation.
|
|
it('reports an unsubmitted run form as a confirmation', async () => {
|
|
const { pendingUserAction } = await import('./shared')
|
|
expect(
|
|
pendingUserAction([toolMessage({ runForm: { path: 'f/a/b', schema: {}, args: {} } })])
|
|
).toBe('confirmation')
|
|
})
|
|
|
|
it('is undefined for a tool the AI is running on its own', async () => {
|
|
const { pendingUserAction } = await import('./shared')
|
|
expect(pendingUserAction([toolMessage()])).toBe(undefined)
|
|
expect(pendingUserAction([toolMessage({ needsConfirmation: true, isLoading: false })])).toBe(
|
|
undefined
|
|
)
|
|
})
|
|
|
|
// A multi-tool turn creates every card before running the calls one at a time,
|
|
// so the blocked card is not the last message.
|
|
it('finds a blocked card sitting behind queued ones', async () => {
|
|
const { pendingUserAction } = await import('./shared')
|
|
expect(pendingUserAction([question, toolMessage(), toolMessage()])).toBe('question')
|
|
expect(pendingUserAction([toolMessage({ needsConfirmation: true }), toolMessage()])).toBe(
|
|
'confirmation'
|
|
)
|
|
})
|
|
|
|
// Text emitted between two tool calls lands as an assistant card between them.
|
|
it('finds a blocked card behind an interleaved assistant card', async () => {
|
|
const { pendingUserAction } = await import('./shared')
|
|
const assistant: DisplayMessage = { role: 'assistant', content: 'and also…' }
|
|
expect(pendingUserAction([question, assistant, toolMessage()])).toBe('question')
|
|
})
|
|
|
|
it('stops at the previous turn rather than reviving its resolved cards', async () => {
|
|
const { pendingUserAction } = await import('./shared')
|
|
const userMessage: DisplayMessage = { role: 'user', index: 0, content: 'go on' }
|
|
expect(pendingUserAction([question, userMessage, toolMessage()])).toBe(undefined)
|
|
})
|
|
|
|
// The composer answers the parked question by id, so the scan must name the
|
|
// blocked card and not one of the queued ones sharing the turn.
|
|
it('names the blocked card so a caller can resolve it', async () => {
|
|
const { pendingUserActionDetail } = await import('./shared')
|
|
const blocked = toolMessage({
|
|
tool_call_id: 'call_ask',
|
|
userQuestion: { question: 'Pick one', choices: ['a'] }
|
|
})
|
|
expect(pendingUserActionDetail([blocked, toolMessage({ tool_call_id: 'call_next' })])).toEqual({
|
|
action: 'question',
|
|
toolCallId: 'call_ask'
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('pollJobCompletion detach', () => {
|
|
function makeCallbacks() {
|
|
return {
|
|
setToolStatus: vi.fn(),
|
|
removeToolStatus: vi.fn(),
|
|
onJobStatus: vi.fn()
|
|
}
|
|
}
|
|
|
|
it('detaches immediately (no polling) when detachAfterMs is 0', async () => {
|
|
const { pollJobCompletion } = await import('./shared')
|
|
const { JobService } = await import('$lib/gen')
|
|
const getJob = vi.mocked(JobService.getJob)
|
|
getJob.mockReset()
|
|
const cbs = makeCallbacks()
|
|
|
|
const outcome = await pollJobCompletion('job1', 'w', 'tool1', cbs as any, { detachAfterMs: 0 })
|
|
|
|
expect(outcome).toBe('detached')
|
|
expect(getJob).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('detaches after the inline budget when the job is still running', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const { pollJobCompletion } = await import('./shared')
|
|
const { JobService } = await import('$lib/gen')
|
|
const getJob = vi.mocked(JobService.getJob)
|
|
getJob.mockReset()
|
|
getJob.mockResolvedValue({ type: 'QueuedJob', running: true } as any)
|
|
const getJobUpdates = vi.mocked(JobService.getJobUpdates)
|
|
getJobUpdates.mockReset()
|
|
getJobUpdates.mockResolvedValue({ running: true, completed: false } as any)
|
|
const cbs = makeCallbacks()
|
|
|
|
// detachAfterMs 2000 → 2 polls at 1s each, then detach.
|
|
const promise = pollJobCompletion('job1', 'w', 'tool1', cbs as any, { detachAfterMs: 2000 })
|
|
await vi.advanceTimersByTimeAsync(2000)
|
|
|
|
expect(await promise).toBe('detached')
|
|
// Status is reported as running during the wait (alongside the trimmed
|
|
// Job snapshot that feeds JobStatusIcon).
|
|
expect(cbs.onJobStatus).toHaveBeenCalledWith(
|
|
'job1',
|
|
expect.objectContaining({ status: 'running' })
|
|
)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('returns the completed job when it finishes within the inline budget', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const { pollJobCompletion } = await import('./shared')
|
|
const { JobService } = await import('$lib/gen')
|
|
const getJob = vi.mocked(JobService.getJob)
|
|
getJob.mockReset()
|
|
const completed = { type: 'CompletedJob', success: true, result: 42, logs: 'ran' }
|
|
getJob.mockResolvedValue(completed as any)
|
|
// Still landing on a tick the updates endpoint calls unfinished: the job can
|
|
// complete between the two calls, and `getJob` is what says so.
|
|
const getJobUpdates = vi.mocked(JobService.getJobUpdates)
|
|
getJobUpdates.mockReset()
|
|
getJobUpdates.mockResolvedValue({ completed: false, running: true } as any)
|
|
const cbs = makeCallbacks()
|
|
|
|
const promise = pollJobCompletion('job1', 'w', 'tool1', cbs as any, { detachAfterMs: 15000 })
|
|
await vi.advanceTimersByTimeAsync(1000)
|
|
|
|
const landed = await promise
|
|
expect(landed).toBe(completed)
|
|
// Fetched again with its logs rather than settled on the logless tick fetch,
|
|
// which would reach the model as "No logs available".
|
|
expect((landed as any).logs).toBe('ran')
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
// Streaming rides on a second endpoint; landing the job must not. A failing updates
|
|
// endpoint costs live logs, never the run.
|
|
it('returns the completed job with its logs when the updates endpoint fails', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const { pollJobCompletion } = await import('./shared')
|
|
const { JobService } = await import('$lib/gen')
|
|
const getJob = vi.mocked(JobService.getJob)
|
|
getJob.mockReset()
|
|
const completed = { type: 'CompletedJob', success: true, result: 42, logs: 'ran' }
|
|
getJob.mockResolvedValue(completed as any)
|
|
const getJobUpdates = vi.mocked(JobService.getJobUpdates)
|
|
getJobUpdates.mockReset()
|
|
getJobUpdates.mockRejectedValue(new Error('updates unavailable'))
|
|
const cbs = makeCallbacks()
|
|
|
|
const promise = pollJobCompletion('job1', 'w', 'tool1', cbs as any, { detachAfterMs: 15000 })
|
|
await vi.advanceTimersByTimeAsync(1000)
|
|
|
|
const landed = await promise
|
|
expect(landed).toBe(completed)
|
|
expect((landed as any).logs).toBe('ran')
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('legacy mode (no detach) throws a timeout error when the job never completes', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const { pollJobCompletion } = await import('./shared')
|
|
const { JobService } = await import('$lib/gen')
|
|
const getJob = vi.mocked(JobService.getJob)
|
|
getJob.mockReset()
|
|
getJob.mockResolvedValue({ type: 'QueuedJob', running: true } as any)
|
|
const getJobUpdates = vi.mocked(JobService.getJobUpdates)
|
|
getJobUpdates.mockReset()
|
|
getJobUpdates.mockResolvedValue({ running: true, completed: false } as any)
|
|
const cbs = makeCallbacks()
|
|
|
|
const promise = pollJobCompletion('job1', 'w', 'tool1', cbs as any)
|
|
const assertion = expect(promise).rejects.toThrow('timed out')
|
|
await vi.advanceTimersByTimeAsync(60000)
|
|
await assertion
|
|
expect(cbs.setToolStatus).toHaveBeenCalledWith(
|
|
'tool1',
|
|
expect.objectContaining({ error: expect.any(String) })
|
|
)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('executeTestRun result note', () => {
|
|
// A failed run's card lands on the error, with the logs a tab away: the model must stay
|
|
// free to quote them, so only a success says the user already sees the run.
|
|
it('tells the model not to repeat a successful result, and not a failed one', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const { executeTestRun } = await import('./shared')
|
|
const { JobService } = await import('$lib/gen')
|
|
const getJobUpdates = vi.mocked(JobService.getJobUpdates)
|
|
getJobUpdates.mockReset()
|
|
getJobUpdates.mockResolvedValue({ completed: true, running: false } as any)
|
|
const run = async (success: boolean) => {
|
|
vi.mocked(JobService.getJob).mockReset()
|
|
vi.mocked(JobService.getJob).mockResolvedValue({
|
|
type: 'CompletedJob',
|
|
success,
|
|
result: [1, 2],
|
|
logs: 'ran'
|
|
} as any)
|
|
const promise = executeTestRun({
|
|
jobStarter: async () => 'job1',
|
|
workspace: 'w',
|
|
toolId: 'tool1',
|
|
startMessage: 'Starting...',
|
|
contextName: 'script',
|
|
// The job hooks are what mark the global/sessions chat.
|
|
toolCallbacks: {
|
|
setToolStatus: vi.fn(),
|
|
removeToolStatus: vi.fn(),
|
|
onJobStatus: vi.fn(),
|
|
onJobStarted: vi.fn()
|
|
} as any
|
|
})
|
|
await vi.advanceTimersByTimeAsync(1000)
|
|
return promise
|
|
}
|
|
expect(await run(true)).toContain('Do not repeat')
|
|
expect(await run(false)).not.toContain('Do not repeat')
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('deriveChatJobStatus', () => {
|
|
// CompletedJob is discriminated by the presence of a `success` key; the branch
|
|
// order deliberately mirrors JobStatusIcon so the badge and scalar never drift.
|
|
it('maps a canceled completed job to canceled (canceled wins over success=false)', async () => {
|
|
const { deriveChatJobStatus } = await import('./shared')
|
|
expect(deriveChatJobStatus({ success: false, canceled: true } as any)).toBe('canceled')
|
|
})
|
|
|
|
it('maps a successful completed job to success', async () => {
|
|
const { deriveChatJobStatus } = await import('./shared')
|
|
expect(deriveChatJobStatus({ success: true, canceled: false } as any)).toBe('success')
|
|
})
|
|
|
|
it('maps a non-canceled failed completed job to failure', async () => {
|
|
const { deriveChatJobStatus } = await import('./shared')
|
|
expect(deriveChatJobStatus({ success: false, canceled: false } as any)).toBe('failure')
|
|
})
|
|
|
|
it('maps a running suspended queued job to suspended', async () => {
|
|
const { deriveChatJobStatus } = await import('./shared')
|
|
expect(deriveChatJobStatus({ running: true, suspend: 1 } as any)).toBe('suspended')
|
|
})
|
|
|
|
it('maps a running queued job to running', async () => {
|
|
const { deriveChatJobStatus } = await import('./shared')
|
|
expect(deriveChatJobStatus({ running: true } as any)).toBe('running')
|
|
})
|
|
|
|
it('maps a future-scheduled queued job to scheduled', async () => {
|
|
const { deriveChatJobStatus } = await import('./shared')
|
|
const future = new Date(Date.now() + 3_600_000).toISOString()
|
|
expect(deriveChatJobStatus({ running: false, scheduled_for: future } as any)).toBe('scheduled')
|
|
})
|
|
|
|
it('maps a plain (non-running, non-scheduled) queued job to queued', async () => {
|
|
const { deriveChatJobStatus } = await import('./shared')
|
|
expect(deriveChatJobStatus({ running: false } as any)).toBe('queued')
|
|
})
|
|
})
|
|
|
|
describe('trimJob', () => {
|
|
const HEAVY = ['logs', 'args', 'result', 'raw_code', 'raw_flow', 'flow_status']
|
|
|
|
it('preserves the ABSENCE of a success key on a queued job (JobStatusIcon in-operator invariant)', async () => {
|
|
const { trimJob } = await import('./shared')
|
|
const queued = {
|
|
id: 'j1',
|
|
running: true,
|
|
logs: 'x',
|
|
args: {},
|
|
result: 1,
|
|
raw_code: 'c',
|
|
raw_flow: {},
|
|
flow_status: {}
|
|
}
|
|
const trimmed = trimJob(queued as any)
|
|
// The load-bearing invariant: a running/queued job must NOT gain a `success`
|
|
// key, or deriveChatJobStatus/JobStatusIcon would misread it as completed.
|
|
expect('success' in trimmed).toBe(false)
|
|
expect(trimmed.running).toBe(true)
|
|
expect(trimmed.id).toBe('j1')
|
|
})
|
|
|
|
it('deletes the six heavy fields but keeps the status-discriminant scalar', async () => {
|
|
const { trimJob } = await import('./shared')
|
|
const job = {
|
|
id: 'j1',
|
|
success: true,
|
|
logs: 'x',
|
|
args: { a: 1 },
|
|
result: [1],
|
|
raw_code: 'c',
|
|
raw_flow: { modules: [] },
|
|
flow_status: { step: 0 }
|
|
}
|
|
const trimmed = trimJob(job as any) as Record<string, unknown>
|
|
for (const k of HEAVY) expect(k in trimmed).toBe(false)
|
|
expect('success' in trimmed).toBe(true)
|
|
expect(trimmed.success).toBe(true)
|
|
})
|
|
|
|
it('does not mutate the input job', async () => {
|
|
const { trimJob } = await import('./shared')
|
|
const job = { id: 'j1', success: true, result: 42 }
|
|
trimJob(job as any)
|
|
expect(job.result).toBe(42)
|
|
})
|
|
})
|
|
|
|
describe('processToolCall preAction', () => {
|
|
// preAction's "-ing" label must land at execution start, not stream time —
|
|
// firing it earlier would relabel a still-queued card as active.
|
|
it('invokes preAction at promotion, before the tool fn runs', async () => {
|
|
const { processToolCall } = await import('./shared')
|
|
const calls: string[] = []
|
|
const tool = {
|
|
def: { type: 'function' as const, function: { name: 'patch_app_file', parameters: {} } },
|
|
preAction: () => calls.push('preAction'),
|
|
fn: vi.fn().mockImplementation(async () => {
|
|
calls.push('fn')
|
|
return 'ok'
|
|
})
|
|
}
|
|
await processToolCall({
|
|
tools: [tool] as any,
|
|
toolCall: {
|
|
id: 'call_1',
|
|
type: 'function',
|
|
function: { name: 'patch_app_file', arguments: '{}' }
|
|
},
|
|
helpers: {},
|
|
toolCallbacks: { setToolStatus: vi.fn() } as any,
|
|
workspace: 'test'
|
|
})
|
|
expect(calls).toEqual(['preAction', 'fn'])
|
|
})
|
|
})
|
|
|
|
describe('queuedToolStatus', () => {
|
|
const tool = (extra: Record<string, unknown> = {}) => ({
|
|
def: { type: 'function' as const, function: { name: 'run_script', parameters: {} } },
|
|
fn: vi.fn(),
|
|
...extra
|
|
})
|
|
|
|
it('humanizes snake_case and camelCase tool names by default', async () => {
|
|
const { queuedToolStatus } = await import('./shared')
|
|
expect(queuedToolStatus([], 'run_script', '{}')).toMatchObject({
|
|
isLoading: false,
|
|
isQueued: true,
|
|
isStreamingArguments: false,
|
|
content: 'Run script'
|
|
})
|
|
expect(queuedToolStatus([], 'askUserQuestion', '{}').content).toBe('Ask user question')
|
|
})
|
|
|
|
it('derives the label from parsed args via queuedLabel', async () => {
|
|
const { queuedToolStatus } = await import('./shared')
|
|
const t = tool({ queuedLabel: (args: any) => `Test ${args.path}` })
|
|
expect(queuedToolStatus([t] as any, 'run_script', '{"path": "u/admin/x"}').content).toBe(
|
|
'Test u/admin/x'
|
|
)
|
|
})
|
|
|
|
it('falls back to the humanized name when args are truncated', async () => {
|
|
const { queuedToolStatus } = await import('./shared')
|
|
const t = tool({ queuedLabel: (args: any) => `Test ${args.path}` })
|
|
expect(queuedToolStatus([t] as any, 'run_script', '{"path": "u/adm').content).toBe('Run script')
|
|
})
|
|
})
|
|
|
|
describe('appendPendingToolImages', () => {
|
|
// Tool results are string-only, so tool-produced images ride a follow-up
|
|
// user message appended after the whole tool batch. It must land in BOTH
|
|
// arrays (messages = sent next iteration, addedMessages = committed to
|
|
// history) and drain the buffer exactly once — a second flush appending the
|
|
// same screenshots again would duplicate them in history.
|
|
it('appends one user message to both arrays and drains the buffer once', async () => {
|
|
const { appendPendingToolImages } = await import('./shared')
|
|
let pending = [{ dataUrl: 'data:image/png;base64,SHOT', mediaType: 'image/png' as const }]
|
|
const toolCallbacks = {
|
|
setToolStatus: vi.fn(),
|
|
takePendingToolImages: () => {
|
|
const taken = pending
|
|
pending = []
|
|
return taken
|
|
}
|
|
}
|
|
const messages: any[] = []
|
|
const addedMessages: any[] = []
|
|
|
|
appendPendingToolImages(messages, addedMessages, toolCallbacks as any)
|
|
|
|
expect(messages).toHaveLength(1)
|
|
expect(messages[0]).toBe(addedMessages[0])
|
|
expect(messages[0].role).toBe('user')
|
|
expect(messages[0].content[1]).toEqual({
|
|
type: 'image_url',
|
|
image_url: { url: 'data:image/png;base64,SHOT' }
|
|
})
|
|
|
|
appendPendingToolImages(messages, addedMessages, toolCallbacks as any)
|
|
expect(messages).toHaveLength(1)
|
|
expect(addedMessages).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe('openItemPreviewAction', () => {
|
|
// The action's `type` is the key the sessions page registers its handler under,
|
|
// so it must stay 'open_item_preview'; `previewKind`/`path` are passed verbatim
|
|
// to previewTargetForSessionTarget.
|
|
it('carries the kind and path through to the dispatch action', () => {
|
|
expect(openItemPreviewAction('flow', 'f/team/etl')).toEqual({
|
|
id: 'open-item-preview:flow:edit:f/team/etl',
|
|
type: 'open_item_preview',
|
|
label: 'Open flow preview',
|
|
previewKind: 'flow',
|
|
path: 'f/team/etl',
|
|
mode: 'edit'
|
|
})
|
|
})
|
|
|
|
// The side is part of the id: a transcript can offer the same path both ways —
|
|
// a write card opening its draft's editor, a prose mention opening the deployed
|
|
// page — and one id would collapse them into a single action.
|
|
it('distinguishes the two sides of the same item', () => {
|
|
const edit = openItemPreviewAction('flow', 'f/team/etl')
|
|
const view = openItemPreviewAction('flow', 'f/team/etl', 'view')
|
|
expect(view.mode).toBe('view')
|
|
expect(view.id).not.toBe(edit.id)
|
|
})
|
|
|
|
// raw_app is the internal kind; the user-facing label says "app".
|
|
it('labels raw_app as "app"', () => {
|
|
expect(openItemPreviewAction('raw_app', 'u/me/dash').label).toBe('Open app preview')
|
|
})
|
|
})
|
|
|
|
describe('createSearchHubScriptsTool', () => {
|
|
const hit = (version_id: number, app: string, summary: string) => ({
|
|
version_id,
|
|
app,
|
|
summary,
|
|
ask_id: version_id,
|
|
id: version_id,
|
|
kind: 'script' as const,
|
|
score: 1
|
|
})
|
|
|
|
async function runWithContent(getHubScriptByPath: ReturnType<typeof vi.fn>) {
|
|
const { ScriptService } = await import('$lib/gen')
|
|
Object.assign(ScriptService, {
|
|
queryHubScripts: vi.fn(async () => [
|
|
hit(1, 'discord', 'Send a message'),
|
|
hit(2, 'slack', 'Post a message')
|
|
]),
|
|
getHubScriptByPath
|
|
})
|
|
const { createSearchHubScriptsTool } = await import('./shared')
|
|
const raw = await createSearchHubScriptsTool(true).fn({
|
|
args: { query: 'send a message' },
|
|
toolId: 't1',
|
|
toolCallbacks: { setToolStatus: vi.fn() }
|
|
} as any)
|
|
return JSON.parse(raw).results
|
|
}
|
|
|
|
it('reports each script language alongside its content', async () => {
|
|
const results = await runWithContent(
|
|
vi.fn(async ({ path }: { path: string }) => ({
|
|
content: `// ${path}`,
|
|
language: path.startsWith('hub/1/') ? 'bunnative' : 'python3'
|
|
}))
|
|
)
|
|
|
|
expect(results).toEqual([
|
|
{
|
|
path: 'hub/1/discord/send_a_message',
|
|
summary: 'Send a message',
|
|
integration: 'discord',
|
|
language: 'bunnative',
|
|
content: '// hub/1/discord/send_a_message'
|
|
},
|
|
{
|
|
path: 'hub/2/slack/post_a_message',
|
|
summary: 'Post a message',
|
|
integration: 'slack',
|
|
language: 'python3',
|
|
content: '// hub/2/slack/post_a_message'
|
|
}
|
|
])
|
|
})
|
|
|
|
it('keeps the other results when one content fetch fails', async () => {
|
|
const results = await runWithContent(
|
|
vi.fn(async ({ path }: { path: string }) => {
|
|
if (path.startsWith('hub/1/')) throw new Error('hub unreachable')
|
|
return { content: 'ok', language: 'python3' }
|
|
})
|
|
)
|
|
|
|
expect(results[0].error).toContain('hub unreachable')
|
|
expect(results[0].content).toBeUndefined()
|
|
expect(results[1].content).toBe('ok')
|
|
})
|
|
|
|
// Browsing by app is what surfaces an integration's other scripts as examples,
|
|
// and only the top-scripts endpoint takes no query and applies no similarity
|
|
// floor, so the near-misses worth reading survive instead of being cut.
|
|
it('lists an integration through the top-scripts endpoint, with descriptions', async () => {
|
|
const { ScriptService } = await import('$lib/gen')
|
|
const getTopHubScripts = vi.fn(async () => ({
|
|
asks: [{ ...hit(3, 'stripe', 'Create a Payout'), description: 'Send funds to your bank' }]
|
|
}))
|
|
Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []), getTopHubScripts })
|
|
|
|
const { createSearchHubScriptsTool } = await import('./shared')
|
|
const raw = await createSearchHubScriptsTool().fn({
|
|
args: { integration: 'stripe' },
|
|
toolId: 't1',
|
|
toolCallbacks: { setToolStatus: vi.fn() }
|
|
} as any)
|
|
|
|
expect(getTopHubScripts).toHaveBeenCalledWith({ app: 'stripe', kind: 'script', limit: 20 })
|
|
expect(ScriptService.queryHubScripts).not.toHaveBeenCalled()
|
|
expect(JSON.parse(raw).results).toEqual([
|
|
{
|
|
path: 'hub/3/stripe/create_a_payout',
|
|
summary: 'Create a Payout',
|
|
integration: 'stripe',
|
|
description: 'Send funds to your bank'
|
|
}
|
|
])
|
|
})
|
|
|
|
// Narrowing a query to a slug is the only thing that searches an integration the
|
|
// user named: ranking alone surfaces other integrations whose scripts merely mention
|
|
// it, so a query that drops `app` silently searches the whole hub instead.
|
|
it('narrows a query to the integration it was given', async () => {
|
|
const { ScriptService } = await import('$lib/gen')
|
|
const queryHubScripts = vi.fn(async () => [hit(9, 'salesforce', 'SOSL Search')])
|
|
Object.assign(ScriptService, { queryHubScripts, getTopHubScripts: vi.fn() })
|
|
|
|
const { createSearchHubScriptsTool } = await import('./shared')
|
|
const raw = await createSearchHubScriptsTool().fn({
|
|
args: { query: 'look up an account', integration: 'salesforce' },
|
|
toolId: 't1',
|
|
toolCallbacks: { setToolStatus: vi.fn() }
|
|
} as any)
|
|
|
|
expect(queryHubScripts).toHaveBeenCalledWith({
|
|
text: 'look up an account',
|
|
kind: 'script',
|
|
app: 'salesforce'
|
|
})
|
|
expect(ScriptService.getTopHubScripts).not.toHaveBeenCalled()
|
|
expect(JSON.parse(raw).results.map((r: any) => r.integration)).toEqual(['salesforce'])
|
|
})
|
|
|
|
// A search below the similarity floor otherwise dead-ends; the slug is what
|
|
// lets the model fall back to browsing the integration.
|
|
it('suggests matching integrations when the search finds nothing', async () => {
|
|
const { ScriptService, IntegrationService } = await import('$lib/gen')
|
|
Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []) })
|
|
Object.assign(IntegrationService, {
|
|
listHubIntegrations: vi.fn(async () => [{ name: 'stripe' }, { name: 'slack' }])
|
|
})
|
|
|
|
const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared')
|
|
clearHubIntegrationsCache()
|
|
const raw = await createSearchHubScriptsTool().fn({
|
|
args: { query: 'refund a stripe charge' },
|
|
toolId: 't1',
|
|
toolCallbacks: { setToolStatus: vi.fn() }
|
|
} as any)
|
|
|
|
expect(JSON.parse(raw)).toEqual({ results: [], suggested_integrations: ['stripe'] })
|
|
})
|
|
|
|
// Matching a query word anywhere inside a slug turns every short English word into
|
|
// a hit — `for` in salesforce, `the` in basis_theory — so a task that names no
|
|
// integration came back with a confident-looking list of them.
|
|
it('suggests nothing for a request that names no integration', async () => {
|
|
const { ScriptService, IntegrationService } = await import('$lib/gen')
|
|
Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []) })
|
|
Object.assign(IntegrationService, {
|
|
listHubIntegrations: vi.fn(async () => [
|
|
{ name: 'salesforce' },
|
|
{ name: 'basis_theory' },
|
|
{ name: 'hackernews' },
|
|
{ name: 's3' },
|
|
// A slug that is an ordinary word with a letter in front of it: matching
|
|
// one character in would read "node" as this integration.
|
|
{ name: 'enode' }
|
|
])
|
|
})
|
|
|
|
const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared')
|
|
clearHubIntegrationsCache()
|
|
const raw = await createSearchHubScriptsTool().fn({
|
|
args: { query: 'list all the invoices for the new month in a node script' },
|
|
toolId: 't1',
|
|
toolCallbacks: { setToolStatus: vi.fn() }
|
|
} as any)
|
|
|
|
expect(JSON.parse(raw).suggested_integrations).toEqual([])
|
|
})
|
|
|
|
// Google's integrations compress the vendor to a `g`, so the word a user says
|
|
// appears in the name the hub curates and nowhere in the slug.
|
|
it('reaches an integration whose slug compresses the vendor name', async () => {
|
|
const { ScriptService, IntegrationService } = await import('$lib/gen')
|
|
Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []) })
|
|
Object.assign(IntegrationService, {
|
|
listHubIntegrations: vi.fn(async () => [
|
|
{ name: 'gsheets', display_name: 'Google Sheets' },
|
|
{ name: 'gdrive', display_name: 'Google Drive' },
|
|
{ name: 'smartsheet', display_name: null }
|
|
])
|
|
})
|
|
|
|
const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared')
|
|
clearHubIntegrationsCache()
|
|
const raw = await createSearchHubScriptsTool().fn({
|
|
args: { query: 'add a row to a google sheet' },
|
|
toolId: 't1',
|
|
toolCallbacks: { setToolStatus: vi.fn() }
|
|
} as any)
|
|
|
|
// "google" names both products, and a suggestion list is allowed to offer both.
|
|
expect(JSON.parse(raw).suggested_integrations).toEqual(['gsheets', 'gdrive'])
|
|
})
|
|
|
|
// The hub names only the slugs its own titleisation gets wrong, so the ones it
|
|
// leaves null are still named here.
|
|
it('falls back to the local name table for a slug the hub does not name', async () => {
|
|
const { ScriptService, IntegrationService } = await import('$lib/gen')
|
|
Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []) })
|
|
Object.assign(IntegrationService, {
|
|
listHubIntegrations: vi.fn(async () => [{ name: 'gcloud', display_name: null }])
|
|
})
|
|
|
|
const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared')
|
|
clearHubIntegrationsCache()
|
|
const raw = await createSearchHubScriptsTool().fn({
|
|
args: { query: 'deploy a service to google cloud' },
|
|
toolId: 't1',
|
|
toolCallbacks: { setToolStatus: vi.fn() }
|
|
} as any)
|
|
|
|
expect(JSON.parse(raw).suggested_integrations).toEqual(['gcloud'])
|
|
})
|
|
|
|
// A slug shorter than the token floor is only reachable by an exact match.
|
|
it('still reaches a two-character integration slug', async () => {
|
|
const { ScriptService, IntegrationService } = await import('$lib/gen')
|
|
Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []) })
|
|
Object.assign(IntegrationService, {
|
|
listHubIntegrations: vi.fn(async () => [{ name: 's3' }, { name: 'salesforce' }])
|
|
})
|
|
|
|
const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared')
|
|
clearHubIntegrationsCache()
|
|
const raw = await createSearchHubScriptsTool().fn({
|
|
args: { query: 'upload a file to s3' },
|
|
toolId: 't1',
|
|
toolCallbacks: { setToolStatus: vi.fn() }
|
|
} as any)
|
|
|
|
expect(JSON.parse(raw).suggested_integrations).toEqual(['s3'])
|
|
})
|
|
|
|
// Only about one hub integration in twelve carries authored provider knowledge,
|
|
// and a search result is where the model first sees a slug — so the mark has to
|
|
// ride along, or nothing tells it which integrations have verified notes to read.
|
|
it('marks the integrations get_hub_integration has knowledge for', async () => {
|
|
const { ScriptService, IntegrationService } = await import('$lib/gen')
|
|
Object.assign(ScriptService, {
|
|
queryHubScripts: vi.fn(async () => [
|
|
hit(1, 'confluence', 'Create page'),
|
|
hit(2, 'notion', 'Create page')
|
|
])
|
|
})
|
|
Object.assign(IntegrationService, {
|
|
listHubIntegrations: vi.fn(async () => [
|
|
{ name: 'Confluence', documented: true },
|
|
{ name: 'notion', documented: false }
|
|
])
|
|
})
|
|
|
|
const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared')
|
|
clearHubIntegrationsCache()
|
|
const raw = await createSearchHubScriptsTool().fn({
|
|
args: { query: 'create a wiki page' },
|
|
toolId: 't1',
|
|
toolCallbacks: { setToolStatus: vi.fn() }
|
|
} as any)
|
|
|
|
const results = JSON.parse(raw).results
|
|
// the hub's slugs are case-sensitive, so the two casings are one integration
|
|
expect(results.find((r: any) => r.integration === 'confluence').documented).toBe(true)
|
|
expect(results.find((r: any) => r.integration === 'notion').documented).toBeUndefined()
|
|
})
|
|
|
|
// A hub predating the flag sends no `documented` at all; reading that as "yes"
|
|
// would send the model after notes that are not coming.
|
|
it('claims no knowledge when the hub does not report any', async () => {
|
|
const { ScriptService, IntegrationService } = await import('$lib/gen')
|
|
Object.assign(ScriptService, {
|
|
queryHubScripts: vi.fn(async () => [hit(1, 'confluence', 'Create page')])
|
|
})
|
|
Object.assign(IntegrationService, {
|
|
listHubIntegrations: vi.fn(async () => [{ name: 'confluence' }])
|
|
})
|
|
|
|
const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared')
|
|
clearHubIntegrationsCache()
|
|
const raw = await createSearchHubScriptsTool().fn({
|
|
args: { query: 'create a wiki page' },
|
|
toolId: 't1',
|
|
toolCallbacks: { setToolStatus: vi.fn() }
|
|
} as any)
|
|
|
|
expect(JSON.parse(raw).results[0].documented).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('getHubIntegrationTool', () => {
|
|
// The plan-mode gate fails closed, so a read-only tool that is not tagged is
|
|
// silently unusable while planning.
|
|
it('is usable while planning, like the hub search beside it', async () => {
|
|
const { getHubIntegrationTool, createSearchHubScriptsTool } = await import('./shared')
|
|
expect(getHubIntegrationTool.planModeSafe).toBe(true)
|
|
expect(createSearchHubScriptsTool().planModeSafe).toBe(true)
|
|
})
|
|
|
|
const doc = {
|
|
app: 'confluence',
|
|
display_name: 'Confluence',
|
|
// Authored knowledge, but nobody has asserted whether the script set was pruned.
|
|
curated: null,
|
|
metadata_source: 'curated',
|
|
meta: { gotchas: ['Auth is Basic with an API token, not the password'] },
|
|
derived: {
|
|
api_hosts: [{ host: 'api.atlassian.com', count: 12 }],
|
|
style: 'fetch',
|
|
languages: { bun: 14 },
|
|
script_counts: { total: 14, by_kind: { script: 13 } },
|
|
top_scripts: [
|
|
{ path: 'hub/1/confluence/create_page', summary: 'Create page', language: 'bun' }
|
|
]
|
|
},
|
|
resource_types: [{ name: 'confluence', schema: { type: 'object' } }]
|
|
}
|
|
|
|
// Hand-validated provider knowledge and facts inferred from script bodies must
|
|
// stay under separate keys, or the model will report guesses as verified.
|
|
it('keeps authored notes apart from what was inferred from the scripts', async () => {
|
|
const { IntegrationService } = await import('$lib/gen')
|
|
Object.assign(IntegrationService, { getHubIntegrationMeta: vi.fn(async () => doc) })
|
|
|
|
const { getHubIntegrationTool } = await import('./shared')
|
|
const parsed = JSON.parse(
|
|
await getHubIntegrationTool.fn({
|
|
args: { integration: 'confluence' },
|
|
toolId: 't1',
|
|
toolCallbacks: { setToolStatus: vi.fn() }
|
|
} as any)
|
|
)
|
|
|
|
expect(parsed.verified_provider_notes).toEqual(doc.meta)
|
|
expect(parsed.scripts_note).toBeUndefined()
|
|
expect(parsed.observed_from_scripts).toEqual({
|
|
api_hosts: ['api.atlassian.com'],
|
|
style: 'fetch',
|
|
languages: ['bun'],
|
|
script_counts: { total: 14, by_kind: { script: 13 } }
|
|
})
|
|
})
|
|
|
|
// The hub repo owns this payload, so a hub older than this client can send a
|
|
// subset. Reading it must degrade to less content, never to a tool error.
|
|
it('returns what an older hub sent instead of failing on the missing sections', async () => {
|
|
const { IntegrationService } = await import('$lib/gen')
|
|
Object.assign(IntegrationService, {
|
|
getHubIntegrationMeta: vi.fn(async () => ({
|
|
app: 'stripe',
|
|
display_name: 'Stripe',
|
|
curated: false
|
|
}))
|
|
})
|
|
|
|
const { getHubIntegrationTool } = await import('./shared')
|
|
const parsed = JSON.parse(
|
|
await getHubIntegrationTool.fn({
|
|
args: { integration: 'stripe' },
|
|
toolId: 't1',
|
|
toolCallbacks: { setToolStatus: vi.fn() }
|
|
} as any)
|
|
)
|
|
|
|
expect(parsed.integration).toBe('stripe')
|
|
expect(parsed.observed_from_scripts).toBeUndefined()
|
|
expect(parsed.resource_types).toEqual([])
|
|
expect(parsed.example_scripts).toEqual([])
|
|
})
|
|
|
|
// `curated` has three states and only `true` is a licence to call the scripts good
|
|
// examples. `false` and the unassessed `null` must both stay silent, or the 212
|
|
// integrations nobody has looked at get characterised anyway.
|
|
it.each([
|
|
[true, true],
|
|
[false, false],
|
|
[null, false]
|
|
])('speaks about the script set only when curated is true (%s)', async (curated, expected) => {
|
|
const { IntegrationService } = await import('$lib/gen')
|
|
Object.assign(IntegrationService, {
|
|
getHubIntegrationMeta: vi.fn(async () => ({ ...doc, curated }))
|
|
})
|
|
|
|
const { getHubIntegrationTool } = await import('./shared')
|
|
const parsed = JSON.parse(
|
|
await getHubIntegrationTool.fn({
|
|
args: { integration: 'confluence' },
|
|
toolId: 't1',
|
|
toolCallbacks: { setToolStatus: vi.fn() }
|
|
} as any)
|
|
)
|
|
|
|
expect(!!parsed.scripts_note).toBe(expected)
|
|
})
|
|
|
|
// A hub that times out has said nothing about whether the integration exists, and
|
|
// reporting it as absent would stick for the rest of the conversation.
|
|
it('does not report a transient hub failure as a missing integration', async () => {
|
|
const { IntegrationService } = await import('$lib/gen')
|
|
Object.assign(IntegrationService, {
|
|
getHubIntegrationMeta: vi.fn(async () => {
|
|
throw Object.assign(new Error('Service Unavailable'), { status: 503 })
|
|
}),
|
|
listHubIntegrations: vi.fn(async () => [{ name: 'confluence' }])
|
|
})
|
|
|
|
const { getHubIntegrationTool, clearHubIntegrationsCache } = await import('./shared')
|
|
clearHubIntegrationsCache()
|
|
const parsed = JSON.parse(
|
|
await getHubIntegrationTool.fn({
|
|
args: { integration: 'confluence' },
|
|
toolId: 't1',
|
|
toolCallbacks: { setToolStatus: vi.fn() }
|
|
} as any)
|
|
)
|
|
|
|
expect(parsed.error).toContain('Could not reach the hub')
|
|
expect(parsed.error).not.toContain('No hub metadata')
|
|
})
|
|
|
|
// A hub with no such integration and one too old to serve the endpoint both 404;
|
|
// neither may surface as a tool error, since the model can still read scripts.
|
|
it('suggests real slugs instead of failing when the integration is unknown', async () => {
|
|
const { IntegrationService } = await import('$lib/gen')
|
|
Object.assign(IntegrationService, {
|
|
getHubIntegrationMeta: vi.fn(async () => {
|
|
throw Object.assign(new Error('Not Found'), { status: 404 })
|
|
}),
|
|
listHubIntegrations: vi.fn(async () => [{ name: 'stripe' }, { name: 'slack' }])
|
|
})
|
|
|
|
const { getHubIntegrationTool, clearHubIntegrationsCache } = await import('./shared')
|
|
clearHubIntegrationsCache()
|
|
const parsed = JSON.parse(
|
|
await getHubIntegrationTool.fn({
|
|
args: { integration: 'stripe_billing' },
|
|
toolId: 't1',
|
|
toolCallbacks: { setToolStatus: vi.fn() }
|
|
} as any)
|
|
)
|
|
|
|
expect(parsed.error).toContain('stripe_billing')
|
|
expect(parsed.suggested_integrations).toEqual(['stripe'])
|
|
})
|
|
})
|
|
|
|
describe('processToolCall confirmation hooks', () => {
|
|
async function hookedTool() {
|
|
const { createToolDef } = await import('./shared')
|
|
return {
|
|
def: createToolDef(z.object({}), 'apply_change', 'Apply change'),
|
|
requiresConfirmation: true,
|
|
fn: vi.fn().mockResolvedValue('ok'),
|
|
onConfirmationRequested: vi.fn()
|
|
}
|
|
}
|
|
|
|
it('requests before the card resolves', async () => {
|
|
const tool = await hookedTool()
|
|
let requestedBeforeResolve = false
|
|
const requestConfirmation = vi.fn(async () => {
|
|
requestedBeforeResolve = tool.onConfirmationRequested.mock.calls.length === 1
|
|
return false
|
|
})
|
|
|
|
await runToolCall(tool, { requestConfirmation })
|
|
|
|
expect(requestedBeforeResolve).toBe(true)
|
|
// The hook needs the id and callbacks to attach what it sets up to this card.
|
|
expect(tool.onConfirmationRequested).toHaveBeenCalledWith(
|
|
expect.objectContaining({ toolId: 'call_plan', toolCallbacks: expect.any(Object) })
|
|
)
|
|
expect(tool.fn).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('fires no hook when the confirmation is auto-accepted', async () => {
|
|
const tool = await hookedTool()
|
|
|
|
await runToolCall(tool, {
|
|
requestConfirmation: vi.fn(async () => true),
|
|
shouldAutoAcceptToolConfirmations: () => true
|
|
})
|
|
|
|
expect(tool.onConfirmationRequested).not.toHaveBeenCalled()
|
|
expect(tool.fn).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
// Any tool gets the web search card by returning this shape. The card renders urls and
|
|
// titles only, so a result carrying anything more must keep its JSON pane.
|
|
describe('webSearchResultOf', () => {
|
|
it('reads the shape, from the value or its JSON text', () => {
|
|
const result = { sources: [{ url: 'https://a.dev', title: 'A' }], query: 'a' }
|
|
expect(webSearchResultOf(result)).toEqual(result)
|
|
expect(webSearchResultOf(JSON.stringify(result))).toEqual(result)
|
|
})
|
|
|
|
// A Python tool serializes an absent optional as null, and the card has nothing to tell
|
|
// its author why an almost-right result fell back to JSON.
|
|
it('reads null on an optional field as absent', () => {
|
|
expect(
|
|
webSearchResultOf({ sources: [{ url: 'https://a.dev', title: null }], query: null })
|
|
).toEqual({
|
|
sources: [{ url: 'https://a.dev', title: undefined }],
|
|
query: undefined
|
|
})
|
|
})
|
|
|
|
it.each([
|
|
['an extra key', { sources: [{ url: 'https://a.dev' }], summary: 'x' }],
|
|
['an extra source field', { sources: [{ url: 'https://a.dev', snippet: 'x' }] }],
|
|
['a source without url', { sources: [{ title: 'A' }] }],
|
|
// The card renders no relative or javascript: link, so a result whose urls it would
|
|
// drop keeps its own JSON rather than showing an empty source list.
|
|
['a relative url', { sources: [{ url: '/docs/pg17', title: 'PG 17' }] }],
|
|
['a javascript: url', { sources: [{ url: 'javascript:alert(1)' }] }],
|
|
['no sources', { sources: [] }],
|
|
['a bare list of links', [{ url: 'https://a.dev' }]]
|
|
])('rejects %s', (_, result) => {
|
|
expect(webSearchResultOf(result)).toBeUndefined()
|
|
})
|
|
})
|