1import {
2 CommandId,
3 ComposerContextId,
4 EnvironmentId,
5 MessageId,
6 ProjectId,
7 ProviderInstanceId,
8 ThreadId,
9} from "@t3tools/contracts";
10import { describe, expect, it } from "vite-plus/test";
11
12import {
13 isPendingThreadCreationVisible,
14 pendingThreadCreationMessage,
15 pendingThreadCreationShell,
16 resolvePendingThreadCreation,
17 type PendingThreadCreation,
18} from "./pending-thread-creation";
19import type { QueuedThreadMessage } from "./thread-outbox-model";
20
21const creation: QueuedThreadMessage = {
22 environmentId: EnvironmentId.make("environment-1"),
23 threadId: ThreadId.make("thread-1"),
24 messageId: MessageId.make("message-1"),
25 commandId: CommandId.make("command-1"),
26 text: "Fix the flaky login test",
27 attachments: [
28 {
29 id: "draft-image",
30 type: "image",
31 name: "screen.png",
32 mimeType: "image/png",
33 sizeBytes: 10,
34 previewUri: "data:image/png;base64,AAAA",
35 },
36 ],
37 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" },
38 runtimeMode: "full-access",
39 creation: {
40 projectId: ProjectId.make("project-1"),
41 workspaceMode: "worktree",
42 branch: "main",
43 worktreePath: null,
44 },
45 createdAt: "2026-08-24T12:00:00.000Z",
46};
47
48describe("resolvePendingThreadCreation", () => {
49 const threadKey = `${creation.environmentId}:${creation.threadId}`;
50 const pending: PendingThreadCreation = { message: creation, outcome: null };
51 const prompt = { id: creation.messageId };
52
53 it("keeps setup visible through the prompt echo and shell cleanup until detail has a turn", () => {
54 let previous = resolvePendingThreadCreation({
55 threadKey,
56 pending,
57 previous: null,
58 detail: null,
59 });
60 expect(previous).toBe(pending);
61
62 previous = resolvePendingThreadCreation({
63 threadKey,
64 pending,
65 previous,
66 detail: { messages: [], latestTurn: null, session: null },
67 });
68 expect(previous).toBe(pending);
69
70 // The user message arrives before the provider publishes a timed turn.
71 previous = resolvePendingThreadCreation({
72 threadKey,
73 pending,
74 previous,
75 detail: { messages: [prompt], latestTurn: null, session: { status: "starting" } },
76 });
77 expect(previous).toBe(pending);
78
79 // The shell stream may observe the turn and collect the global outcome
80 // before this screen's detail stream catches up.
81 previous = resolvePendingThreadCreation({
82 threadKey,
83 pending: null,
84 previous,
85 detail: { messages: [prompt], latestTurn: null, session: { status: "starting" } },
86 });
87 expect(previous).toBe(pending);
88
89 expect(
90 resolvePendingThreadCreation({
91 threadKey,
92 pending: null,
93 previous,
94 detail: {
95 messages: [prompt],
96 latestTurn: { turnId: "turn-1" },
97 session: { status: "running" },
98 },
99 }),
100 ).toBeNull();
101 });
102
103 it("keeps the prompt until both the turn and its message have arrived", () => {
104 expect(
105 resolvePendingThreadCreation({
106 threadKey,
107 pending,
108 previous: null,
109 detail: { messages: [], latestTurn: { turnId: "turn-1" }, session: { status: "running" } },
110 }),
111 ).toBe(pending);
112 });
113
114 it.each(["error", "stopped", "interrupted"])("ends setup when startup is %s", (status) => {
115 expect(
116 resolvePendingThreadCreation({
117 threadKey,
118 pending: null,
119 previous: pending,
120 detail: { messages: [prompt], latestTurn: null, session: { status } },
121 }),
122 ).toBeNull();
123 });
124
125 it("preserves rejected task recovery", () => {
126 const failed: PendingThreadCreation = {
127 message: creation,
128 outcome: { kind: "failed", message: creation, reason: "Checkout failed" },
129 };
130 expect(
131 resolvePendingThreadCreation({
132 threadKey,
133 pending: failed,
134 previous: pending,
135 detail: { messages: [], latestTurn: null, session: { status: "error" } },
136 }),
137 ).toBe(failed);
138 });
139
140 it("does not carry setup into another thread or invent it for existing threads", () => {
141 expect(
142 resolvePendingThreadCreation({
143 threadKey: "another-thread",
144 pending: null,
145 previous: pending,
146 detail: null,
147 }),
148 ).toBeNull();
149 expect(
150 resolvePendingThreadCreation({
151 threadKey,
152 pending: null,
153 previous: null,
154 detail: null,
155 }),
156 ).toBeNull();
157 });
158});
159
160describe("pendingThreadCreationShell", () => {
161 it("shapes a queued creation as the thread shell the screen renders before creation", () => {
162 expect(pendingThreadCreationShell(creation)).toMatchObject({
163 environmentId: creation.environmentId,
164 id: creation.threadId,
165 projectId: creation.creation!.projectId,
166 title: "Fix the flaky login test",
167 modelSelection: creation.modelSelection,
168 runtimeMode: "full-access",
169 interactionMode: "default",
170 branch: "main",
171 worktreePath: null,
172 latestRun: null,
173 runtime: null,
174 latestUserMessageAt: creation.createdAt,
175 });
176 });
177
178 it("keeps a local task's explicit worktree path", () => {
179 expect(
180 pendingThreadCreationShell({
181 ...creation,
182 creation: {
183 ...creation.creation!,
184 workspaceMode: "local",
185 worktreePath: "/repo/.worktrees/feature",
186 },
187 })?.worktreePath,
188 ).toBe("/repo/.worktrees/feature");
189 });
190
191 it("returns null for a follow-up message or a creation without a model", () => {
192 expect(pendingThreadCreationShell({ ...creation, creation: undefined })).toBeNull();
193 expect(pendingThreadCreationShell({ ...creation, modelSelection: undefined })).toBeNull();
194 });
195});
196
197describe("isPendingThreadCreationVisible", () => {
198 const creationMessageId = String(creation.messageId);
199
200 it("stands in before any detail has loaded", () => {
201 expect(isPendingThreadCreationVisible({ creationMessageId, loadedMessageIds: null })).toBe(
202 true,
203 );
204 });
205
206 // The regression: the server creates the thread, THEN builds the worktree,
207 // then starts the turn. The shell and an empty detail arrive seconds before
208 // the prompt, and keying on the shell left the thread empty for that whole
209 // window.
210 it("keeps standing in while the created thread has no messages yet", () => {
211 expect(isPendingThreadCreationVisible({ creationMessageId, loadedMessageIds: [] })).toBe(true);
212 });
213
214 it("keeps standing in when the thread holds only unrelated messages", () => {
215 expect(
216 isPendingThreadCreationVisible({ creationMessageId, loadedMessageIds: ["someone-else"] }),
217 ).toBe(true);
218 });
219
220 it("stands down once the delivered prompt lands under the same id", () => {
221 expect(
222 isPendingThreadCreationVisible({
223 creationMessageId,
224 loadedMessageIds: ["someone-else", creationMessageId],
225 }),
226 ).toBe(false);
227 });
228});
229
230describe("pendingThreadCreationMessage", () => {
231 it("keeps inline context available while the thread is being created", () => {
232 const record = {
233 version: 1 as const,
234 kind: "mention" as const,
235 contextId: ComposerContextId.make("setup-file"),
236 label: "Checkout.tsx",
237 path: "src/Checkout.tsx",
238 };
239 const context = { version: 1 as const, records: [record] };
240 const text = "[Checkout.tsx](t3-context://v1/mention/setup-file)";
241 const message = pendingThreadCreationMessage({ ...creation, text, context });
242 expect(message.text).toBe(text);
243 expect(message.context).toEqual(context);
244 });
245
246 it("renders the queued prompt as the first user message", () => {
247 expect(pendingThreadCreationMessage(creation)).toEqual({
248 id: creation.messageId,
249 role: "user",
250 text: creation.text,
251 streaming: false,
252 createdAt: creation.createdAt,
253 updatedAt: creation.createdAt,
254 });
255 });
256
257 // Draft attachment ids are local; the feed resolves attachment rows against
258 // the server and would spin forever on them.
259 it("omits the queued attachments rather than passing local draft ids to the feed", () => {
260 expect(pendingThreadCreationMessage(creation)).not.toHaveProperty("attachments");
261 });
262});