Skip to content
66 changes: 66 additions & 0 deletions core/schemas/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -1179,6 +1179,8 @@ const (
ResponsesMessageTypeWebFetchCall ResponsesMessageType = "web_fetch_call"
ResponsesMessageTypeFunctionCall ResponsesMessageType = "function_call"
ResponsesMessageTypeFunctionCallOutput ResponsesMessageType = "function_call_output"
ResponsesMessageTypeToolSearchCall ResponsesMessageType = "tool_search_call"
ResponsesMessageTypeToolSearchOutput ResponsesMessageType = "tool_search_output"
ResponsesMessageTypeCodeInterpreterCall ResponsesMessageType = "code_interpreter_call"
ResponsesMessageTypeLocalShellCall ResponsesMessageType = "local_shell_call"
ResponsesMessageTypeLocalShellCallOutput ResponsesMessageType = "local_shell_call_output"
Expand Down Expand Up @@ -1225,6 +1227,10 @@ type ResponsesMessage struct {
Author json.RawMessage `json:"author,omitempty"`
Recipient json.RawMessage `json:"recipient,omitempty"`

// Discovered tool_search_output tools. Programmatic callers must set this,
// not ResponsesMCPListTools.Tools, because the entries are ResponsesTool-shaped.
ToolSearchOutputTools json.RawMessage `json:"-"`

*ResponsesToolMessage // For Tool calls and outputs

CacheControl *CacheControl `json:"cache_control,omitempty"` // Carries cache_control for function_call and function_call_output message types
Expand Down Expand Up @@ -1297,6 +1303,20 @@ func (m *ResponsesMessage) UnmarshalJSON(data []byte) error {

m.setToolArguments(aux.Arguments)

// The embedded ResponsesMCPListTools decode of `tools` drops the type
// discriminator, so capture the raw array and skip that lossy parse.
if m.Type != nil && *m.Type == ResponsesMessageTypeToolSearchOutput {
var probe struct {
Tools json.RawMessage `json:"tools,omitempty"`
}
if err := Unmarshal(data, &probe); err == nil && len(probe.Tools) > 0 && string(probe.Tools) != "null" {
m.ToolSearchOutputTools = probe.Tools
}
if m.ResponsesToolMessage != nil {
m.ResponsesMCPListTools = nil
}
}

return nil
}

Expand Down Expand Up @@ -1338,6 +1358,51 @@ func responsesToolArgumentsToString(raw json.RawMessage) string {
return string(raw)
}

// MarshalJSON preserves OpenAI's per-item argument shape after UnmarshalJSON
// normalizes both forms into the internal string field.
func (m ResponsesMessage) MarshalJSON() ([]byte, error) {
type Alias ResponsesMessage

// Re-emit the raw tools captured during unmarshal so the type discriminator survives.
if m.Type != nil && *m.Type == ResponsesMessageTypeToolSearchOutput {
aux := &struct {
Arguments json.RawMessage `json:"arguments,omitempty"`
Tools json.RawMessage `json:"tools,omitempty"`
*Alias
}{
Alias: (*Alias)(&m),
}
if m.ToolSearchOutputTools != nil {
aux.Tools = m.ToolSearchOutputTools
}
if m.ResponsesToolMessage != nil && m.Arguments != nil {
aux.Arguments = json.RawMessage(*m.Arguments)
}
return MarshalSorted(aux)
}

aux := &struct {
Arguments json.RawMessage `json:"arguments,omitempty"`
*Alias
}{
Alias: (*Alias)(&m),
}

if m.ResponsesToolMessage != nil && m.Arguments != nil {
if m.Type != nil && *m.Type == ResponsesMessageTypeToolSearchCall {
aux.Arguments = json.RawMessage(*m.Arguments)
} else {
encoded, err := Marshal(*m.Arguments)
if err != nil {
return nil, err
}
aux.Arguments = encoded
}
}

return MarshalSorted(aux)
}

type ResponsesMessageRoleType string

const (
Expand Down Expand Up @@ -1533,6 +1598,7 @@ type ResponsesToolMessage struct {
Name *string `json:"name,omitempty"` // Common name field for tool calls
Namespace *string `json:"namespace,omitempty"` // Namespace for function_call items (set by OpenAI when namespace tools are used)
Arguments *string `json:"arguments,omitempty"`
Execution *string `json:"execution,omitempty"` // "client" on deferred calls (e.g. tool_search_call); Codex needs it to dispatch the call
Output *ResponsesToolMessageOutputStruct `json:"output,omitempty"`
Action *ResponsesToolMessageActionStruct `json:"action,omitempty"`
Error *string `json:"error,omitempty"`
Expand Down
191 changes: 191 additions & 0 deletions core/schemas/responses_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package schemas

import (
"encoding/json"
"strings"
"testing"
)
Expand Down Expand Up @@ -262,6 +263,196 @@ func TestResponsesMessageToolCallArguments(t *testing.T) {
})
}

func TestResponsesMessageMarshalsToolSearchArgumentsAsObject(t *testing.T) {
toolSearchType := ResponsesMessageTypeToolSearchCall
functionType := ResponsesMessageTypeFunctionCall
callID := "call_123"

t.Run("tool_search_call arguments marshal as a JSON object", func(t *testing.T) {
args := `{"query":"observability logs","limit":10}`
msg := ResponsesMessage{
Type: &toolSearchType,
ResponsesToolMessage: &ResponsesToolMessage{CallID: &callID, Arguments: &args},
}
encoded, err := MarshalSorted(msg)
if err != nil {
t.Fatalf("marshal tool_search_call: %v", err)
}
if !strings.Contains(string(encoded), `"arguments":{"query":"observability logs","limit":10}`) {
t.Fatalf("expected object-valued arguments, got %s", encoded)
}
if strings.Contains(string(encoded), `"arguments":"`) {
t.Fatalf("tool_search_call arguments must not be stringified, got %s", encoded)
}
})

t.Run("tool_search_call empty arguments marshal as an empty object", func(t *testing.T) {
args := `{}`
msg := ResponsesMessage{
Type: &toolSearchType,
ResponsesToolMessage: &ResponsesToolMessage{CallID: &callID, Arguments: &args},
}
encoded, err := MarshalSorted(msg)
if err != nil {
t.Fatalf("marshal tool_search_call: %v", err)
}
if !strings.Contains(string(encoded), `"arguments":{}`) {
t.Fatalf("expected empty object arguments, got %s", encoded)
}
})

t.Run("function_call arguments stay a JSON string", func(t *testing.T) {
args := `{"city":"Paris"}`
msg := ResponsesMessage{
Type: &functionType,
ResponsesToolMessage: &ResponsesToolMessage{CallID: &callID, Arguments: &args},
}
encoded, err := MarshalSorted(msg)
if err != nil {
t.Fatalf("marshal function_call: %v", err)
}
if !strings.Contains(string(encoded), `"arguments":"{\"city\":\"Paris\"}"`) {
t.Fatalf("expected stringified arguments, got %s", encoded)
}
})

t.Run("real tool_search_call frame round-trips object -> string -> object", func(t *testing.T) {
raw := []byte(`{"type":"response.output_item.done","output_index":1,"sequence_number":5,"item":{"id":"tsc_1","type":"tool_search_call","status":"completed","arguments":{"query":"observability logs","limit":10},"call_id":"call_1","execution":"client"}}`)

var resp BifrostResponsesStreamResponse
if err := Unmarshal(raw, &resp); err != nil {
t.Fatalf("unmarshal tool_search_call frame: %v", err)
}
if resp.Item == nil || resp.Item.Arguments == nil {
t.Fatalf("expected parsed item arguments, got %#v", resp.Item)
}
if *resp.Item.Arguments != `{"query":"observability logs","limit":10}` {
t.Fatalf("expected stringified internal arguments, got %q", *resp.Item.Arguments)
}

encoded, err := MarshalSorted(resp.Item)
if err != nil {
t.Fatalf("marshal parsed item: %v", err)
}
if !strings.Contains(string(encoded), `"arguments":{"query":"observability logs","limit":10}`) {
t.Fatalf("expected re-emitted object arguments, got %s", encoded)
}
if strings.Contains(string(encoded), `"arguments":"`) {
t.Fatalf("tool_search_call arguments must round-trip as an object, got %s", encoded)
}
})

t.Run("non-tool item without arguments marshals without panicking", func(t *testing.T) {
reasoningType := ResponsesMessageTypeReasoning
msg := ResponsesMessage{Type: &reasoningType}
encoded, err := MarshalSorted(msg)
if err != nil {
t.Fatalf("marshal reasoning item: %v", err)
}
if strings.Contains(string(encoded), `"arguments"`) {
t.Fatalf("did not expect arguments key, got %s", encoded)
}
})
}

func TestResponsesMessagePreservesToolSearchExecution(t *testing.T) {
raw := []byte(`{"id":"tsc_1","type":"tool_search_call","status":"completed","arguments":{"query":"loki"},"call_id":"call_1","execution":"client"}`)

var msg ResponsesMessage
if err := Unmarshal(raw, &msg); err != nil {
t.Fatalf("unmarshal tool_search_call: %v", err)
}
if msg.ResponsesToolMessage == nil || msg.Execution == nil || *msg.Execution != "client" {
t.Fatalf("expected execution=client to survive unmarshal, got %#v", msg.ResponsesToolMessage)
}

encoded, err := MarshalSorted(msg)
if err != nil {
t.Fatalf("marshal tool_search_call: %v", err)
}
if !strings.Contains(string(encoded), `"execution":"client"`) {
t.Fatalf("expected execution to round-trip, got %s", encoded)
}
}

func TestResponsesMessageRoundTripsToolSearchOutputTools(t *testing.T) {
raw := []byte(`{"id":"tso_1","type":"tool_search_output","call_id":"call_1","tools":[{"type":"namespace","name":"telemetry","tools":[{"type":"function","name":"query_loki_logs","description":"query loki","parameters":{"type":"object","properties":{"run_id":{"type":"string"}}}}]}]}`)

var msg ResponsesMessage
if err := Unmarshal(raw, &msg); err != nil {
t.Fatalf("unmarshal tool_search_output: %v", err)
}
if msg.Type == nil || *msg.Type != ResponsesMessageTypeToolSearchOutput {
t.Fatalf("expected tool_search_output type, got %#v", msg.Type)
}
if len(msg.ToolSearchOutputTools) == 0 {
t.Fatalf("expected raw tools to be captured, got none")
}

encoded, err := MarshalSorted(msg)
if err != nil {
t.Fatalf("marshal tool_search_output: %v", err)
}
for _, want := range []string{`"type":"namespace"`, `"type":"function"`, `"name":"query_loki_logs"`} {
if !strings.Contains(string(encoded), want) {
t.Fatalf("expected re-emitted tools to contain %s, got %s", want, encoded)
}
}
}

func TestResponsesMessageMarshalsToolSearchOutputArgumentsAsObject(t *testing.T) {
toolSearchOutputType := ResponsesMessageTypeToolSearchOutput
callID := "call_1"
args := `{"query":"loki"}`
tools := json.RawMessage(`[{"type":"namespace","name":"telemetry","tools":[{"type":"function","name":"query_loki_logs"}]}]`)
msg := ResponsesMessage{
Type: &toolSearchOutputType,
ToolSearchOutputTools: tools,
ResponsesToolMessage: &ResponsesToolMessage{CallID: &callID, Arguments: &args},
}

encoded, err := MarshalSorted(msg)
if err != nil {
t.Fatalf("marshal tool_search_output: %v", err)
}
if !strings.Contains(string(encoded), `"arguments":{"query":"loki"}`) {
t.Fatalf("expected object-valued arguments, got %s", encoded)
}
if strings.Contains(string(encoded), `"arguments":"`) {
t.Fatalf("tool_search_output arguments must not be stringified, got %s", encoded)
}
}

func TestDeepCopyResponsesMessagePreservesToolSearchFields(t *testing.T) {
toolSearchOutputType := ResponsesMessageTypeToolSearchOutput
callID := "call_1"
name := "query_loki_logs"
namespace := "telemetry"
args := `{"query":"loki"}`
execution := "client"
tools := json.RawMessage(`[{"type":"namespace","name":"telemetry","tools":[{"type":"function","name":"query_loki_logs"}]}]`)

copied := DeepCopyResponsesMessage(ResponsesMessage{
Type: &toolSearchOutputType,
ToolSearchOutputTools: tools,
ResponsesToolMessage: &ResponsesToolMessage{
CallID: &callID,
Name: &name,
Namespace: &namespace,
Arguments: &args,
Execution: &execution,
},
})

if copied.ToolSearchOutputTools == nil || string(copied.ToolSearchOutputTools) != string(tools) {
t.Fatalf("expected raw tool_search_output tools to survive copy, got %s", copied.ToolSearchOutputTools)
}
if copied.ResponsesToolMessage == nil || copied.Namespace == nil || *copied.Namespace != namespace {
t.Fatalf("expected namespace to survive copy, got %#v", copied.ResponsesToolMessage)
}
if copied.Execution == nil || *copied.Execution != execution {
t.Fatalf("expected execution to survive copy, got %#v", copied.ResponsesToolMessage)
=======
// TestResponsesMessagePreservesAdditionalTools verifies that codex
// `additional_tools` input items (sent for code-mode models such as
// gpt-5.6-sol) round-trip byte-identically. These items carry a `tools` array
Expand Down
13 changes: 13 additions & 0 deletions core/schemas/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -1284,6 +1284,9 @@ func DeepCopyResponsesMessage(original ResponsesMessage) ResponsesMessage {
if original.Recipient != nil {
copy.Recipient = append(json.RawMessage(nil), original.Recipient...)
}
if original.ToolSearchOutputTools != nil {
copy.ToolSearchOutputTools = append(json.RawMessage(nil), original.ToolSearchOutputTools...)
}

if original.Content != nil {
copy.Content = &ResponsesMessageContent{}
Expand Down Expand Up @@ -1321,6 +1324,16 @@ func DeepCopyResponsesMessage(original ResponsesMessage) ResponsesMessage {
copy.ResponsesToolMessage.Arguments = &copyArguments
}

if original.ResponsesToolMessage.Namespace != nil {
copyNamespace := *original.ResponsesToolMessage.Namespace
copy.ResponsesToolMessage.Namespace = &copyNamespace
}

if original.ResponsesToolMessage.Execution != nil {
copyExecution := *original.ResponsesToolMessage.Execution
copy.ResponsesToolMessage.Execution = &copyExecution
}

if original.ResponsesToolMessage.Error != nil {
copyError := *original.ResponsesToolMessage.Error
copy.ResponsesToolMessage.Error = &copyError
Expand Down
39 changes: 39 additions & 0 deletions framework/streaming/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package streaming
import (
"encoding/json"
"fmt"
"reflect"
"sort"
"strings"
"time"
Expand Down Expand Up @@ -234,6 +235,10 @@ func deepCopyResponsesMessage(original schemas.ResponsesMessage) schemas.Respons
if original.Recipient != nil {
copy.Recipient = append(json.RawMessage(nil), original.Recipient...)
}
// The framework module still compiles against released core versions that
// do not expose every Responses API field, so newer fields are copied by name
// when a workspace build provides them.
copyRawMessageFieldByName(&copy, original, "ToolSearchOutputTools")

// Deep copy ResponsesReasoning if present
if original.ResponsesReasoning != nil {
Expand Down Expand Up @@ -276,6 +281,13 @@ func deepCopyResponsesMessage(original schemas.ResponsesMessage) schemas.Respons
copy.ResponsesToolMessage.Arguments = &copyArguments
}

if original.ResponsesToolMessage.Namespace != nil {
copyNamespace := *original.ResponsesToolMessage.Namespace
copy.ResponsesToolMessage.Namespace = &copyNamespace
}

copyOptionalStringFieldByName(copy.ResponsesToolMessage, original.ResponsesToolMessage, "Execution")

if original.ResponsesToolMessage.Error != nil {
copyError := *original.ResponsesToolMessage.Error
copy.ResponsesToolMessage.Error = &copyError
Expand Down Expand Up @@ -474,6 +486,33 @@ func deepCopyResponsesMessage(original schemas.ResponsesMessage) schemas.Respons
return copy
}

func copyRawMessageFieldByName(dst *schemas.ResponsesMessage, src schemas.ResponsesMessage, fieldName string) {
srcField := reflect.ValueOf(src).FieldByName(fieldName)
if !srcField.IsValid() || srcField.IsNil() {
return
}
raw, ok := srcField.Interface().(json.RawMessage)
if !ok {
return
}
dstField := reflect.ValueOf(dst).Elem().FieldByName(fieldName)
if dstField.IsValid() && dstField.CanSet() {
dstField.Set(reflect.ValueOf(append(json.RawMessage(nil), raw...)))
}
}

func copyOptionalStringFieldByName(dst *schemas.ResponsesToolMessage, src *schemas.ResponsesToolMessage, fieldName string) {
srcField := reflect.ValueOf(src).Elem().FieldByName(fieldName)
if !srcField.IsValid() || srcField.IsNil() {
return
}
copyValue := srcField.Elem().String()
dstField := reflect.ValueOf(dst).Elem().FieldByName(fieldName)
if dstField.IsValid() && dstField.CanSet() {
dstField.Set(reflect.ValueOf(&copyValue))
}
}

// deepCopyResponsesMessageContentBlock creates a deep copy of a ResponsesMessageContentBlock
func deepCopyResponsesMessageContentBlock(original schemas.ResponsesMessageContentBlock) schemas.ResponsesMessageContentBlock {
copy := schemas.ResponsesMessageContentBlock{
Expand Down