Vectrum
Sign In
355 lines · 10.2 KB · Go
package serve

import (
	"context"
	"io"
	"strings"
	"sync"
	"testing"
	"time"

	"codey/internal/acp"
	"codey/internal/agent"
	"codey/internal/llm"
	"codey/internal/metrics"
	"codey/internal/subagent"
	"codey/internal/tools"
)

// fakeAgents is a supervisor the test drives by hand: no children, no model
// calls, just the two callbacks and a snapshot the test controls.
type fakeAgents struct {
	mu       sync.Mutex
	observe  func(subagent.Event)
	changed  func()
	infos    []subagent.Info
	reports  []subagent.Info
	claimed  map[string]bool
	shutdown bool
}

func newFakeAgents() *fakeAgents { return &fakeAgents{claimed: map[string]bool{}} }

func (f *fakeAgents) Subscribe(observe func(subagent.Event), changed func()) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.observe, f.changed = observe, changed
}

func (f *fakeAgents) Snapshot() []subagent.Info {
	f.mu.Lock()
	defer f.mu.Unlock()
	return append([]subagent.Info(nil), f.infos...)
}

func (f *fakeAgents) TakeReports() []subagent.Info {
	f.mu.Lock()
	defer f.mu.Unlock()
	out := f.reports
	f.reports = nil
	return out
}

func (f *fakeAgents) ClaimReport(id string) bool {
	f.mu.Lock()
	defer f.mu.Unlock()
	if f.claimed[id] {
		return false
	}
	f.claimed[id] = true
	return true
}

func (f *fakeAgents) Shutdown() {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.shutdown = true
}

// say makes a child speak.
func (f *fakeAgents) say(id, preset, text string) {
	f.mu.Lock()
	observe := f.observe
	f.mu.Unlock()
	observe(subagent.Event{ID: id, Preset: preset, Model: "test-model",
		Agent: agent.Event{Kind: agent.EventText, Text: text}})
}

// finish ends a child, queues its report, and fires the change callback.
func (f *fakeAgents) finish(info subagent.Info) {
	f.mu.Lock()
	f.infos = append(f.infos, info)
	if info.Status == subagent.Done {
		f.reports = append(f.reports, info)
	}
	changed := f.changed
	f.mu.Unlock()
	changed()
}

// live adds a child that has not finished.
func (f *fakeAgents) live(info subagent.Info) {
	f.mu.Lock()
	f.infos = append(f.infos, info)
	f.mu.Unlock()
}

// --- the row translation, tested directly --------------------------------

func TestASubagentGetsItsOwnRow(t *testing.T) {
	e, r := newRecorded()
	sess := &session{emit: e, agentsMoved: make(chan struct{}, 1)}
	f := newFakeAgents()
	sess.agents = f
	sess.watchAgents()

	f.say("a1", "explore", strings.Repeat("looking around. ", 40))

	if len(r.updates) < 2 {
		t.Fatalf("want a row and an update, got %v", r.kinds())
	}
	open := r.updates[0]
	if open.SessionUpdate != acp.UpdateToolCall {
		t.Fatalf("first update = %q", open.SessionUpdate)
	}
	if open.ToolCallID != "agent-a1" {
		t.Errorf("toolCallId = %q — it must not collide with a real tool call", open.ToolCallID)
	}
	if !strings.Contains(open.Title, "a1") || !strings.Contains(open.Title, "explore") {
		t.Errorf("title = %q", open.Title)
	}
	if open.Status != acp.StatusInProgress {
		t.Errorf("status = %q", open.Status)
	}
	if !strings.Contains(contentText(t, r.updates[1]), "looking around") {
		t.Errorf("the child's words did not reach its row")
	}
}

