import type { AssistantContentBlock, ToolResultMessage } from "./types"; import { resolveLocalFilePath } from "./file-links"; import { isEditToolName, isWriteToolName } from "./tool-names"; export interface WrittenFile { /** Resolved absolute path of a file this turn wrote. */ filePath: string; } function isFileWritingToolName(toolName: string): boolean { return isWriteToolName(toolName) || isEditToolName(toolName); } function readToolPath(input: Record | undefined): string | null { if (!input) return null; const value = input.file_path ?? input.path; return typeof value === "string" && value.length > 0 ? value : null; } /** * Collect the distinct files a single assistant turn actually wrote. * * Every entry is derived from a `write`/`edit` tool call whose result arrived * and did not error — never from the reply text. A path the assistant merely * mentions in prose is not evidence that any file was touched, so it is not a * source here; the tool call is the record of what happened. * * Paths are resolved against `cwd`, deduped, and kept in first-seen order. */ export function extractTurnWrittenFiles( content: AssistantContentBlock[], toolResults: Map | undefined, cwd?: string, ): WrittenFile[] { const seen = new Set(); const writtenFiles: WrittenFile[] = []; for (const block of content) { if (block.type !== "toolCall") continue; if (!isFileWritingToolName(block.toolName)) continue; // No result yet (still streaming) or the call failed — nothing was written. const result = toolResults?.get(block.toolCallId); if (!result || result.isError) continue; const rawPath = readToolPath(block.input); if (!rawPath) continue; // Tool arguments are filesystem paths, not hrefs: preserve characters such // as #, ?, and :digits that have special meaning in links and source refs. const filePath = resolveLocalFilePath(rawPath, cwd); if (!filePath) continue; if (seen.has(filePath)) continue; seen.add(filePath); writtenFiles.push({ filePath }); } return writtenFiles; }