openai: accept plaintext-labeled Codex agent messages (#18329)

This commit is contained in:
Parth Sareen
2026-09-08 17:09:48 -07:00
committed by GitHub
parent cd1c5a145d
commit 86f7292934
6 changed files with 472 additions and 0 deletions
+205
View File
@@ -0,0 +1,205 @@
package proxy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/ollama/ollama/openai"
)
func TestNormalizeOllamaAgentMessagesPreservesConversation(t *testing.T) {
body := []byte(`{"model":"test:cloud","input":[
{"type":"message","role":"developer","content":"Environment"},
{"type":"agent_message","id":"amsg_initial","author":"/root","recipient":"/root/child","content":[{"type":"input_text","text":"Read the "},{"type":"input_text","text":"fixture.\n"}],"internal_chat_message_metadata_passthrough":{"turn_id":"initial-turn"}},
{"type":"function_call","call_id":"call_read","name":"read_file","arguments":"{}"},
{"type":"function_call_output","call_id":"call_read","output":"fixture contents"},
{"type":"agent_message","author":"/root/child","recipient":"/root","content":[{"type":"input_text","text":"Task finished."}]},
{"type":"agent_message","author":"/root","recipient":"/root/child","content":[{"type":"input_text","text":"Now return RCA_FOLLOWUP."}]}
]}`)
got, err := normalizeOllamaRequestBody(body, routingModel{})
if err != nil {
t.Fatal(err)
}
var request openai.ResponsesRequest
if err := json.Unmarshal(got, &request); err != nil {
t.Fatal(err)
}
chat, err := openai.FromResponsesRequest(request)
if err != nil {
t.Fatal(err)
}
wantRoles := []string{"system", "user", "assistant", "tool", "user", "user"}
wantContent := []string{"Environment", "Agent message from \"/root\" to \"/root/child\":\nRead the fixture.\n", "", "fixture contents", "Agent message from \"/root/child\" to \"/root\":\nTask finished.", "Agent message from \"/root\" to \"/root/child\":\nNow return RCA_FOLLOWUP."}
if len(chat.Messages) != len(wantRoles) {
t.Fatalf("got %d messages: %+v", len(chat.Messages), chat.Messages)
}
for i, msg := range chat.Messages {
if msg.Role != wantRoles[i] || msg.Content != wantContent[i] {
t.Errorf("message %d = %q %q; want %q %q", i, msg.Role, msg.Content, wantRoles[i], wantContent[i])
}
}
if len(chat.Messages[2].ToolCalls) != 1 || chat.Messages[2].ToolCalls[0].ID != "call_read" || chat.Messages[3].ToolCallID != "call_read" {
t.Fatal("tool call pairing changed")
}
var payload struct {
Input []map[string]json.RawMessage `json:"input"`
}
if err := json.Unmarshal(got, &payload); err != nil {
t.Fatal(err)
}
if string(payload.Input[1]["id"]) != `"amsg_initial"` || string(payload.Input[1]["internal_chat_message_metadata_passthrough"]) != `{"turn_id":"initial-turn"}` {
t.Fatalf("message metadata changed: %s", got)
}
again, err := normalizeOllamaRequestBody(got, routingModel{})
if err != nil || !bytes.Equal(got, again) {
t.Fatalf("normalization is not idempotent: %s, %v", again, err)
}
native, changed, err := normalizeNativeRequestBody(body)
if err != nil || changed || !bytes.Equal(native, body) {
t.Fatalf("native conversation changed: %s, %v", native, err)
}
}
func TestNormalizeOllamaAgentMessageRejectsIncompleteContent(t *testing.T) {
for _, tt := range []struct{ name, content, want string }{
{"unknown", `[{"type":"new_content","text":"secret-task"}]`, "unsupported Codex agent message content type"},
{"missing type", `[{"text":"secret-task"}]`, "unsupported Codex agent message content type"},
{"missing text", `[{"type":"input_text"}]`, "requires text"},
{"null text", `[{"type":"input_text","text":null}]`, "requires text"},
{"non-string text", `[{"type":"input_text","text":42}]`, "decode Codex agent message content"},
{"empty", `[]`, "requires author, recipient, and content"},
{"null", `null`, "requires author, recipient, and content"},
{"not array", `"secret-task"`, "decode Codex agent message"},
} {
t.Run(tt.name, func(t *testing.T) {
item := []byte(fmt.Sprintf(`{"type":"agent_message","author":"/root","recipient":"/root/child","content":%s}`, tt.content))
got, keep, err := normalizeOllamaInputItem(item)
if err == nil || !strings.Contains(err.Error(), tt.want) || keep || got != nil {
t.Fatalf("got %s, %v, %v", got, keep, err)
}
if strings.Contains(err.Error(), "secret-") {
t.Fatalf("content exposed in error: %v", err)
}
})
}
for _, field := range []string{"author", "recipient"} {
t.Run("missing "+field, func(t *testing.T) {
item := map[string]any{"type": "agent_message", "author": "/root", "recipient": "/root/child", "content": []any{map[string]string{"type": "input_text", "text": "task"}}}
delete(item, field)
raw, _ := json.Marshal(item)
if _, _, err := normalizeOllamaInputItem(raw); err == nil {
t.Fatalf("accepted message without %s", field)
}
})
}
}
func TestNormalizeOllamaAgentMessageAcceptsEncryptedContentAsText(t *testing.T) {
item := []byte(`{"type":"agent_message","author":"/root","recipient":"/root/child","content":[{"type":"input_text","text":"Payload:\n"},{"type":"encrypted_content","encrypted_content":"secret-task"},{"type":"input_text","text":"trailing instruction"}]}`)
got, keep, err := normalizeOllamaInputItem(item)
if err != nil || !keep {
t.Fatalf("normalize failed: %v", err)
}
var msg struct {
Type string `json:"type"`
Role string `json:"role"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(got, &msg); err != nil {
t.Fatal(err)
}
if msg.Type != "message" || msg.Role != "user" {
t.Fatalf("converted message = %q %q", msg.Type, msg.Role)
}
want := []struct{ Type, Text string }{
{"input_text", "Agent message from \"/root\" to \"/root/child\":\n"},
{"input_text", "Payload:\n"},
{"input_text", "secret-task"},
{"input_text", "trailing instruction"},
}
if len(msg.Content) != len(want) {
t.Fatalf("content parts = %+v", msg.Content)
}
for i, part := range msg.Content {
if part.Type != want[i].Type || part.Text != want[i].Text {
t.Errorf("part %d = %q %q; want %q %q", i, part.Type, part.Text, want[i].Type, want[i].Text)
}
}
}
func TestCodexDesktopEncryptedAgentMessageReachesOllamaAsText(t *testing.T) {
var bodies [][]byte
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Error(err)
}
bodies = append(bodies, body)
w.WriteHeader(http.StatusNoContent)
}))
defer upstream.Close()
h := newTestCodexDesktop(t, upstream.URL, upstream.URL, writeCatalog(t, "glm-5.3-flash:cloud", "glm-5.3:cloud"))
for _, model := range []string{"glm-5.3:cloud", "gpt-5.6-terra"} {
t.Run(model, func(t *testing.T) {
body := fmt.Sprintf(`{"model":%q,"input":[{"type":"message","role":"user","content":"old task"},{"type":"agent_message","author":"/root","recipient":"/root/child","content":[{"type":"input_text","text":"Payload:"},{"type":"encrypted_content","encrypted_content":"secret-cipher"}]}]}`, model)
req := httptest.NewRequest("POST", CodexDesktopPathPrefix+"/v1/responses", strings.NewReader(body))
req.RemoteAddr = "127.0.0.1:1234"
req.Header.Set("Authorization", "Bearer native-test")
req.Header.Set("ChatGPT-Account-ID", "test-account")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != 204 {
t.Fatalf("request = %d: %s", w.Code, w.Body)
}
forwarded := bodies[len(bodies)-1]
if !bytes.Contains(forwarded, []byte("secret-cipher")) {
t.Errorf("payload lost: %s", forwarded)
}
if model == "gpt-5.6-terra" {
if !bytes.Contains(forwarded, []byte(`"type":"agent_message"`)) {
t.Errorf("native transcript changed: %s", forwarded)
}
return
}
if bytes.Contains(forwarded, []byte(`"type":"agent_message"`)) {
t.Errorf("agent message not converted for Ollama: %s", forwarded)
}
if !bytes.Contains(forwarded, []byte(`\"type\":\"input_text\",\"text\":\"secret-cipher\"`)) &&
!bytes.Contains(forwarded, []byte(`"text":"secret-cipher"`)) {
t.Errorf("encrypted payload not flattened to text: %s", forwarded)
}
})
}
}
func TestAgentMessageEnvelopeMatchesOpenAI(t *testing.T) {
item := []byte(`{"type":"agent_message","author":"/root","recipient":"/root/child","content":[{"type":"input_text","text":"task"}]}`)
got, keep, err := normalizeOllamaInputItem(item)
if err != nil || !keep {
t.Fatalf("normalize failed: %v", err)
}
var msg struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(got, &msg); err != nil {
t.Fatal(err)
}
if len(msg.Content) == 0 {
t.Fatal("no content parts")
}
want := fmt.Sprintf(openai.AgentMessageEnvelopeFormat, "/root", "/root/child")
if msg.Content[0].Text != want {
t.Fatalf("envelope = %q; want %q (keep the proxy envelope in sync with openai.AgentMessageEnvelopeFormat)", msg.Content[0].Text, want)
}
}
+68
View File
@@ -344,6 +344,74 @@ func normalizeOllamaInputItem(item json.RawMessage) (json.RawMessage, bool, erro
return nil, false, fmt.Errorf("encode system message: %w", err)
}
return converted, true, nil
case "agent_message":
var message struct {
Author string `json:"author"`
Recipient string `json:"recipient"`
Content []json.RawMessage `json:"content"`
}
if err := json.Unmarshal(item, &message); err != nil {
return nil, false, fmt.Errorf("decode Codex agent message: %w", err)
}
if message.Author == "" || message.Recipient == "" || len(message.Content) == 0 {
return nil, false, fmt.Errorf("Codex agent message requires author, recipient, and content")
}
parts := make([]json.RawMessage, 0, len(message.Content))
for _, raw := range message.Content {
var content struct {
Type string `json:"type"`
Text *string `json:"text"`
EncryptedContent *string `json:"encrypted_content"`
}
if err := json.Unmarshal(raw, &content); err != nil {
return nil, false, fmt.Errorf("decode Codex agent message content: %w", err)
}
switch content.Type {
case "input_text":
if content.Text == nil {
return nil, false, fmt.Errorf("Codex agent message input_text requires text")
}
parts = append(parts, raw)
case "encrypted_content":
// Codex labels these payloads encrypted even when they are plain text.
if content.EncryptedContent == nil {
return nil, false, fmt.Errorf("Codex agent message encrypted_content requires a value")
}
text, err := json.Marshal(map[string]string{
"type": "input_text",
"text": *content.EncryptedContent,
})
if err != nil {
return nil, false, fmt.Errorf("encode Codex agent message text: %w", err)
}
parts = append(parts, text)
default:
return nil, false, fmt.Errorf("unsupported Codex agent message content type %q", content.Type)
}
}
// Keep the envelope in sync with openai.AgentMessageEnvelopeFormat.
envelope, err := json.Marshal(map[string]string{
"type": "input_text",
"text": fmt.Sprintf("Agent message from %q to %q:\n", message.Author, message.Recipient),
})
if err != nil {
return nil, false, fmt.Errorf("encode Codex agent message envelope: %w", err)
}
var converted map[string]json.RawMessage
if err := json.Unmarshal(item, &converted); err != nil {
return nil, false, fmt.Errorf("decode Codex agent message: %w", err)
}
converted["type"] = json.RawMessage(`"message"`)
converted["role"] = json.RawMessage(`"user"`)
delete(converted, "author")
delete(converted, "recipient")
converted["content"], err = json.Marshal(append([]json.RawMessage{envelope}, parts...))
if err != nil {
return nil, false, fmt.Errorf("encode Codex agent message content: %w", err)
}
body, err := json.Marshal(converted)
return body, true, err
case "function_call", "function_call_output":
return item, true, nil
case "tool_search_call", "tool_search_output", "compaction_trigger":
+81
View File
@@ -54,6 +54,15 @@ type ResponsesFileContent struct {
func (ResponsesFileContent) responsesContent() {}
// ResponsesEncryptedContent is content a provider labeled as encrypted; for
// Ollama-native conversations the value is plain text, which we accept as-is.
type ResponsesEncryptedContent struct {
Type string `json:"type"` // always "encrypted_content"
EncryptedContent string `json:"encrypted_content"`
}
func (ResponsesEncryptedContent) responsesContent() {}
type ResponsesInputMessage struct {
Type string `json:"type"` // always "message"
Role string `json:"role"` // one of `user`, `system`, `developer`
@@ -139,6 +148,12 @@ func unmarshalResponsesContent(data []byte) (ResponsesContent, error) {
return nil, err
}
return content, nil
case "encrypted_content":
var content ResponsesEncryptedContent
if err := json.Unmarshal(data, &content); err != nil {
return nil, err
}
return content, nil
default:
return nil, fmt.Errorf("unknown content type: %s", typeField.Type)
}
@@ -264,6 +279,55 @@ type ResponsesReasoningInput struct {
func (ResponsesReasoningInput) responsesInputItem() {}
// AgentMessageEnvelopeFormat is the routing prefix for converted agent
// messages; internal/proxy uses the same text (cross-pinned by tests).
const AgentMessageEnvelopeFormat = "Agent message from %q to %q:\n"
func agentMessageContent(author, recipient, content string) string {
if author == "" && recipient == "" {
return content
}
return fmt.Sprintf(AgentMessageEnvelopeFormat+"%s", author, recipient, content)
}
// ResponsesAgentMessageInput is a message passed between Codex agents in the
// multi-agent collaboration flow.
type ResponsesAgentMessageInput struct {
ID string `json:"id,omitempty"`
Type string `json:"type"` // always "agent_message"
Author string `json:"author"`
Recipient string `json:"recipient"`
Content []ResponsesContent `json:"content"`
}
func (ResponsesAgentMessageInput) responsesInputItem() {}
func (m *ResponsesAgentMessageInput) UnmarshalJSON(data []byte) error {
var aux struct {
ID string `json:"id"`
Type string `json:"type"`
Author string `json:"author"`
Recipient string `json:"recipient"`
Content []json.RawMessage `json:"content"`
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
m.ID = aux.ID
m.Type = aux.Type
m.Author = aux.Author
m.Recipient = aux.Recipient
m.Content = make([]ResponsesContent, 0, len(aux.Content))
for i, raw := range aux.Content {
content, err := unmarshalResponsesContent(raw)
if err != nil {
return fmt.Errorf("content[%d]: %w", i, err)
}
m.Content = append(m.Content, content)
}
return nil
}
// unmarshalResponsesInputItem unmarshals a single input item from JSON.
func unmarshalResponsesInputItem(data []byte) (ResponsesInputItem, error) {
var typeField struct {
@@ -324,6 +388,12 @@ func unmarshalResponsesInputItem(data []byte) (ResponsesInputItem, error) {
return nil, err
}
return call, nil
case "agent_message":
var agentMessage ResponsesAgentMessageInput
if err := json.Unmarshal(data, &agentMessage); err != nil {
return nil, err
}
return agentMessage, nil
case "compaction":
var compaction ResponsesCompactionItem
if err := json.Unmarshal(data, &compaction); err != nil {
@@ -498,6 +568,15 @@ func FromResponsesRequest(r ResponsesRequest) (*api.ChatRequest, error) {
case ResponsesReasoningInput:
// Store thinking to merge with the next assistant message
pendingThinking = v.EncryptedContent
case ResponsesAgentMessageInput:
content, _, err := convertResponsesContent(v.Content)
if err != nil {
return nil, err
}
messages = append(messages, api.Message{
Role: "user",
Content: agentMessageContent(v.Author, v.Recipient, content),
})
case ResponsesInputMessage:
msg, err := convertInputMessage(v)
if err != nil {
@@ -956,6 +1035,8 @@ func convertResponsesContent(contents []ResponsesContent) (string, []api.ImageDa
content += v.Text
case ResponsesOutputTextContent:
content += v.Text
case ResponsesEncryptedContent:
content += v.EncryptedContent
case ResponsesImageContent:
if v.ImageURL == "" {
continue // Skip if no URL (FileID not supported)
+9
View File
@@ -531,6 +531,15 @@ func compactionMessage(item ResponsesInputItem) (api.Message, string, error) {
thinking = "[opaque reasoning state omitted during Ollama compaction]"
}
return api.Message{Role: "assistant", Thinking: thinking}, "reasoning", nil
case ResponsesAgentMessageInput:
content, _, err := convertResponsesContent(value.Content)
if err != nil {
return api.Message{}, "", err
}
return api.Message{
Role: "user",
Content: agentMessageContent(value.Author, value.Recipient, content),
}, "message", nil
case ResponsesCompactionItem, ResponsesCompactionTrigger:
return api.Message{}, "", errors.New("unexpected compaction control item")
default:
+27
View File
@@ -3,6 +3,7 @@ package openai
import (
"bytes"
"encoding/json"
"fmt"
"slices"
"strings"
"testing"
@@ -790,3 +791,29 @@ func TestCompactionStreamContainsExactlyOneCompletedItem(t *testing.T) {
t.Fatalf("done=%d completed=%d events=%+v", done, completed, events)
}
}
func TestCompactionMessageAgentMessage(t *testing.T) {
var item ResponsesAgentMessageInput
err := json.Unmarshal([]byte(`{
"type": "agent_message",
"author": "/root",
"recipient": "/root/worker",
"content": [
{"type": "input_text", "text": "Message Type: NEW_TASK\nPayload:\n"},
{"type": "encrypted_content", "encrypted_content": "analyze the parser"}
]
}`), &item)
if err != nil {
t.Fatal(err)
}
msg, kind, err := compactionMessage(item)
if err != nil {
t.Fatal(err)
}
want := AgentMessageEnvelopeFormat + "Message Type: NEW_TASK\nPayload:\nanalyze the parser"
want = fmt.Sprintf(want, "/root", "/root/worker")
if kind != "message" || msg.Role != "user" || msg.Content != want {
t.Fatalf("kind=%q msg=%#v", kind, msg)
}
}
+82
View File
@@ -392,6 +392,41 @@ func TestFromResponsesRequestMergesMessageAfterFunctionCall(t *testing.T) {
}
}
func TestFromResponsesRequestAgentMessage(t *testing.T) {
var req ResponsesRequest
err := json.Unmarshal([]byte(`{
"model": "test-model",
"input": [
{"role": "user", "content": "Delegate the parser analysis."},
{"type": "function_call", "call_id": "call_spawn", "namespace": "collaboration", "name": "spawn_agent", "arguments": "{\"task_name\":\"worker\"}"},
{"type": "function_call_output", "call_id": "call_spawn", "output": "{\"task_name\":\"/root/worker\"}"},
{"type": "agent_message", "id": "amsg_1", "author": "/root", "recipient": "/root/worker", "content": [
{"type": "input_text", "text": "Message Type: NEW_TASK\nTask name: /root/worker\nSender: /root\nPayload:\n"},
{"type": "encrypted_content", "encrypted_content": "Analyze the parser module and report issues."}
]}
]
}`), &req)
if err != nil {
t.Fatal(err)
}
chat, err := FromResponsesRequest(req)
if err != nil {
t.Fatal(err)
}
if len(chat.Messages) != 4 {
t.Fatalf("messages = %#v", chat.Messages)
}
agentMsg := chat.Messages[3]
want := "Agent message from \"/root\" to \"/root/worker\":\n" +
"Message Type: NEW_TASK\nTask name: /root/worker\nSender: /root\nPayload:\n" +
"Analyze the parser module and report issues."
if agentMsg.Role != "user" || agentMsg.Content != want {
t.Fatalf("agent message = %#v", agentMsg)
}
}
func TestResponsesRequest_UnmarshalJSON(t *testing.T) {
tests := []struct {
name string
@@ -3054,3 +3089,50 @@ func TestResponsesStreamConverter_FinalOutputKeepsStreamedItemOrder(t *testing.T
}
}
}
func TestFromResponsesRequestAgentMessageWithoutRouting(t *testing.T) {
var req ResponsesRequest
err := json.Unmarshal([]byte(`{
"model": "test-model",
"input": [{"type": "agent_message", "content": [
{"type": "encrypted_content", "encrypted_content": "bare payload"}
]}]
}`), &req)
if err != nil {
t.Fatal(err)
}
chat, err := FromResponsesRequest(req)
if err != nil {
t.Fatal(err)
}
if len(chat.Messages) != 1 {
t.Fatalf("messages = %#v", chat.Messages)
}
// No routing fields: payload passes through without an envelope.
if chat.Messages[0].Role != "user" || chat.Messages[0].Content != "bare payload" {
t.Fatalf("agent message = %#v", chat.Messages[0])
}
}
func TestFromResponsesRequestAcceptsEncryptedContentPart(t *testing.T) {
var req ResponsesRequest
err := json.Unmarshal([]byte(`{
"model": "test-model",
"input": [{"type": "message", "role": "user", "content": [
{"type": "input_text", "text": "prefix "},
{"type": "encrypted_content", "encrypted_content": "opaque to us"}
]}]
}`), &req)
if err != nil {
t.Fatal(err)
}
chat, err := FromResponsesRequest(req)
if err != nil {
t.Fatal(err)
}
if len(chat.Messages) != 1 || chat.Messages[0].Content != "prefix opaque to us" {
t.Fatalf("messages = %#v", chat.Messages)
}
}