From 57305b99cdec166f6e3d2aaf6b2f8a43d435f34e Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 20 Aug 2026 08:00:23 +0200 Subject: [PATCH] feat(coding-agent): added /extended-context slash command and pass settings to model registry - Add the `/extended-context` builtin slash command to toggle premium long-context windows. - Update `ModelRegistry` to accept settings and apply context-window caps using the finalized configuration. - Add tests covering extended context toggle behavior and session context window capping. --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/config/model-registry.ts | 32 ++++++----- packages/coding-agent/src/main.ts | 14 +++-- packages/coding-agent/src/sdk.ts | 13 +++-- .../src/slash-commands/builtin-modes.ts | 54 ++++++++++++++++++- .../coding-agent/test/acp-builtins.test.ts | 18 +++++++ .../test/available-commands.test.ts | 1 + .../test/sdk-model-selection.test.ts | 34 ++++++++++++ 8 files changed, 143 insertions(+), 24 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 00e3c20db..e12be5c1a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,6 +11,7 @@ - Backgroundable Python — `eval` cells can run async and auto-background like `bash`, with configurable thresholds. - Local Claude token counting — Anthropic-family tokens now count via a native local tokenizer, and every counter (session maintenance, advisor, stats, context tools) uses the active model's own tokenizer. - `extendedContext` setting — pick whether models with premium long-context pricing (272K/1M tiers on Codex-class models) use the extended window or compact early and stay on standard pricing. +- `/extended-context` — toggle premium long-context windows without leaving the session. - Speculative compaction — with `compaction.asyncEnabled`, all compaction modes compact in parallel while the session continues, then splice the result in instantly. - `tokenizer` property on custom models and `modelOverrides` to pin the tokenizer family for proxy models. - `qwenTemplateReasoningEffort` in `models.yml` `compat` to disable the Qwen 3.8+ reasoning-effort template parameter for strict local servers. diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 483c0fc57..5a9a3b0e4 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -108,7 +108,7 @@ export { import { ModelsConfigFile, type ProviderValidationModel, validateProviderConfiguration } from "./models-config"; import type { ModelOverride, ModelsConfig, ProviderAuthMode } from "./models-config-schema"; -import { settings } from "./settings"; +import { settings, type Settings } from "./settings"; // DeviceCheck attestation (`x-oai-attestation`) for ChatGPT-OAuth Codex // requests; the pi-ai provider resolves it just-in-time per request. @@ -132,21 +132,21 @@ interface CustomModelsResult { */ type ModifyModelsHook = (models: Model[], credentials: OAuthCredentials) => Model[]; -function getDisabledProviderIdsFromSettings(): Set { +function getDisabledProviderIdsFromSettings(settingsInstance?: Settings): Set { try { - return new Set(settings.get("disabledProviders")); + return new Set((settingsInstance ?? settings).get("disabledProviders")); } catch { return new Set(); } } /** - * Whether premium long-context windows are enabled. Defaults to true when the - * settings singleton is not initialized (SDK embedding, early boot). + * Whether premium long-context windows are enabled. Defaults to true when no + * settings source is available (SDK embedding, early boot). */ -function isExtendedContextEnabledFromSettings(): boolean { +function isExtendedContextEnabledFromSettings(settingsInstance?: Settings): boolean { try { - return settings.get("extendedContext"); + return (settingsInstance ?? settings).get("extendedContext"); } catch { return true; } @@ -209,6 +209,7 @@ export class ModelRegistry { #runtimeModelManagers: Map; sourceId: string }> = new Map(); #ignoreLocalModelConfig: boolean; #fetch: FetchImpl; + #settings: Settings | undefined; #resolveCommandBackedApiKey(provider: string, options?: { forceCommandRefresh?: boolean }): CommandApiKeyResolution { const keyConfig = this.#customProviderApiKeys.get(provider); @@ -252,10 +253,13 @@ export class ModelRegistry { * must never apply client-side credential or routing overrides. */ ignoreLocalModelConfig?: boolean; + /** Settings source for availability and context-window policies. */ + settings?: Settings; fetch?: FetchImpl; }, ) { this.#ignoreLocalModelConfig = options?.ignoreLocalModelConfig ?? false; + this.#settings = options?.settings; this.#fetch = options?.fetch ?? (isBunTestRuntime() @@ -886,7 +890,7 @@ export class ModelRegistry { } #addImplicitDiscoverableProviders(configuredProviders: Set): void { - const disabledProviders = getDisabledProviderIdsFromSettings(); + const disabledProviders = getDisabledProviderIdsFromSettings(this.#settings); if (!configuredProviders.has("ollama") && !disabledProviders.has("ollama")) { this.#discoverableProviders.push({ provider: "ollama", @@ -1058,7 +1062,7 @@ export class ModelRegistry { strategy: ModelRefreshStrategy, providerFilter?: ReadonlySet, ): Promise { - const disabledProviders = getDisabledProviderIdsFromSettings(); + const disabledProviders = getDisabledProviderIdsFromSettings(this.#settings); const selectedDiscoverableProviders = ( providerFilter ? this.#discoverableProviders.filter(provider => providerFilter.has(provider.provider)) @@ -1367,7 +1371,7 @@ export class ModelRegistry { }), }, ]; - const disabledProviders = getDisabledProviderIdsFromSettings(); + const disabledProviders = getDisabledProviderIdsFromSettings(this.#settings); const standardProviderDescriptors = PROVIDER_DESCRIPTORS.filter(descriptor => { if (disabledProviders.has(descriptor.providerId)) return false; if (configuredDiscoveryProviders.has(descriptor.providerId)) return false; @@ -1592,7 +1596,7 @@ export class ModelRegistry { }); } #applyHardcodedModelPolicies(models: Model[]): Model[] { - const extendedContext = isExtendedContextEnabledFromSettings(); + const extendedContext = isExtendedContextEnabledFromSettings(this.#settings); return models.map(model => { // Extended context off: cap models with a premium long-context price // tier (e.g. GPT-5.6 bills 2x input above 272K) at the standard-pricing @@ -1683,7 +1687,7 @@ export class ModelRegistry { * full bundled catalog (thousands of models, ~50 providers). */ #createProviderAvailabilityCheck(): (provider: string) => boolean { - const disabledProviders = getDisabledProviderIdsFromSettings(); + const disabledProviders = getDisabledProviderIdsFromSettings(this.#settings); const byProvider = new Map(); return provider => { let available = byProvider.get(provider); @@ -1752,7 +1756,7 @@ export class ModelRegistry { } getDiscoverableProviders(): string[] { - const disabledProviders = getDisabledProviderIdsFromSettings(); + const disabledProviders = getDisabledProviderIdsFromSettings(this.#settings); return this.#discoverableProviders .filter(provider => !disabledProviders.has(provider.provider)) .map(provider => provider.provider); @@ -1770,7 +1774,7 @@ export class ModelRegistry { hasProvider(providerId: string): boolean { const providerModels = this.#hasFullSnapshot ? this.#models : this.#composeStaticModels(new Set([providerId])); if (providerModels.some(model => model.provider === providerId)) return true; - if (getDisabledProviderIdsFromSettings().has(providerId)) return false; + if (getDisabledProviderIdsFromSettings(this.#settings).has(providerId)) return false; return ( this.#discoverableProviders.some(provider => provider.provider === providerId) || this.#runtimeModelManagers.has(providerId) diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 0bf73f280..b0d771e4f 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -1289,9 +1289,9 @@ export async function runRootCommand( // tree; declare it so headless subagent optimizations (e.g. skipping replan // title refresh) can tell a focusable process from a print/RPC/eval one. setInteractiveHost(isInteractive); - // Create AuthStorage and ModelRegistry upfront. A configured-but-unreachable - // auth broker throws here; convert it to an actionable stderr message + clean - // exit instead of a raw uncaught stack trace (issue #8096). + // Create AuthStorage upfront. A configured-but-unreachable auth broker throws + // here; convert it to an actionable stderr message + clean exit instead of a + // raw uncaught stack trace (issue #8096). let authStorage: AuthStorage; try { authStorage = await logger.time("discoverAuthStorage", deps.discoverAuthStorage ?? discoverAuthStorage); @@ -1301,7 +1301,6 @@ export async function runRootCommand( process.stderr.write(`${chalk.red(`Error: ${message}`)}\n`); process.exit(1); } - const modelRegistry = logger.time("modelRegistry:init", () => new ModelRegistry(authStorage)); const settingsInstance = deps.settings ?? (await logger.time("settings:init", Settings.init, { cwd, configFiles: parsedArgs.config })); @@ -1319,6 +1318,13 @@ export async function runRootCommand( } else if (parsedArgs.mode === "acp") { applyAcpDefaultSettingOverrides(settingsInstance); } + + // The registry composes policy-dependent metadata synchronously, including + // extended-context window caps, so it must receive the finalized settings. + const modelRegistry = logger.time( + "modelRegistry:init", + () => new ModelRegistry(authStorage, undefined, { settings: settingsInstance }), + ); if (parsedArgs.noPty || parsedArgs.mode === "rpc-ui") { Bun.env.PI_NO_PTY = "1"; } diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 8d0196938..a91ef4f76 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -1241,12 +1241,19 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro registerSshCleanup(); registerEvalCleanup(); + const settings = await (options.settings ?? + options.settingsManager ?? + logger.time("settings", Settings.init, { cwd, agentDir })); + logger.time("initializeWithSettings", initializeWithSettings, settings); + // Pin authStorage to modelRegistry.authStorage: ModelRegistry.getApiKey() routes refresh // failures through that instance, so any divergent storage handed to the bridge / mcpManager // / session would silently miss credential_disabled events. const modelRegistry = options.modelRegistry ?? - new ModelRegistry(options.authStorage ?? (await logger.time("discoverModels", discoverAuthStorage, agentDir))); + new ModelRegistry(options.authStorage ?? (await logger.time("discoverModels", discoverAuthStorage, agentDir)), undefined, { + settings, + }); // Track whether we internally created the authStorage so we can close it // if construction fails before the session takes ownership. const ownsAuthStorage = !options.authStorage && !options.modelRegistry; @@ -1270,10 +1277,6 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro startupCredentialDisabledEvents.push(event); } }); - const settings = await (options.settings ?? - options.settingsManager ?? - logger.time("settings", Settings.init, { cwd, agentDir })); - logger.time("initializeWithSettings", initializeWithSettings, settings); if (!options.modelRegistry) { modelRegistry.refreshInBackground(); } diff --git a/packages/coding-agent/src/slash-commands/builtin-modes.ts b/packages/coding-agent/src/slash-commands/builtin-modes.ts index 311f778fc..7199a6921 100644 --- a/packages/coding-agent/src/slash-commands/builtin-modes.ts +++ b/packages/coding-agent/src/slash-commands/builtin-modes.ts @@ -5,7 +5,7 @@ import { getModelMatchPreferences, resolveCliModel, } from "../config/model-resolver"; -import type { SettingPath } from "../config/settings"; +import type { SettingPath, Settings } from "../config/settings"; import { describeLoopLimitRuntime } from "../modes/loop-limit"; import type { InteractiveModeContext } from "../modes/types"; import type { AgentSession } from "../session/agent-session"; @@ -54,6 +54,32 @@ function formatFastModeStatus(session: AgentSession): string { return session.isFastModeEnabled() ? "on" : "off"; } +/** `/extended-context status` label for the premium long-context window setting. */ +function formatExtendedContextStatus(settings: Settings): string { + return settings.get("extendedContext") ? "on" : "off"; +} + +/** Applies an `/extended-context` argument and returns its operator feedback. */ +function applyExtendedContextCommand(settings: Settings, args: string): string | undefined { + const arg = args.trim().toLowerCase(); + const current = settings.get("extendedContext"); + if (!arg || arg === "toggle") { + const enabled = !current; + settings.set("extendedContext", enabled); + return `Extended context ${enabled ? "enabled" : "disabled"}.`; + } + if (arg === "on") { + settings.set("extendedContext", true); + return "Extended context enabled."; + } + if (arg === "off") { + settings.set("extendedContext", false); + return "Extended context disabled."; + } + if (arg === "status") return `Extended context is ${formatExtendedContextStatus(settings)}.`; + return undefined; +} + /** Detailed, session-effective `/computer status` diagnostics. */ async function formatComputerUseStatus(session: AgentSession): Promise { const enabled = session.settings.get("computer.enabled"); @@ -433,6 +459,32 @@ export const BUILTIN_MODE_SLASH_COMMANDS: ReadonlyArray = [ runtime.ctx.editor.setText(""); }, }, + { + name: "extended-context", + description: "Toggle premium long-context windows", + acpDescription: "Toggle extended context", + acpInputHint: "[on|off|status]", + subcommands: [ + { name: "on", description: "Enable premium long-context windows" }, + { name: "off", description: "Use standard-pricing context windows" }, + { name: "status", description: "Show extended context status" }, + ], + allowArgs: true, + getTuiAutocompleteDescription: runtime => + `Extended context: ${formatExtendedContextStatus(runtime.ctx.settings)}`, + handle: async (command, runtime) => { + const output = applyExtendedContextCommand(runtime.settings, command.args); + if (!output) return usage("Usage: /extended-context [on|off|status]", runtime); + await runtime.output(output); + return commandConsumed(); + }, + handleTui: (command, runtime) => { + const output = applyExtendedContextCommand(runtime.ctx.settings, command.args); + refreshStatusLine(runtime.ctx); + runtime.ctx.showStatus(output ?? "Usage: /extended-context [on|off|status]"); + runtime.ctx.editor.setText(""); + }, + }, { name: "computer", description: "Toggle the native computer-use tool for this session", diff --git a/packages/coding-agent/test/acp-builtins.test.ts b/packages/coding-agent/test/acp-builtins.test.ts index 5a3c48c5c..34b9723c3 100644 --- a/packages/coding-agent/test/acp-builtins.test.ts +++ b/packages/coding-agent/test/acp-builtins.test.ts @@ -243,6 +243,24 @@ describe("ACP builtin slash commands", () => { expect(output).toEqual(["Fast mode is off."]); }); + it("toggles extended context with explicit controls and reports state", async () => { + const { output, runtime } = createRuntime(); + + expect(await executeAcpBuiltinSlashCommand("/extended-context off", runtime)).toEqual({ consumed: true }); + expect(runtime.settings.get("extendedContext")).toBe(false); + expect(await executeAcpBuiltinSlashCommand("/extended-context on", runtime)).toEqual({ consumed: true }); + expect(runtime.settings.get("extendedContext")).toBe(true); + expect(await executeAcpBuiltinSlashCommand("/extended-context", runtime)).toEqual({ consumed: true }); + expect(runtime.settings.get("extendedContext")).toBe(false); + expect(await executeAcpBuiltinSlashCommand("/extended-context status", runtime)).toEqual({ consumed: true }); + expect(output).toEqual([ + "Extended context disabled.", + "Extended context enabled.", + "Extended context disabled.", + "Extended context is off.", + ]); + }); + it("forces a tool and returns remaining prompt text", async () => { const { output, runtime } = createRuntime(); diff --git a/packages/coding-agent/test/available-commands.test.ts b/packages/coding-agent/test/available-commands.test.ts index 504f7559b..181d28603 100644 --- a/packages/coding-agent/test/available-commands.test.ts +++ b/packages/coding-agent/test/available-commands.test.ts @@ -47,6 +47,7 @@ describe("buildAvailableSlashCommands", () => { expect(byName["reset-usage"]).toBeUndefined(); expect(byName.fast.description).toBe("Toggle fast mode"); + expect(byName["extended-context"].description).toBe("Toggle extended context"); expect(byName["ext:hello"].description).toBe("Extension hello"); expect(byName["custom:hello"].description).toBe("Custom hello"); expect(byName["server:prompt"].description).toBe("MCP prompt"); diff --git a/packages/coding-agent/test/sdk-model-selection.test.ts b/packages/coding-agent/test/sdk-model-selection.test.ts index 26dd790c8..8ca855280 100644 --- a/packages/coding-agent/test/sdk-model-selection.test.ts +++ b/packages/coding-agent/test/sdk-model-selection.test.ts @@ -1029,6 +1029,40 @@ describe("createAgentSession deferred model pattern resolution", () => { } }); + test("caps premium Codex context before a new session starts", async () => { + const authStorage = createInMemoryAuthStorage(); + authStoragesToClose.push(authStorage); + authStorage.setRuntimeApiKey("openai-codex", "codex-oauth-token"); + + const { session } = await createAgentSession({ + cwd: tempDir, + agentDir: tempDir, + authStorage, + settings: Settings.isolated({ extendedContext: false }), + sessionManager: SessionManager.inMemory(), + disableExtensionDiscovery: true, + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + skipPythonPreflight: true, + rules: [], + preloadedCustomToolPaths: [], + toolNames: ["read"], + modelPattern: "openai-codex/gpt-5.6-sol", + }); + + try { + expect(session.model?.provider).toBe("openai-codex"); + expect(session.model?.id).toBe("gpt-5.6-sol"); + expect(session.model?.contextWindow).toBe(272_000); + } finally { + await session.dispose(); + } + }); + test("restores role model max selector from extension provider after startup resume", async () => { const defaultModel = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!defaultModel) {