nandi/oripublic Fork 0
4166f8fba899b222fab287c398dcab9bf6f6e5c9
Commits
Clone
git clone https://git.rickub.com/nandi/ori.git
git clone ssh://git@rickub.com/nandi/ori.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

forked from bots-garden/ori

agent_test.go · 278 lines · 8.5 KBGo Blame HistoryRaw
✨ Introduce new feature(s): ACP web client — Go backend (agent, bridge, httpserver, mockagent) + React SPA (Zed-like agent panel), tests, quality gate PASS 2434cc7 k33g yesterday1package agent_test
2
3import (
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
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday15 "rickub.com/bots-garden/ori/internal/agent"
✨ Introduce new feature(s): ACP web client — Go backend (agent, bridge, httpserver, mockagent) + React SPA (Zed-like agent panel), tests, quality gate PASS 2434cc7 k33g yesterday16)
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.
21type 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
35func (a *mockAgent) SetAgentConnection(conn *acp.AgentSideConnection) { a.conn = conn }
36
37func (a *mockAgent) Initialize(_ context.Context, params acp.InitializeRequest) (acp.InitializeResponse, error) {
38 return acp.InitializeResponse{ProtocolVersion: params.ProtocolVersion}, nil
39}
40
41func (a *mockAgent) NewSession(context.Context, acp.NewSessionRequest) (acp.NewSessionResponse, error) {
42 return acp.NewSessionResponse{SessionId: "sess-mock"}, nil
43}
44
45func (a *mockAgent) Authenticate(context.Context, acp.AuthenticateRequest) (acp.AuthenticateResponse, error) {
46 return acp.AuthenticateResponse{}, nil
47}
48
49func (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
56func (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.
98type recordingHandler struct {
99 mu sync.Mutex
100 texts []string
101 granted bool
102 asked int
103}
104
105func (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
113func (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
125func (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.
133func 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
152func 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
159func 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
166func 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
182func 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
198func 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
211func 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
234func 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
246func 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
262func 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
269func 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}