From e1788a230f5a8e44e4af738e25961468d0cace42 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 29 Oct 2026 23:12:54 +0000 Subject: [PATCH 1/6] fix(providers/openai): skip reasoning that can't go inline Reasoning without finalized encrypted content was sent as an item_reference whenever store was true. That only resolves while OpenAI still holds the source response. A reference to reasoning from an unstored response, or from a stored response whose stream was cut, gets a 404 that fails the whole request, so every later turn of the chat fails the same way. Such reasoning is now skipped in both store modes. By default, models before GPT-5.6 ignore reasoning from earlier turns anyway. --- providers/openai/openai_test.go | 59 +++++-------------- providers/openai/responses_language_model.go | 9 +-- .../openai/responses_reasoning_replay_test.go | 18 ++++-- 3 files changed, 32 insertions(+), 54 deletions(-) diff --git a/providers/openai/openai_test.go b/providers/openai/openai_test.go index d69be21d7..efe0476fb 100644 --- a/providers/openai/openai_test.go +++ b/providers/openai/openai_test.go @@ -4770,30 +4770,6 @@ func TestResponsesToPrompt_ReasoningWithStore(t *testing.T) { }, } - t.Run("store true emits item_reference for reasoning", func(t *testing.T) { - t.Parallel() - - input, warnings, err := toResponsesPrompt(prompt, "system", true) - - require.NoError(t, err) - require.Empty(t, warnings) - - // Unfinalized metadata (stream placeholders or rows persisted - // before Finalized existed) falls back to an item_reference with - // store=true. Order: user, item_reference(rs_*), assistant text, - // user. - require.Len(t, input, 4) - - for _, item := range input { - require.Nil(t, item.OfReasoning, - "unfinalized reasoning must not be replayed inline") - } - - require.NotNil(t, input[1].OfItemReference, - "expected reasoning replayed via item_reference") - require.Equal(t, reasoningItemID, input[1].OfItemReference.ID) - }) - t.Run("store true skips reasoning when item id missing", func(t *testing.T) { t.Parallel() @@ -4911,7 +4887,7 @@ func TestResponsesToPrompt_ReasoningWithWebSearchCombined(t *testing.T) { }, } - t.Run("store true references reasoning before the search", func(t *testing.T) { + t.Run("store true skips reasoning before the search", func(t *testing.T) { t.Parallel() input, warnings, err := toResponsesPrompt(prompt, "system", true) @@ -4919,14 +4895,13 @@ func TestResponsesToPrompt_ReasoningWithWebSearchCombined(t *testing.T) { require.NoError(t, err) require.Empty(t, warnings) - // Order: user, item_reference(rs_*), web_search_call, - // assistant text, user. - require.Len(t, input, 5) - - require.NotNil(t, input[1].OfItemReference) - require.Equal(t, reasoningItemID, input[1].OfItemReference.ID) - - require.NotNil(t, input[2].OfWebSearchCall) + // Order: user, web_search_call, assistant text, user. + require.Len(t, input, 4) + require.NotNil(t, input[1].OfWebSearchCall) + for _, item := range input { + require.Nil(t, item.OfItemReference) + require.Nil(t, item.OfReasoning) + } }) t.Run("store false replays the search without reasoning", func(t *testing.T) { @@ -5002,19 +4977,15 @@ func TestResponsesToPrompt_ReasoningWithFunctionCallCombined(t *testing.T) { require.NoError(t, err) require.Empty(t, warnings) - // Order: user, item_reference(rs_003), function_call(call_003), - // function_call_output(call_003), user. - require.Len(t, input, 5) - - require.NotNil(t, input[1].OfItemReference) - require.Equal(t, reasoningItemID, input[1].OfItemReference.ID, - "reasoning item_reference must precede function_call") + // Reasoning without encrypted content is skipped. Order: user, + // function_call(call_003), function_call_output(call_003), user. + require.Len(t, input, 4) - require.NotNil(t, input[2].OfFunctionCall) - require.Equal(t, functionCallID, input[2].OfFunctionCall.CallID) + require.NotNil(t, input[1].OfFunctionCall) + require.Equal(t, functionCallID, input[1].OfFunctionCall.CallID) - require.NotNil(t, input[3].OfFunctionCallOutput) - require.Equal(t, functionCallID, input[3].OfFunctionCallOutput.CallID) + require.NotNil(t, input[2].OfFunctionCallOutput) + require.Equal(t, functionCallID, input[2].OfFunctionCallOutput.CallID) } func TestResponsesStream_RequiresTerminalEventBeforeFinish(t *testing.T) { diff --git a/providers/openai/responses_language_model.go b/providers/openai/responses_language_model.go index a92affca8..7b706cb3e 100644 --- a/providers/openai/responses_language_model.go +++ b/providers/openai/responses_language_model.go @@ -682,13 +682,10 @@ func toResponsesPromptWithValidation(prompt fantasy.Prompt, systemMessageMode st } if item, ok := responsesReasoningInputItem(meta); ok { input = append(input, item) - } else if store { - input = append(input, responses.ResponseInputItemParamOfItemReference(meta.ItemID)) - } else { - // Unstored reasoning without finalized encrypted - // content cannot be resolved by the API. - continue } + // Reasoning that cannot be sent inline is skipped, not + // referenced: a reference to an item OpenAI did not store + // fails the whole request. continue case fantasy.ContentTypeToolResult: toolResultPart, ok := fantasy.AsContentType[fantasy.ToolResultPart](c) diff --git a/providers/openai/responses_reasoning_replay_test.go b/providers/openai/responses_reasoning_replay_test.go index d0b2aa651..e227f6ae3 100644 --- a/providers/openai/responses_reasoning_replay_test.go +++ b/providers/openai/responses_reasoning_replay_test.go @@ -109,8 +109,6 @@ func TestResponsesGenerate_ReasoningMetadataIsFinalized(t *testing.T) { func TestResponsesReplay_FinalizedReasoningInput(t *testing.T) { t.Parallel() - // The SDK marshals item references without a type field. - reference := map[string]any{"id": "rs_1"} fullItem := func(summary ...any) map[string]any { return map[string]any{ "type": "reasoning", @@ -126,6 +124,8 @@ func TestResponsesReplay_FinalizedReasoningInput(t *testing.T) { encrypted *string summary []string webSearch bool + // unfinalized marks metadata saved before Finalized existed. + unfinalized bool // want is the reasoning input item; nil means none is sent. want map[string]any }{ @@ -154,11 +154,21 @@ func TestResponsesReplay_FinalizedReasoningInput(t *testing.T) { { name: "no blob store true", store: true, - want: reference, }, { name: "no blob store false", }, + { + name: "unfinalized store true", + store: true, + encrypted: new("enc-final"), + unfinalized: true, + }, + { + name: "unfinalized store false", + encrypted: new("enc-final"), + unfinalized: true, + }, { name: "store true before web search", store: true, @@ -185,7 +195,7 @@ func TestResponsesReplay_FinalizedReasoningInput(t *testing.T) { ItemID: "rs_1", EncryptedContent: tc.encrypted, Summary: tc.summary, - Finalized: true, + Finalized: !tc.unfinalized, }}}, next, fantasy.TextPart{Text: "a"}, From 2f7f24392b4f6f0d42e0051a10aa39790e39f9c6 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 29 Oct 2026 23:15:25 +0000 Subject: [PATCH 2/6] refactor(providers/openai): drop store from prompt conversion Prompt conversion no longer reads store: searches and reasoning go inline or not at all in both modes. Tests that ran once per store value now run once. --- providers/openai/openai_test.go | 120 ++++++------------ providers/openai/responses_language_model.go | 11 +- providers/openai/responses_params_test.go | 2 +- providers/openai/responses_text_phase_test.go | 32 ++--- providers/openai/responses_web_search_test.go | 48 +++---- providers/openai/tool_result_media_test.go | 4 +- 6 files changed, 78 insertions(+), 139 deletions(-) diff --git a/providers/openai/openai_test.go b/providers/openai/openai_test.go index efe0476fb..9197ae30f 100644 --- a/providers/openai/openai_test.go +++ b/providers/openai/openai_test.go @@ -3819,7 +3819,7 @@ func TestResponsesToPrompt_DropsEmptyMessages(t *testing.T) { }, } - input, warnings, err := toResponsesPrompt(prompt, "system", false) + input, warnings, err := toResponsesPrompt(prompt, "system") require.NoError(t, err) @@ -3847,7 +3847,7 @@ func TestResponsesToPrompt_DropsEmptyMessages(t *testing.T) { }, } - input, warnings, err := toResponsesPrompt(prompt, "system", false) + input, warnings, err := toResponsesPrompt(prompt, "system") require.NoError(t, err) @@ -3886,7 +3886,7 @@ func TestResponsesToPrompt_DropsEmptyMessages(t *testing.T) { }, } - input, warnings, err := toResponsesPrompt(prompt, "system", false) + input, warnings, err := toResponsesPrompt(prompt, "system") require.NoError(t, err) @@ -3909,7 +3909,7 @@ func TestResponsesToPrompt_DropsEmptyMessages(t *testing.T) { }, } - input, warnings, err := toResponsesPrompt(prompt, "system", false) + input, warnings, err := toResponsesPrompt(prompt, "system") require.NoError(t, err) @@ -3933,7 +3933,7 @@ func TestResponsesToPrompt_DropsEmptyMessages(t *testing.T) { }, } - input, warnings, err := toResponsesPrompt(prompt, "system", false) + input, warnings, err := toResponsesPrompt(prompt, "system") require.NoError(t, err) @@ -3966,7 +3966,7 @@ func TestResponsesToPrompt_DropsEmptyMessages(t *testing.T) { }, } - input, warnings, err := toResponsesPrompt(prompt, "system", false) + input, warnings, err := toResponsesPrompt(prompt, "system") require.NoError(t, err) @@ -3999,7 +3999,7 @@ func TestResponsesToPrompt_DropsEmptyMessages(t *testing.T) { }, } - input, warnings, err := toResponsesPrompt(prompt, "system", false) + input, warnings, err := toResponsesPrompt(prompt, "system") require.NoError(t, err) @@ -4696,42 +4696,20 @@ func TestResponsesToPrompt_WebSearchProviderExecutedToolResults(t *testing.T) { }, } - t.Run("store false skips search without metadata", func(t *testing.T) { - t.Parallel() - - input, warnings, err := toResponsesPrompt(prompt, "system instructions", false) - - require.NoError(t, err) - - require.Equal(t, []fantasy.CallWarning{{ - Type: fantasy.CallWarningTypeOther, - Message: `dropping web search "ws_01": it has no web search metadata`, - }}, warnings) - require.Len(t, input, 2, - "expected user + assistant text when store=false") - require.Nil(t, input[0].OfItemReference) - require.Nil(t, input[1].OfItemReference) - }) + input, warnings, err := toResponsesPrompt(prompt, "system instructions") - t.Run("store true skips search without metadata", func(t *testing.T) { - t.Parallel() - - input, warnings, err := toResponsesPrompt(prompt, "system instructions", true) - - require.NoError(t, err) + require.NoError(t, err) - require.Equal(t, []fantasy.CallWarning{{ - Type: fantasy.CallWarningTypeOther, - Message: `dropping web search "ws_01": it has no web search metadata`, - }}, warnings) - require.Len(t, input, 2, - "expected user + assistant text when store=true") - require.Nil(t, input[0].OfItemReference) - require.Nil(t, input[1].OfItemReference) - }) + require.Equal(t, []fantasy.CallWarning{{ + Type: fantasy.CallWarningTypeOther, + Message: `dropping web search "ws_01": it has no web search metadata`, + }}, warnings) + require.Len(t, input, 2, "expected user + assistant text") + require.Nil(t, input[0].OfItemReference) + require.Nil(t, input[1].OfItemReference) } -func TestResponsesToPrompt_ReasoningWithStore(t *testing.T) { +func TestResponsesToPrompt_ReasoningWithoutEncryptedContent(t *testing.T) { t.Parallel() encryptedContent := "gAAAAABpvAwtDPh5dSXW86hwbwoTo4DJHANQ" @@ -4770,7 +4748,7 @@ func TestResponsesToPrompt_ReasoningWithStore(t *testing.T) { }, } - t.Run("store true skips reasoning when item id missing", func(t *testing.T) { + t.Run("skips reasoning when item id missing", func(t *testing.T) { t.Parallel() noIDPrompt := fantasy.Prompt{ @@ -4800,7 +4778,7 @@ func TestResponsesToPrompt_ReasoningWithStore(t *testing.T) { }, } - input, warnings, err := toResponsesPrompt(noIDPrompt, "system", true) + input, warnings, err := toResponsesPrompt(noIDPrompt, "system") require.NoError(t, err) require.Empty(t, warnings) @@ -4814,23 +4792,20 @@ func TestResponsesToPrompt_ReasoningWithStore(t *testing.T) { } }) - t.Run("store false skips reasoning", func(t *testing.T) { + t.Run("skips unfinalized reasoning", func(t *testing.T) { t.Parallel() - input, warnings, err := toResponsesPrompt(prompt, "system", false) + input, warnings, err := toResponsesPrompt(prompt, "system") require.NoError(t, err) require.Empty(t, warnings) - // Unfinalized metadata is skipped with store=false: user, - // assistant text, follow-up user. + // user, assistant text, follow-up user. require.Len(t, input, 3) for _, item := range input { - require.Nil(t, item.OfReasoning, - "reasoning items must not appear when store=false") - require.Nil(t, item.OfItemReference, - "reasoning item_reference must not appear when store=false") + require.Nil(t, item.OfReasoning) + require.Nil(t, item.OfItemReference) } }) } @@ -4887,40 +4862,19 @@ func TestResponsesToPrompt_ReasoningWithWebSearchCombined(t *testing.T) { }, } - t.Run("store true skips reasoning before the search", func(t *testing.T) { - t.Parallel() - - input, warnings, err := toResponsesPrompt(prompt, "system", true) + input, warnings, err := toResponsesPrompt(prompt, "system") - require.NoError(t, err) - require.Empty(t, warnings) - - // Order: user, web_search_call, assistant text, user. - require.Len(t, input, 4) - require.NotNil(t, input[1].OfWebSearchCall) - for _, item := range input { - require.Nil(t, item.OfItemReference) - require.Nil(t, item.OfReasoning) - } - }) - - t.Run("store false replays the search without reasoning", func(t *testing.T) { - t.Parallel() - - input, warnings, err := toResponsesPrompt(prompt, "system", false) - - require.NoError(t, err) - require.Empty(t, warnings) + require.NoError(t, err) + require.Empty(t, warnings) - // Reasoning without encrypted content is skipped under - // store=false. user, web_search_call, assistant text, user. - require.Len(t, input, 4) - require.NotNil(t, input[1].OfWebSearchCall) - for _, item := range input { - require.Nil(t, item.OfItemReference) - require.Nil(t, item.OfReasoning) - } - }) + // Reasoning without encrypted content is skipped. Order: user, + // web_search_call, assistant text, user. + require.Len(t, input, 4) + require.NotNil(t, input[1].OfWebSearchCall) + for _, item := range input { + require.Nil(t, item.OfItemReference) + require.Nil(t, item.OfReasoning) + } } func TestResponsesToPrompt_ReasoningWithFunctionCallCombined(t *testing.T) { @@ -4972,7 +4926,7 @@ func TestResponsesToPrompt_ReasoningWithFunctionCallCombined(t *testing.T) { }, } - input, warnings, err := toResponsesPrompt(prompt, "system", true) + input, warnings, err := toResponsesPrompt(prompt, "system") require.NoError(t, err) require.Empty(t, warnings) @@ -5582,7 +5536,7 @@ func TestComputerUseGenerateRoundTrip_NonImageResult(t *testing.T) { }, } - input, warnings, err := toResponsesPrompt(prompt, "system", false) + input, warnings, err := toResponsesPrompt(prompt, "system") require.NoError(t, err) // Should warn about non-image result. diff --git a/providers/openai/responses_language_model.go b/providers/openai/responses_language_model.go index 7b706cb3e..bfbefc10d 100644 --- a/providers/openai/responses_language_model.go +++ b/providers/openai/responses_language_model.go @@ -222,14 +222,13 @@ func (o responsesLanguageModel) prepareParams(call fantasy.Call) (*responses.Res allowPreviousResponseToolOutputs = true } - storeEnabled := openaiOptions != nil && openaiOptions.Store != nil && *openaiOptions.Store var input responses.ResponseInputParam var inputWarnings []fantasy.CallWarning var err error if allowPreviousResponseToolOutputs { - input, inputWarnings, err = toResponsesPromptWithValidation(call.Prompt, modelConfig.systemMessageMode, storeEnabled, true) + input, inputWarnings, err = toResponsesPromptWithValidation(call.Prompt, modelConfig.systemMessageMode, true) } else { - input, inputWarnings, err = toResponsesPrompt(call.Prompt, modelConfig.systemMessageMode, storeEnabled) + input, inputWarnings, err = toResponsesPrompt(call.Prompt, modelConfig.systemMessageMode) } warnings = append(warnings, inputWarnings...) if err != nil { @@ -462,11 +461,11 @@ func responsesUsage(resp responses.Response) fantasy.Usage { return usage } -func toResponsesPrompt(prompt fantasy.Prompt, systemMessageMode string, store bool) (responses.ResponseInputParam, []fantasy.CallWarning, error) { - return toResponsesPromptWithValidation(prompt, systemMessageMode, store, false) +func toResponsesPrompt(prompt fantasy.Prompt, systemMessageMode string) (responses.ResponseInputParam, []fantasy.CallWarning, error) { + return toResponsesPromptWithValidation(prompt, systemMessageMode, false) } -func toResponsesPromptWithValidation(prompt fantasy.Prompt, systemMessageMode string, store bool, allowUnpairedFunctionCallOutputs bool) (responses.ResponseInputParam, []fantasy.CallWarning, error) { +func toResponsesPromptWithValidation(prompt fantasy.Prompt, systemMessageMode string, allowUnpairedFunctionCallOutputs bool) (responses.ResponseInputParam, []fantasy.CallWarning, error) { var input responses.ResponseInputParam var warnings []fantasy.CallWarning diff --git a/providers/openai/responses_params_test.go b/providers/openai/responses_params_test.go index 63955acf9..17865a4f5 100644 --- a/providers/openai/responses_params_test.go +++ b/providers/openai/responses_params_test.go @@ -580,7 +580,7 @@ func TestPrepareParams_ValidatesFunctionCallOutputPairing(t *testing.T) { input, warnings, err := toResponsesPrompt(fantasy.Prompt{ testResponsesProviderToolResultMessage("ws_01"), - }, "system", false) + }, "system") require.NoError(t, err) require.Equal(t, []fantasy.CallWarning{{ Type: fantasy.CallWarningTypeOther, diff --git a/providers/openai/responses_text_phase_test.go b/providers/openai/responses_text_phase_test.go index aab070916..f7f9b78b1 100644 --- a/providers/openai/responses_text_phase_test.go +++ b/providers/openai/responses_text_phase_test.go @@ -123,24 +123,18 @@ func TestResponsesToPrompt_ReplaysTextPhase(t *testing.T) { }, } - for _, store := range []bool{true, false} { - t.Run(fmt.Sprintf("store %t", store), func(t *testing.T) { - t.Parallel() - - input, warnings, err := toResponsesPrompt(prompt, "system", store) - require.NoError(t, err) - require.Empty(t, warnings) - require.Len(t, input, 5) - - var phases []any - for _, item := range input[1:] { - raw, err := json.Marshal(item) - require.NoError(t, err) - var fields map[string]any - require.NoError(t, json.Unmarshal(raw, &fields)) - phases = append(phases, fields["phase"]) - } - require.Equal(t, []any{"commentary", "final_answer", nil, nil}, phases) - }) + input, warnings, err := toResponsesPrompt(prompt, "system") + require.NoError(t, err) + require.Empty(t, warnings) + require.Len(t, input, 5) + + var phases []any + for _, item := range input[1:] { + raw, err := json.Marshal(item) + require.NoError(t, err) + var fields map[string]any + require.NoError(t, json.Unmarshal(raw, &fields)) + phases = append(phases, fields["phase"]) } + require.Equal(t, []any{"commentary", "final_answer", nil, nil}, phases) } diff --git a/providers/openai/responses_web_search_test.go b/providers/openai/responses_web_search_test.go index 530370e0f..e53c048e9 100644 --- a/providers/openai/responses_web_search_test.go +++ b/providers/openai/responses_web_search_test.go @@ -459,10 +459,10 @@ func withoutItemID(meta *WebSearchCallMetadata) *WebSearchCallMetadata { return &copied } -func requireResponsesInputJSON(t *testing.T, prompt fantasy.Prompt, store bool, want string) []fantasy.CallWarning { +func requireResponsesInputJSON(t *testing.T, prompt fantasy.Prompt, want string) []fantasy.CallWarning { t.Helper() - input, warnings, err := toResponsesPrompt(prompt, "system", store) + input, warnings, err := toResponsesPrompt(prompt, "system") require.NoError(t, err) encoded, err := json.Marshal(input) require.NoError(t, err) @@ -470,7 +470,7 @@ func requireResponsesInputJSON(t *testing.T, prompt fantasy.Prompt, store bool, return warnings } -func TestResponsesToPrompt_UnstoredWebSearchCallAction(t *testing.T) { +func TestResponsesToPrompt_WebSearchCallAction(t *testing.T) { t.Parallel() for _, tc := range webSearchCallActionCases { @@ -484,7 +484,7 @@ func TestResponsesToPrompt_UnstoredWebSearchCallAction(t *testing.T) { fantasy.TextPart{Text: "Answer."}, )}, } - warnings := requireResponsesInputJSON(t, prompt, false, `[`+ + warnings := requireResponsesInputJSON(t, prompt, `[`+ `{"role":"user","content":[{"type":"input_text","text":"Search."}]},`+ tc.wantInline+`,`+ `{"role":"assistant","content":"Answer."}]`) @@ -537,7 +537,6 @@ func TestResponsesToPrompt_WebSearchCallReplay(t *testing.T) { tests := []struct { name string - store bool content []fantasy.MessagePart laterContent []fantasy.MessagePart // toolContentBefore and toolContentAfter are tool messages before @@ -548,19 +547,12 @@ func TestResponsesToPrompt_WebSearchCallReplay(t *testing.T) { wantWarning string }{ { - name: "UnstoredInterleavedWithReasoning", + name: "InterleavedWithReasoning", content: interleaved, want: `[` + userJSON + `,` + searchJSON + `,` + reasoningJSON + `,` + openPageJSON + `,` + answerJSON + `]`, }, { - // A stored search is sent inline too, never referenced. - name: "StoredInterleavedWithReasoning", - store: true, - content: interleaved, - want: `[` + userJSON + `,` + searchJSON + `,` + reasoningJSON + `,` + openPageJSON + `,` + answerJSON + `]`, - }, - { - name: "UnstoredRepeatedParts", + name: "RepeatedParts", content: slices.Concat( webSearchCallParts("ws_01", search), webSearchCallParts("ws_01", search), @@ -568,7 +560,7 @@ func TestResponsesToPrompt_WebSearchCallReplay(t *testing.T) { want: `[` + userJSON + `,` + searchJSON + `]`, }, { - name: "UnstoredWithoutIDs", + name: "WithoutIDs", content: []fantasy.MessagePart{ webSearchCallParts("", withoutItemID(search))[1], webSearchCallParts("", withoutItemID(openPage))[1], @@ -577,7 +569,7 @@ func TestResponsesToPrompt_WebSearchCallReplay(t *testing.T) { }, { // Without a call ID, the metadata's item ID identifies the search. - name: "UnstoredRepeatedResultWithoutCallID", + name: "RepeatedResultWithoutCallID", content: []fantasy.MessagePart{ webSearchCallParts("", search)[1], webSearchCallParts("", search)[1], @@ -587,18 +579,18 @@ func TestResponsesToPrompt_WebSearchCallReplay(t *testing.T) { { // No warning: the assistant message after the tool message // replays the search. - name: "UnstoredResultInEarlierToolMessage", + name: "ResultInEarlierToolMessage", toolContentBefore: []fantasy.MessagePart{webSearchCallParts("ws_01", search)[1]}, content: []fantasy.MessagePart{webSearchCallParts("ws_01", search)[1], answer}, want: `[` + userJSON + `,` + searchJSON + `,` + answerJSON + `]`, }, { - name: "UnstoredSearchOnly", + name: "SearchOnly", content: webSearchCallParts("ws_01", search), want: `[` + userJSON + `,` + searchJSON + `]`, }, { - name: "UnstoredPre62SearchWithoutStatus", + name: "Pre62SearchWithoutStatus", content: append( webSearchCallParts("ws_01", &WebSearchCallMetadata{ ItemID: "ws_01", @@ -611,7 +603,7 @@ func TestResponsesToPrompt_WebSearchCallReplay(t *testing.T) { { // Before #62 only Type and Query were saved. The API accepts a // find_in_page action without url and pattern. - name: "UnstoredPre62FindInPageWithoutStatus", + name: "Pre62FindInPageWithoutStatus", content: append( webSearchCallParts("ws_01", &WebSearchCallMetadata{ ItemID: "ws_01", @@ -622,7 +614,7 @@ func TestResponsesToPrompt_WebSearchCallReplay(t *testing.T) { want: `[` + userJSON + `,{"type":"web_search_call","action":{"type":"find_in_page"}},` + answerJSON + `]`, }, { - name: "UnstoredWithoutAction", + name: "WithoutAction", content: append( webSearchCallParts("ws_01", &WebSearchCallMetadata{ItemID: "ws_01", Status: "completed"}), answer, @@ -631,7 +623,7 @@ func TestResponsesToPrompt_WebSearchCallReplay(t *testing.T) { wantWarning: `dropping web search "ws_01": metadata has no action`, }, { - name: "UnstoredWithoutMetadata", + name: "WithoutMetadata", content: append(webSearchCallParts("ws_01", nil), answer), want: `[` + userJSON + `,` + answerJSON + `]`, wantWarning: `dropping web search "ws_01": it has no web search metadata`, @@ -639,7 +631,7 @@ func TestResponsesToPrompt_WebSearchCallReplay(t *testing.T) { { // A part that can't replay a search doesn't warn when another // part replays it. - name: "UnstoredUnreplayableThenReplayable", + name: "UnreplayableThenReplayable", content: []fantasy.MessagePart{ webSearchCallParts("ws_01", &WebSearchCallMetadata{ItemID: "ws_01", Status: "completed"})[1], answer, @@ -648,7 +640,7 @@ func TestResponsesToPrompt_WebSearchCallReplay(t *testing.T) { want: `[` + userJSON + `,` + answerJSON + `,` + searchJSON + `]`, }, { - name: "UnstoredRepeatedWithoutMetadata", + name: "RepeatedWithoutMetadata", content: []fantasy.MessagePart{ webSearchCallParts("ws_01", nil)[1], webSearchCallParts("ws_01", nil)[1], @@ -658,7 +650,7 @@ func TestResponsesToPrompt_WebSearchCallReplay(t *testing.T) { wantWarning: `dropping web search "ws_01": it has no web search metadata`, }, { - name: "UnstoredRepeatedInToolMessages", + name: "RepeatedInToolMessages", toolContentBefore: []fantasy.MessagePart{webSearchCallParts("ws_01", search)[1]}, content: []fantasy.MessagePart{answer}, toolContentAfter: []fantasy.MessagePart{webSearchCallParts("ws_01", search)[1]}, @@ -667,14 +659,14 @@ func TestResponsesToPrompt_WebSearchCallReplay(t *testing.T) { }, { // The assistant message's reason wins over the tool message's. - name: "UnstoredWithoutMetadataAlsoInToolMessage", + name: "WithoutMetadataAlsoInToolMessage", toolContentBefore: []fantasy.MessagePart{webSearchCallParts("ws_01", search)[1]}, content: []fantasy.MessagePart{webSearchCallParts("ws_01", nil)[1], answer}, want: `[` + userJSON + `,` + answerJSON + `]`, wantWarning: `dropping web search "ws_01": it has no web search metadata`, }, { - name: "UnstoredUnknownActionType", + name: "UnknownActionType", content: append( webSearchCallParts("ws_01", &WebSearchCallMetadata{ ItemID: "ws_01", @@ -702,7 +694,7 @@ func TestResponsesToPrompt_WebSearchCallReplay(t *testing.T) { if tc.toolContentAfter != nil { prompt = append(prompt, fantasy.Message{Role: fantasy.MessageRoleTool, Content: tc.toolContentAfter}) } - warnings := requireResponsesInputJSON(t, prompt, tc.store, tc.want) + warnings := requireResponsesInputJSON(t, prompt, tc.want) var messages []string for _, warning := range warnings { messages = append(messages, warning.Message) diff --git a/providers/openai/tool_result_media_test.go b/providers/openai/tool_result_media_test.go index aa86cbd67..c24b8dafa 100644 --- a/providers/openai/tool_result_media_test.go +++ b/providers/openai/tool_result_media_test.go @@ -328,7 +328,7 @@ func TestToResponsesPrompt_MediaToolResult_ImagePNG(t *testing.T) { }, } - input, warnings, err := toResponsesPrompt(prompt, "system", false) + input, warnings, err := toResponsesPrompt(prompt, "system") require.NoError(t, err) require.Empty(t, warnings) // Assistant function call + function_call_output + synthetic user image @@ -373,7 +373,7 @@ func TestToResponsesPrompt_MediaToolResult_UnsupportedMediaType(t *testing.T) { }, } - input, warnings, err := toResponsesPrompt(prompt, "system", false) + input, warnings, err := toResponsesPrompt(prompt, "system") require.NoError(t, err) // Assistant function call + function_call_output, but no synthetic user From 8a6fe75c397402c1ffc5cd16fc84822a2e2ed90f Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 29 Oct 2026 23:17:26 +0000 Subject: [PATCH 3/6] test(providers/openai): name the reasoning skip test for what it checks --- providers/openai/openai_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/openai/openai_test.go b/providers/openai/openai_test.go index 9197ae30f..3e65537f6 100644 --- a/providers/openai/openai_test.go +++ b/providers/openai/openai_test.go @@ -4709,7 +4709,7 @@ func TestResponsesToPrompt_WebSearchProviderExecutedToolResults(t *testing.T) { require.Nil(t, input[1].OfItemReference) } -func TestResponsesToPrompt_ReasoningWithoutEncryptedContent(t *testing.T) { +func TestResponsesToPrompt_SkipsReasoningThatCannotGoInline(t *testing.T) { t.Parallel() encryptedContent := "gAAAAABpvAwtDPh5dSXW86hwbwoTo4DJHANQ" From 13e36ee99d17740284a43baf0f23601064ac85bf Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 29 Oct 2026 23:19:15 +0000 Subject: [PATCH 4/6] refactor(providers/openai): drop unreachable item_reference check --- providers/openai/openai_test.go | 4 ++-- providers/openai/responses_language_model.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/providers/openai/openai_test.go b/providers/openai/openai_test.go index 3e65537f6..3e37b9072 100644 --- a/providers/openai/openai_test.go +++ b/providers/openai/openai_test.go @@ -4783,8 +4783,8 @@ func TestResponsesToPrompt_SkipsReasoningThatCannotGoInline(t *testing.T) { require.NoError(t, err) require.Empty(t, warnings) - // Without an ItemID we cannot reference the reasoning - // item. Order: user, assistant text, user. + // Without an ItemID the reasoning cannot be sent. Order: user, + // assistant text, user. require.Len(t, input, 3) for _, item := range input { require.Nil(t, item.OfReasoning) diff --git a/providers/openai/responses_language_model.go b/providers/openai/responses_language_model.go index bfbefc10d..e3c190e24 100644 --- a/providers/openai/responses_language_model.go +++ b/providers/openai/responses_language_model.go @@ -1116,7 +1116,7 @@ func hasVisibleResponsesUserContent(content responses.ResponseInputMessageConten func hasVisibleResponsesAssistantContent(items []responses.ResponseInputItemUnionParam, startIdx int) bool { // Check if we added any assistant content parts from this message for i := startIdx; i < len(items); i++ { - if items[i].OfMessage != nil || items[i].OfFunctionCall != nil || items[i].OfItemReference != nil || items[i].OfReasoning != nil || items[i].OfComputerCall != nil || items[i].OfWebSearchCall != nil { + if items[i].OfMessage != nil || items[i].OfFunctionCall != nil || items[i].OfReasoning != nil || items[i].OfComputerCall != nil || items[i].OfWebSearchCall != nil { return true } } From a4b7d52d936092a39b72e63940e0264ac367844e Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 29 Oct 2026 23:31:42 +0000 Subject: [PATCH 5/6] docs(providers/openai): say reasoning without replay data is skipped --- providers/openai/responses_language_model.go | 5 +++-- providers/openai/responses_options.go | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/providers/openai/responses_language_model.go b/providers/openai/responses_language_model.go index e3c190e24..2512ec528 100644 --- a/providers/openai/responses_language_model.go +++ b/providers/openai/responses_language_model.go @@ -683,8 +683,9 @@ func toResponsesPromptWithValidation(prompt fantasy.Prompt, systemMessageMode st input = append(input, item) } // Reasoning that cannot be sent inline is skipped, not - // referenced: a reference to an item OpenAI did not store - // fails the whole request. + // referenced: a reference to an item OpenAI does not hold, + // such as one from an unstored response, fails the whole + // request. continue case fantasy.ContentTypeToolResult: toolResultPart, ok := fantasy.AsContentType[fantasy.ToolResultPart](c) diff --git a/providers/openai/responses_options.go b/providers/openai/responses_options.go index 4abfe76f8..aea3b2545 100644 --- a/providers/openai/responses_options.go +++ b/providers/openai/responses_options.go @@ -150,8 +150,9 @@ type ResponsesReasoningMetadata struct { EncryptedContent *string `json:"encrypted_content"` Summary []string `json:"summary"` // Finalized marks metadata copied from the completed reasoning output - // item. Only finalized encrypted content is replayed inline; streaming - // placeholders and metadata persisted before this field existed are not. + // item. Only finalized metadata with encrypted content is replayed; + // other reasoning, such as streaming placeholders and metadata persisted + // before this field existed, is skipped. Finalized bool `json:"finalized,omitempty"` } From 7752a175f9fc4e12354d521a4108fccbae922aac Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 29 Oct 2026 23:31:42 +0000 Subject: [PATCH 6/6] test(providers/openai): cover the reasoning skip directly The item ID test now uses finalized encrypted reasoning, so only the ID check rejects it. A reasoning-only assistant message is dropped with the empty-message warning. The two Combined tests only rechecked the skip, and the store false row repeated the store true one. --- providers/openai/openai_test.go | 170 ++++-------------- .../openai/responses_reasoning_replay_test.go | 8 +- providers/openai/responses_web_search_test.go | 4 +- 3 files changed, 39 insertions(+), 143 deletions(-) diff --git a/providers/openai/openai_test.go b/providers/openai/openai_test.go index 3e37b9072..8d7100e27 100644 --- a/providers/openai/openai_test.go +++ b/providers/openai/openai_test.go @@ -3829,6 +3829,35 @@ func TestResponsesToPrompt_DropsEmptyMessages(t *testing.T) { require.Contains(t, warnings[0].Message, "dropping empty assistant message") }) + t.Run("should drop assistant messages with only reasoning that cannot be sent", func(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "Hello"}, + }, + }, + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + fantasy.ReasoningPart{ProviderOptions: fantasy.ProviderOptions{ + Name: &ResponsesReasoningMetadata{ItemID: "rs_1"}, + }}, + }, + }, + } + + input, warnings, err := toResponsesPrompt(prompt, "system") + + require.NoError(t, err) + + require.Len(t, input, 1, "should only have user message") + require.Len(t, warnings, 1) + require.Contains(t, warnings[0].Message, "dropping empty assistant message") + }) + t.Run("should keep assistant messages with text content", func(t *testing.T) { t.Parallel() @@ -4764,7 +4793,11 @@ func TestResponsesToPrompt_SkipsReasoningThatCannotGoInline(t *testing.T) { fantasy.ReasoningPart{ Text: "thinking", ProviderOptions: fantasy.ProviderOptions{ - Name: &ResponsesReasoningMetadata{Summary: []string{}}, + Name: &ResponsesReasoningMetadata{ + EncryptedContent: &encryptedContent, + Summary: []string{}, + Finalized: true, + }, }, }, fantasy.TextPart{Text: "4"}, @@ -4783,8 +4816,7 @@ func TestResponsesToPrompt_SkipsReasoningThatCannotGoInline(t *testing.T) { require.NoError(t, err) require.Empty(t, warnings) - // Without an ItemID the reasoning cannot be sent. Order: user, - // assistant text, user. + // user, assistant text, user. require.Len(t, input, 3) for _, item := range input { require.Nil(t, item.OfReasoning) @@ -4810,138 +4842,6 @@ func TestResponsesToPrompt_SkipsReasoningThatCannotGoInline(t *testing.T) { }) } -func TestResponsesToPrompt_ReasoningWithWebSearchCombined(t *testing.T) { - t.Parallel() - - reasoningItemID := "rs_002" - webSearchItemID := "ws_002" - - prompt := fantasy.Prompt{ - { - Role: fantasy.MessageRoleUser, - Content: []fantasy.MessagePart{ - fantasy.TextPart{Text: "What is the weather in San Francisco?"}, - }, - }, - { - Role: fantasy.MessageRoleAssistant, - Content: []fantasy.MessagePart{ - fantasy.ReasoningPart{ - Text: "I should look this up.", - ProviderOptions: fantasy.ProviderOptions{ - Name: &ResponsesReasoningMetadata{ - ItemID: reasoningItemID, - Summary: []string{}, - }, - }, - }, - fantasy.ToolCallPart{ - ToolCallID: webSearchItemID, - ToolName: "web_search", - ProviderExecuted: true, - }, - fantasy.ToolResultPart{ - ToolCallID: webSearchItemID, - ProviderExecuted: true, - ProviderOptions: fantasy.ProviderOptions{ - Name: &WebSearchCallMetadata{ - ItemID: webSearchItemID, - Status: "completed", - Action: &WebSearchAction{Type: "search", Query: "weather in San Francisco"}, - }, - }, - }, - fantasy.TextPart{Text: "Sunny."}, - }, - }, - { - Role: fantasy.MessageRoleUser, - Content: []fantasy.MessagePart{ - fantasy.TextPart{Text: "And Tokyo?"}, - }, - }, - } - - input, warnings, err := toResponsesPrompt(prompt, "system") - - require.NoError(t, err) - require.Empty(t, warnings) - - // Reasoning without encrypted content is skipped. Order: user, - // web_search_call, assistant text, user. - require.Len(t, input, 4) - require.NotNil(t, input[1].OfWebSearchCall) - for _, item := range input { - require.Nil(t, item.OfItemReference) - require.Nil(t, item.OfReasoning) - } -} - -func TestResponsesToPrompt_ReasoningWithFunctionCallCombined(t *testing.T) { - t.Parallel() - - reasoningItemID := "rs_003" - functionCallID := "call_003" - - prompt := fantasy.Prompt{ - { - Role: fantasy.MessageRoleUser, - Content: []fantasy.MessagePart{ - fantasy.TextPart{Text: "compute 1+1"}, - }, - }, - { - Role: fantasy.MessageRoleAssistant, - Content: []fantasy.MessagePart{ - fantasy.ReasoningPart{ - Text: "I'll call add.", - ProviderOptions: fantasy.ProviderOptions{ - Name: &ResponsesReasoningMetadata{ - ItemID: reasoningItemID, - Summary: []string{}, - }, - }, - }, - fantasy.ToolCallPart{ - ToolCallID: functionCallID, - ToolName: "add", - Input: `{"a":1,"b":1}`, - }, - }, - }, - { - Role: fantasy.MessageRoleTool, - Content: []fantasy.MessagePart{ - fantasy.ToolResultPart{ - ToolCallID: functionCallID, - Output: fantasy.ToolResultOutputContentText{Text: "2"}, - }, - }, - }, - { - Role: fantasy.MessageRoleUser, - Content: []fantasy.MessagePart{ - fantasy.TextPart{Text: "thanks"}, - }, - }, - } - - input, warnings, err := toResponsesPrompt(prompt, "system") - - require.NoError(t, err) - require.Empty(t, warnings) - - // Reasoning without encrypted content is skipped. Order: user, - // function_call(call_003), function_call_output(call_003), user. - require.Len(t, input, 4) - - require.NotNil(t, input[1].OfFunctionCall) - require.Equal(t, functionCallID, input[1].OfFunctionCall.CallID) - - require.NotNil(t, input[2].OfFunctionCallOutput) - require.Equal(t, functionCallID, input[2].OfFunctionCallOutput.CallID) -} - func TestResponsesStream_RequiresTerminalEventBeforeFinish(t *testing.T) { t.Parallel() diff --git a/providers/openai/responses_reasoning_replay_test.go b/providers/openai/responses_reasoning_replay_test.go index e227f6ae3..9c27fab6c 100644 --- a/providers/openai/responses_reasoning_replay_test.go +++ b/providers/openai/responses_reasoning_replay_test.go @@ -124,7 +124,8 @@ func TestResponsesReplay_FinalizedReasoningInput(t *testing.T) { encrypted *string summary []string webSearch bool - // unfinalized marks metadata saved before Finalized existed. + // unfinalized clears Finalized, as in streaming placeholders and + // metadata saved before Finalized existed. unfinalized bool // want is the reasoning input item; nil means none is sent. want map[string]any @@ -164,11 +165,6 @@ func TestResponsesReplay_FinalizedReasoningInput(t *testing.T) { encrypted: new("enc-final"), unfinalized: true, }, - { - name: "unfinalized store false", - encrypted: new("enc-final"), - unfinalized: true, - }, { name: "store true before web search", store: true, diff --git a/providers/openai/responses_web_search_test.go b/providers/openai/responses_web_search_test.go index e53c048e9..b7cb73c8f 100644 --- a/providers/openai/responses_web_search_test.go +++ b/providers/openai/responses_web_search_test.go @@ -590,7 +590,7 @@ func TestResponsesToPrompt_WebSearchCallReplay(t *testing.T) { want: `[` + userJSON + `,` + searchJSON + `]`, }, { - name: "Pre62SearchWithoutStatus", + name: "LegacySearchWithoutStatus", content: append( webSearchCallParts("ws_01", &WebSearchCallMetadata{ ItemID: "ws_01", @@ -603,7 +603,7 @@ func TestResponsesToPrompt_WebSearchCallReplay(t *testing.T) { { // Before #62 only Type and Query were saved. The API accepts a // find_in_page action without url and pattern. - name: "Pre62FindInPageWithoutStatus", + name: "LegacyFindInPageWithoutStatus", content: append( webSearchCallParts("ws_01", &WebSearchCallMetadata{ ItemID: "ws_01",