diff --git a/README.md b/README.md index befb2bc..5c61108 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Reusable extensions for the [Pi coding agent](https://github.com/earendil-works/ ## PR footer -`extensions/pr-footer.ts` contributes the current GitHub pull request to the shared footer as a right-aligned, clickable ` #123` link on the directory/branch line above the model information. A colored circle beside it shows the aggregate check status: +`extensions/pr-footer.ts` contributes the current GitHub pull request to the shared footer as a clickable ` #123` link after the session name on the directory/branch line. A colored circle beside it shows the aggregate check status: - Green: checks passed - Yellow: checks are pending or in progress @@ -31,7 +31,7 @@ It uses the GitHub CLI to resolve the pull request and check status for the chec - Change model and reasoning effort - Terminate subagents and release their resources -The subagent extension independently contributes its token use and status to `extensions/session-footer.ts`, the package's generic composable footer. When subagents are involved, a third footer line shows their aggregate status. With an empty editor, press Option+Down (Alt+Down) to select that line and Enter to open the manager; `/subagents` opens it directly. The manager shows individual status and transcripts and supports model, effort, messaging, and termination controls. Run `/subagents-cleanup` to stop and remove every retained subagent. +The subagent extension independently contributes its token use and status to `extensions/session-footer.ts`, the package's generic composable footer. When subagents are involved, the right side of the footer's second row shows their aggregate status with the same in-progress, completed, failed, or stopped icon used by the manager. With an empty editor, press Option+Down (Alt+Down) to select that summary and Enter to open the manager; `/subagents` opens it directly. The manager shows individual status and transcripts and supports model, effort, messaging, and termination controls. Run `/subagents-cleanup` to stop and remove every retained subagent. ## Auto model routing @@ -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. The right side of the TUI footer's first row mirrors that selection before the runtime detailsβ€”`Auto (auto) β€’ (provider) model β€’ effort` for the adaptive entry, or `Auto () β€’ (provider) model β€’ effort` for a pinned oneβ€”without a separate Auto Router icon. The Auto label reflects the selection, 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. @@ -124,7 +124,7 @@ The bundled Vite/React app provides a shadcn/ui-style session shell with: - Fork-point selection from the session's real user-message entries - Optional Tailscale Serve publishing for HTTPS access from authorized tailnet identities -A linked `🌐` appears at the far left of Pi's first footer line, immediately before the directory. Click it to open that session directly, or run `/web` to display its URL. +A linked `⧉` appears at the far left of Pi's first footer line, immediately before the directory. Click it to open that session directly, or run `/web` to display its URL. The server is intentionally tokenless so installed iOS home-screen links remain stable. It binds only to localhost unless explicitly published through Tailscale Serve. Local machine users are therefore inside the trust boundary; remote access relies on Tailscale Service grants, which must be limited to trusted identities. Browser WebSockets also require an exact same-host `Origin`, preventing unrelated websites from driving shell-capable sessions. Do not expose the localhost port with a generic reverse proxy or Tailscale Funnel. @@ -134,7 +134,7 @@ Browser-created sessions use Pi's RPC mode, while native Pi processes keep their ### Tailscale -If Tailscale is installed and connected, opt into tailnet-only publishing with `/web-tailscale on`. The running server immediately configures Tailscale Serve to proxy its HTTPS MagicDNS address to the localhost-only backend, and future starts restore it automatically. `/web`, the footer globe, and `/web-tailscale status` then use the tailnet URL. Node-level publishing defaults to HTTPS port `8443` to avoid macOS port-443 conflicts. +If Tailscale is installed and connected, opt into tailnet-only publishing with `/web-tailscale on`. The running server immediately configures Tailscale Serve to proxy its HTTPS MagicDNS address to the localhost-only backend, and future starts restore it automatically. `/web`, the footer link, and `/web-tailscale status` then use the tailnet URL. Node-level publishing defaults to HTTPS port `8443` to avoid macOS port-443 conflicts. The equivalent global Pi setting in `~/.pi/agent/settings.json` is: diff --git a/extensions/auto-router.ts b/extensions/auto-router.ts index 5574b16..943bd44 100644 --- a/extensions/auto-router.ts +++ b/extensions/auto-router.ts @@ -14,6 +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, + lastAutoRoutedModelFromEntries, +} from "../web/model-status.js"; import { classifyTurnComplexity } from "./auto-router-classify.js"; import { AutoRouterHealthStore, @@ -42,9 +46,11 @@ 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"; +const AUTO_ACTIVE_ENTRY_TYPE = AUTO_ROUTER_ACTIVE_ENTRY; const FOOTER_KEY = "auto-router"; const PINNED_MODEL_PREFIX = `${AUTO_MODEL_ID}-`; @@ -134,7 +140,7 @@ function formatTier(tier: AutoRouterEffortLevel): string { * changed. `/usage` is the place to see what actually got routed to. */ function footerBadge(pinnedTier: AutoRouterEffortLevel | undefined): string { - return `πŸ”€ Auto (${pinnedTier ?? "auto"})`; + return `Auto (${pinnedTier ?? "auto"})`; } /** A registered-but-inert `/model` entry: never actually dispatched to, since `before_agent_start` always swaps in a real routed model first. */ @@ -176,14 +182,33 @@ 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(); + let compactionLease = false; + let preflightPromptRouted = false; + let postTurnCompactionRoutePending = false; + let postTurnCompactionPreviousRoute: string | undefined; 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, { sessionId: currentSessionId, key: FOOTER_KEY, - identitySuffix: (theme: Theme) => theme.fg("accent", footerBadge(pinnedTier)), + modelPrefix: (theme: Theme) => theme.fg("accent", footerBadge(pinnedTier)), } satisfies FooterContribution); } @@ -202,12 +227,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 +415,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 +467,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( @@ -430,7 +478,7 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { ctx: ExtensionContext, prompt: string, hasImages: boolean, - ): Promise { + ): Promise { const settings = await readAutoRouterSettings(); let tier: AutoRouterEffortLevel; @@ -504,8 +552,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. @@ -516,9 +565,55 @@ 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 + // 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; + pinnedTier = tierFromModelId(ctx.model.id); + pi.appendEntry(AUTO_ACTIVE_ENTRY_TYPE, { enabled: true, pinnedTier }); + publishFooter(); + } + try { + 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( + error instanceof Error ? error.message : String(error), + "error", + ); + } + // 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" }; + } + }); + async function reconcileAllProviders( modelRegistry: ModelRegistry, settings: AutoRouterSettings, @@ -552,11 +647,33 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { ) return; if (action === "route") { - 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; } - 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) => { @@ -581,6 +698,10 @@ export default async function autoRouter(pi: ExtensionAPI): Promise { pi.on("session_start", async (_event, ctx) => { currentSessionId = ctx.sessionManager.getSessionId(); 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. @@ -598,6 +719,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. @@ -611,13 +737,93 @@ 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 + ) { + 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), + "error", + ); + } + } + } + }); + + 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) return; + 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); }); + 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; + commitPostTurnCompactionRoute(); + }); + + pi.on("session_shutdown", async () => { + compactionLease = false; + routingInFlight = false; + preflightPromptRouted = false; + commitPostTurnCompactionRoute(); + }); + 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/footer-events.ts b/extensions/footer-events.ts index 0495926..9ff2b7d 100644 --- a/extensions/footer-events.ts +++ b/extensions/footer-events.ts @@ -25,7 +25,10 @@ export type FooterContribution = { identityPrefix?: (theme: Theme) => string | undefined; /** Rendered inline after the branch/session identity on the first footer line. */ identitySuffix?: (theme: Theme) => string | undefined; - topRight?: (theme: Theme) => string | undefined; + /** Rendered before the active model on the right of the first footer line. */ + modelPrefix?: (theme: Theme) => string | undefined; + /** Rendered on the right of the usage statistics on the second footer line. */ + statsRight?: (theme: Theme) => string | undefined; status?: { text: string; selected?: boolean; @@ -57,7 +60,12 @@ export function parseFooterContribution( typeof event.identitySuffix !== "function" ) return undefined; - if (event.topRight !== undefined && typeof event.topRight !== "function") + if ( + event.modelPrefix !== undefined && + typeof event.modelPrefix !== "function" + ) + return undefined; + if (event.statsRight !== undefined && typeof event.statsRight !== "function") return undefined; if ( event.onBranchChange !== undefined && 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/extensions/pr-footer.ts b/extensions/pr-footer.ts index c86e611..cee7fa8 100644 --- a/extensions/pr-footer.ts +++ b/extensions/pr-footer.ts @@ -161,7 +161,7 @@ export default function prFooter(pi: ExtensionAPI): void { const contribution: FooterContribution = { sessionId: currentSessionId, key: "pull-request", - topRight: current + identitySuffix: current ? (theme) => renderPullRequest(current, theme) : undefined, onBranchChange: () => { diff --git a/extensions/session-footer.ts b/extensions/session-footer.ts index 4481e99..91490c8 100644 --- a/extensions/session-footer.ts +++ b/extensions/session-footer.ts @@ -64,8 +64,28 @@ 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) return truncateToWidth(right, 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( + leftWidth, + Math.max(1, Math.floor(width * 0.4)), + ); + const fittedLeft = truncateToWidth(left, leftBudget, "..."); + const fittedLeftWidth = visibleWidth(fittedLeft); + const rightBudget = Math.max(0, width - fittedLeftWidth - 1); + if (rightBudget === 0) return truncateToWidth(fittedLeft, width, ""); + 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 = maxLeftWidth > 0 ? truncateToWidth(left, maxLeftWidth, "...") : ""; @@ -147,7 +167,7 @@ export default function sessionFooter(pi: ExtensionAPI): void { } }) .join(" "); - if (identitySuffix) cwd += ` ${identitySuffix}`; + if (identitySuffix) cwd += ` β€’ ${identitySuffix}`; const totals: UsageTotals = { input: 0, @@ -203,11 +223,22 @@ export default function sessionFooter(pi: ExtensionAPI): void { if (ctx.model && footerData.getAvailableProviderCount() > 1) model = `(${ctx.model.provider}) ${model}`; - const topRight = Array.from(contributions.entries()) + const modelPrefix = Array.from(contributions.entries()) .sort(([a], [b]) => a.localeCompare(b)) .flatMap(([, contribution]) => { try { - const rendered = contribution.topRight?.(theme); + const rendered = contribution.modelPrefix?.(theme); + return rendered ? [rendered] : []; + } catch { + return []; + } + }) + .join(theme.fg("dim", " β€’ ")); + const statsRight = Array.from(contributions.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .flatMap(([, contribution]) => { + try { + const rendered = contribution.statsRight?.(theme); return rendered ? [rendered] : []; } catch { return []; @@ -217,15 +248,15 @@ export default function sessionFooter(pi: ExtensionAPI): void { const cwdLine = identityPrefix ? `${identityPrefix} ${theme.fg("dim", cwd)}` : theme.fg("dim", cwd); + const modelLine = modelPrefix + ? `${modelPrefix}${theme.fg("dim", " β€’ ")}${theme.fg("dim", model)}` + : theme.fg("dim", model); + const statsLine = theme.fg("dim", stats.join(" ")); const lines = [ - topRight - ? alignSides(cwdLine, topRight, width) - : truncateToWidth(cwdLine, width, theme.fg("dim", "...")), - alignSides( - theme.fg("dim", stats.join(" ")), - theme.fg("dim", model), - width, - ), + alignSides(cwdLine, modelLine, width), + statsRight + ? alignSides(statsLine, statsRight, width) + : truncateToWidth(statsLine, width, theme.fg("dim", "...")), ]; const contributionStatuses = Array.from(contributions.entries()) diff --git a/extensions/subagents/format.ts b/extensions/subagents/format.ts index 6917761..ee2ccb8 100644 --- a/extensions/subagents/format.ts +++ b/extensions/subagents/format.ts @@ -84,6 +84,44 @@ export function statusColor( } } +export function subagentFooterSummary( + statuses: Iterable, +): { text: string; status: SubagentStatus } | undefined { + let total = 0; + let working = 0; + let completed = 0; + let failed = 0; + let terminated = 0; + for (const status of statuses) { + total++; + if ( + status === "creating" || + status === "working" || + status === "terminating" + ) + working++; + else if (status === "completed") completed++; + else if (status === "failed") failed++; + else terminated++; + } + if (total === 0) return undefined; + const parts = [`${total} subagent${total === 1 ? "" : "s"}`]; + if (working) parts.push(`${working} working`); + if (completed) parts.push(`${completed} done`); + if (failed) parts.push(`${failed} failed`); + if (terminated) parts.push(`${terminated} stopped`); + return { + text: parts.join(" β€’ "), + status: failed + ? "failed" + : working + ? "working" + : terminated + ? "terminated" + : "completed", + }; +} + export function truncateToolOutput(text: string): string { const buffer = Buffer.from(text, "utf8"); if (buffer.length <= MAX_TOOL_OUTPUT_BYTES) return text; diff --git a/extensions/subagents/manager.ts b/extensions/subagents/manager.ts index eac8d44..0d141b6 100644 --- a/extensions/subagents/manager.ts +++ b/extensions/subagents/manager.ts @@ -27,8 +27,10 @@ import { formatTokens, modelName, sanitizeName, + statusColor, statusIcon, stringifyCompact, + subagentFooterSummary, truncateChars, truncateToolOutput, } from "./format.js"; @@ -921,33 +923,6 @@ export class SubagentManager { return usage; } - private footerText(): string | undefined { - if (this.agents.size === 0) return undefined; - let working = 0; - let completed = 0; - let failed = 0; - let terminated = 0; - for (const agent of this.agents.values()) { - if ( - agent.status === "creating" || - agent.status === "working" || - agent.status === "terminating" - ) - working++; - else if (agent.status === "completed") completed++; - else if (agent.status === "failed") failed++; - else terminated++; - } - const parts = [ - `β—† ${this.agents.size} subagent${this.agents.size === 1 ? "" : "s"}`, - ]; - if (working) parts.push(`${working} working`); - if (completed) parts.push(`${completed} done`); - if (failed) parts.push(`${failed} failed`); - if (terminated) parts.push(`${terminated} stopped`); - return parts.join(" β€’ "); - } - private publishWebStatus(): void { this.webStatusPublishTimer = undefined; const ctx = this.currentContext; @@ -971,12 +946,27 @@ export class SubagentManager { const usage = asFooterUsage( subtractUsage(this.totalUsage, this.accountedUsage), ); - const statusText = this.footerText(); + const footerSummary = subagentFooterSummary( + Array.from(this.agents.values(), (agent) => agent.status), + ); const contribution: FooterContribution = { sessionId, key: "subagents", - status: statusText - ? { text: statusText, selected: this.footerSelected } + statsRight: footerSummary + ? (theme) => { + const icon = theme.fg( + statusColor(footerSummary.status), + statusIcon(footerSummary.status), + ); + const text = theme.fg( + this.footerSelected ? "accent" : "dim", + footerSummary.text, + ); + const rendered = `${icon} ${text}`; + return this.footerSelected + ? theme.bg("selectedBg", rendered) + : rendered; + } : undefined, usage, }; diff --git a/extensions/web-sessions.ts b/extensions/web-sessions.ts index a722384..faccc7e 100644 --- a/extensions/web-sessions.ts +++ b/extensions/web-sessions.ts @@ -9,6 +9,15 @@ import { type Theme, } from "@earendil-works/pi-coding-agent"; import { agentEndTerminalNotice } from "../web/assistant-message.js"; +import { + applyRuntimeModelStatus, + isAutoModelReference, + isAutoRuntimeModelSwap, + lastAutoRoutedModelFromEntries, + selectedAutoModelFromEntries, + selectedModelReference, + webModelReference, +} from "../web/model-status.js"; import { WEB_COMPACT_COMMAND, WEB_COMPACT_EXTENSION_COMMAND, @@ -36,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, @@ -80,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( @@ -243,6 +258,10 @@ type BridgeState = { reconnectTimer?: ReturnType; reconnectAttempt: number; 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; }; @@ -255,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); }, @@ -285,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({ @@ -570,8 +591,11 @@ function hyperlink(url: string, label: string): string { return `\x1b]8;;${url}\x1b\\${label}\x1b]8;;\x1b\\`; } -function renderGlobe(theme: Theme, url: string): string { - return hyperlink(url, theme.fg("accent", "🌐")); +function renderWebLink(theme: Theme, url: string): string { + // 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 { @@ -580,7 +604,7 @@ function publishFooter(pi: ExtensionAPI, state: BridgeState): void { sessionId: state.session.id, key: FOOTER_KEY, identityPrefix: server - ? (theme) => renderGlobe(theme, sessionUrl(server, state.session.id)) + ? (theme) => renderWebLink(theme, sessionUrl(server, state.session.id)) : undefined, onBranchChange: () => { void refreshGitMetadata(pi, state); @@ -922,7 +946,14 @@ async function executeAgentCommand( 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}`, + lastModel: null, + }); respond(state, requestId, true); return; } @@ -1247,6 +1278,10 @@ function makeSession( branch, model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined, thinkingLevel: ctx.thinkingLevel, + selectedModel: + selectedAutoModelFromEntries(entries) ?? + (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined), + lastModel: lastAutoRoutedModelFromEntries(entries), status: statusForContext(ctx), source: "tui", createdAt: header ? Date.parse(header.timestamp) || Date.now() : Date.now(), @@ -1262,6 +1297,33 @@ 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" && action !== "discard") || + !ctx || + !bridge || + bridge.closed || + ctx.sessionManager.getSessionId() !== bridge.session.id + ) + 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 // skill invocations as user-authored text so the agent follows the advertised // progressive-disclosure contract and loads SKILL.md with read when needed. @@ -1338,6 +1400,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; + }; + // Managed RPC sessions use this private command so compaction can route // through Auto before the core RPC compact path resolves model auth. pi.registerCommand(WEB_COMPACT_EXTENSION_COMMAND, { @@ -1575,6 +1648,8 @@ export default function webSessions(pi: ExtensionAPI): void { closed: false, reconnectAttempt: 0, pending: [], + autoTurnRouting: false, + autoRuntimeRouting: false, metrics: { usage: session.usage, contextUsage: session.contextUsage }, sourceReplacement, }; @@ -1626,19 +1701,48 @@ export default function webSessions(pi: ExtensionAPI): void { } }); + pi.on("before_agent_start", (_event, ctx) => { + 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) - updateSession(bridge, { - model: `${event.model.provider}/${event.model.id}`, + const activeBridge = activeBridgeFor(ctx); + if (activeBridge) { + const runtimeModel = webModelReference(event.model); + const previousModel = event.previousModel + ? webModelReference(event.previousModel) + : undefined; + 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, + ctx.thinkingLevel, + autoRoute, + ); + if (!autoRoute) activeBridge.autoTurnRouting = false; + updateSession(activeBridge, { + ...next, + lastModel: next.lastModel, }); + } 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")); @@ -1650,14 +1754,29 @@ export default function webSessions(pi: ExtensionAPI): void { forward(event, ctx, status); }); pi.on("agent_settled", (event, ctx) => { - if (bridge?.session.compaction) { - endBridgeCompaction(bridge, { + const activeBridge = activeBridgeFor(ctx); + if (activeBridge) { + activeBridge.autoTurnRouting = false; + activeBridge.autoRuntimeRouting = false; + const selectedModel = selectedModelReference(activeBridge.session); + if ( + isAutoModelReference(selectedModel) && + activeBridge.session.model !== selectedModel + ) + updateSession(activeBridge, { model: selectedModel }); + } + 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/package.json b/package.json index 178073e..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", diff --git a/tests/auto-router-extension.test.ts b/tests/auto-router-extension.test.ts index b979dba..df55073 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(): 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(): FakePi { }, setModel: async (m: Model) => { setModelCalls.push(m); - currentModel.value = m; - return true; + 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); @@ -150,8 +158,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 { identitySuffix?: (theme: Theme) => string | undefined } | undefined; - return last?.identitySuffix?.(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 = { @@ -240,7 +254,7 @@ test("selecting Auto marks it active without eagerly routing, showing the adapti expect(fake.setModelCalls).toEqual([]); expect(fake.thinkingLevelCalls).toEqual([]); - expect(lastFooterBadge(fake.footerEvents)).toBe("πŸ”€ Auto (auto)"); + expect(lastFooterBadge(fake.footerEvents)).toBe("Auto (auto)"); }); test("Auto routes manual compaction away from its inert placeholder and restores it afterward", async () => { @@ -276,6 +290,321 @@ 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("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("discards an unused post-turn compaction 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, + }); + await selectAuto(fake, ctx); + + 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 () => { + 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); + await fake.fire("before_agent_start", { prompt: "next prompt" }, ctx); + + expect(fake.currentModel.value).toBe(a); + expect(fake.setModelCalls).toEqual([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: "medium-model" }] }, + high: { models: [{ provider: "prov", id: "high-model" }] }, + }, + }); + + const fake = createFakePi({ + setModelResult: (selected) => selected !== high, + }); + await autoRouter(fake.pi); + fake.currentModel.value = AUTO_PLACEHOLDER; + const ctx = fakeCtx({ + modelRegistry: fakeModelRegistry({ + models: [medium, high], + classify: () => "high", + }), + 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.setModelCalls).toEqual([high]); + 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({ + 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" }] } } }); @@ -288,7 +617,7 @@ test("selecting a pinned Auto () entry shows that tier in the footer immed await selectPinned(fake, ctx, "high"); expect(fake.setModelCalls).toEqual([]); - expect(lastFooterBadge(fake.footerEvents)).toBe("πŸ”€ Auto (high)"); + expect(lastFooterBadge(fake.footerEvents)).toBe("Auto (high)"); }); test("a pinned Auto () entry routes directly within that tier, skipping classification entirely", async () => { @@ -366,7 +695,7 @@ test("session_start restores a pinned tier from a persisted entry and reverts to expect(fake.setModelCalls).toEqual([pinnedPlaceholder("xhigh")]); expect(ctx.model).toEqual(pinnedPlaceholder("xhigh")); - expect(lastFooterBadge(fake.footerEvents)).toBe("πŸ”€ Auto (xhigh)"); + expect(lastFooterBadge(fake.footerEvents)).toBe("Auto (xhigh)"); }); test("before_agent_start self-heals into the correct pinned tier when ctx.model is already that pinned placeholder", async () => { @@ -413,7 +742,7 @@ test("before_agent_start routes to the classified tier, and the picker shows Aut expect(fake.thinkingLevelCalls).toEqual(["high"]); // The footer badge reflects the adaptive selection itself, not which tier this particular // turn classified to - that's what /usage is for. - expect(lastFooterBadge(fake.footerEvents)).toBe("πŸ”€ Auto (auto)"); + expect(lastFooterBadge(fake.footerEvents)).toBe("Auto (auto)"); // Mid-turn, /model would show the real routed model, not "Auto". expect(ctx.model).toEqual(high); @@ -423,7 +752,7 @@ test("before_agent_start routes to the classified tier, and the picker shows Aut expect(ctx.model).toEqual(AUTO_PLACEHOLDER); expect(fake.setModelCalls.at(-1)).toEqual(AUTO_PLACEHOLDER); // ...and the footer badge is unchanged, since the selection never changed. - expect(lastFooterBadge(fake.footerEvents)).toBe("πŸ”€ Auto (auto)"); + expect(lastFooterBadge(fake.footerEvents)).toBe("Auto (auto)"); }); test("before_agent_start notifies when the classifier gives no usable answer, instead of silently defaulting", async () => { @@ -479,7 +808,7 @@ test("a model's `effort` override sets its own thinking level, independent of th // The footer badge still just says "Auto (auto)" - the adaptive selection, not the tier or // effort this turn happened to land on (that mismatch, e.g. "Auto (max)" next to a model // actually running at a lower effort, is exactly what this badge no longer claims). - expect(lastFooterBadge(fake.footerEvents)).toBe("πŸ”€ Auto (auto)"); + expect(lastFooterBadge(fake.footerEvents)).toBe("Auto (auto)"); await fake.runCommand("usage", "", ctx); const notified = ctx.notifications.at(-1)?.message ?? ""; @@ -913,7 +1242,7 @@ test("deactivating Auto removes its footer badge", async () => { await fake.fire("session_start", {}, ctx); await selectAuto(fake, ctx); - expect(lastFooterBadge(fake.footerEvents)).toBe("πŸ”€ Auto (auto)"); + expect(lastFooterBadge(fake.footerEvents)).toBe("Auto (auto)"); await fake.fire("model_select", { model: manual, previousModel: a, source: "set" }, ctx); expect(fake.footerEvents.at(-1)).toMatchObject({ remove: true }); @@ -945,7 +1274,7 @@ test("session_start on a cleanly-idle Auto session leaves the placeholder select await fake.fire("session_start", {}, ctx); expect(fake.setModelCalls).toEqual([]); - expect(lastFooterBadge(fake.footerEvents)).toBe("πŸ”€ Auto (auto)"); + expect(lastFooterBadge(fake.footerEvents)).toBe("Auto (auto)"); }); test("a brand-new session whose defaultModel is auto/auto routes on the first turn with no prior /model pick or session entries", async () => { @@ -1017,5 +1346,5 @@ test("session_start restored mid-turn (e.g. after a crash) reverts back to the A expect(ctx.model).toEqual(AUTO_PLACEHOLDER); // Unpinned (no `pinnedTier` in the persisted entry), so the badge reflects the adaptive // selection, not `ctx.thinkingLevel` left over from whatever was mid-flight at the crash. - expect(lastFooterBadge(fake.footerEvents)).toBe("πŸ”€ Auto (auto)"); + expect(lastFooterBadge(fake.footerEvents)).toBe("Auto (auto)"); }); diff --git a/tests/session-footer.test.ts b/tests/session-footer.test.ts new file mode 100644 index 0000000..db474da --- /dev/null +++ b/tests/session-footer.test.ts @@ -0,0 +1,114 @@ +import { expect, test } from "bun:test"; +import { visibleWidth } from "@earendil-works/pi-tui"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +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 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", () => { + const hooks = new Map void>(); + const eventHandlers = new Map void>(); + let footerFactory: + | ((tui: unknown, theme: unknown, footerData: unknown) => { + render(width: number): string[]; + }) + | undefined; + const pi = { + events: { + on(name: string, handler: (value: unknown) => void) { + eventHandlers.set(name, handler); + return () => eventHandlers.delete(name); + }, + }, + on(name: string, handler: (event: unknown, ctx: unknown) => void) { + hooks.set(name, handler); + }, + } as unknown as ExtensionAPI; + + sessionFooter(pi); + const home = process.env.HOME || process.env.USERPROFILE || "/tmp"; + const ctx = { + mode: "tui", + model: { + provider: "provider", + id: "model", + reasoning: true, + contextWindow: 100_000, + }, + thinkingLevel: "high", + getContextUsage: () => ({ + tokens: 25_000, + contextWindow: 100_000, + percent: 25, + }), + sessionManager: { + getSessionId: () => "session-1", + getCwd: () => `${home}/repo`, + getSessionName: () => "Session", + getEntries: () => [], + }, + ui: { + setFooter(factory: typeof footerFactory) { + footerFactory = factory; + }, + }, + }; + hooks.get("session_start")?.({}, ctx); + + const emit = (contribution: FooterContribution) => + eventHandlers.get(FOOTER_CONTRIBUTION_EVENT)?.(contribution); + emit({ + sessionId: "session-1", + key: "web", + identityPrefix: () => "⧉ ", + }); + emit({ + sessionId: "session-1", + key: "pr", + identitySuffix: () => "PR #17", + }); + emit({ + sessionId: "session-1", + key: "auto", + modelPrefix: () => "Auto (auto)", + }); + emit({ + sessionId: "session-1", + key: "subagents", + statsRight: () => "◐ 1 subagent β€’ 1 working", + }); + + const component = footerFactory?.( + { requestRender() {} }, + { + fg: (_color: string, text: string) => text, + bg: (_color: string, text: string) => text, + }, + { + getGitBranch: () => "main", + getAvailableProviderCount: () => 2, + getExtensionStatuses: () => new Map(), + onBranchChange: () => () => undefined, + }, + ); + expect(component).toBeDefined(); + const lines = component?.render(100) ?? []; + expect(lines).toHaveLength(2); + expect(lines[0]).toStartWith("⧉ ~/repo (main) β€’ Session β€’ PR #17"); + expect(lines[0]).toEndWith( + "Auto (auto) β€’ (provider) model β€’ high", + ); + expect(lines[1]).toStartWith("25.0%/100k"); + expect(lines[1]).toEndWith("◐ 1 subagent β€’ 1 working"); +}); diff --git a/tests/subagents.test.ts b/tests/subagents.test.ts index 01590d2..30f7301 100644 --- a/tests/subagents.test.ts +++ b/tests/subagents.test.ts @@ -7,6 +7,7 @@ import { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent"; import { SubagentManager } from "../extensions/subagents/manager.ts"; import { stringifyCompact, + subagentFooterSummary, truncateChars, truncateToolOutput, } from "../extensions/subagents/format.ts"; @@ -130,6 +131,28 @@ test("compact formatting handles non-JSON values and preserves Unicode code poin assert.equal(truncateChars("aπŸ™‚b", 2), "aπŸ™‚\n[… 1 characters omitted]"); }); +test("subagent footer summary uses the modal status icon state", () => { + assert.deepEqual(subagentFooterSummary([]), undefined); + assert.deepEqual(subagentFooterSummary(["completed"]), { + text: "1 subagent β€’ 1 done", + status: "completed", + }); + assert.deepEqual( + subagentFooterSummary(["working", "completed", "terminated"]), + { + text: "3 subagents β€’ 1 working β€’ 1 done β€’ 1 stopped", + status: "working", + }, + ); + assert.deepEqual( + subagentFooterSummary(["working", "completed", "failed"]), + { + text: "3 subagents β€’ 1 working β€’ 1 done β€’ 1 failed", + status: "failed", + }, + ); +}); + test("subagent tool output truncates at a valid UTF-8 byte boundary", () => { const source = `a${"πŸ™‚".repeat(Math.ceil(MAX_TOOL_OUTPUT_BYTES / 4) + 10)}`; const result = truncateToolOutput(source); diff --git a/tests/web-model-options.test.ts b/tests/web-model-options.test.ts new file mode 100644 index 0000000..80f8ec3 --- /dev/null +++ b/tests/web-model-options.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; +import type { WebModelOption } from "../web/protocol.ts"; +import { + thinkingLevelsForSelectedModel, + visibleRoutedThinkingLevel, +} from "../web/client/model-options.ts"; + +const models: WebModelOption[] = [ + { + provider: "minimax", + id: "MiniMax-M3", + name: "MiniMax-M3", + reasoning: true, + thinkingLevels: ["off", "minimal", "low", "medium", "high"], + }, + { + provider: "openai-codex", + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + reasoning: true, + thinkingLevels: [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + ], + }, +]; + +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"), + ).toEqual(["off", "minimal", "low", "medium", "high"]); +}); + +test("effort menu does not guess when selected model metadata is missing", () => { + expect( + thinkingLevelsForSelectedModel(models, "unknown/model"), + ).toEqual([]); +}); + +test("effort menu shows no guesses while model metadata is unavailable", () => { + expect(thinkingLevelsForSelectedModel([], "minimax/MiniMax-M3")).toEqual( + [], + ); +}); diff --git a/tests/web-model-status.test.ts b/tests/web-model-status.test.ts new file mode 100644 index 0000000..811a79e --- /dev/null +++ b/tests/web-model-status.test.ts @@ -0,0 +1,292 @@ +import { expect, test } from "bun:test"; +import { + applyRuntimeModelStatus, + autoTierFromReference, + isAutoModelReference, + isAutoRuntimeModelSwap, + lastAutoRoutedModelFromEntries, + selectedAutoModelFromEntries, + selectedModelReference, +} from "../web/model-status"; + +test("reconstructs a durable Auto selection", () => { + 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([ + { + type: "custom", + customType: "vessup:auto-router:active", + data: { enabled: false }, + }, + ]), + ).toBeUndefined(); +}); + +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( + lastAutoRoutedModelFromEntries([ + { type: "model_change", provider: "auto", modelId: "auto" }, + autoEnabled, + { + 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( + 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(); +}); + +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("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([ + { + 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); + expect(isAutoModelReference("openai/gpt-5.6-luna")).toBe(false); + expect(isAutoModelReference(undefined)).toBe(false); +}); + +test("only treats an Auto placeholder transition as a routed model swap", () => { + expect( + isAutoRuntimeModelSwap( + "auto/auto", + "auto/auto", + "openai-codex/gpt-5.6-luna", + ), + ).toBe(true); + expect( + isAutoRuntimeModelSwap( + "auto/auto", + "openai-codex/gpt-5.6-luna", + "anthropic/claude-sonnet", + ), + ).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", + lastModel: "openai-codex/gpt-5.6-luna", + }); + 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("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( + { + model: "openai-codex/gpt-5.6-luna", + thinkingLevel: "high", + selectedModel: "auto/auto", + lastModel: "openai-codex/gpt-5.6-luna", + }, + "auto/auto", + "off", + false, + ), + ).toEqual({ + model: "auto/auto", + thinkingLevel: "off", + selectedModel: "auto/auto", + 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/tests/web-record-sync.test.ts b/tests/web-record-sync.test.ts new file mode 100644 index 0000000..e3e7f7b --- /dev/null +++ b/tests/web-record-sync.test.ts @@ -0,0 +1,144 @@ +import { expect, test } from "bun:test"; +import { createRecordSync } from "../web/server/recordSync.ts"; +import { mergedLastModel } from "../web/server/sessionRegistry.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("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 = { + id: "session-1", + model: "openai-codex/routed", + selectedModel: "auto/auto", + thinkingLevel: "high", + modelTurnGeneration: 2, + autoTurnActive: true, + autoTurnSettling: false, + status: "working", + name: "before", + } as unknown as SessionRecord; + + sync.updateRecordFromState( + record, + { + model: { provider: "auto", id: "auto" }, + thinkingLevel: "off", + isCompacting: false, + isStreaming: false, + sessionName: "after", + }, + 1, + ); + + expect(record).toMatchObject({ + model: "openai-codex/routed", + selectedModel: "auto/auto", + thinkingLevel: "high", + modelTurnGeneration: 2, + autoTurnActive: true, + status: "working", + name: "after", + }); +}); 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/app.tsx b/web/client/app.tsx index bccbdc2..a38e042 100644 --- a/web/client/app.tsx +++ b/web/client/app.tsx @@ -354,6 +354,8 @@ function sessionMatches(session: WebSession, query: string): boolean { session.branch, session.projectName, session.model, + session.selectedModel, + session.lastModel, session.status, sessionStatusLabel(session), ].some((value) => value?.toLocaleLowerCase().includes(needle)); diff --git a/web/client/model-options.ts b/web/client/model-options.ts new file mode 100644 index 0000000..3fed862 --- /dev/null +++ b/web/client/model-options.ts @@ -0,0 +1,22 @@ +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[], + selectedModelReference: string | undefined, +): string[] { + if (!selectedModelReference) return []; + + const selectedModel = models.find( + (model) => `${model.provider}/${model.id}` === selectedModelReference, + ); + return selectedModel?.thinkingLevels + ? [...selectedModel.thinkingLevels] + : []; +} diff --git a/web/client/semantic-session.tsx b/web/client/semantic-session.tsx index 27a3e97..e7a68b8 100644 --- a/web/client/semantic-session.tsx +++ b/web/client/semantic-session.tsx @@ -41,7 +41,6 @@ import { CheckCircle2, ChevronDown, ChevronRight, - CircleGauge, FilePenLine, FileText, FileUp, @@ -87,6 +86,7 @@ import { type SemanticImage, type WebQueuedMessage, type WebQueueReplacement, + type WebModelOption, type WebSession, type WebSessionOptions, type WebSlashCommand, @@ -110,8 +110,17 @@ import { TooltipProvider, TooltipTrigger, } from "./components/ui/tooltip"; +import { + autoTierFromReference, + isAutoModelReference, + selectedModelReference, +} from "../model-status"; import { assertClientPromptPayloadFits } from "./image-payload"; import { cn } from "./lib/utils"; +import { + thinkingLevelsForSelectedModel, + visibleRoutedThinkingLevel, +} from "./model-options"; import { anchoredScrollTop, resolveScrollFollow } from "./scroll-follow"; import { hasActiveSessionWork } from "./session-status"; import { toolHasArgumentDetails } from "./tool-expansion"; @@ -598,6 +607,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`; @@ -649,9 +664,15 @@ function combinedUsage( }; } -function TokenDetails({ session }: { session: WebSession }) { - const usage = combinedUsage(session.usage, session.subagentUsage); - const context = session.contextUsage; +function TokenDetails({ + session, + includeContext = true, +}: { + session: WebSession | null; + includeContext?: boolean; +}) { + const usage = combinedUsage(session?.usage, session?.subagentUsage); + const context = session?.contextUsage; return (
@@ -674,19 +695,21 @@ function TokenDetails({ session }: { session: WebSession }) { Cost ${(usage?.cost.total ?? 0).toFixed(3)} - {context?.contextWindow ? ( - - Context{" "} - - {context.percent == null ? "?" : `${context.percent.toFixed(1)}%`} /{" "} - {formatTokenCount(context.contextWindow)} - - - ) : ( - - Context unknown - - )} + {includeContext && + (context?.contextWindow ? ( + + Context{" "} + + {context.percent == null ? "?" : `${context.percent.toFixed(1)}%`} Β·{" "} + {context.tokens == null ? "?" : formatTokenCount(context.tokens)} /{" "} + {formatTokenCount(context.contextWindow)} + + + ) : ( + + Context unknown + + ))}
); } @@ -727,6 +750,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, ]; @@ -738,7 +762,6 @@ function sameTokenTelemetry( const ComposerTokenInfo = React.memo( function ComposerTokenInfo({ session }: { session: WebSession | null }) { - const [open, setOpen] = React.useState(false); if (!session) return null; const usage = combinedUsage(session.usage, session.subagentUsage); const context = session.contextUsage; @@ -754,29 +777,7 @@ const ComposerTokenInfo = React.memo( ? `${context.percent == null ? "?" : `${context.percent.toFixed(1)}%`}/${formatTokenCount(context.contextWindow)}` : "?/?", ].join(" "); - return ( - <> - {compact} - - - - - - - - - - - - - - ); + return {compact}; }, (previous, next) => sameTokenTelemetry(previous.session, next.session), ); @@ -789,6 +790,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 = @@ -843,15 +845,21 @@ const ContextProgressCircle = React.memo( ); return ( - + -
{compacting ? "Compacting context…" : "Context"} + {session && } {contextText}
@@ -861,13 +869,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, ); @@ -2600,7 +2602,6 @@ export function SemanticSession({ setControlBusy(true); try { await onSelectModel(provider, modelId); - setModelMenuOpen(false); setActionError(null); } catch (cause) { reportActionError(cause); @@ -2616,7 +2617,6 @@ export function SemanticSession({ setControlBusy(true); try { await onSelectThinkingLevel(level); - setModelMenuOpen(false); setActionError(null); } catch (cause) { reportActionError(cause); @@ -2628,27 +2628,64 @@ export function SemanticSession({ } }; - const modelLabel = session?.model?.split("/").pop() ?? "Model"; - const effortLabel = session?.thinkingLevel ?? "off"; - const availableModels = + const selectedModelRef = selectedModelReference(session ?? {}); + const selectedModelIdLabel = selectedModelRef?.split("/").pop() ?? "Model"; + const autoSelected = isAutoModelReference(selectedModelRef); + const autoTier = autoTierFromReference(selectedModelRef); + const fallbackModelLabel = autoSelected + ? `Auto (${autoTier ?? "auto"})` + : selectedModelIdLabel; + const rawThinkingLevel = session?.thinkingLevel; + const effortLabel = + autoSelected + ? `(${autoTier ?? "auto"})` + : rawThinkingLevel && rawThinkingLevel !== "off" + ? rawThinkingLevel + : ""; + const availableModels: WebModelOption[] = 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: fallbackModelLabel, reasoning: true, }, ]; })(); - const availableEfforts = - sessionOptions.thinkingLevels.length > 0 - ? sessionOptions.thinkingLevels - : ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; + const selectedModelOption = availableModels.find( + (model) => `${model.provider}/${model.id}` === selectedModelRef, + ); + const modelLabel = autoSelected + ? "Auto" + : (selectedModelOption?.name ?? fallbackModelLabel); + const effectiveModelReference = + autoSelected && session?.model && !isAutoModelReference(session.model) + ? session.model + : autoSelected + ? session?.lastModel + : undefined; + const routedEffortLabel = visibleRoutedThinkingLevel(rawThinkingLevel); + const turnModelSummary = + effectiveModelReference && !isAutoModelReference(effectiveModelReference) + ? routedEffortLabel + ? `${formatModelReference(effectiveModelReference)} Β· ${routedEffortLabel}` + : formatModelReference(effectiveModelReference) + : undefined; + const availableEfforts = thinkingLevelsForSelectedModel( + sessionOptions.models, + selectedModelRef, + ); + 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( @@ -3405,12 +3442,33 @@ export function SemanticSession({ variant="ghost" size="sm" disabled={controlBusy || !connected} + title={ + turnModelSummary + ? `Selected ${modelLabel}${effortLabel ? ` ${effortLabel}` : ""}; using ${turnModelSummary}` + : undefined + } onMouseDown={(event) => event.preventDefault()} onClick={() => setModelMenuOpen((open) => !open)} > - {modelLabel} - Β· - {effortLabel} + {modelLabel} + {autoSelected ? ( + {effortLabel} + ) : ( + effortLabel && ( + <> + Β· + {effortLabel} + + ) + )} + {turnModelSummary && ( + <> + β†’ + + {turnModelSummary} + + + )}
-
Model
- {availableModels.map((model) => { - const value = `${model.provider}/${model.id}`; - return ( - - ); - })} -
-
-
Effort
-
- {availableEfforts.map((level) => ( - - ))} -
+ {orderedModels.length > 0 && + orderedModels.map((model) => { + const value = `${model.provider}/${model.id}`; + return ( + + ); + })}
+ {!autoSelected && availableEfforts.length > 0 && ( +
+
+ Effort +
+
+ {availableEfforts.map((level) => ( + + ))} +
+
+ )}
diff --git a/web/client/styles.css b/web/client/styles.css index b9c3f3e..af181fb 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; @@ -1054,6 +1039,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%; @@ -1111,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; } @@ -1256,9 +1254,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; diff --git a/web/model-status.ts b/web/model-status.ts new file mode 100644 index 0000000..a0faeab --- /dev/null +++ b/web/model-status.ts @@ -0,0 +1,221 @@ +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; + /** The last concrete runtime model used for an Auto selection. */ + lastModel?: string | null; +}; + +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", + "high", + "xhigh", + "max", +]); + +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"; +} + +/** Incrementally fold durable Auto selection and routed-model transitions. */ +export function autoRoutingStateFromEntries( + entries: readonly unknown[], + 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; + 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 ( + 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 !== AUTO_ROUTER_ACTIVE_ENTRY + ) + continue; + 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; + 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; +} + +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. */ +export function webModelReference(model: WebModelIdentity): string { + return `${model.provider}/${model.id}`; +} + +/** Return whether a protocol model reference names one of Auto Router's placeholders. */ +export function isAutoModelReference(reference: string | undefined): boolean { + return reference?.startsWith("auto/") === true; +} + +/** Resolve the user selection, falling back to the runtime model for old payloads. */ +export function selectedModelReference( + status: Pick, +): string | undefined { + return status.selectedModel ?? status.model; +} + +/** Identify Auto's placeholder-to-concrete runtime swap, not a later manual change. */ +export function isAutoRuntimeModelSwap( + selectedModel: string | undefined, + previousModel: string | undefined, + runtimeModel: string, +): boolean { + return ( + isAutoModelReference(selectedModel) && + isAutoModelReference(previousModel) && + !isAutoModelReference(runtimeModel) + ); +} + +/** + * 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); + + const next: WebModelStatus = { + ...status, + model: runtimeModel, + ...(runtimeThinkingLevel !== undefined + ? { thinkingLevel: runtimeThinkingLevel } + : {}), + selectedModel: preservingAutoSelection ? selectedModel : runtimeModel, + }; + if (preservingAutoSelection) next.lastModel = runtimeModel; + else if ( + !isAutoModelReference(runtimeModel) || + (isAutoModelReference(selectedModel) && selectedModel !== 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"; +} diff --git a/web/protocol.ts b/web/protocol.ts index 8eb1c26..f4064f5 100644 --- a/web/protocol.ts +++ b/web/protocol.ts @@ -148,8 +148,13 @@ 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; + /** 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/commandRouter.ts b/web/server/commandRouter.ts index 147c03c..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,26 +457,33 @@ 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, }; } case "set_model": + // A browser model change is explicit user selection, not the Auto + // router's transient runtime swap. Only invalidate Auto tracking + // after the runtime accepts the new model. await record.managed.setModel(command.provider, command.modelId); + 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 7675c5b..765e2ce 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, @@ -93,6 +94,8 @@ export function createManagedSessionLauncher(options: { const { replaceRecordHistory, appendRecordHistory } = history; const { updateRecordFromState, + beginTurnModelTracking, + finishTurnModelTracking, updateRecordFromStats, updateSubagentsFromToolEvent, } = recordSync; @@ -147,6 +150,12 @@ export function createManagedSessionLauncher(options: { name: name ?? resumed?.session.name, 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, + ), + autoTurnSettling: false, status: "starting", source: "web", createdAt: resumed?.session.createdAt ?? Date.now(), @@ -193,6 +202,35 @@ 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; + const snapshot = isRecord(state) ? state : {}; + updateRecordFromState( + record, + { + model: snapshot.model, + thinkingLevel: snapshot.thinkingLevel, + sessionName: snapshot.sessionName, + }, + generation, + ); + broadcastSessionToAll(record); + }) + .catch(() => undefined); + } + } if (event.type === "agent_end" && !record.compaction) { markAgentSettling(record); record.status = @@ -227,6 +265,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/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 a9745c1..94fe403 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"; @@ -19,18 +24,58 @@ 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") { - record.model = + if (modelStateIsCurrent && model && typeof model.id === "string") { + const runtimeModel = typeof model.provider === "string" && model.provider ? `${model.provider}/${model.id}` : model.id; - } - if (typeof s.thinkingLevel === "string") + const selectedModel = selectedModelReference(record); + const preservingAutoSelection = + record.autoTurnActive === true && + isAutoModelReference(selectedModel) && + !isAutoModelReference(runtimeModel); + // The settlement refresh can race Auto's asynchronous placeholder + // restore. While settling, retain the placeholder for any concrete + // snapshot and clear this phase only when the runtime reports Auto. + const preservingSettledAutoSelection = + record.autoTurnSettling === true && + isAutoModelReference(selectedModel) && + !isAutoModelReference(runtimeModel); + if (preservingSettledAutoSelection) { + record.model = selectedModel; + record.selectedModel = selectedModel; + record.lastModel = runtimeModel; + if (typeof s.thinkingLevel === "string") + record.thinkingLevel = s.thinkingLevel; + } else { + 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; + record.lastModel = next.lastModel; + if (record.autoTurnSettling === true) + record.autoTurnSettling = false; + } + if (!preservingAutoSelection) record.autoTurnActive = false; + } else if (modelStateIsCurrent && typeof s.thinkingLevel === "string") { record.thinkingLevel = s.thinkingLevel; + } if (typeof s.sessionFile === "string") { record.file = s.sessionFile; runtime.sessionsByFile.set(normalizePath(s.sessionFile), record); @@ -46,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"; @@ -57,6 +102,36 @@ export function createRecordSync(options: { record.updatedAt = Date.now(); } + function beginTurnModelTracking(record: SessionRecord): number { + const generation = (record.modelTurnGeneration ?? 0) + 1; + record.modelTurnGeneration = generation; + record.autoTurnSettling = false; + record.autoTurnActive = isAutoModelReference( + selectedModelReference(record), + ); + if (!record.autoTurnActive) { + record.selectedModel ??= record.model; + record.lastModel = undefined; + } + return generation; + } + + function finishTurnModelTracking(record: SessionRecord): void { + record.modelTurnGeneration = (record.modelTurnGeneration ?? 0) + 1; + const selectedModel = selectedModelReference(record); + // Keep the preservation flag through the first settlement refresh. The + // Auto extension may still be finishing its asynchronous placeholder + // restore when that get_state request is answered. + if (record.autoTurnActive && isAutoModelReference(selectedModel)) { + record.model = selectedModel; + record.autoTurnSettling = true; + return; + } + record.autoTurnActive = false; + record.autoTurnSettling = false; + record.selectedModel ??= record.model; + } + function updateRecordFromStats(record: SessionRecord, value: unknown): void { if (!isRecord(value)) return; if (isRecord(value.tokens)) { @@ -188,6 +263,8 @@ export function createRecordSync(options: { previous.branch !== next.branch || previous.model !== next.model || previous.thinkingLevel !== next.thinkingLevel || + previous.selectedModel !== next.selectedModel || + (previous.lastModel ?? undefined) !== (next.lastModel ?? undefined) || previous.status !== next.status || previous.source !== next.source || previous.messageCount !== next.messageCount || @@ -202,6 +279,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..9de60c3 100644 --- a/web/server/server-types.ts +++ b/web/server/server-types.ts @@ -45,6 +45,8 @@ export type SessionRecord = { branch?: string; model?: string; thinkingLevel?: string; + selectedModel?: string; + lastModel?: string | null; status: SessionStatus; source: SessionSource; createdAt: number; @@ -64,6 +66,12 @@ 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; + /** True until the settlement refresh observes Auto's restored placeholder. */ + autoTurnSettling?: boolean; agentStartGeneration?: number; activityGeneration?: number; settlingGeneration?: number; diff --git a/web/server/session-file-catalog.ts b/web/server/session-file-catalog.ts index d004dc1..f634850 100644 --- a/web/server/session-file-catalog.ts +++ b/web/server/session-file-catalog.ts @@ -12,6 +12,15 @@ 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 { + AUTO_ROUTER_ACTIVE_ENTRY, + type AutoRoutingState, + autoRoutingStateFromEntries, + lastAutoRoutedModelFromEntries, + lastAutoRoutedModelFromState, + selectedAutoModelFromEntries, + selectedAutoModelFromState, +} from "../model-status.js"; import { replacementFromEntries, WORKTREE_REPLACEMENT_ENTRY, @@ -46,6 +55,7 @@ export function createSessionFileCatalog(options: { parsedBytes: number; scan: SessionFileScan; metadataEntries: Record[]; + autoRoutingState: AutoRoutingState; } >(); @@ -238,7 +248,13 @@ export function createSessionFileCatalog(options: { entries: unknown[], ): Pick< WebSession, - "name" | "model" | "thinkingLevel" | "parentSession" | "messageCount" + | "name" + | "model" + | "thinkingLevel" + | "selectedModel" + | "lastModel" + | "parentSession" + | "messageCount" > { let name: string | undefined; let model: string | undefined; @@ -252,7 +268,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" @@ -261,7 +280,15 @@ 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, + selectedModel: selectedAutoModelFromEntries(entries), + lastModel: lastAutoRoutedModelFromEntries(entries), + parentSession, + messageCount, + }; } function readManagedWorktreePrefix( @@ -336,6 +363,8 @@ 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", createdAt: @@ -446,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) { @@ -459,8 +492,12 @@ 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") - model = entry.modelId; + if (entry.type === "model_change" && typeof entry.modelId === "string") { + model = + typeof entry.provider === "string" && entry.provider + ? `${entry.provider}/${entry.modelId}` + : entry.modelId; + } if ( entry.type === "thinking_level_change" && typeof entry.thinkingLevel === "string" @@ -473,6 +510,13 @@ export function createSessionFileCatalog(options: { ) { 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; @@ -502,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, @@ -510,6 +558,8 @@ export function createSessionFileCatalog(options: { name, model, thinkingLevel, + selectedModel: selectedAutoModelFromState(autoRoutingState), + lastModel: lastAutoRoutedModelFromState(autoRoutingState), status: "offline", source: isManagedSessionFile(file) ? "web" : "saved", createdAt: incremental @@ -544,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 fe202a6..7310759 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"; @@ -19,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. @@ -62,6 +77,8 @@ export function createSessionRegistry(options: { branch: session.branch, model: session.model, thinkingLevel: session.thinkingLevel, + selectedModel: session.selectedModel, + lastModel: session.lastModel, status: session.status, source: session.source, createdAt: session.createdAt, @@ -124,7 +141,9 @@ export function createSessionRegistry(options: { images: item.images?.map((image) => ({ ...image })), })), }) as SessionRecord; + const nextLastModel = mergedLastModel(record, session); Object.assign(record, session); + record.lastModel = nextLastModel; record.kind = kind; if (history.length > 0 && !existing) { record.history = displayHistory; @@ -153,7 +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); + record.lastModel = nextLastModel; record.kind = kind; if (history.length > 0) replaceRecordHistory(