package bridge_test import ( "context" "encoding/json" "errors" "sync" "testing" "time" acp "github.com/coder/acp-go-sdk" "rickub.com/bots-garden/ori/internal/bridge" ) // fakeSession implements bridge.Prompter without any agent behind it. type fakeSession struct { mu sync.Mutex prompts []string blocks [][]acp.ContentBlock cancelled bool // respond controls what Prompt returns; nil means end_turn. respond func(text string) (acp.StopReason, error) // block, when non-nil, is closed by the test to let Prompt return. block chan struct{} } func (f *fakeSession) ID() string { return "sess-fake" } func (f *fakeSession) Prompt(_ context.Context, blocks []acp.ContentBlock) (acp.StopReason, error) { text := "" if len(blocks) > 0 && blocks[0].Text != nil { text = blocks[0].Text.Text } f.mu.Lock() f.prompts = append(f.prompts, text) f.blocks = append(f.blocks, blocks) respond := f.respond block := f.block f.mu.Unlock() if block != nil { <-block } if respond != nil { return respond(text) } return acp.StopReasonEndTurn, nil } func (f *fakeSession) Cancel(context.Context) error { f.mu.Lock() defer f.mu.Unlock() f.cancelled = true return nil } // collect drains a subscriber channel until the wanted message type shows up // or the timeout hits; it returns every message seen, decoded. func collect(t *testing.T, ch <-chan []byte, wantType string) []bridge.Outgoing { t.Helper() var seen []bridge.Outgoing deadline := time.After(5 * time.Second) for { select { case raw, ok := <-ch: if !ok { t.Fatalf("channel closed while waiting for %q; saw %+v", wantType, seen) } var msg bridge.Outgoing if err := json.Unmarshal(raw, &msg); err != nil { t.Fatalf("undecodable outgoing message %q: %v", raw, err) } seen = append(seen, msg) if msg.Type == wantType { return seen } case <-deadline: t.Fatalf("timed out waiting for %q; saw %+v", wantType, seen) } } } func decode(t *testing.T, raw []byte) bridge.Outgoing { t.Helper() var msg bridge.Outgoing if err := json.Unmarshal(raw, &msg); err != nil { t.Fatalf("undecodable message %q: %v", raw, err) } return msg } func TestSubscribeSendsHello(t *testing.T) { b := bridge.New(nil) b.SetSession(&fakeSession{}) _, replay, unsubscribe := b.Subscribe() defer unsubscribe() if len(replay) == 0 { t.Fatal("Subscribe returned no replay events, want at least the hello") } hello := decode(t, replay[0]) if hello.Type != bridge.OutgoingHello || hello.SessionId != "sess-fake" { t.Errorf("first replay event = %+v, want hello for sess-fake", hello) } } func TestPromptRunsTurnAndBroadcastsMarkers(t *testing.T) { b := bridge.New(nil) session := &fakeSession{} b.SetSession(session) events, _, unsubscribe := b.Subscribe() defer unsubscribe() b.HandleIncoming(context.Background(), []byte(`{"type":"prompt","text":"hello agent"}`)) seen := collect(t, events, bridge.OutgoingTurnEnded) if seen[0].Type != bridge.OutgoingUserMessage || seen[0].Text != "hello agent" { t.Errorf("first event = %+v, want the echoed user message", seen[0]) } if seen[1].Type != bridge.OutgoingTurnStarted { t.Errorf("second event = %q, want turn_started", seen[1].Type) } last := seen[len(seen)-1] if last.StopReason != string(acp.StopReasonEndTurn) { t.Errorf("turn_ended stopReason = %q, want end_turn", last.StopReason) } session.mu.Lock() defer session.mu.Unlock() if len(session.prompts) != 1 || session.prompts[0] != "hello agent" { t.Errorf("session received prompts %v, want [hello agent]", session.prompts) } } func TestSecondPromptDuringTurnIsRejected(t *testing.T) { b := bridge.New(nil) session := &fakeSession{block: make(chan struct{})} b.SetSession(session) events, _, unsubscribe := b.Subscribe() defer unsubscribe() b.HandleIncoming(context.Background(), []byte(`{"type":"prompt","text":"first"}`)) collect(t, events, bridge.OutgoingTurnStarted) b.HandleIncoming(context.Background(), []byte(`{"type":"prompt","text":"second"}`)) seen := collect(t, events, bridge.OutgoingError) if last := seen[len(seen)-1]; last.Message != "a turn is already running" { t.Errorf("error message = %q", last.Message) } close(session.block) collect(t, events, bridge.OutgoingTurnEnded) } func TestPromptWithoutSessionReportsError(t *testing.T) { b := bridge.New(nil) events, _, unsubscribe := b.Subscribe() defer unsubscribe() b.HandleIncoming(context.Background(), []byte(`{"type":"prompt","text":"x"}`)) seen := collect(t, events, bridge.OutgoingError) if last := seen[len(seen)-1]; last.Message != "no agent session" { t.Errorf("error message = %q, want \"no agent session\"", last.Message) } } func TestPromptFailureEndsTurnWithError(t *testing.T) { b := bridge.New(nil) b.SetSession(&fakeSession{respond: func(string) (acp.StopReason, error) { return "", errors.New("agent exploded") }}) events, _, unsubscribe := b.Subscribe() defer unsubscribe() b.HandleIncoming(context.Background(), []byte(`{"type":"prompt","text":"x"}`)) seen := collect(t, events, bridge.OutgoingTurnEnded) foundError := false for _, msg := range seen { if msg.Type == bridge.OutgoingError { foundError = true } } if !foundError { t.Error("no error event before turn_ended, want one mentioning the failure") } } func TestCancelReachesSession(t *testing.T) { b := bridge.New(nil) session := &fakeSession{} b.SetSession(session) b.HandleIncoming(context.Background(), []byte(`{"type":"cancel"}`)) session.mu.Lock() defer session.mu.Unlock() if !session.cancelled { t.Error("cancel never reached the session") } } func TestMalformedAndUnknownMessagesReportErrors(t *testing.T) { b := bridge.New(nil) events, _, unsubscribe := b.Subscribe() defer unsubscribe() b.HandleIncoming(context.Background(), []byte(`{not json`)) collect(t, events, bridge.OutgoingError) b.HandleIncoming(context.Background(), []byte(`{"type":"nope"}`)) seen := collect(t, events, bridge.OutgoingError) if last := seen[len(seen)-1]; last.Message != `unknown message type "nope"` { t.Errorf("error message = %q", last.Message) } } func TestSessionUpdateIsRelayedAndReplayed(t *testing.T) { b := bridge.New(nil) b.SetSession(&fakeSession{}) events, _, unsubscribe := b.Subscribe() defer unsubscribe() b.HandleSessionUpdate(context.Background(), acp.SessionNotification{ SessionId: "sess-fake", Update: acp.UpdateAgentMessageText("chunk!"), }) seen := collect(t, events, bridge.OutgoingSessionUpdate) update := seen[len(seen)-1].Update if update == nil { t.Fatal("session_update event has no update payload") } var acpUpdate struct { SessionUpdate string `json:"sessionUpdate"` Content struct { Text string `json:"text"` } `json:"content"` } if err := json.Unmarshal(update, &acpUpdate); err != nil { t.Fatalf("update payload is not the raw ACP shape: %v", err) } if acpUpdate.SessionUpdate != "agent_message_chunk" || acpUpdate.Content.Text != "chunk!" { t.Errorf("relayed update = %+v, want agent_message_chunk with text \"chunk!\"", acpUpdate) } // A client connecting after the fact receives the update in its replay. _, replay, unsubscribeLate := b.Subscribe() defer unsubscribeLate() found := false for _, raw := range replay { if decode(t, raw).Type == bridge.OutgoingSessionUpdate { found = true } } if !found { t.Error("late subscriber's replay lacks the session update") } } func TestPermissionRoundTrip(t *testing.T) { b := bridge.New(nil) b.SetSession(&fakeSession{}) events, _, unsubscribe := b.Subscribe() defer unsubscribe() type result struct { resp acp.RequestPermissionResponse err error } got := make(chan result, 1) go func() { resp, err := b.HandlePermissionRequest(context.Background(), acp.RequestPermissionRequest{ SessionId: "sess-fake", Options: []acp.PermissionOption{{OptionId: "allow", Name: "Allow", Kind: acp.PermissionOptionKindAllowOnce}}, }) got <- result{resp, err} }() seen := collect(t, events, bridge.OutgoingPermissionRequest) request := seen[len(seen)-1] if request.RequestId == "" || request.Request == nil { t.Fatalf("permission_request event incomplete: %+v", request) } b.HandleIncoming(context.Background(), []byte(`{"type":"permission_response","requestId":"`+request.RequestId+`","optionId":"allow"}`)) r := <-got if r.err != nil { t.Fatalf("HandlePermissionRequest returned an error: %v", r.err) } if r.resp.Outcome.Selected == nil || r.resp.Outcome.Selected.OptionId != "allow" { t.Errorf("outcome = %+v, want selected \"allow\"", r.resp.Outcome) } collect(t, events, bridge.OutgoingPermissionResolved) } func TestPermissionCancelledByAgentContext(t *testing.T) { b := bridge.New(nil) b.SetSession(&fakeSession{}) ctx, cancel := context.WithCancel(context.Background()) go cancel() resp, err := b.HandlePermissionRequest(ctx, acp.RequestPermissionRequest{SessionId: "sess-fake"}) if err != nil { t.Fatalf("HandlePermissionRequest returned an error: %v", err) } if resp.Outcome.Cancelled == nil { t.Errorf("outcome = %+v, want cancelled", resp.Outcome) } } func TestPendingPermissionIsReplayedToLateJoiner(t *testing.T) { b := bridge.New(nil) b.SetSession(&fakeSession{}) answered := make(chan struct{}) go func() { _, _ = b.HandlePermissionRequest(context.Background(), acp.RequestPermissionRequest{SessionId: "sess-fake"}) close(answered) }() // Wait for the request to be registered by watching a live subscriber. events, _, unsubscribe := b.Subscribe() seen := collect(t, events, bridge.OutgoingPermissionRequest) requestId := seen[len(seen)-1].RequestId unsubscribe() _, replay, unsubscribeLate := b.Subscribe() defer unsubscribeLate() found := false for _, raw := range replay { msg := decode(t, raw) if msg.Type == bridge.OutgoingPermissionRequest && msg.RequestId == requestId { found = true } } if !found { t.Error("late subscriber's replay lacks the pending permission request") } b.HandleIncoming(context.Background(), []byte(`{"type":"permission_response","requestId":"`+requestId+`","cancelled":true}`)) <-answered } func TestPromptAttachmentsBecomeResourceLinks(t *testing.T) { b := bridge.New(nil) session := &fakeSession{} b.SetSession(session) b.SetWorkspaceRoot("/work") events, _, unsubscribe := b.Subscribe() defer unsubscribe() b.HandleIncoming(context.Background(), []byte(`{"type":"prompt","text":"explain @src/main.go and @/etc/hosts", "attachments":[{"path":"src/main.go","name":"src/main.go"},{"path":"/etc/hosts"},{"path":""}]}`)) seen := collect(t, events, bridge.OutgoingTurnEnded) if len(seen[0].Attachments) != 3 || seen[0].Attachments[0].Name != "src/main.go" { t.Errorf("user_message attachments = %+v, want the three sent", seen[0].Attachments) } session.mu.Lock() defer session.mu.Unlock() blocks := session.blocks[0] if len(blocks) != 3 { t.Fatalf("prompt blocks = %d, want text + 2 resource links (empty path skipped)", len(blocks)) } if blocks[0].Text == nil || blocks[0].Text.Text != "explain @src/main.go and @/etc/hosts" { t.Errorf("first block = %+v, want the text", blocks[0]) } cases := []struct{ name, uri string }{ {"src/main.go", "file:///work/src/main.go"}, {"/etc/hosts", "file:///etc/hosts"}, } for i, tc := range cases { link := blocks[i+1].ResourceLink if link == nil || link.Name != tc.name || link.Uri != tc.uri || link.Type != "resource_link" { t.Errorf("block[%d] = %+v, want resource_link %s → %s", i+1, blocks[i+1], tc.name, tc.uri) } } }