forked from bots-garden/ori
| ✨ Introduce new feature(s): ACP web client — Go backend (agent, bridge, httpserver, mockagent) + React SPA (Zed-like agent panel), tests, quality gate PASS | 1 | package agent_test |
| 2 | ||
| 3 | import ( | |
| 4 | "context" | |
| 5 | "io" | |
| 6 | "os" | |
| 7 | "path/filepath" | |
| 8 | "strings" | |
| 9 | "sync" | |
| 10 | "testing" | |
| 11 | "time" | |
| 12 | ||
| 13 | acp "github.com/coder/acp-go-sdk" | |
| 14 | ||
| 15 | "github.com/bots-garden/ori/internal/agent" | |
| 16 | ) | |
| 17 | ||
| 18 | // mockAgent is a deterministic in-process ACP agent. The embedded acp.Agent | |
| 19 | // interface satisfies the methods this test never exercises; calling one of | |
| 20 | // them would panic, which is exactly what we want a test to do. | |
| 21 | type mockAgent struct { | |
| 22 | acp.Agent | |
| 23 | conn *acp.AgentSideConnection | |
| 24 | ||
| 25 | mu sync.Mutex | |
| 26 | cancelled bool | |
| 27 | // askPermission makes Prompt request a permission before answering. | |
| 28 | askPermission bool | |
| 29 | // readFile makes Prompt read this file through the client before answering. | |
| 30 | readFile string | |
| 31 | // writeFile makes Prompt write "written by agent" to this file. | |
| 32 | writeFile string | |
| 33 | } | |
| 34 | ||
| 35 | func (a *mockAgent) SetAgentConnection(conn *acp.AgentSideConnection) { a.conn = conn } | |
| 36 | ||
| 37 | func (a *mockAgent) Initialize(_ context.Context, params acp.InitializeRequest) (acp.InitializeResponse, error) { | |
| 38 | return acp.InitializeResponse{ProtocolVersion: params.ProtocolVersion}, nil | |
| 39 | } | |
| 40 | ||
| 41 | func (a *mockAgent) NewSession(context.Context, acp.NewSessionRequest) (acp.NewSessionResponse, error) { | |
| 42 | return acp.NewSessionResponse{SessionId: "sess-mock"}, nil | |
| 43 | } | |
| 44 | ||
| 45 | func (a *mockAgent) Authenticate(context.Context, acp.AuthenticateRequest) (acp.AuthenticateResponse, error) { | |
| 46 | return acp.AuthenticateResponse{}, nil | |
| 47 | } | |
| 48 | ||
| 49 | func (a *mockAgent) Cancel(context.Context, acp.CancelNotification) error { | |
| 50 | a.mu.Lock() | |
| 51 | defer a.mu.Unlock() | |
| 52 | a.cancelled = true | |
| 53 | return nil | |
| 54 | } | |
| 55 | ||
| 56 | func (a *mockAgent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.PromptResponse, error) { | |
| 57 | send := func(text string) error { | |
| 58 | return a.conn.SessionUpdate(ctx, acp.SessionNotification{ | |
| 59 | SessionId: params.SessionId, | |
| 60 | Update: acp.UpdateAgentMessageText(text), | |
| 61 | }) | |
| 62 | } | |
| 63 | if err := send("Hello "); err != nil { | |
| 64 | return acp.PromptResponse{}, err | |
| 65 | } | |
| 66 | if a.askPermission { | |
| 67 | resp, err := a.conn.RequestPermission(ctx, acp.RequestPermissionRequest{ | |
| 68 | SessionId: params.SessionId, | |
| 69 | Options: []acp.PermissionOption{ | |
| 70 | {OptionId: "allow", Name: "Allow", Kind: acp.PermissionOptionKindAllowOnce}, | |
| 71 | {OptionId: "reject", Name: "Reject", Kind: acp.PermissionOptionKindRejectOnce}, | |
| 72 | }, | |
| 73 | }) | |
| 74 | if err != nil { | |
| 75 | return acp.PromptResponse{}, err | |
| 76 | } | |
| 77 | if resp.Outcome.Selected == nil || resp.Outcome.Selected.OptionId != "allow" { | |
| 78 | return acp.PromptResponse{StopReason: acp.StopReasonRefusal}, nil | |
| 79 | } | |
| 80 | } | |
| 81 | if a.readFile != "" { | |
| 82 | if _, err := a.conn.ReadTextFile(ctx, acp.ReadTextFileRequest{SessionId: params.SessionId, Path: a.readFile}); err != nil { | |
| 83 | return acp.PromptResponse{}, err | |
| 84 | } | |
| 85 | } | |
| 86 | if a.writeFile != "" { | |
| 87 | if _, err := a.conn.WriteTextFile(ctx, acp.WriteTextFileRequest{SessionId: params.SessionId, Path: a.writeFile, Content: "written by agent"}); err != nil { | |
| 88 | return acp.PromptResponse{}, err | |
| 89 | } | |
| 90 | } | |
| 91 | if err := send("world"); err != nil { | |
| 92 | return acp.PromptResponse{}, err | |
| 93 | } | |
| 94 | return acp.PromptResponse{StopReason: acp.StopReasonEndTurn}, nil | |
| 95 | } | |
| 96 | ||
| 97 | // recordingHandler collects session updates and answers permission requests. | |
| 98 | type recordingHandler struct { | |
| 99 | mu sync.Mutex | |
| 100 | texts []string | |
| 101 | granted bool | |
| 102 | asked int | |
| 103 | } | |
| 104 | ||
| 105 | func (h *recordingHandler) HandleSessionUpdate(_ context.Context, n acp.SessionNotification) { | |
| 106 | h.mu.Lock() | |
| 107 | defer h.mu.Unlock() | |
| 108 | if chunk := n.Update.AgentMessageChunk; chunk != nil && chunk.Content.Text != nil { | |
| 109 | h.texts = append(h.texts, chunk.Content.Text.Text) | |
| 110 | } | |
| 111 | } | |
| 112 | ||
| 113 | func (h *recordingHandler) HandlePermissionRequest(_ context.Context, req acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error) { | |
| 114 | h.mu.Lock() | |
| 115 | defer h.mu.Unlock() | |
| 116 | h.asked++ | |
| 117 | choice := acp.PermissionOptionId("reject") | |
| 118 | if h.granted { | |
| 119 | choice = "allow" | |
| 120 | } | |
| 121 | _ = req | |
| 122 | return acp.RequestPermissionResponse{Outcome: acp.NewRequestPermissionOutcomeSelected(choice)}, nil | |
| 123 | } | |
| 124 | ||
| 125 | func (h *recordingHandler) joined() string { | |
| 126 | h.mu.Lock() | |
| 127 | defer h.mu.Unlock() | |
| 128 | return strings.Join(h.texts, "") | |
| 129 | } | |
| 130 | ||
| 131 | // connectMock wires a mock agent and an agent.Session together with in-memory | |
| 132 | // pipes, no subprocess involved. | |
| 133 | func connectMock(t *testing.T, mock *mockAgent, handler agent.Handler) *agent.Session { | |
| 134 | t.Helper() | |
| 135 | clientToAgentR, clientToAgentW := io.Pipe() | |
| 136 | agentToClientR, agentToClientW := io.Pipe() | |
| 137 | ||
| 138 | mock.SetAgentConnection(acp.NewAgentSideConnection(mock, agentToClientW, clientToAgentR)) | |
| 139 | ||
| 140 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 141 | t.Cleanup(cancel) | |
| 142 | session, err := agent.Connect(ctx, clientToAgentW, agentToClientR, agent.Options{ | |
| 143 | Cwd: t.TempDir(), | |
| 144 | Handler: handler, | |
| 145 | }) | |
| 146 | if err != nil { | |
| 147 | t.Fatalf("Connect returned an error: %v", err) | |
| 148 | } | |
| 149 | return session | |
| 150 | } | |
| 151 | ||
| 152 | func TestConnectPerformsHandshake(t *testing.T) { | |
| 153 | session := connectMock(t, &mockAgent{}, &recordingHandler{}) | |
| 154 | if session.ID() != "sess-mock" { | |
| 155 | t.Errorf("session ID = %q, want %q", session.ID(), "sess-mock") | |
| 156 | } | |
| 157 | } | |
| 158 | ||
| 159 | func TestConnectRequiresHandler(t *testing.T) { | |
| 160 | _, err := agent.Connect(context.Background(), io.Discard, strings.NewReader(""), agent.Options{}) | |
| 161 | if err == nil { | |
| 162 | t.Fatal("Connect accepted a nil Handler, want an error") | |
| 163 | } | |
| 164 | } | |
| 165 | ||
| 166 | func TestPromptStreamsUpdates(t *testing.T) { | |
| 167 | handler := &recordingHandler{} | |
| 168 | session := connectMock(t, &mockAgent{}, handler) | |
| 169 | ||
| 170 | stop, err := session.PromptText(context.Background(), "hi") | |
| 171 | if err != nil { | |
| 172 | t.Fatalf("PromptText returned an error: %v", err) | |
| 173 | } | |
| 174 | if stop != acp.StopReasonEndTurn { | |
| 175 | t.Errorf("stop reason = %q, want %q", stop, acp.StopReasonEndTurn) | |
| 176 | } | |
| 177 | if got := handler.joined(); got != "Hello world" { | |
| 178 | t.Errorf("streamed text = %q, want %q", got, "Hello world") | |
| 179 | } | |
| 180 | } | |
| 181 | ||
| 182 | func TestPermissionGrantedFlowsBackToAgent(t *testing.T) { | |
| 183 | handler := &recordingHandler{granted: true} | |
| 184 | session := connectMock(t, &mockAgent{askPermission: true}, handler) | |
| 185 | ||
| 186 | stop, err := session.PromptText(context.Background(), "do something sensitive") | |
| 187 | if err != nil { | |
| 188 | t.Fatalf("PromptText returned an error: %v", err) | |
| 189 | } | |
| 190 | if stop != acp.StopReasonEndTurn { | |
| 191 | t.Errorf("stop reason = %q, want %q (permission was granted)", stop, acp.StopReasonEndTurn) | |
| 192 | } | |
| 193 | if handler.asked != 1 { | |
| 194 | t.Errorf("permission requests seen by handler = %d, want 1", handler.asked) | |
| 195 | } | |
| 196 | } | |
| 197 | ||
| 198 | func TestPermissionRejectedStopsTheTurn(t *testing.T) { | |
| 199 | handler := &recordingHandler{granted: false} | |
| 200 | session := connectMock(t, &mockAgent{askPermission: true}, handler) | |
| 201 | ||
| 202 | stop, err := session.PromptText(context.Background(), "do something sensitive") | |
| 203 | if err != nil { | |
| 204 | t.Fatalf("PromptText returned an error: %v", err) | |
| 205 | } | |
| 206 | if stop != acp.StopReasonRefusal { | |
| 207 | t.Errorf("stop reason = %q, want %q (permission was rejected)", stop, acp.StopReasonRefusal) | |
| 208 | } | |
| 209 | } | |
| 210 | ||
| 211 | func TestCancelReachesTheAgent(t *testing.T) { | |
| 212 | mock := &mockAgent{} | |
| 213 | session := connectMock(t, mock, &recordingHandler{}) | |
| 214 | ||
| 215 | if err := session.Cancel(context.Background()); err != nil { | |
| 216 | t.Fatalf("Cancel returned an error: %v", err) | |
| 217 | } | |
| 218 | // Cancel is a notification: poll briefly for its arrival. | |
| 219 | deadline := time.Now().Add(2 * time.Second) | |
| 220 | for { | |
| 221 | mock.mu.Lock() | |
| 222 | cancelled := mock.cancelled | |
| 223 | mock.mu.Unlock() | |
| 224 | if cancelled { | |
| 225 | return | |
| 226 | } | |
| 227 | if time.Now().After(deadline) { | |
| 228 | t.Fatal("agent never received the cancel notification") | |
| 229 | } | |
| 230 | time.Sleep(10 * time.Millisecond) | |
| 231 | } | |
| 232 | } | |
| 233 | ||
| 234 | func TestAgentCanReadClientFiles(t *testing.T) { | |
| 235 | file := filepath.Join(t.TempDir(), "note.txt") | |
| 236 | if err := os.WriteFile(file, []byte("line 1\nline 2"), 0o644); err != nil { | |
| 237 | t.Fatal(err) | |
| 238 | } | |
| 239 | session := connectMock(t, &mockAgent{readFile: file}, &recordingHandler{}) | |
| 240 | ||
| 241 | if _, err := session.PromptText(context.Background(), "read it"); err != nil { | |
| 242 | t.Fatalf("PromptText returned an error: %v", err) | |
| 243 | } | |
| 244 | } | |
| 245 | ||
| 246 | func TestAgentCanWriteClientFiles(t *testing.T) { | |
| 247 | file := filepath.Join(t.TempDir(), "sub", "out.txt") | |
| 248 | session := connectMock(t, &mockAgent{writeFile: file}, &recordingHandler{}) | |
| 249 | ||
| 250 | if _, err := session.PromptText(context.Background(), "write it"); err != nil { | |
| 251 | t.Fatalf("PromptText returned an error: %v", err) | |
| 252 | } | |
| 253 | content, err := os.ReadFile(file) | |
| 254 | if err != nil { | |
| 255 | t.Fatalf("the agent's write never landed: %v", err) | |
| 256 | } | |
| 257 | if string(content) != "written by agent" { | |
| 258 | t.Errorf("written content = %q, want %q", content, "written by agent") | |
| 259 | } | |
| 260 | } | |
| 261 | ||
| 262 | func TestStartRejectsMissingCommand(t *testing.T) { | |
| 263 | _, err := agent.Start(context.Background(), agent.Options{Handler: &recordingHandler{}}) | |
| 264 | if err == nil { | |
| 265 | t.Fatal("Start accepted an empty Command, want an error") | |
| 266 | } | |
| 267 | } | |
| 268 | ||
| 269 | func TestStartReportsUnknownExecutable(t *testing.T) { | |
| 270 | _, err := agent.Start(context.Background(), agent.Options{ | |
| 271 | Command: []string{"/does/not/exist-ori-agent"}, | |
| 272 | Cwd: t.TempDir(), | |
| 273 | Handler: &recordingHandler{}, | |
| 274 | }) | |
| 275 | if err == nil { | |
| 276 | t.Fatal("Start accepted a non-existent executable, want an error") | |
| 277 | } | |
| 278 | } |