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

itemDetail.ts

273 lines9.5 KB

1import type { AssetResource, OrchestrationV2TurnItem } from "@t3tools/contracts";
2import { toolOutputImageBlocks, toolOutputImages } from "@t3tools/shared/toolOutput";
3import * as DateTime from "effect/DateTime";
4​
5const MAX_TEXT_BLOCK_DEPTH = 4;
6​
7function isRecord(value: unknown): value is Record<string, unknown> {
8 return typeof value === "object" && value !== null && !Array.isArray(value);
9}
10​
11function isSummarizedValue(value: unknown): boolean {
12 return isRecord(value) && value.truncated === true && typeof value.summary === "string";
14​
15function textFromBlocks(
16 value: unknown,
17 depth: number,
18 imageBlocks: ReadonlyArray<unknown>,
19): string | null {
20 if (depth > MAX_TEXT_BLOCK_DEPTH) return null;
21 if (typeof value === "string") return value;
22 if (Array.isArray(value)) {
23 const parts = value.map((block) => textFromBlocks(block, depth + 1, imageBlocks));
24 return parts.every((part) => part !== null)
25 ? parts.filter((part) => part !== "").join("\n")
26 : null;
27 }
28 if (!isRecord(value)) return null;
29 if (value.type === "text" && typeof value.text === "string") return value.text;
30 if (value.type === "image") return imageBlocks.includes(value) ? "" : "[image]";
31 if (value.type === "resource_link" && typeof value.uri === "string") return value.uri;
32 if (value.type === "resource" && isRecord(value.resource)) {
33 const resource = value.resource;
34 if (typeof resource.text === "string") return resource.text;
35 if (typeof resource.uri === "string") return resource.uri;
36 }
37 const keys = Object.keys(value).filter((key) => key !== "isError" && key !== "is_error");
38 if (keys.length === 1 && keys[0] === "content")
39 return textFromBlocks(value.content, depth + 1, imageBlocks);
40 if (keys.length === 2 && keys.includes("content") && keys.includes("structuredContent")) {
41 const text = textFromBlocks(value.content, depth + 1, imageBlocks);
42 const images = Array.isArray(value.content)
43 ? value.content.filter((block) => isRecord(block) && block.type === "image")
44 : [];
45 const structured = formatJsonValue(value.structuredContent);
46 if (structured !== null && (images.length > 0 || !text?.trim())) {
47 const markers = textFromBlocks(images, depth + 1, imageBlocks);
48 return [structured, markers].filter(Boolean).join("\n");
49 }
50 return text?.trim() ? text : null;
51 }
52 return null;
54​
55function parseJson(text: string): unknown {
56 try {
57 return JSON.parse(text);
58 } catch {
59 return undefined;
60 }
62​
63export function isJsonToolText(text: string): boolean {
64 if (parseJson(text) !== undefined) return true;
65 const documents = text.trim().split(/\n\s*\n/);
66 return documents.length > 1 && documents.every((document) => parseJson(document) !== undefined);
68​
69function prettyJsonText(text: string): string {
70 const trimmed = text.trim();
71 if (!/^[[{]/.test(trimmed)) return text;
72 const whole = parseJson(trimmed);
73 if (whole !== undefined) return JSON.stringify(whole, null, 2);
74 const lines = trimmed.split("\n").filter((line) => line.trim());
75 const documents = lines.map((line) => parseJson(line.trim()));
76 if (documents.some((document) => document === undefined)) return text;
77 return documents.map((document) => JSON.stringify(document, null, 2)).join("\n\n");
79​
80function formatJsonValue(value: unknown): string | null {
81 if (value === undefined || value === null) return null;
82 if (typeof value === "string" && !value.trim()) return null;
83 let json: string | undefined;
84 try {
85 json = JSON.stringify(value, null, 2);
86 } catch {
87 return String(value);
88 }
89 if (json === undefined || json === "{}" || json === "[]") return null;
90 return json;
92​
93function formatToolValue(value: unknown, hasImagePreview = false): string | null {
94 if (value === undefined || value === null) return null;
95 if (hasImagePreview && isRecord(value) && value.type === "image") return null;
96 const text = textFromBlocks(value, 0, toolOutputImageBlocks(value));
97 if (text !== null) return text.trim() ? prettyJsonText(text) : null;
98 return formatJsonValue(value);
100​
101export function toolCallLines(input: {
102 readonly command?: string | undefined;
103 readonly args?: unknown;
104}): {
105 readonly command: string | null;
106 readonly args: ReadonlyArray<readonly [string, string]> | null;
107 readonly argsText: string | null;
108} {
109 if (input.command !== undefined) {
110 const command = input.command.trim();
111 return { command: command || null, args: null, argsText: null };
112 }
113 const args = input.args;
114 if (isRecord(args) && !isSummarizedValue(args)) {
115 const entries = Object.entries(args).flatMap(
116 ([key, value]): Array<readonly [string, string]> => {
117 if (value === undefined) return [];
118 // An empty string or null can be the point of a call (a clear or reset), so show it.
119 return [
120 [
121 key,
122 typeof value === "string" && value !== ""
123 ? value
124 : (JSON.stringify(value) ?? String(value)),
125 ],
126 ];
127 },
128 );
129 return { command: null, args: entries.length > 0 ? entries : null, argsText: null };
130 }
131 return { command: null, args: null, argsText: formatToolValue(args) };
133​
134function toolCallHasLines(lines: ReturnType<typeof toolCallLines>): boolean {
135 return lines.command !== null || lines.args !== null || lines.argsText !== null;
137​
138const LIVE_TURN_ITEM_STATUSES: ReadonlySet<OrchestrationV2TurnItem["status"]> = new Set([
139 "idle",
140 "pending",
141 "running",
142 "waiting",
143]);
144​
145/**
146 * A running item keeps one key, so an open row fetches once while it streams and
147 * again when it finishes, not on every update.
148 */
149export function turnItemDetailRevision(item: OrchestrationV2TurnItem): string {
150 return LIVE_TURN_ITEM_STATUSES.has(item.status) ? "live" : DateTime.formatIso(item.updatedAt);
152​
153export function turnItemNeedsDetailFetch(item: OrchestrationV2TurnItem): boolean {
154 switch (item.type) {
155 case "command_execution":
156 return item.outputOmitted === true;
157 case "dynamic_tool":
158 return item.outputOmitted === true || isSummarizedValue(item.input);
159 default:
160 return false;
161 }
163​
164function commandOutputText(output: string): string {
165 if (!output.trimStart().startsWith('{"stdout"')) return output;
166 const parsed = parseJson(output.trim());
167 if (
168 !isRecord(parsed) ||
169 typeof parsed.stdout !== "string" ||
170 typeof parsed.stderr !== "string" ||
171 typeof parsed.interrupted !== "boolean"
172 ) {
173 return output;
174 }
175 return [parsed.stdout, parsed.stderr]
176 .filter((part): part is string => typeof part === "string" && part.trim().length > 0)
177 .join("\n");
179​
180export function turnItemOutputText(item: OrchestrationV2TurnItem): string | null {
181 switch (item.type) {
182 case "command_execution":
183 return item.output?.trim() ? commandOutputText(item.output) || null : null;
184 case "dynamic_tool":
185 return item.outputOmitted === true
186 ? null
187 : formatToolValue(item.output, item.viewedImagePath !== undefined);
188 case "file_search":
189 return item.results?.length
190 ? item.results
191 .map((result) =>
192 [
193 `${result.fileName}${result.line === undefined ? "" : `:${result.line}`}`,
194 result.preview?.trim(),
195 ]
196 .filter(Boolean)
197 .join("\n"),
198 )
199 .join("\n")
200 : null;
201 case "web_search":
202 return item.results?.length
203 ? item.results
204 .map((result) =>
205 [
206 result.title?.trim() || result.url,
207 result.title ? result.url : undefined,
208 result.snippet?.trim(),
209 ]
210 .filter(Boolean)
211 .join("\n"),
212 )
213 .join("\n\n")
214 : null;
215 default:
216 return null;
217 }
219​
220export function turnItemOutputImages(
221 item: OrchestrationV2TurnItem,
222): ReadonlyArray<Extract<AssetResource, { readonly _tag: "tool-output-image" }>> {
223 if (item.type !== "dynamic_tool" || item.outputOmitted === true) return [];
224 return toolOutputImages(item.output).map((_, index) => ({
225 _tag: "tool-output-image",
226 threadId: item.threadId,
227 itemId: item.id,
228 index,
229 }));
231​
232export function turnItemHasDetail(item: OrchestrationV2TurnItem): boolean {
233 switch (item.type) {
234 case "reasoning":
235 return item.text.trim().length > 0;
236 case "command_execution":
237 return (
238 toolCallLines({ command: item.input }).command !== null ||
239 item.outputOmitted === true ||
240 Boolean(item.output?.trim()) ||
241 (item.exitCode !== undefined && item.exitCode !== 0)
242 );
243 case "file_change":
244 case "checkpoint":
245 case "fork":
246 case "handoff":
247 return true;
248 case "file_search":
249 return (item.results?.length ?? 0) > 0 || Boolean(item.pattern?.trim());
250 case "web_search":
251 return (item.results?.length ?? 0) > 0 || (item.patterns?.length ?? 0) > 0;
252 case "dynamic_tool":
253 return item.outputOmitted === true || toolCallHasLines(toolCallLines({ args: item.input }));
254 case "approval_request":
255 return Boolean(item.prompt?.trim());
256 case "user_input_request":
257 return item.questions.length > 0;
258 case "notification":
259 return Boolean(item.detail?.trim());
260 case "system_notice":
261 return item.message.trim().length > 0;
262 case "error":
263 return item.failure.message.trim().length > 0;
264 case "proposed_plan":
265 return item.markdown.trim().length > 0;
266 case "todo_list":
267 return item.steps.length > 0;
268 case "subagent":
269 return item.childThreadId !== null;
270 default:
271 return false;
272 }