package agent
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/johannSo/codey/internal/llm"
"github.com/johannSo/codey/internal/metrics"
"github.com/johannSo/codey/internal/tools"
)
// settle is how long a test waits before concluding that nothing is going to
// happen. A paused agent proves itself by silence, which can only be measured
// against a clock.
const settle = 150 * time.Millisecond
// pausableAPI announces each request on a channel so a test can tell the
// difference between "not sent yet" and "never sent".
type pausableAPI struct {
mu sync.Mutex
responses []string
bodies [][]byte
n int
sent chan struct{}
srv *httptest.Server
}
func newPausableAPI(t *testing.T, responses ...string) *pausableAPI {
t.Helper()
f := &pausableAPI{responses: responses, sent: make(chan struct{}, len(responses)+4)}
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
f.mu.Lock()
i := f.n
f.n++
f.bodies = append(f.bodies, body)
f.mu.Unlock()
f.sent <- struct{}{}
if i >= len(f.responses) {
w.WriteHeader(http.StatusInternalServerError)
return
}
io.WriteString(w, f.responses[i])
}))
t.Cleanup(f.srv.Close)
return f
}
// lastRequest is the body of the most recent request, decoded.
func (f *pausableAPI) lastRequest(t *testing.T) llm.Request {
t.Helper()
f.mu.Lock()
defer f.mu.Unlock()
if len(f.bodies) == 0 {
t.Fatal("no request was made")
}
var req llm.Request
if err := json.Unmarshal(f.bodies[len(f.bodies)-1], &req); err != nil {
t.Fatalf("request body does not decode: %v", err)
}
return req
}
func (f *pausableAPI) count() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.n
}
// waitForRequest blocks until one more request arrives, or fails the test.
func (f *pausableAPI) waitForRequest(t *testing.T) {
t.Helper()
select {
case <-f.sent:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for a request")
}
}
func newPausableAgent(t *testing.T, f *pausableAPI, root string) *Agent {
t.Helper()
log := metrics.NewWriter(&bytes.Buffer{}, "test")
state := NewState()
reg := tools.NewRegistry(log).Register(tools.NewRead(root, state))
client := llm.New(llm.WithBaseURL(f.srv.URL), llm.WithAPIKey("k"))
return New(client, reg, state, log, Options{})
}
// A paused agent sends nothing until it is resumed. This is the whole of what
// pause means: the conversation is intact, it is simply not being spent on.
func TestPauseHoldsTheNextRequestUntilResume(t *testing.T) {
root := t.TempDir()
os.WriteFile(filepath.Join(root, "a.go"), []byte("A\n"), 0o644)
f := newPausableAPI(t,
msg("tool_use", toolUseBlock("tu_1", "read_file", `{"path":"a.go"}`)),
msg("end_turn", textBlock("done")),
)
a := newPausableAgent(t, f, root)
paused := make(chan struct{}, 1)
a.Observe = func(ev Event) {
if ev.Kind == EventToolResult {
a.Pause()
}
if ev.Kind == EventPaused {
paused <- struct{}{}
}
}
out := make(chan string, 1)
go func() {
text, err := a.Run(context.Background(), "go")
if err != nil {
t.Errorf("Run: %v", err)
}
out <- text
}()
f.waitForRequest(t)
select {
case <-paused:
case <-time.After(2 * time.Second):
t.Fatal("no EventPaused was emitted")
}
time.Sleep(settle)
if n := f.count(); n != 1 {
t.Fatalf("a paused agent sent %d requests, want 1", n)
}
a.Resume()
select {
case text := <-out:
if text != "done" {
t.Errorf("after resume the run returned %q, want %q", text, "done")
}
case <-time.After(2 * time.Second):
t.Fatal("the run never finished after Resume")
}
if n := f.count(); n != 2 {
t.Errorf("made %d requests overall, want 2", n)
}
}
// Pausing mid-turn does not abandon the work in flight: the tool calls the
// model already asked for still run, and their results are still what the
// resumed request carries. Holding anywhere else would leave a tool_use with
// no tool_result, which is an API error, not a pause.
func TestPauseWaitsForTheTurnInFlightToFinish(t *testing.T) {
root := t.TempDir()
os.WriteFile(filepath.Join(root, "a.go"), []byte("A\n"), 0o644)
f := newPausableAPI(t,
msg("tool_use", toolUseBlock("tu_1", "read_file", `{"path":"a.go"}`)),
msg("end_turn", textBlock("done")),
)
a := newPausableAgent(t, f, root)
var order []EventKind
var mu sync.Mutex
a.Observe = func(ev Event) {
mu.Lock()
defer mu.Unlock()
switch ev.Kind {
case EventToolStart:
a.Pause()
order = append(order, ev.Kind)
case EventToolResult, EventPaused, EventResumed:
order = append(order, ev.Kind)
}
}
done := make(chan struct{})
go func() {
if _, err := a.Run(context.Background(), "go"); err != nil {
t.Errorf("Run: %v", err)
}
close(done)
}()
// Give the hold time to land, then release it.
time.Sleep(settle)
a.Resume()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("the run never finished after Resume")
}
mu.Lock()
defer mu.Unlock()
want := []EventKind{EventToolStart, EventToolResult, EventPaused, EventResumed}
if len(order) != len(want) {
t.Fatalf("events = %v, want %v", order, want)
}
for i := range want {
if order[i] != want[i] {
t.Fatalf("events = %v, want %v", order, want)
}
}
}
// Killing a paused agent is the ordinary case — you pause one to look at it and
// then decide it is not worth continuing. It must not sit on the channel
// forever waiting for a Resume that is never coming.
func TestCancelWhilePausedEndsTheRun(t *testing.T) {
f := newPausableAPI(t, msg("end_turn", textBlock("hi")))
a := newPausableAgent(t, f, t.TempDir())
a.Pause()
ctx, cancel := context.WithCancel(context.Background())
errc := make(chan error, 1)
go func() {
_, err := a.Run(ctx, "go")
errc <- err
}()
time.Sleep(settle)
if n := f.count(); n != 0 {
t.Fatalf("an agent paused before its first turn sent %d requests", n)
}
cancel()
select {
case err := <-errc:
if !errors.Is(err, context.Canceled) {
t.Errorf("Run returned %v, want context.Canceled", err)
}
case <-time.After(2 * time.Second):
t.Fatal("a cancelled pause never unblocked")
}
}
// A subagent's report reaches the parent as a user message the front end put
// into the conversation directly, not as something the human typed. Continue
// drives the loop on the history as it stands, so the report is one message
// and whatever the user says next is another.
func TestContinueRunsOnTheHistoryWithoutAddingAMessage(t *testing.T) {
f := newPausableAPI(t, msg("end_turn", textBlock("acknowledged")))
a := newPausableAgent(t, f, t.TempDir())
a.State().Append(llm.Message{
Role: llm.RoleUser,
Content: []llm.ContentBlock{llm.TextBlock("[subagent a1 finished] it is in loop.go")},
})
text, err := a.Continue(context.Background())
if err != nil {
t.Fatalf("Continue: %v", err)
}
if text != "acknowledged" {
t.Errorf("Continue returned %q", text)
}
history := a.State().History()
if len(history) != 2 {
t.Fatalf("history has %d messages, want the report and the answer", len(history))
}
if history[0].Content[0].Text != "[subagent a1 finished] it is in loop.go" {
t.Errorf("first message = %+v, want the report untouched", history[0])
}
}
// Continuing an empty conversation would send a request with no messages,
// which the API rejects; saying so here is clearer than the error that comes
// back from the endpoint.
func TestContinueOnAnEmptyConversationIsRefused(t *testing.T) {
f := newPausableAPI(t)
a := newPausableAgent(t, f, t.TempDir())
if _, err := a.Continue(context.Background()); err == nil {
t.Fatal("continuing an empty conversation succeeded")
}
if f.count() != 0 {
t.Error("it sent a request anyway")
}
}
// A paused agent has not spent the turn it is holding before, so nothing
// should be counting it yet: the sidebar would show a turn that never
// happened.
func TestNoTurnIsAnnouncedUntilThePauseIsReleased(t *testing.T) {
f := newPausableAPI(t, msg("end_turn", textBlock("done")))
a := newPausableAgent(t, f, t.TempDir())
a.Pause()
turns := make(chan int, 8)
a.Observe = func(ev Event) {
if ev.Kind == EventTurnStart {
turns <- ev.Turn
}
}
go a.Run(context.Background(), "go")
time.Sleep(settle)
select {
case turn := <-turns:
t.Fatalf("turn %d was announced while the agent was still held", turn)
default:
}
a.Resume()
select {
case <-turns:
case <-time.After(2 * time.Second):
t.Fatal("no turn was announced after the pause was released")
}
}
// Effort is not a universal parameter. Sending the session's level to a model
// that rejects it fails the whole run with a 400 — which is how a Haiku
// subagent spawned from a high-effort session used to die before its first
// turn.
func TestEffortIsOmittedForAModelThatRejectsIt(t *testing.T) {
cases := []struct {
model, effort string
wantEffort string
}{
{"claude-opus-5", "high", "high"},
{"claude-haiku-4-5", "high", ""},
{"claude-opus-4-6", "xhigh", ""},
{"claude-opus-4-6", "max", "max"},
}
for _, c := range cases {
f := newPausableAPI(t, msg("end_turn", textBlock("ok")))
a := newPausableAgent(t, f, t.TempDir())
a.client.SetModel(c.model)
a.SetEffort(c.effort)
if _, err := a.Run(context.Background(), "go"); err != nil {
t.Fatalf("%s: Run: %v", c.model, err)
}
req := f.lastRequest(t)
switch {
case c.wantEffort == "" && req.OutputConfig != nil:
t.Errorf("%s at %s: sent output_config %+v, which the model rejects",
c.model, c.effort, req.OutputConfig)
case c.wantEffort != "" && (req.OutputConfig == nil || req.OutputConfig.Effort != c.wantEffort):
t.Errorf("%s at %s: output_config = %+v, want effort %q",
c.model, c.effort, req.OutputConfig, c.wantEffort)
}
}
}