grok: a Codex subagent's plain agent_message goes to Grok as the user's message, not refused with 422 "unknown item type" (plugins#61, opencode-grok-auth 0.1.11)

Grok's Responses backend refuses a request carrying Codex MultiAgentV2's
agent_message item (a subagent's task, or its reply kept in the lead's
history) with 422 unknown item type "agent_message". plugins#61 counted
512 such 422s on a moved Grok subscription from 10-03 to 10-09, and fixed
it in @magpie-community/opencode-grok-auth 0.1.11 (on npm).

What changed:
- the grok mover's min goes from 0.1.10 to 0.1.11, so keepMovedCurrent
  updates moved users' plugin, and its comment says the fix.
- grokBody (the built-in's request shaper, which also runs for the Grok
  plugin's provider, plugins.go) does what the plugin does: an
  agent_message whose content is a non-empty list of input_text /
  input_image parts goes as {"type":"message","role":"user"}, with
  "From <author> to <recipient>\n\n" (or From / To alone, or nothing)
  as its first part, in its place among the input. One with
  encrypted_content on the item or a part, an empty or null content, or a
  part of another kind is left byte for byte, so magpie reads no sealed
  task and the gateway's sealed-task guard still sees it. A body with no
  agent_message is returned as it came.

Tests:
- provider: TestGrokBodyAgentMessageGoesAsUser (the plugin's task, reply
  with an image between a call and its output, sender alone / recipient
  alone / none) and TestGrokBodyLeavesSealedAgentMessage (the plugin's
  seven sealed or malformed items, and an ordinary history byte for byte).
- gateway: TestGrokGetsAgentMessageAsUser (built-in, streamed and not,
  through the handler) and its parity TestGrokPluginGetsAgentMessageAsUser
  (the Grok plugin's provider, streamed, relayed).
- With origin/main's grok.go back, TestGrokBodyAgentMessageGoesAsUser,
  TestGrokGetsAgentMessageAsUser and TestGrokPluginGetsAgentMessageAsUser
  FAIL ("the agent_message went as" the untouched item); with the change
  they pass.

Run: gofmt -l (nothing); go vet, GOOS=linux and GOOS=windows go vet
-tags nogui on ./internal/provider ./internal/gateway (clean);
go test -tags nogui ./internal/provider ./internal/gateway under a temp
HOME with pinned caches (ok). Not tried against the real Grok backend:
no live Grok request was made.
This commit is contained in:
yetone committed 2026-10-09 22:48:55 +08:00
1 parent acdaa28aa1
commit d7f3c20be3
6 files changed
+269 -5

No files matched your search

@@ -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)
}
}
@@ -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])
}
+61 -3
View File
@@ -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 <author> to <recipient>" 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"
+72
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
+3 -1
View File
@@ -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" {