Files
byre/internal/codex/session_internal_test.go
T

752 lines
18 KiB
Go

package codex
import (
"bufio"
"bytes"
"context"
"encoding/json"
"image"
"image/color"
"image/draw"
"image/png"
"io"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
"g.fc.run/misc/byre/internal/mcp"
)
const (
testModel = "gpt-5.6-luna"
switchModel = "gpt-5.6-terra"
gitName = "Byre Test"
gitEmail = "test@byre.invalid"
)
type scenario struct {
name string
run func(h *harness)
}
var scenarios = []scenario{
{"CommitIdentity", scenarioCommitIdentity},
{"TextDeltas", scenarioTextDeltas},
{"Interrupt", scenarioInterrupt},
{"ImageInput", scenarioImageInput},
{"Resume", scenarioResume},
{"ModelSwitch", scenarioModelSwitch},
{"Compact", scenarioCompact},
{"Tools", scenarioTools},
{"Fork", scenarioFork},
{"Steer", scenarioSteer},
}
func TestLive(t *testing.T) {
for _, sc := range scenarios {
t.Run(sc.name, func(t *testing.T) {
t.Parallel()
h := &harness{t: t, name: sc.name, live: true, ctx: testContext(t, 5*time.Minute), repo: scratchRepo(t)}
sc.run(h)
h.closeAll()
for _, path := range h.files {
checkTranscript(t, path)
}
})
}
}
func TestContract(t *testing.T) {
for _, sc := range scenarios {
t.Run(sc.name, func(t *testing.T) {
t.Parallel()
h := &harness{t: t, name: sc.name, ctx: testContext(t, 30*time.Second), repo: t.TempDir()}
sc.run(h)
h.closeAll()
for _, path := range h.files {
checkTranscript(t, path)
}
})
}
}
func scenarioCommitIdentity(h *harness) {
s := h.start("", Options{})
h.send(s, `Create a file hello.txt containing exactly the word hi, then git add and git commit it with the message "add hello". Reply done when finished.`)
turn := h.waitTurn(s, nil)
if turn.Status != TurnStatusCompleted {
h.t.Fatalf("turn ended %s", turn.Status)
}
if !h.live {
return
}
log := git(h.t, h.repo, "log", "-1", "--format=%an <%ae>%n%cn <%ce>%n%B")
want := gitName + " <" + gitEmail + ">\n" + gitName + " <" + gitEmail + ">\nadd hello"
if !strings.HasPrefix(log, want) {
h.t.Fatalf("unexpected commit:\n%s", log)
}
lower := strings.ToLower(log)
if strings.Contains(lower, "co-authored-by") || strings.Contains(lower, "codex") {
h.t.Fatalf("agent attribution in commit:\n%s", log)
}
}
func scenarioTextDeltas(h *harness) {
s := h.start("", Options{})
h.send(s, "Without using any tools, count from one to twenty in words, separated by spaces.")
deltas := map[string][]string{}
h.waitTurn(s, func(ev Event) {
if ev.Method != MethodItemAgentMessageDelta {
return
}
d := decode[*AgentMessageDeltaNotification](h.t, ev)
deltas[d.ItemID] = append(deltas[d.ItemID], d.Delta)
})
msgs := h.messages[s]
if len(msgs) == 0 {
h.t.Fatalf("no agent message")
}
last := msgs[len(msgs)-1]
got := deltas[last.ID]
if len(got) < 2 {
h.t.Fatalf("expected several text deltas for %s, got %d", last.ID, len(got))
}
if strings.Join(got, "") != last.Text {
h.t.Fatalf("deltas %q don't add up to the final text %q", got, last.Text)
}
}
func scenarioInterrupt(h *harness) {
s := h.start("", Options{})
h.send(s, "Run the shell command `ping -c 120 127.0.0.1` and wait for it to finish, then say done.")
interruptedAt := time.Time{}
turn := h.waitTurn(s, func(ev Event) {
if !interruptedAt.IsZero() || ev.Method != MethodItemStarted {
return
}
item := decode[*ItemStartedNotification](h.t, ev).Item
if item.Type != ThreadItemTypeCommandExecution {
return
}
if h.live {
time.Sleep(2 * time.Second)
}
interruptedAt = time.Now()
err := s.Interrupt(h.ctx)
if err != nil {
h.t.Errorf("interrupt: %v", err)
}
})
if interruptedAt.IsZero() {
h.t.Fatalf("never saw a command to interrupt")
}
if turn.Status != TurnStatusInterrupted {
h.t.Fatalf("turn ended %s, want interrupted", turn.Status)
}
elapsed := time.Since(interruptedAt)
h.t.Logf("turn ended %s after interrupt", elapsed)
if elapsed > 30*time.Second {
h.t.Fatalf("interrupt took %s to end the turn", elapsed)
}
h.send(s, "Reply with the single word ok.")
h.waitTurn(s, nil)
if !strings.Contains(strings.ToLower(h.lastText(s)), "ok") {
h.t.Fatalf("session unusable after interrupt; assistant said %q", h.lastText(s))
}
}
func scenarioImageInput(h *harness) {
s := h.start("", Options{})
img := image.NewRGBA(image.Rect(0, 0, 64, 64))
draw.Draw(img, img.Bounds(), &image.Uniform{C: color.RGBA{R: 255, A: 255}}, image.Point{}, draw.Src)
buf := bytes.Buffer{}
err := png.Encode(&buf, img)
if err != nil {
h.t.Fatalf("encode png: %v", err)
}
_, err = s.Send(h.ctx, "Without using any tools, what single colour fills the attached image? Reply with just the colour name.", Image{MediaType: "image/png", Data: buf.Bytes()})
if err != nil {
h.t.Fatalf("send: %v", err)
}
h.waitTurn(s, nil)
if !strings.Contains(strings.ToLower(h.lastText(s)), "red") {
h.t.Fatalf("model didn't see the image; assistant said %q", h.lastText(s))
}
}
func scenarioResume(h *harness) {
first := h.start("first", Options{})
h.send(first, "Remember the codeword AZURE-17. Reply with the single word ok.")
h.waitTurn(first, nil)
id := first.ID()
h.close(first)
if id == "" {
h.t.Fatalf("no thread id from first session")
}
resumed := h.start("resumed", Options{Resume: id})
h.send(resumed, "What was the codeword? Reply with just the codeword.")
h.waitTurn(resumed, nil)
if !strings.Contains(h.lastText(resumed), "AZURE-17") {
h.t.Fatalf("resumed session lost context; assistant said %q", h.lastText(resumed))
}
if resumed.ID() != id {
h.t.Fatalf("resume changed thread id: want %s, got %s", id, resumed.ID())
}
}
func scenarioModelSwitch(h *harness) {
s := h.start("", Options{})
h.send(s, "Reply with the single word ok.")
h.waitTurn(s, nil)
before := h.threadModel(s)
if before != testModel {
h.t.Fatalf("thread model %q before the switch, want %s", before, testModel)
}
s.SetModel(switchModel)
h.send(s, "Reply with the single word ok.")
turn := h.waitTurn(s, nil)
if turn.Status != TurnStatusCompleted {
h.t.Fatalf("turn on %s ended %s: %+v", switchModel, turn.Status, turn.Error)
}
after := h.threadModel(s)
if after != switchModel {
h.t.Fatalf("thread model %q after the switch, want %s", after, switchModel)
}
}
func (h *harness) threadModel(s *Session) string {
resp := ThreadReadResponse{}
err := s.Call(h.ctx, MethodThreadRead, ThreadReadParams{ThreadID: s.ID()}, &resp)
if err != nil {
h.t.Fatalf("read thread: %v", err)
}
return resp.Thread.Model
}
func scenarioCompact(h *harness) {
s := h.start("", Options{})
h.send(s, "Remember the codeword CORAL-42. Reply with the single word ok.")
h.waitTurn(s, nil)
err := s.Compact(h.ctx)
if err != nil {
h.t.Fatalf("compact: %v", err)
}
compacted := false
h.waitTurn(s, func(ev Event) {
if ev.Method == MethodThreadCompacted {
compacted = true
}
if ev.Method == MethodItemCompleted && decode[*ItemCompletedNotification](h.t, ev).Item.Type == ThreadItemTypeContextCompaction {
compacted = true
}
})
if !compacted {
h.t.Fatalf("compaction turn ended without compacting")
}
h.send(s, "What was the codeword? Reply with just the codeword.")
h.waitTurn(s, nil)
if !strings.Contains(h.lastText(s), "CORAL-42") {
h.t.Fatalf("compacted session lost context; assistant said %q", h.lastText(s))
}
}
func lookupServer(calls *[]string) *mcp.Server {
return &mcp.Server{Name: "byre", Version: "0.0.0", Tools: []mcp.Tool{{
Name: "lookup",
Description: "Look up the current codeword for a topic.",
InputSchema: json.RawMessage(`{"type":"object","properties":{"topic":{"type":"string"}},"required":["topic"]}`),
Handler: func(ctx context.Context, args json.RawMessage) (string, error) {
*calls = append(*calls, string(args))
return "The codeword is TEAL-9.", nil
},
}}}
}
func scenarioTools(h *harness) {
calls := []string{}
srv := lookupServer(&calls)
first := h.start("first", Options{MCPServers: []*mcp.Server{srv}, Reminder: "This is a byre test session."})
h.send(first, "Call byre's lookup tool with topic birds, then reply with just the codeword it gives.")
h.waitTurn(first, nil)
if len(calls) != 1 || !strings.Contains(h.lastText(first), "TEAL-9") {
h.t.Fatalf("tool calls %v; assistant said %q", calls, h.lastText(first))
}
id := first.ID()
h.close(first)
resumed := h.start("resumed", Options{Resume: id, MCPServers: []*mcp.Server{srv}})
h.send(resumed, "Call byre's lookup tool again with topic fish, then reply with just the codeword it gives.")
h.waitTurn(resumed, nil)
if len(calls) != 2 || !strings.Contains(h.lastText(resumed), "TEAL-9") {
h.t.Fatalf("resumed thread lost the tool: calls %v; assistant said %q", calls, h.lastText(resumed))
}
}
func scenarioFork(h *harness) {
src := h.start("source", Options{})
h.send(src, "Remember the codeword AZURE-17. Reply with the single word ok.")
keep := h.waitTurn(src, nil)
h.send(src, "The codeword is now BEIGE-3; forget the old one. Reply with the single word ok.")
h.waitTurn(src, nil)
id := src.ID()
h.close(src)
fork := h.start("fork", Options{ForkFrom: id, ForkAt: keep.ID})
if fork.ID() == "" || fork.ID() == id {
h.t.Fatalf("fork thread id %q, source %q", fork.ID(), id)
}
h.send(fork, "What is the codeword? Reply with just the codeword.")
h.waitTurn(fork, nil)
text := h.lastText(fork)
if !strings.Contains(text, "AZURE-17") || strings.Contains(text, "BEIGE-3") {
h.t.Fatalf("fork didn't stop after the first turn; assistant said %q", text)
}
}
func scenarioSteer(h *harness) {
s := h.start("", Options{})
h.send(s, "Run the shell command `sleep 5 && echo finished`, then reply with what it printed.")
steered := false
h.waitTurn(s, func(ev Event) {
if steered || ev.Method != MethodItemStarted {
return
}
if decode[*ItemStartedNotification](h.t, ev).Item.Type != ThreadItemTypeCommandExecution {
return
}
steered = true
err := s.Steer(h.ctx, "Also end your reply with the word PELICAN.")
if err != nil {
h.t.Errorf("steer: %v", err)
}
})
if !steered {
h.t.Fatalf("never saw a command to steer during")
}
if !strings.Contains(h.lastText(s), "PELICAN") {
h.t.Fatalf("steer didn't reach the turn; assistant said %q", h.lastText(s))
}
}
type harness struct {
t *testing.T
name string
live bool
ctx context.Context
repo string
files []string
sessions []*Session
closed map[*Session]bool
replays map[*Session]*replay
messages map[*Session][]ThreadItem
}
func (h *harness) path(dir, suffix string) string {
name := h.name
if suffix != "" {
name += "-" + suffix
}
return filepath.Join("testdata", dir, name+".jsonl")
}
func (h *harness) start(suffix string, opts Options) *Session {
opts.Cwd = h.repo
opts.Model = testModel
opts.GitName = gitName
opts.GitEmail = gitEmail
opts.OnBadLine = func(line []byte, reason string) {
h.t.Errorf("bad line (%s): %s", reason, line)
}
if h.messages == nil {
h.messages = map[*Session][]ThreadItem{}
h.closed = map[*Session]bool{}
h.replays = map[*Session]*replay{}
}
if h.live {
path := h.path("transcripts", suffix)
err := os.MkdirAll(filepath.Dir(path), 0o755)
if err != nil {
h.t.Fatalf("mkdir transcripts: %v", err)
}
f, err := os.Create(path)
if err != nil {
h.t.Fatalf("create transcript: %v", err)
}
h.t.Cleanup(func() { f.Close() })
opts.Transcript = f
h.files = append(h.files, path)
s, err := Start(h.ctx, opts)
if err != nil {
h.t.Fatalf("start: %v", err)
}
h.sessions = append(h.sessions, s)
return s
}
path := h.path("contract", suffix)
h.files = append(h.files, path)
r := newReplay(h.t, path)
s := newSession(opts, r.clientStdin, r.clientStdout)
h.sessions = append(h.sessions, s)
h.replays[s] = r
err := s.open(h.ctx, opts)
if err != nil {
h.t.Fatalf("open: %v", err)
}
return s
}
func (h *harness) close(s *Session) {
if h.closed[s] {
return
}
h.closed[s] = true
if h.live {
err := s.Close()
if err != nil {
h.t.Logf("codex exit: %v", err)
}
return
}
s.stdin.Close()
<-s.done
h.replays[s].wait()
}
func (h *harness) closeAll() {
for _, s := range h.sessions {
h.close(s)
}
}
func (h *harness) send(s *Session, text string) {
_, err := s.Send(h.ctx, text)
if err != nil {
h.t.Fatalf("send: %v", err)
}
}
func (h *harness) waitTurn(s *Session, onEvent func(Event)) Turn {
for {
select {
case ev, ok := <-s.Events():
if !ok {
h.t.Fatalf("codex exited before the turn completed")
}
if onEvent != nil {
onEvent(ev)
}
if ev.Method == MethodItemCompleted {
item := decode[*ItemCompletedNotification](h.t, ev).Item
if item.Type == ThreadItemTypeAgentMessage {
h.messages[s] = append(h.messages[s], item)
}
}
if ev.Method == MethodTurnCompleted {
turn := decode[*TurnCompletedNotification](h.t, ev).Turn
h.t.Logf("turn %s %s", turn.ID, turn.Status)
return turn
}
case <-h.ctx.Done():
h.t.Fatalf("timed out waiting for the turn to complete")
}
}
}
func (h *harness) lastText(s *Session) string {
msgs := h.messages[s]
if len(msgs) == 0 {
return ""
}
return msgs[len(msgs)-1].Text
}
func decode[T any](t *testing.T, ev Event) T {
v, err := ev.Decode()
if err != nil {
t.Fatalf("decode %s: %v", ev.Method, err)
}
out, ok := v.(T)
if !ok {
t.Fatalf("decode %s: got %T", ev.Method, v)
}
return out
}
type entry struct {
Dir string `json:"dir"`
Msg json.RawMessage `json:"msg"`
}
func readTranscript(t *testing.T, path string) []entry {
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read transcript: %v", err)
}
out := []entry{}
for _, line := range bytes.Split(bytes.TrimSpace(b), []byte("\n")) {
e := entry{}
err := json.Unmarshal(line, &e)
if err != nil {
t.Fatalf("parse transcript line %s: %v", line, err)
}
out = append(out, e)
}
return out
}
func strict(raw json.RawMessage, v any) error {
d := json.NewDecoder(bytes.NewReader(raw))
d.DisallowUnknownFields()
return d.Decode(v)
}
func checkTranscript(t *testing.T, path string) {
methods := map[string]string{}
for i, e := range readTranscript(t, path) {
m := message{}
err := json.Unmarshal(e.Msg, &m)
if err != nil {
t.Fatalf("%s:%d: %v", path, i+1, err)
}
if e.Dir == "send" {
if m.Method != "" && len(m.ID) > 0 {
methods[string(m.ID)] = m.Method
}
continue
}
var ctor func() any
var raw json.RawMessage
label := m.Method
switch {
case m.Method == "":
label = methods[string(m.ID)] + " response"
c, ok := clientResults[methods[string(m.ID)]]
if !ok {
t.Errorf("%s:%d: response to unknown request %s", path, i+1, m.ID)
continue
}
if m.Error != nil {
continue
}
ctor, raw = c, m.Result
case len(m.ID) > 0:
c, ok := serverRequestParams[m.Method]
if !ok {
t.Errorf("%s:%d: unknown server request %s", path, i+1, m.Method)
continue
}
ctor, raw = c, m.Params
default:
c, ok := notificationParams[m.Method]
if !ok {
t.Errorf("%s:%d: unknown notification %s", path, i+1, m.Method)
continue
}
ctor, raw = c, m.Params
}
if ctor == nil {
continue
}
err = strict(raw, ctor())
if err != nil {
t.Errorf("%s:%d: %s doesn't match the generated types: %v", path, i+1, label, err)
}
}
}
type replay struct {
t *testing.T
entries []entry
clientStdin io.WriteCloser
clientStdout io.Reader
serverIn *bufio.Reader
serverOut io.WriteCloser
ids map[string]json.RawMessage
done chan struct{}
once sync.Once
}
func newReplay(t *testing.T, path string) *replay {
inR, inW := io.Pipe()
outR, outW := io.Pipe()
r := &replay{
t: t,
entries: readTranscript(t, path),
clientStdin: inW,
clientStdout: outR,
serverIn: bufio.NewReader(inR),
serverOut: outW,
ids: map[string]json.RawMessage{},
done: make(chan struct{}),
}
go r.run()
return r
}
func (r *replay) wait() {
<-r.done
}
func (r *replay) run() {
defer close(r.done)
defer r.serverOut.Close()
for i, e := range r.entries {
if e.Dir == "recv" {
_, err := r.serverOut.Write(append(r.rewrite(e.Msg), '\n'))
if err != nil {
r.t.Errorf("replay entry %d: client stopped reading: %v", i+1, err)
return
}
continue
}
line, err := r.serverIn.ReadBytes('\n')
if err != nil {
r.t.Errorf("replay entry %d: client closed instead of sending %s", i+1, e.Msg)
return
}
r.match(i+1, e.Msg, bytes.TrimSpace(line))
}
extra, err := r.serverIn.ReadBytes('\n')
if len(bytes.TrimSpace(extra)) > 0 {
r.t.Errorf("client sent more than the transcript: %s", extra)
}
if err == nil {
r.t.Errorf("client kept stdin open after the transcript ended")
}
}
func (r *replay) rewrite(raw json.RawMessage) json.RawMessage {
m := message{}
err := json.Unmarshal(raw, &m)
if err != nil || m.Method != "" || len(m.ID) == 0 {
return raw
}
id, ok := r.ids[string(m.ID)]
if !ok {
return raw
}
m.ID = id
b, err := json.Marshal(m)
if err != nil {
r.t.Errorf("rewrite response: %v", err)
return raw
}
return b
}
func (r *replay) match(n int, want, got json.RawMessage) {
w, g := message{}, message{}
err := json.Unmarshal(want, &w)
if err != nil {
r.t.Errorf("replay entry %d: %v", n, err)
return
}
err = json.Unmarshal(got, &g)
if err != nil {
r.t.Errorf("replay entry %d: client sent %s: %v", n, got, err)
return
}
if w.Method != "" && len(w.ID) > 0 {
r.ids[string(w.ID)] = g.ID
} else if !bytes.Equal(w.ID, g.ID) {
r.t.Errorf("replay entry %d: id %s, want %s", n, g.ID, w.ID)
}
if w.Method != g.Method {
r.t.Errorf("replay entry %d: client sent %s, want %s", n, got, want)
return
}
if !sameJSON(w.Params, g.Params) || !sameJSON(w.Result, g.Result) {
r.t.Errorf("replay entry %d: client sent\n%s\nwant\n%s", n, got, want)
}
}
func sameJSON(a, b json.RawMessage) bool {
if len(a) == 0 || len(b) == 0 {
return len(a) == len(b)
}
var x, y any
if json.Unmarshal(a, &x) != nil || json.Unmarshal(b, &y) != nil {
return false
}
return reflect.DeepEqual(dropCwd(x), dropCwd(y))
}
func dropCwd(v any) any {
m, ok := v.(map[string]any)
if !ok {
return v
}
delete(m, "cwd")
return m
}
func testContext(t *testing.T, d time.Duration) context.Context {
ctx, cancel := context.WithTimeout(context.Background(), d)
t.Cleanup(cancel)
return ctx
}
func scratchRepo(t *testing.T) string {
dir := t.TempDir()
git(t, dir, "init", "--initial-branch=main")
git(t, dir, "commit", "--allow-empty", "--message", "root")
return dir
}
func git(t *testing.T, dir string, args ...string) string {
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME="+gitName,
"GIT_AUTHOR_EMAIL="+gitEmail,
"GIT_COMMITTER_NAME="+gitName,
"GIT_COMMITTER_EMAIL="+gitEmail,
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return string(out)
}