diff --git a/src/agents/session-model-ref.test.ts b/src/agents/session-model-ref.test.ts new file mode 100644 index 000000000000..3eacd13bc0ae --- /dev/null +++ b/src/agents/session-model-ref.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveSessionModelRef } from "./session-model-ref.js"; + +function modelConfig(primary: string): OpenClawConfig { + return { + agents: { + defaults: { model: { primary } }, + list: [{ id: "main", default: true }], + }, + } as OpenClawConfig; +} + +describe("resolveSessionModelRef", () => { + test("prefers a complete explicit override over runtime identity and current defaults", () => { + const resolved = resolveSessionModelRef( + modelConfig("anthropic/claude-opus-4-6"), + { + providerOverride: "openrouter", + modelOverride: "moonshotai/kimi-k2.5", + modelProvider: "openai", + model: "gpt-5.4", + }, + "main", + ); + + expect(resolved).toEqual({ provider: "openrouter", model: "moonshotai/kimi-k2.5" }); + }); + + test("uses the current agent default instead of stale runtime identity", () => { + const resolved = resolveSessionModelRef( + modelConfig("anthropic/claude-opus-4-6"), + { modelProvider: "openai", model: "gpt-5.4" }, + "main", + ); + + expect(resolved).toEqual({ provider: "anthropic", model: "claude-opus-4-6" }); + }); + + test("preserves runtime identity for legacy callers without an agent id", () => { + const resolved = resolveSessionModelRef(modelConfig("anthropic/claude-opus-4-6"), { + modelProvider: "openai", + model: "gpt-5.4", + }); + + expect(resolved).toEqual({ provider: "openai", model: "gpt-5.4" }); + }); + + test("prefers a legacy model-only override over runtime identity without an agent id", () => { + const resolved = resolveSessionModelRef(modelConfig("anthropic/claude-opus-4-6"), { + modelOverride: "claude-haiku-4-5", + modelProvider: "openai", + model: "gpt-5.4", + }); + + expect(resolved).toEqual({ provider: "anthropic", model: "claude-haiku-4-5" }); + }); + + test("resolves a legacy model-only override under the current default provider", () => { + const resolved = resolveSessionModelRef( + modelConfig("anthropic/claude-opus-4-6"), + { + modelOverride: "claude-haiku-4-5", + modelProvider: "openai", + model: "gpt-5.4", + }, + "main", + ); + + expect(resolved).toEqual({ provider: "anthropic", model: "claude-haiku-4-5" }); + }); +}); diff --git a/src/agents/session-model-ref.ts b/src/agents/session-model-ref.ts index 8c3e301c1980..29964ce471e4 100644 --- a/src/agents/session-model-ref.ts +++ b/src/agents/session-model-ref.ts @@ -36,9 +36,6 @@ export function resolveSessionModelRef( } const runtimeProvider = normalizeOptionalString(entry?.modelProvider); const runtimeModel = normalizeOptionalString(entry?.model); - if (runtimeProvider && runtimeModel) { - return { provider: runtimeProvider, model: runtimeModel }; - } const resolved = agentId ? resolveDefaultModelForAgent({ @@ -55,16 +52,16 @@ export function resolveSessionModelRef( const persisted = resolvePersistedSelectedModelRef({ defaultProvider: resolved.provider || DEFAULT_PROVIDER, - runtimeProvider, - runtimeModel, + // Runtime fields record the previous run. Agent-scoped selection must use + // current config or an explicit override; legacy callers without an agent + // still use the persisted pair as their fallback selection context. + runtimeProvider: agentId ? undefined : runtimeProvider, + runtimeModel: agentId ? undefined : runtimeModel, overrideProvider: normalizedOverride.providerOverride, overrideModel: normalizedOverride.modelOverride, allowPluginNormalization: options?.allowPluginNormalization, }); - if (persisted) { - return persisted; - } - return resolved; + return persisted ?? resolved; } export function resolveSessionModelIdentityRef( diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index e5ed9aca636f..eb40dfbc3b7c 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -8350,6 +8350,8 @@ describe("gateway agent handler chat.abort integration", () => { sessionId: "existing-session-id", model: "vision-model", modelProvider: "test", + providerOverride: "test", + modelOverride: "vision-model", }); mocks.updateSessionStore.mockResolvedValue(undefined); mocks.agentCommand.mockReturnValueOnce(new Promise(() => {})); @@ -8446,6 +8448,8 @@ describe("gateway agent handler chat.abort integration", () => { updatedAt: Date.now(), model: "vision-model", modelProvider: "test", + providerOverride: "test", + modelOverride: "vision-model", }; let deleted = false; mocks.loadSessionEntry.mockImplementation(() => ({ @@ -8514,6 +8518,8 @@ describe("gateway agent handler chat.abort integration", () => { updatedAt: Date.now(), model: "vision-model", modelProvider: "test", + providerOverride: "test", + modelOverride: "vision-model", }; let currentEntry = persistedEntry; mocks.loadSessionEntry.mockImplementation(() => ({ @@ -8585,6 +8591,8 @@ describe("gateway agent handler chat.abort integration", () => { updatedAt: Date.now(), modelProvider: "test", model: "vision-model", + providerOverride: "test", + modelOverride: "vision-model", }, canonicalKey: "global", }); diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 3095a3ff7d5e..c08831d424bf 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -4810,6 +4810,8 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.sessionEntry = { modelProvider: "test-provider", model: "vision-model", + providerOverride: "test-provider", + modelOverride: "vision-model", }; mockState.modelCatalog = [ { @@ -5190,6 +5192,8 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.sessionEntry = { modelProvider: "modelscope", model: "Qwen/Qwen3.5-35B-A3B", + providerOverride: "modelscope", + modelOverride: "Qwen/Qwen3.5-35B-A3B", }; mockState.modelCatalog = [ { @@ -6115,6 +6119,8 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.sessionEntry = { modelProvider: "test-provider", model: "vision-model", + providerOverride: "test-provider", + modelOverride: "vision-model", }; mockState.modelCatalog = [ { diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index f70629bcf24b..71f66560af4f 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -1428,6 +1428,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { key: created.key, sessionId: created.entry.sessionId, entry: created.entry, + resolved: created.resolved, runStarted: false, ...(createdWorktree ? { worktree: createdWorktree } : {}), }, @@ -1459,6 +1460,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { ...(runPayload ? runPayload : {}), ...(runStarted && typeof messageSeq === "number" ? { messageSeq } : {}), ...(runError ? { runError } : {}), + resolved: created.resolved, ...(createdWorktree ? { worktree: createdWorktree } : {}), }, undefined, @@ -2255,7 +2257,11 @@ export const sessionsHandlers: GatewayRequestHandlers = { respond(false, undefined, result.error); return; } - respond(true, { ok: true, key: result.key, entry: result.entry }, undefined); + respond( + true, + { ok: true, key: result.key, entry: result.entry, resolved: result.resolved }, + undefined, + ); emitSessionsChanged(context, { sessionKey: result.key, ...(result.key === "global" ? { agentId: result.agentId } : {}), diff --git a/src/gateway/server-methods/tools-effective.global-agent.integration.test.ts b/src/gateway/server-methods/tools-effective.global-agent.integration.test.ts index c2afd6c9ed4a..0b8b7be7012b 100644 --- a/src/gateway/server-methods/tools-effective.global-agent.integration.test.ts +++ b/src/gateway/server-methods/tools-effective.global-agent.integration.test.ts @@ -75,6 +75,7 @@ describe("tools.effective global agent integration", () => { const storeTemplate = path.join(dir, "{agentId}", "sessions.json"); testState.sessionStorePath = storeTemplate; testState.sessionConfig = { scope: "global" }; + testState.agentConfig = undefined; testState.agentsConfig = { list: [{ id: "main", default: true }, { id: "work" }] }; mainStorePath = storeTemplate.replace("{agentId}", "main"); workStorePath = storeTemplate.replace("{agentId}", "work"); @@ -107,6 +108,8 @@ describe("tools.effective global agent integration", () => { global: sessionStoreEntry("sess-work-global", { modelProvider: "openai", model: "work-model", + providerOverride: "openai", + modelOverride: "work-model", }), }, }); @@ -134,6 +137,59 @@ describe("tools.effective global agent integration", () => { ); }); + it("uses the hot-reloaded agent default instead of stale runtime identity", async () => { + const configModule = await getGatewayConfigModule(); + testState.agentConfig = { model: { primary: "openai/stale-model" } }; + configModule.clearRuntimeConfigSnapshot(); + configModule.clearConfigCache(); + getRuntimeConfig = configModule.getRuntimeConfig; + await writeSessionStore({ + storePath: workStorePath, + agentId: "work", + entries: { + global: sessionStoreEntry("sess-work-global", { + modelProvider: "openai", + model: "stale-model", + }), + }, + }); + + const requestTools = async (id: string) => { + const respond = vi.fn(); + await toolsEffectiveHandlers["tools.effective"]({ + params: { sessionKey: "global", agentId: "work" }, + respond: respond as never, + context: { getRuntimeConfig } as never, + client: null, + req: { type: "req", id, method: "tools.effective" }, + isWebchatConnect: () => false, + }); + expect(respond.mock.calls[0]?.[0]).toBe(true); + }; + + await requestTools("req-tools-effective-before-reload"); + expect(inventoryMocks.resolveEffectiveToolInventory).toHaveBeenLastCalledWith( + expect.objectContaining({ + modelProvider: "openai", + modelId: "stale-model", + }), + ); + + testState.agentConfig = { model: { primary: "anthropic/current-model" } }; + configModule.clearRuntimeConfigSnapshot(); + configModule.clearConfigCache(); + await requestTools("req-tools-effective-after-reload"); + + expect(inventoryMocks.resolveEffectiveToolInventory).toHaveBeenLastCalledWith( + expect.objectContaining({ + agentId: "work", + sessionKey: "global", + modelProvider: "anthropic", + modelId: "current-model", + }), + ); + }); + // Negative control on the real session-resolution path: a non-global key owned // by `main` must keep rejecting a mismatched configured agent. Before the // ownership-narrowing fix the requested agent overrode session-agent resolution diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index 2251a7edd664..0457b601f04e 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -326,7 +326,7 @@ test("sessions.create reset-in-place persists the returned worktree cwd", async const previousStateDir = process.env.OPENCLAW_STATE_DIR; process.env.OPENCLAW_STATE_DIR = path.join(root, "state"); closeOpenClawStateDatabaseForTest(); - testState.agentConfig = { workspace }; + testState.agentConfig = { workspace, model: { primary: "openai/current-model" } }; testState.sessionConfig = { dmScope: "main" }; const { storePath } = await createSessionStoreDir(); await writeSessionStore({ entries: { main: sessionStoreEntry("sess-reset-parent") } }); @@ -335,6 +335,7 @@ test("sessions.create reset-in-place persists the returned worktree cwd", async const created = await directSessionReq<{ key: string; entry: { spawnedCwd?: string }; + resolved: { modelProvider?: string; model?: string }; worktree: { id: string; path: string; branch: string }; }>( "sessions.create", @@ -349,6 +350,10 @@ test("sessions.create reset-in-place persists the returned worktree cwd", async expect(created.ok).toBe(true); expect(created.payload?.key).toBe("agent:main:main"); + expect(created.payload?.resolved).toEqual({ + modelProvider: "openai", + model: "current-model", + }); const worktree = created.payload?.worktree; worktreeId = worktree?.id; expect(created.payload?.entry.spawnedCwd).toBe(worktree?.path); @@ -360,13 +365,21 @@ test("sessions.create reset-in-place persists the returned worktree cwd", async // A later plain New Chat on the same main session must leave the worktree: cwd clears // and the (clean) session worktree is lossless-removed rather than left orphaned. - const reset = await directSessionReq<{ key: string; entry: { spawnedCwd?: string } }>( + const reset = await directSessionReq<{ + key: string; + entry: { spawnedCwd?: string }; + resolved: { modelProvider?: string; model?: string }; + }>( "sessions.create", { agentId: "main", parentSessionKey: "main", emitCommandHooks: true }, { client: { connect: { scopes: ["operator.write"] } } as never }, ); expect(reset.ok).toBe(true); expect(reset.payload?.entry.spawnedCwd).toBeUndefined(); + expect(reset.payload?.resolved).toEqual({ + modelProvider: "openai", + model: "current-model", + }); expect( listRegistryWorktrees(process.env).filter( (record) => @@ -484,7 +497,7 @@ test("sessions.create stores dashboard session model and parent linkage, and cre expect(header.id).toBe(created.payload?.sessionId); }); -test("sessions.create inherits parent runtime model selection without stale context metadata", async () => { +test("sessions.create inherits explicit selection without runtime model identity", async () => { const { storePath } = await createSessionStoreDir(); await writeSessionStore({ entries: { @@ -530,6 +543,7 @@ test("sessions.create inherits parent runtime model selection without stale cont const created = await directSessionReq<{ key?: string; + resolved?: { modelProvider?: string; model?: string }; entry?: { providerOverride?: string; modelOverride?: string; @@ -562,8 +576,9 @@ test("sessions.create inherits parent runtime model selection without stale cont expect(created.payload?.entry?.modelOverride).toBe("gpt-5.5"); expect(created.payload?.entry?.modelOverrideSource).toBe("user"); expect(created.payload?.entry?.agentRuntimeOverride).toBe("codex"); - expect(created.payload?.entry?.modelProvider).toBe("codex"); - expect(created.payload?.entry?.model).toBe("gpt-5.5"); + expect(created.payload?.entry?.modelProvider).toBeUndefined(); + expect(created.payload?.entry?.model).toBeUndefined(); + expect(created.payload?.resolved).toEqual({ modelProvider: "codex", model: "gpt-5.5" }); expect(created.payload?.entry?.contextTokens).toBeUndefined(); expect(created.payload?.entry?.inputTokens).toBeUndefined(); expect(created.payload?.entry?.outputTokens).toBeUndefined(); @@ -581,15 +596,57 @@ test("sessions.create inherits parent runtime model selection without stale cont { providerOverride?: string; modelOverride?: string; + modelProvider?: string; + model?: string; parentSessionKey?: string; } >; const key = created.payload?.key as string; expect(rawStore[key]?.providerOverride).toBe("codex"); expect(rawStore[key]?.modelOverride).toBe("gpt-5.5"); + expect(rawStore[key]?.modelProvider).toBeUndefined(); + expect(rawStore[key]?.model).toBeUndefined(); expect(rawStore[key]?.parentSessionKey).toBe("agent:main:main"); }); +test("sessions.create resolves the current default instead of inherited runtime identity", async () => { + const { storePath } = await createSessionStoreDir(); + testState.agentConfig = { model: { primary: "anthropic/current-model" } }; + await writeSessionStore({ + entries: { + main: sessionStoreEntry("sess-parent-stale", { + modelProvider: "openai", + model: "stale-model", + }), + }, + }); + + const created = await directSessionReq<{ + key?: string; + resolved?: { modelProvider?: string; model?: string }; + entry?: { modelProvider?: string; model?: string }; + }>("sessions.create", { + agentId: "main", + parentSessionKey: "main", + }); + + expect(created.ok).toBe(true); + expect(created.payload?.entry?.modelProvider).toBeUndefined(); + expect(created.payload?.entry?.model).toBeUndefined(); + expect(created.payload?.resolved).toEqual({ + modelProvider: "anthropic", + model: "current-model", + }); + + const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< + string, + { modelProvider?: string; model?: string } + >; + const key = created.payload?.key as string; + expect(store[key]?.modelProvider).toBeUndefined(); + expect(store[key]?.model).toBeUndefined(); +}); + test("sessions.create accepts an explicit key for persistent dashboard sessions", async () => { await createSessionStoreDir(); diff --git a/src/gateway/server.sessions.reset-models.test.ts b/src/gateway/server.sessions.reset-models.test.ts index db5feb668359..afdca3b9161a 100644 --- a/src/gateway/server.sessions.reset-models.test.ts +++ b/src/gateway/server.sessions.reset-models.test.ts @@ -78,6 +78,7 @@ type ModelResetEntry = Pick< ResetSessionEntry, "providerOverride" | "modelOverride" | "modelOverrideSource" | "modelProvider" | "model" >; +type ResolvedSessionModel = { modelProvider: string; model: string }; type SessionEntryOverrides = NonNullable[1]>; const ownedChildMetadata = { @@ -142,17 +143,12 @@ function expectOwnedChildMetadata(entry: ResetSessionEntry | undefined, sessionF }); } -function expectModelResetFields(entry: ModelResetEntry | undefined, expected: ModelResetEntry) { - for (const key of Object.keys(expected) as Array) { - expect(entry?.[key]).toBe(expected[key]); - } -} - async function expectMainResetModelFields(params: { defaultPrimary: string; sessionId: string; entry: SessionEntryOverrides & ModelResetEntry; expected: ModelResetEntry; + expectedResolved: ResolvedSessionModel; }) { const { storePath } = await createSessionStoreDir(); testState.agentConfig = { @@ -171,16 +167,29 @@ async function expectMainResetModelFields(params: { ok: true; key: string; entry: ModelResetEntry; + resolved: ResolvedSessionModel; }>("sessions.reset", { key: "main" }); expect(reset.ok).toBe(true); - expectModelResetFields(reset.payload?.entry, params.expected); + expect(reset.payload?.resolved).toEqual(params.expectedResolved); + const selectionKeys: Array< + keyof Pick + > = ["providerOverride", "modelOverride", "modelOverrideSource"]; + for (const key of selectionKeys) { + expect(reset.payload?.entry?.[key]).toBe(params.expected[key]); + } + expect(reset.payload?.entry.modelProvider).toBe(params.expectedResolved.modelProvider); + expect(reset.payload?.entry.model).toBe(params.expectedResolved.model); const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< string, ModelResetEntry >; - expectModelResetFields(store["agent:main:main"], params.expected); + for (const key of selectionKeys) { + expect(store["agent:main:main"]?.[key]).toBe(params.expected[key]); + } + expect(store["agent:main:main"]?.modelProvider).toBeUndefined(); + expect(store["agent:main:main"]?.model).toBeUndefined(); } test("sessions.reset recomputes model from defaults instead of stale runtime model", async () => { @@ -211,6 +220,7 @@ test("sessions.reset recomputes model from defaults instead of stale runtime mod model?: string; contextTokens?: number; }; + resolved: ResolvedSessionModel; }>("sessions.reset", { key: "main" }); expect(reset.ok).toBe(true); @@ -220,6 +230,10 @@ test("sessions.reset recomputes model from defaults instead of stale runtime mod if (!sessionFile) { throw new Error("expected reset session file"); } + expect(reset.payload?.resolved).toEqual({ + modelProvider: "openai", + model: "gpt-test-a", + }); expect(reset.payload?.entry.modelProvider).toBe("openai"); expect(reset.payload?.entry.model).toBe("gpt-test-a"); expect(reset.payload?.entry.contextTokens).toBeUndefined(); @@ -436,9 +450,8 @@ test("sessions.reset preserves legacy explicit model overrides without modelOver providerOverride: "anthropic", modelOverride: "claude-opus-4-1", modelOverrideSource: "user", - modelProvider: "anthropic", - model: "claude-opus-4-1", }, + expectedResolved: { modelProvider: "anthropic", model: "claude-opus-4-1" }, }); }); @@ -457,9 +470,8 @@ test("sessions.reset clears fallback-pinned model overrides and restores the sel expected: { providerOverride: undefined, modelOverride: undefined, - modelProvider: "openai", - model: "gpt-test-a", }, + expectedResolved: { modelProvider: "openai", model: "gpt-test-a" }, }); }); @@ -478,9 +490,8 @@ test("sessions.reset follows the updated default after an auto fallback pinned a expected: { providerOverride: undefined, modelOverride: undefined, - modelProvider: "openai", - model: "gpt-test-c", }, + expectedResolved: { modelProvider: "openai", model: "gpt-test-c" }, }); }); diff --git a/src/gateway/session-create-service.ts b/src/gateway/session-create-service.ts index fdc1b107eb20..97502feb6e9c 100644 --- a/src/gateway/session-create-service.ts +++ b/src/gateway/session-create-service.ts @@ -15,6 +15,7 @@ import { } from "../agents/agent-scope.js"; import { isEmbeddedAgentRunActive } from "../agents/embedded-agent.js"; import type { ModelCatalogEntry } from "../agents/model-catalog.types.js"; +import { resolveSessionModelRef } from "../agents/session-model-ref.js"; import { forkSessionFromParent, resolveParentForkDecision, @@ -106,9 +107,7 @@ export function buildDashboardSessionKey(agentId: string): string { return `agent:${agentId}:dashboard:${randomUUID()}`; } -function inheritSessionRuntimeSelection( - parentEntry: SessionEntry | undefined, -): Partial { +function inheritSessionSelection(parentEntry: SessionEntry | undefined): Partial { if (!parentEntry) { return {}; } @@ -121,8 +120,6 @@ function inheritSessionRuntimeSelection( ...(parentEntry.agentRuntimeOverride ? { agentRuntimeOverride: parentEntry.agentRuntimeOverride } : {}), - ...(parentEntry.modelProvider ? { modelProvider: parentEntry.modelProvider } : {}), - ...(parentEntry.model ? { model: parentEntry.model } : {}), ...(parentEntry.thinkingLevel ? { thinkingLevel: parentEntry.thinkingLevel } : {}), ...(parentEntry.fastMode !== undefined ? { fastMode: parentEntry.fastMode } : {}), ...(parentEntry.verboseLevel ? { verboseLevel: parentEntry.verboseLevel } : {}), @@ -151,6 +148,7 @@ type CreateGatewaySessionResult = key: string; agentId: string; entry: SessionEntry; + resolved: { modelProvider: string; model: string }; resetExisting: boolean; } | { ok: false; error: ErrorShape }; @@ -302,6 +300,7 @@ export async function createGatewaySession(params: { key: resetResult.key, agentId: resetResult.agentId, entry: resetResult.entry, + resolved: resetResult.resolved, resetExisting: true, }; } @@ -413,7 +412,7 @@ export async function createGatewaySession(params: { } const inheritedSelection = normalizeOptionalString(params.model) ? {} - : inheritSessionRuntimeSelection(currentParentSessionEntry); + : inheritSessionSelection(currentParentSessionEntry); const entry: SessionEntry = { ...patched.entry, ...inheritedSelection, @@ -519,11 +518,17 @@ export async function createGatewaySession(params: { }); } + const selectedModel = resolveSessionModelRef(params.cfg, created.entry, target.agentId); + return { ok: true, key: target.canonicalKey, agentId: target.agentId, entry: created.entry, + resolved: { + modelProvider: selectedModel.provider, + model: selectedModel.model, + }, resetExisting: false, }; }; diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 9bbecb2b26ac..8c82511cd293 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -20,6 +20,7 @@ import { clearBootstrapSnapshot } from "../agents/bootstrap-cache.js"; import { clearAllCliSessions } from "../agents/cli-session.js"; import { abortEmbeddedAgentRun, waitForEmbeddedAgentRunEnd } from "../agents/embedded-agent.js"; import { resetRegisteredAgentHarnessSessions } from "../agents/harness/registry.js"; +import { resolveSessionModelRef } from "../agents/session-model-ref.js"; import { stopSubagentsForRequester } from "../auto-reply/reply/abort.js"; import { buildSessionEndHookPayload, @@ -78,7 +79,6 @@ import { loadSessionEntry, resolveGatewaySessionStoreTarget, resolveSessionStoreKey, - resolveSessionModelRef, } from "./session-utils.js"; const ACP_RUNTIME_CLEANUP_TIMEOUT_MS = 15_000; @@ -118,22 +118,6 @@ function resolveResetSessionFile(params: { ); } -function stripRuntimeModelState(entry?: SessionEntry): SessionEntry | undefined { - if (!entry) { - return entry; - } - return { - ...entry, - // Reset should keep user selection preferences but drop per-run resolved - // model state so the next turn rehydrates from current config. - model: undefined, - modelProvider: undefined, - contextTokens: undefined, - contextBudgetStatus: undefined, - systemPromptReport: undefined, - }; -} - export function archiveSessionTranscriptsForSessionDetailed(params: { sessionId: string | undefined; storePath: string; @@ -876,7 +860,14 @@ export async function performGatewaySessionReset(params: { assertCurrent?: () => void; onCommitted?: (commit: { key: string; sessionId: string }) => void; }): Promise< - | { ok: true; key: string; entry: SessionEntry; agentId: string; storePath: string } + | { + ok: true; + key: string; + entry: SessionEntry; + resolved: { modelProvider: string; model: string }; + agentId: string; + storePath: string; + } | { ok: false; error: ReturnType } > { const resetTarget = (() => { @@ -1080,17 +1071,6 @@ export async function performGatewaySessionReset(params: { const resetPreservedSelection = resolveResetPreservedSelection({ entry: currentEntry, }); - const resetEntry = { - ...stripRuntimeModelState(currentEntry), - providerOverride: undefined, - modelOverride: undefined, - modelOverrideSource: undefined, - authProfileOverride: undefined, - authProfileOverrideSource: undefined, - authProfileOverrideCompactionCount: undefined, - ...resetPreservedSelection, - }; - const resolvedModel = resolveSessionModelRef(cfg, resetEntry, sessionAgentId); const now = Date.now(); const nextSessionId = randomUUID(); const sessionFile = resolveResetSessionFile({ @@ -1124,9 +1104,6 @@ export async function performGatewaySessionReset(params: { groupActivation: currentEntry?.groupActivation, groupActivationNeedsSystemIntro: currentEntry?.groupActivationNeedsSystemIntro, chatType: currentEntry?.chatType, - model: resolvedModel.model, - modelProvider: resolvedModel.provider, - contextTokens: resetEntry?.contextTokens, compactionCount: currentEntry?.compactionCount, compactionCheckpoints: currentEntry?.compactionCheckpoints, sendPolicy: currentEntry?.sendPolicy, @@ -1235,6 +1212,18 @@ export async function performGatewaySessionReset(params: { }, }); const next = lifecycle.nextEntry; + const selectedModel = resolveSessionModelRef(cfg, next, target.agentId); + const resolved = { + modelProvider: selectedModel.provider, + model: selectedModel.model, + }; + // Runtime model identity is a response projection, not reset persistence. Keep the + // established RPC entry shape while the stored row retains selection intent only. + const responseEntry: SessionEntry = { + ...next, + modelProvider: resolved.modelProvider, + model: resolved.model, + }; const oldSessionId = lifecycle.previousSessionId; const oldSessionFile = lifecycle.previousSessionFile; @@ -1268,7 +1257,8 @@ export async function performGatewaySessionReset(params: { return { ok: true, key: target.canonicalKey, - entry: next, + entry: responseEntry, + resolved, agentId: target.agentId, storePath, }; diff --git a/src/tui/embedded-backend.test.ts b/src/tui/embedded-backend.test.ts index d5f2200f1514..022ecd3dd271 100644 --- a/src/tui/embedded-backend.test.ts +++ b/src/tui/embedded-backend.test.ts @@ -216,7 +216,12 @@ vi.mock("../gateway/session-create-service.js", () => ({ })); vi.mock("../gateway/session-reset-service.js", () => ({ - performGatewaySessionReset: () => ({ ok: true, key: "agent:main:main", entry: {} }), + performGatewaySessionReset: () => ({ + ok: true, + key: "agent:main:main", + entry: {}, + resolved: { modelProvider: "openai", model: "gpt-5.4" }, + }), })); vi.mock("../gateway/session-transcript-readers.js", () => ({ @@ -301,6 +306,7 @@ describe("EmbeddedTuiBackend", () => { ok: true, key: "agent:main:tui-created", entry: { sessionId: "created-session" }, + resolved: { modelProvider: "openai", model: "gpt-5.4" }, resetExisting: false, }); listSessionsFromStoreAsyncMock.mockReset(); @@ -385,6 +391,19 @@ describe("EmbeddedTuiBackend", () => { ok: true, key: "agent:main:tui-created", entry: { sessionId: "created-session" }, + resolved: { modelProvider: "openai", model: "gpt-5.4" }, + }); + }); + + it("returns the resolved model from the shared reset lifecycle", async () => { + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + const backend = new EmbeddedTuiBackend(); + + await expect(backend.resetSession("main", "new")).resolves.toEqual({ + ok: true, + key: "agent:main:main", + entry: {}, + resolved: { modelProvider: "openai", model: "gpt-5.4" }, }); }); diff --git a/src/tui/embedded-backend.ts b/src/tui/embedded-backend.ts index 059738ff5097..d8365f0e1590 100644 --- a/src/tui/embedded-backend.ts +++ b/src/tui/embedded-backend.ts @@ -676,7 +676,7 @@ export class EmbeddedTuiBackend implements TuiBackend { if (!result.ok) { throw new Error(result.error.message); } - return { ok: true as const, key: result.key, entry: result.entry }; + return { ok: true as const, key: result.key, entry: result.entry, resolved: result.resolved }; } async createSession(opts: TuiSessionCreateOptions) { @@ -692,7 +692,12 @@ export class EmbeddedTuiBackend implements TuiBackend { if (!result.ok) { throw new Error(result.error.message); } - return { ok: true as const, key: result.key, entry: result.entry }; + return { + ok: true as const, + key: result.key, + entry: result.entry, + resolved: result.resolved, + }; } private async runBtwTurn(params: {