Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/runtime/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
27 changes: 25 additions & 2 deletions src/runtime/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -2303,15 +2305,36 @@ 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, {
userMessage,
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;
Expand Down
5 changes: 5 additions & 0 deletions src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
44 changes: 44 additions & 0 deletions src/session/session-llm.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
53 changes: 53 additions & 0 deletions src/session/session-llm.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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,
};
}
5 changes: 5 additions & 0 deletions src/session/session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ export interface SessionState {
* webhook.
* - `ephemeralTask: true` + `scheduledBy: <sessionId>` — 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<string, unknown>;
/**
Expand Down
79 changes: 79 additions & 0 deletions src/tui/chat-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down
Loading
Loading