Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (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 / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

1563 lines
49 KiB
TypeScript

/**
* @vitest-environment node
*/
import { loggerMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { readSSEStream } from '@/lib/core/utils/sse'
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
import {
agentStreamProtocolResponseHeaders,
createStreamingResponse,
} from '@/lib/workflows/streaming/streaming'
const workflowStreamingLoggerCallIndex = loggerMock.createLogger.mock.calls.findIndex(
([name]) => name === 'WorkflowStreaming'
)
const workflowStreamingLogger =
loggerMock.createLogger.mock.results[workflowStreamingLoggerCallIndex]?.value
if (!workflowStreamingLogger) {
throw new Error('WorkflowStreaming logger mock was not initialized')
}
const { mockDownloadFile } = vi.hoisted(() => ({
mockDownloadFile: vi.fn(),
}))
vi.mock('@/lib/uploads', () => ({
StorageService: {
downloadFile: mockDownloadFile,
},
}))
const manifestChunk = [{ id: 1 }]
const manifestChunkBytes = Buffer.byteLength(JSON.stringify(manifestChunk), 'utf8')
const manifest = {
__simLargeArrayManifest: true,
version: 2,
kind: 'array',
totalCount: 1,
chunkCount: 1,
byteSize: manifestChunkBytes,
chunks: [
{
ref: {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'array',
size: manifestChunkBytes,
key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_ABCDEFGHIJKL.json',
executionId: 'execution-1',
},
count: 1,
byteSize: manifestChunkBytes,
},
],
preview: [{ id: 1 }],
}
async function collectSSEEvents(
stream: ReadableStream<Uint8Array>
): Promise<Record<string, unknown>[]> {
const reader = stream.getReader()
const decoder = new TextDecoder()
const events: Record<string, unknown>[] = []
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
buffer += decoder.decode()
break
}
buffer += decoder.decode(value, { stream: true })
}
} finally {
reader.releaseLock()
}
for (const chunk of buffer.split('\n\n')) {
if (!chunk.startsWith('data: ')) {
continue
}
const payload = chunk.substring(6)
if (payload === '[DONE]') {
continue
}
const event = JSON.parse(payload) as unknown
if (event === '[DONE]') {
continue
}
events.push(event as Record<string, unknown>)
}
return events
}
describe('createStreamingResponse', () => {
beforeEach(() => {
vi.clearAllMocks()
clearLargeValueCacheForTests()
})
it('forwards raw execution state to terminal logging', async () => {
const safeComplete = vi.fn().mockResolvedValue(undefined)
const executionState = {
blockStates: { 'function-1': { output: { result: 'raw-secret-value' } } },
}
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
streamConfig: {
selectedOutputs: [],
includeFileBase64: false,
},
executeFn: async () =>
({
success: true,
status: 'completed',
output: { result: 'raw-secret-value' },
logs: [],
metadata: { duration: 1 },
executionState,
_streamingMetadata: {
loggingSession: { safeComplete },
processedInput: { input: 'raw-runtime-value' },
},
}) as any,
})
await readSSEStream(stream)
expect(safeComplete).toHaveBeenCalledWith(
expect.objectContaining({
finalOutput: { result: 'raw-secret-value' },
workflowInput: { input: 'raw-runtime-value' },
executionState,
})
)
})
it('projects stream failures for logs without changing the terminal error frame', async () => {
const secret = 'streaming-secret-value'
const message = `Provider exposed ${secret} __var_API_KEY __sim_code_1_binding_0`
const rawError = new Error(message)
const stream = await createStreamingResponse({
requestId: 'request-secret-failure',
executionId: 'execution-1',
streamConfig: {},
executeFn: async () => {
throw rawError
},
})
const events = await collectSSEEvents(stream)
expect(events).toContainEqual({ event: 'error', error: message })
expect(events.some((event) => event.event === 'final')).toBe(false)
expect(workflowStreamingLogger.error).toHaveBeenCalledWith(
'[request-secret-failure] Stream error',
{
errorType: 'error',
hasStack: true,
}
)
const loggerPayload = JSON.stringify(workflowStreamingLogger.error.mock.calls)
expect(loggerPayload).not.toContain(secret)
expect(loggerPayload).not.toContain('__var_')
expect(loggerPayload).not.toContain('__sim_')
expect(rawError.message).toBe(message)
})
it('fails closed when a block stream reader rejects while preserving the raw stream frame', async () => {
const secret = 'block-stream-secret-7f3a91'
const message = `reader failed ${secret} __var_API_KEY __sim_code_2_binding_0`
const rawError = new Error(message)
const stream = await createStreamingResponse({
requestId: 'request-block-stream-failure',
executionId: 'execution-1',
streamConfig: {},
executeFn: async ({ onStream }) => {
await onStream({
stream: new ReadableStream({
start(controller) {
controller.error(rawError)
},
}),
execution: {
blockId: 'agent-1',
success: false,
output: {},
logs: [],
metadata: {},
},
} as any)
return {
success: true,
output: {},
logs: [],
metadata: { duration: 1 },
} as any
},
})
const events = await collectSSEEvents(stream)
expect(events).toContainEqual({ event: 'stream_error', blockId: 'agent-1', error: message })
expect(workflowStreamingLogger.error).toHaveBeenCalledWith(
'[request-block-stream-failure] Error reading stream for block agent-1',
{ errorType: 'error', hasStack: true }
)
const loggerPayload = JSON.stringify(workflowStreamingLogger.error.mock.calls)
expect(loggerPayload).not.toContain(secret)
expect(loggerPayload).not.toContain('__var_')
expect(loggerPayload).not.toContain('__sim_')
expect(rawError.message).toBe(message)
})
it('extracts block-level selected outputs from JSON content payloads', async () => {
const output = { content: JSON.stringify({ answer: 'ok' }) }
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
streamConfig: {
selectedOutputs: ['block'],
includeFileBase64: false,
},
executeFn: async ({ onBlockComplete }) => {
await onBlockComplete('block', output)
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
await expect(readSSEStream(stream)).resolves.toBe(JSON.stringify({ answer: 'ok' }, null, 2))
})
it('extracts selected outputs from JSON content payloads', async () => {
const output = { content: JSON.stringify({ answer: 'ok' }) }
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
streamConfig: {
selectedOutputs: ['block_answer'],
includeFileBase64: false,
},
executeFn: async ({ onBlockComplete }) => {
await onBlockComplete('block', output)
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
await expect(readSSEStream(stream)).resolves.toBe('ok')
})
it('auto-materializes whole manifest selected outputs under the inline cap', async () => {
mockDownloadFile.mockResolvedValue(Buffer.from(JSON.stringify(manifestChunk), 'utf8'))
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
streamConfig: {
selectedOutputs: ['block_issues'],
includeFileBase64: false,
},
executeFn: async ({ onBlockComplete }) => {
const output = { issues: manifest }
await onBlockComplete('block', output)
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
await expect(readSSEStream(stream)).resolves.toBe(JSON.stringify(manifestChunk, null, 2))
})
it('auto-materializes whole-block selected outputs containing manifests', async () => {
mockDownloadFile.mockResolvedValue(Buffer.from(JSON.stringify(manifestChunk), 'utf8'))
const output = { issues: manifest }
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
streamConfig: {
selectedOutputs: ['block'],
includeFileBase64: true,
},
executeFn: async ({ onBlockComplete }) => {
await onBlockComplete('block', output)
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
await expect(readSSEStream(stream)).resolves.toBe(
JSON.stringify({ issues: manifestChunk }, null, 2)
)
expect(mockDownloadFile).toHaveBeenCalled()
})
it('inlines materialized selected outputs without recompacting them into refs', async () => {
const largeString = 'x'.repeat(8 * 1024 * 1024 + 1)
const largeStringJson = JSON.stringify(largeString)
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_LARGESTRING1',
kind: 'string',
size: Buffer.byteLength(largeStringJson, 'utf8'),
key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_LARGESTRING1.json',
executionId: 'execution-1',
}
mockDownloadFile.mockResolvedValue(Buffer.from(largeStringJson, 'utf8'))
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
streamConfig: {
selectedOutputs: ['block_text'],
includeFileBase64: false,
},
executeFn: async ({ onBlockComplete }) => {
const output = { text: ref }
await onBlockComplete('block', output)
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
const streamed = await readSSEStream(stream)
expect(streamed).toHaveLength(largeString.length)
expect(streamed).not.toContain('__simLargeValueRef')
})
it('deduplicates repeated equivalent selected outputs before streaming', async () => {
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
streamConfig: {
selectedOutputs: ['block_text', 'block.text', 'block_text'],
includeFileBase64: false,
},
executeFn: async ({ onBlockComplete }) => {
const output = { text: 'ok' }
await onBlockComplete('block', output)
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
const events = await collectSSEEvents(stream)
const chunkEvents = events.filter((event) => 'chunk' in event)
expect(chunkEvents).toHaveLength(1)
expect(chunkEvents[0]).toMatchObject({ blockId: 'block', chunk: 'ok' })
})
it('fails closed when selected-output materialization logs a secret-bearing error', async () => {
const secret = 'selected-output-secret-7f3a91'
const message = `download failed ${secret} __var_API_KEY __sim_code_3_binding_0`
const rawError = new Error(message)
const value = {
toJSON() {
throw rawError
},
}
const stream = await createStreamingResponse({
requestId: 'request-selected-output-failure',
executionId: 'execution-1',
streamConfig: {
selectedOutputs: ['block_value'],
includeFileBase64: false,
},
executeFn: async ({ onBlockComplete }) => {
await onBlockComplete('block', { value })
return {
success: true,
output: {},
logs: [],
metadata: { duration: 1 },
} as any
},
})
const events = await collectSSEEvents(stream)
expect(events).toContainEqual({ event: 'error', blockId: 'block', error: message })
expect(workflowStreamingLogger.warn).toHaveBeenCalledWith(
'[request-selected-output-failure] Failed to materialize selected output',
{
blockId: 'block',
outputId: 'block_value',
errorType: 'error',
hasStack: true,
}
)
const loggerPayload = JSON.stringify(workflowStreamingLogger.warn.mock.calls)
expect(loggerPayload).not.toContain(secret)
expect(loggerPayload).not.toContain('__var_')
expect(loggerPayload).not.toContain('__sim_')
expect(rawError.message).toBe(message)
})
it('fails when distinct selected outputs aggregate over the inline cap', async () => {
const largeString = 'x'.repeat(9 * 1024 * 1024)
const largeStringJson = JSON.stringify(largeString)
const largeStringBytes = Buffer.byteLength(largeStringJson, 'utf8')
const firstRef = {
__simLargeValueRef: true,
version: 1,
id: 'lv_MULTIREF0001',
kind: 'string',
size: largeStringBytes,
key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_MULTIREF0001.json',
executionId: 'execution-1',
}
const secondRef = {
__simLargeValueRef: true,
version: 1,
id: 'lv_MULTIREF0002',
kind: 'string',
size: largeStringBytes,
key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_MULTIREF0002.json',
executionId: 'execution-1',
}
mockDownloadFile.mockImplementation(async ({ key }) => {
if (key === firstRef.key || key === secondRef.key) {
return Buffer.from(largeStringJson, 'utf8')
}
throw new Error(`Unexpected key: ${key}`)
})
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
streamConfig: {
selectedOutputs: ['block_first', 'block_second'],
includeFileBase64: false,
},
executeFn: async ({ onBlockComplete }) => {
const output = { first: firstRef, second: secondRef }
await onBlockComplete('block', output)
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
const events = await collectSSEEvents(stream)
expect(events).toContainEqual({
event: 'error',
blockId: 'block',
error:
'Selected output is too large to inline; select a nested field or use pagination/preview.',
})
expect(events.some((event) => event.event === 'final')).toBe(false)
})
it('accounts escaped string JSON bytes against the aggregate selected-output cap', async () => {
const first = '\\'.repeat(Math.floor((16 * 1024 * 1024 - 2) / 2))
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
streamConfig: {
selectedOutputs: ['block_first', 'block_second'],
includeFileBase64: false,
},
executeFn: async ({ onBlockComplete }) => {
const output = { first, second: 'ok' }
await onBlockComplete('block', output)
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
const events = await collectSSEEvents(stream)
expect(events).toContainEqual({
event: 'error',
blockId: 'block',
error:
'Selected output is too large to inline; select a nested field or use pagination/preview.',
})
expect(events.some((event) => event.event === 'final')).toBe(false)
})
it('fails when nested refs aggregate over the inline selected-output cap', async () => {
const largeString = 'x'.repeat(9 * 1024 * 1024)
const largeStringJson = JSON.stringify(largeString)
const largeStringBytes = Buffer.byteLength(largeStringJson, 'utf8')
const nestedRefA = {
__simLargeValueRef: true,
version: 1,
id: 'lv_NESTEDREF001',
kind: 'string',
size: largeStringBytes,
key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_NESTEDREF001.json',
executionId: 'execution-1',
}
const nestedRefB = {
__simLargeValueRef: true,
version: 1,
id: 'lv_NESTEDREF002',
kind: 'string',
size: largeStringBytes,
key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_NESTEDREF002.json',
executionId: 'execution-1',
}
const nestedChunk = [nestedRefA, nestedRefB]
const nestedChunkBytes = Buffer.byteLength(JSON.stringify(nestedChunk), 'utf8')
const nestedManifest = {
...manifest,
totalCount: 2,
byteSize: nestedChunkBytes,
chunks: [
{
ref: {
...manifest.chunks[0].ref,
size: nestedChunkBytes,
},
count: 2,
byteSize: nestedChunkBytes,
},
],
preview: [],
}
mockDownloadFile.mockImplementation(async ({ key }) => {
if (key === nestedManifest.chunks[0].ref.key) {
return Buffer.from(JSON.stringify(nestedChunk), 'utf8')
}
if (key === nestedRefA.key) {
return Buffer.from(largeStringJson, 'utf8')
}
if (key === nestedRefB.key) {
return Buffer.from(largeStringJson, 'utf8')
}
throw new Error(`Unexpected key: ${key}`)
})
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
streamConfig: {
selectedOutputs: ['block_issues'],
includeFileBase64: false,
},
executeFn: async ({ onBlockComplete }) => {
const output = { issues: nestedManifest }
await onBlockComplete('block', output)
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
const events = await collectSSEEvents(stream)
expect(events).toContainEqual({
event: 'error',
blockId: 'block',
error:
'Selected output is too large to inline; select a nested field or use pagination/preview.',
})
expect(events.some((event) => event.event === 'final')).toBe(false)
expect(JSON.stringify(events)).not.toContain('__simLargeValueRef')
})
it('fails clearly instead of streaming raw manifest internals when selected output is over cap', async () => {
const oversizedManifest = {
...manifest,
byteSize: 16 * 1024 * 1024 + 1,
chunks: [
{
...manifest.chunks[0],
ref: {
...manifest.chunks[0].ref,
size: 16 * 1024 * 1024 + 1,
},
byteSize: 16 * 1024 * 1024 + 1,
},
],
}
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
streamConfig: {
selectedOutputs: ['block_issues'],
includeFileBase64: false,
},
executeFn: async ({ onBlockComplete }) => {
const output = { issues: oversizedManifest }
await onBlockComplete('block', output)
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
const events = await collectSSEEvents(stream)
expect(events).toContainEqual({
event: 'error',
blockId: 'block',
error:
'Selected output is too large to inline; select a nested field or use pagination/preview.',
})
expect(events.some((event) => event.event === 'final')).toBe(false)
expect(JSON.stringify(events)).not.toContain('__simLargeArrayManifest')
expect(mockDownloadFile).not.toHaveBeenCalled()
})
it('uses live large-value keys for selected-output materialization', async () => {
const largeValueKeys: string[] = []
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_MNOPQRSTUVWX',
kind: 'object',
size: 15,
key: 'execution/workspace-1/workflow-1/source-execution/large-value-lv_MNOPQRSTUVWX.json',
executionId: 'source-execution',
}
mockDownloadFile.mockResolvedValue(Buffer.from(JSON.stringify({ nested: 'ok' }), 'utf8'))
const stream = await createStreamingResponse({
requestId: 'request-1',
executionId: 'execution-1',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
largeValueKeys,
streamConfig: {
selectedOutputs: ['block.value.nested'],
},
executeFn: async ({ onBlockComplete }) => {
largeValueKeys.push(ref.key)
await onBlockComplete('block', { value: ref })
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output: { value: ref },
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
await expect(readSSEStream(stream)).resolves.toBe('ok')
})
})
describe('final envelope tool payloads', () => {
const agentOutput = {
content: 'Done',
toolCalls: {
count: 1,
list: [
{
name: 'get_weather',
duration: 12,
arguments: { city: 'private' },
result: { temperature: 72 },
},
],
},
}
function executeFnReturning(output: Record<string, unknown>) {
return async () =>
({
success: true,
output,
logs: [
{
blockId: 'agent-1',
output,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
}) as any
}
it('redacts tool arguments and results for public chat', async () => {
// No outputConfigs means the whole block output rides the envelope, which
// must not become a side channel around the tool-frame gate.
const stream = await createStreamingResponse({
requestId: 'request-1',
streamConfig: { isSecureMode: true, selectedOutputs: [] },
executeFn: executeFnReturning(agentOutput),
})
const events = await collectSSEEvents(stream)
const final = events.find((event) => event.event === 'final')
const toolCall = (final?.data as any).output.toolCalls.list[0]
expect(toolCall).toEqual({ name: 'get_weather', duration: 12 })
expect(JSON.stringify(final)).not.toContain('private')
expect(JSON.stringify(final)).not.toContain('72')
})
/**
* A deployment almost always selects outputs, so redaction that only covered
* the empty-selection branch would be dead in the case it exists for.
*/
it('redacts tool payloads when the deployment selects toolCalls directly', async () => {
const stream = await createStreamingResponse({
requestId: 'request-1',
streamConfig: { isSecureMode: true, selectedOutputs: ['block_toolCalls'] },
executeFn: async ({ onBlockComplete }) => {
const toolOnlyOutput = { toolCalls: agentOutput.toolCalls }
await onBlockComplete('block', toolOnlyOutput)
return {
success: true,
output: {},
logs: [
{
blockId: 'block',
output: toolOnlyOutput,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
// The payload rides the chunk frame, not `final`, so assert on the whole
// stream — sanitizing only the envelope would still leak here.
const events = await collectSSEEvents(stream)
const serialized = JSON.stringify(events)
expect(serialized).not.toContain('private')
expect(serialized).not.toContain('72')
expect(serialized).toContain('get_weather')
})
it('keeps tool results for the authenticated workflow API', async () => {
const stream = await createStreamingResponse({
requestId: 'request-1',
streamConfig: { isSecureMode: false, selectedOutputs: [] },
executeFn: executeFnReturning(agentOutput),
})
const events = await collectSSEEvents(stream)
const final = events.find((event) => event.event === 'final')
const toolCall = (final?.data as any).output.toolCalls.list[0]
expect(toolCall.arguments).toEqual({ city: 'private' })
expect(toolCall.result).toEqual({ temperature: 72 })
})
})
describe('agent stream protocol response headers', () => {
const requestHeaders = new Headers({
'x-sim-stream-protocol': 'agent-events-v1',
})
it('echoes the protocol whenever the client negotiated it', () => {
// v1 framing (live text + chunk_reset) is in effect on client capability
// alone, so the echo must not depend on the event policies.
expect(agentStreamProtocolResponseHeaders({ requestHeaders })).toEqual({
'x-sim-stream-protocol': 'agent-events-v1',
})
})
it('stays inactive for legacy clients and when no headers are supplied', () => {
expect(agentStreamProtocolResponseHeaders({ requestHeaders: new Headers() })).toEqual({})
expect(agentStreamProtocolResponseHeaders({})).toEqual({})
})
})
describe('createStreamingResponse agent-events-v1', () => {
beforeEach(() => {
vi.clearAllMocks()
clearLargeValueCacheForTests()
})
function createAgentStreamExecuteFn(options: {
thinking?: string[]
answer: string
fail?: boolean
tools?: Array<
| { type: 'tool_call_start'; id: string; name: string; args?: unknown }
| {
type: 'tool_call_end'
id: string
name: string
status: string
result?: unknown
}
>
}) {
return async ({
onStream,
abortSignal,
}: {
onStream: (streamingExec: any) => Promise<void>
onBlockComplete: (blockId: string, output: unknown) => Promise<void>
abortSignal: AbortSignal
}) => {
let textController!: ReadableStreamDefaultController<Uint8Array>
let sink: { onEvent: (event: unknown) => void | Promise<void> } | undefined
const textStream = new ReadableStream<Uint8Array>({
start(controller) {
textController = controller
},
})
const onStreamPromise = onStream({
stream: textStream,
streamFormat: 'text',
subscribe: (nextSink: { onEvent: (event: unknown) => void | Promise<void> }) => {
sink = nextSink
return () => {
sink = undefined
}
},
execution: {
blockId: 'agent-1',
success: true,
output: { content: options.answer },
logs: [],
metadata: {},
},
})
if (options.fail) {
textController.error(new Error('provider reset'))
await onStreamPromise.catch(() => {})
throw new Error('provider reset')
}
for (const text of options.thinking ?? []) {
await sink?.onEvent({ type: 'thinking_delta', text })
}
for (const toolEvent of options.tools ?? []) {
await sink?.onEvent(toolEvent)
}
// Mirror the pump: text dispatches to the sink first, then projects to bytes.
await sink?.onEvent({ type: 'text_delta', text: options.answer, turn: 'final' })
textController.enqueue(new TextEncoder().encode(options.answer))
textController.close()
await onStreamPromise
expect(abortSignal).toBeDefined()
return {
success: true,
output: { content: options.answer },
logs: [
{
blockId: 'agent-1',
output: { content: '' },
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
}
}
async function collectSSEPayloads(stream: ReadableStream<Uint8Array>): Promise<string[]> {
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) {
buffer += decoder.decode()
break
}
buffer += decoder.decode(value, { stream: true })
}
return buffer
.split('\n\n')
.map((chunk) => chunk.trim())
.filter((chunk) => chunk.startsWith('data: '))
.map((chunk) => chunk.slice(6))
}
/**
* A client that never declared a protocol version has no contract for frame
* shapes, so policy alone must not expose them.
*/
it('stays text-only without the protocol header even with both policies on', async () => {
const stream = await createStreamingResponse({
requestId: 'request-1',
streamConfig: {
includeThinking: true,
includeToolCalls: true,
selectedOutputs: ['agent-1_content'],
},
executeFn: createAgentStreamExecuteFn({
thinking: ['a thought'],
answer: 'Hello',
tools: [{ type: 'tool_call_start', id: 'toolu_1', name: 'get_weather' }],
}),
})
const events = await collectSSEEvents(stream)
expect(events.some((event) => event.event === 'thinking')).toBe(false)
expect(events.some((event) => event.event === 'tool')).toBe(false)
expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Hello' })
expect(events.some((event) => event.event === 'final')).toBe(true)
})
it('stays fully text-only when both policies are off', async () => {
const stream = await createStreamingResponse({
requestId: 'request-1',
streamConfig: {
includeThinking: false,
includeToolCalls: false,
selectedOutputs: ['agent-1_content'],
},
executeFn: createAgentStreamExecuteFn({
thinking: ['secret thought'],
answer: 'Hello',
tools: [{ type: 'tool_call_start', id: 'toolu_1', name: 'get_weather' }],
}),
})
const events = await collectSSEEvents(stream)
expect(events.some((event) => event.event === 'thinking')).toBe(false)
expect(events.some((event) => event.event === 'tool')).toBe(false)
expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Hello' })
})
it('header + includeThinking emits thinking on data and answer on chunk', async () => {
const headers = new Headers({
'x-sim-stream-protocol': 'agent-events-v1',
})
const stream = await createStreamingResponse({
requestId: 'request-1',
requestHeaders: headers,
streamConfig: {
includeThinking: true,
includeToolCalls: false,
selectedOutputs: ['agent-1_content'],
},
executeFn: createAgentStreamExecuteFn({
thinking: ['hmm ', 'yes'],
answer: 'Answer',
}),
})
const events = await collectSSEEvents(stream)
expect(events.filter((event) => event.event === 'thinking')).toEqual([
{ blockId: 'agent-1', event: 'thinking', data: 'hmm ' },
{ blockId: 'agent-1', event: 'thinking', data: 'yes' },
])
expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Answer' })
expect(events.some((event) => event.event === 'final')).toBe(true)
})
it('includeToolCalls emits tool start/end frames without exposing args or results', async () => {
const headers = new Headers({
'x-sim-stream-protocol': 'agent-events-v1',
})
const stream = await createStreamingResponse({
requestId: 'request-1',
requestHeaders: headers,
streamConfig: {
includeThinking: false,
includeToolCalls: true,
selectedOutputs: ['agent-1_content'],
},
executeFn: createAgentStreamExecuteFn({
answer: 'Done',
tools: [
{
type: 'tool_call_start',
id: 'toolu_1',
name: 'get_weather',
args: { city: 'private' },
},
{
type: 'tool_call_end',
id: 'toolu_1',
name: 'get_weather',
status: 'success',
result: { temperature: 72 },
},
],
}),
})
const events = await collectSSEEvents(stream)
expect(events.filter((event) => event.event === 'tool')).toEqual([
{
blockId: 'agent-1',
event: 'tool',
phase: 'start',
id: 'toolu_1',
name: 'get_weather',
},
{
blockId: 'agent-1',
event: 'tool',
phase: 'end',
id: 'toolu_1',
name: 'get_weather',
status: 'success',
},
])
expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Done' })
expect(
events.some(
(event) =>
typeof event.chunk === 'string' &&
(String(event.chunk).includes('toolu_1') || String(event.chunk).includes('get_weather'))
)
).toBe(false)
})
it('tool-only policy streams pending text live and resets intermediate turns', async () => {
const headers = new Headers({
'x-sim-stream-protocol': 'agent-events-v1',
})
const stream = await createStreamingResponse({
requestId: 'request-1',
requestHeaders: headers,
streamConfig: {
includeThinking: false,
includeToolCalls: true,
selectedOutputs: ['agent-1_content'],
},
executeFn: async ({ onStream }) => {
let textController!: ReadableStreamDefaultController<Uint8Array>
let sink: { onEvent: (event: unknown) => void | Promise<void> } | undefined
const textStream = new ReadableStream<Uint8Array>({
start(controller) {
textController = controller
},
})
const onStreamPromise = onStream({
stream: textStream,
streamFormat: 'text',
subscribe: (nextSink: { onEvent: (event: unknown) => void | Promise<void> }) => {
sink = nextSink
return () => {}
},
execution: {
blockId: 'agent-1',
success: true,
output: { content: 'Final answer' },
logs: [],
metadata: {},
},
} as any)
// Turn 1: live preamble, then tools follow → intermediate turn_end.
await sink?.onEvent({ type: 'text_delta', text: 'Checking…', turn: 'pending' })
await sink?.onEvent({ type: 'tool_call_start', id: 'toolu_1', name: 'get_weather' })
await sink?.onEvent({ type: 'turn_end', turn: 'intermediate' })
await sink?.onEvent({
type: 'tool_call_end',
id: 'toolu_1',
name: 'get_weather',
status: 'success',
})
// Turn 2: live final answer; pump projects it to bytes at turn_end.
await sink?.onEvent({ type: 'text_delta', text: 'Final ', turn: 'pending' })
await sink?.onEvent({ type: 'text_delta', text: 'answer', turn: 'pending' })
await sink?.onEvent({ type: 'turn_end', turn: 'final' })
textController.enqueue(new TextEncoder().encode('Final answer'))
textController.close()
await onStreamPromise
return {
success: true,
output: { content: 'Final answer' },
logs: [
{
blockId: 'agent-1',
output: { content: '' },
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
const events = await collectSSEEvents(stream)
// Live text arrives as chunk frames in stream order, with a reset between turns.
const answerFlow = events
.filter((event) => event.chunk !== undefined || event.event === 'chunk_reset')
.map((event) => (event.event === 'chunk_reset' ? 'RESET' : event.chunk))
expect(answerFlow).toEqual(['Checking…', 'RESET', 'Final ', 'answer'])
// The byte-path flush of the same final text must not duplicate chunk frames.
expect(events.filter((event) => event.chunk !== undefined).map((event) => event.chunk)).toEqual(
['Checking…', 'Final ', 'answer']
)
})
it('streams answer text live for a negotiated client with both policies off', async () => {
// Answer cadence follows client capability, not event policy: a chat with
// thinking and tools both disabled must still stream token by token, the
// way it did before streaming tool loops existed.
const headers = new Headers({
'x-sim-stream-protocol': 'agent-events-v1',
})
const stream = await createStreamingResponse({
requestId: 'request-1',
requestHeaders: headers,
streamConfig: {
includeThinking: false,
includeToolCalls: false,
selectedOutputs: ['agent-1_content'],
},
executeFn: async ({ onStream }) => {
let textController!: ReadableStreamDefaultController<Uint8Array>
let sink: { onEvent: (event: unknown) => void | Promise<void> } | undefined
const textStream = new ReadableStream<Uint8Array>({
start(controller) {
textController = controller
},
})
const onStreamPromise = onStream({
stream: textStream,
streamFormat: 'text',
subscribe: (nextSink: { onEvent: (event: unknown) => void | Promise<void> }) => {
sink = nextSink
return () => {}
},
execution: {
blockId: 'agent-1',
success: true,
output: { content: 'Final answer' },
logs: [],
metadata: {},
},
} as any)
await sink?.onEvent({ type: 'thinking_delta', text: 'secret reasoning' })
await sink?.onEvent({ type: 'tool_call_start', id: 'toolu_1', name: 'get_weather' })
await sink?.onEvent({ type: 'turn_end', turn: 'intermediate' })
await sink?.onEvent({
type: 'tool_call_end',
id: 'toolu_1',
name: 'get_weather',
status: 'success',
})
await sink?.onEvent({ type: 'text_delta', text: 'Final ', turn: 'pending' })
await sink?.onEvent({ type: 'text_delta', text: 'answer', turn: 'pending' })
await sink?.onEvent({ type: 'turn_end', turn: 'final' })
textController.enqueue(new TextEncoder().encode('Final answer'))
textController.close()
await onStreamPromise
return {
success: true,
output: { content: 'Final answer' },
logs: [
{
blockId: 'agent-1',
output: { content: '' },
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
const events = await collectSSEEvents(stream)
expect(events.filter((event) => event.chunk !== undefined).map((event) => event.chunk)).toEqual(
['Final ', 'answer']
)
// Capability alone must not expose either gated event type.
expect(events.some((event) => event.event === 'thinking')).toBe(false)
expect(events.some((event) => event.event === 'tool')).toBe(false)
})
it('dual gate keeps byte-path chunks for response-format transformed streams', async () => {
const headers = new Headers({
'x-sim-stream-protocol': 'agent-events-v1',
})
const stream = await createStreamingResponse({
requestId: 'request-1',
requestHeaders: headers,
streamConfig: {
includeThinking: true,
includeToolCalls: false,
selectedOutputs: ['agent-1_content'],
},
executeFn: async ({ onStream }) => {
let textController!: ReadableStreamDefaultController<Uint8Array>
let sink: { onEvent: (event: unknown) => void | Promise<void> } | undefined
const textStream = new ReadableStream<Uint8Array>({
start(controller) {
textController = controller
},
})
const onStreamPromise = onStream({
stream: textStream,
streamFormat: 'text',
subscribe: (nextSink: { onEvent: (event: unknown) => void | Promise<void> }) => {
sink = nextSink
return () => {}
},
clientStreamTransformed: true,
execution: {
blockId: 'agent-1',
success: true,
output: { content: '{"answer":"extracted"}' },
logs: [],
metadata: {},
},
} as any)
// Sink text must NOT become chunk frames — bytes are a different projection.
await sink?.onEvent({ type: 'text_delta', text: '{"answer":"', turn: 'pending' })
await sink?.onEvent({ type: 'text_delta', text: 'extracted"}', turn: 'pending' })
await sink?.onEvent({ type: 'turn_end', turn: 'final' })
textController.enqueue(new TextEncoder().encode('extracted'))
textController.close()
await onStreamPromise
return {
success: true,
output: { content: '{"answer":"extracted"}' },
logs: [
{
blockId: 'agent-1',
output: { content: '' },
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
const events = await collectSSEEvents(stream)
expect(events.filter((event) => event.chunk !== undefined).map((event) => event.chunk)).toEqual(
['extracted']
)
expect(events.some((event) => event.event === 'chunk_reset')).toBe(false)
})
it('includeThinking without includeToolCalls does not emit tool frames', async () => {
const headers = new Headers({
'x-sim-stream-protocol': 'agent-events-v1',
})
const stream = await createStreamingResponse({
requestId: 'request-1',
requestHeaders: headers,
streamConfig: {
includeThinking: true,
includeToolCalls: false,
selectedOutputs: ['agent-1_content'],
},
executeFn: createAgentStreamExecuteFn({
answer: 'Answer',
tools: [{ type: 'tool_call_start', id: 'toolu_1', name: 'get_weather' }],
}),
})
const events = await collectSSEEvents(stream)
expect(events.some((event) => event.event === 'tool')).toBe(false)
expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Answer' })
})
it('includeToolCalls without includeThinking does not emit thinking', async () => {
const headers = new Headers({
'x-sim-stream-protocol': 'agent-events-v1',
})
const stream = await createStreamingResponse({
requestId: 'request-1',
requestHeaders: headers,
streamConfig: {
includeThinking: false,
includeToolCalls: true,
selectedOutputs: ['agent-1_content'],
},
executeFn: createAgentStreamExecuteFn({
thinking: ['should not appear'],
answer: 'Answer',
}),
})
const events = await collectSSEEvents(stream)
expect(events.some((event) => event.event === 'thinking')).toBe(false)
expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Answer' })
})
it('provider failure emits one terminal error, no final, then [DONE]', async () => {
const stream = await createStreamingResponse({
requestId: 'request-1',
streamConfig: {},
executeFn: createAgentStreamExecuteFn({
answer: 'partial',
fail: true,
}),
})
const payloads = await collectSSEPayloads(stream)
const events = payloads
.filter((payload) => payload !== '[DONE]' && payload !== '"[DONE]"')
.map((payload) => JSON.parse(payload) as Record<string, unknown>)
expect(events.filter((event) => event.event === 'error')).toHaveLength(1)
expect(events.some((event) => event.event === 'final')).toBe(false)
expect(payloads.some((payload) => payload === '[DONE]' || payload === '"[DONE]"')).toBe(true)
})
it('requestSignal abort propagates to executeFn abortSignal', async () => {
const requestAbort = new AbortController()
let sawAbort = false
const stream = await createStreamingResponse({
requestId: 'request-1',
requestSignal: requestAbort.signal,
streamConfig: {},
executeFn: async ({ abortSignal }) => {
requestAbort.abort()
sawAbort = abortSignal.aborted
return {
success: false,
status: 'cancelled',
output: {},
logs: [],
} as any
},
})
const events = await collectSSEEvents(stream)
expect(sawAbort).toBe(true)
expect(events.some((event) => event.event === 'final')).toBe(false)
expect(events).toContainEqual({ event: 'error', error: 'Client cancelled request' })
})
it('thinking never enters streamedChunks / log content rewrite', async () => {
const headers = new Headers({
'x-sim-stream-protocol': 'agent-events-v1',
})
let rewrittenContent: string | undefined
const stream = await createStreamingResponse({
requestId: 'request-1',
requestHeaders: headers,
streamConfig: {
includeThinking: true,
includeToolCalls: false,
selectedOutputs: ['agent-1_content'],
},
executeFn: async ({ onStream }) => {
let textController!: ReadableStreamDefaultController<Uint8Array>
let sink: { onEvent: (event: unknown) => void | Promise<void> } | undefined
const textStream = new ReadableStream<Uint8Array>({
start(controller) {
textController = controller
},
})
const onStreamPromise = onStream({
stream: textStream,
streamFormat: 'text',
subscribe: (nextSink: any) => {
sink = nextSink
return () => {
sink = undefined
}
},
execution: {
blockId: 'agent-1',
success: true,
output: { content: 'visible' },
logs: [],
metadata: {},
},
} as any)
await sink?.onEvent({ type: 'thinking_delta', text: 'PRIVATE_THINKING' })
textController.enqueue(new TextEncoder().encode('visible'))
textController.close()
await onStreamPromise
return {
success: true,
output: {},
logs: [
{
blockId: 'agent-1',
output: { content: '' },
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 1,
success: true,
},
],
} as any
},
})
const events = await collectSSEEvents(stream)
const answerChunks = events.filter((event) => typeof event.chunk === 'string')
expect(answerChunks.every((event) => !String(event.chunk).includes('PRIVATE_THINKING'))).toBe(
true
)
expect(events).toContainEqual({
blockId: 'agent-1',
event: 'thinking',
data: 'PRIVATE_THINKING',
})
// Force consumption of stream so log rewrite runs
expect(events.some((event) => event.event === 'final')).toBe(true)
void rewrittenContent
})
})