From afdf03b563170d2c39bbc1cf73703cb158d9a3dc Mon Sep 17 00:00:00 2001 From: Christof Date: Thu, 7 May 2026 11:18:39 +0200 Subject: [PATCH] fix: clear reset skills snapshot (#78873) --- CHANGELOG.md | 1 + src/auto-reply/reply/session.test.ts | 48 +++++++++++++++++++ src/auto-reply/reply/session.ts | 3 ++ .../server.sessions.reset-models.test.ts | 40 ++++++++++++++++ src/gateway/session-reset-service.ts | 5 +- 5 files changed, 96 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52698c9a182..ffd0b8e3cc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -141,6 +141,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/sessions: clear cached skills snapshots during `/new` and `sessions.reset` so long-lived channel sessions rebuild the visible skill list after skills change. (#78873) Thanks @Evizero. - fix(auto-reply): gate inline skill tool dispatch [AI]. (#78517) Thanks @pgondhi987. - Canvas plugin: keep legacy root `canvasHost` configs valid until `openclaw doctor --fix` migrates them into `plugins.entries.canvas.config.host`, move Canvas/A2UI clients to gateway protocol v4 plugin surfaces, and refresh the generated A2UI bundle hash so normal builds stay clean. - feishu: honor config write policy for dynamic agents [AI]. (#78520) Thanks @pgondhi987. diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 461ebef2f58..d044335915c 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -729,6 +729,54 @@ describe("initSessionState RawBody", () => { expect(result.triggerBodyNormalized).toBe("/NEW KeepThisCase"); }); + it("drops cached skills snapshot when /new rotates an existing session", async () => { + const root = await makeCaseDir("openclaw-rawbody-reset-skills-"); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:signal:direct:uuid:reset-skills"; + const existingSessionId = "session-with-stale-skills"; + + await writeSessionStoreFast(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: Date.now(), + systemSent: true, + skillsSnapshot: { + prompt: "stale", + skills: [{ name: "stale" }], + version: 0, + }, + }, + }); + + const cfg = { + session: { + store: storePath, + resetTriggers: ["/new"], + }, + } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + RawBody: "/new continue", + ChatType: "direct", + SessionKey: sessionKey, + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.sessionEntry.skillsSnapshot).toBeUndefined(); + + const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< + string, + { skillsSnapshot?: unknown } + >; + expect(store[sessionKey]?.skillsSnapshot).toBeUndefined(); + }); + it("drains stale system events when /new rotates an existing session", async () => { const root = await makeCaseDir("openclaw-rawbody-reset-system-events-"); const storePath = path.join(root, "sessions.json"); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index cc2e31af08d..78b82029196 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -768,6 +768,9 @@ export async function initSessionState(params: { sessionEntry.outputTokens = undefined; sessionEntry.estimatedCostUsd = undefined; sessionEntry.contextTokens = undefined; + // Skills snapshots are prompt/runtime caches. Do not preserve a stale + // snapshot through /new; the next turn must rebuild the visible skill list. + sessionEntry.skillsSnapshot = undefined; } // Preserve per-session overrides while resetting compaction state on /new. sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry }; diff --git a/src/gateway/server.sessions.reset-models.test.ts b/src/gateway/server.sessions.reset-models.test.ts index bec49f0bb8c..5095540865f 100644 --- a/src/gateway/server.sessions.reset-models.test.ts +++ b/src/gateway/server.sessions.reset-models.test.ts @@ -50,6 +50,46 @@ test("sessions.reset recomputes model from defaults instead of stale runtime mod await expect(fs.stat(reset.payload?.entry.sessionFile as string)).resolves.toBeTruthy(); }); +test("sessions.reset drops cached skills snapshot so /new rebuilds visible skills", async () => { + const { storePath } = await createSessionStoreDir(); + testState.agentConfig = { + model: { + primary: "openai/gpt-test-a", + }, + }; + + await writeSessionStore({ + entries: { + main: sessionStoreEntry("sess-stale-skills", { + skillsSnapshot: { + prompt: "stale", + skills: [{ name: "stale" }], + version: 0, + }, + }), + }, + }); + + const reset = await directSessionReq<{ + ok: true; + key: string; + entry: { + sessionId: string; + skillsSnapshot?: unknown; + }; + }>("sessions.reset", { key: "main" }); + + expect(reset.ok).toBe(true); + expect(reset.payload?.entry.sessionId).not.toBe("sess-stale-skills"); + expect(reset.payload?.entry.skillsSnapshot).toBeUndefined(); + + const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< + string, + { skillsSnapshot?: unknown } + >; + expect(store["agent:main:main"]?.skillsSnapshot).toBeUndefined(); +}); + test("sessions.reset preserves legacy explicit model overrides without modelOverrideSource", async () => { const { storePath } = await createSessionStoreDir(); testState.agentConfig = { diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index d53b31491ba..69e7e7f7522 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -623,7 +623,10 @@ export async function performGatewaySessionReset(params: { lastTo: currentEntry?.lastTo, lastAccountId: currentEntry?.lastAccountId, lastThreadId: currentEntry?.lastThreadId, - skillsSnapshot: currentEntry?.skillsSnapshot, + // Do not carry the cached skills catalog across /new. Long-lived channel + // sessions (Signal DMs/groups in particular) otherwise keep advertising a + // stale block even after reset/restart, because the + // skills snapshot version is runtime-local and may reset to 0. acp: currentEntry?.acp, inputTokens: 0, outputTokens: 0,