diff --git a/apps/sim/providers/openai/core.response-status.test.ts b/apps/sim/providers/openai/core.response-status.test.ts index 96aa01888e5..43bad98daf9 100644 --- a/apps/sim/providers/openai/core.response-status.test.ts +++ b/apps/sim/providers/openai/core.response-status.test.ts @@ -11,6 +11,8 @@ import { } from '@sim/testing/mocks/providers-conversation-history.mock' import { providersUtilsMock, providersUtilsMockFns } from '@sim/testing/mocks/providers-utils.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { classifyFailure } from '@/lib/core/errors/failure-log' +import { isNonRetryableExecutionError } from '@/lib/execution/non-retryable-error' import { executeResponsesProviderRequest } from '@/providers/openai/core' import type { ProviderRequest, ProviderResponse } from '@/providers/types' @@ -113,6 +115,41 @@ describe('OpenAI non-streaming response status handling', () => { expect(fetchMock).not.toHaveBeenCalled() }) + it('refuses a request with no input items before sending, as a nonretryable author failure', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse( + { + error: { + message: + 'One of input or previous_response_id or prompt or conversation must be provided.', + }, + }, + 400 + ) + ) + const error = await run(fetchMock, { + systemPrompt: '', + messages: [ + { role: 'system', content: '' }, + { role: 'user', content: '' }, + ], + }).catch((caught: unknown) => caught) + + expect(error).toMatchObject({ name: 'EmptyProviderInputError' }) + expect(isNonRetryableExecutionError(error)).toBe(true) + expect(classifyFailure(error)).toBe('user') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('still sends a request that carries only a system prompt', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(COMPLETED_RESPONSE)) + await run(fetchMock, { systemPrompt: 'You are terse.', messages: [] }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const body = JSON.parse(fetchMock.mock.calls[0][1].body) + expect(body.input).toEqual([{ role: 'system', content: 'You are terse.' }]) + }) + it('lets an estimated oversized request reach the provider and propagates its actual context rejection', async () => { mockConversationContext.mockReturnValue({ agentConversation: {} }) const fetchMock = vi.fn().mockResolvedValue({ diff --git a/apps/sim/providers/openai/core.ts b/apps/sim/providers/openai/core.ts index 5c4bf0d774d..be1bf695d8c 100644 --- a/apps/sim/providers/openai/core.ts +++ b/apps/sim/providers/openai/core.ts @@ -30,7 +30,7 @@ import { createStreamingExecution } from '@/providers/streaming-execution' import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' -import { ProviderError } from '@/providers/types' +import { EmptyProviderInputError, ProviderError } from '@/providers/types' import { enforceStrictSchema, prepareToolExecution, @@ -188,6 +188,7 @@ export async function executeResponsesProviderRequest( } const initialInput = buildResponsesInputFromMessages(allMessages, config.providerId) + if (initialInput.length === 0) throw new EmptyProviderInputError() /** * `stream` is always explicit: OpenAI defaults it to false, but Responses-compatible diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index ad38a079106..c561f1fc849 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -1,4 +1,5 @@ import type { BillingAttributionSnapshot } from '@/lib/billing/core/attribution' +import { UserFailure } from '@/lib/core/errors/user-failure' import type { CustomBlockInputFieldType } from '@/blocks/custom/build-config' import type { ProviderTimingSegment, StreamingExecution, UserFile } from '@/executor/types' @@ -294,4 +295,20 @@ export class ProviderError extends Error { } } +/** + * A request with no input items to send: every system and user message was absent or resolved + * to empty. The provider would reject it on every attempt, so it is the author's to fix and is + * never retried or handed to a fallback model. + */ +export class EmptyProviderInputError extends UserFailure { + readonly retryable = false + + constructor() { + super( + 'The Agent has no messages to send. Add a system prompt or user message, or check that the value it references (for example ) is not empty.' + ) + this.name = 'EmptyProviderInputError' + } +} + export const providers: Record = {}