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 bridge_test |
| 2 | ||
| 3 | import ( | |
| 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 | 13 | "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 | 14 | ) |
| 15 | ||
| 16 | // fakeSession implements bridge.Prompter without any agent behind it. | |
| 17 | type fakeSession struct { | |
| 18 | mu sync.Mutex | |
| 19 | prompts []string | |
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 20 | 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 | 21 | cancelled bool |
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 22 | // 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 | 23 | respond func(text string) (acp.StopReason, error) |
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 24 | // 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 | 25 | block chan struct{} |
| 26 | } | |
| 27 | ||
| 28 | func (f *fakeSession) ID() string { return "sess-fake" } | |
| 29 | ||
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 30 | func (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 | 35 | f.mu.Lock() |
| 36 | f.prompts = append(f.prompts, text) | |
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 37 | 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 | 38 | 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 | ||
| 50 | func (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. | |
| 59 | func 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 | ||
| 83 | func 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 | ||
| 92 | func 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 | ||
| 108 | func 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 | ||
| 136 | func 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 | ||
| 157 | func 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 | ||
| 169 | func 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 | ||
| 191 | func 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 | ||
| 205 | func 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 | ||
| 220 | func 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 | ||
| 263 | func 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 | ||
| 301 | func 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 | ||
| 317 | func 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 | 349 | |
| 350 | func 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 | } |