diff --git a/backend/internal/handler/openai_delegation_bootstrap_test.go b/backend/internal/handler/openai_delegation_bootstrap_test.go
new file mode 100644
index 0000000000..9e30752aa6
--- /dev/null
+++ b/backend/internal/handler/openai_delegation_bootstrap_test.go
@@ -0,0 +1,176 @@
+package handler
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
+)
+
+const delegationEnvelope = `thread-1do the work`
+
+func TestNormalizeCodexDelegationBootstrapSupportedTools(t *testing.T) {
+ tests := []struct {
+ name string
+ namespace string
+ tool string
+ callID string
+ }{
+ {name: "app create missing call id", namespace: "codex_app", tool: "create_thread"},
+ {name: "app send empty call id", namespace: "codex_app", tool: "send_message_to_thread", callID: `,"call_id":""`},
+ {name: "tui create whitespace call id", namespace: "codex_tui", tool: "create_thread", callID: `,"call_id":" "`},
+ {name: "tui send missing call id", namespace: "codex_tui", tool: "send_message_to_thread"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"` + tt.namespace + `","name":"` + tt.tool + `","output":"` + delegationEnvelope + `"` + tt.callID + `}]}`)
+ 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, delegationEnvelope, gjson.GetBytes(got, "input.0.content.0.text").String())
+ require.False(t, gjson.GetBytes(got, "input.0.call_id").Exists())
+ })
+ }
+}
+
+func TestNormalizeCodexDelegationBootstrapRejectsUnsafeShapes(t *testing.T) {
+ tests := []struct {
+ name string
+ item string
+ extra string
+ }{
+ {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"}`},
+ {name: "mixed invalid delegation output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"not an envelope"}`},
+ {name: "non string call id", item: `{"type":"function_call_output","call_id":42,"name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
+ {name: "padded type", item: `{"type":" function_call_output ","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
+ {name: "padded name", item: `{"type":"function_call_output","name":" create_thread ","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
+ {name: "padded namespace", item: `{"type":"function_call_output","name":"create_thread","namespace":" codex_app ","output":"` + delegationEnvelope + `"}`},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ body := []byte(`{"model":"gpt-5","input":[` + tt.item + `]` + tt.extra + `}`)
+ got, changed := normalizeCodexDelegationBootstrap(body)
+ require.False(t, changed)
+ require.Equal(t, body, got)
+ })
+ }
+}
+
+func TestNormalizeCodexDelegationBootstrapPreviousResponseID(t *testing.T) {
+ tests := []struct {
+ name string
+ value string
+ changed bool
+ }{
+ {name: "absent", changed: true},
+ {name: "blank string", value: `,"previous_response_id":" "`, changed: true},
+ {name: "non-empty string", value: `,"previous_response_id":"resp-1"`},
+ {name: "null", value: `,"previous_response_id":null`},
+ {name: "number", value: `,"previous_response_id":42`},
+ {name: "boolean", value: `,"previous_response_id":false`},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]` + tt.value + `}`)
+ got, changed := normalizeCodexDelegationBootstrap(body)
+ require.Equal(t, tt.changed, changed)
+ if !tt.changed {
+ require.Equal(t, body, got)
+ }
+ })
+ }
+}
+
+func TestNormalizeCodexDelegationBootstrapRejectsDuplicateMembers(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ }{
+ {
+ name: "top-level previous response id",
+ body: `{"model":"gpt-5","previous_response_id":"","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`,
+ },
+ {
+ name: "candidate discriminator",
+ body: `{"model":"gpt-5","input":[{"type":"message","type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`,
+ },
+ {
+ name: "candidate call id",
+ body: `{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","call_id":"call-1","call_id":"","output":"` + delegationEnvelope + `"}]}`,
+ },
+ {
+ name: "candidate output",
+ body: `{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"not a delegation","output":"` + delegationEnvelope + `"}]}`,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ body := []byte(tt.body)
+ got, changed := normalizeCodexDelegationBootstrap(body)
+ require.False(t, changed)
+ require.Equal(t, body, got)
+ })
+ }
+}
+
+func TestNormalizeCodexDelegationBootstrapPreservesExactNumbers(t *testing.T) {
+ body := []byte(`{"model":"gpt-5","metadata":{"integer":9007199254740993},"input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`)
+ got, changed := normalizeCodexDelegationBootstrap(body)
+ require.True(t, changed)
+ require.Equal(t, "9007199254740993", gjson.GetBytes(got, "metadata.integer").Raw)
+}
+
+func TestNormalizeCodexDelegationBootstrapRequiresCompleteEnvelope(t *testing.T) {
+ malformed := []string{
+ `thread-1`,
+ `work`,
+ `thread-1 `,
+ `prefixthread-1work`,
+ `thread-1worksuffix`,
+ `thread-1workx`,
+ `thread-1work`,
+ `thread-1work`,
+ `thread-1work`,
+ `thread-1work`,
+ `thread-1work`,
+ `thread-1work`,
+ `thread-1work`,
+ }
+ for _, envelope := range malformed {
+ body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":` + mustJSON(t, envelope) + `}]}`)
+ got, changed := normalizeCodexDelegationBootstrap(body)
+ require.False(t, changed, envelope)
+ require.Equal(t, body, got)
+ }
+}
+
+func TestNormalizeCodexDelegationBootstrapPreservesOrderAndIsIdempotent(t *testing.T) {
+ body := []byte(`{"model":"gpt-5","input":[{"type":"message","role":"user","content":"before"},{"type":"function_call_output","namespace":"codex_tui","name":"send_message_to_thread","output":"` + delegationEnvelope + `"},{"type":"message","role":"user","content":"after"}]}`)
+ got, changed := normalizeCodexDelegationBootstrap(body)
+ require.True(t, changed)
+ require.Equal(t, "before", gjson.GetBytes(got, "input.0.content").String())
+ require.Equal(t, delegationEnvelope, gjson.GetBytes(got, "input.1.content.0.text").String())
+ require.Equal(t, "after", gjson.GetBytes(got, "input.2.content").String())
+
+ again, changedAgain := normalizeCodexDelegationBootstrap(got)
+ require.False(t, changedAgain)
+ require.Equal(t, got, again)
+}
+
+func mustJSON(t *testing.T, value string) string {
+ t.Helper()
+ encoded, err := json.Marshal(value)
+ require.NoError(t, err)
+ return string(encoded)
+}
diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go
index e57cc759e6..40763b1976 100644
--- a/backend/internal/handler/openai_gateway_handler.go
+++ b/backend/internal/handler/openai_gateway_handler.go
@@ -1,10 +1,13 @@
package handler
import (
+ "bytes"
"context"
"encoding/json"
+ "encoding/xml"
"errors"
"fmt"
+ "io"
"net/http"
"runtime/debug"
"strconv"
@@ -435,6 +438,12 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed {
body = cappedBody
}
+ if normalizedBody, changed := normalizeCodexDelegationBootstrap(body); changed {
+ body = normalizedBody
+ reqLog.Info("openai.codex_delegation_bootstrap_normalized",
+ zap.String("normalization", "call_output_to_user_message"),
+ )
+ }
reqStream, ok := parseOpenAICompatibleStream(body)
if !ok {
@@ -1581,6 +1590,229 @@ func (h *OpenAIGatewayHandler) validateFunctionCallOutputRequest(c *gin.Context,
return false
}
+func normalizeCodexDelegationBootstrap(body []byte) ([]byte, bool) {
+ if !hasUniqueJSONMembers(body) {
+ return body, false
+ }
+ decoder := json.NewDecoder(bytes.NewReader(body))
+ decoder.UseNumber()
+ var request map[string]any
+ if err := decoder.Decode(&request); err != nil {
+ return body, false
+ }
+ if previousResponseID, exists := request["previous_response_id"]; exists {
+ value, ok := previousResponseID.(string)
+ if !ok || strings.TrimSpace(value) != "" {
+ return body, false
+ }
+ }
+ input, ok := request["input"].([]any)
+ if !ok {
+ 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.
+ 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) {
+ callIDValue, exists := item["call_id"]
+ callID, isString := callIDValue.(string)
+ if exists && (!isString || strings.TrimSpace(callID) != "") {
+ return body, false
+ }
+ if !isCodexDelegationCandidate(item) {
+ return body, false
+ }
+ }
+ }
+
+ changed := false
+ for i, raw := range input {
+ item, ok := raw.(map[string]any)
+ if !ok || !isCodexDelegationCandidate(item) {
+ continue
+ }
+ output, ok := item["output"].(string)
+ if !ok || !validCodexDelegationEnvelope(output) {
+ continue
+ }
+ input[i] = map[string]any{
+ "type": "message",
+ "role": "user",
+ "content": []any{map[string]any{
+ "type": "input_text",
+ "text": output,
+ }},
+ }
+ changed = true
+ }
+ if !changed {
+ return body, false
+ }
+ normalized, err := json.Marshal(request)
+ if err != nil {
+ return body, false
+ }
+ return normalized, true
+}
+
+func hasUniqueJSONMembers(body []byte) bool {
+ decoder := json.NewDecoder(bytes.NewReader(body))
+ if !consumeUniqueJSONValue(decoder) {
+ return false
+ }
+ _, err := decoder.Token()
+ return err == io.EOF
+}
+
+func consumeUniqueJSONValue(decoder *json.Decoder) bool {
+ token, err := decoder.Token()
+ if err != nil {
+ return false
+ }
+ delim, ok := token.(json.Delim)
+ if !ok {
+ return true
+ }
+
+ switch delim {
+ case '{':
+ members := make(map[string]struct{})
+ for decoder.More() {
+ keyToken, err := decoder.Token()
+ if err != nil {
+ return false
+ }
+ key, ok := keyToken.(string)
+ if !ok {
+ return false
+ }
+ if _, duplicate := members[key]; duplicate {
+ return false
+ }
+ members[key] = struct{}{}
+ if !consumeUniqueJSONValue(decoder) {
+ return false
+ }
+ }
+ end, err := decoder.Token()
+ return err == nil && end == json.Delim('}')
+ case '[':
+ for decoder.More() {
+ if !consumeUniqueJSONValue(decoder) {
+ return false
+ }
+ }
+ end, err := decoder.Token()
+ return err == nil && end == json.Delim(']')
+ default:
+ return false
+ }
+}
+
+func isResponsesCallOutputType(typ string) bool {
+ return strings.HasSuffix(typ, "_call_output") || typ == "tool_search_output"
+}
+
+func isCodexDelegationCandidate(item map[string]any) bool {
+ if stringField(item, "type") != "function_call_output" ||
+ !isCodexDelegationTool(stringField(item, "namespace"), stringField(item, "name")) {
+ return false
+ }
+ output, ok := item["output"].(string)
+ return ok && validCodexDelegationEnvelope(output)
+}
+
+func stringField(item map[string]any, key string) string {
+ value, _ := item[key].(string)
+ return value
+}
+
+func isCodexDelegationTool(namespace, name string) bool {
+ return (namespace == "codex_app" || namespace == "codex_tui") &&
+ (name == "create_thread" || name == "send_message_to_thread")
+}
+
+func validCodexDelegationEnvelope(value string) bool {
+ decoder := xml.NewDecoder(strings.NewReader(value))
+ var rootSeen, sourceSeen, inputSeen bool
+ var childName string
+ var childText bytes.Buffer
+ depth := 0
+ for {
+ token, err := decoder.Token()
+ if err == io.EOF {
+ return rootSeen && depth == 0 && sourceSeen && inputSeen
+ }
+ if err != nil {
+ return false
+ }
+ switch current := token.(type) {
+ case xml.StartElement:
+ depth++
+ if current.Name.Space != "" || len(current.Attr) != 0 || (depth == 1 && current.Name.Local != "codex_delegation") || depth > 2 {
+ return false
+ }
+ if depth == 1 {
+ if rootSeen {
+ return false
+ }
+ rootSeen = true
+ continue
+ }
+ if current.Name.Local != "source_thread_id" && current.Name.Local != "input" {
+ return false
+ }
+ childName = current.Name.Local
+ childText.Reset()
+ case xml.EndElement:
+ if current.Name.Space != "" {
+ return false
+ }
+ if depth == 2 {
+ if current.Name.Local != childName || strings.TrimSpace(childText.String()) == "" {
+ return false
+ }
+ if childName == "source_thread_id" {
+ if sourceSeen {
+ return false
+ }
+ sourceSeen = true
+ } else {
+ if inputSeen {
+ return false
+ }
+ inputSeen = true
+ }
+ childName = ""
+ }
+ depth--
+ if depth < 0 {
+ return false
+ }
+ case xml.CharData:
+ if depth == 2 {
+ _, _ = childText.Write(current)
+ } else if len(bytes.TrimSpace(current)) != 0 {
+ return false
+ }
+ case xml.Comment:
+ return false
+ case xml.ProcInst, xml.Directive:
+ return false
+ }
+ }
+}
+
func (h *OpenAIGatewayHandler) acquireResponsesUserSlot(
c *gin.Context,
userID int64,