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..1e315b7 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; }; -function createExtension(): TestExtension { +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 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,323 @@ 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: "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 () => { + 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..946cd9e 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,42 @@ 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) { + 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, + ctx, + ctx.isIdle() ? "immediate" : "steer", + ); + return !runtime.state.paused; } async function handleSessionStart( @@ -871,8 +912,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 +923,6 @@ async function initializeSessionState( function initialSessionState( ctx: ExtensionContext, current: ModelTarget | undefined, - thinkingLevel: string, ): DownshiftState { return { sessionId: ctx.sessionManager.getSessionId(), @@ -891,7 +931,7 @@ function initialSessionState( position: "premium", handoff: "idle", continueAfterHandoff: false, - capturedPremium: current ? { ...current, thinkingLevel } : undefined, + capturedPremium: current, }; }