Vectrum
Sign In
382 lines · 10.8 KB · Go
package acp

import (
	"bufio"
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"io"
	"strings"
	"sync"
	"testing"
	"time"
)

// stubHandler answers the core methods with whatever the test set on it.
type stubHandler struct {
	initialize func(InitializeRequest) (InitializeResponse, error)
	authFail   error
	newSession func(NewSessionRequest) (NewSessionResponse, error)
	prompt     func(context.Context, PromptRequest) (PromptResponse, error)

	mu        sync.Mutex
	cancelled []string
}

func (s *stubHandler) Initialize(_ context.Context, req InitializeRequest) (InitializeResponse, error) {
	if s.initialize != nil {
		return s.initialize(req)
	}
	return InitializeResponse{ProtocolVersion: ProtocolVersion, AuthMethods: []AuthMethod{}}, nil
}

func (s *stubHandler) Authenticate(context.Context, AuthenticateRequest) error { return s.authFail }

func (s *stubHandler) NewSession(_ context.Context, req NewSessionRequest) (NewSessionResponse, error) {
	if s.newSession != nil {
		return s.newSession(req)
	}
	return NewSessionResponse{SessionID: "s1"}, nil
}

func (s *stubHandler) Prompt(ctx context.Context, req PromptRequest) (PromptResponse, error) {
	if s.prompt != nil {
		return s.prompt(ctx, req)
	}
	return PromptResponse{StopReason: StopEndTurn}, nil
}

func (s *stubHandler) Cancel(_ context.Context, p CancelParams) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.cancelled = append(s.cancelled, p.SessionID)
}

// peer is the far end of a connection: a pipe to write requests into and a
// reader that hands back one decoded frame at a time.
type peer struct {
	t    *testing.T
	in   *io.PipeWriter
	out  *bufio.Reader
	conn *Conn
	done chan error
}

func newPeer(t *testing.T, h Handler) *peer {
	t.Helper()
	toAgent, fromPeer := io.Pipe()
	fromAgent, toPeer := io.Pipe()

	c := NewConn(toAgent, toPeer, h)
	p := &peer{t: t, in: fromPeer, out: bufio.NewReader(fromAgent), conn: c, done: make(chan error, 1)}

	go func() { p.done <- c.Serve(context.Background()) }()
	t.Cleanup(func() {
		_ = fromPeer.Close()
		select {
		case <-p.done:
		case <-time.After(2 * time.Second):
			t.Error("Serve did not return after the stream closed")
		}
	})
	return p
}

func (p *peer) send(line string) {
	p.t.Helper()
	if _, err := io.WriteString(p.in, line+"\n"); err != nil {
		p.t.Fatalf("writing to the agent: %v", err)
	}
}

// readRaw returns the next line exactly as it went over the wire, because some
// of what matters here — an absent key — does not survive decoding.
func (p *peer) readRaw() string {
	p.t.Helper()
	type result struct {
		line string
		err  error
	}
	ch := make(chan result, 1)
	go func() {
		line, err := p.out.ReadString('\n')
		ch <- result{line, err}
	}()
	select {
	case r := <-ch:
		if r.err != nil {
			p.t.Fatalf("reading from the agent: %v", r.err)
		}
		return strings.TrimSuffix(r.line, "\n")
	case <-time.After(2 * time.Second):
		p.t.Fatal("timed out waiting for a frame from the agent")
		return ""
	}
}

func (p *peer) read() Message {
	p.t.Helper()
	var m Message
	line := p.readRaw()
	if err := json.Unmarshal([]byte(line), &m); err != nil {
		p.t.Fatalf("undecodable frame %q: %v", line, err)
	}
	return m
}

