Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9e1581e
docs: rt chat invite spec: rooms from the viewer, agents from herdr p…
m4ttheweric Aug 27, 2026
052079b
docs: invite spec: rt pane group, spawning a pane from the picker
m4ttheweric Aug 27, 2026
873a02c
docs: invite spec: review round 1 fixes (note attribution, timeouts, …
m4ttheweric Aug 27, 2026
59be668
docs: invite spec: review round 2 fixes (waiting-call socket timeouts…
m4ttheweric Aug 27, 2026
1cb3f93
docs: invite spec: chatInvite timeout 30s (review advisory)
m4ttheweric Aug 27, 2026
3f467b8
docs: rt chat invite plans, parts 1 (rt), 2 (viewer), 3 (join skill)
m4ttheweric Aug 27, 2026
f8b9202
docs: plans: no dashes in grep patterns or headers
m4ttheweric Aug 27, 2026
2ae099a
docs: plans: review round 1 fixes (shellQuote expectations, test help…
m4ttheweric Aug 27, 2026
0ad482a
docs: join-skill plan: land by fast-forward (no marketplace remote), …
m4ttheweric Aug 27, 2026
773079e
docs: rt plan: registration poll wording
m4ttheweric Aug 27, 2026
dfd9926
docs: plans: untracked-files guard, prettier-safe imports, mentions f…
m4ttheweric Aug 27, 2026
dc0a795
herdr: NDJSON socket client with a fake server for tests
m4ttheweric Aug 27, 2026
c1f75db
herdr: fix close-before-data race, guard settled open, track id in fa…
m4ttheweric Aug 27, 2026
577d838
lib: repo-for-cwd, the git-free cwd to repo resolution shared by chat…
m4ttheweric Aug 27, 2026
f3a0a62
daemon: pane:list and pane:peek, herdr panes joined to presence
m4ttheweric Aug 27, 2026
ab6382b
daemon: pane:accounts (cswap list) and pane:directories (repo index p…
m4ttheweric Aug 27, 2026
68a7e9c
cswap: tolerate colon in headroom labels; pane:directories: isolate p…
m4ttheweric Aug 27, 2026
caf8733
daemon: pane:spawn starts claude in a herdr tab; chat.herdrWorkspace …
m4ttheweric Aug 27, 2026
250d4cb
daemon: chat:invite types /chat:join into a herdr pane
m4ttheweric Aug 27, 2026
e33708e
rt-client: pane and invite wrappers with their own timeouts
m4ttheweric Aug 27, 2026
af86a71
cli: rt pane list, peek, spawn, accounts, directories
m4ttheweric Aug 27, 2026
4bcf8ae
cli: rt chat invite, rt chat read --last N
m4ttheweric Aug 27, 2026
9f3e16c
chat: drop em dashes from task 9 comments and describe block
m4ttheweric Aug 27, 2026
796bd9a
rt-chat skill: read --last, rt chat invite, the rt pane group, recrui…
m4ttheweric Aug 27, 2026
5db72ad
rt-client: 0.6.2, pane and invite wrappers
m4ttheweric Aug 27, 2026
3a68b24
repo-for-cwd: drop em dashes from the moved doc comments
m4ttheweric Aug 27, 2026
e3b2565
ci: pass tsc --noEmit and repo-purity
m4ttheweric Aug 27, 2026
a402328
ci: omitBehavior for pane peek; normalize CR and line separators in i…
m4ttheweric Aug 27, 2026
4861f71
rt-client: derive pane and invite wrapper types from Commands
m4ttheweric Aug 27, 2026
30a85fa
daemon: bound pane:spawn registration poll by wall-clock, not attempt…
m4ttheweric Aug 27, 2026
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
62 changes: 62 additions & 0 deletions commands/__tests__/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ let server: ReturnType<typeof Bun.serve> | null = null;
// Real child processes (spawnChat); reaped in afterEach so a stray tail can't
// outlive its test.
const children: Array<ReturnType<typeof Bun.spawn>> = [];
// Scripted replies for a command, consulted before the real handlers (for
// commands whose real handler has side effects a unit test must not trigger:
// chat:invite would actually type into a herdr pane). Reset every test.
let canned: Record<string, unknown> = {};
// Every command this fake daemon dispatched, in order, for asserting exactly
// what a verb sent the daemon.
let seen: Array<{ cmd: string; payload: unknown }> = [];

beforeEach(() => {
origHome = process.env.HOME;
Expand All @@ -67,18 +74,23 @@ beforeEach(() => {
const sockDir = join(home, ".mattstack", "rt");
mkdirSync(sockDir, { recursive: true });

canned = {};
seen = [];

server = Bun.serve({
unix: join(sockDir, "rt.sock"),
async fetch(req) {
const cmd = new URL(req.url).pathname.slice(1);
const payload = req.method === "POST" ? await req.json() : {};
seen.push({ cmd, payload });
// The tail drives the events bus directly; the CLI-verb harness has no
// real bus, so stub just enough for a spawned tail to arm and block.
if (cmd === "events:head") return Response.json({ ok: true, data: { cursor: 0 } });
if (cmd === "events:wait") {
await Bun.sleep(300); // empty long-poll round; the tail loops and stays alive
return Response.json({ ok: true, data: { events: [], cursor: 0 } });
}
if (cmd in canned) return Response.json(canned[cmd]);
const handlers = createChatHandlers({ db: getStateDb(), emitEvent: () => 0 }) as unknown as Record<string, (p: unknown) => Promise<unknown>>;
const handler = handlers[cmd];
if (!handler) return Response.json({ ok: false, error: `unknown command: ${cmd}` });
Expand Down Expand Up @@ -1030,3 +1042,53 @@ describe("pidfile identity — only a real rt chat tail reads as live", () => {
expect(__test__.looksLikeRtChatTail("vim tail-of-a-chat.log")).toBe(false);
});
});

// ─── Task 9: `rt chat read --last N` and `rt chat invite <pane>` ───────────

describe("rt chat CLI: read --last, invite", () => {
test("read --last N shows the newest N messages regardless of the cursor, then marks read", async () => {
await runChat(["join", "build", "--as", "alice"]);
await runChat(["post", "build", "seed one", "--as", "alice"]);
await runChat(["post", "build", "seed two", "--as", "alice"]);
await runChat(["join", "build", "--as", "bob"]);
const nothing = await runChat(["read", "build", "--as", "bob", "--json"]);
expect(JSON.parse(nothing).rooms[0]?.messages ?? []).toHaveLength(0);
const last = await runChat(["read", "build", "--last", "5", "--as", "bob", "--json"]);
expect(JSON.parse(last).rooms[0].messages.map((m: { body: string }) => m.body)).toEqual(["seed one", "seed two"]);
const again = await runChat(["read", "build", "--as", "bob", "--json"]);
expect(JSON.parse(again).rooms[0]?.messages ?? []).toHaveLength(0);
});

test("read --last refuses --since and a non-positive N", async () => {
await runChat(["join", "build", "--as", "alice"]);
expect((await runChatRaw(["read", "build", "--last", "5", "--since", "5m", "--as", "alice"])).code).toBe(1);
expect((await runChatRaw(["read", "build", "--last", "0", "--as", "alice"])).code).toBe(1);
});

test("read --last requires a room", async () => {
expect((await runChatRaw(["read", "--last", "5", "--as", "alice"])).code).toBe(1);
});

test("invite sends the pane, room, note, the human handle when not signed in, and the caller pane", async () => {
canned = { "chat:invite": { ok: true, data: { paneId: "w1:p1", delivered: "accepted" } } };
process.env.HERDR_PANE_ID = "w9:p9";
const out = await runChat(["invite", "w1:p1", "--room", "build", "--note", "take vite"]);
expect(out).toContain("accepted");
const sent = seen.find((s) => s.cmd === "chat:invite")!;
expect(sent.payload).toEqual({ paneId: "w1:p1", room: "build", note: "take vite", from: "matt", callerPane: "w9:p9" });
});

test("invite uses the session's own handle when signed in, and reports refusals with exit 0", async () => {
await runChat(["sign-in", "--as", "carol", "--session", "sess-c", "--no-room"]);
canned = { "chat:invite": { ok: true, data: { paneId: "w1:p1", delivered: "refused", reason: "at a prompt" } } };
const r = await runChatRaw(["invite", "w1:p1", "--room", "build", "--session", "sess-c"]);
expect(r.code).toBe(0);
expect(r.stdout).toContain("refused: at a prompt");
expect((seen.find((s) => s.cmd === "chat:invite")!.payload as { from: string }).from).toBe("carol");
});

test("invite requires a pane and --room", async () => {
expect((await runChatRaw(["invite"])).code).toBe(1);
expect((await runChatRaw(["invite", "w1:p1"])).code).toBe(1);
});
});
103 changes: 103 additions & 0 deletions commands/__tests__/pane.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, expect, spyOn, test } from "bun:test";
import { mkdirSync, mkdtempSync, realpathSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { paneAccounts, paneDirectories, paneList, panePeek, paneSpawn } from "../pane.ts";

let home: string;
let origHome: string | undefined;
let server: ReturnType<typeof Bun.serve> | undefined;
let seen: Array<{ cmd: string; payload: unknown }> = [];
let replies: Record<string, unknown> = {};

beforeEach(() => {
origHome = process.env.HOME;
home = realpathSync(mkdtempSync(join(tmpdir(), "rt-pane-cli-")));
process.env.HOME = home;
const sockDir = join(home, ".mattstack", "rt");
mkdirSync(sockDir, { recursive: true });
seen = [];
server = Bun.serve({
unix: join(sockDir, "rt.sock"),
async fetch(req) {
const cmd = new URL(req.url).pathname.slice(1);
const payload = req.method === "POST" ? await req.json() : {};
seen.push({ cmd, payload });
return Response.json(replies[cmd] ?? { ok: false, error: `unknown command: ${cmd}` });
},
});
});

afterEach(() => {
server?.stop(true);
process.env.HOME = origHome;
});

async function run(fn: (args: string[]) => Promise<void>, args: string[]) {
const out: string[] = [];
const err: string[] = [];
const logSpy = spyOn(console, "log").mockImplementation((...a: unknown[]) => { out.push(a.map(String).join(" ")); });
const errSpy = spyOn(console, "error").mockImplementation((...a: unknown[]) => { err.push(a.map(String).join(" ")); });
const exitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit sentinel"); });
let code = 0;
try {
await fn(args);
} catch (e) {
if (e instanceof Error && e.message === "process.exit sentinel") code = (exitSpy.mock.calls.at(-1)?.[0] as number | undefined) ?? 1;
else throw e;
} finally {
logSpy.mockRestore(); errSpy.mockRestore(); exitSpy.mockRestore();
}
return { code, stdout: out.join("\n"), stderr: err.join("\n") };
}

const PANE = { paneId: "w1:p1", workspace: "acme", title: "Evaluate codegen", cwd: "/repos/acme", repo: "acme", branch: "main", agentStatus: "idle", presence: { handle: "meg", status: "live", rooms: ["build"] } };

test("pane list --json prints the rows; plain prints one line per pane", async () => {
const panes = [PANE, { ...PANE, paneId: "w1:p2", presence: undefined, title: "fred" }];
replies = { "pane:list": { ok: true, data: { panes } } };
const json = await run(paneList, ["--json"]);
expect(JSON.parse(json.stdout)).toEqual({ ok: true, panes });
const plain = await run(paneList, []);
expect(plain.stdout).toContain("w1:p1");
expect(plain.stdout).toContain("meg");
expect(plain.stdout).toContain("not signed in");
});

test("pane list reports herdr unavailable and exits 1", async () => {
replies = { "pane:list": { ok: false, error: "herdr unavailable: no socket" } };
const r = await run(paneList, []);
expect(r.code).toBe(1);
expect(r.stderr).toContain("herdr unavailable");
});

test("pane peek passes the pane id and --lines", async () => {
replies = { "pane:peek": { ok: true, data: { paneId: "w1:p1", lines: ["a", "b"] } } };
const r = await run(panePeek, ["w1:p1", "--lines", "2"]);
expect(seen[0]).toEqual({ cmd: "pane:peek", payload: { paneId: "w1:p1", lines: 2 } });
expect(r.stdout).toBe("a\nb");
});

test("pane spawn passes every flag and prints the pane and readiness", async () => {
replies = { "pane:spawn": { ok: true, data: { pane: PANE, ready: true } } };
const r = await run(paneSpawn, ["--cwd", "/repos/acme", "--account", "Acme", "--model", "claude-fable-5", "--effort", "high", "--workspace", "chat", "--prompt", "read AGENTS.md", "--json"]);
expect(seen[0]!.payload).toEqual({ cwd: "/repos/acme", account: "Acme", model: "claude-fable-5", effort: "high", workspace: "chat", prompt: "read AGENTS.md" });
expect(JSON.parse(r.stdout)).toMatchObject({ ok: true, ready: true, pane: { paneId: "w1:p1" } });
});

test("pane spawn requires --cwd", async () => {
const r = await run(paneSpawn, []);
expect(r.code).toBe(1);
expect(r.stderr).toContain("--cwd");
});

test("pane accounts and directories render", async () => {
replies = {
"pane:accounts": { ok: true, data: { accounts: [{ slot: 1, email: "a@b.c", alias: "A", headroom: "5h 3%" }] } },
"pane:directories": { ok: true, data: { directories: [{ path: "/repos/chat", repo: "chat" }] } },
};
expect((await run(paneAccounts, [])).stdout).toContain("A");
const d = await run(paneDirectories, ["--q", "chat"]);
expect(seen.at(-1)).toEqual({ cmd: "pane:directories", payload: { q: "chat" } });
expect(d.stdout).toContain("/repos/chat");
});
Loading
Loading