1import type {
2 OrchestrationV2DomainEvent,
3 OrchestrationV2ContextHandoff,
4 OrchestrationV2ThreadProjection,
5 OrchestrationV2TurnItem,
6} from "@t3tools/contracts";
7import {
8 compactDynamicToolOutput,
9 omitToolOutputImageData,
10 toolOutputImages,
11 toolOutputIndicatesFailure,
12} from "@t3tools/shared/toolOutput";
13
14const MAX_DETAIL_STRING_BYTES = 32_768;
15const MAX_DYNAMIC_VALUE_BYTES = 16_384;
16const MAX_ON_DEMAND_BYTES = 256 * 1024;
17
18function truncateDetail(
19 value: string | undefined,
20 maxBytes = MAX_DETAIL_STRING_BYTES,
21): string | undefined {
22 if (
23 value === undefined ||
24 (value.length <= maxBytes && Buffer.byteLength(value, "utf8") <= maxBytes)
25 ) {
26 return value;
27 }
28 // UTF-8 needs at least one byte per UTF-16 code unit. Only encode the prefix
29 // that could fit, rather than allocating a buffer for the complete output.
30 const prefix = Buffer.from(value.slice(0, maxBytes), "utf8")
31 .subarray(0, maxBytes)
32 .toString("utf8")
33 .replace(/\uFFFD$/u, "");
34 return `${prefix}\n… output truncated for transport`;
35}
36
37/**
38 * Bounds the text a provider file change keeps in the event log and its
39 * projection. Clients never receive more than this (see projectTurnItemForWire),
40 * so a full diff of a large file would only grow the database and the heap that
41 * reads it back. Agents reading the thread over MCP get the bounded text.
42 */
43export function boundFileChangeForStorage(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem {
44 if (item.type !== "file_change") return item;
45 const { diffStr, oldStr, newStr } = item;
46 const bounded = {
47 ...item,
48 ...(diffStr === undefined ? {} : { diffStr: truncateDetail(diffStr) }),
49 ...(oldStr === undefined ? {} : { oldStr: truncateDetail(oldStr) }),
50 ...(newStr === undefined ? {} : { newStr: truncateDetail(newStr) }),
51 };
52 return bounded.diffStr === diffStr && bounded.oldStr === oldStr && bounded.newStr === newStr
53 ? item
54 : bounded;
55}
56
57function summarizeDynamicValue(value: unknown): unknown {
58 let serialized: string;
59 try {
60 if (typeof value === "string" && value.length > MAX_DYNAMIC_VALUE_BYTES) {
61 serialized = value;
62 } else {
63 const json = JSON.stringify(value) ?? String(value);
64 if (Buffer.byteLength(json, "utf8") <= MAX_DYNAMIC_VALUE_BYTES) {
65 return value;
66 }
67 serialized = typeof value === "string" ? value : json;
68 }
69 } catch {
70 serialized = "Unserializable tool output";
71 }
72
73 // Preserve the first nonblank normalized line, but stop after the preview.
74 // Splitting and normalizing every line can allocate far more than the input.
75 const start = /\S/u.exec(serialized)?.index;
76 let firstLine = start === undefined ? "Large tool output" : "";
77 let pendingSpace = false;
78 for (let index = start ?? serialized.length; index < serialized.length; index += 1) {
79 const character = serialized[index]!;
80 if (character === "\n") break;
81 if (/\s/u.test(character)) {
82 pendingSpace = true;
83 continue;
84 }
85 if (pendingSpace) firstLine += " ";
86 firstLine += character;
87 pendingSpace = false;
88 if (firstLine.length > 160) break;
89 }
90 return {
91 summary: firstLine.length <= 160 ? firstLine : `${firstLine.slice(0, 159).trimEnd()}…`,
92 truncated: true,
93 };
94}
95
96export function projectTurnItemForWire(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem {
97 switch (item.type) {
98 case "handoff": {
99 const { summary: _summary, ...projected } = item;
100 return projected;
101 }
102 case "command_execution": {
103 const { output, ...projected } = item;
104 // Clients used this preview to recognize provider-reported failures. Keep
105 // the outcome without retaining or serializing the output that proved it.
106 const failed =
107 item.outputIndicatesFailure === true ||
108 (item.exitCode !== undefined && item.exitCode !== 0) ||
109 (output !== undefined &&
110 toolOutputIndicatesFailure(output.slice(0, MAX_DETAIL_STRING_BYTES)));
111 return {
112 ...projected,
113 ...(failed ? { outputIndicatesFailure: true } : {}),
114 ...(output?.trim() ? { outputOmitted: true } : {}),
115 };
116 }
117 case "file_change": {
118 // File identity and counts are enough for activity. Full diffs come from
119 // checkpoint diffs; storage keeps only a bounded copy of this text.
120 const { diffStr, oldStr: _old, newStr: _new, ...projected } = item;
121 // A failed edit stores the provider's error where the diff would be.
122 return item.status === "failed" && diffStr?.trim()
123 ? { ...projected, diffStr: truncateDetail(diffStr) }
124 : projected;
125 }
126 case "subagent":
127 return {
128 ...item,
129 prompt: truncateDetail(item.prompt) ?? "",
130 progress: truncateDetail(item.progress),
131 result: item.result === null ? null : (truncateDetail(item.result) ?? null),
132 };
133 case "dynamic_tool": {
134 const { output: rawOutput, ...projected } = item;
135 const output = compactDynamicToolOutput(rawOutput);
136 return {
137 ...projected,
138 input: summarizeDynamicValue(item.input),
139 ...(output === undefined ? {} : { output }),
140 ...(hasDynamicValue(rawOutput) ? { outputOmitted: true } : {}),
141 };
142 }
143 default:
144 return item;
145 }
146}
147
148function hasDynamicValue(value: unknown): boolean {
149 if (value === undefined || value === null) return false;
150 if (typeof value === "string") return value.trim().length > 0;
151 if (Array.isArray(value)) return value.length > 0;
152 return typeof value !== "object" || Object.keys(value).length > 0;
153}
154
155function boundDynamicValue(value: unknown): unknown {
156 if (value === undefined) return value;
157 if (typeof value === "string") return truncateDetail(value, MAX_ON_DEMAND_BYTES);
158 let json: string;
159 try {
160 // Compact, so measuring does not inflate the value; clients indent it.
161 json = JSON.stringify(value) ?? String(value);
162 } catch {
163 return "Unserializable tool value";
164 }
165 return Buffer.byteLength(json, "utf8") <= MAX_ON_DEMAND_BYTES
166 ? value
167 : truncateDetail(json, MAX_ON_DEMAND_BYTES);
168}
169
170/**
171 * A tool output for a detail read, without image bytes. When the rest is too
172 * large to send whole, its truncated text still comes with the image markers,
173 * in order, so clients can load each image by index.
174 */
175function boundToolOutput(output: unknown): unknown {
176 const omitted = omitToolOutputImageData(output);
177 const bounded = boundDynamicValue(omitted);
178 if (typeof bounded !== "string" || typeof omitted === "string") return bounded;
179 const images = toolOutputImages(omitted);
180 return images.length === 0
181 ? bounded
182 : [
183 { type: "text", text: bounded },
184 ...images.map((image) => ({ type: "image", mimeType: image.mimeType })),
185 ];
186}
187
188/**
189 * Projects one item for an on-demand detail read: keeps the input and output
190 * the timeline withholds, bounded so a huge result cannot stall the socket.
191 * Image bytes are left out; clients load them as `tool-output-image` assets.
192 */
193export function projectTurnItemForDetail(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem {
194 switch (item.type) {
195 case "command_execution":
196 return {
197 ...item,
198 input: truncateDetail(item.input, MAX_ON_DEMAND_BYTES) ?? "",
199 output: truncateDetail(item.output, MAX_ON_DEMAND_BYTES),
200 };
201 case "dynamic_tool":
202 return {
203 ...item,
204 input: boundDynamicValue(item.input),
205 output: boundToolOutput(item.output),
206 };
207 case "subagent":
208 return {
209 ...item,
210 prompt: truncateDetail(item.prompt, MAX_ON_DEMAND_BYTES) ?? "",
211 progress: truncateDetail(item.progress, MAX_ON_DEMAND_BYTES),
212 result:
213 item.result === null ? null : (truncateDetail(item.result, MAX_ON_DEMAND_BYTES) ?? null),
214 };
215 case "handoff":
216 case "file_change":
217 return projectTurnItemForWire(item);
218 default:
219 return item;
220 }
221}
222
223export function projectContextHandoffForWire(
224 handoff: OrchestrationV2ContextHandoff,
225): OrchestrationV2ContextHandoff {
226 const { history: _history, delivery: _delivery, ...projected } = handoff;
227 return { ...projected, summaryText: "" };
228}
229
230export function projectThreadProjectionForWire(
231 projection: OrchestrationV2ThreadProjection,
232): OrchestrationV2ThreadProjection {
233 const projectedById = new Map<string, OrchestrationV2TurnItem>();
234 const project = (item: OrchestrationV2TurnItem) => {
235 const key = `${item.threadId}:${item.id}`;
236 const existing = projectedById.get(key);
237 if (existing !== undefined) return existing;
238 const projected = projectTurnItemForWire(item);
239 projectedById.set(key, projected);
240 return projected;
241 };
242 return {
243 ...projection,
244 contextHandoffs: projection.contextHandoffs.map(projectContextHandoffForWire),
245 turnItems: projection.turnItems.map(project),
246 visibleTurnItems: projection.visibleTurnItems.map((row) => ({
247 ...row,
248 item: project(row.item),
249 })),
250 };
251}
252
253export function projectDomainEventForWire(
254 event: OrchestrationV2DomainEvent,
255): OrchestrationV2DomainEvent {
256 return event.type === "turn-item.updated"
257 ? { ...event, payload: projectTurnItemForWire(event.payload) }
258 : event.type === "context-handoff.updated"
259 ? { ...event, payload: projectContextHandoffForWire(event.payload) }
260 : event;
261}