From af90a9bd1fbcee62c5e8a87dbb3bd71ee4216f27 Mon Sep 17 00:00:00 2001 From: NellPoi Date: Thu, 3 Sep 2026 11:39:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(openai):=20=E5=85=BC=E5=AE=B9=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E4=BB=BB=E5=8A=A1=E5=A7=94=E6=B4=BE=E7=BB=AD=E8=81=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openai_delegation_bootstrap_test.go | 18 ++++++--- .../handler/openai_gateway_handler.go | 37 ++++++++++++------- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/backend/internal/handler/openai_delegation_bootstrap_test.go b/backend/internal/handler/openai_delegation_bootstrap_test.go index 9e30752aa6..38854c0781 100644 --- a/backend/internal/handler/openai_delegation_bootstrap_test.go +++ b/backend/internal/handler/openai_delegation_bootstrap_test.go @@ -43,11 +43,7 @@ func TestNormalizeCodexDelegationBootstrapRejectsUnsafeShapes(t *testing.T) { }{ {name: "ordinary missing call id", item: `{"type":"function_call_output","name":"other","namespace":"codex_app","output":"` + delegationEnvelope + `"}`}, {name: "valid call id", item: `{"type":"function_call_output","call_id":"call-1","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`}, - {name: "previous response", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`, extra: `,"previous_response_id":"resp-1"`}, - {name: "real call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call","call_id":"call-1"}`}, {name: "ambiguous call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call"}`}, - {name: "item reference context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"item_reference","id":"call-1"}`}, - {name: "built-in call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call","call_id":"call-1"}`}, {name: "ambiguous built-in call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call"}`}, {name: "mixed missing call id output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call_output","output":"done"}`}, {name: "mixed tool search output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"tool_search_output","output":"done"}`}, @@ -67,6 +63,18 @@ func TestNormalizeCodexDelegationBootstrapRejectsUnsafeShapes(t *testing.T) { } } +func TestNormalizeCodexDelegationBootstrapWithHistoricalContext(t *testing.T) { + body := []byte(`{"model":"gpt-5","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"send_message_to_thread","output":"` + delegationEnvelope + `"},{"type":"function_call","call_id":"call-1"},{"type":"function_call_output","call_id":"call-1","output":"done"},{"type":"item_reference","id":"item-1"},{"type":"computer_call","call_id":"call-2"}]}`) + + got, changed := normalizeCodexDelegationBootstrap(body) + require.True(t, changed) + require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String()) + require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String()) + require.Equal(t, "resp-1", gjson.GetBytes(got, "previous_response_id").String()) + require.Equal(t, "call-1", gjson.GetBytes(got, "input.2.call_id").String()) + require.Equal(t, "item-1", gjson.GetBytes(got, "input.3.id").String()) +} + func TestNormalizeCodexDelegationBootstrapPreviousResponseID(t *testing.T) { tests := []struct { name string @@ -75,7 +83,7 @@ func TestNormalizeCodexDelegationBootstrapPreviousResponseID(t *testing.T) { }{ {name: "absent", changed: true}, {name: "blank string", value: `,"previous_response_id":" "`, changed: true}, - {name: "non-empty string", value: `,"previous_response_id":"resp-1"`}, + {name: "non-empty string", value: `,"previous_response_id":"resp-1"`, changed: true}, {name: "null", value: `,"previous_response_id":null`}, {name: "number", value: `,"previous_response_id":42`}, {name: "boolean", value: `,"previous_response_id":false`}, diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index ce67e07e85..3636ba8331 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -1600,14 +1600,17 @@ func (h *OpenAIGatewayHandler) validateFunctionCallOutputRequest(c *gin.Context, } func normalizeCodexDelegationBootstrap(body []byte) ([]byte, bool) { - return normalizeCodexCallOutputBootstrap(body, isCodexDelegationCandidate) + // 已有任务通过 send_message_to_thread 唤醒时会携带 previous_response_id; + // 完整历史回放还会带有已配对的调用项。delegation 仍是客户端注入的用户输入, + // 不属于这些历史调用的结果,因此允许它与可明确配对的历史上下文共存。 + return normalizeCodexCallOutputBootstrap(body, isCodexDelegationCandidate, true) } func normalizeCodexAutomationBootstrap(body []byte) ([]byte, bool) { - return normalizeCodexCallOutputBootstrap(body, isCodexAutomationCandidate) + return normalizeCodexCallOutputBootstrap(body, isCodexAutomationCandidate, false) } -func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]any) bool) ([]byte, bool) { +func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]any) bool, allowHistoricalContext bool) ([]byte, bool) { if !hasUniqueJSONMembers(body) { return body, false } @@ -1619,7 +1622,7 @@ func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string] } if previousResponseID, exists := request["previous_response_id"]; exists { value, ok := previousResponseID.(string) - if !ok || strings.TrimSpace(value) != "" { + if !ok || (!allowHistoricalContext && strings.TrimSpace(value) != "") { return body, false } } @@ -1628,27 +1631,35 @@ func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string] return body, false } - // Any call/reference anchor makes a call-less output ambiguous. Responses - // built-ins follow the *_call / *_call_output naming convention, so classify - // by the wire type shape instead of maintaining an incomplete allowlist. + // Responses built-ins follow the *_call / *_call_output naming convention, + // so classify by the wire type shape instead of maintaining an incomplete + // allowlist. Delegation may coexist with historical anchors only when their + // IDs make them unambiguous; automation retains the bootstrap-only boundary. for _, raw := range input { item, ok := raw.(map[string]any) if !ok { continue } typ := stringField(item, "type") - if typ == "item_reference" || strings.HasSuffix(typ, "_call") { - return body, false - } - if isResponsesCallOutputType(typ) { + if isCandidate(item) { callIDValue, exists := item["call_id"] callID, isString := callIDValue.(string) if exists && (!isString || strings.TrimSpace(callID) != "") { return body, false } - if !isCandidate(item) { - return body, false + continue + } + if typ == "item_reference" { + if allowHistoricalContext && strings.TrimSpace(stringField(item, "id")) != "" { + continue } + return body, false + } + if strings.HasSuffix(typ, "_call") || isResponsesCallOutputType(typ) { + if allowHistoricalContext && strings.TrimSpace(stringField(item, "call_id")) != "" { + continue + } + return body, false } }