diff --git a/src/resources/extensions/sf/bootstrap/register-hooks.ts b/src/resources/extensions/sf/bootstrap/register-hooks.ts index 25f587d97..d3f968b4b 100644 --- a/src/resources/extensions/sf/bootstrap/register-hooks.ts +++ b/src/resources/extensions/sf/bootstrap/register-hooks.ts @@ -34,7 +34,11 @@ import { initializeLearningRuntime, resetLearningRuntime, selectLearnedModel } f let isFirstSession = true; async function syncServiceTierStatus(ctx: ExtensionContext): Promise { - const { getEffectiveServiceTier, formatServiceTierFooterStatus } = await import("../service-tier.js"); + const { getEffectiveServiceTier, formatServiceTierFooterStatus, isServiceTierDisabled } = await import("../service-tier.js"); + // Skip the footer event entirely when the feature is explicitly disabled — + // no setStatus call, no RPC traffic, no leak into headless stderr even if + // the TUI_FOOTER_STATUS_KEYS filter is bypassed. + if (isServiceTierDisabled()) return; ctx.ui.setStatus("sf-fast", formatServiceTierFooterStatus(getEffectiveServiceTier(), ctx.model?.id)); } @@ -454,7 +458,10 @@ export function registerHooks(pi: ExtensionAPI): void { // ── Service Tier ──────────────────────────────────────────────────── const modelId = event.model?.id; if (!modelId) return payload; - const { getEffectiveServiceTier, supportsServiceTier } = await import("../service-tier.js"); + const { getEffectiveServiceTier, supportsServiceTier, isServiceTierDisabled } = await import("../service-tier.js"); + // Short-circuit on explicit disable — never inject service_tier on any + // setup that has opted out, regardless of model. + if (isServiceTierDisabled()) return payload; const tier = getEffectiveServiceTier(); if (!tier || !supportsServiceTier(modelId)) return payload; payload.service_tier = tier; diff --git a/src/resources/extensions/sf/preferences-types.ts b/src/resources/extensions/sf/preferences-types.ts index 8c81c5988..084e29353 100644 --- a/src/resources/extensions/sf/preferences-types.ts +++ b/src/resources/extensions/sf/preferences-types.ts @@ -324,8 +324,17 @@ export interface SFPreferences { gate_evaluation?: GateEvaluationConfig; /** GitHub sync configuration. Opt-in: syncs SF events to GitHub Issues, Milestones, and PRs. */ github?: GitHubSyncConfig; - /** OpenAI service tier preference. "priority" = 2x cost, faster. "flex" = 0.5x cost, slower. Only affects gpt-5.4 models. */ - service_tier?: "priority" | "flex"; + /** + * OpenAI service tier preference. Only affects gpt-5.4 models. + * - "priority" — 2x cost, faster. + * - "flex" — 0.5x cost, slower. + * - "off" — explicitly disabled: no footer status event, no + * service_tier field in outbound requests. Use this on + * setups that will never run gpt-5.4 so the feature is + * fully dormant. + * - undefined — not configured (default). + */ + service_tier?: "priority" | "flex" | "off"; /** Opt-in: search existing issues and PRs before filing from /sf forensics. Uses additional AI tokens. */ forensics_dedup?: boolean; /** Opt-in: show per-prompt and cumulative session token cost in the footer. Default: false. */ diff --git a/src/resources/extensions/sf/service-tier.ts b/src/resources/extensions/sf/service-tier.ts index e1f4f8338..8e146c07d 100644 --- a/src/resources/extensions/sf/service-tier.ts +++ b/src/resources/extensions/sf/service-tier.ts @@ -22,6 +22,7 @@ import { ensurePreferencesFile, serializePreferencesToFrontmatter } from "./comm // ─── Types ─────────────────────────────────────────────────────────────────── export type ServiceTierSetting = "priority" | "flex" | undefined; +export type ServiceTierPref = "priority" | "flex" | "off" | undefined; const SERVICE_TIER_SCOPE_NOTE = "Only affects gpt-5.4 models, regardless of provider."; @@ -57,8 +58,12 @@ export function supportsServiceTier(modelId: string): boolean { */ export function formatServiceTierStatus(tier: ServiceTierSetting): string { if (!tier) { + const explicitlyDisabled = isServiceTierDisabled(); + const header = explicitlyDisabled + ? "Service tier: disabled (service_tier: \"off\" in preferences)" + : "Service tier: disabled"; return [ - "Service tier: disabled", + header, "", "Usage:", " /sf fast on Set to priority (2x cost, faster)", @@ -106,6 +111,9 @@ export function resolveServiceTierIcon(tier: ServiceTierSetting, modelId: string /** * Read the effective service_tier setting from preferences. + * Returns undefined for both "explicitly disabled" (`"off"`) and "not + * configured"; callers that need to distinguish the two should use + * {@link isServiceTierDisabled}. */ export function getEffectiveServiceTier(): ServiceTierSetting { const prefs = loadEffectiveSFPreferences()?.preferences; @@ -114,6 +122,17 @@ export function getEffectiveServiceTier(): ServiceTierSetting { return undefined; } +/** + * True when the user has explicitly set `service_tier: "off"` in preferences. + * Callers that register UI/hooks for the feature should skip registration + * entirely in this case — not just no-op their behavior. That keeps the + * feature fully dormant on setups that will never run a gpt-5.4 model. + */ +export function isServiceTierDisabled(): boolean { + const prefs = loadEffectiveSFPreferences()?.preferences; + return (prefs?.service_tier as ServiceTierPref) === "off"; +} + // ─── Preference Write ──────────────────────────────────────────────────────── function extractBodyAfterFrontmatter(content: string): string | null { @@ -168,10 +187,21 @@ export async function handleFast(args: string, ctx: ExtensionCommandContext): Pr return; } - if (trimmed === "on") { - await writeGlobalServiceTier(ctx, "priority"); - ctx.ui.setStatus("sf-fast", formatServiceTierFooterStatus("priority", ctx.model?.id)); - ctx.ui.notify("Service tier set to priority (2x cost, faster responses). Only affects gpt-5.4 models, regardless of provider.", "info"); + if (trimmed === "on" || trimmed === "flex") { + if (isServiceTierDisabled()) { + ctx.ui.notify( + "Service tier is explicitly disabled (service_tier: \"off\" in preferences). Remove that line before enabling.", + "warning", + ); + return; + } + const nextTier: ServiceTierSetting = trimmed === "on" ? "priority" : "flex"; + await writeGlobalServiceTier(ctx, nextTier); + ctx.ui.setStatus("sf-fast", formatServiceTierFooterStatus(nextTier, ctx.model?.id)); + const label = nextTier === "priority" + ? "priority (2x cost, faster responses)" + : "flex (0.5x cost, slower responses)"; + ctx.ui.notify(`Service tier set to ${label}. Only affects gpt-5.4 models, regardless of provider.`, "info"); return; } @@ -182,13 +212,6 @@ export async function handleFast(args: string, ctx: ExtensionCommandContext): Pr return; } - if (trimmed === "flex") { - await writeGlobalServiceTier(ctx, "flex"); - ctx.ui.setStatus("sf-fast", formatServiceTierFooterStatus("flex", ctx.model?.id)); - ctx.ui.notify("Service tier set to flex (0.5x cost, slower responses). Only affects gpt-5.4 models, regardless of provider.", "info"); - return; - } - ctx.ui.notify( "Usage: /sf fast [on|off|flex|status]\n\n on Priority tier (2x cost, faster)\n off Disable service tier\n flex Flex tier (0.5x cost, slower)\n status Show current setting", "warning", diff --git a/src/resources/extensions/sf/tests/service-tier.test.ts b/src/resources/extensions/sf/tests/service-tier.test.ts index 2192c9aa7..991c12042 100644 --- a/src/resources/extensions/sf/tests/service-tier.test.ts +++ b/src/resources/extensions/sf/tests/service-tier.test.ts @@ -125,3 +125,65 @@ describe("resolveServiceTierIcon", () => { assert.equal(icon, ""); }); }); + +// ─── Disable via "off" preference ──────────────────────────────────────────── +// +// These are structural tests against the source so they don't depend on the +// effective-preferences loader behaving the same way in a test env as in a +// real SF install. The runtime behavior is covered by hook integration paths. + +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +const __dirname = dirname(fileURLToPath(import.meta.url)); +const serviceTierSrc = readFileSync(join(__dirname, "..", "service-tier.ts"), "utf-8"); +const hooksSrc = readFileSync(join(__dirname, "..", "bootstrap", "register-hooks.ts"), "utf-8"); + +describe("service_tier: \"off\" disable", () => { + test("isServiceTierDisabled is exported and keys off the preference", () => { + assert.match(serviceTierSrc, /export function isServiceTierDisabled\(\)/); + assert.match( + serviceTierSrc, + /isServiceTierDisabled[\s\S]*?prefs\?\.service_tier[\s\S]*?===\s*"off"/, + ); + }); + + test("getEffectiveServiceTier does not treat \"off\" as a tier", () => { + // getEffectiveServiceTier only returns priority|flex — "off" collapses to undefined. + assert.match( + serviceTierSrc, + /export function getEffectiveServiceTier\(\)[\s\S]*?if \(raw === "priority" \|\| raw === "flex"\) return raw;\s*return undefined;/, + ); + }); + + test("syncServiceTierStatus skips setStatus when disabled", () => { + assert.match( + hooksSrc, + /async function syncServiceTierStatus\([\s\S]*?if \(isServiceTierDisabled\(\)\) return;[\s\S]*?setStatus\("sf-fast"/, + ); + }); + + test("before_provider_request hook short-circuits when disabled", () => { + // Must check isServiceTierDisabled before the normal tier/support gate. + const hook = hooksSrc.slice(hooksSrc.indexOf("// ── Service Tier ──")); + assert.match(hook, /if \(isServiceTierDisabled\(\)\) return payload/); + assert.ok( + hook.indexOf("isServiceTierDisabled") < hook.indexOf("getEffectiveServiceTier()"), + "disable check must run before tier resolution", + ); + }); + + test("/sf fast on refuses when explicitly disabled", () => { + // handleFast("on") must bail out with a warning rather than silently + // overwriting the explicit "off" pref. + assert.match( + serviceTierSrc, + /if \(trimmed === "on" \|\| trimmed === "flex"\)[\s\S]*?if \(isServiceTierDisabled\(\)\)[\s\S]*?"warning"/, + ); + }); + + test("preferences-types declares \"off\" as a valid service_tier value", () => { + const typesSrc = readFileSync(join(__dirname, "..", "preferences-types.ts"), "utf-8"); + assert.match(typesSrc, /service_tier\?:\s*"priority"\s*\|\s*"flex"\s*\|\s*"off"/); + }); +});