diff --git a/backend/internal/service/openai_responses_input_compat.go b/backend/internal/service/openai_responses_input_compat.go index e8c4a8116d..29619bc4dd 100644 --- a/backend/internal/service/openai_responses_input_compat.go +++ b/backend/internal/service/openai_responses_input_compat.go @@ -7,7 +7,8 @@ import ( const openAIResponsesInputTextMaxChars = 10000000 // sanitizeOpenAIResponsesOrphanToolOutputs removes tool-output items that have -// no matching call or item reference anywhere in the current input. +// no matching call or item reference anywhere in the current input. Named +// function outputs without a call ID are standalone inputs, not orphan results. func sanitizeOpenAIResponsesOrphanToolOutputs(reqBody map[string]any, input []any, hasPreviousResponseID bool) bool { if len(input) == 0 || hasPreviousResponseID { return false @@ -45,6 +46,15 @@ func sanitizeOpenAIResponsesOrphanToolOutputs(reqBody map[string]any, input []an } callID := strings.TrimSpace(firstNonEmptyString(item["call_id"])) + // Codex sends externally supplied inputs (such as task delegation) as + // named function outputs without a preceding function call. Preserve + // their native type, namespace and output instead of silently dropping + // the request that started the turn. + if callID == "" && strings.TrimSpace(firstNonEmptyString(item["type"])) == "function_call_output" && + strings.TrimSpace(firstNonEmptyString(item["name"])) != "" { + normalized = append(normalized, rawItem) + continue + } _, hasToolCall := toolCallIDs[callID] _, hasReference := referenceIDs[callID] if callID != "" && (hasToolCall || hasReference) { diff --git a/backend/internal/service/openai_responses_input_compat_test.go b/backend/internal/service/openai_responses_input_compat_test.go index fc9fa7418a..e0d01c03d7 100644 --- a/backend/internal/service/openai_responses_input_compat_test.go +++ b/backend/internal/service/openai_responses_input_compat_test.go @@ -2,6 +2,8 @@ package service import ( "context" + "encoding/json" + "fmt" "net/http" "strings" "testing" @@ -11,6 +13,21 @@ import ( ) func TestSanitizeOpenAIResponsesOrphanToolOutputs(t *testing.T) { + t.Run("named standalone inputs do not legitimize orphan results", func(t *testing.T) { + named := map[string]any{"type": "function_call_output", "name": "send_message_to_thread", "namespace": "codex_app", "output": "delegation"} + input := []any{ + named, + map[string]any{"type": "function_call_output", "output": "missing name and call id"}, + map[string]any{"type": "function_call_output", "name": " ", "output": "blank name"}, + map[string]any{"type": "function_call_output", "name": "send_message_to_thread", "call_id": "missing", "output": "orphan result"}, + map[string]any{"type": "custom_tool_call_output", "name": "apply_patch", "output": "missing call id"}, + } + reqBody := map[string]any{"input": input} + + require.True(t, sanitizeOpenAIResponsesOrphanToolOutputs(reqBody, input, false)) + require.Equal(t, []any{named}, reqBody["input"]) + }) + t.Run("preserves matches regardless of item order", func(t *testing.T) { input := []any{ map[string]any{"type": "tool_search_output", "call_id": "search_1", "output": "first"}, @@ -71,6 +88,48 @@ func TestSanitizeOpenAIResponsesOrphanToolOutputs(t *testing.T) { }) } +func TestWebSocketCompatibilityPreservesNamedDelegation(t *testing.T) { + for _, toolName := range []string{"create_thread", "send_message_to_thread"} { + for _, withHistory := range []bool{false, true} { + for _, previousResponseID := range []string{"", "resp_previous"} { + name := fmt.Sprintf("%s/history=%t/previous=%t", toolName, withHistory, previousResponseID != "") + t.Run(name, func(t *testing.T) { + input := []any{} + if withHistory { + input = append(input, + map[string]any{"type": "function_call", "call_id": "fc_history", "name": "lookup", "arguments": "{}"}, + map[string]any{"type": "function_call_output", "call_id": "fc_history", "output": "historical result"}, + ) + } + input = append(input, + map[string]any{"type": "message", "role": "user", "content": []any{map[string]any{"type": "input_text", "text": "Updated environment context."}}}, + map[string]any{"type": "function_call_output", "name": toolName, "namespace": "codex_app", "output": "\n source-task\n Reply with DELEGATION_OK.\n"}, + ) + reqBody := map[string]any{"type": "response.create", "model": "gpt-5.5", "input": input} + if previousResponseID != "" { + reqBody["previous_response_id"] = previousResponseID + } + body, err := json.Marshal(reqBody) + require.NoError(t, err) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + + normalized, _, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account, false) + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal(normalized, &got)) + require.Equal(t, input, got["input"], "preserve the delegation envelope, native type and history in order") + require.Equal(t, reqBody["previous_response_id"], got["previous_response_id"]) + + again, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, account, false) + require.NoError(t, err) + require.False(t, changed, "normalization must be idempotent") + require.JSONEq(t, string(normalized), string(again)) + }) + } + } + } +} + func TestOpenAIResponsesInputTextIsNeverSilentlyTruncated(t *testing.T) { atLimit := strings.Repeat("z", openAIResponsesInputTextMaxChars) oversized := strings.Repeat("a", openAIResponsesInputTextMaxChars) + "δΈ­"