import { formatWorkspaceFilePathForDisplay } from "@tutti-os/workspace-file-manager/services"; import { fileChangePathsFromChanges } from "./workspaceAgentFileChangePayload.ts"; import type { AgentActivityMessage, AgentActivitySession } from "@tutti-os/agent-activity-core"; import type { CollectWorkspaceAgentGeneratedFilesOptions, WorkspaceAgentChangedFile } from "./workspaceAgentActivityListTypes"; import { workspaceAgentSessionMessageAliases } from "./workspaceAgentSessionMessageAliases.ts"; import { isImageGenerationToolCall } from "./imageGenerationTool.ts"; import { isInsideOrEqualWorkspaceFilePath, normalizeWorkspaceFilePath } from "../actions/workspaceFilePathCandidate"; export interface WorkspaceAgentGeneratedFilesSource { sessionMessagesById: Readonly>; sessions: readonly AgentActivitySession[]; } export function collectWorkspaceAgentGeneratedFiles( source: WorkspaceAgentGeneratedFilesSource, options: CollectWorkspaceAgentGeneratedFilesOptions = {} ): WorkspaceAgentChangedFile[] { const requestedWorkspaceRoot = normalizeComparablePath( options.workspaceRoot ?? "" ); const sessionCwdFilter = normalizeComparablePath( options.sessionCwd ?? "", requestedWorkspaceRoot ); const allowedAgentTargetIds = options.agentTargetIds ? new Set(options.agentTargetIds.map((id) => id.trim()).filter(Boolean)) : null; const provenanceSessions = allowedAgentTargetIds ? source.sessions.filter( (session) => session.agentTargetId !== null && allowedAgentTargetIds.has(session.agentTargetId) ) : source.sessions; const workspaceRoot = sessionCwdFilter || requestedWorkspaceRoot || resolveWorkspaceRootFromSessions(provenanceSessions); const sessions = sessionCwdFilter ? provenanceSessions.filter( (session) => normalizeComparablePath(session.cwd ?? "", workspaceRoot) === sessionCwdFilter ) : provenanceSessions; const filesByPath = new Map(); for (const session of sessions) { const sessionCwd = normalizeComparablePath(session.cwd ?? "", workspaceRoot) || workspaceRoot; const normalizePath = createAgentGeneratedFilePathNormalizer({ sessionCwd, workspaceRoot }); const messages = resolveWorkspaceAgentSessionMessages( source.sessionMessagesById, session ); if (messages.length === 0) { continue; } for (const file of changedFilesForSession(messages, normalizePath, { requireSuccessfulFileChangeTool: true })) { filesByPath.set(file.path, file); } for (const path of imageGenerationPathsFromMessages( messages, normalizePath )) { if (filesByPath.has(path)) { continue; } filesByPath.set(path, { path, label: path.split("/").filter(Boolean).at(-1) ?? path }); } } return applyShortestUniqueFileLabels([...filesByPath.values()]); } type ChangedFilePathNormalizer = (value: unknown) => string | null; interface ChangedFileCollectionOptions { canonicalFileChanges?: readonly Record[]; requireSuccessfulFileChangeTool?: boolean; } export function changedFilesForSession( messages: readonly AgentActivityMessage[], normalizePath: ChangedFilePathNormalizer = defaultChangedFilePathNormalizer, options: ChangedFileCollectionOptions = {} ): WorkspaceAgentChangedFile[] { const changedFilesByPath = new Map(); const appendPath = (path: string | null): void => { if (!path || changedFilesByPath.has(path)) { return; } changedFilesByPath.set(path, { path, label: path }); }; for (const fileChanges of options.canonicalFileChanges ?? []) { for (const path of canonicalFileChangePaths(fileChanges, normalizePath)) { appendPath(path); } } for (const message of messages) { for (const path of changedFilePathsFromMessage( message, normalizePath, options )) { appendPath(path); } } return applyShortestUniqueFileLabels(Array.from(changedFilesByPath.values())); } function canonicalFileChangePaths( fileChanges: Record, normalizePath: ChangedFilePathNormalizer ): string[] { const files = arrayValue(fileChanges.files); if ("files" in fileChanges) { return (files ?? []) .map((file) => normalizeCanonicalFileChangePath(objectValue(file)?.path, normalizePath) ) .filter((path): path is string => path !== null); } const paths = "changes" in fileChanges ? fileChangePathsFromChanges(fileChanges.changes) : "coverage" in fileChanges ? [] : fileChangePathsFromChanges(fileChanges); return paths .map((path) => normalizeCanonicalFileChangePath(path, normalizePath)) .filter((path): path is string => path !== null); } function normalizeCanonicalFileChangePath( value: unknown, normalizePath: ChangedFilePathNormalizer ): string | null { return ( normalizePath(value) ?? normalizedChangedFilePath(value, { allowRelative: true }) ); } function changedFilePathsFromMessage( message: AgentActivityMessage, normalizePath: ChangedFilePathNormalizer = defaultChangedFilePathNormalizer, options: ChangedFileCollectionOptions = {} ): string[] { const isSuccessfulFileChangeTool = isSuccessfulFileChangeToolMessage(message); if (options.requireSuccessfulFileChangeTool && !isSuccessfulFileChangeTool) { return []; } const payload = objectValue(message.payload); const explicitFileChanges = fileChangePaths( arrayValue(objectValue(payload?.fileChanges)?.files), normalizePath ); if (explicitFileChanges.length > 0) { return explicitFileChanges; } if (!isSuccessfulFileChangeTool) { return []; } const input = objectValue(payload?.input); const output = objectValue(payload?.output); const paths = dedupeStrings([ ...pathsValue(payload?.paths, normalizePath), ...pathsValue(output?.paths, normalizePath), ...pathsValue(input?.paths, normalizePath), ...changeMapPaths(payload?.changes, normalizePath), ...changeMapPaths(output?.changes, normalizePath), ...changeMapPaths(input?.changes, normalizePath), stringValue(payload?.path), stringValue(payload?.filePath), stringValue(payload?.file_path), stringValue(input?.path), stringValue(input?.filePath), stringValue(input?.file_path), stringValue(output?.path), stringValue(output?.filePath), stringValue(output?.file_path) ]); return paths .map((path) => normalizePath(path)) .filter((path): path is string => path !== null); } function defaultChangedFilePathNormalizer(value: unknown): string | null { return normalizedChangedFilePath(value); } function resolveWorkspaceRootFromSessions( sessions: readonly AgentActivitySession[] ): string { for (const session of sessions) { const cwd = normalizeComparablePath(session.cwd ?? ""); if (cwd) { return cwd; } } return ""; } function normalizeComparablePath(path: string, rootPath?: string): string { const trimmed = path.trim(); if (!trimmed) { return ""; } const normalizedInput = trimmed.replaceAll("\\", "/"); const normalized = normalizeWorkspaceFilePath( trimmed, rootPath?.trim().replaceAll("\\", "/") ); if ( /^\/[A-Za-z]:\//.test(normalized) && !/^[A-Za-z]:\//.test(normalizedInput) ) { return formatWorkspaceFilePathForDisplay(normalized, "win32") .replaceAll("\\", "/") .replace(/\/+$/, ""); } return normalizedInput.replace(/\/+$/, ""); } function createAgentGeneratedFilePathNormalizer(input: { sessionCwd?: string | null; workspaceRoot?: string | null; }): ChangedFilePathNormalizer { const workspaceRoot = normalizeComparablePath(input.workspaceRoot ?? ""); const sessionCwd = normalizeComparablePath(input.sessionCwd ?? "", workspaceRoot) || workspaceRoot; return (value: unknown) => { if (typeof value !== "string") { return null; } return resolveAgentGeneratedFilePath(value, workspaceRoot, sessionCwd); }; } function resolveAgentGeneratedFilePath( rawPath: string, workspaceRoot: string, sessionCwd: string ): string | null { // Agent activity payloads cross the daemon boundary using the host's native // separator. Normalize before checking virtual namespaces such as the // out-of-workspace generated-image directory; otherwise a Windows path is // rejected because its `\\` segments are invisible to the `/`-only check. const path = normalizeComparablePath(rawPath, workspaceRoot); if (!path || isStructuredPayloadPath(path)) { return null; } if (path.startsWith("/workspace/")) { return path; } if (isAgentStateGeneratedImagePath(path)) { return path; } if (isAbsoluteAgentGeneratedFilePath(path)) { if ( workspaceRoot && !isInsideOrEqualWorkspaceFilePath(path, workspaceRoot) && !isAgentStateGeneratedImagePath(path) ) { return null; } return path; } const base = sessionCwd || workspaceRoot; if (!base) { return null; } const resolved = joinAgentGeneratedFilePath(base, path.replace(/^\.?\//, "")); if ( workspaceRoot && !isInsideOrEqualWorkspaceFilePath(resolved, workspaceRoot) && !isAgentStateGeneratedImagePath(resolved) ) { return null; } return resolved; } function isAbsoluteAgentGeneratedFilePath(path: string): boolean { return path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path); } function joinAgentGeneratedFilePath( base: string, relativePath: string ): string { const normalizedBase = base.replace(/\\/g, "/").replace(/\/+$/, ""); const normalizedRelative = relativePath.replace(/\\/g, "/"); return `${normalizedBase}/${normalizedRelative}`; } function imageGenerationPathsFromMessages( messages: readonly AgentActivityMessage[], normalizePath: ChangedFilePathNormalizer ): string[] { const paths: string[] = []; for (const message of messages) { const payload = objectValue(message.payload); if (!payload) { continue; } const output = objectValue(payload.output); const savedPath = stringValue(output?.savedPath); const savedPaths = arrayValue(output?.savedPaths)?.flatMap((value) => { const path = stringValue(value); return path ? [path] : []; }) ?? []; if ( !isImageGenerationToolCall({ toolName: stringValue(payload.toolName), displayName: stringValue(payload.name), outputSavedPath: savedPath, outputSavedPaths: savedPaths, outputMimeType: output?.imageMimeType, outputText: output?.text, inputPrompt: objectValue(payload.input)?.prompt }) ) { continue; } for (const uri of dedupeStrings([savedPath, ...savedPaths])) { const normalized = normalizePath(uri); if (normalized) { paths.push(normalized); } } } return dedupeStrings(paths); } function isAgentStateGeneratedImagePath(path: string): boolean { const segments = path.split("/").filter(Boolean); const stateRootIndex = segments.findIndex( (segment) => segment === ".tutti" || segment === ".tutti-dev" ); if (stateRootIndex < 0) { return false; } const statePath = segments.slice(stateRootIndex); return ( statePath[1] === "agent" && statePath[2] === "runs" && statePath.includes("generated_images") ); } function normalizedChangedFilePath( value: unknown, options: { allowRelative?: boolean } = {} ): string | null { if (typeof value !== "string") { return null; } const path = value.trim(); if (!path || isStructuredPayloadPath(path)) { return null; } if (!options.allowRelative && !path.startsWith("/workspace/")) { return null; } return path; } function applyShortestUniqueFileLabels( files: WorkspaceAgentChangedFile[] ): WorkspaceAgentChangedFile[] { const suffixByPath = shortestUniquePathSuffixByPath( files.map((file) => file.path) ); return files.map((file) => ({ ...file, label: suffixByPath.get(file.path) ?? file.label })); } function shortestUniquePathSuffixByPath(paths: string[]): Map { const partsByPath = new Map( paths.map((path) => [path, splitPathSegments(path)]) ); const labels = new Map(); const unresolved = new Set(paths); for (let depth = 1; unresolved.size > 0; depth += 1) { const grouped = new Map(); for (const path of unresolved) { const parts = partsByPath.get(path) ?? []; const suffix = parts.slice(-Math.min(depth, parts.length)).join("/"); const fallback = parts.at(-1) ?? path; const label = suffix || fallback; const group = grouped.get(label); if (group) { group.push(path); } else { grouped.set(label, [path]); } } let resolvedCount = 0; for (const [label, group] of grouped) { if (group.length !== 1) { continue; } const path = group[0]; if (!path) { continue; } labels.set(path, label); unresolved.delete(path); resolvedCount += 1; } if (resolvedCount === 0) { for (const path of unresolved) { labels.set(path, path); } break; } } return labels; } function splitPathSegments(path: string): string[] { return path.trim().split(/[\\/]/).filter(Boolean); } function isSuccessfulFileChangeToolMessage( message: AgentActivityMessage ): boolean { if (normalizeToken(message.kind) !== "tool_call") { return false; } if (!isSuccessfulFileChangeStatus(message.status ?? undefined)) { return false; } const payload = objectValue(message.payload); if (!isSuccessfulFileChangePayloadStatus(payload)) { return false; } return hasFileChangeSignal(payload); } function isSuccessfulFileChangePayloadStatus( payload: Record | null ): boolean { if (!payload) { return true; } if (!isSuccessfulFileChangeRecordStatus(payload)) { return false; } const output = objectValue(payload.output); if (output && !isSuccessfulFileChangeRecordStatus(output)) { return false; } return true; } function isSuccessfulFileChangeRecordStatus( record: Record ): boolean { const status = stringValue(record.status); if (status && !isSuccessfulFileChangeStatus(status)) { return false; } const success = booleanValue(record.success); if (success === false) { return false; } return true; } function isSuccessfulFileChangeStatus(value: string | undefined): boolean { const normalizedStatus = normalizeToken(value); return ( !normalizedStatus || normalizedStatus === "completed" || normalizedStatus === "success" || normalizedStatus === "succeeded" || normalizedStatus === "ok" ); } function hasFileChangeSignal(payload: Record | null): boolean { if (!payload) { return false; } return [ payload, objectValue(payload.input), objectValue(payload.output) ].some((record) => record !== null && recordHasFileChangeToolSignal(record)); } function recordHasFileChangeToolSignal( record: Record ): boolean { const activityKind = stringValue(record.activityKind); if ( activityKind && isFileChangeNormalizedToolName(normalizeToolName(activityKind)) ) { return true; } if (stringValue(record.fileChangeKind)) { return true; } const toolCall = objectValue(record.toolCall); const toolCallKind = normalizeToken(stringValue(toolCall?.kind) ?? undefined); if ( toolCallKind === "write" || toolCallKind === "edit" || toolCallKind === "delete" ) { return true; } const toolName = normalizeToolName( stringValue(record.toolName) ?? stringValue(record.title) ?? stringValue(record.name) ?? "" ); return isFileChangeNormalizedToolName(toolName); } function isFileChangeNormalizedToolName(normalizedToolName: string): boolean { if (!normalizedToolName) { return false; } const exactMatches = new Set([ "write", "writefile", "create", "createfile", "delete", "deletefile", "edit", "editfile", "multiedit", "applypatch", "move", "notebookedit" ]); if (exactMatches.has(normalizedToolName)) { return true; } for (const prefix of exactMatches) { if (normalizedToolName.startsWith(`${prefix}/`)) { return true; } } return false; } function fileChangePaths( files: readonly unknown[] | null, normalizePath: ChangedFilePathNormalizer = defaultChangedFilePathNormalizer ): string[] { if (!files) { return []; } return files .map((file) => normalizePath(objectValue(file)?.path)) .filter((path): path is string => path !== null); } function changeMapPaths( value: unknown, normalizePath: ChangedFilePathNormalizer = defaultChangedFilePathNormalizer ): string[] { return fileChangePathsFromChanges(value) .map((path) => normalizePath(path)) .filter((path): path is string => path !== null); } function pathsValue( value: unknown, normalizePath: ChangedFilePathNormalizer = defaultChangedFilePathNormalizer ): string[] { const paths = arrayValue(value); if (!paths) { return []; } return paths .map((path) => normalizePath(path)) .filter((path): path is string => path !== null); } function objectValue(value: unknown): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } return value as Record; } function arrayValue(value: unknown): readonly unknown[] | null { return Array.isArray(value) ? value : null; } function stringValue(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } function booleanValue(value: unknown): boolean | null { return typeof value === "boolean" ? value : null; } function dedupeStrings(values: Array): string[] { return [ ...new Set( values.filter( (value): value is string => typeof value === "string" && value.length > 0 ) ) ]; } function normalizeToolName(value: string): string { return value .trim() .toLowerCase() .replaceAll("_", "") .replaceAll("-", "") .replaceAll(" ", ""); } function isStructuredPayloadPath(path: string): boolean { if (/[\r\n]/.test(path)) { return true; } if (!path.startsWith("{") && !path.startsWith("[")) { return false; } try { JSON.parse(path); return true; } catch { return false; } } function resolveWorkspaceAgentSessionMessages( sessionMessagesById: Record | undefined, session: AgentActivitySession ): AgentActivityMessage[] { if (!sessionMessagesById) { return []; } for (const alias of workspaceAgentSessionMessageAliases(session)) { const messages = sessionMessagesById[alias]; if (messages) { return messages; } } return []; } function normalizeToken(value: string | undefined): string { return value?.trim().toLowerCase() ?? ""; }