func TestEveryFrameCarriesTheProtocolVersion(t *testing.T) {
	p := newPeer(t, &stubHandler{})

	p.send(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}`)
	if got := p.read().JSONRPC; got != "2.0" {
		t.Errorf("response jsonrpc = %q, want 2.0", got)
	}

	go func() {
		_ = p.conn.Notify(context.Background(), MethodSessionUpdate, SessionNotification{SessionID: "s1"})
	}()
	if got := p.read().JSONRPC; got != "2.0" {
		t.Errorf("notification jsonrpc = %q, want 2.0", got)
	}
}

func TestANotificationHasNoIDKeyAtAll(t *testing.T) {
	p := newPeer(t, &stubHandler{})

	go func() {
		_ = p.conn.Notify(context.Background(), MethodSessionUpdate, SessionNotification{
			SessionID: "s1",
			Update:    Update{SessionUpdate: UpdateAgentMessageChunk, Content: TextContent("hi")},
		})
	}()

	// A decoded map would show the difference between absent and null as the
	// same missing key, and null is the thing that breaks the client.
	line := p.readRaw()
	if strings.Contains(line, `"id"`) {
		t.Errorf("notification carries an id: %s", line)
	}
	if !strings.Contains(line, `"sessionUpdate":"agent_message_chunk"`) {
		t.Errorf("notification lost its tag: %s", line)
	}
}

func TestUnknownMethodIsMethodNotFound(t *testing.T) {
	p := newPeer(t, &stubHandler{})

	p.send(`{"jsonrpc":"2.0","id":"abc","method":"session/set_model","params":{}}`)
	m := p.read()

	if m.ID == nil || string(*m.ID) != `"abc"` {
		t.Errorf("id did not come back byte for byte: %v", m.ID)
	}
	if m.Error == nil || m.Error.Code != CodeMethodNotFound {
		t.Fatalf("want a method-not-found error, got %+v", m.Error)
	}
}

// extendedHandler answers anything the core set does not.
type extendedHandler struct {
	stubHandler
	seen string
}

func (e *extendedHandler) Extra(_ context.Context, method string, _ json.RawMessage) (any, error) {
	e.seen = method
	return map[string]any{}, nil
}

func TestAnExtenderAnswersTheRest(t *testing.T) {
	h := &extendedHandler{}
	p := newPeer(t, h)

	p.send(`{"jsonrpc":"2.0","id":7,"method":"session/set_model","params":{"modelId":"x"}}`)
	m := p.read()

	if m.Error != nil {
		t.Fatalf("unexpected error: %v", m.Error)
	}
	if h.seen != "session/set_model" {
		t.Errorf("Extra saw %q", h.seen)
	}
	if string(m.Result) != "{}" {
		t.Errorf("result = %s, want {}", m.Result)
	}
}

func TestAHandlerErrorBecomesInternalAndTheLoopSurvives(t *testing.T) {
	h := &stubHandler{initialize: func(InitializeRequest) (InitializeResponse, error) {
		return InitializeResponse{}, errors.New("no")
	}}
	p := newPeer(t, h)

	p.send(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`)
	if m := p.read(); m.Error == nil || m.Error.Code != CodeInternal {
		t.Fatalf("want an internal error, got %+v", m.Error)
	}

	// The connection is still usable, which is the point.
	p.send(`{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/tmp"}}`)
	if m := p.read(); m.Error != nil {
		t.Fatalf("the connection did not survive the error: %v", m.Error)
	}
}

func TestBadParamsAreInvalidParamsNotInternal(t *testing.T) {
	p := newPeer(t, &stubHandler{})

	p.send(`{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":42}}`)
	if m := p.read(); m.Error == nil || m.Error.Code != CodeInvalidParams {
		t.Fatalf("want invalid params, got %+v", m.Error)
	}
}

func TestMalformedLinesAreAnsweredWhenTheyCanBeAndSkippedWhenTheyCannot(t *testing.T) {
	p := newPeer(t, &stubHandler{})

	// Unparseable as a whole, but the id is recoverable: the peer is waiting.
	p.send(`{"jsonrpc":"2.0","id":3,"method":"initialize","params":{`)
	if m := p.read(); m.Error == nil || m.Error.Code != CodeParse {
		t.Fatalf("want a parse error, got %+v", m.Error)
	}

	// Nothing to answer, and nothing to do but carry on.
	p.send(`not json at all`)
	p.send(`{"jsonrpc":"2.0","id":4,"method":"initialize","params":{}}`)
	if m := p.read(); m.Error != nil {
		t.Fatalf("the loop did not survive a junk line: %v", m.Error)
	}
}

