Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions apps/sim/executor/handlers/agent/agent-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,49 @@ describe('AgentBlockHandler', () => {
)
})

/**
* A stalled model call reaches here as the runtime's own `TimeoutError`, whose bare
* message ("The operation timed out.") names nothing. It must become a Sim-level
* message WITHOUT discarding the phase detail the provider attached — that detail is
* the only thing distinguishing "never answered" from "body never completed".
*/
it('maps a provider TimeoutError to a Sim message while keeping the phase detail', async () => {
const inputs = { model: 'gpt-4o', userPrompt: 'hi', apiKey: 'test-api-key' }
mockGetProviderFromModel.mockReturnValue('openai')

// Faithful to production: providers rewrap the transport failure in a
// ProviderError, which overwrites `name` — so only the cause still classifies it.
const transport = new Error(
'The operation timed out. [phase=reading-response-body elapsedMs=60001 status=200 contentLength=32116]'
)
transport.name = 'TimeoutError'
const wrapped = new Error(transport.message, { cause: transport })
wrapped.name = 'ProviderError'
mockExecuteProviderRequest.mockRejectedValueOnce(wrapped)

const error = await handler.execute(mockContext, mockBlock, inputs).catch((e) => e)

expect(error.message).toContain('Provider request timed out')
expect(error.message).toContain('phase=reading-response-body')
expect(error.message).toContain('status=200')
})

it('maps a provider AbortError the same way', async () => {
const inputs = { model: 'gpt-4o', userPrompt: 'hi', apiKey: 'test-api-key' }
mockGetProviderFromModel.mockReturnValue('openai')

const aborted = new Error('aborted [phase=awaiting-response-headers elapsedMs=12]')
aborted.name = 'AbortError'
const wrapped = new Error(aborted.message, { cause: aborted })
wrapped.name = 'ProviderError'
mockExecuteProviderRequest.mockRejectedValueOnce(wrapped)

const error = await handler.execute(mockContext, mockBlock, inputs).catch((e) => e)

expect(error.message).toContain('Provider request timed out')
expect(error.message).toContain('phase=awaiting-response-headers')
})

it('should handle streaming responses with text/event-stream content type', async () => {
const mockStreamBody = new ReadableStream({
start(controller) {
Expand Down
32 changes: 30 additions & 2 deletions apps/sim/executor/handlers/agent/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,22 @@ import { getToolAsync } from '@/tools/utils.server'

const logger = createLogger('AgentBlockHandler')

/**
* True when a failure originated from a transport deadline or abort, at any depth of the
* cause chain.
*
* Providers rewrap transport failures (`ProviderError` overwrites `name`), so a check on
* the top-level `name` alone misses every wrapped case. Bounded to a short walk so a
* self-referential cause cannot loop.
*/
function isTransportTimeout(error: unknown): boolean {
for (let current = error, depth = 0; current instanceof Error && depth < 5; depth++) {
if (current.name === 'AbortError' || current.name === 'TimeoutError') return true
current = current.cause
}
return false
}

/**
* Handler for Agent blocks that process LLM requests with optional tools.
*/
Expand Down Expand Up @@ -1299,8 +1315,20 @@ export class AgentBlockHandler implements BlockHandler {
timestamp: new Date().toISOString(),
})

if (error.name === 'AbortError') {
throw new Error('Provider request timed out - the API took too long to respond')
/**
* `TimeoutError` is what the runtime raises on a fetch deadline; without it a
* stalled model call reached the trace as the bare runtime string.
*
* The cause chain is walked, not just `name`: providers rewrap transport failures in
* a `ProviderError`, which overwrites `name`, so the classification only survives on
* `cause`. The original message is kept rather than replaced — providers annotate it
* with the request phase they died in, and that detail is the only thing separating a
* request that was never answered from one whose body stalled.
*/
if (isTransportTimeout(error)) {
throw new Error(
`Provider request timed out - the API took too long to respond (${error.message})`
)
}
if (error.name === 'TypeError' && error.message.includes('fetch')) {
throw new Error(
Expand Down
258 changes: 258 additions & 0 deletions apps/sim/providers/openai/core.response-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
/**
* @vitest-environment node
*
* `/v1/responses` answers HTTP 200 for generations that did not succeed — `status:
* 'failed'` with a populated `error`, or `status: 'incomplete'` with a reason. The
* non-streaming path read only `output`, so those reached the user as a success with
* empty content and billed tokens, while the trace span independently recorded
* `finishReason: 'error'`.
*
* These cover the status/error gate and pin the `incomplete` policy to the one the
* streaming loop already applies, so the two paths cannot silently diverge again.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { executeResponsesProviderRequest } from '@/providers/openai/core'
import type { ProviderRequest, ProviderResponse } from '@/providers/types'

vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 }))

vi.mock('@/providers/utils', () => ({
isFunctionToolCall: () => false,
calculateCost: () => ({ input: 0, output: 0, total: 0 }),
sumToolCosts: () => 0,
enforceStrictSchema: (schema: unknown) => schema,
prepareToolExecution: () => ({ toolParams: {}, executionParams: {} }),
prepareToolsWithUsageControl: (tools: unknown[]) => ({
tools,
toolChoice: undefined,
forcedTools: [],
hasFilteredTools: false,
}),
trackForcedToolUsage: () => ({ hasUsedForcedTool: false, usedForcedTools: [] }),
supportsReasoningEffort: () => false,
}))

const { mockExecuteProviderTool } = vi.hoisted(() => ({
mockExecuteProviderTool: vi.fn(),
}))

vi.mock('@/providers/runtime-context', () => ({
executeProviderTool: mockExecuteProviderTool,
}))

function jsonResponse(body: unknown) {
return {
ok: true,
status: 200,
headers: new Headers(),
json: () => Promise.resolve(body),
}
}

const USAGE = { input_tokens: 1, output_tokens: 1, total_tokens: 2 }

function message(text: string) {
return {
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text }],
}
}

function functionCall(args: string) {
return { type: 'function_call', call_id: 'call_1', name: 'exa_search', arguments: args }
}

const COMPLETED_RESPONSE = {
id: 'resp_1',
status: 'completed',
error: null,
incomplete_details: null,
output: [message('hello')],
usage: USAGE,
}

describe('OpenAI non-streaming response status handling', () => {
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any

beforeEach(() => {
vi.clearAllMocks()
mockExecuteProviderTool.mockResolvedValue({ success: true, output: { results: [] } })
})

function run(fetchMock: unknown, request: Partial<ProviderRequest> = {}) {
return executeResponsesProviderRequest(
{ apiKey: 'k', model: 'gpt-5.5', messages: [{ role: 'user', content: 'hi' }], ...request },
{
providerId: 'openai',
providerLabel: 'OpenAI',
modelName: 'gpt-5.5',
endpoint: 'https://api.openai.com/v1/responses',
headers: { Authorization: 'Bearer k' },
logger,
fetch: fetchMock as typeof fetch,
}
)
}

const TOOL_REQUEST: Partial<ProviderRequest> = {
tools: [{ id: 'exa_search', name: 'exa_search', description: 'search', params: {} }],
}

it('fails the block on a 200 carrying status "failed", surfacing the API error message', async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
id: 'resp_1',
status: 'failed',
error: { code: 'server_error', message: 'The model produced an invalid response.' },
incomplete_details: null,
output: [],
usage: USAGE,
})
)

await expect(run(fetchMock)).rejects.toThrow('The model produced an invalid response.')
})

it('fails the block when error is populated but status is absent', async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
id: 'resp_1',
error: { code: null, message: 'Upstream provider rejected the request.' },
incomplete_details: null,
output: [],
usage: USAGE,
})
)

await expect(run(fetchMock)).rejects.toThrow('Upstream provider rejected the request.')
})

/**
* Decision, matching `streamResponsesTurn`: an `incomplete` response truncated by
* `max_output_tokens` with no tool call is NOT an error — the partial prose is a
* usable answer and is returned as the block content.
*/
it('returns the partial content of a max_output_tokens incomplete response instead of failing', async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
id: 'resp_1',
status: 'incomplete',
error: null,
incomplete_details: { reason: 'max_output_tokens' },
output: [message('a truncated but usable answer')],
usage: USAGE,
})
)

const result = (await run(fetchMock)) as ProviderResponse
expect(result.content).toBe('a truncated but usable answer')
})

/**
* The other half of the same decision: every other incomplete reason is an error,
* because the generation stopped for a reason the caller must be told about.
*/
it('fails the block on an incomplete response whose reason is not max_output_tokens', async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
id: 'resp_1',
status: 'incomplete',
error: null,
incomplete_details: { reason: 'content_filter' },
output: [message('partial')],
usage: USAGE,
})
)

await expect(run(fetchMock)).rejects.toThrow(/content_filter/)
})

