Files
magpie/internal/gateway/codex_developer_cache_test.go
forever ee7ab461c5 gateway tests: a Claude Code run a test keeps for the next turn ends with the test, so -count no longer gathers 11 fake Claude Code processes a round (#1060)
Six tests have their server keep a Claude Code run for a conversation's
next turn, which the bridge holds for idleLongest (an hour):
TestClaudeRunKeptForTheNextTurn, TestClaudeOneOffAskNotKept,
TestClaudeRunLetGoWhenTheConversationMovedOn and
TestClaudeRunKeptAcrossEffort (claude_subscription_test.go),
TestClaudeRunKeptAfterTheClientsOwnToolCall and
TestCodexThreadKeepsItsClaudeRunAcrossAMemoryCall. Nothing ended those
runs, so each test's fake Claude Code ran on until the test binary
exited, 11 more each round: under -race -count=50, gateway.test had 77
of them after about 8 rounds.

Each now ends its server's runs as the gateway does when it stops,
t.Cleanup(s.subscription.abortAll), like the tests that already keep
runs. fakeClaude, and TestClaudeRunKeptAcrossEffort's own fake, register
runsEndWithTest before New(), so it runs after the test's cleanups: it
fails the test when a run of that fake is still in its server's bridge
and not ended, and ends it. Like the park sweep, it counts a run killed
or exited, on its way out of the bridge, as ended.

Without the six abortAll cleanups the six tests fail:

    claude_subscription_foreign_test.go:19: Claude Code runs the test started, still going after it: 3
    --- FAIL: TestClaudeRunKeptAfterTheClientsOwnToolCall
    claude_subscription_test.go:148: Claude Code runs the test started, still going after it: 3
    --- FAIL: TestClaudeRunKeptForTheNextTurn
    claude_subscription_test.go:192: Claude Code runs the test started, still going after it: 1
    --- FAIL: TestClaudeOneOffAskNotKept
    claude_subscription_test.go:349: Claude Code runs the test started, still going after it: 2
    --- FAIL: TestClaudeRunLetGoWhenTheConversationMovedOn
    claude_subscription_test.go:571: Claude Code runs the test started, still going after it: 1
    --- FAIL: TestClaudeRunKeptAcrossEffort
    codex_developer_cache_test.go:73: Claude Code runs the test started, still going after it: 1
    --- FAIL: TestCodexThreadKeepsItsClaudeRunAcrossAMemoryCall

With them they pass.

The test binary's processes after each round of -count=3, each test run
alone with a probe that lists gateway.test's descendants after it: before,
3, 6, 9 (TestClaudeRunKeptForTheNextTurn,
TestClaudeRunKeptAfterTheClientsOwnToolCall), 2, 4, 6
(TestClaudeRunLetGoWhenTheConversationMovedOn) and 1, 2, 3 (the other
three); the six in one run 11, 22, 33. After, none for each. The
package's other tests, run the same way one by one, left none running
into their next round: in a first round the probe saw only the stand-ins
TestMain runs once (testenv.StandIns), and once a fake of
TestClaudeBridgeSafeguardContextUpdates that abortAll had just killed.
The 21 that need bun, which end their plugin host with plugin.Settle, and
the live Codex test were skipped here.

-race -count=20 of the six passes. gofmt -l, go vet ./..., go build ./...,
GOOS=linux go build -tags nogui ./..., GOOS=windows go build ./... and
go test -tags nogui ./... pass under a temp HOME with Go's caches pinned.
Tests only.
2026-10-07 05:59:51 +08:00

110 lines
4.7 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()
t.Cleanup(s.subscription.abortAll)
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)
}
}