Skip to content
Source
Sign inSign up
Open History to see the last commit

toolItems.ts

219 lines7.7 KB

1import type { OrchestrationV2TurnItem } from "@t3tools/contracts";
2import { resolveT3McpToolId } from "@t3tools/shared/t3McpToolPresentation";
3import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity";
4import {
5 MAX_TOOL_OUTPUT_IMAGES,
6 readToolOutputImage,
7 type ToolOutputImage,
8} from "@t3tools/shared/toolOutput";
9​
10export function openCodeToolOutput(
11 output: string | undefined,
12 files: ReadonlyArray<{ readonly uri: string; readonly mime: string }> = [],
13) {
14 const images: Array<ToolOutputImage & { type: "image" }> = [];
15 for (const file of files) {
16 const match = /^data:(image\/[a-z0-9.+-]+);base64,([A-Za-z0-9+/]+={0,2})$/i.exec(file.uri);
17 if (match?.[1]?.toLowerCase() !== file.mime.toLowerCase()) continue;
18 const image = readToolOutputImage({ type: "image", mimeType: file.mime, data: match?.[2] });
19 if (image === null) continue;
20 images.push({ type: "image", ...image });
21 if (images.length === MAX_TOOL_OUTPUT_IMAGES) break;
22 }
23 return images.length === 0
24 ? output
25 : { content: [...(output ? [{ type: "text", text: output }] : []), ...images] };
27​
28// Search results stay on the timeline wire, so keep their text a preview.
29const SEARCH_PREVIEW_MAX_CHARS = 8_000;
30​
31type ToolItemBase = Omit<
32 Extract<OrchestrationV2TurnItem, { type: "dynamic_tool" }>,
33 "type" | "toolName" | "input" | "output"
34>;
35​
36function openCodeToolProjectionKind(
37 toolName: string,
38): "command_execution" | "file_change" | "file_search" | "web_search" | "dynamic_tool" {
39 if (toolName.startsWith("mcp__") || resolveT3McpToolId(toolName) !== null) return "dynamic_tool";
40 const normalized = toolName.toLowerCase();
41 if (normalized === "todowrite") {
42 return "dynamic_tool";
43 }
44 if (normalized.includes("bash") || normalized.includes("shell")) {
45 return "command_execution";
46 }
47 if (normalized.includes("edit") || normalized.includes("write") || normalized.includes("patch")) {
48 return "file_change";
49 }
50 if (normalized.includes("web") || normalized === "codesearch" || normalized === "code_search") {
51 return "web_search";
52 }
53 if (normalized === "read") {
54 return "dynamic_tool";
55 }
56 if (
57 normalized.includes("glob") ||
58 normalized.includes("grep") ||
59 normalized.includes("search") ||
60 normalized.includes("lsp")
61 ) {
62 return "file_search";
63 }
64 return "dynamic_tool";
66​
67function recordString(input: unknown, ...keys: ReadonlyArray<string>): string | undefined {
68 if (typeof input !== "object" || input === null) return undefined;
69 for (const key of keys) {
70 const value = (input as Record<string, unknown>)[key];
71 if (typeof value === "string" && value.trim().length > 0) return value;
72 }
73 return undefined;
75​
76function recordText(input: Record<string, unknown>, ...keys: ReadonlyArray<string>) {
77 for (const key of keys) {
78 const value = input[key];
79 if (typeof value === "string") return value;
80 }
81 return undefined;
83​
84function recordNumber(input: unknown, ...keys: ReadonlyArray<string>): number | undefined {
85 if (typeof input !== "object" || input === null) return undefined;
86 for (const key of keys) {
87 const value = (input as Record<string, unknown>)[key];
88 if (typeof value === "number" && Number.isFinite(value)) return value;
89 }
90 return undefined;
92​
93function stableJson(value: unknown): string {
94 if (typeof value === "string") return value;
95 try {
96 return JSON.stringify(value, null, 2);
97 } catch {
98 return String(value);
99 }
101​
102function nativeReadOutput(output: string): string {
103 const header = /^<path>[^\r\n]+<\/path>\r?\n<type>(directory|file)<\/type>\r?\n/.exec(output);
104 if (header === null) return output;
105 const body = output.slice(header[0].length);
106 // File lines are numbered; only a closing tag on its own line ends the envelope.
107 const envelope =
108 header[1] === "directory"
109 ? /^<entries>\r?\n([\s\S]*?)\r?\n<\/entries>(?=\r?\n|$)/.exec(body)
110 : /^<content>(?:\r?\n)?([\s\S]*?)\r?\n<\/content>(?=\r?\n|$)/.exec(body);
111 // OpenCode can append instructions after the envelope; keep that suffix verbatim.
112 return envelope === null ? output : envelope[1] + body.slice(envelope[0].length);
114​
115/** OpenCode's shell tool succeeds when the command exits non-zero. */
116export function openCodeCommandFailed(toolName: string, completedMetadata: unknown): boolean {
117 const exitCode = recordNumber(completedMetadata, "exit", "exitCode");
118 return (
119 openCodeToolProjectionKind(toolName) === "command_execution" &&
120 exitCode !== undefined &&
121 exitCode !== 0
122 );
124​
125export function openCodeToolTurnItem(
126 base: ToolItemBase,
127 tool: {
128 readonly name: string;
129 readonly input: Record<string, unknown>;
130 readonly output: string | undefined;
131 readonly completedMetadata: unknown;
132 readonly files?: ReadonlyArray<{ readonly uri: string; readonly mime: string }> | undefined;
133 },
134): OrchestrationV2TurnItem {
135 const { input, output } = tool;
136 switch (openCodeToolProjectionKind(tool.name)) {
137 case "command_execution": {
138 const exitCode = recordNumber(tool.completedMetadata, "exit", "exitCode");
139 return {
140 ...base,
141 type: "command_execution",
142 input: recordString(input, "command", "cmd") ?? stableJson(input),
143 ...(output === undefined ? {} : { output }),
144 ...(exitCode === undefined ? {} : { exitCode }),
145 };
146 }
147 case "file_change": {
148 const oldStr = recordText(input, "oldString", "oldText");
149 const newStr = recordText(input, "newString", "content", "newText");
150 const diffStr =
151 recordString(tool.completedMetadata, "diff", "patch") ??
152 (base.status === "failed" && output?.trim() ? output : undefined);
153 return {
154 ...base,
155 type: "file_change",
156 fileName: recordString(input, "filePath", "path", "file") ?? tool.name,
157 ...(oldStr === undefined ? {} : { oldStr }),
158 ...(newStr === undefined ? {} : { newStr }),
159 ...(diffStr === undefined ? {} : { diffStr }),
160 };
161 }
162 case "file_search": {
163 const pattern = recordString(input, "pattern", "query", "path", "filePath");
164 const searchRoot = (recordString(input, "path", "filePath") ?? pattern)?.trim();
165 return {
166 ...base,
167 title:
168 formatSearchToolLabel({ input, ...(pattern === undefined ? {} : { pattern }) }) ??
169 base.title,
170 type: "file_search",
171 ...(pattern === undefined ? {} : { pattern }),
172 ...(!output?.trim() || searchRoot === undefined
173 ? {}
174 : {
175 results: [
176 { fileName: searchRoot, preview: output.slice(0, SEARCH_PREVIEW_MAX_CHARS) },
177 ],
178 }),
179 };
180 }
181 case "web_search": {
182 const pattern = recordString(input, "query", "url", "pattern");
183 const url = recordString(input, "url")?.trim();
184 return {
185 ...base,
186 type: "web_search",
187 ...(pattern === undefined ? {} : { patterns: [pattern] }),
188 ...(!output?.trim()
189 ? {}
190 : {
191 results: [
192 {
193 ...(url === undefined ? {} : { url }),
194 snippet: output.slice(0, SEARCH_PREVIEW_MAX_CHARS),
195 },
196 ],
197 }),
198 };
199 }
200 case "dynamic_tool": {
201 const readPath = recordString(input, "filePath", "path", "file");
202 const cleanedOutput =
203 tool.name === "read" && output !== undefined ? nativeReadOutput(output) : output;
204 return {
205 ...base,
206 title:
207 tool.name.toLowerCase() === "read" && readPath !== undefined
208 ? formatReadToolLabel(readPath)
209 : base.title,
210 type: "dynamic_tool",
211 toolName: tool.name,
212 input,
213 ...(cleanedOutput === undefined && !tool.files?.length
214 ? {}
215 : { output: openCodeToolOutput(cleanedOutput, tool.files) }),
216 };
217 }
218 }