1import {
2 MessageId,
3 ContextHandoffId,
4 ProviderThreadId,
5 NodeId,
6 EventId,
7 ProjectId,
8 ProviderDriverKind,
9 ProviderInstanceId,
10 ThreadId,
11 TurnItemId,
12 type OrchestrationV2ThreadProjection,
13 OrchestrationV2TurnItem,
14 OrchestrationV2TurnItemJson,
15} from "@t3tools/contracts";
16import { describe, expect, it } from "@effect/vitest";
17import * as DateTime from "effect/DateTime";
18import * as Schema from "effect/Schema";
19
20import {
21 projectTurnItemForWire,
22 projectTurnItemForDetail,
23 projectDomainEventForWire,
24} from "./WireProjection.ts";
25import { threadShellFromProjection } from "./ProjectionStore.ts";
26import { MAX_TOOL_OUTPUT_IMAGES, toolOutputImages } from "@t3tools/shared/toolOutput";
27
28const decodeTurnItem = Schema.decodeUnknownSync(OrchestrationV2TurnItem);
29const encodeTurnItemJson = Schema.encodeSync(OrchestrationV2TurnItemJson);
30const decodeTurnItemJson = Schema.decodeUnknownSync(OrchestrationV2TurnItemJson);
31
32const base = {
33 id: TurnItemId.make("tool-1"),
34 type: "dynamic_tool" as const,
35 threadId: ThreadId.make("thread-1"),
36 runId: null,
37 nodeId: null,
38 providerThreadId: null,
39 providerTurnId: null,
40 nativeItemRef: null,
41 parentItemId: null,
42 ordinal: 1,
43 status: "completed" as const,
44 title: "MCP tool",
45 toolName: "mcp__github__fetch_pr",
46 input: { pr: 42 },
47 startedAt: DateTime.makeUnsafe("2026-08-13T00:00:00.000Z"),
48 completedAt: DateTime.makeUnsafe("2026-08-13T00:00:01.000Z"),
49 updatedAt: DateTime.makeUnsafe("2026-08-13T00:00:01.000Z"),
50};
51
52describe("orchestration V2 wire projection", () => {
53 it("keeps copied handoff transcripts out of activity items and live events", () => {
54 const item = {
55 ...base,
56 type: "handoff" as const,
57 contextHandoffId: ContextHandoffId.make("handoff:wire"),
58 fromProviderThreadIds: [],
59 toProviderThreadId: ProviderThreadId.make("target"),
60 fromProviderInstanceIds: [],
61 toProviderInstanceId: ProviderInstanceId.make("codex"),
62 strategy: "full_thread_summary" as const,
63 summary: "PRIVATE_HANDOFF_TRANSCRIPT",
64 };
65 const projected = projectTurnItemForWire(item);
66 expect(projected).not.toHaveProperty("summary");
67 expect(decodeTurnItem(projected)).toMatchObject({ contextHandoffId: item.contextHandoffId });
68 const event = {
69 id: EventId.make("handoff:wire"),
70 type: "turn-item.updated" as const,
71 threadId: base.threadId,
72 occurredAt: base.updatedAt,
73 payload: item,
74 };
75 expect(JSON.stringify(projectDomainEventForWire(event))).not.toContain(item.summary);
76 expect(item.summary).toBe("PRIVATE_HANDOFF_TRANSCRIPT");
77 });
78
79 it("preserves image metadata through wire and JSON contracts while redacting output", () => {
80 const item = {
81 ...base,
82 toolName: "Read",
83 input: { file_path: "/workspace/reference.png" },
84 viewedImagePath: "/workspace/reference.png",
85 output: { data: "private-image-data" },
86 } satisfies OrchestrationV2TurnItem;
87 const projected = projectTurnItemForWire(item);
88 const live = decodeTurnItem(projected);
89 const encoded = encodeTurnItemJson(live);
90 const json = decodeTurnItemJson(encoded);
91 const decoded = decodeTurnItem(json);
92 expect(decoded).toMatchObject({ viewedImagePath: "/workspace/reference.png" });
93 expect(decoded).not.toHaveProperty("output");
94 expect(item.output.data).toBe("private-image-data");
95 });
96
97 it("preserves provider notices in bounded items and live events", () => {
98 const item = {
99 ...base,
100 type: "system_notice" as const,
101 message: "Safeguards flagged this message. Switched to Opus 4.8.",
102 };
103 expect(projectTurnItemForWire(item)).toEqual(item);
104 const event = {
105 id: EventId.make("notice-wire-event"),
106 type: "turn-item.updated" as const,
107 threadId: item.threadId,
108 occurredAt: item.updatedAt,
109 payload: item,
110 };
111 expect(projectDomainEventForWire(event)).toEqual(event);
112 });
113
114 it("keeps transcript bodies out of shell rows", () => {
115 const now = DateTime.makeUnsafe("2026-08-13T00:00:00.000Z");
116 const threadId = ThreadId.make("thread-shell-budget");
117 const projection = {
118 thread: {
119 createdBy: "user",
120 creationSource: "web",
121 id: threadId,
122 projectId: ProjectId.make("project-shell-budget"),
123 title: "Shell payload budget",
124 providerInstanceId: ProviderInstanceId.make("codex"),
125 modelSelection: null,
126 runtimeMode: "full-access",
127 interactionMode: "default",
128 branch: null,
129 worktreePath: null,
130 lineage: {
131 parentThreadId: null,
132 relationshipToParent: null,
133 rootThreadId: threadId,
134 },
135 forkedFrom: null,
136 activeProviderThreadId: null,
137 createdAt: now,
138 updatedAt: now,
139 archivedAt: null,
140 settledOverride: null,
141 settledAt: null,
142 deletedAt: null,
143 },
144 runs: [],
145 attempts: [],
146 runtimeRequests: [],
147 messages: [
148 {
149 id: MessageId.make("message-shell-budget"),
150 role: "assistant",
151 text: "x".repeat(1_000_000),
152 updatedAt: now,
153 },
154 ],
155 providerSessions: [],
156 providerThreads: [],
157 turnItems: [],
158 visibleTurnItems: [],
159 plans: [],
160 updatedAt: now,
161 } as unknown as OrchestrationV2ThreadProjection;
162
163 const shell = threadShellFromProjection(projection);
164
165 expect(shell.latestVisibleMessage).toBeNull();
166 expect(JSON.stringify(shell).length).toBeLessThan(2_000);
167 });
168
169 it("omits oversized dynamic tool results without mutating persistence data", () => {
170 const output = { content: [{ type: "text", text: "first line\n" + "x".repeat(100_000) }] };
171 const item = { ...base, output } satisfies OrchestrationV2TurnItem;
172 const projected = projectTurnItemForWire(item);
173
174 expect(item.output).toBe(output);
175 expect(projected).not.toBe(item);
176 expect(JSON.stringify(projected).length).toBeLessThan(2_000);
177 expect(projected).not.toHaveProperty("output");
178 });
179
180 it("omits even small dynamic tool results while retaining input", () => {
181 const item = { ...base, output: { ok: true } } satisfies OrchestrationV2TurnItem;
182 expect(projectTurnItemForWire(item)).toEqual({ ...base, outputOmitted: true });
183 expect(item.output).toEqual({ ok: true });
184 });
185
186 it("keeps undefined dynamic input intact", () => {
187 const item = { ...base, input: undefined } satisfies OrchestrationV2TurnItem;
188 expect(projectTurnItemForWire(item)).toEqual(item);
189 });
190
191 it.each([
192 [" \t\r\n\n first\t line \r\n" + "x\n".repeat(10_000), "first line"],
193 [" \n".repeat(10_000), "Large tool output"],
194 ["a".repeat(160) + "\n" + "x".repeat(20_000), "a".repeat(160)],
195 ["a".repeat(161) + "\n" + "x".repeat(20_000), "a".repeat(159) + "…"],
196 ["a".repeat(159) + "\t b" + "x".repeat(20_000), "a".repeat(159) + "…"],
197 [" café\u00a0\u2003😀 \t\r\n" + "x".repeat(20_000), "café 😀"],
198 ])("preserves bounded input summary normalization for case %#", (input, summary) => {
199 const projected = projectTurnItemForWire({ ...base, input });
200 expect(projected.type === "dynamic_tool" ? projected.input : null).toEqual({
201 summary,
202 truncated: true,
203 });
204 });
205
206 it("uses encoded JSON bytes for strings near the dynamic-value limit", () => {
207 const small = '"'.repeat(8_191);
208 const large = '"'.repeat(8_192);
209 const smallItem = { ...base, input: small };
210 expect(projectTurnItemForWire(smallItem)).toEqual(smallItem);
211 const projected = projectTurnItemForWire({ ...base, input: large });
212 expect(projected.type === "dynamic_tool" ? projected.input : null).toEqual({
213 summary: '"'.repeat(159) + "…",
214 truncated: true,
215 });
216 });
217
218 it("serializes an oversized structured input only once for its summary", () => {
219 let serializations = 0;
220 const input = {
221 toJSON() {
222 serializations += 1;
223 return { text: "x".repeat(100_000) };
224 },
225 };
226 const projected = projectTurnItemForWire({ ...base, input });
227 expect(serializations).toBe(1);
228 expect(projected.type === "dynamic_tool" ? projected.input : null).toMatchObject({
229 truncated: true,
230 });
231 });
232
233 it("truncates detail at a UTF-8 boundary without changing the source", () => {
234 const progress = "a".repeat(32_767) + "😀" + "x".repeat(100_000);
235 const item = {
236 ...base,
237 type: "subagent" as const,
238 subagentId: NodeId.make("child-agent"),
239 origin: "app_owned" as const,
240 driver: ProviderDriverKind.make("codex"),
241 providerInstanceId: ProviderInstanceId.make("codex"),
242 childThreadId: null,
243 prompt: "Inspect code",
244 result: null,
245 progress,
246 };
247 const projected = projectTurnItemForWire(item);
248 expect(projected.type === "subagent" ? projected.progress : null).toBe(
249 "a".repeat(32_767) + "\n… output truncated for transport",
250 );
251 expect(item.progress).toBe(progress);
252 });
253
254 it.each(["", "small output", "x".repeat(1_048_576)])(
255 "omits command output of every size, case %#",
256 (output) => {
257 const item = { ...base, type: "command_execution" as const, input: "test", output };
258 const projected = projectTurnItemForWire(item);
259 expect(projected).not.toHaveProperty("output");
260 expect(projected).toMatchObject({ input: "test", status: "completed" });
261 // Clients fetch withheld output on demand, so they need to know it exists.
262 expect(projected.type === "command_execution" ? projected.outputOmitted : null).toBe(
263 output ? true : undefined,
264 );
265 expect(item.output).toBe(output);
266 },
267 );
268
269 it.each([
270 ["echo ok", "echo ok"],
271 ["a".repeat(262_143) + "😀", "a".repeat(262_143) + "\n… output truncated for transport"],
272 ])("bounds fetched command input without changing persistence, case %#", (input, expected) => {
273 const item = { ...base, type: "command_execution" as const, input, output: "ok" };
274 const projected = projectTurnItemForDetail(item);
275 expect(projected).toMatchObject({ input: expected, output: "ok" });
276 expect(item.input).toBe(input);
277 });
278
279 it("leaves a screenshot's bytes out of the fetched tool output", () => {
280 const data = "A".repeat(600_000);
281 const item = {
282 ...base,
283 type: "dynamic_tool" as const,
284 toolName: "mcp__t3-code__device_screenshot",
285 input: { deviceId: "phone" },
286 output: {
287 content: [{ type: "image", source: { type: "base64", media_type: "image/png", data } }],
288 },
289 };
290 expect(projectTurnItemForDetail(item)).toMatchObject({
291 output: { content: [{ type: "image", mimeType: "image/png" }] },
292 });
293 expect(item.output.content[0]?.source.data).toBe(data);
294 });
295
296 it("keeps image markers when the rest of a tool output is too large to send", () => {
297 const image = {
298 type: "image",
299 source: { type: "base64", media_type: "image/png", data: "AAAA" },
300 };
301 const item = {
302 ...base,
303 type: "dynamic_tool" as const,
304 output: { content: [{ type: "text", text: "x".repeat(300_000) }, image] },
305 };
306 const projected = projectTurnItemForDetail(item);
307 const output = projected.type === "dynamic_tool" ? projected.output : null;
308 expect(Array.isArray(output) ? output.slice(1) : null).toEqual([
309 { type: "image", mimeType: "image/png" },
310 ]);
311 expect(JSON.stringify(output).length).toBeLessThan(270_000);
312 });
313
314 it("bounds a tool output made of many images", () => {
315 const image = { type: "image", data: "AAAA", mimeType: "image/png" };
316 const item = {
317 ...base,
318 type: "dynamic_tool" as const,
319 output: Array.from({ length: 10_000 }, () => image),
320 };
321 const projected = projectTurnItemForDetail(item);
322 const output = projected.type === "dynamic_tool" ? projected.output : null;
323 // The truncated text block plus a fixed number of markers, however many images there are.
324 expect(Array.isArray(output) ? output.length : null).toBe(1 + MAX_TOOL_OUTPUT_IMAGES);
325 expect(Array.isArray(output) ? String(output[0]?.text).length : null).toBeLessThan(263_000);
326 expect(toolOutputImages(output)).toHaveLength(MAX_TOOL_OUTPUT_IMAGES);
327 });
328
329 it("keeps failure evidence without retaining command output", () => {
330 const item = {
331 ...base,
332 type: "command_execution" as const,
333 input: "cat missing-file",
334 output: "cat: missing-file: No such file or directory",
335 };
336 const projected = projectTurnItemForWire(item);
337 expect(projected).not.toHaveProperty("output");
338 expect(projected).toMatchObject({ outputIndicatesFailure: true });
339 expect(projectTurnItemForWire(projected)).toEqual(projected);
340 expect(projectTurnItemForWire({ ...item, output: "", exitCode: 2 })).toMatchObject({
341 outputIndicatesFailure: true,
342 exitCode: 2,
343 });
344 });
345
346 it("omits inline file bodies but preserves file identity and change counts", () => {
347 const item = {
348 ...base,
349 type: "file_change" as const,
350 fileName: "src/main.ts",
351 additions: 3,
352 deletions: 1,
353 diffStr: "+new code",
354 oldStr: "old code",
355 newStr: "new code",
356 };
357 const projected = projectTurnItemForWire(item);
358 expect(projected).not.toHaveProperty("diffStr");
359 expect(projected).not.toHaveProperty("oldStr");
360 expect(projected).not.toHaveProperty("newStr");
361 expect(projected).toMatchObject({ fileName: "src/main.ts", additions: 3, deletions: 1 });
362 expect(item.diffStr).toBe("+new code");
363 // A failed edit keeps the provider's error so expanding the row can show it.
364 const failed = projectTurnItemForWire({
365 ...item,
366 status: "failed",
367 diffStr: "String to replace not found",
368 });
369 expect(failed).toMatchObject({ diffStr: "String to replace not found" });
370 expect(failed).not.toHaveProperty("newStr");
371 });
372
373 it("retains only result identities and failure metadata in live tool events", () => {
374 const output = {
375 isError: true,
376 structuredContent: { threadId: "child-thread", messageId: "message", text: "PRIVATE_BODY" },
377 content: [{ type: "text", text: "PRIVATE_BODY" }],
378 };
379 const event = {
380 id: EventId.make("result-event"),
381 type: "turn-item.updated" as const,
382 threadId: base.threadId,
383 occurredAt: base.updatedAt,
384 payload: { ...base, output },
385 };
386 const projected = projectDomainEventForWire(event);
387 expect(projected.payload).toMatchObject({
388 output: { isError: true, threadId: "child-thread", messageId: "message" },
389 });
390 expect(JSON.stringify(projected)).not.toContain("PRIVATE_BODY");
391 expect(event.payload.output).toBe(output);
392 expect(projectDomainEventForWire(projected)).toEqual(projected);
393 });
394
395 it("does not serialize raw dynamic output to discard it", () => {
396 const output = {
397 toJSON: () => {
398 throw new Error("Raw output must not be serialized");
399 },
400 };
401 expect(projectTurnItemForWire({ ...base, output })).not.toHaveProperty("output");
402 });
403});