chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,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
}
}