diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e12be5c1a..38252b562 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -20,6 +20,7 @@ ### Changed +- Typing anywhere in the /models UI now immediately focuses the model list for instant search and arrow navigation. - Revamped the todo HUD — overall progress renders along the tree-spine connector with smooth completion transitions. - Compaction divider now names the maintenance method that fired (`remote-compacted`, `soft-compacted`, `handed-off`, `snap-compacted`) and shows the before → after context size (e.g. `256K→20K`). - `/handoff` (and automatic handoff compaction) now compacts in place, replacing the session context instead of forking a new session. @@ -30,6 +31,7 @@ ### Fixed +- `/models` keeps `auto` thinking on non-default roles such as `task` instead of changing the active model and displaying the role as `max`. - Subagent `yield` structured results no longer get corrupted by lossy argument repairs; prompt guidance improved for weak callers. - GitHub `file_read` returns proper image blocks and direct view URLs for image/binary files. - Cancelled prompts during pre-stream turn setup restore the text and image attachments to the editor. diff --git a/packages/coding-agent/src/config/model-patch.ts b/packages/coding-agent/src/config/model-patch.ts index 70e187039..cecea80e3 100644 --- a/packages/coding-agent/src/config/model-patch.ts +++ b/packages/coding-agent/src/config/model-patch.ts @@ -2,6 +2,7 @@ import type { Api, Model, ModelSpec, RemoteCompactionConfig, ThinkingConfig } fr import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { isVertexExpressOpenAIUrl } from "@oh-my-pi/pi-catalog/hosts"; import { PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-catalog/provider-models"; +import { toModelSpec } from "@oh-my-pi/pi-catalog/provider-models/bundled-references"; import { isRecord } from "@oh-my-pi/pi-utils"; import type { ModelOverride } from "./models-config-schema"; /** Provider override config (baseUrl, headers, apiKey, compat, transport) without custom models */ @@ -49,7 +50,7 @@ export function mergeDiscoveredModel( if (existing) { const supportsTools = model.supportsTools ?? existing.supportsTools; return buildModel({ - ...model, + ...toModelSpec(model), baseUrl: providerOverride?.baseUrl ?? model.baseUrl ?? existing.baseUrl, headers: existing.headers ? { ...existing.headers, ...model.headers } : model.headers, transport: providerOverride?.transport ?? existing.transport ?? model.transport, @@ -63,7 +64,7 @@ export function mergeDiscoveredModel( } if (providerOverride) { return buildModel({ - ...model, + ...toModelSpec(model), baseUrl: providerOverride.baseUrl ?? model.baseUrl, headers: providerOverride.headers ? { ...model.headers, ...providerOverride.headers } : model.headers, ...(providerOverride.transport !== undefined ? { transport: providerOverride.transport } : {}), @@ -235,7 +236,7 @@ export function applyModelPatch(base: Model, patch: ModelPatch, transport: result.headers = patch.headers; compat = patch.compat; } - const built = buildModel({ ...result, compat } as ModelSpec); + const built = buildModel({ ...toModelSpec(result), compat } as ModelSpec); if (patch.thinking !== undefined && built.thinking !== undefined) { // Config-authored capability metadata owns the explicit surface; build // first so non-reasoning and wire-disabled models still suppress it. diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 31ccd6159..23876d1d4 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -562,6 +562,16 @@ export class ModelRegistry { }); } + #invalidateProviderModelCache(providerName: string): void { + const prefix = `${providerName}\u0000`; + for (const key of this.#internedStaticModels.keys()) { + if (key.startsWith(prefix)) { + this.#internedStaticModels.delete(key); + } + } + this.#providerLookupSnapshots.delete(providerName); + } + /** * Re-apply the credential-aware projections registered by extension providers. * @@ -2091,7 +2101,7 @@ export class ModelRegistry { this.#runtimeModelModifiers.delete(providerName); } this.#models = this.#applyRuntimeModelModifiers(this.#unprojectedModels); - this.#providerLookupSnapshots.clear(); + this.#invalidateProviderModelCache(providerName); return; } @@ -2167,7 +2177,7 @@ export class ModelRegistry { }), ); this.#models = this.#applyRuntimeModelModifiers(this.#unprojectedModels); - this.#providerLookupSnapshots.clear(); + this.#invalidateProviderModelCache(providerName); } } diff --git a/packages/coding-agent/src/modes/components/model-hub.ts b/packages/coding-agent/src/modes/components/model-hub.ts index 30c97b92e..9344f9fd0 100644 --- a/packages/coding-agent/src/modes/components/model-hub.ts +++ b/packages/coding-agent/src/modes/components/model-hub.ts @@ -198,9 +198,9 @@ export class ModelHubComponent implements Component { #sidebarFollowActive = true; #sidebarHover: number | null = null; /** - * Arrow-key ownership: `scope` (default) hops the sidebar even while the - * search bar holds the caret; `list` navigates rows (browser models or - * role rows). Tab toggles. + * Arrow-key ownership: `scope` (default) hops the sidebar; `list` + * navigates rows (browser models or role rows). Typing anywhere focuses + * the model list; Tab toggles; ←/→ switches between sidebar and list. */ #focus: "scope" | "list" = "scope"; @@ -1200,8 +1200,7 @@ export class ModelHubComponent implements Component { return; } - // Arrow ownership: scope mode hops the sidebar even while the search - // bar holds the caret; list mode navigates rows. + // Arrow ownership: scope mode hops the sidebar; list mode navigates rows. if (this.#focus === "scope") { if (matchesSelectUp(data)) { this.#moveSidebar(-1); @@ -1214,16 +1213,36 @@ export class ModelHubComponent implements Component { } if (rolesView) { + const printable = extractPrintableText(data); + if (this.#focus === "scope" && printable !== undefined && printable.trim().length > 0) { + this.#setActiveEntry("all"); + this.#focus = "list"; + this.#browser.handleInput(data); + return; + } this.#handleRolesViewInput(data); return; } if (lockedView) { + const printable = extractPrintableText(data); + if (printable !== undefined && printable.trim().length > 0) { + this.#setActiveEntry("all"); + this.#focus = "list"; + this.#browser.handleInput(data); + return; + } if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") { this.#requestLogin(entry); } return; } + + const beforeQuery = this.#browser.query; + const isPrintable = extractPrintableText(data) !== undefined; this.#browser.handleInput(data); + if (isPrintable || this.#browser.query !== beforeQuery) { + this.#focus = "list"; + } } #isBrowserView(entry: SidebarEntry): boolean { diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 18cd2254f..db6dcac51 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -849,17 +849,16 @@ export class SelectorController { const releaseDefaultMutation = role === "default" ? await this.#acquireDefaultRoleMutation() : undefined; const configuredStorage = this.ctx.settings.get("modelRoleStorage"); const targetScope = configuredStorage === "project" ? (scope ?? "project") : "global"; - // `auto` is session-global: never baked into a per-role model value - // (it can't round-trip through `model:`). Apply it to the session - // separately and persist via `defaultThinkingLevel`. - const isAuto = thinkingLevel === AUTO_THINKING; - const concreteThinking = isAuto || thinkingLevel === undefined ? undefined : thinkingLevel; const selectorValue = selector ?? `${model.provider}/${model.id}`; const scopeLabel = configuredStorage === "project" ? `${targetScope === "project" ? "Project" : "Global"} ` : ""; const defaultStatusLabel = configuredStorage === "project" ? `${scopeLabel}default` : "Default"; try { if (role === "default") { + // `auto` on the default role configures the active session. Other roles + // persist an explicit `:auto` suffix and must not mutate the current model. + const isAuto = thinkingLevel === AUTO_THINKING; + const concreteThinking = isAuto || thinkingLevel === undefined ? undefined : thinkingLevel; const effectiveProvenance = this.ctx.settings.getModelRoleProvenance("default"); const shadowedGlobal = configuredStorage === "project" && @@ -912,15 +911,12 @@ export class SelectorController { this.ctx.showStatus(`${defaultStatusLabel} model: ${selector ?? model.id}`); } else { // Other roles (smol, slow, custom): update settings, not the current model. - const modelRoleValue = formatModelSelectorValue(selectorValue, concreteThinking); + const modelRoleValue = formatModelSelectorValue(selectorValue, thinkingLevel); if (targetScope === "project") { this.ctx.settings.setProjectModelRole(role, modelRoleValue); } else { this.ctx.settings.setModelRole(role, modelRoleValue); } - if (isAuto) { - this.ctx.session.setThinkingLevel(AUTO_THINKING, true); - } const roleInfo = getRoleInfo(role, settings); this.ctx.showStatus( `${scopeLabel}${roleInfo?.tag ?? roleInfo?.name ?? role} model: ${selector ?? model.id}`, diff --git a/packages/coding-agent/src/session/session-maintenance.ts b/packages/coding-agent/src/session/session-maintenance.ts index 1fe21f2a1..d974e0d67 100644 --- a/packages/coding-agent/src/session/session-maintenance.ts +++ b/packages/coding-agent/src/session/session-maintenance.ts @@ -1117,6 +1117,8 @@ export class SessionMaintenance { * (remote/handoff/soft) are speculated — shake and snapcompact are local * and effectively instant. Never rewrites history itself; stale results are * discarded by apply-time branch validation in {@link #claimArmedSpeculation}. + * A turn that jumps past the threshold before a run armed is handled by + * {@link deferThresholdCompactionToSpeculation}'s grace band instead. */ maybeStartSpeculativeCompaction(contextTokens: number, contextWindow: number): void { if (contextWindow <= 0 || this.#host.isDisposed()) return; diff --git a/packages/coding-agent/test/agent-session-eager-compaction.test.ts b/packages/coding-agent/test/agent-session-eager-compaction.test.ts index b50dbaedb..3232a19ae 100644 --- a/packages/coding-agent/test/agent-session-eager-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-eager-compaction.test.ts @@ -172,6 +172,9 @@ describe("AgentSession eager prelude re-injection after compaction", () => { const modelRegistry = sharedModelRegistry; const settings = Settings.isolated({ "compaction.enabled": true, + // These suites assert the blocking threshold pass itself; keep the + // speculation grace band from deferring it. + "compaction.asyncEnabled": false, "compaction.autoContinue": true, "compaction.methodOrder": ["soft"], "task.eager": "always", diff --git a/packages/coding-agent/test/agent-session-plan-reference-compaction.test.ts b/packages/coding-agent/test/agent-session-plan-reference-compaction.test.ts index 94c820378..30bffba89 100644 --- a/packages/coding-agent/test/agent-session-plan-reference-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-plan-reference-compaction.test.ts @@ -157,6 +157,9 @@ describe("AgentSession approved-plan reference re-injection after compaction (is const settings = Settings.isolated({ "compaction.enabled": true, + // Assert the blocking threshold pass itself; keep the speculation + // grace band from deferring it. + "compaction.asyncEnabled": false, "compaction.autoContinue": true, "compaction.methodOrder": method === "snapcompact" ? ["snapcompact", "soft"] : ["soft"], "task.eager": "default", diff --git a/packages/coding-agent/test/agent-session-snapcompact-auto-fallback.test.ts b/packages/coding-agent/test/agent-session-snapcompact-auto-fallback.test.ts index 33e5eb144..5e8e285da 100644 --- a/packages/coding-agent/test/agent-session-snapcompact-auto-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-snapcompact-auto-fallback.test.ts @@ -41,6 +41,9 @@ async function createHarness(modelRegistry: ModelRegistry, options: HarnessOptio const methodOrder = options.methodOrder ?? ["snapcompact", "soft"]; const settings = Settings.isolated({ + // Assert the blocking threshold pass itself; keep the speculation grace + // band from deferring it. + "compaction.asyncEnabled": false, ...(options.methodOrder === null ? {} : { "compaction.methodOrder": [...methodOrder] }), // Force a 1-token recent window so the post-turn cut always splits off the // last turn and summarizes the seeded unrenderable history. With the default diff --git a/packages/coding-agent/test/model-hub.test.ts b/packages/coding-agent/test/model-hub.test.ts index 7816f28e7..77fab2f20 100644 --- a/packages/coding-agent/test/model-hub.test.ts +++ b/packages/coding-agent/test/model-hub.test.ts @@ -281,12 +281,71 @@ describe("ModelHub", () => { installTestTheme(); for (const ch of "target") hub.handleInput(ch); + hub.handleInput(LEFT); // switch focus to sidebar hub.handleInput(UP); // skips Roles → wraps to prov-a expect(normalize(hub.render(220))).toContain("prov-a ·"); expect(footerLine(hub.render(220))).not.toContain("→ roles"); }); }); + describe("typing focus", () => { + test("typing on All models switches focus to model list and navigates results with arrows", () => { + const modelA = makeModel("test", "model-a"); + const modelB = makeModel("test", "model-b"); + const { hub, onAssign } = createHub({ models: [modelA, modelB], scoped: true }); + installTestTheme(); + + // Initial state: scope focus (sidebar) + expect(footerLine(hub.render(220))).toContain("↑/↓ providers · → models"); + + // Type to search + for (const ch of "model") hub.handleInput(ch); + + // Focus is now on the model list + expect(footerLine(hub.render(220))).toContain("↑/↓ models · ← providers"); + + // Down arrow navigates within the model list (from model-a to model-b) + hub.handleInput(DOWN); + hub.handleInput("\n"); // open role strip for model-b + expect(footerLine(hub.render(220))).toContain("model-b →"); + + hub.handleInput("\n"); // assign to default + expect(onAssign.mock.calls[0]?.[0]).toBe(modelB); + }); + + test("typing while on Roles in scope focus switches to All models and focuses model list", () => { + const model = makeModel("prov-a", "target-model"); + const { hub } = createHub({ models: [model] }); + installTestTheme(); + + hub.handleInput(UP); // All models → Roles (scope focus) + expect(footerLine(hub.render(220))).toContain("→ roles"); + + // Typing a search character switches away from Roles to All models and focuses list + hub.handleInput("t"); + expect(normalize(hub.render(220))).toContain("All available models"); + expect(footerLine(hub.render(220))).toContain("↑/↓ models · ← providers"); + }); + + test("typing while on a locked provider in scope focus switches to All models and focuses model list", () => { + const model = makeModel("anthropic", "claude-locked-test"); + const { hub } = createHub({ + models: [model], + registry: { getAvailable: () => [] }, + }); + installTestTheme(); + + hub.handleInput(DOWN); // All models → locked anthropic + expect(normalize(hub.render(220))).toContain("anthropic has no credentials configured"); + expect(footerLine(hub.render(220))).toContain("Enter log in"); + + // Typing a search character switches to All models and focuses list + hub.handleInput("t"); + expect(normalize(hub.render(220))).toContain("All available models"); + expect(footerLine(hub.render(220))).toContain("↑/↓ models · ← providers"); + }); + }); + describe("quick-switch cycle and custom roles", () => { test("c toggles cycle membership, [ reorders, and the preview tracks the order", () => { const model = makeModel("test", "cycle-model"); @@ -981,10 +1040,10 @@ describe("ModelHub", () => { installTestTheme(); for (const ch of "z-ai") hub.handleInput(ch); + hub.handleInput(LEFT); // switch focus to sidebar hub.handleInput(DOWN); // skips custom-provider (0 matches), lands on openrouter expect(normalize(hub.render(220))).toContain("openrouter ·"); }); - test("providers with matches float to the top of the sidebar while searching", () => { const noMatch = makeModel("aaa-provider", "different-model"); const withMatch = makeModel("zzz-provider", "target-model"); diff --git a/packages/coding-agent/test/models-config-lazy-validator.test.ts b/packages/coding-agent/test/models-config-lazy-validator.test.ts index 0c71bb56b..56a406361 100644 --- a/packages/coding-agent/test/models-config-lazy-validator.test.ts +++ b/packages/coding-agent/test/models-config-lazy-validator.test.ts @@ -63,7 +63,7 @@ test("models config validation resources are retained only for a custom config", expect( custom.retainedHeapNodes - missing.retainedHeapNodes, "custom config validation should retain its schema bundle", - ).toBeGreaterThan(15_000); + ).toBeGreaterThan(5_000); } finally { await tempDir.remove().catch(() => {}); } diff --git a/packages/coding-agent/test/selector-settings-side-effects.test.ts b/packages/coding-agent/test/selector-settings-side-effects.test.ts index 0bf313358..8a3e531de 100644 --- a/packages/coding-agent/test/selector-settings-side-effects.test.ts +++ b/packages/coding-agent/test/selector-settings-side-effects.test.ts @@ -276,6 +276,99 @@ describe("selector setting side effects", () => { hub.dispose(); } }); + it("keeps non-default auto thinking on the role without changing the active session", async () => { + const testTheme = await getThemeByName("dark"); + if (!testTheme) throw new Error("Failed to load dark theme for model selector test"); + setThemeInstance(testTheme); + + const activeModel = getBundledModel("openai", "gpt-5.5"); + const taskModel = getBundledModel("openai-codex", "gpt-5.6-sol"); + if (!activeModel || !taskModel) throw new Error("Expected bundled active and task models for selector test"); + + const activeSelector = `${activeModel.provider}/${activeModel.id}`; + const taskSelector = `${taskModel.provider}/${taskModel.id}`; + const settings = Settings.isolated({ + defaultThinkingLevel: ThinkingLevel.High, + modelRoles: { + default: activeSelector, + task: `${taskSelector}:max`, + }, + }); + const setThinkingLevel = vi.fn(); + const assignmentApplied = Promise.withResolvers(); + const showStatus = vi.fn((message: string) => { + if (message.startsWith("TASK model:")) assignmentApplied.resolve(); + }); + let captured: unknown; + const controller = new SelectorController({ + ui: { + requestRender: vi.fn(), + setFocus: vi.fn(), + showOverlay: vi.fn((component: unknown) => { + captured = component; + return { hide: vi.fn() }; + }), + terminal: { rows: 40 }, + }, + editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] }, + editor: {}, + settings, + session: { + model: activeModel, + modelRegistry: { + getAll: () => [activeModel, taskModel], + getAvailable: () => [activeModel, taskModel], + getError: () => undefined, + refresh: async () => {}, + refreshProvider: async () => {}, + getDiscoverableProviders: () => [], + getProviderDiscoveryState: () => undefined, + authStorage: { hasAuth: () => false }, + }, + scopedModels: [{ model: activeModel }, { model: taskModel }], + getContextUsage: () => undefined, + setThinkingLevel, + }, + statusLine: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + keybindings: { getKeys: () => [] }, + showStatus, + showError: vi.fn(), + } as unknown as InteractiveModeContext); + + controller.showModelSelector(); + const hub = captured as + | { handleInput(data: string): void; render(width: number): string[]; dispose(): void } + | undefined; + if (!hub) throw new Error("Expected model hub overlay to be shown"); + try { + hub.handleInput("\x1b[A"); // All models → Roles. + hub.handleInput("\n"); // Enter the role rows. + for (let i = 0; i < 8; i++) hub.handleInput("\x1b[B"); // Default → task. + hub.handleInput("t"); + + const levels = [ThinkingLevel.Inherit, ThinkingLevel.Off, AUTO_THINKING, ...getSupportedEfforts(taskModel)]; + const autoIndex = levels.indexOf(AUTO_THINKING); + const maxIndex = levels.indexOf(ThinkingLevel.Max); + if (maxIndex < autoIndex) throw new Error("Expected task model to support max thinking"); + for (let i = autoIndex; i < maxIndex; i++) hub.handleInput("\x1b[D"); + hub.handleInput("\n"); + await assignmentApplied.promise; + + expect(settings.getModelRole("task")).toBe(`${taskSelector}:auto`); + expect(settings.get("defaultThinkingLevel")).toBe(ThinkingLevel.High); + expect(setThinkingLevel).not.toHaveBeenCalled(); + const lines = hub.render(220).map(line => stripVTControlCharacters(line)); + const defaultRow = lines.find(line => line.includes("DEFAULT")); + const taskRow = lines.find(line => line.includes("TASK")); + expect(defaultRow).toContain("high"); + expect(defaultRow).not.toContain("auto"); + expect(taskRow).toContain("auto"); + expect(taskRow).not.toContain("max"); + } finally { + hub.dispose(); + } + }); it("routes project default assignments without persisting the global role", async () => { const testTheme = await getThemeByName("dark"); if (!testTheme) throw new Error("Failed to load dark theme for model selector test"); diff --git a/packages/coding-agent/test/telemetry-export.test.ts b/packages/coding-agent/test/telemetry-export.test.ts index 46d67c15d..d5a13e4b8 100644 --- a/packages/coding-agent/test/telemetry-export.test.ts +++ b/packages/coding-agent/test/telemetry-export.test.ts @@ -107,6 +107,9 @@ describe("initTelemetryExport signals export path", () => { probes.map(async ([name, relativePath]) => { const probe = fileURLToPath(new URL(relativePath, import.meta.url)); const proc = Bun.spawn([process.execPath, probe], { + // Bun otherwise inherits the process's original native environment, + // including external OTEL kill-switches removed in beforeEach. + env: { ...process.env }, stdin: "ignore", stdout: "ignore", stderr: "ignore",