diff --git a/internal/gateway/grok_agent_message_test.go b/internal/gateway/grok_agent_message_test.go new file mode 100644 index 00000000..7c2ad18c --- /dev/null +++ b/internal/gateway/grok_agent_message_test.go @@ -0,0 +1,95 @@ +package gateway + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// agentMessageTurn is a lead's turn with its subagent's plain reply in it, +// between the call that sent the task and its output, as Codex's +// MultiAgentV2 hands it back, with no tools. +const agentMessageTurn = `"input":[ + {"type":"message","role":"user","content":"go"}, + {"type":"function_call","call_id":"c1","name":"send_message","arguments":"{}"}, + {"type":"agent_message","author":"/root/research","recipient":"/root","content":[{"type":"input_text","text":"Message Type: FINAL_ANSWER\nPayload:\nfound the cause"}]}, + {"type":"function_call_output","call_id":"c1","output":"sent"}]` + +// checkAgentMessageSent fails unless Grok was given the subagent's reply +// as the user's message, in its place, its sender and recipient first. +func checkAgentMessageSent(t *testing.T, q map[string]any) { + t.Helper() + in, _ := q["input"].([]any) + if len(in) != 4 { + t.Fatalf("Grok was given %v", q["input"]) + } + m, _ := in[2].(map[string]any) + got, _ := json.Marshal(m) + if want := `{"content":[{"text":"From /root/research to /root\n\n","type":"input_text"},{"text":"Message Type: FINAL_ANSWER\nPayload:\nfound the cause","type":"input_text"}],"role":"user","type":"message"}`; string(got) != want { + t.Fatalf("the agent_message went as\n%s\nwant\n%s", got, want) + } + if o, _ := in[3].(map[string]any); o["type"] != "function_call_output" || o["call_id"] != "c1" { + t.Fatalf("its call's output went as %v", in[3]) + } +} + +// A Codex subagent's plain agent_message reaches a Grok subscription as the +// user's message, which its backend refused with 422 "unknown item type +// \"agent_message\"" (plugins#61). +func TestGrokGetsAgentMessageAsUser(t *testing.T) { + grokSignedIn(t) + var mu sync.Mutex + var sent []map[string]any + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/responses" { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"data":[{"id":"grok-4.7","api_backend":"responses"}]}`) + return + } + b, _ := io.ReadAll(r.Body) + var q map[string]any + json.Unmarshal(b, &q) + mu.Lock() + sent = append(sent, q) + mu.Unlock() + done := `{"id":"r1","object":"response","status":"completed","output":[{"type":"message","id":"m1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok","annotations":[]}]}],"usage":{"input_tokens":5,"output_tokens":1}}` + if q["stream"] != true { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, done) + return + } + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, sse( + `event: response.created`+"\n"+`data: {"type":"response.created","response":{"id":"r1","object":"response","status":"in_progress","output":[]}}`, + `event: response.completed`+"\n"+`data: {"type":"response.completed","response":`+done+`}`)) + })) + defer up.Close() + was := provider.GrokBase + provider.GrokBase = up.URL + "/v1" + defer func() { provider.GrokBase = was }() + + s := New() + for _, stream := range []string{"true", "false"} { + mu.Lock() + sent = nil + mu.Unlock() + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/v1/responses", strings.NewReader(`{"model":"grok/grok-4.7","stream":`+stream+`,`+agentMessageTurn+`}`))) + if rec.Code != 200 { + t.Fatalf("stream %s: %d %s", stream, rec.Code, rec.Body) + } + mu.Lock() + if len(sent) != 1 { + t.Fatalf("stream %s: Grok was asked %d times", stream, len(sent)) + } + q := sent[0] + mu.Unlock() + checkAgentMessageSent(t, q) + } +} diff --git a/internal/gateway/plugin_grok_parity_test.go b/internal/gateway/plugin_grok_parity_test.go index b379579a..a484b5bd 100644 --- a/internal/gateway/plugin_grok_parity_test.go +++ b/internal/gateway/plugin_grok_parity_test.go @@ -177,3 +177,40 @@ func TestGrokPluginSearchesItself(t *testing.T) { t.Fatalf("%s searches by itself: %v on Responses, %v on Chat", pid, searchesItself(*p, provider.Responses), searchesItself(*p, provider.Chat)) } } + +// A Codex subagent's plain agent_message reaches the Grok plugin as the +// user's message, as with the built-in (TestGrokGetsAgentMessageAsUser): +// streamed, as Codex asks, the request is relayed, not translated. +func TestGrokPluginGetsAgentMessageAsUser(t *testing.T) { + var mu sync.Mutex + var sent []map[string]any + pid := besideFake(t, "grok", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + var q map[string]any + json.Unmarshal(b, &q) + mu.Lock() + sent = append(sent, q) + mu.Unlock() + done := `{"id":"r1","object":"response","status":"completed","output":[{"type":"message","id":"m1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok","annotations":[]}]}],"usage":{"input_tokens":5,"output_tokens":1}}` + if q["stream"] != true { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, done) + return + } + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, sse( + `event: response.created`+"\n"+`data: {"type":"response.created","response":{"id":"r1","object":"response","status":"in_progress","output":[]}}`, + `event: response.completed`+"\n"+`data: {"type":"response.completed","response":`+done+`}`)) + })) + rec := httptest.NewRecorder() + New().Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/v1/responses", strings.NewReader(`{"model":"`+pid+`/fake-resp","stream":true,`+agentMessageTurn+`}`))) + if rec.Code != 200 { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + mu.Lock() + defer mu.Unlock() + if len(sent) != 1 { + t.Fatalf("asked %d times", len(sent)) + } + checkAgentMessageSent(t, sent[0]) +} diff --git a/internal/provider/grok.go b/internal/provider/grok.go index a8d190a3..38a3593d 100644 --- a/internal/provider/grok.go +++ b/internal/provider/grok.go @@ -225,9 +225,12 @@ var grokTools = map[string]bool{"function": true, "web_search": true, "x_search" // A tool_choice with no tools // left goes too: the backend turns the request away over it ("A // tool_choice was set on the request but no tools were specified"), as it -// would Codex's compaction summary, sent without tools (#378). +// would Codex's compaction summary, sent without tools (#378). A Codex +// agent_message (a subagent's task, or its reply in the lead's history), +// which the backend turns away with 422 "unknown item type", goes as the +// user's message, its sender and recipient said first (grokAgentMessage). func grokBody(body []byte) []byte { - if !bytes.Contains(body, []byte(`"tools"`)) && !bytes.Contains(body, []byte(`"reasoning"`)) && !bytes.Contains(body, []byte(`"tool_choice"`)) && !bytes.Contains(body, []byte(`"namespace"`)) { + if !bytes.Contains(body, []byte(`"tools"`)) && !bytes.Contains(body, []byte(`"reasoning"`)) && !bytes.Contains(body, []byte(`"tool_choice"`)) && !bytes.Contains(body, []byte(`"namespace"`)) && !bytes.Contains(body, []byte(`"agent_message"`)) { return body } dec := json.NewDecoder(bytes.NewReader(body)) @@ -281,7 +284,11 @@ func grokBody(body []byte) []byte { } } input, _ := m["input"].([]any) - for _, it := range input { + for i, it := range input { + if msg, ok := grokAgentMessage(it); ok { + input[i], it = msg, msg + dirty = true + } if im, ok := it.(map[string]any); ok && flatCall(im) { dirty = true } @@ -302,6 +309,57 @@ func grokBody(body []byte) []byte { return b } +// grokAgentMessage is a plain Codex agent_message as the user's message +// Grok's backend takes: "From to " first, then its text +// and images as they came, in its place among the input. One sealed +// (encrypted_content on it or on a part), empty, or with a part of another +// kind is left as it is: magpie reads no sealed task, and the gateway's +// guard for one (hasSealedAgentMessage) still sees it. The plugin does the +// same (@magpie-community/opencode-grok-auth 0.1.11, plugins#61). +func grokAgentMessage(it any) (map[string]any, bool) { + im, ok := it.(map[string]any) + if !ok || im["type"] != "agent_message" { + return nil, false + } + if _, sealed := im["encrypted_content"]; sealed { + return nil, false + } + parts, _ := im["content"].([]any) + if len(parts) == 0 { + return nil, false + } + for _, p := range parts { + pm, ok := p.(map[string]any) + if !ok { + return nil, false + } + if _, sealed := pm["encrypted_content"]; sealed { + return nil, false + } + _, isText := pm["text"].(string) + _, isURL := pm["image_url"].(string) + if !(pm["type"] == "input_text" && isText || pm["type"] == "input_image" && isURL) { + return nil, false + } + } + from, _ := im["author"].(string) + to, _ := im["recipient"].(string) + header := "" + switch { + case from != "" && to != "": + header = "From " + from + " to " + to + case from != "": + header = "From " + from + case to != "": + header = "To " + to + } + content := parts + if header != "" { + content = append([]any{map[string]any{"type": "input_text", "text": header + "\n\n"}}, parts...) + } + return map[string]any{"type": "message", "role": "user", "content": content}, true +} + // liteNamespace is the namespace Codex's Responses Lite groups its own // functions in, which Codex reads as none at all. const liteNamespace = "functions" diff --git a/internal/provider/grok_test.go b/internal/provider/grok_test.go index b5e24c05..113295dd 100644 --- a/internal/provider/grok_test.go +++ b/internal/provider/grok_test.go @@ -378,3 +378,75 @@ func TestObjectRootNestedUnion(t *testing.T) { t.Errorf("kind %s", kind) } } + +// A plain Codex agent_message, which Grok's backend refuses with 422 +// "unknown item type", goes as the user's message: its sender and +// recipient said first, its text and images as they came, in its place +// among the calls beside it; a request without tools too. The plugin +// does the same (opencode-grok-auth 0.1.11, plugins#61). +func TestGrokBodyAgentMessageGoesAsUser(t *testing.T) { + task := `{"type":"agent_message","author":"/root","recipient":"/root/research","content":[{"type":"input_text","text":"Message Type: NEW_TASK\nPayload:\nresearch the failure"}]}` + want := `{"input":[{"content":[{"text":"From /root to /root/research\n\n","type":"input_text"},{"text":"Message Type: NEW_TASK\nPayload:\nresearch the failure","type":"input_text"}],"role":"user","type":"message"}]}` + if got := string(grokBody([]byte(`{"input":[` + task + `]}`))); got != want { + t.Fatalf("task:\ngot %s\nwant %s", got, want) + } + + before := `{"type":"function_call","call_id":"call_1","name":"send_message","arguments":"{}"}` + after := `{"type":"function_call_output","call_id":"call_1","output":"sent"}` + reply := `{"type":"agent_message","author":"/root/research","recipient":"/root","content":[{"type":"input_text","text":"Message Type: FINAL_ANSWER\nPayload:\nfound the cause"},{"type":"input_image","image_url":"data:image/png;base64,fixture"}]}` + var got struct { + Input []map[string]any `json:"input"` + } + if err := json.Unmarshal(grokBody([]byte(`{"tools":[],"input":[`+before+`,`+reply+`,`+after+`]}`)), &got); err != nil { + t.Fatal(err) + } + if len(got.Input) != 3 || got.Input[0]["type"] != "function_call" || got.Input[2]["type"] != "function_call_output" || got.Input[2]["call_id"] != "call_1" { + t.Fatalf("input %v", got.Input) + } + msg, _ := json.Marshal(got.Input[1]) + if w := `{"content":[{"text":"From /root/research to /root\n\n","type":"input_text"},{"text":"Message Type: FINAL_ANSWER\nPayload:\nfound the cause","type":"input_text"},{"image_url":"data:image/png;base64,fixture","type":"input_image"}],"role":"user","type":"message"}`; string(msg) != w { + t.Fatalf("reply:\ngot %s\nwant %s", msg, w) + } + + // one sender alone, or none, says what it has + for in, w := range map[string]string{ + `{"type":"agent_message","author":"/root","content":[{"type":"input_text","text":"x"}]}`: `[{"text":"From /root\n\n","type":"input_text"},{"text":"x","type":"input_text"}]`, + `{"type":"agent_message","recipient":"/root","content":[{"type":"input_text","text":"x"}]}`: `[{"text":"To /root\n\n","type":"input_text"},{"text":"x","type":"input_text"}]`, + `{"type":"agent_message","content":[{"type":"input_text","text":"x"}]}`: `[{"text":"x","type":"input_text"}]`, + } { + var g struct { + Input []struct { + Type string `json:"type"` + Content json.RawMessage `json:"content"` + } `json:"input"` + } + json.Unmarshal(grokBody([]byte(`{"input":[`+in+`]}`)), &g) + if len(g.Input) != 1 || g.Input[0].Type != "message" || string(g.Input[0].Content) != w { + t.Errorf("%s\n-> %+v", in, g.Input) + } + } +} + +// A sealed, empty or unknown agent_message is never converted or dropped: +// the gateway's guard for a sealed task still sees it. A history with +// none goes byte for byte. +func TestGrokBodyLeavesSealedAgentMessage(t *testing.T) { + for _, item := range []string{ + `{"type":"agent_message","content":[{"type":"encrypted_content","encrypted_content":"sealed fixture"}]}`, + `{"type":"agent_message","content":[{"type":"input_text","text":"header"},{"type":"encrypted_content","encrypted_content":"sealed fixture"}]}`, + `{"type":"agent_message","content":[{"type":"input_text","encrypted_content":"sealed fixture"}]}`, + `{"type":"agent_message","encrypted_content":"sealed fixture","content":[{"type":"input_text","text":"header"}]}`, + `{"type":"agent_message","content":[{"type":"future_content","payload":"keep me"}]}`, + `{"type":"agent_message","content":[]}`, + `{"type":"agent_message","content":null}`, + } { + body := `{"input":[` + item + `]}` + if got := string(grokBody([]byte(body))); got != body { + t.Errorf("%s\n-> %s", body, got) + } + } + same := `{ "input": [{"type":"message","role":"user","content":"hello"}] }` + if got := string(grokBody([]byte(same))); got != same { + t.Fatalf("an ordinary history was changed: %s", got) + } +} diff --git a/internal/provider/migrate_side.go b/internal/provider/migrate_side.go index b7213df4..583d9576 100644 --- a/internal/provider/migrate_side.go +++ b/internal/provider/migrate_side.go @@ -108,7 +108,7 @@ func init() { // names, as the built-in does: the homes stay where they are. movers["grok"] = &mover{ pkg: "@magpie-community/opencode-grok-auth", - min: "0.1.10", // a Go key the Provider API refuses is taken as Go's and asked at /alpha/generate (#969); a failure's status and its sign-in mark as the built-in's; grok-4.7's reasoning levels; a token Grok refuses early reads as expired; the sign-in renewed ahead of time through auth.refresh; a 429 shows a spent Rate limit window until it lifts; a login with no link says why; a tool whose parameters are a root union goes as a plain object (#1271) + min: "0.1.11", // a Go key the Provider API refuses is taken as Go's and asked at /alpha/generate (#969); a failure's status and its sign-in mark as the built-in's; grok-4.7's reasoning levels; a token Grok refuses early reads as expired; the sign-in renewed ahead of time through auth.refresh; a 429 shows a spent Rate limit window until it lifts; a login with no link says why; a tool whose parameters are a root union goes as a plain object (#1271); a plain Codex agent_message (a subagent's task or reply) goes as the user's message, not refused with 422 "unknown item type", a sealed one as it came (plugins#61) agents: []string{"grok"}, out: func() ([]Moving, error) { var out []Moving diff --git a/internal/provider/plugins.go b/internal/provider/plugins.go index df254b62..d9d12a2b 100644 --- a/internal/provider/plugins.go +++ b/internal/provider/plugins.go @@ -283,7 +283,9 @@ func pluginProvider(pp plugin.Provider, l pluginLogin) Provider { a := &Account{Agent: "plugin", User: user, Plan: l.Plan, Stream: true, plugin: &pp, pluginKey: acct.Key} if pp.ID == "grok" { // Codex's namespaced tools go to Grok flat, as the built-in sends - // them (#404): the plugin's own rewrite would leave them out + // them (#404): the plugin's own rewrite would leave them out; and + // a plain agent_message goes as the user's message, as the plugin + // itself sends it from 0.1.11 a.body = grokBody } if pp.ID == "zed" {