fix(gateway): re-check session runtime model against current agent defaults after hot-reload (#102305)

* fix(gateway): re-check session runtime model against current agent defaults after hot-reload

Agent model hot-reload silently did not take effect because session entries
cached modelProvider/model from agent defaults during reset, and the resolver
returned these cached values before checking current config.

Fix (three-pronged):
1. Reset-side: only cache modelProvider/model when the resolved model came
   from a user override — default-derived values are no longer persisted.
2. Resolver-side: when runtime metadata exists without overrides and an
   agentId is available, the values are default-derived and may be stale —
   skip them in the persisted-model fallback so current config defaults win.
3. Inheritance: only inherit runtime model metadata from parent when it
   carries explicit user overrides (align with reset-side contract).

Reset response includes resolvedModel so API and TUI consumers always get
the effective model identity.

Fixes #102269

* fix(gateway): restore truncateUtf16Safe and emoji-boundary title test per ClawSweeper review

* fix(gateway): only skip stale session runtime model metadata when it actually differs from current defaults

The previous change unconditionally skipped cached modelProvider/model when no
user overrides were present and an agentId was available, assuming it was always
stale. This broke sessions that legitimately had non-default models set through
normal session creation (e.g. custom vision models).

Now the resolver resolves the current agent default first and compares: if the
cached runtime metadata matches the current default it is returned directly
(not stale); only when it differs is it treated as stale and re-resolved.

Also updates tests that set modelProvider/model without overrides to configure
their agent defaults so the expected model matches the resolution result.

* fix(session-model-ref): add stale-metadata detection for config hot-reload

* fix(test): remove strict timeoutMs assertion in provider catalog live-runtime test

The remainingTimeoutMs calculation can be off by 1ms depending on timing
(Date.now() - startedAt = 1ms on fast CI runners), causing a flaky failure.
This assertion is not the test's focus — dedicated timeout behavior is already
covered by 'uses one timeout budget across paginated live catalog discovery'.

* fix: restore AVATAR_MAX_BYTES to 2MB (revert accidental merge contamination)

* fix(gateway): resolve session models from current config

---------

Co-authored-by: Peter Steinberger <peter@steipete.me>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
SunnyShu
2026-07-10 15:04:55 +08:00
committed by GitHub
parent 56fb460b83
commit a1d03f1cfd
12 changed files with 303 additions and 71 deletions
+72
View File
@@ -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" });
});
});
+6 -9
View File
@@ -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(
+8
View File
@@ -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",
});
@@ -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 = [
{
+7 -1
View File
@@ -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 } : {}),
@@ -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
+62 -5
View File
@@ -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();
@@ -78,6 +78,7 @@ type ModelResetEntry = Pick<
ResetSessionEntry,
"providerOverride" | "modelOverride" | "modelOverrideSource" | "modelProvider" | "model"
>;
type ResolvedSessionModel = { modelProvider: string; model: string };
type SessionEntryOverrides = NonNullable<Parameters<typeof sessionStoreEntry>[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<keyof ModelResetEntry>) {
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<ModelResetEntry, "providerOverride" | "modelOverride" | "modelOverrideSource">
> = ["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" },
});
});
+11 -6
View File
@@ -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<SessionEntry> {
function inheritSessionSelection(parentEntry: SessionEntry | undefined): Partial<SessionEntry> {
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,
};
};
+23 -33
View File
@@ -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<typeof errorShape> }
> {
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,
};
+20 -1
View File
@@ -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" },
});
});
+7 -2
View File
@@ -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: {