1import {
2 isProviderSendTurnSupportedImageMimeType,
3 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
4} from "@t3tools/contracts";
5import * as Predicate from "effect/Predicate";
6
7import {
8 HTML_RENDER_TOOL_NAME,
9 readHtmlRenderReference,
10 type HtmlRenderReference,
11} from "./htmlRender.ts";
12import {
13 claudeMcpToolName,
14 MCP_APP_OUTPUT_KEY,
15 readMcpAppReference,
16 type McpAppReference,
17} from "./mcpApp.ts";
18import { resolveT3McpToolId } from "./t3McpToolPresentation.ts";
19
20const MAX_PARSED_BYTES = 16_384;
21const MAX_METADATA_BYTES = 8_192;
22const MAX_ID_LENGTH = 256;
23const MAX_THREADS = 100;
24const MAX_CONTENT_BLOCKS = 32;
25const MAX_ENVELOPE_DEPTH = 4;
26const MAX_ENVELOPE_NODES = 128;
27const encoder = new TextEncoder();
28
29interface ResultReadBudget {
30 remainingBytes: number;
31 remainingNodes: number;
32 exceeded: boolean;
33}
34
35interface ResultEnvelope {
36 data?: Record<PropertyKey, unknown>;
37 failed: boolean;
38}
39
40interface CompactToolOutput {
41 isError?: true;
42 threadId?: string;
43 messageId?: string;
44 taskId?: string;
45 scheduledTaskId?: string;
46 status?: "rolled_back";
47 htmlRender?: HtmlRenderReference;
48 [MCP_APP_OUTPUT_KEY]?: McpAppReference;
49 thread?: { threadId: string };
50 threads?: Array<{ threadId?: string; status?: "rolled_back" }>;
51 linkedCount?: number;
52 unlinkedCount?: number;
53}
54
55function readResult(value: unknown, budget: ResultReadBudget, depth = 0): ResultEnvelope {
56 if (depth > MAX_ENVELOPE_DEPTH || budget.remainingNodes-- <= 0) {
57 budget.exceeded = true;
58 return { failed: false };
59 }
60 if (typeof value === "string") {
61 if (value.length > budget.remainingBytes) {
62 budget.exceeded = true;
63 return { failed: false };
64 }
65 const bytes = encoder.encode(value).byteLength;
66 if (bytes > budget.remainingBytes) {
67 budget.exceeded = true;
68 return { failed: false };
69 }
70 budget.remainingBytes -= bytes;
71 try {
72 return readResult(JSON.parse(value), budget, depth + 1);
73 } catch {
74 return { failed: false };
75 }
76 }
77 if (Array.isArray(value)) {
78 if (value.length > MAX_CONTENT_BLOCKS) {
79 budget.exceeded = true;
80 return { failed: false };
81 }
82 let data: ResultEnvelope["data"];
83 let failed = false;
84 for (const block of value) {
85 const text = Predicate.isObject(block) ? block.text : undefined;
86 const result = readResult(
87 Predicate.isObject(text) ? (text.text ?? text) : text,
88 budget,
89 depth + 1,
90 );
91 data ??= result.data;
92 failed ||= result.failed;
93 if (budget.exceeded) break;
94 }
95 return { ...(data === undefined ? {} : { data }), failed };
96 }
97 if (!Predicate.isObject(value)) return { failed: false };
98 const failed =
99 value.isError === true ||
100 value.is_error === true ||
101 value._tag === "OrchestratorMcpFailure" ||
102 value.error != null;
103 // Grok CLI wraps MCP results as { type: "MCP", output: { OkayOutput: "<result json>" } }.
104 const content =
105 value.structuredContent ??
106 value.content ??
107 (Predicate.isObject(value.output) ? value.output.OkayOutput : undefined);
108 if (content !== undefined) {
109 const nested = readResult(content, budget, depth + 1);
110 return { ...nested, failed: failed || nested.failed };
111 }
112 return { data: value, failed };
113}
114
115function boundedId(value: unknown): string | undefined {
116 if (typeof value !== "string" || value.length > MAX_ID_LENGTH || value.trim() === "") {
117 return undefined;
118 }
119 return Array.from(value).join("");
120}
121
122export function compactDynamicToolOutput(value: unknown): CompactToolOutput | undefined {
123 const budget: ResultReadBudget = {
124 remainingBytes: MAX_PARSED_BYTES,
125 remainingNodes: MAX_ENVELOPE_NODES,
126 exceeded: false,
127 };
128 const result = readResult(value, budget);
129 const output: CompactToolOutput = result.failed ? { isError: true } : {};
130 const data = budget.exceeded ? undefined : result.data;
131 if (data !== undefined) {
132 for (const key of ["threadId", "messageId", "taskId", "scheduledTaskId"] as const) {
133 const id = boundedId(data[key]);
134 if (id !== undefined) output[key] = id;
135 }
136 if (data.status === "rolled_back") output.status = "rolled_back";
137 for (const key of ["linkedCount", "unlinkedCount"] as const) {
138 const count = data[key];
139 if (typeof count === "number" && Number.isSafeInteger(count)) output[key] = count;
140 }
141 const htmlRender = readHtmlRenderReference(data.htmlRender);
142 if (htmlRender !== undefined) output.htmlRender = htmlRender;
143 const mcpApp = readMcpAppReference(data[MCP_APP_OUTPUT_KEY]);
144 if (mcpApp !== undefined) output[MCP_APP_OUTPUT_KEY] = mcpApp;
145 const nestedThreadId = Predicate.isObject(data.thread)
146 ? boundedId(data.thread.threadId)
147 : undefined;
148 if (nestedThreadId !== undefined) output.thread = { threadId: nestedThreadId };
149
150 if (Array.isArray(data.threads)) {
151 let complete = data.threads.length <= MAX_THREADS;
152 const threads: NonNullable<CompactToolOutput["threads"]> = [];
153 if (complete) {
154 for (const entry of data.threads) {
155 if (!Predicate.isObject(entry)) {
156 complete = false;
157 break;
158 }
159 const threadId = boundedId(entry.threadId);
160 const rolledBack = entry.status === "rolled_back";
161 if (threadId === undefined && !rolledBack) {
162 complete = false;
163 break;
164 }
165 threads.push({
166 ...(threadId === undefined ? {} : { threadId }),
167 ...(rolledBack ? { status: "rolled_back" as const } : {}),
168 });
169 }
170 }
171 if (complete) output.threads = threads;
172 else {
173 // Partial batches or top-level ids make the summary parser report a false creation count.
174 delete output.threadId;
175 delete output.status;
176 }
177 }
178 }
179 const oversized = () => encoder.encode(JSON.stringify(output)).byteLength > MAX_METADATA_BYTES;
180 if (oversized()) {
181 delete output.threads;
182 delete output.threadId;
183 delete output.status;
184 }
185 const app = output[MCP_APP_OUTPUT_KEY];
186 if (app?.csp !== undefined && oversized()) {
187 const { csp: _csp, ...rest } = app;
188 output[MCP_APP_OUTPUT_KEY] = rest;
189 }
190 if (oversized()) delete output[MCP_APP_OUTPUT_KEY];
191 return Object.keys(output).length === 0 ? undefined : output;
192}
193
194// OpenCode 2 Code Mode reports a script's nested T3 tool calls as one item with the script's result.
195const OPENCODE_CODE_MODE_TOOL_NAME = "execute";
196
197export function htmlRenderFromToolItem(item: {
198 readonly toolName: string | null | undefined;
199 readonly output?: unknown;
200}): HtmlRenderReference | undefined {
201 if (
202 item.toolName !== OPENCODE_CODE_MODE_TOOL_NAME &&
203 resolveT3McpToolId(item.toolName) !== HTML_RENDER_TOOL_NAME
204 ) {
205 return undefined;
206 }
207 const output = compactDynamicToolOutput(item.output);
208 return output?.isError ? undefined : output?.htmlRender;
209}
210
211/** The thread-owned file `preview_recording_stop` stored for the agent. */
212export function previewRecordingIdFromToolItem(item: {
213 readonly toolName: string | null | undefined;
214 readonly output?: unknown;
215}): string | undefined {
216 if (resolveT3McpToolId(item.toolName) !== "preview_recording_stop") return undefined;
217 const budget: ResultReadBudget = {
218 remainingBytes: MAX_PARSED_BYTES,
219 remainingNodes: MAX_ENVELOPE_NODES,
220 exceeded: false,
221 };
222 const result = readResult(item.output, budget);
223 return result.failed || budget.exceeded ? undefined : boundedId(result.data?.id);
224}
225
226/** Accept app references only when server and tool match the item, so tools cannot impersonate another app source. */
227export function mcpAppFromToolItem(item: {
228 readonly toolName: string | null | undefined;
229 readonly output?: unknown;
230}): McpAppReference | undefined {
231 const app = compactDynamicToolOutput(item.output)?.[MCP_APP_OUTPUT_KEY];
232 return app !== undefined &&
233 (item.toolName === `${app.server}.${app.tool}` ||
234 item.toolName === claudeMcpToolName(app.server, app.tool))
235 ? app
236 : undefined;
237}
238
239/** Some providers report completion even when command output describes a failure. */
240export function toolOutputIndicatesFailure(text: string): boolean {
241 return (
242 /file not found|no files found|enoent|no such file|commandnotfoundexception|command not found|is not recognized as the name of a cmdlet|a parameter cannot be found that matches parameter name/i.test(
243 text,
244 ) ||
245 (/cannot find path/i.test(text) && /because it does not exist/i.test(text)) ||
246 (/is not recognized/i.test(text) && /the term '/i.test(text)) ||
247 /<exited with exit code\s+[1-9]\d*\s*>/i.test(text) ||
248 /exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text) ||
249 /exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text)
250 );
251}
252
253const TEST_COMMAND_PATTERN =
254 /(?:^|[\s;&|(/])(?:vitest|jest|pytest|mocha|ava|rspec|phpunit|ctest|playwright\s+test|(?:bun|deno|go|cargo|dotnet|mix|swift|npm|pnpm|yarn|vp)\s+(?:run\s+)?test(?::\S+)?|(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?t(?:est)?)(?=$|[\s;&|)])/i;
255
256/** Whether a shell command runs a test suite, so a failure reads as failing tests. */
257export function commandRunsTests(command: string): boolean {
258 return TEST_COMMAND_PATTERN.test(command);
259}
260
261/**
262 * Failed test count from common runner summaries (vitest, jest, bun, pytest,
263 * cargo, go). Undefined when the output names no count.
264 */
265export function countFailedTests(output: string): number | undefined {
266 const summary =
267 /^\s*Tests?:?\s+(\d+)\s+failed\b/im.exec(output) ??
268 /^\s*(\d+)\s+fail\b/m.exec(output) ??
269 /\btest result: FAILED\.[^\n]*?\b(\d+) failed\b/i.exec(output) ??
270 /^=+[^\n]*?\b(\d+) failed\b[^\n]*=+\s*$/m.exec(output);
271 if (summary) {
272 const count = Number(summary[1]);
273 return Number.isSafeInteger(count) && count > 0 ? count : undefined;
274 }
275 const goFailures = output.match(/^\s*--- FAIL: /gm)?.length ?? 0;
276 return goFailures > 0 ? goFailures : undefined;
277}
278
279export interface ToolOutputImage {
280 readonly mimeType: string;
281 readonly data?: string;
282}
283
284/** Raster types only, so agent SVG/HTML cannot be served inline. */
285export function readToolOutputImage(block: unknown): ToolOutputImage | null {
286 if (!Predicate.isObject(block) || block.type !== "image") return null;
287 const source = Predicate.isObject(block.source) ? block.source : undefined;
288 if (source !== undefined && source.type !== "base64") return null;
289 const mimeType = source === undefined ? block.mimeType : source.media_type;
290 const data = source === undefined ? block.data : source.data;
291 if (typeof mimeType !== "string" || !isProviderSendTurnSupportedImageMimeType(mimeType)) {
292 return null;
293 }
294 const image = { mimeType: mimeType.toLowerCase() };
295 return typeof data === "string" ? { ...image, data } : image;
296}
297
298function outputBlocks(value: unknown): ReadonlyArray<unknown> {
299 if (Array.isArray(value)) return value;
300 if (Predicate.isObject(value) && Array.isArray(value.content)) return value.content;
301 return [value];
302}
303
304export const MAX_TOOL_OUTPUT_IMAGES = 8;
305
306export const MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH =
307 Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4;
308
309export function toolOutputImageBlocks(value: unknown): ReadonlyArray<unknown> {
310 const blocks: unknown[] = [];
311 for (const block of outputBlocks(value)) {
312 if (readToolOutputImage(block) === null) continue;
313 blocks.push(block);
314 if (blocks.length === MAX_TOOL_OUTPUT_IMAGES) break;
315 }
316 return blocks;
317}
318
319/** Order defines the shared tool-output-image asset index. */
320export function toolOutputImages(value: unknown): ReadonlyArray<ToolOutputImage> {
321 return toolOutputImageBlocks(value).flatMap((block) => readToolOutputImage(block) ?? []);
322}
323
324export function mcpToolResultOutput(result: {
325 readonly content?: unknown;
326 readonly structuredContent?: unknown;
327}): unknown {
328 if (result.structuredContent != null && toolOutputImages(result.content).length > 0) {
329 return { content: result.content, structuredContent: result.structuredContent };
330 }
331 return result.structuredContent ?? result.content;
332}
333
334export function omitToolOutputImageData(value: unknown): unknown {
335 const omit = (block: unknown) => {
336 const image = readToolOutputImage(block);
337 return image?.data === undefined ? block : { type: "image", mimeType: image.mimeType };
338 };
339 if (Array.isArray(value)) return value.map(omit);
340 if (Predicate.isObject(value) && Array.isArray(value.content)) {
341 return { ...value, content: value.content.map(omit) };
342 }
343 return omit(value);
344}