func TestARowIsOpenedOnceAndClosedOnce(t *testing.T) {
	e, r := newRecorded()
	sess := &session{emit: e, agentsMoved: make(chan struct{}, 1)}
	f := newFakeAgents()
	sess.agents = f
	sess.watchAgents()

	f.say("a1", "explore", "one")
	r.clock = r.clock.Add(progressAfter)
	f.say("a1", "explore", "two")

	info := subagent.Info{ID: "a1", Preset: "explore", Status: subagent.Done, Turns: 3, Report: "found it"}
	f.finish(info)
	// The change callback fires repeatedly once a child has ended.
	f.finish(info)
	f.finish(info)

	var opens, closes int
	for _, u := range r.updates {
		switch {
		case u.SessionUpdate == acp.UpdateToolCall:
			opens++
		case u.Status == acp.StatusCompleted || u.Status == acp.StatusFailed:
			closes++
		}
	}
	if opens != 1 {
		t.Errorf("%d rows opened for one agent", opens)
	}
	if closes != 1 {
		t.Errorf("%d closing updates for one agent", closes)
	}
	if got := contentText(t, r.updates[len(r.updates)-1]); !strings.Contains(got, "found it") {
		t.Errorf("the row closed without the report: %q", got)
	}
}

func TestAFailedSubagentClosesAsFailed(t *testing.T) {
	e, r := newRecorded()
	sess := &session{emit: e, agentsMoved: make(chan struct{}, 1)}
	f := newFakeAgents()
	sess.agents = f
	sess.watchAgents()

	f.finish(subagent.Info{ID: "a1", Preset: "plan", Status: subagent.Failed, Err: "the endpoint refused"})

	last := r.updates[len(r.updates)-1]
	if last.Status != acp.StatusFailed {
		t.Errorf("status = %q, want failed", last.Status)
	}
	if !strings.Contains(contentText(t, last), "the endpoint refused") {
		t.Errorf("content = %q", contentText(t, last))
	}
	// It never said anything, so the row has to be opened before it is closed
	// or there is nothing on screen to close.
	if r.updates[0].SessionUpdate != acp.UpdateToolCall {
		t.Errorf("a silent agent left no row: %v", r.kinds())
	}
}

// --- the turn, over the wire ---------------------------------------------

// newAgentsServer wires a server whose sessions all share one fake supervisor.
func newAgentsServer(t *testing.T, f *fakeAPI, agents Agents) *testClient {
	t.Helper()
	log := metrics.NewWriter(io.Discard, "test")
	return newTestServer(t, f, Options{
		NewAgent: func(req AgentRequest) (AgentSet, error) {
			reg := tools.NewRegistry(log).Register(tools.NewRead(req.Root, req.State))
			client := llm.New(llm.WithBaseURL(f.srv.URL), llm.WithAPIKey("k"), llm.WithModel("test-model"))
			return AgentSet{
				Agent:  agent.New(client, reg, req.State, log, agent.Options{}),
				Agents: agents,
			}, nil
		},
	})
}

func TestATurnStaysOpenWhileASubagentWorks(t *testing.T) {
	// Ending the turn while a child is running would leave its row nowhere to
	// appear and its report nobody to be handed to.
	f := newFakeAPI(t,
		msg("end_turn", textBlock("spawned it")),
		msg("end_turn", textBlock("and here is what it found")),
	)
	agents := newFakeAgents()
	agents.live(subagent.Info{ID: "a1", Preset: "explore", Status: subagent.Running})

	c := newAgentsServer(t, f, agents)
	id := c.handshake(t.TempDir())

	done := make(chan acp.PromptResponse, 1)
	go func() { done <- c.prompt(id, "find the bug") }()

	select {
	case <-done:
		t.Fatal("the turn ended while a subagent was still running")
	case <-time.After(150 * time.Millisecond):
	}

	// The child finishes and reports. That restarts the turn with its report.
	agents.mu.Lock()
	agents.infos = nil
	agents.mu.Unlock()
	agents.finish(subagent.Info{ID: "a1", Preset: "explore", Status: subagent.Done, Turns: 2, Report: "it is the parser"})

	select {
	case resp := <-done:
		if resp.StopReason != acp.StopEndTurn {
			t.Errorf("stopReason = %q", resp.StopReason)
		}
	case <-time.After(5 * time.Second):
		t.Fatal("the turn never ended")
	}

	// The report has to have reached the model, not only the screen.
	f.mu.Lock()
	defer f.mu.Unlock()
	if len(f.requests) != 2 {
		t.Fatalf("the parent was asked %d times, want 2", len(f.requests))
	}
	if !strings.Contains(string(f.requests[1]), "it is the parser") {
		t.Error("the subagent's report was never handed to the parent")
	}
}

