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())
}
}