| new file mode 100644 |
| index 0000000..c6d223f |
| --- /dev/null |
| +++ b/agents_transport_test.go |
| @@ -0,0 +1,235 @@ |
| +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") |
| + } |
| +} |