func TestAReportAlreadyClaimedIsNotHandedOverTwice(t *testing.T) {
	// agent_wait may have collected it. Putting it in again would charge the
	// model twice for something already in its context.
	f := newFakeAPI(t, msg("end_turn", textBlock("done")))
	agents := newFakeAgents()
	agents.claimed["a1"] = true
	agents.reports = []subagent.Info{{ID: "a1", Preset: "explore", Status: subagent.Done, Report: "already seen"}}

	c := newAgentsServer(t, f, agents)
	id := c.handshake(t.TempDir())
	c.prompt(id, "go")

	f.mu.Lock()
	defer f.mu.Unlock()
	if len(f.requests) != 1 {
		t.Fatalf("the parent was asked %d times, want 1", len(f.requests))
	}
}

func TestCancellingATurnStopsWaitingForChildren(t *testing.T) {
	f := newFakeAPI(t, msg("end_turn", textBlock("spawned")))
	agents := newFakeAgents()
	agents.live(subagent.Info{ID: "a1", Preset: "explore", Status: subagent.Running})

	c := newAgentsServer(t, f, agents)
	id := c.handshake(t.TempDir())

	done := make(chan acp.PromptResponse, 1)
	go func() { done <- c.prompt(id, "find it") }()

	time.Sleep(100 * time.Millisecond)
	c.notify(acp.MethodCancel, acp.CancelParams{SessionID: id})

	select {
	case resp := <-done:
		if resp.StopReason != acp.StopCancelled {
			t.Errorf("stopReason = %q, want cancelled", resp.StopReason)
		}
	case <-time.After(5 * time.Second):
		t.Fatal("cancelling did not break the wait")
	}
}

func TestTheChildrenAreStoppedWhenTheConnectionEnds(t *testing.T) {
	f := newFakeAPI(t, msg("end_turn", textBlock("ok")))
	agents := newFakeAgents()

	toAgent, fromClient := io.Pipe()
	fromAgent, toClient := io.Pipe()
	go func() { _, _ = io.Copy(io.Discard, fromAgent) }()

	log := metrics.NewWriter(io.Discard, "test")
	srv := New(Options{
		NewAgent: func(req AgentRequest) (AgentSet, error) {
			reg := tools.NewRegistry(log).Register(tools.NewRead(req.Root, req.State))
			client := llm.New(llm.WithBaseURL(f.srv.URL), llm.WithAPIKey("k"), llm.WithModel("m"))
			return AgentSet{Agent: agent.New(client, reg, req.State, log, agent.Options{}), Agents: agents}, nil
		},
	})

	served := make(chan error, 1)
	go func() { served <- srv.Run(context.Background(), toAgent, toClient) }()

	// Open a session, then hang up.
	if _, err := io.WriteString(fromClient, `{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":`+quote(t.TempDir())+"}}\n"); err != nil {
		t.Fatal(err)
	}
	time.Sleep(100 * time.Millisecond)
	_ = fromClient.Close()

	select {
	case <-served:
	case <-time.After(3 * time.Second):
		t.Fatal("Run did not return")
	}

	agents.mu.Lock()
	defer agents.mu.Unlock()
	if !agents.shutdown {
		t.Error("a subagent outlived the connection, still editing a tree nobody is watching")
	}
}

func TestSubagentRowsAreBoundedLikeAnyOtherToolOutput(t *testing.T) {
	e, r := newRecorded()
	sess := &session{emit: e, agentsMoved: make(chan struct{}, 1)}
	f := newFakeAgents()
	sess.agents = f
	sess.watchAgents()

	f.say("a1", "explore", strings.Repeat("x", progressMaxBytes*2)+"END")

	body := contentText(t, r.updates[len(r.updates)-1])
	if len(body) > progressMaxBytes+64 {
		t.Errorf("an unbounded %d bytes went over the wire", len(body))
	}
	// The child's line is newline-terminated, so trim before looking at the end.
	if !strings.HasSuffix(strings.TrimSpace(body), "END") {
		t.Error("the tail is the useful part and it was the part dropped")
	}
}