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,599 @@
|
||||
import '@sim/testing/mocks/executor'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
import { MothershipBlockHandler } from '@/executor/handlers/mothership/mothership-handler'
|
||||
import type { ExecutionContext, StreamingExecution } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
const {
|
||||
mockBuildAuthHeaders,
|
||||
mockBuildAPIUrl,
|
||||
mockExtractAPIErrorMessage,
|
||||
mockGenerateId,
|
||||
mockIsExecutionCancelled,
|
||||
mockIsRedisCancellationEnabled,
|
||||
mockReadUserFileContent,
|
||||
} = vi.hoisted(() => ({
|
||||
mockBuildAuthHeaders: vi.fn(),
|
||||
mockBuildAPIUrl: vi.fn(),
|
||||
mockExtractAPIErrorMessage: vi.fn(),
|
||||
mockGenerateId: vi.fn(),
|
||||
mockIsExecutionCancelled: vi.fn(),
|
||||
mockIsRedisCancellationEnabled: vi.fn(),
|
||||
mockReadUserFileContent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/executor/utils/http', () => ({
|
||||
buildAuthHeaders: mockBuildAuthHeaders,
|
||||
buildAPIUrl: mockBuildAPIUrl,
|
||||
extractAPIErrorMessage: mockExtractAPIErrorMessage,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/utils/id', () => ({
|
||||
generateId: mockGenerateId,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/execution/cancellation', () => ({
|
||||
isExecutionCancelled: mockIsExecutionCancelled,
|
||||
isRedisCancellationEnabled: mockIsRedisCancellationEnabled,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/execution/payloads/materialization.server', () => ({
|
||||
readUserFileContent: mockReadUserFileContent,
|
||||
}))
|
||||
|
||||
function createAbortError(): Error {
|
||||
const error = new Error('The operation was aborted')
|
||||
error.name = 'AbortError'
|
||||
return error
|
||||
}
|
||||
|
||||
function createAbortableFetchPromise(signal?: AbortSignal): Promise<Response> {
|
||||
return new Promise((_resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(createAbortError())
|
||||
return
|
||||
}
|
||||
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(createAbortError())
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function readStreamText(stream: ReadableStream): Promise<string> {
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let text = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
text += decoder.decode(value, { stream: true })
|
||||
}
|
||||
|
||||
text += decoder.decode()
|
||||
reader.releaseLock()
|
||||
return text
|
||||
}
|
||||
|
||||
describe('MothershipBlockHandler', () => {
|
||||
let handler: MothershipBlockHandler
|
||||
let block: SerializedBlock
|
||||
let context: ExecutionContext
|
||||
let fetchMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
handler = new MothershipBlockHandler()
|
||||
fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
mockBuildAuthHeaders.mockResolvedValue({ Authorization: 'Bearer internal' })
|
||||
mockBuildAPIUrl.mockReturnValue(new URL('/api/mothership/execute', 'http://localhost:3000'))
|
||||
mockExtractAPIErrorMessage.mockResolvedValue('boom')
|
||||
mockGenerateId.mockReset()
|
||||
mockIsExecutionCancelled.mockReset()
|
||||
mockIsRedisCancellationEnabled.mockReset()
|
||||
mockIsRedisCancellationEnabled.mockReturnValue(false)
|
||||
mockReadUserFileContent.mockReset()
|
||||
|
||||
block = {
|
||||
id: 'mothership-block-1',
|
||||
metadata: { id: BlockType.MOTHERSHIP, name: 'Mothership' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: BlockType.MOTHERSHIP, params: {} },
|
||||
inputs: { prompt: 'string', conversationId: 'string', files: 'file[]' },
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
} as SerializedBlock
|
||||
|
||||
context = {
|
||||
workflowId: 'workflow-1',
|
||||
executionId: 'execution-1',
|
||||
workspaceId: 'workspace-1',
|
||||
userId: 'user-1',
|
||||
blockStates: new Map(),
|
||||
blockLogs: [],
|
||||
metadata: { duration: 0 },
|
||||
environmentVariables: {},
|
||||
decisions: { router: new Map(), condition: new Map() },
|
||||
loopExecutions: new Map(),
|
||||
completedLoops: new Set(),
|
||||
executedBlocks: new Set(),
|
||||
activeExecutionPath: new Set(),
|
||||
} as ExecutionContext
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function createNdjsonResponse(events: unknown[]): Response {
|
||||
const encoder = new TextEncoder()
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
for (const event of events) {
|
||||
controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`))
|
||||
}
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
it('forwards workflow and execution metadata with generated UUID ids', async () => {
|
||||
mockGenerateId.mockReturnValueOnce('chat-uuid')
|
||||
mockGenerateId.mockReturnValueOnce('message-uuid')
|
||||
mockGenerateId.mockReturnValueOnce('request-uuid')
|
||||
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
content: 'done',
|
||||
model: 'mothership',
|
||||
conversationId: 'chat-uuid',
|
||||
tokens: { total: 5 },
|
||||
toolCalls: [],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const result = await handler.execute(context, block, { prompt: 'Hello from workflow' })
|
||||
|
||||
expect(result).toEqual({
|
||||
content: 'done',
|
||||
model: 'mothership',
|
||||
conversationId: 'chat-uuid',
|
||||
tokens: { total: 5 },
|
||||
toolCalls: { list: [], count: 0 },
|
||||
cost: undefined,
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('http://localhost:3000/api/mothership/execute')
|
||||
expect(options.method).toBe('POST')
|
||||
expect(options.signal).toBeInstanceOf(AbortSignal)
|
||||
expect(options.headers).toMatchObject({
|
||||
Accept: 'application/x-ndjson',
|
||||
'X-Mothership-Execute-Stream': 'ndjson',
|
||||
})
|
||||
|
||||
const body = JSON.parse(String(options.body))
|
||||
expect(body).toEqual({
|
||||
messages: [{ role: 'user', content: 'Hello from workflow' }],
|
||||
workspaceId: 'workspace-1',
|
||||
userId: 'user-1',
|
||||
chatId: 'chat-uuid',
|
||||
messageId: 'message-uuid',
|
||||
requestId: 'request-uuid',
|
||||
workflowId: 'workflow-1',
|
||||
executionId: 'execution-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses a provided conversation ID as the mothership chat ID', async () => {
|
||||
mockGenerateId.mockReturnValueOnce('message-uuid')
|
||||
mockGenerateId.mockReturnValueOnce('request-uuid')
|
||||
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
content: 'continued',
|
||||
model: 'mothership',
|
||||
conversationId: 'existing-chat-id',
|
||||
tokens: {},
|
||||
toolCalls: [],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const result = await handler.execute(context, block, {
|
||||
prompt: 'Continue this thread',
|
||||
conversationId: ' existing-chat-id ',
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
content: 'continued',
|
||||
model: 'mothership',
|
||||
conversationId: 'existing-chat-id',
|
||||
tokens: {},
|
||||
toolCalls: { list: [], count: 0 },
|
||||
cost: undefined,
|
||||
})
|
||||
|
||||
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
const body = JSON.parse(String(options.body))
|
||||
expect(body).toEqual({
|
||||
messages: [{ role: 'user', content: 'Continue this thread' }],
|
||||
workspaceId: 'workspace-1',
|
||||
userId: 'user-1',
|
||||
chatId: 'existing-chat-id',
|
||||
messageId: 'message-uuid',
|
||||
requestId: 'request-uuid',
|
||||
workflowId: 'workflow-1',
|
||||
executionId: 'execution-1',
|
||||
})
|
||||
expect(mockGenerateId).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('consumes mothership execute heartbeat streams until the final result', async () => {
|
||||
mockGenerateId.mockReturnValueOnce('chat-uuid')
|
||||
mockGenerateId.mockReturnValueOnce('message-uuid')
|
||||
mockGenerateId.mockReturnValueOnce('request-uuid')
|
||||
|
||||
fetchMock.mockResolvedValue(
|
||||
createNdjsonResponse([
|
||||
{ type: 'heartbeat', timestamp: '2026-05-15T18:13:48.000Z' },
|
||||
{
|
||||
type: 'final',
|
||||
data: {
|
||||
content: 'streamed done',
|
||||
model: 'mothership',
|
||||
conversationId: 'chat-uuid',
|
||||
tokens: { total: 7 },
|
||||
toolCalls: [{ name: 'tool_a', params: { a: 1 }, result: 'ok', durationMs: 42 }],
|
||||
cost: { total: 0.1 },
|
||||
},
|
||||
},
|
||||
])
|
||||
)
|
||||
|
||||
const result = await handler.execute(context, block, { prompt: 'Hello from workflow' })
|
||||
|
||||
expect(result).toEqual({
|
||||
content: 'streamed done',
|
||||
model: 'mothership',
|
||||
conversationId: 'chat-uuid',
|
||||
tokens: { total: 7 },
|
||||
toolCalls: {
|
||||
list: [
|
||||
{
|
||||
name: 'tool_a',
|
||||
arguments: { a: 1 },
|
||||
result: 'ok',
|
||||
error: undefined,
|
||||
duration: 42,
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
},
|
||||
cost: { total: 0.1 },
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves failed tool calls as output metadata without throwing', async () => {
|
||||
mockGenerateId.mockReturnValueOnce('chat-uuid')
|
||||
mockGenerateId.mockReturnValueOnce('message-uuid')
|
||||
mockGenerateId.mockReturnValueOnce('request-uuid')
|
||||
|
||||
fetchMock.mockResolvedValue(
|
||||
createNdjsonResponse([
|
||||
{
|
||||
type: 'final',
|
||||
data: {
|
||||
content: 'The lookup failed, so I could not use that result.',
|
||||
model: 'mothership',
|
||||
conversationId: 'chat-uuid',
|
||||
tokens: { total: 7 },
|
||||
toolCalls: [
|
||||
{
|
||||
name: 'lookup_customer',
|
||||
status: 'error',
|
||||
params: { email: 'missing@example.com' },
|
||||
result: { success: false, error: 'Customer not found' },
|
||||
error: 'Customer not found',
|
||||
durationMs: 42,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
)
|
||||
|
||||
const result = await handler.execute(context, block, { prompt: 'Hello from workflow' })
|
||||
|
||||
expect(result).toEqual({
|
||||
content: 'The lookup failed, so I could not use that result.',
|
||||
model: 'mothership',
|
||||
conversationId: 'chat-uuid',
|
||||
tokens: { total: 7 },
|
||||
toolCalls: {
|
||||
list: [
|
||||
expect.objectContaining({
|
||||
name: 'lookup_customer',
|
||||
status: 'error',
|
||||
arguments: { email: 'missing@example.com' },
|
||||
result: { success: false, error: 'Customer not found' },
|
||||
error: 'Customer not found',
|
||||
duration: 42,
|
||||
}),
|
||||
],
|
||||
count: 1,
|
||||
},
|
||||
cost: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces mothership execute stream errors', async () => {
|
||||
mockGenerateId.mockReturnValueOnce('chat-uuid')
|
||||
mockGenerateId.mockReturnValueOnce('message-uuid')
|
||||
mockGenerateId.mockReturnValueOnce('request-uuid')
|
||||
|
||||
fetchMock.mockResolvedValue(
|
||||
createNdjsonResponse([
|
||||
{ type: 'heartbeat', timestamp: '2026-05-15T18:13:48.000Z' },
|
||||
{ type: 'error', error: 'Mothership execution aborted' },
|
||||
])
|
||||
)
|
||||
|
||||
await expect(
|
||||
handler.execute(context, block, { prompt: 'Hello from workflow' })
|
||||
).rejects.toThrow('Sim execution failed: Mothership execution aborted')
|
||||
})
|
||||
|
||||
it('streams mothership assistant chunks and preserves final metadata', async () => {
|
||||
context.stream = true
|
||||
context.selectedOutputs = [`${block.id}_content`]
|
||||
mockGenerateId.mockReturnValueOnce('chat-uuid')
|
||||
mockGenerateId.mockReturnValueOnce('message-uuid')
|
||||
mockGenerateId.mockReturnValueOnce('request-uuid')
|
||||
|
||||
fetchMock.mockResolvedValue(
|
||||
createNdjsonResponse([
|
||||
{ type: 'heartbeat', timestamp: '2026-05-15T18:13:48.000Z' },
|
||||
{ type: 'chunk', content: 'Hello' },
|
||||
{ type: 'heartbeat', timestamp: '2026-05-15T18:14:03.000Z' },
|
||||
{ type: 'chunk', content: ' world' },
|
||||
{
|
||||
type: 'final',
|
||||
data: {
|
||||
content: 'Hello world',
|
||||
model: 'mothership',
|
||||
conversationId: 'chat-uuid',
|
||||
tokens: { total: 7 },
|
||||
toolCalls: [{ name: 'tool_a', params: { a: 1 }, result: 'ok', durationMs: 42 }],
|
||||
cost: { total: 0.1 },
|
||||
},
|
||||
},
|
||||
])
|
||||
)
|
||||
|
||||
const result = await handler.execute(context, block, { prompt: 'Hello from workflow' })
|
||||
expect(result).toHaveProperty('stream')
|
||||
|
||||
const streamingExecution = result as StreamingExecution
|
||||
await expect(readStreamText(streamingExecution.stream)).resolves.toBe('Hello world')
|
||||
expect(streamingExecution.execution.output).toEqual({
|
||||
content: 'Hello world',
|
||||
model: 'mothership',
|
||||
conversationId: 'chat-uuid',
|
||||
tokens: { total: 7 },
|
||||
toolCalls: {
|
||||
list: [
|
||||
{
|
||||
name: 'tool_a',
|
||||
arguments: { a: 1 },
|
||||
result: 'ok',
|
||||
error: undefined,
|
||||
duration: 42,
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
},
|
||||
cost: { total: 0.1 },
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces mothership streaming errors while streaming selected content', async () => {
|
||||
context.stream = true
|
||||
context.selectedOutputs = [`${block.id}_content`]
|
||||
mockGenerateId.mockReturnValueOnce('chat-uuid')
|
||||
mockGenerateId.mockReturnValueOnce('message-uuid')
|
||||
mockGenerateId.mockReturnValueOnce('request-uuid')
|
||||
|
||||
fetchMock.mockResolvedValue(
|
||||
createNdjsonResponse([
|
||||
{ type: 'chunk', content: 'partial' },
|
||||
{ type: 'error', error: 'Mothership execution aborted' },
|
||||
])
|
||||
)
|
||||
|
||||
const result = (await handler.execute(context, block, {
|
||||
prompt: 'Hello from workflow',
|
||||
})) as StreamingExecution
|
||||
|
||||
await expect(readStreamText(result.stream)).rejects.toThrow(
|
||||
'Sim execution failed: Mothership execution aborted'
|
||||
)
|
||||
})
|
||||
|
||||
it('embeds attached files for the mothership execute request', async () => {
|
||||
const fileContent = Buffer.from('hello mothership', 'utf8').toString('base64')
|
||||
mockGenerateId.mockReturnValueOnce('chat-uuid')
|
||||
mockGenerateId.mockReturnValueOnce('message-uuid')
|
||||
mockGenerateId.mockReturnValueOnce('request-uuid')
|
||||
mockReadUserFileContent.mockResolvedValueOnce(fileContent)
|
||||
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
content: 'analyzed',
|
||||
model: 'mothership',
|
||||
conversationId: 'chat-uuid',
|
||||
tokens: {},
|
||||
toolCalls: [],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const result = await handler.execute(context, block, {
|
||||
prompt: 'Analyze this file',
|
||||
files: [
|
||||
{
|
||||
name: 'notes.txt',
|
||||
key: 'workspace/workspace-1/notes.txt',
|
||||
size: 16,
|
||||
type: 'text/plain',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
content: 'analyzed',
|
||||
model: 'mothership',
|
||||
conversationId: 'chat-uuid',
|
||||
})
|
||||
expect(mockReadUserFileContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^file-/),
|
||||
key: 'workspace/workspace-1/notes.txt',
|
||||
name: 'notes.txt',
|
||||
url: '',
|
||||
size: 16,
|
||||
type: 'text/plain',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
encoding: 'base64',
|
||||
userId: 'user-1',
|
||||
workspaceId: 'workspace-1',
|
||||
workflowId: 'workflow-1',
|
||||
executionId: 'execution-1',
|
||||
requestId: 'request-uuid',
|
||||
})
|
||||
)
|
||||
|
||||
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
const body = JSON.parse(String(options.body))
|
||||
expect(body.fileAttachments).toEqual([
|
||||
{
|
||||
type: 'document',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'text/plain',
|
||||
data: fileContent,
|
||||
},
|
||||
filename: 'notes.txt',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('propagates local aborts to the mothership request', async () => {
|
||||
const abortController = new AbortController()
|
||||
context.abortSignal = abortController.signal
|
||||
|
||||
mockGenerateId.mockReturnValueOnce('chat-uuid')
|
||||
mockGenerateId.mockReturnValueOnce('message-uuid')
|
||||
mockGenerateId.mockReturnValueOnce('request-uuid')
|
||||
|
||||
fetchMock.mockImplementation((_url: string, options?: RequestInit) =>
|
||||
createAbortableFetchPromise(options?.signal as AbortSignal | undefined)
|
||||
)
|
||||
|
||||
const executionPromise = handler.execute(context, block, { prompt: 'Abort me' })
|
||||
const abortedExecution = executionPromise.catch((error) => error)
|
||||
|
||||
abortController.abort()
|
||||
|
||||
await expect(abortedExecution).resolves.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('propagates durable workflow cancellation to the mothership request', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
mockGenerateId.mockReturnValueOnce('chat-uuid')
|
||||
mockGenerateId.mockReturnValueOnce('message-uuid')
|
||||
mockGenerateId.mockReturnValueOnce('request-uuid')
|
||||
mockIsRedisCancellationEnabled.mockReturnValue(true)
|
||||
mockIsExecutionCancelled.mockResolvedValueOnce(false).mockResolvedValueOnce(true)
|
||||
|
||||
fetchMock.mockImplementation((_url: string, options?: RequestInit) =>
|
||||
createAbortableFetchPromise(options?.signal as AbortSignal | undefined)
|
||||
)
|
||||
|
||||
const executionPromise = handler.execute(context, block, { prompt: 'Cancel me durably' })
|
||||
const abortedExecution = executionPromise.catch((error) => error)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
await expect(abortedExecution).resolves.toMatchObject({ name: 'AbortError' })
|
||||
expect(mockIsExecutionCancelled).toHaveBeenCalledWith('execution-1')
|
||||
})
|
||||
|
||||
it('aborts the mothership request when selected-output streaming is cancelled', async () => {
|
||||
context.stream = true
|
||||
context.selectedOutputs = [`${block.id}_content`]
|
||||
|
||||
mockGenerateId.mockReturnValueOnce('chat-uuid')
|
||||
mockGenerateId.mockReturnValueOnce('message-uuid')
|
||||
mockGenerateId.mockReturnValueOnce('request-uuid')
|
||||
|
||||
let fetchSignal: AbortSignal | undefined
|
||||
fetchMock.mockImplementation((_url: string, options?: RequestInit) => {
|
||||
fetchSignal = options?.signal as AbortSignal | undefined
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start() {},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8' },
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const result = (await handler.execute(context, block, { prompt: 'Cancel stream' })) as
|
||||
| StreamingExecution
|
||||
| undefined
|
||||
|
||||
await result?.stream.cancel('client_cancelled')
|
||||
|
||||
expect(fetchSignal?.aborted).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,459 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation'
|
||||
import { readUserFileContent } from '@/lib/execution/payloads/materialization.server'
|
||||
import {
|
||||
createFileContentFromBase64,
|
||||
type MessageContent,
|
||||
processSingleFileToUserFile,
|
||||
type RawFileInput,
|
||||
} from '@/lib/uploads/utils/file-utils'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { normalizeFileInput } from '@/blocks/utils'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
import type {
|
||||
BlockHandler,
|
||||
ExecutionContext,
|
||||
NormalizedBlockOutput,
|
||||
StreamingExecution,
|
||||
} from '@/executor/types'
|
||||
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
const logger = createLogger('MothershipBlockHandler')
|
||||
const CANCELLATION_CHECK_INTERVAL_MS = 500
|
||||
const MAX_MOTHERSHIP_ATTACHMENT_BYTES = 10 * 1024 * 1024
|
||||
const MOTHERSHIP_EXECUTE_STREAM_HEADER = 'X-Mothership-Execute-Stream'
|
||||
const MOTHERSHIP_EXECUTE_STREAM_VALUE = 'ndjson'
|
||||
|
||||
type MothershipFileAttachment = MessageContent & {
|
||||
filename?: string
|
||||
}
|
||||
|
||||
type MothershipExecuteResult = {
|
||||
content?: string
|
||||
model?: string
|
||||
conversationId?: string
|
||||
tokens?: Record<string, unknown>
|
||||
toolCalls?: Array<Record<string, unknown>>
|
||||
cost?: unknown
|
||||
}
|
||||
|
||||
type MothershipExecuteStreamEvent =
|
||||
| { type: 'heartbeat'; timestamp?: string }
|
||||
| { type: 'chunk'; content?: string }
|
||||
| { type: 'final'; data: MothershipExecuteResult }
|
||||
| { type: 'error'; error?: string }
|
||||
|
||||
function parseMothershipExecuteStreamLine(line: string): MothershipExecuteStreamEvent | undefined {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) return undefined
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed) as MothershipExecuteStreamEvent
|
||||
} catch {
|
||||
throw new Error('Sim execution stream returned malformed data')
|
||||
}
|
||||
}
|
||||
|
||||
function formatMothershipBlockOutput(
|
||||
result: MothershipExecuteResult,
|
||||
fallbackChatId: string
|
||||
): NormalizedBlockOutput {
|
||||
const formattedList = (result.toolCalls || []).map((tc: Record<string, unknown>) => ({
|
||||
name: typeof tc.name === 'string' ? tc.name : String(tc.name ?? ''),
|
||||
...(typeof tc.status === 'string' ? { status: tc.status } : {}),
|
||||
arguments: (tc.arguments || tc.params || tc.input || {}) as Record<string, unknown>,
|
||||
result: (tc.result ?? tc.output) as any,
|
||||
error: typeof tc.error === 'string' ? tc.error : undefined,
|
||||
duration: typeof tc.durationMs === 'number' ? tc.durationMs : 0,
|
||||
}))
|
||||
const toolCalls: NormalizedBlockOutput['toolCalls'] = {
|
||||
list: formattedList,
|
||||
count: formattedList.length,
|
||||
}
|
||||
|
||||
return {
|
||||
content: result.content || '',
|
||||
model: result.model || 'mothership',
|
||||
conversationId: result.conversationId || fallbackChatId,
|
||||
tokens: (result.tokens || {}) as NormalizedBlockOutput['tokens'],
|
||||
toolCalls,
|
||||
cost: result.cost as NormalizedBlockOutput['cost'] | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function isContentSelectedForStreaming(ctx: ExecutionContext, block: SerializedBlock): boolean {
|
||||
if (!ctx.stream) return false
|
||||
|
||||
return (
|
||||
ctx.selectedOutputs?.some((outputId) => {
|
||||
if (outputId === block.id) return true
|
||||
return outputId === `${block.id}.content` || outputId === `${block.id}_content`
|
||||
}) ?? false
|
||||
)
|
||||
}
|
||||
|
||||
async function readMothershipExecuteResponse(response: Response): Promise<MothershipExecuteResult> {
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (!contentType.includes('application/x-ndjson')) {
|
||||
return response.json()
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('Sim execution stream ended without a response body')
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let finalResult: MothershipExecuteResult | undefined
|
||||
|
||||
const processLine = (line: string) => {
|
||||
const event = parseMothershipExecuteStreamLine(line)
|
||||
if (!event) return
|
||||
|
||||
if (event.type === 'heartbeat' || event.type === 'chunk') {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === 'error') {
|
||||
throw new Error(`Sim execution failed: ${event.error || 'Unknown error'}`)
|
||||
}
|
||||
|
||||
if (event.type === 'final') {
|
||||
finalResult = event.data
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error('Sim execution stream returned an unknown event')
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
processLine(line)
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode()
|
||||
processLine(buffer)
|
||||
|
||||
if (!finalResult) {
|
||||
throw new Error('Sim execution stream ended without a final result')
|
||||
}
|
||||
|
||||
return finalResult
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
function createMothershipStreamingExecution(
|
||||
response: Response,
|
||||
fallbackChatId: string,
|
||||
blockId: string,
|
||||
options: {
|
||||
onCancel?: (reason?: unknown) => void
|
||||
onDone?: () => void
|
||||
} = {}
|
||||
): StreamingExecution {
|
||||
if (!response.body) {
|
||||
throw new Error('Sim execution stream ended without a response body')
|
||||
}
|
||||
|
||||
const output = formatMothershipBlockOutput({}, fallbackChatId)
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
|
||||
let cancelled = false
|
||||
let cleanedUp = false
|
||||
const cleanup = () => {
|
||||
if (cleanedUp) return
|
||||
cleanedUp = true
|
||||
options.onDone?.()
|
||||
}
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
reader = response.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
const encoder = new TextEncoder()
|
||||
let buffer = ''
|
||||
let sawFinal = false
|
||||
|
||||
const processLine = (line: string) => {
|
||||
const event = parseMothershipExecuteStreamLine(line)
|
||||
if (!event) return
|
||||
|
||||
if (event.type === 'heartbeat') {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === 'chunk') {
|
||||
if (event.content) {
|
||||
controller.enqueue(encoder.encode(event.content))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === 'error') {
|
||||
throw new Error(`Sim execution failed: ${event.error || 'Unknown error'}`)
|
||||
}
|
||||
|
||||
if (event.type === 'final') {
|
||||
sawFinal = true
|
||||
Object.assign(output, formatMothershipBlockOutput(event.data, fallbackChatId))
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error('Sim execution stream returned an unknown event')
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (cancelled) return
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
processLine(line)
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode()
|
||||
processLine(buffer)
|
||||
|
||||
if (!sawFinal) {
|
||||
throw new Error('Sim execution stream ended without a final result')
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
controller.close()
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
controller.error(error)
|
||||
}
|
||||
} finally {
|
||||
cleanup()
|
||||
reader?.releaseLock()
|
||||
}
|
||||
},
|
||||
cancel(reason) {
|
||||
cancelled = true
|
||||
options.onCancel?.(reason)
|
||||
cleanup()
|
||||
return reader?.cancel(reason)
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
stream,
|
||||
execution: {
|
||||
success: true,
|
||||
output,
|
||||
blockId,
|
||||
logs: [],
|
||||
metadata: {
|
||||
duration: 0,
|
||||
startTime: new Date().toISOString(),
|
||||
},
|
||||
isStreaming: true,
|
||||
} as StreamingExecution['execution'] & { blockId: string },
|
||||
}
|
||||
}
|
||||
|
||||
async function buildMothershipFileAttachments(
|
||||
filesInput: unknown,
|
||||
ctx: ExecutionContext,
|
||||
requestId: string
|
||||
): Promise<MothershipFileAttachment[] | undefined> {
|
||||
const files = normalizeFileInput(filesInput)
|
||||
if (!files || files.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!ctx.userId) {
|
||||
throw new Error('Mothership file attachments require an authenticated user.')
|
||||
}
|
||||
|
||||
const attachments: MothershipFileAttachment[] = []
|
||||
for (const file of files) {
|
||||
const userFile = processSingleFileToUserFile(file as RawFileInput, requestId, logger)
|
||||
const base64 = await readUserFileContent(userFile, {
|
||||
encoding: 'base64',
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
workflowId: ctx.workflowId,
|
||||
executionId: ctx.executionId,
|
||||
largeValueExecutionIds: ctx.largeValueExecutionIds,
|
||||
largeValueKeys: ctx.largeValueKeys,
|
||||
fileKeys: ctx.fileKeys,
|
||||
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
|
||||
requestId,
|
||||
logger,
|
||||
maxBytes: MAX_MOTHERSHIP_ATTACHMENT_BYTES,
|
||||
maxSourceBytes: MAX_MOTHERSHIP_ATTACHMENT_BYTES,
|
||||
})
|
||||
|
||||
const content = createFileContentFromBase64(base64, userFile.type)
|
||||
if (!content) {
|
||||
throw new Error(`File type is not supported for Mothership attachments: ${userFile.name}`)
|
||||
}
|
||||
|
||||
attachments.push({ ...content, filename: userFile.name })
|
||||
}
|
||||
|
||||
return attachments
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for Mothership blocks that proxy requests to the Mothership AI agent.
|
||||
*
|
||||
* Unlike the Agent block (which calls LLM providers directly), the Mothership
|
||||
* block delegates to the full Mothership infrastructure: main agent, subagents,
|
||||
* integration tools, memory, and workspace context.
|
||||
*/
|
||||
export class MothershipBlockHandler implements BlockHandler {
|
||||
canHandle(block: SerializedBlock): boolean {
|
||||
return block.metadata?.id === BlockType.MOTHERSHIP
|
||||
}
|
||||
|
||||
async execute(
|
||||
ctx: ExecutionContext,
|
||||
block: SerializedBlock,
|
||||
inputs: Record<string, any>
|
||||
): Promise<BlockOutput | StreamingExecution> {
|
||||
const prompt = inputs.prompt
|
||||
if (!prompt || typeof prompt !== 'string') {
|
||||
throw new Error('Prompt input is required')
|
||||
}
|
||||
const messages = [{ role: 'user' as const, content: prompt }]
|
||||
const providedConversationId =
|
||||
typeof inputs.conversationId === 'string' ? inputs.conversationId.trim() : ''
|
||||
const chatId = providedConversationId || generateId()
|
||||
const messageId = generateId()
|
||||
const requestId = generateId()
|
||||
const fileAttachments = await buildMothershipFileAttachments(inputs.files, ctx, requestId)
|
||||
|
||||
const url = buildAPIUrl('/api/mothership/execute')
|
||||
const headers = await buildAuthHeaders(ctx.userId)
|
||||
headers.Accept = 'application/x-ndjson'
|
||||
headers[MOTHERSHIP_EXECUTE_STREAM_HEADER] = MOTHERSHIP_EXECUTE_STREAM_VALUE
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
messages,
|
||||
workspaceId: ctx.workspaceId || '',
|
||||
userId: ctx.userId || '',
|
||||
chatId,
|
||||
messageId,
|
||||
requestId,
|
||||
...(fileAttachments && { fileAttachments }),
|
||||
...(ctx.workflowId ? { workflowId: ctx.workflowId } : {}),
|
||||
...(ctx.executionId ? { executionId: ctx.executionId } : {}),
|
||||
}
|
||||
|
||||
logger.info('Executing Mothership block', {
|
||||
blockId: block.id,
|
||||
messageId,
|
||||
requestId,
|
||||
workflowId: ctx.workflowId,
|
||||
executionId: ctx.executionId,
|
||||
chatId,
|
||||
fileAttachmentCount: fileAttachments?.length ?? 0,
|
||||
})
|
||||
|
||||
const abortController = new AbortController()
|
||||
const onAbort = () => {
|
||||
if (!abortController.signal.aborted) {
|
||||
abortController.abort(ctx.abortSignal?.reason ?? 'workflow_abort')
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.abortSignal?.aborted) {
|
||||
onAbort()
|
||||
} else {
|
||||
ctx.abortSignal?.addEventListener('abort', onAbort, { once: true })
|
||||
}
|
||||
|
||||
const executionId = ctx.executionId
|
||||
const useRedisCancellation = isRedisCancellationEnabled() && !!executionId
|
||||
let pollInFlight = false
|
||||
const cancellationPoller =
|
||||
useRedisCancellation && executionId
|
||||
? setInterval(() => {
|
||||
if (pollInFlight || abortController.signal.aborted) {
|
||||
return
|
||||
}
|
||||
pollInFlight = true
|
||||
void isExecutionCancelled(executionId)
|
||||
.then((cancelled) => {
|
||||
if (cancelled && !abortController.signal.aborted) {
|
||||
abortController.abort('workflow_execution_cancelled')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn('Failed to poll workflow cancellation for Mothership block', {
|
||||
blockId: block.id,
|
||||
executionId,
|
||||
error: toError(error).message,
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
pollInFlight = false
|
||||
})
|
||||
}, CANCELLATION_CHECK_INTERVAL_MS)
|
||||
: undefined
|
||||
const cleanupAbortListeners = () => {
|
||||
if (cancellationPoller) {
|
||||
clearInterval(cancellationPoller)
|
||||
}
|
||||
ctx.abortSignal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
let response: Response
|
||||
let cleanupImmediately = true
|
||||
try {
|
||||
response = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMsg = await extractAPIErrorMessage(response)
|
||||
throw new Error(`Sim execution failed: ${errorMsg}`)
|
||||
}
|
||||
|
||||
if (isContentSelectedForStreaming(ctx, block)) {
|
||||
const streamingExecution = createMothershipStreamingExecution(response, chatId, block.id, {
|
||||
onCancel: (reason) => {
|
||||
if (!abortController.signal.aborted) {
|
||||
abortController.abort(reason ?? 'mothership_stream_cancelled')
|
||||
}
|
||||
},
|
||||
onDone: cleanupAbortListeners,
|
||||
})
|
||||
cleanupImmediately = false
|
||||
return streamingExecution
|
||||
}
|
||||
|
||||
const result = await readMothershipExecuteResponse(response)
|
||||
return formatMothershipBlockOutput(result, chatId)
|
||||
} finally {
|
||||
if (cleanupImmediately) {
|
||||
cleanupAbortListeners()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user