chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,28 @@
import type { StreamHandler } from './types'
import { flushSubagentThinkingBlock, flushThinkingBlock } from './types'
export const handleCompleteEvent: StreamHandler = (event, context) => {
flushSubagentThinkingBlock(context)
flushThinkingBlock(context)
if (event.type !== 'complete') {
context.streamComplete = true
return
}
if (event.payload.usage) {
context.usage = {
prompt: (context.usage?.prompt || 0) + (event.payload.usage.input_tokens || 0),
completion: (context.usage?.completion || 0) + (event.payload.usage.output_tokens || 0),
}
}
if (event.payload.cost) {
context.cost = {
input: (context.cost?.input || 0) + (event.payload.cost.input || 0),
output: (context.cost?.output || 0) + (event.payload.cost.output || 0),
total: (context.cost?.total || 0) + (event.payload.cost.total || 0),
}
}
context.streamComplete = true
}
@@ -0,0 +1,16 @@
import type { StreamHandler } from './types'
import { flushSubagentThinkingBlock, flushThinkingBlock } from './types'
export const handleErrorEvent: StreamHandler = (event, context) => {
flushSubagentThinkingBlock(context)
flushThinkingBlock(context)
if (event.type !== 'error') {
context.streamComplete = true
return
}
const message = event.payload.message || event.payload.error
if (message) {
context.errors.push(message)
}
context.streamComplete = true
}
@@ -0,0 +1,979 @@
/**
* @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()
})
})
@@ -0,0 +1,52 @@
import { createLogger } from '@sim/logger'
import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1'
import type { StreamEvent, StreamingContext } from '@/lib/copilot/request/types'
import { handleCompleteEvent } from './complete'
import { handleErrorEvent } from './error'
import { handleResourceEvent } from './resource'
import { handleRunEvent } from './run'
import { handleSessionEvent } from './session'
import { handleSpanEvent } from './span'
import { handleTextEvent } from './text'
import { handleToolEvent, prePersistClientExecutableToolCall } from './tool'
import type { StreamHandler } from './types'
export { prePersistClientExecutableToolCall }
export type { StreamHandler } from './types'
const logger = createLogger('CopilotHandlerRouting')
export const sseHandlers: Record<string, StreamHandler> = {
[MothershipStreamV1EventType.session]: handleSessionEvent,
[MothershipStreamV1EventType.tool]: (e, c, ec, o) => handleToolEvent(e, c, ec, o, 'main'),
[MothershipStreamV1EventType.text]: handleTextEvent('main'),
[MothershipStreamV1EventType.resource]: handleResourceEvent,
[MothershipStreamV1EventType.run]: handleRunEvent,
[MothershipStreamV1EventType.complete]: handleCompleteEvent,
[MothershipStreamV1EventType.error]: handleErrorEvent,
[MothershipStreamV1EventType.span]: handleSpanEvent,
}
export const subAgentHandlers: Record<string, StreamHandler> = {
[MothershipStreamV1EventType.text]: handleTextEvent('subagent'),
[MothershipStreamV1EventType.tool]: (e, c, ec, o) => handleToolEvent(e, c, ec, o, 'subagent'),
[MothershipStreamV1EventType.span]: handleSpanEvent,
}
export function handleSubagentRouting(event: StreamEvent, _context: StreamingContext): boolean {
if (event.scope?.lane !== 'subagent') return false
// Scope-only attribution: a subagent event MUST carry its own parentToolCallId.
// With concurrent subagents there is no single "current" lane to fall back to —
// routing by a global pointer would mis-attribute interleaved events to the
// last-started subagent. A missing parentToolCallId is a contract violation
// (Go always stamps it), so warn and route to the main lane rather than guess.
if (!event.scope?.parentToolCallId) {
logger.warn('Subagent event missing parent tool call id; routing to main lane', {
type: event.type,
subagent: event.scope?.agentId,
})
return false
}
return true
}
@@ -0,0 +1,7 @@
import type { StreamHandler } from './types'
export const handleResourceEvent: StreamHandler = (event) => {
if (event.type !== 'resource') {
return
}
}
@@ -0,0 +1,73 @@
import { createLogger } from '@sim/logger'
import {
MothershipStreamV1RunKind,
MothershipStreamV1ToolOutcome,
} from '@/lib/copilot/generated/mothership-stream-v1'
import type { StreamHandler } from './types'
import { addContentBlock } from './types'
const logger = createLogger('CopilotRunHandler')
export const handleRunEvent: StreamHandler = (event, context) => {
if (event.type !== 'run') {
return
}
if (event.payload.kind === MothershipStreamV1RunKind.checkpoint_pause) {
const frames = (event.payload.frames ?? []).map((frame) => ({
parentToolCallId: frame.parentToolCallId,
parentToolName: frame.parentToolName,
pendingToolIds: frame.pendingToolIds,
// Carried through for the per-subagent resume fan-out; undefined under the
// legacy bundled-frame model (all frames share the top-level checkpointId).
...(frame.checkpointId ? { checkpointId: frame.checkpointId } : {}),
}))
context.awaitingAsyncContinuation = {
checkpointId: event.payload.checkpointId,
executionId: event.payload.executionId || context.executionId,
runId: event.payload.runId || context.runId,
pendingToolCallIds: event.payload.pendingToolCallIds,
frames: frames.length > 0 ? frames : undefined,
}
logger.info('Received checkpoint pause', {
checkpointId: context.awaitingAsyncContinuation.checkpointId,
executionId: context.awaitingAsyncContinuation.executionId,
runId: context.awaitingAsyncContinuation.runId,
pendingToolCallIds: context.awaitingAsyncContinuation.pendingToolCallIds,
frameCount: frames.length,
})
context.streamComplete = true
return
}
if (event.payload.kind === MothershipStreamV1RunKind.compaction_start) {
addContentBlock(context, {
type: 'tool_call',
toolCall: {
id: `compaction-${Date.now()}`,
name: 'context_compaction',
status: 'executing',
},
})
return
}
if (event.payload.kind === MothershipStreamV1RunKind.resumed) {
context.awaitingAsyncContinuation = undefined
context.streamComplete = false
logger.info('Received run resumed event')
return
}
if (event.payload.kind === MothershipStreamV1RunKind.compaction_done) {
addContentBlock(context, {
type: 'tool_call',
toolCall: {
id: `compaction-${Date.now()}`,
name: 'context_compaction',
status: MothershipStreamV1ToolOutcome.success,
},
})
}
}
@@ -0,0 +1,14 @@
import { MothershipStreamV1SessionKind } from '@/lib/copilot/generated/mothership-stream-v1'
import type { StreamHandler } from './types'
export const handleSessionEvent: StreamHandler = (event, context, execContext) => {
if (event.type !== 'session' || event.payload.kind !== MothershipStreamV1SessionKind.chat) {
return
}
const chatId = event.payload.chatId
context.chatId = chatId
if (chatId) {
execContext.chatId = chatId
}
}
@@ -0,0 +1,66 @@
import {
MothershipStreamV1SpanLifecycleEvent,
MothershipStreamV1SpanPayloadKind,
} from '@/lib/copilot/generated/mothership-stream-v1'
import type { StreamHandler } from './types'
/**
* Mirror Go-emitted span lifecycle events onto the Sim-side TraceCollector.
*
* Go publishes `span` events for subagent lifecycles and structured-result
* payloads. For subagents, the start/end pair is also used for UI routing
* elsewhere; here we additionally record a named span on the trace collector
* so the final RequestTraceV1 report shows the full nested structure without
* requiring the reader to inspect the raw envelope stream.
*/
export const handleSpanEvent: StreamHandler = (event, context) => {
if (event.type !== 'span') {
return
}
const payload = event.payload as {
kind?: string
event?: string
agent?: string
data?: unknown
}
const kind = payload?.kind ?? ''
const evt = payload?.event ?? ''
if (kind === MothershipStreamV1SpanPayloadKind.subagent) {
const scopeAgent =
typeof payload.agent === 'string' && payload.agent ? payload.agent : 'subagent'
// Key by the deterministic spanId so two concurrent runs of the SAME agent
// (e.g. two parallel `research` subagents) get distinct trace spans. Fall
// back to agent:parentToolCallId for legacy events that predate span ids.
const traceKey = event.scope?.spanId || `${scopeAgent}:${event.scope?.parentToolCallId || ''}`
if (evt === MothershipStreamV1SpanLifecycleEvent.start) {
const span = context.trace.startSpan(`subagent:${scopeAgent}`, 'go.subagent', {
agent: scopeAgent,
parentToolCallId: event.scope?.parentToolCallId,
spanId: event.scope?.spanId,
})
context.subAgentTraceSpans ??= new Map()
context.subAgentTraceSpans.set(traceKey, span)
} else if (evt === MothershipStreamV1SpanLifecycleEvent.end) {
const span = context.subAgentTraceSpans?.get(traceKey)
if (span) {
context.trace.endSpan(span, 'ok')
context.subAgentTraceSpans?.delete(traceKey)
}
}
return
}
if (
kind === MothershipStreamV1SpanPayloadKind.structured_result ||
kind === MothershipStreamV1SpanPayloadKind.subagent_result
) {
const span = context.trace.startSpan(`${kind}:${payload.agent ?? 'main'}`, `go.${kind}`, {
agent: payload.agent,
hasData: payload.data !== undefined,
})
context.trace.endSpan(span, 'ok')
return
}
}
@@ -0,0 +1,81 @@
import { MothershipStreamV1TextChannel } from '@/lib/copilot/generated/mothership-stream-v1'
import type { StreamHandler, ToolScope } from './types'
import {
addContentBlock,
flushSubagentThinkingBlock,
flushThinkingBlock,
getScopedParentToolCallId,
getScopedSpanIdentity,
} from './types'
export function handleTextEvent(scope: ToolScope): StreamHandler {
return (event, context) => {
if (event.type !== 'text') {
return
}
const chunk = event.payload.text
if (!chunk) {
return
}
if (scope === 'subagent') {
const parentToolCallId = getScopedParentToolCallId(event, context)
if (!parentToolCallId) return
const spanIdentity = getScopedSpanIdentity(event)
if (event.payload.channel === MothershipStreamV1TextChannel.thinking) {
// Per-lane thinking: each concurrent subagent accumulates into its own
// block keyed by parentToolCallId, so interleaved chunks from a sibling
// subagent never flush or corrupt this lane's reasoning.
let block = context.subagentThinkingBlocks.get(parentToolCallId)
if (!block) {
block = {
type: 'subagent_thinking',
content: '',
parentToolCallId,
...spanIdentity,
timestamp: Date.now(),
}
context.subagentThinkingBlocks.set(parentToolCallId, block)
}
block.content = `${block.content || ''}${chunk}`
return
}
// Real text for this lane: close this lane's thinking block first so the
// persisted order is [thinking, text] within the lane.
flushSubagentThinkingBlock(context, parentToolCallId)
if (context.isInThinkingBlock) {
flushThinkingBlock(context)
}
context.subAgentContent[parentToolCallId] =
(context.subAgentContent[parentToolCallId] || '') + chunk
addContentBlock(context, {
type: 'subagent_text',
content: chunk,
parentToolCallId,
...spanIdentity,
})
return
}
if (event.payload.channel === MothershipStreamV1TextChannel.thinking) {
if (!context.currentThinkingBlock) {
context.currentThinkingBlock = {
type: 'thinking',
content: '',
timestamp: Date.now(),
}
context.isInThinkingBlock = true
}
context.currentThinkingBlock.content = `${context.currentThinkingBlock.content || ''}${chunk}`
return
}
if (context.isInThinkingBlock) {
flushThinkingBlock(context)
}
context.accumulatedContent += chunk
context.finalAssistantContent += chunk
addContentBlock(context, { type: 'text', content: chunk })
}
}
@@ -0,0 +1,560 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/copilot/async-runs/lifecycle'
import { markAsyncToolDelivered, upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository'
import { STREAM_TIMEOUT_MS } from '@/lib/copilot/constants'
import {
MothershipStreamV1AsyncToolRecordStatus,
type MothershipStreamV1ToolCallDescriptor,
MothershipStreamV1ToolOutcome,
type MothershipStreamV1ToolResultPayload,
} from '@/lib/copilot/generated/mothership-stream-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { withCopilotSpan } from '@/lib/copilot/request/otel'
import {
isToolArgsDeltaStreamEvent,
isToolCallStreamEvent,
isToolResultStreamEvent,
TOOL_CALL_STATUS,
} from '@/lib/copilot/request/session'
import { markToolResultSeen, wasToolResultSeen } from '@/lib/copilot/request/sse-utils'
import { setTerminalToolCallState } from '@/lib/copilot/request/tool-call-state'
import { executeToolAndReport, waitForToolCompletion } from '@/lib/copilot/request/tools/executor'
import type {
ExecutionContext,
OrchestratorOptions,
StreamEvent,
StreamingContext,
ToolCallState,
} from '@/lib/copilot/request/types'
import { getToolEntry, isSimExecuted } from '@/lib/copilot/tool-executor'
import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display'
import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools'
import type { ToolScope } from './types'
import {
abortPendingToolIfStreamDead,
addContentBlock,
emitSyntheticToolResult,
ensureTerminalToolCallState,
flushSubagentThinkingBlock,
flushThinkingBlock,
getScopedParentToolCallId,
getScopedSpanIdentity,
getToolCallUI,
getToolResultErrorMessage,
handleClientCompletion,
inferToolSuccess,
registerPendingToolPromise,
} from './types'
const logger = createLogger('CopilotToolHandler')
function applyToolDisplay(toolCall: ToolCallState | undefined): void {
if (!toolCall?.name) return
toolCall.displayTitle = getToolDisplayTitle(
toolCall.name,
toolCall.params as Record<string, unknown> | undefined
)
}
/**
* Upsert the durable `async_tool_calls` row before the authoritative tool-call
* SSE frame is forwarded to the client, so `/api/copilot/confirm` can never
* race ahead of the row that identifies the call. This is the sole
* persistence point for client-executable tools; gating mirrors the
* client-wait branch in `dispatchToolExecution`.
*/
export async function prePersistClientExecutableToolCall(
event: StreamEvent,
context: StreamingContext
): Promise<void> {
if (event.type !== 'tool') return
if (!isToolCallStreamEvent(event)) return
const data = event.payload
const isGenerating = data.status === TOOL_CALL_STATUS.generating
const isPartial = data.partial === true || isGenerating
if (isPartial) return
const ui = getToolCallUI(data)
if (!ui.clientExecutable) return
const catalogEntry = getToolEntry(data.toolName)
const isInternal = ui.internal === true || catalogEntry?.internal === true
if (isInternal) return
const delegateWorkflowRunToClient = isWorkflowToolName(data.toolName)
if (isSimExecuted(data.toolName) && !delegateWorkflowRunToClient) return
if (!context.runId) return
await upsertAsyncToolCall({
runId: context.runId,
toolCallId: data.toolCallId,
toolName: data.toolName,
args: data.arguments,
status: MothershipStreamV1AsyncToolRecordStatus.running,
}).catch((err) => {
logger.warn('Failed to pre-persist async tool row before forwarding call frame', {
toolCallId: data.toolCallId,
toolName: data.toolName,
error: getErrorMessage(err),
})
})
}
/**
* Unified tool event handler for both main and subagent scopes.
*
* The main vs subagent differences are:
* - Subagent requires a parentToolCallId and tracks tool calls in subAgentToolCalls
* - Subagent result phase also updates the subAgentToolCalls record
* - Subagent call phase stores in both subAgentToolCalls and context.toolCalls
* - Main call phase only stores in context.toolCalls
*/
export async function handleToolEvent(
event: StreamEvent,
context: StreamingContext,
execContext: ExecutionContext,
options: OrchestratorOptions,
scope: ToolScope
): Promise<void> {
const isSubagent = scope === 'subagent'
const parentToolCallId = isSubagent ? getScopedParentToolCallId(event, context) : undefined
if (isSubagent && !parentToolCallId) return
if (event.type !== 'tool') {
return
}
if (isToolArgsDeltaStreamEvent(event)) {
return
}
// A tool event breaks the thinking stream. Flush any open thinking
// block into contentBlocks BEFORE we add the tool_call block, or
// contentBlocks will end up with tool_call before thinking — which
// re-renders on reload in the wrong order (Mothership group above
// the Thinking block, even though thinking happened first). A subagent
// tool event flushes only its OWN lane so a concurrent sibling's thinking
// is left intact; a main tool event flushes all subagent lanes.
if (isSubagent && parentToolCallId) {
flushSubagentThinkingBlock(context, parentToolCallId)
} else {
flushSubagentThinkingBlock(context)
}
flushThinkingBlock(context)
if (isToolResultStreamEvent(event)) {
handleResultPhase(event.payload, context, parentToolCallId)
return
}
if (!isToolCallStreamEvent(event)) {
return
}
if (!parentToolCallId) {
context.sawMainToolCall = true
context.finalAssistantContent = ''
}
await handleCallPhase(
event.payload,
context,
execContext,
options,
parentToolCallId,
scope,
getScopedSpanIdentity(event)
)
}
function handleResultPhase(
data: MothershipStreamV1ToolResultPayload,
context: StreamingContext,
parentToolCallId: string | undefined
): void {
const { toolCallId, toolName } = data
const mainToolCall = ensureTerminalToolCallState(context, toolCallId, toolName)
const { success, hasResultData } = inferToolSuccess(data)
let status: MothershipStreamV1ToolOutcome
if (data.status === MothershipStreamV1ToolOutcome.cancelled) {
status = MothershipStreamV1ToolOutcome.cancelled
} else if (data.status === MothershipStreamV1ToolOutcome.skipped) {
status = MothershipStreamV1ToolOutcome.skipped
} else if (data.status === MothershipStreamV1ToolOutcome.rejected) {
status = MothershipStreamV1ToolOutcome.rejected
} else {
status = success ? MothershipStreamV1ToolOutcome.success : MothershipStreamV1ToolOutcome.error
}
const endTime = Date.now()
const errorMessage =
!success && status !== MothershipStreamV1ToolOutcome.skipped
? getToolResultErrorMessage(data) ||
(status === MothershipStreamV1ToolOutcome.cancelled
? 'Tool cancelled'
: status === MothershipStreamV1ToolOutcome.rejected
? 'Tool rejected'
: 'Tool failed')
: undefined
if (parentToolCallId) {
const toolCalls = context.subAgentToolCalls[parentToolCallId] || []
const subAgentToolCall = toolCalls.find((tc) => tc.id === toolCallId)
if (subAgentToolCall) {
setTerminalToolCallState(subAgentToolCall, {
status,
...(hasResultData ? { output: data.output } : {}),
...(errorMessage ? { error: errorMessage } : {}),
endTime,
})
}
}
setTerminalToolCallState(mainToolCall, {
status,
...(hasResultData ? { output: data.output } : {}),
...(errorMessage ? { error: errorMessage } : {}),
endTime,
})
stampToolCallBlockEnd(context, toolCallId, endTime)
markToolResultSeen(toolCallId)
}
function stampToolCallBlockEnd(
context: StreamingContext,
toolCallId: string,
endTime: number
): void {
for (let i = context.contentBlocks.length - 1; i >= 0; i--) {
const block = context.contentBlocks[i]
if (block.type === 'tool_call' && block.toolCall?.id === toolCallId) {
if (block.endedAt === undefined) block.endedAt = endTime
return
}
}
}
async function handleCallPhase(
data: MothershipStreamV1ToolCallDescriptor,
context: StreamingContext,
execContext: ExecutionContext,
options: OrchestratorOptions,
parentToolCallId: string | undefined,
scope: ToolScope,
spanIdentity: { spanId?: string; parentSpanId?: string }
): Promise<void> {
const { toolCallId, toolName } = data
const args = data.arguments
const isGenerating = data.status === TOOL_CALL_STATUS.generating
const isPartial = data.partial === true || isGenerating
const existing = context.toolCalls.get(toolCallId)
const isSubagent = scope === 'subagent'
const ui = getToolCallUI(data)
if (isPartial && shouldDelayVfsPlaceholder(toolName, args)) return
if (isSubagent) {
if (wasToolResultSeen(toolCallId) || existing?.endTime) {
if (existing && !existing.name && toolName) existing.name = toolName
if (existing && !existing.params && args) existing.params = args
applyToolDisplay(existing)
return
}
} else {
if (
existing?.endTime ||
(existing && existing.status !== 'pending' && existing.status !== 'executing')
) {
if (!existing.name && toolName) existing.name = toolName
if (!existing.params && args) existing.params = args
applyToolDisplay(existing)
return
}
}
if (isSubagent) {
registerSubagentToolCall(
context,
toolCallId,
toolName,
args,
parentToolCallId!,
ui,
spanIdentity
)
} else {
registerMainToolCall(context, toolCallId, toolName, args, existing, ui)
}
if (isPartial) return
if (!isSubagent && wasToolResultSeen(toolCallId)) return
if (context.pendingToolPromises.has(toolCallId) || existing?.status === 'executing') {
return
}
const toolCall = context.toolCalls.get(toolCallId)
if (!toolCall) return
// Capture the invoking subagent's channel id so the executor can thread it
// into the server tool context — this is what scopes the workspace_file ->
// edit_content intent handoff to one file subagent under concurrency.
if (parentToolCallId) toolCall.parentToolCallId = parentToolCallId
const readPath = typeof args?.path === 'string' ? args.path : undefined
if (toolName === 'read' && readPath?.startsWith('internal/')) return
const { clientExecutable, simExecutable, internal } = ui
const catalogEntry = getToolEntry(toolName)
const isInternal = internal || catalogEntry?.internal === true
const staticSimExecuted = isSimExecuted(toolName)
const willDispatch = !isInternal && (staticSimExecuted || simExecutable || clientExecutable)
logger.info('Tool call routing decision', {
toolCallId,
toolName,
scope,
isSubagent,
parentToolCallId,
executor: data.executor,
clientExecutable,
simExecutable,
staticSimExecuted,
internal: isInternal,
hasPendingPromise: context.pendingToolPromises.has(toolCallId),
existingStatus: existing?.status,
willDispatch,
})
if (isInternal) return
if (!willDispatch) return
await dispatchToolExecution(
toolCall,
toolCallId,
toolName,
args,
context,
execContext,
options,
clientExecutable,
scope
)
}
function shouldDelayVfsPlaceholder(
toolName: string,
args: Record<string, unknown> | undefined
): boolean {
return (toolName === 'read' || toolName === 'glob') && !args
}
function removeToolCallContentBlock(context: StreamingContext, toolCallId: string): void {
for (let i = context.contentBlocks.length - 1; i >= 0; i--) {
const block = context.contentBlocks[i]
if (block.type === 'tool_call' && block.toolCall?.id === toolCallId) {
context.contentBlocks.splice(i, 1)
}
}
}
function registerSubagentToolCall(
context: StreamingContext,
toolCallId: string,
toolName: string,
args: Record<string, unknown> | undefined,
parentToolCallId: string,
ui: { title?: string; phaseLabel?: string; hidden?: boolean },
spanIdentity: { spanId?: string; parentSpanId?: string }
): void {
if (!context.subAgentToolCalls[parentToolCallId]) {
context.subAgentToolCalls[parentToolCallId] = []
}
const hideFromUi = isToolHiddenInUi(toolName) || ui.hidden === true
let toolCall = context.toolCalls.get(toolCallId)
if (toolCall) {
if (!toolCall.name && toolName) toolCall.name = toolName
if (args && !toolCall.params) toolCall.params = args
applyToolDisplay(toolCall)
if (hideFromUi) removeToolCallContentBlock(context, toolCallId)
} else {
toolCall = {
id: toolCallId,
name: toolName,
status: 'pending',
params: args,
startTime: Date.now(),
}
applyToolDisplay(toolCall)
context.toolCalls.set(toolCallId, toolCall)
const parentToolCall = context.toolCalls.get(parentToolCallId)
if (!hideFromUi) {
addContentBlock(context, {
type: 'tool_call',
toolCall,
calledBy: parentToolCall?.name,
parentToolCallId,
...spanIdentity,
})
}
}
const subagentToolCalls = context.subAgentToolCalls[parentToolCallId]
const existingSubagentToolCall = subagentToolCalls.find((tc) => tc.id === toolCallId)
if (existingSubagentToolCall) {
if (!existingSubagentToolCall.name && toolName) existingSubagentToolCall.name = toolName
if (args && !existingSubagentToolCall.params) existingSubagentToolCall.params = args
applyToolDisplay(existingSubagentToolCall)
} else {
subagentToolCalls.push(toolCall)
}
}
function registerMainToolCall(
context: StreamingContext,
toolCallId: string,
toolName: string,
args: Record<string, unknown> | undefined,
existing: ToolCallState | undefined,
ui: { title?: string; phaseLabel?: string; hidden?: boolean }
): void {
const hideFromUi = isToolHiddenInUi(toolName) || ui.hidden === true
if (existing) {
if (args && !existing.params) existing.params = args
applyToolDisplay(existing)
if (hideFromUi) {
removeToolCallContentBlock(context, toolCallId)
return
}
if (
!hideFromUi &&
!context.contentBlocks.some((b) => b.type === 'tool_call' && b.toolCall?.id === toolCallId)
) {
addContentBlock(context, { type: 'tool_call', toolCall: existing })
}
} else {
const created: ToolCallState = {
id: toolCallId,
name: toolName,
status: 'pending',
params: args,
startTime: Date.now(),
}
applyToolDisplay(created)
context.toolCalls.set(toolCallId, created)
if (!hideFromUi) {
addContentBlock(context, { type: 'tool_call', toolCall: created })
}
}
}
async function dispatchToolExecution(
toolCall: ToolCallState,
toolCallId: string,
toolName: string,
args: Record<string, unknown> | undefined,
context: StreamingContext,
execContext: ExecutionContext,
options: OrchestratorOptions,
clientExecutable: boolean,
scope: ToolScope
): Promise<void> {
const scopeLabel = scope === 'subagent' ? 'subagent ' : ''
const fireToolExecution = () => {
const pendingPromise = (async () => {
return executeToolAndReport(toolCallId, context, execContext, options)
})().catch((err) => {
logger.error(`Parallel ${scopeLabel}tool execution failed`, {
toolCallId,
toolName,
error: toError(err).message,
})
return {
status: MothershipStreamV1ToolOutcome.error,
message: 'Tool execution failed',
data: { error: 'Tool execution failed' },
}
})
registerPendingToolPromise(context, toolCallId, pendingPromise)
}
if (options.interactive === false) {
if (options.autoExecuteTools !== false) {
if (!abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) {
fireToolExecution()
}
}
return
}
if (clientExecutable) {
const delegateWorkflowRunToClient = isWorkflowToolName(toolName)
if (isSimExecuted(toolName) && !delegateWorkflowRunToClient) {
if (!abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) {
fireToolExecution()
}
} else {
toolCall.status = 'executing'
const pendingPromise = withCopilotSpan(
TraceSpan.CopilotToolWaitForClientResult,
{
[TraceAttr.ToolName]: toolName,
[TraceAttr.ToolCallId]: toolCallId,
[TraceAttr.ToolTimeoutMs]: options.timeout || STREAM_TIMEOUT_MS,
...(context.runId ? { [TraceAttr.RunId]: context.runId } : {}),
},
async (span) => {
const completion = await waitForToolCompletion(
toolCallId,
options.timeout || STREAM_TIMEOUT_MS,
options.abortSignal
)
span.setAttribute(TraceAttr.ToolCompletionReceived, completion !== undefined)
if (completion) {
span.setAttribute(TraceAttr.ToolOutcome, completion.status)
}
handleClientCompletion(toolCall, toolCallId, completion)
if (completion?.status === ASYNC_TOOL_CONFIRMATION_STATUS.background) {
await markAsyncToolDelivered(toolCallId).catch((err) => {
logger.warn(`Failed to mark background ${scopeLabel}tool delivered`, {
toolCallId,
toolName,
error: toError(err).message,
})
})
}
await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options)
return (
completion ?? {
status: MothershipStreamV1ToolOutcome.error,
message: 'Tool completion missing',
data: { error: 'Tool completion missing' },
}
)
}
).catch((err) => {
logger.error(`Client-executable ${scopeLabel}tool wait failed`, {
toolCallId,
toolName,
error: toError(err).message,
})
return {
status: MothershipStreamV1ToolOutcome.error,
message: 'Tool wait failed',
data: { error: 'Tool wait failed' },
}
})
registerPendingToolPromise(context, toolCallId, pendingPromise)
}
return
}
if (options.autoExecuteTools !== false) {
if (!abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) {
fireToolExecution()
}
}
}
@@ -0,0 +1,317 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { isRecordLike } from '@sim/utils/object'
import type {
AsyncCompletionSignal,
AsyncTerminalCompletionSnapshot,
} from '@/lib/copilot/async-runs/lifecycle'
import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/copilot/async-runs/lifecycle'
import {
MothershipStreamV1EventType,
type MothershipStreamV1StreamScope,
type MothershipStreamV1ToolCallDescriptor,
MothershipStreamV1ToolExecutor,
MothershipStreamV1ToolMode,
MothershipStreamV1ToolOutcome,
MothershipStreamV1ToolPhase,
type MothershipStreamV1ToolResultPayload,
} from '@/lib/copilot/generated/mothership-stream-v1'
import { asRecord, markToolResultSeen } from '@/lib/copilot/request/sse-utils'
import { setTerminalToolCallState } from '@/lib/copilot/request/tool-call-state'
import type {
ContentBlock,
ExecutionContext,
OrchestratorOptions,
StreamEvent,
StreamingContext,
ToolCallState,
} from '@/lib/copilot/request/types'
export type StreamHandler = (
event: StreamEvent,
context: StreamingContext,
execContext: ExecutionContext,
options: OrchestratorOptions
) => void | Promise<void>
export type ToolScope = 'main' | MothershipStreamV1StreamScope['lane']
const logger = createLogger('CopilotHandlerHelpers')
export function addContentBlock(
context: StreamingContext,
block: Omit<ContentBlock, 'timestamp'>
): void {
context.contentBlocks.push({
...block,
timestamp: Date.now(),
})
}
export function stampBlockEnd(block: ContentBlock): void {
if (block.endedAt === undefined) block.endedAt = Date.now()
}
/**
* Flush any open thinking block into contentBlocks and clear the thinking state.
* Safe to call repeatedly.
*/
export function flushThinkingBlock(context: StreamingContext): void {
if (context.currentThinkingBlock) {
stampBlockEnd(context.currentThinkingBlock)
context.contentBlocks.push(context.currentThinkingBlock)
}
context.isInThinkingBlock = false
context.currentThinkingBlock = null
}
/**
* Flush open subagent thinking blocks into contentBlocks. With a parentToolCallId
* it flushes only that lane (used when a tool/text event arrives for a specific
* subagent); with no argument it flushes ALL open lanes (used at stream end and
* at subagent lifecycle boundaries). Safe to call repeatedly.
*/
export function flushSubagentThinkingBlock(
context: StreamingContext,
parentToolCallId?: string
): void {
if (parentToolCallId !== undefined) {
const block = context.subagentThinkingBlocks.get(parentToolCallId)
if (block) {
stampBlockEnd(block)
context.contentBlocks.push(block)
context.subagentThinkingBlocks.delete(parentToolCallId)
}
return
}
for (const block of context.subagentThinkingBlocks.values()) {
stampBlockEnd(block)
context.contentBlocks.push(block)
}
context.subagentThinkingBlocks.clear()
}
/**
* Resolve the subagent lane an event belongs to, using ONLY the event's own
* scope. The legacy fallback to a single "current subagent" pointer was removed:
* with concurrent subagents that pointer reflects whichever subagent started
* most recently and would mis-attribute interleaved events. Every subagent-lane
* event is guaranteed to carry parentToolCallId (Go stamps it), so a missing one
* is a real contract violation — callers warn and drop rather than guess.
*/
export function getScopedParentToolCallId(
event: StreamEvent,
_context: StreamingContext
): string | undefined {
return event.scope?.parentToolCallId
}
/**
* Extract the deterministic span identity from an event's scope. Returns an
* empty object for legacy events that predate span identity so callers can
* spread it safely and fall back to `parentToolCallId`-based grouping.
*/
export function getScopedSpanIdentity(event: StreamEvent): {
spanId?: string
parentSpanId?: string
} {
const spanId = event.scope?.spanId
const parentSpanId = event.scope?.parentSpanId
return {
...(spanId ? { spanId } : {}),
...(parentSpanId ? { parentSpanId } : {}),
}
}
export function registerPendingToolPromise(
context: StreamingContext,
toolCallId: string,
pendingPromise: Promise<AsyncCompletionSignal>
): void {
context.pendingToolPromises.set(toolCallId, pendingPromise)
pendingPromise.finally(() => {
if (context.pendingToolPromises.get(toolCallId) === pendingPromise) {
context.pendingToolPromises.delete(toolCallId)
}
})
}
/**
* When the Sim->Go stream is aborted, avoid starting server-side tool work and
* unblock the Go async waiter with a terminal 499 completion.
*/
export function abortPendingToolIfStreamDead(
toolCall: ToolCallState,
toolCallId: string,
options: OrchestratorOptions,
context: StreamingContext
): boolean {
if (!options.abortSignal?.aborted && !context.wasAborted) {
return false
}
toolCall.status = MothershipStreamV1ToolOutcome.cancelled
toolCall.endTime = Date.now()
markToolResultSeen(toolCallId)
const toolSpan = context.trace.startSpan(toolCall.name || 'unknown_tool', 'tool.execute', {
toolCallId,
toolName: toolCall.name,
cancelReason: 'stream_dead_before_dispatch',
abortSignalAborted: options.abortSignal?.aborted ?? false,
abortReason: options.abortSignal?.aborted
? String(options.abortSignal.reason ?? 'unknown')
: undefined,
wasAborted: context.wasAborted ?? false,
})
context.trace.endSpan(toolSpan, 'cancelled')
return true
}
/**
* Extract the behavioral `ui` flags from a typed tool_call payload. The Go
* backend enriches tool_call events with `ui: { clientExecutable, internal,
* hidden }`; presentation (title/icon) is derived client-side from the tool name.
*/
export function getToolCallUI(data: MothershipStreamV1ToolCallDescriptor): {
clientExecutable: boolean
simExecutable: boolean
internal: boolean
hidden: boolean
} {
const raw = asRecord(data.ui)
return {
clientExecutable:
raw.clientExecutable === true || data.executor === MothershipStreamV1ToolExecutor.client,
simExecutable: data.executor === MothershipStreamV1ToolExecutor.sim,
internal: raw.internal === true,
hidden: raw.hidden === true,
}
}
/**
* Handle the completion signal from a client-executable tool.
* Shared by both main and subagent scopes.
*/
export function handleClientCompletion(
toolCall: ToolCallState,
toolCallId: string,
completion: AsyncTerminalCompletionSnapshot | null
): void {
if (completion?.status === ASYNC_TOOL_CONFIRMATION_STATUS.background) {
setTerminalToolCallState(toolCall, {
status: MothershipStreamV1ToolOutcome.skipped,
...(completion.data !== undefined ? { output: completion.data } : {}),
})
markToolResultSeen(toolCallId)
return
}
if (completion?.status === MothershipStreamV1ToolOutcome.cancelled) {
setTerminalToolCallState(toolCall, {
status: MothershipStreamV1ToolOutcome.cancelled,
...(completion.data !== undefined ? { output: completion.data } : {}),
error: completion.message || 'Tool cancelled',
})
markToolResultSeen(toolCallId)
return
}
const success = completion?.status === MothershipStreamV1ToolOutcome.success
setTerminalToolCallState(toolCall, {
status: success ? MothershipStreamV1ToolOutcome.success : MothershipStreamV1ToolOutcome.error,
...(completion?.data !== undefined ? { output: completion.data } : {}),
...(success ? {} : { error: completion?.message || 'Tool failed' }),
})
markToolResultSeen(toolCallId)
}
/**
* Emit a synthetic tool_result SSE event to the client after a client-executable
* tool completes. The Go backend's actual tool_result is skipped (markToolResultSeen),
* so the client would never learn the outcome without this.
*/
export async function emitSyntheticToolResult(
toolCallId: string,
toolName: string,
completion: AsyncTerminalCompletionSnapshot | null,
options: OrchestratorOptions
): Promise<void> {
const isBackground = completion?.status === ASYNC_TOOL_CONFIRMATION_STATUS.background
const success = isBackground || completion?.status === MothershipStreamV1ToolOutcome.success
const isCancelled = completion?.status === MothershipStreamV1ToolOutcome.cancelled
const completionData = completion?.data
const syntheticStatus = isBackground
? MothershipStreamV1ToolOutcome.skipped
: completion?.status === MothershipStreamV1ToolOutcome.success ||
completion?.status === MothershipStreamV1ToolOutcome.error ||
completion?.status === MothershipStreamV1ToolOutcome.cancelled
? completion.status
: undefined
const resultPayload = isCancelled
? isRecordLike(completionData)
? { ...completionData, reason: 'user_cancelled', cancelledByUser: true }
: completionData !== undefined
? { output: completionData, reason: 'user_cancelled', cancelledByUser: true }
: { reason: 'user_cancelled', cancelledByUser: true }
: completionData
try {
await options.onEvent?.({
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId,
toolName,
executor: MothershipStreamV1ToolExecutor.client,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.result,
success,
output: resultPayload,
...(syntheticStatus ? { status: syntheticStatus } : {}),
...(!success && completion?.message ? { error: completion.message } : {}),
},
})
} catch (error) {
logger.warn('Failed to emit synthetic tool_result', {
toolCallId,
toolName,
error: toError(error).message,
})
}
}
export function getToolResultErrorMessage(
data: MothershipStreamV1ToolResultPayload | undefined
): string | undefined {
return data?.error
}
export function inferToolSuccess(data: MothershipStreamV1ToolResultPayload | undefined): {
success: boolean
hasResultData: boolean
hasError: boolean
} {
const errorMessage = getToolResultErrorMessage(data)
const hasResultData = data?.output !== undefined
const hasError = Boolean(errorMessage)
const success = data?.success === true
return { success, hasResultData, hasError }
}
export function ensureTerminalToolCallState(
context: StreamingContext,
toolCallId: string,
toolName: string
): ToolCallState {
const existing = context.toolCalls.get(toolCallId)
if (existing) {
return existing
}
const toolCall: ToolCallState = {
id: toolCallId,
name: toolName || 'unknown_tool',
status: 'pending',
startTime: Date.now(),
}
context.toolCalls.set(toolCallId, toolCall)
addContentBlock(context, { type: 'tool_call', toolCall })
return toolCall
}