diff --git a/src/browser/components/CommandPalette/CommandPalette.tsx b/src/browser/components/CommandPalette/CommandPalette.tsx index 000e6b1d18..0ce24f8db8 100644 --- a/src/browser/components/CommandPalette/CommandPalette.tsx +++ b/src/browser/components/CommandPalette/CommandPalette.tsx @@ -13,7 +13,7 @@ import { matchesKeybind, } from "@/browser/utils/ui/keybinds"; import { stopKeyboardPropagation } from "@/browser/utils/events"; -import { resolveSlashCommandExperimentValue } from "@/browser/utils/slashCommands/experimentVisibility"; +import { createSlashCommandExperimentResolver } from "@/browser/utils/slashCommands/experimentVisibility"; import { getSlashCommandSuggestions } from "@/browser/utils/slashCommands/suggestions"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; @@ -291,11 +291,10 @@ export const CommandPalette: React.FC = ({ getSlashContext const suggestions = getSlashCommandSuggestions(q, { agentSkills, variant: ctx.workspaceId ? "workspace" : "creation", - isExperimentEnabled: (experimentId) => - resolveSlashCommandExperimentValue(experimentId, { - goals: goalsExperimentEnabled, - workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, - }), + isExperimentEnabled: createSlashCommandExperimentResolver({ + goals: goalsExperimentEnabled, + workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, + }), }); const section = "Slash Commands"; const groups: PaletteGroup[] = [ diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 171ae75f1c..d6ccd7e5ca 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -78,7 +78,7 @@ import { getSlashCommandSuggestions, type SlashSuggestion, } from "@/browser/utils/slashCommands/suggestions"; -import { resolveSlashCommandExperimentValue } from "@/browser/utils/slashCommands/experimentVisibility"; +import { createSlashCommandExperimentResolver } from "@/browser/utils/slashCommands/experimentVisibility"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/browser/components/Tooltip/Tooltip"; import { AgentModePicker } from "@/browser/components/AgentModePicker/AgentModePicker"; import { ContextUsageIndicatorButton } from "@/browser/components/ContextUsageIndicatorButton/ContextUsageIndicatorButton"; @@ -1404,11 +1404,10 @@ const ChatInputInner: React.FC = (props) => { const suggestions = getSlashCommandSuggestions(input, { agentSkills: agentSkillDescriptors, variant, - isExperimentEnabled: (experimentId) => - resolveSlashCommandExperimentValue(experimentId, { - goals: goalsExperimentEnabled, - workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, - }), + isExperimentEnabled: createSlashCommandExperimentResolver({ + goals: goalsExperimentEnabled, + workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, + }), }); setCommandSuggestions((prev) => replaceSuggestions(prev, suggestions)); setShowCommandSuggestions(suggestions.length > 0); @@ -1424,11 +1423,10 @@ const ChatInputInner: React.FC = (props) => { // Show only when suggestions are hidden and the input is exactly "/command " with no args yet. const commandGhostHint = getCommandGhostHint(input, showCommandSuggestions, { variant, - isExperimentEnabled: (experimentId) => - resolveSlashCommandExperimentValue(experimentId, { - goals: goalsExperimentEnabled, - workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, - }), + isExperimentEnabled: createSlashCommandExperimentResolver({ + goals: goalsExperimentEnabled, + workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, + }), }); // Load agent skills for suggestions diff --git a/src/browser/features/Messages/Mermaid.tsx b/src/browser/features/Messages/Mermaid.tsx index f961fdb84a..f378403845 100644 --- a/src/browser/features/Messages/Mermaid.tsx +++ b/src/browser/features/Messages/Mermaid.tsx @@ -8,6 +8,11 @@ import { usePersistedState } from "@/browser/hooks/usePersistedState"; const MIN_HEIGHT = 300; const MAX_HEIGHT = 1200; +// Sanitizer lookup tables. Hoisted to module scope so they aren't reallocated on +// every sanitizeMermaidSvg() call (which can run per render for large SVGs). +const SANITIZER_URL_ATTRIBUTES = new Set(["href", "xlink:href", "src", "action", "formaction"]); +const SANITIZER_BLOCKED_URL_SCHEMES = ["javascript:", "vbscript:", "data:text/html"]; + // Initialize mermaid mermaid.initialize({ startOnLoad: false, @@ -162,9 +167,6 @@ export function sanitizeMermaidSvg(svg: string): string | null { node.remove(); }); - const urlAttributes = new Set(["href", "xlink:href", "src", "action", "formaction"]); - const blockedSchemes = ["javascript:", "vbscript:", "data:text/html"]; - const elementsToScan: Element[] = [svgRoot, ...Array.from(svgRoot.querySelectorAll("*"))]; elementsToScan.forEach((element) => { for (const attribute of Array.from(element.attributes)) { @@ -177,8 +179,8 @@ export function sanitizeMermaidSvg(svg: string): string | null { } if ( - urlAttributes.has(attributeName) && - blockedSchemes.some((scheme) => canonicalUrlValue.startsWith(scheme)) + SANITIZER_URL_ATTRIBUTES.has(attributeName) && + SANITIZER_BLOCKED_URL_SCHEMES.some((scheme) => canonicalUrlValue.startsWith(scheme)) ) { element.removeAttribute(attribute.name); } diff --git a/src/browser/utils/slashCommands/experimentVisibility.ts b/src/browser/utils/slashCommands/experimentVisibility.ts index d7b575b528..f168694ff9 100644 --- a/src/browser/utils/slashCommands/experimentVisibility.ts +++ b/src/browser/utils/slashCommands/experimentVisibility.ts @@ -18,3 +18,17 @@ export function resolveSlashCommandExperimentValue( return undefined; } } + +/** + * Build the `isExperimentEnabled` predicate consumed by slash-command + * discovery surfaces (suggestions, ghost hints, command palette). Each + * surface previously inlined the same `(experimentId) => + * resolveSlashCommandExperimentValue(experimentId, snapshot)` lambda; this + * helper keeps the resolver wiring in one place so callsites only describe + * the snapshot they observe. + */ +export function createSlashCommandExperimentResolver( + snapshot: SlashCommandExperimentSnapshot +): (experimentId: ExperimentId) => boolean | undefined { + return (experimentId) => resolveSlashCommandExperimentValue(experimentId, snapshot); +} diff --git a/src/cli/run.ts b/src/cli/run.ts index cffcc84085..3a4ea89c54 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -69,10 +69,9 @@ import assert from "../common/utils/assert"; import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import { log, type LogLevel } from "../node/services/log"; import chalk from "chalk"; -import type { InitLogger, WorkspaceInitResult } from "../node/runtime/Runtime"; +import type { InitLogger, Runtime, WorkspaceInitResult } from "../node/runtime/Runtime"; import { DockerRuntime } from "../node/runtime/DockerRuntime"; import { createRuntime, runFullInit } from "../node/runtime/runtimeFactory"; -import type { Runtime } from "../node/runtime/Runtime"; import { execSync } from "child_process"; import { getParseOptions } from "./argv"; import { EXPERIMENT_IDS } from "../common/constants/experiments"; diff --git a/src/node/services/agentStatusService.ts b/src/node/services/agentStatusService.ts index de84f80e63..50b1ff1413 100644 --- a/src/node/services/agentStatusService.ts +++ b/src/node/services/agentStatusService.ts @@ -475,6 +475,21 @@ function extractMessageText(message: MuxMessage): string { .join("\n"); } +/** + * Lifecycle phase the status model sees for each AI SDK v5 tool-part `state`. + * This is the single highest-signal datum for distinguishing "Deploying + * service" (in flight) from "Deployed service" (finished) — without it the + * prompt was forced to guess from prose alone. States not listed here + * (e.g. `input-streaming`) intentionally yield no phase suffix so the + * model isn't asked to interpret transient pre-call states. + */ +const TOOL_PART_PHASE_BY_STATE: Record = { + "input-available": "running", + "output-available": "done", + // Output returned but body stripped (still "done" for our purposes). + "output-redacted": "done", +}; + function summarizeToolPart(part: unknown): string | null { if (typeof part !== "object" || part === null) return null; const record = part as { type?: unknown; toolName?: unknown; state?: unknown }; @@ -488,20 +503,8 @@ function summarizeToolPart(part: unknown): string | null { ? type.slice(5) : null; if (!toolName) return null; - // The lifecycle phase is the single highest-signal datum the status model - // needs to distinguish "Deploying service" (in flight) from "Deployed - // service" (finished). AI SDK v5 tool parts carry a `state` field: - // - "input-available" → call sent, no result yet (running) - // - "output-available" → result returned (done) - // - "output-redacted" → result returned but body stripped (still done) - // Without this marker the prompt was forced to guess from prose alone. const state = typeof record.state === "string" ? record.state : null; - const phase = - state === "output-available" || state === "output-redacted" - ? "done" - : state === "input-available" - ? "running" - : null; + const phase = state !== null ? (TOOL_PART_PHASE_BY_STATE[state] ?? null) : null; return phase ? `[tool ${toolName} ${phase}]` : `[tool ${toolName}]`; } diff --git a/src/node/services/tools/imageArtifacts.ts b/src/node/services/tools/imageArtifacts.ts index e712091f83..4492c64dc1 100644 --- a/src/node/services/tools/imageArtifacts.ts +++ b/src/node/services/tools/imageArtifacts.ts @@ -23,6 +23,12 @@ const THUMBNAIL_MEDIA_TYPE = "image/webp"; export const IMAGE_TOOL_PROVIDER_SETUP_HINT = "Check OpenAI provider credentials, billing, rate limits, and content policy."; +// Setup hint surfaced when a single image tool call requests more images than +// the configured `maxImagesPerCall` allows. Shared across `image_generate` and +// `image_edit` so the guidance stays consistent. +export const IMAGE_TOOL_EXCESS_COUNT_SETUP_HINT = + "Adjust Settings > Experiments > Image Tools or request fewer images."; + type ImageModelOperation = "generation" | "editing"; type ImageToolName = "image_generate" | "image_edit"; diff --git a/src/node/services/tools/image_edit.ts b/src/node/services/tools/image_edit.ts index cf1f750d48..cfc994f70c 100644 --- a/src/node/services/tools/image_edit.ts +++ b/src/node/services/tools/image_edit.ts @@ -19,6 +19,7 @@ import { getImageDimensions, getImageDimensionsFromMetadata, getImageOutputDir, + IMAGE_TOOL_EXCESS_COUNT_SETUP_HINT, IMAGE_TOOL_PROVIDER_SETUP_HINT, processImageArtifacts, reportImageToolUsage, @@ -68,7 +69,7 @@ export const createImageEditTool: ToolFactory = (config) => { return { success: false, error: `Requested ${requestedCount} edited images, but Image Tools is configured for a maximum of ${runtime.maxImagesPerCall}.`, - setupHint: "Adjust Settings > Experiments > Image Tools or request fewer images.", + setupHint: IMAGE_TOOL_EXCESS_COUNT_SETUP_HINT, }; } diff --git a/src/node/services/tools/image_generate.ts b/src/node/services/tools/image_generate.ts index 2cc00bd497..3dd20a5152 100644 --- a/src/node/services/tools/image_generate.ts +++ b/src/node/services/tools/image_generate.ts @@ -12,6 +12,7 @@ import { buildOpenAIImageProviderOptions, formatImageModelError, getImageOutputDir, + IMAGE_TOOL_EXCESS_COUNT_SETUP_HINT, IMAGE_TOOL_PROVIDER_SETUP_HINT, processImageArtifacts, reportImageToolUsage, @@ -48,7 +49,7 @@ export const createImageGenerateTool: ToolFactory = (config) => { return { success: false, error: `Requested ${requestedCount} images, but Image Tools is configured for a maximum of ${runtime.maxImagesPerCall}.`, - setupHint: "Adjust Settings > Experiments > Image Tools or request fewer images.", + setupHint: IMAGE_TOOL_EXCESS_COUNT_SETUP_HINT, }; }