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

399 lines
12 KiB
TypeScript

import '@sim/testing/mocks/executor'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
executeTool,
completeAsyncToolCall,
markAsyncToolRunning,
upsertAsyncToolCall,
onEvent,
recordSimToolMetric,
setAttribute,
withCopilotToolSpan,
} = vi.hoisted(() => {
const setAttribute = vi.fn()
return {
executeTool: vi.fn(),
completeAsyncToolCall: vi.fn(),
markAsyncToolRunning: vi.fn(),
upsertAsyncToolCall: vi.fn(),
onEvent: vi.fn(),
recordSimToolMetric: vi.fn(),
setAttribute,
withCopilotToolSpan: vi.fn(
(_input: unknown, fn: (span: { setAttribute: typeof setAttribute }) => Promise<unknown>) =>
fn({ setAttribute })
),
}
})
vi.mock('@/lib/copilot/tool-executor', () => ({
ensureHandlersRegistered: vi.fn(),
executeTool,
}))
vi.mock('@/lib/copilot/async-runs/repository', () => ({
completeAsyncToolCall,
markAsyncToolRunning,
upsertAsyncToolCall,
}))
vi.mock('@/lib/copilot/persistence/tool-confirm', () => ({
publishToolConfirmation: vi.fn(),
}))
vi.mock('@/lib/copilot/request/metrics', () => ({
recordSimToolMetric,
}))
vi.mock('@/lib/copilot/request/otel', () => ({
withCopilotToolSpan,
}))
vi.mock('@/lib/copilot/request/sse-utils', () => ({
markToolResultSeen: vi.fn(),
}))
vi.mock('@/lib/copilot/request/tools/files', () => ({
maybeWriteOutputToFile: vi.fn(async (_toolName, _params, result) => result),
}))
vi.mock('@/lib/copilot/request/tools/resources', () => ({
handleResourceSideEffects: vi.fn(),
}))
vi.mock('@/lib/copilot/request/tools/tables', () => ({
maybeWriteOutputToTable: vi.fn(async (_toolName, _params, result) => result),
maybeWriteReadCsvToTable: vi.fn(async (_toolName, _params, result) => result),
}))
vi.mock('@/lib/copilot/request/tools/workflow-context', () => ({
applyCreateWorkflowOutputToContext: vi.fn(),
}))
import { TOOL_WATCHDOG_DEFAULT_MS, TOOL_WATCHDOG_LONG_RUNNING_MS } from '@/lib/copilot/constants'
import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1'
import { createStreamingContext } from '@/lib/copilot/request/context/request-context'
import {
buildToolExecutionContext,
executeToolAndReport,
pendingToolWaitBudgetMs,
toolWatchdogTimeoutMs,
} from '@/lib/copilot/request/tools/executor'
import type { ExecutionContext, ToolCallState } from '@/lib/copilot/request/types'
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
function buildStreamingContext(toolCall: ToolCallState) {
return createStreamingContext({
runId: 'run-1',
messageId: 'message-1',
toolCalls: new Map([[toolCall.id, toolCall]]),
})
}
function buildPendingToolCall(): ToolCallState {
return {
id: 'tool-call-1',
name: 'test_tool',
status: 'pending',
params: {},
}
}
describe('toolWatchdogTimeoutMs', () => {
it('gives request-scoped MCP tools the long-running watchdog', () => {
expect(toolWatchdogTimeoutMs('mcp-363de040-web_search_exa')).toBe(TOOL_WATCHDOG_LONG_RUNNING_MS)
})
it('keeps ordinary tools on the strict default watchdog', () => {
expect(toolWatchdogTimeoutMs('read')).toBe(TOOL_WATCHDOG_DEFAULT_MS)
})
it.each(['deploy_api', 'deploy_chat', 'deploy_mcp', 'redeploy', 'promote_to_live'])(
'does not undercut deployment tool %s with the default watchdog',
(toolName) => {
expect(toolWatchdogTimeoutMs(toolName)).toBe(TOOL_WATCHDOG_LONG_RUNNING_MS)
}
)
})
describe('pendingToolWaitBudgetMs', () => {
it('waits on a person for as long as the whole turn allows', () => {
// The 60s default would force-fail a permission prompt while the user was
// still reading it, resuming Go before they ever answered.
expect(pendingToolWaitBudgetMs({ name: 'terminal_run', status: 'awaiting_approval' })).toBe(
TOOL_WATCHDOG_LONG_RUNNING_MS
)
})
it('falls back to the tool\u2019s own watchdog once it is actually executing', () => {
expect(pendingToolWaitBudgetMs({ name: 'terminal_run', status: 'executing' })).toBe(
TOOL_WATCHDOG_DEFAULT_MS
)
})
})
describe('buildToolExecutionContext', () => {
it('threads logical tool-call identity into the handler context', () => {
const executionContext: ExecutionContext = {
userId: 'user-1',
workflowId: 'workflow-1',
runId: 'run-1',
sandboxProfile: 'mothership',
}
expect(
buildToolExecutionContext(
{
id: 'call-1',
parentToolCallId: 'parent-1',
},
executionContext
)
).toMatchObject({
runId: 'run-1',
sandboxProfile: 'mothership',
toolCallId: 'call-1',
parentToolCallId: 'parent-1',
})
})
it('isolates one tool from a sibling secret activation and merges settled provenance', () => {
const parentRegistry = new ResolvedSecretTraceRegistry([
{ name: 'TOKEN', plaintext: 'secret', encryptedValue: 'encrypted-secret' },
])
const completeSiblingActivation = parentRegistry.beginPendingActivation()
const executionContext: ExecutionContext = {
userId: 'user-1',
workflowId: 'workflow-1',
resolvedSecretTraceRegistry: parentRegistry,
}
const toolContext = buildToolExecutionContext({ id: 'call-1' }, executionContext)
const toolRegistry = toolContext.resolvedSecretTraceRegistry
expect(toolRegistry).not.toBe(parentRegistry)
expect(toolRegistry?.isComplete()).toBe(true)
expect(toolRegistry?.recordResolved('TOKEN', 'secret')).toBe(true)
parentRegistry.mergeToolCallRegistry(toolRegistry!)
completeSiblingActivation()
expect(parentRegistry.getActiveMatches()).toEqual([
{ plaintext: 'secret', replacement: '{{TOKEN}}' },
])
})
})
describe('executeToolAndReport provenance isolation', () => {
beforeEach(() => {
vi.clearAllMocks()
completeAsyncToolCall.mockResolvedValue(null)
markAsyncToolRunning.mockResolvedValue(null)
upsertAsyncToolCall.mockResolvedValue(null)
})
it('merges a complete child only after its projected result is safe', async () => {
const registry = new ResolvedSecretTraceRegistry([
{ name: 'TOKEN', plaintext: 'secret-value', encryptedValue: 'ciphertext' },
])
executeTool.mockImplementationOnce(
async (
_toolName: string,
_params: Record<string, unknown>,
toolContext: ExecutionContext
) => {
toolContext.resolvedSecretTraceRegistry?.recordResolved('TOKEN', 'secret-value', {
propagated: true,
})
return { success: true, output: { value: 'secret-value' } }
}
)
const toolCall = buildPendingToolCall()
const completion = await executeToolAndReport(
toolCall.id,
buildStreamingContext(toolCall),
{ userId: 'user-1', workflowId: 'workflow-1', resolvedSecretTraceRegistry: registry },
{ onEvent }
)
expect(completion).toEqual({
status: MothershipStreamV1ToolOutcome.success,
message: 'Tool completed',
data: { value: '{{TOKEN}}' },
})
expect(registry.getActiveMatches()).toEqual([
{ plaintext: 'secret-value', replacement: '{{TOKEN}}' },
])
})
it('structurally omits an incomplete result without poisoning the parent turn', async () => {
const registry = new ResolvedSecretTraceRegistry([
{ name: 'TOKEN', plaintext: 'secret-value', encryptedValue: 'ciphertext' },
])
executeTool.mockImplementationOnce(
async (
_toolName: string,
_params: Record<string, unknown>,
toolContext: ExecutionContext
) => {
toolContext.resolvedSecretTraceRegistry?.markIncomplete()
return { success: true, output: { value: 'secret-value' } }
}
)
const toolCall = buildPendingToolCall()
const completion = await executeToolAndReport(
toolCall.id,
buildStreamingContext(toolCall),
{ userId: 'user-1', workflowId: 'workflow-1', resolvedSecretTraceRegistry: registry },
{ onEvent }
)
expect(completion).toEqual({
status: MothershipStreamV1ToolOutcome.success,
message: 'Tool completed',
data: { success: true },
})
expect(registry.isComplete()).toBe(true)
expect(registry.getActiveMatches()).toEqual([])
expect(JSON.stringify([completion, onEvent.mock.calls])).not.toContain('secret-value')
})
it('structurally fails an incomplete thrown error without poisoning the parent turn', async () => {
const registry = new ResolvedSecretTraceRegistry([
{ name: 'TOKEN', plaintext: 'secret-value', encryptedValue: 'ciphertext' },
])
executeTool.mockImplementationOnce(
async (
_toolName: string,
_params: Record<string, unknown>,
toolContext: ExecutionContext
) => {
toolContext.resolvedSecretTraceRegistry?.markIncomplete()
throw new Error('secret-value')
}
)
const toolCall = buildPendingToolCall()
const completion = await executeToolAndReport(
toolCall.id,
buildStreamingContext(toolCall),
{ userId: 'user-1', workflowId: 'workflow-1', resolvedSecretTraceRegistry: registry },
{ onEvent }
)
expect(completion.status).toBe(MothershipStreamV1ToolOutcome.error)
expect(registry.isComplete()).toBe(true)
expect(registry.getActiveMatches()).toEqual([])
expect(JSON.stringify([completion, onEvent.mock.calls])).not.toContain('secret-value')
})
it('discards an incomplete child when execution is aborted before result delivery', async () => {
const registry = new ResolvedSecretTraceRegistry([
{ name: 'TOKEN', plaintext: 'secret-value', encryptedValue: 'ciphertext' },
])
const abortController = new AbortController()
executeTool.mockImplementationOnce(
async (
_toolName: string,
_params: Record<string, unknown>,
toolContext: ExecutionContext
) => {
toolContext.resolvedSecretTraceRegistry?.markIncomplete()
abortController.abort()
return { success: true, output: { value: 'secret-value' } }
}
)
const toolCall = buildPendingToolCall()
const completion = await executeToolAndReport(
toolCall.id,
buildStreamingContext(toolCall),
{
userId: 'user-1',
workflowId: 'workflow-1',
abortSignal: abortController.signal,
resolvedSecretTraceRegistry: registry,
},
{ onEvent }
)
expect(completion.status).toBe(MothershipStreamV1ToolOutcome.cancelled)
expect(registry.isComplete()).toBe(true)
expect(registry.getActiveMatches()).toEqual([])
expect(JSON.stringify([completion, onEvent.mock.calls])).not.toContain('secret-value')
})
})
describe('executeToolAndReport metrics', () => {
const executionContext: ExecutionContext = {
userId: 'user-1',
workflowId: 'workflow-1',
}
beforeEach(() => {
vi.clearAllMocks()
})
it('forwards the stored agent on normal completion', async () => {
const toolCall: ToolCallState = {
id: 'call-1',
name: 'read',
status: MothershipStreamV1ToolOutcome.success,
result: { success: true, output: 'done' },
agentId: 'workflow',
endTime: Date.now(),
}
const context = createStreamingContext({
toolCalls: new Map([[toolCall.id, toolCall]]),
})
await executeToolAndReport(toolCall.id, context, executionContext)
expect(recordSimToolMetric).toHaveBeenCalledWith(
'read',
'workflow',
MothershipStreamV1ToolOutcome.success,
expect.any(Number)
)
expect(withCopilotToolSpan).toHaveBeenCalledWith(
expect.objectContaining({ agentName: 'workflow' }),
expect.any(Function)
)
})
it.each([
{ agentId: 'workflow', expectedAgentId: 'workflow' },
{ agentId: undefined, expectedAgentId: 'main' },
])(
'forwards $expectedAgentId when an unexpected error occurs',
async ({ agentId, expectedAgentId }) => {
const toolCall: ToolCallState = {
id: 'call-2',
name: 'read',
status: MothershipStreamV1ToolOutcome.error,
agentId,
endTime: Date.now(),
}
const context = createStreamingContext({
toolCalls: new Map([[toolCall.id, toolCall]]),
})
await expect(executeToolAndReport(toolCall.id, context, executionContext)).rejects.toThrow(
'missing a canonical error'
)
expect(recordSimToolMetric).toHaveBeenCalledWith(
'read',
expectedAgentId,
MothershipStreamV1ToolOutcome.error,
expect.any(Number)
)
expect(withCopilotToolSpan).toHaveBeenCalledWith(
expect.objectContaining({ agentName: expectedAgentId }),
expect.any(Function)
)
}
)
})