Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions e2e/tests/chat-inbox-delivery.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
'<cross-session-message from-name="poster (#testroom)">\n[#testroom] poster: @recipient hello from e2e\n</cross-session-message>',
);
}, 30_000);

test("a DM lands only on its recipient's inbox, and stops once that recipient signs out", async () => {
Expand DownExpand Up@@ -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(
'<cross-session-message from-name="c (dm)">\n[dm] c: secret for a\n</cross-session-message>',
);
// 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);
Expand Down
18 changes: 12 additions & 6 deletions lib/daemon/__tests__/chat-delivery.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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, '<cross-session-message from-name="a (#general)">\n[#general] a: @b hi\n</cross-session-message>'],
]);
expect(lastReadId(h.db, "general", "b")).toBe(posted.data.id);
});

Expand DownExpand Up@@ -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(
'<cross-session-message from-name="rt chat (2 messages)">\n[#general] a: one\n[#general] a: two\n</cross-session-message>',
);
expect(lastReadId(h.db, "general", "b")).toBe(second.data.id);
});

Expand DownExpand Up@@ -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('<cross-session-message from-name="a (#general)">\n[#general] a: one\n</cross-session-message>');
expect(calls[1]![1]).toBe('<cross-session-message from-name="a (#general)">\n[#general] a: two\n</cross-session-message>');
expect(lastReadId(h.db, "general", "b")).toBe(second.data.id);
});

Expand DownExpand Up@@ -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(
'<cross-session-message from-name="rt chat (2 messages)">\n[#general] a: one\n[#general] a: two\n</cross-session-message>',
);
expect(lastReadId(h.db, "general", "b")).toBe(second.data.id);
});

Expand DownExpand Up@@ -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, '<cross-session-message from-name="a (dm)">\n[dm] a: hi\n</cross-session-message>']]);
});

test("the desk-notification path still fires on a mention, independent of inbox delivery", async () => {
Expand Down
25 changes: 24 additions & 1 deletion lib/daemon/__tests__/inbox.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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([
Expand All@@ -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(
'<cross-session-message from-name="max (#general)">\n[#general] max: hello\n</cross-session-message>',
);
});

test("wrapCrossSession neutralizes attribute-breaking characters in the label", () => {
const wrapped = wrapCrossSession('x" bad="<y>', "body");
expect(wrapped.startsWith("<cross-session-message from-name=\"x' bad='")).toBe(true);
expect(wrapped).not.toContain('""');
expect(wrapped.split("\n")[0]).not.toContain("<y>");
});

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[] = [];
Expand Down
7 changes: 4 additions & 3 deletions lib/daemon/handlers/chat.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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);
Expand DownExpand Up@@ -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) => {
Expand Down
26 changes: 26 additions & 0 deletions lib/daemon/inbox.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<cross-session-message ...>` 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 `<cross-session-message from-name="${safe}">\n${body}\n</cross-session-message>`;
}

/** 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)`;
}
8 changes: 7 additions & 1 deletion skills/rt-chat/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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):

```
<cross-session-message from-name="handle (#room)">
[#room] handle: body
</cross-session-message>
```

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
Expand Down
Loading