mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user