1import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLegacy";
2import { buildProjectThreadStartTurnInput } from "./projectThreadStartTurn";
3import {
4 ProjectId,
5 ProviderInstanceId,
6 ComposerContextId,
7 type OrchestrationMessageContext,
8} from "@t3tools/contracts";
9import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
10import {
11 ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
12 collectComposerContextReferences,
13 formatComposerContextReference,
14 projectComposerContextForProvider,
15} from "@t3tools/shared/composerContextReferences";
16import { describe, expect, it } from "vite-plus/test";
17import {
18 composerContextEditorTokens,
19 composerContextSendBlockReason,
20 composerMentionPath,
21 createComposerContextHistory,
22 referencedComposerContext,
23 reidentifyComposerContext,
24 uploadedComposerContext,
25 serializeComposerMessageForServer,
26 pullRequestComposerContext,
27} from "./composerContext";
28
29const terminal = {
30 version: 1 as const,
31 kind: "terminal" as const,
32 contextId: ComposerContextId.make("terminal-1"),
33 label: "Build output",
34 terminalId: "main",
35 terminalLabel: "Terminal",
36 lineStart: 4,
37 lineEnd: 5,
38 text: "build failed\nretry",
39};
40const image = {
41 version: 1 as const,
42 kind: "image" as const,
43 contextId: ComposerContextId.make("image-1"),
44 label: "Screenshot",
45 attachmentId: "local-image",
46 name: "shot.png",
47 mimeType: "image/png",
48 sizeBytes: 123,
49};
50const annotation = {
51 version: 1 as const,
52 kind: "preview-annotation" as const,
53 contextId: ComposerContextId.make("preview-1"),
54 label: "Button",
55 annotationId: "button-1",
56 pageUrl: "https://example.com",
57 pageTitle: null,
58 comment: "Keep the cart",
59 targetSummary: "Button",
60 styleChanges: [],
61 screenshotContextId: image.contextId,
62};
63
64describe("mobile composer context", () => {
65 it("rejects a malformed record instead of allowing the wire decoder to drop its payload", () => {
66 expect(
67 composerContextSendBlockReason({
68 version: 1,
69 records: [{ ...terminal, label: "x".repeat(201) }],
70 }),
71 ).not.toBeNull();
72 });
73 it("does not evict live recovery payloads from the editor's bounded undo history", () => {
74 const records = Array.from({ length: 400 }, (_, index) => ({
75 ...terminal,
76 contextId: ComposerContextId.make(`terminal-${index}`),
77 }));
78 const text = records.map(formatComposerContextReference).join(" ");
79 const restore = createComposerContextHistory();
80 expect(restore(text, { version: 1, records })?.records).toEqual(records);
81 expect(
82 restore(records.slice(1).map(formatComposerContextReference).join(" "), {
83 version: 1,
84 records,
85 })?.records,
86 ).toEqual(records.slice(1));
87 });
88
89 it("blocks a context payload that exceeds the aggregate wire budget", () => {
90 const record = {
91 ...annotation,
92 styleChangeDetails: Array.from({ length: 200 }, () => ({
93 targetId: "element",
94 selector: null,
95 property: "content",
96 previousValue: "x".repeat(8_000),
97 value: "y".repeat(8_000),
98 })),
99 };
100 expect(composerContextSendBlockReason({ version: 1, records: [record] })).toBeNull();
101 expect(
102 composerContextSendBlockReason({
103 version: 1,
104 records: Array.from({ length: 6 }, (_, index) => ({
105 ...record,
106 contextId: ComposerContextId.make(`preview-${index}`),
107 })),
108 }),
109 ).toContain("too much context");
110 });
111
112 it("blocks over-limit recovery drafts until enough context has been removed", () => {
113 const records = Array.from({ length: 201 }, (_, index) => ({
114 ...terminal,
115 contextId: ComposerContextId.make(`terminal-${index}`),
116 }));
117 expect(composerContextSendBlockReason({ version: 1, records })).toContain("at most 200");
118 expect(
119 composerContextSendBlockReason({ version: 1, records: records.slice(0, 200) }),
120 ).toBeNull();
121 expect(composerContextSendBlockReason()).toBeNull();
122 });
123
124 it("opens the full file path from bare, quoted, and canonical mentions", () => {
125 expect(composerMentionPath("@src/Checkout.tsx")).toBe("src/Checkout.tsx");
126 expect(composerMentionPath('@"src/My Checkout.tsx"')).toBe("src/My Checkout.tsx");
127 expect(composerMentionPath("[Checkout.tsx](src/Checkout.tsx)")).toBe("src/Checkout.tsx");
128 const mention = {
129 version: 1 as const,
130 kind: "mention" as const,
131 contextId: ComposerContextId.make("mention-1"),
132 label: "Checkout.tsx",
133 path: "src/Checkout.tsx",
134 };
135 expect(
136 composerMentionPath(formatComposerContextReference(mention), {
137 version: 1,
138 records: [mention],
139 }),
140 ).toBe("src/Checkout.tsx");
141 expect(composerMentionPath(formatComposerContextReference(mention))).toBeNull();
142 expect(composerMentionPath("$playwright")).toBeNull();
143 expect(
144 composerMentionPath(formatComposerContextReference(terminal), {
145 version: 1,
146 records: [terminal],
147 }),
148 ).toBeNull();
149 });
150
151 it("restores deleted payloads on undo without adding removed context to the current draft", () => {
152 const restore = createComposerContextHistory();
153 const source = formatComposerContextReference(annotation);
154 const initial = { version: 1 as const, records: [annotation, image] };
155 expect(restore("deleted", initial)).toBeUndefined();
156 expect(restore(source)?.records).toEqual(initial.records);
157 expect(restore("deleted")?.records ?? []).toEqual([]);
158 expect(createComposerContextHistory()(source)?.records).toEqual([]);
159 });
160 it("keeps exact source positions and repeated references alongside existing native tokens", () => {
161 const reference = formatComposerContextReference(terminal);
162 const text = `Use $playwright and [app.ts](src/app.ts) with ${reference} then ${reference}`;
163 const tokens = composerContextEditorTokens(text, collectComposerInlineTokens(text));
164 expect(tokens.map((token) => token.type)).toEqual(["skill", "mention", "context", "context"]);
165 for (const token of tokens) expect(text.slice(token.start, token.end)).toBe(token.source);
166 });
167
168 it("removes deleted payloads but keeps the screenshot linked to a remaining annotation", () => {
169 const context: OrchestrationMessageContext = {
170 version: 1,
171 records: [terminal, annotation, image],
172 };
173 expect(
174 referencedComposerContext(formatComposerContextReference(annotation), context)?.records,
175 ).toEqual([annotation, image]);
176 expect(referencedComposerContext("plain text", context)).toBeUndefined();
177 });
178
179 it("reidentifies pasted records and their screenshot binding without changing repeated-reference identity", () => {
180 let next = 0;
181 const text = `${formatComposerContextReference(annotation)} ${formatComposerContextReference(annotation)}`;
182 const imported = reidentifyComposerContext(text, [annotation, image], () => `copy-${++next}`);
183 expect(collectComposerContextReferences(imported.text).map((ref) => ref.contextId)).toEqual([
184 "copy-1",
185 "copy-1",
186 ]);
187 expect(imported.context.records[0]).toMatchObject({
188 contextId: "copy-1",
189 screenshotContextId: "copy-2",
190 });
191 expect(annotation.contextId).toBe("preview-1");
192 });
193
194 it("binds uploaded files to their wire ids and preserves terminal payloads for every provider", () => {
195 const context = uploadedComposerContext(
196 { version: 1, records: [terminal, image] },
197 [{ id: "local-image" }],
198 [{ id: "uploaded-image" }],
199 );
200 expect(context?.records).toEqual([terminal, { ...image, attachmentId: "uploaded-image" }]);
201 const prompt = projectComposerContextForProvider({
202 text: formatComposerContextReference(terminal),
203 records: context!.records,
204 });
205 expect(prompt).toContain("4 | build failed\n5 | retry");
206 expect(prompt).not.toContain('unavailable="true"');
207 });
208});
209
210describe("host context compatibility", () => {
211 it("sends an image-only prompt as the attachment-only prompt and keeps the image", () => {
212 const context: OrchestrationMessageContext = { version: 1, records: [image] };
213 const reference = formatComposerContextReference(image);
214 expect(serializeComposerMessageForServer(reference, context, true)).toEqual({
215 text: ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
216 context,
217 });
218 const withProse = `${reference} what is this?`;
219 expect(serializeComposerMessageForServer(withProse, context, true)).toEqual({
220 text: withProse,
221 context,
222 });
223 });
224
225 it("carries the current PR diff file within the composer wire limits", () => {
226 const metadata = {
227 number: 42,
228 title: "Fix checkout",
229 url: "https://github.com/example/repo/pull/42",
230 headBranch: "fix-checkout",
231 baseBranch: "main",
232 state: "open" as const,
233 isDraft: false,
234 };
235 const record = pullRequestComposerContext(metadata, "pr-42", "src/checkout.ts");
236 expect(record.filePath).toBe("src/checkout.ts");
237 const prompt = projectComposerContextForProvider({
238 text: formatComposerContextReference(record),
239 records: [record],
240 });
241 expect(prompt).toContain(metadata.url);
242 expect(prompt).toContain("src/checkout.ts");
243 expect(composerContextSendBlockReason({ version: 1, records: [record] })).toBeNull();
244 const large = pullRequestComposerContext(
245 {
246 ...metadata,
247 title: "x".repeat(3000),
248 headBranch: "y".repeat(3000),
249 baseBranch: "z".repeat(3000),
250 },
251 "pr-42",
252 `src/${"[long file]".repeat(500)}.ts`,
253 );
254 expect(composerContextSendBlockReason({ version: 1, records: [large] })).toBeNull();
255 });
256
257 it.each(["existing-thread", "new-task"])("serializes %s sends for an older host", (path) => {
258 const pr = pullRequestComposerContext(
259 {
260 number: 42,
261 title: "Fix checkout",
262 url: "https://github.com/example/repo/pull/42",
263 headBranch: "fix-checkout",
264 baseBranch: "main",
265 state: "open",
266 isDraft: false,
267 },
268 "pr-42",
269 );
270 const review = {
271 ...pr,
272 contextId: ComposerContextId.make("review-1"),
273 sectionId: "review",
274 filePath: "checkout.ts",
275 text: "Handle the empty cart",
276 diff: "- old\n+ new",
277 };
278 const context: OrchestrationMessageContext = { version: 1, records: [terminal, review, pr] };
279 const text = context.records.map(formatComposerContextReference).join(" ");
280 // Missing capability on an old host is treated like false by both dispatch paths.
281 const wire = serializeComposerMessageForServer(text, context, false);
282 const message =
283 path === "existing-thread"
284 ? wire
285 : buildProjectThreadStartTurnInput({
286 ...wire,
287 projectId: ProjectId.make("project"),
288 projectCwd: "/workspace",
289 threadId: "thread",
290 commandId: "command",
291 messageId: "message",
292 createdAt: "2026-01-01T00:00:00Z",
293 uploadedAttachments: [],
294 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" },
295 runtimeMode: "full-access",
296 interactionMode: "default",
297 workspaceMode: "local",
298 branch: null,
299 worktreePath: null,
300 startFromOrigin: false,
301 worktreeBranchName: "unused",
302 }).message;
303 expect(message).not.toHaveProperty("context");
304 expect(message.text).not.toContain("t3-context://");
305 expect(
306 upgradeLegacyContextMessage(message.text).records.find(
307 (record) => record.kind === "terminal",
308 ),
309 ).toMatchObject({
310 text: terminal.text,
311 lineStart: terminal.lineStart,
312 lineEnd: terminal.lineEnd,
313 });
314 expect(message.text).toContain(review.text);
315 expect(message.text).toContain(review.diff);
316 expect(message.text).toContain(pr.pullRequest!.url);
317 expect(serializeComposerMessageForServer(text, context, true)).toEqual({ text, context });
318 expect(context.records).toEqual([terminal, review, pr]);
319 });
320});