gateway: Devin's call ids reach the agent as [A-Za-z0-9_-] only and go back to Devin as Devin gave them, so Claude Code runs SWE-2's tool calls (#1304, StarMoonCity)

SWE-2 names a call after its tool and turn ("Bash:0#a65b6a5e02194b87bbc796c4428c946d"). Claude Code takes a tool_use id only of [A-Za-z0-9_-], dropped every such call and failed the turn with "The model's tool call could not be parsed (retry also failed)"; Codex and pi take any id. decodeDevin now sends an id with another character, or one beginning "dv_", as "dv_" and its base64url (devinOutID), and buildDevin turns it back (devinInID) on the assistant's call and on the result that answers it, so Devin sees its own id. A safe id goes both ways as it came. It is stateless, and the same mapping the Devin plugin makes in 0.1.13 (outID/inID).

TestDevinCallIDsSafeBothWays takes Devin's reply with the reporter's literal id through serveDevin on Anthropic, Chat and Responses, and answers the call in the next request of each: with devinOutID and devinInID made to return the id as it came (the old behavior) it fails "anthropic: call id \"Bash:0#a65b6a5e02194b87bbc796c4428c946d\", want one of [A-Za-z0-9_-] only"; with only devinInID taken out (a one-way clean, as the issue proposed) it fails too, Devin getting the cleaned id. TestDevinSafeCallIDsUnchanged keeps safe ids, and a caller's own "dv_…", as they came.

go test -tags nogui ./internal/provider ./internal/gateway pass on Linux (golang:1.26.8 in Docker, temp HOME, module cache mounted read-only): on this Mac every freshly built test binary hangs at exec (syspolicyd at 100% CPU), so no darwin test run. go vet -tags nogui for darwin, GOOS=linux and GOOS=windows and gofmt -l pass. Not tried against a real Devin account.
This commit is contained in:
yetone committed 2026-10-08 19:57:21 +08:00
1 parent 169c23c18e
commit d358107766
2 files changed
+191 -2

No files matched your search

+33 -2
View File
@@ -20,15 +20,18 @@ import (
"bytes"
"compress/gzip"
"context"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"io"
"math"
"net/http"
"regexp"
"runtime"
"slices"
"strings"
"unicode/utf8"
"github.com/yetone/magpie/internal/provider"
)
@@ -309,6 +312,33 @@ func inlineImages(ims []Part) []Part {
return out
}
// Devin names a call after its tool and turn (Bash:0#a65b6a5e…), and
// Claude Code takes a tool_use id only of [A-Za-z0-9_-]: it drops a call
// whose id has the ':' or '#', and the turn fails "could not be parsed"
// (#1304). Such an id goes out as "dv_" and its base64url, and comes back
// to Devin as it was, so its result still answers its call. An id that is
// already safe goes out as it came, unless it begins "dv_". The Devin
// plugin (packages/devin, outID and inID) maps the same way.
var devinSafeID = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
func devinOutID(id string) string {
if devinSafeID.MatchString(id) && !strings.HasPrefix(id, "dv_") {
return id
}
return "dv_" + base64.RawURLEncoding.EncodeToString([]byte(id))
}
func devinInID(id string) string {
if !strings.HasPrefix(id, "dv_") || !devinSafeID.MatchString(id) {
return id
}
raw, err := base64.RawURLEncoding.DecodeString(id[3:])
if err != nil || len(raw) == 0 || !utf8.Valid(raw) || devinOutID(string(raw)) != id {
return id
}
return string(raw)
}
// buildDevin is the GetChatMessage request for r, to the model uid.
func buildDevin(r *Request, uid, key string) []byte {
var msgs []devinMsg
@@ -330,6 +360,7 @@ func buildDevin(r *Request, uid, key string) []byte {
texts = append(texts, p.Text)
}
case ToolCall:
p.ID = devinInID(p.ID)
a.calls = append(a.calls, p)
case Thinking:
if strings.HasPrefix(p.Signature, "sealed.") {
@@ -363,7 +394,7 @@ func buildDevin(r *Request, uid, key string) []byte {
case ToolResult:
// only a call just made is answered, and only once
for i, c := range pending {
if c.ID != p.CallID {
if c.ID != devinInID(p.CallID) {
continue
}
out := p.Text
@@ -617,7 +648,7 @@ func (d *devinDecoder) frame(b []byte) []Event {
if id == "" {
id = "call_" + randomToken()[:24]
}
evs = append(evs, Event{Kind: KToolStart, ID: id, Name: name})
evs = append(evs, Event{Kind: KToolStart, ID: devinOutID(id), Name: name})
}
if args != "" {
evs = append(evs, Event{Kind: KToolArgs, Text: args})
+158
View File
@@ -0,0 +1,158 @@
package gateway
import (
"bytes"
"encoding/json"
"regexp"
"strings"
"testing"
"github.com/yetone/magpie/internal/provider"
)
// devinRawID is the call id SWE-2 gave in #1304 (StarMoonCity's capture).
// Claude Code takes a tool_use id only of [A-Za-z0-9_-] and dropped the
// call: "The model's tool call could not be parsed (retry also failed)".
const devinRawID = "Bash:0#a65b6a5e02194b87bbc796c4428c946d"
var anthropicSafeID = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
// devinCallReply is Devin's reply making that call.
func devinCallReply() []byte {
return bytes.Join([][]byte{
devinFrame(0, pb{}.bytes(6, pb{}.str(1, devinRawID).str(2, "Bash").str(3, `{"command":"date"}`)), false),
devinFrame(0, pb{}.varint(5, 10), false),
devinFrame(2, []byte("{}"), false),
}, nil)
}
// devinIDsSent is the ids of the calls and of the results the request for r
// gives Devin.
func devinIDsSent(t *testing.T, r *Request) (calls, answers []string) {
t.Helper()
for _, m := range decodeDevinRequest(t, buildDevin(r, "swe-2-high", "k")).msgs {
for _, f := range m {
switch f.num {
case 6:
calls = append(calls, string(pbFields(f.data)[0].data))
case 7:
answers = append(answers, string(f.data))
}
}
}
return
}
// On each protocol the call's id reaches the agent of [A-Za-z0-9_-] only,
// and the agent's next request, answering it under that id, gives Devin
// the call and its result under the id Devin gave.
func TestDevinCallIDsSafeBothWays(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
done := devinUpstream(t, devinCallReply())
defer done()
for _, c := range []struct {
from provider.Protocol
ask string
id func(body []byte) string
next func(id string) string
parse func([]byte) (*Request, error)
}{
{provider.Anthropic, `{"model":"x","max_tokens":10,"messages":[{"role":"user","content":"用 Bash 跑一下 date"}]}`,
func(b []byte) string {
var m struct{ Content []struct{ Type, ID string } }
json.Unmarshal(b, &m)
for _, p := range m.Content {
if p.Type == "tool_use" {
return p.ID
}
}
return ""
},
func(id string) string {
return `{"model":"x","max_tokens":10,"messages":[{"role":"user","content":"用 Bash 跑一下 date"},
{"role":"assistant","content":[{"type":"tool_use","id":"` + id + `","name":"Bash","input":{"command":"date"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"` + id + `","content":"Thu Oct 8 10:00:00 CST 2026"}]}]}`
}, parseAnthropic},
{provider.Chat, `{"model":"x","messages":[{"role":"user","content":"用 Bash 跑一下 date"}]}`,
func(b []byte) string {
var m struct {
Choices []struct {
Message struct {
ToolCalls []struct{ ID string } `json:"tool_calls"`
}
}
}
json.Unmarshal(b, &m)
if len(m.Choices) == 0 || len(m.Choices[0].Message.ToolCalls) == 0 {
return ""
}
return m.Choices[0].Message.ToolCalls[0].ID
},
func(id string) string {
return `{"model":"x","messages":[{"role":"user","content":"用 Bash 跑一下 date"},
{"role":"assistant","content":null,"tool_calls":[{"id":"` + id + `","type":"function","function":{"name":"Bash","arguments":"{\"command\":\"date\"}"}}]},
{"role":"tool","tool_call_id":"` + id + `","content":"Thu Oct 8 10:00:00 CST 2026"}]}`
}, parseChat},
{provider.Responses, `{"model":"x","input":"用 Bash 跑一下 date"}`,
func(b []byte) string {
var m struct {
Output []struct {
Type string
CallID string `json:"call_id"`
}
}
json.Unmarshal(b, &m)
for _, o := range m.Output {
if o.Type == "function_call" {
return o.CallID
}
}
return ""
},
func(id string) string {
return `{"model":"x","input":[{"role":"user","content":"用 Bash 跑一下 date"},
{"type":"function_call","call_id":"` + id + `","name":"Bash","arguments":"{\"command\":\"date\"}"},
{"type":"function_call_output","call_id":"` + id + `","output":"Thu Oct 8 10:00:00 CST 2026"}]}`
}, parseResponses},
} {
w := serveDevinOnce(t, c.from, c.ask)
id := c.id(w.Body.Bytes())
if w.Code != 200 || !anthropicSafeID.MatchString(id) {
t.Fatalf("%s: call id %q, want one of [A-Za-z0-9_-] only (%d %s)", c.from, id, w.Code, w.Body)
}
r, err := c.parse([]byte(c.next(id)))
if err != nil {
t.Fatalf("%s: %v", c.from, err)
}
calls, answers := devinIDsSent(t, r)
if strings.Join(calls, ",") != devinRawID || strings.Join(answers, ",") != devinRawID {
t.Fatalf("%s: Devin got calls %q, results for %q, want both %q", c.from, calls, answers, devinRawID)
}
}
// streamed to Claude Code too
w := serveDevinOnce(t, provider.Anthropic, `{"model":"x","max_tokens":10,"stream":true,"messages":[{"role":"user","content":"hi"}]}`)
if out := w.Body.String(); strings.Contains(out, devinRawID) || !strings.Contains(out, `"id":"`+devinOutID(devinRawID)+`"`) {
t.Fatalf("streamed: %s", out)
}
}
// An id that is already safe goes both ways as it came; one of the
// caller's own that begins dv_ (no id devinOutID makes) does too.
func TestDevinSafeCallIDsUnchanged(t *testing.T) {
for _, id := range []string{"toolu_01AbC", "call_abc-123", "t1", "dv_", "dv_abc"} {
if got := devinInID(devinOutID(id)); got != id {
t.Errorf("%q round-trips to %q", id, got)
}
if got := devinInID(id); got != id {
t.Errorf("%q from the agent reaches Devin as %q", id, got)
}
}
for _, id := range []string{"toolu_01AbC", "call_abc-123"} {
if got := devinOutID(id); got != id {
t.Errorf("%q goes out as %q", id, got)
}
}
}