/**
* The confusing-failure case: a truncated `function_call` holds half-written JSON.
* Executing it made `parseToolArguments` throw, reporting a tool bug rather than the
* truncation that actually happened.
*/
it('does not execute a tool call from a non-completed response', async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
id: 'resp_1',
status: 'incomplete',
error: null,
incomplete_details: { reason: 'max_output_tokens' },
output: [functionCall('{"query": "half writ')],
usage: USAGE,
})
)

await expect(run(fetchMock, TOOL_REQUEST)).rejects.toThrow(/max_output_tokens/)
expect(mockExecuteProviderTool).not.toHaveBeenCalled()
})

it('leaves a healthy completed response entirely unaffected', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(COMPLETED_RESPONSE))

const result = (await run(fetchMock)) as ProviderResponse
expect(result.content).toBe('hello')
expect(result.toolCalls).toBeUndefined()
expect(result.tokens?.total).toBe(2)
expect(fetchMock).toHaveBeenCalledTimes(1)
})

it('still runs the multi-turn tool loop end to end', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
jsonResponse({
id: 'resp_tool',
status: 'completed',
error: null,
incomplete_details: null,
output: [functionCall('{"query":"sim"}')],
usage: USAGE,
})
)
.mockResolvedValueOnce(jsonResponse(COMPLETED_RESPONSE))

const result = (await run(fetchMock, TOOL_REQUEST)) as ProviderResponse

expect(fetchMock).toHaveBeenCalledTimes(2)
expect(mockExecuteProviderTool).toHaveBeenCalledTimes(1)
expect(result.toolCalls).toHaveLength(1)
expect(result.toolCalls?.[0].success).toBe(true)
expect(result.content).toBe('hello')
expect(result.tokens?.total).toBe(4)
})

/**
* The gate sits in `postResponses`, so it must cover continuation turns too — a loop
* that starts healthy and fails on turn two must still fail the block.
*/
it('fails the block when a later tool-loop turn comes back failed', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
jsonResponse({
id: 'resp_tool',
status: 'completed',
error: null,
incomplete_details: null,
output: [functionCall('{"query":"sim"}')],
usage: USAGE,
})
)
.mockResolvedValueOnce(
jsonResponse({
id: 'resp_2',
status: 'failed',
error: { code: 'server_error', message: 'Second turn blew up.' },
incomplete_details: null,
output: [],
usage: USAGE,
})
)

await expect(run(fetchMock, TOOL_REQUEST)).rejects.toThrow('Second turn blew up.')
expect(mockExecuteProviderTool).toHaveBeenCalledTimes(1)
})
})
Loading
Loading