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
980 lines
31 KiB
TypeScript
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()
|
|
})
|
|
})
|