diff --git a/e2e/tests/chat-inbox-delivery.test.ts b/e2e/tests/chat-inbox-delivery.test.ts index 078e1718..0622f98a 100644 --- a/e2e/tests/chat-inbox-delivery.test.ts +++ b/e2e/tests/chat-inbox-delivery.test.ts @@ -234,7 +234,9 @@ describe("rt chat inbox delivery (e2e)", () => { const frame = await waitForFrame(inbox.frames, (f) => frameContent(f).includes("hello from e2e")); expect(frame.type).toBe("user"); - expect(frameContent(frame)).toBe("[#testroom] poster: @recipient hello from e2e"); + expect(frameContent(frame)).toBe( + '\n[#testroom] poster: @recipient hello from e2e\n', + ); }, 30_000); test("a DM lands only on its recipient's inbox, and stops once that recipient signs out", async () => { @@ -269,7 +271,9 @@ describe("rt chat inbox delivery (e2e)", () => { await dm(home, "a", "secret for a", "sess-c"); const frame = await waitForFrame(inboxA.frames, (f) => frameContent(f).includes("secret for a")); - expect(frameContent(frame)).toBe("[dm] c: secret for a"); + expect(frameContent(frame)).toBe( + '\n[dm] c: secret for a\n', + ); // b is not a participant of this DM: nothing about it ever reaches b's inbox. await Bun.sleep(300); expect(inboxB.frames.some((f) => frameContent(f).includes("secret for a"))).toBe(false); diff --git a/lib/daemon/__tests__/chat-delivery.test.ts b/lib/daemon/__tests__/chat-delivery.test.ts index ab2f612f..85849c9c 100644 --- a/lib/daemon/__tests__/chat-delivery.test.ts +++ b/lib/daemon/__tests__/chat-delivery.test.ts @@ -77,7 +77,9 @@ test("posting to a room delivers the body to a signed-in recipient's inbox and a const posted = await h["chat:post"]({ room: "general", handle: "a", body: "@b hi" }); if (!posted.ok) throw new Error("unreachable"); await Bun.sleep(0); - expect(calls).toEqual([[sock, "[#general] a: @b hi"]]); + expect(calls).toEqual([ + [sock, '\n[#general] a: @b hi\n'], + ]); expect(lastReadId(h.db, "general", "b")).toBe(posted.data.id); }); @@ -276,7 +278,9 @@ test("a failed delivery batches with the next successful one, catching up the wh if (!second.ok) throw new Error("unreachable"); await Bun.sleep(0); expect(calls).toHaveLength(2); - expect(calls[1]![1]).toBe("[#general] a: one\n[#general] a: two"); + expect(calls[1]![1]).toBe( + '\n[#general] a: one\n[#general] a: two\n', + ); expect(lastReadId(h.db, "general", "b")).toBe(second.data.id); }); @@ -320,8 +324,8 @@ test("concurrent posts to the same recipient serialize delivery so a held first await Bun.sleep(0); expect(calls).toHaveLength(2); - expect(calls[0]![1]).toBe("[#general] a: one"); - expect(calls[1]![1]).toBe("[#general] a: two"); + expect(calls[0]![1]).toBe('\n[#general] a: one\n'); + expect(calls[1]![1]).toBe('\n[#general] a: two\n'); expect(lastReadId(h.db, "general", "b")).toBe(second.data.id); }); @@ -364,7 +368,9 @@ test("a held first delivery that ultimately fails still lets the second carry bo await Bun.sleep(0); expect(calls).toHaveLength(2); - expect(calls[1]![1]).toBe("[#general] a: one\n[#general] a: two"); + expect(calls[1]![1]).toBe( + '\n[#general] a: one\n[#general] a: two\n', + ); expect(lastReadId(h.db, "general", "b")).toBe(second.data.id); }); @@ -438,7 +444,7 @@ test("a dm post renders with the [dm] tag, not the room hash", async () => { await settleWelcome(calls); await h["chat:dm"]({ from: "a", to: "b", body: "hi" }); await Bun.sleep(0); - expect(calls).toEqual([[sock, "[dm] a: hi"]]); + expect(calls).toEqual([[sock, '\n[dm] a: hi\n']]); }); test("the desk-notification path still fires on a mention, independent of inbox delivery", async () => { diff --git a/lib/daemon/__tests__/inbox.test.ts b/lib/daemon/__tests__/inbox.test.ts index 7927a097..3dce5a81 100644 --- a/lib/daemon/__tests__/inbox.test.ts +++ b/lib/daemon/__tests__/inbox.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { mkdtempSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; -import { deliverToInbox, renderDeliveries } from "../inbox.ts"; +import { deliverToInbox, deliveryLabel, renderDeliveries, wrapCrossSession } from "../inbox.ts"; test("renderDeliveries formats room and dm lines", () => { expect(renderDeliveries([ @@ -11,6 +11,29 @@ test("renderDeliveries formats room and dm lines", () => { ])).toBe("[#general] max: hello\n[dm] eli: hi"); }); +test("wrapCrossSession produces the exact envelope Claude Code collapses on", () => { + expect(wrapCrossSession("max (#general)", "[#general] max: hello")).toBe( + '\n[#general] max: hello\n', + ); +}); + +test("wrapCrossSession neutralizes attribute-breaking characters in the label", () => { + const wrapped = wrapCrossSession('x" bad="', "body"); + expect(wrapped.startsWith(""); +}); + +test("deliveryLabel names the sender for one message and counts a batch", () => { + expect(deliveryLabel([{ room: "general", dm: false, handle: "max" }])).toBe("max (#general)"); + expect(deliveryLabel([{ room: "dm-1", dm: true, handle: "eli" }])).toBe("eli (dm)"); + expect(deliveryLabel([ + { room: "general", dm: false, handle: "max" }, + { room: "general", dm: false, handle: "eli" }, + { room: "general", dm: false, handle: "kai" }, + ])).toBe("rt chat (3 messages)"); +}); + test("deliverToInbox writes exactly one msgV:1 user frame line", async () => { const path = join(mkdtempSync(join(tmpdir(), "inbox-")), "s.sock"); const lines: string[] = []; diff --git a/lib/daemon/handlers/chat.ts b/lib/daemon/handlers/chat.ts index 1989740b..0852d46d 100644 --- a/lib/daemon/handlers/chat.ts +++ b/lib/daemon/handlers/chat.ts @@ -45,7 +45,7 @@ import { herdrRequest } from "../../herdr/client.ts"; import { injectIntoPane, herdrError } from "../inject.ts"; import type { HerdrSnapshot } from "./pane.ts"; import { resolveInbox, inboxAlive } from "../../claude-registry.ts"; -import { deliverToInbox, renderDeliveries } from "../inbox.ts"; +import { deliverToInbox, deliveryLabel, renderDeliveries, wrapCrossSession } from "../inbox.ts"; import { repoForCwd, branchForCwd } from "../../repo-for-cwd.ts"; import { deriveRoomForCwdAsync } from "../../chat-room.ts"; import { runCapture } from "../../subprocess.ts"; @@ -152,7 +152,8 @@ async function deliverPost( if (!binding || !inboxAlive(binding)) return; const pending = pendingMessages(msg.room, recipient, msg.id, db); if (pending.length === 0) return; - const content = renderDeliveries(pending.map((m) => ({ room: msg.room, dm: msg.dm, handle: m.handle, body: m.body }))); + const items = pending.map((m) => ({ room: msg.room, dm: msg.dm, handle: m.handle, body: m.body })); + const content = wrapCrossSession(deliveryLabel(items), renderDeliveries(items)); const result = await deps.deliver(binding.socketPath, content); if (!result.ok) { await reportUnreadBadge(herdr, presence.pane, pending.length); @@ -575,7 +576,7 @@ export function createChatHandlers(opts: { const peeked = peekUnread({ handle: data.handle, limit: WELCOME_CATCHUP_LIMIT }, db); const catchup = peeked.map((r) => ({ room: r.room, lines: r.messages.map((m) => `${m.handle}: ${m.body}`) })); const catchupCursors = peeked.map((r) => ({ room: r.room, upToId: r.messages[r.messages.length - 1]!.id })); - const welcomeContent = renderWelcome(data.handle, rooms, catchup); + const welcomeContent = wrapCrossSession("rt chat", renderWelcome(data.handle, rooms, catchup)); const welcomeSessionId = sessionId; queueMicrotask(() => { deliverWelcome(db, deliveryChains, inboxDeps, welcomeSessionId, data.handle, welcomeContent, catchupCursors).catch((err) => { diff --git a/lib/daemon/inbox.ts b/lib/daemon/inbox.ts index e4e3618c..20e267e2 100644 --- a/lib/daemon/inbox.ts +++ b/lib/daemon/inbox.ts @@ -70,3 +70,29 @@ export function renderDeliveries( .map((item) => `${item.dm ? "[dm]" : `[#${item.room}]`} ${item.handle}: ${item.body}`) .join("\n"); } + +/** + * Claude Code's terminal renders an inbound peer message collapsed (one + * labeled row, body hidden until expanded) ONLY when the content opens with + * its `` envelope; bare text renders in full. + * `from-name` is the collapsed row's label. No `from` attribute: that is a + * SendMessage reply address, and rt recipients reply via `rt chat post/dm` + * (taught in the body), so advertising an unreachable address would misteach + * the reply path. The envelope changes presentation only -- the model always + * receives the full body. + */ +export function wrapCrossSession(label: string, body: string): string { + const safe = label.replace(/["<>]/g, "'"); + return `\n${body}\n`; +} + +/** The collapsed row's label: the sender for a single message, a count for a batched catch-up. */ +export function deliveryLabel( + items: Array<{ room: string; dm: boolean; handle: string }>, +): string { + if (items.length === 1) { + const item = items[0]!; + return `${item.handle} (${item.dm ? "dm" : `#${item.room}`})`; + } + return `rt chat (${items.length} messages)`; +} diff --git a/skills/rt-chat/SKILL.md b/skills/rt-chat/SKILL.md index 6acfa29a..267e5aea 100644 --- a/skills/rt-chat/SKILL.md +++ b/skills/rt-chat/SKILL.md @@ -62,12 +62,18 @@ to re-derive the reply contract from this doc afterward. ## How messages reach you Delivery is automatic and push-based. A chat body arrives directly in your -context as one line per message: +context as one line per message, wrapped in your host's peer-message +envelope (so your terminal shows it as a collapsed one-line row, like any +cross-session message): ``` + [#room] handle: body + ``` +The envelope's `from-name` is a display label, not a reply address: reply +with `rt chat post`/`rt chat dm` (below), never a session-messaging tool. Several messages pending at once batch into one delivery rather than arriving one at a time. There is nothing to arm, nothing to poll, and no tool to keep running in the background: the daemon pushes into your inbox