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
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user