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,33 @@
import { generateId } from '@sim/utils/id'
import { TraceCollector } from '@/lib/copilot/request/trace'
import type { StreamingContext } from '@/lib/copilot/request/types'
/**
* Create a fresh StreamingContext.
*/
export function createStreamingContext(overrides?: Partial<StreamingContext>): StreamingContext {
return {
chatId: undefined,
executionId: undefined,
runId: undefined,
messageId: generateId(),
accumulatedContent: '',
finalAssistantContent: '',
sawMainToolCall: false,
contentBlocks: [],
toolCalls: new Map(),
pendingToolPromises: new Map(),
currentThinkingBlock: null,
subagentThinkingBlocks: new Map(),
isInThinkingBlock: false,
subAgentContent: {},
subAgentToolCalls: {},
pendingContent: '',
streamComplete: false,
wasAborted: false,
errors: [],
activeFileIntents: new Map(),
trace: new TraceCollector(),
...overrides,
}
}
@@ -0,0 +1,97 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1'
import { FunctionExecute } from '@/lib/copilot/generated/tool-catalog-v1'
import { buildToolCallSummaries } from '@/lib/copilot/request/context/result'
import { TraceCollector } from '@/lib/copilot/request/trace'
import type { StreamingContext } from '@/lib/copilot/request/types'
function makeContext(): StreamingContext {
return {
chatId: undefined,
requestId: undefined,
executionId: undefined,
runId: undefined,
messageId: 'msg-1',
accumulatedContent: '',
finalAssistantContent: '',
sawMainToolCall: false,
contentBlocks: [],
toolCalls: new Map(),
pendingToolPromises: new Map(),
awaitingAsyncContinuation: undefined,
currentThinkingBlock: null,
subagentThinkingBlocks: new Map(),
isInThinkingBlock: false,
subAgentContent: {},
subAgentToolCalls: {},
pendingContent: '',
streamComplete: false,
wasAborted: false,
errors: [],
trace: new TraceCollector(),
}
}
describe('buildToolCallSummaries', () => {
it.concurrent('keeps pending tools as pending instead of defaulting to success', () => {
const context = makeContext()
context.toolCalls.set('tool-1', {
id: 'tool-1',
name: 'download_to_workspace_file',
status: 'pending',
startTime: 1,
})
const summaries = buildToolCallSummaries(context)
expect(summaries).toHaveLength(1)
expect(summaries[0]?.status).toBe('pending')
})
it.concurrent('keeps executing tools as executing when no result exists yet', () => {
const context = makeContext()
context.toolCalls.set('tool-2', {
id: 'tool-2',
name: FunctionExecute.id,
status: 'executing',
startTime: 1,
})
const summaries = buildToolCallSummaries(context)
expect(summaries).toHaveLength(1)
expect(summaries[0]?.status).toBe('executing')
})
it.concurrent(
'preserves canonical terminal statuses instead of deriving them from result success',
() => {
const context = makeContext()
context.toolCalls.set('tool-3', {
id: 'tool-3',
name: 'download_to_workspace_file',
status: MothershipStreamV1ToolOutcome.cancelled,
result: { success: false },
error: 'Stopped by user',
startTime: 1,
endTime: 2,
})
const summaries = buildToolCallSummaries(context)
expect(summaries).toHaveLength(1)
expect(summaries[0]).toEqual({
id: 'tool-3',
name: 'download_to_workspace_file',
status: MothershipStreamV1ToolOutcome.cancelled,
params: undefined,
result: undefined,
error: 'Stopped by user',
durationMs: 1,
})
}
)
})
@@ -0,0 +1,18 @@
import { getToolCallStateOutput } from '@/lib/copilot/request/tool-call-state'
import type { StreamingContext, ToolCallSummary } from '@/lib/copilot/request/types'
/**
* Build a ToolCallSummary array from the streaming context.
*/
export function buildToolCallSummaries(context: StreamingContext): ToolCallSummary[] {
return Array.from(context.toolCalls.values()).map((toolCall) => ({
id: toolCall.id,
name: toolCall.name,
status: toolCall.status,
params: toolCall.params,
result: getToolCallStateOutput(toolCall),
error: toolCall.error,
durationMs:
toolCall.endTime && toolCall.startTime ? toolCall.endTime - toolCall.startTime : undefined,
}))
}
@@ -0,0 +1,79 @@
import { trace } from '@opentelemetry/api'
import {
BasicTracerProvider,
InMemorySpanExporter,
SimpleSpanProcessor,
} from '@opentelemetry/sdk-trace-base'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
describe('fetchGo', () => {
const exporter = new InMemorySpanExporter()
const provider = new BasicTracerProvider({
spanProcessors: [new SimpleSpanProcessor(exporter)],
})
beforeEach(() => {
exporter.reset()
trace.setGlobalTracerProvider(provider)
vi.restoreAllMocks()
})
it('emits a client span with http.* attrs and injects traceparent', async () => {
const fetchMock = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => {
const headers = init.headers as Record<string, string>
expect(headers.traceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-0[0-9a-f]$/)
return new Response('ok', {
status: 200,
headers: { 'content-length': '2' },
})
})
vi.stubGlobal('fetch', fetchMock)
const res = await fetchGo('https://backend.example.com/api/copilot', {
method: 'POST',
body: 'payload',
operation: 'stream',
attributes: { 'copilot.leg': 'sim_to_go' },
})
expect(res.status).toBe(200)
const spans = exporter.getFinishedSpans()
expect(spans).toHaveLength(1)
const attrs = spans[0].attributes
expect(spans[0].name).toBe('sim → go /api/copilot')
expect(attrs['http.method']).toBe('POST')
expect(attrs['http.url']).toBe('https://backend.example.com/api/copilot')
expect(attrs['http.target']).toBe('/api/copilot')
expect(attrs['http.status_code']).toBe(200)
expect(attrs['copilot.operation']).toBe('stream')
expect(attrs['copilot.leg']).toBe('sim_to_go')
expect(typeof attrs['http.response.headers_ms']).toBe('number')
})
it('marks span as error on non-2xx response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('nope', { status: 500 })))
const res = await fetchGo('https://backend.example.com/api/tools/resume', {
method: 'POST',
})
expect(res.status).toBe(500)
const spans = exporter.getFinishedSpans()
expect(spans).toHaveLength(1)
expect(spans[0].status.code).toBe(2)
})
it('records exceptions when fetch throws', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network boom')))
await expect(
fetchGo('https://backend.example.com/api/traces', { method: 'POST' })
).rejects.toThrow('network boom')
const spans = exporter.getFinishedSpans()
expect(spans).toHaveLength(1)
expect(spans[0].status.code).toBe(2)
expect(spans[0].events.some((e) => e.name === 'exception')).toBe(true)
})
})
+112
View File
@@ -0,0 +1,112 @@
import { type Context, context, SpanStatusCode, trace } from '@opentelemetry/api'
import { CopilotLeg } from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { traceHeaders } from '@/lib/copilot/request/go/propagation'
import { isActionableErrorStatus, markSpanForError } from '@/lib/copilot/request/otel'
// Lazy tracer resolution: module-level `trace.getTracer()` can be evaluated
// before `instrumentation-node.ts` installs the TracerProvider under
// Next.js 16 + Turbopack dev, freezing a NoOp tracer and silently dropping
// every outbound Sim → Go span. Resolving per-call avoids the race.
const getTracer = () => trace.getTracer('sim-copilot-http', '1.0.0')
interface OutboundFetchOptions extends RequestInit {
otelContext?: Context
spanName?: string
operation?: string
attributes?: Record<string, string | number | boolean>
}
/**
* Perform an outbound Sim → Go fetch wrapped in an OTel child span so each
* call shows up as a distinct segment in Jaeger, and propagates the W3C
* traceparent so the Go-side span joins the same trace.
*
* The span captures generic attributes (method, status, duration, response
* size, error code) so any future latency investigation — not just images or
* Bedrock — has uniform metadata to work with.
*/
export async function fetchGo(url: string, options: OutboundFetchOptions = {}): Promise<Response> {
const {
otelContext,
spanName,
operation,
attributes,
headers: providedHeaders,
...init
} = options
const parsed = safeParseUrl(url)
const pathname = parsed?.pathname ?? url
const method = (init.method ?? 'GET').toUpperCase()
const parentContext = otelContext ?? context.active()
const span = getTracer().startSpan(
spanName ?? `sim → go ${pathname}`,
{
attributes: {
[TraceAttr.HttpMethod]: method,
[TraceAttr.HttpUrl]: url,
[TraceAttr.HttpTarget]: pathname,
[TraceAttr.NetPeerName]: parsed?.host ?? '',
[TraceAttr.CopilotLeg]: CopilotLeg.SimToGo,
...(operation ? { [TraceAttr.CopilotOperation]: operation } : {}),
...(attributes ?? {}),
},
},
parentContext
)
const activeContext = trace.setSpan(parentContext, span)
const propagatedHeaders = traceHeaders({}, activeContext)
const mergedHeaders = {
...(providedHeaders as Record<string, string> | undefined),
...propagatedHeaders,
}
const start = performance.now()
try {
const response = await context.with(activeContext, () =>
fetch(url, {
...init,
method,
headers: mergedHeaders,
})
)
const elapsedMs = performance.now() - start
const contentLength = Number(response.headers.get('content-length') ?? 0)
span.setAttribute(TraceAttr.HttpStatusCode, response.status)
span.setAttribute(TraceAttr.HttpResponseHeadersMs, Math.round(elapsedMs))
if (contentLength > 0) {
span.setAttribute(TraceAttr.HttpResponseContentLength, contentLength)
}
// Only mark ERROR for actionable status codes. 4xx that represent
// normal auth/validation rejections (400/401/403/404/405/422/etc.)
// stay UNSET so error dashboards don't drown in expected rejection
// paths. See `isActionableErrorStatus` in Go's telemetry middleware
// for the mirror rule (5xx + 402/409/429).
if (isActionableErrorStatus(response.status)) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: `HTTP ${response.status}`,
})
} else {
span.setStatus({ code: SpanStatusCode.OK })
}
return response
} catch (error) {
span.setAttribute(TraceAttr.HttpResponseHeadersMs, Math.round(performance.now() - start))
markSpanForError(span, error)
throw error
} finally {
span.end()
}
}
function safeParseUrl(url: string): URL | null {
try {
return new URL(url)
} catch {
return null
}
}
@@ -0,0 +1,761 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1'
import {
createFilePreviewSession,
type FilePreviewContentMode,
type FilePreviewSession,
type FilePreviewTargetKind,
isToolArgsDeltaStreamEvent,
isToolCallStreamEvent,
isToolResultStreamEvent,
type SyntheticFilePreviewPayload,
upsertFilePreviewSession,
} from '@/lib/copilot/request/session'
import type {
ActiveFileIntent,
ExecutionContext,
OrchestratorOptions,
StreamEvent,
StreamingContext,
} from '@/lib/copilot/request/types'
import { peekFileIntent } from '@/lib/copilot/tools/server/files/file-intent-store'
import {
buildFilePreviewText,
loadWorkspaceFileTextForPreview,
} from '@/lib/copilot/tools/server/files/file-preview'
import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
const logger = createLogger('CopilotFilePreviewAdapter')
type JsonRecord = Record<string, unknown>
type FileIntent = ActiveFileIntent
type EditContentStreamState = {
raw: string
lastContentSnapshot?: string
}
type FilePreviewStreamState = {
session: FilePreviewSession
lastEmittedPreviewText: string
lastSnapshotAt: number
}
type ParsedWorkspaceFileArgs = {
operation: string
target: FileIntent['target']
title?: string
contentType?: string
edit?: JsonRecord
}
const PATCH_PREVIEW_SNAPSHOT_INTERVAL_MS = 80
const DELTA_PREVIEW_CHECKPOINT_INTERVAL_MS = 1000
function asJsonRecord(value: unknown): JsonRecord | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as JsonRecord)
: undefined
}
function toPreviewTargetKind(kind: string | undefined): FilePreviewTargetKind | undefined {
return kind === 'new_file' || kind === 'file_id' ? kind : undefined
}
async function resolvePreviewTarget(args: {
workspaceId?: string
target: FileIntent['target']
}): Promise<FileIntent['target']> {
if (args.target.kind !== 'path' || !args.workspaceId || !args.target.path) {
return args.target
}
const file = await resolveWorkspaceFileReference(args.workspaceId, args.target.path)
if (!file) {
return args.target
}
return {
kind: 'file_id',
fileId: file.id,
fileName: args.target.fileName ?? file.name,
path: args.target.path,
}
}
function parseWorkspaceFileArgs(value: unknown): ParsedWorkspaceFileArgs | undefined {
const args = asJsonRecord(value)
if (!args) {
return undefined
}
const operation = typeof args.operation === 'string' ? args.operation : undefined
const target = asJsonRecord(args.target)
const targetKind = typeof target?.kind === 'string' ? target.kind : undefined
if (!operation || !target || !targetKind) {
return undefined
}
const fileId = typeof target.fileId === 'string' ? target.fileId : undefined
const fileName = typeof target.fileName === 'string' ? target.fileName : undefined
const path = typeof target.path === 'string' ? target.path : undefined
const title = typeof args.title === 'string' ? args.title : undefined
const contentType = typeof args.contentType === 'string' ? args.contentType : undefined
const edit = asJsonRecord(args.edit)
return {
operation,
target: {
kind: targetKind,
...(fileId ? { fileId } : {}),
...(fileName ? { fileName } : {}),
...(path ? { path } : {}),
},
...(title ? { title } : {}),
...(contentType ? { contentType } : {}),
...(edit ? { edit } : {}),
}
}
function extractWorkspaceFileResult(output: unknown): { fileId?: string; fileName?: string } {
const candidates: JsonRecord[] = []
const root = asJsonRecord(output)
if (root) {
candidates.push(root)
const rootData = asJsonRecord(root.data)
if (rootData) candidates.push(rootData)
const rootOutput = asJsonRecord(root.output)
if (rootOutput) {
candidates.push(rootOutput)
const outputData = asJsonRecord(rootOutput.data)
if (outputData) candidates.push(outputData)
}
}
for (const candidate of candidates) {
const fileId =
typeof candidate.id === 'string'
? candidate.id
: typeof candidate.fileId === 'string'
? candidate.fileId
: undefined
if (!fileId) continue
const fileName =
typeof candidate.name === 'string'
? candidate.name
: typeof candidate.fileName === 'string'
? candidate.fileName
: undefined
return { fileId, fileName }
}
return {}
}
export function decodeJsonStringPrefix(input: string): string {
let output = ''
for (let i = 0; i < input.length; i++) {
const ch = input[i]
if (ch !== '\\') {
output += ch
continue
}
const next = input[i + 1]
if (!next) break
if (next === 'n') {
output += '\n'
i++
continue
}
if (next === 't') {
output += '\t'
i++
continue
}
if (next === 'r') {
output += '\r'
i++
continue
}
if (next === '"') {
output += '"'
i++
continue
}
if (next === '\\') {
output += '\\'
i++
continue
}
if (next === '/') {
output += '/'
i++
continue
}
if (next === 'b') {
output += '\b'
i++
continue
}
if (next === 'f') {
output += '\f'
i++
continue
}
if (next === 'u') {
const hex = input.slice(i + 2, i + 6)
if (hex.length < 4 || !/^[0-9a-fA-F]{4}$/.test(hex)) break
output += String.fromCharCode(Number.parseInt(hex, 16))
i += 5
continue
}
break
}
return output
}
export function extractEditContent(raw: string): string {
const marker = '"content":'
const idx = raw.indexOf(marker)
if (idx === -1) return ''
const rest = raw.slice(idx + marker.length).trimStart()
if (!rest.startsWith('"')) return rest
let end = -1
for (let i = 1; i < rest.length; i++) {
if (rest[i] === '\\') {
i++
continue
}
if (rest[i] === '"') {
end = i
break
}
}
const inner = end === -1 ? rest.slice(1) : rest.slice(1, end)
return decodeJsonStringPrefix(inner)
}
function isContentOperation(
operation: string | undefined
): operation is 'append' | 'update' | 'patch' {
return operation === 'append' || operation === 'update' || operation === 'patch'
}
function isDocFormat(fileName: string | undefined): boolean {
return /\.(pptx|docx|pdf)$/i.test(fileName ?? '')
}
function buildPreviewSessionFromIntent(
streamId: string,
intent: FileIntent,
current?: FilePreviewSession
): FilePreviewSession {
const targetKind = toPreviewTargetKind(intent.target.kind)
return createFilePreviewSession({
streamId,
toolCallId: intent.toolCallId,
fileName: intent.target.fileName ?? current?.fileName,
...(intent.target.fileId ? { fileId: intent.target.fileId } : {}),
...(targetKind ? { targetKind } : {}),
operation: intent.operation,
...(intent.edit ? { edit: intent.edit } : {}),
...(typeof current?.baseContent === 'string' ? { baseContent: current.baseContent } : {}),
previewText: current?.previewText ?? '',
previewVersion: current?.previewVersion ?? 0,
status: current?.status ?? 'pending',
completedAt: current?.completedAt,
})
}
async function persistFilePreviewSession(session: FilePreviewSession): Promise<void> {
try {
await upsertFilePreviewSession(session)
} catch (error) {
logger.warn('Failed to persist file preview session', {
streamId: session.streamId,
toolCallId: session.toolCallId,
previewVersion: session.previewVersion,
error: toError(error).message,
})
}
}
export function buildPreviewContentUpdate(
previousText: string,
nextText: string,
lastSnapshotAt: number,
now: number,
operation: string | undefined
): { content: string; contentMode: FilePreviewContentMode; lastSnapshotAt: number } {
const shouldForceSnapshot =
previousText.length === 0 ||
!nextText.startsWith(previousText) ||
operation === 'patch' ||
operation === 'append' ||
now - lastSnapshotAt >= DELTA_PREVIEW_CHECKPOINT_INTERVAL_MS
if (shouldForceSnapshot) {
return {
content: nextText,
contentMode: 'snapshot',
lastSnapshotAt: now,
}
}
return {
content: nextText.slice(previousText.length),
contentMode: 'delta',
lastSnapshotAt,
}
}
export interface FilePreviewAdapterState {
editContentState: Map<string, EditContentStreamState>
filePreviewState: Map<string, FilePreviewStreamState>
}
export function createFilePreviewAdapterState(): FilePreviewAdapterState {
return {
editContentState: new Map<string, EditContentStreamState>(),
filePreviewState: new Map<string, FilePreviewStreamState>(),
}
}
async function emitPreviewEvent(
streamEvent: StreamEvent,
options: Pick<OrchestratorOptions, 'onEvent'>,
payload: SyntheticFilePreviewPayload
): Promise<void> {
await options.onEvent?.({
type: MothershipStreamV1EventType.tool,
payload,
...(streamEvent.scope ? { scope: streamEvent.scope } : {}),
})
}
export async function processFilePreviewStreamEvent(input: {
streamId: string
streamEvent: StreamEvent
context: StreamingContext
execContext: ExecutionContext
options: Pick<OrchestratorOptions, 'onEvent'>
state: FilePreviewAdapterState
}): Promise<void> {
const { streamId, streamEvent, context, execContext, options, state } = input
const { editContentState, filePreviewState } = state
// Scope the in-flight intent to the invoking file subagent's channel (its
// outer tool_use id) so two file agents streaming concurrently never read or
// overwrite each other's intent. workspace_file and edit_content from the same
// file agent share this channel id, so they pair up; siblings stay isolated.
const channelId = streamEvent.scope?.parentToolCallId ?? ''
const getIntent = (): FileIntent | null => context.activeFileIntents.get(channelId) ?? null
const setIntent = (intent: FileIntent): void => {
context.activeFileIntents.set(channelId, intent)
}
const clearIntent = (): void => {
context.activeFileIntents.delete(channelId)
}
if (isToolCallStreamEvent(streamEvent) && streamEvent.payload.toolName === 'workspace_file') {
const toolCallId = streamEvent.payload.toolCallId
const parsedArgs = parseWorkspaceFileArgs(streamEvent.payload.arguments)
if (toolCallId && parsedArgs) {
const { operation, title, contentType, edit } = parsedArgs
const target = await resolvePreviewTarget({
workspaceId: execContext.workspaceId,
target: parsedArgs.target,
})
const previewTargetKind = toPreviewTargetKind(target.kind)
const { fileId, fileName } = target
const isContentOp = isContentOperation(operation)
// Per-channel: a re-declared workspace_file just overwrites THIS channel's
// slot. No cross-message intent clearing — that would wipe a concurrent
// sibling file agent's pending intent.
const intent: FileIntent = {
toolCallId,
operation,
target,
...(title ? { title } : {}),
...(contentType ? { contentType } : {}),
...(edit ? { edit } : {}),
}
setIntent(intent)
if (isContentOp && previewTargetKind) {
let previewBaseContent: string | undefined
if (
execContext.workspaceId &&
fileId &&
(operation === 'append' || operation === 'patch')
) {
previewBaseContent = await loadWorkspaceFileTextForPreview(
execContext.workspaceId,
fileId
)
}
let session = buildPreviewSessionFromIntent(streamId, intent)
if (previewBaseContent !== undefined) {
session = { ...session, baseContent: previewBaseContent }
}
filePreviewState.set(toolCallId, {
session,
lastEmittedPreviewText: '',
lastSnapshotAt: 0,
})
await persistFilePreviewSession(session)
await emitPreviewEvent(streamEvent, options, {
toolCallId,
toolName: 'workspace_file',
previewPhase: 'file_preview_start',
})
await emitPreviewEvent(streamEvent, options, {
toolCallId,
toolName: 'workspace_file',
previewPhase: 'file_preview_target',
operation,
target: {
kind: previewTargetKind,
...(fileId ? { fileId } : {}),
...(fileName ? { fileName } : {}),
},
...(title ? { title } : {}),
})
if (edit) {
await emitPreviewEvent(streamEvent, options, {
toolCallId,
toolName: 'workspace_file',
previewPhase: 'file_preview_edit_meta',
edit,
})
}
}
}
}
const workspaceResultIntent = getIntent()
if (
isToolResultStreamEvent(streamEvent) &&
streamEvent.payload.toolName === 'workspace_file' &&
workspaceResultIntent &&
isContentOperation(workspaceResultIntent.operation)
) {
const result = extractWorkspaceFileResult(streamEvent.payload.output)
if (result.fileId && workspaceResultIntent.target.kind === 'path') {
const intent: FileIntent = {
...workspaceResultIntent,
target: {
kind: 'file_id',
fileId: result.fileId,
fileName: result.fileName ?? workspaceResultIntent.target.fileName,
path: workspaceResultIntent.target.path,
},
}
setIntent(intent)
let previewBaseContent: string | undefined
if (
execContext.workspaceId &&
(intent.operation === 'append' || intent.operation === 'patch')
) {
previewBaseContent = await loadWorkspaceFileTextForPreview(
execContext.workspaceId,
result.fileId
)
}
let session = buildPreviewSessionFromIntent(streamId, intent)
if (previewBaseContent !== undefined) {
session = { ...session, baseContent: previewBaseContent }
}
filePreviewState.set(intent.toolCallId, {
session,
lastEmittedPreviewText: '',
lastSnapshotAt: 0,
})
await persistFilePreviewSession(session)
await emitPreviewEvent(streamEvent, options, {
toolCallId: intent.toolCallId,
toolName: 'workspace_file',
previewPhase: 'file_preview_start',
})
await emitPreviewEvent(streamEvent, options, {
toolCallId: intent.toolCallId,
toolName: 'workspace_file',
previewPhase: 'file_preview_target',
operation: intent.operation,
target: {
kind: 'file_id',
fileId: result.fileId,
...(result.fileName ? { fileName: result.fileName } : {}),
},
...(intent.title ? { title: intent.title } : {}),
})
if (intent.edit) {
await emitPreviewEvent(streamEvent, options, {
toolCallId: intent.toolCallId,
toolName: 'workspace_file',
previewPhase: 'file_preview_edit_meta',
edit: intent.edit,
})
}
}
}
const patchDeleteIntent = getIntent()
if (
isToolResultStreamEvent(streamEvent) &&
streamEvent.payload.toolName === 'workspace_file' &&
patchDeleteIntent &&
isContentOperation(patchDeleteIntent.operation) &&
patchDeleteIntent.operation === 'patch' &&
patchDeleteIntent.edit?.strategy === 'anchored' &&
patchDeleteIntent.edit?.mode === 'delete_between' &&
execContext.workspaceId &&
patchDeleteIntent.target.fileId &&
!isDocFormat(patchDeleteIntent.target.fileName)
) {
const currentPreview = filePreviewState.get(patchDeleteIntent.toolCallId)
const previewText = buildFilePreviewText({
operation: 'patch',
streamedContent: '',
existingContent: currentPreview?.session.baseContent,
edit: currentPreview?.session.edit,
})
if (previewText !== undefined) {
const baseSession = buildPreviewSessionFromIntent(
streamId,
patchDeleteIntent,
currentPreview?.session
)
const nextSession: FilePreviewSession = {
...baseSession,
status: 'streaming',
previewText,
previewVersion: (currentPreview?.session.previewVersion ?? 0) + 1,
updatedAt: new Date().toISOString(),
}
filePreviewState.set(patchDeleteIntent.toolCallId, {
session: nextSession,
lastEmittedPreviewText: previewText,
lastSnapshotAt: Date.now(),
})
await persistFilePreviewSession(nextSession)
await emitPreviewEvent(streamEvent, options, {
toolCallId: nextSession.toolCallId,
toolName: 'workspace_file',
previewPhase: 'file_preview_content',
content: previewText,
contentMode: 'snapshot',
previewVersion: nextSession.previewVersion,
fileName: nextSession.fileName,
...(nextSession.fileId ? { fileId: nextSession.fileId } : {}),
...(nextSession.targetKind ? { targetKind: nextSession.targetKind } : {}),
...(nextSession.operation ? { operation: nextSession.operation } : {}),
...(nextSession.edit ? { edit: nextSession.edit } : {}),
})
}
}
if (isToolArgsDeltaStreamEvent(streamEvent) && streamEvent.payload.toolName === 'edit_content') {
const toolCallId = streamEvent.payload.toolCallId
const delta = streamEvent.payload.argumentsDelta
const stateForTool = editContentState.get(toolCallId) ?? { raw: '' }
stateForTool.raw += delta
const editIntent = getIntent()
if (editIntent) {
const streamedContent = extractEditContent(stateForTool.raw)
if (streamedContent !== (stateForTool.lastContentSnapshot ?? '')) {
stateForTool.lastContentSnapshot = streamedContent
let currentPreview = filePreviewState.get(editIntent.toolCallId) ?? {
session: buildPreviewSessionFromIntent(streamId, editIntent),
lastEmittedPreviewText: '',
lastSnapshotAt: 0,
}
if (
currentPreview.session.baseContent === undefined &&
(editIntent.operation === 'append' || editIntent.operation === 'patch') &&
execContext.workspaceId &&
editIntent.target.fileId
) {
const intentBase = await peekFileIntent(
execContext.workspaceId,
editIntent.target.fileId,
{
chatId: execContext.chatId,
messageId: execContext.messageId,
channelId,
}
)
if (typeof intentBase?.existingContent === 'string') {
const seededSession: FilePreviewSession = {
...currentPreview.session,
baseContent: intentBase.existingContent,
...(intentBase.edit ? { edit: intentBase.edit } : {}),
}
currentPreview = {
...currentPreview,
session: seededSession,
}
filePreviewState.set(editIntent.toolCallId, currentPreview)
await persistFilePreviewSession(seededSession)
}
}
const previewText = isContentOperation(editIntent.operation)
? buildFilePreviewText({
operation: editIntent.operation,
streamedContent,
existingContent: currentPreview.session.baseContent,
edit: currentPreview.session.edit,
})
: undefined
if (previewText !== undefined) {
const baseSession = buildPreviewSessionFromIntent(
streamId,
editIntent,
currentPreview.session
)
const now = Date.now()
const nextSession: FilePreviewSession = {
...baseSession,
status: 'streaming',
previewText,
previewVersion: (currentPreview.session.previewVersion ?? 0) + 1,
updatedAt: new Date(now).toISOString(),
}
await persistFilePreviewSession(nextSession)
if (
nextSession.operation === 'patch' &&
now - currentPreview.lastSnapshotAt < PATCH_PREVIEW_SNAPSHOT_INTERVAL_MS
) {
filePreviewState.set(editIntent.toolCallId, {
session: nextSession,
lastEmittedPreviewText: currentPreview.lastEmittedPreviewText,
lastSnapshotAt: currentPreview.lastSnapshotAt,
})
} else {
const previewUpdate = buildPreviewContentUpdate(
currentPreview.lastEmittedPreviewText,
nextSession.previewText,
currentPreview.lastSnapshotAt,
now,
nextSession.operation
)
filePreviewState.set(editIntent.toolCallId, {
session: nextSession,
lastEmittedPreviewText: nextSession.previewText,
lastSnapshotAt: previewUpdate.lastSnapshotAt,
})
await emitPreviewEvent(streamEvent, options, {
toolCallId: nextSession.toolCallId,
toolName: 'workspace_file',
previewPhase: 'file_preview_content',
content: previewUpdate.content,
contentMode: previewUpdate.contentMode,
previewVersion: nextSession.previewVersion,
fileName: nextSession.fileName,
...(nextSession.fileId ? { fileId: nextSession.fileId } : {}),
...(nextSession.targetKind ? { targetKind: nextSession.targetKind } : {}),
...(nextSession.operation ? { operation: nextSession.operation } : {}),
...(nextSession.edit ? { edit: nextSession.edit } : {}),
})
}
} else {
filePreviewState.set(editIntent.toolCallId, {
session: currentPreview.session,
lastEmittedPreviewText: currentPreview.lastEmittedPreviewText,
lastSnapshotAt: currentPreview.lastSnapshotAt,
})
}
}
}
editContentState.set(toolCallId, stateForTool)
}
if (isToolCallStreamEvent(streamEvent) && streamEvent.payload.toolName === 'edit_content') {
const toolCallId = streamEvent.payload.toolCallId
if (toolCallId) {
editContentState.delete(toolCallId)
}
}
const editResultIntent = getIntent()
if (
isToolResultStreamEvent(streamEvent) &&
streamEvent.payload.toolName === 'edit_content' &&
editResultIntent
) {
const currentPreview = filePreviewState.get(editResultIntent.toolCallId)
const completedAt = new Date().toISOString()
if (
currentPreview &&
currentPreview.lastEmittedPreviewText !== currentPreview.session.previewText &&
currentPreview.session.previewText.length > 0
) {
filePreviewState.set(editResultIntent.toolCallId, {
session: currentPreview.session,
lastEmittedPreviewText: currentPreview.session.previewText,
lastSnapshotAt: Date.now(),
})
await emitPreviewEvent(streamEvent, options, {
toolCallId: currentPreview.session.toolCallId,
toolName: 'workspace_file',
previewPhase: 'file_preview_content',
content: currentPreview.session.previewText,
contentMode: 'snapshot',
previewVersion: currentPreview.session.previewVersion,
fileName: currentPreview.session.fileName,
...(currentPreview.session.fileId ? { fileId: currentPreview.session.fileId } : {}),
...(currentPreview.session.targetKind
? { targetKind: currentPreview.session.targetKind }
: {}),
...(currentPreview.session.operation
? { operation: currentPreview.session.operation }
: {}),
...(currentPreview.session.edit ? { edit: currentPreview.session.edit } : {}),
})
}
if (currentPreview) {
const completedSession: FilePreviewSession = {
...currentPreview.session,
status: 'complete',
updatedAt: completedAt,
completedAt,
}
filePreviewState.set(editResultIntent.toolCallId, {
session: completedSession,
lastEmittedPreviewText: completedSession.previewText,
lastSnapshotAt: Date.now(),
})
await persistFilePreviewSession(completedSession)
}
await emitPreviewEvent(streamEvent, options, {
toolCallId: editResultIntent.toolCallId,
toolName: 'workspace_file',
previewPhase: 'file_preview_complete',
fileId: editResultIntent.target.fileId,
output: streamEvent.payload.output,
...(currentPreview ? { previewVersion: currentPreview.session.previewVersion } : {}),
})
clearIntent()
}
}
+129
View File
@@ -0,0 +1,129 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
const logger = createLogger('CopilotSseParser')
export class FatalSseEventError extends Error {}
function createParseFailure(message: string, preview: string): FatalSseEventError {
logger.error(message, { preview })
return new FatalSseEventError(message)
}
function normalizeSseLine(line: string): string {
return line.endsWith('\r') ? line.slice(0, -1) : line
}
/**
* Processes an SSE stream by calling onEvent for each parsed event.
*
* @param onEvent Called per parsed event. Return true to stop processing.
*/
export async function processSSEStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
decoder: TextDecoder,
abortSignal: AbortSignal | undefined,
onEvent: (event: unknown) => boolean | undefined | Promise<boolean | undefined>
): Promise<void> {
let buffer = ''
try {
try {
while (true) {
if (abortSignal?.aborted) {
logger.info('SSE stream aborted by signal')
break
}
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
let stopped = false
for (const line of lines) {
const normalizedLine = normalizeSseLine(line)
if (abortSignal?.aborted) {
logger.info('SSE stream aborted mid-chunk (between events)')
return
}
if (!normalizedLine.trim()) continue
if (!normalizedLine.startsWith('data: ')) continue
const jsonStr = normalizedLine.slice(6)
if (jsonStr === '[DONE]') continue
let parsed: unknown
try {
parsed = JSON.parse(jsonStr)
} catch (error) {
const preview = jsonStr.slice(0, 200)
const detail = toError(error).message
throw createParseFailure(`Failed to parse SSE event JSON: ${detail}`, preview)
}
try {
if (await onEvent(parsed)) {
stopped = true
break
}
} catch (error) {
if (error instanceof FatalSseEventError) {
throw error
}
logger.warn('Failed to handle SSE event', {
preview: jsonStr.slice(0, 200),
error: toError(error).message,
})
}
}
if (stopped) break
}
} catch (error) {
const aborted =
abortSignal?.aborted || (error instanceof DOMException && error.name === 'AbortError')
if (aborted) {
logger.info('SSE stream read aborted')
return
}
throw error
}
const normalizedBuffer = normalizeSseLine(buffer)
if (normalizedBuffer.trim() && normalizedBuffer.startsWith('data: ')) {
const jsonStr = normalizedBuffer.slice(6)
if (jsonStr === '[DONE]') {
return
}
let parsed: unknown
try {
parsed = JSON.parse(jsonStr)
} catch (error) {
const preview = normalizedBuffer.slice(0, 200)
const detail = toError(error).message
throw createParseFailure(`Failed to parse final SSE buffer JSON: ${detail}`, preview)
}
try {
await onEvent(parsed)
} catch (error) {
if (error instanceof FatalSseEventError) {
throw error
}
logger.warn('Failed to handle final SSE event', {
preview: normalizedBuffer.slice(0, 200),
error: toError(error).message,
})
}
}
} finally {
try {
reader.releaseLock()
} catch {
logger.warn('Failed to release SSE reader lock')
}
}
}
@@ -0,0 +1,57 @@
import { type Context, context } from '@opentelemetry/api'
import { W3CTraceContextPropagator } from '@opentelemetry/core'
const propagator = new W3CTraceContextPropagator()
const headerSetter = {
set(carrier: Record<string, string>, key: string, value: string) {
carrier[key] = value
},
}
const headerGetter = {
keys(carrier: Headers): string[] {
const out: string[] = []
carrier.forEach((_, key) => {
out.push(key)
})
return out
},
get(carrier: Headers, key: string): string | undefined {
return carrier.get(key) ?? undefined
},
}
/**
* Injects W3C trace context (traceparent, tracestate) into outbound HTTP
* headers so Go-side spans join the same OTel trace tree as the calling
* Sim span.
*
* Usage: spread the result into your fetch headers:
* fetch(url, { headers: { ...myHeaders, ...traceHeaders() } })
*/
export function traceHeaders(
carrier?: Record<string, string>,
otelContext?: Context
): Record<string, string> {
const headers: Record<string, string> = carrier ?? {}
propagator.inject(otelContext ?? context.active(), headers, headerSetter)
return headers
}
/**
* Extracts W3C trace context from incoming request headers (traceparent /
* tracestate) and returns an OTel Context seeded with the upstream span.
*
* Use this at the top of inbound Sim route handlers that Go calls into
* (e.g. /api/billing/update-cost, /api/copilot/api-keys/validate) so the
* Sim-side span becomes a proper child of the Go-side client span in the
* same trace — closing the round trip in Jaeger.
*
* When no traceparent is present (e.g. calls from a browser or a client
* that hasn't been instrumented), this returns `context.active()`
* unchanged, and any span started under it becomes a new root — the same
* behavior as before this helper existed.
*/
export function contextFromRequestHeaders(headers: Headers): Context {
return propagator.extract(context.active(), headers, headerGetter)
}
@@ -0,0 +1,869 @@
/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
MothershipStreamV1CompletionStatus,
MothershipStreamV1EventType,
MothershipStreamV1ToolExecutor,
MothershipStreamV1ToolMode,
MothershipStreamV1ToolOutcome,
MothershipStreamV1ToolPhase,
} from '@/lib/copilot/generated/mothership-stream-v1'
vi.mock('@/lib/copilot/request/session', async () => {
const actual = await vi.importActual<typeof import('@/lib/copilot/request/session')>(
'@/lib/copilot/request/session'
)
return {
...actual,
hasAbortMarker: vi.fn().mockResolvedValue(false),
upsertFilePreviewSession: vi.fn(async (session) => session),
}
})
const resolveWorkspaceFileReferenceMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
resolveWorkspaceFileReference: resolveWorkspaceFileReferenceMock,
}))
vi.mock('@/lib/copilot/tools/server/files/file-preview', async () => {
const actual = await vi.importActual<
typeof import('@/lib/copilot/tools/server/files/file-preview')
>('@/lib/copilot/tools/server/files/file-preview')
return {
...actual,
loadWorkspaceFileTextForPreview: vi.fn().mockResolvedValue(''),
}
})
import {
buildPreviewContentUpdate,
decodeJsonStringPrefix,
extractEditContent,
runStreamLoop,
} from '@/lib/copilot/request/go/stream'
import { AbortReason, createEvent, hasAbortMarker } from '@/lib/copilot/request/session'
import { RequestTraceV1Outcome, TraceCollector } from '@/lib/copilot/request/trace'
import type { ExecutionContext, StreamingContext } from '@/lib/copilot/request/types'
function createSseResponse(events: unknown[]): Response {
const payload = events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join('')
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(payload))
controller.close()
},
}),
{
status: 200,
headers: {
'Content-Type': 'text/event-stream',
},
}
)
}
function createRawSseResponse(payload: string): Response {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(payload))
controller.close()
},
}),
{
status: 200,
headers: {
'Content-Type': 'text/event-stream',
},
}
)
}
function createStreamingContext(): StreamingContext {
return {
messageId: 'msg-1',
accumulatedContent: '',
finalAssistantContent: '',
sawMainToolCall: false,
contentBlocks: [],
toolCalls: new Map(),
pendingToolPromises: new Map(),
currentThinkingBlock: null,
subagentThinkingBlocks: new Map(),
isInThinkingBlock: false,
subAgentContent: {},
subAgentToolCalls: {},
pendingContent: '',
streamComplete: false,
wasAborted: false,
errors: [],
activeFileIntents: new Map(),
trace: new TraceCollector(),
}
}
describe('copilot go stream helpers', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
resolveWorkspaceFileReferenceMock.mockReset()
resolveWorkspaceFileReferenceMock.mockResolvedValue(null)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('decodes complete escapes and stops at incomplete unicode escapes', () => {
expect(decodeJsonStringPrefix('hello\\nworld')).toBe('hello\nworld')
expect(decodeJsonStringPrefix('emoji \\u263A')).toBe('emoji ☺')
expect(decodeJsonStringPrefix('partial \\u26')).toBe('partial ')
})
it('extracts the streamed edit_content prefix from partial JSON', () => {
expect(extractEditContent('{"content":"hello\\nwor')).toBe('hello\nwor')
expect(extractEditContent('{"content":"tab\\tvalue"}')).toBe('tab\tvalue')
})
it('emits full snapshots for append (sidebar viewer uses replace mode; no delta merge)', () => {
expect(buildPreviewContentUpdate('hello', 'hello world', 100, 200, 'append')).toEqual({
content: 'hello world',
contentMode: 'snapshot',
lastSnapshotAt: 200,
})
})
it('emits deltas for update when the preview extends the previous text', () => {
expect(buildPreviewContentUpdate('hello', 'hello world', 100, 200, 'update')).toEqual({
content: ' world',
contentMode: 'delta',
lastSnapshotAt: 100,
})
})
it('falls back to snapshots for patches and divergent content', () => {
expect(buildPreviewContentUpdate('hello', 'goodbye', 100, 200, 'update')).toEqual({
content: 'goodbye',
contentMode: 'snapshot',
lastSnapshotAt: 200,
})
expect(buildPreviewContentUpdate('hello', 'hello world', 100, 200, 'patch')).toEqual({
content: 'hello world',
contentMode: 'snapshot',
lastSnapshotAt: 200,
})
})
it('hydrates path-based workspace_file edits into file preview events before edit_content streams', async () => {
resolveWorkspaceFileReferenceMock.mockResolvedValue({
id: 'file-1',
name: 'notes.md',
})
const workspaceFileCall = createEvent({
streamId: 'stream-1',
cursor: '1',
seq: 1,
requestId: 'req-1',
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'workspace-file-path-1',
toolName: 'workspace_file',
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.call,
arguments: {
operation: 'update',
target: { kind: 'path', path: 'files/notes.md' },
title: 'Update notes',
},
},
})
const workspaceFileResult = createEvent({
streamId: 'stream-1',
cursor: '2',
seq: 2,
requestId: 'req-1',
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'workspace-file-path-1',
toolName: 'workspace_file',
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.result,
success: true,
output: {
success: true,
data: { id: 'file-1', name: 'notes.md', operation: 'update' },
},
},
})
const editContentDelta = createEvent({
streamId: 'stream-1',
cursor: '3',
seq: 3,
requestId: 'req-1',
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'edit-content-path-1',
toolName: 'edit_content',
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.args_delta,
argumentsDelta: '{"content":"hello world',
},
})
const editContentResult = createEvent({
streamId: 'stream-1',
cursor: '4',
seq: 4,
requestId: 'req-1',
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'edit-content-path-1',
toolName: 'edit_content',
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.result,
success: true,
output: {
success: true,
data: { id: 'file-1', name: 'notes.md' },
},
},
})
const complete = createEvent({
streamId: 'stream-1',
cursor: '5',
seq: 5,
requestId: 'req-1',
type: MothershipStreamV1EventType.complete,
payload: {
status: MothershipStreamV1CompletionStatus.complete,
},
})
vi.mocked(fetch).mockResolvedValueOnce(
createSseResponse([
workspaceFileCall,
workspaceFileResult,
editContentDelta,
editContentResult,
complete,
])
)
const onEvent = vi.fn()
const context = createStreamingContext()
const execContext: ExecutionContext = {
userId: 'user-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
messageId: 'msg-1',
}
await runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, {
onEvent,
timeout: 1000,
})
const previewEvents = onEvent.mock.calls
.map(([event]) => event)
.filter(
(event) =>
event.type === MothershipStreamV1EventType.tool && 'previewPhase' in event.payload
)
expect(previewEvents.map((event) => event.payload.previewPhase)).toEqual([
'file_preview_start',
'file_preview_target',
'file_preview_content',
'file_preview_complete',
])
expect(previewEvents[1].payload).toMatchObject({
previewPhase: 'file_preview_target',
target: { kind: 'file_id', fileId: 'file-1', fileName: 'notes.md' },
})
expect(previewEvents[2].payload).toMatchObject({
previewPhase: 'file_preview_content',
fileId: 'file-1',
targetKind: 'file_id',
content: 'hello world',
})
expect(previewEvents[3].payload).toMatchObject({
previewPhase: 'file_preview_complete',
fileId: 'file-1',
})
expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith('workspace-1', 'files/notes.md')
})
it('resolves workflow alias paths to the backing file before streaming previews', async () => {
resolveWorkspaceFileReferenceMock.mockResolvedValue({
id: 'changelog-file-1',
name: 'workflow-1.md',
})
const workspaceFileCall = createEvent({
streamId: 'stream-1',
cursor: '1',
seq: 1,
requestId: 'req-1',
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'workspace-file-alias-1',
toolName: 'workspace_file',
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.call,
arguments: {
operation: 'append',
target: { kind: 'path', path: 'workflows/My%20Workflow/changelog.md' },
title: 'Update changelog',
},
},
})
const editContentDelta = createEvent({
streamId: 'stream-1',
cursor: '2',
seq: 2,
requestId: 'req-1',
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'edit-content-alias-1',
toolName: 'edit_content',
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.args_delta,
argumentsDelta: '{"content":"\\n- Added a workflow step',
},
})
const editContentResult = createEvent({
streamId: 'stream-1',
cursor: '3',
seq: 3,
requestId: 'req-1',
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'edit-content-alias-1',
toolName: 'edit_content',
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.result,
success: true,
output: {
success: true,
data: { id: 'changelog-file-1', name: 'workflow-1.md' },
},
},
})
const complete = createEvent({
streamId: 'stream-1',
cursor: '4',
seq: 4,
requestId: 'req-1',
type: MothershipStreamV1EventType.complete,
payload: {
status: MothershipStreamV1CompletionStatus.complete,
},
})
vi.mocked(fetch).mockResolvedValueOnce(
createSseResponse([workspaceFileCall, editContentDelta, editContentResult, complete])
)
const onEvent = vi.fn()
const context = createStreamingContext()
const execContext: ExecutionContext = {
userId: 'user-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
messageId: 'msg-1',
}
await runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, {
onEvent,
timeout: 1000,
})
const previewEvents = onEvent.mock.calls
.map(([event]) => event)
.filter(
(event) =>
event.type === MothershipStreamV1EventType.tool && 'previewPhase' in event.payload
)
expect(previewEvents.map((event) => event.payload.previewPhase)).toEqual([
'file_preview_start',
'file_preview_target',
'file_preview_content',
'file_preview_complete',
])
expect(previewEvents[1].payload).toMatchObject({
previewPhase: 'file_preview_target',
target: { kind: 'file_id', fileId: 'changelog-file-1', fileName: 'workflow-1.md' },
})
expect(previewEvents[2].payload).toMatchObject({
previewPhase: 'file_preview_content',
fileId: 'changelog-file-1',
targetKind: 'file_id',
content: '\n- Added a workflow step',
})
expect(previewEvents[3].payload).toMatchObject({
previewPhase: 'file_preview_complete',
fileId: 'changelog-file-1',
})
expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith(
'workspace-1',
'workflows/My%20Workflow/changelog.md'
)
})
it('drops duplicate tool_result events before forwarding them', async () => {
const toolResult = createEvent({
streamId: 'stream-1',
cursor: '1',
seq: 1,
requestId: 'req-1',
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'tool-result-dedupe',
toolName: 'search_online',
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.result,
success: true,
output: { value: 'ok' },
},
})
const complete = createEvent({
streamId: 'stream-1',
cursor: '2',
seq: 2,
requestId: 'req-1',
type: MothershipStreamV1EventType.complete,
payload: {
status: MothershipStreamV1CompletionStatus.complete,
},
})
vi.mocked(fetch).mockResolvedValueOnce(createSseResponse([toolResult, toolResult, complete]))
const onEvent = vi.fn()
const context = createStreamingContext()
const execContext: ExecutionContext = {
userId: 'user-1',
workflowId: 'workflow-1',
}
await runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, {
onEvent,
timeout: 1000,
})
expect(onEvent.mock.calls.map(([event]) => event.type)).toEqual([
MothershipStreamV1EventType.tool,
MothershipStreamV1EventType.complete,
])
expect(onEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: MothershipStreamV1EventType.tool,
payload: expect.objectContaining({
toolCallId: 'tool-result-dedupe',
phase: MothershipStreamV1ToolPhase.result,
}),
})
)
expect(context.toolCalls.get('tool-result-dedupe')).toEqual(
expect.objectContaining({
id: 'tool-result-dedupe',
name: 'search_online',
status: MothershipStreamV1ToolOutcome.success,
result: { success: true, output: { value: 'ok' } },
})
)
})
it('does not retry transient backend statuses because stream requests are not idempotent', async () => {
vi.mocked(fetch).mockResolvedValueOnce(new Response('bad gateway', { status: 502 }))
const context = createStreamingContext()
const execContext: ExecutionContext = {
userId: 'user-1',
workflowId: 'workflow-1',
}
await expect(
runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, {
timeout: 1000,
})
).rejects.toMatchObject({
name: 'CopilotBackendError',
status: 502,
body: 'bad gateway',
})
expect(fetch).toHaveBeenCalledTimes(1)
})
it('does not retry non-transient backend statuses before the SSE stream opens', async () => {
vi.mocked(fetch).mockResolvedValueOnce(new Response('limit reached', { status: 402 }))
const context = createStreamingContext()
const execContext: ExecutionContext = {
userId: 'user-1',
workflowId: 'workflow-1',
}
await expect(
runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, {
timeout: 1000,
})
).rejects.toThrow('Usage limit reached')
expect(fetch).toHaveBeenCalledTimes(1)
})
it('does not retry network errors because Go may already be executing the request', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new TypeError('fetch failed'))
const context = createStreamingContext()
const execContext: ExecutionContext = {
userId: 'user-1',
workflowId: 'workflow-1',
}
await expect(
runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, {
timeout: 1000,
})
).rejects.toThrow('fetch failed')
expect(fetch).toHaveBeenCalledTimes(1)
})
it('fails closed when the shared stream ends before a terminal event', async () => {
const textEvent = createEvent({
streamId: 'stream-1',
cursor: '1',
seq: 1,
requestId: 'req-1',
type: MothershipStreamV1EventType.text,
payload: {
channel: 'assistant',
text: 'partial response',
},
})
vi.mocked(fetch).mockResolvedValueOnce(createSseResponse([textEvent]))
const context = createStreamingContext()
const execContext: ExecutionContext = {
userId: 'user-1',
workflowId: 'workflow-1',
}
await expect(
runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, {
timeout: 1000,
})
).rejects.toThrow('Copilot backend stream ended before a terminal event')
expect(
context.errors.some((message) =>
message.includes('Copilot backend stream ended before a terminal event')
)
).toBe(true)
})
it('reclassifies as aborted when the body closes without terminal but the abort marker is set', async () => {
const textEvent = createEvent({
streamId: 'stream-1',
cursor: '1',
seq: 1,
requestId: 'req-1',
type: MothershipStreamV1EventType.text,
payload: {
channel: 'assistant',
text: 'partial response',
},
})
vi.mocked(fetch).mockResolvedValueOnce(createSseResponse([textEvent]))
vi.mocked(hasAbortMarker).mockResolvedValueOnce(true)
const context = createStreamingContext()
const execContext: ExecutionContext = {
userId: 'user-1',
workflowId: 'workflow-1',
}
await runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, {
timeout: 1000,
})
expect(hasAbortMarker).toHaveBeenCalledWith(context.messageId)
expect(context.wasAborted).toBe(true)
expect(
context.errors.some((message) =>
message.includes('Copilot backend stream ended before a terminal event')
)
).toBe(false)
})
it('invokes onAbortObserved with MarkerObservedAtBodyClose when reclassifying via the abort marker', async () => {
const textEvent = createEvent({
streamId: 'stream-1',
cursor: '1',
seq: 1,
requestId: 'req-1',
type: MothershipStreamV1EventType.text,
payload: {
channel: 'assistant',
text: 'partial response',
},
})
vi.mocked(fetch).mockResolvedValueOnce(createSseResponse([textEvent]))
vi.mocked(hasAbortMarker).mockResolvedValueOnce(true)
const context = createStreamingContext()
const execContext: ExecutionContext = {
userId: 'user-1',
workflowId: 'workflow-1',
}
const onAbortObserved = vi.fn()
await runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, {
timeout: 1000,
onAbortObserved,
})
expect(onAbortObserved).toHaveBeenCalledTimes(1)
expect(onAbortObserved).toHaveBeenCalledWith(AbortReason.MarkerObservedAtBodyClose)
expect(context.wasAborted).toBe(true)
})
it('does not invoke onAbortObserved when no abort marker is present at body close', async () => {
const textEvent = createEvent({
streamId: 'stream-1',
cursor: '1',
seq: 1,
requestId: 'req-1',
type: MothershipStreamV1EventType.text,
payload: {
channel: 'assistant',
text: 'partial response',
},
})
vi.mocked(fetch).mockResolvedValueOnce(createSseResponse([textEvent]))
vi.mocked(hasAbortMarker).mockResolvedValueOnce(false)
const context = createStreamingContext()
const execContext: ExecutionContext = {
userId: 'user-1',
workflowId: 'workflow-1',
}
const onAbortObserved = vi.fn()
await expect(
runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, {
timeout: 1000,
onAbortObserved,
})
).rejects.toThrow('Copilot backend stream ended before a terminal event')
expect(onAbortObserved).not.toHaveBeenCalled()
})
it('still fails closed when the body closes without terminal and the abort marker check throws', async () => {
const textEvent = createEvent({
streamId: 'stream-1',
cursor: '1',
seq: 1,
requestId: 'req-1',
type: MothershipStreamV1EventType.text,
payload: {
channel: 'assistant',
text: 'partial response',
},
})
vi.mocked(fetch).mockResolvedValueOnce(createSseResponse([textEvent]))
vi.mocked(hasAbortMarker).mockRejectedValueOnce(new Error('redis unavailable'))
const context = createStreamingContext()
const execContext: ExecutionContext = {
userId: 'user-1',
workflowId: 'workflow-1',
}
await expect(
runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, {
timeout: 1000,
})
).rejects.toThrow('Copilot backend stream ended before a terminal event')
expect(context.wasAborted).toBe(false)
})
it('fails closed when the shared stream receives an invalid event', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
createSseResponse([
{
v: 1,
type: MothershipStreamV1EventType.tool,
seq: 1,
ts: '2026-01-01T00:00:00.000Z',
stream: { streamId: 'stream-1', cursor: '1' },
payload: {
phase: MothershipStreamV1ToolPhase.result,
},
},
])
)
const context = createStreamingContext()
const execContext: ExecutionContext = {
userId: 'user-1',
workflowId: 'workflow-1',
}
await expect(
runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, {
timeout: 1000,
})
).rejects.toThrow('Received invalid stream event on shared path')
expect(
context.errors.some((message) =>
message.includes('Received invalid stream event on shared path')
)
).toBe(true)
})
it('fails closed when the shared stream receives malformed JSON', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
createRawSseResponse('data: {"v":1,"type":"text","payload":\n\n')
)
const context = createStreamingContext()
const execContext: ExecutionContext = {
userId: 'user-1',
workflowId: 'workflow-1',
}
await expect(
runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, {
timeout: 1000,
})
).rejects.toThrow('Failed to parse SSE event JSON')
expect(
context.errors.some((message) => message.includes('Failed to parse SSE event JSON'))
).toBe(true)
})
it('records a split canonical request id and go trace id from the stream envelope', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
createSseResponse([
{
v: 1,
type: MothershipStreamV1EventType.text,
seq: 1,
ts: '2026-01-01T00:00:00.000Z',
stream: { streamId: 'stream-1', cursor: '1' },
trace: {
requestId: 'sim-request-1',
goTraceId: 'go-trace-1',
},
payload: {
channel: 'assistant',
text: 'hello',
},
},
createEvent({
streamId: 'stream-1',
cursor: '2',
seq: 2,
requestId: 'sim-request-1',
type: MothershipStreamV1EventType.complete,
payload: {
status: MothershipStreamV1CompletionStatus.complete,
},
}),
])
)
const context = createStreamingContext()
context.requestId = 'sim-request-1'
const execContext: ExecutionContext = {
userId: 'user-1',
workflowId: 'workflow-1',
}
await runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, {
timeout: 1000,
})
expect(context.requestId).toBe('sim-request-1')
expect(
context.trace.build({
outcome: RequestTraceV1Outcome.success,
simRequestId: 'sim-request-1',
}).goTraceId
).toBe('go-trace-1')
})
it('records span identity on the subagent block from the scope', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
createSseResponse([
createEvent({
streamId: 'stream-1',
cursor: '1',
seq: 1,
requestId: 'req-1',
type: MothershipStreamV1EventType.span,
scope: {
lane: 'subagent',
agentId: 'deploy',
parentToolCallId: 'tc-deploy-inner',
spanId: 'S2',
parentSpanId: 'S1',
},
payload: {
kind: 'subagent',
event: 'start',
agent: 'deploy',
data: { tool_call_id: 'tc-deploy-inner', nested: true },
},
}),
createEvent({
streamId: 'stream-1',
cursor: '2',
seq: 2,
requestId: 'req-1',
type: MothershipStreamV1EventType.complete,
payload: { status: MothershipStreamV1CompletionStatus.complete },
}),
])
)
const context = createStreamingContext()
const execContext: ExecutionContext = {
userId: 'user-1',
workflowId: 'workflow-1',
}
await runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, {
timeout: 1000,
})
const subagentBlock = context.contentBlocks.find((block) => block.type === 'subagent')
expect(subagentBlock).toBeDefined()
expect(subagentBlock?.spanId).toBe('S2')
expect(subagentBlock?.parentSpanId).toBe('S1')
expect(subagentBlock?.parentToolCallId).toBe('tc-deploy-inner')
})
})
+708
View File
@@ -0,0 +1,708 @@
import { type Context, SpanStatusCode } from '@opentelemetry/api'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { ORCHESTRATION_TIMEOUT_MS } from '@/lib/copilot/constants'
import {
MothershipStreamV1EventType,
MothershipStreamV1SpanLifecycleEvent,
} from '@/lib/copilot/generated/mothership-stream-v1'
import { CopilotSseCloseReason } from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
import {
buildPreviewContentUpdate,
createFilePreviewAdapterState,
decodeJsonStringPrefix,
extractEditContent,
processFilePreviewStreamEvent,
} from '@/lib/copilot/request/go/file-preview-adapter'
import { FatalSseEventError, processSSEStream } from '@/lib/copilot/request/go/parser'
import {
handleSubagentRouting,
prePersistClientExecutableToolCall,
sseHandlers,
subAgentHandlers,
} from '@/lib/copilot/request/handlers'
import {
flushSubagentThinkingBlock,
flushThinkingBlock,
} from '@/lib/copilot/request/handlers/types'
import { getCopilotTracer } from '@/lib/copilot/request/otel'
import {
AbortReason,
eventToStreamEvent,
hasAbortMarker,
isSubagentSpanStreamEvent,
parsePersistedStreamEventEnvelope,
} from '@/lib/copilot/request/session'
import { shouldSkipToolCallEvent, shouldSkipToolResultEvent } from '@/lib/copilot/request/sse-utils'
import type {
ExecutionContext,
OrchestratorOptions,
StreamEvent,
StreamingContext,
} from '@/lib/copilot/request/types'
const logger = createLogger('CopilotGoStream')
export { buildPreviewContentUpdate, decodeJsonStringPrefix, extractEditContent }
type JsonRecord = Record<string, unknown>
type SubagentSpanData = {
pending?: boolean
toolCallId?: string
}
function asJsonRecord(value: unknown): JsonRecord | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as JsonRecord)
: undefined
}
function parseSubagentSpanData(value: unknown): SubagentSpanData | undefined {
const data = asJsonRecord(value)
if (!data) {
return undefined
}
const toolCallId = typeof data.tool_call_id === 'string' ? data.tool_call_id : undefined
const pending = typeof data.pending === 'boolean' ? data.pending : undefined
return {
...(toolCallId ? { toolCallId } : {}),
...(pending !== undefined ? { pending } : {}),
}
}
export class CopilotBackendError extends Error {
status?: number
body?: string
constructor(message: string, options?: { status?: number; body?: string }) {
super(message)
this.name = 'CopilotBackendError'
this.status = options?.status
this.body = options?.body
}
}
export class BillingLimitError extends Error {
constructor(public readonly userId: string) {
super('Usage limit reached')
this.name = 'BillingLimitError'
}
}
/**
* Options for the shared stream processing loop.
*/
export interface StreamLoopOptions extends OrchestratorOptions {
/**
* Called for each normalized event BEFORE standard handler dispatch.
* Return true to skip the default handler for this event.
*/
onBeforeDispatch?: (event: StreamEvent, context: StreamingContext) => boolean | undefined
/**
* Called when the Go backend's trace ID (go_trace_id) is first received via SSE.
*/
onGoTraceId?: (goTraceId: string) => void
otelContext?: Context
}
/**
* Run the SSE stream processing loop against the Go backend.
*
* Handles: fetch -> parse -> normalize -> dedupe -> subagent routing -> handler dispatch.
* Callers provide the fetch URL/options and can intercept events via onBeforeDispatch.
* Feature-specific normalization runs through dedicated adapters before the raw event is forwarded.
*/
export async function runStreamLoop(
fetchUrl: string,
fetchOptions: RequestInit,
context: StreamingContext,
execContext: ExecutionContext,
options: StreamLoopOptions
): Promise<void> {
const { timeout = ORCHESTRATION_TIMEOUT_MS, abortSignal } = options
const filePreviewAdapterState = createFilePreviewAdapterState()
const pathname = new URL(fetchUrl).pathname
const requestBodyBytes = estimateBodyBytes(fetchOptions.body)
const fetchSpan = context.trace.startSpan(`HTTP Request → ${pathname}`, 'sim.http.fetch', {
url: fetchUrl,
method: fetchOptions.method ?? 'GET',
requestBodyBytes,
})
const fetchStart = performance.now()
let response: Response
try {
response = await fetchGo(fetchUrl, {
...fetchOptions,
signal: abortSignal,
otelContext: options.otelContext,
spanName: `sim → go ${pathname}`,
operation: 'stream',
attributes: {
[TraceAttr.CopilotStream]: true,
...(requestBodyBytes ? { [TraceAttr.HttpRequestContentLength]: requestBodyBytes } : {}),
},
})
} catch (error) {
fetchSpan.attributes = {
...(fetchSpan.attributes ?? {}),
headersMs: Math.round(performance.now() - fetchStart),
}
context.trace.endSpan(fetchSpan, abortSignal?.aborted ? 'cancelled' : 'error')
throw error
}
const headersElapsedMs = Math.round(performance.now() - fetchStart)
fetchSpan.attributes = {
...(fetchSpan.attributes ?? {}),
status: response.status,
headersMs: headersElapsedMs,
}
if (!response.ok) {
context.trace.endSpan(fetchSpan, 'error')
const errorText = await response.text().catch(() => '')
if (response.status === 402) {
throw new BillingLimitError(execContext.userId)
}
throw new CopilotBackendError(
`Copilot backend error (${response.status}): ${errorText || response.statusText}`,
{ status: response.status, body: errorText || response.statusText }
)
}
if (!response.body) {
context.trace.endSpan(fetchSpan, 'error')
throw new CopilotBackendError('Copilot backend response missing body')
}
context.trace.endSpan(fetchSpan)
const bodySpan = context.trace.startSpan(`SSE Body → ${pathname}`, 'sim.http.stream_body', {
url: fetchUrl,
method: fetchOptions.method ?? 'GET',
})
// Aggregate counters populated inline by the reader wrapper + onEvent
// dispatcher below and flushed to both the legacy TraceCollector span
// and the OTel read-loop span when the loop terminates. Kept as plain
// JS variables (not span attrs) so incrementing them is free — we
// only pay OTel cost once at span End().
//
// Idle-gap tracking is split two ways so we can tell apart
// upstream-silent from we-were-busy:
//
// - `longestInboundGapMs`: biggest time between consecutive
// `reader.read()` calls returning bytes. Upper bound on
// "Go silent". Actually also includes Node waiting for main
// thread free, so see dispatchMs below.
// - `longestDispatchMs`: biggest time any single event handler
// took between "event received" and "returned control". Upper
// bound on "Sim was CPU-bound on a handler". If this is high
// AND inbound gap is high at the same time, it's Sim. If only
// inbound gap is high, it's upstream.
// - `totalDispatchMs`: sum of all handler times. Helps gauge
// whether handlers in aggregate ate a meaningful fraction of
// the read loop.
const counters = {
bytes: 0,
chunks: 0,
events: 0,
eventsByType: {
session: 0,
text: 0,
tool: 0,
span: 0,
resource: 0,
run: 0,
error: 0,
complete: 0,
} as Record<MothershipStreamV1EventType, number>,
firstEventMs: undefined as number | undefined,
lastChunkMs: performance.now(),
longestInboundGapMs: 0,
longestDispatchMs: 0,
totalDispatchMs: 0,
}
const bodyStart = performance.now()
let endedOn: string = CopilotSseCloseReason.Terminal
// Wrap the body's reader so we can track per-chunk bytes and the gap
// between chunks. `processSSEStream` consumes this reader exactly as
// it would the raw one — no API changes there.
const IDLE_GAP_EVENT_THRESHOLD_MS = 10000
const rawReader = response.body.getReader()
const reader: ReadableStreamDefaultReader<Uint8Array> = {
async read() {
const result = await rawReader.read()
if (!result.done && result.value) {
const now = performance.now()
const gap = now - counters.lastChunkMs
if (gap > counters.longestInboundGapMs) counters.longestInboundGapMs = gap
counters.lastChunkMs = now
counters.chunks += 1
counters.bytes += result.value.byteLength
}
return result
},
cancel: (reason) => rawReader.cancel(reason),
releaseLock: () => rawReader.releaseLock(),
get closed() {
return rawReader.closed
},
}
const decoder = new TextDecoder()
const timeoutId = setTimeout(() => {
context.errors.push('Request timed out')
context.streamComplete = true
endedOn = CopilotSseCloseReason.Timeout
reader.cancel().catch(() => {})
}, timeout)
try {
await processSSEStream(reader, decoder, abortSignal, async (raw) => {
// Track how long THIS handler invocation takes so we can tell
// apart "Go was silent" from "we were CPU-bound on a handler".
// `longestInboundGapMs` includes handler time (the next reader.read
// doesn't run until the previous handler returns), so dispatch
// time is the correction needed to isolate upstream silence.
const dispatchStart = performance.now()
try {
if (counters.events === 0) {
counters.firstEventMs = Math.round(performance.now() - bodyStart)
}
counters.events += 1
if (abortSignal?.aborted) {
context.wasAborted = true
return true
}
const parsedEvent = parsePersistedStreamEventEnvelope(raw)
if (!parsedEvent.ok) {
const detail = [parsedEvent.message, ...(parsedEvent.errors ?? [])]
.filter(Boolean)
.join('; ')
const failureMessage = `Received invalid stream event on shared path: ${detail}`
context.errors.push(failureMessage)
logger.error('Received invalid stream event on shared path', {
reason: parsedEvent.reason,
message: parsedEvent.message,
errors: parsedEvent.errors,
})
throw new FatalSseEventError(failureMessage)
}
const envelope = parsedEvent.event
const streamEvent = eventToStreamEvent(envelope)
if (envelope.trace?.requestId) {
const goTraceId = envelope.trace.goTraceId || envelope.trace.requestId
context.trace.setGoTraceId(goTraceId)
options.onGoTraceId?.(goTraceId)
}
// Per-type counters for the copilot.sse.read_loop span. Bound set
// (8 types) so this can never blow up into high cardinality.
if (streamEvent.type in counters.eventsByType) {
counters.eventsByType[streamEvent.type as MothershipStreamV1EventType] += 1
}
// Surface the full error payload the moment it arrives on the wire. This
// is the single chokepoint every error event passes through (main AND
// subagent lanes), before subagent routing — which has no `error`
// handler — would otherwise swallow it. The client only renders
// `message`/`displayMessage`, so log `code`/`provider`/`data` (the raw
// upstream provider error) here to explain a client-side "Stream error".
if (streamEvent.type === MothershipStreamV1EventType.error) {
const errorPayload = streamEvent.payload
logger.error('Received error event from Go copilot stream', {
path: pathname,
lane: streamEvent.scope?.lane ?? 'main',
parentToolCallId: streamEvent.scope?.parentToolCallId,
agentId: streamEvent.scope?.agentId,
code: errorPayload.code,
provider: errorPayload.provider,
message: errorPayload.message,
error: errorPayload.error,
displayMessage: errorPayload.displayMessage,
data: errorPayload.data,
requestId: context.requestId,
messageId: context.messageId,
})
}
if (shouldSkipToolCallEvent(streamEvent) || shouldSkipToolResultEvent(streamEvent)) {
return
}
await processFilePreviewStreamEvent({
streamId: envelope.stream.streamId,
streamEvent,
context,
execContext,
options,
state: filePreviewAdapterState,
})
await prePersistClientExecutableToolCall(streamEvent, context)
try {
await options.onEvent?.(streamEvent)
} catch (error) {
logger.warn('Failed to forward stream event', {
type: streamEvent.type,
error: getErrorMessage(error),
})
}
// Yield a macrotask so Node.js flushes the HTTP response buffer to
// the browser. Microtask yields (await Promise.resolve()) are not
// enough — the I/O layer needs a full event loop tick to write.
await new Promise<void>((resolve) => setImmediate(resolve))
if (options.onBeforeDispatch?.(streamEvent, context)) {
return context.streamComplete || undefined
}
if (isSubagentSpanStreamEvent(streamEvent)) {
const spanData = parseSubagentSpanData(streamEvent.payload.data)
const toolCallId = streamEvent.scope?.parentToolCallId || spanData?.toolCallId
// Deterministic nesting identity. spanId / parentSpanId are the
// primary keys; the toolCallId-keyed stack below is the legacy
// fallback for streams that predate span identity.
const spanId = streamEvent.scope?.spanId
const parentSpanId = streamEvent.scope?.parentSpanId
const subagentName = streamEvent.payload.agent
const spanEvt = streamEvent.payload.event
const isPendingPause = spanData?.pending === true
// A subagent lifecycle boundary breaks the main thinking stream.
// Flush any open thinking block into contentBlocks BEFORE we push
// the `subagent` marker, or the persisted order ends up
// [subagent, thinking] and the UI renders the subagent group
// above a thinking block that actually happened first.
flushSubagentThinkingBlock(context)
flushThinkingBlock(context)
if (spanEvt === MothershipStreamV1SpanLifecycleEvent.start) {
if (toolCallId) {
context.subAgentContent[toolCallId] ??= ''
context.subAgentToolCalls[toolCallId] ??= []
}
if (toolCallId && subagentName) {
const openParents = (context.openSubagentParents ??= new Set<string>())
if (!openParents.has(toolCallId)) {
openParents.add(toolCallId)
context.contentBlocks.push({
type: 'subagent',
content: subagentName,
parentToolCallId: toolCallId,
...(spanId ? { spanId } : {}),
...(parentSpanId ? { parentSpanId } : {}),
timestamp: Date.now(),
})
}
} else {
logger.warn('subagent start missing toolCallId or agent name', {
hasToolCallId: Boolean(toolCallId),
hasSubagentName: Boolean(subagentName),
})
}
return
}
if (spanEvt === MothershipStreamV1SpanLifecycleEvent.end) {
if (isPendingPause) {
return
}
if (!toolCallId) {
logger.warn('subagent end missing toolCallId')
}
if (toolCallId) {
for (let i = context.contentBlocks.length - 1; i >= 0; i--) {
const b = context.contentBlocks[i]
if (
b.type === 'subagent' &&
b.endedAt === undefined &&
b.parentToolCallId === toolCallId
) {
b.endedAt = Date.now()
break
}
}
context.openSubagentParents?.delete(toolCallId)
}
return
}
}
// Subagent-lane events are routed ONLY by their own scope. A valid one
// (has parentToolCallId) goes to the subagent handler; a malformed one
// (missing parentToolCallId — Go always stamps it, so this is defensive)
// is DROPPED rather than falling through to the main handler, which would
// merge foreign subagent text/tools into the durable main assistant
// message and mis-attribute it.
if (streamEvent.scope?.lane === 'subagent') {
if (handleSubagentRouting(streamEvent, context)) {
const handler = subAgentHandlers[streamEvent.type]
if (handler) {
await handler(streamEvent, context, execContext, options)
}
}
return context.streamComplete || undefined
}
const handler = sseHandlers[streamEvent.type]
if (handler) {
await handler(streamEvent, context, execContext, options)
}
return context.streamComplete || undefined
} finally {
const dispatchMs = performance.now() - dispatchStart
counters.totalDispatchMs += dispatchMs
if (dispatchMs > counters.longestDispatchMs) counters.longestDispatchMs = dispatchMs
}
})
if (!context.streamComplete && !abortSignal?.aborted && !context.wasAborted) {
let abortRequested = false
try {
abortRequested = await hasAbortMarker(context.messageId)
} catch (error) {
logger.warn('Failed to read abort marker at body close', {
streamId: context.messageId,
error: getErrorMessage(error),
})
}
if (abortRequested) {
options.onAbortObserved?.(AbortReason.MarkerObservedAtBodyClose)
context.wasAborted = true
endedOn = CopilotSseCloseReason.Aborted
} else {
const streamPath = new URL(fetchUrl).pathname
const message = `Copilot backend stream ended before a terminal event on ${streamPath}`
context.errors.push(message)
logger.error('Copilot backend stream ended before a terminal event', {
path: streamPath,
requestId: context.requestId,
messageId: context.messageId,
})
endedOn = CopilotSseCloseReason.ClosedNoTerminal
throw new CopilotBackendError(message, { status: 503 })
}
}
} catch (error) {
if (error instanceof FatalSseEventError && !context.errors.includes(error.message)) {
context.errors.push(error.message)
}
if (endedOn === CopilotSseCloseReason.Terminal) {
endedOn =
error instanceof CopilotBackendError
? CopilotSseCloseReason.BackendError
: error instanceof BillingLimitError
? CopilotSseCloseReason.BillingLimit
: CopilotSseCloseReason.Error
}
throw error
} finally {
if (abortSignal?.aborted) {
context.wasAborted = true
await reader.cancel().catch(() => {})
if (endedOn === CopilotSseCloseReason.Terminal) {
endedOn = CopilotSseCloseReason.Aborted
}
}
// An abort or error can tear down the loop mid-thinking. Flush any
// open thinking blocks so partial-persistence on /chat/stop sees
// them in contentBlocks with endedAt stamped, instead of silently
// dropping the in-flight reasoning.
flushSubagentThinkingBlock(context)
flushThinkingBlock(context)
clearTimeout(timeoutId)
// Legacy TraceCollector span (consumed by the in-memory trace
// collector, kept for backwards compatibility with existing
// tooling). The real OTel span is stamped below.
const bodyDurationMs = Math.round(performance.now() - bodyStart)
bodySpan.attributes = {
...(bodySpan.attributes ?? {}),
eventsReceived: counters.events,
firstEventMs: counters.firstEventMs,
endedOn,
durationMs: bodyDurationMs,
}
context.trace.endSpan(
bodySpan,
endedOn === CopilotSseCloseReason.Terminal
? 'ok'
: endedOn === CopilotSseCloseReason.Aborted
? 'cancelled'
: 'error'
)
// Real OTel span for Tempo/Grafana. Stamped aggregate-only so
// there is no per-chunk OTel cost — one span per read loop with
// integer counters, plus a bounded set of events.
//
// `expectedTerminal` = "the caller considered this leg the FINAL
// leg and genuinely expected a terminal event on the wire." We
// derive it from `context.streamComplete` MINUS the tool-pause
// case: when the server emits a `run.checkpoint_pause`, its
// handler also sets `streamComplete=true` to stop the read loop
// cleanly, but no `complete` SSE event is ever sent in that
// case — that's the tool-pause protocol, not a missing terminal.
// `awaitingAsyncContinuation` is set by the same handler, so
// its presence distinguishes "tool pause, no terminal expected"
// from "caller thought stream was done but server never said so"
// (= the real disappeared-response bug class).
const expectedTerminal = context.streamComplete && !context.awaitingAsyncContinuation
stampSseReadLoopSpan(bodyStart, counters, endedOn, fetchUrl, pathname, {
idleGapEventThresholdMs: IDLE_GAP_EVENT_THRESHOLD_MS,
expectedTerminal,
})
}
}
function estimateBodyBytes(body: BodyInit | null | undefined): number {
if (!body) {
return 0
}
if (typeof body === 'string') {
return body.length
}
if (body instanceof ArrayBuffer) {
return body.byteLength
}
if (ArrayBuffer.isView(body)) {
return body.byteLength
}
return 0
}
type SseReadLoopCounters = {
bytes: number
chunks: number
events: number
eventsByType: Record<MothershipStreamV1EventType, number>
firstEventMs: number | undefined
longestInboundGapMs: number
longestDispatchMs: number
totalDispatchMs: number
}
/**
* Ship a one-shot `copilot.sse.read_loop` OTel span with the aggregate
* counters collected during the read loop. Uses `startTime` so the
* span's duration reflects the actual loop wall clock even though we
* only talk to OTel once at the end.
*
* Deliberately synchronous, no per-chunk span calls: total OTel cost
* per read loop is fixed (~10 attrs + up to 3 events), independent of
* chunk count.
*/
function stampSseReadLoopSpan(
startPerfMs: number,
counters: SseReadLoopCounters,
closeReason: string,
fetchUrl: string,
pathname: string,
opts: { idleGapEventThresholdMs: number; expectedTerminal: boolean }
): void {
// Translate performance.now() values into wall-clock Date values so
// the span's timestamps land in real time (OTel accepts both, but we
// need to pair startTime with a matching "now" for .end()).
const nowPerf = performance.now()
const nowWall = Date.now()
const startWall = nowWall - (nowPerf - startPerfMs)
const terminalEventSeen = counters.eventsByType.complete > 0 || counters.eventsByType.error > 0
// `terminal_event_missing` is the single-attribute dashboard signal
// for the "disappeared response" bug class: the caller considered
// this leg to be the final one (`context.streamComplete === true`)
// but no terminal `complete` or `error` event arrived on the wire.
// Tool-pause legs have expectedTerminal=false and never trip this, so
// dashboards can filter on `{ .copilot.sse.terminal_event_missing = true }`
// without false positives.
const terminalEventMissing = opts.expectedTerminal && !terminalEventSeen
const tracer = getCopilotTracer()
const span = tracer.startSpan(TraceSpan.CopilotSseReadLoop, {
startTime: startWall,
attributes: {
[TraceAttr.HttpUrl]: fetchUrl,
[TraceAttr.HttpPath]: pathname,
[TraceAttr.CopilotSseBytesReceived]: counters.bytes,
[TraceAttr.CopilotSseChunksReceived]: counters.chunks,
[TraceAttr.CopilotSseEventsReceived]: counters.events,
[TraceAttr.CopilotSseEventsSession]: counters.eventsByType.session,
[TraceAttr.CopilotSseEventsText]: counters.eventsByType.text,
[TraceAttr.CopilotSseEventsTool]: counters.eventsByType.tool,
[TraceAttr.CopilotSseEventsSpan]: counters.eventsByType.span,
[TraceAttr.CopilotSseEventsResource]: counters.eventsByType.resource,
[TraceAttr.CopilotSseEventsRun]: counters.eventsByType.run,
[TraceAttr.CopilotSseEventsError]: counters.eventsByType.error,
[TraceAttr.CopilotSseEventsComplete]: counters.eventsByType.complete,
[TraceAttr.CopilotSseLongestInboundGapMs]: Math.round(counters.longestInboundGapMs),
[TraceAttr.CopilotSseLongestDispatchMs]: Math.round(counters.longestDispatchMs),
[TraceAttr.CopilotSseTotalDispatchMs]: Math.round(counters.totalDispatchMs),
[TraceAttr.CopilotSseCloseReason]: closeReason,
[TraceAttr.CopilotSseExpectedTerminal]: opts.expectedTerminal,
[TraceAttr.CopilotSseTerminalEventSeen]: terminalEventSeen,
[TraceAttr.CopilotSseTerminalEventMissing]: terminalEventMissing,
},
})
if (counters.firstEventMs !== undefined) {
span.setAttribute(TraceAttr.CopilotSseFirstEventMs, counters.firstEventMs)
// Anchor the event to the moment the first SSE event was actually
// received (startWall + firstEventMs), not `now`, so a trace
// waterfall shows the diamond at the TTFT point — not at span end.
span.addEvent(
TraceEvent.CopilotSseFirstEvent,
{ [TraceAttr.CopilotSseFirstEventMs]: counters.firstEventMs },
startWall + counters.firstEventMs
)
}
// Fire the idle-gap event when the INBOUND gap (time between TCP
// reads returning bytes) exceeds the threshold. This is the
// "upstream was silent or Sim was CPU-bound" signal; dispatch time
// on its own doesn't warrant an event because it's within our
// control and visible on a dedicated attribute.
if (counters.longestInboundGapMs >= opts.idleGapEventThresholdMs) {
span.addEvent(TraceEvent.CopilotSseIdleGapExceeded, {
[TraceAttr.CopilotSseLongestInboundGapMs]: Math.round(counters.longestInboundGapMs),
[TraceAttr.CopilotSseLongestDispatchMs]: Math.round(counters.longestDispatchMs),
})
}
if (terminalEventSeen) {
span.addEvent(TraceEvent.CopilotSseTerminalEventReceived)
}
// Span status: only mark ERROR for real failures. User aborts and
// clean terminals stay UNSET so dashboards filtering `status=error`
// don't light up for normal cancellations. Tool-pause legs (caller
// didn't set streamComplete) are NOT errors even though they have
// no complete event.
if (terminalEventMissing) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: 'SSE read loop finished without terminal event (caller expected one)',
})
} else if (
closeReason !== CopilotSseCloseReason.Terminal &&
closeReason !== CopilotSseCloseReason.Aborted
) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: `SSE read loop ended with reason: ${closeReason}`,
})
}
span.end(nowWall)
}
@@ -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
}
+104
View File
@@ -0,0 +1,104 @@
import { safeCompare } from '@sim/security/compare'
import { generateId } from '@sim/utils/id'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/copilot/async-runs/lifecycle'
import { env } from '@/lib/core/config/env'
import { generateRequestId } from '@/lib/core/utils/request'
export const NotificationStatus = {
pending: 'pending',
...ASYNC_TOOL_CONFIRMATION_STATUS,
} as const
export type NotificationStatus = (typeof NotificationStatus)[keyof typeof NotificationStatus]
export interface CopilotAuthResult {
userId: string | null
isAuthenticated: boolean
}
export function createUnauthorizedResponse(): NextResponse {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
/**
* Creates a 400 Bad Request response for non-validation errors (business rule
* failures, missing entities, semantic mismatches).
*
* For Zod validation failures, use `validationErrorResponse` from
* `@/lib/api/server` instead — it returns the canonical
* `{ error, details: ZodIssue[] }` shape that lets clients introspect which
* field failed.
*/
export function createBadRequestResponse(message: string): NextResponse {
return NextResponse.json({ error: message }, { status: 400 })
}
export function createForbiddenResponse(message: string): NextResponse {
return NextResponse.json({ error: message }, { status: 403 })
}
export function createNotFoundResponse(message: string): NextResponse {
return NextResponse.json({ error: message }, { status: 404 })
}
export function createInternalServerErrorResponse(message: string): NextResponse {
return NextResponse.json({ error: message }, { status: 500 })
}
export function createRequestId(): string {
return generateId()
}
function createShortRequestId(): string {
return generateRequestId()
}
export interface RequestTracker {
requestId: string
startTime: number
getDuration(): number
}
export function createRequestTracker(short = true): RequestTracker {
const requestId = short ? createShortRequestId() : createRequestId()
const startTime = Date.now()
return {
requestId,
startTime,
getDuration(): number {
return Date.now() - startTime
},
}
}
export async function authenticateCopilotRequestSessionOnly(): Promise<CopilotAuthResult> {
const session = await getSession()
const userId = session?.user?.id || null
return {
userId,
isAuthenticated: userId !== null,
}
}
export function checkInternalApiKey(req: NextRequest) {
const apiKey = req.headers.get('x-api-key')
const expectedApiKey = env.INTERNAL_API_SECRET
if (!expectedApiKey) {
return { success: false, error: 'Internal API key not configured' }
}
if (!apiKey) {
return { success: false, error: 'API key required' }
}
if (!safeCompare(apiKey, expectedApiKey)) {
return { success: false, error: 'Invalid API key' }
}
return { success: true }
}
@@ -0,0 +1,195 @@
import { SpanStatusCode, trace } from '@opentelemetry/api'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { updateRunStatus } from '@/lib/copilot/async-runs/repository'
import {
MothershipStreamV1CompletionStatus,
MothershipStreamV1EventType,
} from '@/lib/copilot/generated/mothership-stream-v1'
import {
type RequestTraceV1Outcome,
RequestTraceV1Outcome as RequestTraceV1OutcomeConst,
} from '@/lib/copilot/generated/request-trace-v1'
import { CopilotFinalizeOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import type { StreamWriter } from '@/lib/copilot/request/session'
import type { OrchestratorResult } from '@/lib/copilot/request/types'
const logger = createLogger('CopilotStreamFinalize')
const getTracer = () => trace.getTracer('sim-copilot-finalize', '1.0.0')
// Single finalization path. `outcome` is the caller's resolved verdict
// so we don't have to re-derive cancel vs error from raw signals.
export async function finalizeStream(
result: OrchestratorResult,
publisher: StreamWriter,
runId: string,
outcome: RequestTraceV1Outcome,
requestId: string
): Promise<void> {
const spanOutcome =
outcome === RequestTraceV1OutcomeConst.cancelled
? CopilotFinalizeOutcome.Aborted
: outcome === RequestTraceV1OutcomeConst.success
? CopilotFinalizeOutcome.Success
: CopilotFinalizeOutcome.Error
const span = getTracer().startSpan(TraceSpan.CopilotFinalizeStream, {
attributes: {
[TraceAttr.CopilotFinalizeOutcome]: spanOutcome,
[TraceAttr.RunId]: runId,
[TraceAttr.RequestId]: requestId,
[TraceAttr.CopilotResultToolCalls]: result.toolCalls?.length ?? 0,
[TraceAttr.CopilotResultContentBlocks]: result.contentBlocks?.length ?? 0,
[TraceAttr.CopilotResultContentLength]: result.content?.length ?? 0,
[TraceAttr.CopilotPublisherSawComplete]: publisher.sawComplete,
[TraceAttr.CopilotPublisherClientDisconnected]: publisher.clientDisconnected,
},
})
try {
if (outcome === RequestTraceV1OutcomeConst.cancelled) {
await handleAborted(result, publisher, runId, requestId)
} else if (outcome === RequestTraceV1OutcomeConst.error) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: result.error || 'orchestration failed',
})
await handleError(result, publisher, runId, requestId)
} else {
await handleSuccess(publisher, runId, requestId)
}
// Successful + cancelled paths fall through as status-unset → set
// OK so dashboards don't show "incomplete" for normal terminals.
if (outcome !== RequestTraceV1OutcomeConst.error) {
span.setStatus({ code: SpanStatusCode.OK })
}
} catch (error) {
span.recordException(toError(error))
span.setStatus({ code: SpanStatusCode.ERROR, message: 'finalize threw' })
throw error
} finally {
span.end()
}
}
async function handleAborted(
result: OrchestratorResult,
publisher: StreamWriter,
runId: string,
requestId: string
): Promise<void> {
const partialContentLen = result.content?.length ?? 0
const toolCallCount = result.toolCalls?.length ?? 0
const blockCount = result.contentBlocks?.length ?? 0
logger.info(`[${requestId}] Stream aborted by explicit stop`, {
partialContentLen,
toolCallCount,
blockCount,
})
if (!publisher.sawComplete) {
const partialContent = result.content || undefined
await publisher.publish({
type: MothershipStreamV1EventType.complete,
payload: {
status: MothershipStreamV1CompletionStatus.cancelled,
...(partialContent ? { partialContent } : {}),
...(partialContentLen ? { partialContentLen } : {}),
...(toolCallCount ? { toolCallCount } : {}),
},
})
}
await publisher.flush()
await loggedRunStatusUpdate(runId, MothershipStreamV1CompletionStatus.cancelled, requestId, {
completedAt: new Date(),
})
}
async function handleError(
result: OrchestratorResult,
publisher: StreamWriter,
runId: string,
requestId: string
): Promise<void> {
const errorMessage =
result.error ||
result.errors?.[0] ||
'An unexpected error occurred while processing the response.'
// Persist whatever was generated before the failure, exactly like an abort —
// a transient provider error (e.g. overloaded) shouldn't discard the partial
// assistant output the user already saw streaming.
const partialContent = result.content || undefined
const partialContentLen = result.content?.length ?? 0
const toolCallCount = result.toolCalls?.length ?? 0
if (publisher.clientDisconnected) {
logger.info(`[${requestId}] Stream failed after client disconnect`, { error: errorMessage })
}
logger.error(`[${requestId}] Orchestration returned failure`, {
error: errorMessage,
partialContentLen,
toolCallCount,
})
// Surface the real error (Go already classifies provider errors like
// "overloaded" into a friendly displayMessage). Don't clobber it with a
// generic string.
await publisher.publish({
type: MothershipStreamV1EventType.error,
payload: {
message: errorMessage,
error: errorMessage,
displayMessage: errorMessage,
data: { displayMessage: errorMessage },
},
})
if (!publisher.sawComplete) {
await publisher.publish({
type: MothershipStreamV1EventType.complete,
payload: {
status: MothershipStreamV1CompletionStatus.error,
...(partialContent ? { partialContent } : {}),
...(partialContentLen ? { partialContentLen } : {}),
...(toolCallCount ? { toolCallCount } : {}),
},
})
}
await publisher.flush()
await loggedRunStatusUpdate(runId, MothershipStreamV1CompletionStatus.error, requestId, {
completedAt: new Date(),
error: errorMessage,
})
}
async function handleSuccess(
publisher: StreamWriter,
runId: string,
requestId: string
): Promise<void> {
if (!publisher.sawComplete) {
await publisher.publish({
type: MothershipStreamV1EventType.complete,
payload: { status: MothershipStreamV1CompletionStatus.complete },
})
}
await publisher.flush()
await loggedRunStatusUpdate(runId, MothershipStreamV1CompletionStatus.complete, requestId, {
completedAt: new Date(),
})
}
async function loggedRunStatusUpdate(
runId: string,
status: Parameters<typeof updateRunStatus>[1],
requestId: string,
updates: Parameters<typeof updateRunStatus>[2] = {}
): Promise<void> {
try {
await updateRunStatus(runId, status, updates)
} catch (error) {
logger.warn(`[${requestId}] Failed to update run status to ${status}`, {
runId,
error: toError(error).message,
})
}
}
@@ -0,0 +1,171 @@
/**
* @vitest-environment node
*/
import { propagation, trace } from '@opentelemetry/api'
import { W3CTraceContextPropagator } from '@opentelemetry/core'
import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OrchestratorResult } from '@/lib/copilot/request/types'
const { runCopilotLifecycle } = vi.hoisted(() => ({
runCopilotLifecycle: vi.fn(),
}))
vi.mock('@/lib/copilot/request/lifecycle/run', () => ({
runCopilotLifecycle,
}))
import { runHeadlessCopilotLifecycle } from './headless'
function createLifecycleResult(overrides?: Partial<OrchestratorResult>): OrchestratorResult {
return {
success: true,
content: 'done',
contentBlocks: [],
toolCalls: [],
chatId: 'chat-1',
...overrides,
}
}
describe('runHeadlessCopilotLifecycle', () => {
beforeEach(() => {
trace.setGlobalTracerProvider(new BasicTracerProvider())
propagation.setGlobalPropagator(new W3CTraceContextPropagator())
})
afterEach(() => {
vi.clearAllMocks()
})
it('runs the lifecycle and returns its result', async () => {
runCopilotLifecycle.mockResolvedValueOnce(
createLifecycleResult({
usage: { prompt: 10, completion: 5 },
cost: { input: 1, output: 2, total: 3 },
})
)
const result = await runHeadlessCopilotLifecycle(
{
message: 'hello',
messageId: 'req-1',
},
{
userId: 'user-1',
chatId: 'chat-1',
workflowId: 'workflow-1',
goRoute: '/api/mothership/execute',
interactive: false,
}
)
expect(result.success).toBe(true)
expect(runCopilotLifecycle).toHaveBeenCalledWith(
expect.objectContaining({ messageId: 'req-1' }),
expect.objectContaining({
simRequestId: 'req-1',
trace: expect.any(Object),
otelContext: expect.any(Object),
chatId: 'chat-1',
})
)
})
it('returns an unsuccessful result from the lifecycle', async () => {
runCopilotLifecycle.mockResolvedValueOnce(
createLifecycleResult({
success: false,
error: 'failed',
})
)
const result = await runHeadlessCopilotLifecycle(
{
message: 'hello',
messageId: 'req-2',
},
{
userId: 'user-1',
chatId: 'chat-1',
workflowId: 'workflow-1',
goRoute: '/api/mothership/execute',
interactive: false,
}
)
expect(result.success).toBe(false)
})
it('prefers an explicit simRequestId over the payload messageId', async () => {
runCopilotLifecycle.mockResolvedValueOnce(createLifecycleResult())
await runHeadlessCopilotLifecycle(
{
message: 'hello',
messageId: 'message-req-id',
},
{
userId: 'user-1',
chatId: 'chat-1',
workflowId: 'workflow-1',
simRequestId: 'workflow-request-id',
goRoute: '/api/mothership/execute',
interactive: false,
}
)
expect(runCopilotLifecycle).toHaveBeenCalledWith(
expect.objectContaining({ messageId: 'message-req-id' }),
expect.objectContaining({
simRequestId: 'workflow-request-id',
})
)
})
it('threads a valid OTel context into the lifecycle', async () => {
let lifecycleTraceparent = ''
runCopilotLifecycle.mockImplementationOnce(async (_payload, options) => {
const { traceHeaders } = await import('@/lib/copilot/request/go/propagation')
lifecycleTraceparent = traceHeaders({}, options.otelContext).traceparent ?? ''
return createLifecycleResult()
})
await runHeadlessCopilotLifecycle(
{
message: 'hello',
messageId: 'req-otel',
},
{
userId: 'user-1',
chatId: 'chat-1',
workflowId: 'workflow-1',
goRoute: '/api/mothership/execute',
interactive: false,
}
)
expect(lifecycleTraceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-0[0-9a-f]$/)
})
it('rethrows when the lifecycle throws', async () => {
runCopilotLifecycle.mockRejectedValueOnce(new Error('kaboom'))
await expect(
runHeadlessCopilotLifecycle(
{
message: 'hello',
messageId: 'req-3',
},
{
userId: 'user-1',
chatId: 'chat-1',
workflowId: 'workflow-1',
goRoute: '/api/mothership/execute',
interactive: false,
}
)
).rejects.toThrow('kaboom')
})
})
@@ -0,0 +1,79 @@
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import type { RequestTraceV1Outcome as RequestTraceOutcome } from '@/lib/copilot/generated/request-trace-v1'
import {
RequestTraceV1Outcome,
RequestTraceV1SpanStatus,
} from '@/lib/copilot/generated/request-trace-v1'
import { CopilotTransport } from '@/lib/copilot/generated/trace-attribute-values-v1'
import type { CopilotLifecycleOptions } from '@/lib/copilot/request/lifecycle/run'
import { runCopilotLifecycle } from '@/lib/copilot/request/lifecycle/run'
import { withCopilotOtelContext } from '@/lib/copilot/request/otel'
import { TraceCollector } from '@/lib/copilot/request/trace'
import type { OrchestratorResult } from '@/lib/copilot/request/types'
const logger = createLogger('CopilotHeadlessLifecycle')
export async function runHeadlessCopilotLifecycle(
requestPayload: Record<string, unknown>,
options: CopilotLifecycleOptions
): Promise<OrchestratorResult> {
const simRequestId =
typeof options.simRequestId === 'string' && options.simRequestId.length > 0
? options.simRequestId
: typeof requestPayload.messageId === 'string' && requestPayload.messageId.length > 0
? requestPayload.messageId
: generateId()
const trace = new TraceCollector()
const requestSpan = trace.startSpan('Headless Sim Agent Request', 'request', {
route: options.goRoute,
workflowId: options.workflowId,
workspaceId: options.workspaceId,
chatId: options.chatId,
})
let result: OrchestratorResult | undefined
let outcome: RequestTraceOutcome = RequestTraceV1Outcome.error
return withCopilotOtelContext(
{
requestId: simRequestId,
route: options.goRoute,
chatId: options.chatId,
workflowId: options.workflowId,
executionId: options.executionId,
runId: options.runId,
transport: CopilotTransport.Headless,
},
async (otelContext) => {
try {
result = await runCopilotLifecycle(requestPayload, {
...options,
trace,
simRequestId,
otelContext,
})
outcome = result.success
? RequestTraceV1Outcome.success
: options.abortSignal?.aborted || result.cancelled
? RequestTraceV1Outcome.cancelled
: RequestTraceV1Outcome.error
return result
} catch (error) {
outcome = options.abortSignal?.aborted
? RequestTraceV1Outcome.cancelled
: RequestTraceV1Outcome.error
throw error
} finally {
trace.endSpan(
requestSpan,
outcome === RequestTraceV1Outcome.success
? RequestTraceV1SpanStatus.ok
: outcome === RequestTraceV1Outcome.cancelled
? RequestTraceV1SpanStatus.cancelled
: RequestTraceV1SpanStatus.error
)
}
}
)
}
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import { createStreamingContext } from '@/lib/copilot/request/context/request-context'
import { makeResumeLegContext, mergeResumeLegOutputs } from '@/lib/copilot/request/lifecycle/run'
// Guards the makeResumeLegContext / mergeResumeLegOutputs contract: the two MUST
// stay in lockstep (every per-leg-isolated scalar is reset on leg creation and
// folded back on merge), and the heavy accumulators stay shared by reference so
// all concurrent legs build one chat. This is the regression the inline comment
// warns about — without per-leg isolation the orchestrator's pre-fanout content
// gets multiplied by the leg count on merge.
describe('resume leg context isolate/merge contract', () => {
it('isolates the per-leg scalars while sharing the heavy accumulators by reference', () => {
const base = createStreamingContext({
accumulatedContent: 'PRE',
finalAssistantContent: 'PRE-FINAL',
usage: { prompt: 10, completion: 5 },
cost: { input: 1, output: 2, total: 3 },
errors: ['pre-existing'],
})
const leg = makeResumeLegContext(base)
// Per-leg scalars reset so a leg accumulates only its OWN output.
expect(leg.accumulatedContent).toBe('')
expect(leg.finalAssistantContent).toBe('')
expect(leg.usage).toBeUndefined()
expect(leg.cost).toBeUndefined()
expect(leg.errors).toEqual([])
expect(leg.streamComplete).toBe(false)
expect(leg.awaitingAsyncContinuation).toBeUndefined()
// A leg's own errors array is a fresh array (not the shared one) so a leg's
// retry rollback can't truncate a sibling's errors.
expect(leg.errors).not.toBe(base.errors)
// Heavy accumulators stay shared by reference (one merged chat).
expect(leg.contentBlocks).toBe(base.contentBlocks)
expect(leg.toolCalls).toBe(base.toolCalls)
expect(leg.pendingToolPromises).toBe(base.pendingToolPromises)
expect(leg.subAgentContent).toBe(base.subAgentContent)
})
it('folds a leg back exactly once (no double-count of the orchestrator content)', () => {
const base = createStreamingContext({ accumulatedContent: 'PRE', errors: ['pre'] })
const leg = makeResumeLegContext(base)
leg.accumulatedContent = 'JOIN'
leg.finalAssistantContent = 'JOIN-FINAL'
leg.usage = { prompt: 100, completion: 50 }
leg.cost = { input: 4, output: 5, total: 9 }
leg.errors.push('leg-err')
mergeResumeLegOutputs(base, leg)
// PRE seeded once + the leg's own output appended once — not PRE+PRE+JOIN.
expect(base.accumulatedContent).toBe('PREJOIN')
expect(base.finalAssistantContent).toBe('JOIN-FINAL')
expect(base.usage).toEqual({ prompt: 100, completion: 50 })
expect(base.cost).toEqual({ input: 4, output: 5, total: 9 })
expect(base.errors).toEqual(['pre', 'leg-err'])
})
it('does not multiply pre-fanout content across many legs (N children + one join leg)', () => {
const base = createStreamingContext({ accumulatedContent: 'PRE' })
// Seven child legs that stream subagent content (not main accumulatedContent)
// contribute nothing to the join scalars; only the join-carrying leg does.
for (let i = 0; i < 7; i++) {
const childLeg = makeResumeLegContext(base)
mergeResumeLegOutputs(base, childLeg)
}
const joinLeg = makeResumeLegContext(base)
joinLeg.accumulatedContent = 'SUMMARY'
joinLeg.usage = { prompt: 1, completion: 1 }
mergeResumeLegOutputs(base, joinLeg)
// Exactly the pre-fanout content + the one join leg's summary — the 7 child
// legs must not each re-append 'PRE'.
expect(base.accumulatedContent).toBe('PRESUMMARY')
expect(base.usage).toEqual({ prompt: 1, completion: 1 })
})
})
@@ -0,0 +1,690 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExecutionContext, StreamingContext } from '@/lib/copilot/request/types'
const {
mockCreateRunSegment,
mockForceFailHungToolCall,
mockGetEffectiveDecryptedEnv,
mockGetMothershipBaseURL,
mockGetMothershipSourceEnvHeaders,
mockPrepareExecutionContext,
mockRunStreamLoop,
mockToolWatchdogTimeoutMs,
mockUpdateRunStatus,
} = vi.hoisted(() => ({
mockCreateRunSegment: vi.fn(),
mockForceFailHungToolCall: vi.fn(),
mockGetEffectiveDecryptedEnv: vi.fn(),
mockGetMothershipBaseURL: vi.fn(),
mockGetMothershipSourceEnvHeaders: vi.fn(),
mockPrepareExecutionContext: vi.fn(),
mockRunStreamLoop: vi.fn(),
mockToolWatchdogTimeoutMs: vi.fn(() => 60_000),
mockUpdateRunStatus: vi.fn(),
}))
vi.mock('@/lib/copilot/async-runs/repository', () => ({
createRunSegment: mockCreateRunSegment,
updateRunStatus: mockUpdateRunStatus,
}))
vi.mock('@/lib/copilot/request/go/stream', () => {
class CopilotBackendError extends Error {
status?: number
constructor(message: string, options?: { status?: number }) {
super(message)
this.name = 'CopilotBackendError'
this.status = options?.status
}
}
class BillingLimitError extends Error {
userId: string
constructor(userId: string) {
super('Usage limit reached')
this.name = 'BillingLimitError'
this.userId = userId
}
}
return {
BillingLimitError,
CopilotBackendError,
runStreamLoop: mockRunStreamLoop,
}
})
vi.mock('@/lib/copilot/server/agent-url', () => ({
getMothershipBaseURL: mockGetMothershipBaseURL,
getMothershipSourceEnvHeaders: mockGetMothershipSourceEnvHeaders,
}))
vi.mock('@/lib/core/config/env', () => ({
env: {
COPILOT_API_KEY: undefined,
},
getEnv: vi.fn((key: string) => (key === 'NEXT_PUBLIC_APP_URL' ? 'http://localhost:3000' : '')),
isTruthy: vi.fn((value: string | undefined) => value === 'true'),
isFalsy: vi.fn((value: string | undefined) => value === 'false'),
}))
vi.mock('@/lib/environment/utils', () => ({
getEffectiveDecryptedEnv: mockGetEffectiveDecryptedEnv,
}))
vi.mock('@/lib/copilot/tools/handlers/context', () => ({
prepareExecutionContext: mockPrepareExecutionContext,
}))
vi.mock('@/lib/copilot/request/tools/billing', () => ({
handleBillingLimitResponse: vi.fn(),
}))
vi.mock('@/lib/copilot/request/tools/executor', () => ({
executeToolAndReport: vi.fn(),
forceFailHungToolCall: mockForceFailHungToolCall,
toolWatchdogTimeoutMs: mockToolWatchdogTimeoutMs,
}))
import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1'
import { CopilotBackendError } from '@/lib/copilot/request/go/stream'
import { runCopilotLifecycle } from '@/lib/copilot/request/lifecycle/run'
describe('runCopilotLifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetMothershipBaseURL.mockResolvedValue('http://mothership.test')
mockGetMothershipSourceEnvHeaders.mockReturnValue({})
})
it('runs cancelled completion persistence when a stream throws after abort', async () => {
const abortController = new AbortController()
abortController.abort('stop')
const onComplete = vi.fn()
const onError = vi.fn()
const executionContext: ExecutionContext = {
userId: 'user-1',
workflowId: '',
workspaceId: 'ws-1',
chatId: 'chat-1',
decryptedEnvVars: {},
}
mockRunStreamLoop.mockImplementationOnce(
async (
_fetchUrl: string,
_fetchOptions: RequestInit,
context: StreamingContext
): Promise<void> => {
context.accumulatedContent = 'partial answer'
context.contentBlocks.push({
type: 'text',
content: 'partial answer',
timestamp: 1,
})
throw new Error('publisher closed after stop')
}
)
const result = await runCopilotLifecycle(
{ message: 'hello', messageId: 'stream-1' },
{
userId: 'user-1',
workspaceId: 'ws-1',
chatId: 'chat-1',
executionId: 'exec-1',
runId: 'run-1',
abortSignal: abortController.signal,
executionContext,
onComplete,
onError,
}
)
expect(onError).not.toHaveBeenCalled()
expect(onComplete).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
cancelled: true,
content: 'partial answer',
chatId: 'chat-1',
requestId: undefined,
error: 'publisher closed after stop',
contentBlocks: [
expect.objectContaining({
type: 'text',
content: 'partial answer',
}),
],
})
)
expect(result).toEqual(
expect.objectContaining({
success: false,
cancelled: true,
content: 'partial answer',
chatId: 'chat-1',
error: 'publisher closed after stop',
})
)
})
it('returns the cancelled result when cancelled completion persistence fails', async () => {
const abortController = new AbortController()
abortController.abort('stop')
const onComplete = vi.fn().mockRejectedValue(new Error('db unavailable'))
const onError = vi.fn()
const executionContext: ExecutionContext = {
userId: 'user-1',
workflowId: '',
workspaceId: 'ws-1',
chatId: 'chat-1',
decryptedEnvVars: {},
}
mockRunStreamLoop.mockImplementationOnce(
async (
_fetchUrl: string,
_fetchOptions: RequestInit,
context: StreamingContext
): Promise<void> => {
context.accumulatedContent = 'partial answer'
throw new Error('publisher closed after stop')
}
)
const result = await runCopilotLifecycle(
{ message: 'hello', messageId: 'stream-1' },
{
userId: 'user-1',
workspaceId: 'ws-1',
chatId: 'chat-1',
executionId: 'exec-1',
runId: 'run-1',
abortSignal: abortController.signal,
executionContext,
onComplete,
onError,
}
)
expect(onError).not.toHaveBeenCalled()
expect(onComplete).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
cancelled: true,
content: 'partial answer',
})
)
expect(result).toEqual(
expect.objectContaining({
success: false,
cancelled: true,
content: 'partial answer',
error: 'publisher closed after stop',
})
)
})
it('uses the final post-tool assistant content for headless results', async () => {
const executionContext: ExecutionContext = {
userId: 'user-1',
workflowId: '',
workspaceId: 'ws-1',
chatId: 'chat-1',
decryptedEnvVars: {},
}
mockRunStreamLoop.mockImplementationOnce(
async (
_fetchUrl: string,
_fetchOptions: RequestInit,
context: StreamingContext
): Promise<void> => {
context.accumulatedContent = 'I will check that.Final answer only.'
context.finalAssistantContent = 'Final answer only.'
context.sawMainToolCall = true
}
)
const result = await runCopilotLifecycle(
{ message: 'hello', messageId: 'stream-1' },
{
userId: 'user-1',
workspaceId: 'ws-1',
chatId: 'chat-1',
executionId: 'exec-1',
runId: 'run-1',
executionContext,
interactive: false,
}
)
expect(result).toEqual(
expect.objectContaining({
success: true,
content: 'Final answer only.',
})
)
})
it('does not fall back to pre-tool narration when headless final content is empty', async () => {
const executionContext: ExecutionContext = {
userId: 'user-1',
workflowId: '',
workspaceId: 'ws-1',
chatId: 'chat-1',
decryptedEnvVars: {},
}
mockRunStreamLoop.mockImplementationOnce(
async (
_fetchUrl: string,
_fetchOptions: RequestInit,
context: StreamingContext
): Promise<void> => {
context.accumulatedContent = 'I will check that.'
context.finalAssistantContent = ''
context.sawMainToolCall = true
}
)
const result = await runCopilotLifecycle(
{ message: 'hello', messageId: 'stream-1' },
{
userId: 'user-1',
workspaceId: 'ws-1',
chatId: 'chat-1',
executionId: 'exec-1',
runId: 'run-1',
executionContext,
interactive: false,
}
)
expect(result).toEqual(
expect.objectContaining({
success: true,
content: '',
})
)
})
it('propagates payload userPermission into the generated execution context', async () => {
let capturedExecContext: ExecutionContext | undefined
mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({})
mockRunStreamLoop.mockImplementationOnce(
async (
_fetchUrl: string,
_fetchOptions: RequestInit,
_context: StreamingContext,
execContext: ExecutionContext
): Promise<void> => {
capturedExecContext = execContext
}
)
await runCopilotLifecycle(
{ message: 'hello', messageId: 'stream-1', userPermission: 'write' },
{
userId: 'user-1',
workspaceId: 'ws-1',
chatId: 'chat-1',
}
)
expect(capturedExecContext).toEqual(
expect.objectContaining({
userId: 'user-1',
workspaceId: 'ws-1',
chatId: 'chat-1',
userPermission: 'write',
})
)
})
it('normalizes the initial request body with workspaceId from lifecycle options', async () => {
let requestBody: Record<string, unknown> | undefined
mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({})
mockRunStreamLoop.mockImplementationOnce(
async (_fetchUrl: string, fetchOptions: RequestInit): Promise<void> => {
requestBody = JSON.parse(String(fetchOptions.body))
}
)
await runCopilotLifecycle(
{ message: 'hello', messageId: 'stream-1' },
{
userId: 'user-1',
workspaceId: 'ws-1',
chatId: 'chat-1',
}
)
expect(requestBody).toEqual(
expect.objectContaining({
workspaceId: 'ws-1',
})
)
})
it('uses the lifecycle workspaceId for async tool resume requests', async () => {
const requestBodies: Record<string, unknown>[] = []
const fetchUrls: string[] = []
const executionContext: ExecutionContext = {
userId: 'user-1',
workflowId: 'workflow-1',
workspaceId: 'ws-1',
chatId: 'chat-1',
decryptedEnvVars: {},
}
mockRunStreamLoop.mockImplementationOnce(
async (
fetchUrl: string,
fetchOptions: RequestInit,
context: StreamingContext
): Promise<void> => {
fetchUrls.push(fetchUrl)
requestBodies.push(JSON.parse(String(fetchOptions.body)))
context.toolCalls.set('tool-1', {
id: 'tool-1',
name: 'read',
status: MothershipStreamV1ToolOutcome.success,
result: { success: true, output: { content: 'file contents' } },
})
context.awaitingAsyncContinuation = {
checkpointId: 'ckpt-1',
pendingToolCallIds: ['tool-1'],
}
}
)
mockRunStreamLoop.mockImplementationOnce(
async (fetchUrl: string, fetchOptions: RequestInit): Promise<void> => {
fetchUrls.push(fetchUrl)
requestBodies.push(JSON.parse(String(fetchOptions.body)))
}
)
await runCopilotLifecycle(
{ message: 'hello', messageId: 'stream-1' },
{
userId: 'user-1',
workspaceId: 'ws-1',
workflowId: 'workflow-1',
chatId: 'chat-1',
executionId: 'exec-1',
runId: 'run-1',
executionContext,
}
)
expect(fetchUrls[1]).toBe('http://mothership.test/api/tools/resume')
expect(requestBodies[1]).toEqual(
expect.objectContaining({
checkpointId: 'ckpt-1',
userId: 'user-1',
workspaceId: 'ws-1',
})
)
})
it('finalizes as success when a resume fails with a retryable error then the retry succeeds', async () => {
const executionContext: ExecutionContext = {
userId: 'user-1',
workflowId: '',
workspaceId: 'ws-1',
chatId: 'chat-1',
decryptedEnvVars: {},
}
// 1) Initial stream pauses on an async tool checkpoint with a resolved
// tool result, so the lifecycle transitions into a resume leg.
mockRunStreamLoop.mockImplementationOnce(
async (
_fetchUrl: string,
_fetchOptions: RequestInit,
context: StreamingContext
): Promise<void> => {
context.toolCalls.set('tool-1', {
id: 'tool-1',
name: 'read',
status: MothershipStreamV1ToolOutcome.success,
result: { success: true, output: { content: 'file contents' } },
})
context.awaitingAsyncContinuation = {
checkpointId: 'ckpt-1',
pendingToolCallIds: ['tool-1'],
}
}
)
// 2) First resume leg dies mid-stream like a transient provider error:
// it records an error AND throws a retryable 5xx.
mockRunStreamLoop.mockImplementationOnce(
async (
_fetchUrl: string,
_fetchOptions: RequestInit,
context: StreamingContext
): Promise<void> => {
context.errors.push(
'Copilot backend stream ended before a terminal event on /api/tools/resume'
)
throw new CopilotBackendError('backend stream ended before a terminal event', {
status: 503,
})
}
)
// 3) Retry of the same resume leg succeeds cleanly.
mockRunStreamLoop.mockImplementationOnce(
async (
_fetchUrl: string,
_fetchOptions: RequestInit,
context: StreamingContext
): Promise<void> => {
context.accumulatedContent = 'Recovered final answer.'
context.finalAssistantContent = 'Recovered final answer.'
}
)
const result = await runCopilotLifecycle(
{ message: 'hello', messageId: 'stream-1' },
{
userId: 'user-1',
workspaceId: 'ws-1',
chatId: 'chat-1',
executionId: 'exec-1',
runId: 'run-1',
executionContext,
}
)
// Three legs ran (initial + failed resume + retried resume), and the
// recovered retry must NOT inherit the failed attempt's error.
expect(mockRunStreamLoop).toHaveBeenCalledTimes(3)
expect(result).toEqual(
expect.objectContaining({
success: true,
cancelled: false,
errors: undefined,
})
)
})
it('marks resume legs willRetryOnStreamError except the final attempt', async () => {
const bodies: Record<string, unknown>[] = []
const executionContext: ExecutionContext = {
userId: 'user-1',
workflowId: '',
workspaceId: 'ws-1',
chatId: 'chat-1',
decryptedEnvVars: {},
}
// Initial leg pauses on a resolved async tool checkpoint → enters resume.
mockRunStreamLoop.mockImplementationOnce(
async (
_fetchUrl: string,
fetchOptions: RequestInit,
context: StreamingContext
): Promise<void> => {
bodies.push(JSON.parse(String(fetchOptions.body)))
context.toolCalls.set('tool-1', {
id: 'tool-1',
name: 'read',
status: MothershipStreamV1ToolOutcome.success,
result: { success: true, output: { content: 'file contents' } },
})
context.awaitingAsyncContinuation = {
checkpointId: 'ckpt-1',
pendingToolCallIds: ['tool-1'],
}
}
)
// Three resume attempts, all failing with a retryable 5xx so the loop
// exhausts MAX_RESUME_ATTEMPTS (= 3) and gives up.
for (let i = 0; i < 3; i++) {
mockRunStreamLoop.mockImplementationOnce(
async (
_fetchUrl: string,
fetchOptions: RequestInit,
context: StreamingContext
): Promise<void> => {
bodies.push(JSON.parse(String(fetchOptions.body)))
context.errors.push('Copilot backend stream ended before a terminal event')
throw new CopilotBackendError('backend stream ended before a terminal event', {
status: 503,
})
}
)
}
await runCopilotLifecycle(
{ message: 'hello', messageId: 'stream-1' },
{
userId: 'user-1',
workspaceId: 'ws-1',
chatId: 'chat-1',
executionId: 'exec-1',
runId: 'run-1',
executionContext,
}
)
// Initial + 3 resume attempts.
expect(mockRunStreamLoop).toHaveBeenCalledTimes(4)
// Initial leg is never retried by this loop → no flag.
expect(bodies[0].willRetryOnStreamError).toBeUndefined()
// Resume attempts 0 and 1 will be retried on a stream error → flagged.
expect(bodies[1].willRetryOnStreamError).toBe(true)
expect(bodies[2].willRetryOnStreamError).toBe(true)
// Final attempt (2) is terminal → not flagged, so Go bills + surfaces it.
expect(bodies[3].willRetryOnStreamError).toBeUndefined()
})
it('force-fails a hung tool promise and resumes with an error result instead of wedging', async () => {
vi.useFakeTimers()
try {
const fetchUrls: string[] = []
const bodies: Record<string, unknown>[] = []
const executionContext: ExecutionContext = {
userId: 'user-1',
workflowId: '',
workspaceId: 'ws-1',
chatId: 'chat-1',
decryptedEnvVars: {},
}
// Mirror the real helper: settle the tool call into a terminal error
// state so the resume loop can serialize an error result for it.
mockForceFailHungToolCall.mockImplementation(
async (toolCallId: string, context: StreamingContext, message: string) => {
const tool = context.toolCalls.get(toolCallId)
if (!tool) return
tool.status = MothershipStreamV1ToolOutcome.error
tool.endTime = Date.now()
tool.result = { success: false }
tool.error = message
}
)
// Initial leg checkpoints on an async tool whose promise NEVER settles —
// the exact shape of the prod incident (claimed, marked running, hung).
mockRunStreamLoop.mockImplementationOnce(
async (
fetchUrl: string,
fetchOptions: RequestInit,
context: StreamingContext
): Promise<void> => {
fetchUrls.push(fetchUrl)
bodies.push(JSON.parse(String(fetchOptions.body)))
context.toolCalls.set('tool-hung', {
id: 'tool-hung',
name: 'read',
status: 'executing',
})
context.pendingToolPromises.set('tool-hung', new Promise(() => {}))
context.awaitingAsyncContinuation = {
checkpointId: 'ckpt-1',
pendingToolCallIds: ['tool-hung'],
}
}
)
// Resume leg completes normally with the error result delivered.
mockRunStreamLoop.mockImplementationOnce(
async (
fetchUrl: string,
fetchOptions: RequestInit,
context: StreamingContext
): Promise<void> => {
fetchUrls.push(fetchUrl)
bodies.push(JSON.parse(String(fetchOptions.body)))
context.accumulatedContent = 'The file read failed, but here is what I know.'
}
)
const lifecycle = runCopilotLifecycle(
{ message: 'hello', messageId: 'stream-1' },
{
userId: 'user-1',
workspaceId: 'ws-1',
chatId: 'chat-1',
executionId: 'exec-1',
runId: 'run-1',
executionContext,
}
)
// Wait budget = watchdog (60s, mocked) + resume grace (30s). Advance past it.
await vi.advanceTimersByTimeAsync(91_000)
const result = await lifecycle
expect(mockForceFailHungToolCall).toHaveBeenCalledWith(
'tool-hung',
expect.anything(),
expect.stringContaining('hung')
)
expect(fetchUrls[1]).toBe('http://mothership.test/api/tools/resume')
expect(bodies[1].results).toEqual([
expect.objectContaining({
callId: 'tool-hung',
name: 'read',
success: false,
data: { error: expect.stringContaining('hung') },
}),
])
expect(result.success).toBe(true)
} finally {
vi.useRealTimers()
}
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,294 @@
/**
* @vitest-environment node
*/
import { propagation, trace } from '@opentelemetry/api'
import { W3CTraceContextPropagator } from '@opentelemetry/core'
import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
MothershipStreamV1CompletionStatus,
MothershipStreamV1EventType,
} from '@/lib/copilot/generated/mothership-stream-v1'
const {
runCopilotLifecycle,
createRunSegment,
updateRunStatus,
resetBuffer,
clearFilePreviewSessions,
scheduleBufferCleanup,
scheduleFilePreviewSessionCleanup,
allocateCursor,
appendEvent,
cleanupAbortMarker,
hasAbortMarker,
releasePendingChatStream,
} = vi.hoisted(() => ({
runCopilotLifecycle: vi.fn(),
createRunSegment: vi.fn(),
updateRunStatus: vi.fn(),
resetBuffer: vi.fn(),
clearFilePreviewSessions: vi.fn(),
scheduleBufferCleanup: vi.fn(),
scheduleFilePreviewSessionCleanup: vi.fn(),
allocateCursor: vi.fn(),
appendEvent: vi.fn(),
cleanupAbortMarker: vi.fn(),
hasAbortMarker: vi.fn(),
releasePendingChatStream: vi.fn(),
}))
vi.mock('@/lib/copilot/request/lifecycle/run', () => ({
runCopilotLifecycle,
}))
vi.mock('@/lib/copilot/async-runs/repository', () => ({
createRunSegment,
updateRunStatus,
}))
let mockPublisherController: ReadableStreamDefaultController | null = null
vi.mock('@/lib/copilot/request/session', () => ({
resetBuffer,
clearFilePreviewSessions,
scheduleBufferCleanup,
scheduleFilePreviewSessionCleanup,
allocateCursor,
appendEvent,
cleanupAbortMarker,
hasAbortMarker,
releasePendingChatStream,
registerActiveStream: vi.fn(),
unregisterActiveStream: vi.fn(),
startAbortPoller: vi.fn().mockReturnValue(setInterval(() => {}, 999999)),
isExplicitStopReason: vi.fn().mockReturnValue(false),
SSE_RESPONSE_HEADERS: {},
StreamWriter: vi.fn().mockImplementation(
class {
attach = vi.fn().mockImplementation((ctrl: ReadableStreamDefaultController) => {
mockPublisherController = ctrl
})
startKeepalive = vi.fn()
stopKeepalive = vi.fn()
flush = vi.fn()
close = vi.fn().mockImplementation(() => {
try {
mockPublisherController?.close()
} catch {
// already closed
}
})
markDisconnected = vi.fn()
publish = vi.fn().mockImplementation(async (event: Record<string, unknown>) => {
appendEvent(event)
})
get clientDisconnected() {
return false
}
get sawComplete() {
return false
}
}
),
}))
vi.mock('@/lib/copilot/request/session/sse', () => ({
SSE_RESPONSE_HEADERS: {},
}))
vi.mock('@sim/db', () => ({
db: {
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(),
})),
})),
},
}))
vi.mock('@/lib/copilot/chat-status', () => ({
chatPubSub: null,
}))
import { createSSEStream } from './start'
async function drainStream(stream: ReadableStream) {
const reader = stream.getReader()
while (true) {
const { done } = await reader.read()
if (done) break
}
}
describe('createSSEStream terminal error handling', () => {
beforeEach(() => {
vi.clearAllMocks()
trace.setGlobalTracerProvider(new BasicTracerProvider())
propagation.setGlobalPropagator(new W3CTraceContextPropagator())
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ title: 'Test title' }), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
})
)
)
resetBuffer.mockResolvedValue(undefined)
clearFilePreviewSessions.mockResolvedValue(undefined)
scheduleBufferCleanup.mockResolvedValue(undefined)
scheduleFilePreviewSessionCleanup.mockResolvedValue(undefined)
allocateCursor
.mockResolvedValueOnce({ seq: 1, cursor: '1' })
.mockResolvedValueOnce({ seq: 2, cursor: '2' })
.mockResolvedValueOnce({ seq: 3, cursor: '3' })
appendEvent.mockImplementation(async (event: unknown) => event)
cleanupAbortMarker.mockResolvedValue(undefined)
hasAbortMarker.mockResolvedValue(false)
releasePendingChatStream.mockResolvedValue(undefined)
createRunSegment.mockResolvedValue(null)
updateRunStatus.mockResolvedValue(null)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('writes a terminal error event before close when orchestration returns success=false', async () => {
runCopilotLifecycle.mockResolvedValue({
success: false,
error: 'resume failed',
content: '',
contentBlocks: [],
toolCalls: [],
})
const stream = createSSEStream({
requestPayload: { message: 'hello' },
userId: 'user-1',
streamId: 'stream-1',
executionId: 'exec-1',
runId: 'run-1',
currentChat: null,
isNewChat: false,
message: 'hello',
titleModel: 'gpt-5.4',
requestId: 'req-1',
orchestrateOptions: {},
})
await drainStream(stream)
expect(appendEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: MothershipStreamV1EventType.error,
})
)
expect(scheduleBufferCleanup).toHaveBeenCalledWith('stream-1')
})
it('writes the thrown terminal error event before close for replay durability', async () => {
runCopilotLifecycle.mockRejectedValue(new Error('kaboom'))
const stream = createSSEStream({
requestPayload: { message: 'hello' },
userId: 'user-1',
streamId: 'stream-1',
executionId: 'exec-1',
runId: 'run-1',
currentChat: null,
isNewChat: false,
message: 'hello',
titleModel: 'gpt-5.4',
requestId: 'req-1',
orchestrateOptions: {},
})
await drainStream(stream)
expect(appendEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: MothershipStreamV1EventType.error,
})
)
expect(scheduleBufferCleanup).toHaveBeenCalledWith('stream-1')
})
it('publishes a cancelled completion (not an error) when the orchestrator reports cancelled without abortSignal aborted', async () => {
runCopilotLifecycle.mockResolvedValue({
success: false,
cancelled: true,
content: '',
contentBlocks: [],
toolCalls: [],
})
const stream = createSSEStream({
requestPayload: { message: 'hello' },
userId: 'user-1',
streamId: 'stream-1',
executionId: 'exec-1',
runId: 'run-1',
currentChat: null,
isNewChat: false,
message: 'hello',
titleModel: 'gpt-5.4',
requestId: 'req-cancelled',
orchestrateOptions: {},
})
await drainStream(stream)
expect(appendEvent).not.toHaveBeenCalledWith(
expect.objectContaining({
type: MothershipStreamV1EventType.error,
})
)
expect(appendEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: MothershipStreamV1EventType.complete,
payload: expect.objectContaining({
status: MothershipStreamV1CompletionStatus.cancelled,
}),
})
)
})
it('passes an OTel context into the streaming lifecycle', async () => {
let lifecycleTraceparent = ''
runCopilotLifecycle.mockImplementation(async (_payload, options) => {
const { traceHeaders } = await import('@/lib/copilot/request/go/propagation')
lifecycleTraceparent = traceHeaders({}, options.otelContext).traceparent ?? ''
return {
success: true,
content: 'OK',
contentBlocks: [],
toolCalls: [],
}
})
const stream = createSSEStream({
requestPayload: { message: 'hello' },
userId: 'user-1',
streamId: 'stream-1',
executionId: 'exec-1',
runId: 'run-1',
currentChat: null,
isNewChat: false,
message: 'hello',
titleModel: 'gpt-5.4',
requestId: 'req-otel',
orchestrateOptions: {
goRoute: '/api/mothership',
workflowId: 'workflow-1',
},
})
await drainStream(stream)
expect(lifecycleTraceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-0[0-9a-f]$/)
})
})
@@ -0,0 +1,531 @@
import { type Context, context as otelContextApi } from '@opentelemetry/api'
import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
import { chatPubSub } from '@/lib/copilot/chat-status'
import {
MothershipStreamV1EventType,
MothershipStreamV1SessionKind,
} from '@/lib/copilot/generated/mothership-stream-v1'
import {
RequestTraceV1Outcome,
RequestTraceV1SpanStatus,
} from '@/lib/copilot/generated/request-trace-v1'
import {
CopilotRequestCancelReason,
type CopilotRequestCancelReasonValue,
CopilotTransport,
} from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1'
import { finalizeStream } from '@/lib/copilot/request/lifecycle/finalize'
import type { CopilotLifecycleOptions } from '@/lib/copilot/request/lifecycle/run'
import { runCopilotLifecycle } from '@/lib/copilot/request/lifecycle/run'
import { type CopilotLifecycleOutcome, startCopilotOtelRoot } from '@/lib/copilot/request/otel'
import {
cleanupAbortMarker,
clearFilePreviewSessions,
isExplicitStopReason,
registerActiveStream,
releasePendingChatStream,
resetBuffer,
StreamWriter,
scheduleBufferCleanup,
scheduleFilePreviewSessionCleanup,
startAbortPoller,
unregisterActiveStream,
} from '@/lib/copilot/request/session'
import { SSE_RESPONSE_HEADERS } from '@/lib/copilot/request/session/sse'
import { TraceCollector } from '@/lib/copilot/request/trace'
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
export { SSE_RESPONSE_HEADERS }
const logger = createLogger('CopilotChatStreaming')
type CurrentChatSummary = {
title?: string | null
} | null
export interface StreamingOrchestrationParams {
requestPayload: Record<string, unknown>
userId: string
streamId: string
executionId: string
runId: string
chatId?: string
currentChat: CurrentChatSummary
isNewChat: boolean
message: string
titleModel: string
titleProvider?: string
requestId: string
workspaceId?: string
orchestrateOptions: Omit<CopilotLifecycleOptions, 'onEvent'>
/**
* Pre-started root; child spans bind to it and `finish()` fires on
* termination. Omit to let the stream start its own root (headless).
*/
otelRoot?: ReturnType<typeof startCopilotOtelRoot>
}
export function createSSEStream(params: StreamingOrchestrationParams): ReadableStream {
const {
requestPayload,
userId,
streamId,
executionId,
runId,
chatId,
currentChat,
isNewChat,
message,
titleModel,
titleProvider,
requestId,
workspaceId,
orchestrateOptions,
otelRoot,
} = params
// Reuse caller's root if provided; otherwise start our own.
const activeOtelRoot =
otelRoot ??
startCopilotOtelRoot({
requestId,
route: orchestrateOptions.goRoute,
chatId,
workflowId: orchestrateOptions.workflowId,
executionId,
runId,
streamId,
transport: CopilotTransport.Stream,
})
const abortController = new AbortController()
registerActiveStream(streamId, abortController)
const publisher = new StreamWriter({ streamId, chatId, requestId })
// Classify cancel: signal.reason (explicit-stop set) wins, then
// clientDisconnected, else Unknown (latent contract bug — log it).
const recordCancelled = (errorMessage?: string): CopilotRequestCancelReasonValue => {
const rawReason = abortController.signal.reason
let cancelReason: CopilotRequestCancelReasonValue
if (isExplicitStopReason(rawReason)) {
cancelReason = CopilotRequestCancelReason.ExplicitStop
} else if (publisher.clientDisconnected) {
cancelReason = CopilotRequestCancelReason.ClientDisconnect
} else {
cancelReason = CopilotRequestCancelReason.Unknown
const serializedReason =
rawReason === undefined
? 'undefined'
: rawReason instanceof Error
? `${rawReason.name}: ${rawReason.message}`
: typeof rawReason === 'string'
? rawReason
: (() => {
try {
return JSON.stringify(rawReason)
} catch {
return String(rawReason)
}
})()
// Contract violation: add the new reason to AbortReason /
// isExplicitStopReason or extend the classifier.
logger.error(`[${requestId}] Stream cancelled with unknown abort reason`, {
streamId,
chatId,
reason: serializedReason,
})
activeOtelRoot.span.setAttribute(TraceAttr.CopilotAbortUnknownReason, serializedReason)
}
activeOtelRoot.span.setAttribute(TraceAttr.CopilotRequestCancelReason, cancelReason)
activeOtelRoot.span.addEvent(TraceEvent.RequestCancelled, {
[TraceAttr.CopilotRequestCancelReason]: cancelReason,
...(errorMessage ? { [TraceAttr.ErrorMessage]: errorMessage } : {}),
})
return cancelReason
}
const collector = new TraceCollector()
return new ReadableStream({
async start(controller) {
publisher.attach(controller)
// Re-enter the root OTel context — ALS doesn't survive the
// Next handler → ReadableStream.start boundary.
await otelContextApi.with(activeOtelRoot.context, async () => {
const otelContext = activeOtelRoot.context
let rootOutcome: CopilotLifecycleOutcome = RequestTraceV1Outcome.error
let rootError: unknown
// `cancelReason` must be declared OUTSIDE the outer `try` so
// it remains in scope for the outer `finally` that calls
// `activeOtelRoot.finish(rootOutcome, rootError, cancelReason)`.
// `let` bindings declared inside a `try` block are NOT visible
// in the paired `finally`; referencing one there raises a
// TDZ ReferenceError, skipping `finish()`, leaving the root
// span never-ended, and making Tempo see every child as an
// orphan under a phantom parent. (Regression landed 2026-04-21.)
let cancelReason: CopilotRequestCancelReasonValue | undefined
try {
const requestSpan = collector.startSpan('Sim Agent Request', 'request', {
streamId,
chatId,
runId,
})
let outcome: CopilotLifecycleOutcome = RequestTraceV1Outcome.error
let lifecycleResult:
| {
usage?: { prompt: number; completion: number }
cost?: { input: number; output: number; total: number }
}
| undefined
await Promise.all([resetBuffer(streamId), clearFilePreviewSessions(streamId)])
if (chatId) {
createRunSegment({
id: runId,
executionId,
chatId,
userId,
workflowId: (requestPayload.workflowId as string | undefined) || null,
workspaceId,
streamId,
model: (requestPayload.model as string | undefined) || null,
provider: (requestPayload.provider as string | undefined) || null,
requestContext: { requestId },
}).catch((error) => {
logger.warn(`[${requestId}] Failed to create copilot run segment`, {
error: getErrorMessage(error),
})
})
}
const abortPoller = startAbortPoller(streamId, abortController, {
requestId,
chatId,
})
publisher.startKeepalive()
if (chatId) {
publisher.publish({
type: MothershipStreamV1EventType.session,
payload: {
kind: MothershipStreamV1SessionKind.chat,
chatId,
},
})
}
fireTitleGeneration({
chatId,
currentChat,
isNewChat,
userId,
message,
titleModel,
titleProvider,
workspaceId,
requestId,
publisher,
otelContext,
})
try {
const result = await runCopilotLifecycle(requestPayload, {
...orchestrateOptions,
executionId,
runId,
trace: collector,
simRequestId: requestId,
otelContext,
abortSignal: abortController.signal,
onEvent: async (event) => {
await publisher.publish(event)
},
onAbortObserved: (reason) => {
if (!abortController.signal.aborted) {
abortController.abort(reason)
}
},
})
lifecycleResult = result
// Outcome classification (priority order):
// 1. `result.success` → success. The orchestrator
// reporting "finished cleanly" wins over any later
// signal change. Matters for the narrow race where
// the user clicks Stop a beat after the stream
// completed.
// 2. `signal.aborted` (from `abortActiveStream` or the
// Redis-marker poller) OR `clientDisconnected` with
// a non-success result → cancelled. `recordCancelled`
// further refines into explicit_stop / client_disconnect
// / unknown via `signal.reason`.
// 3. Otherwise → error.
outcome = result.success
? RequestTraceV1Outcome.success
: result.cancelled || abortController.signal.aborted || publisher.clientDisconnected
? RequestTraceV1Outcome.cancelled
: RequestTraceV1Outcome.error
if (outcome === RequestTraceV1Outcome.cancelled) {
cancelReason = recordCancelled()
}
// Pass the resolved outcome — not `signal.aborted` — so
// `finalizeStream` classifies the same way we did above.
// A client-disconnect-without-controller-abort still needs
// to hit `handleAborted` (not `handleError`) so the chat
// row gets `cancelled` terminal state instead of `error`.
await finalizeStream(result, publisher, runId, outcome, requestId)
} catch (error) {
// Error-path classification: if the abort signal fired or
// the client disconnected, treat the thrown error as a
// cancel (same rationale as the try-path above).
const wasCancelled = abortController.signal.aborted || publisher.clientDisconnected
outcome = wasCancelled ? RequestTraceV1Outcome.cancelled : RequestTraceV1Outcome.error
if (outcome === RequestTraceV1Outcome.cancelled) {
cancelReason = recordCancelled(getErrorMessage(error))
}
if (publisher.clientDisconnected) {
logger.info(`[${requestId}] Stream errored after client disconnect`, {
error: getErrorMessage(error, 'Stream error'),
})
}
// Demote to warn when the throw came from a user-initiated
// cancel — it isn't an "unexpected" failure then, and the
// error-level log pollutes alerting on normal Stop presses.
const logFn = outcome === RequestTraceV1Outcome.cancelled ? logger.warn : logger.error
logFn.call(logger, `[${requestId}] Orchestration ended with ${outcome}:`, error)
const syntheticResult = {
success: false as const,
content: '',
contentBlocks: [],
toolCalls: [],
error: 'An unexpected error occurred while processing the response.',
}
await finalizeStream(syntheticResult, publisher, runId, outcome, requestId)
} finally {
collector.endSpan(
requestSpan,
outcome === RequestTraceV1Outcome.success
? RequestTraceV1SpanStatus.ok
: outcome === RequestTraceV1Outcome.cancelled
? RequestTraceV1SpanStatus.cancelled
: RequestTraceV1SpanStatus.error
)
clearInterval(abortPoller)
try {
await publisher.close()
} catch (error) {
logger.warn(`[${requestId}] Failed to flush stream persistence during close`, {
error: getErrorMessage(error),
})
}
unregisterActiveStream(streamId)
if (chatId) {
await releasePendingChatStream(chatId, streamId)
}
await scheduleBufferCleanup(streamId)
await scheduleFilePreviewSessionCleanup(streamId)
await cleanupAbortMarker(streamId)
rootOutcome = outcome
if (lifecycleResult?.usage) {
activeOtelRoot.span.setAttributes({
[TraceAttr.GenAiUsageInputTokens]: lifecycleResult.usage.prompt ?? 0,
[TraceAttr.GenAiUsageOutputTokens]: lifecycleResult.usage.completion ?? 0,
})
}
if (lifecycleResult?.cost) {
activeOtelRoot.span.setAttributes({
[TraceAttr.BillingCostInputUsd]: lifecycleResult.cost.input ?? 0,
[TraceAttr.BillingCostOutputUsd]: lifecycleResult.cost.output ?? 0,
[TraceAttr.BillingCostTotalUsd]: lifecycleResult.cost.total ?? 0,
})
}
}
} catch (error) {
rootOutcome = RequestTraceV1Outcome.error
rootError = error
throw error
} finally {
// `finish` is idempotent, so it's safe whether the POST
// handler started the root (and may also call finish on an
// error path before the stream ran) or we did. The cancel
// reason (if any) determines whether `cancelled` is an
// expected outcome (explicit_stop → status OK) or a real
// error (client_disconnect / unknown → status ERROR).
//
// Belt-and-suspenders: if `finish()` itself throws (e.g. an
// argument in the TDZ, a bad attribute, a regression in
// status-setting), fall back to `span.end()` directly. A
// root that never ends leaves every child orphaned in Tempo
// under a phantom parent; force-ending it keeps the trace
// shape intact even when the pretty-finalize path is
// broken. The error is logged so Loki greps surface the
// regression instead of it silently costing us trace
// fidelity for hours.
try {
activeOtelRoot.finish(rootOutcome, rootError, cancelReason)
} catch (finishError) {
logger.error(`[${requestId}] activeOtelRoot.finish threw; force-ending root span`, {
error: getErrorMessage(finishError),
})
try {
activeOtelRoot.span.end()
} catch {
// Already ended or an OTel internal failure — nothing
// more we can do. The export pipe has already had its
// chance; swallow to avoid masking the original error
// path.
}
}
}
})
},
cancel() {
// The browser's SSE reader closed. Flip `clientDisconnected` so
// in-flight `publisher.publish` calls silently no-op (prevents
// enqueueing on a closed controller).
//
// Browser disconnect is NOT an abort — firing the controller
// here retroactively reclassifies in-flight successful streams
// as aborted and skips assistant persistence. Let the
// orchestrator drain naturally; publish no-ops post-disconnect.
// Explicit Stop still fires the controller via /chat/abort.
publisher.markDisconnected()
},
})
}
// ---------------------------------------------------------------------------
// Title generation (fire-and-forget side effect)
// ---------------------------------------------------------------------------
function fireTitleGeneration(params: {
chatId?: string
currentChat: CurrentChatSummary
isNewChat: boolean
userId?: string
message: string
titleModel: string
titleProvider?: string
workspaceId?: string
requestId: string
publisher: StreamWriter
otelContext?: Context
}): void {
const {
chatId,
currentChat,
isNewChat,
userId,
message,
titleModel,
titleProvider,
workspaceId,
requestId,
publisher,
otelContext,
} = params
if (!chatId || currentChat?.title || !isNewChat) return
requestChatTitle({
message,
model: titleModel,
provider: titleProvider,
userId,
workspaceId,
otelContext,
})
.then(async (title) => {
if (!title) return
await db.update(copilotChats).set({ title }).where(eq(copilotChats.id, chatId))
await publisher.publish({
type: MothershipStreamV1EventType.session,
payload: { kind: MothershipStreamV1SessionKind.title, title },
})
if (workspaceId) {
chatPubSub?.publishStatusChanged({
workspaceId,
chatId,
type: 'renamed',
})
}
})
.catch((error) => {
logger.error(`[${requestId}] Title generation failed:`, error)
})
}
// ---------------------------------------------------------------------------
// Chat title helper
// ---------------------------------------------------------------------------
export async function requestChatTitle(params: {
message: string
model: string
provider?: string
userId?: string
workspaceId?: string
otelContext?: Context
}): Promise<string | null> {
const { message, model, provider, userId, workspaceId, otelContext } = params
if (!message || !model) return null
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (env.COPILOT_API_KEY) {
headers['x-api-key'] = env.COPILOT_API_KEY
}
Object.assign(headers, getMothershipSourceEnvHeaders())
try {
const { fetchGo } = await import('@/lib/copilot/request/go/fetch')
const mothershipBaseURL = await getMothershipBaseURL({ userId })
const response = await fetchGo(`${mothershipBaseURL}/api/generate-chat-title`, {
method: 'POST',
headers,
body: JSON.stringify({
message,
model,
...(provider ? { provider } : {}),
...(workspaceId ? { workspaceId } : {}),
...(userId ? { userId } : {}),
}),
otelContext,
spanName: 'sim → go /api/generate-chat-title',
operation: 'generate_chat_title',
attributes: {
[TraceAttr.GenAiRequestModel]: model,
...(provider ? { [TraceAttr.GenAiSystem]: provider } : {}),
},
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
logger.warn('Failed to generate chat title via copilot backend', {
status: response.status,
error: payload,
})
return null
}
const title = typeof payload?.title === 'string' ? payload.title.trim() : ''
return title || null
} catch (error) {
logger.error('Error generating chat title:', error)
return null
}
}
+101
View File
@@ -0,0 +1,101 @@
// Sim server-side copilot metrics (U17). Sim's MeterProvider is wired in
// instrumentation-node.ts (OTLP → Mimir, 60s) but had no copilot instruments;
// this module is its first consumer. We emit the SAME metric names + label keys
// + histogram bucket boundaries as the Go side (copilot internal/telemetry +
// contracts/metrics_v1.go) so the GoSim union is queryable as one series set
// — e.g. `copilot.tool.duration` split by `tool.executor` (go|client|sim).
//
// Bounded cardinality only: tool.name is capped to the shared tool catalog
// (else "other"); vfs phase / file-read outcome are bounded sets. NEVER a
// user/chat/request id (those explode Prometheus series).
import { type Counter, type Histogram, metrics } from '@opentelemetry/api'
import { Metric } from '@/lib/copilot/generated/metrics-v1'
import { TOOL_CATALOG } from '@/lib/copilot/generated/tool-catalog-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
// MUST match Go's copilot/internal/telemetry/metrics.go LatencyBucketsMs
// exactly — a histogram_quantile(sum by (le) …) over the GoSim union is only
// valid with identical boundaries. If you change one side, change the other.
const LATENCY_BUCKETS_MS = [
50, 100, 250, 500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 120000, 300000,
]
// File sizes span KB→tens of MB; a bytes-appropriate bucket set (not latency).
const BYTE_BUCKETS = [1024, 8192, 65536, 262144, 1048576, 4194304, 16777216, 67108864, 268435456]
interface CopilotMeterInstruments {
toolDuration: Histogram
toolCalls: Counter
vfsMaterializeDuration: Histogram
fileReadDuration: Histogram
fileReadBytes: Histogram
}
let cached: CopilotMeterInstruments | undefined
// Lazy init: Turbopack/Next can evaluate this module before the NodeSDK
// installs the real MeterProvider, so resolve instruments on first use (a
// no-op meter before then simply drops records — same pattern as getCopilotTracer).
function instruments(): CopilotMeterInstruments {
if (cached) return cached
const meter = metrics.getMeter('sim-copilot')
cached = {
toolDuration: meter.createHistogram(Metric.CopilotToolDuration, {
unit: 'ms',
advice: { explicitBucketBoundaries: LATENCY_BUCKETS_MS },
}),
toolCalls: meter.createCounter(Metric.CopilotToolCalls),
vfsMaterializeDuration: meter.createHistogram(Metric.CopilotVfsMaterializeDuration, {
unit: 'ms',
advice: { explicitBucketBoundaries: LATENCY_BUCKETS_MS },
}),
fileReadDuration: meter.createHistogram(Metric.CopilotFileReadDuration, {
unit: 'ms',
advice: { explicitBucketBoundaries: LATENCY_BUCKETS_MS },
}),
fileReadBytes: meter.createHistogram(Metric.CopilotFileReadSize, {
unit: 'By',
advice: { explicitBucketBoundaries: BYTE_BUCKETS },
}),
}
return cached
}
// Caps tool.name to the shared catalog (matches Go's cappedToolName): a
// catalog tool keeps its name, everything else (user MCP/custom/unknown)
// collapses to "other" so series count stays finite.
function cappedToolName(name: string): string {
return TOOL_CATALOG[name] ? name : 'other'
}
// recordSimToolMetric emits copilot.tool.calls (+1) and copilot.tool.duration
// for one server-side Sim tool dispatch (executor=sim). outcome is the bounded
// tool outcome (success/error/…). Pure telemetry.
export function recordSimToolMetric(name: string, outcome: string, durationMs: number): void {
const { toolDuration, toolCalls } = instruments()
const attrs = {
[TraceAttr.ToolName]: cappedToolName(name),
[TraceAttr.ToolExecutor]: 'sim',
[TraceAttr.ToolOutcome]: outcome,
}
toolCalls.add(1, attrs)
if (durationMs >= 0) toolDuration.record(durationMs, attrs)
}
// recordVfsMaterialize records VFS materialization time. Call once per phase
// with that phase's duration and once with phase="total" for the whole op, so
// the dashboard can show total + per-phase. phase must be a bounded value.
export function recordVfsMaterialize(phase: string, durationMs: number): void {
if (durationMs < 0) return
instruments().vfsMaterializeDuration.record(durationMs, {
[TraceAttr.CopilotVfsPhase]: phase,
})
}
// recordFileRead records server-side file-read duration + size by outcome.
export function recordFileRead(outcome: string, durationMs: number, bytes: number): void {
const { fileReadDuration, fileReadBytes } = instruments()
const attrs = { [TraceAttr.CopilotVfsReadOutcome]: outcome }
if (durationMs >= 0) fileReadDuration.record(durationMs, attrs)
if (bytes >= 0) fileReadBytes.record(bytes, attrs)
}
+593
View File
@@ -0,0 +1,593 @@
import { randomBytes } from 'crypto'
import {
type Context,
context,
ROOT_CONTEXT,
type Span,
type SpanContext,
SpanKind,
SpanStatusCode,
TraceFlags,
trace,
} from '@opentelemetry/api'
import { describeError, toError } from '@sim/utils/errors'
import { RequestTraceV1Outcome } from '@/lib/copilot/generated/request-trace-v1'
import {
CopilotBranchKind,
CopilotRequestCancelReason,
type CopilotRequestCancelReasonValue,
CopilotSurface,
CopilotTransport,
} from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { contextFromRequestHeaders } from '@/lib/copilot/request/go/propagation'
import { isExplicitStopReason } from '@/lib/copilot/request/session/abort-reason'
// OTel GenAI content-capture env var (spec:
// https://opentelemetry.io/docs/specs/semconv/gen-ai/). Mirrored on
// the Go side so a single var controls both halves.
const GENAI_CAPTURE_ENV = 'OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'
// OTLP backends commonly reject attrs over 64 KiB; cap proactively.
const GENAI_MESSAGE_ATTR_MAX_BYTES = 60 * 1024
function isGenAIMessageCaptureEnabled(): boolean {
const raw = (process.env[GENAI_CAPTURE_ENV] || '').toLowerCase().trim()
return raw === 'true' || raw === '1' || raw === 'yes'
}
// True iff `err` represents the user explicitly clicking Stop — the
// only cancellation we treat as expected (non-error).
//
// Policy across the codebase: an explicit user stop leaves span
// status UNSET; every other cancellation (client tab close,
// network drop, internal timeout, uncategorized abort) escalates
// to `status=error` so it shows up on error dashboards. This is
// the Sim mirror of `requestctx.IsExplicitUserStop` on the Go
// side; keep the two semantically aligned.
//
// Detection modes:
//
// - Plain-string reject value: `controller.abort('user_stop:...')`
// rejects fetch() with the reason STRING directly. Matches
// `isExplicitStopReason()` exactly (UserStop / RedisPoller).
// - DOMException / Error object: `controller.abort()` with no arg
// (or older runtimes) rejects with an AbortError whose `.cause`
// or `.message` may carry the reason. We inspect both.
//
// Anything that doesn't resolve to an explicit-stop reason (plain
// AbortError with no identifiable cause, timeout-flavored aborts,
// arbitrary Error instances) returns false and gets `status=error`.
export function isExplicitUserStopError(err: unknown): boolean {
if (err == null) return false
if (typeof err === 'string') return isExplicitStopReason(err)
if (typeof err === 'object') {
const e = err as { cause?: unknown; message?: unknown }
if (isExplicitStopReason(e.cause)) return true
if (typeof e.message === 'string' && isExplicitStopReason(e.message)) return true
}
return false
}
/**
* True iff an HTTP response status code represents a real server-side
* problem (5xx) or a user-visible condition we want to alert on
* (402 Payment Required, 409 Conflict, 429 Too Many Requests).
*
* Everything else — in particular the 4xx flood from bot probes and
* expected auth/validation rejections — stays UNSET on the span so
* dashboards don't treat normal rejections as errors.
*
* Mirrored on the Go side in
* `copilot/internal/http/middleware/telemetry.go`. Keep the two in
* sync if you change the actionable set.
*/
export function isActionableErrorStatus(code: number): boolean {
if (code >= 500) return true
return code === 402 || code === 409 || code === 429
}
// Record exception + set ERROR unless the error is an explicit user
// stop (see `isExplicitUserStopError`). Every other cancellation —
// client disconnect, internal timeout, uncategorized AbortError —
// becomes a real error that the dashboards will surface.
export function markSpanForError(span: Span, error: unknown): void {
const asError = toError(error)
span.recordException(asError)
const described = describeError(error)
if (described.code) {
span.setAttribute(TraceAttr.ErrorCode, described.code)
}
if (!isExplicitUserStopError(error)) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: described.causeChain ? described.causeChain.join(' <- ') : asError.message,
})
}
}
// OTel GenAI message shape (kept minimal). Mirror changes on the Go side.
interface GenAIAgentPart {
type: 'text' | 'tool_call' | 'tool_call_response'
content?: string
id?: string
name?: string
arguments?: Record<string, unknown>
response?: string
}
interface GenAIAgentMessage {
role: 'system' | 'user' | 'assistant' | 'tool'
parts: GenAIAgentPart[]
}
function marshalAgentMessages(messages: GenAIAgentMessage[]): string | undefined {
if (messages.length === 0) return undefined
const json = JSON.stringify(messages)
if (json.length <= GENAI_MESSAGE_ATTR_MAX_BYTES) return json
// Simple tail-preserving truncation: drop from the front until we
// fit. Matches the Go side's behavior. The last message is
// usually the most diagnostic for span-level outcome.
let remaining = messages.slice()
while (remaining.length > 1) {
remaining = remaining.slice(1)
const candidate = JSON.stringify(remaining)
if (candidate.length <= GENAI_MESSAGE_ATTR_MAX_BYTES) return candidate
}
// Single message still over cap — truncate the text part in place
// with a marker so the partial content is still readable.
const only = remaining[0]
for (const part of only.parts) {
if (part.type === 'text' && part.content) {
const headroom = GENAI_MESSAGE_ATTR_MAX_BYTES - 1024
if (part.content.length > headroom) {
part.content = `${part.content.slice(0, headroom)}\n\n[truncated: capture cap ${GENAI_MESSAGE_ATTR_MAX_BYTES} bytes]`
}
}
}
const final = JSON.stringify([only])
return final.length <= GENAI_MESSAGE_ATTR_MAX_BYTES ? final : undefined
}
interface CopilotAgentInputMessages {
userMessage?: string
systemPrompt?: string
}
interface CopilotAgentOutputMessages {
assistantText?: string
toolCalls?: Array<{
id: string
name: string
arguments?: Record<string, unknown>
}>
}
function setAgentInputMessages(span: Span, input: CopilotAgentInputMessages): void {
if (!isGenAIMessageCaptureEnabled()) return
const messages: GenAIAgentMessage[] = []
if (input.systemPrompt) {
messages.push({
role: 'system',
parts: [{ type: 'text', content: input.systemPrompt }],
})
}
if (input.userMessage) {
messages.push({
role: 'user',
parts: [{ type: 'text', content: input.userMessage }],
})
}
const serialized = marshalAgentMessages(messages)
if (serialized) {
span.setAttribute(TraceAttr.GenAiInputMessages, serialized)
}
}
function setAgentOutputMessages(span: Span, output: CopilotAgentOutputMessages): void {
if (!isGenAIMessageCaptureEnabled()) return
const parts: GenAIAgentPart[] = []
if (output.assistantText) {
parts.push({ type: 'text', content: output.assistantText })
}
for (const tc of output.toolCalls ?? []) {
parts.push({
type: 'tool_call',
id: tc.id,
name: tc.name,
...(tc.arguments ? { arguments: tc.arguments } : {}),
})
}
if (parts.length === 0) return
const serialized = marshalAgentMessages([{ role: 'assistant', parts }])
if (serialized) {
span.setAttribute(TraceAttr.GenAiOutputMessages, serialized)
}
}
export type CopilotLifecycleOutcome =
(typeof RequestTraceV1Outcome)[keyof typeof RequestTraceV1Outcome]
// Lazy tracer — Next 16/Turbopack can evaluate modules before NodeSDK
// installs the real TracerProvider; resolving per call avoids a
// cached NoOpTracer silently disabling OTel.
export function getCopilotTracer() {
return trace.getTracer('sim-ai-platform', '1.0.0')
}
function getTracer() {
return getCopilotTracer()
}
// Wrap an inbound handler that Go called into so its span parents
// under the Go-side trace (via `traceparent`).
export async function withIncomingGoSpan<T>(
headers: Headers,
spanName: string,
attributes: Record<string, string | number | boolean> | undefined,
fn: (span: Span) => Promise<T>
): Promise<T> {
const parentContext = contextFromRequestHeaders(headers)
const tracer = getTracer()
return tracer.startActiveSpan(
spanName,
{ kind: SpanKind.SERVER, attributes },
parentContext,
async (span) => {
try {
const result = await fn(span)
span.setStatus({ code: SpanStatusCode.OK })
return result
} catch (error) {
markSpanForError(span, error)
throw error
} finally {
span.end()
}
}
)
}
// Wrap a copilot-lifecycle op in an OTel span. Pass `parentContext`
// explicitly when AsyncLocalStorage-tracked context can be dropped
// across multiple awaits (otherwise the child falls back to a framework
// span that the sampler drops).
export async function withCopilotSpan<T>(
spanName: string,
attributes: Record<string, string | number | boolean> | undefined,
fn: (span: Span) => Promise<T>,
parentContext?: Context
): Promise<T> {
const tracer = getTracer()
const runBody = async (span: Span) => {
try {
const result = await fn(span)
span.setStatus({ code: SpanStatusCode.OK })
return result
} catch (error) {
markSpanForError(span, error)
throw error
} finally {
span.end()
}
}
if (parentContext) {
return tracer.startActiveSpan(spanName, { attributes }, parentContext, runBody)
}
return tracer.startActiveSpan(spanName, { attributes }, runBody)
}
// External OTel `tool.execute` span for Sim-side tool work (the Go
// side's `tool.execute` is just the enqueue, stays ~0ms).
export async function withCopilotToolSpan<T>(
input: {
toolName: string
toolCallId: string
runId?: string
chatId?: string
argsBytes?: number
argsPreview?: string
},
fn: (span: Span) => Promise<T>
): Promise<T> {
const tracer = getTracer()
return tracer.startActiveSpan(
`tool.execute ${input.toolName}`,
{
attributes: {
[TraceAttr.ToolName]: input.toolName,
[TraceAttr.ToolCallId]: input.toolCallId,
[TraceAttr.ToolExecutor]: 'sim',
...(input.runId ? { [TraceAttr.RunId]: input.runId } : {}),
...(input.chatId ? { [TraceAttr.ChatId]: input.chatId } : {}),
...(typeof input.argsBytes === 'number'
? { [TraceAttr.ToolArgsBytes]: input.argsBytes }
: {}),
// argsPreview can leak pasted credentials in tool args; gate
// behind the GenAI content-capture env var.
...(input.argsPreview && isGenAIMessageCaptureEnabled()
? { [TraceAttr.ToolArgsPreview]: input.argsPreview }
: {}),
},
},
async (span) => {
try {
const result = await fn(span)
span.setStatus({ code: SpanStatusCode.OK })
return result
} catch (error) {
markSpanForError(span, error)
throw error
} finally {
span.end()
}
}
)
}
function isValidSpanContext(spanContext: SpanContext): boolean {
return (
/^[0-9a-f]{32}$/.test(spanContext.traceId) &&
spanContext.traceId !== '00000000000000000000000000000000' &&
/^[0-9a-f]{16}$/.test(spanContext.spanId) &&
spanContext.spanId !== '0000000000000000'
)
}
function createFallbackSpanContext(): SpanContext {
return {
traceId: randomBytes(16).toString('hex'),
spanId: randomBytes(8).toString('hex'),
traceFlags: TraceFlags.SAMPLED,
}
}
interface CopilotOtelScope {
// Leave unset on the chat POST — startCopilotOtelRoot will derive
// from the root span's OTel trace ID (same value Grafana uses).
// Set explicitly on paths that need a non-trace-derived ID (headless,
// resume taking an ID from persisted state).
requestId?: string
route?: string
chatId?: string
workflowId?: string
executionId?: string
runId?: string
streamId?: string
transport: 'headless' | 'stream'
userMessagePreview?: string
}
// Dashboard-column width; long enough for triage disambiguation.
const USER_MESSAGE_PREVIEW_MAX_CHARS = 500
function buildAgentSpanAttributes(
scope: CopilotOtelScope & { requestId: string }
): Record<string, string | number | boolean> {
// Gated behind the same env var as full GenAI message capture — a
// 500-char preview is still user prompt content.
const preview = isGenAIMessageCaptureEnabled()
? truncateUserMessagePreview(scope.userMessagePreview)
: undefined
return {
[TraceAttr.GenAiAgentName]: 'mothership',
[TraceAttr.GenAiAgentId]:
scope.transport === CopilotTransport.Stream ? 'mothership-stream' : 'mothership-headless',
[TraceAttr.GenAiOperationName]:
scope.transport === CopilotTransport.Stream ? 'chat' : 'invoke_agent',
[TraceAttr.RequestId]: scope.requestId,
[TraceAttr.SimRequestId]: scope.requestId,
[TraceAttr.CopilotRoute]: scope.route ?? '',
[TraceAttr.CopilotTransport]: scope.transport,
...(scope.chatId ? { [TraceAttr.ChatId]: scope.chatId } : {}),
...(scope.workflowId ? { [TraceAttr.WorkflowId]: scope.workflowId } : {}),
...(scope.executionId ? { [TraceAttr.CopilotExecutionId]: scope.executionId } : {}),
...(scope.runId ? { [TraceAttr.RunId]: scope.runId } : {}),
...(scope.streamId ? { [TraceAttr.StreamId]: scope.streamId } : {}),
...(preview ? { [TraceAttr.CopilotUserMessagePreview]: preview } : {}),
}
}
function truncateUserMessagePreview(raw: unknown): string | undefined {
if (typeof raw !== 'string') return undefined
const collapsed = raw.replace(/\s+/g, ' ').trim()
if (!collapsed) return undefined
if (collapsed.length <= USER_MESSAGE_PREVIEW_MAX_CHARS) return collapsed
return `${collapsed.slice(0, USER_MESSAGE_PREVIEW_MAX_CHARS - 1)}`
}
// Request-shape metadata known only after branch resolution. Stamped
// on the root span for dashboard filtering.
interface CopilotOtelRequestShape {
branchKind?: 'workflow' | 'workspace'
mode?: string
model?: string
provider?: string
createNewChat?: boolean
prefetch?: boolean
fileAttachmentsCount?: number
resourceAttachmentsCount?: number
contextsCount?: number
commandsCount?: number
pendingStreamWaitMs?: number
interruptedPriorStream?: boolean
}
interface CopilotOtelRoot {
span: Span
context: Context
/**
* Finalize the root span. `cancelReason`, when provided, decides
* whether a `cancelled` outcome leaves span status UNSET (for
* explicit user stops — our single non-error cancel class) or
* escalates to ERROR (client disconnect, unknown, etc.). Omit it
* for non-cancellation outcomes.
*/
finish: (
outcome?: CopilotLifecycleOutcome,
error?: unknown,
cancelReason?: CopilotRequestCancelReasonValue
) => void
setInputMessages: (input: CopilotAgentInputMessages) => void
setOutputMessages: (output: CopilotAgentOutputMessages) => void
setRequestShape: (shape: CopilotOtelRequestShape) => void
}
export function startCopilotOtelRoot(
scope: CopilotOtelScope
): CopilotOtelRoot & { requestId: string } {
// TRUE root — don't inherit from Next's HTTP handler span (the
// sampler drops those; we'd orphan the whole mothership tree).
const parentContext = ROOT_CONTEXT
// Start with a placeholder `requestId`, then overwrite using the
// span's actual trace ID so the UI copy-button value pastes
// directly into Grafana.
const span = getTracer().startSpan(
TraceSpan.GenAiAgentExecute,
{ attributes: buildAgentSpanAttributes({ ...scope, requestId: '' }) },
parentContext
)
const carrierSpan = isValidSpanContext(span.spanContext())
? span
: trace.wrapSpanContext(createFallbackSpanContext())
const spanContext = carrierSpan.spanContext()
const requestId =
scope.requestId ??
(spanContext.traceId && spanContext.traceId.length === 32 ? spanContext.traceId : '')
span.setAttribute(TraceAttr.RequestId, requestId)
span.setAttribute(TraceAttr.SimRequestId, requestId)
const rootContext = trace.setSpan(parentContext, carrierSpan)
let finished = false
const finish: CopilotOtelRoot['finish'] = (outcome, error, cancelReason) => {
if (finished) return
finished = true
const resolvedOutcome = outcome ?? RequestTraceV1Outcome.success
span.setAttribute(TraceAttr.CopilotRequestOutcome, resolvedOutcome)
// Policy: `explicit_stop` is the ONLY cancellation we treat as
// expected (status unset → dashboards see it as OK). Everything
// else — client_disconnect, unknown reason, bug-case cancels —
// escalates to ERROR so it shows up on error panels.
const isExplicitStop = cancelReason === CopilotRequestCancelReason.ExplicitStop
if (error) {
markSpanForError(span, error)
if (isExplicitStop || isExplicitUserStopError(error)) {
span.setStatus({ code: SpanStatusCode.OK })
}
} else if (resolvedOutcome === RequestTraceV1Outcome.success) {
span.setStatus({ code: SpanStatusCode.OK })
} else if (resolvedOutcome === RequestTraceV1Outcome.cancelled) {
if (isExplicitStop) {
span.setStatus({ code: SpanStatusCode.OK })
} else {
span.setStatus({
code: SpanStatusCode.ERROR,
message: `cancelled: ${cancelReason ?? 'unknown'}`,
})
}
}
span.end()
}
return {
span,
context: rootContext,
requestId,
finish,
setInputMessages: (input) => setAgentInputMessages(span, input),
setOutputMessages: (output) => setAgentOutputMessages(span, output),
setRequestShape: (shape) => applyRequestShape(span, shape),
}
}
// Pending-stream-lock wait above this = inferred send-to-interrupt.
const INTERRUPT_WAIT_MS_THRESHOLD = 50
function applyRequestShape(span: Span, shape: CopilotOtelRequestShape): void {
if (shape.branchKind) {
span.setAttribute(TraceAttr.CopilotBranchKind, shape.branchKind)
span.setAttribute(
TraceAttr.CopilotSurface,
shape.branchKind === CopilotBranchKind.Workflow
? CopilotSurface.Copilot
: CopilotSurface.Mothership
)
}
if (shape.mode) span.setAttribute(TraceAttr.CopilotMode, shape.mode)
if (shape.model) span.setAttribute(TraceAttr.GenAiRequestModel, shape.model)
if (shape.provider) span.setAttribute(TraceAttr.GenAiSystem, shape.provider)
if (typeof shape.createNewChat === 'boolean') {
span.setAttribute(TraceAttr.CopilotChatIsNew, shape.createNewChat)
}
if (typeof shape.prefetch === 'boolean') {
span.setAttribute(TraceAttr.CopilotPrefetch, shape.prefetch)
}
if (typeof shape.fileAttachmentsCount === 'number') {
span.setAttribute(TraceAttr.CopilotFileAttachmentsCount, shape.fileAttachmentsCount)
}
if (typeof shape.resourceAttachmentsCount === 'number') {
span.setAttribute(TraceAttr.CopilotResourceAttachmentsCount, shape.resourceAttachmentsCount)
}
if (typeof shape.contextsCount === 'number') {
span.setAttribute(TraceAttr.CopilotContextsCount, shape.contextsCount)
}
if (typeof shape.commandsCount === 'number') {
span.setAttribute(TraceAttr.CopilotCommandsCount, shape.commandsCount)
}
if (typeof shape.pendingStreamWaitMs === 'number') {
span.setAttribute(TraceAttr.CopilotPendingStreamWaitMs, shape.pendingStreamWaitMs)
const interrupted =
typeof shape.interruptedPriorStream === 'boolean'
? shape.interruptedPriorStream
: shape.pendingStreamWaitMs > INTERRUPT_WAIT_MS_THRESHOLD
span.setAttribute(TraceAttr.CopilotInterruptedPriorStream, interrupted)
} else if (typeof shape.interruptedPriorStream === 'boolean') {
span.setAttribute(TraceAttr.CopilotInterruptedPriorStream, shape.interruptedPriorStream)
}
}
export async function withCopilotOtelContext<T>(
scope: CopilotOtelScope,
fn: (otelContext: Context) => Promise<T>
): Promise<T> {
const parentContext = context.active()
// Same trace-id-derives-requestId dance as startCopilotOtelRoot — see
// that function for the rationale. Stamp a placeholder, read the real
// trace ID off the span, then overwrite.
const span = getTracer().startSpan(
TraceSpan.GenAiAgentExecute,
{ attributes: buildAgentSpanAttributes({ ...scope, requestId: scope.requestId ?? '' }) },
parentContext
)
const carrierSpan = isValidSpanContext(span.spanContext())
? span
: trace.wrapSpanContext(createFallbackSpanContext())
const spanContext = carrierSpan.spanContext()
const resolvedRequestId =
scope.requestId ??
(spanContext.traceId && spanContext.traceId.length === 32 ? spanContext.traceId : '')
if (resolvedRequestId) {
span.setAttribute(TraceAttr.RequestId, resolvedRequestId)
span.setAttribute(TraceAttr.SimRequestId, resolvedRequestId)
}
const otelContext = trace.setSpan(parentContext, carrierSpan)
let terminalStatusSet = false
try {
const result = await context.with(otelContext, () => fn(otelContext))
span.setStatus({ code: SpanStatusCode.OK })
terminalStatusSet = true
return result
} catch (error) {
markSpanForError(span, error)
terminalStatusSet = true
throw error
} finally {
if (!terminalStatusSet) {
// Extremely defensive: should be unreachable, but avoids leaking
// an unset span status if some future refactor breaks both arms.
span.setStatus({ code: SpanStatusCode.OK })
}
span.end()
}
}
@@ -0,0 +1,52 @@
/**
* Abort-reason vocabulary for Sim-originated cancellations.
*
* This is deliberately a zero-dependency module (no OTel, no logger,
* no DB) so it can be imported from both the telemetry layer
* (`request/otel.ts`) and the abort layer (`request/session/abort.ts`)
* without creating a circular dependency. The longer prose lives in
* `abort.ts`; anything here is the raw classification vocabulary
* consumed by span-status / finalizer code.
*/
/**
* Reason strings passed to `AbortController.abort(reason)` for every
* Sim-originated cancel path.
*/
export const AbortReason = {
/** Same-process stop: browser→Sim→abortActiveStream. */
UserStop: 'user_stop:abortActiveStream',
/**
* Cross-process stop: the Sim node that holds the SSE didn't
* receive the Stop HTTP call, but it polled the Redis abort marker
* that the node that DID receive it wrote, and aborts on the poll.
*/
RedisPoller: 'redis_abort_marker:poller',
/**
* Cross-process stop: same root cause as `RedisPoller`, but observed
* by `runStreamLoop` at body close (the Go body ended before the
* 250ms poller's next tick) rather than by the polling timer.
*/
MarkerObservedAtBodyClose: 'redis_abort_marker:body_close',
/** Internal timeout on the outbound explicit-abort fetch to Go. */
ExplicitAbortFetchTimeout: 'timeout:go_explicit_abort_fetch',
} as const
export type AbortReasonValue = (typeof AbortReason)[keyof typeof AbortReason]
/**
* True iff `reason` indicates the user explicitly triggered the abort
* (as opposed to an implicit client disconnect or server timeout).
* Treated as a small closed vocabulary — any string not in
* `AbortReason` is presumed non-explicit. This is the canonical
* "should I treat this cancellation as expected?" predicate: span
* status-setters consult it to suppress ERROR only for user-initiated
* stops, mirroring `requestctx.IsExplicitUserStop` on the Go side.
*/
export function isExplicitStopReason(reason: unknown): boolean {
return (
reason === AbortReason.UserStop ||
reason === AbortReason.RedisPoller ||
reason === AbortReason.MarkerObservedAtBodyClose
)
}
@@ -0,0 +1,256 @@
/**
* @vitest-environment node
*/
import { redisConfigMock, redisConfigMockFns } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockHasAbortMarker, mockClearAbortMarker, mockWriteAbortMarker } = vi.hoisted(() => ({
mockHasAbortMarker: vi.fn().mockResolvedValue(false),
mockClearAbortMarker: vi.fn().mockResolvedValue(undefined),
mockWriteAbortMarker: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
vi.mock('@/lib/copilot/request/session/buffer', () => ({
hasAbortMarker: mockHasAbortMarker,
clearAbortMarker: mockClearAbortMarker,
writeAbortMarker: mockWriteAbortMarker,
}))
vi.mock('@/lib/copilot/request/otel', () => ({
withCopilotSpan: (_span: unknown, _attrs: unknown, fn: (span: unknown) => unknown) =>
fn({ setAttribute: vi.fn() }),
}))
import {
acquirePendingChatStream,
getChatStreamLockOwners,
releasePendingChatStream,
startAbortPoller,
} from '@/lib/copilot/request/session/abort'
describe('startAbortPoller heartbeat', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockHasAbortMarker.mockResolvedValue(false)
redisConfigMockFns.mockExtendLock.mockResolvedValue(true)
})
afterEach(() => {
vi.useRealTimers()
})
it('extends the chat stream lock approximately every heartbeat interval', async () => {
const controller = new AbortController()
const streamId = 'stream-heartbeat-1'
const chatId = 'chat-heartbeat-1'
const interval = startAbortPoller(streamId, controller, { chatId })
try {
await vi.advanceTimersByTimeAsync(15_000)
expect(redisConfigMockFns.mockExtendLock).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(6_000)
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1)
expect(redisConfigMockFns.mockExtendLock).toHaveBeenLastCalledWith(
`copilot:chat-stream-lock:${chatId}`,
streamId,
60
)
await vi.advanceTimersByTimeAsync(20_000)
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(20_000)
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(3)
} finally {
clearInterval(interval)
}
})
it('does not extend the lock when no chatId is passed (backward compat)', async () => {
const controller = new AbortController()
const interval = startAbortPoller('stream-no-chat', controller, {})
try {
await vi.advanceTimersByTimeAsync(90_000)
expect(redisConfigMockFns.mockExtendLock).not.toHaveBeenCalled()
} finally {
clearInterval(interval)
}
})
it('retries on the next tick when extendLock throws (no 20s backoff)', async () => {
const controller = new AbortController()
const streamId = 'stream-retry'
const chatId = 'chat-retry'
redisConfigMockFns.mockExtendLock.mockRejectedValueOnce(new Error('redis down'))
const interval = startAbortPoller(streamId, controller, { chatId })
try {
await vi.advanceTimersByTimeAsync(20_000)
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1_000)
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(2)
} finally {
clearInterval(interval)
}
})
it('aborts the controller before clearing the marker so the marker is never observable as cleared while the signal is still unaborted', async () => {
const controller = new AbortController()
const streamId = 'stream-order-1'
let signalAbortedWhenMarkerCleared: boolean | null = null
mockClearAbortMarker.mockImplementationOnce(async () => {
signalAbortedWhenMarkerCleared = controller.signal.aborted
})
mockHasAbortMarker.mockResolvedValueOnce(true)
const interval = startAbortPoller(streamId, controller, {})
try {
await vi.advanceTimersByTimeAsync(300)
expect(mockClearAbortMarker).toHaveBeenCalledWith(streamId)
expect(signalAbortedWhenMarkerCleared).toBe(true)
expect(controller.signal.aborted).toBe(true)
} finally {
clearInterval(interval)
}
})
it('does not clear the marker when the signal is already aborted (no double abort)', async () => {
const controller = new AbortController()
controller.abort('preexisting')
const streamId = 'stream-order-2'
mockHasAbortMarker.mockResolvedValueOnce(true)
const interval = startAbortPoller(streamId, controller, {})
try {
await vi.advanceTimersByTimeAsync(300)
expect(mockClearAbortMarker).not.toHaveBeenCalled()
} finally {
clearInterval(interval)
}
})
it('stops heartbeating after ownership is lost', async () => {
const controller = new AbortController()
const streamId = 'stream-lost'
const chatId = 'chat-lost'
redisConfigMockFns.mockExtendLock.mockResolvedValueOnce(false)
const interval = startAbortPoller(streamId, controller, { chatId })
try {
await vi.advanceTimersByTimeAsync(21_000)
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(60_000)
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1)
} finally {
clearInterval(interval)
}
})
})
describe('getChatStreamLockOwners', () => {
beforeEach(() => {
vi.clearAllMocks()
redisConfigMockFns.mockGetRedisClient.mockReturnValue(null)
})
it('returns a verified empty owner map when no chat ids are provided', async () => {
const result = await getChatStreamLockOwners([])
expect(result.status).toBe('verified')
expect(result.ownersByChatId.size).toBe(0)
})
it('returns Redis lock owners keyed by chat id', async () => {
const mget = vi.fn().mockResolvedValue(['stream-1', null, 'stream-3'])
redisConfigMockFns.mockGetRedisClient.mockReturnValue({ mget } as never)
const result = await getChatStreamLockOwners(['chat-1', 'chat-2', 'chat-3'])
expect(mget).toHaveBeenCalledWith([
'copilot:chat-stream-lock:chat-1',
'copilot:chat-stream-lock:chat-2',
'copilot:chat-stream-lock:chat-3',
])
expect(result.status).toBe('verified')
expect(result.ownersByChatId).toEqual(
new Map([
['chat-1', 'stream-1'],
['chat-3', 'stream-3'],
])
)
})
it('returns a verified empty map when every lock has expired in Redis', async () => {
const mget = vi.fn().mockResolvedValue([null, null])
redisConfigMockFns.mockGetRedisClient.mockReturnValue({ mget } as never)
const result = await getChatStreamLockOwners(['chat-stuck-1', 'chat-stuck-2'])
expect(result.status).toBe('verified')
expect(result.ownersByChatId.size).toBe(0)
})
it('trusts verified Redis null over a process-local pending stream', async () => {
const mget = vi.fn().mockResolvedValue([null])
redisConfigMockFns.mockGetRedisClient.mockReturnValue({ mget } as never)
await acquirePendingChatStream('chat-local', 'stream-local')
try {
const result = await getChatStreamLockOwners(['chat-local'])
expect(result.status).toBe('verified')
expect(result.ownersByChatId.size).toBe(0)
} finally {
await releasePendingChatStream('chat-local', 'stream-local')
}
})
it('returns unknown status when Redis is unavailable', async () => {
redisConfigMockFns.mockGetRedisClient.mockReturnValue(null)
const result = await getChatStreamLockOwners(['chat-1', 'chat-2'])
expect(result.status).toBe('unknown')
expect(result.ownersByChatId.size).toBe(0)
})
it('preserves local pending stream owners when Redis is unavailable', async () => {
await acquirePendingChatStream('chat-local', 'stream-local')
try {
const result = await getChatStreamLockOwners(['chat-local', 'chat-remote'])
expect(result.status).toBe('unknown')
expect(result.ownersByChatId).toEqual(new Map([['chat-local', 'stream-local']]))
} finally {
await releasePendingChatStream('chat-local', 'stream-local')
}
})
it('returns unknown status without throwing when mget rejects', async () => {
const mget = vi.fn().mockRejectedValue(new Error('redis down'))
redisConfigMockFns.mockGetRedisClient.mockReturnValue({ mget } as never)
const result = await getChatStreamLockOwners(['chat-1', 'chat-2'])
expect(result.status).toBe('unknown')
expect(result.ownersByChatId.size).toBe(0)
})
})
@@ -0,0 +1,398 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { AbortBackend } from '@/lib/copilot/generated/trace-attribute-values-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 { acquireLock, extendLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
import { AbortReason } from './abort-reason'
import { clearAbortMarker, hasAbortMarker, writeAbortMarker } from './buffer'
const logger = createLogger('SessionAbort')
const activeStreams = new Map<string, AbortController>()
const pendingChatStreams = new Map<
string,
{ promise: Promise<void>; resolve: () => void; streamId: string }
>()
const DEFAULT_ABORT_POLL_MS = 250
/**
* TTL for the per-chat stream lock. Kept short so that if the Sim pod
* holding the lock dies (SIGKILL, OOM, a SIGTERM drain that doesn't
* reach the release path), the lock self-heals inside a minute rather
* than stranding the chat for hours. A live stream keeps the lock alive
* via `CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS` heartbeats.
*/
const CHAT_STREAM_LOCK_TTL_SECONDS = 60
/**
* Heartbeat cadence for extending the per-chat stream lock. Set to a
* third of the TTL so one missed beat still leaves room for recovery
* before the lock expires under a still-live stream.
*/
const CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS = 20_000
export interface ChatStreamLockOwnersResult {
status: 'verified' | 'unknown'
ownersByChatId: Map<string, string>
}
function registerPendingChatStream(chatId: string, streamId: string): void {
let resolve!: () => void
const promise = new Promise<void>((r) => {
resolve = r
})
pendingChatStreams.set(chatId, { promise, resolve, streamId })
}
function resolvePendingChatStream(chatId: string, streamId: string): void {
const entry = pendingChatStreams.get(chatId)
if (entry && entry.streamId === streamId) {
entry.resolve()
pendingChatStreams.delete(chatId)
}
}
function getChatStreamLockKey(chatId: string): string {
return `copilot:chat-stream-lock:${chatId}`
}
export function registerActiveStream(streamId: string, controller: AbortController): void {
activeStreams.set(streamId, controller)
}
export function unregisterActiveStream(streamId: string): void {
activeStreams.delete(streamId)
}
export async function waitForPendingChatStream(
chatId: string,
timeoutMs = 5_000,
expectedStreamId?: string
): Promise<boolean> {
const redis = getRedisClient()
const deadline = Date.now() + timeoutMs
for (;;) {
const entry = pendingChatStreams.get(chatId)
const localPending = !!entry && (!expectedStreamId || entry.streamId === expectedStreamId)
if (redis) {
try {
const ownerStreamId = await redis.get(getChatStreamLockKey(chatId))
const lockReleased =
!ownerStreamId || (expectedStreamId !== undefined && ownerStreamId !== expectedStreamId)
if (!localPending && lockReleased) {
return true
}
} catch (error) {
logger.warn('Failed to inspect chat stream lock while waiting', {
chatId,
expectedStreamId,
error: toError(error).message,
})
}
} else if (!localPending) {
return true
}
if (Date.now() >= deadline) {
return false
}
await sleep(200)
}
}
export async function getPendingChatStreamId(chatId: string): Promise<string | null> {
const localEntry = pendingChatStreams.get(chatId)
if (localEntry?.streamId) {
return localEntry.streamId
}
const redis = getRedisClient()
if (!redis) {
return null
}
try {
return (await redis.get(getChatStreamLockKey(chatId))) || null
} catch (error) {
logger.warn('Failed to load chat stream lock owner', {
chatId,
error: toError(error).message,
})
return null
}
}
/**
* Loads canonical stream lock owners for chat IDs.
*
* `status: 'verified'` means Redis was queried successfully, so a missing
* owner is authoritative. `status: 'unknown'` means only the process-local
* pending map is known, which is not enough to declare remote streams inactive
* in a multi-pod deployment.
*/
export async function getChatStreamLockOwners(
chatIds: string[]
): Promise<ChatStreamLockOwnersResult> {
const localOwnersByChatId = new Map<string, string>()
if (chatIds.length === 0) {
return { status: 'verified', ownersByChatId: localOwnersByChatId }
}
for (const chatId of chatIds) {
const entry = pendingChatStreams.get(chatId)
if (entry?.streamId) localOwnersByChatId.set(chatId, entry.streamId)
}
const redis = getRedisClient()
if (!redis) {
return { status: 'unknown', ownersByChatId: localOwnersByChatId }
}
try {
const keys = chatIds.map(getChatStreamLockKey)
const values = await redis.mget(keys)
const redisOwnersByChatId = new Map<string, string>()
for (let i = 0; i < chatIds.length; i++) {
const owner = values[i]
if (owner) redisOwnersByChatId.set(chatIds[i], owner)
}
return { status: 'verified', ownersByChatId: redisOwnersByChatId }
} catch (error) {
logger.warn('Failed to load chat stream lock owners (batch)', {
count: chatIds.length,
error: toError(error).message,
})
return { status: 'unknown', ownersByChatId: localOwnersByChatId }
}
}
export async function releasePendingChatStream(chatId: string, streamId: string): Promise<void> {
try {
await releaseLock(getChatStreamLockKey(chatId), streamId)
} catch (error) {
logger.warn('Failed to release chat stream lock', {
chatId,
streamId,
error: toError(error).message,
})
} finally {
resolvePendingChatStream(chatId, streamId)
}
}
export async function acquirePendingChatStream(
chatId: string,
streamId: string,
timeoutMs = 5_000
): Promise<boolean> {
// Span records wall time spent waiting for the per-chat stream lock.
// Typical case: sub-10ms uncontested acquire. Worst case: up to
// `timeoutMs` spent polling while a prior stream finishes. Previously
// this time looked like "unexplained gap before llm.stream".
return withCopilotSpan(
TraceSpan.CopilotChatAcquirePendingStreamLock,
{
[TraceAttr.ChatId]: chatId,
[TraceAttr.StreamId]: streamId,
[TraceAttr.LockTimeoutMs]: timeoutMs,
},
async (span) => {
const redis = getRedisClient()
span.setAttribute(TraceAttr.LockBackend, redis ? AbortBackend.Redis : AbortBackend.InProcess)
if (redis) {
const deadline = Date.now() + timeoutMs
for (;;) {
try {
const acquired = await acquireLock(
getChatStreamLockKey(chatId),
streamId,
CHAT_STREAM_LOCK_TTL_SECONDS
)
if (acquired) {
registerPendingChatStream(chatId, streamId)
span.setAttribute(TraceAttr.LockAcquired, true)
return true
}
if (!pendingChatStreams.has(chatId)) {
const ownerStreamId = await redis.get(getChatStreamLockKey(chatId))
if (ownerStreamId) {
const settled = await waitForPendingChatStream(chatId, 0, ownerStreamId)
if (settled) {
continue
}
}
}
} catch (error) {
logger.warn('Failed to acquire chat stream lock', {
chatId,
streamId,
error: toError(error).message,
})
}
if (Date.now() >= deadline) {
span.setAttribute(TraceAttr.LockAcquired, false)
span.setAttribute(TraceAttr.LockTimedOut, true)
return false
}
await sleep(200)
}
}
for (;;) {
const existing = pendingChatStreams.get(chatId)
if (!existing) {
registerPendingChatStream(chatId, streamId)
span.setAttribute(TraceAttr.LockAcquired, true)
return true
}
const settled = await Promise.race([
existing.promise.then(() => true),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), timeoutMs)),
])
if (!settled) {
span.setAttribute(TraceAttr.LockAcquired, false)
span.setAttribute(TraceAttr.LockTimedOut, true)
return false
}
}
}
)
}
/**
* Returns `true` if it aborted an in-process controller,
* `false` if it only wrote the marker (no local controller found).
*
* Spanned because the two operations inside can stall independently
* — Redis latency on `writeAbortMarker` was previously invisible, and
* the "no local controller" branch (happens when the stream handler
* is on a different Sim box than the one receiving /chat/abort) is
* a subtle but important outcome to distinguish from "aborted a live
* controller" in dashboards.
*/
export async function abortActiveStream(streamId: string): Promise<boolean> {
return withCopilotSpan(
TraceSpan.CopilotChatAbortActiveStream,
{ [TraceAttr.StreamId]: streamId },
async (span) => {
await writeAbortMarker(streamId)
span.setAttribute(TraceAttr.CopilotAbortMarkerWritten, true)
const controller = activeStreams.get(streamId)
if (!controller) {
span.setAttribute(TraceAttr.CopilotAbortControllerFired, false)
return false
}
controller.abort(AbortReason.UserStop)
activeStreams.delete(streamId)
span.setAttribute(TraceAttr.CopilotAbortControllerFired, true)
return true
}
)
}
export type { AbortReasonValue } from './abort-reason'
/**
* `AbortReason` vocabulary and the `isExplicitStopReason` classifier
* live in a sibling zero-dependency module so the telemetry layer
* (`request/otel.ts`) can import them without creating a circular
* import back through `session/abort.ts`'s OTel-wrapped helpers.
*
* Context on why the distinction matters: when the user clicks Stop,
* we fire `abortController.abort(AbortReason.UserStop)` from
* `abortActiveStream()`. That causes Sim's SSE writer to close,
* which in turn makes the BROWSER's SSE reader see the stream end
* — which fires the browser-side fetch AbortController and
* propagates back to Sim as `publisher.markDisconnected()`. So on
* an explicit Stop you observe BOTH "explicit reason" AND
* "client disconnected" — the discriminator is the reason string,
* not the client flag.
*
* For any NEW abort path, add its reason in `./abort-reason.ts` and
* update `isExplicitStopReason` if it should be classified as a user
* stop.
*/
export { AbortReason, isExplicitStopReason } from './abort-reason'
const pollingStreams = new Set<string>()
export function startAbortPoller(
streamId: string,
abortController: AbortController,
options?: { pollMs?: number; requestId?: string; chatId?: string }
): ReturnType<typeof setInterval> {
const pollMs = options?.pollMs ?? DEFAULT_ABORT_POLL_MS
const requestId = options?.requestId
const chatId = options?.chatId
let lastHeartbeatAt = Date.now()
let heartbeatOwnershipLost = false
return setInterval(() => {
if (pollingStreams.has(streamId)) return
pollingStreams.add(streamId)
void (async () => {
try {
const shouldAbort = await hasAbortMarker(streamId)
if (shouldAbort && !abortController.signal.aborted) {
abortController.abort(AbortReason.RedisPoller)
await clearAbortMarker(streamId)
}
} catch (error) {
logger.warn('Failed to poll stream abort marker', {
streamId,
...(requestId ? { requestId } : {}),
error: toError(error).message,
})
} finally {
pollingStreams.delete(streamId)
}
if (!chatId || heartbeatOwnershipLost) return
if (Date.now() - lastHeartbeatAt < CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS) return
try {
const owned = await extendLock(
getChatStreamLockKey(chatId),
streamId,
CHAT_STREAM_LOCK_TTL_SECONDS
)
lastHeartbeatAt = Date.now()
if (!owned) {
heartbeatOwnershipLost = true
logger.warn('Lost ownership of chat stream lock — stopping heartbeat', {
chatId,
streamId,
...(requestId ? { requestId } : {}),
})
}
} catch (error) {
logger.warn('Failed to extend chat stream lock TTL', {
chatId,
streamId,
...(requestId ? { requestId } : {}),
error: toError(error).message,
})
}
})()
}, pollMs)
}
export async function cleanupAbortMarker(streamId: string): Promise<void> {
try {
await clearAbortMarker(streamId)
} catch (error) {
logger.warn('Failed to clear stream abort marker during cleanup', {
streamId,
error: toError(error).message,
})
}
}
@@ -0,0 +1,250 @@
/**
* @vitest-environment node
*/
import { redisConfigMock, redisConfigMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
MothershipStreamV1EventType,
MothershipStreamV1TextChannel,
} from '@/lib/copilot/generated/mothership-stream-v1'
import { createEvent } from '@/lib/copilot/request/session/event'
type StoredEnvelope = {
score: number
value: string
}
const createRedisStub = () => {
const counters = new Map<string, number>()
const values = new Map<string, string>()
const sortedSets = new Map<string, StoredEnvelope[]>()
const api = {
incr: vi.fn().mockImplementation((key: string) => {
const next = (counters.get(key) ?? 0) + 1
counters.set(key, next)
return next
}),
expire: vi.fn().mockResolvedValue(1),
del: vi.fn().mockImplementation((...keys: string[]) => {
for (const key of keys) {
values.delete(key)
sortedSets.delete(key)
counters.delete(key)
}
return Promise.resolve(keys.length)
}),
zadd: vi.fn().mockImplementation((key: string, score: number, value: string) => {
const entries = sortedSets.get(key) ?? []
entries.push({ score, value })
sortedSets.set(key, entries)
return Promise.resolve(1)
}),
zremrangebyrank: vi.fn().mockImplementation((key: string, start: number, stop: number) => {
const entries = [...(sortedSets.get(key) ?? [])].sort((a, b) => a.score - b.score)
const normalizedStart = start < 0 ? Math.max(entries.length + start, 0) : start
const normalizedStop = stop < 0 ? entries.length + stop : stop
const next = entries.filter(
(_entry, index) => index < normalizedStart || index > normalizedStop
)
sortedSets.set(key, next)
return Promise.resolve(1)
}),
zrangebyscore: vi.fn().mockImplementation((key: string, min: number, max: string) => {
const upperBound = max === '+inf' ? Number.POSITIVE_INFINITY : Number(max)
const entries = [...(sortedSets.get(key) ?? [])]
.filter((entry) => entry.score >= min && entry.score <= upperBound)
.sort((a, b) => a.score - b.score)
.map((entry) => entry.value)
return Promise.resolve(entries)
}),
set: vi.fn().mockImplementation((key: string, value: string) => {
values.set(key, value)
return Promise.resolve('OK')
}),
get: vi.fn().mockImplementation((key: string) => Promise.resolve(values.get(key) ?? null)),
pipeline: vi.fn().mockImplementation(() => {
const operations: Array<() => Promise<unknown>> = []
const pipeline = {
zadd: (...args: [string, number, string]) => {
operations.push(() => api.zadd(...args))
return pipeline
},
expire: (...args: [string, number]) => {
operations.push(() => api.expire(...args))
return pipeline
},
set: (...args: [string, string, 'EX', number]) => {
operations.push(() => api.set(args[0], args[1]))
return pipeline
},
zremrangebyrank: (...args: [string, number, number]) => {
operations.push(() => api.zremrangebyrank(...args))
return pipeline
},
exec: vi.fn().mockImplementation(async () => {
const results: Array<[null, unknown]> = []
for (const operation of operations) {
results.push([null, await operation()])
}
return results
}),
}
return pipeline
}),
}
return api
}
let mockRedis: ReturnType<typeof createRedisStub>
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
import {
allocateCursor,
appendEvent,
clearBuffer,
readEvents,
scheduleBufferCleanup,
} from '@/lib/copilot/request/session/buffer'
describe('mothership-stream-outbox', () => {
beforeEach(() => {
mockRedis = createRedisStub()
vi.clearAllMocks()
redisConfigMockFns.mockGetRedisClient.mockImplementation(() => mockRedis)
})
it('replays envelopes after a given cursor', async () => {
const firstCursor = await allocateCursor('stream-1')
const secondCursor = await allocateCursor('stream-1')
await appendEvent(
createEvent({
streamId: 'stream-1',
cursor: firstCursor.cursor,
seq: firstCursor.seq,
requestId: 'req-1',
type: MothershipStreamV1EventType.text,
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
})
)
await appendEvent(
createEvent({
streamId: 'stream-1',
cursor: secondCursor.cursor,
seq: secondCursor.seq,
requestId: 'req-1',
type: MothershipStreamV1EventType.text,
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'world' },
})
)
const allEvents = await readEvents('stream-1', '0')
expect(allEvents.map((entry) => entry.payload.text)).toEqual(['hello', 'world'])
const replayed = await readEvents('stream-1', '1')
expect(replayed.map((entry) => entry.payload.text)).toEqual(['world'])
})
it('trims active stream history to eventLimit on every append', async () => {
const cursor = await allocateCursor('stream-1')
await appendEvent(
createEvent({
streamId: 'stream-1',
cursor: cursor.cursor,
seq: cursor.seq,
requestId: 'req-1',
type: MothershipStreamV1EventType.text,
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
})
)
expect(mockRedis.zremrangebyrank).toHaveBeenCalledWith(
'mothership_stream:stream-1:events',
0,
-100_001
)
})
it('clears persisted stream state during teardown cleanup', async () => {
const cursor = await allocateCursor('stream-1')
await appendEvent(
createEvent({
streamId: 'stream-1',
cursor: cursor.cursor,
seq: cursor.seq,
requestId: 'req-1',
type: MothershipStreamV1EventType.text,
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
})
)
expect((await readEvents('stream-1', '0')).length).toBe(1)
await clearBuffer('stream-1')
expect(await readEvents('stream-1', '0')).toEqual([])
})
it('shortens completed stream retention without deleting replay data immediately', async () => {
const cursor = await allocateCursor('stream-1')
await appendEvent(
createEvent({
streamId: 'stream-1',
cursor: cursor.cursor,
seq: cursor.seq,
requestId: 'req-1',
type: MothershipStreamV1EventType.text,
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
})
)
await scheduleBufferCleanup('stream-1', 30)
expect(mockRedis.expire).toHaveBeenCalledWith('mothership_stream:stream-1:events', 30)
expect(mockRedis.expire).toHaveBeenCalledWith('mothership_stream:stream-1:seq', 30)
expect(mockRedis.expire).toHaveBeenCalledWith('mothership_stream:stream-1:abort', 30)
expect((await readEvents('stream-1', '0')).map((entry) => entry.payload.text)).toEqual([
'hello',
])
})
it('skips corrupt replay entries that fail stream validation', async () => {
const cursor = await allocateCursor('stream-1')
await appendEvent(
createEvent({
streamId: 'stream-1',
cursor: cursor.cursor,
seq: cursor.seq,
requestId: 'req-1',
type: MothershipStreamV1EventType.text,
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
})
)
await mockRedis.zadd(
'mothership_stream:stream-1:events',
cursor.seq + 1,
JSON.stringify({
v: 1,
type: 'tool',
seq: cursor.seq + 1,
ts: '2026-04-11T00:00:00.000Z',
stream: { streamId: 'stream-1' },
payload: { toolCallId: 'broken-tool' },
})
)
const replayed = await readEvents('stream-1', '0')
expect(replayed).toHaveLength(1)
expect(replayed[0]?.payload.text).toBe('hello')
})
})
@@ -0,0 +1,249 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { env, envNumber } from '@/lib/core/config/env'
import { getRedisClient } from '@/lib/core/config/redis'
import {
type PersistedStreamEventEnvelope,
parsePersistedStreamEventEnvelopeJson,
} from './contract'
const logger = createLogger('SessionBuffer')
const STREAM_OUTBOX_PREFIX = 'mothership_stream:'
const DEFAULT_TTL_SECONDS = 60 * 60
const DEFAULT_COMPLETED_TTL_SECONDS = 5 * 60
const DEFAULT_EVENT_LIMIT = 100_000
const RETRY_DELAYS_MS = [0, 50, 150] as const
type RedisOperationMetadata = {
operation: string
streamId: string
}
function getEventsKey(streamId: string) {
return `${STREAM_OUTBOX_PREFIX}${streamId}:events`
}
function getSeqKey(streamId: string) {
return `${STREAM_OUTBOX_PREFIX}${streamId}:seq`
}
function getAbortKey(streamId: string) {
return `${STREAM_OUTBOX_PREFIX}${streamId}:abort`
}
export type StreamConfig = {
ttlSeconds: number
eventLimit: number
}
export function getStreamConfig(): StreamConfig {
return {
ttlSeconds: envNumber(env.COPILOT_STREAM_TTL_SECONDS, DEFAULT_TTL_SECONDS, { min: 1 }),
eventLimit: envNumber(env.COPILOT_STREAM_EVENT_LIMIT, DEFAULT_EVENT_LIMIT, { min: 1 }),
}
}
async function withRedisRetry<T>(
metadata: RedisOperationMetadata,
operation: (redis: NonNullable<ReturnType<typeof getRedisClient>>) => Promise<T>
): Promise<T> {
const redis = getRedisClient()
if (!redis) {
throw new Error('Redis is required for mothership stream durability')
}
let lastError: unknown
for (let attempt = 0; attempt < RETRY_DELAYS_MS.length; attempt++) {
const delay = RETRY_DELAYS_MS[attempt]
if (delay > 0) {
await sleep(delay)
}
try {
return await operation(redis)
} catch (error) {
lastError = error
logger.warn('Redis stream operation failed', {
operation: metadata.operation,
streamId: metadata.streamId,
attempt: attempt + 1,
error: toError(error).message,
})
}
}
throw lastError instanceof Error
? lastError
: new Error(`${metadata.operation} failed for stream ${metadata.streamId}`)
}
export async function allocateCursor(streamId: string): Promise<{
seq: number
cursor: string
}> {
const config = getStreamConfig()
const seq = await withRedisRetry({ operation: 'allocate_cursor', streamId }, async (redis) => {
const nextValue = await redis.incr(getSeqKey(streamId))
await redis.expire(getSeqKey(streamId), config.ttlSeconds)
return typeof nextValue === 'number' ? nextValue : Number(nextValue)
})
return { seq, cursor: String(seq) }
}
export async function resetBuffer(streamId: string): Promise<void> {
await clearBuffer(streamId, 'reset_outbox')
}
export async function clearBuffer(streamId: string, operation = 'clear_outbox'): Promise<void> {
await withRedisRetry({ operation, streamId }, async (redis) => {
await redis.del(getEventsKey(streamId), getSeqKey(streamId), getAbortKey(streamId))
})
}
export async function scheduleBufferCleanup(
streamId: string,
ttlSeconds = DEFAULT_COMPLETED_TTL_SECONDS
): Promise<void> {
try {
await withRedisRetry({ operation: 'schedule_outbox_cleanup', streamId }, async (redis) => {
const pipeline = redis.pipeline()
pipeline.expire(getEventsKey(streamId), ttlSeconds)
pipeline.expire(getSeqKey(streamId), ttlSeconds)
pipeline.expire(getAbortKey(streamId), ttlSeconds)
await pipeline.exec()
})
} catch (error) {
logger.warn('Failed to shorten stream buffer TTL during cleanup', {
streamId,
ttlSeconds,
error: toError(error).message,
})
}
}
export async function appendEvents(
envelopes: PersistedStreamEventEnvelope[]
): Promise<PersistedStreamEventEnvelope[]> {
if (envelopes.length === 0) {
return envelopes
}
const streamId = envelopes[0].stream.streamId
const config = getStreamConfig()
await withRedisRetry({ operation: 'append_event', streamId }, async (redis) => {
const key = getEventsKey(streamId)
const seqKey = getSeqKey(streamId)
const pipeline = redis.pipeline()
const zaddArgs: Array<number | string> = []
for (const envelope of envelopes) {
zaddArgs.push(envelope.seq, JSON.stringify(envelope))
}
pipeline.zadd(key, ...(zaddArgs as [number, string, ...Array<number | string>]))
pipeline.zremrangebyrank(key, 0, -config.eventLimit - 1)
pipeline.expire(key, config.ttlSeconds)
pipeline.set(seqKey, String(envelopes[envelopes.length - 1].seq), 'EX', config.ttlSeconds)
await pipeline.exec()
})
return envelopes
}
export async function appendEvent(
envelope: PersistedStreamEventEnvelope
): Promise<PersistedStreamEventEnvelope> {
await appendEvents([envelope])
return envelope
}
export class InvalidCursorError extends Error {
constructor(
public readonly streamId: string,
public readonly cursor: string
) {
super(`Invalid non-numeric cursor "${cursor}" for stream ${streamId}`)
this.name = 'InvalidCursorError'
}
}
export async function readEvents(
streamId: string,
afterCursor: string
): Promise<PersistedStreamEventEnvelope[]> {
const afterSeq = Number(afterCursor || '0')
if (!Number.isFinite(afterSeq)) {
throw new InvalidCursorError(streamId, afterCursor)
}
const minScore = afterSeq + 1
const rawEntries = await withRedisRetry({ operation: 'read_events', streamId }, async (redis) => {
return redis.zrangebyscore(getEventsKey(streamId), minScore, '+inf')
})
const envelopes: PersistedStreamEventEnvelope[] = []
for (const entry of rawEntries) {
const parsed = parsePersistedStreamEventEnvelopeJson(entry)
if (!parsed.ok) {
logger.warn('Skipping corrupt outbox entry', {
streamId,
reason: parsed.reason,
message: parsed.message,
errors: parsed.errors,
})
continue
}
envelopes.push(parsed.event)
}
return envelopes
}
export async function getOldestSeq(streamId: string): Promise<number | null> {
return withRedisRetry({ operation: 'get_oldest_seq', streamId }, async (redis) => {
const entries = await redis.zrangebyscore(getEventsKey(streamId), '-inf', '+inf', 'LIMIT', 0, 1)
if (!entries || entries.length === 0) {
return null
}
try {
const parsed = JSON.parse(entries[0]) as { seq?: number }
return typeof parsed.seq === 'number' ? parsed.seq : null
} catch {
logger.warn('Failed to parse oldest outbox entry', { streamId })
return null
}
})
}
export async function getLatestSeq(streamId: string): Promise<number | null> {
return withRedisRetry({ operation: 'get_latest_seq', streamId }, async (redis) => {
const currentSeq = await redis.get(getSeqKey(streamId))
if (currentSeq === null) {
return null
}
const parsed = Number(currentSeq)
return Number.isFinite(parsed) ? parsed : null
})
}
export async function writeAbortMarker(streamId: string): Promise<void> {
const ttlSeconds = getStreamConfig().ttlSeconds
await withRedisRetry({ operation: 'write_abort_marker', streamId }, async (redis) => {
await redis.set(getAbortKey(streamId), '1', 'EX', ttlSeconds)
})
}
export async function hasAbortMarker(streamId: string): Promise<boolean> {
return withRedisRetry({ operation: 'read_abort_marker', streamId }, async (redis) => {
const marker = await redis.get(getAbortKey(streamId))
return marker === '1'
})
}
export async function clearAbortMarker(streamId: string): Promise<void> {
await withRedisRetry({ operation: 'clear_abort_marker', streamId }, async (redis) => {
await redis.del(getAbortKey(streamId))
})
}
@@ -0,0 +1,216 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
isContractStreamEventEnvelope,
isSyntheticFilePreviewEventEnvelope,
parsePersistedStreamEventEnvelope,
parsePersistedStreamEventEnvelopeJson,
} from './contract'
const BASE_ENVELOPE = {
v: 1 as const,
seq: 1,
ts: '2026-04-11T00:00:00.000Z',
stream: {
streamId: 'stream-1',
cursor: '1',
},
trace: {
requestId: 'req-1',
},
}
describe('stream session contract parser', () => {
it('accepts contract text events', () => {
const event = {
...BASE_ENVELOPE,
trace: {
...BASE_ENVELOPE.trace,
goTraceId: 'go-trace-1',
},
type: 'text' as const,
payload: {
channel: 'assistant' as const,
text: 'hello',
},
}
expect(isContractStreamEventEnvelope(event)).toBe(true)
const parsed = parsePersistedStreamEventEnvelope(event)
expect(parsed).toEqual({
ok: true,
event,
})
})
it('accepts contract session chat events', () => {
const event = {
...BASE_ENVELOPE,
type: 'session' as const,
payload: { kind: 'chat' as const, chatId: 'chat-1' },
}
expect(isContractStreamEventEnvelope(event)).toBe(true)
expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true)
})
it('accepts contract complete events', () => {
const event = {
...BASE_ENVELOPE,
type: 'complete' as const,
payload: { status: 'complete' as const },
}
expect(isContractStreamEventEnvelope(event)).toBe(true)
expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true)
})
it('accepts contract error events', () => {
const event = {
...BASE_ENVELOPE,
type: 'error' as const,
payload: { message: 'something went wrong' },
}
expect(isContractStreamEventEnvelope(event)).toBe(true)
expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true)
})
it('accepts contract tool call events', () => {
const event = {
...BASE_ENVELOPE,
type: 'tool' as const,
payload: {
toolCallId: 'tc-1',
toolName: 'read',
phase: 'call' as const,
executor: 'sim' as const,
mode: 'sync' as const,
},
}
expect(isContractStreamEventEnvelope(event)).toBe(true)
expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true)
})
it('accepts contract span events', () => {
const event = {
...BASE_ENVELOPE,
type: 'span' as const,
payload: {
kind: 'subagent' as const,
event: 'start' as const,
agent: 'file',
},
}
expect(isContractStreamEventEnvelope(event)).toBe(true)
expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true)
})
it('accepts contract resource events', () => {
const event = {
...BASE_ENVELOPE,
type: 'resource' as const,
payload: {
op: 'upsert' as const,
resource: { id: 'r-1', type: 'file', title: 'test.md' },
},
}
expect(isContractStreamEventEnvelope(event)).toBe(true)
expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true)
})
it('accepts contract run events', () => {
const event = {
...BASE_ENVELOPE,
type: 'run' as const,
payload: { kind: 'compaction_start' as const },
}
expect(isContractStreamEventEnvelope(event)).toBe(true)
expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true)
})
it('accepts synthetic file preview events', () => {
const event = {
...BASE_ENVELOPE,
type: 'tool' as const,
payload: {
toolCallId: 'preview-1',
toolName: 'workspace_file' as const,
previewPhase: 'file_preview_content' as const,
content: 'draft body',
contentMode: 'snapshot' as const,
previewVersion: 2,
fileName: 'draft.md',
},
}
expect(isSyntheticFilePreviewEventEnvelope(event)).toBe(true)
const parsed = parsePersistedStreamEventEnvelope(event)
expect(parsed).toEqual({
ok: true,
event,
})
})
it('rejects invalid tool events with structured validation errors', () => {
const parsed = parsePersistedStreamEventEnvelope({
...BASE_ENVELOPE,
type: 'tool',
payload: {
toolCallId: 'tool-1',
toolName: 'read',
},
})
expect(parsed.ok).toBe(false)
if (parsed.ok) {
throw new Error('expected invalid result')
}
expect(parsed.reason).toBe('invalid_stream_event')
})
it('rejects unknown event types', () => {
const parsed = parsePersistedStreamEventEnvelope({
...BASE_ENVELOPE,
type: 'unknown_type',
payload: {},
})
expect(parsed.ok).toBe(false)
if (parsed.ok) {
throw new Error('expected invalid result')
}
expect(parsed.reason).toBe('invalid_stream_event')
expect(parsed.errors).toContain('unknown type="unknown_type"')
})
it('rejects non-object values', () => {
const parsed = parsePersistedStreamEventEnvelope('not an object')
expect(parsed.ok).toBe(false)
if (parsed.ok) {
throw new Error('expected invalid result')
}
expect(parsed.reason).toBe('invalid_stream_event')
expect(parsed.errors).toContain('value is not an object')
})
it('reports invalid JSON separately from schema failures', () => {
const parsed = parsePersistedStreamEventEnvelopeJson('{')
expect(parsed.ok).toBe(false)
if (parsed.ok) {
throw new Error('expected invalid json result')
}
expect(parsed.reason).toBe('invalid_json')
})
})
@@ -0,0 +1,475 @@
import { getErrorMessage } from '@sim/utils/errors'
import { isRecordLike } from '@sim/utils/object'
import type {
MothershipStreamV1EventEnvelope,
MothershipStreamV1StreamRef,
MothershipStreamV1StreamScope,
MothershipStreamV1Trace,
} from '@/lib/copilot/generated/mothership-stream-v1'
import {
MothershipStreamV1EventType,
MothershipStreamV1ResourceOp,
MothershipStreamV1RunKind,
MothershipStreamV1SessionKind,
MothershipStreamV1SpanPayloadKind,
MothershipStreamV1TextChannel,
MothershipStreamV1ToolPhase,
} from '@/lib/copilot/generated/mothership-stream-v1'
import type { FilePreviewTargetKind } from './file-preview-session-contract'
type JsonRecord = Record<string, unknown>
const FILE_PREVIEW_PHASE = {
start: 'file_preview_start',
target: 'file_preview_target',
editMeta: 'file_preview_edit_meta',
content: 'file_preview_content',
complete: 'file_preview_complete',
} as const
type EnvelopeToStreamEvent<T> = T extends {
type: infer TType
payload: infer TPayload
scope?: infer TScope
}
? { type: TType; payload: TPayload; scope?: Exclude<TScope, undefined> }
: never
export type SyntheticFilePreviewPhase = (typeof FILE_PREVIEW_PHASE)[keyof typeof FILE_PREVIEW_PHASE]
export interface SyntheticFilePreviewTarget {
kind: FilePreviewTargetKind
fileId?: string
fileName?: string
}
export interface SyntheticFilePreviewStartPayload {
previewPhase: typeof FILE_PREVIEW_PHASE.start
toolCallId: string
toolName: 'workspace_file'
}
export interface SyntheticFilePreviewTargetPayload {
operation?: string
previewPhase: typeof FILE_PREVIEW_PHASE.target
target: SyntheticFilePreviewTarget
title?: string
toolCallId: string
toolName: 'workspace_file'
}
export interface SyntheticFilePreviewEditMetaPayload {
edit: JsonRecord
previewPhase: typeof FILE_PREVIEW_PHASE.editMeta
toolCallId: string
toolName: 'workspace_file'
}
export interface SyntheticFilePreviewContentPayload {
content: string
contentMode: 'delta' | 'snapshot'
edit?: JsonRecord
fileId?: string
fileName: string
operation?: string
previewPhase: typeof FILE_PREVIEW_PHASE.content
previewVersion: number
targetKind?: string
toolCallId: string
toolName: 'workspace_file'
}
export interface SyntheticFilePreviewCompletePayload {
fileId?: string
output?: unknown
previewPhase: typeof FILE_PREVIEW_PHASE.complete
previewVersion?: number
toolCallId: string
toolName: 'workspace_file'
}
export type SyntheticFilePreviewPayload =
| SyntheticFilePreviewStartPayload
| SyntheticFilePreviewTargetPayload
| SyntheticFilePreviewEditMetaPayload
| SyntheticFilePreviewContentPayload
| SyntheticFilePreviewCompletePayload
export interface SyntheticFilePreviewEventEnvelope {
payload: SyntheticFilePreviewPayload
scope?: MothershipStreamV1StreamScope
seq: number
stream: MothershipStreamV1StreamRef
trace?: MothershipStreamV1Trace
ts: string
type: 'tool'
v: 1
}
export type PersistedStreamEventEnvelope =
| MothershipStreamV1EventEnvelope
| SyntheticFilePreviewEventEnvelope
export type ContractStreamEvent = EnvelopeToStreamEvent<MothershipStreamV1EventEnvelope>
export type SyntheticStreamEvent = EnvelopeToStreamEvent<SyntheticFilePreviewEventEnvelope>
export type SessionStreamEvent = ContractStreamEvent | SyntheticStreamEvent
export type StreamEvent = SessionStreamEvent
export type ToolCallStreamEvent = Extract<
ContractStreamEvent,
{ type: 'tool'; payload: { phase: 'call' } }
>
export type ToolArgsDeltaStreamEvent = Extract<
ContractStreamEvent,
{ type: 'tool'; payload: { phase: 'args_delta' } }
>
export type ToolResultStreamEvent = Extract<
ContractStreamEvent,
{ type: 'tool'; payload: { phase: 'result' } }
>
export type SubagentSpanStreamEvent = Extract<
ContractStreamEvent,
{ type: 'span'; payload: { kind: 'subagent' } }
>
export interface ParseStreamEventEnvelopeSuccess {
ok: true
event: PersistedStreamEventEnvelope
}
export interface ParseStreamEventEnvelopeFailure {
errors?: string[]
message: string
ok: false
reason: 'invalid_json' | 'invalid_stream_event'
}
export type ParseStreamEventEnvelopeResult =
| ParseStreamEventEnvelopeSuccess
| ParseStreamEventEnvelopeFailure
// ---------------------------------------------------------------------------
// Structural helpers (CSP-safe no codegen / eval / new Function)
// ---------------------------------------------------------------------------
function isOptionalString(value: unknown): value is string | undefined {
return value === undefined || typeof value === 'string'
}
function isOptionalFiniteNumber(value: unknown): value is number | undefined {
return value === undefined || (typeof value === 'number' && Number.isFinite(value))
}
function isStreamRef(value: unknown): value is MothershipStreamV1StreamRef {
return (
isRecordLike(value) &&
typeof value.streamId === 'string' &&
value.streamId.length > 0 &&
isOptionalString(value.chatId) &&
isOptionalString(value.cursor)
)
}
function isTrace(value: unknown): value is MothershipStreamV1Trace {
return (
isRecordLike(value) &&
typeof value.requestId === 'string' &&
isOptionalString(value.goTraceId) &&
isOptionalString(value.spanId)
)
}
function isStreamScope(value: unknown): value is MothershipStreamV1StreamScope {
return (
isRecordLike(value) &&
value.lane === 'subagent' &&
isOptionalString(value.agentId) &&
isOptionalString(value.parentToolCallId)
)
}
// ---------------------------------------------------------------------------
// Contract envelope validator (replaces Ajv runtime compilation)
//
// Validates the envelope shell (v, seq, ts, stream, trace?, scope?) and that
// `type` is one of the known event types with a non-null payload object.
// Per-payload-variant validation is intentionally lightweight: the server
// already performs strict schema validation; the client only needs enough
// structural checking to safely dispatch inside the switch statement.
// ---------------------------------------------------------------------------
const KNOWN_EVENT_TYPES: ReadonlySet<string> = new Set(Object.values(MothershipStreamV1EventType))
function isValidEnvelopeShell(value: unknown): value is JsonRecord & {
v: 1
seq: number
ts: string
stream: MothershipStreamV1StreamRef
type: string
payload: JsonRecord
} {
if (!isRecordLike(value)) return false
if (value.v !== 1) return false
if (typeof value.seq !== 'number' || !Number.isFinite(value.seq)) return false
if (typeof value.ts !== 'string') return false
if (!isStreamRef(value.stream)) return false
if (value.trace !== undefined && !isTrace(value.trace)) return false
if (value.scope !== undefined && !isStreamScope(value.scope)) return false
if (typeof value.type !== 'string' || !KNOWN_EVENT_TYPES.has(value.type)) return false
if (!isRecordLike(value.payload)) return false
return true
}
function isValidSessionPayload(payload: JsonRecord): boolean {
const kind = payload.kind
if (typeof kind !== 'string') return false
switch (kind) {
case MothershipStreamV1SessionKind.start:
return true
case MothershipStreamV1SessionKind.chat:
return typeof payload.chatId === 'string'
case MothershipStreamV1SessionKind.title:
return typeof payload.title === 'string'
case MothershipStreamV1SessionKind.trace:
return typeof payload.requestId === 'string'
default:
return false
}
}
function isValidTextPayload(payload: JsonRecord): boolean {
return (
(payload.channel === MothershipStreamV1TextChannel.assistant ||
payload.channel === MothershipStreamV1TextChannel.thinking) &&
typeof payload.text === 'string'
)
}
function isValidToolPayload(payload: JsonRecord): boolean {
if (typeof payload.toolCallId !== 'string') return false
if (typeof payload.toolName !== 'string') return false
const phase = payload.phase
return (
phase === MothershipStreamV1ToolPhase.call ||
phase === MothershipStreamV1ToolPhase.args_delta ||
phase === MothershipStreamV1ToolPhase.result
)
}
function isValidSpanPayload(payload: JsonRecord): boolean {
const kind = payload.kind
return (
kind === MothershipStreamV1SpanPayloadKind.subagent ||
kind === MothershipStreamV1SpanPayloadKind.structured_result ||
kind === MothershipStreamV1SpanPayloadKind.subagent_result
)
}
function isValidResourcePayload(payload: JsonRecord): boolean {
return (
(payload.op === MothershipStreamV1ResourceOp.upsert ||
payload.op === MothershipStreamV1ResourceOp.remove) &&
isRecordLike(payload.resource) &&
typeof (payload.resource as JsonRecord).id === 'string' &&
typeof (payload.resource as JsonRecord).type === 'string'
)
}
function isValidRunPayload(payload: JsonRecord): boolean {
const kind = payload.kind
return (
kind === MothershipStreamV1RunKind.checkpoint_pause ||
kind === MothershipStreamV1RunKind.resumed ||
kind === MothershipStreamV1RunKind.compaction_start ||
kind === MothershipStreamV1RunKind.compaction_done
)
}
function isValidErrorPayload(payload: JsonRecord): boolean {
return typeof payload.message === 'string' || typeof payload.error === 'string'
}
function isValidCompletePayload(payload: JsonRecord): boolean {
return typeof payload.status === 'string'
}
function isContractEnvelope(value: unknown): value is MothershipStreamV1EventEnvelope {
if (!isValidEnvelopeShell(value)) return false
const payload = value.payload as JsonRecord
switch (value.type) {
case MothershipStreamV1EventType.session:
return isValidSessionPayload(payload)
case MothershipStreamV1EventType.text:
return isValidTextPayload(payload)
case MothershipStreamV1EventType.tool:
return isValidToolPayload(payload)
case MothershipStreamV1EventType.span:
return isValidSpanPayload(payload)
case MothershipStreamV1EventType.resource:
return isValidResourcePayload(payload)
case MothershipStreamV1EventType.run:
return isValidRunPayload(payload)
case MothershipStreamV1EventType.error:
return isValidErrorPayload(payload)
case MothershipStreamV1EventType.complete:
return isValidCompletePayload(payload)
default:
return false
}
}
// ---------------------------------------------------------------------------
// Synthetic file-preview envelope validators
// ---------------------------------------------------------------------------
function isSyntheticEnvelopeBase(value: unknown): value is Omit<
SyntheticFilePreviewEventEnvelope,
'payload'
> & {
payload?: unknown
} {
return (
isRecordLike(value) &&
value.v === 1 &&
value.type === 'tool' &&
typeof value.seq === 'number' &&
Number.isFinite(value.seq) &&
typeof value.ts === 'string' &&
isStreamRef(value.stream) &&
(value.trace === undefined || isTrace(value.trace)) &&
(value.scope === undefined || isStreamScope(value.scope))
)
}
function isSyntheticFilePreviewTarget(value: unknown): value is SyntheticFilePreviewTarget {
return (
isRecordLike(value) &&
(value.kind === 'new_file' || value.kind === 'file_id') &&
isOptionalString(value.fileId) &&
isOptionalString(value.fileName)
)
}
function isSyntheticFilePreviewPayload(value: unknown): value is SyntheticFilePreviewPayload {
if (!isRecordLike(value)) {
return false
}
if (typeof value.toolCallId !== 'string' || value.toolName !== 'workspace_file') {
return false
}
switch (value.previewPhase) {
case FILE_PREVIEW_PHASE.start:
return true
case FILE_PREVIEW_PHASE.target:
return (
isSyntheticFilePreviewTarget(value.target) &&
isOptionalString(value.operation) &&
isOptionalString(value.title)
)
case FILE_PREVIEW_PHASE.editMeta:
return isRecordLike(value.edit)
case FILE_PREVIEW_PHASE.content:
return (
typeof value.content === 'string' &&
(value.contentMode === 'delta' || value.contentMode === 'snapshot') &&
typeof value.previewVersion === 'number' &&
Number.isFinite(value.previewVersion) &&
typeof value.fileName === 'string' &&
isOptionalString(value.fileId) &&
isOptionalString(value.targetKind) &&
isOptionalString(value.operation) &&
(value.edit === undefined || isRecordLike(value.edit))
)
case FILE_PREVIEW_PHASE.complete:
return isOptionalString(value.fileId) && isOptionalFiniteNumber(value.previewVersion)
default:
return false
}
}
export function isSyntheticFilePreviewEventEnvelope(
value: unknown
): value is SyntheticFilePreviewEventEnvelope {
return isSyntheticEnvelopeBase(value) && isSyntheticFilePreviewPayload(value.payload)
}
// ---------------------------------------------------------------------------
// Stream event type guards
// ---------------------------------------------------------------------------
export function isToolCallStreamEvent(event: SessionStreamEvent): event is ToolCallStreamEvent {
return event.type === 'tool' && isRecordLike(event.payload) && event.payload.phase === 'call'
}
export function isToolArgsDeltaStreamEvent(
event: SessionStreamEvent
): event is ToolArgsDeltaStreamEvent {
return (
event.type === 'tool' && isRecordLike(event.payload) && event.payload.phase === 'args_delta'
)
}
export function isToolResultStreamEvent(event: SessionStreamEvent): event is ToolResultStreamEvent {
return event.type === 'tool' && isRecordLike(event.payload) && event.payload.phase === 'result'
}
export function isSubagentSpanStreamEvent(
event: SessionStreamEvent
): event is SubagentSpanStreamEvent {
return event.type === 'span' && isRecordLike(event.payload) && event.payload.kind === 'subagent'
}
// ---------------------------------------------------------------------------
// Public contract validators & parsers
// ---------------------------------------------------------------------------
export function isContractStreamEventEnvelope(
value: unknown
): value is MothershipStreamV1EventEnvelope {
return isContractEnvelope(value)
}
export function parsePersistedStreamEventEnvelope(value: unknown): ParseStreamEventEnvelopeResult {
if (isContractEnvelope(value)) {
return { ok: true, event: value }
}
if (isSyntheticFilePreviewEventEnvelope(value)) {
return { ok: true, event: value }
}
const hints: string[] = []
if (!isRecordLike(value)) {
hints.push('value is not an object')
} else {
if (value.v !== 1) hints.push(`unexpected v=${JSON.stringify(value.v)}`)
if (typeof value.type !== 'string') hints.push('missing type')
else if (!KNOWN_EVENT_TYPES.has(value.type)) hints.push(`unknown type="${value.type}"`)
if (!isRecordLike(value.payload)) hints.push('missing or invalid payload')
}
return {
ok: false,
reason: 'invalid_stream_event',
message: 'A stream event failed validation.',
...(hints.length > 0 ? { errors: hints } : {}),
}
}
export function parsePersistedStreamEventEnvelopeJson(raw: string): ParseStreamEventEnvelopeResult {
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch (error) {
const rawMessage = getErrorMessage(error, 'Invalid JSON')
return {
ok: false,
reason: 'invalid_json',
message: 'Received invalid JSON while parsing a stream event.',
...(rawMessage ? { errors: [rawMessage] } : {}),
}
}
return parsePersistedStreamEventEnvelope(parsed)
}
@@ -0,0 +1,63 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
MothershipStreamV1EventType,
MothershipStreamV1TextChannel,
} from '@/lib/copilot/generated/mothership-stream-v1'
import { parsePersistedStreamEventEnvelope } from '@/lib/copilot/request/session'
import { createEvent, eventToStreamEvent } from '@/lib/copilot/request/session/event'
describe('createEvent', () => {
it('creates contract envelopes that pass validation', () => {
const envelope = createEvent({
streamId: 'stream-1',
cursor: '1',
seq: 1,
requestId: 'req-1',
type: MothershipStreamV1EventType.text,
payload: {
channel: MothershipStreamV1TextChannel.assistant,
text: 'hello',
},
})
const parsed = parsePersistedStreamEventEnvelope(envelope)
expect(parsed.ok).toBe(true)
expect(envelope.type).toBe(MothershipStreamV1EventType.text)
expect(envelope.payload).toEqual({
channel: MothershipStreamV1TextChannel.assistant,
text: 'hello',
})
})
it('creates synthetic preview envelopes that round-trip to stream events', () => {
const envelope = createEvent({
streamId: 'stream-1',
cursor: '2',
seq: 2,
requestId: 'req-1',
type: MothershipStreamV1EventType.tool,
payload: {
previewPhase: 'file_preview_start',
toolCallId: 'preview-1',
toolName: 'workspace_file',
},
})
const parsed = parsePersistedStreamEventEnvelope(envelope)
expect(parsed.ok).toBe(true)
const streamEvent = eventToStreamEvent(envelope)
expect(streamEvent).toEqual({
type: MothershipStreamV1EventType.tool,
payload: {
previewPhase: 'file_preview_start',
toolCallId: 'preview-1',
toolName: 'workspace_file',
},
})
})
})
@@ -0,0 +1,74 @@
import {
type PersistedStreamEventEnvelope,
parsePersistedStreamEventEnvelope,
type SessionStreamEvent,
} from './contract'
type CreateEventBase = {
chatId?: string
cursor: string
requestId: string
seq: number
streamId: string
ts?: string
}
type CreateEventVariant<TEvent extends SessionStreamEvent> = CreateEventBase &
Pick<TEvent, 'type' | 'payload' | 'scope'>
export type CreateEventInput = SessionStreamEvent extends infer TEvent
? TEvent extends SessionStreamEvent
? CreateEventVariant<TEvent>
: never
: never
type CreateEventResult<TInput extends CreateEventInput> = Extract<
PersistedStreamEventEnvelope,
{ type: TInput['type']; payload: TInput['payload'] }
>
type StreamEventFromEnvelope<TEnvelope extends PersistedStreamEventEnvelope> = Extract<
SessionStreamEvent,
{ type: TEnvelope['type']; payload: TEnvelope['payload'] }
>
export const TOOL_CALL_STATUS = {
generating: 'generating',
} as const
export function createEvent<TInput extends CreateEventInput>(
input: TInput
): CreateEventResult<TInput> {
const { streamId, chatId, cursor, seq, requestId, type, payload, scope, ts } = input
return {
v: 1,
type,
seq,
ts: ts ?? new Date().toISOString(),
stream: {
streamId,
...(chatId ? { chatId } : {}),
cursor,
},
trace: {
requestId,
},
...(scope ? { scope } : {}),
payload,
} as CreateEventResult<TInput>
}
export function isEventRecord(value: unknown): value is PersistedStreamEventEnvelope {
return parsePersistedStreamEventEnvelope(value).ok
}
export function eventToStreamEvent<TEnvelope extends PersistedStreamEventEnvelope>(
envelope: TEnvelope
): StreamEventFromEnvelope<TEnvelope> {
return {
type: envelope.type,
payload: envelope.payload,
...(envelope.scope ? { scope: envelope.scope } : {}),
} as StreamEventFromEnvelope<TEnvelope>
}
@@ -0,0 +1,68 @@
import type { Context } from '@opentelemetry/api'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
import { AbortReason } from '@/lib/copilot/request/session/abort'
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
export const DEFAULT_EXPLICIT_ABORT_TIMEOUT_MS = 3000
export async function requestExplicitStreamAbort(params: {
streamId: string
userId: string
chatId?: string
workspaceId?: string
timeoutMs?: number
otelContext?: Context
}): Promise<void> {
const {
streamId,
userId,
chatId,
workspaceId,
timeoutMs = DEFAULT_EXPLICIT_ABORT_TIMEOUT_MS,
otelContext,
} = params
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (env.COPILOT_API_KEY) {
headers['x-api-key'] = env.COPILOT_API_KEY
}
Object.assign(headers, getMothershipSourceEnvHeaders())
const controller = new AbortController()
const timeout = setTimeout(
() => controller.abort(AbortReason.ExplicitAbortFetchTimeout),
timeoutMs
)
try {
const mothershipBaseURL = await getMothershipBaseURL({ userId })
const response = await fetchGo(`${mothershipBaseURL}/api/streams/explicit-abort`, {
method: 'POST',
headers,
signal: controller.signal,
body: JSON.stringify({
messageId: streamId,
userId,
...(chatId ? { chatId } : {}),
...(workspaceId ? { workspaceId } : {}),
}),
otelContext,
spanName: 'sim → go /api/streams/explicit-abort',
operation: 'explicit_abort',
attributes: {
[TraceAttr.StreamId]: streamId,
...(chatId ? { [TraceAttr.ChatId]: chatId } : {}),
},
})
if (!response.ok) {
throw new Error(`Explicit abort marker request failed: ${response.status}`)
}
} finally {
clearTimeout(timeout)
}
}
@@ -0,0 +1,53 @@
export const FILE_PREVIEW_SESSION_SCHEMA_VERSION = 1 as const
export type FilePreviewTargetKind = 'new_file' | 'file_id'
export type FilePreviewStatus = 'pending' | 'streaming' | 'complete'
export type FilePreviewContentMode = 'delta' | 'snapshot'
export interface FilePreviewSession {
schemaVersion: typeof FILE_PREVIEW_SESSION_SCHEMA_VERSION
id: string
streamId: string
toolCallId: string
status: FilePreviewStatus
fileName: string
fileId?: string
targetKind?: FilePreviewTargetKind
operation?: string
edit?: Record<string, unknown>
baseContent?: string
previewText: string
previewVersion: number
updatedAt: string
completedAt?: string
}
export function isFilePreviewSession(value: unknown): value is FilePreviewSession {
if (!value || typeof value !== 'object') {
return false
}
const record = value as Record<string, unknown>
return (
record.schemaVersion === FILE_PREVIEW_SESSION_SCHEMA_VERSION &&
typeof record.id === 'string' &&
typeof record.streamId === 'string' &&
typeof record.toolCallId === 'string' &&
typeof record.status === 'string' &&
typeof record.fileName === 'string' &&
(record.baseContent === undefined || typeof record.baseContent === 'string') &&
typeof record.previewText === 'string' &&
typeof record.previewVersion === 'number' &&
typeof record.updatedAt === 'string'
)
}
export function sortFilePreviewSessions(sessions: FilePreviewSession[]): FilePreviewSession[] {
return [...sessions].sort((a, b) => {
const updatedAtCompare = a.updatedAt.localeCompare(b.updatedAt)
if (updatedAtCompare !== 0) {
return updatedAtCompare
}
return a.id.localeCompare(b.id)
})
}
@@ -0,0 +1,42 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
createFilePreviewSession,
sortFilePreviewSessions,
} from '@/lib/copilot/request/session/file-preview-session'
describe('file preview session helpers', () => {
it('preserves baseContent when creating a preview session', () => {
const session = createFilePreviewSession({
streamId: 'stream-1',
toolCallId: 'preview-1',
fileName: 'draft.md',
baseContent: 'existing content',
})
expect(session.baseContent).toBe('existing content')
})
it('sorts preview sessions by updatedAt across tool call ids', () => {
const sessions = sortFilePreviewSessions([
createFilePreviewSession({
streamId: 'stream-1',
toolCallId: 'preview-2',
fileName: 'b.md',
previewVersion: 10,
updatedAt: '2026-04-10T00:00:02.000Z',
}),
createFilePreviewSession({
streamId: 'stream-1',
toolCallId: 'preview-1',
fileName: 'a.md',
previewVersion: 1,
updatedAt: '2026-04-10T00:00:01.000Z',
}),
])
expect(sessions.map((session) => session.id)).toEqual(['preview-1', 'preview-2'])
})
})
@@ -0,0 +1,174 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { getRedisClient } from '@/lib/core/config/redis'
import { getStreamConfig } from './buffer'
import {
FILE_PREVIEW_SESSION_SCHEMA_VERSION,
type FilePreviewSession,
type FilePreviewStatus,
type FilePreviewTargetKind,
isFilePreviewSession,
sortFilePreviewSessions,
} from './file-preview-session-contract'
const logger = createLogger('FilePreviewSessionStore')
const STREAM_OUTBOX_PREFIX = 'mothership_stream:'
const DEFAULT_COMPLETED_TTL_SECONDS = 5 * 60
const RETRY_DELAYS_MS = [0, 50, 150] as const
export type {
FilePreviewSession,
FilePreviewStatus,
FilePreviewTargetKind,
} from './file-preview-session-contract'
export { sortFilePreviewSessions } from './file-preview-session-contract'
function getPreviewSessionsKey(streamId: string): string {
return `${STREAM_OUTBOX_PREFIX}${streamId}:preview_sessions`
}
type RedisOperationMetadata = {
operation: string
streamId: string
}
async function withRedisRetry<T>(
metadata: RedisOperationMetadata,
operation: (redis: NonNullable<ReturnType<typeof getRedisClient>>) => Promise<T>
): Promise<T> {
const redis = getRedisClient()
if (!redis) {
throw new Error('Redis is required for mothership preview durability')
}
let lastError: unknown
for (let attempt = 0; attempt < RETRY_DELAYS_MS.length; attempt++) {
const delay = RETRY_DELAYS_MS[attempt]
if (delay > 0) {
await sleep(delay)
}
try {
return await operation(redis)
} catch (error) {
lastError = error
logger.warn('Redis preview session operation failed', {
operation: metadata.operation,
streamId: metadata.streamId,
attempt: attempt + 1,
error: toError(error).message,
})
}
}
throw lastError instanceof Error
? lastError
: new Error(`${metadata.operation} failed for stream ${metadata.streamId}`)
}
export function createFilePreviewSession(input: {
streamId: string
toolCallId: string
fileName?: string
fileId?: string
targetKind?: FilePreviewTargetKind
operation?: string
edit?: Record<string, unknown>
baseContent?: string
previewText?: string
previewVersion?: number
status?: FilePreviewStatus
updatedAt?: string
completedAt?: string
}): FilePreviewSession {
return {
schemaVersion: FILE_PREVIEW_SESSION_SCHEMA_VERSION,
id: input.toolCallId,
streamId: input.streamId,
toolCallId: input.toolCallId,
status: input.status ?? 'pending',
fileName: input.fileName ?? '',
...(input.fileId ? { fileId: input.fileId } : {}),
...(input.targetKind ? { targetKind: input.targetKind } : {}),
...(input.operation ? { operation: input.operation } : {}),
...(input.edit ? { edit: input.edit } : {}),
...(typeof input.baseContent === 'string' ? { baseContent: input.baseContent } : {}),
previewText: input.previewText ?? '',
previewVersion: input.previewVersion ?? 0,
updatedAt: input.updatedAt ?? new Date().toISOString(),
...(input.completedAt ? { completedAt: input.completedAt } : {}),
}
}
export async function upsertFilePreviewSession(
session: FilePreviewSession
): Promise<FilePreviewSession> {
const config = getStreamConfig()
await withRedisRetry(
{ operation: 'upsert_preview_session', streamId: session.streamId },
async (redis) => {
const key = getPreviewSessionsKey(session.streamId)
const pipeline = redis.pipeline()
pipeline.hset(key, session.id, JSON.stringify(session))
pipeline.expire(key, config.ttlSeconds)
await pipeline.exec()
}
)
return session
}
export async function readFilePreviewSessions(streamId: string): Promise<FilePreviewSession[]> {
const raw = await withRedisRetry(
{ operation: 'read_preview_sessions', streamId },
async (redis) => redis.hgetall(getPreviewSessionsKey(streamId))
)
const sessions: FilePreviewSession[] = []
const values = Object.values(raw ?? {})
for (const entry of values) {
try {
const parsed = JSON.parse(entry) as unknown
if (!isFilePreviewSession(parsed)) {
logger.warn('Skipping invalid file preview session entry', { streamId })
continue
}
sessions.push(parsed)
} catch (error) {
logger.warn('Failed to parse file preview session entry', {
streamId,
error: toError(error).message,
})
}
}
return sortFilePreviewSessions(sessions)
}
export async function clearFilePreviewSessions(streamId: string): Promise<void> {
await withRedisRetry({ operation: 'clear_preview_sessions', streamId }, async (redis) => {
await redis.del(getPreviewSessionsKey(streamId))
})
}
export async function scheduleFilePreviewSessionCleanup(
streamId: string,
ttlSeconds = DEFAULT_COMPLETED_TTL_SECONDS
): Promise<void> {
try {
await withRedisRetry(
{ operation: 'schedule_preview_session_cleanup', streamId },
async (redis) => {
await redis.expire(getPreviewSessionsKey(streamId), ttlSeconds)
}
)
} catch (error) {
logger.warn('Failed to shorten preview session retention', {
streamId,
ttlSeconds,
error: toError(error).message,
})
}
}
@@ -0,0 +1,76 @@
export type { ChatStreamLockOwnersResult } from './abort'
export {
AbortReason,
type AbortReasonValue,
abortActiveStream,
acquirePendingChatStream,
cleanupAbortMarker,
getChatStreamLockOwners,
getPendingChatStreamId,
isExplicitStopReason,
registerActiveStream,
releasePendingChatStream,
startAbortPoller,
unregisterActiveStream,
waitForPendingChatStream,
} from './abort'
export {
allocateCursor,
appendEvent,
appendEvents,
clearAbortMarker,
clearBuffer,
getLatestSeq,
getOldestSeq,
hasAbortMarker,
InvalidCursorError,
readEvents,
resetBuffer,
scheduleBufferCleanup,
writeAbortMarker,
} from './buffer'
export type {
ContractStreamEvent,
PersistedStreamEventEnvelope,
SessionStreamEvent,
StreamEvent,
SubagentSpanStreamEvent,
SyntheticFilePreviewEventEnvelope,
SyntheticFilePreviewPayload,
SyntheticStreamEvent,
ToolArgsDeltaStreamEvent,
ToolCallStreamEvent,
ToolResultStreamEvent,
} from './contract'
export {
isContractStreamEventEnvelope,
isSubagentSpanStreamEvent,
isSyntheticFilePreviewEventEnvelope,
isToolArgsDeltaStreamEvent,
isToolCallStreamEvent,
isToolResultStreamEvent,
parsePersistedStreamEventEnvelope,
parsePersistedStreamEventEnvelopeJson,
} from './contract'
export { createEvent, eventToStreamEvent, isEventRecord, TOOL_CALL_STATUS } from './event'
export {
clearFilePreviewSessions,
createFilePreviewSession,
readFilePreviewSessions,
scheduleFilePreviewSessionCleanup,
upsertFilePreviewSession,
} from './file-preview-session'
export type {
FilePreviewContentMode,
FilePreviewSession,
FilePreviewStatus,
FilePreviewTargetKind,
} from './file-preview-session-contract'
export {
FILE_PREVIEW_SESSION_SCHEMA_VERSION,
isFilePreviewSession,
} from './file-preview-session-contract'
export { checkForReplayGap, type ReplayGapResult } from './recovery'
export { encodeSSEComment, encodeSSEEnvelope, SSE_RESPONSE_HEADERS } from './sse'
export type { StreamBatchEvent } from './types'
export { StreamWriter, type StreamWriterOptions } from './writer'
@@ -0,0 +1,38 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
const { getLatestSeq, getOldestSeq, readEvents } = vi.hoisted(() => ({
getLatestSeq: vi.fn(),
getOldestSeq: vi.fn(),
readEvents: vi.fn(),
}))
vi.mock('./buffer', () => ({
getLatestSeq,
getOldestSeq,
readEvents,
}))
import { checkForReplayGap } from './recovery'
describe('checkForReplayGap', () => {
it('uses the latest buffered request id when run metadata is missing it', async () => {
getOldestSeq.mockResolvedValue(10)
getLatestSeq.mockResolvedValue(12)
readEvents.mockResolvedValue([
{
trace: { requestId: 'req-live-123' },
},
])
const result = await checkForReplayGap('stream-1', '1')
expect(readEvents).toHaveBeenCalledWith('stream-1', '11')
expect(result?.gapDetected).toBe(true)
expect(result?.envelopes[0].trace.requestId).toBe('req-live-123')
expect(result?.envelopes[1].trace.requestId).toBe('req-live-123')
})
})
@@ -0,0 +1,124 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import {
MothershipStreamV1CompletionStatus,
MothershipStreamV1EventType,
} from '@/lib/copilot/generated/mothership-stream-v1'
import { CopilotRecoveryOutcome } from '@/lib/copilot/generated/trace-attribute-values-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 { getLatestSeq, getOldestSeq, readEvents } from './buffer'
import { createEvent } from './event'
const logger = createLogger('SessionRecovery')
export interface ReplayGapResult {
gapDetected: true
envelopes: ReturnType<typeof createEvent>[]
}
export async function checkForReplayGap(
streamId: string,
afterCursor: string,
requestId?: string
): Promise<ReplayGapResult | null> {
const requestedAfterSeq = Number(afterCursor || '0')
if (requestedAfterSeq <= 0) {
// Fast path: no cursor → nothing to check. Skip the span to avoid
// emitting zero-work spans on every stream connect.
return null
}
return withCopilotSpan(
TraceSpan.CopilotRecoveryCheckReplayGap,
{
[TraceAttr.StreamId]: streamId,
[TraceAttr.CopilotRecoveryRequestedAfterSeq]: requestedAfterSeq,
...(requestId ? { [TraceAttr.RequestId]: requestId } : {}),
},
async (span) => {
const oldestSeq = await getOldestSeq(streamId)
const latestSeq = await getLatestSeq(streamId)
span.setAttributes({
[TraceAttr.CopilotRecoveryOldestSeq]: oldestSeq ?? -1,
[TraceAttr.CopilotRecoveryLatestSeq]: latestSeq ?? -1,
})
if (
latestSeq !== null &&
latestSeq > 0 &&
oldestSeq !== null &&
requestedAfterSeq < oldestSeq - 1
) {
const resolvedRequestId = await resolveReplayGapRequestId(streamId, latestSeq, requestId)
logger.warn('Replay gap detected: requested cursor is below oldest available event', {
streamId,
requestedAfterSeq,
oldestAvailableSeq: oldestSeq,
latestSeq,
})
span.setAttribute(TraceAttr.CopilotRecoveryOutcome, CopilotRecoveryOutcome.GapDetected)
const gapEnvelope = createEvent({
streamId,
cursor: String(latestSeq + 1),
seq: latestSeq + 1,
requestId: resolvedRequestId,
type: MothershipStreamV1EventType.error,
payload: {
message: 'Replay history is no longer available. Some events may have been lost.',
code: 'replay_gap',
data: {
oldestAvailableSeq: oldestSeq,
requestedAfterSeq,
},
},
})
const terminalEnvelope = createEvent({
streamId,
cursor: String(latestSeq + 2),
seq: latestSeq + 2,
requestId: resolvedRequestId,
type: MothershipStreamV1EventType.complete,
payload: {
status: MothershipStreamV1CompletionStatus.error,
reason: 'replay_gap',
},
})
return {
gapDetected: true,
envelopes: [gapEnvelope, terminalEnvelope],
}
}
span.setAttribute(TraceAttr.CopilotRecoveryOutcome, CopilotRecoveryOutcome.InRange)
return null
}
)
}
async function resolveReplayGapRequestId(
streamId: string,
latestSeq: number,
requestId?: string
): Promise<string> {
if (typeof requestId === 'string' && requestId.length > 0) {
return requestId
}
try {
const latestEvents = await readEvents(streamId, String(Math.max(latestSeq - 1, 0)))
const latestRequestId = latestEvents[0]?.trace?.requestId
return typeof latestRequestId === 'string' ? latestRequestId : ''
} catch (error) {
logger.warn('Failed to resolve request ID for replay gap', {
streamId,
latestSeq,
error: getErrorMessage(error),
})
return ''
}
}
@@ -0,0 +1,16 @@
import { SSE_HEADERS } from '@/lib/core/utils/sse'
const encoder = new TextEncoder()
export function encodeSSEEnvelope(envelope: unknown): Uint8Array {
return encoder.encode(`data: ${JSON.stringify(envelope)}\n\n`)
}
export function encodeSSEComment(comment: string): Uint8Array {
return encoder.encode(`: ${comment}\n\n`)
}
export const SSE_RESPONSE_HEADERS = {
...SSE_HEADERS,
'Content-Encoding': 'none',
} as const
@@ -0,0 +1,35 @@
import type { PersistedStreamEventEnvelope, SessionStreamEvent } from './contract'
import { parsePersistedStreamEventEnvelope } from './contract'
export type StreamEvent = SessionStreamEvent
export interface StreamBatchEvent {
eventId: number
streamId: string
event: PersistedStreamEventEnvelope
}
export function toStreamBatchEvent(envelope: PersistedStreamEventEnvelope): StreamBatchEvent {
return {
eventId: envelope.seq,
streamId: envelope.stream.streamId,
event: envelope,
}
}
export function isStreamBatchEvent(value: unknown): value is StreamBatchEvent {
if (!value || typeof value !== 'object') {
return false
}
const record = value as Record<string, unknown>
if (
typeof record.eventId !== 'number' ||
!Number.isFinite(record.eventId) ||
typeof record.streamId !== 'string'
) {
return false
}
return parsePersistedStreamEventEnvelope(record.event).ok
}
@@ -0,0 +1,189 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
MothershipStreamV1EventType,
MothershipStreamV1TextChannel,
} from '@/lib/copilot/generated/mothership-stream-v1'
import type { StreamEvent } from '@/lib/copilot/request/session'
const { appendEvents } = vi.hoisted(() => ({
appendEvents: vi.fn(),
}))
vi.mock('@/lib/copilot/request/session/buffer', () => ({
appendEvents,
}))
import { StreamWriter } from '@/lib/copilot/request/session/writer'
function decodeChunk(value: Uint8Array): string {
return new TextDecoder().decode(value)
}
describe('StreamWriter', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
})
it('enqueues before persistence completes and flushes pending writes on close', async () => {
let releasePersist: (() => void) | null = null
appendEvents.mockImplementation(
() =>
new Promise<void>((resolve) => {
releasePersist = resolve
})
)
const writer = new StreamWriter({
streamId: 'stream-1',
chatId: 'chat-1',
requestId: 'req-1',
})
const chunks: string[] = []
let closeCount = 0
const controller = {
enqueue: vi.fn((value: Uint8Array) => {
chunks.push(decodeChunk(value))
}),
close: vi.fn(() => {
closeCount += 1
}),
} as unknown as ReadableStreamDefaultController
writer.attach(controller)
await writer.publish({
type: MothershipStreamV1EventType.text,
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
})
expect(controller.enqueue).toHaveBeenCalledOnce()
expect(appendEvents).not.toHaveBeenCalled()
expect(chunks[0]).toContain('"text":"hello"')
expect(closeCount).toBe(0)
const closePromise = writer.close()
await Promise.resolve()
await Promise.resolve()
expect(appendEvents).toHaveBeenCalledOnce()
expect(closeCount).toBe(0)
const resolvePersist = releasePersist
if (typeof resolvePersist === 'function') {
resolvePersist()
}
await closePromise
expect(closeCount).toBe(1)
})
it('batches publishes on the flush timer and preserves sequence order', async () => {
vi.useFakeTimers()
const persistedSeqs: number[] = []
appendEvents.mockImplementation(async (envelopes) => {
persistedSeqs.push(...envelopes.map((envelope) => envelope.seq))
return envelopes
})
const writer = new StreamWriter({
streamId: 'stream-1',
requestId: 'req-1',
})
const chunks: string[] = []
const controller = {
enqueue: vi.fn((value: Uint8Array) => {
chunks.push(decodeChunk(value))
}),
close: vi.fn(),
} as unknown as ReadableStreamDefaultController
writer.attach(controller)
await Promise.all([
writer.publish({
type: MothershipStreamV1EventType.text,
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'one' },
}),
writer.publish({
type: MothershipStreamV1EventType.text,
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'two' },
}),
])
expect(appendEvents).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(15)
await writer.close()
expect(persistedSeqs).toEqual([1, 2])
expect(appendEvents).toHaveBeenCalledWith([
expect.objectContaining({ seq: 1 }),
expect.objectContaining({ seq: 2 }),
])
expect(chunks[0]).toContain('"seq":1')
expect(chunks[1]).toContain('"seq":2')
})
it('flush waits for persistence and surfaces failures', async () => {
appendEvents.mockRejectedValueOnce(new Error('redis down'))
const writer = new StreamWriter({
streamId: 'stream-1',
requestId: 'req-1',
})
writer.attach({
enqueue: vi.fn(),
close: vi.fn(),
} as unknown as ReadableStreamDefaultController)
await writer.publish({
type: MothershipStreamV1EventType.text,
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'boom' },
})
await expect(writer.flush()).rejects.toThrow('redis down')
})
it('persists synthetic preview events alongside contract events', async () => {
appendEvents.mockResolvedValue([])
const writer = new StreamWriter({
streamId: 'stream-1',
requestId: 'req-1',
})
const chunks: string[] = []
writer.attach({
enqueue: vi.fn((value: Uint8Array) => {
chunks.push(decodeChunk(value))
}),
close: vi.fn(),
} as unknown as ReadableStreamDefaultController)
await writer.publish({
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'preview-1',
toolName: 'workspace_file',
previewPhase: 'file_preview_start',
},
} satisfies StreamEvent)
await writer.flush()
expect(chunks[0]).toContain('"previewPhase":"file_preview_start"')
expect(appendEvents).toHaveBeenCalledWith([
expect.objectContaining({
type: MothershipStreamV1EventType.tool,
payload: expect.objectContaining({
toolCallId: 'preview-1',
previewPhase: 'file_preview_start',
}),
}),
])
})
})
@@ -0,0 +1,200 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1'
import { appendEvents } from './buffer'
import type { PersistedStreamEventEnvelope } from './contract'
import { createEvent } from './event'
import { encodeSSEComment, encodeSSEEnvelope } from './sse'
import type { StreamEvent } from './types'
const logger = createLogger('StreamWriter')
const DEFAULT_KEEPALIVE_MS = 15_000
const DEFAULT_PERSIST_FLUSH_INTERVAL_MS = 15
const DEFAULT_PERSIST_FLUSH_MAX_BATCH = 200
export interface StreamWriterOptions {
streamId: string
chatId?: string
requestId: string
keepaliveMs?: number
}
export class StreamWriter {
private readonly streamId: string
private readonly chatId: string | undefined
private requestId: string
private readonly keepaliveMs: number
private readonly flushIntervalMs: number
private readonly flushMaxBatch: number
private readonly encoder: TextEncoder
private controller: ReadableStreamDefaultController | null = null
private keepaliveInterval: ReturnType<typeof setInterval> | null = null
private flushTimer: ReturnType<typeof setTimeout> | null = null
private _clientDisconnected = false
private _sawComplete = false
private nextSeq = 0
private pendingEnvelopes: PersistedStreamEventEnvelope[] = []
private persistenceTail: Promise<void> = Promise.resolve()
private lastPersistenceError: Error | null = null
constructor(options: StreamWriterOptions) {
this.streamId = options.streamId
this.chatId = options.chatId
this.requestId = options.requestId
this.keepaliveMs = options.keepaliveMs ?? DEFAULT_KEEPALIVE_MS
this.flushIntervalMs = DEFAULT_PERSIST_FLUSH_INTERVAL_MS
this.flushMaxBatch = DEFAULT_PERSIST_FLUSH_MAX_BATCH
this.encoder = new TextEncoder()
}
get clientDisconnected(): boolean {
return this._clientDisconnected
}
get sawComplete(): boolean {
return this._sawComplete
}
updateRequestId(id: string): void {
this.requestId = id
}
attach(controller: ReadableStreamDefaultController): void {
this.controller = controller
}
startKeepalive(): void {
this.keepaliveInterval = setInterval(() => {
if (this._clientDisconnected || !this.controller) return
try {
this.controller.enqueue(encodeSSEComment('keepalive'))
} catch (error) {
this._clientDisconnected = true
logger.warn('Keepalive enqueue failed, marking client disconnected', {
streamId: this.streamId,
requestId: this.requestId,
error: toError(error).message,
})
}
}, this.keepaliveMs)
}
stopKeepalive(): void {
if (this.keepaliveInterval) {
clearInterval(this.keepaliveInterval)
this.keepaliveInterval = null
}
}
publish(event: StreamEvent): void {
const envelope = this.createEnvelope(event)
this.enqueue(envelope)
this.queuePersistence(envelope)
if (event.type === MothershipStreamV1EventType.complete) {
this._sawComplete = true
}
}
markDisconnected(): void {
this._clientDisconnected = true
}
async flush(): Promise<void> {
this.flushPendingPersistence()
await this.persistenceTail
if (this.lastPersistenceError) {
const error = this.lastPersistenceError
this.lastPersistenceError = null
throw error
}
}
async close(): Promise<void> {
this.stopKeepalive()
this.clearFlushTimer()
await this.flush()
if (!this.controller) return
try {
this.controller.close()
} catch {
// Controller already closed
}
this.controller = null
}
private enqueue(envelope: PersistedStreamEventEnvelope): void {
if (this._clientDisconnected || !this.controller) return
try {
this.controller.enqueue(encodeSSEEnvelope(envelope))
} catch (error) {
this._clientDisconnected = true
logger.warn('Envelope enqueue failed, marking client disconnected', {
streamId: this.streamId,
requestId: this.requestId,
seq: envelope.seq,
error: toError(error).message,
})
}
}
private createEnvelope(event: StreamEvent): PersistedStreamEventEnvelope {
const seq = ++this.nextSeq
return createEvent({
...event,
streamId: this.streamId,
chatId: this.chatId,
cursor: String(seq),
seq,
requestId: this.requestId,
})
}
private queuePersistence(envelope: PersistedStreamEventEnvelope): void {
this.pendingEnvelopes.push(envelope)
if (this.pendingEnvelopes.length >= this.flushMaxBatch) {
this.flushPendingPersistence()
return
}
if (this.flushTimer || this.pendingEnvelopes.length === 0) {
return
}
this.flushTimer = setTimeout(() => {
this.flushTimer = null
this.flushPendingPersistence()
}, this.flushIntervalMs)
}
private flushPendingPersistence(): void {
this.clearFlushTimer()
if (this.pendingEnvelopes.length === 0) {
return
}
const batch = this.pendingEnvelopes
this.pendingEnvelopes = []
this.persistenceTail = this.persistenceTail
.catch(() => undefined)
.then(() => appendEvents(batch))
.then(() => {
this.lastPersistenceError = null
})
.catch((error) => {
this.lastPersistenceError = toError(error)
logger.warn('Failed to persist stream envelope batch', {
streamId: this.streamId,
requestId: this.requestId,
batchSize: batch.length,
firstSeq: batch[0]?.seq,
lastSeq: batch[batch.length - 1]?.seq,
error: toError(error).message,
})
})
}
private clearFlushTimer(): void {
if (this.flushTimer) {
clearTimeout(this.flushTimer)
this.flushTimer = null
}
}
}
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import {
MothershipStreamV1EventType,
MothershipStreamV1ToolExecutor,
MothershipStreamV1ToolMode,
MothershipStreamV1ToolPhase,
} from '@/lib/copilot/generated/mothership-stream-v1'
import { TOOL_CALL_STATUS } from '@/lib/copilot/request/session'
import type { StreamEvent } from '@/lib/copilot/request/types'
import { shouldSkipToolCallEvent } from './sse-utils'
describe('shouldSkipToolCallEvent', () => {
it('skips pathless read and glob generating placeholders without marking the call seen', () => {
const readEvent = toolCallEvent('read-generating-placeholder', 'read', undefined, true)
const globEvent = toolCallEvent('glob-generating-placeholder', 'glob', undefined, true)
expect(shouldSkipToolCallEvent(readEvent)).toBe(true)
expect(shouldSkipToolCallEvent(globEvent)).toBe(true)
expect(
shouldSkipToolCallEvent(
toolCallEvent('read-generating-placeholder', 'read', {
path: 'components/integrations/slack/README.md',
})
)
).toBe(false)
expect(
shouldSkipToolCallEvent(
toolCallEvent('glob-generating-placeholder', 'glob', {
pattern: 'components/blocks/*/README.md',
})
)
).toBe(false)
})
it('keeps non-vfs generating placeholders visible', () => {
expect(
shouldSkipToolCallEvent(
toolCallEvent('search-generating-placeholder', 'search_online', undefined, true)
)
).toBe(false)
})
})
function toolCallEvent(
toolCallId: string,
toolName: string,
args?: Record<string, unknown>,
generating = false
): StreamEvent {
return {
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId,
toolName,
executor: MothershipStreamV1ToolExecutor.go,
mode: MothershipStreamV1ToolMode.sync,
phase: MothershipStreamV1ToolPhase.call,
...(generating ? { status: TOOL_CALL_STATUS.generating } : {}),
...(args ? { arguments: args } : {}),
},
} satisfies StreamEvent
}
+75
View File
@@ -0,0 +1,75 @@
import { STREAM_BUFFER_MAX_DEDUP_ENTRIES } from '@/lib/copilot/constants'
import {
isToolCallStreamEvent,
isToolResultStreamEvent,
type ToolCallStreamEvent,
type ToolResultStreamEvent,
} from '@/lib/copilot/request/session'
import { TOOL_CALL_STATUS } from '@/lib/copilot/request/session/event'
import type { StreamEvent } from '@/lib/copilot/request/types'
/** Safely cast event.data to a record for property access. */
export const asRecord = (data: unknown): Record<string, unknown> =>
(data && typeof data === 'object' && !Array.isArray(data) ? data : {}) as Record<string, unknown>
/**
* In-memory tool event dedupe with bounded size.
*
* NOTE: Process-local only. In a multi-instance setup (e.g., ECS),
* each task maintains its own dedupe cache.
*/
const seenToolCalls = new Set<string>()
const seenToolResults = new Set<string>()
function addToSet(set: Set<string>, id: string): void {
if (set.size >= STREAM_BUFFER_MAX_DEDUP_ENTRIES) {
const first = set.values().next().value
if (first) set.delete(first)
}
set.add(id)
}
function getToolCallIdFromCallEvent(event: ToolCallStreamEvent): string {
return event.payload.toolCallId
}
function getToolCallIdFromResultEvent(event: ToolResultStreamEvent): string {
return event.payload.toolCallId
}
function markToolCallSeen(toolCallId: string): void {
addToSet(seenToolCalls, toolCallId)
}
function wasToolCallSeen(toolCallId: string): boolean {
return seenToolCalls.has(toolCallId)
}
export function markToolResultSeen(toolCallId: string): void {
addToSet(seenToolResults, toolCallId)
}
export function wasToolResultSeen(toolCallId: string): boolean {
return seenToolResults.has(toolCallId)
}
export function shouldSkipToolCallEvent(event: StreamEvent): boolean {
if (!isToolCallStreamEvent(event)) return false
if (isPathlessVfsGeneratingEvent(event)) return true
if (event.payload.status === TOOL_CALL_STATUS.generating) return false
const toolCallId = getToolCallIdFromCallEvent(event)
if (event.payload.partial === true) return false
if (wasToolResultSeen(toolCallId) || wasToolCallSeen(toolCallId)) return true
markToolCallSeen(toolCallId)
return false
}
function isPathlessVfsGeneratingEvent(event: ToolCallStreamEvent): boolean {
if (event.payload.status !== TOOL_CALL_STATUS.generating) return false
if (event.payload.toolName !== 'read' && event.payload.toolName !== 'glob') return false
return event.payload.arguments === undefined
}
export function shouldSkipToolResultEvent(event: StreamEvent): boolean {
return isToolResultStreamEvent(event) && wasToolResultSeen(getToolCallIdFromResultEvent(event))
}
@@ -0,0 +1,104 @@
import {
MothershipStreamV1ToolOutcome,
type MothershipStreamV1ToolOutcome as TerminalToolCallStatus,
} from '@/lib/copilot/generated/mothership-stream-v1'
import type { ToolCallState, ToolCallStateResult } from '@/lib/copilot/request/types'
function hasOwnOutput(value: { output?: unknown }): value is { output: unknown } {
return Object.hasOwn(value, 'output')
}
export function isSuccessfulToolCallStatus(status: TerminalToolCallStatus): boolean {
return (
status === MothershipStreamV1ToolOutcome.success ||
status === MothershipStreamV1ToolOutcome.skipped
)
}
export function createToolCallStateResult(input: {
success: boolean
output?: unknown
}): ToolCallStateResult {
return hasOwnOutput(input)
? { success: input.success, output: input.output }
: { success: input.success }
}
export function getToolCallStateOutput(toolCall: Pick<ToolCallState, 'result'>): unknown {
if (!toolCall.result || !hasOwnOutput(toolCall.result)) {
return undefined
}
return toolCall.result.output
}
export function requireToolCallStateResult(
toolCall: Pick<ToolCallState, 'id' | 'status' | 'result'>
): ToolCallStateResult {
if (toolCall.result) {
return toolCall.result
}
throw new Error(
`Terminal tool call ${toolCall.id} is missing a canonical result for status ${toolCall.status}`
)
}
export function requireToolCallError(
toolCall: Pick<ToolCallState, 'id' | 'status' | 'error'>
): string {
if (typeof toolCall.error === 'string' && toolCall.error.length > 0) {
return toolCall.error
}
throw new Error(
`Terminal tool call ${toolCall.id} is missing a canonical error for status ${toolCall.status}`
)
}
export function getToolCallTerminalData(
toolCall: Pick<ToolCallState, 'id' | 'status' | 'result' | 'error'>
): unknown {
const output = getToolCallStateOutput(toolCall)
if (output !== undefined) {
return output
}
if (
toolCall.status === MothershipStreamV1ToolOutcome.success ||
toolCall.status === MothershipStreamV1ToolOutcome.skipped
) {
return undefined
}
return { error: requireToolCallError(toolCall) }
}
export function setTerminalToolCallState(
toolCall: ToolCallState,
input: {
status: TerminalToolCallStatus
output?: unknown
error?: string
endTime?: number
}
): void {
const success = isSuccessfulToolCallStatus(input.status)
toolCall.status = input.status
toolCall.endTime = input.endTime ?? Date.now()
toolCall.result = createToolCallStateResult({
success,
...(Object.hasOwn(input, 'output') ? { output: input.output } : {}),
})
if (success) {
toolCall.error = undefined
return
}
toolCall.error = requireToolCallError({
id: toolCall.id,
status: input.status,
error: input.error,
})
}
@@ -0,0 +1,96 @@
import { createLogger } from '@sim/logger'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { isEnterprise, isPaid } from '@/lib/billing/plan-helpers'
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
import {
MothershipStreamV1CompletionStatus,
MothershipStreamV1EventType,
MothershipStreamV1TextChannel,
} from '@/lib/copilot/generated/mothership-stream-v1'
import { sseHandlers } from '@/lib/copilot/request/handlers'
import type {
ExecutionContext,
OrchestratorOptions,
StreamEvent,
StreamingContext,
} from '@/lib/copilot/request/types'
const logger = createLogger('CopilotBillingEffect')
/**
* Handle a 402 billing-limit response from the Go backend.
*
* Determines whether the user needs a plan upgrade or a limit increase,
* then dispatches synthetic text + complete events through the handler chain
* so the client renders the upgrade prompt.
*/
export async function handleBillingLimitResponse(
userId: string,
context: StreamingContext,
execContext: ExecutionContext,
options: OrchestratorOptions
): Promise<void> {
let action: 'upgrade_plan' | 'increase_limit' = 'upgrade_plan'
let message = "You've reached your usage limit. Please upgrade your plan to continue."
try {
const sub = await getHighestPrioritySubscription(userId)
if (sub && isPaid(sub.plan)) {
// Paid subs use the existing `increase_limit` action so the UI
// (`UsageUpgradeDisplay`) renders its standard button. The message
// text does the work of clarifying the action when the user can't
// actually self-serve the limit change.
action = 'increase_limit'
const orgScoped = isOrgScopedSubscription(sub, userId)
if (orgScoped) {
message = isEnterprise(sub.plan)
? "You've reached your organization's usage limit for this billing period. Only an organization admin or Sim support can raise an enterprise limit — reach out to them to continue."
: "You've reached your organization's usage limit for this billing period. Only an organization owner or admin can raise the limit — please ask them to update it from the team billing settings."
} else {
message =
"You've reached your usage limit for this billing period. Please increase your usage limit from billing settings to continue."
}
}
} catch {
logger.warn('Failed to determine subscription plan, defaulting to upgrade_plan')
}
const upgradePayload = JSON.stringify({
reason: 'usage_limit',
action,
message,
})
const syntheticContent = `<usage_upgrade>${upgradePayload}</usage_upgrade>`
const syntheticEvents: StreamEvent[] = [
{
type: MothershipStreamV1EventType.text,
payload: {
channel: MothershipStreamV1TextChannel.assistant,
text: syntheticContent,
},
},
{
type: MothershipStreamV1EventType.complete,
payload: {
status: MothershipStreamV1CompletionStatus.complete,
},
},
]
for (const event of syntheticEvents) {
try {
await options.onEvent?.(event)
} catch {
logger.warn('Failed to forward synthetic billing event', { type: event.type })
}
// TODO: Handler dispatch should move out of this effect — effects should be
// pure side-effect producers; event dispatch belongs in the stream loop or
// a dedicated dispatcher. Keeping here for now to preserve behavior.
const handler = sseHandlers[event.type]
if (handler) {
await handler(event, context, execContext, options)
}
if (context.streamComplete) break
}
}
@@ -0,0 +1,33 @@
import {
ASYNC_TOOL_CONFIRMATION_STATUS,
type AsyncTerminalCompletionSnapshot,
isAsyncTerminalConfirmationStatus,
} from '@/lib/copilot/async-runs/lifecycle'
import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1'
import { waitForToolConfirmation } from '@/lib/copilot/persistence/tool-confirm'
/**
* Wait for a client-executable workflow tool to report back.
*
* Current browser runtime outcomes are:
* - `success`, `error`, `cancelled`: the workflow finished in the browser
* - `background`: the browser detached on `pagehide`, so the server should stop
* waiting for a foreground result
*/
export async function waitForToolCompletion(
toolCallId: string,
timeoutMs: number,
abortSignal?: AbortSignal
): Promise<AsyncTerminalCompletionSnapshot | null> {
const decision = await waitForToolConfirmation(toolCallId, timeoutMs, abortSignal, {
acceptStatus: (status) =>
status === MothershipStreamV1ToolOutcome.success ||
status === MothershipStreamV1ToolOutcome.error ||
status === ASYNC_TOOL_CONFIRMATION_STATUS.background ||
status === MothershipStreamV1ToolOutcome.cancelled,
})
if (decision && isAsyncTerminalConfirmationStatus(decision.status)) {
return { ...decision, status: decision.status }
}
return null
}
@@ -0,0 +1,864 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { isRecordLike } from '@sim/utils/object'
import type {
AsyncCompletionEnvelope,
AsyncCompletionSignal,
} from '@/lib/copilot/async-runs/lifecycle'
import {
completeAsyncToolCall,
markAsyncToolRunning,
upsertAsyncToolCall,
} from '@/lib/copilot/async-runs/repository'
import { TOOL_WATCHDOG_DEFAULT_MS, TOOL_WATCHDOG_LONG_RUNNING_MS } from '@/lib/copilot/constants'
import {
MothershipStreamV1AsyncToolRecordStatus,
MothershipStreamV1EventType,
MothershipStreamV1ToolExecutor,
MothershipStreamV1ToolMode,
MothershipStreamV1ToolOutcome,
MothershipStreamV1ToolPhase,
} from '@/lib/copilot/generated/mothership-stream-v1'
import {
CrawlWebsite,
CreateFile,
CreateWorkflow,
DownloadToWorkspaceFile,
EditContent,
Ffmpeg,
FunctionExecute,
GenerateAudio,
GenerateImage,
GenerateVideo,
KnowledgeBase,
MaterializeFile,
Media,
Research,
Run,
RunBlock,
RunFromBlock,
RunWorkflow,
RunWorkflowUntilBlock,
WorkspaceFile,
} from '@/lib/copilot/generated/tool-catalog-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { publishToolConfirmation } from '@/lib/copilot/persistence/tool-confirm'
import { recordSimToolMetric } from '@/lib/copilot/request/metrics'
import { withCopilotToolSpan } from '@/lib/copilot/request/otel'
import { markToolResultSeen } from '@/lib/copilot/request/sse-utils'
import {
getToolCallStateOutput,
getToolCallTerminalData,
requireToolCallError,
setTerminalToolCallState,
} from '@/lib/copilot/request/tool-call-state'
import { maybeWriteOutputToFile } from '@/lib/copilot/request/tools/files'
import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources'
import {
maybeWriteOutputToTable,
maybeWriteReadCsvToTable,
} from '@/lib/copilot/request/tools/tables'
import {
type ExecutionContext,
isTerminalToolCallStatus,
type OrchestratorOptions,
type StreamEvent,
type StreamingContext,
type ToolCallState,
} from '@/lib/copilot/request/types'
import { ensureHandlersRegistered, executeTool } from '@/lib/copilot/tool-executor'
export { waitForToolCompletion } from '@/lib/copilot/request/tools/client'
const logger = createLogger('CopilotSseToolExecution')
function hasOutputValue(result: { output?: unknown } | undefined): result is { output: unknown } {
return result !== undefined && Object.hasOwn(result, 'output')
}
interface ToolResultSpanSummary {
resultSuccess: boolean
outputBytes: number
outputKind: string
errorMessage?: string
imageCount?: number
imageBytes?: number
attachmentMediaType?: string
}
function summarizeToolResultForSpan(result: {
success: boolean
output?: unknown
error?: string
}): ToolResultSpanSummary {
const summary: ToolResultSpanSummary = {
resultSuccess: Boolean(result.success),
outputBytes: 0,
outputKind: 'none',
}
if (!result.success && result.error) {
summary.errorMessage = String(result.error).slice(0, 500)
}
if (!hasOutputValue(result)) {
return summary
}
const output = (result as { output: unknown }).output
if (typeof output === 'string') {
summary.outputKind = 'string'
summary.outputBytes = output.length
} else if (output && typeof output === 'object') {
summary.outputKind = Array.isArray(output) ? 'array' : 'object'
try {
summary.outputBytes = JSON.stringify(output).length
} catch {
summary.outputBytes = 0
}
const attachment = extractAttachmentShape(output)
if (attachment) {
summary.imageCount = attachment.imageCount
summary.imageBytes = attachment.imageBytes
if (attachment.mediaType) {
summary.attachmentMediaType = attachment.mediaType
}
}
} else if (output !== undefined && output !== null) {
summary.outputKind = typeof output
summary.outputBytes = String(output).length
}
return summary
}
function extractAttachmentShape(
output: unknown
): { imageCount: number; imageBytes: number; mediaType?: string } | null {
if (!isRecordLike(output)) return null
const candidate = (output as Record<string, unknown>).attachment
if (!isRecordLike(candidate)) return null
const source = (candidate as Record<string, unknown>).source
if (!isRecordLike(source)) return null
const type =
typeof (candidate as Record<string, unknown>).type === 'string'
? ((candidate as Record<string, unknown>).type as string)
: ''
if (type !== 'image') return null
const mediaType =
typeof source.media_type === 'string' ? (source.media_type as string) : undefined
const data = typeof source.data === 'string' ? (source.data as string) : ''
return {
imageCount: 1,
imageBytes: data.length,
mediaType,
}
}
function buildCompletionSignal(input: {
status: AsyncCompletionSignal['status']
message?: string
data?: unknown
}): AsyncCompletionSignal {
return {
status: input.status,
...(input.message !== undefined ? { message: input.message } : {}),
...(input.data !== undefined ? { data: input.data } : {}),
}
}
function getCreateWorkflowOutput(
output: unknown
): { workflowId?: string; workspaceId?: string } | undefined {
if (!isRecordLike(output)) {
return undefined
}
const workflowId = typeof output.workflowId === 'string' ? output.workflowId : undefined
const workspaceId = typeof output.workspaceId === 'string' ? output.workspaceId : undefined
if (!workflowId && !workspaceId) {
return undefined
}
return {
...(workflowId ? { workflowId } : {}),
...(workspaceId ? { workspaceId } : {}),
}
}
export interface AsyncToolCompletion extends AsyncCompletionSignal {}
function publishTerminalToolConfirmation(input: {
toolCallId: string
status: AsyncCompletionEnvelope['status']
message?: string
data?: unknown
}): void {
publishToolConfirmation({
toolCallId: input.toolCallId,
status: input.status,
message: input.message,
data: input.data,
timestamp: new Date().toISOString(),
})
}
function abortRequested(
context: StreamingContext,
execContext: ExecutionContext,
options?: OrchestratorOptions
): boolean {
return Boolean(
options?.abortSignal?.aborted || execContext.abortSignal?.aborted || context.wasAborted
)
}
/**
* Tool classes whose legitimate runtime can far exceed the default watchdog:
* workflow executions, sandboxed code, media/image/audio generation, deep
* research, large downloads, knowledge-base indexing, and file-content
* producers (create/edit/materialize hit the E2B doc compile/recalc/render
* pipeline on doc-backed files). They get the long watchdog cap; everything
* else (read/glob/grep/metadata CRUD/...) must settle within the strict
* default or be failed so the run can continue.
*/
const LONG_RUNNING_TOOL_IDS: ReadonlySet<string> = new Set([
Run.id,
RunBlock.id,
RunFromBlock.id,
RunWorkflow.id,
RunWorkflowUntilBlock.id,
FunctionExecute.id,
GenerateImage.id,
GenerateAudio.id,
GenerateVideo.id,
Ffmpeg.id,
Media.id,
Research.id,
CrawlWebsite.id,
KnowledgeBase.id,
DownloadToWorkspaceFile.id,
CreateFile.id,
EditContent.id,
MaterializeFile.id,
WorkspaceFile.id,
])
export function toolWatchdogTimeoutMs(toolName: string | undefined): number {
return toolName && LONG_RUNNING_TOOL_IDS.has(toolName)
? TOOL_WATCHDOG_LONG_RUNNING_MS
: TOOL_WATCHDOG_DEFAULT_MS
}
class ToolExecutionTimeoutError extends Error {
constructor(toolName: string, timeoutMs: number) {
super(
`Tool '${toolName}' timed out after ${Math.round(timeoutMs / 1000)}s on the Sim executor and was abandoned.`
)
this.name = 'ToolExecutionTimeoutError'
}
}
/**
* Execute a tool with a hard settlement guarantee. If the handler neither
* resolves nor rejects within the tool's watchdog cap, throw a timeout error
* so the standard failure path (persist failed row, publish terminal
* confirmation, resume Go with an error result) runs and the chat never
* wedges behind a hung await. The losing promise keeps running detached; its
* eventual settlement is ignored.
*/
async function executeToolWithWatchdog(toolCall: ToolCallState, execContext: ExecutionContext) {
const timeoutMs = toolWatchdogTimeoutMs(toolCall.name)
// Thread the invoking subagent's channel id per call (execContext is shared
// across the whole turn, so the channel id can't live on it) — server tools
// use it to scope the workspace_file -> edit_content intent handoff.
const toolContext = toolCall.parentToolCallId
? { ...execContext, parentToolCallId: toolCall.parentToolCallId }
: execContext
const execution = executeTool(toolCall.name, toolCall.params || {}, toolContext)
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
execution,
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new ToolExecutionTimeoutError(toolCall.name, timeoutMs)),
timeoutMs
)
}),
])
} finally {
if (timer) clearTimeout(timer)
// Swallow the abandoned promise's eventual rejection so it can't surface
// as an unhandled rejection after a watchdog loss.
execution.catch(() => {})
}
}
/**
* Last-resort settlement for a tool whose promise never settled (a hang the
* per-tool watchdog could not see, e.g. in post-processing or persistence).
* Records a terminal error state + failed async row so the checkpoint loop
* can resume Go with an error result instead of waiting forever.
*/
export async function forceFailHungToolCall(
toolCallId: string,
context: StreamingContext,
message: string
): Promise<void> {
const toolCall = context.toolCalls.get(toolCallId)
if (!toolCall || toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) return
setTerminalToolCallState(toolCall, {
status: MothershipStreamV1ToolOutcome.error,
error: message,
})
logger.error('Force-failed hung tool call', {
toolCallId,
toolName: toolCall.name,
message,
})
markToolResultSeen(toolCallId)
await completeAsyncToolCall({
toolCallId,
status: MothershipStreamV1AsyncToolRecordStatus.failed,
result: { error: message },
error: message,
}).catch((err) => {
logger.warn('Failed to persist force-failed async tool status', {
toolCallId,
error: toError(err).message,
})
})
publishTerminalToolConfirmation({
toolCallId,
status: MothershipStreamV1ToolOutcome.error,
message,
data: { error: message },
})
}
function cancelledCompletion(message: string): AsyncToolCompletion {
return buildCompletionSignal({
status: MothershipStreamV1ToolOutcome.cancelled,
message,
data: { cancelled: true },
})
}
function terminalCompletionFromToolCall(toolCall: ToolCallState): AsyncToolCompletion {
if (toolCall.status === MothershipStreamV1ToolOutcome.cancelled) {
return cancelledCompletion(requireToolCallError(toolCall))
}
if (toolCall.status === MothershipStreamV1ToolOutcome.success) {
const data = getToolCallStateOutput(toolCall)
return buildCompletionSignal({
status: MothershipStreamV1ToolOutcome.success,
message: 'Tool completed',
...(data !== undefined ? { data } : {}),
})
}
if (toolCall.status === MothershipStreamV1ToolOutcome.skipped) {
const data = getToolCallStateOutput(toolCall)
return buildCompletionSignal({
status: MothershipStreamV1ToolOutcome.success,
message: 'Tool skipped',
...(data !== undefined ? { data } : {}),
})
}
const terminalErrorMessage = requireToolCallError(toolCall)
return buildCompletionSignal({
status: MothershipStreamV1ToolOutcome.error,
message: terminalErrorMessage,
data: getToolCallTerminalData(toolCall),
})
}
export async function executeToolAndReport(
toolCallId: string,
context: StreamingContext,
execContext: ExecutionContext,
options?: OrchestratorOptions
): Promise<AsyncToolCompletion> {
const toolCall = context.toolCalls.get(toolCallId)
if (!toolCall)
return buildCompletionSignal({
status: MothershipStreamV1ToolOutcome.error,
message: 'Tool call not found',
})
const argsPayload = toolCall.params
? (() => {
try {
return JSON.stringify(toolCall.params)
} catch {
return undefined
}
})()
: undefined
return withCopilotToolSpan(
{
toolName: toolCall.name,
toolCallId: toolCall.id,
runId: context.runId,
chatId: execContext.chatId,
argsBytes: argsPayload?.length,
argsPreview: argsPayload?.slice(0, 200),
},
async (otelSpan) => {
const startedAt = Date.now()
try {
const completion = await executeToolAndReportInner(toolCall, context, execContext, options)
const durationMs = Date.now() - startedAt
otelSpan.setAttribute(TraceAttr.ToolOutcome, completion.status)
otelSpan.setAttribute(TraceAttr.ToolDurationMs, durationMs)
if (completion.message) {
otelSpan.setAttribute(
TraceAttr.ToolOutcomeMessage,
String(completion.message).slice(0, 500)
)
}
// Durable Grafana signal for "which Sim tool is slowest" (executor=sim);
// pairs with the Go executor-boundary metric (U15) as one series set.
recordSimToolMetric(toolCall.name, completion.status, durationMs)
return completion
} catch (err) {
// executeToolAndReportInner threw (infra/unexpected error, not a normal
// 'error' completion). Still stamp the span + record the dispatch so
// copilot.tool.* isn't silently biased toward successful calls.
const durationMs = Date.now() - startedAt
otelSpan.setAttribute(TraceAttr.ToolOutcome, 'error')
otelSpan.setAttribute(TraceAttr.ToolDurationMs, durationMs)
recordSimToolMetric(toolCall.name, 'error', durationMs)
throw err
}
}
)
}
async function executeToolAndReportInner(
toolCall: ToolCallState,
context: StreamingContext,
execContext: ExecutionContext,
options?: OrchestratorOptions
): Promise<AsyncToolCompletion> {
if (toolCall.status === 'executing') {
return buildCompletionSignal({
status: MothershipStreamV1AsyncToolRecordStatus.running,
message: 'Tool already executing',
})
}
if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) {
return terminalCompletionFromToolCall(toolCall)
}
const markToolCallCancelled = (message: string) => {
setTerminalToolCallState(toolCall, {
status: MothershipStreamV1ToolOutcome.cancelled,
error: message,
})
}
if (abortRequested(context, execContext, options)) {
markToolCallCancelled('Request aborted before tool execution')
markToolResultSeen(toolCall.id)
await completeAsyncToolCall({
toolCallId: toolCall.id,
status: MothershipStreamV1AsyncToolRecordStatus.cancelled,
result: { cancelled: true },
error: 'Request aborted before tool execution',
}).catch((err) => {
logger.warn('Failed to persist async tool status', {
toolCallId: toolCall.id,
error: toError(err).message,
})
})
publishTerminalToolConfirmation({
toolCallId: toolCall.id,
status: MothershipStreamV1ToolOutcome.cancelled,
message: 'Request aborted before tool execution',
data: { cancelled: true },
})
return cancelledCompletion('Request aborted before tool execution')
}
toolCall.status = 'executing'
await upsertAsyncToolCall({
runId: context.runId,
toolCallId: toolCall.id,
toolName: toolCall.name,
args: toolCall.params,
}).catch((err) => {
logger.warn('Failed to persist async tool row before execution', {
toolCallId: toolCall.id,
error: toError(err).message,
})
})
await markAsyncToolRunning(toolCall.id, 'sim-stream').catch((err) => {
logger.warn('Failed to mark async tool running', {
toolCallId: toolCall.id,
error: toError(err).message,
})
})
if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) {
return terminalCompletionFromToolCall(toolCall)
}
const argsPreview = toolCall.params ? JSON.stringify(toolCall.params).slice(0, 200) : undefined
const toolSpan = context.trace.startSpan(toolCall.name, 'tool.execute', {
toolCallId: toolCall.id,
toolName: toolCall.name,
argsPreview,
abortSignalAborted: execContext.abortSignal?.aborted ?? false,
})
const endToolSpan = (
status: string,
detail?: { error?: string; cancelReason?: string; resultSuccess?: boolean }
) => {
const abortDetail: Record<string, unknown> = {}
if (execContext.abortSignal?.aborted) {
abortDetail.abortSignalAborted = true
abortDetail.abortReason = String(execContext.abortSignal.reason ?? 'unknown')
}
if (options?.abortSignal?.aborted) {
abortDetail.optionsAbortReason = String(options.abortSignal.reason ?? 'unknown')
}
if (context.wasAborted) {
abortDetail.wasAborted = true
}
toolSpan.attributes = { ...toolSpan.attributes, ...abortDetail, ...detail }
context.trace.endSpan(toolSpan, status)
}
const endToolSpanFromTerminalState = () => {
const terminalStatus =
toolCall.status === MothershipStreamV1ToolOutcome.cancelled
? 'cancelled'
: toolCall.status === MothershipStreamV1ToolOutcome.success ||
toolCall.status === MothershipStreamV1ToolOutcome.skipped
? 'ok'
: 'error'
endToolSpan(terminalStatus, {
resultSuccess: toolCall.status === MothershipStreamV1ToolOutcome.success,
...(toolCall.error ? { error: toolCall.error } : {}),
})
}
logger.info('Tool execution started', {
toolCallId: toolCall.id,
toolName: toolCall.name,
})
try {
ensureHandlersRegistered()
let result = await executeToolWithWatchdog(toolCall, execContext)
if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) {
endToolSpanFromTerminalState()
return terminalCompletionFromToolCall(toolCall)
}
if (abortRequested(context, execContext, options)) {
markToolCallCancelled('Request aborted during tool execution')
markToolResultSeen(toolCall.id)
await completeAsyncToolCall({
toolCallId: toolCall.id,
status: MothershipStreamV1AsyncToolRecordStatus.cancelled,
result: { cancelled: true },
error: 'Request aborted during tool execution',
}).catch((err) => {
logger.warn('Failed to persist async tool status', {
toolCallId: toolCall.id,
error: toError(err).message,
})
})
publishTerminalToolConfirmation({
toolCallId: toolCall.id,
status: MothershipStreamV1ToolOutcome.cancelled,
message: 'Request aborted during tool execution',
data: { cancelled: true },
})
endToolSpan('cancelled', {
cancelReason: 'abort_during_execution',
error: result.success === false ? result.error : undefined,
})
return cancelledCompletion('Request aborted during tool execution')
}
result = await maybeWriteOutputToFile(toolCall.name, toolCall.params, result, execContext)
if (abortRequested(context, execContext, options)) {
markToolCallCancelled('Request aborted during tool post-processing')
markToolResultSeen(toolCall.id)
await completeAsyncToolCall({
toolCallId: toolCall.id,
status: MothershipStreamV1AsyncToolRecordStatus.cancelled,
result: { cancelled: true },
error: 'Request aborted during tool post-processing',
}).catch((err) => {
logger.warn('Failed to persist async tool status', {
toolCallId: toolCall.id,
error: toError(err).message,
})
})
publishTerminalToolConfirmation({
toolCallId: toolCall.id,
status: MothershipStreamV1ToolOutcome.cancelled,
message: 'Request aborted during tool post-processing',
data: { cancelled: true },
})
endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_file' })
return cancelledCompletion('Request aborted during tool post-processing')
}
result = await maybeWriteOutputToTable(toolCall.name, toolCall.params, result, execContext)
if (abortRequested(context, execContext, options)) {
markToolCallCancelled('Request aborted during tool post-processing')
markToolResultSeen(toolCall.id)
await completeAsyncToolCall({
toolCallId: toolCall.id,
status: MothershipStreamV1AsyncToolRecordStatus.cancelled,
result: { cancelled: true },
error: 'Request aborted during tool post-processing',
}).catch((err) => {
logger.warn('Failed to persist async tool status', {
toolCallId: toolCall.id,
error: toError(err).message,
})
})
publishTerminalToolConfirmation({
toolCallId: toolCall.id,
status: MothershipStreamV1ToolOutcome.cancelled,
message: 'Request aborted during tool post-processing',
data: { cancelled: true },
})
endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_table' })
return cancelledCompletion('Request aborted during tool post-processing')
}
result = await maybeWriteReadCsvToTable(toolCall.name, toolCall.params, result, execContext)
if (abortRequested(context, execContext, options)) {
markToolCallCancelled('Request aborted during tool post-processing')
markToolResultSeen(toolCall.id)
await completeAsyncToolCall({
toolCallId: toolCall.id,
status: MothershipStreamV1AsyncToolRecordStatus.cancelled,
result: { cancelled: true },
error: 'Request aborted during tool post-processing',
}).catch((err) => {
logger.warn('Failed to persist async tool status', {
toolCallId: toolCall.id,
error: toError(err).message,
})
})
publishTerminalToolConfirmation({
toolCallId: toolCall.id,
status: MothershipStreamV1ToolOutcome.cancelled,
message: 'Request aborted during tool post-processing',
data: { cancelled: true },
})
endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_csv' })
return cancelledCompletion('Request aborted during tool post-processing')
}
toolSpan.attributes = {
...toolSpan.attributes,
...summarizeToolResultForSpan(result),
}
setTerminalToolCallState(toolCall, {
status: result.success
? MothershipStreamV1ToolOutcome.success
: MothershipStreamV1ToolOutcome.error,
...(hasOutputValue(result) ? { output: result.output } : {}),
...(result.success ? {} : { error: result.error || 'Tool failed' }),
})
if (result.success) {
const raw = result.output
const preview =
typeof raw === 'string'
? raw.slice(0, 200)
: raw && typeof raw === 'object'
? JSON.stringify(raw).slice(0, 200)
: undefined
logger.info('Tool execution succeeded', {
toolCallId: toolCall.id,
toolName: toolCall.name,
outputPreview: preview,
})
} else {
logger.warn('Tool execution failed', {
toolCallId: toolCall.id,
toolName: toolCall.name,
error: result.error,
params: toolCall.params,
})
}
// If create_workflow was successful, update the execution context with the new workflowId.
// This ensures subsequent tools in the same stream have access to the workflowId.
const createWorkflowOutput = getCreateWorkflowOutput(result.output)
if (
toolCall.name === CreateWorkflow.id &&
result.success &&
createWorkflowOutput?.workflowId &&
!execContext.workflowId
) {
execContext.workflowId = createWorkflowOutput.workflowId
if (createWorkflowOutput.workspaceId) {
execContext.workspaceId = createWorkflowOutput.workspaceId
}
}
const terminalStatus = result.success
? MothershipStreamV1ToolOutcome.success
: MothershipStreamV1ToolOutcome.error
const terminalMessage = result.success ? 'Tool completed' : requireToolCallError(toolCall)
const terminalData = getToolCallTerminalData(toolCall)
markToolResultSeen(toolCall.id)
await completeAsyncToolCall({
toolCallId: toolCall.id,
status: result.success
? MothershipStreamV1AsyncToolRecordStatus.completed
: MothershipStreamV1AsyncToolRecordStatus.failed,
...(terminalData !== undefined ? { result: terminalData } : {}),
error: result.success ? null : terminalMessage,
}).catch((err) => {
logger.warn('Failed to persist async tool completion', {
toolCallId: toolCall.id,
error: toError(err).message,
})
})
publishTerminalToolConfirmation({
toolCallId: toolCall.id,
status: terminalStatus,
message: terminalMessage,
...(terminalData !== undefined ? { data: terminalData } : {}),
})
if (abortRequested(context, execContext, options)) {
markToolCallCancelled('Request aborted before tool result delivery')
endToolSpan('cancelled', { cancelReason: 'abort_before_tool_result_delivery' })
return cancelledCompletion('Request aborted before tool result delivery')
}
// Fire-and-forget: notify the copilot backend that the tool completed.
// IMPORTANT: We must NOT await this — the Go backend may block on the
const resultEvent: StreamEvent = {
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: toolCall.id,
toolName: toolCall.name,
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.result,
success: result.success,
output: result.output,
...(result.success
? { status: MothershipStreamV1ToolOutcome.success }
: { status: MothershipStreamV1ToolOutcome.error }),
},
}
await options?.onEvent?.(resultEvent)
if (abortRequested(context, execContext, options)) {
markToolCallCancelled('Request aborted before resource persistence')
endToolSpan('cancelled', { cancelReason: 'abort_before_resource_persistence' })
return cancelledCompletion('Request aborted before resource persistence')
}
if (result.success && execContext.chatId && !abortRequested(context, execContext, options)) {
await handleResourceSideEffects(
toolCall.name,
toolCall.params,
result,
execContext.chatId,
options?.onEvent,
() => abortRequested(context, execContext, options)
)
}
endToolSpan(result.success ? 'ok' : 'error', {
resultSuccess: result.success,
...(result.success ? {} : { error: terminalMessage }),
})
return buildCompletionSignal({
status: terminalStatus,
message: terminalMessage,
...(terminalData !== undefined ? { data: terminalData } : {}),
})
} catch (error) {
const thrownMessage = toError(error).message
if (abortRequested(context, execContext, options)) {
markToolCallCancelled('Request aborted during tool execution')
markToolResultSeen(toolCall.id)
await completeAsyncToolCall({
toolCallId: toolCall.id,
status: MothershipStreamV1AsyncToolRecordStatus.cancelled,
result: { cancelled: true },
error: 'Request aborted during tool execution',
}).catch((err) => {
logger.warn('Failed to persist async tool status', {
toolCallId: toolCall.id,
error: toError(err).message,
})
})
publishTerminalToolConfirmation({
toolCallId: toolCall.id,
status: MothershipStreamV1ToolOutcome.cancelled,
message: 'Request aborted during tool execution',
data: { cancelled: true },
})
endToolSpan('cancelled', {
cancelReason: 'abort_during_execution_catch',
error: thrownMessage,
})
return cancelledCompletion('Request aborted during tool execution')
}
setTerminalToolCallState(toolCall, {
status: MothershipStreamV1ToolOutcome.error,
error: thrownMessage,
})
logger.error('Tool execution threw', {
toolCallId: toolCall.id,
toolName: toolCall.name,
error: toolCall.error,
params: toolCall.params,
})
markToolResultSeen(toolCall.id)
await completeAsyncToolCall({
toolCallId: toolCall.id,
status: MothershipStreamV1AsyncToolRecordStatus.failed,
result: { error: toolCall.error },
error: toolCall.error,
}).catch((err) => {
logger.warn('Failed to persist async tool error', {
toolCallId: toolCall.id,
error: toError(err).message,
})
})
publishTerminalToolConfirmation({
toolCallId: toolCall.id,
status: MothershipStreamV1ToolOutcome.error,
message: toolCall.error,
data: { error: toolCall.error },
})
const errorEvent: StreamEvent = {
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: toolCall.id,
toolName: toolCall.name,
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.result,
status: MothershipStreamV1ToolOutcome.error,
success: false,
error: toolCall.error,
output: { error: toolCall.error },
},
}
await options?.onEvent?.(errorEvent)
endToolSpan('error', { error: thrownMessage })
return buildCompletionSignal({
status: MothershipStreamV1ToolOutcome.error,
message: toolCall.error,
data: { error: toolCall.error },
})
}
}
@@ -0,0 +1,199 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockWriteWorkspaceFileByPath } = vi.hoisted(() => ({
mockWriteWorkspaceFileByPath: vi.fn(),
}))
vi.mock('@/lib/copilot/vfs/resource-writer', () => ({
writeWorkspaceFileByPath: mockWriteWorkspaceFileByPath,
}))
vi.mock('@/lib/copilot/request/otel', () => ({
withCopilotSpan: (
_name: string,
_attrs: Record<string, unknown> | undefined,
fn: (span: unknown) => Promise<unknown>
) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn(), addEvent: vi.fn() }),
}))
import { FunctionExecute } from '@/lib/copilot/generated/tool-catalog-v1'
import {
extractTabularData,
maybeWriteOutputToFile,
normalizeOutputWorkspaceFileName,
serializeOutputForFile,
unwrapFunctionExecuteOutput,
} from '@/lib/copilot/request/tools/files'
import type { ExecutionContext } from '@/lib/copilot/request/types'
describe('unwrapFunctionExecuteOutput', () => {
it('unwraps the function_execute envelope { result, stdout }', () => {
expect(unwrapFunctionExecuteOutput({ result: 'name,age\nAlice,30', stdout: '' })).toBe(
'name,age\nAlice,30'
)
})
it('passes through objects that do not have both result + stdout', () => {
const output = { data: { rows: [], totalCount: 0 } }
expect(unwrapFunctionExecuteOutput(output)).toBe(output)
})
it('passes through strings and arrays untouched', () => {
expect(unwrapFunctionExecuteOutput('hello')).toBe('hello')
const arr: unknown[] = [{ a: 1 }]
expect(unwrapFunctionExecuteOutput(arr)).toBe(arr)
})
})
describe('serializeOutputForFile (csv)', () => {
it('returns raw CSV text when function_execute result is already a CSV string', () => {
const output = {
result: 'name,age\nAlice,30\nBob,40',
stdout: '(2 rows)',
}
expect(serializeOutputForFile(output, 'csv')).toBe('name,age\nAlice,30\nBob,40')
})
it('converts a result array of objects into CSV', () => {
const output = {
result: [
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 40 },
],
stdout: '',
}
expect(serializeOutputForFile(output, 'csv')).toBe('name,age\nAlice,30\nBob,40')
})
it('returns the raw string when the non-envelope output is already a CSV string', () => {
expect(serializeOutputForFile('a,b\n1,2', 'csv')).toBe('a,b\n1,2')
})
it('falls back to JSON.stringify when the payload is not tabular and not a string', () => {
const output = { result: { foo: 'bar' }, stdout: '' }
expect(serializeOutputForFile(output, 'csv')).toBe('{\n "foo": "bar"\n}')
})
})
describe('serializeOutputForFile (json / txt / md)', () => {
it('unwraps the envelope for json format so the file contains only result', () => {
const output = { result: { hello: 'world' }, stdout: 'log' }
expect(serializeOutputForFile(output, 'json')).toBe('{\n "hello": "world"\n}')
})
it('returns the string payload as-is for txt/md/html formats', () => {
const output = { result: '# Report\n\nHello', stdout: '' }
expect(serializeOutputForFile(output, 'md')).toBe('# Report\n\nHello')
expect(serializeOutputForFile(output, 'txt')).toBe('# Report\n\nHello')
expect(serializeOutputForFile(output, 'html')).toBe('# Report\n\nHello')
})
})
describe('normalizeOutputWorkspaceFileName', () => {
it('derives the leaf file name from workflow alias output paths', () => {
expect(normalizeOutputWorkspaceFileName('workflows/My%20Workflow/changelog.md')).toBe(
'changelog.md'
)
expect(
normalizeOutputWorkspaceFileName('workflows/My%20Workflow/.plans/phase%201/implementation.md')
).toBe('implementation.md')
})
it('still handles normal workspace file output paths', () => {
expect(normalizeOutputWorkspaceFileName('files/Reports/output.csv')).toBe('output.csv')
})
})
describe('maybeWriteOutputToFile', () => {
function buildContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
return {
userId: 'user-1',
workflowId: 'wf-1',
workspaceId: 'workspace-1',
userPermission: 'write',
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
mockWriteWorkspaceFileByPath.mockResolvedValue({
id: 'file-1',
name: 'report.csv',
vfsPath: 'files/report.csv',
mode: 'overwrite',
})
})
it('denies a read-only principal without writing the file', async () => {
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
{ outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } },
{ success: true, output: { result: 'name,age\nAlice,30', stdout: '' } },
buildContext({ userPermission: 'read' })
)
expect(result.success).toBe(false)
expect(result.error).toContain('requires write access')
expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled()
})
it('does not deny a read-only principal when no workspace write occurs (sandbox export active)', async () => {
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
{ outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } },
{ success: true, output: { result: { files: [{ path: 'report.csv' }] }, stdout: '' } },
buildContext({ userPermission: 'read' })
)
expect(result.success).toBe(true)
expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled()
})
it('writes the output file for a write principal', async () => {
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
{ outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } },
{ success: true, output: { result: 'name,age\nAlice,30', stdout: '' } },
buildContext()
)
expect(result.success).toBe(true)
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1)
})
})
describe('extractTabularData', () => {
it('extracts rows directly from an array input', () => {
expect(extractTabularData([{ a: 1 }, { a: 2 }])).toEqual([{ a: 1 }, { a: 2 }])
})
it('does NOT unwrap function_execute envelopes on its own (callers must pre-unwrap)', () => {
// Caller is responsible for unwrapping { result, stdout } envelopes first.
// Keeping that concern out of this function prevents a double unwrap when
// the user's payload itself happens to have matching keys.
expect(extractTabularData({ result: [{ a: 1 }], stdout: '' })).toBeNull()
})
it('extracts rows from the user_table query_rows shape', () => {
const rows = extractTabularData({
data: {
rows: [
{ id: 'row_1', data: { name: 'Alice' } },
{ id: 'row_2', data: { name: 'Bob' } },
],
totalCount: 2,
},
})
expect(rows).toEqual([{ name: 'Alice' }, { name: 'Bob' }])
})
it('returns null for non-tabular inputs', () => {
expect(extractTabularData('plain string')).toBeNull()
expect(extractTabularData(null)).toBeNull()
expect(extractTabularData({ foo: 'bar' })).toBeNull()
})
})
+345
View File
@@ -0,0 +1,345 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { FunctionExecute, UserTable } from '@/lib/copilot/generated/tool-catalog-v1'
import { CopilotOutputFileOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { withCopilotSpan } from '@/lib/copilot/request/otel'
import { denyOutputWriteWithoutWritePermission } from '@/lib/copilot/request/tools/permissions'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer'
const logger = createLogger('CopilotToolResultFiles')
export const OUTPUT_PATH_TOOLS: Set<string> = new Set([FunctionExecute.id, UserTable.id])
export type OutputFormat = 'json' | 'csv' | 'txt' | 'md' | 'html'
export const EXT_TO_FORMAT: Record<string, OutputFormat> = {
'.json': 'json',
'.csv': 'csv',
'.txt': 'txt',
'.md': 'md',
'.html': 'html',
}
export const FORMAT_TO_CONTENT_TYPE: Record<OutputFormat, string> = {
json: 'application/json',
csv: 'text/csv',
txt: 'text/plain',
md: 'text/markdown',
html: 'text/html',
}
/**
* Unwraps the `function_execute` response envelope `{ result, stdout }` so the
* rest of the serialization code works on the user's actual payload (a string,
* array, object, etc.) instead of JSON-stringifying the envelope itself.
*
* Only unwraps when both keys are present — that's the unique shape of
* `function_execute` (see `apps/sim/tools/function/types.ts` `CodeExecutionOutput`).
* `user_table` returns `{ data, message, success }` which is left alone.
*/
export function unwrapFunctionExecuteOutput(output: unknown): unknown {
if (!output || typeof output !== 'object' || Array.isArray(output)) return output
const obj = output as Record<string, unknown>
if ('result' in obj && 'stdout' in obj) {
return obj.result
}
return output
}
/**
* Try to pull a flat array of row-objects out of an already-unwrapped tool
* payload. Callers are responsible for stripping any `function_execute`
* envelope first (via {@link unwrapFunctionExecuteOutput}) — this function
* does not re-unwrap, so a user payload that coincidentally has `result` and
* `stdout` keys is not mistaken for another envelope.
*/
export function extractTabularData(output: unknown): Record<string, unknown>[] | null {
if (!output || typeof output !== 'object') return null
if (Array.isArray(output)) {
if (output.length > 0 && typeof output[0] === 'object' && output[0] !== null) {
return output as Record<string, unknown>[]
}
return null
}
const obj = output as Record<string, unknown>
// user_table query_rows shape: { data: { rows: [{ data: {...} }], totalCount } }
if (obj.data && typeof obj.data === 'object' && !Array.isArray(obj.data)) {
const data = obj.data as Record<string, unknown>
if (Array.isArray(data.rows) && data.rows.length > 0) {
const rows = data.rows as Record<string, unknown>[]
if (typeof rows[0].data === 'object' && rows[0].data !== null) {
return rows.map((r) => r.data as Record<string, unknown>)
}
return rows
}
}
return null
}
export function escapeCsvValue(value: unknown): string {
if (value === null || value === undefined) return ''
const str = typeof value === 'object' ? JSON.stringify(value) : String(value)
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
export function convertRowsToCsv(rows: Record<string, unknown>[]): string {
if (rows.length === 0) return ''
const headerSet = new Set<string>()
for (const row of rows) {
for (const key of Object.keys(row)) {
headerSet.add(key)
}
}
const headers = [...headerSet]
const lines = [headers.map(escapeCsvValue).join(',')]
for (const row of rows) {
lines.push(headers.map((h) => escapeCsvValue(row[h])).join(','))
}
return lines.join('\n')
}
export function normalizeOutputWorkspaceFileName(outputPath: string): string {
const segments = decodeVfsPathSegments(outputPath.trim().replace(/^\/+|\/+$/g, ''))
const fileName = segments.at(-1)
if (!fileName) {
throw new Error('Output path must include a file name')
}
return fileName
}
export function resolveOutputFormat(fileName: string, explicit?: string): OutputFormat {
if (explicit && explicit in FORMAT_TO_CONTENT_TYPE) return explicit as OutputFormat
const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase()
return EXT_TO_FORMAT[ext] ?? 'json'
}
export function serializeOutputForFile(output: unknown, format: OutputFormat): string {
const unwrapped = unwrapFunctionExecuteOutput(output)
if (typeof unwrapped === 'string') return unwrapped
if (format === 'csv') {
const rows = extractTabularData(unwrapped)
if (rows && rows.length > 0) {
return convertRowsToCsv(rows)
}
}
return JSON.stringify(unwrapped, null, 2)
}
export interface OutputFileDeclaration {
path: string
mode?: 'create' | 'overwrite'
format?: OutputFormat
mimeType?: string
sandboxPath?: string
formatPath?: string
}
export function getOutputFileDeclarations(
params: Record<string, unknown> | undefined
): OutputFileDeclaration[] {
const args = params?.args as Record<string, unknown> | undefined
const outputs =
(params?.outputs as { files?: unknown[] } | undefined) ??
(args?.outputs as { files?: unknown[] } | undefined)
if (Array.isArray(outputs?.files)) {
return outputs.files.flatMap((item): OutputFileDeclaration[] => {
if (!item || typeof item !== 'object') return []
const file = item as Record<string, unknown>
if (typeof file.path !== 'string') return []
return [
{
path: file.path,
mode: file.mode === 'overwrite' ? 'overwrite' : 'create',
format: typeof file.format === 'string' ? (file.format as OutputFormat) : undefined,
mimeType: typeof file.mimeType === 'string' ? file.mimeType : undefined,
sandboxPath: typeof file.sandboxPath === 'string' ? file.sandboxPath : undefined,
},
]
})
}
const outputPath =
(params?.outputPath as string | undefined) ?? (args?.outputPath as string | undefined)
if (!outputPath) return []
const overwriteFileId =
(params?.overwriteFileId as string | undefined) ?? (args?.overwriteFileId as string | undefined)
return [
{
path: overwriteFileId || outputPath,
mode: overwriteFileId ? 'overwrite' : 'create',
formatPath: outputPath,
format: ((params?.outputFormat as string | undefined) ??
(args?.outputFormat as string | undefined)) as OutputFormat | undefined,
mimeType:
(params?.outputMimeType as string | undefined) ??
(args?.outputMimeType as string | undefined),
sandboxPath:
(params?.outputSandboxPath as string | undefined) ??
(args?.outputSandboxPath as string | undefined),
},
]
}
export async function maybeWriteOutputToFile(
toolName: string,
params: Record<string, unknown> | undefined,
result: ToolCallResult,
context: ExecutionContext
): Promise<ToolCallResult> {
if (!result.success || !result.output) return result
if (!OUTPUT_PATH_TOOLS.has(toolName)) return result
if (!context.workspaceId || !context.userId) return result
const outputFiles = getOutputFileDeclarations(params).filter((file) => !file.sandboxPath)
if (outputFiles.length === 0) return result
const outputObject =
result.output && typeof result.output === 'object' && !Array.isArray(result.output)
? (result.output as Record<string, unknown>)
: undefined
const resultObject =
outputObject?.result &&
typeof outputObject.result === 'object' &&
!Array.isArray(outputObject.result)
? (outputObject.result as Record<string, unknown>)
: undefined
if (Array.isArray(resultObject?.files)) {
logger.warn('Skipping returned-value output write because sandbox export response is active', {
toolName,
outputCount: outputFiles.length,
})
return result
}
const denied = denyOutputWriteWithoutWritePermission(context)
if (denied) return denied
// Only span the actual write path (where we upload to storage). Fast
// no-op returns above don't need a span — they'd just pad the trace
// with empty work.
return withCopilotSpan(
TraceSpan.CopilotToolsWriteOutputFile,
{
[TraceAttr.ToolName]: toolName,
[TraceAttr.WorkspaceId]: context.workspaceId,
},
async (span) => {
try {
const writtenFiles = []
for (const outputFile of outputFiles) {
const fileName = normalizeOutputWorkspaceFileName(
outputFile.formatPath ?? outputFile.path
)
const format = resolveOutputFormat(fileName, outputFile.format)
const content = serializeOutputForFile(result.output, format)
const contentType = outputFile.mimeType || FORMAT_TO_CONTENT_TYPE[format]
const buffer = Buffer.from(content, 'utf-8')
if (context.abortSignal?.aborted) {
throw new Error('Request aborted before tool mutation could be applied')
}
const written = await writeWorkspaceFileByPath({
workspaceId: context.workspaceId!,
userId: context.userId!,
target: {
path: outputFile.path,
mode: outputFile.mode ?? 'create',
mimeType: outputFile.mimeType,
},
buffer,
inferredMimeType: contentType,
})
writtenFiles.push({
...written,
bytes: buffer.length,
format,
requestedPath: outputFile.path,
})
}
const firstWritten = writtenFiles[0]
span.setAttributes({
[TraceAttr.CopilotOutputFileId]: firstWritten.id,
[TraceAttr.CopilotOutputFileName]: firstWritten.name,
[TraceAttr.CopilotOutputFileFormat]: firstWritten.format,
[TraceAttr.CopilotOutputFilePath]: firstWritten.vfsPath,
[TraceAttr.CopilotOutputFileMode]: firstWritten.mode,
[TraceAttr.CopilotOutputFileBytes]: firstWritten.bytes,
[TraceAttr.CopilotOutputFileOutcome]: CopilotOutputFileOutcome.Uploaded,
})
logger.info('Tool output written to file', {
toolName,
outputCount: writtenFiles.length,
files: writtenFiles.map((file) => ({
fileId: file.id,
vfsPath: file.vfsPath,
size: file.bytes,
})),
})
return {
success: true,
output: {
message:
writtenFiles.length === 1
? `Output ${firstWritten.mode === 'overwrite' ? 'updated' : 'written'} at ${firstWritten.vfsPath} (${firstWritten.bytes} bytes)`
: `Output written to ${writtenFiles.length} files`,
files: writtenFiles.map((file) => ({
fileId: file.id,
fileName: file.name,
vfsPath: file.vfsPath,
size: file.bytes,
downloadUrl: file.downloadUrl,
})),
fileId: firstWritten.id,
fileName: firstWritten.name,
vfsPath: firstWritten.vfsPath,
size: firstWritten.bytes,
downloadUrl: firstWritten.downloadUrl,
},
resources: writtenFiles.map((file) => ({
type: 'file',
id: file.id,
title: file.name,
path: file.vfsPath,
})),
}
} catch (err) {
const message = toError(err).message
logger.warn('Failed to write tool output to file', {
toolName,
outputPaths: outputFiles.map((file) => file.path),
error: message,
})
span.setAttribute(TraceAttr.CopilotOutputFileOutcome, CopilotOutputFileOutcome.Failed)
span.addEvent(TraceEvent.CopilotOutputFileError, {
[TraceAttr.ErrorMessage]: message.slice(0, 500),
})
return {
success: false,
error: `Failed to write output file: ${message}`,
}
}
}
)
}
@@ -0,0 +1,28 @@
import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
/**
* Guards a post-tool output-redirection sink against read-only principals.
*
* `function_execute`, `user_table`, and `read` are read-allowed for execution
* (they don't mutate the workspace themselves), so the router's `WRITE_ACTIONS`
* gate in `tools/server/router.ts` lets read-only collaborators run them. But
* their output-redirection declarations (`outputs.files`, `outputTable`)
* durably persist to the workspace — creating/overwriting files and table rows.
* Those writes must satisfy the same write gate as the dedicated mutation tools.
*
* Returns a denial `ToolCallResult` when the caller lacks write access (so the
* agent surfaces the same `Permission denied` outcome it gets from `create_file`
* / `user_table` writes), or `null` when the write may proceed.
*/
export function denyOutputWriteWithoutWritePermission(
context: ExecutionContext
): ToolCallResult | null {
if (permissionSatisfies(context.userPermission as PermissionType | undefined, 'write')) {
return null
}
return {
success: false,
error: `Permission denied: writing tool output to the workspace requires write access. You have '${context.userPermission ?? 'none'}' permission.`,
}
}
@@ -0,0 +1,134 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import {
MothershipStreamV1EventType,
MothershipStreamV1ResourceOp,
} 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 type { StreamEvent, ToolCallResult } from '@/lib/copilot/request/types'
import {
extractDeletedResourcesFromToolResult,
extractResourcesFromToolResult,
hasDeleteCapability,
isResourceToolName,
persistChatResources,
removeChatResources,
} from '@/lib/copilot/resources/persistence'
const logger = createLogger('CopilotResourceEffects')
/**
* Persist and emit resource events after a successful tool execution.
*
* Handles both creation/upsert and deletion of chat resources depending on
* the tool's capabilities and output shape.
*/
export async function handleResourceSideEffects(
toolName: string,
params: Record<string, unknown> | undefined,
result: ToolCallResult,
chatId: string,
onEvent: ((event: StreamEvent) => void | Promise<void>) | undefined,
isAborted: () => boolean
): Promise<void> {
// Cheap early exit so we don't emit a span for tools that can never
// produce resources (most of them). The span only shows up for tools
// that might actually do resource work.
if (
!hasDeleteCapability(toolName) &&
!isResourceToolName(toolName) &&
!(result.resources && result.resources.length > 0)
) {
return
}
return withCopilotSpan(
TraceSpan.CopilotToolsHandleResourceSideEffects,
{
[TraceAttr.ToolName]: toolName,
[TraceAttr.ChatId]: chatId,
},
async (span) => {
let isDeleteOp = false
let removedCount = 0
let upsertedCount = 0
if (hasDeleteCapability(toolName)) {
const deleted = extractDeletedResourcesFromToolResult(toolName, params, result.output)
if (deleted.length > 0) {
isDeleteOp = true
removedCount = deleted.length
// Detached from the span lifecycle — the span ends before the
// DB call completes. That is intentional; we want the span to
// reflect the synchronous decision + event emission, not the
// best-effort persistence.
removeChatResources(chatId, deleted).catch((err) => {
logger.warn('Failed to remove chat resources after deletion', {
chatId,
error: toError(err).message,
})
})
for (const resource of deleted) {
if (isAborted()) break
await onEvent?.({
type: MothershipStreamV1EventType.resource,
payload: {
op: MothershipStreamV1ResourceOp.remove,
resource: { type: resource.type, id: resource.id, title: resource.title },
},
})
}
}
}
if (!isDeleteOp && !isAborted()) {
const resources =
result.resources && result.resources.length > 0
? result.resources
: isResourceToolName(toolName)
? extractResourcesFromToolResult(toolName, params, result.output)
: []
if (resources.length > 0) {
upsertedCount = resources.length
logger.info('[file-stream-server] Emitting resource upsert events', {
toolName,
chatId,
resources: resources.map((r) => ({ type: r.type, id: r.id, title: r.title })),
})
persistChatResources(chatId, resources).catch((err) => {
logger.warn('Failed to persist chat resources', {
chatId,
error: toError(err).message,
})
})
for (const resource of resources) {
if (isAborted()) break
await onEvent?.({
type: MothershipStreamV1EventType.resource,
payload: {
op: MothershipStreamV1ResourceOp.upsert,
resource: { type: resource.type, id: resource.id, title: resource.title },
},
})
}
}
}
span.setAttributes({
[TraceAttr.CopilotResourcesOp]: isDeleteOp
? 'delete'
: upsertedCount > 0
? 'upsert'
: 'none',
[TraceAttr.CopilotResourcesRemovedCount]: removedCount,
[TraceAttr.CopilotResourcesUpsertedCount]: upsertedCount,
[TraceAttr.CopilotResourcesAborted]: isAborted(),
})
}
)
}
@@ -0,0 +1,253 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TableDefinition } from '@/lib/table'
const { mockGetTableById, mockReplaceTableRows } = vi.hoisted(() => ({
mockGetTableById: vi.fn(),
mockReplaceTableRows: vi.fn(),
}))
vi.mock('@/lib/table/service', () => ({
getTableById: mockGetTableById,
}))
vi.mock('@/lib/table/rows/service', () => ({
replaceTableRows: mockReplaceTableRows,
}))
vi.mock('@/lib/copilot/request/otel', () => ({
withCopilotSpan: (
_name: string,
_attrs: Record<string, unknown> | undefined,
fn: (span: unknown) => Promise<unknown>
) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn(), addEvent: vi.fn() }),
}))
import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1'
import {
maybeWriteOutputToTable,
maybeWriteReadCsvToTable,
} from '@/lib/copilot/request/tools/tables'
import type { ExecutionContext } from '@/lib/copilot/request/types'
function buildTable(overrides: Partial<TableDefinition> = {}): TableDefinition {
return {
id: 'tbl_1',
name: 'People',
description: null,
schema: {
columns: [
{ id: 'col_name', name: 'name', type: 'string' },
{ id: 'col_age', name: 'age', type: 'number' },
],
},
metadata: null,
rowCount: 0,
maxRows: 100,
workspaceId: 'workspace-1',
createdBy: 'user-1',
archivedAt: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
...overrides,
} as TableDefinition
}
function buildContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
return {
userId: 'user-1',
workflowId: 'wf-1',
workspaceId: 'workspace-1',
userPermission: 'write',
...overrides,
}
}
describe('maybeWriteOutputToTable', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetTableById.mockResolvedValue(buildTable())
mockReplaceTableRows.mockResolvedValue({ deletedCount: 0, insertedCount: 2 })
})
it('rejects a table from another workspace without touching it', async () => {
mockGetTableById.mockResolvedValue(buildTable({ workspaceId: 'other-workspace' }))
const result = await maybeWriteOutputToTable(
FunctionExecute.id,
{ outputTable: 'tbl_1' },
{ success: true, output: { result: [{ name: 'Alice' }] } },
buildContext()
)
expect(result).toEqual({ success: false, error: 'Table "tbl_1" not found' })
expect(mockReplaceTableRows).not.toHaveBeenCalled()
})
it('denies a read-only principal without touching the table', async () => {
const result = await maybeWriteOutputToTable(
FunctionExecute.id,
{ outputTable: 'tbl_1' },
{ success: true, output: { result: [{ name: 'Alice' }] } },
buildContext({ userPermission: 'read' })
)
expect(result.success).toBe(false)
expect(result.error).toContain('requires write access')
expect(mockGetTableById).not.toHaveBeenCalled()
expect(mockReplaceTableRows).not.toHaveBeenCalled()
})
it('replaces rows through the service with name keys remapped to column ids', async () => {
const result = await maybeWriteOutputToTable(
FunctionExecute.id,
{ outputTable: 'tbl_1' },
{
success: true,
output: {
result: [
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 40 },
],
},
},
buildContext()
)
expect(result.success).toBe(true)
expect(mockReplaceTableRows).toHaveBeenCalledTimes(1)
const [data, table] = mockReplaceTableRows.mock.calls[0]
expect(data).toMatchObject({
tableId: 'tbl_1',
workspaceId: 'workspace-1',
userId: 'user-1',
rows: [
{ col_name: 'Alice', col_age: 30 },
{ col_name: 'Bob', col_age: 40 },
],
})
expect(table.id).toBe('tbl_1')
})
it('fails fast when no row keys match the table columns', async () => {
const result = await maybeWriteOutputToTable(
FunctionExecute.id,
{ outputTable: 'tbl_1' },
{ success: true, output: { result: [{ wrong: 1 }, { keys: 2 }] } },
buildContext()
)
expect(result.success).toBe(false)
expect(result.error).toContain('Row 1 has no keys matching columns')
expect(mockReplaceTableRows).not.toHaveBeenCalled()
})
it('fails fast when only some rows match instead of writing empty rows', async () => {
const result = await maybeWriteOutputToTable(
FunctionExecute.id,
{ outputTable: 'tbl_1' },
{ success: true, output: { result: [{ name: 'Alice' }, { wrong: 'x' }] } },
buildContext()
)
expect(result.success).toBe(false)
expect(result.error).toContain('Row 2 has no keys matching columns')
expect(mockReplaceTableRows).not.toHaveBeenCalled()
})
it('surfaces service validation failures as tool errors', async () => {
mockReplaceTableRows.mockRejectedValue(new Error('Row 1: name is required'))
const result = await maybeWriteOutputToTable(
FunctionExecute.id,
{ outputTable: 'tbl_1' },
{ success: true, output: { result: [{ age: 30 }] } },
buildContext()
)
expect(result.success).toBe(false)
expect(result.error).toContain('Row 1: name is required')
})
})
describe('maybeWriteReadCsvToTable', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetTableById.mockResolvedValue(buildTable())
mockReplaceTableRows.mockResolvedValue({ deletedCount: 0, insertedCount: 2 })
})
it('rejects a table from another workspace without touching it', async () => {
mockGetTableById.mockResolvedValue(buildTable({ workspaceId: 'other-workspace' }))
const result = await maybeWriteReadCsvToTable(
ReadTool.id,
{ outputTable: 'tbl_1', path: 'files/people.csv' },
{ success: true, output: { content: 'name,age\nAlice,30' } },
buildContext()
)
expect(result).toEqual({ success: false, error: 'Table "tbl_1" not found' })
expect(mockReplaceTableRows).not.toHaveBeenCalled()
})
it('denies a read-only principal without touching the table', async () => {
const result = await maybeWriteReadCsvToTable(
ReadTool.id,
{ outputTable: 'tbl_1', path: 'files/people.csv' },
{ success: true, output: { content: 'name,age\nAlice,30' } },
buildContext({ userPermission: 'read' })
)
expect(result.success).toBe(false)
expect(result.error).toContain('requires write access')
expect(mockGetTableById).not.toHaveBeenCalled()
expect(mockReplaceTableRows).not.toHaveBeenCalled()
})
it('imports CSV content through the service with id-keyed rows', async () => {
const result = await maybeWriteReadCsvToTable(
ReadTool.id,
{ outputTable: 'tbl_1', path: 'files/people.csv' },
{ success: true, output: { content: 'name,age\nAlice,30\nBob,40' } },
buildContext()
)
expect(result.success).toBe(true)
const [data] = mockReplaceTableRows.mock.calls[0]
expect(data.rows).toEqual([
{ col_name: 'Alice', col_age: '30' },
{ col_name: 'Bob', col_age: '40' },
])
})
it('fails fast when the file headers match no table columns', async () => {
const result = await maybeWriteReadCsvToTable(
ReadTool.id,
{ outputTable: 'tbl_1', path: 'files/people.csv' },
{ success: true, output: { content: 'wrong,headers\n1,2' } },
buildContext()
)
expect(result.success).toBe(false)
expect(result.error).toContain('Row 1 has no keys matching columns')
expect(mockReplaceTableRows).not.toHaveBeenCalled()
})
it('surfaces service validation failures as tool errors', async () => {
mockReplaceTableRows.mockRejectedValue(new Error('Row 1: name is required'))
const result = await maybeWriteReadCsvToTable(
ReadTool.id,
{ outputTable: 'tbl_1', path: 'files/people.csv' },
{ success: true, output: { content: 'age\n30' } },
buildContext()
)
expect(result.success).toBe(false)
expect(result.error).toContain('Row 1: name is required')
})
})
@@ -0,0 +1,300 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { parse as csvParse } from 'csv-parse/sync'
import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1'
import { CopilotTableOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { withCopilotSpan } from '@/lib/copilot/request/otel'
import { denyOutputWriteWithoutWritePermission } from '@/lib/copilot/request/tools/permissions'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
import type { RowData, TableDefinition } from '@/lib/table'
import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys'
import { replaceTableRows } from '@/lib/table/rows/service'
import { getTableById } from '@/lib/table/service'
const logger = createLogger('CopilotToolResultTables')
const MAX_OUTPUT_TABLE_ROWS = 10_000
/**
* Replaces a table's rows with wire rows keyed by column name. Translates the
* keys to stable column ids (unknown keys are dropped, matching every other
* name-translating boundary) and delegates to `replaceTableRows`, which owns
* locking, validation, plan row limits, batching, and rowCount maintenance.
*/
async function replaceTableRowsFromWire(
table: TableDefinition,
rows: Array<Record<string, unknown>>,
context: ExecutionContext
): Promise<{ error?: string }> {
const idByName = buildIdByName(table.schema)
const idKeyedRows = rows.map((row) => rowDataNameToId(row as RowData, idByName))
const emptyIndex = idKeyedRows.findIndex((row) => Object.keys(row).length === 0)
if (emptyIndex !== -1) {
return {
error: `Row ${emptyIndex + 1} has no keys matching columns on table "${table.name}" (columns: ${table.schema.columns.map((c) => c.name).join(', ')})`,
}
}
await replaceTableRows(
{
tableId: table.id,
rows: idKeyedRows,
workspaceId: table.workspaceId,
userId: context.userId,
},
table,
generateId().slice(0, 8)
)
return {}
}
export async function maybeWriteOutputToTable(
toolName: string,
params: Record<string, unknown> | undefined,
result: ToolCallResult,
context: ExecutionContext
): Promise<ToolCallResult> {
if (toolName !== FunctionExecute.id) return result
if (!result.success || !result.output) return result
if (!context.workspaceId || !context.userId) return result
const outputTable = params?.outputTable as string | undefined
if (!outputTable) return result
const denied = denyOutputWriteWithoutWritePermission(context)
if (denied) return denied
return withCopilotSpan(
TraceSpan.CopilotToolsWriteOutputTable,
{
[TraceAttr.ToolName]: toolName,
[TraceAttr.CopilotTableId]: outputTable,
[TraceAttr.WorkspaceId]: context.workspaceId,
},
async (span) => {
try {
const table = await getTableById(outputTable)
if (!table || table.workspaceId !== context.workspaceId) {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.TableNotFound)
return {
success: false,
error: `Table "${outputTable}" not found`,
}
}
const rawOutput = result.output
let rows: Array<Record<string, unknown>>
if (rawOutput && typeof rawOutput === 'object' && 'result' in rawOutput) {
const inner = (rawOutput as Record<string, unknown>).result
if (Array.isArray(inner)) {
rows = inner
} else {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape)
return {
success: false,
error: 'outputTable requires the code to return an array of objects',
}
}
} else if (Array.isArray(rawOutput)) {
rows = rawOutput
} else {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape)
return {
success: false,
error: 'outputTable requires the code to return an array of objects',
}
}
span.setAttribute(TraceAttr.CopilotTableRowCount, rows.length)
if (rows.length > MAX_OUTPUT_TABLE_ROWS) {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.RowLimitExceeded)
return {
success: false,
error: `outputTable row limit exceeded: got ${rows.length}, max is ${MAX_OUTPUT_TABLE_ROWS}`,
}
}
if (rows.length === 0) {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.EmptyRows)
return {
success: false,
error: 'outputTable requires at least one row — code returned an empty array',
}
}
if (context.abortSignal?.aborted) {
throw new Error('Request aborted before tool mutation could be applied')
}
const replaceResult = await replaceTableRowsFromWire(table, rows, context)
if (replaceResult.error) {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape)
return { success: false, error: replaceResult.error }
}
logger.info('Tool output written to table', {
toolName,
tableId: outputTable,
rowCount: rows.length,
})
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Wrote)
return {
success: true,
output: {
message: `Wrote ${rows.length} rows to table ${outputTable}`,
tableId: outputTable,
rowCount: rows.length,
},
}
} catch (err) {
logger.warn('Failed to write tool output to table', {
toolName,
outputTable,
error: toError(err).message,
})
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Failed)
span.addEvent(TraceEvent.CopilotTableError, {
[TraceAttr.ErrorMessage]: toError(err).message.slice(0, 500),
})
return {
success: false,
error: `Failed to write to table: ${toError(err).message}`,
}
}
}
)
}
export async function maybeWriteReadCsvToTable(
toolName: string,
params: Record<string, unknown> | undefined,
result: ToolCallResult,
context: ExecutionContext
): Promise<ToolCallResult> {
if (toolName !== ReadTool.id) return result
if (!result.success || !result.output) return result
if (!context.workspaceId || !context.userId) return result
const outputTable = params?.outputTable as string | undefined
if (!outputTable) return result
const denied = denyOutputWriteWithoutWritePermission(context)
if (denied) return denied
return withCopilotSpan(
TraceSpan.CopilotToolsWriteCsvToTable,
{
[TraceAttr.ToolName]: toolName,
[TraceAttr.CopilotTableId]: outputTable,
[TraceAttr.WorkspaceId]: context.workspaceId,
},
async (span) => {
try {
const table = await getTableById(outputTable)
if (!table || table.workspaceId !== context.workspaceId) {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.TableNotFound)
return { success: false, error: `Table "${outputTable}" not found` }
}
const output = result.output as Record<string, unknown>
const content = (output.content as string) || ''
if (!content.trim()) {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.EmptyContent)
return { success: false, error: 'File has no content to import into table' }
}
const filePath = (params?.path as string) || ''
const ext = filePath.split('.').pop()?.toLowerCase()
span.setAttributes({
[TraceAttr.CopilotTableSourcePath]: filePath,
[TraceAttr.CopilotTableSourceFormat]: ext === 'json' ? 'json' : 'csv',
[TraceAttr.CopilotTableSourceContentBytes]: content.length,
})
let rows: Record<string, unknown>[]
if (ext === 'json') {
const parsed = JSON.parse(content)
if (!Array.isArray(parsed)) {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidJsonShape)
return {
success: false,
error: 'JSON file must contain an array of objects for table import',
}
}
rows = parsed
} else {
rows = csvParse(content, {
columns: true,
skip_empty_lines: true,
trim: true,
relax_column_count: true,
relax_quotes: true,
skip_records_with_error: true,
cast: false,
}) as Record<string, unknown>[]
}
span.setAttribute(TraceAttr.CopilotTableRowCount, rows.length)
if (rows.length === 0) {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.EmptyRows)
return { success: false, error: 'File has no data rows to import' }
}
if (rows.length > MAX_OUTPUT_TABLE_ROWS) {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.RowLimitExceeded)
return {
success: false,
error: `Row limit exceeded: got ${rows.length}, max is ${MAX_OUTPUT_TABLE_ROWS}`,
}
}
if (context.abortSignal?.aborted) {
throw new Error('Request aborted before tool mutation could be applied')
}
const replaceResult = await replaceTableRowsFromWire(table, rows, context)
if (replaceResult.error) {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape)
return { success: false, error: replaceResult.error }
}
logger.info('Read output written to table', {
toolName,
tableId: outputTable,
tableName: table.name,
rowCount: rows.length,
filePath,
})
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Imported)
return {
success: true,
output: {
message: `Imported ${rows.length} rows from "${filePath}" into table "${table.name}"`,
tableId: outputTable,
tableName: table.name,
rowCount: rows.length,
},
}
} catch (err) {
logger.warn('Failed to write read output to table', {
toolName,
outputTable,
error: toError(err).message,
})
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Failed)
span.addEvent(TraceEvent.CopilotTableError, {
[TraceAttr.ErrorMessage]: toError(err).message.slice(0, 500),
})
return {
success: false,
error: `Failed to import into table: ${toError(err).message}`,
}
}
}
)
}
+128
View File
@@ -0,0 +1,128 @@
import {
type RequestTraceV1CostSummary,
RequestTraceV1Outcome,
type RequestTraceV1SimReport,
type RequestTraceV1Span,
RequestTraceV1SpanSource,
RequestTraceV1SpanStatus,
type RequestTraceV1UsageSummary,
} from '@/lib/copilot/generated/request-trace-v1'
export class TraceCollector {
private readonly spans: RequestTraceV1Span[] = []
private readonly startMs = Date.now()
private goTraceId?: string
private activeSpan?: RequestTraceV1Span
startSpan(
name: string,
kind: string,
attributes?: Record<string, unknown>,
parent?: RequestTraceV1Span
): RequestTraceV1Span {
const startMs = Date.now()
const span: RequestTraceV1Span = {
name,
kind,
startMs,
endMs: startMs,
durationMs: 0,
status: RequestTraceV1SpanStatus.ok,
source: RequestTraceV1SpanSource.sim,
...(parent
? { parentName: parent.name }
: this.activeSpan
? { parentName: this.activeSpan.name }
: {}),
...(attributes && Object.keys(attributes).length > 0 ? { attributes } : {}),
}
this.spans.push(span)
return span
}
endSpan(
span: RequestTraceV1Span,
status: RequestTraceV1SpanStatus | string = RequestTraceV1SpanStatus.ok
): void {
span.endMs = Date.now()
span.durationMs = span.endMs - span.startMs
span.status = status as RequestTraceV1SpanStatus
}
setActiveSpan(span: RequestTraceV1Span | undefined): void {
this.activeSpan = span
}
setGoTraceId(id: string): void {
if (!this.goTraceId && id) {
this.goTraceId = id
}
}
build(params: {
outcome: RequestTraceV1Outcome
simRequestId: string
streamId?: string
chatId?: string
runId?: string
executionId?: string
// Original user prompt, surfaced on the `request_traces.message`
// column at row-insert time so it's queryable from the DB without
// going through Tempo. Sim already has this at chat-POST time; it's
// threaded through here to the trace report so the row is complete
// the moment it's first written instead of waiting on the late
// analytics UPDATE.
userMessage?: string
usage?: {
prompt: number
completion: number
cacheAttemptedRequests?: number
cacheHitRequests?: number
cacheWriteRequests?: number
cacheReadTokens?: number
cacheWriteTokens?: number
cacheSavingsRate?: number
}
cost?: { input: number; output: number; total: number }
}): RequestTraceV1SimReport {
const endMs = Date.now()
const usage: RequestTraceV1UsageSummary | undefined = params.usage
? {
inputTokens: params.usage.prompt,
outputTokens: params.usage.completion,
cacheAttemptedRequests: params.usage.cacheAttemptedRequests ?? 0,
cacheHitRequests: params.usage.cacheHitRequests ?? 0,
cacheWriteRequests: params.usage.cacheWriteRequests ?? 0,
cacheReadTokens: params.usage.cacheReadTokens ?? 0,
cacheWriteTokens: params.usage.cacheWriteTokens ?? 0,
cacheSavingsRate: params.usage.cacheSavingsRate ?? 0,
}
: undefined
const cost: RequestTraceV1CostSummary | undefined = params.cost
? {
rawTotalCost: params.cost.total,
billedTotalCost: params.cost.total,
}
: undefined
return {
simRequestId: params.simRequestId,
goTraceId: this.goTraceId,
streamId: params.streamId,
chatId: params.chatId,
runId: params.runId,
executionId: params.executionId,
...(params.userMessage ? { userMessage: params.userMessage } : {}),
startMs: this.startMs,
endMs,
durationMs: endMs - this.startMs,
outcome: params.outcome,
usage,
cost,
spans: this.spans,
}
}
}
export { RequestTraceV1Outcome, RequestTraceV1SpanStatus }
+218
View File
@@ -0,0 +1,218 @@
import type { AsyncCompletionSignal } from '@/lib/copilot/async-runs/lifecycle'
import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1'
import type { RequestTraceV1Span } from '@/lib/copilot/generated/request-trace-v1'
import type { StreamEvent } from '@/lib/copilot/request/session'
import type { TraceCollector } from '@/lib/copilot/request/trace'
import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/tool-executor/types'
export type { StreamEvent }
export type LocalToolCallStatus = 'pending' | 'executing'
export type ToolCallStatus = LocalToolCallStatus | MothershipStreamV1ToolOutcome
const TERMINAL_TOOL_STATUSES: ReadonlySet<ToolCallStatus> = new Set<MothershipStreamV1ToolOutcome>(
Object.values(MothershipStreamV1ToolOutcome)
)
export function isTerminalToolCallStatus(status?: string): boolean {
return TERMINAL_TOOL_STATUSES.has(status as ToolCallStatus)
}
export interface ToolCallState {
id: string
name: string
status: ToolCallStatus
displayTitle?: string
params?: Record<string, unknown>
result?: ToolCallStateResult
error?: string
startTime?: number
endTime?: number
/**
* For a subagent-scoped tool call, the invoking subagent's channel id (its
* outer tool_use id, = event.scope.parentToolCallId). Captured at dispatch so
* the executor can thread it into the server tool context and scope the
* workspace_file -> edit_content intent handoff per file subagent. Undefined
* for main-lane tool calls.
*/
parentToolCallId?: string
}
export type ToolCallResult<T = unknown> = ToolExecutionResult & {
output?: T
}
export interface ToolCallStateResult<T = unknown> {
success: boolean
output?: T
}
export const ContentBlockType = {
text: 'text',
thinking: 'thinking',
tool_call: 'tool_call',
subagent_text: 'subagent_text',
subagent_thinking: 'subagent_thinking',
subagent: 'subagent',
} as const
export type ContentBlockType = (typeof ContentBlockType)[keyof typeof ContentBlockType]
export interface ContentBlock {
type: ContentBlockType
content?: string
toolCall?: ToolCallState
calledBy?: string
timestamp: number
endedAt?: number
parentToolCallId?: string
/**
* Deterministic agent-run identity. `spanId` is the stable per-invocation id
* of the subagent that produced the block; `parentSpanId` links it to the run
* that invoked it. These are the primary nesting keys; `parentToolCallId` is
* retained for tool linkage and legacy back-compat.
*/
spanId?: string
parentSpanId?: string
}
export interface ActiveFileIntent {
toolCallId: string
operation: string
target: { kind: string; fileId?: string; fileName?: string; path?: string }
title?: string
contentType?: string
edit?: Record<string, unknown>
}
// One paused subagent frame in an async continuation. Mirrors the wire
// MothershipStreamV1CheckpointPauseFrame the run handler maps from, but is the
// internal shape the resume driver consumes (named once here so the lifecycle
// driver and handlers reference the same type instead of re-declaring it inline).
export interface ResumeFrame {
parentToolCallId: string
parentToolName: string
pendingToolIds: string[]
// Per-subagent checkpoint model: this frame's OWN checkpoint chain. When set,
// the resume loop must POST /api/tools/resume with THIS id (not the top-level
// checkpointId) carrying only this frame's leaf results, and may drive the N
// frames concurrently. Empty under the bundled-frame model.
checkpointId?: string
}
// The async-continuation state captured from a checkpoint_pause: what the resume
// loop needs to drive the next /resume (the bundled top-level id + pending tools,
// or per-subagent frames each carrying their own checkpointId).
export interface ResumeContinuation {
checkpointId: string
executionId?: string
runId?: string
pendingToolCallIds: string[]
frames?: ResumeFrame[]
}
export interface StreamingContext {
chatId?: string
requestId?: string
executionId?: string
runId?: string
messageId: string
accumulatedContent: string
finalAssistantContent: string
sawMainToolCall: boolean
contentBlocks: ContentBlock[]
toolCalls: Map<string, ToolCallState>
pendingToolPromises: Map<string, Promise<AsyncCompletionSignal>>
awaitingAsyncContinuation?: ResumeContinuation
currentThinkingBlock: ContentBlock | null
/**
* Open subagent "thinking" blocks, keyed by parentToolCallId (one lane per
* concurrent subagent). Was a single slot, which collided when two subagents
* streamed thinking concurrently — interleaved chunks flushed each other's
* block. Per-lane keying keeps each subagent's reasoning intact.
*/
subagentThinkingBlocks: Map<string, ContentBlock>
isInThinkingBlock: boolean
subAgentContent: Record<string, string>
subAgentToolCalls: Record<string, ToolCallState[]>
openSubagentParents?: Set<string>
pendingContent: string
streamComplete: boolean
wasAborted: boolean
errors: string[]
usage?: { prompt: number; completion: number }
cost?: { input: number; output: number; total: number }
/**
* In-flight file-write intents keyed by the file subagent's channel id
* (event.scope.parentToolCallId). Was a single slot, which cross-attributed
* streamed content when two file subagents wrote concurrently; per-channel
* keying isolates each agent's preview. The empty-string key holds the
* main-lane / no-scope intent (file writes there are always sequential).
*/
activeFileIntents: Map<string, ActiveFileIntent>
trace: TraceCollector
subAgentTraceSpans?: Map<string, RequestTraceV1Span>
}
interface FileAttachment {
id: string
key: string
name: string
mimeType: string
size: number
}
interface OrchestratorRequest {
message: string
workflowId: string
userId: string
chatId?: string
mode?: 'agent' | 'ask' | 'plan'
model?: string
contexts?: Array<{ type: string; content: string }>
fileAttachments?: FileAttachment[]
commands?: string[]
provider?: string
streamToolCalls?: boolean
version?: string
prefetch?: boolean
userName?: string
}
export interface OrchestratorOptions {
autoExecuteTools?: boolean
timeout?: number
onEvent?: (event: StreamEvent) => void | Promise<void>
onComplete?: (result: OrchestratorResult) => void | Promise<void>
onError?: (error: Error, result?: OrchestratorResult) => void | Promise<void>
abortSignal?: AbortSignal
onAbortObserved?: (reason: string) => void
interactive?: boolean
}
export interface OrchestratorResult {
success: boolean
cancelled?: boolean
content: string
contentBlocks: ContentBlock[]
toolCalls: ToolCallSummary[]
chatId?: string
requestId?: string
error?: string
errors?: string[]
usage?: { prompt: number; completion: number }
cost?: { input: number; output: number; total: number }
}
export interface ToolCallSummary {
id: string
name: string
status: ToolCallStatus
params?: Record<string, unknown>
result?: unknown
error?: string
durationMs?: number
}
export interface ExecutionContext extends ToolExecutionContext {
messageId?: string
}