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

1646 lines
56 KiB
TypeScript

/**
* @vitest-environment node
*/
import { loggerMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
import { projectTraceSpansForSecrets } from '@/lib/logs/execution/trace-secret-projection'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import { BlockType, EDGE } from '@/executor/constants'
import type { DAGNode } from '@/executor/dag/builder'
import { BlockExecutor } from '@/executor/execution/block-executor'
import { ExecutionState } from '@/executor/execution/state'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import { VariableResolver } from '@/executor/variables/resolver'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
const blockExecutorLoggerCallIndex = loggerMock.createLogger.mock.calls.findIndex(
([name]) => name === 'BlockExecutor'
)
const blockExecutorBaseLogger =
loggerMock.createLogger.mock.results[blockExecutorLoggerCallIndex]?.value
if (!blockExecutorBaseLogger) throw new Error('BlockExecutor logger mock was not initialized')
const { mockUploadFile } = vi.hoisted(() => ({
mockUploadFile: vi.fn(),
}))
vi.mock('@/ee/access-control/utils/permission-check', () => ({
validateBlockType: vi.fn(),
}))
vi.mock('@/lib/uploads', () => ({
StorageService: {
uploadFile: mockUploadFile,
},
}))
vi.mock('@/lib/logs/execution/pii-redaction', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/logs/execution/pii-redaction')>()
return {
...actual,
redactObjectStrings: vi.fn(actual.redactObjectStrings),
}
})
function createBlock(): SerializedBlock {
return {
id: 'function-block-1',
metadata: { id: BlockType.FUNCTION, name: 'Function' },
position: { x: 0, y: 0 },
config: { tool: BlockType.FUNCTION, params: {} },
inputs: {},
outputs: {},
enabled: true,
}
}
function createContext(state: ExecutionState): ExecutionContext {
return {
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
blockStates: state.getBlockStates(),
blockLogs: [],
metadata: { requestId: 'request-1', duration: 0 },
environmentVariables: {},
workflowVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
} as ExecutionContext
}
function createNode(block: SerializedBlock): DAGNode {
return {
id: block.id,
block,
incomingEdges: new Set(),
outgoingEdges: new Map(),
metadata: {},
}
}
describe('BlockExecutor', () => {
beforeEach(() => {
vi.clearAllMocks()
clearLargeValueCacheForTests()
mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey }))
})
it('persists function output arrays as manifests in execution state', async () => {
const block = createBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const output = {
result: Array.from({ length: 120_000 }, (_, index) => ({
key: `SIM-${index}`,
payload: 'x'.repeat(100),
})),
}
const handler: BlockHandler = {
canHandle: () => true,
execute: async () => output,
}
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
},
state
)
await executor.execute(createContext(state), createNode(block), block)
const storedOutput = state.getBlockOutput(block.id)
expect(isLargeArrayManifest(storedOutput?.result)).toBe(true)
expect(storedOutput?.result).toMatchObject({
__simLargeArrayManifest: true,
kind: 'array',
totalCount: output.result.length,
})
})
it('carries complete encrypted candidates through large-output compaction', async () => {
const block = createBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const onBlockComplete = vi.fn(async () => {})
const registry = new ResolvedSecretTraceRegistry(
[{ name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'encrypted-secret' }],
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
const handler: BlockHandler = {
canHandle: () => true,
execute: async (blockContext) => {
blockContext.resolvedSecretTraceRegistry?.recordResolved('API_KEY', 'secret-value')
return {
result: {
huge: 'p'.repeat(9 * 1024 * 1024),
public: 'ok',
secret: 'secret-value',
},
}
},
}
const executor = new BlockExecutor([handler], resolver, { onBlockComplete }, state)
const ctx = createContext(state)
ctx.resolvedSecretTraceRegistry = registry
await executor.execute(ctx, createNode(block), block)
await vi.waitFor(() => expect(onBlockComplete).toHaveBeenCalledOnce())
const storedOutput = state.getBlockOutput(block.id)
const storedResult = storedOutput?.result as Record<string, unknown>
expect(isLargeValueRef(storedResult.huge)).toBe(true)
expect(storedResult.public).toBe('ok')
expect(storedResult.secret).toBe('secret-value')
const expectedProvenance = {
version: 1,
complete: true,
entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }],
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
}
expect(state.getBlockState(block.id)?.resolvedSecretTraceProvenance).toEqual(expectedProvenance)
expect(onBlockComplete.mock.calls[0]?.[3]?.resolvedSecretTraceProvenance).toEqual(
expectedProvenance
)
expect(onBlockComplete.mock.calls[0]?.[3]?.displayResolvedSecretTraceProvenance).toEqual(
expectedProvenance
)
expect(JSON.stringify(expectedProvenance)).not.toContain('secret-value')
})
it('persists stable outer-branch aliases for completed parallel branch outputs', async () => {
const block = createBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const output = { result: 'branch-2' }
const handler: BlockHandler = {
canHandle: () => true,
execute: async () => output,
}
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
},
state
)
const node = createNode(block)
node.id = 'function-block-1₍0₎'
node.metadata = {
isParallelBranch: true,
subflowId: 'parallel-1',
subflowType: 'parallel',
originalBlockId: block.id,
branchIndex: 2,
}
await executor.execute(createContext(state), node, block)
expect(state.getBlockOutput('function-block-1__obranch-2')).toEqual(output)
expect(state.getBlockOutput('function-block-1₍2₎')).toEqual(output)
expect(state.getBlockOutput('function-block-1₍0₎')).toEqual(output)
})
it('does not write global aliases for parallel branches inside cloned outer branches', async () => {
const block = createBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const output = { result: 'outer-2-inner-0' }
const handler: BlockHandler = {
canHandle: () => true,
execute: async () => output,
}
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
},
state
)
const node = createNode(block)
node.id = 'function-block-1__cloneabc__obranch-2₍0₎'
node.metadata = {
isParallelBranch: true,
subflowId: 'inner-parallel',
subflowType: 'parallel',
originalBlockId: block.id,
branchIndex: 0,
}
await executor.execute(createContext(state), node, block)
expect(state.getBlockOutput(node.id)).toEqual(output)
expect(state.getBlockOutput('function-block-1__obranch-0')).toBeUndefined()
expect(state.getBlockOutput('function-block-1₍0₎')).toBeUndefined()
})
it('does not let block completion callbacks overtake pending start callbacks', async () => {
const block = createBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const output = { result: 'done' }
const execute = vi.fn(async () => {
events.push('execute')
return output
})
const handler: BlockHandler = {
canHandle: () => true,
execute,
}
const events: string[] = []
let resolveStart!: () => void
const startGate = new Promise<void>((resolve) => {
resolveStart = resolve
})
const onBlockStart = vi.fn(async () => {
events.push('start-called')
await startGate
events.push('start-done')
})
const onBlockComplete = vi.fn(async () => {
events.push('complete')
})
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
onBlockStart,
onBlockComplete,
},
state
)
const execution = executor.execute(createContext(state), createNode(block), block)
expect(onBlockStart).toHaveBeenCalled()
expect(execute).not.toHaveBeenCalled()
expect(onBlockComplete).not.toHaveBeenCalled()
resolveStart()
await execution
await vi.waitFor(() => {
expect(onBlockComplete).toHaveBeenCalled()
})
expect(events).toEqual(['start-called', 'start-done', 'execute', 'complete'])
})
it('projects lifecycle callback diagnostics without changing block execution', async () => {
const secret = 'lifecycle-secret-7f3a91'
const startError = new Error('start failed __var_API_KEY')
const completionError = new Error(`completion failed ${secret} __sim_code_6_binding_0`)
const block = createBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const output = { result: `raw ${secret}` }
const executor = new BlockExecutor(
[
{
canHandle: () => true,
execute: async (blockContext) => {
blockContext.resolvedSecretTraceRegistry?.recordResolved('API_KEY', secret)
return output
},
},
],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
onBlockStart: async () => {
throw startError
},
onBlockComplete: async () => {
throw completionError
},
},
state
)
const ctx = createContext(state)
ctx.resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry([
{ name: 'API_KEY', plaintext: secret, encryptedValue: 'encrypted-api-key' },
])
await expect(executor.execute(ctx, createNode(block), block)).resolves.toEqual(output)
expect(state.getBlockOutput(block.id)).toEqual(output)
const executionLogger = blockExecutorBaseLogger.withMetadata.mock.results.at(-1)?.value
expect(executionLogger).toBeDefined()
await vi.waitFor(() => {
expect(executionLogger?.warn).toHaveBeenCalledWith(
'Block completion callback failed',
expect.objectContaining({
blockId: block.id,
blockType: BlockType.FUNCTION,
error: 'completion failed {{API_KEY}} [RUNTIME_BINDING]',
})
)
})
expect(executionLogger?.warn).toHaveBeenCalledWith(
'Block start callback failed',
expect.objectContaining({
blockId: block.id,
blockType: BlockType.FUNCTION,
error: 'start failed [REDACTED_SECRET]',
})
)
const loggerPayload = JSON.stringify(executionLogger?.warn.mock.calls)
expect(loggerPayload).toContain('{{API_KEY}}')
expect(loggerPayload).not.toContain(secret)
expect(loggerPayload).not.toContain('__var_')
expect(loggerPayload).not.toContain('__sim_')
expect(startError.message).toContain('__var_API_KEY')
expect(completionError.message).toContain(secret)
})
it('attaches encrypted provenance filtered to the exact lifecycle output', async () => {
const block = createBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const onBlockComplete = vi.fn(async () => {})
const registry = new ResolvedSecretTraceRegistry(
[{ name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'encrypted-secret' }],
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
const executor = new BlockExecutor(
[
{
canHandle: () => true,
execute: async (blockContext) => {
blockContext.resolvedSecretTraceRegistry?.recordResolved('API_KEY', 'secret-value')
return { result: 'secret-value', public: 'ok' }
},
},
],
resolver,
{ onBlockComplete },
state
)
const ctx = createContext(state)
ctx.resolvedSecretTraceRegistry = registry
await executor.execute(ctx, createNode(block), block)
await vi.waitFor(() => expect(onBlockComplete).toHaveBeenCalledOnce())
expect(onBlockComplete.mock.calls[0]?.[3]?.resolvedSecretTraceProvenance).toEqual({
version: 1,
complete: true,
entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }],
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
})
})
it('does not attribute a sibling public value to another block secret', async () => {
const secretBlock = createBlock()
secretBlock.id = 'secret-block'
const publicBlock = createBlock()
publicBlock.id = 'public-block'
const workflow: SerializedWorkflow = {
version: '1',
blocks: [secretBlock, publicBlock],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const onBlockComplete = vi.fn(async () => {})
const registry = new ResolvedSecretTraceRegistry([
{ name: 'SHORT_SECRET', plaintext: 'Test', encryptedValue: 'encrypted-test' },
])
const handler: BlockHandler = {
canHandle: () => true,
execute: async (blockContext, block) => {
if (block.id === secretBlock.id) {
blockContext.resolvedSecretTraceRegistry?.recordResolved('SHORT_SECRET', 'Test')
}
return { result: 'Test' }
},
}
const executor = new BlockExecutor([handler], resolver, { onBlockComplete }, state)
const ctx = createContext(state)
ctx.resolvedSecretTraceRegistry = registry
await executor.execute(ctx, createNode(secretBlock), secretBlock)
await executor.execute(ctx, createNode(publicBlock), publicBlock)
await vi.waitFor(() => expect(onBlockComplete).toHaveBeenCalledTimes(2))
expect(state.getBlockState(secretBlock.id)?.resolvedSecretTraceProvenance?.entries).toEqual([
{ name: 'SHORT_SECRET', encryptedValue: 'encrypted-test' },
])
expect(state.getBlockState(publicBlock.id)?.resolvedSecretTraceProvenance?.entries).toEqual([])
expect(onBlockComplete.mock.calls[1]?.[3]?.resolvedSecretTraceProvenance?.entries).toEqual([])
})
it('uses a handler-narrowed registry for output provenance and parent commit', async () => {
const block: SerializedBlock = {
...createBlock(),
metadata: { id: BlockType.MOTHERSHIP, name: 'Sim Chat' },
config: { tool: BlockType.MOTHERSHIP, params: { selector: 'x' } },
}
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const onBlockComplete = vi.fn(async () => {})
const registry = new ResolvedSecretTraceRegistry([
{ name: 'PRIVATE_SELECTOR', plaintext: 'x', encryptedValue: 'encrypted-selector' },
])
const handler: BlockHandler = {
canHandle: () => true,
execute: async (blockContext, _block, inputs) => {
const callRegistry = blockContext.resolvedSecretTraceRegistry!
callRegistry.recordResolvedAtInputPath('PRIVATE_SELECTOR', 'x', ['selector'])
callRegistry.recordResolvedInputProjection(['selector'], 'x', '{{PRIVATE_SELECTOR}}')
inputs.selector = '{{PRIVATE_SELECTOR}}'
blockContext.resolvedSecretTraceRegistry = callRegistry.forkForInputPaths([])
return { result: 'Box' }
},
}
const executor = new BlockExecutor([handler], resolver, { onBlockComplete }, state)
const ctx = createContext(state)
ctx.resolvedSecretTraceRegistry = registry
await expect(executor.execute(ctx, createNode(block), block)).resolves.toEqual({
result: 'Box',
})
await vi.waitFor(() => expect(onBlockComplete).toHaveBeenCalledOnce())
expect(state.getBlockOutput(block.id)).toEqual({ result: 'Box' })
expect(ctx.blockLogs[0]?.input).toEqual({ selector: '{{PRIVATE_SELECTOR}}' })
expect(onBlockComplete.mock.calls[0]?.[3]?.input).toEqual({
selector: '{{PRIVATE_SELECTOR}}',
})
expect(onBlockComplete.mock.calls[0]?.[3]?.output).toEqual({ result: 'Box' })
expect(state.getBlockState(block.id)?.resolvedSecretTraceProvenance?.entries).toEqual([])
expect(
onBlockComplete.mock.calls[0]?.[3]?.displayResolvedSecretTraceProvenance?.entries
).toEqual([])
expect(registry.getActiveMatches()).toEqual([])
})
it('uses a handler-narrowed registry when execution fails after private input settlement', async () => {
const block: SerializedBlock = {
...createBlock(),
metadata: { id: BlockType.MOTHERSHIP, name: 'Sim Chat' },
config: { tool: BlockType.MOTHERSHIP, params: { selector: 'x' } },
}
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const onBlockComplete = vi.fn(async () => {})
const registry = new ResolvedSecretTraceRegistry([
{ name: 'PRIVATE_SELECTOR', plaintext: 'x', encryptedValue: 'encrypted-selector' },
])
const handler: BlockHandler = {
canHandle: () => true,
execute: async (blockContext, _block, inputs) => {
const callRegistry = blockContext.resolvedSecretTraceRegistry!
callRegistry.recordResolvedAtInputPath('PRIVATE_SELECTOR', 'x', ['selector'])
callRegistry.recordResolvedInputProjection(['selector'], 'x', '{{PRIVATE_SELECTOR}}')
inputs.selector = '{{PRIVATE_SELECTOR}}'
blockContext.resolvedSecretTraceRegistry = callRegistry.forkForInputPaths([])
throw new Error('Provider request preparation failed')
},
}
const executor = new BlockExecutor([handler], resolver, { onBlockComplete }, state)
const ctx = createContext(state)
ctx.resolvedSecretTraceRegistry = registry
await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow(
'Provider request preparation failed'
)
await vi.waitFor(() => expect(onBlockComplete).toHaveBeenCalledOnce())
expect(state.getBlockOutput(block.id)).toEqual({
error: 'Provider request preparation failed',
})
expect(ctx.blockLogs[0]?.input).toEqual({ selector: '{{PRIVATE_SELECTOR}}' })
expect(onBlockComplete.mock.calls[0]?.[3]?.input).toEqual({
selector: '{{PRIVATE_SELECTOR}}',
})
expect(state.getBlockState(block.id)?.resolvedSecretTraceProvenance?.entries).toEqual([])
expect(
onBlockComplete.mock.calls[0]?.[3]?.displayResolvedSecretTraceProvenance?.entries
).toEqual([])
expect(registry.getActiveMatches()).toEqual([])
expect(JSON.stringify(ctx.blockLogs)).not.toContain('"x"')
})
it('fires block completion callbacks for pausing blocks so clients receive pause output', async () => {
const block = {
...createBlock(),
id: 'hitl-block-1',
metadata: { id: BlockType.HUMAN_IN_THE_LOOP, name: 'Human in the Loop' },
config: { tool: BlockType.HUMAN_IN_THE_LOOP, params: {} },
}
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const output = {
response: { status: 'paused' },
_pauseMetadata: {
contextId: 'pause-context-1',
blockId: block.id,
response: { status: 'paused' },
timestamp: new Date().toISOString(),
pauseKind: 'human' as const,
},
}
const handler: BlockHandler = {
canHandle: () => true,
execute: async () => output,
}
const onBlockStart = vi.fn(async () => {})
const onBlockComplete = vi.fn(async () => {})
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
onBlockStart,
onBlockComplete,
},
state
)
await executor.execute(createContext(state), createNode(block), block)
expect(onBlockStart).toHaveBeenCalled()
expect(onBlockComplete).toHaveBeenCalledWith(
block.id,
'Human in the Loop',
BlockType.HUMAN_IN_THE_LOOP,
expect.objectContaining({
output: expect.objectContaining({
response: { status: 'paused' },
}),
}),
undefined,
undefined
)
expect(state.getBlockOutput(block.id)).toEqual(output)
})
it('does not soft-succeed non-agent blocks on user AbortError', async () => {
const block = createBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const abortController = new AbortController()
const handler: BlockHandler = {
canHandle: () => true,
execute: async () => {
abortController.abort('user')
throw new DOMException('The operation was aborted.', 'AbortError')
},
}
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
},
state
)
const ctx = createContext(state)
ctx.abortSignal = abortController.signal
await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow(/abort/i)
const output = state.getBlockOutput(block.id)
expect(output?.error).toBeTruthy()
expect(output).not.toEqual({ content: '' })
})
it('keeps Sim Chat secret policy in runtime inputs and out of trace inputs', async () => {
const block = createBlock()
block.id = 'mothership-block-1'
block.metadata = { id: BlockType.MOTHERSHIP, name: 'Sim Chat' }
block.config = {
tool: BlockType.MOTHERSHIP,
params: {
prompt: 'Run the task',
secretScope: 'selected',
mountedSecrets: ['OPENAI_API_KEY'],
},
}
block.privateInputIds = ['secretScope', 'mountedSecrets']
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const handler: BlockHandler = {
canHandle: () => true,
execute: async (_ctx, _block, inputs) => {
expect(inputs).toMatchObject({
prompt: 'Run the task',
secretScope: 'selected',
mountedSecrets: ['OPENAI_API_KEY'],
})
return { content: 'done' }
},
}
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
},
state
)
const ctx = createContext(state)
await executor.execute(ctx, createNode(block), block)
expect(ctx.blockLogs[0]?.input).toEqual({ prompt: 'Run the task' })
const { traceSpans } = buildTraceSpans({
success: true,
output: { content: 'done' },
logs: ctx.blockLogs,
})
expect(traceSpans[0]?.input).toEqual({ prompt: 'Run the task' })
})
it('preserves Function secret placeholders until the execution boundary', async () => {
const secret = 'function-secret-literal-7f3a91'
const block = createBlock()
block.metadata.name = 'Function 1'
block.config.params = {
code: 'return {{OPENAI_API_KEY}}',
language: 'javascript',
}
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const registry = new ResolvedSecretTraceRegistry([
{
name: 'OPENAI_API_KEY',
plaintext: secret,
encryptedValue: 'encrypted-openai-api-key',
},
])
const resolver = new VariableResolver(workflow, {}, state)
const syntaxError =
'Syntax Error: Line 1: `return {{OPENAI_API_KEY}}` - Invalid or unexpected token'
const handler: BlockHandler = {
canHandle: () => true,
execute: async (_ctx, _block, inputs) => {
expect(inputs.code).toBe('return {{OPENAI_API_KEY}}')
throw new Error(syntaxError)
},
}
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
},
state
)
const ctx = createContext(state)
ctx.environmentVariables = { OPENAI_API_KEY: secret }
ctx.resolvedSecretTraceRegistry = registry
await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow(
`Function 1: ${syntaxError}`
)
expect(registry.getActiveMatches()).toEqual([])
expect(state.getBlockOutput(block.id)).toEqual({ error: syntaxError })
expect(ctx.blockLogs[0]).toMatchObject({
input: { code: 'return {{OPENAI_API_KEY}}' },
output: { error: syntaxError },
error: syntaxError,
})
const rawLogs = structuredClone(ctx.blockLogs)
const { traceSpans: rawTraceSpans } = buildTraceSpans({
success: false,
output: { error: syntaxError },
error: `Function 1: ${syntaxError}`,
logs: ctx.blockLogs,
})
const rawTraceSnapshot = structuredClone(rawTraceSpans)
const projectedTraceSpans = await projectTraceSpansForSecrets(rawTraceSpans, {
registry,
store: {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
},
})
expect(ctx.blockLogs).toEqual(rawLogs)
expect(rawTraceSpans).toEqual(rawTraceSnapshot)
expect(projectedTraceSpans).toEqual([
expect.objectContaining({
name: 'Function 1',
input: expect.objectContaining({ code: 'return {{OPENAI_API_KEY}}' }),
output: {
error: 'Syntax Error: Line 1: `return {{OPENAI_API_KEY}}` - Invalid or unexpected token',
},
}),
])
expect(JSON.stringify(projectedTraceSpans)).not.toContain(secret)
})
it('keeps raw error-port output while projecting operational logger metadata', async () => {
const secret = 'function-error-secret-7f3a91'
const rawError = `failed ${secret} __var_API_KEY __sim_code_3_binding_1`
const block = createBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const handler: BlockHandler = {
canHandle: () => true,
execute: async (blockContext) => {
blockContext.resolvedSecretTraceRegistry?.recordResolved('API_KEY', secret)
throw new Error(rawError)
},
}
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
},
state
)
const ctx = createContext(state)
ctx.resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry([
{
name: 'API_KEY',
plaintext: secret,
encryptedValue: 'encrypted-api-key',
},
])
const node = createNode(block)
node.outgoingEdges.set('error-edge', { target: 'error-handler', sourceHandle: EDGE.ERROR })
await expect(executor.execute(ctx, node, block)).resolves.toEqual({ error: rawError })
expect(state.getBlockOutput(block.id)).toEqual({ error: rawError })
expect(ctx.blockLogs[0]).toMatchObject({ error: rawError, output: { error: rawError } })
const executionLogger = blockExecutorBaseLogger.withMetadata.mock.results.at(-1)?.value
expect(executionLogger).toBeDefined()
const loggerCalls = JSON.stringify({
error: executionLogger?.error.mock.calls,
info: executionLogger?.info.mock.calls,
})
expect(loggerCalls).toContain('{{API_KEY}}')
expect(loggerCalls).not.toContain(secret)
expect(loggerCalls).not.toContain('__var_')
expect(loggerCalls).not.toContain('__sim_')
})
})
describe('BlockExecutor streaming pump', () => {
function createAgentBlock(): SerializedBlock {
return {
id: 'agent-block-1',
metadata: { id: BlockType.AGENT, name: 'Agent' },
position: { x: 0, y: 0 },
config: { tool: BlockType.AGENT, params: {} },
inputs: {},
outputs: {},
enabled: true,
}
}
function createExecutor(handler: BlockHandler) {
const block = createAgentBlock()
const workflow: SerializedWorkflow = {
version: '1',
blocks: [block],
connections: [],
loops: {},
parallels: {},
}
const state = new ExecutionState()
const resolver = new VariableResolver(workflow, {}, state)
const executor = new BlockExecutor(
[handler],
resolver,
{
workspaceId: 'workspace-1',
executionId: 'execution-1',
userId: 'user-1',
metadata: {
requestId: 'request-1',
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
userId: 'user-1',
triggerType: 'manual',
useDraftState: false,
startTime: new Date().toISOString(),
},
},
state
)
return { executor, block, state, resolver }
}
it('projects resolver-owned inputs for display without carrying them into output provenance', async () => {
const secret = 'x'
const handler: BlockHandler = {
canHandle: () => true,
execute: async (blockContext, _block, inputs) => {
expect(inputs.systemPrompt).toBe(secret)
const sourceRegistry = blockContext.resolvedSecretTraceRegistry
blockContext.resolvedSecretTraceRegistry = sourceRegistry?.forkForInputPaths([])
return { content: 'Box' }
},
}
const { executor, block, state } = createExecutor(handler)
block.config.params = { systemPrompt: '{{TOKEN}}' }
const ctx = createContext(state)
const registry = new ResolvedSecretTraceRegistry([
{ name: 'TOKEN', plaintext: secret, encryptedValue: 'encrypted-token' },
])
ctx.environmentVariables = { TOKEN: secret }
ctx.resolvedSecretTraceRegistry = registry
await executor.execute(ctx, createNode(block), block)
expect(ctx.blockLogs[0]).toMatchObject({
input: { systemPrompt: '{{TOKEN}}' },
output: { content: 'Box' },
})
expect(state.getBlockState(block.id)?.resolvedSecretTraceProvenance).toEqual({
version: 1,
complete: true,
entries: [],
})
expect(registry.getActiveMatches()).toEqual([])
})
it('keeps terminal error output provenance separate from low-entropy input provenance', async () => {
const secret = 'x'
const handler: BlockHandler = {
canHandle: () => true,
execute: async (blockContext, _block, inputs) => {
expect(inputs.systemPrompt).toBe(secret)
const sourceRegistry = blockContext.resolvedSecretTraceRegistry
blockContext.resolvedSecretTraceRegistry = sourceRegistry?.forkForInputPaths([])
throw new Error('Box')
},
}
const { executor, block, state } = createExecutor(handler)
block.config.params = { systemPrompt: '{{TOKEN}}' }
const ctx = createContext(state)
const registry = new ResolvedSecretTraceRegistry([
{ name: 'TOKEN', plaintext: secret, encryptedValue: 'encrypted-token' },
])
ctx.environmentVariables = { TOKEN: secret }
ctx.resolvedSecretTraceRegistry = registry
await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow('Agent: Box')
expect(ctx.blockLogs[0]).toMatchObject({
input: { systemPrompt: '{{TOKEN}}' },
output: { error: 'Box' },
})
expect(state.getBlockState(block.id)?.resolvedSecretTraceProvenance).toEqual({
version: 1,
complete: true,
entries: [],
})
expect(registry.getActiveMatches()).toEqual([])
})
it('carries echoed raw-boundary secret provenance on terminal errors only', async () => {
const promptSecret = 'x'
const apiKey = 'provider-credential-secret'
const handler: BlockHandler = {
canHandle: () => true,
execute: async (blockContext) => {
const sourceRegistry = blockContext.resolvedSecretTraceRegistry
blockContext.errorResolvedSecretTraceRegistry = sourceRegistry?.forkForInputPaths([
['apiKey'],
])
blockContext.resolvedSecretTraceRegistry = sourceRegistry?.forkForInputPaths([])
throw new Error(`Provider rejected ${apiKey}`)
},
}
const { executor, block, state } = createExecutor(handler)
block.config.params = {
systemPrompt: '{{PROMPT_TOKEN}}',
apiKey: '{{API_KEY}}',
}
const ctx = createContext(state)
const registry = new ResolvedSecretTraceRegistry([
{
name: 'PROMPT_TOKEN',
plaintext: promptSecret,
encryptedValue: 'encrypted-prompt-token',
},
{ name: 'API_KEY', plaintext: apiKey, encryptedValue: 'encrypted-api-key' },
])
ctx.environmentVariables = { PROMPT_TOKEN: promptSecret, API_KEY: apiKey }
ctx.resolvedSecretTraceRegistry = registry
await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow(
`Agent: Provider rejected ${apiKey}`
)
expect(ctx.blockLogs[0]).toMatchObject({
input: { systemPrompt: '{{PROMPT_TOKEN}}', apiKey: '[REDACTED]' },
output: { error: `Provider rejected ${apiKey}` },
})
const expectedProvenance = {
version: 1,
complete: true,
entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-api-key' }],
}
expect(state.getBlockState(block.id)?.resolvedSecretTraceProvenance).toEqual(expectedProvenance)
expect(ctx.blockLogs[0]?.displayResolvedSecretTraceProvenance).toEqual(expectedProvenance)
expect(registry.getActiveMatches()).toEqual([])
})
it('suppresses an incomplete display input without failing block execution', async () => {
const handler: BlockHandler = {
canHandle: () => true,
execute: async (blockContext) => {
blockContext.resolvedSecretTraceRegistry =
blockContext.resolvedSecretTraceRegistry?.forkForInputPaths([])
return { content: 'done' }
},
}
const { executor, block, state, resolver } = createExecutor(handler)
const inputs = {
userPrompt: 'Use the configured tool.',
tools: [{ params: { apiKey: 'unknown-value' } }],
}
vi.spyOn(resolver, 'resolveInputs').mockImplementation(async (blockContext) => {
await blockContext.resolvedSecretTraceRegistry?.importProvenanceForValueAtInputPath(
{ version: 1 },
'unknown-value',
['tools', '0', 'params', 'apiKey'],
{ trusted: true }
)
return inputs
})
const ctx = createContext(state)
ctx.resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry()
await expect(executor.execute(ctx, createNode(block), block)).resolves.toEqual({
content: 'done',
})
expect(ctx.blockLogs[0]?.input).toEqual({})
expect(ctx.blockLogs[0]?.output).toEqual({ content: 'done' })
})
function createAgentEventsStreamingHandler(options: {
events: Array<Record<string, unknown>>
attachThinkingOnDrain?: string
failAfterText?: string
streamError?: Error
onFullContent?: (content: string) => void | Promise<void>
resolvedSecret?: { name: string; value: string }
separateResultRegistry?: boolean
}): BlockHandler {
return {
canHandle: () => true,
execute: async (blockContext) => {
if (options.resolvedSecret) {
blockContext.resolvedSecretTraceRegistry?.recordResolved(
options.resolvedSecret.name,
options.resolvedSecret.value
)
}
const diagnosticRegistry = options.separateResultRegistry
? blockContext.resolvedSecretTraceRegistry
: undefined
if (diagnosticRegistry) {
blockContext.resolvedSecretTraceRegistry = diagnosticRegistry.forkForInputPaths([])
}
const timeSegment: Record<string, unknown> = {
type: 'model',
name: 'claude-test',
startTime: Date.now(),
endTime: Date.now(),
duration: 1,
}
const output = {
content: '',
model: 'claude-test',
tokens: { input: 1, output: 2, total: 3 },
providerTiming: {
startTime: new Date().toISOString(),
endTime: new Date().toISOString(),
duration: 1,
timeSegments: [timeSegment],
},
cost: { input: 0, output: 0, total: 0 },
}
const stream = new ReadableStream({
start(controller) {
if (options.failAfterText) {
controller.enqueue({
type: 'text_delta',
text: options.failAfterText,
turn: 'final',
})
controller.error(options.streamError ?? new Error('provider reset'))
return
}
for (const event of options.events) {
controller.enqueue(event)
}
if (options.attachThinkingOnDrain) {
timeSegment.thinkingContent = options.attachThinkingOnDrain
}
controller.close()
},
})
return {
stream,
streamFormat: 'agent-events-v1' as const,
execution: {
success: true,
output,
logs: [],
metadata: {
startTime: new Date().toISOString(),
endTime: new Date().toISOString(),
duration: 1,
},
},
onFullContent: options.onFullContent,
diagnosticResolvedSecretTraceRegistry: diagnosticRegistry,
}
},
}
}
it('projects answer text to onStream and content; sink gets full timeline', async () => {
const onFullContent = vi.fn()
const handler = createAgentEventsStreamingHandler({
events: [
{ type: 'thinking_delta', text: 'hmm ' },
{ type: 'thinking_delta', text: 'yes' },
{ type: 'text_delta', text: 'Hello ', turn: 'final' },
{ type: 'text_delta', text: 'world', turn: 'final' },
],
attachThinkingOnDrain: 'hmm yes',
onFullContent,
})
const { executor, block, state } = createExecutor(handler)
const ctx = createContext(state)
const forwarded: string[] = []
const sinkEvents: Array<Record<string, unknown>> = []
ctx.onStream = async (streamingExec) => {
expect(streamingExec.streamFormat).toBe('text')
streamingExec.subscribe?.({
onEvent: async (event) => {
sinkEvents.push(event as Record<string, unknown>)
},
})
const reader = streamingExec.stream.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
forwarded.push(decoder.decode(value, { stream: true }))
}
}
await executor.execute(ctx, createNode(block), block)
expect(forwarded.join('')).toBe('Hello world')
expect(state.getBlockOutput(block.id)?.content).toBe('Hello world')
expect(onFullContent).toHaveBeenCalledWith('Hello world')
expect(sinkEvents).toEqual([
{ type: 'thinking_delta', text: 'hmm ' },
{ type: 'thinking_delta', text: 'yes' },
{ type: 'text_delta', text: 'Hello ', turn: 'final' },
{ type: 'text_delta', text: 'world', turn: 'final' },
])
expect(state.getBlockOutput(block.id)?.providerTiming?.timeSegments?.[0]?.thinkingContent).toBe(
'hmm yes'
)
})
it('drains without onStream and still persists answer content', async () => {
const handler = createAgentEventsStreamingHandler({
events: [{ type: 'text_delta', text: 'offline answer', turn: 'final' }],
})
const { executor, block, state } = createExecutor(handler)
const ctx = createContext(state)
await executor.execute(ctx, createNode(block), block)
expect(state.getBlockOutput(block.id)?.content).toBe('offline answer')
})
it('persists tool-result provenance activated in a narrowed registry during stream drain', async () => {
const selector = 'x'
const resultSecret = 'stream-tool-result-secret'
const handler: BlockHandler = {
canHandle: () => true,
execute: async (blockContext, _block, inputs) => {
const sourceRegistry = blockContext.resolvedSecretTraceRegistry!
sourceRegistry.recordResolvedAtInputPath('PRIVATE_SELECTOR', selector, ['selector'])
sourceRegistry.recordResolvedInputProjection(['selector'], selector, '{{PRIVATE_SELECTOR}}')
inputs.selector = '{{PRIVATE_SELECTOR}}'
const runtimeRegistry = sourceRegistry.forkForInputPaths([])
blockContext.resolvedSecretTraceRegistry = runtimeRegistry
const output = {
content: '',
toolCalls: { list: [] as Array<Record<string, unknown>>, count: 0 },
}
const stream = new ReadableStream({
start(controller) {
runtimeRegistry.recordResolved('TOOL_RESULT', resultSecret, { propagated: true })
output.toolCalls = {
list: [{ name: 'lookup', result: { value: resultSecret, public: 'Box' } }],
count: 1,
}
controller.enqueue({ type: 'text_delta', text: 'done', turn: 'final' })
controller.close()
},
})
return {
stream,
streamFormat: 'agent-events-v1' as const,
execution: {
success: true,
output,
logs: [],
metadata: { startTime: new Date().toISOString(), duration: 1 },
},
}
},
}
const { executor, block, state } = createExecutor(handler)
block.config.params = { selector }
const ctx = createContext(state)
const registry = new ResolvedSecretTraceRegistry([
{
name: 'PRIVATE_SELECTOR',
plaintext: selector,
encryptedValue: 'encrypted-selector',
},
{
name: 'TOOL_RESULT',
plaintext: resultSecret,
encryptedValue: 'encrypted-tool-result',
},
])
ctx.resolvedSecretTraceRegistry = registry
await executor.execute(ctx, createNode(block), block)
expect(state.getBlockOutput(block.id)).toEqual({
content: 'done',
toolCalls: {
list: [{ name: 'lookup', result: { value: resultSecret, public: 'Box' } }],
count: 1,
},
})
expect(ctx.blockLogs[0]?.input).toEqual({ selector: '{{PRIVATE_SELECTOR}}' })
expect(state.getBlockState(block.id)?.resolvedSecretTraceProvenance).toEqual({
version: 1,
complete: true,
entries: [{ name: 'TOOL_RESULT', encryptedValue: 'encrypted-tool-result' }],
})
expect(registry.getActiveMatches()).toEqual([
{ plaintext: resultSecret, replacement: '{{TOOL_RESULT}}' },
])
})
it('throws on mid-stream provider error (no truncated success)', async () => {
const secret = 'stream-pump-secret-7f3a91'
const rawError = new Error(`provider reset ${secret} __var_API_KEY __sim_code_4_binding_1`)
const handler = createAgentEventsStreamingHandler({
failAfterText: 'partial',
streamError: rawError,
resolvedSecret: { name: 'API_KEY', value: secret },
separateResultRegistry: true,
})
const { executor, block, state } = createExecutor(handler)
const ctx = createContext(state)
ctx.resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry([
{ name: 'API_KEY', plaintext: secret, encryptedValue: 'encrypted-api-key' },
])
ctx.onStream = async (streamingExec) => {
expect(streamingExec).not.toHaveProperty('diagnosticResolvedSecretTraceRegistry')
const reader = streamingExec.stream.getReader()
try {
while (true) {
const { done } = await reader.read()
if (done) break
}
} catch {
// consumer may see the error; block must still fail
}
}
await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow(rawError.message)
expect(state.getBlockOutput(block.id)?.content).not.toBe('partial')
const executionLogger = blockExecutorBaseLogger.withMetadata.mock.results.at(-1)?.value
expect(executionLogger).toBeDefined()
expect(executionLogger?.error).toHaveBeenCalledWith('Error reading stream for block', {
blockId: block.id,
error: 'provider reset {{API_KEY}} {{API_KEY}} [RUNTIME_BINDING]',
errorName: 'Error',
stack: expect.any(String),
})
const loggerPayload = JSON.stringify(executionLogger?.error.mock.calls)
expect(loggerPayload).toContain('{{API_KEY}}')
expect(loggerPayload).not.toContain(secret)
expect(loggerPayload).not.toContain('__var_')
expect(loggerPayload).not.toContain('__sim_')
expect(rawError.message).toContain(secret)
})
it('projects response parsing and full-content callback diagnostics without changing output', async () => {
const secret = 'stream-content-secret-7f3a91'
const content = `not-json ${secret}`
const callbackError = new Error(
`callback failed ${secret} __var_API_KEY __sim_code_5_binding_0`
)
const handler = createAgentEventsStreamingHandler({
events: [{ type: 'text_delta', text: content, turn: 'final' }],
onFullContent: async () => {
throw callbackError
},
resolvedSecret: { name: 'API_KEY', value: secret },
})
const { executor, block, state } = createExecutor(handler)
block.config.params = { responseFormat: 'json' }
const ctx = createContext(state)
ctx.resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry([
{ name: 'API_KEY', plaintext: secret, encryptedValue: 'encrypted-api-key' },
])
await executor.execute(ctx, createNode(block), block)
expect(state.getBlockOutput(block.id)?.content).toBe(content)
const executionLogger = blockExecutorBaseLogger.withMetadata.mock.results.at(-1)?.value
expect(executionLogger).toBeDefined()
expect(executionLogger?.warn).toHaveBeenCalledWith(
'Failed to parse streamed content for response format',
expect.objectContaining({ blockId: block.id, errorName: 'SyntaxError' })
)
expect(executionLogger?.error).toHaveBeenCalledWith('onFullContent callback failed', {
blockId: block.id,
error: 'callback failed {{API_KEY}} {{API_KEY}} [RUNTIME_BINDING]',
errorName: 'Error',
stack: expect.any(String),
})
const loggerPayload = JSON.stringify({
warn: executionLogger?.warn.mock.calls,
error: executionLogger?.error.mock.calls,
})
expect(loggerPayload).toContain('{{API_KEY}}')
expect(loggerPayload).not.toContain(secret)
expect(loggerPayload).not.toContain('__var_')
expect(loggerPayload).not.toContain('__sim_')
expect(callbackError.message).toContain(secret)
})
it('soft-completes on user abort with drained answer text (no failed block)', async () => {
const abortController = new AbortController()
const handler = createAgentEventsStreamingHandler({
events: [
{ type: 'text_delta', text: 'partial answer', turn: 'final' },
{ type: 'thinking_delta', text: 'more' },
],
})
const { executor, block, state } = createExecutor(handler)
const ctx = createContext(state)
ctx.abortSignal = abortController.signal
ctx.onStream = async (streamingExec) => {
streamingExec.subscribe?.({ onEvent: async () => {} })
const reader = streamingExec.stream.getReader()
try {
// Drain the first projected answer chunk, then Stop — pump must keep it.
const first = await reader.read()
expect(first.done).toBe(false)
abortController.abort('user')
while (true) {
const { done } = await reader.read()
if (done) break
}
} catch {
// abort may cancel the text stream
}
}
await executor.execute(ctx, createNode(block), block)
const output = state.getBlockOutput(block.id)
expect(output?.error).toBeUndefined()
// Soft-complete must keep text already projected before Stop — not empty content.
expect(output?.content).toBe('partial answer')
expect(output).not.toMatchObject({ error: expect.any(String) })
})
it('fails on timeout but keeps drained answer text in block output', async () => {
const abortController = new AbortController()
const handler = createAgentEventsStreamingHandler({
events: [
{ type: 'text_delta', text: 'partial before timeout', turn: 'final' },
{ type: 'thinking_delta', text: 'more' },
],
})
const { executor, block, state } = createExecutor(handler)
const ctx = createContext(state)
ctx.abortSignal = abortController.signal
ctx.onStream = async (streamingExec) => {
streamingExec.subscribe?.({ onEvent: async () => {} })
const reader = streamingExec.stream.getReader()
try {
const first = await reader.read()
expect(first.done).toBe(false)
abortController.abort('timeout')
while (true) {
const { done } = await reader.read()
if (done) break
}
} catch {
// timeout may cancel the text stream
}
}
await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow(/timed out/i)
const output = state.getBlockOutput(block.id)
expect(output?.error).toBeTruthy()
expect(output?.content).toBe('partial before timeout')
})
it('with PII redaction: no live forward and strips thinking from traces', async () => {
const { redactObjectStrings } = await import('@/lib/logs/execution/pii-redaction')
vi.mocked(redactObjectStrings).mockImplementation(async (value) => {
if (typeof value === 'string') {
return `[masked]${value}` as never
}
// Object walk is exercised elsewhere; keep streaming-stage string mask as-is.
return value as never
})
const handler = createAgentEventsStreamingHandler({
events: [
{ type: 'thinking_delta', text: 'secret thought' },
{ type: 'text_delta', text: 'alice@example.com said hi', turn: 'final' },
],
attachThinkingOnDrain: 'secret thought',
})
const { executor, block, state } = createExecutor(handler)
const ctx = createContext(state)
const onStream = vi.fn()
ctx.onStream = onStream
ctx.piiBlockOutputRedaction = {
enabled: true,
entityTypes: ['EMAIL_ADDRESS'],
language: 'en',
}
await executor.execute(ctx, createNode(block), block)
expect(onStream).not.toHaveBeenCalled()
expect(state.getBlockOutput(block.id)?.content).toBe('[masked]alice@example.com said hi')
expect(
state.getBlockOutput(block.id)?.providerTiming?.timeSegments?.[0]?.thinkingContent
).toBeUndefined()
})
})