package main // 🤖 End-to-end tests for internal/client's live typed-event + ephemeral // transport (liveevents.go) — the plumbing the agents bridge rides // (docs/superpowers/specs/2026-08-05-eleven-claude-agents-design.md). Same // harness as eleven_client_test.go: real router via newTestServer, two real // E2EE members. import ( "bytes" "context" "encoding/json" "net/http" "testing" "time" lclient "lchat/internal/client" "lchat/internal/e2ee" ) // groupForTest creates a named group containing both members, the way the web // composer does, and returns its thread id. func groupForTest(t *testing.T, tsURL string, a, b *lclient.Client) string { t.Helper() aUID, err := a.Me() if err != nil { t.Fatal(err) } bUID, err := b.Me() if err != nil { t.Fatal(err) } tk, err := e2ee.GenerateKey() if err != nil { t.Fatal(err) } tkB64 := e2ee.B64uEncode(tk) wrapTo := func(pub string) string { w, err := e2ee.WrapThreadKey(tkB64, pub) if err != nil { t.Fatal(err) } return w } nameBlob, err := e2ee.EncryptJSON(tk, map[string]string{"name": "Agents Test"}) if err != nil { t.Fatal(err) } body, _ := json.Marshal(map[string]any{ "token": a.Session.Token, "kind": "group", "name_blob": nameBlob, "members": []map[string]string{ {"user_id": aUID, "wrapped": wrapTo(a.Session.UserPub)}, {"user_id": bUID, "wrapped": wrapTo(b.Session.UserPub)}, }, }) resp, err := http.Post(tsURL+"/api/threads", "application/json", bytes.NewReader(body)) if err != nil { t.Fatal(err) } defer resp.Body.Close() var created struct { ThreadID string `json:"thread_id"` } if err := json.NewDecoder(resp.Body).Decode(&created); err != nil || created.ThreadID == "" { t.Fatalf("create thread: %v (id %q)", err, created.ThreadID) } return created.ThreadID } func TestClientTypedEventDelivery(t *testing.T) { ts, _ := newTestServer(t) bridge := joinForTest(t, ts.URL, "Claude") member := joinForTest(t, ts.URL, "Paul") thread := groupForTest(t, ts.URL, bridge, member) events := make(chan lclient.Event, 8) msgs := make(chan lclient.Message, 8) ready := make(chan struct{}) bridge.OnEvent = func(e lclient.Event) { events <- e } bridge.OnMessage = func(m lclient.Message) { msgs <- m } bridge.OnReady = func() { ready <- struct{}{} } ctx, cancel := context.WithCancel(context.Background()) defer cancel() go bridge.Run(ctx) select { case <-ready: case <-time.After(5 * time.Second): t.Fatal("bridge never connected") } // A member emits a typed event (a steering decision): it must arrive on // OnEvent with the transport fields injected, and never on OnMessage. if err := member.SendEvent(thread, map[string]any{ "type": "session.approval", "id": "s1", "rid": "a1", "state": "allowed", }); err != nil { t.Fatalf("SendEvent: %v", err) } select { case e := <-events: if e.Type != "session.approval" || e.ThreadID != thread || e.ID == 0 { t.Errorf("event: %+v", e) } if e.Payload["name"] != "Paul" || e.Payload["rid"] != "a1" { t.Errorf("payload: %+v", e.Payload) } if _, ok := e.Payload["uid"].(string); !ok { t.Errorf("uid not injected: %+v", e.Payload) } case <-time.After(5 * time.Second): t.Fatal("typed event never arrived on OnEvent") } select { case m := <-msgs: t.Errorf("typed event leaked to OnMessage: %+v", m) default: } // A plain chat message still lands on OnMessage, not OnEvent. if err := member.Send(thread, "hello"); err != nil { t.Fatal(err) } select { case m := <-msgs: if m.Text != "hello" { t.Errorf("message: %+v", m) } case <-time.After(5 * time.Second): t.Fatal("chat message never arrived on OnMessage") } select { case e := <-events: t.Errorf("chat message leaked to OnEvent: %+v", e) default: } } func TestClientTypedEventCatchUp(t *testing.T) { ts, _ := newTestServer(t) bridge := joinForTest(t, ts.URL, "Claude") member := joinForTest(t, ts.URL, "Paul") thread := groupForTest(t, ts.URL, bridge, member) // Baseline the bridge's cursor on the thread (first connect delivers // nothing historical), then drop the connection. ready := make(chan struct{}) events := make(chan lclient.Event, 8) bridge.OnEvent = func(e lclient.Event) { events <- e } bridge.OnReady = func() { ready <- struct{}{} } ctx1, cancel1 := context.WithCancel(context.Background()) go bridge.Run(ctx1) select { case <-ready: case <-time.After(5 * time.Second): t.Fatal("bridge never connected") } cancel1() // An event lands while the bridge is away; the reconnect's catch-up must // deliver it to OnEvent. if err := member.SendEvent(thread, map[string]any{"type": "session.prompt", "id": "s1", "text": "fix it"}); err != nil { t.Fatal(err) } ctx2, cancel2 := context.WithCancel(context.Background()) defer cancel2() go bridge.Run(ctx2) select { case e := <-events: if e.Type != "session.prompt" || e.Payload["text"] != "fix it" { t.Errorf("catch-up event: %+v", e) } case <-time.After(5 * time.Second): t.Fatal("catch-up never delivered the typed event") } } func TestClientEphemeralRoundTrip(t *testing.T) { ts, _ := newTestServer(t) bridge := joinForTest(t, ts.URL, "Claude") member := joinForTest(t, ts.URL, "Paul") thread := groupForTest(t, ts.URL, bridge, member) // Both ends live: the bridge sends the tail, the member's client receives // it on OnEphemeral. run := func(c *lclient.Client) (chan lclient.Event, context.CancelFunc) { ev := make(chan lclient.Event, 8) ready := make(chan struct{}) c.OnEphemeral = func(e lclient.Event) { ev <- e } c.OnReady = func() { ready <- struct{}{} } ctx, cancel := context.WithCancel(context.Background()) go c.Run(ctx) select { case <-ready: case <-time.After(5 * time.Second): t.Fatal("client never connected") } return ev, cancel } memberEv, cancelM := run(member) defer cancelM() _, cancelB := run(bridge) defer cancelB() if err := bridge.SendEphemeral(thread, map[string]any{"type": "session.stream", "id": "s1", "text": "partial…"}); err != nil { t.Fatalf("SendEphemeral: %v", err) } select { case e := <-memberEv: if e.Type != "session.stream" || e.ID != 0 || e.Payload["name"] != "Claude" || e.Payload["text"] != "partial…" { t.Errorf("ephemeral: %+v", e) } case <-time.After(5 * time.Second): t.Fatal("ephemeral frame never arrived") } // Relay-only means relay-only: nothing was stored. evs, err := member.Events(thread, "session.") if err != nil { t.Fatal(err) } if len(evs) != 0 { t.Errorf("ephemeral frame was stored: %+v", evs) } } func TestClientSendEphemeralRequiresConnection(t *testing.T) { ts, _ := newTestServer(t) bridge := joinForTest(t, ts.URL, "Claude") member := joinForTest(t, ts.URL, "Paul") thread := groupForTest(t, ts.URL, bridge, member) // No Run: an ephemeral frame has no store to land in, so without a live // socket the send must fail loudly rather than vanish. if err := bridge.SendEphemeral(thread, map[string]any{"type": "session.stream"}); err == nil { t.Fatal("SendEphemeral without a connection should error") } }