From 948791ffc2c9dd309259f44e23710380331100ad Mon Sep 17 00:00:00 2001 From: "nekomimi.pet" Date: Mon, 3 Aug 2026 00:07:22 -0400 Subject: [PATCH] preserve typed multi-agent arguments in ordered responses --- .../claude_openai-responses_response.go | 49 ++++++++++++ .../claude_openai-responses_response_test.go | 77 +++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response.go b/internal/translator/claude/openai/responses/claude_openai-responses_response.go index 1a575992..b95f9845 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_response.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_response.go @@ -70,6 +70,52 @@ type claudeResponsesUsageTokens struct { var dataTag = []byte("data:") +var claudeMultiAgentArgumentTypes = map[string]map[string]string{ + "spawn_agent": { + "fork_context": "boolean", + "items": "array", + }, + "wait_agent": { + "targets": "array", + "timeout_ms": "integer", + }, + "send_input": { + "interrupt": "boolean", + "items": "array", + }, +} + +func normalizeClaudeMultiAgentArguments(name, arguments string) string { + bareName := strings.TrimPrefix(strings.TrimSpace(name), "multi_agent_v1__") + fields := claudeMultiAgentArgumentTypes[bareName] + if len(fields) == 0 || !gjson.Valid(arguments) { + return arguments + } + updated := []byte(arguments) + for field, expectedType := range fields { + value := gjson.GetBytes(updated, field) + if value.Type != gjson.String { + continue + } + inner := strings.TrimSpace(value.String()) + parsed := gjson.Parse(inner) + valid := false + switch expectedType { + case "array": + valid = gjson.Valid(inner) && parsed.IsArray() + case "integer": + valid = gjson.Valid(inner) && parsed.Type == gjson.Number && !strings.ContainsAny(inner, ".eE") + case "boolean": + valid = inner == "true" || inner == "false" + } + if !valid { + continue + } + updated, _ = sjson.SetRawBytes(updated, field, []byte(inner)) + } + return string(updated) +} + func (u *claudeResponsesUsageTokens) Merge(usage gjson.Result) { if !usage.Exists() { return @@ -428,6 +474,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin args = buf.String() } } + args = normalizeClaudeMultiAgentArguments(st.FuncNames[idx], args) fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`) fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq()) fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", st.CurrentFCID)) @@ -603,6 +650,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin if callID == "" && st.CurrentFCID != "" { callID = st.CurrentFCID } + args = normalizeClaudeMultiAgentArguments(name, args) item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`) item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID)) item, _ = sjson.SetBytes(item, "arguments", args) @@ -898,6 +946,7 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string if args == "" { args = "{}" } + args = normalizeClaudeMultiAgentArguments(outputItem.name, args) item = []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`) item, _ = sjson.SetBytes(item, "id", outputItem.id) item, _ = sjson.SetBytes(item, "arguments", args) diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go index 0b014d25..88eccc94 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go @@ -893,3 +893,80 @@ func TestConvertClaudeResponseToOpenAIResponsesNonStream_RestoresNamespaceFuncti t.Fatalf("non-stream output namespace = %q, want mcp__node_repl", got) } } + +func TestConvertClaudeResponseToOpenAIResponses_RestoresMultiAgentArgumentTypes(t *testing.T) { + originalRequest := []byte(`{ + "model":"gpt-test", + "tools":[{ + "type":"namespace", + "name":"multi_agent_v1", + "tools":[{"type":"function","name":"wait_agent","parameters":{"type":"object","properties":{}}}] + }] + }`) + chunks := [][]byte{ + []byte(`data: {"type":"message_start","message":{"id":"msg_123","usage":{"input_tokens":1,"output_tokens":0}}}`), + []byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_abc","name":"multi_agent_v1__wait_agent","input":{}}}`), + []byte(`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"targets\":\"[\\\"agent-1\\\"]\",\"timeout_ms\":\"15000\"}"}}`), + []byte(`data: {"type":"content_block_stop","index":1}`), + []byte(`data: {"type":"message_stop"}`), + } + + var param any + var done gjson.Result + var completed gjson.Result + for _, chunk := range chunks { + for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", originalRequest, nil, chunk, ¶m) { + event, data := parseClaudeResponsesSSEEvent(t, output) + switch event { + case "response.output_item.done": + if data.Get("item.type").String() == "function_call" { + done = data + } + case "response.completed": + completed = data + } + } + } + + for _, tc := range []struct { + label string + arguments string + }{ + {"done", done.Get("item.arguments").String()}, + {"completed", completed.Get("response.output.0.arguments").String()}, + } { + if !gjson.Get(tc.arguments, "targets").IsArray() { + t.Fatalf("%s targets should be an array: %s", tc.label, tc.arguments) + } + if got := gjson.Get(tc.arguments, "timeout_ms"); got.Type != gjson.Number || got.Int() != 15000 { + t.Fatalf("%s timeout_ms should be integer 15000: %s", tc.label, tc.arguments) + } + } +} + +func TestConvertClaudeResponseToOpenAIResponsesNonStream_RestoresMultiAgentArgumentTypes(t *testing.T) { + originalRequest := []byte(`{ + "model":"gpt-test", + "tools":[{ + "type":"namespace", + "name":"multi_agent_v1", + "tools":[{"type":"function","name":"wait_agent","parameters":{"type":"object","properties":{}}}] + }] + }`) + raw := []byte(strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_nonstream","usage":{"input_tokens":1,"output_tokens":0}}}`, + `data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_abc","name":"multi_agent_v1__wait_agent","input":{}}}`, + `data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"targets\":\"[\\\"agent-1\\\"]\",\"timeout_ms\":\"15000\"}"}}`, + `data: {"type":"content_block_stop","index":1}`, + `data: {"type":"message_stop"}`, + }, "\n")) + + out := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", originalRequest, nil, raw, nil) + arguments := gjson.GetBytes(out, "output.0.arguments").String() + if !gjson.Get(arguments, "targets").IsArray() { + t.Fatalf("non-stream targets should be an array: %s", arguments) + } + if got := gjson.Get(arguments, "timeout_ms"); got.Type != gjson.Number || got.Int() != 15000 { + t.Fatalf("non-stream timeout_ms should be integer 15000: %s", arguments) + } +} -- 2.51.2