1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
3import type { QueuedThreadMessage } from "./thread-outbox-model";
4
5const state = vi.hoisted(() => ({
6 dispatching: null as string | null,
7 held: {} as Record<string, true>,
8 draft: { text: "Existing draft", attachments: [] as { id: string }[] },
9 confirm: vi.fn(async () => true),
10 remove: vi.fn(async () => true),
11 flush: vi.fn(async () => {}),
12}));
13vi.mock("./atom-registry", () => ({
14 appAtomRegistry: {
15 get: (atom: string) => (atom === "dispatching" ? state.dispatching : state.held),
16 },
17}));
18vi.mock("./use-thread-outbox", () => ({
19 dispatchingQueuedMessageIdAtom: "dispatching",
20 editingQueuedMessageIdsAtom: "editing",
21 holdEditingQueuedMessage: (id: string) => {
22 state.held[id] = true;
23 },
24 releaseEditingQueuedMessage: (id: string) => {
25 delete state.held[id];
26 },
27}));
28vi.mock("./thread-outbox", () => ({
29 confirmThreadOutboxMessageQueued: state.confirm,
30 threadOutboxRevision: () => 1,
31}));
32vi.mock("./thread-outbox-removal", () => ({ removeThreadOutboxMessage: state.remove }));
33vi.mock("./use-composer-drafts", () => ({
34 waitForComposerDraftsLoaded: async () => {},
35 getComposerDraftSnapshot: () => state.draft,
36 mergeComposerDraftContent: async (_key: string, message: QueuedThreadMessage) => {
37 state.draft = {
38 text: `${state.draft.text}\n\n${message.text}`,
39 attachments: [...state.draft.attachments, ...message.attachments],
40 };
41 },
42 updateComposerDraftSettings: () => {},
43 flushComposerDrafts: state.flush,
44 undoComposerDraftMerge: async (_key: string, snapshot: typeof state.draft) => {
45 state.draft = snapshot;
46 },
47}));
48import { editPendingThreadMessage } from "./edit-pending-thread-message";
49
50const message: QueuedThreadMessage = {
51 environmentId: EnvironmentId.make("env"),
52 threadId: ThreadId.make("thread"),
53 messageId: MessageId.make("message"),
54 commandId: CommandId.make("command"),
55 text: "Queued task",
56 createdAt: "2026-09-06T10:00:00.000Z",
57 attachments: [
58 {
59 id: "file",
60 type: "file",
61 name: "notes.txt",
62 mimeType: "text/plain",
63 sizeBytes: 10,
64 fileUri: "file:///notes.txt",
65 },
66 ],
67};
68beforeEach(() => {
69 vi.clearAllMocks();
70 state.dispatching = null;
71 state.held = {};
72 state.draft = { text: "Existing draft", attachments: [] };
73 state.confirm.mockResolvedValue(true);
74 state.remove.mockResolvedValue(true);
75 state.flush.mockResolvedValue(undefined);
76});
77describe("editing a pending message", () => {
78 it("locks delivery and persists text and attachments before removing the queued copy", async () => {
79 state.confirm.mockImplementationOnce(async () => {
80 expect(state.held[message.messageId]).toBe(true);
81 return true;
82 });
83 state.remove.mockImplementationOnce(async () => {
84 expect(state.flush).toHaveBeenCalled();
85 expect(state.draft.text).toBe("Existing draft\n\nQueued task");
86 expect(state.draft.attachments).toEqual(message.attachments);
87 return true;
88 });
89 expect(await editPendingThreadMessage(message)).toBe(true);
90 expect(state.held).toEqual({});
91 });
92 it("does not reclaim a message already being dispatched", async () => {
93 state.dispatching = message.messageId;
94 expect(await editPendingThreadMessage(message)).toBe(false);
95 expect(state.confirm).not.toHaveBeenCalled();
96 expect(state.draft.text).toBe("Existing draft");
97 });
98 it("rolls back the draft if removing the queued message fails", async () => {
99 state.remove.mockRejectedValueOnce(new Error("disk error"));
100 await expect(editPendingThreadMessage(message)).rejects.toThrow("disk error");
101 expect(state.draft).toEqual({ text: "Existing draft", attachments: [] });
102 expect(state.held).toEqual({});
103 });
104 it("rolls back when a newer queue revision wins", async () => {
105 state.remove.mockResolvedValueOnce(false);
106 expect(await editPendingThreadMessage(message)).toBe(false);
107 expect(state.draft.text).toBe("Existing draft");
108 });
109});