func TestRequestWaitsForTheMatchingResponse(t *testing.T) {
	p := newPeer(t, &stubHandler{})

	var out RequestPermissionResult
	errc := make(chan error, 1)
	go func() {
		errc <- p.conn.Request(context.Background(), MethodRequestPermission,
			RequestPermissionParams{SessionID: "s1"}, &out)
	}()

	req := p.read()
	if req.Method != MethodRequestPermission {
		t.Fatalf("method = %q", req.Method)
	}
	if req.ID == nil {
		t.Fatal("an outbound request must carry an id")
	}
	p.send(`{"jsonrpc":"2.0","id":` + string(*req.ID) + `,"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}`)

	if err := <-errc; err != nil {
		t.Fatalf("Request: %v", err)
	}
	if out.Outcome.OptionID != OptAllowOnce {
		t.Errorf("outcome = %+v", out.Outcome)
	}
}

func TestRequestGivesUpWithItsContext(t *testing.T) {
	p := newPeer(t, &stubHandler{})

	ctx, cancel := context.WithCancel(context.Background())
	errc := make(chan error, 1)
	go func() {
		errc <- p.conn.Request(ctx, MethodRequestPermission, RequestPermissionParams{}, nil)
	}()
	p.read() // let it go out before cancelling
	cancel()

	select {
	case err := <-errc:
		if !errors.Is(err, context.Canceled) {
			t.Fatalf("want context.Canceled, got %v", err)
		}
	case <-time.After(2 * time.Second):
		t.Fatal("Request did not return when its context was cancelled")
	}
}

func TestRequestFailsWhenTheConnectionEnds(t *testing.T) {
	toAgent, fromPeer := io.Pipe()
	fromAgent, toPeer := io.Pipe()
	// Drained so the outbound request cannot block on an unread pipe.
	go func() { _, _ = io.Copy(io.Discard, fromAgent) }()

	c := NewConn(toAgent, toPeer, &stubHandler{})
	served := make(chan error, 1)
	go func() { served <- c.Serve(context.Background()) }()

	errc := make(chan error, 1)
	go func() {
		errc <- c.Request(context.Background(), MethodRequestPermission, RequestPermissionParams{}, nil)
	}()

	time.Sleep(20 * time.Millisecond)
	_ = fromPeer.Close()

	select {
	case err := <-errc:
		if !errors.Is(err, ErrClosed) {
			t.Fatalf("want ErrClosed, got %v", err)
		}
	case <-time.After(2 * time.Second):
		t.Fatal("a pending request outlived its connection")
	}
	if err := <-served; err != nil {
		t.Errorf("Serve after a clean close: %v", err)
	}
}

func TestCancelArrivesWhileAPromptIsStillRunning(t *testing.T) {
	release := make(chan struct{})
	h := &stubHandler{}
	h.prompt = func(ctx context.Context, _ PromptRequest) (PromptResponse, error) {
		<-release
		return PromptResponse{StopReason: StopCancelled}, nil
	}
	p := newPeer(t, h)

	p.send(`{"jsonrpc":"2.0","id":1,"method":"session/prompt","params":{"sessionId":"s1","prompt":[]}}`)
	p.send(`{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"s1"}}`)

	// The cancel must land while the prompt is still blocked. Polling rather
	// than sleeping a fixed time keeps the test honest on a loaded machine.
	deadline := time.Now().Add(2 * time.Second)
	for {
		h.mu.Lock()
		got := len(h.cancelled)
		h.mu.Unlock()
		if got == 1 {
			break
		}
		if time.Now().After(deadline) {
			t.Fatal("the cancel was queued behind the prompt")
		}
		time.Sleep(time.Millisecond)
	}

	close(release)
	if m := p.read(); m.Error != nil {
		t.Fatalf("prompt failed: %v", m.Error)
	}
}

func TestCleanEOFIsNotAnError(t *testing.T) {
	c := NewConn(strings.NewReader(""), &bytes.Buffer{}, &stubHandler{})
	if err := c.Serve(context.Background()); err != nil {
		t.Errorf("Serve on an empty stream: %v", err)
	}
}

func TestATrailingLineWithoutANewlineIsStillHandled(t *testing.T) {
	// A peer that exits right after writing may not terminate its last line.
	var out bytes.Buffer
	c := NewConn(strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`), &out, &stubHandler{})
	if err := c.Serve(context.Background()); err != nil {
		t.Fatalf("Serve: %v", err)
	}
	if !strings.Contains(out.String(), `"protocolVersion":1`) {
		t.Errorf("the last line was dropped: %q", out.String())
	}
}