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 | "net/http/httptest" | |
| 6 | "strings" | |
| 7 | "testing" | |
| 8 | "time" | |
| 9 | ||
| 10 | acp "github.com/coder/acp-go-sdk" | |
| 11 | "github.com/coder/websocket" | |
| 12 | "github.com/coder/websocket/wsjson" | |
| 13 | ||
| 14 | "github.com/bots-garden/ori/internal/bridge" | |
| 15 | ) | |
| 16 | ||
| 17 | // dialTestServer starts an HTTP test server exposing the bridge's WebSocket | |
| 18 | // handler and connects a client to it. | |
| 19 | func dialTestServer(t *testing.T, b *bridge.Bridge) (*websocket.Conn, context.Context) { | |
| 20 | t.Helper() | |
| 21 | server := httptest.NewServer(b.WebSocketHandler()) | |
| 22 | t.Cleanup(server.Close) | |
| 23 | ||
| 24 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 25 | t.Cleanup(cancel) | |
| 26 | ||
| 27 | url := "ws" + strings.TrimPrefix(server.URL, "http") | |
| 28 | conn, _, err := websocket.Dial(ctx, url, nil) | |
| 29 | if err != nil { | |
| 30 | t.Fatalf("websocket dial failed: %v", err) | |
| 31 | } | |
| 32 | t.Cleanup(func() { _ = conn.CloseNow() }) | |
| 33 | return conn, ctx | |
| 34 | } | |
| 35 | ||
| 36 | // readUntil reads messages until the wanted type arrives. | |
| 37 | func readUntil(t *testing.T, ctx context.Context, conn *websocket.Conn, wantType string) bridge.Outgoing { | |
| 38 | t.Helper() | |
| 39 | for { | |
| 40 | var msg bridge.Outgoing | |
| 41 | if err := wsjson.Read(ctx, conn, &msg); err != nil { | |
| 42 | t.Fatalf("websocket read failed while waiting for %q: %v", wantType, err) | |
| 43 | } | |
| 44 | if msg.Type == wantType { | |
| 45 | return msg | |
| 46 | } | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | func TestWebSocketHelloThenPromptTurn(t *testing.T) { | |
| 51 | b := bridge.New(nil) | |
| 52 | b.SetSession(&fakeSession{}) | |
| 53 | conn, ctx := dialTestServer(t, b) | |
| 54 | ||
| 55 | hello := readUntil(t, ctx, conn, bridge.OutgoingHello) | |
| 56 | if hello.SessionId != "sess-fake" { | |
| 57 | t.Errorf("hello sessionId = %q, want sess-fake", hello.SessionId) | |
| 58 | } | |
| 59 | ||
| 60 | if err := wsjson.Write(ctx, conn, bridge.Incoming{Type: bridge.IncomingPrompt, Text: "hi"}); err != nil { | |
| 61 | t.Fatalf("websocket write failed: %v", err) | |
| 62 | } | |
| 63 | readUntil(t, ctx, conn, bridge.OutgoingTurnStarted) | |
| 64 | ended := readUntil(t, ctx, conn, bridge.OutgoingTurnEnded) | |
| 65 | if ended.StopReason != string(acp.StopReasonEndTurn) { | |
| 66 | t.Errorf("turn_ended stopReason = %q, want end_turn", ended.StopReason) | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | func TestWebSocketReceivesSessionUpdates(t *testing.T) { | |
| 71 | b := bridge.New(nil) | |
| 72 | b.SetSession(&fakeSession{}) | |
| 73 | conn, ctx := dialTestServer(t, b) | |
| 74 | readUntil(t, ctx, conn, bridge.OutgoingHello) | |
| 75 | ||
| 76 | b.HandleSessionUpdate(context.Background(), acp.SessionNotification{ | |
| 77 | SessionId: "sess-fake", | |
| 78 | Update: acp.UpdateAgentMessageText("streamed"), | |
| 79 | }) | |
| 80 | ||
| 81 | update := readUntil(t, ctx, conn, bridge.OutgoingSessionUpdate) | |
| 82 | if update.Update == nil || !strings.Contains(string(update.Update), "streamed") { | |
| 83 | t.Errorf("session_update payload = %s, want it to carry the streamed text", update.Update) | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | func TestWebSocketPermissionRoundTrip(t *testing.T) { | |
| 88 | b := bridge.New(nil) | |
| 89 | b.SetSession(&fakeSession{}) | |
| 90 | conn, ctx := dialTestServer(t, b) | |
| 91 | readUntil(t, ctx, conn, bridge.OutgoingHello) | |
| 92 | ||
| 93 | done := make(chan acp.RequestPermissionResponse, 1) | |
| 94 | go func() { | |
| 95 | resp, _ := b.HandlePermissionRequest(context.Background(), acp.RequestPermissionRequest{ | |
| 96 | SessionId: "sess-fake", | |
| 97 | Options: []acp.PermissionOption{{OptionId: "allow", Name: "Allow", Kind: acp.PermissionOptionKindAllowOnce}}, | |
| 98 | }) | |
| 99 | done <- resp | |
| 100 | }() | |
| 101 | ||
| 102 | request := readUntil(t, ctx, conn, bridge.OutgoingPermissionRequest) | |
| 103 | if err := wsjson.Write(ctx, conn, bridge.Incoming{ | |
| 104 | Type: bridge.IncomingPermissionResponse, | |
| 105 | RequestId: request.RequestId, | |
| 106 | OptionId: "allow", | |
| 107 | }); err != nil { | |
| 108 | t.Fatalf("websocket write failed: %v", err) | |
| 109 | } | |
| 110 | ||
| 111 | resp := <-done | |
| 112 | if resp.Outcome.Selected == nil || resp.Outcome.Selected.OptionId != "allow" { | |
| 113 | t.Errorf("outcome = %+v, want selected \"allow\"", resp.Outcome) | |
| 114 | } | |
| 115 | readUntil(t, ctx, conn, bridge.OutgoingPermissionResolved) | |
| 116 | } | |
| 117 | ||
| 118 | func TestWebSocketLateJoinerGetsReplay(t *testing.T) { | |
| 119 | b := bridge.New(nil) | |
| 120 | b.SetSession(&fakeSession{}) | |
| 121 | ||
| 122 | b.HandleSessionUpdate(context.Background(), acp.SessionNotification{ | |
| 123 | SessionId: "sess-fake", | |
| 124 | Update: acp.UpdateAgentMessageText("before you arrived"), | |
| 125 | }) | |
| 126 | ||
| 127 | conn, ctx := dialTestServer(t, b) | |
| 128 | readUntil(t, ctx, conn, bridge.OutgoingHello) | |
| 129 | update := readUntil(t, ctx, conn, bridge.OutgoingSessionUpdate) | |
| 130 | if !strings.Contains(string(update.Update), "before you arrived") { | |
| 131 | t.Errorf("replayed update = %s, want the pre-connection chunk", update.Update) | |
| 132 | } | |
| 133 | } |