From 5f9b075998a825783c8d3d18c5f916f1c2480023 Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:15:43 -0400 Subject: [PATCH 01/25] web: show Auto routing details during turns --- README.md | 2 +- extensions/web-sessions.ts | 53 ++++++++++++++++++++-- tests/web-model-status.test.ts | 70 ++++++++++++++++++++++++++++++ web/client/app.tsx | 1 + web/client/semantic-session.tsx | 60 ++++++++++++++++++++----- web/client/styles.css | 10 +++++ web/model-status.ts | 49 +++++++++++++++++++++ web/protocol.ts | 3 ++ web/server/commandRouter.ts | 5 +++ web/server/managedSessionCreate.ts | 28 ++++++++++++ web/server/recordSync.ts | 47 ++++++++++++++++++-- web/server/server-types.ts | 5 +++ web/server/sessionRegistry.ts | 1 + 13 files changed, 316 insertions(+), 18 deletions(-) create mode 100644 tests/web-model-status.test.ts create mode 100644 web/model-status.ts diff --git a/README.md b/README.md index befb2bc..ebb82c0 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Run `/usage` to see health and usage for every configured model, grouped by tier `/usage` also shows the last several routing decisions under "Recent classifications" β€” what the classifier's raw reply actually was, the level it parsed to, and the tier/model it routed to. The classification call itself is otherwise a throwaway completion whose result would normally vanish the moment it's parsed, so if a turn ever looks under- or over-routed, this is what to check first rather than guessing from the code. -The `/model` picker's effort/thinking control is inert while any Auto entry is selected, since effort is chosen per turn (or fixed to the pinned tier) internally. `/model` keeps showing whichever Auto entry you picked selected even after routing: the real model is only swapped in for the duration of each turn and swapped back to that same inert Auto placeholder as soon as it settles, so reopening `/model` between turns still shows "Auto (auto)" or "Auto (high)" (whichever you picked), not whichever model last handled a turn. A `πŸ”€ Auto ()` badge in the TUI footer mirrors that same selection - `πŸ”€ Auto (auto)` for the adaptive entry, `πŸ”€ Auto ()` for a pinned one - not whatever a given turn happened to classify or dispatch to (check `/usage` for that; a model's own `effort` override in particular can differ from its tier, so the two aren't the same thing). Manually picking a real (non-Auto) model from `/model` turns Auto off; reselecting any Auto entry turns it back on. +The `/model` picker's effort/thinking control is inert while any Auto entry is selected, since effort is chosen per turn (or fixed to the pinned tier) internally. `/model` keeps showing whichever Auto entry you picked selected even after routing: the real model is only swapped in for the duration of each turn and swapped back to that same inert Auto placeholder as soon as it settles, so reopening `/model` between turns still shows "Auto (auto)" or "Auto (high)" (whichever you picked), not whichever model last handled a turn. A `πŸ”€ Auto ()` badge in the TUI footer mirrors that same selection - `πŸ”€ Auto (auto)` for the adaptive entry, `πŸ”€ Auto ()` for a pinned one - not whatever a given turn happened to classify or dispatch to (check `/usage` for that; a model's own `effort` override in particular can differ from its tier, so the two aren't the same thing). Pi Web follows the same distinction: its model control keeps Auto checked while a turn is running, and appends the concrete routed model and effort used for that turn. Manually picking a real (non-Auto) model from `/model` turns Auto off; reselecting any Auto entry turns it back on. If you've scoped `/model` with `enabledModels` (or `--models`), Pi's picker defaults to showing only that scoped list, hiding everything else β€” including every Auto entry β€” behind a manual Tab to "all". At session start, Auto best-effort appends an `auto/*` pattern to `enabledModels` (only when scoping is already configured, and only if it isn't already present) so every Auto entry shows up in the default scoped view too, without changing anything else about what's scoped. diff --git a/extensions/web-sessions.ts b/extensions/web-sessions.ts index d135638..bc498bb 100644 --- a/extensions/web-sessions.ts +++ b/extensions/web-sessions.ts @@ -9,6 +9,12 @@ import { type Theme, } from "@earendil-works/pi-coding-agent"; import { agentEndTerminalNotice } from "../web/assistant-message.js"; +import { + applyRuntimeModelStatus, + isAutoModelReference, + selectedModelReference, + webModelReference, +} from "../web/model-status.js"; import { WEB_COMPACT_COMMAND } from "../web/compact-command.js"; import { boundedWebHistory } from "../web/history.js"; import type { @@ -239,6 +245,8 @@ type BridgeState = { reconnectTimer?: ReturnType; reconnectAttempt: number; pending: AgentToServerMessage[]; + /** Set before Auto's before_agent_start hook swaps in the concrete model. */ + autoTurnRouting: boolean; metrics: Pick; sourceReplacement?: WorktreeSessionReplacement; }; @@ -850,6 +858,9 @@ async function executeAgentCommand( throw new Error( `Model not found: ${command.provider}/${command.modelId}`, ); + // A browser model change is explicit user selection, not Auto's + // transient model swap for the current turn. + state.autoTurnRouting = false; if (!(await pi.setModel(model))) throw new Error( `No credentials available for ${command.provider}/${command.modelId}`, @@ -1183,6 +1194,9 @@ function makeSession( branch, model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined, thinkingLevel: ctx.thinkingLevel, + selectedModel: ctx.model + ? `${ctx.model.provider}/${ctx.model.id}` + : undefined, status: statusForContext(ctx), source: "tui", createdAt: header ? Date.parse(header.timestamp) || Date.now() : Date.now(), @@ -1497,6 +1511,7 @@ export default function webSessions(pi: ExtensionAPI): void { closed: false, reconnectAttempt: 0, pending: [], + autoTurnRouting: false, metrics: { usage: session.usage, contextUsage: session.contextUsage }, sourceReplacement, }; @@ -1548,15 +1563,36 @@ export default function webSessions(pi: ExtensionAPI): void { } }); + pi.on("before_agent_start", (_event, ctx) => { + if ( + bridge && + ctx.sessionManager.getSessionId() === bridge.session.id + ) { + bridge.autoTurnRouting = isAutoModelReference( + selectedModelReference(bridge.session), + ); + } + }); pi.on("session_info_changed", (event, ctx) => { if (bridge) updateSession(bridge, { name: event.name }); forward(event, ctx); }); pi.on("model_select", (event, ctx) => { - if (bridge) - updateSession(bridge, { - model: `${event.model.provider}/${event.model.id}`, - }); + if (bridge) { + const runtimeModel = webModelReference(event.model); + const autoRoute = + bridge.autoTurnRouting && + isAutoModelReference(selectedModelReference(bridge.session)) && + !isAutoModelReference(runtimeModel); + const next = applyRuntimeModelStatus( + bridge.session, + runtimeModel, + ctx.thinkingLevel, + autoRoute, + ); + if (!autoRoute) bridge.autoTurnRouting = false; + updateSession(bridge, next); + } forward(event, ctx); }); pi.on("thinking_level_select", (event, ctx) => { @@ -1572,6 +1608,15 @@ export default function webSessions(pi: ExtensionAPI): void { forward(event, ctx, status); }); pi.on("agent_settled", (event, ctx) => { + if (bridge) { + bridge.autoTurnRouting = false; + const selectedModel = selectedModelReference(bridge.session); + if ( + isAutoModelReference(selectedModel) && + bridge.session.model !== selectedModel + ) + updateSession(bridge, { model: selectedModel }); + } if (bridge?.session.compaction) { endBridgeCompaction(bridge, { aborted: false, diff --git a/tests/web-model-status.test.ts b/tests/web-model-status.test.ts new file mode 100644 index 0000000..178fde5 --- /dev/null +++ b/tests/web-model-status.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "bun:test"; +import { + applyRuntimeModelStatus, + isAutoModelReference, + selectedModelReference, +} from "../web/model-status"; + +test("recognizes Auto placeholders without matching ordinary models", () => { + expect(isAutoModelReference("auto/auto")).toBe(true); + expect(isAutoModelReference("auto/auto-high")).toBe(true); + expect(isAutoModelReference("openai/gpt-5.6-luna")).toBe(false); + expect(isAutoModelReference(undefined)).toBe(false); +}); + +test("keeps Auto selected while recording the routed runtime model and effort", () => { + const status = applyRuntimeModelStatus( + { + model: "auto/auto", + thinkingLevel: "off", + selectedModel: "auto/auto", + }, + "openai-codex/gpt-5.6-luna", + "high", + true, + ); + expect(status).toEqual({ + model: "openai-codex/gpt-5.6-luna", + thinkingLevel: "high", + selectedModel: "auto/auto", + }); + expect(selectedModelReference(status)).toBe("auto/auto"); +}); + +test("ordinary model changes replace both the runtime and selected model", () => { + expect( + applyRuntimeModelStatus( + { + model: "openai-codex/gpt-5.6-luna", + thinkingLevel: "high", + selectedModel: "auto/auto", + }, + "anthropic/claude-sonnet", + "medium", + false, + ), + ).toEqual({ + model: "anthropic/claude-sonnet", + thinkingLevel: "medium", + selectedModel: "anthropic/claude-sonnet", + }); +}); + +test("the Auto placeholder is selected again after the runtime reverts", () => { + expect( + applyRuntimeModelStatus( + { + model: "openai-codex/gpt-5.6-luna", + thinkingLevel: "high", + selectedModel: "auto/auto", + }, + "auto/auto", + "off", + false, + ), + ).toEqual({ + model: "auto/auto", + thinkingLevel: "off", + selectedModel: "auto/auto", + }); +}); diff --git a/web/client/app.tsx b/web/client/app.tsx index 64030ab..015dd4a 100644 --- a/web/client/app.tsx +++ b/web/client/app.tsx @@ -354,6 +354,7 @@ function sessionMatches(session: WebSession, query: string): boolean { session.branch, session.projectName, session.model, + session.selectedModel, session.status, sessionStatusLabel(session), ].some((value) => value?.toLocaleLowerCase().includes(needle)); diff --git a/web/client/semantic-session.tsx b/web/client/semantic-session.tsx index 4e06d77..3a19c8c 100644 --- a/web/client/semantic-session.tsx +++ b/web/client/semantic-session.tsx @@ -110,6 +110,10 @@ import { TooltipProvider, TooltipTrigger, } from "./components/ui/tooltip"; +import { + isAutoModelReference, + selectedModelReference, +} from "../model-status"; import { assertClientPromptPayloadFits } from "./image-payload"; import { cn } from "./lib/utils"; import { anchoredScrollTop, resolveScrollFollow } from "./scroll-follow"; @@ -2527,23 +2531,42 @@ export function SemanticSession({ } }; - const modelLabel = session?.model?.split("/").pop() ?? "Model"; + const selectedModelRef = selectedModelReference(session ?? {}); + const selectedModelIdLabel = selectedModelRef?.split("/").pop() ?? "Model"; + const autoSelected = isAutoModelReference(selectedModelRef); const effortLabel = session?.thinkingLevel ?? "off"; const availableModels = sessionOptions.models.length > 0 ? sessionOptions.models : (() => { - const slashIndex = session?.model?.indexOf("/") ?? -1; - if (!session?.model || slashIndex < 0) return []; + const slashIndex = selectedModelRef?.indexOf("/") ?? -1; + if (!selectedModelRef || slashIndex < 0) return []; return [ { - provider: session.model.slice(0, slashIndex), - id: session.model.slice(slashIndex + 1), - name: modelLabel, + provider: selectedModelRef.slice(0, slashIndex), + id: selectedModelRef.slice(slashIndex + 1), + name: selectedModelIdLabel, reasoning: true, }, ]; })(); + const selectedModelOption = availableModels.find( + (model) => `${model.provider}/${model.id}` === selectedModelRef, + ); + const modelLabel = autoSelected + ? (selectedModelOption?.name ?? + (selectedModelRef === "auto/auto" + ? "Auto (auto)" + : `Auto (${selectedModelRef?.slice("auto/auto-".length) ?? "auto"})`)) + : selectedModelIdLabel; + const effectiveModelLabel = session?.model?.split("/").pop(); + const turnModelSummary = + autoSelected && + session?.model && + session.model !== selectedModelRef && + effectiveModelLabel + ? `${effectiveModelLabel} Β· ${effortLabel}` + : undefined; const availableEfforts = sessionOptions.thinkingLevels.length > 0 ? sessionOptions.thinkingLevels @@ -3269,12 +3292,29 @@ export function SemanticSession({ variant="ghost" size="sm" disabled={controlBusy || !connected} + title={ + turnModelSummary + ? `Selected ${modelLabel}; using ${turnModelSummary}` + : undefined + } onMouseDown={(event) => event.preventDefault()} onClick={() => setModelMenuOpen((open) => !open)} > - {modelLabel} - Β· - {effortLabel} + {modelLabel} + {!autoSelected && ( + <> + Β· + {effortLabel} + + )} + {turnModelSummary && ( + <> + β†’ + + {turnModelSummary} + + + )} {model.name} {value} - {session?.model === value && ( + {selectedModelRef === value && ( )} diff --git a/web/client/styles.css b/web/client/styles.css index 7609516..005c004 100644 --- a/web/client/styles.css +++ b/web/client/styles.css @@ -1045,6 +1045,16 @@ details[open] > summary .semantic-tool-chevron { text-overflow: ellipsis; white-space: nowrap; } +.semantic-composer-control > .semantic-model-selection, +.semantic-composer-control > .semantic-turn-model { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.semantic-composer-control > .semantic-turn-model { + color: rgb(125 211 252); +} .semantic-composer-menu button { display: flex; width: 100%; diff --git a/web/model-status.ts b/web/model-status.ts new file mode 100644 index 0000000..3da7f24 --- /dev/null +++ b/web/model-status.ts @@ -0,0 +1,49 @@ +export type WebModelStatus = { + /** The runtime model currently assigned to the session. */ + model?: string; + thinkingLevel?: string; + /** The model the user selected; differs from `model` only while Auto routes. */ + selectedModel?: string; +}; + +export type WebModelIdentity = { provider: string; id: string }; + +export function webModelReference(model: WebModelIdentity): string { + return `${model.provider}/${model.id}`; +} + +export function isAutoModelReference(reference: string | undefined): boolean { + return reference?.startsWith("auto/") === true; +} + +export function selectedModelReference( + status: Pick, +): string | undefined { + return status.selectedModel ?? status.model; +} + +/** + * Keep the user's selected Auto placeholder separate from the concrete runtime + * model used for the active turn. Ordinary model changes update both values. + */ +export function applyRuntimeModelStatus( + status: WebModelStatus, + runtimeModel: string, + runtimeThinkingLevel: string | undefined, + autoTurnActive: boolean, +): WebModelStatus { + const selectedModel = selectedModelReference(status); + const preservingAutoSelection = + autoTurnActive && + isAutoModelReference(selectedModel) && + !isAutoModelReference(runtimeModel); + + return { + ...status, + model: runtimeModel, + ...(runtimeThinkingLevel !== undefined + ? { thinkingLevel: runtimeThinkingLevel } + : {}), + selectedModel: preservingAutoSelection ? selectedModel : runtimeModel, + }; +} diff --git a/web/protocol.ts b/web/protocol.ts index 8eb1c26..b51e46c 100644 --- a/web/protocol.ts +++ b/web/protocol.ts @@ -148,8 +148,11 @@ export type WebSession = { cwd: string; name?: string; branch?: string; + /** The runtime model currently assigned to the session. */ model?: string; thinkingLevel?: string; + /** The model selected by the user; remains Auto while routing a turn. */ + selectedModel?: string; status: SessionStatus; source: SessionSource; createdAt: number; diff --git a/web/server/commandRouter.ts b/web/server/commandRouter.ts index 7d9ae0c..b169530 100644 --- a/web/server/commandRouter.ts +++ b/web/server/commandRouter.ts @@ -473,6 +473,11 @@ export function createCommandRouter(options: { }; } case "set_model": + // A browser model change is explicit user selection, not the Auto + // router's transient runtime swap. Prevent a concurrent turn + // snapshot from treating the new model as the current Auto target. + record.modelTurnGeneration = (record.modelTurnGeneration ?? 0) + 1; + record.autoTurnActive = false; await record.managed.setModel(command.provider, command.modelId); await refreshManagedSession(record); return; diff --git a/web/server/managedSessionCreate.ts b/web/server/managedSessionCreate.ts index aa8d51b..611d204 100644 --- a/web/server/managedSessionCreate.ts +++ b/web/server/managedSessionCreate.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { isAutoModelReference } from "../model-status.js"; import { agentEndTerminalNotice, assistantTerminalNotice, @@ -90,6 +91,8 @@ export function createManagedSessionLauncher(options: { const { replaceRecordHistory, appendRecordHistory } = history; const { updateRecordFromState, + beginTurnModelTracking, + finishTurnModelTracking, updateRecordFromStats, updateSubagentsFromToolEvent, } = recordSync; @@ -144,6 +147,10 @@ export function createManagedSessionLauncher(options: { name: name ?? resumed?.session.name, model: resumed?.session.model, thinkingLevel: resumed?.session.thinkingLevel, + selectedModel: resumed?.session.selectedModel ?? resumed?.session.model, + autoTurnActive: isAutoModelReference( + resumed?.session.selectedModel ?? resumed?.session.model, + ), status: "starting", source: "web", createdAt: resumed?.session.createdAt ?? Date.now(), @@ -190,6 +197,26 @@ export function createManagedSessionLauncher(options: { record.status = "working"; record.agentRunning = true; } + if (event.type === "turn_start") { + const generation = beginTurnModelTracking(record); + const managedAtTurnStart = record.managed; + if (record.autoTurnActive && managedAtTurnStart) { + void managedAtTurnStart + .getState() + .then((state) => { + if ( + runtime.sessions.get(record.id) !== record || + record.managed !== managedAtTurnStart || + record.modelTurnGeneration !== generation || + !record.autoTurnActive + ) + return; + updateRecordFromState(record, state); + broadcastSessionToAll(record); + }) + .catch(() => undefined); + } + } if (event.type === "agent_end" && !record.compaction) { markAgentSettling(record); record.status = @@ -224,6 +251,7 @@ export function createManagedSessionLauncher(options: { event.type === "agent_settled" && isCurrentAgentSettlement(record) ) { + finishTurnModelTracking(record); cancelQueueSettleFallback(record); record.settlingGeneration = undefined; // Pi emits agent_settled only when no retry, compaction, or internal diff --git a/web/server/recordSync.ts b/web/server/recordSync.ts index a9745c1..836ef75 100644 --- a/web/server/recordSync.ts +++ b/web/server/recordSync.ts @@ -1,3 +1,8 @@ +import { + applyRuntimeModelStatus, + isAutoModelReference, + selectedModelReference, +} from "../model-status.js"; import type { WebSession } from "../protocol.js"; import type { SessionFileCatalog, SessionRecord } from "./server-types.js"; import type { ServerRuntimeState } from "./serverRuntimeState.js"; @@ -24,13 +29,27 @@ export function createRecordSync(options: { if (!s) return; const model = s.model as Record | null | undefined; if (model && typeof model.id === "string") { - record.model = + const runtimeModel = typeof model.provider === "string" && model.provider ? `${model.provider}/${model.id}` : model.id; - } - if (typeof s.thinkingLevel === "string") + const preservingAutoSelection = + record.autoTurnActive === true && + isAutoModelReference(selectedModelReference(record)) && + !isAutoModelReference(runtimeModel); + const next = applyRuntimeModelStatus( + record, + runtimeModel, + typeof s.thinkingLevel === "string" ? s.thinkingLevel : undefined, + preservingAutoSelection, + ); + record.model = next.model; + record.thinkingLevel = next.thinkingLevel; + record.selectedModel = next.selectedModel; + if (!preservingAutoSelection) record.autoTurnActive = false; + } else if (typeof s.thinkingLevel === "string") { record.thinkingLevel = s.thinkingLevel; + } if (typeof s.sessionFile === "string") { record.file = s.sessionFile; runtime.sessionsByFile.set(normalizePath(s.sessionFile), record); @@ -57,6 +76,25 @@ export function createRecordSync(options: { record.updatedAt = Date.now(); } + function beginTurnModelTracking(record: SessionRecord): number { + const generation = (record.modelTurnGeneration ?? 0) + 1; + record.modelTurnGeneration = generation; + record.autoTurnActive = isAutoModelReference( + selectedModelReference(record), + ); + if (!record.autoTurnActive) record.selectedModel ??= record.model; + return generation; + } + + function finishTurnModelTracking(record: SessionRecord): void { + record.modelTurnGeneration = (record.modelTurnGeneration ?? 0) + 1; + const selectedModel = selectedModelReference(record); + if (record.autoTurnActive && isAutoModelReference(selectedModel)) + record.model = selectedModel; + record.autoTurnActive = false; + record.selectedModel ??= record.model; + } + function updateRecordFromStats(record: SessionRecord, value: unknown): void { if (!isRecord(value)) return; if (isRecord(value.tokens)) { @@ -188,6 +226,7 @@ export function createRecordSync(options: { previous.branch !== next.branch || previous.model !== next.model || previous.thinkingLevel !== next.thinkingLevel || + previous.selectedModel !== next.selectedModel || previous.status !== next.status || previous.source !== next.source || previous.messageCount !== next.messageCount || @@ -202,6 +241,8 @@ export function createRecordSync(options: { return { updateRecordFromState, + beginTurnModelTracking, + finishTurnModelTracking, updateRecordFromStats, updateSubagentsFromToolEvent, catalogSessionChanged, diff --git a/web/server/server-types.ts b/web/server/server-types.ts index 6210c9f..fc96e9b 100644 --- a/web/server/server-types.ts +++ b/web/server/server-types.ts @@ -45,6 +45,7 @@ export type SessionRecord = { branch?: string; model?: string; thinkingLevel?: string; + selectedModel?: string; status: SessionStatus; source: SessionSource; createdAt: number; @@ -64,6 +65,10 @@ export type SessionRecord = { historyBytes?: number; active: boolean; agentRunning?: boolean; + /** Internal generation used to ignore stale get_state responses from prior turns. */ + modelTurnGeneration?: number; + /** True while an Auto selection is being resolved for the current turn. */ + autoTurnActive?: boolean; agentStartGeneration?: number; activityGeneration?: number; settlingGeneration?: number; diff --git a/web/server/sessionRegistry.ts b/web/server/sessionRegistry.ts index fe202a6..921b75c 100644 --- a/web/server/sessionRegistry.ts +++ b/web/server/sessionRegistry.ts @@ -62,6 +62,7 @@ export function createSessionRegistry(options: { branch: session.branch, model: session.model, thinkingLevel: session.thinkingLevel, + selectedModel: session.selectedModel, status: session.status, source: session.source, createdAt: session.createdAt, From ea687c8763fd77d348cc7a21628286314a2d471c Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:28:45 -0400 Subject: [PATCH 02/25] Address Auto routing review feedback --- extensions/web-sessions.ts | 73 +++++++++++++++++++++------------ web/client/semantic-session.tsx | 15 +++---- web/model-status.ts | 3 ++ web/server/commandRouter.ts | 6 +-- web/server/recordSync.ts | 7 +++- 5 files changed, 66 insertions(+), 38 deletions(-) diff --git a/extensions/web-sessions.ts b/extensions/web-sessions.ts index bc498bb..616b8ee 100644 --- a/extensions/web-sessions.ts +++ b/extensions/web-sessions.ts @@ -858,14 +858,17 @@ async function executeAgentCommand( throw new Error( `Model not found: ${command.provider}/${command.modelId}`, ); - // A browser model change is explicit user selection, not Auto's - // transient model swap for the current turn. - state.autoTurnRouting = false; if (!(await pi.setModel(model))) throw new Error( `No credentials available for ${command.provider}/${command.modelId}`, ); - updateSession(state, { model: `${model.provider}/${model.id}` }); + // A browser model change is explicit user selection, not Auto's + // transient model swap for the current turn. + state.autoTurnRouting = false; + updateSession(state, { + model: `${model.provider}/${model.id}`, + selectedModel: `${model.provider}/${model.id}`, + }); respond(state, requestId, true); return; } @@ -1288,6 +1291,17 @@ export default function webSessions(pi: ExtensionAPI): void { }); }; + const activeBridgeFor = ( + ctx: ExtensionContext, + ): BridgeState | undefined => { + const state = bridge; + return state && + !state.closed && + ctx.sessionManager.getSessionId() === state.session.id + ? state + : undefined; + }; + pi.registerCommand("web", { description: "Show the current session in the Pi web app", handler: async (_args, ctx) => { @@ -1564,39 +1578,39 @@ export default function webSessions(pi: ExtensionAPI): void { }); pi.on("before_agent_start", (_event, ctx) => { - if ( - bridge && - ctx.sessionManager.getSessionId() === bridge.session.id - ) { - bridge.autoTurnRouting = isAutoModelReference( - selectedModelReference(bridge.session), + const activeBridge = activeBridgeFor(ctx); + if (activeBridge) + activeBridge.autoTurnRouting = isAutoModelReference( + selectedModelReference(activeBridge.session), ); - } }); pi.on("session_info_changed", (event, ctx) => { - if (bridge) updateSession(bridge, { name: event.name }); + const activeBridge = activeBridgeFor(ctx); + if (activeBridge) updateSession(activeBridge, { name: event.name }); forward(event, ctx); }); pi.on("model_select", (event, ctx) => { - if (bridge) { + const activeBridge = activeBridgeFor(ctx); + if (activeBridge) { const runtimeModel = webModelReference(event.model); const autoRoute = - bridge.autoTurnRouting && - isAutoModelReference(selectedModelReference(bridge.session)) && + activeBridge.autoTurnRouting && + isAutoModelReference(selectedModelReference(activeBridge.session)) && !isAutoModelReference(runtimeModel); const next = applyRuntimeModelStatus( - bridge.session, + activeBridge.session, runtimeModel, ctx.thinkingLevel, autoRoute, ); - if (!autoRoute) bridge.autoTurnRouting = false; - updateSession(bridge, next); + if (!autoRoute) activeBridge.autoTurnRouting = false; + updateSession(activeBridge, next); } forward(event, ctx); }); pi.on("thinking_level_select", (event, ctx) => { - if (bridge) updateSession(bridge, { thinkingLevel: event.level }); + const activeBridge = activeBridgeFor(ctx); + if (activeBridge) updateSession(activeBridge, { thinkingLevel: event.level }); forward(event, ctx); }); pi.on("agent_start", (event, ctx) => forward(event, ctx, "working")); @@ -1608,23 +1622,28 @@ export default function webSessions(pi: ExtensionAPI): void { forward(event, ctx, status); }); pi.on("agent_settled", (event, ctx) => { - if (bridge) { - bridge.autoTurnRouting = false; - const selectedModel = selectedModelReference(bridge.session); + const activeBridge = activeBridgeFor(ctx); + if (activeBridge) { + activeBridge.autoTurnRouting = false; + const selectedModel = selectedModelReference(activeBridge.session); if ( isAutoModelReference(selectedModel) && - bridge.session.model !== selectedModel + activeBridge.session.model !== selectedModel ) - updateSession(bridge, { model: selectedModel }); + updateSession(activeBridge, { model: selectedModel }); } - if (bridge?.session.compaction) { - endBridgeCompaction(bridge, { + if (activeBridge?.session.compaction) { + endBridgeCompaction(activeBridge, { aborted: false, willRetry: false, errorMessage: "Compaction stopped before completion", }); } - forward(event, ctx, bridge?.session.status === "error" ? "error" : "idle"); + forward( + event, + ctx, + activeBridge?.session.status === "error" ? "error" : "idle", + ); }); pi.on("turn_start", (event, ctx) => forward(event, ctx, "working")); pi.on("turn_end", (event, ctx) => forward(event, ctx)); diff --git a/web/client/semantic-session.tsx b/web/client/semantic-session.tsx index 3a19c8c..36c6fbb 100644 --- a/web/client/semantic-session.tsx +++ b/web/client/semantic-session.tsx @@ -2534,6 +2534,11 @@ export function SemanticSession({ const selectedModelRef = selectedModelReference(session ?? {}); const selectedModelIdLabel = selectedModelRef?.split("/").pop() ?? "Model"; const autoSelected = isAutoModelReference(selectedModelRef); + const fallbackModelLabel = autoSelected + ? selectedModelRef === "auto/auto" + ? "Auto (auto)" + : `Auto (${selectedModelRef?.slice("auto/auto-".length) ?? "auto"})` + : selectedModelIdLabel; const effortLabel = session?.thinkingLevel ?? "off"; const availableModels = sessionOptions.models.length > 0 @@ -2545,7 +2550,7 @@ export function SemanticSession({ { provider: selectedModelRef.slice(0, slashIndex), id: selectedModelRef.slice(slashIndex + 1), - name: selectedModelIdLabel, + name: fallbackModelLabel, reasoning: true, }, ]; @@ -2553,12 +2558,7 @@ export function SemanticSession({ const selectedModelOption = availableModels.find( (model) => `${model.provider}/${model.id}` === selectedModelRef, ); - const modelLabel = autoSelected - ? (selectedModelOption?.name ?? - (selectedModelRef === "auto/auto" - ? "Auto (auto)" - : `Auto (${selectedModelRef?.slice("auto/auto-".length) ?? "auto"})`)) - : selectedModelIdLabel; + const modelLabel = selectedModelOption?.name ?? fallbackModelLabel; const effectiveModelLabel = session?.model?.split("/").pop(); const turnModelSummary = autoSelected && @@ -3355,6 +3355,7 @@ export function SemanticSession({ - - - - - - - - - ); + return {compact}; }, (previous, next) => sameTokenTelemetry(previous.session, next.session), ); @@ -841,6 +825,7 @@ const ContextProgressCircle = React.memo(
{compacting ? "Compacting context…" : "Context"} + {session && } {contextText}
diff --git a/web/client/styles.css b/web/client/styles.css index 005c004..ef45d52 100644 --- a/web/client/styles.css +++ b/web/client/styles.css @@ -937,21 +937,6 @@ details[open] > summary .semantic-tool-chevron { font-variant-numeric: tabular-nums; white-space: nowrap; } -.semantic-token-mobile { - display: none; -} -.semantic-token-mobile > button { - display: grid; - width: 2.25rem; - height: 2.25rem; - place-items: center; - border-radius: 0.5rem; - color: rgb(113 113 122); -} -.semantic-token-mobile > button:hover { - background: rgb(39 39 42); - color: rgb(212 212 216); -} .semantic-token-details { display: grid; min-width: 11rem; @@ -1249,9 +1234,6 @@ details[open] > summary .semantic-tool-chevron { border-left: 0; padding-top: 0.35rem; } - .semantic-token-mobile { - display: inline-flex; - } .semantic-tool summary, .semantic-tool-call summary { gap: 0.35rem; From a28588e78e80f1802642e6e0b592ffb37694e3cb Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:30:56 -0400 Subject: [PATCH 06/25] Add space after footer web link icon --- extensions/web-sessions.ts | 5 ++++- tests/session-footer.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/extensions/web-sessions.ts b/extensions/web-sessions.ts index b26469b..03a31f4 100644 --- a/extensions/web-sessions.ts +++ b/extensions/web-sessions.ts @@ -580,7 +580,10 @@ function hyperlink(url: string, label: string): string { } function renderWebLink(theme: Theme, url: string): string { - return hyperlink(url, theme.fg("accent", "⧉")); + // Some terminals eat the trailing space between the OSC 8 link close and + // the following glyph, leaving the icon visually glued to the directory + // text. Wrapping the trailing space inside the hyperlink avoids that. + return hyperlink(url, `${theme.fg("accent", "⧉")} `); } function publishFooter(pi: ExtensionAPI, state: BridgeState): void { diff --git a/tests/session-footer.test.ts b/tests/session-footer.test.ts index 457c8f4..93b049b 100644 --- a/tests/session-footer.test.ts +++ b/tests/session-footer.test.ts @@ -61,7 +61,7 @@ test("the shared footer places identity, routing, and activity in two rows", () emit({ sessionId: "session-1", key: "web", - identityPrefix: () => "⧉", + identityPrefix: () => "⧉ ", }); emit({ sessionId: "session-1", @@ -95,7 +95,7 @@ test("the shared footer places identity, routing, and activity in two rows", () expect(component).toBeDefined(); const lines = component?.render(100) ?? []; expect(lines).toHaveLength(2); - expect(lines[0]).toStartWith("⧉ ~/repo (main) β€’ Session β€’ PR #17"); + expect(lines[0]).toStartWith("⧉ ~/repo (main) β€’ Session β€’ PR #17"); expect(lines[0]).toEndWith( "Auto (auto) β€’ (provider) model β€’ high", ); From 0d9339ecbc57dd61eaeb820a588534529360ff6f Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:25:10 -0400 Subject: [PATCH 07/25] Address Auto refresh and mobile tooltip feedback --- tests/web-record-sync.test.ts | 69 +++++++++++++++++++++++++++++ web/client/semantic-session.tsx | 19 ++++---- web/server/managedSessionRefresh.ts | 10 ++++- web/server/recordSync.ts | 13 ++++-- 4 files changed, 98 insertions(+), 13 deletions(-) create mode 100644 tests/web-record-sync.test.ts diff --git a/tests/web-record-sync.test.ts b/tests/web-record-sync.test.ts new file mode 100644 index 0000000..835f9b7 --- /dev/null +++ b/tests/web-record-sync.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from "bun:test"; +import { createRecordSync } from "../web/server/recordSync.ts"; +import type { + SessionFileCatalog, + SessionRecord, +} from "../web/server/server-types.ts"; +import type { ServerRuntimeState } from "../web/server/serverRuntimeState.ts"; + +function recordSync() { + const catalog = { + isRecord: (value: unknown): value is Record => + typeof value === "object" && value !== null, + normalizePath: (value: string) => value, + parseSessionMetadataFile: () => undefined, + toNumber: (value: unknown, fallback = 0) => + typeof value === "number" ? value : fallback, + zeroWebUsage: () => ({ + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }), + } as unknown as SessionFileCatalog; + const state = { + sessionsByFile: new Map(), + } as unknown as ServerRuntimeState; + return createRecordSync({ catalog, state }); +} + +test("a stale refresh cannot cancel Auto tracking for a newer turn", () => { + const sync = recordSync(); + const record = { + id: "session-1", + model: "openai-codex/routed", + selectedModel: "auto/auto", + thinkingLevel: "high", + modelTurnGeneration: 2, + autoTurnActive: true, + autoTurnSettling: false, + name: "before", + } as unknown as SessionRecord; + + sync.updateRecordFromState( + record, + { + model: { provider: "auto", id: "auto" }, + thinkingLevel: "off", + sessionName: "after", + }, + 1, + ); + + expect(record).toMatchObject({ + model: "openai-codex/routed", + selectedModel: "auto/auto", + thinkingLevel: "high", + modelTurnGeneration: 2, + autoTurnActive: true, + name: "after", + }); +}); diff --git a/web/client/semantic-session.tsx b/web/client/semantic-session.tsx index 9c03526..7d3f4e1 100644 --- a/web/client/semantic-session.tsx +++ b/web/client/semantic-session.tsx @@ -738,6 +738,7 @@ function sameTokenTelemetry( usage?.cacheRead ?? 0, usage?.cacheWrite ?? 0, usage?.cost.total ?? 0, + session.contextUsage?.tokens ?? null, session.contextUsage?.percent ?? null, session.contextUsage?.contextWindow ?? 0, ]; @@ -777,6 +778,7 @@ const ContextProgressCircle = React.memo( session: WebSession | null; interactive?: boolean; }) { + const [open, setOpen] = React.useState(false); const context = session?.contextUsage; const contextTokens = context?.tokens ?? 0; const rawPercent = @@ -831,9 +833,14 @@ const ContextProgressCircle = React.memo( ); return ( - + - @@ -850,13 +857,7 @@ const ContextProgressCircle = React.memo( }, (previous, next) => previous.interactive === next.interactive && - previous.session?.id === next.session?.id && - previous.session?.contextUsage?.tokens === - next.session?.contextUsage?.tokens && - previous.session?.contextUsage?.contextWindow === - next.session?.contextUsage?.contextWindow && - previous.session?.contextUsage?.percent === - next.session?.contextUsage?.percent && + sameTokenTelemetry(previous.session, next.session) && previous.session?.compaction?.reason === next.session?.compaction?.reason, ); diff --git a/web/server/managedSessionRefresh.ts b/web/server/managedSessionRefresh.ts index 7397321..2ee655f 100644 --- a/web/server/managedSessionRefresh.ts +++ b/web/server/managedSessionRefresh.ts @@ -111,6 +111,10 @@ export function createManagedSessionRefresh(options: { nextFile: string, ) => ManagedIdentityTransition, ): Promise { + // A settlement refresh may finish after a queued prompt starts. Keep the + // generation from request time so its model snapshot cannot cancel the + // newer turn's Auto tracking. + const modelTurnGeneration = record.modelTurnGeneration ?? 0; await runManagedRefresh( () => serializeManagedRefresh(record, async () => { @@ -267,7 +271,11 @@ export function createManagedSessionRefresh(options: { ); } - updateRecordFromState(record, nextState); + updateRecordFromState( + record, + nextState, + modelTurnGeneration, + ); try { replaceRecordHistory( record, diff --git a/web/server/recordSync.ts b/web/server/recordSync.ts index 6d094b3..2b76d05 100644 --- a/web/server/recordSync.ts +++ b/web/server/recordSync.ts @@ -24,11 +24,18 @@ export function createRecordSync(options: { zeroWebUsage, } = catalog; - function updateRecordFromState(record: SessionRecord, state: unknown): void { + function updateRecordFromState( + record: SessionRecord, + state: unknown, + expectedModelTurnGeneration?: number, + ): void { const s = state as Record | undefined; if (!s) return; + const modelStateIsCurrent = + expectedModelTurnGeneration === undefined || + (record.modelTurnGeneration ?? 0) === expectedModelTurnGeneration; const model = s.model as Record | null | undefined; - if (model && typeof model.id === "string") { + if (modelStateIsCurrent && model && typeof model.id === "string") { const runtimeModel = typeof model.provider === "string" && model.provider ? `${model.provider}/${model.id}` @@ -64,7 +71,7 @@ export function createRecordSync(options: { record.autoTurnSettling = false; } if (!preservingAutoSelection) record.autoTurnActive = false; - } else if (typeof s.thinkingLevel === "string") { + } else if (modelStateIsCurrent && typeof s.thinkingLevel === "string") { record.thinkingLevel = s.thinkingLevel; } if (typeof s.sessionFile === "string") { From ee8818212ffcfc7f7986e4c3983cc24e693c73ac Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:32:56 -0400 Subject: [PATCH 08/25] Show Auto's last routed model after turns --- extensions/web-sessions.ts | 8 +++++++- tests/web-model-status.test.ts | 21 +++++++++++++++++++++ tests/web-record-sync.test.ts | 4 ++++ web/client/app.tsx | 1 + web/client/semantic-session.tsx | 20 ++++++++++++++------ web/model-status.ts | 27 ++++++++++++++++++++++++++- web/protocol.ts | 2 ++ web/server/commandRouter.ts | 1 + web/server/managedSessionCreate.ts | 1 + web/server/recordSync.ts | 12 +++++++++--- web/server/server-types.ts | 1 + web/server/session-file-catalog.ts | 27 ++++++++++++++++++++++++--- web/server/sessionRegistry.ts | 1 + 13 files changed, 112 insertions(+), 14 deletions(-) diff --git a/extensions/web-sessions.ts b/extensions/web-sessions.ts index 03a31f4..3182e49 100644 --- a/extensions/web-sessions.ts +++ b/extensions/web-sessions.ts @@ -13,6 +13,7 @@ import { applyRuntimeModelStatus, isAutoModelReference, isAutoRuntimeModelSwap, + lastConcreteModelFromEntries, selectedModelReference, webModelReference, } from "../web/model-status.js"; @@ -940,6 +941,7 @@ async function executeAgentCommand( updateSession(state, { model: `${model.provider}/${model.id}`, selectedModel: `${model.provider}/${model.id}`, + lastModel: undefined, }); respond(state, requestId, true); return; @@ -1268,6 +1270,7 @@ function makeSession( selectedModel: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined, + lastModel: lastConcreteModelFromEntries(entries), status: statusForContext(ctx), source: "tui", createdAt: header ? Date.parse(header.timestamp) || Date.now() : Date.now(), @@ -1692,7 +1695,10 @@ export default function webSessions(pi: ExtensionAPI): void { autoRoute, ); if (!autoRoute) activeBridge.autoTurnRouting = false; - updateSession(activeBridge, next); + updateSession(activeBridge, { + ...next, + lastModel: next.lastModel, + }); } forward(event, ctx); }); diff --git a/tests/web-model-status.test.ts b/tests/web-model-status.test.ts index d4cf832..f3eae97 100644 --- a/tests/web-model-status.test.ts +++ b/tests/web-model-status.test.ts @@ -3,9 +3,29 @@ import { applyRuntimeModelStatus, isAutoModelReference, isAutoRuntimeModelSwap, + lastConcreteModelFromEntries, selectedModelReference, } from "../web/model-status"; +test("finds the last concrete model in model-change entries", () => { + expect( + lastConcreteModelFromEntries([ + { type: "model_change", provider: "auto", modelId: "auto" }, + { + type: "model_change", + provider: "openai-codex", + modelId: "gpt-5.6-luna", + }, + { type: "model_change", provider: "auto", modelId: "auto" }, + ]), + ).toBe("openai-codex/gpt-5.6-luna"); + expect( + lastConcreteModelFromEntries([ + { type: "model_change", provider: "auto", modelId: "auto" }, + ]), + ).toBeUndefined(); +}); + test("recognizes Auto placeholders without matching ordinary models", () => { expect(isAutoModelReference("auto/auto")).toBe(true); expect(isAutoModelReference("auto/auto-high")).toBe(true); @@ -45,6 +65,7 @@ test("keeps Auto selected while recording the routed runtime model and effort", model: "openai-codex/gpt-5.6-luna", thinkingLevel: "high", selectedModel: "auto/auto", + lastModel: "openai-codex/gpt-5.6-luna", }); expect(selectedModelReference(status)).toBe("auto/auto"); }); diff --git a/tests/web-record-sync.test.ts b/tests/web-record-sync.test.ts index 835f9b7..559aadd 100644 --- a/tests/web-record-sync.test.ts +++ b/tests/web-record-sync.test.ts @@ -45,6 +45,7 @@ test("a stale refresh cannot cancel Auto tracking for a newer turn", () => { modelTurnGeneration: 2, autoTurnActive: true, autoTurnSettling: false, + status: "working", name: "before", } as unknown as SessionRecord; @@ -53,6 +54,8 @@ test("a stale refresh cannot cancel Auto tracking for a newer turn", () => { { model: { provider: "auto", id: "auto" }, thinkingLevel: "off", + isCompacting: false, + isStreaming: false, sessionName: "after", }, 1, @@ -64,6 +67,7 @@ test("a stale refresh cannot cancel Auto tracking for a newer turn", () => { thinkingLevel: "high", modelTurnGeneration: 2, autoTurnActive: true, + status: "working", name: "after", }); }); diff --git a/web/client/app.tsx b/web/client/app.tsx index 0ef8ea7..a38e042 100644 --- a/web/client/app.tsx +++ b/web/client/app.tsx @@ -355,6 +355,7 @@ function sessionMatches(session: WebSession, query: string): boolean { session.projectName, session.model, session.selectedModel, + session.lastModel, session.status, sessionStatusLabel(session), ].some((value) => value?.toLocaleLowerCase().includes(needle)); diff --git a/web/client/semantic-session.tsx b/web/client/semantic-session.tsx index 7d3f4e1..70d2c7f 100644 --- a/web/client/semantic-session.tsx +++ b/web/client/semantic-session.tsx @@ -601,6 +601,12 @@ function FormattedOutput({ ) : null; } +function formatModelReference(reference: string): string { + const slashIndex = reference.indexOf("/"); + if (slashIndex < 0) return reference; + return `(${reference.slice(0, slashIndex)}) ${reference.slice(slashIndex + 1)}`; +} + function formatTokenCount(count: number): string { if (count < 1_000) return String(count); if (count < 10_000) return `${(count / 1_000).toFixed(1)}k`; @@ -2646,13 +2652,15 @@ export function SemanticSession({ (model) => `${model.provider}/${model.id}` === selectedModelRef, ); const modelLabel = selectedModelOption?.name ?? fallbackModelLabel; - const effectiveModelLabel = session?.model?.split("/").pop(); + const effectiveModelReference = + autoSelected && session?.model && !isAutoModelReference(session.model) + ? session.model + : autoSelected + ? session?.lastModel + : undefined; const turnModelSummary = - autoSelected && - session?.model && - session.model !== selectedModelRef && - effectiveModelLabel - ? `${effectiveModelLabel} Β· ${effortLabel}` + effectiveModelReference && !isAutoModelReference(effectiveModelReference) + ? `${formatModelReference(effectiveModelReference)} Β· ${effortLabel}` : undefined; const availableEfforts = sessionOptions.thinkingLevels.length > 0 diff --git a/web/model-status.ts b/web/model-status.ts index 3b4d3d8..cbb3bb2 100644 --- a/web/model-status.ts +++ b/web/model-status.ts @@ -4,10 +4,32 @@ export type WebModelStatus = { thinkingLevel?: string; /** The model the user selected; differs from `model` only while Auto routes. */ selectedModel?: string; + /** The last concrete runtime model used for an Auto selection. */ + lastModel?: string; }; export type WebModelIdentity = { provider: string; id: string }; +/** Find the last concrete model recorded in Pi's model-change entries. */ +export function lastConcreteModelFromEntries( + entries: readonly unknown[], +): string | undefined { + let lastModel: string | undefined; + for (const entry of entries) { + if (!entry || typeof entry !== "object") continue; + const value = entry as Record; + if ( + value.type !== "model_change" || + typeof value.provider !== "string" || + typeof value.modelId !== "string" + ) + continue; + const model = `${value.provider}/${value.modelId}`; + if (!isAutoModelReference(model)) lastModel = model; + } + return lastModel; +} + /** Format a provider/model pair for the Web session protocol. */ export function webModelReference(model: WebModelIdentity): string { return `${model.provider}/${model.id}`; @@ -54,7 +76,7 @@ export function applyRuntimeModelStatus( isAutoModelReference(selectedModel) && !isAutoModelReference(runtimeModel); - return { + const next: WebModelStatus = { ...status, model: runtimeModel, ...(runtimeThinkingLevel !== undefined @@ -62,4 +84,7 @@ export function applyRuntimeModelStatus( : {}), selectedModel: preservingAutoSelection ? selectedModel : runtimeModel, }; + if (preservingAutoSelection) next.lastModel = runtimeModel; + else if (!isAutoModelReference(runtimeModel)) delete next.lastModel; + return next; } diff --git a/web/protocol.ts b/web/protocol.ts index b51e46c..39a46ac 100644 --- a/web/protocol.ts +++ b/web/protocol.ts @@ -153,6 +153,8 @@ export type WebSession = { thinkingLevel?: string; /** The model selected by the user; remains Auto while routing a turn. */ selectedModel?: string; + /** The most recent concrete model used by Auto, if any. */ + lastModel?: string; status: SessionStatus; source: SessionSource; createdAt: number; diff --git a/web/server/commandRouter.ts b/web/server/commandRouter.ts index f0b1e6f..d6435c7 100644 --- a/web/server/commandRouter.ts +++ b/web/server/commandRouter.ts @@ -481,6 +481,7 @@ export function createCommandRouter(options: { record.modelTurnGeneration = (record.modelTurnGeneration ?? 0) + 1; record.autoTurnActive = false; record.autoTurnSettling = false; + record.lastModel = undefined; await refreshManagedSession(record); return; case "set_thinking_level": diff --git a/web/server/managedSessionCreate.ts b/web/server/managedSessionCreate.ts index d8cb0d2..063660b 100644 --- a/web/server/managedSessionCreate.ts +++ b/web/server/managedSessionCreate.ts @@ -151,6 +151,7 @@ export function createManagedSessionLauncher(options: { model: resumed?.session.model, thinkingLevel: resumed?.session.thinkingLevel, selectedModel: resumed?.session.selectedModel ?? resumed?.session.model, + lastModel: resumed?.session.lastModel, autoTurnActive: isAutoModelReference( resumed?.session.selectedModel ?? resumed?.session.model, ), diff --git a/web/server/recordSync.ts b/web/server/recordSync.ts index 2b76d05..742de92 100644 --- a/web/server/recordSync.ts +++ b/web/server/recordSync.ts @@ -55,6 +55,7 @@ export function createRecordSync(options: { if (preservingSettledAutoSelection) { record.model = selectedModel; record.selectedModel = selectedModel; + record.lastModel = runtimeModel; if (typeof s.thinkingLevel === "string") record.thinkingLevel = s.thinkingLevel; } else { @@ -67,6 +68,7 @@ export function createRecordSync(options: { record.model = next.model; record.thinkingLevel = next.thinkingLevel; record.selectedModel = next.selectedModel; + record.lastModel = next.lastModel; if (record.autoTurnSettling === true) record.autoTurnSettling = false; } @@ -89,10 +91,10 @@ export function createRecordSync(options: { : undefined; if (typeof s.messageCount === "number") record.messageCount = s.messageCount; - if (s.isCompacting === true) { + if (modelStateIsCurrent && s.isCompacting === true) { record.compaction ??= { reason: "threshold", startedAt: Date.now() }; record.status = "working"; - } else if (s.isCompacting === false) { + } else if (modelStateIsCurrent && s.isCompacting === false) { record.compaction = undefined; if (s.isStreaming === false && record.status !== "error") record.status = "idle"; @@ -107,7 +109,10 @@ export function createRecordSync(options: { record.autoTurnActive = isAutoModelReference( selectedModelReference(record), ); - if (!record.autoTurnActive) record.selectedModel ??= record.model; + if (!record.autoTurnActive) { + record.selectedModel ??= record.model; + record.lastModel = undefined; + } return generation; } @@ -259,6 +264,7 @@ export function createRecordSync(options: { previous.model !== next.model || previous.thinkingLevel !== next.thinkingLevel || previous.selectedModel !== next.selectedModel || + previous.lastModel !== next.lastModel || previous.status !== next.status || previous.source !== next.source || previous.messageCount !== next.messageCount || diff --git a/web/server/server-types.ts b/web/server/server-types.ts index afde2e9..cf12c9b 100644 --- a/web/server/server-types.ts +++ b/web/server/server-types.ts @@ -46,6 +46,7 @@ export type SessionRecord = { model?: string; thinkingLevel?: string; selectedModel?: string; + lastModel?: string; status: SessionStatus; source: SessionSource; createdAt: number; diff --git a/web/server/session-file-catalog.ts b/web/server/session-file-catalog.ts index d004dc1..2a89b80 100644 --- a/web/server/session-file-catalog.ts +++ b/web/server/session-file-catalog.ts @@ -12,6 +12,7 @@ import { import { basename, dirname, join, normalize, resolve, sep } from "node:path"; import type { SessionManager } from "@earendil-works/pi-coding-agent"; import type { WebSession } from "../protocol.js"; +import { lastConcreteModelFromEntries } from "../model-status.js"; import { replacementFromEntries, WORKTREE_REPLACEMENT_ENTRY, @@ -238,7 +239,12 @@ export function createSessionFileCatalog(options: { entries: unknown[], ): Pick< WebSession, - "name" | "model" | "thinkingLevel" | "parentSession" | "messageCount" + | "name" + | "model" + | "thinkingLevel" + | "lastModel" + | "parentSession" + | "messageCount" > { let name: string | undefined; let model: string | undefined; @@ -261,7 +267,14 @@ export function createSessionFileCatalog(options: { if (entry.type === "session" && typeof entry.parentSession === "string") parentSession = entry.parentSession; } - return { name, model, thinkingLevel, parentSession, messageCount }; + return { + name, + model, + thinkingLevel, + lastModel: lastConcreteModelFromEntries(entries), + parentSession, + messageCount, + }; } function readManagedWorktreePrefix( @@ -336,6 +349,7 @@ export function createSessionFileCatalog(options: { name: meta.name, model: meta.model, thinkingLevel: meta.thinkingLevel, + lastModel: meta.lastModel, status: "offline", source: isManagedSessionFile(file) ? "web" : "saved", createdAt: @@ -443,6 +457,7 @@ export function createSessionFileCatalog(options: { let thinkingLevel = incremental ? cached.scan.session.thinkingLevel : undefined; + let lastModel = incremental ? cached.scan.session.lastModel : undefined; let messageCount = incremental ? cached.scan.session.messageCount : 0; let preview = incremental ? cached.scan.session.preview : undefined; const metadataEntries = incremental ? [...cached.metadataEntries] : []; @@ -459,8 +474,13 @@ export function createSessionFileCatalog(options: { if (!entry || typeof entry.type !== "string") continue; if (entry.type === "session_info" && typeof entry.name === "string") name = entry.name; - if (entry.type === "model_change" && typeof entry.modelId === "string") + if (entry.type === "model_change" && typeof entry.modelId === "string") { model = entry.modelId; + if (typeof entry.provider === "string") { + const reference = `${entry.provider}/${entry.modelId}`; + if (!reference.startsWith("auto/")) lastModel = reference; + } + } if ( entry.type === "thinking_level_change" && typeof entry.thinkingLevel === "string" @@ -510,6 +530,7 @@ export function createSessionFileCatalog(options: { name, model, thinkingLevel, + lastModel, status: "offline", source: isManagedSessionFile(file) ? "web" : "saved", createdAt: incremental diff --git a/web/server/sessionRegistry.ts b/web/server/sessionRegistry.ts index 921b75c..35c1e27 100644 --- a/web/server/sessionRegistry.ts +++ b/web/server/sessionRegistry.ts @@ -63,6 +63,7 @@ export function createSessionRegistry(options: { model: session.model, thinkingLevel: session.thinkingLevel, selectedModel: session.selectedModel, + lastModel: session.lastModel, status: session.status, source: session.source, createdAt: session.createdAt, From f3d9706422fcad4889a313166452770080f56d4d Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:40:48 -0400 Subject: [PATCH 09/25] Normalize persisted model references --- web/server/session-file-catalog.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/web/server/session-file-catalog.ts b/web/server/session-file-catalog.ts index 2a89b80..0de36ea 100644 --- a/web/server/session-file-catalog.ts +++ b/web/server/session-file-catalog.ts @@ -258,7 +258,10 @@ export function createSessionFileCatalog(options: { if (entry.type === "session_info" && typeof entry.name === "string") name = entry.name; if (entry.type === "model_change" && typeof entry.modelId === "string") - model = entry.modelId; + model = + typeof entry.provider === "string" && entry.provider + ? `${entry.provider}/${entry.modelId}` + : entry.modelId; if ( entry.type === "thinking_level_change" && typeof entry.thinkingLevel === "string" @@ -475,7 +478,10 @@ export function createSessionFileCatalog(options: { if (entry.type === "session_info" && typeof entry.name === "string") name = entry.name; if (entry.type === "model_change" && typeof entry.modelId === "string") { - model = entry.modelId; + model = + typeof entry.provider === "string" && entry.provider + ? `${entry.provider}/${entry.modelId}` + : entry.modelId; if (typeof entry.provider === "string") { const reference = `${entry.provider}/${entry.modelId}`; if (!reference.startsWith("auto/")) lastModel = reference; From 8a60074216ea1bcae75ffeb4f88bb46ce9ef7915 Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:50:52 -0400 Subject: [PATCH 10/25] Address remaining Auto model review comments --- extensions/auto-router.ts | 5 ++++ extensions/session-footer.ts | 12 ++++++++- extensions/web-sessions.ts | 4 +-- tests/session-footer.test.ts | 9 ++++++- tests/web-model-status.test.ts | 36 ++++++++++++++++++++++--- web/model-status.ts | 42 ++++++++++++++++++++++++------ web/server/session-file-catalog.ts | 19 ++++++-------- web/server/sessionRegistry.ts | 13 +++++++++ 8 files changed, 113 insertions(+), 27 deletions(-) diff --git a/extensions/auto-router.ts b/extensions/auto-router.ts index 318931f..ab49023 100644 --- a/extensions/auto-router.ts +++ b/extensions/auto-router.ts @@ -598,6 +598,11 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { restored.pinnedTier ?? (modelIsAuto && ctx.model ? tierFromModelId(ctx.model.id) : undefined); if (autoActive) { + if (modelIsAuto && !restored.active) + pi.appendEntry(AUTO_ACTIVE_ENTRY_TYPE, { + enabled: true, + pinnedTier, + }); if (ctx.model && ctx.model.provider !== AUTO_PROVIDER_ID) { // Restored mid-turn (e.g. an interrupted process, before agent_settled could // revert it). Normalize back to the placeholder so /model shows Auto again. diff --git a/extensions/session-footer.ts b/extensions/session-footer.ts index 9b9e839..e70a8d9 100644 --- a/extensions/session-footer.ts +++ b/extensions/session-footer.ts @@ -65,7 +65,17 @@ export function formatCwd(cwd: string, home: string | undefined): string { export function alignSides(left: string, right: string, width: number): string { if (width <= 0) return ""; const rightWidth = visibleWidth(right); - if (rightWidth > width) return truncateToWidth(right, width, ""); + if (rightWidth > width) { + if (!left) return truncateToWidth(right, width, ""); + const leftBudget = Math.min( + visibleWidth(left), + Math.max(1, Math.floor(width * 0.4)), + ); + const fittedLeft = truncateToWidth(left, leftBudget, "..."); + const rightBudget = Math.max(0, width - visibleWidth(fittedLeft) - 1); + if (rightBudget === 0) return truncateToWidth(fittedLeft, width, ""); + return `${fittedLeft} ${truncateToWidth(right, rightBudget, "...")}`; + } const maxLeftWidth = Math.max(0, width - rightWidth - (left ? 1 : 0)); const fittedLeft = maxLeftWidth > 0 ? truncateToWidth(left, maxLeftWidth, "...") : ""; diff --git a/extensions/web-sessions.ts b/extensions/web-sessions.ts index 3182e49..e880982 100644 --- a/extensions/web-sessions.ts +++ b/extensions/web-sessions.ts @@ -13,7 +13,7 @@ import { applyRuntimeModelStatus, isAutoModelReference, isAutoRuntimeModelSwap, - lastConcreteModelFromEntries, + lastAutoRoutedModelFromEntries, selectedModelReference, webModelReference, } from "../web/model-status.js"; @@ -1270,7 +1270,7 @@ function makeSession( selectedModel: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined, - lastModel: lastConcreteModelFromEntries(entries), + lastModel: lastAutoRoutedModelFromEntries(entries), status: statusForContext(ctx), source: "tui", createdAt: header ? Date.parse(header.timestamp) || Date.now() : Date.now(), diff --git a/tests/session-footer.test.ts b/tests/session-footer.test.ts index 93b049b..9a09ecf 100644 --- a/tests/session-footer.test.ts +++ b/tests/session-footer.test.ts @@ -1,11 +1,18 @@ import { expect, test } from "bun:test"; +import { visibleWidth } from "@earendil-works/pi-tui"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import sessionFooter from "../extensions/session-footer.ts"; +import sessionFooter, { alignSides } from "../extensions/session-footer.ts"; import { FOOTER_CONTRIBUTION_EVENT, type FooterContribution, } from "../extensions/footer-events.ts"; +test("wide model details do not erase footer identity", () => { + const line = alignSides("⧉ ~/repo (main)", "Auto (auto) β€’ (provider) very-long-model β€’ high", 24); + expect(line).toContain("⧉"); + expect(visibleWidth(line)).toBeLessThanOrEqual(24); +}); + test("the shared footer places identity, routing, and activity in two rows", () => { const hooks = new Map void>(); const eventHandlers = new Map void>(); diff --git a/tests/web-model-status.test.ts b/tests/web-model-status.test.ts index f3eae97..77d6dc3 100644 --- a/tests/web-model-status.test.ts +++ b/tests/web-model-status.test.ts @@ -3,14 +3,25 @@ import { applyRuntimeModelStatus, isAutoModelReference, isAutoRuntimeModelSwap, - lastConcreteModelFromEntries, + lastAutoRoutedModelFromEntries, selectedModelReference, } from "../web/model-status"; -test("finds the last concrete model in model-change entries", () => { +test("finds only models Auto actually routed to", () => { + const autoEnabled = { + type: "custom", + customType: "vessup:auto-router:active", + data: { enabled: true }, + }; + const autoDisabled = { + type: "custom", + customType: "vessup:auto-router:active", + data: { enabled: false }, + }; expect( - lastConcreteModelFromEntries([ + lastAutoRoutedModelFromEntries([ { type: "model_change", provider: "auto", modelId: "auto" }, + autoEnabled, { type: "model_change", provider: "openai-codex", @@ -20,8 +31,25 @@ test("finds the last concrete model in model-change entries", () => { ]), ).toBe("openai-codex/gpt-5.6-luna"); expect( - lastConcreteModelFromEntries([ + lastAutoRoutedModelFromEntries([ + { type: "model_change", provider: "anthropic", modelId: "manual" }, { type: "model_change", provider: "auto", modelId: "auto" }, + autoEnabled, + { type: "model_change", provider: "anthropic", modelId: "manual" }, + autoDisabled, + ]), + ).toBeUndefined(); + expect( + lastAutoRoutedModelFromEntries([ + { type: "model_change", provider: "auto", modelId: "auto" }, + ]), + ).toBeUndefined(); + expect( + lastAutoRoutedModelFromEntries([ + { type: "model_change", provider: "", modelId: "model" }, + { type: "model_change", provider: "auto", modelId: "auto" }, + autoEnabled, + { type: "model_change", provider: "provider", modelId: "" }, ]), ).toBeUndefined(); }); diff --git a/web/model-status.ts b/web/model-status.ts index cbb3bb2..960f98b 100644 --- a/web/model-status.ts +++ b/web/model-status.ts @@ -10,24 +10,50 @@ export type WebModelStatus = { export type WebModelIdentity = { provider: string; id: string }; -/** Find the last concrete model recorded in Pi's model-change entries. */ -export function lastConcreteModelFromEntries( +/** Find the last concrete model that Auto actually routed to. */ +export function lastAutoRoutedModelFromEntries( entries: readonly unknown[], ): string | undefined { + let autoActive = false; + let pendingRoute: string | undefined; let lastModel: string | undefined; for (const entry of entries) { if (!entry || typeof entry !== "object") continue; const value = entry as Record; + if (value.type === "model_change") { + if ( + typeof value.provider !== "string" || + value.provider.length === 0 || + typeof value.modelId !== "string" || + value.modelId.length === 0 + ) + continue; + const model = `${value.provider}/${value.modelId}`; + if (isAutoModelReference(model)) { + if (pendingRoute) lastModel = pendingRoute; + pendingRoute = undefined; + } else if (autoActive) { + pendingRoute = model; + } + continue; + } if ( - value.type !== "model_change" || - typeof value.provider !== "string" || - typeof value.modelId !== "string" + value.type !== "custom" || + value.customType !== "vessup:auto-router:active" ) continue; - const model = `${value.provider}/${value.modelId}`; - if (!isAutoModelReference(model)) lastModel = model; + const data = value.data; + const enabled = + typeof data === "object" && + data !== null && + (data as Record).enabled === true; + autoActive = enabled; + if (!enabled) { + pendingRoute = undefined; + lastModel = undefined; + } } - return lastModel; + return lastModel ?? pendingRoute; } /** Format a provider/model pair for the Web session protocol. */ diff --git a/web/server/session-file-catalog.ts b/web/server/session-file-catalog.ts index 0de36ea..1b664cd 100644 --- a/web/server/session-file-catalog.ts +++ b/web/server/session-file-catalog.ts @@ -12,7 +12,7 @@ import { import { basename, dirname, join, normalize, resolve, sep } from "node:path"; import type { SessionManager } from "@earendil-works/pi-coding-agent"; import type { WebSession } from "../protocol.js"; -import { lastConcreteModelFromEntries } from "../model-status.js"; +import { lastAutoRoutedModelFromEntries } from "../model-status.js"; import { replacementFromEntries, WORKTREE_REPLACEMENT_ENTRY, @@ -274,7 +274,7 @@ export function createSessionFileCatalog(options: { name, model, thinkingLevel, - lastModel: lastConcreteModelFromEntries(entries), + lastModel: lastAutoRoutedModelFromEntries(entries), parentSession, messageCount, }; @@ -460,7 +460,6 @@ export function createSessionFileCatalog(options: { let thinkingLevel = incremental ? cached.scan.session.thinkingLevel : undefined; - let lastModel = incremental ? cached.scan.session.lastModel : undefined; let messageCount = incremental ? cached.scan.session.messageCount : 0; let preview = incremental ? cached.scan.session.preview : undefined; const metadataEntries = incremental ? [...cached.metadataEntries] : []; @@ -482,10 +481,6 @@ export function createSessionFileCatalog(options: { typeof entry.provider === "string" && entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId; - if (typeof entry.provider === "string") { - const reference = `${entry.provider}/${entry.modelId}`; - if (!reference.startsWith("auto/")) lastModel = reference; - } } if ( entry.type === "thinking_level_change" && @@ -493,9 +488,11 @@ export function createSessionFileCatalog(options: { ) thinkingLevel = entry.thinkingLevel; if ( - entry.type === "custom" && - (entry.customType === WORKTREE_SESSION_ENTRY || - entry.customType === WORKTREE_REPLACEMENT_ENTRY) + (entry.type === "custom" && + (entry.customType === WORKTREE_SESSION_ENTRY || + entry.customType === WORKTREE_REPLACEMENT_ENTRY || + entry.customType === "vessup:auto-router:active")) || + entry.type === "model_change" ) { metadataEntries.push(entry); } @@ -536,7 +533,7 @@ export function createSessionFileCatalog(options: { name, model, thinkingLevel, - lastModel, + lastModel: lastAutoRoutedModelFromEntries(metadataEntries), status: "offline", source: isManagedSessionFile(file) ? "web" : "saved", createdAt: incremental diff --git a/web/server/sessionRegistry.ts b/web/server/sessionRegistry.ts index 35c1e27..b12b2a7 100644 --- a/web/server/sessionRegistry.ts +++ b/web/server/sessionRegistry.ts @@ -3,6 +3,7 @@ import { type SessionEntry, } from "@earendil-works/pi-coding-agent"; import { boundedWebHistory, webHistoryByteLength } from "../history.js"; +import { isAutoModelReference } from "../model-status.js"; import { compareWebSessions, type WebSession } from "../protocol.js"; import type { MissingSessions } from "./missingSessions.js"; import { resolveSessionProject } from "./projects.js"; @@ -40,6 +41,16 @@ export function createSessionRegistry(options: { const { isMissingInactiveSession } = missingSessions; const { replaceRecordHistory } = history; + function clearStaleAutoModel(record: SessionRecord, session: WebSession): void { + const selectedModel = session.selectedModel ?? session.model; + if ( + session.lastModel === undefined && + selectedModel && + !isAutoModelReference(selectedModel) + ) + record.lastModel = undefined; + } + function sessionToClientPayload( session: WebSession, includeSubagentTranscripts = false, @@ -127,6 +138,7 @@ export function createSessionRegistry(options: { })), }) as SessionRecord; Object.assign(record, session); + clearStaleAutoModel(record, session); record.kind = kind; if (history.length > 0 && !existing) { record.history = displayHistory; @@ -156,6 +168,7 @@ export function createSessionRegistry(options: { const historyManagedWorktree = history.length > 0 ? managedWorktreeFromEntries(history) : undefined; Object.assign(record, session); + clearStaleAutoModel(record, session); record.kind = kind; if (history.length > 0) replaceRecordHistory( From 302abf3413ff1fe6dba281dce0d69ea448873306 Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:53:46 -0400 Subject: [PATCH 11/25] Restore durable Auto selections --- extensions/web-sessions.ts | 7 +++--- tests/web-model-status.test.ts | 22 ++++++++++++++++++ web/model-status.ts | 37 ++++++++++++++++++++++++++++++ web/server/session-file-catalog.ts | 9 +++++++- 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/extensions/web-sessions.ts b/extensions/web-sessions.ts index e880982..f9eca7d 100644 --- a/extensions/web-sessions.ts +++ b/extensions/web-sessions.ts @@ -14,6 +14,7 @@ import { isAutoModelReference, isAutoRuntimeModelSwap, lastAutoRoutedModelFromEntries, + selectedAutoModelFromEntries, selectedModelReference, webModelReference, } from "../web/model-status.js"; @@ -1267,9 +1268,9 @@ function makeSession( branch, model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined, thinkingLevel: ctx.thinkingLevel, - selectedModel: ctx.model - ? `${ctx.model.provider}/${ctx.model.id}` - : undefined, + selectedModel: + selectedAutoModelFromEntries(entries) ?? + (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined), lastModel: lastAutoRoutedModelFromEntries(entries), status: statusForContext(ctx), source: "tui", diff --git a/tests/web-model-status.test.ts b/tests/web-model-status.test.ts index 77d6dc3..5f1c570 100644 --- a/tests/web-model-status.test.ts +++ b/tests/web-model-status.test.ts @@ -4,9 +4,31 @@ import { isAutoModelReference, isAutoRuntimeModelSwap, lastAutoRoutedModelFromEntries, + selectedAutoModelFromEntries, selectedModelReference, } from "../web/model-status"; +test("reconstructs a durable Auto selection", () => { + expect( + selectedAutoModelFromEntries([ + { + type: "custom", + customType: "vessup:auto-router:active", + data: { enabled: true, pinnedTier: "high" }, + }, + ]), + ).toBe("auto/auto-high"); + expect( + selectedAutoModelFromEntries([ + { + type: "custom", + customType: "vessup:auto-router:active", + data: { enabled: false }, + }, + ]), + ).toBeUndefined(); +}); + test("finds only models Auto actually routed to", () => { const autoEnabled = { type: "custom", diff --git a/web/model-status.ts b/web/model-status.ts index 960f98b..4c9bbbd 100644 --- a/web/model-status.ts +++ b/web/model-status.ts @@ -10,6 +10,43 @@ export type WebModelStatus = { export type WebModelIdentity = { provider: string; id: string }; +const AUTO_ROUTER_EFFORTS = new Set([ + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +]); + +/** Reconstruct the durable Auto placeholder selected for a saved session. */ +export function selectedAutoModelFromEntries( + entries: readonly unknown[], +): string | undefined { + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (!entry || typeof entry !== "object") continue; + const value = entry as Record; + if ( + value.type !== "custom" || + value.customType !== "vessup:auto-router:active" + ) + continue; + const data = value.data; + if ( + !data || + typeof data !== "object" || + (data as Record).enabled !== true + ) + return undefined; + const pinnedTier = (data as Record).pinnedTier; + return typeof pinnedTier === "string" && AUTO_ROUTER_EFFORTS.has(pinnedTier) + ? `auto/auto-${pinnedTier}` + : "auto/auto"; + } + return undefined; +} + /** Find the last concrete model that Auto actually routed to. */ export function lastAutoRoutedModelFromEntries( entries: readonly unknown[], diff --git a/web/server/session-file-catalog.ts b/web/server/session-file-catalog.ts index 1b664cd..ce1a1d7 100644 --- a/web/server/session-file-catalog.ts +++ b/web/server/session-file-catalog.ts @@ -12,7 +12,10 @@ import { import { basename, dirname, join, normalize, resolve, sep } from "node:path"; import type { SessionManager } from "@earendil-works/pi-coding-agent"; import type { WebSession } from "../protocol.js"; -import { lastAutoRoutedModelFromEntries } from "../model-status.js"; +import { + lastAutoRoutedModelFromEntries, + selectedAutoModelFromEntries, +} from "../model-status.js"; import { replacementFromEntries, WORKTREE_REPLACEMENT_ENTRY, @@ -242,6 +245,7 @@ export function createSessionFileCatalog(options: { | "name" | "model" | "thinkingLevel" + | "selectedModel" | "lastModel" | "parentSession" | "messageCount" @@ -274,6 +278,7 @@ export function createSessionFileCatalog(options: { name, model, thinkingLevel, + selectedModel: selectedAutoModelFromEntries(entries), lastModel: lastAutoRoutedModelFromEntries(entries), parentSession, messageCount, @@ -352,6 +357,7 @@ export function createSessionFileCatalog(options: { name: meta.name, model: meta.model, thinkingLevel: meta.thinkingLevel, + selectedModel: meta.selectedModel, lastModel: meta.lastModel, status: "offline", source: isManagedSessionFile(file) ? "web" : "saved", @@ -533,6 +539,7 @@ export function createSessionFileCatalog(options: { name, model, thinkingLevel, + selectedModel: selectedAutoModelFromEntries(metadataEntries), lastModel: lastAutoRoutedModelFromEntries(metadataEntries), status: "offline", source: isManagedSessionFile(file) ? "web" : "saved", From bd442011c895b2c906176842eb0a84d20d29397e Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:58:49 -0400 Subject: [PATCH 12/25] Polish Auto model persistence review fixes --- extensions/web-sessions.ts | 2 +- tests/web-model-status.test.ts | 2 ++ web/client/semantic-session.tsx | 2 +- web/model-status.ts | 2 +- web/protocol.ts | 4 ++-- web/server/managedSessionCreate.ts | 10 +++++++++- web/server/server-types.ts | 2 +- web/server/sessionRegistry.ts | 4 ++++ 8 files changed, 21 insertions(+), 7 deletions(-) diff --git a/extensions/web-sessions.ts b/extensions/web-sessions.ts index f9eca7d..ff32279 100644 --- a/extensions/web-sessions.ts +++ b/extensions/web-sessions.ts @@ -942,7 +942,7 @@ async function executeAgentCommand( updateSession(state, { model: `${model.provider}/${model.id}`, selectedModel: `${model.provider}/${model.id}`, - lastModel: undefined, + lastModel: null, }); respond(state, requestId, true); return; diff --git a/tests/web-model-status.test.ts b/tests/web-model-status.test.ts index 5f1c570..c538d4a 100644 --- a/tests/web-model-status.test.ts +++ b/tests/web-model-status.test.ts @@ -146,6 +146,7 @@ test("the Auto placeholder is selected again after the runtime reverts", () => { model: "openai-codex/gpt-5.6-luna", thinkingLevel: "high", selectedModel: "auto/auto", + lastModel: "openai-codex/gpt-5.6-luna", }, "auto/auto", "off", @@ -155,5 +156,6 @@ test("the Auto placeholder is selected again after the runtime reverts", () => { model: "auto/auto", thinkingLevel: "off", selectedModel: "auto/auto", + lastModel: "openai-codex/gpt-5.6-luna", }); }); diff --git a/web/client/semantic-session.tsx b/web/client/semantic-session.tsx index 70d2c7f..614a67c 100644 --- a/web/client/semantic-session.tsx +++ b/web/client/semantic-session.tsx @@ -695,7 +695,7 @@ function TokenDetails({ Context{" "} {context.percent == null ? "?" : `${context.percent.toFixed(1)}%`} Β·{" "} - {formatTokenCount(context.tokens ?? 0)} /{" "} + {context.tokens == null ? "?" : formatTokenCount(context.tokens)} /{" "} {formatTokenCount(context.contextWindow)} diff --git a/web/model-status.ts b/web/model-status.ts index 4c9bbbd..f4edb17 100644 --- a/web/model-status.ts +++ b/web/model-status.ts @@ -5,7 +5,7 @@ export type WebModelStatus = { /** The model the user selected; differs from `model` only while Auto routes. */ selectedModel?: string; /** The last concrete runtime model used for an Auto selection. */ - lastModel?: string; + lastModel?: string | null; }; export type WebModelIdentity = { provider: string; id: string }; diff --git a/web/protocol.ts b/web/protocol.ts index 39a46ac..f4064f5 100644 --- a/web/protocol.ts +++ b/web/protocol.ts @@ -153,8 +153,8 @@ export type WebSession = { thinkingLevel?: string; /** The model selected by the user; remains Auto while routing a turn. */ selectedModel?: string; - /** The most recent concrete model used by Auto, if any. */ - lastModel?: string; + /** The most recent concrete model used by Auto, if any. Null explicitly clears it. */ + lastModel?: string | null; status: SessionStatus; source: SessionSource; createdAt: number; diff --git a/web/server/managedSessionCreate.ts b/web/server/managedSessionCreate.ts index 063660b..e050815 100644 --- a/web/server/managedSessionCreate.ts +++ b/web/server/managedSessionCreate.ts @@ -216,7 +216,15 @@ export function createManagedSessionLauncher(options: { !record.autoTurnActive ) return; - updateRecordFromState(record, state); + const snapshot = isRecord(state) ? state : {}; + updateRecordFromState( + record, + { + model: snapshot.model, + thinkingLevel: snapshot.thinkingLevel, + }, + generation, + ); broadcastSessionToAll(record); }) .catch(() => undefined); diff --git a/web/server/server-types.ts b/web/server/server-types.ts index cf12c9b..9de60c3 100644 --- a/web/server/server-types.ts +++ b/web/server/server-types.ts @@ -46,7 +46,7 @@ export type SessionRecord = { model?: string; thinkingLevel?: string; selectedModel?: string; - lastModel?: string; + lastModel?: string | null; status: SessionStatus; source: SessionSource; createdAt: number; diff --git a/web/server/sessionRegistry.ts b/web/server/sessionRegistry.ts index b12b2a7..9d5c3e1 100644 --- a/web/server/sessionRegistry.ts +++ b/web/server/sessionRegistry.ts @@ -42,6 +42,10 @@ export function createSessionRegistry(options: { const { replaceRecordHistory } = history; function clearStaleAutoModel(record: SessionRecord, session: WebSession): void { + if (session.lastModel === null) { + record.lastModel = undefined; + return; + } const selectedModel = session.selectedModel ?? session.model; if ( session.lastModel === undefined && From 5e7d8ff12f97c66f48fb124440d8ca2bc8a74ffd Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:12:42 -0400 Subject: [PATCH 13/25] Prevent Auto compaction placeholder errors --- extensions/auto-router.ts | 98 ++++++++++++++++++++++------- extensions/web-sessions.ts | 37 ++++++++--- tests/auto-router-extension.test.ts | 91 ++++++++++++++++++++++++++- 3 files changed, 194 insertions(+), 32 deletions(-) diff --git a/extensions/auto-router.ts b/extensions/auto-router.ts index ab49023..7d218f9 100644 --- a/extensions/auto-router.ts +++ b/extensions/auto-router.ts @@ -42,6 +42,8 @@ import { export const AUTO_ROUTER_COMPACTION_EVENT = "pi-kit:auto-router:prepare-compaction"; +export const AUTO_ROUTER_MODEL_ROUTING_EVENT = + "pi-kit:auto-router:model-routing"; const AUTO_PROVIDER_ID = "auto"; const AUTO_MODEL_ID = "auto"; const AUTO_ACTIVE_ENTRY_TYPE = "vessup:auto-router:active"; @@ -176,8 +178,23 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { * every turn routes within that tier directly, skipping classification entirely. */ let pinnedTier: AutoRouterEffortLevel | undefined; let routingInFlight = false; + let modelTransitionTail = Promise.resolve(); const healthStore = new AutoRouterHealthStore(); + async function withModelTransition(operation: () => Promise): Promise { + const previous = modelTransitionTail; + let release!: () => void; + modelTransitionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + return await operation(); + } finally { + release(); + } + } + function publishFooter(): void { if (!currentSessionId) return; pi.events.emit(FOOTER_CONTRIBUTION_EVENT, { @@ -202,12 +219,16 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { currentAutoModelId(), ); if (!placeholder) return; - routingInFlight = true; - try { - await pi.setModel(placeholder); - } finally { - routingInFlight = false; - } + await withModelTransition(async () => { + routingInFlight = true; + publishModelRouting(pi, ctx, true); + try { + await pi.setModel(placeholder); + } finally { + routingInFlight = false; + publishModelRouting(pi, ctx, false); + } + }); } function clearFooter(): void { @@ -386,28 +407,44 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { return undefined; } + function publishModelRouting( + pi: ExtensionAPI, + ctx: ExtensionContext, + active: boolean, + ): void { + pi.events.emit(AUTO_ROUTER_MODEL_ROUTING_EVENT, { + action: active ? "start" : "end", + ctx, + }); + } + async function applyRouting( pi: ExtensionAPI, ctx: ExtensionContext, model: Model, effort: AutoRouterEffortLevel, - ): Promise { - routingInFlight = true; - try { - const success = await pi.setModel(model); - if (!success) { - if (ctx.hasUI) { - ctx.ui.notify( - `Auto: no credentials configured for ${model.provider}/${model.id}`, - "warning", - ); + ): Promise { + return withModelTransition(async () => { + routingInFlight = true; + publishModelRouting(pi, ctx, true); + try { + const success = await pi.setModel(model); + if (!success) { + if (ctx.hasUI) { + ctx.ui.notify( + `Auto: no credentials configured for ${model.provider}/${model.id}`, + "warning", + ); + } + return false; } - return; + await pi.setThinkingLevel(resolveSupportedEffort(ctx, model, effort)); + return true; + } finally { + routingInFlight = false; + publishModelRouting(pi, ctx, false); } - await pi.setThinkingLevel(resolveSupportedEffort(ctx, model, effort)); - } finally { - routingInFlight = false; - } + }); } async function routeForCompaction( @@ -422,7 +459,10 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { throw new Error( "Auto could not select a configured model for compaction.", ); - await applyRouting(pi, ctx, selected.model, selected.effort); + if (!(await applyRouting(pi, ctx, selected.model, selected.effort))) + throw new Error( + `Auto could not authenticate ${selected.model.provider}/${selected.model.id} for compaction`, + ); } async function routeForPrompt( @@ -519,6 +559,20 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { await applyRouting(pi, ctx, picked.model, picked.effort); } + pi.on("input", async (_event, ctx) => { + // Core checks for threshold compaction before before_agent_start. Route away + // from Auto's inert placeholder during that preflight so summarization uses + // a real model instead of http://127.0.0.1:0. + if (ctx.model?.provider !== AUTO_PROVIDER_ID) return; + if (!autoActive) { + autoActive = true; + pinnedTier = tierFromModelId(ctx.model.id); + pi.appendEntry(AUTO_ACTIVE_ENTRY_TYPE, { enabled: true, pinnedTier }); + publishFooter(); + } + await routeForCompaction(pi, ctx); + }); + async function reconcileAllProviders( modelRegistry: ModelRegistry, settings: AutoRouterSettings, diff --git a/extensions/web-sessions.ts b/extensions/web-sessions.ts index ff32279..21c8fab 100644 --- a/extensions/web-sessions.ts +++ b/extensions/web-sessions.ts @@ -45,7 +45,10 @@ import { isSkillSlashCommand, } from "../web/slash-commands.js"; import { formatWorktreeCreateCommandArgs } from "../web/worktree-command.js"; -import { AUTO_ROUTER_COMPACTION_EVENT } from "./auto-router.js"; +import { + AUTO_ROUTER_COMPACTION_EVENT, + AUTO_ROUTER_MODEL_ROUTING_EVENT, +} from "./auto-router.js"; import { FOOTER_CONTRIBUTION_EVENT, type FooterContribution, @@ -254,6 +257,8 @@ type BridgeState = { pending: AgentToServerMessage[]; /** Set before Auto's before_agent_start hook swaps in the concrete model. */ autoTurnRouting: boolean; + /** True while Auto itself is applying a runtime model swap. */ + autoRuntimeRouting: boolean; metrics: Pick; sourceReplacement?: WorktreeSessionReplacement; }; @@ -1287,6 +1292,21 @@ function makeSession( export default function webSessions(pi: ExtensionAPI): void { let bridge: BridgeState | undefined; + pi.events.on(AUTO_ROUTER_MODEL_ROUTING_EVENT, (value) => { + if (!isRecord(value)) return; + const action = value.action; + const ctx = value.ctx as ExtensionContext | undefined; + if ( + (action !== "start" && action !== "end") || + !ctx || + !bridge || + bridge.closed || + ctx.sessionManager.getSessionId() !== bridge.session.id + ) + return; + bridge.autoRuntimeRouting = action === "start"; + }); + // RPC mode normally expands /skill:name before the agent sees it. Pi Web keeps // skill invocations as user-authored text so the agent follows the advertised // progressive-disclosure contract and loads SKILL.md with read when needed. @@ -1612,6 +1632,7 @@ export default function webSessions(pi: ExtensionAPI): void { reconnectAttempt: 0, pending: [], autoTurnRouting: false, + autoRuntimeRouting: false, metrics: { usage: session.usage, contextUsage: session.contextUsage }, sourceReplacement, }; @@ -1682,13 +1703,12 @@ export default function webSessions(pi: ExtensionAPI): void { const previousModel = event.previousModel ? webModelReference(event.previousModel) : undefined; - const autoRoute = - activeBridge.autoTurnRouting && - isAutoRuntimeModelSwap( - selectedModelReference(activeBridge.session), - previousModel, - runtimeModel, - ); + const selectedModel = selectedModelReference(activeBridge.session); + const autoRoute = activeBridge.autoRuntimeRouting + ? isAutoModelReference(selectedModel) && + !isAutoModelReference(runtimeModel) + : activeBridge.autoTurnRouting && + isAutoRuntimeModelSwap(selectedModel, previousModel, runtimeModel); const next = applyRuntimeModelStatus( activeBridge.session, runtimeModel, @@ -1720,6 +1740,7 @@ export default function webSessions(pi: ExtensionAPI): void { const activeBridge = activeBridgeFor(ctx); if (activeBridge) { activeBridge.autoTurnRouting = false; + activeBridge.autoRuntimeRouting = false; const selectedModel = selectedModelReference(activeBridge.session); if ( isAutoModelReference(selectedModel) && diff --git a/tests/auto-router-extension.test.ts b/tests/auto-router-extension.test.ts index 75ab3b6..e99019f 100644 --- a/tests/auto-router-extension.test.ts +++ b/tests/auto-router-extension.test.ts @@ -150,8 +150,14 @@ function createFakePi(): FakePi { const FAKE_THEME = { fg: (_kind: string, text: string) => text } as unknown as Theme; function lastFooterBadge(footerEvents: unknown[]): string | undefined { - const last = footerEvents.at(-1) as { modelPrefix?: (theme: Theme) => string | undefined } | undefined; - return last?.modelPrefix?.(FAKE_THEME); + for (let index = footerEvents.length - 1; index >= 0; index -= 1) { + const event = footerEvents[index] as + | { modelPrefix?: (theme: Theme) => string | undefined } + | undefined; + const badge = event?.modelPrefix?.(FAKE_THEME); + if (badge) return badge; + } + return undefined; } type FakeRegistryOptions = { @@ -276,6 +282,87 @@ test("Auto routes manual compaction away from its inert placeholder and restores expect(fake.currentModel.value?.id).toBe("auto"); }); +test("Auto routes before prompt preflight so compaction cannot use its placeholder", async () => { + const a = model("prov", "model-a"); + await writeConfig({ + efforts: { medium: { models: [{ provider: "prov", id: "model-a" }] } }, + }); + + const fake = createFakePi(); + await autoRouter(fake.pi); + fake.currentModel.value = AUTO_PLACEHOLDER; + const ctx = fakeCtx({ + modelRegistry: fakeModelRegistry({ models: [a] }), + currentModel: fake.currentModel, + }); + await selectAuto(fake, ctx); + + await fake.fire("input", { text: "next prompt", source: "interactive" }, ctx); + + expect(fake.currentModel.value).toBe(a); + expect(fake.setModelCalls).toEqual([a]); +}); + +test("serializes compaction routing with placeholder restoration", async () => { + const a = model("prov", "model-a"); + await writeConfig({ + efforts: { medium: { models: [{ provider: "prov", id: "model-a" }] } }, + }); + + const fake = createFakePi(); + await autoRouter(fake.pi); + fake.currentModel.value = AUTO_PLACEHOLDER; + const ctx = fakeCtx({ + modelRegistry: fakeModelRegistry({ models: [a] }), + currentModel: fake.currentModel, + }); + await selectAuto(fake, ctx); + + let release!: () => void; + let entered!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const enteredGate = new Promise((resolve) => { + entered = resolve; + }); + const originalSetModel = (fake.pi as unknown as { + setModel: (model: Model) => Promise; + }).setModel; + let calls = 0; + (fake.pi as unknown as { + setModel: (model: Model) => Promise; + }).setModel = async (model) => { + calls++; + if (calls === 1) { + entered(); + await gate; + } + return originalSetModel(model); + }; + + const runAction = async (action: "route" | "restore") => { + const operations: Promise[] = []; + fake.emitEvent(AUTO_ROUTER_COMPACTION_EVENT, { + action, + ctx, + waitUntil: (operation: Promise) => operations.push(operation), + }); + await Promise.all(operations); + }; + + const routing = runAction("route"); + await enteredGate; + fake.currentModel.value = a; + const restoring = runAction("restore"); + await Bun.sleep(5); + expect(calls).toBe(1); + release(); + await Promise.all([routing, restoring]); + expect(calls).toBe(2); + expect(fake.currentModel.value?.provider).toBe("auto"); +}); + test("selecting a pinned Auto () entry shows that tier in the footer immediately, before any turn runs", async () => { const a = model("prov", "model-a"); await writeConfig({ efforts: { high: { models: [{ provider: "prov", id: "model-a" }] } } }); From 87a757f49768e2be8eec830ba708fd39bcbfb48f Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:16:19 -0400 Subject: [PATCH 14/25] Wait for Auto restoration before compaction preflight --- extensions/auto-router.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/extensions/auto-router.ts b/extensions/auto-router.ts index 7d218f9..81915cd 100644 --- a/extensions/auto-router.ts +++ b/extensions/auto-router.ts @@ -562,7 +562,11 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { pi.on("input", async (_event, ctx) => { // Core checks for threshold compaction before before_agent_start. Route away // from Auto's inert placeholder during that preflight so summarization uses - // a real model instead of http://127.0.0.1:0. + // a real model instead of http://127.0.0.1:0. If placeholder restoration + // is still in flight, wait for that transition before deciding whether the + // current model is safe for core's preflight compaction check. + if (!autoActive && ctx.model?.provider !== AUTO_PROVIDER_ID) return; + if (routingInFlight) await modelTransitionTail; if (ctx.model?.provider !== AUTO_PROVIDER_ID) return; if (!autoActive) { autoActive = true; From b7bd05480e1ef4b06e30a23a2e545e1fa591c33a Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:28:08 -0400 Subject: [PATCH 15/25] Show Auto model + effort without 'off' on web input --- tests/web-model-status.test.ts | 9 ++++++++ web/client/semantic-session.tsx | 38 ++++++++++++++++++++++----------- web/model-status.ts | 9 ++++++++ 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/tests/web-model-status.test.ts b/tests/web-model-status.test.ts index c538d4a..97dd9ca 100644 --- a/tests/web-model-status.test.ts +++ b/tests/web-model-status.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import { applyRuntimeModelStatus, + autoTierFromReference, isAutoModelReference, isAutoRuntimeModelSwap, lastAutoRoutedModelFromEntries, @@ -159,3 +160,11 @@ test("the Auto placeholder is selected again after the runtime reverts", () => { lastModel: "openai-codex/gpt-5.6-luna", }); }); + +test("autoTierFromReference resolves adaptive and pinned tiers, but never ordinary models", () => { + expect(autoTierFromReference("auto/auto")).toBe("auto"); + expect(autoTierFromReference("auto/auto-max")).toBe("max"); + expect(autoTierFromReference("auto/auto-medium")).toBe("medium"); + expect(autoTierFromReference("anthropic/claude-sonnet")).toBeUndefined(); + expect(autoTierFromReference(undefined)).toBeUndefined(); +}); diff --git a/web/client/semantic-session.tsx b/web/client/semantic-session.tsx index 614a67c..77ebcd3 100644 --- a/web/client/semantic-session.tsx +++ b/web/client/semantic-session.tsx @@ -110,6 +110,7 @@ import { TooltipTrigger, } from "./components/ui/tooltip"; import { + autoTierFromReference, isAutoModelReference, selectedModelReference, } from "../model-status"; @@ -2627,12 +2628,17 @@ export function SemanticSession({ const selectedModelRef = selectedModelReference(session ?? {}); const selectedModelIdLabel = selectedModelRef?.split("/").pop() ?? "Model"; const autoSelected = isAutoModelReference(selectedModelRef); + const autoTier = autoTierFromReference(selectedModelRef); const fallbackModelLabel = autoSelected - ? selectedModelRef === "auto/auto" - ? "Auto (auto)" - : `Auto (${selectedModelRef?.slice("auto/auto-".length) ?? "auto"})` + ? `Auto (${autoTier ?? "auto"})` : selectedModelIdLabel; - const effortLabel = session?.thinkingLevel ?? "off"; + const rawThinkingLevel = session?.thinkingLevel; + const effortLabel = + autoSelected + ? `(${autoTier ?? "auto"})` + : rawThinkingLevel && rawThinkingLevel !== "off" + ? rawThinkingLevel + : ""; const availableModels = sessionOptions.models.length > 0 ? sessionOptions.models @@ -2651,7 +2657,9 @@ export function SemanticSession({ const selectedModelOption = availableModels.find( (model) => `${model.provider}/${model.id}` === selectedModelRef, ); - const modelLabel = selectedModelOption?.name ?? fallbackModelLabel; + const modelLabel = autoSelected + ? "Auto" + : (selectedModelOption?.name ?? fallbackModelLabel); const effectiveModelReference = autoSelected && session?.model && !isAutoModelReference(session.model) ? session.model @@ -2660,7 +2668,9 @@ export function SemanticSession({ : undefined; const turnModelSummary = effectiveModelReference && !isAutoModelReference(effectiveModelReference) - ? `${formatModelReference(effectiveModelReference)} Β· ${effortLabel}` + ? effortLabel + ? `${formatModelReference(effectiveModelReference)} Β· ${effortLabel}` + : formatModelReference(effectiveModelReference) : undefined; const availableEfforts = sessionOptions.thinkingLevels.length > 0 @@ -3424,18 +3434,22 @@ export function SemanticSession({ disabled={controlBusy || !connected} title={ turnModelSummary - ? `Selected ${modelLabel}; using ${turnModelSummary}` + ? `Selected ${modelLabel}${effortLabel ? ` ${effortLabel}` : ""}; using ${turnModelSummary}` : undefined } onMouseDown={(event) => event.preventDefault()} onClick={() => setModelMenuOpen((open) => !open)} > {modelLabel} - {!autoSelected && ( - <> - Β· - {effortLabel} - + {autoSelected ? ( + {effortLabel} + ) : ( + effortLabel && ( + <> + Β· + {effortLabel} + + ) )} {turnModelSummary && ( <> diff --git a/web/model-status.ts b/web/model-status.ts index f4edb17..b755277 100644 --- a/web/model-status.ts +++ b/web/model-status.ts @@ -151,3 +151,12 @@ export function applyRuntimeModelStatus( else if (!isAutoModelReference(runtimeModel)) delete next.lastModel; return next; } + +/** Pull the Auto tier label out of an `auto/auto-` reference (or `"auto"` for the adaptive one). */ +export function autoTierFromReference(reference: string | undefined): string | undefined { + if (reference === undefined) return undefined; + if (!isAutoModelReference(reference)) return undefined; + if (reference === "auto/auto") return "auto"; + const PREFIX = "auto/auto-"; + return reference.startsWith(PREFIX) ? reference.slice(PREFIX.length) : "auto"; +} From f7fd66088623addfc375edc0ffb4fa0e669d9bf7 Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:40:07 -0400 Subject: [PATCH 16/25] Group Auto Router picks in menu, hide effort when Auto is set --- web/client/semantic-session.tsx | 126 +++++++++++++++++++++----------- web/client/styles.css | 3 + 2 files changed, 86 insertions(+), 43 deletions(-) diff --git a/web/client/semantic-session.tsx b/web/client/semantic-session.tsx index 77ebcd3..dbabe00 100644 --- a/web/client/semantic-session.tsx +++ b/web/client/semantic-session.tsx @@ -2597,7 +2597,6 @@ export function SemanticSession({ setControlBusy(true); try { await onSelectModel(provider, modelId); - setModelMenuOpen(false); setActionError(null); } catch (cause) { reportActionError(cause); @@ -2613,7 +2612,6 @@ export function SemanticSession({ setControlBusy(true); try { await onSelectThinkingLevel(level); - setModelMenuOpen(false); setActionError(null); } catch (cause) { reportActionError(cause); @@ -2676,6 +2674,12 @@ export function SemanticSession({ sessionOptions.thinkingLevels.length > 0 ? sessionOptions.thinkingLevels : ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; + const autoModels = availableModels.filter( + (model) => model.provider === "auto", + ); + const nonAutoModels = availableModels.filter( + (model) => model.provider !== "auto", + ); const slashMatch = editingQueueId ? null : draft.match(/^\/([^\s]*)$/); const slashQuery = slashMatch?.[1] ?? ""; const matchingSlashCommands = React.useMemo( @@ -3470,48 +3474,84 @@ export function SemanticSession({ >
-
Model
- {availableModels.map((model) => { - const value = `${model.provider}/${model.id}`; - return ( - - ); - })} -
-
-
Effort
-
- {availableEfforts.map((level) => ( - - ))} -
+ {autoModels.length > 0 && ( + <> +
+ Auto Router +
+ {autoModels.map((model) => { + const value = `${model.provider}/${model.id}`; + return ( + + ); + })} + + )} + {nonAutoModels.length > 0 && ( + <> +
+ {autoModels.length > 0 ? "Models" : "Model"} +
+ {nonAutoModels.map((model) => { + const value = `${model.provider}/${model.id}`; + return ( + + ); + })} + + )}
+ {!autoSelected && ( +
+
+ Effort +
+
+ {availableEfforts.map((level) => ( + + ))} +
+
+ )}
diff --git a/web/client/styles.css b/web/client/styles.css index d03427d..af181fb 100644 --- a/web/client/styles.css +++ b/web/client/styles.css @@ -1106,6 +1106,9 @@ details[open] > summary .semantic-tool-chevron { grid-template-columns: minmax(0, 1.35fr) minmax(13rem, 1fr); align-items: start; } +.semantic-model-menu-sections:not(:has(.semantic-model-menu-effort)) { + grid-template-columns: minmax(0, 1fr); +} .semantic-model-menu-section { min-width: 0; } From 4994fd36fa3ddb84c28dc4c02e7ac24e530546ba Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:53:24 -0400 Subject: [PATCH 17/25] Mark the 'off' effort as selected when it's the actual session level --- web/client/semantic-session.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/client/semantic-session.tsx b/web/client/semantic-session.tsx index dbabe00..cd26463 100644 --- a/web/client/semantic-session.tsx +++ b/web/client/semantic-session.tsx @@ -3544,7 +3544,7 @@ export function SemanticSession({ {level} - {effortLabel === level && ( + {rawThinkingLevel === level && ( )} From 551ba41e024997a85c6706b0ac9bd5daecd564aa Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:41:42 -0400 Subject: [PATCH 18/25] Harden Auto compaction and refine model menu --- extensions/auto-router.ts | 49 ++++++++++++++-- extensions/web-sessions.ts | 13 +++-- tests/auto-router-extension.test.ts | 87 +++++++++++++++++++++++++++- web/client/semantic-session.tsx | 88 +++++++++++++++++++++-------- web/client/styles.css | 6 ++ web/server/commandRouter.ts | 28 ++++----- 6 files changed, 222 insertions(+), 49 deletions(-) diff --git a/extensions/auto-router.ts b/extensions/auto-router.ts index 81915cd..810bd60 100644 --- a/extensions/auto-router.ts +++ b/extensions/auto-router.ts @@ -179,6 +179,7 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { let pinnedTier: AutoRouterEffortLevel | undefined; let routingInFlight = false; let modelTransitionTail = Promise.resolve(); + let compactionLease = false; const healthStore = new AutoRouterHealthStore(); async function withModelTransition(operation: () => Promise): Promise { @@ -559,7 +560,10 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { await applyRouting(pi, ctx, picked.model, picked.effort); } - pi.on("input", async (_event, ctx) => { + pi.on("input", async (event, ctx) => { + // Steering/follow-up messages do not run core's pre-prompt compaction check, + // and changing the model while an agent is streaming would race its request. + if (event.streamingBehavior) return; // Core checks for threshold compaction before before_agent_start. Route away // from Auto's inert placeholder during that preflight so summarization uses // a real model instead of http://127.0.0.1:0. If placeholder restoration @@ -574,7 +578,20 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { pi.appendEntry(AUTO_ACTIVE_ENTRY_TYPE, { enabled: true, pinnedTier }); publishFooter(); } - await routeForCompaction(pi, ctx); + try { + await routeForCompaction(pi, ctx); + } catch (error) { + if (ctx.hasUI) { + ctx.ui.notify( + error instanceof Error ? error.message : String(error), + "error", + ); + } + // Do not let core continue with Auto's inert placeholder after routing + // fails: its auth/preflight error otherwise becomes a misleading connection + // error (and may be retried as compaction failures). + return { action: "handled" }; + } }); async function reconcileAllProviders( @@ -610,11 +627,20 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { ) return; if (action === "route") { + if (value.holdThroughCompaction === true) compactionLease = true; if (autoActive) waitUntil(routeForCompaction(pi, ctx)); return; } - if (autoActive && ctx.model?.provider !== AUTO_PROVIDER_ID) - waitUntil(revertToAutoPlaceholder(pi, ctx)); + waitUntil( + (async () => { + try { + if (autoActive && ctx.model?.provider !== AUTO_PROVIDER_ID) + await revertToAutoPlaceholder(pi, ctx); + } finally { + compactionLease = false; + } + })(), + ); }); pi.on("model_select", (event, _ctx) => { @@ -674,8 +700,21 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { void ensureAutoModelScopedInGlobalSettings().catch(() => undefined); }); + pi.on("agent_end", async (_event, ctx) => { + // A user can switch to Auto while a turn is still running. Core checks + // post-turn compaction immediately after agent_end, before agent_settled, + // so proactively replace the placeholder here if that happened mid-turn. + if ( + !compactionLease && + autoActive && + ctx.model?.provider === AUTO_PROVIDER_ID + ) { + await routeForCompaction(pi, ctx); + } + }); + pi.on("agent_settled", async (_event, ctx) => { - if (!autoActive) return; + if (!autoActive || compactionLease) return; if (!ctx.model || ctx.model.provider === AUTO_PROVIDER_ID) return; await revertToAutoPlaceholder(pi, ctx); }); diff --git a/extensions/web-sessions.ts b/extensions/web-sessions.ts index 21c8fab..8e5b368 100644 --- a/extensions/web-sessions.ts +++ b/extensions/web-sessions.ts @@ -92,9 +92,12 @@ function modelThinkingLevels(model: { thinkingLevelMap?: Partial>; }): string[] { if (!model.reasoning) return ["off"]; - return THINKING_LEVELS.filter( - (level) => model.thinkingLevelMap?.[level] !== null, - ); + return THINKING_LEVELS.filter((level) => { + const mapped = model.thinkingLevelMap?.[level]; + if (mapped === null) return false; + if (level === "xhigh" || level === "max") return mapped !== undefined; + return true; + }); } export function isScopedModelAllowed( @@ -271,11 +274,13 @@ function runAutoRouterCompactionAction( pi: ExtensionAPI, ctx: ExtensionContext, action: "route" | "restore", + holdThroughCompaction = false, ): Promise { const operations: Promise[] = []; pi.events.emit(AUTO_ROUTER_COMPACTION_EVENT, { action, ctx, + holdThroughCompaction, waitUntil(operation: Promise) { operations.push(operation); }, @@ -301,7 +306,7 @@ async function compactWithWebRouting( bridge?: BridgeState, ): Promise { try { - await runAutoRouterCompactionAction(pi, ctx, "route"); + await runAutoRouterCompactionAction(pi, ctx, "route", true); return await new Promise((resolveCompaction, rejectCompaction) => { try { ctx.compact({ diff --git a/tests/auto-router-extension.test.ts b/tests/auto-router-extension.test.ts index e99019f..bdee7f1 100644 --- a/tests/auto-router-extension.test.ts +++ b/tests/auto-router-extension.test.ts @@ -82,7 +82,7 @@ type FakePi = { currentModel: ModelRef; }; -function createFakePi(): FakePi { +function createFakePi(options: { setModelResult?: boolean } = {}): FakePi { const handlers = new Map(); const commands = new Map Promise }>(); const setModelCalls: Model[] = []; @@ -118,8 +118,8 @@ function createFakePi(): FakePi { }, setModel: async (m: Model) => { setModelCalls.push(m); - currentModel.value = m; - return true; + if (options.setModelResult !== false) currentModel.value = m; + return options.setModelResult !== false; }, setThinkingLevel: async (level: string) => { thinkingLevelCalls.push(level); @@ -282,6 +282,61 @@ test("Auto routes manual compaction away from its inert placeholder and restores expect(fake.currentModel.value?.id).toBe("auto"); }); +test("keeps Auto on a concrete model while a web compaction aborts the agent", async () => { + const a = model("prov", "model-a"); + await writeConfig({ + efforts: { medium: { models: [{ provider: "prov", id: "model-a" }] } }, + }); + + const fake = createFakePi(); + await autoRouter(fake.pi); + fake.currentModel.value = AUTO_PLACEHOLDER; + const ctx = fakeCtx({ + modelRegistry: fakeModelRegistry({ models: [a] }), + currentModel: fake.currentModel, + }); + await selectAuto(fake, ctx); + + const runAction = async ( + action: "route" | "restore", + holdThroughCompaction = false, + ) => { + const operations: Promise[] = []; + fake.emitEvent(AUTO_ROUTER_COMPACTION_EVENT, { + action, + ctx, + holdThroughCompaction, + waitUntil: (operation: Promise) => operations.push(operation), + }); + await Promise.all(operations); + }; + + await runAction("route", true); + await fake.fire("agent_settled", {}, ctx); + expect(fake.currentModel.value).toBe(a); + await runAction("restore"); + expect(fake.currentModel.value).toBe(AUTO_PLACEHOLDER); +}); + +test("routes at agent_end if Auto was selected while a turn was running", async () => { + const a = model("prov", "model-a"); + await writeConfig({ + efforts: { medium: { models: [{ provider: "prov", id: "model-a" }] } }, + }); + + const fake = createFakePi(); + await autoRouter(fake.pi); + fake.currentModel.value = AUTO_PLACEHOLDER; + const ctx = fakeCtx({ + modelRegistry: fakeModelRegistry({ models: [a] }), + currentModel: fake.currentModel, + }); + await selectAuto(fake, ctx); + + await fake.fire("agent_end", {}, ctx); + expect(fake.currentModel.value).toBe(a); +}); + test("Auto routes before prompt preflight so compaction cannot use its placeholder", async () => { const a = model("prov", "model-a"); await writeConfig({ @@ -303,6 +358,32 @@ test("Auto routes before prompt preflight so compaction cannot use its placehold expect(fake.setModelCalls).toEqual([a]); }); +test("handles failed preflight routing instead of dispatching the Auto placeholder", async () => { + const a = model("prov", "model-a"); + await writeConfig({ + efforts: { medium: { models: [{ provider: "prov", id: "model-a" }] } }, + }); + + const fake = createFakePi({ setModelResult: false }); + await autoRouter(fake.pi); + fake.currentModel.value = AUTO_PLACEHOLDER; + const ctx = fakeCtx({ + modelRegistry: fakeModelRegistry({ models: [a] }), + currentModel: fake.currentModel, + }); + await selectAuto(fake, ctx); + + const result = await fake.fire( + "input", + { text: "next prompt", source: "interactive" }, + ctx, + ); + + expect(result).toEqual({ action: "handled" }); + expect(fake.currentModel.value).toBe(AUTO_PLACEHOLDER); + expect(ctx.notifications.some(({ type }) => type === "error")).toBe(true); +}); + test("serializes compaction routing with placeholder restoration", async () => { const a = model("prov", "model-a"); await writeConfig({ diff --git a/web/client/semantic-session.tsx b/web/client/semantic-session.tsx index cd26463..52ffb8f 100644 --- a/web/client/semantic-session.tsx +++ b/web/client/semantic-session.tsx @@ -86,6 +86,7 @@ import { type SemanticImage, type WebQueuedMessage, type WebQueueReplacement, + type WebModelOption, type WebSession, type WebSessionOptions, type WebSlashCommand, @@ -608,6 +609,26 @@ function formatModelReference(reference: string): string { return `(${reference.slice(0, slashIndex)}) ${reference.slice(slashIndex + 1)}`; } +function formatProviderLabel(provider: string): string { + const knownLabels: Record = { + "openai-codex": "OpenAI Codex", + openai: "OpenAI", + anthropic: "Anthropic", + minimax: "MiniMax", + "kimi-coding": "Kimi Coding", + "opencode-go": "OpenCode Go", + zai: "Zai", + }; + return ( + knownLabels[provider] ?? + provider + .split(/[-_]/g) + .filter(Boolean) + .map((part) => part[0]?.toUpperCase() + part.slice(1)) + .join(" ") + ); +} + function formatTokenCount(count: number): string { if (count < 1_000) return String(count); if (count < 10_000) return `${(count / 1_000).toFixed(1)}k`; @@ -2637,7 +2658,7 @@ export function SemanticSession({ : rawThinkingLevel && rawThinkingLevel !== "off" ? rawThinkingLevel : ""; - const availableModels = + const availableModels: WebModelOption[] = sessionOptions.models.length > 0 ? sessionOptions.models : (() => { @@ -2671,15 +2692,24 @@ export function SemanticSession({ : formatModelReference(effectiveModelReference) : undefined; const availableEfforts = - sessionOptions.thinkingLevels.length > 0 + selectedModelOption?.thinkingLevels ?? + (sessionOptions.thinkingLevels.length > 0 ? sessionOptions.thinkingLevels - : ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; + : ["off", "minimal", "low", "medium", "high", "xhigh", "max"]); const autoModels = availableModels.filter( (model) => model.provider === "auto", ); const nonAutoModels = availableModels.filter( (model) => model.provider !== "auto", ); + const modelsByProvider = Array.from( + nonAutoModels.reduce((groups, model) => { + const group = groups.get(model.provider) ?? []; + group.push(model); + groups.set(model.provider, group); + return groups; + }, new Map()), + ); const slashMatch = editingQueueId ? null : draft.match(/^\/([^\s]*)$/); const slashQuery = slashMatch?.[1] ?? ""; const matchingSlashCommands = React.useMemo( @@ -3501,31 +3531,41 @@ export function SemanticSession({ })} )} - {nonAutoModels.length > 0 && ( + {modelsByProvider.length > 0 && ( <>
{autoModels.length > 0 ? "Models" : "Model"}
- {nonAutoModels.map((model) => { - const value = `${model.provider}/${model.id}`; - return ( - - ); - })} + {modelsByProvider.map(([provider, models]) => ( + +
+ {formatProviderLabel(provider)} +
+ {models.map((model) => { + const value = `${model.provider}/${model.id}`; + return ( + + ); + })} +
+ ))} )} diff --git a/web/client/styles.css b/web/client/styles.css index af181fb..9d016cb 100644 --- a/web/client/styles.css +++ b/web/client/styles.css @@ -1093,6 +1093,12 @@ details[open] > summary .semantic-tool-chevron { letter-spacing: 0.08em; text-transform: uppercase; } +.semantic-model-menu-provider-label { + padding: 0.35rem 0.65rem 0.15rem; + color: rgb(161 161 170); + font-size: 0.68rem; + font-weight: 600; +} .semantic-composer-menu-divider { height: 1px; margin: 0.35rem 0.45rem; diff --git a/web/server/commandRouter.ts b/web/server/commandRouter.ts index d6435c7..343912a 100644 --- a/web/server/commandRouter.ts +++ b/web/server/commandRouter.ts @@ -1,5 +1,7 @@ import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; +import type { Api, Model } from "@earendil-works/pi-ai"; +import { getSupportedThinkingLevels } from "@earendil-works/pi-ai"; import { isPrivateWebSessionCommand } from "../compact-command.js"; import type { ClientCommandMessage, @@ -455,20 +457,20 @@ export function createCommandRouter(options: { (model) => `${model.provider}/${model.id}`, ) : await readEnabledModelPatterns(options.config.settingsPath); - const modelOptions = models.map((model) => ({ - provider: String(model.provider ?? ""), - id: String(model.id ?? ""), - name: String(model.name ?? model.id ?? ""), - reasoning: model.reasoning === true, - })); + const modelOptions = models.map((model) => { + const thinkingLevels = getSupportedThinkingLevels( + model as unknown as Model, + ); + return { + provider: String(model.provider ?? ""), + id: String(model.id ?? ""), + name: String(model.name ?? model.id ?? ""), + reasoning: model.reasoning === true, + thinkingLevels: [...thinkingLevels], + }; + }); return { - models: filterModelsByScopePatterns( - modelOptions, - scopePatterns, - ).map((model) => ({ - ...model, - thinkingLevels: levels, - })), + models: filterModelsByScopePatterns(modelOptions, scopePatterns), thinkingLevels: levels, commands: webCommands, }; From d69973fe9740e7a1ac732f170fc593d758504fa9 Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:48:36 -0400 Subject: [PATCH 19/25] Release Auto compaction leases safely --- extensions/auto-router.ts | 40 ++++++++++++++++++++++++++--- tests/auto-router-extension.test.ts | 32 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/extensions/auto-router.ts b/extensions/auto-router.ts index 810bd60..76ddb1b 100644 --- a/extensions/auto-router.ts +++ b/extensions/auto-router.ts @@ -627,8 +627,21 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { ) return; if (action === "route") { - if (value.holdThroughCompaction === true) compactionLease = true; - if (autoActive) waitUntil(routeForCompaction(pi, ctx)); + const holdThroughCompaction = value.holdThroughCompaction === true; + if (holdThroughCompaction) compactionLease = true; + if (!autoActive) { + if (holdThroughCompaction) compactionLease = false; + return; + } + const route = routeForCompaction(pi, ctx); + waitUntil( + holdThroughCompaction + ? route.catch((error) => { + compactionLease = false; + throw error; + }) + : route, + ); return; } waitUntil( @@ -665,6 +678,7 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { pi.on("session_start", async (_event, ctx) => { currentSessionId = ctx.sessionManager.getSessionId(); routingInFlight = false; + compactionLease = false; // Reuse the single instance rather than replacing it: a stale instance's pending // debounced-save timer would otherwise still fire independently and could overwrite // this reload's freshly-loaded state on disk with the old in-memory data. @@ -709,7 +723,16 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { autoActive && ctx.model?.provider === AUTO_PROVIDER_ID ) { - await routeForCompaction(pi, ctx); + try { + await routeForCompaction(pi, ctx); + } catch (error) { + if (ctx.hasUI) { + ctx.ui.notify( + error instanceof Error ? error.message : String(error), + "error", + ); + } + } } }); @@ -719,6 +742,17 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { await revertToAutoPlaceholder(pi, ctx); }); + pi.on("session_compact", () => { + // A successful compaction is the end of the lease even if the caller is + // interrupted before it can issue the paired restore action. + compactionLease = false; + }); + + pi.on("session_shutdown", async () => { + compactionLease = false; + routingInFlight = false; + }); + pi.on("before_agent_start", async (event, ctx) => { if (!autoActive) { // `autoActive` is bookkeeping derived from model_select/session_start events, and every diff --git a/tests/auto-router-extension.test.ts b/tests/auto-router-extension.test.ts index bdee7f1..0d089ed 100644 --- a/tests/auto-router-extension.test.ts +++ b/tests/auto-router-extension.test.ts @@ -318,6 +318,38 @@ test("keeps Auto on a concrete model while a web compaction aborts the agent", a expect(fake.currentModel.value).toBe(AUTO_PLACEHOLDER); }); +test("releases a held compaction lease when compaction completes", async () => { + const a = model("prov", "model-a"); + await writeConfig({ + efforts: { medium: { models: [{ provider: "prov", id: "model-a" }] } }, + }); + + const fake = createFakePi(); + await autoRouter(fake.pi); + fake.currentModel.value = AUTO_PLACEHOLDER; + const ctx = fakeCtx({ + modelRegistry: fakeModelRegistry({ models: [a] }), + currentModel: fake.currentModel, + }); + await selectAuto(fake, ctx); + + const operations: Promise[] = []; + fake.emitEvent(AUTO_ROUTER_COMPACTION_EVENT, { + action: "route", + ctx, + holdThroughCompaction: true, + waitUntil: (operation: Promise) => operations.push(operation), + }); + await Promise.all(operations); + await fake.fire("session_compact", {}, ctx); + + fake.currentModel.value = AUTO_PLACEHOLDER; + await fake.fire("agent_end", {}, ctx); + expect(fake.currentModel.value).toBe(a); + await fake.fire("agent_settled", {}, ctx); + expect(fake.currentModel.value).toBe(AUTO_PLACEHOLDER); +}); + test("routes at agent_end if Auto was selected while a turn was running", async () => { const a = model("prov", "model-a"); await writeConfig({ From 062fffd50d997f076e63957bb552ae45c1a86524 Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:02:31 -0400 Subject: [PATCH 20/25] Order model pickers without provider headings --- bun.lock | 3 + package.json | 3 + ...endil-works%2Fpi-coding-agent@0.84.1.patch | 41 ++++++ web/client/semantic-session.tsx | 125 ++++-------------- web/client/styles.css | 6 - 5 files changed, 74 insertions(+), 104 deletions(-) create mode 100644 patches/@earendil-works%2Fpi-coding-agent@0.84.1.patch diff --git a/bun.lock b/bun.lock index 82f360c..0e73efd 100644 --- a/bun.lock +++ b/bun.lock @@ -51,6 +51,9 @@ ], }, }, + "patchedDependencies": { + "@earendil-works/pi-coding-agent@0.84.1": "patches/@earendil-works%2Fpi-coding-agent@0.84.1.patch", + }, "packages": { "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], diff --git a/package.json b/package.json index 178073e..9a4c950 100644 --- a/package.json +++ b/package.json @@ -92,5 +92,8 @@ "diff": "8.0.3", "highlight.js": "11.11.1", "proper-lockfile": "4.1.2" + }, + "patchedDependencies": { + "@earendil-works/pi-coding-agent@0.84.1": "patches/@earendil-works%2Fpi-coding-agent@0.84.1.patch" } } diff --git a/patches/@earendil-works%2Fpi-coding-agent@0.84.1.patch b/patches/@earendil-works%2Fpi-coding-agent@0.84.1.patch new file mode 100644 index 0000000..0152f80 --- /dev/null +++ b/patches/@earendil-works%2Fpi-coding-agent@0.84.1.patch @@ -0,0 +1,41 @@ +diff --git a/dist/modes/interactive/components/model-selector.js b/dist/modes/interactive/components/model-selector.js +index afdb7896d9c55cb04ca529d25f1d3e21ae707757..dfa0a9373941cd38e56d1fba5d0ca166f0fcf7fb 100644 +--- a/dist/modes/interactive/components/model-selector.js ++++ b/dist/modes/interactive/components/model-selector.js +@@ -104,11 +104,11 @@ export class ModelSelectorComponent extends Container { + const refreshed = this.modelRuntime.getModel(scoped.model.provider, scoped.model.id); + return refreshed ? { ...scoped, model: refreshed } : scoped; + }); +- this.scopedModelItems = this.scopedModels.map((scoped) => ({ ++ this.scopedModelItems = this.sortModels(this.scopedModels.map((scoped) => ({ + provider: scoped.model.provider, + id: scoped.model.id, + model: scoped.model, +- })); ++ }))); + this.activeModels = this.scope === "scoped" ? this.scopedModelItems : this.allModels; + this.filteredModels = this.activeModels; + const currentIndex = this.filteredModels.findIndex((item) => modelsAreEqual(this.currentModel, item.model)); +@@ -172,15 +172,20 @@ export class ModelSelectorComponent extends Container { + } + sortModels(models) { + const sorted = [...models]; +- // Sort: current model first, then by provider ++ // Group Auto first, then group the remaining models by provider. + sorted.sort((a, b) => { ++ const aProvider = a.provider === "auto" ? "" : a.provider; ++ const bProvider = b.provider === "auto" ? "" : b.provider; ++ const providerOrder = aProvider.localeCompare(bProvider); ++ if (providerOrder !== 0) ++ return providerOrder; + const aIsCurrent = modelsAreEqual(this.currentModel, a.model); + const bIsCurrent = modelsAreEqual(this.currentModel, b.model); + if (aIsCurrent && !bIsCurrent) + return -1; + if (!aIsCurrent && bIsCurrent) + return 1; +- return a.provider.localeCompare(b.provider); ++ return 0; + }); + return sorted; + } diff --git a/web/client/semantic-session.tsx b/web/client/semantic-session.tsx index 52ffb8f..136e84b 100644 --- a/web/client/semantic-session.tsx +++ b/web/client/semantic-session.tsx @@ -609,26 +609,6 @@ function formatModelReference(reference: string): string { return `(${reference.slice(0, slashIndex)}) ${reference.slice(slashIndex + 1)}`; } -function formatProviderLabel(provider: string): string { - const knownLabels: Record = { - "openai-codex": "OpenAI Codex", - openai: "OpenAI", - anthropic: "Anthropic", - minimax: "MiniMax", - "kimi-coding": "Kimi Coding", - "opencode-go": "OpenCode Go", - zai: "Zai", - }; - return ( - knownLabels[provider] ?? - provider - .split(/[-_]/g) - .filter(Boolean) - .map((part) => part[0]?.toUpperCase() + part.slice(1)) - .join(" ") - ); -} - function formatTokenCount(count: number): string { if (count < 1_000) return String(count); if (count < 10_000) return `${(count / 1_000).toFixed(1)}k`; @@ -2696,20 +2676,12 @@ export function SemanticSession({ (sessionOptions.thinkingLevels.length > 0 ? sessionOptions.thinkingLevels : ["off", "minimal", "low", "medium", "high", "xhigh", "max"]); - const autoModels = availableModels.filter( - (model) => model.provider === "auto", - ); - const nonAutoModels = availableModels.filter( - (model) => model.provider !== "auto", - ); - const modelsByProvider = Array.from( - nonAutoModels.reduce((groups, model) => { - const group = groups.get(model.provider) ?? []; - group.push(model); - groups.set(model.provider, group); - return groups; - }, new Map()), - ); + const orderedModels = [ + ...availableModels.filter((model) => model.provider === "auto"), + ...availableModels + .filter((model) => model.provider !== "auto") + .sort((a, b) => a.provider.localeCompare(b.provider)), + ]; const slashMatch = editingQueueId ? null : draft.match(/^\/([^\s]*)$/); const slashQuery = slashMatch?.[1] ?? ""; const matchingSlashCommands = React.useMemo( @@ -3504,70 +3476,27 @@ export function SemanticSession({ >
- {autoModels.length > 0 && ( - <> -
- Auto Router -
- {autoModels.map((model) => { - const value = `${model.provider}/${model.id}`; - return ( - - ); - })} - - )} - {modelsByProvider.length > 0 && ( - <> -
- {autoModels.length > 0 ? "Models" : "Model"} -
- {modelsByProvider.map(([provider, models]) => ( - -
- {formatProviderLabel(provider)} -
- {models.map((model) => { - const value = `${model.provider}/${model.id}`; - return ( - - ); - })} -
- ))} - - )} + {orderedModels.length > 0 && + orderedModels.map((model) => { + const value = `${model.provider}/${model.id}`; + return ( + + ); + })}
{!autoSelected && (
diff --git a/web/client/styles.css b/web/client/styles.css index 9d016cb..af181fb 100644 --- a/web/client/styles.css +++ b/web/client/styles.css @@ -1093,12 +1093,6 @@ details[open] > summary .semantic-tool-chevron { letter-spacing: 0.08em; text-transform: uppercase; } -.semantic-model-menu-provider-label { - padding: 0.35rem 0.65rem 0.15rem; - color: rgb(161 161 170); - font-size: 0.68rem; - font-weight: 600; -} .semantic-composer-menu-divider { height: 1px; margin: 0.35rem 0.45rem; From 0ff248b5cb6c4f7a1b8d4d7b6635ca64bbc4efd1 Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:09:40 -0400 Subject: [PATCH 21/25] Order TUI models without patching Pi dependency --- bun.lock | 3 - extensions/model-order.ts | 96 +++++++++++++++++++ package.json | 4 +- ...endil-works%2Fpi-coding-agent@0.84.1.patch | 41 -------- 4 files changed, 97 insertions(+), 47 deletions(-) create mode 100644 extensions/model-order.ts delete mode 100644 patches/@earendil-works%2Fpi-coding-agent@0.84.1.patch diff --git a/bun.lock b/bun.lock index 0e73efd..82f360c 100644 --- a/bun.lock +++ b/bun.lock @@ -51,9 +51,6 @@ ], }, }, - "patchedDependencies": { - "@earendil-works/pi-coding-agent@0.84.1": "patches/@earendil-works%2Fpi-coding-agent@0.84.1.patch", - }, "packages": { "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], diff --git a/extensions/model-order.ts b/extensions/model-order.ts new file mode 100644 index 0000000..3b67fb9 --- /dev/null +++ b/extensions/model-order.ts @@ -0,0 +1,96 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +type ModelItem = { + provider: string; + id: string; + model: unknown; +}; + +type SelectorInstance = { + currentModel: unknown; + scope: string; + scopedModelItems: ModelItem[]; + activeModels: ModelItem[]; + filteredModels: ModelItem[]; + selectedIndex: number; + sortModels: (models: ModelItem[]) => ModelItem[]; +}; + +type SelectorPrototype = { + sortModels?: (this: SelectorInstance, models: ModelItem[]) => ModelItem[]; + loadModelsFromSnapshot?: (this: SelectorInstance) => void; + [PATCHED]: boolean | undefined; +}; + +const PATCHED = Symbol("pi-kit-model-order-patched"); + +function modelKey(model: unknown): string | undefined { + if (!model || typeof model !== "object") return undefined; + const value = model as { provider?: unknown; id?: unknown }; + return typeof value.provider === "string" && typeof value.id === "string" + ? `${value.provider}/${value.id}` + : undefined; +} + +function orderModels(this: SelectorInstance, models: ModelItem[]): ModelItem[] { + const currentKey = modelKey(this.currentModel); + return [...models].sort((a, b) => { + // Keep Auto Router together at the top, then keep every other provider + // contiguous. The current model is only promoted within its provider. + const aProvider = a.provider === "auto" ? "" : a.provider; + const bProvider = b.provider === "auto" ? "" : b.provider; + const providerOrder = aProvider.localeCompare(bProvider); + if (providerOrder !== 0) return providerOrder; + + const aIsCurrent = currentKey === `${a.provider}/${a.id}`; + const bIsCurrent = currentKey === `${b.provider}/${b.id}`; + if (aIsCurrent && !bIsCurrent) return -1; + if (!aIsCurrent && bIsCurrent) return 1; + return 0; + }); +} + +async function installModelOrdering(): Promise { + const packageEntry = import.meta.resolve("@earendil-works/pi-coding-agent"); + const modulePath = new URL( + "./modes/interactive/components/model-selector.js", + packageEntry, + ).href; + const module = (await import(modulePath)) as unknown as { + ModelSelectorComponent?: { prototype?: SelectorPrototype }; + }; + const prototype = module.ModelSelectorComponent?.prototype; + if (!prototype || prototype[PATCHED]) return; + + const originalLoad = prototype.loadModelsFromSnapshot; + if (!originalLoad) return; + + prototype[PATCHED] = true; + prototype.sortModels = orderModels; + prototype.loadModelsFromSnapshot = function (this: SelectorInstance): void { + originalLoad.call(this); + this.scopedModelItems = this.sortModels(this.scopedModelItems); + if (this.scope !== "scoped") return; + + this.activeModels = this.scopedModelItems; + this.filteredModels = this.activeModels; + const currentIndex = this.filteredModels.findIndex( + (item) => modelKey(this.currentModel) === `${item.provider}/${item.id}`, + ); + this.selectedIndex = + currentIndex >= 0 + ? currentIndex + : Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1)); + }; +} + +export default function modelOrder(pi: ExtensionAPI): void { + pi.on("session_start", async (_event, ctx) => { + if (ctx.mode !== "tui") return; + try { + await installModelOrdering(); + } catch { + // The selector is an internal TUI component and may move between Pi versions. + } + }); +} diff --git a/package.json b/package.json index 9a4c950..5478e97 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "pi": { "extensions": [ + "./extensions/model-order.ts", "./extensions/session-footer.ts", "./extensions/pr-footer.ts", "./extensions/terminal-output.ts", @@ -92,8 +93,5 @@ "diff": "8.0.3", "highlight.js": "11.11.1", "proper-lockfile": "4.1.2" - }, - "patchedDependencies": { - "@earendil-works/pi-coding-agent@0.84.1": "patches/@earendil-works%2Fpi-coding-agent@0.84.1.patch" } } diff --git a/patches/@earendil-works%2Fpi-coding-agent@0.84.1.patch b/patches/@earendil-works%2Fpi-coding-agent@0.84.1.patch deleted file mode 100644 index 0152f80..0000000 --- a/patches/@earendil-works%2Fpi-coding-agent@0.84.1.patch +++ /dev/null @@ -1,41 +0,0 @@ -diff --git a/dist/modes/interactive/components/model-selector.js b/dist/modes/interactive/components/model-selector.js -index afdb7896d9c55cb04ca529d25f1d3e21ae707757..dfa0a9373941cd38e56d1fba5d0ca166f0fcf7fb 100644 ---- a/dist/modes/interactive/components/model-selector.js -+++ b/dist/modes/interactive/components/model-selector.js -@@ -104,11 +104,11 @@ export class ModelSelectorComponent extends Container { - const refreshed = this.modelRuntime.getModel(scoped.model.provider, scoped.model.id); - return refreshed ? { ...scoped, model: refreshed } : scoped; - }); -- this.scopedModelItems = this.scopedModels.map((scoped) => ({ -+ this.scopedModelItems = this.sortModels(this.scopedModels.map((scoped) => ({ - provider: scoped.model.provider, - id: scoped.model.id, - model: scoped.model, -- })); -+ }))); - this.activeModels = this.scope === "scoped" ? this.scopedModelItems : this.allModels; - this.filteredModels = this.activeModels; - const currentIndex = this.filteredModels.findIndex((item) => modelsAreEqual(this.currentModel, item.model)); -@@ -172,15 +172,20 @@ export class ModelSelectorComponent extends Container { - } - sortModels(models) { - const sorted = [...models]; -- // Sort: current model first, then by provider -+ // Group Auto first, then group the remaining models by provider. - sorted.sort((a, b) => { -+ const aProvider = a.provider === "auto" ? "" : a.provider; -+ const bProvider = b.provider === "auto" ? "" : b.provider; -+ const providerOrder = aProvider.localeCompare(bProvider); -+ if (providerOrder !== 0) -+ return providerOrder; - const aIsCurrent = modelsAreEqual(this.currentModel, a.model); - const bIsCurrent = modelsAreEqual(this.currentModel, b.model); - if (aIsCurrent && !bIsCurrent) - return -1; - if (!aIsCurrent && bIsCurrent) - return 1; -- return a.provider.localeCompare(b.provider); -+ return 0; - }); - return sorted; - } From b5a54b844910765f61e1ebffff16fd4e3ba90b73 Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:12:24 -0400 Subject: [PATCH 22/25] Serialize model menu selections --- web/client/semantic-session.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/client/semantic-session.tsx b/web/client/semantic-session.tsx index 136e84b..7eb5a3a 100644 --- a/web/client/semantic-session.tsx +++ b/web/client/semantic-session.tsx @@ -3483,6 +3483,7 @@ export function SemanticSession({
- {!autoSelected && ( + {!autoSelected && availableEfforts.length > 0 && (
Effort From f784f912417fe160b174cc3e6b05e03453c008e4 Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:03:32 -0400 Subject: [PATCH 24/25] fix remaining PR review findings --- extensions/auto-router.ts | 37 +++++-- extensions/session-footer.ts | 18 +++- tests/auto-router-extension.test.ts | 35 ++++-- tests/session-footer.test.ts | 9 +- tests/web-model-options.test.ts | 11 +- tests/web-model-status.test.ts | 85 +++++++++++++-- tests/web-record-sync.test.ts | 71 ++++++++++++ tests/web-session-file-catalog.test.ts | 66 +++++++++++- web/client/model-options.ts | 6 ++ web/client/semantic-session.tsx | 10 +- web/model-status.ts | 143 ++++++++++++++++--------- web/server/managedSessionCreate.ts | 1 + web/server/recordSync.ts | 2 +- web/server/session-file-catalog.ts | 34 ++++-- web/server/sessionRegistry.ts | 34 +++--- 15 files changed, 453 insertions(+), 109 deletions(-) diff --git a/extensions/auto-router.ts b/extensions/auto-router.ts index 76ddb1b..f6cdbd6 100644 --- a/extensions/auto-router.ts +++ b/extensions/auto-router.ts @@ -14,6 +14,7 @@ import { type Theme, } from "@earendil-works/pi-coding-agent"; import { Container, Markdown, matchesKey, Text } from "@earendil-works/pi-tui"; +import { AUTO_ROUTER_ACTIVE_ENTRY } from "../web/model-status.js"; import { classifyTurnComplexity } from "./auto-router-classify.js"; import { AutoRouterHealthStore, @@ -46,7 +47,7 @@ export const AUTO_ROUTER_MODEL_ROUTING_EVENT = "pi-kit:auto-router:model-routing"; const AUTO_PROVIDER_ID = "auto"; const AUTO_MODEL_ID = "auto"; -const AUTO_ACTIVE_ENTRY_TYPE = "vessup:auto-router:active"; +const AUTO_ACTIVE_ENTRY_TYPE = AUTO_ROUTER_ACTIVE_ENTRY; const FOOTER_KEY = "auto-router"; const PINNED_MODEL_PREFIX = `${AUTO_MODEL_ID}-`; @@ -180,6 +181,7 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { let routingInFlight = false; let modelTransitionTail = Promise.resolve(); let compactionLease = false; + let preflightPromptRouted = false; const healthStore = new AutoRouterHealthStore(); async function withModelTransition(operation: () => Promise): Promise { @@ -471,7 +473,7 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { ctx: ExtensionContext, prompt: string, hasImages: boolean, - ): Promise { + ): Promise { const settings = await readAutoRouterSettings(); let tier: AutoRouterEffortLevel; @@ -545,8 +547,9 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { "warning", ); } - return; + return false; } + if (!(await applyRouting(pi, ctx, picked.model, picked.effort))) return false; // Persisted so `/usage` can show what the classifier actually said - the classify call // itself is otherwise a throwaway completion whose result is discarded after parsing, which // made a prior misrouting report impossible to actually verify against real evidence. @@ -557,13 +560,14 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { effort: picked.effort, model: picked.model, }); - await applyRouting(pi, ctx, picked.model, picked.effort); + return true; } pi.on("input", async (event, ctx) => { // Steering/follow-up messages do not run core's pre-prompt compaction check, // and changing the model while an agent is streaming would race its request. if (event.streamingBehavior) return; + preflightPromptRouted = false; // Core checks for threshold compaction before before_agent_start. Route away // from Auto's inert placeholder during that preflight so summarization uses // a real model instead of http://127.0.0.1:0. If placeholder restoration @@ -579,7 +583,19 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { publishFooter(); } try { - await routeForCompaction(pi, ctx); + if ( + !(await routeForPrompt( + pi, + ctx, + event.text, + Boolean(event.images?.length), + )) + ) { + if (ctx.hasUI) + ctx.ui.notify("Auto could not route this prompt.", "error"); + return { action: "handled" }; + } + preflightPromptRouted = true; } catch (error) { if (ctx.hasUI) { ctx.ui.notify( @@ -587,9 +603,8 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { "error", ); } - // Do not let core continue with Auto's inert placeholder after routing - // fails: its auth/preflight error otherwise becomes a misleading connection - // error (and may be retried as compaction failures). + // Do not let core continue on Auto's inert placeholder or a preflight + // fallback when the prompt's final route could not be selected. return { action: "handled" }; } }); @@ -679,6 +694,7 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { currentSessionId = ctx.sessionManager.getSessionId(); routingInFlight = false; compactionLease = false; + preflightPromptRouted = false; // Reuse the single instance rather than replacing it: a stale instance's pending // debounced-save timer would otherwise still fire independently and could overwrite // this reload's freshly-loaded state on disk with the old in-memory data. @@ -751,9 +767,14 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { pi.on("session_shutdown", async () => { compactionLease = false; routingInFlight = false; + preflightPromptRouted = false; }); pi.on("before_agent_start", async (event, ctx) => { + if (preflightPromptRouted) { + preflightPromptRouted = false; + return; + } if (!autoActive) { // `autoActive` is bookkeeping derived from model_select/session_start events, and every // path that's supposed to keep it in sync with reality is a separate thing to get right - diff --git a/extensions/session-footer.ts b/extensions/session-footer.ts index e70a8d9..91490c8 100644 --- a/extensions/session-footer.ts +++ b/extensions/session-footer.ts @@ -64,17 +64,27 @@ export function formatCwd(cwd: string, home: string | undefined): string { /** Align text on both sides while preserving ANSI and OSC 8 escape sequences. */ export function alignSides(left: string, right: string, width: number): string { if (width <= 0) return ""; + const leftWidth = visibleWidth(left); const rightWidth = visibleWidth(right); - if (rightWidth > width) { + const sidesOverlap = + leftWidth > 0 && + rightWidth > 0 && + leftWidth + 1 + rightWidth > width; + if (rightWidth > width || sidesOverlap) { if (!left) return truncateToWidth(right, width, ""); const leftBudget = Math.min( - visibleWidth(left), + leftWidth, Math.max(1, Math.floor(width * 0.4)), ); const fittedLeft = truncateToWidth(left, leftBudget, "..."); - const rightBudget = Math.max(0, width - visibleWidth(fittedLeft) - 1); + const fittedLeftWidth = visibleWidth(fittedLeft); + const rightBudget = Math.max(0, width - fittedLeftWidth - 1); if (rightBudget === 0) return truncateToWidth(fittedLeft, width, ""); - return `${fittedLeft} ${truncateToWidth(right, rightBudget, "...")}`; + const fittedRight = truncateToWidth(right, rightBudget, "..."); + const padding = " ".repeat( + Math.max(1, width - fittedLeftWidth - visibleWidth(fittedRight)), + ); + return fittedLeft + padding + fittedRight; } const maxLeftWidth = Math.max(0, width - rightWidth - (left ? 1 : 0)); const fittedLeft = diff --git a/tests/auto-router-extension.test.ts b/tests/auto-router-extension.test.ts index 0d089ed..a0b974f 100644 --- a/tests/auto-router-extension.test.ts +++ b/tests/auto-router-extension.test.ts @@ -82,7 +82,11 @@ type FakePi = { currentModel: ModelRef; }; -function createFakePi(options: { setModelResult?: boolean } = {}): FakePi { +function createFakePi( + options: { + setModelResult?: boolean | ((model: Model, call: number) => boolean); + } = {}, +): FakePi { const handlers = new Map(); const commands = new Map Promise }>(); const setModelCalls: Model[] = []; @@ -118,8 +122,12 @@ function createFakePi(options: { setModelResult?: boolean } = {}): FakePi { }, setModel: async (m: Model) => { setModelCalls.push(m); - if (options.setModelResult !== false) currentModel.value = m; - return options.setModelResult !== false; + const success = + typeof options.setModelResult === "function" + ? options.setModelResult(m, setModelCalls.length) + : options.setModelResult !== false; + if (success) currentModel.value = m; + return success; }, setThinkingLevel: async (level: string) => { thinkingLevelCalls.push(level); @@ -385,22 +393,32 @@ test("Auto routes before prompt preflight so compaction cannot use its placehold await selectAuto(fake, ctx); await fake.fire("input", { text: "next prompt", source: "interactive" }, ctx); + await fake.fire("before_agent_start", { prompt: "next prompt" }, ctx); expect(fake.currentModel.value).toBe(a); expect(fake.setModelCalls).toEqual([a]); }); -test("handles failed preflight routing instead of dispatching the Auto placeholder", async () => { - const a = model("prov", "model-a"); +test("stops the prompt when its classified model cannot be selected", async () => { + const medium = model("prov", "medium-model"); + const high = model("prov", "high-model"); await writeConfig({ - efforts: { medium: { models: [{ provider: "prov", id: "model-a" }] } }, + efforts: { + medium: { models: [{ provider: "prov", id: "medium-model" }] }, + high: { models: [{ provider: "prov", id: "high-model" }] }, + }, }); - const fake = createFakePi({ setModelResult: false }); + const fake = createFakePi({ + setModelResult: (selected) => selected !== high, + }); await autoRouter(fake.pi); fake.currentModel.value = AUTO_PLACEHOLDER; const ctx = fakeCtx({ - modelRegistry: fakeModelRegistry({ models: [a] }), + modelRegistry: fakeModelRegistry({ + models: [medium, high], + classify: () => "high", + }), currentModel: fake.currentModel, }); await selectAuto(fake, ctx); @@ -412,6 +430,7 @@ test("handles failed preflight routing instead of dispatching the Auto placehold ); expect(result).toEqual({ action: "handled" }); + expect(fake.setModelCalls).toEqual([high]); expect(fake.currentModel.value).toBe(AUTO_PLACEHOLDER); expect(ctx.notifications.some(({ type }) => type === "error")).toBe(true); }); diff --git a/tests/session-footer.test.ts b/tests/session-footer.test.ts index 9a09ecf..db474da 100644 --- a/tests/session-footer.test.ts +++ b/tests/session-footer.test.ts @@ -8,9 +8,12 @@ import { } from "../extensions/footer-events.ts"; test("wide model details do not erase footer identity", () => { - const line = alignSides("⧉ ~/repo (main)", "Auto (auto) β€’ (provider) very-long-model β€’ high", 24); - expect(line).toContain("⧉"); - expect(visibleWidth(line)).toBeLessThanOrEqual(24); + const left = "⧉ ~/repo (main)"; + for (const right of ["123456789", "1234567890", "12345678901"]) { + const line = alignSides(left, right, 10); + expect(line).toContain("⧉"); + expect(visibleWidth(line)).toBeLessThanOrEqual(10); + } }); test("the shared footer places identity, routing, and activity in two rows", () => { diff --git a/tests/web-model-options.test.ts b/tests/web-model-options.test.ts index 45065e3..80f8ec3 100644 --- a/tests/web-model-options.test.ts +++ b/tests/web-model-options.test.ts @@ -1,6 +1,9 @@ import { expect, test } from "bun:test"; import type { WebModelOption } from "../web/protocol.ts"; -import { thinkingLevelsForSelectedModel } from "../web/client/model-options.ts"; +import { + thinkingLevelsForSelectedModel, + visibleRoutedThinkingLevel, +} from "../web/client/model-options.ts"; const models: WebModelOption[] = [ { @@ -27,6 +30,12 @@ const models: WebModelOption[] = [ }, ]; +test("Auto's routed model shows the runtime effort, not its selected tier", () => { + expect(visibleRoutedThinkingLevel("max")).toBe("max"); + expect(visibleRoutedThinkingLevel("off")).toBe(""); + expect(visibleRoutedThinkingLevel(undefined)).toBe(""); +}); + test("effort menu uses only the selected model's supported thinking levels", () => { expect( thinkingLevelsForSelectedModel(models, "minimax/MiniMax-M3"), diff --git a/tests/web-model-status.test.ts b/tests/web-model-status.test.ts index 97dd9ca..532598d 100644 --- a/tests/web-model-status.test.ts +++ b/tests/web-model-status.test.ts @@ -10,15 +10,25 @@ import { } from "../web/model-status"; test("reconstructs a durable Auto selection", () => { - expect( - selectedAutoModelFromEntries([ - { - type: "custom", - customType: "vessup:auto-router:active", - data: { enabled: true, pinnedTier: "high" }, - }, - ]), - ).toBe("auto/auto-high"); + for (const tier of [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + ]) { + expect( + selectedAutoModelFromEntries([ + { + type: "custom", + customType: "vessup:auto-router:active", + data: { enabled: true, pinnedTier: tier }, + }, + ]), + ).toBe(`auto/auto-${tier}`); + } expect( selectedAutoModelFromEntries([ { @@ -77,6 +87,43 @@ test("finds only models Auto actually routed to", () => { ).toBeUndefined(); }); +test("returns a newer in-flight Auto route over the prior completed route", () => { + const autoEnabled = { + type: "custom", + customType: "vessup:auto-router:active", + data: { enabled: true }, + }; + expect( + lastAutoRoutedModelFromEntries([ + { type: "model_change", provider: "auto", modelId: "auto" }, + autoEnabled, + { type: "model_change", provider: "provider", modelId: "older" }, + { type: "model_change", provider: "auto", modelId: "auto" }, + { type: "model_change", provider: "provider", modelId: "newer" }, + ]), + ).toBe("provider/newer"); +}); + +test("switching Auto tiers clears the previous tier's routed model", () => { + expect( + lastAutoRoutedModelFromEntries([ + { + type: "custom", + customType: "vessup:auto-router:active", + data: { enabled: true }, + }, + { type: "model_change", provider: "provider", modelId: "routed" }, + { type: "model_change", provider: "auto", modelId: "auto" }, + { type: "model_change", provider: "auto", modelId: "auto-high" }, + { + type: "custom", + customType: "vessup:auto-router:active", + data: { enabled: true, pinnedTier: "high" }, + }, + ]), + ).toBeUndefined(); +}); + test("recognizes Auto placeholders without matching ordinary models", () => { expect(isAutoModelReference("auto/auto")).toBe(true); expect(isAutoModelReference("auto/auto-high")).toBe(true); @@ -140,6 +187,26 @@ test("ordinary model changes replace both the runtime and selected model", () => }); }); +test("switching to a different Auto placeholder clears the old route", () => { + expect( + applyRuntimeModelStatus( + { + model: "auto/auto", + thinkingLevel: "off", + selectedModel: "auto/auto", + lastModel: "openai-codex/gpt-5.6-luna", + }, + "auto/auto-high", + "off", + false, + ), + ).toEqual({ + model: "auto/auto-high", + thinkingLevel: "off", + selectedModel: "auto/auto-high", + }); +}); + test("the Auto placeholder is selected again after the runtime reverts", () => { expect( applyRuntimeModelStatus( diff --git a/tests/web-record-sync.test.ts b/tests/web-record-sync.test.ts index 559aadd..e3e7f7b 100644 --- a/tests/web-record-sync.test.ts +++ b/tests/web-record-sync.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; import { createRecordSync } from "../web/server/recordSync.ts"; +import { mergedLastModel } from "../web/server/sessionRegistry.ts"; import type { SessionFileCatalog, SessionRecord, @@ -35,6 +36,76 @@ function recordSync() { return createRecordSync({ catalog, state }); } +test("model refresh payloads preserve the authoritative session name", () => { + const sync = recordSync(); + const record = { + id: "session-1", + name: "Named session", + status: "idle", + } as unknown as SessionRecord; + + sync.updateRecordFromState(record, { + thinkingLevel: "high", + sessionName: "Named session", + }); + expect(record.name).toBe("Named session"); + + sync.updateRecordFromState(record, { thinkingLevel: "high" }); + expect(record.name).toBeUndefined(); +}); + +test("catalog comparisons normalize explicit last-model clears", () => { + const sync = recordSync(); + const previous = { + id: "session-1", + lastModel: undefined, + } as unknown as SessionRecord; + const next = { + id: "session-1", + lastModel: null, + } as unknown as SessionRecord; + + expect(sync.catalogSessionChanged(previous, next)).toBe(false); + next.lastModel = "provider/routed"; + expect(sync.catalogSessionChanged(previous, next)).toBe(true); +}); + +test("session merges preserve only the current Auto selection's route", () => { + const previous = { + model: "auto/auto", + selectedModel: "auto/auto", + lastModel: "provider/previous", + }; + expect( + mergedLastModel(previous, { + model: "auto/auto", + selectedModel: "auto/auto", + lastModel: undefined, + }), + ).toBe("provider/previous"); + expect( + mergedLastModel(previous, { + model: "auto/auto-high", + selectedModel: "auto/auto-high", + lastModel: undefined, + }), + ).toBeUndefined(); + expect( + mergedLastModel(previous, { + model: "provider/manual", + selectedModel: "provider/manual", + lastModel: undefined, + }), + ).toBeUndefined(); + expect( + mergedLastModel(previous, { + model: "auto/auto", + selectedModel: "auto/auto", + lastModel: null, + }), + ).toBeUndefined(); +}); + test("a stale refresh cannot cancel Auto tracking for a newer turn", () => { const sync = recordSync(); const record = { diff --git a/tests/web-session-file-catalog.test.ts b/tests/web-session-file-catalog.test.ts index 73f6f71..0695673 100644 --- a/tests/web-session-file-catalog.test.ts +++ b/tests/web-session-file-catalog.test.ts @@ -1,5 +1,12 @@ import { afterEach, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm, truncate, writeFile } from "node:fs/promises"; +import { + appendFile, + mkdir, + mkdtemp, + rm, + truncate, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { ManagedSessionStore } from "../web/server/managed-session-store.ts"; @@ -40,6 +47,63 @@ test("metadata cache results have fresh arrays and current ownership", async () expect(second.session.source).toBe("web"); }); +test("metadata scans retain bounded incremental Auto routing state", async () => { + tempDir = await mkdtemp(join(tmpdir(), "pi-kit-session-auto-metadata-")); + const sessionsDir = join(tempDir, "sessions"); + const file = join(sessionsDir, "auto.jsonl"); + await mkdir(sessionsDir, { recursive: true }); + await writeFile( + file, + `${JSON.stringify({ type: "session", id: "auto", cwd: tempDir })}\n${JSON.stringify({ + type: "custom", + customType: "vessup:auto-router:active", + data: { enabled: true, pinnedTier: "high" }, + })}\n`, + ); + const catalog = createSessionFileCatalog({ + sessionsDir, + managedSessionStore: new ManagedSessionStore(join(tempDir, "managed.json")), + }); + + expect(catalog.parseSessionMetadataFile(file)?.session).toMatchObject({ + selectedModel: "auto/auto-high", + lastModel: undefined, + }); + await appendFile( + file, + `${JSON.stringify({ type: "model_change", provider: "provider", modelId: "a" })}\n`, + ); + expect(catalog.parseSessionMetadataFile(file)?.session.lastModel).toBe( + "provider/a", + ); + await appendFile( + file, + `${JSON.stringify({ type: "model_change", provider: "auto", modelId: "auto-high" })}\n`, + ); + expect(catalog.parseSessionMetadataFile(file)?.session.lastModel).toBe( + "provider/a", + ); + await appendFile( + file, + `${JSON.stringify({ type: "model_change", provider: "provider", modelId: "b" })}\n`, + ); + expect(catalog.parseSessionMetadataFile(file)?.session.lastModel).toBe( + "provider/b", + ); + await appendFile( + file, + `${JSON.stringify({ type: "model_change", provider: "auto", modelId: "auto-high" })}\n${JSON.stringify({ + type: "custom", + customType: "vessup:auto-router:active", + data: { enabled: false }, + })}\n`, + ); + expect(catalog.parseSessionMetadataFile(file)?.session).toMatchObject({ + selectedModel: undefined, + lastModel: undefined, + }); +}); + test("deletion reads worktree ownership from a bounded session prefix", async () => { tempDir = await mkdtemp(join(tmpdir(), "pi-kit-session-prefix-")); const sessionsDir = join(tempDir, "sessions"); diff --git a/web/client/model-options.ts b/web/client/model-options.ts index 454d0a4..3fed862 100644 --- a/web/client/model-options.ts +++ b/web/client/model-options.ts @@ -1,5 +1,11 @@ import type { WebModelOption } from "../protocol.js"; +export function visibleRoutedThinkingLevel( + thinkingLevel: string | undefined, +): string { + return thinkingLevel && thinkingLevel !== "off" ? thinkingLevel : ""; +} + /** Return only the thinking levels advertised for the selected model. */ export function thinkingLevelsForSelectedModel( models: readonly WebModelOption[], diff --git a/web/client/semantic-session.tsx b/web/client/semantic-session.tsx index 77c673c..e7a68b8 100644 --- a/web/client/semantic-session.tsx +++ b/web/client/semantic-session.tsx @@ -117,7 +117,10 @@ import { } from "../model-status"; import { assertClientPromptPayloadFits } from "./image-payload"; import { cn } from "./lib/utils"; -import { thinkingLevelsForSelectedModel } from "./model-options"; +import { + thinkingLevelsForSelectedModel, + visibleRoutedThinkingLevel, +} from "./model-options"; import { anchoredScrollTop, resolveScrollFollow } from "./scroll-follow"; import { hasActiveSessionWork } from "./session-status"; import { toolHasArgumentDetails } from "./tool-expansion"; @@ -2666,10 +2669,11 @@ export function SemanticSession({ : autoSelected ? session?.lastModel : undefined; + const routedEffortLabel = visibleRoutedThinkingLevel(rawThinkingLevel); const turnModelSummary = effectiveModelReference && !isAutoModelReference(effectiveModelReference) - ? effortLabel - ? `${formatModelReference(effectiveModelReference)} Β· ${effortLabel}` + ? routedEffortLabel + ? `${formatModelReference(effectiveModelReference)} Β· ${routedEffortLabel}` : formatModelReference(effectiveModelReference) : undefined; const availableEfforts = thinkingLevelsForSelectedModel( diff --git a/web/model-status.ts b/web/model-status.ts index b755277..2872c68 100644 --- a/web/model-status.ts +++ b/web/model-status.ts @@ -10,7 +10,10 @@ export type WebModelStatus = { export type WebModelIdentity = { provider: string; id: string }; +export const AUTO_ROUTER_ACTIVE_ENTRY = "vessup:auto-router:active"; + const AUTO_ROUTER_EFFORTS = new Set([ + "off", "minimal", "low", "medium", @@ -19,41 +22,33 @@ const AUTO_ROUTER_EFFORTS = new Set([ "max", ]); -/** Reconstruct the durable Auto placeholder selected for a saved session. */ -export function selectedAutoModelFromEntries( - entries: readonly unknown[], -): string | undefined { - for (let index = entries.length - 1; index >= 0; index -= 1) { - const entry = entries[index]; - if (!entry || typeof entry !== "object") continue; - const value = entry as Record; - if ( - value.type !== "custom" || - value.customType !== "vessup:auto-router:active" - ) - continue; - const data = value.data; - if ( - !data || - typeof data !== "object" || - (data as Record).enabled !== true - ) - return undefined; - const pinnedTier = (data as Record).pinnedTier; - return typeof pinnedTier === "string" && AUTO_ROUTER_EFFORTS.has(pinnedTier) - ? `auto/auto-${pinnedTier}` - : "auto/auto"; - } - return undefined; +export type AutoRoutingState = { + active: boolean; + selectedModel?: string; + currentPlaceholder?: string; + pendingRoute?: string; + lastModel?: string; +}; + +function selectedAutoModelFromData(data: unknown): string | undefined { + if ( + !data || + typeof data !== "object" || + (data as Record).enabled !== true + ) + return undefined; + const pinnedTier = (data as Record).pinnedTier; + return typeof pinnedTier === "string" && AUTO_ROUTER_EFFORTS.has(pinnedTier) + ? `auto/auto-${pinnedTier}` + : "auto/auto"; } -/** Find the last concrete model that Auto actually routed to. */ -export function lastAutoRoutedModelFromEntries( +/** Incrementally fold durable Auto selection and routed-model transitions. */ +export function autoRoutingStateFromEntries( entries: readonly unknown[], -): string | undefined { - let autoActive = false; - let pendingRoute: string | undefined; - let lastModel: string | undefined; + initial: AutoRoutingState = { active: false }, +): AutoRoutingState { + const state: AutoRoutingState = { ...initial }; for (const entry of entries) { if (!entry || typeof entry !== "object") continue; const value = entry as Record; @@ -67,30 +62,78 @@ export function lastAutoRoutedModelFromEntries( continue; const model = `${value.provider}/${value.modelId}`; if (isAutoModelReference(model)) { - if (pendingRoute) lastModel = pendingRoute; - pendingRoute = undefined; - } else if (autoActive) { - pendingRoute = model; + if ( + state.active && + state.currentPlaceholder && + state.currentPlaceholder !== model + ) { + state.pendingRoute = undefined; + state.lastModel = undefined; + } else if (state.pendingRoute) { + state.lastModel = state.pendingRoute; + state.pendingRoute = undefined; + } + state.currentPlaceholder = model; + } else if (state.active) { + state.pendingRoute = model; } continue; } if ( value.type !== "custom" || - value.customType !== "vessup:auto-router:active" + value.customType !== AUTO_ROUTER_ACTIVE_ENTRY ) continue; - const data = value.data; - const enabled = - typeof data === "object" && - data !== null && - (data as Record).enabled === true; - autoActive = enabled; - if (!enabled) { - pendingRoute = undefined; - lastModel = undefined; + const selectedModel = selectedAutoModelFromData(value.data); + if (!selectedModel) { + state.active = false; + state.selectedModel = undefined; + state.currentPlaceholder = undefined; + state.pendingRoute = undefined; + state.lastModel = undefined; + continue; + } + if (!state.active) { + state.currentPlaceholder = selectedModel; + } else if ( + state.selectedModel && + state.selectedModel !== selectedModel + ) { + state.pendingRoute = undefined; + state.lastModel = undefined; + state.currentPlaceholder = selectedModel; } + state.active = true; + state.selectedModel = selectedModel; + state.currentPlaceholder ??= selectedModel; } - return lastModel ?? pendingRoute; + return state; +} + +export function selectedAutoModelFromState( + state: AutoRoutingState, +): string | undefined { + return state.active ? state.selectedModel : undefined; +} + +export function lastAutoRoutedModelFromState( + state: AutoRoutingState, +): string | undefined { + return state.active ? (state.pendingRoute ?? state.lastModel) : undefined; +} + +/** Reconstruct the durable Auto placeholder selected for a saved session. */ +export function selectedAutoModelFromEntries( + entries: readonly unknown[], +): string | undefined { + return selectedAutoModelFromState(autoRoutingStateFromEntries(entries)); +} + +/** Find the most recent concrete model that Auto actually routed to. */ +export function lastAutoRoutedModelFromEntries( + entries: readonly unknown[], +): string | undefined { + return lastAutoRoutedModelFromState(autoRoutingStateFromEntries(entries)); } /** Format a provider/model pair for the Web session protocol. */ @@ -148,7 +191,11 @@ export function applyRuntimeModelStatus( selectedModel: preservingAutoSelection ? selectedModel : runtimeModel, }; if (preservingAutoSelection) next.lastModel = runtimeModel; - else if (!isAutoModelReference(runtimeModel)) delete next.lastModel; + else if ( + !isAutoModelReference(runtimeModel) || + (isAutoModelReference(selectedModel) && selectedModel !== runtimeModel) + ) + delete next.lastModel; return next; } diff --git a/web/server/managedSessionCreate.ts b/web/server/managedSessionCreate.ts index e050815..765e2ce 100644 --- a/web/server/managedSessionCreate.ts +++ b/web/server/managedSessionCreate.ts @@ -222,6 +222,7 @@ export function createManagedSessionLauncher(options: { { model: snapshot.model, thinkingLevel: snapshot.thinkingLevel, + sessionName: snapshot.sessionName, }, generation, ); diff --git a/web/server/recordSync.ts b/web/server/recordSync.ts index 742de92..94fe403 100644 --- a/web/server/recordSync.ts +++ b/web/server/recordSync.ts @@ -264,7 +264,7 @@ export function createRecordSync(options: { previous.model !== next.model || previous.thinkingLevel !== next.thinkingLevel || previous.selectedModel !== next.selectedModel || - previous.lastModel !== next.lastModel || + (previous.lastModel ?? undefined) !== (next.lastModel ?? undefined) || previous.status !== next.status || previous.source !== next.source || previous.messageCount !== next.messageCount || diff --git a/web/server/session-file-catalog.ts b/web/server/session-file-catalog.ts index ce1a1d7..f634850 100644 --- a/web/server/session-file-catalog.ts +++ b/web/server/session-file-catalog.ts @@ -13,8 +13,13 @@ import { basename, dirname, join, normalize, resolve, sep } from "node:path"; import type { SessionManager } from "@earendil-works/pi-coding-agent"; import type { WebSession } from "../protocol.js"; import { + AUTO_ROUTER_ACTIVE_ENTRY, + type AutoRoutingState, + autoRoutingStateFromEntries, lastAutoRoutedModelFromEntries, + lastAutoRoutedModelFromState, selectedAutoModelFromEntries, + selectedAutoModelFromState, } from "../model-status.js"; import { replacementFromEntries, @@ -50,6 +55,7 @@ export function createSessionFileCatalog(options: { parsedBytes: number; scan: SessionFileScan; metadataEntries: Record[]; + autoRoutingState: AutoRoutingState; } >(); @@ -469,6 +475,10 @@ export function createSessionFileCatalog(options: { let messageCount = incremental ? cached.scan.session.messageCount : 0; let preview = incremental ? cached.scan.session.preview : undefined; const metadataEntries = incremental ? [...cached.metadataEntries] : []; + let autoRoutingState: AutoRoutingState = incremental + ? { ...cached.autoRoutingState } + : { active: false }; + const autoRoutingEntries: Record[] = []; const usage = zeroWebUsage(); if (incremental) addWebUsage(usage, cached.scan.session.usage); for (const line of lines) { @@ -494,14 +504,19 @@ export function createSessionFileCatalog(options: { ) thinkingLevel = entry.thinkingLevel; if ( - (entry.type === "custom" && - (entry.customType === WORKTREE_SESSION_ENTRY || - entry.customType === WORKTREE_REPLACEMENT_ENTRY || - entry.customType === "vessup:auto-router:active")) || - entry.type === "model_change" + entry.type === "custom" && + (entry.customType === WORKTREE_SESSION_ENTRY || + entry.customType === WORKTREE_REPLACEMENT_ENTRY) ) { metadataEntries.push(entry); } + if ( + entry.type === "model_change" || + (entry.type === "custom" && + entry.customType === AUTO_ROUTER_ACTIVE_ENTRY) + ) { + autoRoutingEntries.push(entry); + } if (entry.type === "message") { messageCount += 1; const message = isRecord(entry.message) ? entry.message : undefined; @@ -531,6 +546,10 @@ export function createSessionFileCatalog(options: { : typeof header?.cwd === "string" && header.cwd ? header.cwd : dirname(file); + autoRoutingState = autoRoutingStateFromEntries( + autoRoutingEntries, + autoRoutingState, + ); const managedWorktree = managedWorktreeFromEntries(metadataEntries); const session: WebSession = { id, @@ -539,8 +558,8 @@ export function createSessionFileCatalog(options: { name, model, thinkingLevel, - selectedModel: selectedAutoModelFromEntries(metadataEntries), - lastModel: lastAutoRoutedModelFromEntries(metadataEntries), + selectedModel: selectedAutoModelFromState(autoRoutingState), + lastModel: lastAutoRoutedModelFromState(autoRoutingState), status: "offline", source: isManagedSessionFile(file) ? "web" : "saved", createdAt: incremental @@ -575,6 +594,7 @@ export function createSessionFileCatalog(options: { parsedBytes, scan, metadataEntries, + autoRoutingState, }); return freshMetadataScan(scan, file); } catch { diff --git a/web/server/sessionRegistry.ts b/web/server/sessionRegistry.ts index 9d5c3e1..7310759 100644 --- a/web/server/sessionRegistry.ts +++ b/web/server/sessionRegistry.ts @@ -20,6 +20,20 @@ import type { ServerStores } from "./serverStores.js"; import type { SessionHistory } from "./sessionHistory.js"; import { managedWorktreeFromEntries } from "./worktrees.js"; +export function mergedLastModel( + previous: Pick, + session: Pick, +): string | undefined { + if (typeof session.lastModel === "string") return session.lastModel; + if (session.lastModel === null) return undefined; + const selectedModel = session.selectedModel ?? session.model; + if (!isAutoModelReference(selectedModel)) return undefined; + const previousSelection = previous.selectedModel ?? previous.model; + return previousSelection === selectedModel + ? (previous.lastModel ?? undefined) + : undefined; +} + /** * Owns the live session catalog: the id- and file-keyed record maps, record * construction from scans, and the projected client payload. @@ -41,20 +55,6 @@ export function createSessionRegistry(options: { const { isMissingInactiveSession } = missingSessions; const { replaceRecordHistory } = history; - function clearStaleAutoModel(record: SessionRecord, session: WebSession): void { - if (session.lastModel === null) { - record.lastModel = undefined; - return; - } - const selectedModel = session.selectedModel ?? session.model; - if ( - session.lastModel === undefined && - selectedModel && - !isAutoModelReference(selectedModel) - ) - record.lastModel = undefined; - } - function sessionToClientPayload( session: WebSession, includeSubagentTranscripts = false, @@ -141,8 +141,9 @@ export function createSessionRegistry(options: { images: item.images?.map((image) => ({ ...image })), })), }) as SessionRecord; + const nextLastModel = mergedLastModel(record, session); Object.assign(record, session); - clearStaleAutoModel(record, session); + record.lastModel = nextLastModel; record.kind = kind; if (history.length > 0 && !existing) { record.history = displayHistory; @@ -171,8 +172,9 @@ export function createSessionRegistry(options: { makeSessionRecord(session, kind, history, managedWorktreeScanned); const historyManagedWorktree = history.length > 0 ? managedWorktreeFromEntries(history) : undefined; + const nextLastModel = mergedLastModel(record, session); Object.assign(record, session); - clearStaleAutoModel(record, session); + record.lastModel = nextLastModel; record.kind = kind; if (history.length > 0) replaceRecordHistory( From 18ff3d885f3dbe46604dd47bdaa64888aed29ff4 Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:21:52 -0400 Subject: [PATCH 25/25] fix speculative Auto compaction status --- extensions/auto-router.ts | 51 ++++++++++++- extensions/web-sessions.ts | 14 +++- tests/auto-router-extension.test.ts | 112 +++++++++++++++++++++++++++- tests/web-model-status.test.ts | 55 ++++++++++++++ web/model-status.ts | 12 +++ 5 files changed, 241 insertions(+), 3 deletions(-) diff --git a/extensions/auto-router.ts b/extensions/auto-router.ts index f6cdbd6..943bd44 100644 --- a/extensions/auto-router.ts +++ b/extensions/auto-router.ts @@ -14,7 +14,10 @@ import { type Theme, } from "@earendil-works/pi-coding-agent"; import { Container, Markdown, matchesKey, Text } from "@earendil-works/pi-tui"; -import { AUTO_ROUTER_ACTIVE_ENTRY } from "../web/model-status.js"; +import { + AUTO_ROUTER_ACTIVE_ENTRY, + lastAutoRoutedModelFromEntries, +} from "../web/model-status.js"; import { classifyTurnComplexity } from "./auto-router-classify.js"; import { AutoRouterHealthStore, @@ -182,6 +185,8 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { let modelTransitionTail = Promise.resolve(); let compactionLease = false; let preflightPromptRouted = false; + let postTurnCompactionRoutePending = false; + let postTurnCompactionPreviousRoute: string | undefined; const healthStore = new AutoRouterHealthStore(); async function withModelTransition(operation: () => Promise): Promise { @@ -695,6 +700,8 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { routingInFlight = false; compactionLease = false; preflightPromptRouted = false; + postTurnCompactionRoutePending = false; + postTurnCompactionPreviousRoute = undefined; // Reuse the single instance rather than replacing it: a stale instance's pending // debounced-save timer would otherwise still fire independently and could overwrite // this reload's freshly-loaded state on disk with the old in-memory data. @@ -740,8 +747,13 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { ctx.model?.provider === AUTO_PROVIDER_ID ) { try { + postTurnCompactionPreviousRoute = lastAutoRoutedModelFromEntries( + ctx.sessionManager.getEntries(), + ); await routeForCompaction(pi, ctx); + postTurnCompactionRoutePending = true; } catch (error) { + postTurnCompactionPreviousRoute = undefined; if (ctx.hasUI) { ctx.ui.notify( error instanceof Error ? error.message : String(error), @@ -752,9 +764,44 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { } }); + function commitPostTurnCompactionRoute(): void { + postTurnCompactionRoutePending = false; + postTurnCompactionPreviousRoute = undefined; + } + + pi.on("session_before_compact", () => { + // Core has now committed to a real compaction attempt, so a model selected + // at agent_end is no longer speculative and should remain visible as used. + commitPostTurnCompactionRoute(); + }); + + pi.on("agent_start", () => { + // A retry or queued continuation can begin after agent_end. In that case + // the candidate is handling a real request even if no compaction starts. + if (postTurnCompactionRoutePending) commitPostTurnCompactionRoute(); + }); + pi.on("agent_settled", async (_event, ctx) => { if (!autoActive || compactionLease) return; if (!ctx.model || ctx.model.provider === AUTO_PROVIDER_ID) return; + if (postTurnCompactionRoutePending) { + const restoreRoute = postTurnCompactionPreviousRoute; + commitPostTurnCompactionRoute(); + // The agent_end route existed only to make core's post-turn compaction + // check safe, but no compaction started. Roll it back in durable and live + // Web status without erasing an earlier model that Auto genuinely used. + pi.appendEntry(AUTO_ACTIVE_ENTRY_TYPE, { + enabled: true, + pinnedTier, + resetRoute: true, + restoreRoute, + }); + pi.events.emit(AUTO_ROUTER_MODEL_ROUTING_EVENT, { + action: "discard", + ctx, + restoreRoute, + }); + } await revertToAutoPlaceholder(pi, ctx); }); @@ -762,12 +809,14 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { // A successful compaction is the end of the lease even if the caller is // interrupted before it can issue the paired restore action. compactionLease = false; + commitPostTurnCompactionRoute(); }); pi.on("session_shutdown", async () => { compactionLease = false; routingInFlight = false; preflightPromptRouted = false; + commitPostTurnCompactionRoute(); }); pi.on("before_agent_start", async (event, ctx) => { diff --git a/extensions/web-sessions.ts b/extensions/web-sessions.ts index 8e5b368..faccc7e 100644 --- a/extensions/web-sessions.ts +++ b/extensions/web-sessions.ts @@ -1302,7 +1302,7 @@ export default function webSessions(pi: ExtensionAPI): void { const action = value.action; const ctx = value.ctx as ExtensionContext | undefined; if ( - (action !== "start" && action !== "end") || + (action !== "start" && action !== "end" && action !== "discard") || !ctx || !bridge || bridge.closed || @@ -1310,6 +1310,18 @@ export default function webSessions(pi: ExtensionAPI): void { ) return; bridge.autoRuntimeRouting = action === "start"; + if (action === "discard") { + const selectedModel = selectedModelReference(bridge.session); + if (isAutoModelReference(selectedModel)) { + updateSession(bridge, { + model: selectedModel, + lastModel: + typeof value.restoreRoute === "string" + ? value.restoreRoute + : null, + }); + } + } }); // RPC mode normally expands /skill:name before the agent sees it. Pi Web keeps diff --git a/tests/auto-router-extension.test.ts b/tests/auto-router-extension.test.ts index a0b974f..df55073 100644 --- a/tests/auto-router-extension.test.ts +++ b/tests/auto-router-extension.test.ts @@ -358,7 +358,7 @@ test("releases a held compaction lease when compaction completes", async () => { expect(fake.currentModel.value).toBe(AUTO_PLACEHOLDER); }); -test("routes at agent_end if Auto was selected while a turn was running", async () => { +test("discards an unused post-turn compaction route", async () => { const a = model("prov", "model-a"); await writeConfig({ efforts: { medium: { models: [{ provider: "prov", id: "model-a" }] } }, @@ -375,6 +375,116 @@ test("routes at agent_end if Auto was selected while a turn was running", async await fake.fire("agent_end", {}, ctx); expect(fake.currentModel.value).toBe(a); + await fake.fire("agent_settled", {}, ctx); + + expect(fake.currentModel.value).toBe(AUTO_PLACEHOLDER); + expect(fake.appendedEntries.at(-1)).toEqual({ + type: "vessup:auto-router:active", + data: { + enabled: true, + pinnedTier: undefined, + resetRoute: true, + restoreRoute: undefined, + }, + }); +}); + +test("discarding a speculative route preserves the prior real Auto route", async () => { + const a = model("prov", "model-a"); + await writeConfig({ + efforts: { medium: { models: [{ provider: "prov", id: "model-a" }] } }, + }); + + const fake = createFakePi(); + await autoRouter(fake.pi); + fake.currentModel.value = AUTO_PLACEHOLDER; + const ctx = fakeCtx({ + modelRegistry: fakeModelRegistry({ models: [a] }), + currentModel: fake.currentModel, + entries: [ + { + type: "custom", + customType: "vessup:auto-router:active", + data: { enabled: true }, + }, + { type: "model_change", provider: "prov", modelId: "previous" }, + { type: "model_change", provider: "auto", modelId: "auto" }, + ], + }); + await selectAuto(fake, ctx); + + await fake.fire("agent_end", {}, ctx); + await fake.fire("agent_settled", {}, ctx); + + expect(fake.appendedEntries.at(-1)).toEqual({ + type: "vessup:auto-router:active", + data: { + enabled: true, + pinnedTier: undefined, + resetRoute: true, + restoreRoute: "prov/previous", + }, + }); +}); + +test("retains a post-turn route when an automatic continuation starts", async () => { + const a = model("prov", "model-a"); + await writeConfig({ + efforts: { medium: { models: [{ provider: "prov", id: "model-a" }] } }, + }); + + const fake = createFakePi(); + await autoRouter(fake.pi); + fake.currentModel.value = AUTO_PLACEHOLDER; + const ctx = fakeCtx({ + modelRegistry: fakeModelRegistry({ models: [a] }), + currentModel: fake.currentModel, + }); + await selectAuto(fake, ctx); + + await fake.fire("agent_end", {}, ctx); + await fake.fire("agent_start", {}, ctx); + await fake.fire("agent_settled", {}, ctx); + + expect( + fake.appendedEntries.some( + ({ data }) => + typeof data === "object" && + data !== null && + (data as { resetRoute?: boolean }).resetRoute === true, + ), + ).toBe(false); + expect(fake.currentModel.value).toBe(AUTO_PLACEHOLDER); +}); + +test("retains a post-turn route when compaction actually starts", async () => { + const a = model("prov", "model-a"); + await writeConfig({ + efforts: { medium: { models: [{ provider: "prov", id: "model-a" }] } }, + }); + + const fake = createFakePi(); + await autoRouter(fake.pi); + fake.currentModel.value = AUTO_PLACEHOLDER; + const ctx = fakeCtx({ + modelRegistry: fakeModelRegistry({ models: [a] }), + currentModel: fake.currentModel, + }); + await selectAuto(fake, ctx); + + await fake.fire("agent_end", {}, ctx); + await fake.fire("session_before_compact", {}, ctx); + await fake.fire("agent_settled", {}, ctx); + + expect( + fake.appendedEntries.some( + ({ data }) => + typeof data === "object" && + data !== null && + (data as { resetRoute?: boolean }).resetRoute === true, + ), + ).toBe(false); + expect(fake.currentModel.value).toBe(AUTO_PLACEHOLDER); }); test("Auto routes before prompt preflight so compaction cannot use its placeholder", async () => { diff --git a/tests/web-model-status.test.ts b/tests/web-model-status.test.ts index 532598d..811a79e 100644 --- a/tests/web-model-status.test.ts +++ b/tests/web-model-status.test.ts @@ -104,6 +104,61 @@ test("returns a newer in-flight Auto route over the prior completed route", () = ).toBe("provider/newer"); }); +test("a discarded speculative route is not reported as used", () => { + expect( + lastAutoRoutedModelFromEntries([ + { + type: "custom", + customType: "vessup:auto-router:active", + data: { enabled: true }, + }, + { type: "model_change", provider: "provider", modelId: "candidate" }, + { + type: "custom", + customType: "vessup:auto-router:active", + data: { enabled: true, resetRoute: true }, + }, + { type: "model_change", provider: "auto", modelId: "auto" }, + ]), + ).toBeUndefined(); + expect( + selectedAutoModelFromEntries([ + { + type: "custom", + customType: "vessup:auto-router:active", + data: { enabled: true }, + }, + { + type: "custom", + customType: "vessup:auto-router:active", + data: { enabled: true, resetRoute: true }, + }, + ]), + ).toBe("auto/auto"); + expect( + lastAutoRoutedModelFromEntries([ + { + type: "custom", + customType: "vessup:auto-router:active", + data: { enabled: true }, + }, + { type: "model_change", provider: "provider", modelId: "previous" }, + { type: "model_change", provider: "auto", modelId: "auto" }, + { type: "model_change", provider: "provider", modelId: "speculative" }, + { + type: "custom", + customType: "vessup:auto-router:active", + data: { + enabled: true, + resetRoute: true, + restoreRoute: "provider/previous", + }, + }, + { type: "model_change", provider: "auto", modelId: "auto" }, + ]), + ).toBe("provider/previous"); +}); + test("switching Auto tiers clears the previous tier's routed model", () => { expect( lastAutoRoutedModelFromEntries([ diff --git a/web/model-status.ts b/web/model-status.ts index 2872c68..a0faeab 100644 --- a/web/model-status.ts +++ b/web/model-status.ts @@ -106,6 +106,18 @@ export function autoRoutingStateFromEntries( state.active = true; state.selectedModel = selectedModel; state.currentPlaceholder ??= selectedModel; + if ( + value.data && + typeof value.data === "object" && + (value.data as Record).resetRoute === true + ) { + state.pendingRoute = undefined; + const restoreRoute = (value.data as Record).restoreRoute; + state.lastModel = + typeof restoreRoute === "string" && restoreRoute.length > 0 + ? restoreRoute + : undefined; + } } return state; }