diff --git a/frontend/src/app/pages/AgentChat/parsing/settingsToolMeta.ts b/frontend/src/app/pages/AgentChat/parsing/settingsToolMeta.ts new file mode 100644 index 00000000..57a2a9d0 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/parsing/settingsToolMeta.ts @@ -0,0 +1,73 @@ +// Render an agent's SettingsWrite/SettingsRead so the transcript shows WHAT it +// touched at a glance, with secrets masked. The agent's raw `changes` input can +// carry a key it's trying to set, and the generic MCP renderer would paint it; a +// settings value is the one new place a secret could land on screen, so it's the +// one place we mask. Mirrors the backend's name rule (redaction.is_secret_field). + +const SECRET_NAME_RE = /_(key|token|secret)$/i; +// Narrow, prefix-anchored so it can't mask a long path or system prompt (those +// have slashes/spaces); it only catches a real key pasted into a non-secret field. +const KEYISH_VALUE_RE = /^(sk-|sk-ant-|AIza|ghp_|gho_|github_pat_|xox[baprs]-)/; + +export function isSettingsWriteTool(toolName: string): boolean { + return /__SettingsWrite$/.test(toolName); +} +export function isSettingsReadTool(toolName: string): boolean { + return /__SettingsRead$/.test(toolName); +} + +function isSecretField(name: string): boolean { + return SECRET_NAME_RE.test(name) || name === 'installation_id'; +} + +function maskValue(key: string, value: unknown, cap: number): string { + if (isSecretField(key)) return '••••'; + if (typeof value === 'string') { + if (KEYISH_VALUE_RE.test(value.trim())) return '••••'; + return value.length > cap ? value.slice(0, cap) + '…' : value; + } + if (value === null || value === undefined) return 'none'; + return JSON.stringify(value); +} + +const LABELS: Record = { + default_model: 'Default model', + default_mode: 'Default mode', + default_system_prompt: 'System prompt', + default_thinking_level: 'Thinking level', + default_max_turns: 'Max turns', + default_folder: 'Default folder', + theme: 'Theme', + browser_homepage: 'Browser homepage', + new_agent_shortcut: 'New-agent shortcut', + zoom_sensitivity: 'Zoom sensitivity', +}; + +function humanizeKey(key: string): string { + return LABELS[key] || key.replace(/_/g, ' ').replace(/^\w/, (ch) => ch.toUpperCase()); +} + +interface SettingsChangeRow { + label: string; + value: string; +} + +function settingsChangeRows(input: unknown, cap: number): SettingsChangeRow[] { + const changes = (input as { changes?: unknown })?.changes; + if (!changes || typeof changes !== 'object') return []; + return Object.entries(changes as Record) + // some models tack a free-form 'reason' onto the args; it isn't a setting. + .filter(([k]) => k !== 'reason') + .map(([k, v]) => ({ label: humanizeKey(k), value: maskValue(k, v, cap) })); +} + +/** One-line header preview, e.g. "Theme → Light · Default model → Sonnet". */ +export function settingsWriteSummary(input: unknown): string { + return settingsChangeRows(input, 40).map((r) => `${r.label} → ${r.value}`).join(' · '); +} + +/** Expanded body: one masked change per line. Replaces the raw-JSON render. */ +export function settingsWriteDisplay(input: unknown): string { + const rows = settingsChangeRows(input, 200); + return rows.length === 0 ? 'No changes.' : rows.map((r) => `${r.label}: ${r.value}`).join('\n'); +} diff --git a/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts b/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts index 8413c5cf..6e14aff3 100644 --- a/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts +++ b/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts @@ -1,6 +1,7 @@ import { AgentMessage } from '@/shared/state/agentsSlice'; import { prettyPath, prettyUrl, quoteQuery, bashCommandDetail } from './toolLabels'; import { parseMcpToolName, getMcpInputSummary, getGmailHeader } from '@/shared/mcpToolMeta'; +import { isSettingsWriteTool, isSettingsReadTool, settingsWriteSummary, settingsWriteDisplay } from './settingsToolMeta'; export function getToolData(call: AgentMessage) { const content = typeof call.content === 'object' ? call.content : {}; @@ -18,6 +19,11 @@ export function isBashTool(name: string) { export function getInputSummary(toolName: string, input: any): string { try { + // Settings tool first: legible change list, secrets masked. Must precede the + // generic MCP path (it is an MCP tool) or it renders raw changes JSON. + if (isSettingsWriteTool(toolName)) return settingsWriteSummary(input); + if (isSettingsReadTool(toolName)) return ''; + const mcp = parseMcpToolName(toolName); if (mcp.isMcp) return getMcpInputSummary(input); @@ -60,6 +66,11 @@ function formatMcpInputDisplay(input: any): string { export function formatInputDisplay(toolName: string, input: any): string { try { + // Masked, one-per-line change list instead of raw changes JSON (which would + // paint a secret value the agent tried to set). + if (isSettingsWriteTool(toolName)) return settingsWriteDisplay(input); + if (isSettingsReadTool(toolName)) return ''; + const mcp = parseMcpToolName(toolName); if (mcp.isMcp) return formatMcpInputDisplay(input); diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx index fb1c6bb5..4000ef72 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx @@ -15,6 +15,7 @@ import { GoogleServiceIcon } from '../mcp-cards/GoogleServiceIcon'; import { ElapsedTimer, formatElapsed } from '../parsing/toolBubbleChrome'; import { useTermColors } from '../parsing/toolColorize'; import { ParsedResult } from '../parsing/toolResultParsing'; +import { isSettingsWriteTool, settingsWriteSummary } from '../parsing/settingsToolMeta'; import { McpToolInfo, getMcpShortAction } from '@/shared/mcpToolMeta'; import { McpResultCard } from '../mcp-cards/McpResultCard'; @@ -56,6 +57,9 @@ export const CompactMcpBubble: React.FC = ({ const ServiceIcon = mcpInfo.isMcp && mcpInfo.service ? : null; + // A grouped settings write shows the masked change list (input-derived, so it + // reads even while pending) instead of the generic "Applied: theme" result line. + const headerSummary = isSettingsWriteTool(toolName) ? settingsWriteSummary(input) : resultSummary; return ( @@ -83,7 +87,7 @@ export const CompactMcpBubble: React.FC = ({ > {serviceLabel} - {resultSummary && !isError && ( + {headerSummary && !isError && ( = ({ : { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }), }} > - {resultSummary} + {headerSummary} )} - {!resultSummary && !showTimer && } + {!headerSummary && !showTimer && } {showTimer && ( <>