Files
magpie/internal/gateway/codex_developer_cache_test.go
T
yetone 0cb6e13d91 gateway: a developer message Codex adds mid-thread stays where it is, so the thread's cached prefix does too (#502, Koohoko: after a memory request the next turn of the same session wrote the whole conversation to the cache again) — Codex adds a developer message to a thread where its context changed (a world-state or skills diff, a settings or model change, and so after its memory pipeline has run) and keeps it there in every later request, which leaves OpenAI's prefix as it was. parseResponses lifted every developer and system message, wherever it stood, into the system prompt, so on a translated route that turn's system changed: Anthropic's system comes before the messages, so the whole conversation was written to the cache again, and a Claude subscription's waiting Claude Code is found by its system (turnKey), so a new one was started and told the whole conversation in one message. Developer messages before the thread's first reply (Codex's initial context) are lifted as before; one after it now stays in place as a user message inside <system-reminder>, which the router's classifier already leaves out of the user's words. The memory request itself (another model, its own prompt, the same session) moves nothing in magpie: affinity is scoped by model or by a group's first words, the Claude bridge's runs by the conversation. TestCodexLaterDeveloperMessageKeepsTheSystem and TestCodexThreadKeepsItsClaudeRunAcrossAMemoryCall (a Codex thread on a Claude subscription, a memory call in the same session between, then a developer message: the same Claude Code goes on) fail without the change. No real vendor request was made. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./internal/gateway/ ./internal/provider/ pass.
2026-10-02 13:00:22 +08:00

109 lines
4.6 KiB
Go

package gateway
import (
"encoding/json"
"net/http/httptest"
"regexp"
"strconv"
"strings"
"testing"
"github.com/yetone/magpie/internal/provider"
)
// codexItem is a Responses input message, as Codex sends one.
func codexItem(role, text string) string {
kind := "input_text"
if role == "assistant" {
kind = "output_text"
}
return `{"type":"message","role":"` + role + `","content":[{"type":"` + kind + `","text":` + strconv.Quote(text) + `}]}`
}
// A developer message Codex adds once its conversation has gone on (its
// context changed: world state, settings) stays where it is: the system
// prompt, and so Anthropic's cached prefix, is the one the turns before
// had (#502: the turn after it wrote the whole conversation to the cache).
func TestCodexLaterDeveloperMessageKeepsTheSystem(t *testing.T) {
head := codexItem("developer", "<permissions instructions>sandboxed</permissions instructions>") + `,` +
codexItem("user", "<environment_context>cwd</environment_context>") + `,` + codexItem("user", "fix the bug")
turn1 := `{"model":"claude-opus-5-5","instructions":"You are Codex.","input":[` + head + `]}`
turn2 := `{"model":"claude-opus-5-5","instructions":"You are Codex.","input":[` + head + `,` +
codexItem("assistant", "done") + `,` + codexItem("developer", "<skills_instructions>a new skill</skills_instructions>") + `,` +
codexItem("user", "thanks, and the test?") + `]}`
r1, err := parseResponses([]byte(turn1))
if err != nil {
t.Fatal(err)
}
r2, err := parseResponses([]byte(turn2))
if err != nil {
t.Fatal(err)
}
if r1.System != r2.System || !strings.Contains(r1.System, "permissions instructions") {
t.Fatalf("system changed:\n%q\n%q", r1.System, r2.System)
}
var q1, q2 struct {
System json.RawMessage
Messages []json.RawMessage
}
json.Unmarshal(buildAnthropic(r1, "claude-opus-5-5"), &q1)
json.Unmarshal(buildAnthropic(r2, "claude-opus-5-5"), &q2)
if string(q1.System) != string(q2.System) {
t.Fatalf("Anthropic's system changed:\n%s\n%s", q1.System, q2.System)
}
last := r2.Messages[len(r2.Messages)-1]
if len(r2.Messages) != 3 || last.Role != "user" || !strings.Contains(text(last.Parts), "<system-reminder>\n<skills_instructions>") ||
!strings.HasSuffix(text(last.Parts), "thanks, and the test?") {
t.Fatalf("messages: %+v", r2.Messages)
}
// the user's words are what the router's classifier is asked about
if got := userText(r2); got != "thanks, and the test?" {
t.Fatalf("user text %q", got)
}
if turn, within := turnIn(r2); turn != 2 || within {
t.Fatalf("turn %d within %v", turn, within)
}
}
// A Codex thread on a Claude subscription goes on in the Claude Code that
// had its turn before, when a memory request of Codex's (another model,
// its own instructions, the same session) came in between and Codex then
// added a developer message to the thread.
func TestCodexThreadKeepsItsClaudeRunAcrossAMemoryCall(t *testing.T) {
fakeClaude(t)
s := New()
p := provider.Provider{ID: "claude", Account: &provider.Account{Agent: "claude", User: "u"}}
said := regexp.MustCompile(`pid \d+ turn \d+`)
ask := func(body string) string {
t.Helper()
req := httptest.NewRequest("POST", "/v1/responses", strings.NewReader(body))
req.Header.Set("session_id", "019a-codex-thread")
rec := httptest.NewRecorder()
var u Usage
if code, msg := s.serveClaudeSubscription(rec, req, provider.Responses, p, "claude-sonnet-5", []byte(body), &u); code != 200 {
t.Fatalf("%d %s", code, msg)
}
got := said.FindString(rec.Body.String())
if got == "" {
t.Fatalf("no answer: %s", rec.Body)
}
return got
}
tools := `"tools":[{"type":"function","name":"shell","parameters":{"type":"object"}}]`
head := codexItem("developer", "<permissions instructions>sandboxed</permissions instructions>") + `,` + codexItem("user", "fix the bug")
first := ask(`{"model":"claude-sonnet-5","instructions":"You are Codex.",` + tools + `,"input":[` + head + `]}`)
pid, _, _ := strings.Cut(strings.TrimPrefix(first, "pid "), " ")
// Codex's memory extraction: the same session, a prompt of its own
if mem := ask(`{"model":"claude-sonnet-5","instructions":"Extract memories.","input":[` + codexItem("user", "rollout: …") + `]}`); strings.Contains(mem, "pid "+pid+" ") {
t.Fatalf("the memory call took the thread's run: %q", mem)
}
second := ask(`{"model":"claude-sonnet-5","instructions":"You are Codex.",` + tools + `,"input":[` + head + `,` +
codexItem("assistant", first) + `,` + codexItem("developer", "<skills_instructions>memories updated</skills_instructions>") + `,` +
codexItem("user", "and the test?") + `]}`)
if second != "pid "+pid+" turn 2" {
t.Fatalf("the thread's next turn: %q, first %q", second, first)
}
}