1/**
2 * Turn-item bodies for OpenCode tool calls, shared by the 1.x and 2.x runtimes.
3 * Both report a tool as a name, a JSON input, text output, and free-form
4 * metadata; only how they deliver those differs.
5 */
6import type { OrchestrationV2TurnItem } from "@t3tools/contracts";
7import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity";
8
9// Search results stay on the timeline wire, so keep their text a preview.
10const SEARCH_PREVIEW_MAX_CHARS = 8_000;
11
12type ToolItemBase = Omit<
13 Extract<OrchestrationV2TurnItem, { type: "dynamic_tool" }>,
14 "type" | "toolName" | "input" | "output"
15>;
16
17export function openCodeToolProjectionKind(
18 toolName: string,
19): "command_execution" | "file_change" | "file_search" | "web_search" | "dynamic_tool" {
20 const normalized = toolName.toLowerCase();
21 if (normalized === "todowrite") {
22 return "dynamic_tool";
23 }
24 if (normalized.includes("bash") || normalized.includes("shell")) {
25 return "command_execution";
26 }
27 if (normalized.includes("edit") || normalized.includes("write") || normalized.includes("patch")) {
28 return "file_change";
29 }
30 if (normalized.includes("web") || normalized === "codesearch" || normalized === "code_search") {
31 return "web_search";
32 }
33 if (normalized === "read") {
34 return "dynamic_tool";
35 }
36 if (
37 normalized.includes("glob") ||
38 normalized.includes("grep") ||
39 normalized.includes("search") ||
40 normalized.includes("lsp")
41 ) {
42 return "file_search";
43 }
44 return "dynamic_tool";
45}
46
47function recordString(input: unknown, ...keys: ReadonlyArray<string>): string | undefined {
48 if (typeof input !== "object" || input === null) return undefined;
49 for (const key of keys) {
50 const value = (input as Record<string, unknown>)[key];
51 if (typeof value === "string" && value.trim().length > 0) return value;
52 }
53 return undefined;
54}
55
56function recordNumber(input: unknown, ...keys: ReadonlyArray<string>): number | undefined {
57 if (typeof input !== "object" || input === null) return undefined;
58 for (const key of keys) {
59 const value = (input as Record<string, unknown>)[key];
60 if (typeof value === "number" && Number.isFinite(value)) return value;
61 }
62 return undefined;
63}
64
65function stableJson(value: unknown): string {
66 if (typeof value === "string") return value;
67 try {
68 return JSON.stringify(value, null, 2);
69 } catch {
70 return String(value);
71 }
72}
73
74/**
75 * The turn item for one tool call. `completedMetadata` is the tool's metadata
76 * once it succeeded: exit codes and diffs are only trusted from a finished call.
77 */
78export function openCodeToolTurnItem(
79 base: ToolItemBase,
80 tool: {
81 readonly name: string;
82 readonly input: Record<string, unknown>;
83 readonly output: string | undefined;
84 readonly completedMetadata: unknown;
85 },
86): OrchestrationV2TurnItem {
87 const { input, output } = tool;
88 switch (openCodeToolProjectionKind(tool.name)) {
89 case "command_execution": {
90 const exitCode = recordNumber(tool.completedMetadata, "exit", "exitCode");
91 return {
92 ...base,
93 type: "command_execution",
94 input: recordString(input, "command", "cmd") ?? stableJson(input),
95 ...(output === undefined ? {} : { output }),
96 ...(exitCode === undefined ? {} : { exitCode }),
97 };
98 }
99 case "file_change": {
100 const oldStr = recordString(input, "oldString", "oldText");
101 const newStr = recordString(input, "newString", "content", "newText");
102 // A failed edit has no diff; keep its error where the diff would be.
103 const diffStr =
104 recordString(tool.completedMetadata, "diff", "patch") ??
105 (base.status === "failed" && output?.trim() ? output : undefined);
106 return {
107 ...base,
108 type: "file_change",
109 fileName: recordString(input, "filePath", "path", "file") ?? tool.name,
110 ...(oldStr === undefined ? {} : { oldStr }),
111 ...(newStr === undefined ? {} : { newStr }),
112 ...(diffStr === undefined ? {} : { diffStr }),
113 };
114 }
115 case "file_search": {
116 const pattern = recordString(input, "pattern", "query", "path", "filePath");
117 // OpenCode reports matches as plain text, so keep it as one result row
118 // under the searched path, like the ACP search projection.
119 const searchRoot = (recordString(input, "path", "filePath") ?? pattern)?.trim();
120 return {
121 ...base,
122 title:
123 formatSearchToolLabel({ input, ...(pattern === undefined ? {} : { pattern }) }) ??
124 base.title,
125 type: "file_search",
126 ...(pattern === undefined ? {} : { pattern }),
127 ...(!output?.trim() || searchRoot === undefined
128 ? {}
129 : {
130 results: [
131 { fileName: searchRoot, preview: output.slice(0, SEARCH_PREVIEW_MAX_CHARS) },
132 ],
133 }),
134 };
135 }
136 case "web_search": {
137 const pattern = recordString(input, "query", "url", "pattern");
138 const url = recordString(input, "url")?.trim();
139 return {
140 ...base,
141 type: "web_search",
142 ...(pattern === undefined ? {} : { patterns: [pattern] }),
143 ...(!output?.trim()
144 ? {}
145 : {
146 results: [
147 {
148 ...(url === undefined ? {} : { url }),
149 snippet: output.slice(0, SEARCH_PREVIEW_MAX_CHARS),
150 },
151 ],
152 }),
153 };
154 }
155 case "dynamic_tool": {
156 const readPath = recordString(input, "filePath", "path", "file");
157 return {
158 ...base,
159 title:
160 tool.name.toLowerCase() === "read" && readPath !== undefined
161 ? formatReadToolLabel(readPath)
162 : base.title,
163 type: "dynamic_tool",
164 toolName: tool.name,
165 input,
166 ...(output === undefined ? {} : { output }),
167 };
168 }
169 }
170}