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:
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])
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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" {
|
||||
|
||||
Reference in new issue
Block a user