From f8fe0e61ae6153b06a71ae0331f19f99b76bf2fe Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 17 Jan 2026 18:38:34 -0600 Subject: [PATCH 1/2] refactor: centralize tool ui-only output --- .../tools/AskUserQuestionToolCall.tsx | 10 +- src/browser/components/tools/BashToolCall.tsx | 12 +- .../components/tools/FileEditToolCall.tsx | 15 +- .../tools/shared/ToolPrimitives.tsx | 32 ++- .../components/tools/shared/toolUtils.tsx | 19 +- src/browser/stores/GitStatusStore.ts | 3 + .../messages/StreamingMessageAggregator.ts | 10 +- .../messages/applyToolOutputRedaction.ts | 6 +- .../utils/messages/toolOutputRedaction.ts | 185 ------------------ src/common/orpc/schemas/tools.ts | 83 +++++--- src/common/types/tools.ts | 42 +++- .../utils/tools/askUserQuestionSummary.ts | 9 + src/common/utils/tools/toolDefinitions.ts | 120 +++++++++--- src/common/utils/tools/toolOutputUiOnly.ts | 157 +++++++++++++++ src/node/services/mock/mockAiRouter.ts | 6 +- src/node/services/tools/ask_user_question.ts | 31 ++- src/node/services/tools/bash.ts | 3 + src/node/services/tools/file_edit_insert.ts | 13 +- .../services/tools/file_edit_operation.ts | 13 +- .../services/tools/file_edit_replace_lines.ts | 8 +- src/node/services/tools/notify.test.ts | 6 +- src/node/services/tools/notify.ts | 16 +- src/node/services/workspaceService.ts | 10 +- 23 files changed, 508 insertions(+), 301 deletions(-) delete mode 100644 src/browser/utils/messages/toolOutputRedaction.ts create mode 100644 src/common/utils/tools/askUserQuestionSummary.ts create mode 100644 src/common/utils/tools/toolOutputUiOnly.ts diff --git a/src/browser/components/tools/AskUserQuestionToolCall.tsx b/src/browser/components/tools/AskUserQuestionToolCall.tsx index db25b5c2a8..7eee9883a9 100644 --- a/src/browser/components/tools/AskUserQuestionToolCall.tsx +++ b/src/browser/components/tools/AskUserQuestionToolCall.tsx @@ -26,9 +26,10 @@ import type { AskUserQuestionQuestion, AskUserQuestionToolArgs, AskUserQuestionToolResult, - AskUserQuestionToolSuccessResult, + AskUserQuestionUiOnlyPayload, ToolErrorResult, } from "@/common/types/tools"; +import { getToolOutputUiOnly } from "@/common/utils/tools/toolOutputUiOnly"; const OTHER_VALUE = "__other__"; @@ -58,7 +59,7 @@ function unwrapJsonContainer(value: unknown): unknown { return value; } -function isAskUserQuestionToolSuccessResult(val: unknown): val is AskUserQuestionToolSuccessResult { +function isAskUserQuestionPayload(val: unknown): val is AskUserQuestionUiOnlyPayload { if (!val || typeof val !== "object") { return false; } @@ -261,8 +262,11 @@ export function AskUserQuestionToolCall(props: { return unwrapJsonContainer(props.result); }, [props.result]); + const uiOnlyPayload = getToolOutputUiOnly(resultUnwrapped)?.ask_user_question; + const successResult = - resultUnwrapped && isAskUserQuestionToolSuccessResult(resultUnwrapped) ? resultUnwrapped : null; + uiOnlyPayload ?? + (resultUnwrapped && isAskUserQuestionPayload(resultUnwrapped) ? resultUnwrapped : null); const errorResult = resultUnwrapped && isToolErrorResult(resultUnwrapped) ? resultUnwrapped : null; diff --git a/src/browser/components/tools/BashToolCall.tsx b/src/browser/components/tools/BashToolCall.tsx index e272de4cf1..cc429013ff 100644 --- a/src/browser/components/tools/BashToolCall.tsx +++ b/src/browser/components/tools/BashToolCall.tsx @@ -19,6 +19,7 @@ import { useToolExpansion, getStatusDisplay, formatDuration, + getToolOutputSeverity, type ToolStatus, } from "./shared/toolUtils"; import { cn } from "@/common/lib/utils"; @@ -192,6 +193,7 @@ export const BashToolCall: React.FC = ({ const showLiveOutput = !isBackground && (status === "executing" || (Boolean(liveOutput) && !resultHasOutput)); + const severity = getToolOutputSeverity(result); const truncatedInfo = result && "truncated" in result ? result.truncated : undefined; const handleToggle = () => { @@ -224,11 +226,13 @@ export const BashToolCall: React.FC = ({ {result && ` • took ${formatDuration(result.wall_duration_ms)}`} {!result && } - {result && } + {result && ( + + )} )} - - {getStatusDisplay(effectiveStatus)} + + {getStatusDisplay(effectiveStatus, severity)} {/* Show "Background" button when bash is executing and can be sent to background. Use invisible when executing but not yet confirmed as foreground to avoid layout flash. */} @@ -295,7 +299,7 @@ export const BashToolCall: React.FC = ({ {result.success === false && result.error && ( Error - {result.error} + {result.error} )} diff --git a/src/browser/components/tools/FileEditToolCall.tsx b/src/browser/components/tools/FileEditToolCall.tsx index 5f49ed89e7..edaed2fc80 100644 --- a/src/browser/components/tools/FileEditToolCall.tsx +++ b/src/browser/components/tools/FileEditToolCall.tsx @@ -9,6 +9,7 @@ import type { FileEditReplaceLinesToolArgs, FileEditReplaceLinesToolResult, } from "@/common/types/tools"; +import { getToolOutputUiOnly } from "@/common/utils/tools/toolOutputUiOnly"; import { ToolContainer, ToolHeader, @@ -104,6 +105,8 @@ export const FileEditToolCall: React.FC = ({ const { expanded, toggleExpanded } = useToolExpansion(initialExpanded); const [showRaw, setShowRaw] = React.useState(false); + const uiOnlyDiff = getToolOutputUiOnly(result)?.file_edit?.diff; + const diff = result && result.success ? (uiOnlyDiff ?? result.diff) : undefined; const filePath = "file_path" in args ? args.file_path : undefined; // Copy to clipboard with feedback @@ -111,11 +114,11 @@ export const FileEditToolCall: React.FC = ({ // Build kebab menu items for successful edits with diffs const kebabMenuItems: KebabMenuItem[] = - result && result.success && result.diff + result && result.success && diff ? [ { label: copied ? "✓ Copied" : "Copy Patch", - onClick: () => void copyToClipboard(result.diff), + onClick: () => void copyToClipboard(diff), }, { label: showRaw ? "Show Parsed" : "Show Patch", @@ -139,7 +142,7 @@ export const FileEditToolCall: React.FC = ({ {filePath} - {!(result && result.success && result.diff) && ( + {!(result && result.success && diff) && ( {getStatusDisplay(status)} )} {kebabMenuItems.length > 0 && ( @@ -161,15 +164,15 @@ export const FileEditToolCall: React.FC = ({ )} {result.success && - result.diff && + diff && (showRaw ? (
-                      {result.diff}
+                      {diff}
                     
) : ( - renderDiff(result.diff, filePath, onReviewNote) + renderDiff(diff, filePath, onReviewNote) ))} )} diff --git a/src/browser/components/tools/shared/ToolPrimitives.tsx b/src/browser/components/tools/shared/ToolPrimitives.tsx index 37036429a5..a5676a16d2 100644 --- a/src/browser/components/tools/shared/ToolPrimitives.tsx +++ b/src/browser/components/tools/shared/ToolPrimitives.tsx @@ -1,3 +1,4 @@ +import type { ToolOutputSeverity } from "@/common/types/tools"; import React from "react"; import { cn } from "@/common/lib/utils"; import { Tooltip, TooltipTrigger, TooltipContent } from "../../ui/tooltip"; @@ -58,16 +59,17 @@ export const ToolName: React.FC> = ({ interface StatusIndicatorProps extends React.HTMLAttributes { status: string; + severity?: ToolOutputSeverity; } -const getStatusColor = (status: string) => { +const getStatusColor = (status: string, severity?: ToolOutputSeverity) => { switch (status) { case "executing": return "text-pending"; case "completed": return "text-success"; case "failed": - return "text-danger"; + return severity === "soft" ? "text-warning" : "text-danger"; case "interrupted": return "text-interrupted"; case "backgrounded": @@ -79,6 +81,7 @@ const getStatusColor = (status: string) => { export const StatusIndicator: React.FC = ({ status, + severity, className, children, ...props @@ -87,7 +90,7 @@ export const StatusIndicator: React.FC = ({ className={cn( "text-[10px] ml-auto opacity-80 whitespace-nowrap shrink-0", "[&_.status-text]:inline [@container(max-width:350px)]:[&_.status-text]:hidden", - getStatusColor(status), + getStatusColor(status, severity), className )} {...props} @@ -182,13 +185,17 @@ export const ToolIcon: React.FC = ({ emoji, toolName }) => ( /** * Error display box with danger styling */ -export const ErrorBox: React.FC> = ({ - className, - ...props -}) => ( +interface ErrorBoxProps extends React.HTMLAttributes { + severity?: ToolOutputSeverity; +} + +export const ErrorBox: React.FC = ({ className, severity, ...props }) => (
> = ({ interface ExitCodeBadgeProps { exitCode: number; className?: string; + severity?: ToolOutputSeverity; } -export const ExitCodeBadge: React.FC = ({ exitCode, className }) => ( +export const ExitCodeBadge: React.FC = ({ exitCode, className, severity }) => ( diff --git a/src/browser/components/tools/shared/toolUtils.tsx b/src/browser/components/tools/shared/toolUtils.tsx index b738a361a9..3d930de1c9 100644 --- a/src/browser/components/tools/shared/toolUtils.tsx +++ b/src/browser/components/tools/shared/toolUtils.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react"; +import type { ToolErrorResult, ToolOutputSeverity } from "@/common/types/tools"; +import { getToolOutputUiOnly } from "@/common/utils/tools/toolOutputUiOnly"; import { LoadingDots } from "./ToolPrimitives"; -import type { ToolErrorResult } from "@/common/types/tools"; /** * Shared utilities and hooks for tool components @@ -26,7 +27,10 @@ export function useToolExpansion(initialExpanded = false) { /** * Get display element for tool status */ -export function getStatusDisplay(status: ToolStatus): React.ReactNode { +export function getStatusDisplay( + status: ToolStatus, + severity?: ToolOutputSeverity +): React.ReactNode { switch (status) { case "executing": return ( @@ -41,6 +45,13 @@ export function getStatusDisplay(status: ToolStatus): React.ReactNode { ); case "failed": + if (severity === "soft") { + return ( + <> + ⚠ warning + + ); + } return ( <> ✗ failed @@ -88,6 +99,10 @@ export function formatDuration(ms: number): string { return `${Math.round(ms / 3600000)}h`; } +export function getToolOutputSeverity(output: unknown): ToolOutputSeverity | undefined { + return getToolOutputUiOnly(output)?.severity; +} + /** * Type guard for ToolErrorResult shape: { success: false, error: string }. * Use this when you need type narrowing to access error. diff --git a/src/browser/stores/GitStatusStore.ts b/src/browser/stores/GitStatusStore.ts index 18b350fdfc..603f0ba189 100644 --- a/src/browser/stores/GitStatusStore.ts +++ b/src/browser/stores/GitStatusStore.ts @@ -11,6 +11,7 @@ import { STORAGE_KEYS, WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults" import { useSyncExternalStore } from "react"; import { MapStore } from "./MapStore"; import { isSSHRuntime } from "@/common/types/runtime"; +import { getToolOutputUiOnly } from "@/common/utils/tools/toolOutputUiOnly"; import { RefreshController } from "@/browser/utils/RefreshController"; /** @@ -324,8 +325,10 @@ export class GitStatusStore { } if (!result.data.success) { + const uiOnlySeverity = getToolOutputUiOnly(result.data)?.severity; // Don't log output overflow errors at all (common in large repos, handled gracefully) if ( + uiOnlySeverity !== "soft" && !result.data.error?.includes("OUTPUT TRUNCATED") && !result.data.error?.includes("OUTPUT OVERFLOW") ) { diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 4759c441dc..8e5ee15076 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -22,6 +22,7 @@ import type { } from "@/common/types/stream"; import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import type { TodoItem, StatusSetToolResult, NotifyToolResult } from "@/common/types/tools"; +import { getToolOutputUiOnly } from "@/common/utils/tools/toolOutputUiOnly"; import type { WorkspaceChatMessage, StreamErrorMessage, DeleteMessage } from "@/common/orpc/types"; import { isInitStart, isInitOutput, isInitEnd, isMuxMessage } from "@/common/orpc/types"; @@ -1416,8 +1417,13 @@ export class StreamingMessageAggregator { // Handle browser notifications when Electron wasn't available if (toolName === "notify" && hasSuccessResult(output)) { const result = output as Extract; - if (result.notifiedVia === "browser") { - this.sendBrowserNotification(result.title, result.message, result.workspaceId); + const uiOnlyNotify = getToolOutputUiOnly(output)?.notify; + const legacyNotify = output as { notifiedVia?: string; workspaceId?: string }; + const notifiedVia = uiOnlyNotify?.notifiedVia ?? legacyNotify.notifiedVia; + const workspaceId = uiOnlyNotify?.workspaceId ?? legacyNotify.workspaceId; + + if (notifiedVia === "browser") { + this.sendBrowserNotification(result.title, result.message, workspaceId); } } diff --git a/src/browser/utils/messages/applyToolOutputRedaction.ts b/src/browser/utils/messages/applyToolOutputRedaction.ts index ee3171d61d..bd2290d147 100644 --- a/src/browser/utils/messages/applyToolOutputRedaction.ts +++ b/src/browser/utils/messages/applyToolOutputRedaction.ts @@ -1,9 +1,9 @@ /** - * Apply centralized tool-output redaction to a list of MuxMessages. + * Strip UI-only tool output before sending to providers. * Produces a cloned array safe for sending to providers without touching persisted history/UI. */ import type { MuxMessage } from "@/common/types/message"; -import { redactToolOutput } from "./toolOutputRedaction"; +import { stripToolOutputUiOnly } from "@/common/utils/tools/toolOutputUiOnly"; export function applyToolOutputRedaction(messages: MuxMessage[]): MuxMessage[] { return messages.map((msg) => { @@ -15,7 +15,7 @@ export function applyToolOutputRedaction(messages: MuxMessage[]): MuxMessage[] { return { ...part, - output: redactToolOutput(part.toolName, part.output), + output: stripToolOutputUiOnly(part.output), }; }); diff --git a/src/browser/utils/messages/toolOutputRedaction.ts b/src/browser/utils/messages/toolOutputRedaction.ts deleted file mode 100644 index a30bd72ac7..0000000000 --- a/src/browser/utils/messages/toolOutputRedaction.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Centralized registry for redacting heavy tool outputs before sending to providers. - * - * Phase 1 policy: - * - Keep tool results intact in persisted history and UI. - * - When building provider requests, redact/compact known heavy fields. - * - * Why centralize: - * - Single source of truth for redaction logic. - * - Type safety: if a tool's result type changes, these redactors should fail type-checks. - */ - -import type { - AskUserQuestionToolSuccessResult, - FileEditInsertToolResult, - FileEditReplaceStringToolResult, - FileEditReplaceLinesToolResult, - NotifyToolResult, -} from "@/common/types/tools"; - -// Tool-output from AI SDK is often wrapped like: { type: 'json', value: } -// Keep this helper local so all redactors handle both wrapped and plain objects consistently. -function unwrapJsonContainer(output: unknown): { wrapped: boolean; value: unknown } { - if (output && typeof output === "object" && "type" in output && "value" in output) { - const obj = output as { type: unknown; value: unknown }; - if (obj.type === "json") { - return { wrapped: true, value: obj.value }; - } - } - return { wrapped: false, value: output }; -} - -function rewrapJsonContainer(wrapped: boolean, value: unknown): unknown { - if (wrapped) { - const result: { type: string; value: unknown } = { type: "json", value }; - return result; - } - return value; -} - -function isFileEditInsertResult(v: unknown): v is FileEditInsertToolResult { - return ( - typeof v === "object" && - v !== null && - "success" in v && - typeof (v as { success: unknown }).success === "boolean" - ); -} -// Narrowing helpers for our tool result types -function isFileEditResult( - v: unknown -): v is FileEditReplaceStringToolResult | FileEditReplaceLinesToolResult { - return ( - typeof v === "object" && - v !== null && - "success" in v && - typeof (v as { success: unknown }).success === "boolean" - ); -} - -// Redactors per tool - unified for both string and line replace -function redactFileEditReplace(output: unknown): unknown { - const unwrapped = unwrapJsonContainer(output); - const val = unwrapped.value; - - if (!isFileEditResult(val)) return output; // unknown structure, leave as-is - - if (val.success && "edits_applied" in val) { - // Build base compact result - const compact: Record = { - success: true, - edits_applied: val.edits_applied, - diff: "[diff omitted in context - call file_read on the target file if needed]", - }; - - // Preserve line metadata for line-based edits (only if present) - if ("lines_replaced" in val) { - compact.lines_replaced = val.lines_replaced; - } - if ("line_delta" in val) { - compact.line_delta = val.line_delta; - } - - return rewrapJsonContainer(unwrapped.wrapped, compact); - } - - // Failure payloads are small; pass through unchanged - return output; -} - -function redactFileEditInsert(output: unknown): unknown { - const unwrapped = unwrapJsonContainer(output); - const val = unwrapped.value; - - if (!isFileEditInsertResult(val)) return output; - - if (val.success) { - const compact: FileEditInsertToolResult = { - success: true, - diff: "[diff omitted in context - call file_read on the target file if needed]", - }; - return rewrapJsonContainer(unwrapped.wrapped, compact); - } - - return output; -} - -function isAskUserQuestionToolSuccessResult(val: unknown): val is AskUserQuestionToolSuccessResult { - if (!val || typeof val !== "object") return false; - const record = val as Record; - - if (!Array.isArray(record.questions)) return false; - if (!record.answers || typeof record.answers !== "object") return false; - - // answers is Record - for (const [k, v] of Object.entries(record.answers as Record)) { - if (typeof k !== "string" || typeof v !== "string") return false; - } - - return true; -} - -function redactAskUserQuestion(output: unknown): unknown { - const unwrapped = unwrapJsonContainer(output); - const val = unwrapped.value; - - if (!isAskUserQuestionToolSuccessResult(val)) { - return output; - } - - const pairs = Object.entries(val.answers) - .map(([question, answer]) => `"${question}"="${answer}"`) - .join(", "); - - const summary = - pairs.length > 0 - ? `User has answered your questions: ${pairs}. You can now continue with the user's answers in mind.` - : "User has answered your questions. You can now continue with the user's answers in mind."; - - return rewrapJsonContainer(unwrapped.wrapped, summary); -} - -function isNotifyResult(val: unknown): val is NotifyToolResult { - return ( - typeof val === "object" && - val !== null && - "success" in val && - typeof (val as { success: unknown }).success === "boolean" - ); -} - -/** - * Redact notify tool output to remove internal routing fields (notifiedVia, workspaceId). - * The model only needs to know the notification succeeded. - */ -function redactNotify(output: unknown): unknown { - const unwrapped = unwrapJsonContainer(output); - const val = unwrapped.value; - - if (!isNotifyResult(val)) return output; - - if (val.success) { - return rewrapJsonContainer(unwrapped.wrapped, { success: true }); - } - - // Failure payloads pass through unchanged - return output; -} - -// Public API - registry entrypoint. Add new tools here as needed. -export function redactToolOutput(toolName: string, output: unknown): unknown { - switch (toolName) { - case "ask_user_question": - return redactAskUserQuestion(output); - case "file_edit_replace_string": - case "file_edit_replace_lines": - return redactFileEditReplace(output); - case "file_edit_insert": - return redactFileEditInsert(output); - case "notify": - return redactNotify(output); - default: - return output; - } -} diff --git a/src/common/orpc/schemas/tools.ts b/src/common/orpc/schemas/tools.ts index f8fc19c770..0ab37dc848 100644 --- a/src/common/orpc/schemas/tools.ts +++ b/src/common/orpc/schemas/tools.ts @@ -1,33 +1,62 @@ import { z } from "zod"; +const ToolOutputUiOnlySchema = z.object({ + severity: z.enum(["soft", "hard"]).optional(), + ask_user_question: z + .object({ + questions: z.array(z.unknown()), + answers: z.record(z.string(), z.string()), + }) + .optional(), + file_edit: z + .object({ + diff: z.string(), + }) + .optional(), + notify: z + .object({ + notifiedVia: z.enum(["electron", "browser"]), + workspaceId: z.string().optional(), + }) + .optional(), +}); + +const ToolOutputUiOnlyFieldSchema = { + ui_only: ToolOutputUiOnlySchema.optional(), +}; + export const BashToolResultSchema = z.discriminatedUnion("success", [ - z.object({ - success: z.literal(true), - wall_duration_ms: z.number(), - output: z.string(), - exitCode: z.literal(0), - note: z.string().optional(), - truncated: z - .object({ - reason: z.string(), - totalLines: z.number(), - }) - .optional(), - }), - z.object({ - success: z.literal(false), - wall_duration_ms: z.number(), - output: z.string().optional(), - exitCode: z.number(), - error: z.string(), - note: z.string().optional(), - truncated: z - .object({ - reason: z.string(), - totalLines: z.number(), - }) - .optional(), - }), + z + .object({ + success: z.literal(true), + wall_duration_ms: z.number(), + output: z.string(), + exitCode: z.literal(0), + note: z.string().optional(), + truncated: z + .object({ + reason: z.string(), + totalLines: z.number(), + }) + .optional(), + }) + .extend(ToolOutputUiOnlyFieldSchema), + z + .object({ + success: z.literal(false), + wall_duration_ms: z.number(), + output: z.string().optional(), + exitCode: z.number(), + error: z.string(), + note: z.string().optional(), + truncated: z + .object({ + reason: z.string(), + totalLines: z.number(), + }) + .optional(), + }) + .extend(ToolOutputUiOnlyFieldSchema), ]); export const FileTreeNodeSchema = z.object({ diff --git a/src/common/types/tools.ts b/src/common/types/tools.ts index 864362afa5..6531e3b06d 100644 --- a/src/common/types/tools.ts +++ b/src/common/types/tools.ts @@ -54,16 +54,45 @@ export type AgentSkillReadFileToolArgs = z.infer< >; export type AgentSkillReadFileToolResult = z.infer; +export type ToolOutputSeverity = "soft" | "hard"; + +export interface AskUserQuestionUiOnlyPayload { + questions: AskUserQuestionQuestion[]; + answers: Record; +} + +export interface FileEditUiOnlyPayload { + diff: string; +} + +export interface NotifyUiOnlyPayload { + notifiedVia: "electron" | "browser"; + workspaceId?: string; +} + +export interface ToolOutputUiOnly { + severity?: ToolOutputSeverity; + ask_user_question?: AskUserQuestionUiOnlyPayload; + file_edit?: FileEditUiOnlyPayload; + notify?: NotifyUiOnlyPayload; +} + +export interface ToolOutputUiOnlyFields { + ui_only?: ToolOutputUiOnly; +} // FileReadToolResult derived from Zod schema (single source of truth) export type FileReadToolResult = z.infer; -export interface FileEditDiffSuccessBase { +export interface FileEditDiffSuccessBase extends ToolOutputUiOnlyFields { success: true; diff: string; warning?: string; } -export interface FileEditErrorResult { +export const FILE_EDIT_DIFF_OMITTED_MESSAGE = + "[diff omitted in context - call file_read on the target file if needed]"; + +export interface FileEditErrorResult extends ToolOutputUiOnlyFields { success: false; error: string; note?: string; // Agent-only message (not displayed in UI) @@ -147,7 +176,7 @@ export const TOOL_EDIT_WARNING = "Always check the tool result before proceeding with other operations."; // Generic tool error shape emitted via streamManager on tool-error parts. -export interface ToolErrorResult { +export interface ToolErrorResult extends ToolOutputUiOnlyFields { success: false; error: string; } @@ -313,14 +342,11 @@ export type WebFetchToolResult = z.infer; // Notify Tool Types export type NotifyToolResult = - | { + | (ToolOutputUiOnlyFields & { success: true; - notifiedVia: "electron" | "browser"; title: string; message?: string; - /** Workspace ID for navigation on notification click */ - workspaceId?: string; - } + }) | { success: false; error: string; diff --git a/src/common/utils/tools/askUserQuestionSummary.ts b/src/common/utils/tools/askUserQuestionSummary.ts new file mode 100644 index 0000000000..b2f239eab8 --- /dev/null +++ b/src/common/utils/tools/askUserQuestionSummary.ts @@ -0,0 +1,9 @@ +export function buildAskUserQuestionSummary(answers: Record): string { + const pairs = Object.entries(answers) + .map(([question, answer]) => `"${question}"="${answer}"`) + .join(", "); + + return pairs.length > 0 + ? `User has answered your questions: ${pairs}. You can now continue with the user's answers in mind.` + : "User has answered your questions. You can now continue with the user's answers in mind."; +} diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index 25aae4d43b..54ab6f8df9 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -58,6 +58,31 @@ export const AskUserQuestionQuestionSchema = z } }); +const AskUserQuestionUiOnlySchema = z.object({ + questions: z.array(AskUserQuestionQuestionSchema), + answers: z.record(z.string(), z.string()), +}); + +const ToolOutputUiOnlySchema = z.object({ + severity: z.enum(["soft", "hard"]).optional(), + ask_user_question: AskUserQuestionUiOnlySchema.optional(), + file_edit: z + .object({ + diff: z.string(), + }) + .optional(), + notify: z + .object({ + notifiedVia: z.enum(["electron", "browser"]), + workspaceId: z.string().optional(), + }) + .optional(), +}); + +const ToolOutputUiOnlyFieldSchema = { + ui_only: ToolOutputUiOnlySchema.optional(), +}; + export const AskUserQuestionToolArgsSchema = z .object({ questions: z.array(AskUserQuestionQuestionSchema).min(1).max(4), @@ -77,13 +102,24 @@ export const AskUserQuestionToolArgsSchema = z } }); -export const AskUserQuestionToolResultSchema = z +const AskUserQuestionToolSummarySchema = z + .object({ + summary: z.string(), + }) + .extend(ToolOutputUiOnlyFieldSchema); + +const AskUserQuestionToolLegacySchema = z .object({ questions: z.array(AskUserQuestionQuestionSchema), answers: z.record(z.string(), z.string()), }) .strict(); +export const AskUserQuestionToolResultSchema = z.union([ + AskUserQuestionToolSummarySchema, + AskUserQuestionToolLegacySchema, +]); + // ----------------------------------------------------------------------------- // task (sub-workspaces as subagents) // ----------------------------------------------------------------------------- @@ -816,27 +852,30 @@ const TruncatedInfoSchema = z.object({ /** * Bash tool result - success, background spawn, or failure. */ -export const BashToolResultSchema = z.union([ - // Foreground success - z.object({ +const BashToolSuccessSchema = z + .object({ success: z.literal(true), output: z.string(), exitCode: z.literal(0), wall_duration_ms: z.number(), note: z.string().optional(), truncated: TruncatedInfoSchema.optional(), - }), - // Background spawn success - z.object({ + }) + .extend(ToolOutputUiOnlyFieldSchema); + +const BashToolBackgroundSchema = z + .object({ success: z.literal(true), output: z.string(), exitCode: z.literal(0), wall_duration_ms: z.number(), taskId: z.string(), backgroundProcessId: z.string(), - }), - // Failure - z.object({ + }) + .extend(ToolOutputUiOnlyFieldSchema); + +const BashToolFailureSchema = z + .object({ success: z.literal(false), output: z.string().optional(), exitCode: z.number(), @@ -844,7 +883,16 @@ export const BashToolResultSchema = z.union([ wall_duration_ms: z.number(), note: z.string().optional(), truncated: TruncatedInfoSchema.optional(), - }), + }) + .extend(ToolOutputUiOnlyFieldSchema); + +export const BashToolResultSchema = z.union([ + // Foreground success + BashToolSuccessSchema, + // Background spawn success + BashToolBackgroundSchema, + // Failure + BashToolFailureSchema, ]); /** @@ -945,33 +993,41 @@ export const AgentSkillReadFileToolResultSchema = FileReadToolResultSchema; * File edit insert tool result - diff or error. */ export const FileEditInsertToolResultSchema = z.union([ - z.object({ - success: z.literal(true), - diff: z.string(), - warning: z.string().optional(), - }), - z.object({ - success: z.literal(false), - error: z.string(), - note: z.string().optional(), - }), + z + .object({ + success: z.literal(true), + diff: z.string(), + warning: z.string().optional(), + }) + .extend(ToolOutputUiOnlyFieldSchema), + z + .object({ + success: z.literal(false), + error: z.string(), + note: z.string().optional(), + }) + .extend(ToolOutputUiOnlyFieldSchema), ]); /** * File edit replace string tool result - diff with edit count or error. */ export const FileEditReplaceStringToolResultSchema = z.union([ - z.object({ - success: z.literal(true), - diff: z.string(), - edits_applied: z.number(), - warning: z.string().optional(), - }), - z.object({ - success: z.literal(false), - error: z.string(), - note: z.string().optional(), - }), + z + .object({ + success: z.literal(true), + diff: z.string(), + edits_applied: z.number(), + warning: z.string().optional(), + }) + .extend(ToolOutputUiOnlyFieldSchema), + z + .object({ + success: z.literal(false), + error: z.string(), + note: z.string().optional(), + }) + .extend(ToolOutputUiOnlyFieldSchema), ]); /** diff --git a/src/common/utils/tools/toolOutputUiOnly.ts b/src/common/utils/tools/toolOutputUiOnly.ts new file mode 100644 index 0000000000..3558e7df39 --- /dev/null +++ b/src/common/utils/tools/toolOutputUiOnly.ts @@ -0,0 +1,157 @@ +import type { + AskUserQuestionUiOnlyPayload, + FileEditUiOnlyPayload, + NotifyUiOnlyPayload, + ToolOutputUiOnly, +} from "@/common/types/tools"; + +export const TOOL_OUTPUT_UI_ONLY_FIELD = "ui_only" as const; + +interface JsonContainer { + type: "json"; + value: unknown; +} + +function unwrapJsonContainer(output: unknown): { wrapped: boolean; value: unknown } { + if (output && typeof output === "object" && "type" in output && "value" in output) { + const record = output as { type?: unknown; value?: unknown }; + if (record.type === "json") { + return { wrapped: true, value: record.value }; + } + } + return { wrapped: false, value: output }; +} + +function rewrapJsonContainer(wrapped: boolean, value: unknown): unknown { + if (!wrapped) { + return value; + } + + const container: JsonContainer = { + type: "json", + value, + }; + return container; +} + +function stripUiOnlyDeep(value: unknown): unknown { + if (!value || typeof value !== "object") { + return value; + } + + if (Array.isArray(value)) { + return value.map(stripUiOnlyDeep); + } + + const record = value as Record; + const stripped: Record = {}; + + for (const [key, nested] of Object.entries(record)) { + if (key === TOOL_OUTPUT_UI_ONLY_FIELD) { + continue; + } + stripped[key] = stripUiOnlyDeep(nested); + } + + return stripped; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function isStringRecord(value: unknown): value is Record { + if (!isRecord(value)) { + return false; + } + + return Object.values(value).every((entry) => typeof entry === "string"); +} + +function isAskUserQuestionUiOnly(value: unknown): value is AskUserQuestionUiOnlyPayload { + if (!isRecord(value)) { + return false; + } + + if (!Array.isArray(value.questions)) { + return false; + } + + return isStringRecord(value.answers); +} + +function isFileEditUiOnly(value: unknown): value is FileEditUiOnlyPayload { + return isRecord(value) && typeof value.diff === "string"; +} + +function isNotifyUiOnly(value: unknown): value is NotifyUiOnlyPayload { + if (!isRecord(value)) { + return false; + } + + const notifiedVia = value.notifiedVia; + if (notifiedVia !== "electron" && notifiedVia !== "browser") { + return false; + } + + if ( + "workspaceId" in value && + value.workspaceId !== undefined && + typeof value.workspaceId !== "string" + ) { + return false; + } + + return true; +} + +function isUiOnlyRecord(value: unknown): value is ToolOutputUiOnly { + if (!isRecord(value)) { + return false; + } + + const record = value; + + if ("severity" in record) { + const severity = record.severity; + if (severity !== undefined && severity !== "soft" && severity !== "hard") { + return false; + } + } + + if ("ask_user_question" in record && !isAskUserQuestionUiOnly(record.ask_user_question)) { + return false; + } + + if ("file_edit" in record && !isFileEditUiOnly(record.file_edit)) { + return false; + } + + if ("notify" in record && !isNotifyUiOnly(record.notify)) { + return false; + } + + return true; +} + +export function getToolOutputUiOnly(output: unknown): ToolOutputUiOnly | undefined { + const unwrapped = unwrapJsonContainer(output); + const value = unwrapped.value; + + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + + if (!(TOOL_OUTPUT_UI_ONLY_FIELD in value)) { + return undefined; + } + + const uiOnly = (value as Record)[TOOL_OUTPUT_UI_ONLY_FIELD]; + return isUiOnlyRecord(uiOnly) ? uiOnly : undefined; +} + +export function stripToolOutputUiOnly(output: unknown): unknown { + const unwrapped = unwrapJsonContainer(output); + const stripped = stripUiOnlyDeep(unwrapped.value); + return rewrapJsonContainer(unwrapped.wrapped, stripped); +} diff --git a/src/node/services/mock/mockAiRouter.ts b/src/node/services/mock/mockAiRouter.ts index c4bcfce6eb..1d1e61d6c0 100644 --- a/src/node/services/mock/mockAiRouter.ts +++ b/src/node/services/mock/mockAiRouter.ts @@ -261,9 +261,13 @@ function buildToolNotifyReply(): MockAiRouterReply { }, result: { success: true, - notifiedVia: "electron", title: "Task Complete", message: "Your requested task has been completed successfully.", + ui_only: { + notify: { + notifiedVia: "electron", + }, + }, }, }, ], diff --git a/src/node/services/tools/ask_user_question.ts b/src/node/services/tools/ask_user_question.ts index 3507a20332..3ff2def68d 100644 --- a/src/node/services/tools/ask_user_question.ts +++ b/src/node/services/tools/ask_user_question.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { tool } from "ai"; import type { AskUserQuestionToolResult } from "@/common/types/tools"; +import { buildAskUserQuestionSummary } from "@/common/utils/tools/askUserQuestionSummary"; import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; import { askUserQuestionManager } from "@/node/services/askUserQuestionManager"; @@ -15,7 +16,15 @@ export const createAskUserQuestionTool: ToolFactory = (config: ToolConfiguration // Claude Code allows passing pre-filled answers directly. If provided, we can short-circuit // and return immediately without prompting. if (args.answers && Object.keys(args.answers).length > 0) { - return { questions: args.questions, answers: args.answers }; + return { + summary: buildAskUserQuestionSummary(args.answers), + ui_only: { + ask_user_question: { + questions: args.questions, + answers: args.answers, + }, + }, + }; } assert(config.workspaceId, "ask_user_question requires a workspaceId"); @@ -29,7 +38,15 @@ export const createAskUserQuestionTool: ToolFactory = (config: ToolConfiguration if (!abortSignal) { const answers = await pendingPromise; - return { questions: args.questions, answers }; + return { + summary: buildAskUserQuestionSummary(answers), + ui_only: { + ask_user_question: { + questions: args.questions, + answers, + }, + }, + }; } if (abortSignal.aborted) { @@ -60,7 +77,15 @@ export const createAskUserQuestionTool: ToolFactory = (config: ToolConfiguration const answers = await Promise.race([pendingPromise, abortPromise]); assert(answers && typeof answers === "object", "Expected answers to be an object"); - return { questions: args.questions, answers }; + return { + summary: buildAskUserQuestionSummary(answers), + ui_only: { + ask_user_question: { + questions: args.questions, + answers, + }, + }, + }; }, }); }; diff --git a/src/node/services/tools/bash.ts b/src/node/services/tools/bash.ts index 92e76d5290..5b0dd18f1a 100644 --- a/src/node/services/tools/bash.ts +++ b/src/node/services/tools/bash.ts @@ -771,6 +771,9 @@ File will be automatically cleaned up when stream ends.`; error: output, exitCode: -1, wall_duration_ms, + ui_only: { + severity: "soft", + }, }; } catch (err) { // If temp file creation fails, fall back to original error diff --git a/src/node/services/tools/file_edit_insert.ts b/src/node/services/tools/file_edit_insert.ts index 08e772436f..40ea47d247 100644 --- a/src/node/services/tools/file_edit_insert.ts +++ b/src/node/services/tools/file_edit_insert.ts @@ -1,6 +1,10 @@ import { tool } from "ai"; import type { FileEditInsertToolArgs, FileEditInsertToolResult } from "@/common/types/tools"; -import { EDIT_FAILED_NOTE_PREFIX, NOTE_READ_FILE_RETRY } from "@/common/types/tools"; +import { + EDIT_FAILED_NOTE_PREFIX, + FILE_EDIT_DIFF_OMITTED_MESSAGE, + NOTE_READ_FILE_RETRY, +} from "@/common/types/tools"; import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; import { generateDiff, validateAndCorrectPath, validatePlanModeAccess } from "./fileCommon"; @@ -98,7 +102,12 @@ export const createFileEditInsertTool: ToolFactory = (config: ToolConfiguration) const diff = generateDiff(resolvedPath, "", content); return { success: true, - diff, + diff: FILE_EDIT_DIFF_OMITTED_MESSAGE, + ui_only: { + file_edit: { + diff, + }, + }, ...(pathWarning && { warning: pathWarning }), }; } diff --git a/src/node/services/tools/file_edit_operation.ts b/src/node/services/tools/file_edit_operation.ts index dc30dea76a..5142487b22 100644 --- a/src/node/services/tools/file_edit_operation.ts +++ b/src/node/services/tools/file_edit_operation.ts @@ -1,4 +1,8 @@ -import type { FileEditDiffSuccessBase, FileEditErrorResult } from "@/common/types/tools"; +import { + FILE_EDIT_DIFF_OMITTED_MESSAGE, + type FileEditDiffSuccessBase, + type FileEditErrorResult, +} from "@/common/types/tools"; import type { ToolConfiguration } from "@/common/utils/tools/tools"; import { generateDiff, @@ -143,7 +147,12 @@ export async function executeFileEditOperation({ return { success: true, - diff, + diff: FILE_EDIT_DIFF_OMITTED_MESSAGE, + ui_only: { + file_edit: { + diff, + }, + }, ...operationResult.metadata, ...(pathWarning && { warning: pathWarning }), }; diff --git a/src/node/services/tools/file_edit_replace_lines.ts b/src/node/services/tools/file_edit_replace_lines.ts index 891c14b5fa..d4ec62e1fc 100644 --- a/src/node/services/tools/file_edit_replace_lines.ts +++ b/src/node/services/tools/file_edit_replace_lines.ts @@ -1,18 +1,20 @@ import { tool } from "ai"; +import type { ToolOutputUiOnlyFields } from "@/common/types/tools"; import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; import { executeFileEditOperation } from "./file_edit_operation"; import { handleLineReplace, type LineReplaceArgs } from "./file_edit_replace_shared"; -export interface FileEditReplaceLinesResult { +export interface FileEditReplaceLinesResult extends ToolOutputUiOnlyFields { success: true; diff: string; edits_applied: number; lines_replaced: number; line_delta: number; + warning?: string; } -export interface FileEditReplaceLinesError { +export interface FileEditReplaceLinesError extends ToolOutputUiOnlyFields { success: false; error: string; } @@ -43,6 +45,8 @@ export const createFileEditReplaceLinesTool: ToolFactory = (config: ToolConfigur return { success: true, diff: result.diff, + ui_only: result.ui_only, + warning: result.warning, edits_applied: result.edits_applied, lines_replaced: result.lines_replaced!, line_delta: result.line_delta!, diff --git a/src/node/services/tools/notify.test.ts b/src/node/services/tools/notify.test.ts index 9c3a98b335..fe6ef9d9b0 100644 --- a/src/node/services/tools/notify.test.ts +++ b/src/node/services/tools/notify.test.ts @@ -80,7 +80,7 @@ describe("notify tool", () => { expect(result.success).toBe(true); if (result.success) { - expect(result.notifiedVia).toBe("browser"); + expect(result.ui_only?.notify?.notifiedVia).toBe("browser"); expect(result.title).toBe("Test Notification"); expect(result.message).toBe("This is a test"); } @@ -100,7 +100,7 @@ describe("notify tool", () => { // In non-Electron, returns success with browser fallback expect(result.success).toBe(true); if (result.success) { - expect(result.notifiedVia).toBe("browser"); + expect(result.ui_only?.notify?.notifiedVia).toBe("browser"); expect(result.title).toBe("Test Notification"); expect(result.message).toBeUndefined(); } @@ -123,7 +123,7 @@ describe("notify tool", () => { expect(result.success).toBe(true); if (result.success) { - expect(result.workspaceId).toBe("test-workspace-123"); + expect(result.ui_only?.notify?.workspaceId).toBe("test-workspace-123"); } }); }); diff --git a/src/node/services/tools/notify.ts b/src/node/services/tools/notify.ts index 960955ebf3..222421c614 100644 --- a/src/node/services/tools/notify.ts +++ b/src/node/services/tools/notify.ts @@ -137,10 +137,14 @@ export const createNotifyTool: ToolFactory = (config) => { if (result.success) { return { success: true, - notifiedVia: "electron", title: truncatedTitle, message: truncatedMessage, - workspaceId: config.workspaceId, + ui_only: { + notify: { + notifiedVia: "electron", + workspaceId: config.workspaceId, + }, + }, }; } @@ -148,10 +152,14 @@ export const createNotifyTool: ToolFactory = (config) => { // This is not an error; the notification will be delivered via Web Notifications API return { success: true, - notifiedVia: "browser", title: truncatedTitle, message: truncatedMessage, - workspaceId: config.workspaceId, + ui_only: { + notify: { + notifiedVia: "browser", + workspaceId: config.workspaceId, + }, + }, }; }, }); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index bdcd290fe1..265dbf15d9 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -45,6 +45,7 @@ import type { WorkspaceActivitySnapshot, } from "@/common/types/workspace"; import { isDynamicToolPart } from "@/common/types/toolParts"; +import { buildAskUserQuestionSummary } from "@/common/utils/tools/askUserQuestionSummary"; import { AskUserQuestionToolArgsSchema, AskUserQuestionToolResultSchema, @@ -1913,8 +1914,13 @@ export class WorkspaceService extends EventEmitter { } const nextOutput: AskUserQuestionToolSuccessResult = { - questions: parsedArgs.data.questions, - answers, + summary: buildAskUserQuestionSummary(answers), + ui_only: { + ask_user_question: { + questions: parsedArgs.data.questions, + answers, + }, + }, }; output = nextOutput; From 3c9ef097f47da5c781ea5083f65cd3cc8ae6cccd Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 18 Jan 2026 10:36:19 -0600 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A4=96=20fix:=20prefer=20ui=5Fonly=20?= =?UTF-8?q?diffs=20for=20compaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ensure edited file diff extraction prefers ui_only.file_edit.diff when present. --- _Generated with `mux` • Model: `openai:gpt-5.2-codex` • Thinking: `xhigh` • Cost: `8.50`_ --- .../utils/messages/extractEditedFiles.test.ts | 37 ++++++++++++++++++- .../utils/messages/extractEditedFiles.ts | 9 ++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/common/utils/messages/extractEditedFiles.test.ts b/src/common/utils/messages/extractEditedFiles.test.ts index 21aeea44d8..6937083ba5 100644 --- a/src/common/utils/messages/extractEditedFiles.test.ts +++ b/src/common/utils/messages/extractEditedFiles.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import { createPatch } from "diff"; +import { FILE_EDIT_DIFF_OMITTED_MESSAGE } from "@/common/types/tools"; import type { MuxMessage } from "@/common/types/message"; import { extractEditedFileDiffs, extractEditedFilePaths } from "./extractEditedFiles"; @@ -11,6 +12,7 @@ function createAssistantMessage( toolName: string; filePath: string; diff: string; + uiOnlyDiff?: string; success?: boolean; }> ): MuxMessage { @@ -23,7 +25,19 @@ function createAssistantMessage( toolName: tc.toolName, state: "output-available" as const, input: { file_path: tc.filePath }, - output: { success: tc.success ?? true, diff: tc.diff }, + output: { + success: tc.success ?? true, + diff: tc.diff, + ...(tc.uiOnlyDiff + ? { + ui_only: { + file_edit: { + diff: tc.uiOnlyDiff, + }, + }, + } + : {}), + }, })), }; } @@ -129,6 +143,27 @@ describe("extractEditedFileDiffs", () => { expect(result[0].diff).toBe(diff); }); + it("should prefer ui_only diffs when present", () => { + const originalContent = "line1\nline2\nline3"; + const newContent = "line1\nmodified\nline3"; + const diff = makeDiff("/path/to/file.ts", originalContent, newContent); + + const messages: MuxMessage[] = [ + createAssistantMessage([ + { + toolName: "file_edit_replace_string", + filePath: "/path/to/file.ts", + diff: FILE_EDIT_DIFF_OMITTED_MESSAGE, + uiOnlyDiff: diff, + }, + ]), + ]; + + const result = extractEditedFileDiffs(messages); + expect(result).toHaveLength(1); + expect(result[0].diff).toBe(diff); + }); + it("should combine multiple non-overlapping diffs for the same file", () => { // Edit 1: change line 2 const original = "line1\nline2\nline3\nline4\nline5"; diff --git a/src/common/utils/messages/extractEditedFiles.ts b/src/common/utils/messages/extractEditedFiles.ts index dfc518d86e..917a6da45b 100644 --- a/src/common/utils/messages/extractEditedFiles.ts +++ b/src/common/utils/messages/extractEditedFiles.ts @@ -1,4 +1,5 @@ import type { MuxMessage } from "@/common/types/message"; +import { getToolOutputUiOnly } from "@/common/utils/tools/toolOutputUiOnly"; import { FILE_EDIT_TOOL_NAMES } from "@/common/types/tools"; import { MAX_EDITED_FILES, MAX_FILE_CONTENT_SIZE } from "@/common/constants/attachments"; import { applyPatch, createPatch, parsePatch } from "diff"; @@ -198,7 +199,11 @@ export function extractEditedFileDiffs(messages: MuxMessage[]): FileEditDiff[] { if (part.state !== "output-available") continue; const output = part.output as FileEditToolOutput | undefined; - if (!output?.success || !output.diff) continue; + if (!output?.success) continue; + + const uiOnly = getToolOutputUiOnly(output); + const diff = uiOnly?.file_edit?.diff ?? output.diff; + if (!diff) continue; const input = part.input as FileEditToolInput | undefined; const filePath = input?.file_path; @@ -208,7 +213,7 @@ export function extractEditedFileDiffs(messages: MuxMessage[]): FileEditDiff[] { if (!diffsByPath.has(filePath)) { diffsByPath.set(filePath, []); } - diffsByPath.get(filePath)!.push(output.diff); + diffsByPath.get(filePath)!.push(diff); // Update edit order (move to end if already exists) const idx = editOrder.indexOf(filePath);