返回 CodeWhale
transcript.ts
根目录 / extensions / vscode / src / transcript.ts
1 /**
2 * Transcript projection: turns runtime `ItemRecord`s (snapshot rows and SSE
3 * payloads) into the `ItemView` shape the chat webview renders.
4 *
5 * This is deliberately pure and free of `vscode` imports so it can be unit
6 * tested under plain `node --test`. Two behaviors live here because both are
7 * trust-boundary sensitive and were getting them wrong in the view layer:
8 *
9 * - `detail` vs `summary`: the runtime truncates `summary` to a stub and puts
10 * the real body in `detail`, which is `skip_serializing_if` on the wire and
11 * therefore legitimately absent. Prefer `detail`, fall back to `summary`.
12 * - file paths for the "Open file" action arrive inside `metadata.tool_input`
13 * (a JSON string), not as `metadata.path`. That value is model-influenced,
14 * so it is parsed defensively here and containment-checked before use.
15 */
16
17 import * as path from "node:path";
18 import type { ItemRecord } from "./api";
19 import { renderMarkdown } from "./markdown";
20
21 export interface ItemView {
22 id: string;
23 kind: string;
24 status?: string;
25 turnId?: string;
26 summary: string;
27 detail?: string;
28 metadata?: Record<string, unknown>;
29 /** Workspace-relative or absolute path parsed out of tool metadata, if any. */
30 filePath?: string;
31 /** Rendered markdown for completed agent messages. */
32 html?: string;
33 codeBlocks?: string[];
34 /** In-progress agent text (plain, re-rendered on completion). */
35 streamText?: string;
36 rev: number;
37 }
38
39 /** Item status implied by an SSE event name. */
40 export function statusForEvent(event: string): string {
41 if (event === "item.completed") {
42 return "completed";
43 }
44 if (event === "item.failed") {
45 return "failed";
46 }
47 if (event === "item.interrupted" || event === "item.canceled") {
48 return "interrupted";
49 }
50 return "in_progress";
51 }
52
53 /**
54 * Build the view for one item, merging with whatever is already on screen so a
55 * partial SSE payload never blanks text that was already rendered.
56 */
57 export function projectItem(
58 item: ItemRecord,
59 existing: ItemView | undefined,
60 event?: string,
61 ): ItemView {
62 const isTerminal = event === "item.completed" || item.status === "completed";
63 const streamText = existing?.streamText;
64 const rev = (existing?.rev ?? 0) + 1;
65 const turnId = item.turnId ?? existing?.turnId;
66 const detail = item.detail ?? existing?.detail;
67 const metadata = item.metadata ?? existing?.metadata;
68
69 if (item.kind === "agent_message") {
70 // `detail` carries the full reply; `summary` is a 280-char stub on reload.
71 const text = detail || item.summary || streamText || existing?.summary || "";
72 if (!isTerminal) {
73 return {
74 id: item.id,
75 kind: item.kind,
76 status: item.status,
77 turnId,
78 summary: text,
79 detail,
80 streamText: text,
81 rev,
82 };
83 }
84 const rendered = renderMarkdown(text);
85 return {
86 id: item.id,
87 kind: item.kind,
88 status: item.status,
89 turnId,
90 summary: text,
91 detail,
92 html: rendered.html,
93 codeBlocks: rendered.codeBlocks,
94 rev,
95 };
96 }
97
98 return {
99 id: item.id,
100 kind: item.kind,
101 status: item.status,
102 turnId,
103 summary: item.summary || existing?.summary || "",
104 detail,
105 metadata,
106 filePath: extractFilePath(metadata),
107 rev,
108 };
109 }
110
111 const PATH_KEYS = ["path", "file_path", "filePath", "file", "notebook_path", "target_file"];
112
113 function firstStringField(record: Record<string, unknown>, keys: readonly string[]): string | undefined {
114 for (const key of keys) {
115 const value = record[key];
116 if (typeof value === "string" && value.trim() !== "") {
117 return value.trim();
118 }
119 }
120 return undefined;
121 }
122
123 /**
124 * Pull a file path out of item metadata. The runtime puts tool arguments in
125 * `metadata.tool_input` as a JSON string, so a direct `metadata.path` lookup
126 * finds nothing for the file-change items that most want an Open button.
127 * Everything here is untrusted model output: parse failures are swallowed and
128 * callers must still validate the result before opening it.
129 */
130 export function extractFilePath(metadata: Record<string, unknown> | undefined): string | undefined {
131 if (!metadata) {
132 return undefined;
133 }
134 const direct = firstStringField(metadata, PATH_KEYS);
135 if (direct) {
136 return sanitizePath(direct);
137 }
138 const raw = metadata.tool_input ?? metadata.toolInput ?? metadata.input ?? metadata.arguments;
139 let parsed: unknown = raw;
140 if (typeof raw === "string") {
141 try {
142 parsed = JSON.parse(raw) as unknown;
143 } catch {
144 return undefined; // not JSON; nothing safe to offer
145 }
146 }
147 if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
148 return undefined;
149 }
150 const nested = firstStringField(parsed as Record<string, unknown>, PATH_KEYS);
151 return nested ? sanitizePath(nested) : undefined;
152 }
153
154 /** Reject paths carrying control characters or NULs before they reach the FS. */
155 function sanitizePath(value: string): string | undefined {
156 return /[\u0000-\u001f\u007f]/.test(value) ? undefined : value;
157 }
158
159 /**
160 * True when `candidate` resolves strictly inside `root`. Used to keep a
161 * model-supplied path from escaping the workspace via `..` or an absolute
162 * path somewhere else on disk.
163 */
164 export function isInsideRoot(root: string, candidate: string): boolean {
165 if (!root) {
166 return false;
167 }
168 const rootAbs = path.resolve(root);
169 // Relative candidates resolve against the root, not the process cwd.
170 const relative = path.relative(rootAbs, path.resolve(rootAbs, candidate));
171 return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
172 }
173
173 lines TYPESCRIPT