chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 Go∪Sim 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 Go∪Sim 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user