Open History to see the last commit
1import { describe, expect, it } from "vite-plus/test";
2import {
3 CommandId,
4 ComposerContextId,
5 EnvironmentId,
6 MessageId,
7 ProjectId,
8 ThreadId,
9} from "@t3tools/contracts";
10import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
11import {
12 appendPendingThreadMessages,
13 createThreadFeedGrouping,
14 isCompactionCommandFeedEntry,
15 selectPendingAcknowledgedMessages,
16} from "./pending-thread-feed";
17
18const pending = (id: string): QueuedThreadMessage => ({
19 environmentId: EnvironmentId.make("env"),
20 threadId: ThreadId.make("thread"),
21 messageId: MessageId.make(id),
22 commandId: CommandId.make(id),
23 text: id,
24 attachments: [],
25 createdAt: "2026-09-06T10:00:00.000Z",
26});
27
28describe("pending timeline messages", () => {
29 it("keeps unchanged row identities while text streams and updates adjacent grouping", () => {
30 const group = createThreadFeedGrouping();
31 const [first, second, third] = appendPendingThreadMessages(
32 [],
33 [],
34 [pending("first"), pending("second"), pending("third")],
35 );
36 if (first?.type !== "message" || second?.type !== "message" || third?.type !== "message") {
37 throw new Error("Expected message rows");
38 }
39 const reply = {
40 ...third,
41 id: "reply",
42 message: { ...third.message, role: "assistant" as const, text: "start" },
43 };
44 const initial = group([first, second, reply], {});
45 const streamed = group(
46 [first, second, { ...reply, message: { ...reply.message, text: "streamed" } }],
47 {},
48 );
49 expect(streamed[0]).toBe(initial[0]);
50 expect(streamed[1]).toBe(initial[1]);
51 expect(streamed[2]).not.toBe(initial[2]);
52 const appended = group([first, second, third, reply], {});
53 expect(appended[0]).toBe(initial[0]);
54 expect(appended[1]).not.toBe(initial[1]);
55 expect(appended[1]?.userMessageGrouping.groupedWithNext).toBe(true);
56 expect(appended[2]?.userMessageGrouping.groupedWithPrevious).toBe(true);
57 const removed = group([first, second, reply], {});
58 expect(removed[1]?.userMessageGrouping.groupedWithNext).toBe(false);
59 const steer = {
60 ...second,
61 message: { ...second.message, inputIntent: "promoted_queued_to_steer" as const },
62 };
63 const singleSteer = group([first, steer], {});
64 const steers = group(
65 [{ ...first, message: { ...first.message, inputIntent: "steer" } }, steer],
66 {},
67 );
68 expect(steers[1]?.userMessageGrouping.continuesSteer).toBe(true);
69 expect(singleSteer[1]?.userMessageGrouping.continuesSteer).toBe(false);
70 expect(steers[1]).not.toBe(singleSteer[1]);
71 });
72
73 it("preserves setup, workflow, and compaction grouping boundaries", () => {
74 const group = createThreadFeedGrouping();
75 const feed = appendPendingThreadMessages([], [], [pending("first"), pending("second")]);
76 expect(group(feed, {})[0]?.userMessageGrouping.groupedWithNext).toBe(true);
77 for (const options of [{ setupAnchorIndex: 0 }, { workflowOpeningMessageId: "first" }]) {
78 const rows = group(feed, options);
79 expect(rows[0]?.userMessageGrouping.groupedWithNext).toBe(false);
80 expect(rows[1]?.userMessageGrouping.groupedWithPrevious).toBe(false);
81 }
82 const compact = appendPendingThreadMessages(
83 [],
84 [],
85 [{ ...pending("compact"), text: "/compact" }],
86 )[0]!;
87 expect(group([feed[0]!, compact], {})[0]?.userMessageGrouping.groupedWithNext).toBe(true);
88 expect(
89 group([feed[0]!, { ...compact, pendingMessage: undefined }], {})[0]?.userMessageGrouping
90 .groupedWithNext,
91 ).toBe(false);
92 });
93
94 it.each([false, true])(
95 "keeps a queued compact command in the pending message row (attachment: %s)",
96 (withAttachment) => {
97 const queued = {
98 ...pending("compact"),
99 text: "/compact",
100 attachments: withAttachment
101 ? [
102 {
103 id: "image-1",
104 type: "image" as const,
105 name: "photo.png",
106 mimeType: "image/png",
107 sizeBytes: 3,
108 fileUri: "file:///documents/photo.png",
109 previewUri: "file:///documents/photo.png",
110 },
111 ]
112 : [],
113 };
114 const entry = appendPendingThreadMessages([], [], [queued])[0]!;
115 expect(isCompactionCommandFeedEntry(entry)).toBe(false);
116 expect(entry.pendingMessage).toEqual(queued);
117 if (!withAttachment) {
118 expect(isCompactionCommandFeedEntry({ ...entry, pendingMessage: undefined })).toBe(true);
119 }
120 },
121 );
122
123 it("marks the synthetic creation prompt pending without duplicating it", () => {
124 const queued = {
125 ...pending("creation"),
126 creation: {
127 projectId: ProjectId.make("project"),
128 workspaceMode: "local" as const,
129 branch: "main",
130 worktreePath: null,
131 },
132 };
133 const prompt = appendPendingThreadMessages([], [], [queued])[0]!;
134 const synthetic = { ...prompt, pendingMessage: undefined };
135 const result = appendPendingThreadMessages([synthetic], [synthetic], [queued]);
136 expect(result).toHaveLength(1);
137 expect(result[0]?.pendingMessage).toEqual(queued);
138 expect(result[0]?.acknowledged).toBe(false);
139 });
140
141 it("retains context records while a message is waiting for delivery", () => {
142 const record = {
143 version: 1 as const,
144 kind: "mention" as const,
145 contextId: ComposerContextId.make("setup-file"),
146 label: "Checkout.tsx",
147 path: "src/Checkout.tsx",
148 };
149 const context = { version: 1 as const, records: [record] };
150 const text = "[Checkout.tsx](t3-context://v1/mention/setup-file)";
151 const entries = appendPendingThreadMessages([], [], [{ ...pending("context"), text, context }]);
152 const entry = entries[0];
153 expect(entry?.type).toBe("message");
154 if (entry?.type !== "message") throw new Error("Expected a pending message");
155 expect(entry.message.text).toBe(text);
156 expect(entry.message.context).toEqual(context);
157 });
158
159 it("keeps pending messages after newer agent activity in queue order", () => {
160 const activity = {
161 type: "thinking",
162 runId: null,
163 id: "thinking",
164 createdAt: "2026-09-06T11:00:00.000Z",
165 } as const;
166 const entries = appendPendingThreadMessages(
167 [activity],
168 [],
169 [pending("first"), pending("second")],
170 );
171 expect(entries.map((entry) => entry.id)).toEqual(["thinking", "first", "second"]);
172 expect(entries[1]?.pendingMessage?.text).toBe("first");
173 });
174
175 it("reuses the message id and suppresses the pending copy when delivery appears", () => {
176 const queued = pending("sent");
177 const optimistic = appendPendingThreadMessages([], [], [queued])[0]!;
178 const delivered = { ...optimistic, pendingMessage: undefined };
179 expect(appendPendingThreadMessages([delivered], [delivered], [queued])).toEqual([delivered]);
180 expect(appendPendingThreadMessages([], [delivered], [queued])).toEqual([]);
181 });
182
183 it("stops showing an acknowledged message once the thread catches up, even when the feed hides it", () => {
184 // A rolled-back or cancelled queued run keeps its message in projection.messages
185 // while its user_message item leaves the visible feed.
186 const echoed = pending("rolled-back");
187 // A bounded projection pages an applied message out; it must not come back as pending.
188 const pagedOut = { ...pending("paged-out"), sequence: 40 };
189 const waiting = { ...pending("waiting"), sequence: 50 };
190 const shown = selectPendingAcknowledgedMessages([echoed, pagedOut, waiting], {
191 threadKey: "env:thread",
192 queuedMessages: [],
193 sequence: 45,
194 projectedMessages: [{ id: echoed.messageId }],
195 });
196 expect(shown).toEqual([waiting]);
197 expect(appendPendingThreadMessages([], [], shown).map((entry) => entry.id)).toEqual([
198 "waiting",
199 ]);
200 });
201});