fix(openai): 兼容历史任务委派续聊

This commit is contained in:
NellPoi
2026-09-03 11:39:23 +08:00
parent 5097b31457
commit af90a9bd1f
2 changed files with 37 additions and 18 deletions
@@ -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`},
@@ -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
}
}