nandi/oripublic Fork 0
5d476ff5f3dc3b7db3afaccaedc7d9661981449e
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

bridge_test.go · 386 lines · 11.3 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 bridge_test
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "sync"
8 "testing"
9 "time"
10
11 acp "github.com/coder/acp-go-sdk"
12
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday13 "rickub.com/bots-garden/ori/internal/bridge"
✨ 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 yesterday14)
15
16// fakeSession implements bridge.Prompter without any agent behind it.
17type fakeSession struct {
18 mu sync.Mutex
19 prompts []string
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday20 blocks [][]acp.ContentBlock
✨ 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 yesterday21 cancelled bool
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday22 // respond controls what Prompt returns; nil means end_turn.
✨ 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 yesterday23 respond func(text string) (acp.StopReason, error)
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday24 // block, when non-nil, is closed by the test to let Prompt return.
✨ 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 yesterday25 block chan struct{}
26}
27
28func (f *fakeSession) ID() string { return "sess-fake" }
29
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday30func (f *fakeSession) Prompt(_ context.Context, blocks []acp.ContentBlock) (acp.StopReason, error) {
31 text := ""
32 if len(blocks) > 0 && blocks[0].Text != nil {
33 text = blocks[0].Text.Text
34 }
✨ 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 yesterday35 f.mu.Lock()
36 f.prompts = append(f.prompts, text)
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday37 f.blocks = append(f.blocks, blocks)
✨ 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 yesterday38 respond := f.respond
39 block := f.block
40 f.mu.Unlock()
41 if block != nil {
42 <-block
43 }
44 if respond != nil {
45 return respond(text)
46 }
47 return acp.StopReasonEndTurn, nil
48}
49
50func (f *fakeSession) Cancel(context.Context) error {
51 f.mu.Lock()
52 defer f.mu.Unlock()
53 f.cancelled = true
54 return nil
55}
56
57// collect drains a subscriber channel until the wanted message type shows up
58// or the timeout hits; it returns every message seen, decoded.
59func collect(t *testing.T, ch <-chan []byte, wantType string) []bridge.Outgoing {
60 t.Helper()
61 var seen []bridge.Outgoing
62 deadline := time.After(5 * time.Second)
63 for {
64 select {
65 case raw, ok := <-ch:
66 if !ok {
67 t.Fatalf("channel closed while waiting for %q; saw %+v", wantType, seen)
68 }
69 var msg bridge.Outgoing
70 if err := json.Unmarshal(raw, &msg); err != nil {
71 t.Fatalf("undecodable outgoing message %q: %v", raw, err)
72 }
73 seen = append(seen, msg)
74 if msg.Type == wantType {
75 return seen
76 }
77 case <-deadline:
78 t.Fatalf("timed out waiting for %q; saw %+v", wantType, seen)
79 }
80 }
81}
82
83func decode(t *testing.T, raw []byte) bridge.Outgoing {
84 t.Helper()
85 var msg bridge.Outgoing
86 if err := json.Unmarshal(raw, &msg); err != nil {
87 t.Fatalf("undecodable message %q: %v", raw, err)
88 }
89 return msg
90}
91
92func TestSubscribeSendsHello(t *testing.T) {
93 b := bridge.New(nil)
94 b.SetSession(&fakeSession{})
95
96 _, replay, unsubscribe := b.Subscribe()
97 defer unsubscribe()
98
99 if len(replay) == 0 {
100 t.Fatal("Subscribe returned no replay events, want at least the hello")
101 }
102 hello := decode(t, replay[0])
103 if hello.Type != bridge.OutgoingHello || hello.SessionId != "sess-fake" {
104 t.Errorf("first replay event = %+v, want hello for sess-fake", hello)
105 }
106}
107
108func TestPromptRunsTurnAndBroadcastsMarkers(t *testing.T) {
109 b := bridge.New(nil)
110 session := &fakeSession{}
111 b.SetSession(session)
112
113 events, _, unsubscribe := b.Subscribe()
114 defer unsubscribe()
115
116 b.HandleIncoming(context.Background(), []byte(`{"type":"prompt","text":"hello agent"}`))
117
118 seen := collect(t, events, bridge.OutgoingTurnEnded)
119 if seen[0].Type != bridge.OutgoingUserMessage || seen[0].Text != "hello agent" {
120 t.Errorf("first event = %+v, want the echoed user message", seen[0])
121 }
122 if seen[1].Type != bridge.OutgoingTurnStarted {
123 t.Errorf("second event = %q, want turn_started", seen[1].Type)
124 }
125 last := seen[len(seen)-1]
126 if last.StopReason != string(acp.StopReasonEndTurn) {
127 t.Errorf("turn_ended stopReason = %q, want end_turn", last.StopReason)
128 }
129 session.mu.Lock()
130 defer session.mu.Unlock()
131 if len(session.prompts) != 1 || session.prompts[0] != "hello agent" {
132 t.Errorf("session received prompts %v, want [hello agent]", session.prompts)
133 }
134}
135
136func TestSecondPromptDuringTurnIsRejected(t *testing.T) {
137 b := bridge.New(nil)
138 session := &fakeSession{block: make(chan struct{})}
139 b.SetSession(session)
140
141 events, _, unsubscribe := b.Subscribe()
142 defer unsubscribe()
143
144 b.HandleIncoming(context.Background(), []byte(`{"type":"prompt","text":"first"}`))
145 collect(t, events, bridge.OutgoingTurnStarted)
146
147 b.HandleIncoming(context.Background(), []byte(`{"type":"prompt","text":"second"}`))
148 seen := collect(t, events, bridge.OutgoingError)
149 if last := seen[len(seen)-1]; last.Message != "a turn is already running" {
150 t.Errorf("error message = %q", last.Message)
151 }
152
153 close(session.block)
154 collect(t, events, bridge.OutgoingTurnEnded)
155}
156
157func TestPromptWithoutSessionReportsError(t *testing.T) {
158 b := bridge.New(nil)
159 events, _, unsubscribe := b.Subscribe()
160 defer unsubscribe()
161
162 b.HandleIncoming(context.Background(), []byte(`{"type":"prompt","text":"x"}`))
163 seen := collect(t, events, bridge.OutgoingError)
164 if last := seen[len(seen)-1]; last.Message != "no agent session" {
165 t.Errorf("error message = %q, want \"no agent session\"", last.Message)
166 }
167}
168
169func TestPromptFailureEndsTurnWithError(t *testing.T) {
170 b := bridge.New(nil)
171 b.SetSession(&fakeSession{respond: func(string) (acp.StopReason, error) {
172 return "", errors.New("agent exploded")
173 }})
174 events, _, unsubscribe := b.Subscribe()
175 defer unsubscribe()
176
177 b.HandleIncoming(context.Background(), []byte(`{"type":"prompt","text":"x"}`))
178 seen := collect(t, events, bridge.OutgoingTurnEnded)
179
180 foundError := false
181 for _, msg := range seen {
182 if msg.Type == bridge.OutgoingError {
183 foundError = true
184 }
185 }
186 if !foundError {
187 t.Error("no error event before turn_ended, want one mentioning the failure")
188 }
189}
190
191func TestCancelReachesSession(t *testing.T) {
192 b := bridge.New(nil)
193 session := &fakeSession{}
194 b.SetSession(session)
195
196 b.HandleIncoming(context.Background(), []byte(`{"type":"cancel"}`))
197
198 session.mu.Lock()
199 defer session.mu.Unlock()
200 if !session.cancelled {
201 t.Error("cancel never reached the session")
202 }
203}
204
205func TestMalformedAndUnknownMessagesReportErrors(t *testing.T) {
206 b := bridge.New(nil)
207 events, _, unsubscribe := b.Subscribe()
208 defer unsubscribe()
209
210 b.HandleIncoming(context.Background(), []byte(`{not json`))
211 collect(t, events, bridge.OutgoingError)
212
213 b.HandleIncoming(context.Background(), []byte(`{"type":"nope"}`))
214 seen := collect(t, events, bridge.OutgoingError)
215 if last := seen[len(seen)-1]; last.Message != `unknown message type "nope"` {
216 t.Errorf("error message = %q", last.Message)
217 }
218}
219
220func TestSessionUpdateIsRelayedAndReplayed(t *testing.T) {
221 b := bridge.New(nil)
222 b.SetSession(&fakeSession{})
223 events, _, unsubscribe := b.Subscribe()
224 defer unsubscribe()
225
226 b.HandleSessionUpdate(context.Background(), acp.SessionNotification{
227 SessionId: "sess-fake",
228 Update: acp.UpdateAgentMessageText("chunk!"),
229 })
230
231 seen := collect(t, events, bridge.OutgoingSessionUpdate)
232 update := seen[len(seen)-1].Update
233 if update == nil {
234 t.Fatal("session_update event has no update payload")
235 }
236 var acpUpdate struct {
237 SessionUpdate string `json:"sessionUpdate"`
238 Content struct {
239 Text string `json:"text"`
240 } `json:"content"`
241 }
242 if err := json.Unmarshal(update, &acpUpdate); err != nil {
243 t.Fatalf("update payload is not the raw ACP shape: %v", err)
244 }
245 if acpUpdate.SessionUpdate != "agent_message_chunk" || acpUpdate.Content.Text != "chunk!" {
246 t.Errorf("relayed update = %+v, want agent_message_chunk with text \"chunk!\"", acpUpdate)
247 }
248
249 // A client connecting after the fact receives the update in its replay.
250 _, replay, unsubscribeLate := b.Subscribe()
251 defer unsubscribeLate()
252 found := false
253 for _, raw := range replay {
254 if decode(t, raw).Type == bridge.OutgoingSessionUpdate {
255 found = true
256 }
257 }
258 if !found {
259 t.Error("late subscriber's replay lacks the session update")
260 }
261}
262
263func TestPermissionRoundTrip(t *testing.T) {
264 b := bridge.New(nil)
265 b.SetSession(&fakeSession{})
266 events, _, unsubscribe := b.Subscribe()
267 defer unsubscribe()
268
269 type result struct {
270 resp acp.RequestPermissionResponse
271 err error
272 }
273 got := make(chan result, 1)
274 go func() {
275 resp, err := b.HandlePermissionRequest(context.Background(), acp.RequestPermissionRequest{
276 SessionId: "sess-fake",
277 Options: []acp.PermissionOption{{OptionId: "allow", Name: "Allow", Kind: acp.PermissionOptionKindAllowOnce}},
278 })
279 got <- result{resp, err}
280 }()
281
282 seen := collect(t, events, bridge.OutgoingPermissionRequest)
283 request := seen[len(seen)-1]
284 if request.RequestId == "" || request.Request == nil {
285 t.Fatalf("permission_request event incomplete: %+v", request)
286 }
287
288 b.HandleIncoming(context.Background(), []byte(`{"type":"permission_response","requestId":"`+request.RequestId+`","optionId":"allow"}`))
289
290 r := <-got
291 if r.err != nil {
292 t.Fatalf("HandlePermissionRequest returned an error: %v", r.err)
293 }
294 if r.resp.Outcome.Selected == nil || r.resp.Outcome.Selected.OptionId != "allow" {
295 t.Errorf("outcome = %+v, want selected \"allow\"", r.resp.Outcome)
296 }
297
298 collect(t, events, bridge.OutgoingPermissionResolved)
299}
300
301func TestPermissionCancelledByAgentContext(t *testing.T) {
302 b := bridge.New(nil)
303 b.SetSession(&fakeSession{})
304
305 ctx, cancel := context.WithCancel(context.Background())
306 go cancel()
307
308 resp, err := b.HandlePermissionRequest(ctx, acp.RequestPermissionRequest{SessionId: "sess-fake"})
309 if err != nil {
310 t.Fatalf("HandlePermissionRequest returned an error: %v", err)
311 }
312 if resp.Outcome.Cancelled == nil {
313 t.Errorf("outcome = %+v, want cancelled", resp.Outcome)
314 }
315}
316
317func TestPendingPermissionIsReplayedToLateJoiner(t *testing.T) {
318 b := bridge.New(nil)
319 b.SetSession(&fakeSession{})
320
321 answered := make(chan struct{})
322 go func() {
323 _, _ = b.HandlePermissionRequest(context.Background(), acp.RequestPermissionRequest{SessionId: "sess-fake"})
324 close(answered)
325 }()
326
327 // Wait for the request to be registered by watching a live subscriber.
328 events, _, unsubscribe := b.Subscribe()
329 seen := collect(t, events, bridge.OutgoingPermissionRequest)
330 requestId := seen[len(seen)-1].RequestId
331 unsubscribe()
332
333 _, replay, unsubscribeLate := b.Subscribe()
334 defer unsubscribeLate()
335 found := false
336 for _, raw := range replay {
337 msg := decode(t, raw)
338 if msg.Type == bridge.OutgoingPermissionRequest && msg.RequestId == requestId {
339 found = true
340 }
341 }
342 if !found {
343 t.Error("late subscriber's replay lacks the pending permission request")
344 }
345
346 b.HandleIncoming(context.Background(), []byte(`{"type":"permission_response","requestId":"`+requestId+`","cancelled":true}`))
347 <-answered
348}
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday349
350func TestPromptAttachmentsBecomeResourceLinks(t *testing.T) {
351 b := bridge.New(nil)
352 session := &fakeSession{}
353 b.SetSession(session)
354 b.SetWorkspaceRoot("/work")
355
356 events, _, unsubscribe := b.Subscribe()
357 defer unsubscribe()
358
359 b.HandleIncoming(context.Background(), []byte(`{"type":"prompt","text":"explain @src/main.go and @/etc/hosts",
360 "attachments":[{"path":"src/main.go","name":"src/main.go"},{"path":"/etc/hosts"},{"path":""}]}`))
361
362 seen := collect(t, events, bridge.OutgoingTurnEnded)
363 if len(seen[0].Attachments) != 3 || seen[0].Attachments[0].Name != "src/main.go" {
364 t.Errorf("user_message attachments = %+v, want the three sent", seen[0].Attachments)
365 }
366
367 session.mu.Lock()
368 defer session.mu.Unlock()
369 blocks := session.blocks[0]
370 if len(blocks) != 3 {
371 t.Fatalf("prompt blocks = %d, want text + 2 resource links (empty path skipped)", len(blocks))
372 }
373 if blocks[0].Text == nil || blocks[0].Text.Text != "explain @src/main.go and @/etc/hosts" {
374 t.Errorf("first block = %+v, want the text", blocks[0])
375 }
376 cases := []struct{ name, uri string }{
377 {"src/main.go", "file:///work/src/main.go"},
378 {"/etc/hosts", "file:///etc/hosts"},
379 }
380 for i, tc := range cases {
381 link := blocks[i+1].ResourceLink
382 if link == nil || link.Name != tc.name || link.Uri != tc.uri || link.Type != "resource_link" {
383 t.Errorf("block[%d] = %+v, want resource_link %s → %s", i+1, blocks[i+1], tc.name, tc.uri)
384 }
385 }
386}