From 68955cc39ec6743a9b7b546cbd942adbd1ace1d0 Mon Sep 17 00:00:00 2001 From: Jeffrey Boadi Date: Sat, 11 Jul 2026 12:02:51 +0200 Subject: [PATCH 1/2] fix(commands): reconcile targets when enabling downshift Capture the complete active model target, make managed target switches idempotent, and establish the configured premium or economy policy before reporting a session as enabled. Keep failed reconciliation paused without a contradictory success message, while preserving handoff ordering and session-level enable overrides. Extend the lifecycle adapter tests for updated targets, current-model capture, idempotence, failures, and internal event suppression, and document the new /downshift on behavior. --- README.md | 4 +- src/downshift.test.ts | 422 +++++++++++++++++++++++++++++++++++++----- src/downshift.ts | 93 +++++++--- 3 files changed, 445 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index b32e1b6..d6d98df 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,9 @@ Disables Downshift for the current session. ### `/downshift on` -Re-enables Downshift for the current session. +Re-enables Downshift for the current session and reconciles the active model and thinking level with the latest configuration. Below the threshold, it activates the resolved premium target. At or above the threshold, it begins the configured transition to economy. When handoff notes are enabled, Downshift establishes premium before requesting the handoff and switches to economy after the handoff completes; otherwise, it switches directly to economy. + +Configuration edits are side-effect-free while Downshift is paused or disabled. Changing a model or threshold does not switch the active model until you run `/downshift on`. ### `/downshift help` diff --git a/src/downshift.test.ts b/src/downshift.test.ts index 2771a94..3bb8bb0 100644 --- a/src/downshift.test.ts +++ b/src/downshift.test.ts @@ -1,20 +1,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Model, ThinkingLevel } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, } from "@earendil-works/pi-coding-agent"; +import type { DownshiftConfig, DownshiftState } from "./downshift-core"; const fsMocks = vi.hoisted(() => ({ - config: { - enabled: true, - threshold: { percent: 50 }, - economy: { provider: "test", model: "economy", thinkingLevel: "off" }, - premiumSource: "current", - startOnPremium: false, - upshiftAfterCompaction: false, - handoffBeforeDownshift: true, - }, + config: null as unknown, readFile: vi.fn(), writeFile: vi.fn(), })); @@ -27,7 +21,7 @@ vi.mock("node:fs/promises", () => ({ import downshift from "./downshift"; type EventHandler = ( - event: unknown, + event: any, ctx: ExtensionContext, ) => void | Promise; @@ -36,15 +30,64 @@ type CommandHandler = ( ctx: ExtensionCommandContext, ) => void | Promise; +type ActiveTarget = { + model: Model; + thinkingLevel: string; +}; + type TestExtension = { + active: ActiveTarget; handlers: Map; commands: Map; pi: ExtensionAPI; + setModel: ReturnType; + setThinkingLevel: ReturnType; +}; + +const models = [ + testModel("premium-old"), + testModel("premium-new"), + testModel("economy-old"), + testModel("economy-new"), +]; + +const defaultConfig: DownshiftConfig = { + enabled: true, + threshold: { percent: 50 }, + economy: target("economy-old", "off"), + premiumSource: "current", + startOnPremium: false, + upshiftAfterCompaction: false, + handoffBeforeDownshift: true, }; -function createExtension(): TestExtension { +function testModel(id: string): Model { + return { provider: "test", id, reasoning: true } as Model; +} + +function target(model: string, thinkingLevel: string) { + return { provider: "test", model, thinkingLevel }; +} + +function setConfig(patch: Partial = {}): void { + fsMocks.config = { + ...defaultConfig, + ...patch, + threshold: patch.threshold ?? defaultConfig.threshold, + economy: patch.economy ?? defaultConfig.economy, + } satisfies DownshiftConfig; +} + +function createExtension(active: ActiveTarget): TestExtension { const handlers = new Map(); const commands = new Map(); + const setModel = vi.fn(async (model: Model) => { + active.model = model; + return true; + }); + const setThinkingLevel = vi.fn((level: ThinkingLevel) => { + active.thinkingLevel = level; + }); const pi = { on: (event: string, handler: EventHandler) => { handlers.set(event, handler); @@ -54,23 +97,42 @@ function createExtension(): TestExtension { }, appendEntry: vi.fn(), sendUserMessage: vi.fn(), - setModel: vi.fn(), - setThinkingLevel: vi.fn(), + setModel, + getThinkingLevel: () => active.thinkingLevel as ThinkingLevel, + setThinkingLevel, } as unknown as ExtensionAPI; downshift(pi); - return { handlers, commands, pi }; + return { active, handlers, commands, pi, setModel, setThinkingLevel }; } -function createContext(usage: { - current: { tokens: number; percent: number }; -}) { +function createContext( + usage: { current: { tokens: number; percent: number } }, + active: ActiveTarget = { + model: models[0], + thinkingLevel: "off", + }, +) { const status = vi.fn(); const select = vi.fn(); const input = vi.fn(); const notify = vi.fn(); const ctx = { hasUI: true, + get model() { + return active.model; + }, getContextUsage: () => usage.current, + isIdle: () => true, + modelRegistry: { + find: (provider: string, id: string) => + models.find((model) => model.provider === provider && model.id === id), + refresh: vi.fn(), + getAvailable: () => models, + }, + sessionManager: { + getEntries: () => [], + getSessionId: () => "test-session", + }, ui: { setStatus: status, select, @@ -79,6 +141,7 @@ function createContext(usage: { }, }; return { + active, commandContext: ctx as unknown as ExtensionCommandContext, context: ctx as unknown as ExtensionContext, input, @@ -88,8 +151,28 @@ function createContext(usage: { }; } +function latestState(pi: ExtensionAPI): DownshiftState { + const appendEntry = vi.mocked(pi.appendEntry); + return appendEntry.mock.calls.at(-1)?.[1] as DownshiftState; +} + +async function pauseDownshift( + extension: TestExtension, + context: ExtensionContext, +): Promise { + await extension.handlers.get("session_start")?.({ reason: "new" }, context); + await extension.handlers + .get("model_select") + ?.({ source: "cycle" }, context); + expect(latestState(extension.pi).paused).toBe(true); + extension.setModel.mockClear(); + extension.setThinkingLevel.mockClear(); + vi.mocked(extension.pi.sendUserMessage).mockClear(); +} + describe("downshift lifecycle adapter", () => { beforeEach(() => { + setConfig(); fsMocks.readFile.mockClear(); fsMocks.writeFile.mockClear(); fsMocks.readFile.mockImplementation(async () => @@ -100,18 +183,18 @@ describe("downshift lifecycle adapter", () => { it("refreshes status at turn_end and agent_settled without downshifting", async () => { const usage = { current: { tokens: 100, percent: 10 } }; - const { handlers, pi } = createExtension(); - const { context, status } = createContext(usage); + const fixture = createContext(usage); + const { handlers, pi } = createExtension(fixture.active); - await handlers.get("turn_end")?.({}, context); - expect(status).toHaveBeenLastCalledWith( + await handlers.get("turn_end")?.({}, fixture.context); + expect(fixture.status).toHaveBeenLastCalledWith( "downshift", "⇣ premium (40% left)", ); usage.current = { tokens: 200, percent: 20 }; - await handlers.get("agent_settled")?.({}, context); - expect(status).toHaveBeenLastCalledWith( + await handlers.get("agent_settled")?.({}, fixture.context); + expect(fixture.status).toHaveBeenLastCalledWith( "downshift", "⇣ premium (30% left)", ); @@ -120,42 +203,41 @@ describe("downshift lifecycle adapter", () => { }); it("registers a thinking level select handler", () => { - const { handlers } = createExtension(); + const fixture = createContext({ current: { tokens: 100, percent: 10 } }); + const { handlers } = createExtension(fixture.active); expect(handlers.has("thinking_level_select")).toBe(true); }); it("opens configuration for the bare command", async () => { const usage = { current: { tokens: 100, percent: 10 } }; - const { commands } = createExtension(); - const { commandContext, select, input, status } = createContext(usage); - select + const fixture = createContext(usage); + const { commands } = createExtension(fixture.active); + fixture.select .mockResolvedValueOnce("threshold: 50%") .mockResolvedValueOnce("percent") .mockResolvedValueOnce(undefined); - input.mockResolvedValueOnce("60"); + fixture.input.mockResolvedValueOnce("60"); - await commands.get("downshift")?.("", commandContext); + await commands.get("downshift")?.("", fixture.commandContext); expect(fsMocks.writeFile).toHaveBeenCalledOnce(); - expect(status).toHaveBeenLastCalledWith( + expect(fixture.status).toHaveBeenLastCalledWith( "downshift", "⇣ premium (50% left)", ); expect(fsMocks.writeFile.mock.invocationCallOrder[0]).toBeLessThan( - status.mock.invocationCallOrder[0], + fixture.status.mock.invocationCallOrder[0], ); }); it("warns with help for the unsupported config subcommand", async () => { - const { commands } = createExtension(); - const { commandContext, notify } = createContext({ - current: { tokens: 100, percent: 10 }, - }); + const fixture = createContext({ current: { tokens: 100, percent: 10 } }); + const { commands } = createExtension(fixture.active); - await commands.get("downshift")?.("config", commandContext); + await commands.get("downshift")?.("config", fixture.commandContext); - expect(notify).toHaveBeenCalledWith( + expect(fixture.notify).toHaveBeenCalledWith( expect.stringContaining("/downshift - configure Downshift"), "warning", ); @@ -163,22 +245,270 @@ describe("downshift lifecycle adapter", () => { }); it("does not advertise the unsupported config subcommand in help or status", async () => { - const { commands } = createExtension(); - const { commandContext, notify } = createContext({ - current: { tokens: 100, percent: 10 }, - }); + const fixture = createContext({ current: { tokens: 100, percent: 10 } }); + const { commands } = createExtension(fixture.active); - await commands.get("downshift")?.("help", commandContext); - expect(notify).toHaveBeenCalledWith( + await commands.get("downshift")?.("help", fixture.commandContext); + expect(fixture.notify).toHaveBeenCalledWith( expect.not.stringContaining("/downshift config"), "info", ); - notify.mockClear(); - await commands.get("downshift")?.("status", commandContext); - expect(notify).toHaveBeenCalledWith( + fixture.notify.mockClear(); + await commands.get("downshift")?.("status", fixture.commandContext); + expect(fixture.notify).toHaveBeenCalledWith( expect.not.stringContaining("/downshift config"), "info", ); }); + + it("activates an updated explicit premium target below threshold", async () => { + setConfig({ + premiumSource: "explicit", + premium: target("premium-new", "high"), + }); + const fixture = createContext( + { current: { tokens: 100, percent: 10 } }, + { model: models[0], thinkingLevel: "low" }, + ); + const extension = createExtension(fixture.active); + await pauseDownshift(extension, fixture.context); + + await extension.commands + .get("downshift") + ?.("on", fixture.commandContext); + + expect(extension.setModel).toHaveBeenCalledOnce(); + expect(extension.setModel).toHaveBeenCalledWith(models[1]); + expect(extension.setThinkingLevel).toHaveBeenCalledOnce(); + expect(extension.setThinkingLevel).toHaveBeenCalledWith("high"); + expect(extension.pi.sendUserMessage).not.toHaveBeenCalled(); + expect(latestState(extension.pi)).toMatchObject({ + sessionMode: "on", + paused: false, + position: "premium", + handoff: "idle", + }); + expect(fixture.notify).toHaveBeenCalledWith( + "downshift on for this session", + "info", + ); + }); + + it("activates an updated economy target directly above threshold without handoff", async () => { + setConfig({ + economy: target("economy-new", "medium"), + handoffBeforeDownshift: false, + }); + const fixture = createContext( + { current: { tokens: 100, percent: 60 } }, + { model: models[0], thinkingLevel: "low" }, + ); + const extension = createExtension(fixture.active); + await pauseDownshift(extension, fixture.context); + + await extension.commands + .get("downshift") + ?.("on", fixture.commandContext); + + expect(extension.setModel).toHaveBeenCalledOnce(); + expect(extension.setModel).toHaveBeenCalledWith(models[3]); + expect(extension.setThinkingLevel).toHaveBeenCalledWith("medium"); + expect(extension.pi.sendUserMessage).not.toHaveBeenCalled(); + expect(latestState(extension.pi)).toMatchObject({ + paused: false, + position: "economy", + handoff: "done", + }); + }); + + it("establishes updated premium before handing off to updated economy", async () => { + setConfig({ + premiumSource: "explicit", + premium: target("premium-new", "high"), + economy: target("economy-new", "off"), + handoffBeforeDownshift: true, + }); + const fixture = createContext( + { current: { tokens: 100, percent: 60 } }, + { model: models[0], thinkingLevel: "low" }, + ); + const extension = createExtension(fixture.active); + await pauseDownshift(extension, fixture.context); + + await extension.commands + .get("downshift") + ?.("on", fixture.commandContext); + + expect(extension.setModel).toHaveBeenCalledTimes(1); + expect(extension.setModel).toHaveBeenNthCalledWith(1, models[1]); + expect(extension.pi.sendUserMessage).toHaveBeenCalledOnce(); + expect(latestState(extension.pi)).toMatchObject({ + paused: false, + position: "premium", + handoff: "requested", + }); + + await extension.handlers + .get("before_agent_start") + ?.({ prompt: "" }, fixture.context); + await extension.handlers.get("agent_end")?.({}, fixture.context); + + expect(extension.setModel).toHaveBeenCalledTimes(2); + expect(extension.setModel).toHaveBeenNthCalledWith(2, models[3]); + expect(latestState(extension.pi)).toMatchObject({ + paused: false, + position: "economy", + handoff: "done", + }); + }); + + it("captures the complete current premium target without redundant changes", async () => { + setConfig({ premiumSource: "current" }); + const fixture = createContext( + { current: { tokens: 100, percent: 10 } }, + { model: models[0], thinkingLevel: "high" }, + ); + const extension = createExtension(fixture.active); + await pauseDownshift(extension, fixture.context); + + await extension.commands + .get("downshift") + ?.("on", fixture.commandContext); + + expect(extension.setModel).not.toHaveBeenCalled(); + expect(extension.setThinkingLevel).not.toHaveBeenCalled(); + expect(latestState(extension.pi)).toMatchObject({ + paused: false, + position: "premium", + capturedPremium: target("premium-old", "high"), + }); + }); + + it("is idempotent when repeatedly enabled on the configured target", async () => { + setConfig({ + premiumSource: "explicit", + premium: target("premium-old", "high"), + }); + const fixture = createContext( + { current: { tokens: 100, percent: 10 } }, + { model: models[0], thinkingLevel: "high" }, + ); + const extension = createExtension(fixture.active); + + await extension.commands + .get("downshift") + ?.("on", fixture.commandContext); + await extension.commands + .get("downshift") + ?.("on", fixture.commandContext); + + expect(extension.setModel).not.toHaveBeenCalled(); + expect(extension.setThinkingLevel).not.toHaveBeenCalled(); + expect(extension.pi.sendUserMessage).not.toHaveBeenCalled(); + expect(latestState(extension.pi)).toMatchObject({ + paused: false, + position: "premium", + handoff: "idle", + }); + }); + + it("stays paused without success when a required explicit premium is missing", async () => { + setConfig({ + premiumSource: "explicit", + premium: undefined, + handoffBeforeDownshift: true, + }); + const fixture = createContext({ current: { tokens: 100, percent: 10 } }); + const extension = createExtension(fixture.active); + + await extension.commands + .get("downshift") + ?.("on", fixture.commandContext); + + expect(latestState(extension.pi)).toMatchObject({ + paused: true, + lastError: "premium target is unset", + }); + expect(fixture.notify).toHaveBeenCalledWith( + "downshift paused: premium target is unset", + "error", + ); + expect(fixture.notify).not.toHaveBeenCalledWith( + "downshift on for this session", + "info", + ); + }); + + it("stays paused without success when model activation has no API key", async () => { + setConfig({ + premiumSource: "explicit", + premium: target("premium-new", "high"), + }); + const fixture = createContext({ current: { tokens: 100, percent: 10 } }); + const extension = createExtension(fixture.active); + extension.setModel.mockResolvedValueOnce(false); + + await extension.commands + .get("downshift") + ?.("on", fixture.commandContext); + + expect(latestState(extension.pi)).toMatchObject({ + paused: true, + lastError: "no API key for test/premium-new", + }); + expect(fixture.notify).not.toHaveBeenCalledWith( + "downshift on for this session", + "info", + ); + }); + + it("suppresses internally emitted target events but pauses on later manual changes", async () => { + setConfig({ + premiumSource: "explicit", + premium: target("premium-new", "high"), + }); + const fixture = createContext( + { current: { tokens: 100, percent: 10 } }, + { model: models[0], thinkingLevel: "low" }, + ); + const extension = createExtension(fixture.active); + extension.setModel.mockImplementationOnce(async (model: Model) => { + const previousModel = fixture.active.model; + fixture.active.model = model; + await extension.handlers.get("model_select")?.( + { model, previousModel, source: "set" }, + fixture.context, + ); + return true; + }); + extension.setThinkingLevel.mockImplementationOnce( + (level: ThinkingLevel) => { + const previousLevel = fixture.active.thinkingLevel; + fixture.active.thinkingLevel = level; + void extension.handlers.get("thinking_level_select")?.( + { level, previousLevel }, + fixture.context, + ); + }, + ); + + await extension.commands + .get("downshift") + ?.("on", fixture.commandContext); + + expect(latestState(extension.pi)).toMatchObject({ + paused: false, + position: "premium", + lastError: undefined, + }); + + await extension.handlers + .get("thinking_level_select") + ?.({ level: "medium", previousLevel: "high" }, fixture.context); + expect(latestState(extension.pi)).toMatchObject({ + paused: true, + lastError: "manual thinking level change", + }); + }); }); diff --git a/src/downshift.ts b/src/downshift.ts index b01dd02..e007e7b 100644 --- a/src/downshift.ts +++ b/src/downshift.ts @@ -148,14 +148,15 @@ async function getSelectableModels( return ctx.modelRegistry.getAvailable(); } -function getCurrentTarget( +function getActiveTarget( + pi: Pick, ctx: Pick, ): ModelTarget | undefined { if (!ctx.model) return undefined; return { provider: ctx.model.provider, model: ctx.model.id, - thinkingLevel: "off", + thinkingLevel: pi.getThinkingLevel(), }; } @@ -230,14 +231,21 @@ async function switchToTarget( } try { internalTargetChange = true; - const ok = await pi.setModel(model); - if (!ok) - return pause( - pi, - ctx, - `no API key for ${target.provider}/${target.model}`, - ); - pi.setThinkingLevel(target.thinkingLevel as ThinkingLevel); + const active = getActiveTarget(pi, ctx); + const modelChanged = + active?.provider !== target.provider || active.model !== target.model; + if (modelChanged) { + const ok = await pi.setModel(model); + if (!ok) + return pause( + pi, + ctx, + `no API key for ${target.provider}/${target.model}`, + ); + } + if (pi.getThinkingLevel() !== target.thinkingLevel) { + pi.setThinkingLevel(target.thinkingLevel as ThinkingLevel); + } saveState(pi, { position, lastError: undefined }); ctx.ui.notify(`downshift: ${reason} to ${targetLabel(target)}`, "info"); return true; @@ -816,26 +824,29 @@ async function enableSession( ctx: ExtensionCommandContext, ): Promise { const config = await readConfig(); + if (!config) { + updateStatus(ctx, config); + pause(pi, ctx, "config missing", { + sessionMode: "on", + handoff: "idle", + continueAfterHandoff: false, + }); + return; + } saveState(pi, enabledSessionState(pi, ctx, config)); - await maybeDownshift( - coreDeps(pi, ctx), - runtime, - ctx, - ctx.isIdle() ? "immediate" : "steer", - ); + const reconciled = await reconcileEnabledSession(pi, ctx, config); updateStatus(ctx, config); - ctx.ui.notify("downshift on for this session", "info"); + if (reconciled) ctx.ui.notify("downshift on for this session", "info"); } function enabledSessionState( pi: ExtensionAPI, ctx: ExtensionCommandContext, - config: DownshiftConfig | undefined, + config: DownshiftConfig, ): Partial { return { sessionMode: "on", paused: false, - position: "premium", lastError: undefined, handoff: "idle", continueAfterHandoff: false, @@ -846,12 +857,41 @@ function enabledSessionState( function capturedPremiumForEnable( pi: ExtensionAPI, ctx: ExtensionCommandContext, - config: DownshiftConfig | undefined, + config: DownshiftConfig, ): ModelTarget | undefined { - const current = getCurrentTarget(ctx); - if (config?.premiumSource !== "current" || !current) + const current = getActiveTarget(pi, ctx); + if (config.premiumSource !== "current" || !current) return runtime.state.capturedPremium; - return { ...current, thinkingLevel: pi.getThinkingLevel() }; + return current; +} + +async function reconcileEnabledSession( + pi: ExtensionAPI, + ctx: ExtensionCommandContext, + config: DownshiftConfig, +): Promise { + const needsPremium = + config.handoffBeforeDownshift || + !thresholdReached(ctx.getContextUsage(), config.threshold); + if (needsPremium) { + const premium = resolvePremiumTarget(config); + if (!premium) return pause(pi, ctx, "premium target is unset"); + const switched = await switchToTarget( + pi, + ctx, + premium, + "premium", + "resumed", + ); + if (!switched) return false; + } + await maybeDownshift( + coreDeps(pi, ctx), + runtime, + ctx, + ctx.isIdle() ? "immediate" : "steer", + ); + return !runtime.state.paused; } async function handleSessionStart( @@ -871,8 +911,8 @@ async function initializeSessionState( ctx: ExtensionContext, config: DownshiftConfig | undefined, ): Promise { - const current = getCurrentTarget(ctx); - runtime.state = initialSessionState(ctx, current, pi.getThinkingLevel()); + const current = getActiveTarget(pi, ctx); + runtime.state = initialSessionState(ctx, current); saveState(pi); if (shouldStartOnPremium(config, event, ctx)) { await switchToTarget(pi, ctx, config.premium, "premium", "started"); @@ -882,7 +922,6 @@ async function initializeSessionState( function initialSessionState( ctx: ExtensionContext, current: ModelTarget | undefined, - thinkingLevel: string, ): DownshiftState { return { sessionId: ctx.sessionManager.getSessionId(), @@ -891,7 +930,7 @@ function initialSessionState( position: "premium", handoff: "idle", continueAfterHandoff: false, - capturedPremium: current ? { ...current, thinkingLevel } : undefined, + capturedPremium: current, }; } From 2910a5a64b6f4ade826a686b30fb7070904225d9 Mon Sep 17 00:00:00 2001 From: Jeffrey Boadi Date: Sat, 11 Jul 2026 12:11:29 +0200 Subject: [PATCH 2/2] fix(commands): reconcile economy from retained state Activate the configured economy target directly when enabling above the threshold without a handoff, instead of allowing maybeDownshift to return early from a retained economy position. Cover the full lifecycle from an automatic economy transition through session disablement, configuration change, and re-enablement. --- src/downshift.test.ts | 53 +++++++++++++++++++++++++++++++++++++++++++ src/downshift.ts | 23 ++++++++++--------- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/downshift.test.ts b/src/downshift.test.ts index 3bb8bb0..1e315b7 100644 --- a/src/downshift.test.ts +++ b/src/downshift.test.ts @@ -315,11 +315,64 @@ describe("downshift lifecycle adapter", () => { expect(extension.setModel).toHaveBeenCalledWith(models[3]); expect(extension.setThinkingLevel).toHaveBeenCalledWith("medium"); expect(extension.pi.sendUserMessage).not.toHaveBeenCalled(); + expect(latestState(extension.pi)).toMatchObject({ + paused: false, + position: "economy", + handoff: "idle", + }); + }); + + it("reconciles a changed economy target after disabling from economy", async () => { + setConfig({ handoffBeforeDownshift: false }); + const fixture = createContext( + { current: { tokens: 100, percent: 60 } }, + { model: models[0], thinkingLevel: "high" }, + ); + const extension = createExtension(fixture.active); + + await extension.handlers + .get("session_start") + ?.({ reason: "new" }, fixture.context); + await extension.handlers.get("context")?.({}, fixture.context); + expect(extension.setModel).toHaveBeenLastCalledWith(models[2]); expect(latestState(extension.pi)).toMatchObject({ paused: false, position: "economy", handoff: "done", }); + + await extension.commands + .get("downshift") + ?.("off", fixture.commandContext); + expect(latestState(extension.pi)).toMatchObject({ + sessionMode: "off", + position: "economy", + }); + + setConfig({ + economy: target("economy-new", "medium"), + handoffBeforeDownshift: false, + }); + extension.setModel.mockClear(); + extension.setThinkingLevel.mockClear(); + + await extension.commands + .get("downshift") + ?.("on", fixture.commandContext); + + expect(extension.setModel).toHaveBeenCalledOnce(); + expect(extension.setModel).toHaveBeenCalledWith(models[3]); + expect(extension.setThinkingLevel).toHaveBeenCalledWith("medium"); + expect(latestState(extension.pi)).toMatchObject({ + sessionMode: "on", + paused: false, + position: "economy", + handoff: "idle", + }); + expect(fixture.notify).toHaveBeenCalledWith( + "downshift on for this session", + "info", + ); }); it("establishes updated premium before handing off to updated economy", async () => { diff --git a/src/downshift.ts b/src/downshift.ts index e007e7b..946cd9e 100644 --- a/src/downshift.ts +++ b/src/downshift.ts @@ -873,18 +873,19 @@ async function reconcileEnabledSession( const needsPremium = config.handoffBeforeDownshift || !thresholdReached(ctx.getContextUsage(), config.threshold); - if (needsPremium) { - const premium = resolvePremiumTarget(config); - if (!premium) return pause(pi, ctx, "premium target is unset"); - const switched = await switchToTarget( - pi, - ctx, - premium, - "premium", - "resumed", - ); - if (!switched) return false; + if (!needsPremium) { + return switchToTarget(pi, ctx, config.economy, "economy", "resumed"); } + const premium = resolvePremiumTarget(config); + if (!premium) return pause(pi, ctx, "premium target is unset"); + const switched = await switchToTarget( + pi, + ctx, + premium, + "premium", + "resumed", + ); + if (!switched) return false; await maybeDownshift( coreDeps(pi, ctx), runtime,