import { beforeEach, describe, expect, it, vi } from "vitest"; import { createHash } from "node:crypto"; import copyTurnExtension from "./copy-turn.ts"; vi.mock("@selesai/code", () => ({ copyToClipboard: vi.fn(), })); import { copyToClipboard } from "@selesai/code"; type AnyMessage = any; type Handler = (event: any, ctx: any) => any; type Command = { description: string; handler: (args: string, ctx: any) => Promise }; // hashFor(m) = sha1(JSON.stringify([role, resultOf(m)])).slice(0, 6) function hashForText(role: string, text: string): string { return createHash("sha1").update(JSON.stringify([role, text])).digest("hex").slice(0, 6); } function createHarness() { const handlers = new Map(); const commands = new Map(); const sent: AnyMessage[] = []; const pi = { on: vi.fn((event: string, handler: Handler) => { handlers.set(event, (e: any, c: any) => handler(e, { mode: "tui", ...(c ?? {}) })); }), registerCommand: vi.fn((name: string, opts: Command) => { commands.set(name, opts); }), registerMessageRenderer: vi.fn(), sendMessage: vi.fn((msg: AnyMessage) => { sent.push(msg); }), }; copyTurnExtension(pi as any); return { handlers, commands, sent, pi }; } function branchCtx(messages: AnyMessage[]) { return { sessionManager: { getBranch: () => messages.map((message) => ({ type: "message", message })) }, ui: { notify: vi.fn(), setStatus: vi.fn() }, }; } describe("copy-turn: nothing enters the conversation", () => { beforeEach(() => { vi.clearAllMocks(); }); it("does not register automatic reply hints or add conversation messages", () => { const { handlers, sent, pi } = createHarness(); expect([...handlers.keys()]).toEqual(["context"]); expect(handlers.has("message_end")).toBe(false); expect(sent).toHaveLength(0); expect(pi.sendMessage).not.toHaveBeenCalled(); }); }); describe("/cp", () => { beforeEach(() => { vi.clearAllMocks(); }); const branch = [ { role: "user", content: "first question" }, { role: "assistant", content: [{ type: "text", text: "first answer" }] }, { role: "user", content: "second question" }, { role: "assistant", content: [{ type: "text", text: "second answer" }] }, ]; it("copies the last assistant reply with no argument, and the last prompt with `user`", async () => { const { commands } = createHarness(); const cmd = commands.get("cp")!; const ctx = branchCtx(branch); await cmd.handler("", ctx); expect(copyToClipboard).toHaveBeenLastCalledWith("second answer"); expect(ctx.ui.notify).toHaveBeenLastCalledWith(`Copied ${hashForText("assistant", "second answer")} (13 chars)`, "info"); await cmd.handler("user", ctx); expect(copyToClipboard).toHaveBeenLastCalledWith("second question"); expect(ctx.ui.setStatus).not.toHaveBeenCalled(); const empty = branchCtx([]); await cmd.handler("", empty); expect(empty.ui.notify).toHaveBeenCalledWith("No assistant message to copy", "warning"); await cmd.handler("user", empty); expect(empty.ui.notify).toHaveBeenCalledWith("No user message to copy", "warning"); expect(copyToClipboard).toHaveBeenCalledTimes(2); }); it("lists recent messages newest first with their hashes", async () => { const { commands } = createHarness(); const cmd = commands.get("cp")!; const ctx = branchCtx([...branch, { role: "assistant", content: "x".repeat(200) }]); await cmd.handler("list", ctx); const text = ctx.ui.notify.mock.calls[0][0] as string; const rows = text.split("\n").slice(1); expect(rows).toHaveLength(5); expect(rows[0]).toBe(`${hashForText("assistant", "x".repeat(200))} assistant ${"x".repeat(60)}…`); expect(rows[4]).toBe(`${hashForText("user", "first question")} user first question`); expect(copyToClipboard).not.toHaveBeenCalled(); const none = branchCtx([]); await cmd.handler("list", none); expect(none.ui.notify).toHaveBeenCalledWith("Nothing to copy yet", "info"); }); it("copies by hash prefix and reports unknown, ambiguous and unusable hashes", async () => { const { commands } = createHarness(); const cmd = commands.get("cp")!; const ctx = branchCtx(branch); await cmd.handler(hashForText("user", "first question").slice(0, 4), ctx); expect(copyToClipboard).toHaveBeenLastCalledWith("first question"); await cmd.handler("zzzzzz", ctx); expect(ctx.ui.notify).toHaveBeenLastCalledWith("No message for hash zzzzzz", "warning"); const a = { role: "user", content: "c" }; const b = { role: "user", content: "e" }; const [ha, hb] = [hashForText("user", "c"), hashForText("user", "e")]; const shared = ha[0] === hb[0] ? ha[0]! : undefined; if (shared) { const two = branchCtx([a, b]); await cmd.handler(shared, two); expect(two.ui.notify).toHaveBeenCalledWith(expect.stringContaining("ambiguous"), "warning"); } // Whitespace-only messages are not copyable, so they have no hash to match. const blank = branchCtx([{ role: "user", content: " " }]); await cmd.handler(hashForText("user", ""), blank); expect(blank.ui.notify).toHaveBeenCalledWith(expect.stringContaining("No message for hash"), "warning"); }); it("matches the hash of a persisted message despite metadata changes", async () => { const { commands } = createHarness(); const cmd = commands.get("cp")!; const persisted = { role: "assistant", content: [{ type: "text", text: "Fix the copy bug now." }], details: { latencyMs: 42 }, stopReason: "error", timestamp: 1_700_000_000_000, usage: { inputTokens: 1, outputTokens: 2 }, }; await cmd.handler(hashForText("assistant", "Fix the copy bug now."), branchCtx([persisted])); expect(copyToClipboard).toHaveBeenCalledWith("Fix the copy bug now."); }); it("strips ANSI codes, copies bash results with the command, and tolerates odd branches", async () => { const { commands } = createHarness(); const cmd = commands.get("cp")!; await cmd.handler(hashForText("user", "\x1b[31mred\x1b[0m text"), branchCtx([{ role: "user", content: "\x1b[31mred\x1b[0m text" }])); expect(copyToClipboard).toHaveBeenLastCalledWith("red text"); await cmd.handler(hashForText("bashExecution", "$ ls\na\nb"), branchCtx([{ role: "bashExecution", command: "ls", output: "a\nb" }])); expect(copyToClipboard).toHaveBeenLastCalledWith("$ ls\na\nb"); await cmd.handler(hashForText("bashExecution", "$ ls"), branchCtx([{ role: "bashExecution", command: "ls" }])); expect(copyToClipboard).toHaveBeenLastCalledWith("$ ls"); await cmd.handler(hashForText("bashExecution", "bare output"), branchCtx([{ role: "bashExecution", output: "bare output" }])); expect(copyToClipboard).toHaveBeenLastCalledWith("bare output"); await cmd.handler(hashForText("message", "roleless content"), branchCtx([{ content: "roleless content" }])); expect(copyToClipboard).toHaveBeenLastCalledWith("roleless content"); const noManager = { ui: { notify: vi.fn() } }; await cmd.handler("abc123", noManager); expect(noManager.ui.notify).toHaveBeenCalledWith("No message for hash abc123", "warning"); }); }); describe("sessions saved by older versions", () => { it("hides saved copy rows and markers from the model, and does not copy them", async () => { const { handlers, commands } = createHarness(); const result = await handlers.get("context")!({ type: "context", messages: [ { role: "user", content: "hello" }, { role: "custom", customType: "copy-turn", content: "⧉ copy user: /cp abc123" }, { role: "assistant", content: [{ type: "text", text: "hi\n\n⧉ copy assistant: /cp def456" }] }, { role: "assistant", content: "plain\n\n⧉ copy assistant: /cp def456" }, ], }); expect(result.messages).toHaveLength(3); expect(result.messages[1].content[0].text).toBe("hi"); expect(result.messages[2].content).toBe("plain"); // A saved marker never changes the hash, and a saved copy row is never offered. const ctx = branchCtx([ { role: "assistant", content: [{ type: "text", text: "Same content\n\n⧉ copy assistant: /cp abc123" }] }, { role: "custom", customType: "copy-turn", content: "⧉ copy user: /cp abc123" }, ]); await commands.get("cp")!.handler(hashForText("assistant", "Same content"), ctx); expect(copyToClipboard).toHaveBeenLastCalledWith("Same content"); await commands.get("cp")!.handler("list", ctx); expect((ctx.ui.notify.mock.calls.at(-1)![0] as string).split("\n")).toHaveLength(2); }); it("still renders saved copy rows", () => { const { pi } = createHarness(); const renderer = (pi.registerMessageRenderer as any).mock.calls[0][1]; const theme = { fg: (_name: string, value: string) => value }; expect(renderer({ details: { hash: "abc123", role: "user" } }, {}, theme).render(80).join("\n")).toContain("⧉ copy user: /cp abc123"); expect(renderer({ content: "hash fedcba in the middle" }, {}, theme).render(80).join("\n")).toContain("⧉ copy message: /cp fedcba"); for (const content of ["no hash here", 42, [{ type: "text" }]]) { expect(renderer({ content }, {}, theme).render(80).join("\n")).toContain("⧉ copy message: /cp ??????"); } }); });