From 38789fb69f22b0123b862ea494e0b0a9cc1b6592 Mon Sep 17 00:00:00 2001 From: Valera Brizhatiuk <19537764+plombeer31@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:49:39 +0300 Subject: [PATCH] session: pin the provider/model per session and restore it on switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The active text provider + chat model are one global config setting, so every session silently followed whatever the operator last picked — switch from an OpenRouter/glm thread into a local-llama thread and the OpenRouter model kept serving it. - executeTurn stamps metadata.llm = { providerId, chatModel } (resolved from the live config at turn start, not the fallback chain's substitute) onto the session with its post-turn save. - The TUI also stamps the open session the moment the operator picks a model, so a choice made between turns survives switching away. - switchSession re-applies the target session's stamp when it differs from the active model, through the LLM panel's own bus actions (providers_select_chat_model / providers_set_active_text) so config persistence + provider reload + panel refresh stay in the one place that owns them. A stamped provider that was removed changes nothing and says so; sessions without a stamp keep the current model. - planModelRestore/readSessionLlmStamp are pure and unit-tested; malformed metadata degrades to "no stamp", never a crash. --- src/runtime/bootstrap.test.ts | 41 +++++++++++ src/runtime/bootstrap.ts | 27 ++++++- src/session/index.ts | 5 ++ src/session/session-llm.test.ts | 44 +++++++++++ src/session/session-llm.ts | 53 ++++++++++++++ src/session/session-state.ts | 5 ++ src/tui/chat-orchestrator.ts | 79 ++++++++++++++++++++ src/tui/session-model-restore.test.ts | 101 ++++++++++++++++++++++++++ src/tui/session-model-restore.ts | 77 ++++++++++++++++++++ 9 files changed, 430 insertions(+), 2 deletions(-) create mode 100644 src/session/session-llm.test.ts create mode 100644 src/session/session-llm.ts create mode 100644 src/tui/session-model-restore.test.ts create mode 100644 src/tui/session-model-restore.ts diff --git a/src/runtime/bootstrap.test.ts b/src/runtime/bootstrap.test.ts index aaa7121a..28023ca2 100644 --- a/src/runtime/bootstrap.test.ts +++ b/src/runtime/bootstrap.test.ts @@ -13,11 +13,17 @@ import { randomBytes } from "node:crypto"; import { createAgentRuntime, managedLocalLlmHealthFailureHint } from "./bootstrap.js"; import { + getConfig, getUserConfigPath, resetConfigCache, USER_CONFIG_DEFAULTS, writeUserConfigFileSync, } from "../config/index.js"; +import { resolveLlmConfig } from "../llm/provider/registry/index.js"; +import { + readSessionLlmStamp, + SESSION_LLM_METADATA_KEY, +} from "../session/session-llm.js"; import { buildSearchCacheKey, createPersistentSearchCache, @@ -755,6 +761,41 @@ describe("createAgentRuntime", () => { } }); + it("stamps the session with the provider/model the turn ran on", async () => { + const runtime = await createAgentRuntime({ + workingDir, + approvalLevel: 5, + overrides: { + browserBackend: backend, + skipLlamaHealthCheck: true, + llamaComplete: async () => ({ + content: JSON.stringify({ + tool: "reply", + args: { text: "hi back" }, + }), + timing: { promptTokens: 5, predictedTokens: 3 }, + slotId: 0, + cacheReused: false, + }), + }, + }); + try { + const session = runtime.createSession(); + const result = await runtime.runTurn(session, "hello", { maxSteps: 5 }); + const expected = { + providerId: resolveLlmConfig(getConfig()).activeTextProvider, + chatModel: null, + }; + // The returned state and the stored row agree, so switching back + // into this session later can restore its provider/model. + expect(result.session.metadata[SESSION_LLM_METADATA_KEY]).toEqual(expected); + const reloaded = runtime.sessionStore.load(session.id)!; + expect(readSessionLlmStamp(reloaded.metadata)).toEqual(expected); + } finally { + await runtime.shutdown(); + } + }); + it("refreshSkills rebuilds the catalog and notifies listeners", async () => { let notified: Array<{ name: string }> = []; const runtime = await createAgentRuntime({ diff --git a/src/runtime/bootstrap.ts b/src/runtime/bootstrap.ts index 143bcc05..055057b8 100644 --- a/src/runtime/bootstrap.ts +++ b/src/runtime/bootstrap.ts @@ -157,6 +157,8 @@ import type { AgentLoopEvent, RunTurnResult } from "../agent/agent-loop.js"; import { SessionStore, createEmptySessionState, + SESSION_LLM_METADATA_KEY, + type SessionLlmStamp, type SessionState, } from "../session/index.js"; @@ -2303,6 +2305,18 @@ export async function createAgentRuntime( // remaining event of the turn and any tool call whose `pendingCalls` // entry went with it is logged with empty args. activeTraceSessions.add(session.id); + // Resolved before the turn runs, from the live config: the model the + // operator chose for this turn is what the session should remember, + // not whatever the config says by the time the turn finishes — and + // deliberately not the fallback chain's emergency substitute either. + const llmResolved = resolveLlmConfig(getConfig()); + const llmEntry = llmResolved.providers.find( + (p) => p.id === llmResolved.activeTextProvider, + ); + const llmStamp: SessionLlmStamp = { + providerId: llmResolved.activeTextProvider, + chatModel: llmEntry?.defaultChatModel ?? llmEntry?.model ?? null, + }; return turnContext.run({ sessionId: session.id }, async () => { try { const result = await loop.runTurn(session, { @@ -2310,8 +2324,17 @@ export async function createAgentRuntime( maxSteps: runOptions.maxSteps ?? config.agent.maxSteps, signal: runOptions.signal ?? new AbortController().signal, }); - sessionStore.save(result.session); - return result; + // Stamp what this turn ran on so switching back into the session + // later can restore its provider/model (session-llm.ts). + const finished: SessionState = { + ...result.session, + metadata: { + ...result.session.metadata, + [SESSION_LLM_METADATA_KEY]: llmStamp, + }, + }; + sessionStore.save(finished); + return { ...result, session: finished }; } finally { activeTraceSessions.delete(session.id); // A delete that arrived mid-turn was deferred to keep the pin honest; diff --git a/src/session/index.ts b/src/session/index.ts index ed288af9..dd3d4844 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -37,3 +37,8 @@ export type { ConversationTurn, PackedConversation, } from "./conversation-turn.js"; +export { + SESSION_LLM_METADATA_KEY, + readSessionLlmStamp, +} from "./session-llm.js"; +export type { SessionLlmStamp } from "./session-llm.js"; diff --git a/src/session/session-llm.test.ts b/src/session/session-llm.test.ts new file mode 100644 index 00000000..dc3fb44f --- /dev/null +++ b/src/session/session-llm.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { readSessionLlmStamp, SESSION_LLM_METADATA_KEY } from "./session-llm.js"; + +describe("readSessionLlmStamp", () => { + it("reads a well-formed stamp", () => { + expect( + readSessionLlmStamp({ + [SESSION_LLM_METADATA_KEY]: { + providerId: "openrouter", + chatModel: "z-ai/glm-5.2", + }, + }), + ).toEqual({ providerId: "openrouter", chatModel: "z-ai/glm-5.2" }); + }); + + it("normalises a missing or empty model to null", () => { + expect( + readSessionLlmStamp({ + [SESSION_LLM_METADATA_KEY]: { providerId: "local-llama" }, + }), + ).toEqual({ providerId: "local-llama", chatModel: null }); + expect( + readSessionLlmStamp({ + [SESSION_LLM_METADATA_KEY]: { providerId: "local-llama", chatModel: "" }, + }), + ).toEqual({ providerId: "local-llama", chatModel: null }); + }); + + it("degrades malformed values to no stamp instead of crashing", () => { + // Metadata is a free-form JSON bag: old sessions, other writers and + // hand-edited stores all feed into it. + expect(readSessionLlmStamp(undefined)).toBeNull(); + expect(readSessionLlmStamp({})).toBeNull(); + expect(readSessionLlmStamp({ [SESSION_LLM_METADATA_KEY]: null })).toBeNull(); + expect(readSessionLlmStamp({ [SESSION_LLM_METADATA_KEY]: "gpt" })).toBeNull(); + expect(readSessionLlmStamp({ [SESSION_LLM_METADATA_KEY]: [] })).toBeNull(); + expect( + readSessionLlmStamp({ [SESSION_LLM_METADATA_KEY]: { providerId: "" } }), + ).toBeNull(); + expect( + readSessionLlmStamp({ [SESSION_LLM_METADATA_KEY]: { providerId: 7 } }), + ).toBeNull(); + }); +}); diff --git a/src/session/session-llm.ts b/src/session/session-llm.ts new file mode 100644 index 00000000..54128983 --- /dev/null +++ b/src/session/session-llm.ts @@ -0,0 +1,53 @@ +/** + * Which provider/model a session runs on, remembered per session. + * + * The active text provider and its default chat model are one global + * config setting, so historically every session silently followed + * whatever the operator last picked — switch from an OpenRouter thread + * into a local-llama thread and the OpenRouter model kept serving it. + * The stamp records the session's own choice in `metadata` (under + * {@link SESSION_LLM_METADATA_KEY}) so switching back into a thread can + * re-apply the provider/model it actually ran on. + * + * Written by two hands: `executeTurn` stamps the configured active + * provider/model at the start of every turn (all origins funnel through + * it), and the TUI stamps immediately when the operator picks a model + * while a session is open — so a choice made between turns is not lost + * by switching away before the next message. + */ + +/** Reserved `SessionState.metadata` key the stamp lives under. */ +export const SESSION_LLM_METADATA_KEY = "llm"; + +export interface SessionLlmStamp { + /** Config id of the text provider the session runs on. */ + providerId: string; + /** + * Chat model id on that provider, or `null` when the provider entry + * names none (a bare llama-server serves whatever it loaded). + */ + chatModel: string | null; +} + +/** + * Read the stamp back out of session metadata. Defensive on purpose: + * metadata is a free-form JSON bag that old sessions, other writers and + * hand-edited stores all feed into, so a malformed value degrades to + * "no stamp" rather than a crash or a garbage provider switch. + */ +export function readSessionLlmStamp( + metadata: Record | undefined, +): SessionLlmStamp | null { + const raw = metadata?.[SESSION_LLM_METADATA_KEY]; + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + return null; + } + const providerId = (raw as { providerId?: unknown }).providerId; + if (typeof providerId !== "string" || providerId.length === 0) return null; + const chatModel = (raw as { chatModel?: unknown }).chatModel; + return { + providerId, + chatModel: + typeof chatModel === "string" && chatModel.length > 0 ? chatModel : null, + }; +} diff --git a/src/session/session-state.ts b/src/session/session-state.ts index 83fb35c4..9ff7168f 100644 --- a/src/session/session-state.ts +++ b/src/session/session-state.ts @@ -140,6 +140,11 @@ export interface SessionState { * webhook. * - `ephemeralTask: true` + `scheduledBy: ` — stamped on * fresh sessions created by `tasks.schedule` with `newSession=true`. + * - `llm: { providerId, chatModel }` — the text provider/model this + * session runs on. Stamped by `executeTurn` at the top of every + * turn and by the TUI when the operator picks a model mid-session; + * read back on session switch to restore the session's own model. + * See `session-llm.ts`. */ metadata: Record; /** diff --git a/src/tui/chat-orchestrator.ts b/src/tui/chat-orchestrator.ts index 12c03a1c..f91cc627 100644 --- a/src/tui/chat-orchestrator.ts +++ b/src/tui/chat-orchestrator.ts @@ -8,6 +8,17 @@ import { isFailedSessionStatus, type SessionState, } from "../session/session-state.js"; +import { getConfig } from "../config/index.js"; +import { resolveLlmConfig } from "../llm/provider/registry/index.js"; +import { + readSessionLlmStamp, + SESSION_LLM_METADATA_KEY, + type SessionLlmStamp, +} from "../session/session-llm.js"; +import { + describeModelRestore, + planModelRestore, +} from "./session-model-restore.js"; import { checkForAppUpdate, runAppUpdate, canSelfUpdate } from "../update/index.js"; import { clearTtyScreen } from "./clear-tty-screen.js"; import { @@ -234,6 +245,27 @@ export class ChatOrchestrator { } this.turnEvents.record(action.sessionId, action.event); }); + // A model picked while a thread is open belongs to that thread: + // stamp it immediately so switching away before the next turn runs + // does not lose the choice. `providers_select_chat_model` carries + // both ids; `providers_set_active_text` names only the provider, + // whose current default model the config still knows (setActiveText + // does not change it, so reading here is not racing the write). + bus.subscribe((action) => { + if (action.type === "providers_select_chat_model") { + this.stampSessionModel({ + providerId: action.providerId, + chatModel: action.modelId, + }); + } else if (action.type === "providers_set_active_text") { + const resolved = resolveLlmConfig(getConfig()); + const entry = resolved.providers.find((p) => p.id === action.id); + this.stampSessionModel({ + providerId: action.id, + chatModel: entry?.defaultChatModel ?? entry?.model ?? null, + }); + } + }); this.chatPull.attach(bus); } @@ -557,6 +589,9 @@ export class ChatOrchestrator { running ? " — a turn is still running here" : "" }`, }); + // Each thread keeps the model it ran on: entering one whose stamp + // differs from the active provider/model re-applies it. + this.restoreSessionModel(loaded); // After `session_switched`, so they land in the new transcript // rather than the one that was just replaced. for (const notice of notices) this.notify(notice); @@ -572,6 +607,50 @@ export class ChatOrchestrator { } } + /** + * Re-apply the model the target session last ran on, when it differs + * from the active one (`planModelRestore` decides). Goes through the + * LLM panel's own bus actions so config persistence, provider reload + * and panel refresh all happen in the one place that already owns + * them. A stamped provider that has since been removed changes + * nothing and says so. + */ + private restoreSessionModel(loaded: SessionState): void { + const plan = planModelRestore( + readSessionLlmStamp(loaded.metadata), + resolveLlmConfig(getConfig()), + ); + const line = describeModelRestore(plan); + if (line) this.bus.emit({ type: "runtime_info", line }); + if (plan.kind === "select") { + this.bus.emit({ + type: "providers_select_chat_model", + providerId: plan.providerId, + modelId: plan.modelId, + }); + } else if (plan.kind === "activate") { + this.bus.emit({ + type: "providers_set_active_text", + id: plan.providerId, + }); + } + } + + /** + * Write the provider/model stamp onto the open session and persist + * it. No-op without a live session — the choice then simply stays the + * global default the next session inherits. + */ + private stampSessionModel(stamp: SessionLlmStamp): void { + const session = this.session; + if (!session) return; + this.session = { + ...session, + metadata: { ...session.metadata, [SESSION_LLM_METADATA_KEY]: stamp }, + }; + this.runtime.sessionStore.save(this.session); + } + /** * Re-offer the re-attached turn's buffered events to the reducer. * They are tagged with the now-visible session, so they apply; live diff --git a/src/tui/session-model-restore.test.ts b/src/tui/session-model-restore.test.ts new file mode 100644 index 00000000..74402c51 --- /dev/null +++ b/src/tui/session-model-restore.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import type { ResolvedLlmConfig } from "../llm/provider/registry/index.js"; +import { + describeModelRestore, + planModelRestore, +} from "./session-model-restore.js"; + +function resolved(overrides: Partial = {}): ResolvedLlmConfig { + return { + activeTextProvider: "openrouter", + activeEmbeddingProvider: "local-llama-embed", + toolTransport: "auto", + providers: [ + { + id: "openrouter", + kind: "openrouter", + defaultChatModel: "z-ai/glm-5.2", + }, + { id: "local-llama", kind: "llama-server" }, + { id: "aimlapi", kind: "aimlapi", model: "legacy-model" }, + ], + ...overrides, + }; +} + +describe("planModelRestore", () => { + it("does nothing without a stamp", () => { + expect(planModelRestore(null, resolved())).toEqual({ kind: "none" }); + }); + + it("does nothing when the stamp is already the active provider/model", () => { + expect( + planModelRestore( + { providerId: "openrouter", chatModel: "z-ai/glm-5.2" }, + resolved(), + ), + ).toEqual({ kind: "none" }); + }); + + it("selects the stamped model when the session ran on a different one", () => { + expect( + planModelRestore( + { providerId: "openrouter", chatModel: "another/model" }, + resolved(), + ), + ).toEqual({ + kind: "select", + providerId: "openrouter", + modelId: "another/model", + }); + }); + + it("selects across providers, falling back to the legacy `model` field", () => { + expect( + planModelRestore( + { providerId: "aimlapi", chatModel: "legacy-model" }, + resolved(), + ), + ).toEqual({ + kind: "select", + providerId: "aimlapi", + modelId: "legacy-model", + }); + }); + + it("activates a model-less provider instead of selecting", () => { + expect( + planModelRestore({ providerId: "local-llama", chatModel: null }, resolved()), + ).toEqual({ kind: "activate", providerId: "local-llama" }); + // …and stays put when that provider is already active. + expect( + planModelRestore( + { providerId: "local-llama", chatModel: null }, + resolved({ activeTextProvider: "local-llama" }), + ), + ).toEqual({ kind: "none" }); + }); + + it("reports a provider deleted since the session ran, changing nothing", () => { + const plan = planModelRestore( + { providerId: "gone", chatModel: "x/y" }, + resolved(), + ); + expect(plan).toEqual({ kind: "missing", providerId: "gone", chatModel: "x/y" }); + expect(describeModelRestore(plan)).toContain("no longer configured"); + }); + + it("describes only plans that act or warn", () => { + expect(describeModelRestore({ kind: "none" })).toBeNull(); + expect( + describeModelRestore({ + kind: "select", + providerId: "openrouter", + modelId: "another/model", + }), + ).toContain("openrouter/another/model"); + expect( + describeModelRestore({ kind: "activate", providerId: "local-llama" }), + ).toContain("local-llama"); + }); +}); diff --git a/src/tui/session-model-restore.ts b/src/tui/session-model-restore.ts new file mode 100644 index 00000000..d5e3a9d2 --- /dev/null +++ b/src/tui/session-model-restore.ts @@ -0,0 +1,77 @@ +import type { ResolvedLlmConfig } from "../llm/provider/registry/index.js"; +import type { SessionLlmStamp } from "../session/session-llm.js"; + +/** + * What switching into a session should do about the active model. + * + * Pure decision, separated from the orchestrator so the interesting + * cases (no stamp, provider deleted since, already active, model-less + * provider) are unit-testable without a bus or a config file. The + * orchestrator translates the plan into the same actions the LLM panel + * emits — `providers_select_chat_model` / `providers_set_active_text` — + * so restoring goes through the one code path that already knows how to + * persist the config and reload the provider. + */ +export type ModelRestorePlan = + /** Nothing to do: no stamp, or the stamp is already the active model. */ + | { kind: "none" } + /** The stamped provider is gone from the config; say so, change nothing. */ + | { kind: "missing"; providerId: string; chatModel: string | null } + /** Re-apply provider + chat model (the LLM panel's select-model path). */ + | { kind: "select"; providerId: string; modelId: string } + /** + * Re-apply the provider alone — the stamp names no model (e.g. a bare + * llama-server entry), so only the active-provider switch applies. + */ + | { kind: "activate"; providerId: string }; + +export function planModelRestore( + stamp: SessionLlmStamp | null, + resolved: ResolvedLlmConfig, +): ModelRestorePlan { + if (!stamp) return { kind: "none" }; + const entry = resolved.providers.find((p) => p.id === stamp.providerId); + if (!entry) { + return { + kind: "missing", + providerId: stamp.providerId, + chatModel: stamp.chatModel, + }; + } + const activeEntry = resolved.providers.find( + (p) => p.id === resolved.activeTextProvider, + ); + const activeModel = + activeEntry?.defaultChatModel ?? activeEntry?.model ?? null; + const sameProvider = stamp.providerId === resolved.activeTextProvider; + if (stamp.chatModel === null) { + // A model-less stamp asks only for the provider. When it is already + // active, whatever model it currently serves is as close to "what + // the session ran on" as the stamp can say. + return sameProvider + ? { kind: "none" } + : { kind: "activate", providerId: stamp.providerId }; + } + if (sameProvider && stamp.chatModel === activeModel) return { kind: "none" }; + return { + kind: "select", + providerId: stamp.providerId, + modelId: stamp.chatModel, + }; +} + +/** One line for the transcript describing what a plan is about to do. */ +export function describeModelRestore(plan: ModelRestorePlan): string | null { + switch (plan.kind) { + case "none": + return null; + case "missing": + return `this session last ran on "${plan.providerId}${ + plan.chatModel ? `/${plan.chatModel}` : "" + }", which is no longer configured — keeping the current model`; + case "select": + return `restoring this session's model: ${plan.providerId}/${plan.modelId}`; + case "activate": + return `restoring this session's provider: ${plan.providerId}`; + } +}