Fix stale runtime model reuse on session reset (#41173)

Merged via squash.

Prepared head SHA: d8a04a466a
Co-authored-by: PonyX-lab <266766228+PonyX-lab@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
PonyX-lab
2026-03-11 05:02:43 +08:00
committed by GitHub
parent 0c17e7c225
commit 53374394fb
5 changed files with 131 additions and 2 deletions

View File

@@ -76,6 +76,7 @@ Docs: https://docs.openclaw.ai
- Telegram/docs: clarify that `channels.telegram.groups` allowlists chats while `groupAllowFrom` allowlists users inside those chats, and point invalid negative chat IDs at the right config key. (#42451) Thanks @altaywtf.
- Models/Alibaba Cloud Model Studio: wire `MODELSTUDIO_API_KEY` through shared env auth, implicit provider discovery, and shell-env fallback so onboarding works outside the wizard too. (#40634) Thanks @pomelo-nwu.
- ACP/sessions_spawn: implicitly stream `mode="run"` ACP spawns to parent only for eligible subagent orchestrator sessions (heartbeat `target: "last"` with a usable session-local route), restoring parent progress relays without thread binding. (#42404) Thanks @davidguttman.
- Sessions/reset model recompute: clear stale runtime model, context-token, and system-prompt metadata before session resets recompute the replacement session, so resets pick up current defaults and explicit overrides instead of reusing old runtime model state. (#41173) thanks @PonyX-lab.
## 2026.3.8

View File

@@ -1255,6 +1255,79 @@ describe("runReplyAgent typing (heartbeat)", () => {
});
});
it("clears stale runtime model fields when resetSession retries after compaction failure", async () => {
await withTempStateDir(async (stateDir) => {
const sessionId = "session-stale-model";
const storePath = path.join(stateDir, "sessions", "sessions.json");
const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId);
const sessionEntry: SessionEntry = {
sessionId,
updatedAt: Date.now(),
sessionFile: transcriptPath,
modelProvider: "qwencode",
model: "qwen3.5-plus-2026-02-15",
contextTokens: 123456,
systemPromptReport: {
source: "run",
generatedAt: Date.now(),
sessionId,
sessionKey: "main",
provider: "qwencode",
model: "qwen3.5-plus-2026-02-15",
workspaceDir: stateDir,
bootstrapMaxChars: 1000,
bootstrapTotalMaxChars: 2000,
systemPrompt: {
chars: 10,
projectContextChars: 5,
nonProjectContextChars: 5,
},
injectedWorkspaceFiles: [],
skills: {
promptChars: 0,
entries: [],
},
tools: {
listChars: 0,
schemaChars: 0,
entries: [],
},
},
};
const sessionStore = { main: sessionEntry };
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
await fs.writeFile(transcriptPath, "ok", "utf-8");
state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => {
throw new Error(
'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
);
});
const { run } = createMinimalRun({
sessionEntry,
sessionStore,
sessionKey: "main",
storePath,
});
await run();
expect(sessionStore.main.modelProvider).toBeUndefined();
expect(sessionStore.main.model).toBeUndefined();
expect(sessionStore.main.contextTokens).toBeUndefined();
expect(sessionStore.main.systemPromptReport).toBeUndefined();
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(persisted.main.modelProvider).toBeUndefined();
expect(persisted.main.model).toBeUndefined();
expect(persisted.main.contextTokens).toBeUndefined();
expect(persisted.main.systemPromptReport).toBeUndefined();
});
});
it("surfaces overflow fallback when embedded run returns empty payloads", async () => {
state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({
payloads: [],

View File

@@ -278,6 +278,10 @@ export async function runReplyAgent(params: {
updatedAt: Date.now(),
systemSent: false,
abortedLastRun: false,
modelProvider: undefined,
model: undefined,
contextTokens: undefined,
systemPromptReport: undefined,
fallbackNoticeSelectedModel: undefined,
fallbackNoticeActiveModel: undefined,
fallbackNoticeReason: undefined,

View File

@@ -128,6 +128,19 @@ function migrateAndPruneSessionStoreKey(params: {
return { target, primaryKey, entry: params.store[primaryKey] };
}
function stripRuntimeModelState(entry?: SessionEntry): SessionEntry | undefined {
if (!entry) {
return entry;
}
return {
...entry,
model: undefined,
modelProvider: undefined,
contextTokens: undefined,
systemPromptReport: undefined,
};
}
function archiveSessionTranscriptsForSession(params: {
sessionId: string | undefined;
storePath: string;
@@ -507,9 +520,10 @@ export const sessionsHandlers: GatewayRequestHandlers = {
const next = await updateSessionStore(storePath, (store) => {
const { primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store });
const entry = store[primaryKey];
const resetEntry = stripRuntimeModelState(entry);
const parsed = parseAgentSessionKey(primaryKey);
const sessionAgentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg));
const resolvedModel = resolveSessionModelRef(cfg, entry, sessionAgentId);
const resolvedModel = resolveSessionModelRef(cfg, resetEntry, sessionAgentId);
oldSessionId = entry?.sessionId;
oldSessionFile = entry?.sessionFile;
const now = Date.now();
@@ -524,7 +538,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
responseUsage: entry?.responseUsage,
model: resolvedModel.model,
modelProvider: resolvedModel.provider,
contextTokens: entry?.contextTokens,
contextTokens: resetEntry?.contextTokens,
sendPolicy: entry?.sendPolicy,
label: entry?.label,
origin: snapshotSessionOrigin(entry),

View File

@@ -591,6 +591,43 @@ describe("gateway server sessions", () => {
ws.close();
});
test("sessions.reset recomputes model from defaults instead of stale runtime model", async () => {
await createSessionStoreDir();
testState.agentConfig = {
model: {
primary: "openai/gpt-test-a",
},
};
await writeSessionStore({
entries: {
main: {
sessionId: "sess-stale-model",
updatedAt: Date.now(),
modelProvider: "qwencode",
model: "qwen3.5-plus-2026-02-15",
contextTokens: 123456,
},
},
});
const { ws } = await openClient();
const reset = await rpcReq<{
ok: true;
key: string;
entry: { sessionId: string; modelProvider?: string; model?: string; contextTokens?: number };
}>(ws, "sessions.reset", { key: "main" });
expect(reset.ok).toBe(true);
expect(reset.payload?.key).toBe("agent:main:main");
expect(reset.payload?.entry.sessionId).not.toBe("sess-stale-model");
expect(reset.payload?.entry.modelProvider).toBe("openai");
expect(reset.payload?.entry.model).toBe("gpt-test-a");
expect(reset.payload?.entry.contextTokens).toBeUndefined();
ws.close();
});
test("sessions.preview resolves legacy mixed-case main alias with custom mainKey", async () => {
const { dir, storePath } = await createSessionStoreDir();
testState.agentsConfig = { list: [{ id: "ops", default: true }] };