d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
691 lines
20 KiB
TypeScript
691 lines
20 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { ExecutionContext, StreamingContext } from '@/lib/copilot/request/types'
|
|
|
|
const {
|
|
mockCreateRunSegment,
|
|
mockForceFailHungToolCall,
|
|
mockGetEffectiveDecryptedEnv,
|
|
mockGetMothershipBaseURL,
|
|
mockGetMothershipSourceEnvHeaders,
|
|
mockPrepareExecutionContext,
|
|
mockRunStreamLoop,
|
|
mockToolWatchdogTimeoutMs,
|
|
mockUpdateRunStatus,
|
|
} = vi.hoisted(() => ({
|
|
mockCreateRunSegment: vi.fn(),
|
|
mockForceFailHungToolCall: vi.fn(),
|
|
mockGetEffectiveDecryptedEnv: vi.fn(),
|
|
mockGetMothershipBaseURL: vi.fn(),
|
|
mockGetMothershipSourceEnvHeaders: vi.fn(),
|
|
mockPrepareExecutionContext: vi.fn(),
|
|
mockRunStreamLoop: vi.fn(),
|
|
mockToolWatchdogTimeoutMs: vi.fn(() => 60_000),
|
|
mockUpdateRunStatus: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/lib/copilot/async-runs/repository', () => ({
|
|
createRunSegment: mockCreateRunSegment,
|
|
updateRunStatus: mockUpdateRunStatus,
|
|
}))
|
|
|
|
vi.mock('@/lib/copilot/request/go/stream', () => {
|
|
class CopilotBackendError extends Error {
|
|
status?: number
|
|
|
|
constructor(message: string, options?: { status?: number }) {
|
|
super(message)
|
|
this.name = 'CopilotBackendError'
|
|
this.status = options?.status
|
|
}
|
|
}
|
|
|
|
class BillingLimitError extends Error {
|
|
userId: string
|
|
|
|
constructor(userId: string) {
|
|
super('Usage limit reached')
|
|
this.name = 'BillingLimitError'
|
|
this.userId = userId
|
|
}
|
|
}
|
|
|
|
return {
|
|
BillingLimitError,
|
|
CopilotBackendError,
|
|
runStreamLoop: mockRunStreamLoop,
|
|
}
|
|
})
|
|
|
|
vi.mock('@/lib/copilot/server/agent-url', () => ({
|
|
getMothershipBaseURL: mockGetMothershipBaseURL,
|
|
getMothershipSourceEnvHeaders: mockGetMothershipSourceEnvHeaders,
|
|
}))
|
|
|
|
vi.mock('@/lib/core/config/env', () => ({
|
|
env: {
|
|
COPILOT_API_KEY: undefined,
|
|
},
|
|
getEnv: vi.fn((key: string) => (key === 'NEXT_PUBLIC_APP_URL' ? 'http://localhost:3000' : '')),
|
|
isTruthy: vi.fn((value: string | undefined) => value === 'true'),
|
|
isFalsy: vi.fn((value: string | undefined) => value === 'false'),
|
|
}))
|
|
|
|
vi.mock('@/lib/environment/utils', () => ({
|
|
getEffectiveDecryptedEnv: mockGetEffectiveDecryptedEnv,
|
|
}))
|
|
|
|
vi.mock('@/lib/copilot/tools/handlers/context', () => ({
|
|
prepareExecutionContext: mockPrepareExecutionContext,
|
|
}))
|
|
|
|
vi.mock('@/lib/copilot/request/tools/billing', () => ({
|
|
handleBillingLimitResponse: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/lib/copilot/request/tools/executor', () => ({
|
|
executeToolAndReport: vi.fn(),
|
|
forceFailHungToolCall: mockForceFailHungToolCall,
|
|
toolWatchdogTimeoutMs: mockToolWatchdogTimeoutMs,
|
|
}))
|
|
|
|
import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1'
|
|
import { CopilotBackendError } from '@/lib/copilot/request/go/stream'
|
|
import { runCopilotLifecycle } from '@/lib/copilot/request/lifecycle/run'
|
|
|
|
describe('runCopilotLifecycle', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockGetMothershipBaseURL.mockResolvedValue('http://mothership.test')
|
|
mockGetMothershipSourceEnvHeaders.mockReturnValue({})
|
|
})
|
|
|
|
it('runs cancelled completion persistence when a stream throws after abort', async () => {
|
|
const abortController = new AbortController()
|
|
abortController.abort('stop')
|
|
const onComplete = vi.fn()
|
|
const onError = vi.fn()
|
|
const executionContext: ExecutionContext = {
|
|
userId: 'user-1',
|
|
workflowId: '',
|
|
workspaceId: 'ws-1',
|
|
chatId: 'chat-1',
|
|
decryptedEnvVars: {},
|
|
}
|
|
|
|
mockRunStreamLoop.mockImplementationOnce(
|
|
async (
|
|
_fetchUrl: string,
|
|
_fetchOptions: RequestInit,
|
|
context: StreamingContext
|
|
): Promise<void> => {
|
|
context.accumulatedContent = 'partial answer'
|
|
context.contentBlocks.push({
|
|
type: 'text',
|
|
content: 'partial answer',
|
|
timestamp: 1,
|
|
})
|
|
throw new Error('publisher closed after stop')
|
|
}
|
|
)
|
|
|
|
const result = await runCopilotLifecycle(
|
|
{ message: 'hello', messageId: 'stream-1' },
|
|
{
|
|
userId: 'user-1',
|
|
workspaceId: 'ws-1',
|
|
chatId: 'chat-1',
|
|
executionId: 'exec-1',
|
|
runId: 'run-1',
|
|
abortSignal: abortController.signal,
|
|
executionContext,
|
|
onComplete,
|
|
onError,
|
|
}
|
|
)
|
|
|
|
expect(onError).not.toHaveBeenCalled()
|
|
expect(onComplete).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
success: false,
|
|
cancelled: true,
|
|
content: 'partial answer',
|
|
chatId: 'chat-1',
|
|
requestId: undefined,
|
|
error: 'publisher closed after stop',
|
|
contentBlocks: [
|
|
expect.objectContaining({
|
|
type: 'text',
|
|
content: 'partial answer',
|
|
}),
|
|
],
|
|
})
|
|
)
|
|
expect(result).toEqual(
|
|
expect.objectContaining({
|
|
success: false,
|
|
cancelled: true,
|
|
content: 'partial answer',
|
|
chatId: 'chat-1',
|
|
error: 'publisher closed after stop',
|
|
})
|
|
)
|
|
})
|
|
|
|
it('returns the cancelled result when cancelled completion persistence fails', async () => {
|
|
const abortController = new AbortController()
|
|
abortController.abort('stop')
|
|
const onComplete = vi.fn().mockRejectedValue(new Error('db unavailable'))
|
|
const onError = vi.fn()
|
|
const executionContext: ExecutionContext = {
|
|
userId: 'user-1',
|
|
workflowId: '',
|
|
workspaceId: 'ws-1',
|
|
chatId: 'chat-1',
|
|
decryptedEnvVars: {},
|
|
}
|
|
|
|
mockRunStreamLoop.mockImplementationOnce(
|
|
async (
|
|
_fetchUrl: string,
|
|
_fetchOptions: RequestInit,
|
|
context: StreamingContext
|
|
): Promise<void> => {
|
|
context.accumulatedContent = 'partial answer'
|
|
throw new Error('publisher closed after stop')
|
|
}
|
|
)
|
|
|
|
const result = await runCopilotLifecycle(
|
|
{ message: 'hello', messageId: 'stream-1' },
|
|
{
|
|
userId: 'user-1',
|
|
workspaceId: 'ws-1',
|
|
chatId: 'chat-1',
|
|
executionId: 'exec-1',
|
|
runId: 'run-1',
|
|
abortSignal: abortController.signal,
|
|
executionContext,
|
|
onComplete,
|
|
onError,
|
|
}
|
|
)
|
|
|
|
expect(onError).not.toHaveBeenCalled()
|
|
expect(onComplete).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
success: false,
|
|
cancelled: true,
|
|
content: 'partial answer',
|
|
})
|
|
)
|
|
expect(result).toEqual(
|
|
expect.objectContaining({
|
|
success: false,
|
|
cancelled: true,
|
|
content: 'partial answer',
|
|
error: 'publisher closed after stop',
|
|
})
|
|
)
|
|
})
|
|
|
|
it('uses the final post-tool assistant content for headless results', async () => {
|
|
const executionContext: ExecutionContext = {
|
|
userId: 'user-1',
|
|
workflowId: '',
|
|
workspaceId: 'ws-1',
|
|
chatId: 'chat-1',
|
|
decryptedEnvVars: {},
|
|
}
|
|
|
|
mockRunStreamLoop.mockImplementationOnce(
|
|
async (
|
|
_fetchUrl: string,
|
|
_fetchOptions: RequestInit,
|
|
context: StreamingContext
|
|
): Promise<void> => {
|
|
context.accumulatedContent = 'I will check that.Final answer only.'
|
|
context.finalAssistantContent = 'Final answer only.'
|
|
context.sawMainToolCall = true
|
|
}
|
|
)
|
|
|
|
const result = await runCopilotLifecycle(
|
|
{ message: 'hello', messageId: 'stream-1' },
|
|
{
|
|
userId: 'user-1',
|
|
workspaceId: 'ws-1',
|
|
chatId: 'chat-1',
|
|
executionId: 'exec-1',
|
|
runId: 'run-1',
|
|
executionContext,
|
|
interactive: false,
|
|
}
|
|
)
|
|
|
|
expect(result).toEqual(
|
|
expect.objectContaining({
|
|
success: true,
|
|
content: 'Final answer only.',
|
|
})
|
|
)
|
|
})
|
|
|
|
it('does not fall back to pre-tool narration when headless final content is empty', async () => {
|
|
const executionContext: ExecutionContext = {
|
|
userId: 'user-1',
|
|
workflowId: '',
|
|
workspaceId: 'ws-1',
|
|
chatId: 'chat-1',
|
|
decryptedEnvVars: {},
|
|
}
|
|
|
|
mockRunStreamLoop.mockImplementationOnce(
|
|
async (
|
|
_fetchUrl: string,
|
|
_fetchOptions: RequestInit,
|
|
context: StreamingContext
|
|
): Promise<void> => {
|
|
context.accumulatedContent = 'I will check that.'
|
|
context.finalAssistantContent = ''
|
|
context.sawMainToolCall = true
|
|
}
|
|
)
|
|
|
|
const result = await runCopilotLifecycle(
|
|
{ message: 'hello', messageId: 'stream-1' },
|
|
{
|
|
userId: 'user-1',
|
|
workspaceId: 'ws-1',
|
|
chatId: 'chat-1',
|
|
executionId: 'exec-1',
|
|
runId: 'run-1',
|
|
executionContext,
|
|
interactive: false,
|
|
}
|
|
)
|
|
|
|
expect(result).toEqual(
|
|
expect.objectContaining({
|
|
success: true,
|
|
content: '',
|
|
})
|
|
)
|
|
})
|
|
|
|
it('propagates payload userPermission into the generated execution context', async () => {
|
|
let capturedExecContext: ExecutionContext | undefined
|
|
mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({})
|
|
mockRunStreamLoop.mockImplementationOnce(
|
|
async (
|
|
_fetchUrl: string,
|
|
_fetchOptions: RequestInit,
|
|
_context: StreamingContext,
|
|
execContext: ExecutionContext
|
|
): Promise<void> => {
|
|
capturedExecContext = execContext
|
|
}
|
|
)
|
|
|
|
await runCopilotLifecycle(
|
|
{ message: 'hello', messageId: 'stream-1', userPermission: 'write' },
|
|
{
|
|
userId: 'user-1',
|
|
workspaceId: 'ws-1',
|
|
chatId: 'chat-1',
|
|
}
|
|
)
|
|
|
|
expect(capturedExecContext).toEqual(
|
|
expect.objectContaining({
|
|
userId: 'user-1',
|
|
workspaceId: 'ws-1',
|
|
chatId: 'chat-1',
|
|
userPermission: 'write',
|
|
})
|
|
)
|
|
})
|
|
|
|
it('normalizes the initial request body with workspaceId from lifecycle options', async () => {
|
|
let requestBody: Record<string, unknown> | undefined
|
|
mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({})
|
|
mockRunStreamLoop.mockImplementationOnce(
|
|
async (_fetchUrl: string, fetchOptions: RequestInit): Promise<void> => {
|
|
requestBody = JSON.parse(String(fetchOptions.body))
|
|
}
|
|
)
|
|
|
|
await runCopilotLifecycle(
|
|
{ message: 'hello', messageId: 'stream-1' },
|
|
{
|
|
userId: 'user-1',
|
|
workspaceId: 'ws-1',
|
|
chatId: 'chat-1',
|
|
}
|
|
)
|
|
|
|
expect(requestBody).toEqual(
|
|
expect.objectContaining({
|
|
workspaceId: 'ws-1',
|
|
})
|
|
)
|
|
})
|
|
|
|
it('uses the lifecycle workspaceId for async tool resume requests', async () => {
|
|
const requestBodies: Record<string, unknown>[] = []
|
|
const fetchUrls: string[] = []
|
|
const executionContext: ExecutionContext = {
|
|
userId: 'user-1',
|
|
workflowId: 'workflow-1',
|
|
workspaceId: 'ws-1',
|
|
chatId: 'chat-1',
|
|
decryptedEnvVars: {},
|
|
}
|
|
|
|
mockRunStreamLoop.mockImplementationOnce(
|
|
async (
|
|
fetchUrl: string,
|
|
fetchOptions: RequestInit,
|
|
context: StreamingContext
|
|
): Promise<void> => {
|
|
fetchUrls.push(fetchUrl)
|
|
requestBodies.push(JSON.parse(String(fetchOptions.body)))
|
|
context.toolCalls.set('tool-1', {
|
|
id: 'tool-1',
|
|
name: 'read',
|
|
status: MothershipStreamV1ToolOutcome.success,
|
|
result: { success: true, output: { content: 'file contents' } },
|
|
})
|
|
context.awaitingAsyncContinuation = {
|
|
checkpointId: 'ckpt-1',
|
|
pendingToolCallIds: ['tool-1'],
|
|
}
|
|
}
|
|
)
|
|
mockRunStreamLoop.mockImplementationOnce(
|
|
async (fetchUrl: string, fetchOptions: RequestInit): Promise<void> => {
|
|
fetchUrls.push(fetchUrl)
|
|
requestBodies.push(JSON.parse(String(fetchOptions.body)))
|
|
}
|
|
)
|
|
|
|
await runCopilotLifecycle(
|
|
{ message: 'hello', messageId: 'stream-1' },
|
|
{
|
|
userId: 'user-1',
|
|
workspaceId: 'ws-1',
|
|
workflowId: 'workflow-1',
|
|
chatId: 'chat-1',
|
|
executionId: 'exec-1',
|
|
runId: 'run-1',
|
|
executionContext,
|
|
}
|
|
)
|
|
|
|
expect(fetchUrls[1]).toBe('http://mothership.test/api/tools/resume')
|
|
expect(requestBodies[1]).toEqual(
|
|
expect.objectContaining({
|
|
checkpointId: 'ckpt-1',
|
|
userId: 'user-1',
|
|
workspaceId: 'ws-1',
|
|
})
|
|
)
|
|
})
|
|
|
|
it('finalizes as success when a resume fails with a retryable error then the retry succeeds', async () => {
|
|
const executionContext: ExecutionContext = {
|
|
userId: 'user-1',
|
|
workflowId: '',
|
|
workspaceId: 'ws-1',
|
|
chatId: 'chat-1',
|
|
decryptedEnvVars: {},
|
|
}
|
|
|
|
// 1) Initial stream pauses on an async tool checkpoint with a resolved
|
|
// tool result, so the lifecycle transitions into a resume leg.
|
|
mockRunStreamLoop.mockImplementationOnce(
|
|
async (
|
|
_fetchUrl: string,
|
|
_fetchOptions: RequestInit,
|
|
context: StreamingContext
|
|
): Promise<void> => {
|
|
context.toolCalls.set('tool-1', {
|
|
id: 'tool-1',
|
|
name: 'read',
|
|
status: MothershipStreamV1ToolOutcome.success,
|
|
result: { success: true, output: { content: 'file contents' } },
|
|
})
|
|
context.awaitingAsyncContinuation = {
|
|
checkpointId: 'ckpt-1',
|
|
pendingToolCallIds: ['tool-1'],
|
|
}
|
|
}
|
|
)
|
|
|
|
// 2) First resume leg dies mid-stream like a transient provider error:
|
|
// it records an error AND throws a retryable 5xx.
|
|
mockRunStreamLoop.mockImplementationOnce(
|
|
async (
|
|
_fetchUrl: string,
|
|
_fetchOptions: RequestInit,
|
|
context: StreamingContext
|
|
): Promise<void> => {
|
|
context.errors.push(
|
|
'Copilot backend stream ended before a terminal event on /api/tools/resume'
|
|
)
|
|
throw new CopilotBackendError('backend stream ended before a terminal event', {
|
|
status: 503,
|
|
})
|
|
}
|
|
)
|
|
|
|
// 3) Retry of the same resume leg succeeds cleanly.
|
|
mockRunStreamLoop.mockImplementationOnce(
|
|
async (
|
|
_fetchUrl: string,
|
|
_fetchOptions: RequestInit,
|
|
context: StreamingContext
|
|
): Promise<void> => {
|
|
context.accumulatedContent = 'Recovered final answer.'
|
|
context.finalAssistantContent = 'Recovered final answer.'
|
|
}
|
|
)
|
|
|
|
const result = await runCopilotLifecycle(
|
|
{ message: 'hello', messageId: 'stream-1' },
|
|
{
|
|
userId: 'user-1',
|
|
workspaceId: 'ws-1',
|
|
chatId: 'chat-1',
|
|
executionId: 'exec-1',
|
|
runId: 'run-1',
|
|
executionContext,
|
|
}
|
|
)
|
|
|
|
// Three legs ran (initial + failed resume + retried resume), and the
|
|
// recovered retry must NOT inherit the failed attempt's error.
|
|
expect(mockRunStreamLoop).toHaveBeenCalledTimes(3)
|
|
expect(result).toEqual(
|
|
expect.objectContaining({
|
|
success: true,
|
|
cancelled: false,
|
|
errors: undefined,
|
|
})
|
|
)
|
|
})
|
|
|
|
it('marks resume legs willRetryOnStreamError except the final attempt', async () => {
|
|
const bodies: Record<string, unknown>[] = []
|
|
const executionContext: ExecutionContext = {
|
|
userId: 'user-1',
|
|
workflowId: '',
|
|
workspaceId: 'ws-1',
|
|
chatId: 'chat-1',
|
|
decryptedEnvVars: {},
|
|
}
|
|
|
|
// Initial leg pauses on a resolved async tool checkpoint → enters resume.
|
|
mockRunStreamLoop.mockImplementationOnce(
|
|
async (
|
|
_fetchUrl: string,
|
|
fetchOptions: RequestInit,
|
|
context: StreamingContext
|
|
): Promise<void> => {
|
|
bodies.push(JSON.parse(String(fetchOptions.body)))
|
|
context.toolCalls.set('tool-1', {
|
|
id: 'tool-1',
|
|
name: 'read',
|
|
status: MothershipStreamV1ToolOutcome.success,
|
|
result: { success: true, output: { content: 'file contents' } },
|
|
})
|
|
context.awaitingAsyncContinuation = {
|
|
checkpointId: 'ckpt-1',
|
|
pendingToolCallIds: ['tool-1'],
|
|
}
|
|
}
|
|
)
|
|
|
|
// Three resume attempts, all failing with a retryable 5xx so the loop
|
|
// exhausts MAX_RESUME_ATTEMPTS (= 3) and gives up.
|
|
for (let i = 0; i < 3; i++) {
|
|
mockRunStreamLoop.mockImplementationOnce(
|
|
async (
|
|
_fetchUrl: string,
|
|
fetchOptions: RequestInit,
|
|
context: StreamingContext
|
|
): Promise<void> => {
|
|
bodies.push(JSON.parse(String(fetchOptions.body)))
|
|
context.errors.push('Copilot backend stream ended before a terminal event')
|
|
throw new CopilotBackendError('backend stream ended before a terminal event', {
|
|
status: 503,
|
|
})
|
|
}
|
|
)
|
|
}
|
|
|
|
await runCopilotLifecycle(
|
|
{ message: 'hello', messageId: 'stream-1' },
|
|
{
|
|
userId: 'user-1',
|
|
workspaceId: 'ws-1',
|
|
chatId: 'chat-1',
|
|
executionId: 'exec-1',
|
|
runId: 'run-1',
|
|
executionContext,
|
|
}
|
|
)
|
|
|
|
// Initial + 3 resume attempts.
|
|
expect(mockRunStreamLoop).toHaveBeenCalledTimes(4)
|
|
// Initial leg is never retried by this loop → no flag.
|
|
expect(bodies[0].willRetryOnStreamError).toBeUndefined()
|
|
// Resume attempts 0 and 1 will be retried on a stream error → flagged.
|
|
expect(bodies[1].willRetryOnStreamError).toBe(true)
|
|
expect(bodies[2].willRetryOnStreamError).toBe(true)
|
|
// Final attempt (2) is terminal → not flagged, so Go bills + surfaces it.
|
|
expect(bodies[3].willRetryOnStreamError).toBeUndefined()
|
|
})
|
|
|
|
it('force-fails a hung tool promise and resumes with an error result instead of wedging', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const fetchUrls: string[] = []
|
|
const bodies: Record<string, unknown>[] = []
|
|
const executionContext: ExecutionContext = {
|
|
userId: 'user-1',
|
|
workflowId: '',
|
|
workspaceId: 'ws-1',
|
|
chatId: 'chat-1',
|
|
decryptedEnvVars: {},
|
|
}
|
|
|
|
// Mirror the real helper: settle the tool call into a terminal error
|
|
// state so the resume loop can serialize an error result for it.
|
|
mockForceFailHungToolCall.mockImplementation(
|
|
async (toolCallId: string, context: StreamingContext, message: string) => {
|
|
const tool = context.toolCalls.get(toolCallId)
|
|
if (!tool) return
|
|
tool.status = MothershipStreamV1ToolOutcome.error
|
|
tool.endTime = Date.now()
|
|
tool.result = { success: false }
|
|
tool.error = message
|
|
}
|
|
)
|
|
|
|
// Initial leg checkpoints on an async tool whose promise NEVER settles —
|
|
// the exact shape of the prod incident (claimed, marked running, hung).
|
|
mockRunStreamLoop.mockImplementationOnce(
|
|
async (
|
|
fetchUrl: string,
|
|
fetchOptions: RequestInit,
|
|
context: StreamingContext
|
|
): Promise<void> => {
|
|
fetchUrls.push(fetchUrl)
|
|
bodies.push(JSON.parse(String(fetchOptions.body)))
|
|
context.toolCalls.set('tool-hung', {
|
|
id: 'tool-hung',
|
|
name: 'read',
|
|
status: 'executing',
|
|
})
|
|
context.pendingToolPromises.set('tool-hung', new Promise(() => {}))
|
|
context.awaitingAsyncContinuation = {
|
|
checkpointId: 'ckpt-1',
|
|
pendingToolCallIds: ['tool-hung'],
|
|
}
|
|
}
|
|
)
|
|
|
|
// Resume leg completes normally with the error result delivered.
|
|
mockRunStreamLoop.mockImplementationOnce(
|
|
async (
|
|
fetchUrl: string,
|
|
fetchOptions: RequestInit,
|
|
context: StreamingContext
|
|
): Promise<void> => {
|
|
fetchUrls.push(fetchUrl)
|
|
bodies.push(JSON.parse(String(fetchOptions.body)))
|
|
context.accumulatedContent = 'The file read failed, but here is what I know.'
|
|
}
|
|
)
|
|
|
|
const lifecycle = runCopilotLifecycle(
|
|
{ message: 'hello', messageId: 'stream-1' },
|
|
{
|
|
userId: 'user-1',
|
|
workspaceId: 'ws-1',
|
|
chatId: 'chat-1',
|
|
executionId: 'exec-1',
|
|
runId: 'run-1',
|
|
executionContext,
|
|
}
|
|
)
|
|
|
|
// Wait budget = watchdog (60s, mocked) + resume grace (30s). Advance past it.
|
|
await vi.advanceTimersByTimeAsync(91_000)
|
|
const result = await lifecycle
|
|
|
|
expect(mockForceFailHungToolCall).toHaveBeenCalledWith(
|
|
'tool-hung',
|
|
expect.anything(),
|
|
expect.stringContaining('hung')
|
|
)
|
|
expect(fetchUrls[1]).toBe('http://mothership.test/api/tools/resume')
|
|
expect(bodies[1].results).toEqual([
|
|
expect.objectContaining({
|
|
callId: 'tool-hung',
|
|
name: 'read',
|
|
success: false,
|
|
data: { error: expect.stringContaining('hung') },
|
|
}),
|
|
])
|
|
expect(result.success).toBe(true)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
})
|