Files
simstudioai--sim/apps/sim/lib/copilot/request/handlers/handlers.test.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

980 lines
31 KiB
TypeScript

/**
* @vitest-environment node
*/
import { sleep } from '@sim/utils/helpers'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TraceCollector } from '@/lib/copilot/request/trace'
const { isSimExecuted, executeTool, ensureHandlersRegistered } = vi.hoisted(() => ({
isSimExecuted: vi.fn().mockReturnValue(true),
executeTool: vi.fn().mockResolvedValue({ success: true, output: { ok: true } }),
ensureHandlersRegistered: vi.fn(),
}))
const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall, markAsyncToolDelivered } =
vi.hoisted(() => ({
upsertAsyncToolCall: vi.fn(),
markAsyncToolRunning: vi.fn(),
completeAsyncToolCall: vi.fn(),
markAsyncToolDelivered: vi.fn(),
}))
const { waitForToolCompletion } = vi.hoisted(() => ({
waitForToolCompletion: vi.fn(),
}))
vi.mock('@/lib/copilot/tool-executor', () => ({
isSimExecuted,
executeTool,
ensureHandlersRegistered,
getToolEntry: vi.fn().mockReturnValue(undefined),
}))
vi.mock('@/lib/copilot/async-runs/repository', () => ({
createRunSegment: vi.fn(),
updateRunStatus: vi.fn(),
getLatestRunForExecution: vi.fn(),
getLatestRunForStream: vi.fn(),
getRunSegment: vi.fn(),
createRunCheckpoint: vi.fn(),
getAsyncToolCall: vi.fn(),
markAsyncToolStatus: vi.fn(),
listAsyncToolCallsForRun: vi.fn(),
getAsyncToolCalls: vi.fn(),
claimCompletedAsyncToolCall: vi.fn(),
releaseCompletedAsyncToolClaim: vi.fn(),
upsertAsyncToolCall,
markAsyncToolRunning,
markAsyncToolDelivered,
completeAsyncToolCall,
}))
vi.mock('@/lib/copilot/request/tools/client', () => ({
waitForToolCompletion,
}))
import {
MothershipStreamV1AsyncToolRecordStatus,
MothershipStreamV1EventType,
MothershipStreamV1ResourceOp,
MothershipStreamV1RunKind,
MothershipStreamV1TextChannel,
MothershipStreamV1ToolExecutor,
MothershipStreamV1ToolMode,
MothershipStreamV1ToolOutcome,
MothershipStreamV1ToolPhase,
} from '@/lib/copilot/generated/mothership-stream-v1'
import { Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1'
import { sseHandlers, subAgentHandlers } from '@/lib/copilot/request/handlers'
import type { ExecutionContext, StreamEvent, StreamingContext } from '@/lib/copilot/request/types'
describe('sse-handlers tool lifecycle', () => {
let context: StreamingContext
let execContext: ExecutionContext
beforeEach(() => {
vi.clearAllMocks()
upsertAsyncToolCall.mockResolvedValue(null)
markAsyncToolRunning.mockResolvedValue(null)
completeAsyncToolCall.mockResolvedValue(null)
markAsyncToolDelivered.mockResolvedValue(null)
waitForToolCompletion.mockResolvedValue(null)
context = {
chatId: undefined,
messageId: 'msg-1',
accumulatedContent: '',
finalAssistantContent: '',
sawMainToolCall: false,
trace: new TraceCollector(),
contentBlocks: [],
toolCalls: new Map(),
pendingToolPromises: new Map(),
currentThinkingBlock: null,
subagentThinkingBlocks: new Map(),
isInThinkingBlock: false,
subAgentContent: {},
subAgentToolCalls: {},
pendingContent: '',
streamComplete: false,
wasAborted: false,
errors: [],
}
execContext = {
userId: 'user-1',
workflowId: 'workflow-1',
}
})
it('keeps only the latest post-tool assistant text for headless final content', async () => {
await sseHandlers.text(
{
type: MothershipStreamV1EventType.text,
payload: {
channel: MothershipStreamV1TextChannel.assistant,
text: 'I will check that.',
},
} satisfies StreamEvent,
context,
execContext,
{ interactive: false }
)
await sseHandlers.tool(
{
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'tool-1',
toolName: ReadTool.id,
arguments: { path: 'foo.txt' },
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.call,
},
} satisfies StreamEvent,
context,
execContext,
{ interactive: false, autoExecuteTools: false }
)
await sseHandlers.text(
{
type: MothershipStreamV1EventType.text,
payload: {
channel: MothershipStreamV1TextChannel.assistant,
text: 'Final answer only.',
},
} satisfies StreamEvent,
context,
execContext,
{ interactive: false }
)
expect(context.accumulatedContent).toBe('I will check that.Final answer only.')
expect(context.finalAssistantContent).toBe('Final answer only.')
})
it('executes tool_call and emits tool_result', async () => {
executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } })
const onEvent = vi.fn()
await sseHandlers.tool(
{
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'tool-1',
toolName: ReadTool.id,
arguments: { workflowId: 'workflow-1' },
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.call,
ui: {},
},
} satisfies StreamEvent,
context,
execContext,
{ onEvent, interactive: false, timeout: 1000 }
)
// tool_call fires execution without awaiting (fire-and-forget for parallel execution),
// so we flush pending microtasks before asserting
await sleep(0)
expect(executeTool).toHaveBeenCalledTimes(1)
expect(onEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: MothershipStreamV1EventType.tool,
payload: expect.objectContaining({
toolCallId: 'tool-1',
success: true,
phase: MothershipStreamV1ToolPhase.result,
}),
})
)
const updated = context.toolCalls.get('tool-1')
expect(updated?.status).toBe(MothershipStreamV1ToolOutcome.success)
// Display titles are derived client-side from the tool name (+args), not the
// stream; read with no path resolves to the static "Reading file".
expect(updated?.displayTitle).toBe('Reading file')
expect(updated?.result?.output).toEqual({ ok: true })
expect(context.contentBlocks.at(0)).toEqual(
expect.objectContaining({
type: 'tool_call',
toolCall: expect.objectContaining({
id: 'tool-1',
displayTitle: 'Reading file',
}),
})
)
})
it('preserves primitive tool outputs through async completion persistence', async () => {
executeTool.mockResolvedValueOnce({ success: true, output: 'done' })
const onEvent = vi.fn()
await sseHandlers.tool(
{
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'tool-primitive',
toolName: ReadTool.id,
arguments: { workflowId: 'workflow-1' },
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.call,
},
} satisfies StreamEvent,
context,
execContext,
{ onEvent, interactive: false, timeout: 1000 }
)
await sleep(0)
expect(completeAsyncToolCall).toHaveBeenCalledWith(
expect.objectContaining({
toolCallId: 'tool-primitive',
status: MothershipStreamV1AsyncToolRecordStatus.completed,
result: 'done',
error: null,
})
)
expect(onEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: MothershipStreamV1EventType.tool,
payload: expect.objectContaining({
toolCallId: 'tool-primitive',
phase: MothershipStreamV1ToolPhase.result,
success: true,
output: 'done',
}),
})
)
const updated = context.toolCalls.get('tool-primitive')
expect(updated?.status).toBe(MothershipStreamV1ToolOutcome.success)
expect(updated?.result?.output).toBe('done')
})
it('marks background client workflow tools delivered after synthetic result emission', async () => {
waitForToolCompletion.mockResolvedValueOnce({
status: 'background',
data: { detached: true },
})
const onEvent = vi.fn()
await sseHandlers.tool(
{
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'tool-background',
toolName: 'run_workflow',
arguments: { workflowId: 'workflow-1' },
executor: MothershipStreamV1ToolExecutor.client,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.call,
},
} satisfies StreamEvent,
context,
execContext,
{ onEvent, interactive: true, timeout: 1000 }
)
await sleep(0)
await Promise.allSettled(context.pendingToolPromises.values())
expect(markAsyncToolDelivered).toHaveBeenCalledWith('tool-background')
expect(onEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: MothershipStreamV1EventType.tool,
payload: expect.objectContaining({
toolCallId: 'tool-background',
phase: MothershipStreamV1ToolPhase.result,
status: MothershipStreamV1ToolOutcome.skipped,
success: true,
output: { detached: true },
}),
})
)
expect(context.toolCalls.get('tool-background')?.status).toBe(
MothershipStreamV1ToolOutcome.skipped
)
})
it('does not add hidden tool calls to content blocks', async () => {
executeTool.mockResolvedValueOnce({ success: true, output: { skill: 'ok' } })
await sseHandlers.tool(
{
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'tool-hidden',
toolName: 'load_agent_skill',
arguments: { skill_name: 'markdown-writing' },
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.call,
},
} satisfies StreamEvent,
context,
execContext,
{ interactive: false, timeout: 1000 }
)
await sleep(0)
expect(executeTool).toHaveBeenCalledTimes(1)
expect(context.contentBlocks).toEqual([])
expect(context.toolCalls.get('tool-hidden')?.name).toBe('load_agent_skill')
})
it('does not add ui-hidden tool calls to content blocks', async () => {
await sseHandlers.tool(
{
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'tool-ui-hidden',
toolName: 'read',
arguments: { path: 'components/integrations/slack/README.md' },
executor: MothershipStreamV1ToolExecutor.go,
mode: MothershipStreamV1ToolMode.sync,
phase: MothershipStreamV1ToolPhase.call,
ui: { hidden: true },
},
} satisfies StreamEvent,
context,
execContext,
{ interactive: false, timeout: 1000 }
)
expect(context.contentBlocks).toEqual([])
expect(context.toolCalls.get('tool-ui-hidden')?.name).toBe('read')
})
it('removes an existing content block when a later frame marks the tool hidden', async () => {
await sseHandlers.tool(
{
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'tool-hidden-after-partial',
toolName: 'read',
executor: MothershipStreamV1ToolExecutor.go,
mode: MothershipStreamV1ToolMode.sync,
phase: MothershipStreamV1ToolPhase.call,
status: 'generating',
arguments: { path: 'components/integrations' },
},
} satisfies StreamEvent,
context,
execContext,
{ interactive: false, timeout: 1000 }
)
expect(context.contentBlocks).toHaveLength(1)
await sseHandlers.tool(
{
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'tool-hidden-after-partial',
toolName: 'read',
executor: MothershipStreamV1ToolExecutor.go,
mode: MothershipStreamV1ToolMode.sync,
phase: MothershipStreamV1ToolPhase.call,
arguments: { path: 'components/integrations/slack/README.md' },
ui: { hidden: true },
},
} satisfies StreamEvent,
context,
execContext,
{ interactive: false, timeout: 1000 }
)
expect(context.contentBlocks).toEqual([])
})
it('does not show pathless read or glob generating placeholders', async () => {
for (const toolName of ['read', 'glob'] as const) {
await sseHandlers.tool(
{
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: `${toolName}-generating`,
toolName,
executor: MothershipStreamV1ToolExecutor.go,
mode: MothershipStreamV1ToolMode.sync,
phase: MothershipStreamV1ToolPhase.call,
status: 'generating',
},
} satisfies StreamEvent,
context,
execContext,
{ interactive: false, timeout: 1000 }
)
}
expect(context.contentBlocks).toEqual([])
expect(context.toolCalls.has('read-generating')).toBe(false)
expect(context.toolCalls.has('glob-generating')).toBe(false)
})
it('updates stored params when a subagent generating event is followed by the final tool call', async () => {
executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } })
context.toolCalls.set('parent-1', {
id: 'parent-1',
name: 'workflow',
status: 'pending',
startTime: Date.now(),
})
await subAgentHandlers.tool(
{
type: MothershipStreamV1EventType.tool,
scope: { lane: 'subagent', parentToolCallId: 'parent-1', agentId: 'workflow' },
payload: {
toolCallId: 'sub-tool-1',
toolName: 'create_workflow',
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.call,
status: 'generating',
},
} satisfies StreamEvent,
context,
execContext,
{ interactive: false, timeout: 1000 }
)
await subAgentHandlers.tool(
{
type: MothershipStreamV1EventType.tool,
scope: { lane: 'subagent', parentToolCallId: 'parent-1', agentId: 'workflow' },
payload: {
toolCallId: 'sub-tool-1',
toolName: 'create_workflow',
arguments: { name: 'Example Workflow' },
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.call,
status: 'executing',
},
} satisfies StreamEvent,
context,
execContext,
{ interactive: false, timeout: 1000 }
)
await sleep(0)
expect(executeTool).toHaveBeenCalledWith(
'create_workflow',
{ name: 'Example Workflow' },
expect.any(Object)
)
expect(context.toolCalls.get('sub-tool-1')?.params).toEqual({ name: 'Example Workflow' })
expect(context.subAgentToolCalls['parent-1']?.[0]?.params).toEqual({
name: 'Example Workflow',
})
})
it('routes subagent text using the event scope parent tool call id', async () => {
context.subAgentContent['parent-1'] = ''
await subAgentHandlers.text(
{
type: MothershipStreamV1EventType.text,
scope: { lane: 'subagent', parentToolCallId: 'parent-1', agentId: 'deploy' },
payload: {
channel: MothershipStreamV1TextChannel.assistant,
text: 'hello from deploy',
},
} satisfies StreamEvent,
context,
execContext,
{ interactive: false, timeout: 1000 }
)
expect(context.subAgentContent['parent-1']).toBe('hello from deploy')
expect(context.contentBlocks.at(-1)).toEqual(
expect.objectContaining({
type: 'subagent_text',
content: 'hello from deploy',
})
)
})
it('routes main assistant text with no scope into accumulatedContent', async () => {
await sseHandlers.text(
{
type: MothershipStreamV1EventType.text,
payload: {
channel: MothershipStreamV1TextChannel.assistant,
text: 'hello from main',
},
} satisfies StreamEvent,
context,
execContext,
{ interactive: false, timeout: 1000 }
)
expect(context.accumulatedContent).toBe('hello from main')
expect(context.contentBlocks.at(-1)).toEqual(
expect.objectContaining({
type: 'text',
content: 'hello from main',
})
)
})
it('routes subagent tool calls using the event scope parent tool call id', async () => {
executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } })
context.toolCalls.set('parent-1', {
id: 'parent-1',
name: 'deploy',
status: 'pending',
startTime: Date.now(),
})
await subAgentHandlers.tool(
{
type: MothershipStreamV1EventType.tool,
scope: { lane: 'subagent', parentToolCallId: 'parent-1', agentId: 'deploy' },
payload: {
toolCallId: 'sub-tool-scope-1',
toolName: 'read',
arguments: { path: 'workflow.json' },
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.call,
},
} satisfies StreamEvent,
context,
execContext,
{ interactive: false, timeout: 1000 }
)
await sleep(0)
expect(context.subAgentToolCalls['parent-1']?.[0]?.id).toBe('sub-tool-scope-1')
})
it('keeps two concurrent subagent lanes separate for text and thinking', async () => {
const send = (parent: string, channel: MothershipStreamV1TextChannel, text: string) =>
subAgentHandlers.text(
{
type: MothershipStreamV1EventType.text,
scope: {
lane: 'subagent',
parentToolCallId: parent,
spanId: `span-${parent}`,
agentId: 'research',
},
payload: { channel, text },
} satisfies StreamEvent,
context,
execContext,
{ interactive: false, timeout: 1000 }
)
// Interleaved thinking across two concurrent lanes.
await send('A', MothershipStreamV1TextChannel.thinking, 'A-think-1 ')
await send('B', MothershipStreamV1TextChannel.thinking, 'B-think-1 ')
await send('A', MothershipStreamV1TextChannel.thinking, 'A-think-2')
// Each lane accumulates its own thinking block — no cross-contamination.
expect(context.subagentThinkingBlocks.get('A')?.content).toBe('A-think-1 A-think-2')
expect(context.subagentThinkingBlocks.get('B')?.content).toBe('B-think-1 ')
// Interleaved assistant text across the two lanes.
await send('A', MothershipStreamV1TextChannel.assistant, 'A-text')
await send('B', MothershipStreamV1TextChannel.assistant, 'B-text')
expect(context.subAgentContent.A).toBe('A-text')
expect(context.subAgentContent.B).toBe('B-text')
// Assistant text flushed each lane's thinking into contentBlocks, attributed
// to the correct parent (not whichever subagent streamed most recently).
const thinking = context.contentBlocks.filter((b) => b.type === 'subagent_thinking')
expect(thinking.find((b) => b.parentToolCallId === 'A')?.content).toBe('A-think-1 A-think-2')
expect(thinking.find((b) => b.parentToolCallId === 'B')?.content).toBe('B-think-1 ')
})
it('drops a subagent text event that is missing its parent tool call id', async () => {
const before = context.contentBlocks.length
await subAgentHandlers.text(
{
type: MothershipStreamV1EventType.text,
scope: { lane: 'subagent', agentId: 'research' },
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'orphan' },
} satisfies StreamEvent,
context,
execContext,
{ interactive: false, timeout: 1000 }
)
// No lane to attribute to — nothing is added rather than mis-attributed.
expect(context.contentBlocks.length).toBe(before)
expect(Object.keys(context.subAgentContent)).not.toContain('undefined')
})
it('skips duplicate tool_call after result', async () => {
executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } })
const event = {
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'tool-dup',
toolName: ReadTool.id,
arguments: { workflowId: 'workflow-1' },
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.call,
},
}
await sseHandlers.tool(event as StreamEvent, context, execContext, { interactive: false })
await sleep(0)
await sseHandlers.tool(event as StreamEvent, context, execContext, { interactive: false })
expect(executeTool).toHaveBeenCalledTimes(1)
})
it('marks an in-flight tool as cancelled when aborted mid-execution', async () => {
const abortController = new AbortController()
const userStopController = new AbortController()
execContext.abortSignal = abortController.signal
execContext.userStopSignal = userStopController.signal
executeTool.mockImplementationOnce(
() =>
new Promise((resolve) => {
setTimeout(() => resolve({ success: true, output: { ok: true } }), 0)
})
)
await sseHandlers.tool(
{
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'tool-cancel',
toolName: ReadTool.id,
arguments: { workflowId: 'workflow-1' },
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.call,
},
} satisfies StreamEvent,
context,
execContext,
{
interactive: false,
timeout: 1000,
abortSignal: abortController.signal,
userStopSignal: userStopController.signal,
}
)
userStopController.abort()
abortController.abort()
await sleep(10)
const updated = context.toolCalls.get('tool-cancel')
expect(updated?.status).toBe(MothershipStreamV1ToolOutcome.cancelled)
expect(updated?.result).toEqual({ success: false })
expect(updated?.error).toBe('Request aborted during tool execution')
})
it('does not replace an in-flight pending promise on duplicate tool_call', async () => {
let resolveTool: ((value: { success: boolean; output: { ok: boolean } }) => void) | undefined
executeTool.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveTool = resolve
})
)
const event = {
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'tool-inflight',
toolName: ReadTool.id,
arguments: { workflowId: 'workflow-1' },
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.call,
},
}
await sseHandlers.tool(event as StreamEvent, context, execContext, { interactive: false })
await sleep(0)
const firstPromise = context.pendingToolPromises.get('tool-inflight')
expect(firstPromise).toBeDefined()
await sseHandlers.tool(event as StreamEvent, context, execContext, { interactive: false })
expect(executeTool).toHaveBeenCalledTimes(1)
expect(context.pendingToolPromises.get('tool-inflight')).toBe(firstPromise)
resolveTool?.({ success: true, output: { ok: true } })
await sleep(0)
expect(context.pendingToolPromises.has('tool-inflight')).toBe(false)
})
it('still executes the tool when async row upsert fails', async () => {
upsertAsyncToolCall.mockRejectedValueOnce(new Error('db down'))
executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } })
await sseHandlers.tool(
{
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'tool-upsert-fail',
toolName: ReadTool.id,
arguments: { workflowId: 'workflow-1' },
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.call,
},
} satisfies StreamEvent,
context,
execContext,
{ onEvent: vi.fn(), interactive: false, timeout: 1000 }
)
await sleep(0)
expect(executeTool).toHaveBeenCalledTimes(1)
expect(context.toolCalls.get('tool-upsert-fail')?.status).toBe(
MothershipStreamV1ToolOutcome.success
)
})
it('does not execute a tool if a terminal tool_result arrives before local execution starts', async () => {
let resolveUpsert: ((value: null) => void) | undefined
upsertAsyncToolCall.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveUpsert = resolve
})
)
const onEvent = vi.fn()
await sseHandlers.tool(
{
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'tool-race',
toolName: ReadTool.id,
arguments: { workflowId: 'workflow-1' },
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.call,
},
} satisfies StreamEvent,
context,
execContext,
{ onEvent, interactive: false, timeout: 1000 }
)
await sseHandlers.tool(
{
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'tool-race',
toolName: ReadTool.id,
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.result,
success: true,
output: { ok: true },
},
} satisfies StreamEvent,
context,
execContext,
{ onEvent, interactive: false, timeout: 1000 }
)
resolveUpsert?.(null)
await sleep(0)
expect(executeTool).not.toHaveBeenCalled()
expect(context.toolCalls.get('tool-race')?.status).toBe(MothershipStreamV1ToolOutcome.success)
expect(context.toolCalls.get('tool-race')?.result?.output).toEqual({ ok: true })
})
it('does not execute a tool if a tool_result arrives before the tool_call event', async () => {
const onEvent = vi.fn()
await sseHandlers.tool(
{
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'tool-early-result',
toolName: ReadTool.id,
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.result,
success: true,
output: { ok: true },
},
} satisfies StreamEvent,
context,
execContext,
{ onEvent, interactive: false, timeout: 1000 }
)
await sseHandlers.tool(
{
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'tool-early-result',
toolName: ReadTool.id,
arguments: { workflowId: 'workflow-1' },
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.call,
},
} satisfies StreamEvent,
context,
execContext,
{ onEvent, interactive: false, timeout: 1000 }
)
await sleep(0)
expect(executeTool).not.toHaveBeenCalled()
expect(context.toolCalls.get('tool-early-result')?.status).toBe(
MothershipStreamV1ToolOutcome.success
)
})
it('reads canonical tool result errors from the error field', async () => {
await sseHandlers.tool(
{
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'tool-output-only',
toolName: ReadTool.id,
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.result,
success: false,
error: 'output-failure',
output: { detail: 'extra-context' },
},
} satisfies StreamEvent,
context,
execContext,
{ onEvent: vi.fn(), interactive: false, timeout: 1000 }
)
const updated = context.toolCalls.get('tool-output-only')
expect(updated?.status).toBe(MothershipStreamV1ToolOutcome.error)
expect(updated?.result?.output).toEqual({ detail: 'extra-context' })
expect(updated?.error).toBe('output-failure')
})
it('preserves skipped tool results from the stream contract', async () => {
await sseHandlers.tool(
{
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'tool-skipped',
toolName: ReadTool.id,
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.result,
status: MothershipStreamV1ToolOutcome.skipped,
success: true,
output: { detached: true },
},
} satisfies StreamEvent,
context,
execContext,
{ onEvent: vi.fn(), interactive: false, timeout: 1000 }
)
const updated = context.toolCalls.get('tool-skipped')
expect(updated?.status).toBe(MothershipStreamV1ToolOutcome.skipped)
expect(updated?.result?.output).toEqual({ detached: true })
expect(updated?.error).toBeUndefined()
})
it('executes dynamic sim tools based on payload executor', async () => {
isSimExecuted.mockReturnValueOnce(false)
executeTool.mockResolvedValueOnce({ success: true, output: { emails: [] } })
await sseHandlers.tool(
{
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'tool-dynamic-sim',
toolName: 'gmail_read',
arguments: { maxResults: 10 },
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.call,
},
} satisfies StreamEvent,
context,
execContext,
{ interactive: false, timeout: 1000 }
)
await sleep(0)
expect(executeTool).toHaveBeenCalledWith('gmail_read', { maxResults: 10 }, expect.any(Object))
expect(context.toolCalls.get('tool-dynamic-sim')?.status).toBe(
MothershipStreamV1ToolOutcome.success
)
})
it('clears pending continuation state when a run resumes', async () => {
context.awaitingAsyncContinuation = {
checkpointId: 'cp-1',
executionId: 'exec-1',
runId: 'run-1',
pendingToolCallIds: ['tool-1'],
}
context.streamComplete = true
await sseHandlers.run(
{
type: MothershipStreamV1EventType.run,
payload: {
kind: MothershipStreamV1RunKind.resumed,
},
} satisfies StreamEvent,
context,
execContext,
{ interactive: false, timeout: 1000 }
)
expect(context.awaitingAsyncContinuation).toBeUndefined()
expect(context.streamComplete).toBe(false)
})
it('routes resource events through an explicit main-lane handler', async () => {
expect(() =>
sseHandlers.resource(
{
type: MothershipStreamV1EventType.resource,
payload: {
op: MothershipStreamV1ResourceOp.upsert,
resource: {
type: 'file',
id: 'file-1',
title: 'Document',
},
},
} satisfies StreamEvent,
context,
execContext,
{ interactive: false, timeout: 1000 }
)
).not.toThrow()
})
})