package agent_test import ( "context" "io" "os" "path/filepath" "strings" "sync" "testing" "time" acp "github.com/coder/acp-go-sdk" "rickub.com/bots-garden/ori/internal/agent" ) // mockAgent is a deterministic in-process ACP agent. The embedded acp.Agent // interface satisfies the methods this test never exercises; calling one of // them would panic, which is exactly what we want a test to do. type mockAgent struct { acp.Agent conn *acp.AgentSideConnection mu sync.Mutex cancelled bool // askPermission makes Prompt request a permission before answering. askPermission bool // readFile makes Prompt read this file through the client before answering. readFile string // writeFile makes Prompt write "written by agent" to this file. writeFile string } func (a *mockAgent) SetAgentConnection(conn *acp.AgentSideConnection) { a.conn = conn } func (a *mockAgent) Initialize(_ context.Context, params acp.InitializeRequest) (acp.InitializeResponse, error) { return acp.InitializeResponse{ProtocolVersion: params.ProtocolVersion}, nil } func (a *mockAgent) NewSession(context.Context, acp.NewSessionRequest) (acp.NewSessionResponse, error) { return acp.NewSessionResponse{SessionId: "sess-mock"}, nil } func (a *mockAgent) Authenticate(context.Context, acp.AuthenticateRequest) (acp.AuthenticateResponse, error) { return acp.AuthenticateResponse{}, nil } func (a *mockAgent) Cancel(context.Context, acp.CancelNotification) error { a.mu.Lock() defer a.mu.Unlock() a.cancelled = true return nil } func (a *mockAgent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.PromptResponse, error) { send := func(text string) error { return a.conn.SessionUpdate(ctx, acp.SessionNotification{ SessionId: params.SessionId, Update: acp.UpdateAgentMessageText(text), }) } if err := send("Hello "); err != nil { return acp.PromptResponse{}, err } if a.askPermission { resp, err := a.conn.RequestPermission(ctx, acp.RequestPermissionRequest{ SessionId: params.SessionId, Options: []acp.PermissionOption{ {OptionId: "allow", Name: "Allow", Kind: acp.PermissionOptionKindAllowOnce}, {OptionId: "reject", Name: "Reject", Kind: acp.PermissionOptionKindRejectOnce}, }, }) if err != nil { return acp.PromptResponse{}, err } if resp.Outcome.Selected == nil || resp.Outcome.Selected.OptionId != "allow" { return acp.PromptResponse{StopReason: acp.StopReasonRefusal}, nil } } if a.readFile != "" { if _, err := a.conn.ReadTextFile(ctx, acp.ReadTextFileRequest{SessionId: params.SessionId, Path: a.readFile}); err != nil { return acp.PromptResponse{}, err } } if a.writeFile != "" { if _, err := a.conn.WriteTextFile(ctx, acp.WriteTextFileRequest{SessionId: params.SessionId, Path: a.writeFile, Content: "written by agent"}); err != nil { return acp.PromptResponse{}, err } } if err := send("world"); err != nil { return acp.PromptResponse{}, err } return acp.PromptResponse{StopReason: acp.StopReasonEndTurn}, nil } // recordingHandler collects session updates and answers permission requests. type recordingHandler struct { mu sync.Mutex texts []string granted bool asked int } func (h *recordingHandler) HandleSessionUpdate(_ context.Context, n acp.SessionNotification) { h.mu.Lock() defer h.mu.Unlock() if chunk := n.Update.AgentMessageChunk; chunk != nil && chunk.Content.Text != nil { h.texts = append(h.texts, chunk.Content.Text.Text) } } func (h *recordingHandler) HandlePermissionRequest(_ context.Context, req acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error) { h.mu.Lock() defer h.mu.Unlock() h.asked++ choice := acp.PermissionOptionId("reject") if h.granted { choice = "allow" } _ = req return acp.RequestPermissionResponse{Outcome: acp.NewRequestPermissionOutcomeSelected(choice)}, nil } func (h *recordingHandler) joined() string { h.mu.Lock() defer h.mu.Unlock() return strings.Join(h.texts, "") } // connectMock wires a mock agent and an agent.Session together with in-memory // pipes, no subprocess involved. func connectMock(t *testing.T, mock *mockAgent, handler agent.Handler) *agent.Session { t.Helper() clientToAgentR, clientToAgentW := io.Pipe() agentToClientR, agentToClientW := io.Pipe() mock.SetAgentConnection(acp.NewAgentSideConnection(mock, agentToClientW, clientToAgentR)) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) t.Cleanup(cancel) session, err := agent.Connect(ctx, clientToAgentW, agentToClientR, agent.Options{ Cwd: t.TempDir(), Handler: handler, }) if err != nil { t.Fatalf("Connect returned an error: %v", err) } return session } func TestConnectPerformsHandshake(t *testing.T) { session := connectMock(t, &mockAgent{}, &recordingHandler{}) if session.ID() != "sess-mock" { t.Errorf("session ID = %q, want %q", session.ID(), "sess-mock") } } func TestConnectRequiresHandler(t *testing.T) { _, err := agent.Connect(context.Background(), io.Discard, strings.NewReader(""), agent.Options{}) if err == nil { t.Fatal("Connect accepted a nil Handler, want an error") } } func TestPromptStreamsUpdates(t *testing.T) { handler := &recordingHandler{} session := connectMock(t, &mockAgent{}, handler) stop, err := session.PromptText(context.Background(), "hi") if err != nil { t.Fatalf("PromptText returned an error: %v", err) } if stop != acp.StopReasonEndTurn { t.Errorf("stop reason = %q, want %q", stop, acp.StopReasonEndTurn) } if got := handler.joined(); got != "Hello world" { t.Errorf("streamed text = %q, want %q", got, "Hello world") } } func TestPermissionGrantedFlowsBackToAgent(t *testing.T) { handler := &recordingHandler{granted: true} session := connectMock(t, &mockAgent{askPermission: true}, handler) stop, err := session.PromptText(context.Background(), "do something sensitive") if err != nil { t.Fatalf("PromptText returned an error: %v", err) } if stop != acp.StopReasonEndTurn { t.Errorf("stop reason = %q, want %q (permission was granted)", stop, acp.StopReasonEndTurn) } if handler.asked != 1 { t.Errorf("permission requests seen by handler = %d, want 1", handler.asked) } } func TestPermissionRejectedStopsTheTurn(t *testing.T) { handler := &recordingHandler{granted: false} session := connectMock(t, &mockAgent{askPermission: true}, handler) stop, err := session.PromptText(context.Background(), "do something sensitive") if err != nil { t.Fatalf("PromptText returned an error: %v", err) } if stop != acp.StopReasonRefusal { t.Errorf("stop reason = %q, want %q (permission was rejected)", stop, acp.StopReasonRefusal) } } func TestCancelReachesTheAgent(t *testing.T) { mock := &mockAgent{} session := connectMock(t, mock, &recordingHandler{}) if err := session.Cancel(context.Background()); err != nil { t.Fatalf("Cancel returned an error: %v", err) } // Cancel is a notification: poll briefly for its arrival. deadline := time.Now().Add(2 * time.Second) for { mock.mu.Lock() cancelled := mock.cancelled mock.mu.Unlock() if cancelled { return } if time.Now().After(deadline) { t.Fatal("agent never received the cancel notification") } time.Sleep(10 * time.Millisecond) } } func TestAgentCanReadClientFiles(t *testing.T) { file := filepath.Join(t.TempDir(), "note.txt") if err := os.WriteFile(file, []byte("line 1\nline 2"), 0o644); err != nil { t.Fatal(err) } session := connectMock(t, &mockAgent{readFile: file}, &recordingHandler{}) if _, err := session.PromptText(context.Background(), "read it"); err != nil { t.Fatalf("PromptText returned an error: %v", err) } } func TestAgentCanWriteClientFiles(t *testing.T) { file := filepath.Join(t.TempDir(), "sub", "out.txt") session := connectMock(t, &mockAgent{writeFile: file}, &recordingHandler{}) if _, err := session.PromptText(context.Background(), "write it"); err != nil { t.Fatalf("PromptText returned an error: %v", err) } content, err := os.ReadFile(file) if err != nil { t.Fatalf("the agent's write never landed: %v", err) } if string(content) != "written by agent" { t.Errorf("written content = %q, want %q", content, "written by agent") } } func TestStartRejectsMissingCommand(t *testing.T) { _, err := agent.Start(context.Background(), agent.Options{Handler: &recordingHandler{}}) if err == nil { t.Fatal("Start accepted an empty Command, want an error") } } func TestStartReportsUnknownExecutable(t *testing.T) { _, err := agent.Start(context.Background(), agent.Options{ Command: []string{"/does/not/exist-ori-agent"}, Cwd: t.TempDir(), Handler: &recordingHandler{}, }) if err == nil { t.Fatal("Start accepted a non-existent executable, want an error") } }