diff --git a/src/browser/components/InstructionsTab/InstructionsTab.tsx b/src/browser/components/InstructionsTab/InstructionsTab.tsx index 9d16e00b4f..254086cc89 100644 --- a/src/browser/components/InstructionsTab/InstructionsTab.tsx +++ b/src/browser/components/InstructionsTab/InstructionsTab.tsx @@ -16,6 +16,7 @@ import { type WorkspaceInstructions, } from "@/common/types/instructions"; import { getErrorMessage } from "@/common/utils/errors"; +import { formatBytes } from "@/common/utils/formatBytes"; interface InstructionsTabProps { workspaceId: string; @@ -314,9 +315,3 @@ function formatTokens(n: number): string { if (n < 10_000) return `${(n / 1000).toFixed(1)}k`; return `${Math.round(n / 1000)}k`; } - -function formatBytes(n: number): string { - if (n < 1024) return `${n}B`; - if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`; - return `${(n / (1024 * 1024)).toFixed(1)}MB`; -} diff --git a/src/common/types/thinking.ts b/src/common/types/thinking.ts index 7a722c4262..54e47dbfce 100644 --- a/src/common/types/thinking.ts +++ b/src/common/types/thinking.ts @@ -198,6 +198,23 @@ export function getAnthropicEffort(level: ThinkingLevel): AnthropicEffortLevel { return ANTHROPIC_EFFORT[level]; } +/** + * Normalize a model string to its bare model id for capability matching: + * trims, lowercases, and strips both a `provider:` prefix (e.g. `anthropic:`) + * and a `namespace/` segment (e.g. gateway-wrapped `openai/gpt-5.5-pro`). + * + * Kept in one place so capability predicates that key off the bare id + * (see `anthropicSupportsNativeXhigh` and the thinking policy resolver) stay + * consistent about how provider prefixes and gateway namespaces are removed. + */ +export function stripModelProviderPrefixes(modelString: string): string { + return modelString + .trim() + .toLowerCase() + .replace(/^[a-z0-9_-]+:\s*/, "") + .replace(/^[a-z0-9_-]+\//, ""); +} + /** * Whether the given Anthropic model supports the native "xhigh" API effort level * (distinct from "max"). @@ -210,11 +227,7 @@ export function getAnthropicEffort(level: ThinkingLevel): AnthropicEffortLevel { * - Mythos-class models (`claude-fable-*`, `claude-mythos-*`), the tier above Opus. */ export function anthropicSupportsNativeXhigh(modelString: string): boolean { - const withoutPrefix = modelString - .trim() - .toLowerCase() - .replace(/^[a-z0-9_-]+:\s*/, "") - .replace(/^[a-z0-9_-]+\//, ""); + const withoutPrefix = stripModelProviderPrefixes(modelString); // Opus 4.7+ (4-7, 4-8, 4-9, 4-10, 4-11, ...) or any Opus 5+, Sonnet 5+ (5, 6, ... 10+), // plus the Mythos-class Fable / Mythos models that sit above Opus. return ( diff --git a/src/common/utils/thinking/policy.ts b/src/common/utils/thinking/policy.ts index af07a526c8..1d0f83782a 100644 --- a/src/common/utils/thinking/policy.ts +++ b/src/common/utils/thinking/policy.ts @@ -17,6 +17,7 @@ import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OFF, anthropicSupportsNativeXhigh, + stripModelProviderPrefixes, type ThinkingLevel, type ParsedThinkingInput, } from "@/common/types/thinking"; @@ -78,13 +79,10 @@ const DEFAULT_THINKING_POLICY: ThinkingPolicy = ["off", "low", "medium", "high"] * which is the signal used to decide whether to apply a default thinking floor. */ function getExplicitThinkingPolicy(modelString: string): ThinkingPolicy | null { - // Normalize to be robust to provider prefixes, whitespace, gateway wrappers, and version suffixes - const normalized = modelString.trim().toLowerCase(); - const withoutPrefix = normalized.replace(/^[a-z0-9_-]+:\s*/, ""); - - // Many providers/proxies encode the upstream provider as a path segment: - // mux-gateway:openai/gpt-5.5-pro -> openai/gpt-5.5-pro -> gpt-5.5-pro - const withoutProviderNamespace = withoutPrefix.replace(/^[a-z0-9_-]+\//, ""); + // Normalize to be robust to provider prefixes, whitespace, gateway wrappers, and version + // suffixes. Strips both a `provider:` prefix and any upstream-provider path segment that + // proxies encode (e.g. `mux-gateway:openai/gpt-5.5-pro` -> `gpt-5.5-pro`). + const withoutProviderNamespace = stripModelProviderPrefixes(modelString); // Opus 4.7+ supports all 6 levels: xhigh is a native API effort level distinct from max. if (anthropicSupportsNativeXhigh(modelString)) { diff --git a/src/node/services/backgroundProcessManager.ts b/src/node/services/backgroundProcessManager.ts index 4d5e088d15..f374e5fe74 100644 --- a/src/node/services/backgroundProcessManager.ts +++ b/src/node/services/backgroundProcessManager.ts @@ -5,6 +5,7 @@ import { getErrorMessage } from "@/common/utils/errors"; import { log } from "./log"; import { AsyncMutex } from "@/node/utils/concurrency/asyncMutex"; import { BASH_MAX_LINE_BYTES } from "@/common/constants/toolLimits"; +import { stripAnsiControlChars } from "@/node/utils/ansi"; import { LocalBaseRuntime } from "@/node/runtime/LocalBaseRuntime"; const DEFAULT_BACKGROUND_BASH_TAIL_BYTES = 64_000; @@ -16,10 +17,6 @@ const MONITOR_MAX_LAST_LINES = 20; const MONITOR_MAX_PROMPT_LINE_BYTES = Math.min(BASH_MAX_LINE_BYTES, 8_192); const MONITOR_MAX_INCOMPLETE_MATCH_BYTES = 1_000_000; const MONITOR_TRUNCATION_MARKER = "… [truncated] …"; -const ANSI_ESCAPE_PATTERN = new RegExp( - `[${String.fromCharCode(27)}${String.fromCharCode(155)}][[\\]\\()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?${String.fromCharCode(7)})|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))`, - "g" -); export function computeTailStartOffset(fileSizeBytes: number, tailBytes: number): number { assert( @@ -383,12 +380,7 @@ export class BackgroundProcessManager extends EventEmitter { - const code = char.charCodeAt(0); - return code === 9 || code >= 32; - }) - .join(""); + return stripAnsiControlChars(line); } private truncateMonitorLine(line: string): string { @@ -1003,9 +995,9 @@ export class BackgroundProcessManager extends EventEmitter l.length > 0) // Include all non-empty lines on exit - : hasTrailingNewline - ? allLines.slice(0, -1) - : allLines.slice(0, -1); + : allLines.slice(0, -1); // While running, drop the trailing fragment (buffered for next read) // Update buffer for next call (clear on exit, keep incomplete line otherwise) proc.incompleteLineBuffer = diff --git a/src/node/services/bashMonitorWakeStore.ts b/src/node/services/bashMonitorWakeStore.ts index 694632dca8..bf74be5fae 100644 --- a/src/node/services/bashMonitorWakeStore.ts +++ b/src/node/services/bashMonitorWakeStore.ts @@ -9,14 +9,11 @@ import type { Config } from "@/node/config"; import { log } from "@/node/services/log"; import { isErrnoWithCode } from "@/node/utils/fs"; import { MutexMap } from "@/node/utils/concurrency/mutexMap"; +import { stripAnsiControlChars } from "@/node/utils/ansi"; export const BASH_MONITOR_WAKE_DIR = "bash-monitor-wakes"; const MAX_WAKE_LINES = 50; const MAX_WAKE_LINE_BYTES = 8_192; -const ANSI_ESCAPE_PATTERN = new RegExp( - `[${String.fromCharCode(27)}${String.fromCharCode(155)}][[\\]\\()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?${String.fromCharCode(7)})|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))`, - "g" -); // Single-source the wake status enum so the exported TS type and the runtime // Zod validator below can't drift. Mirrors the `as const` tuple pattern used by @@ -87,12 +84,7 @@ function truncateUtf8Prefix(value: string, maxBytes: number): string { } export function sanitizeBashMonitorWakeLine(line: string): string { - const sanitized = [...line.replace(ANSI_ESCAPE_PATTERN, "")] - .filter((char) => { - const code = char.charCodeAt(0); - return code === 9 || code >= 32; - }) - .join(""); + const sanitized = stripAnsiControlChars(line); if (Buffer.byteLength(sanitized, "utf8") <= MAX_WAKE_LINE_BYTES) return sanitized; return `${truncateUtf8Prefix(sanitized, MAX_WAKE_LINE_BYTES)}… [truncated]`; } diff --git a/src/node/utils/ansi.ts b/src/node/utils/ansi.ts new file mode 100644 index 0000000000..fb7e732e44 --- /dev/null +++ b/src/node/utils/ansi.ts @@ -0,0 +1,29 @@ +/** + * Shared terminal-output sanitization used when embedding raw program output in + * JSON wake records / prompt context. The bash-monitor wake store and the + * background process manager previously inlined this identical strip-ANSI + + * drop-control-chars logic (including a byte-for-byte copy of the escape + * pattern); single-sourcing it here keeps the two from drifting apart. + */ + +// Matches ANSI/VT escape sequences (CSI, OSC, etc.). Module-level + global is +// safe to share across callers because String.prototype.replace resets the +// regex lastIndex on every call. +const ANSI_ESCAPE_PATTERN = new RegExp( + `[${String.fromCharCode(27)}${String.fromCharCode(155)}][[\\]\\()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?${String.fromCharCode(7)})|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))`, + "g" +); + +/** + * Strip ANSI/VT escape sequences and drop non-printable control characters, + * keeping only tab (code 9) and any code point >= 0x20, so raw terminal output + * is safe to persist and re-render. + */ +export function stripAnsiControlChars(line: string): string { + return [...line.replace(ANSI_ESCAPE_PATTERN, "")] + .filter((char) => { + const code = char.charCodeAt(0); + return code === 9 || code >= 32; + }) + .join(""); +}