diff --git a/CHANGELOG.md b/CHANGELOG.md index a0c89a9b54a..9da92d3e4dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -215,7 +215,7 @@ Docs: https://docs.openclaw.ai - macOS/Voice Wake: accept trigger-only phrases in the built-in Voice Wake test, matching the settings UI and runtime trigger-only path instead of requiring extra command text after the wake word. Fixes #64986. Thanks @zoiks65. - Cron/TTS: run cron announce payloads through the normal TTS directive transform before outbound delivery, so scheduled `[[tts]]` replies generate voice payloads instead of leaking raw tags. Fixes #52125. Thanks @kenchen3000. - WhatsApp: save downloadable quoted image media from reply context as inbound media, so agents can inspect an image that a user replied to instead of only seeing ``. Fixes #59174. Thanks @gaffner. -- Sessions/store: stop persisting the runtime-only `skillsSnapshot.resolvedSkills` array inside each session entry, so `sessions.json` no longer carries a copy of every parsed `SKILL.md` body for every active session; the embedded runner already rebuilds the array from disk when absent and legacy stores self-heal on the next save. Refs #11950, #6650, #15000. Thanks @amoghasgekar. +- Sessions/store: stop persisting the runtime-only `skillsSnapshot.resolvedSkills` array inside each session entry, so `sessions.json` no longer carries a copy of every parsed `SKILL.md` body for every active session; `ensureSkillSnapshot` rehydrates the array from disk on cold resume so the embedded runner, the Claude CLI skills plugin, and the Claude live-session fingerprint all see populated skills, and legacy stores self-heal on the next save. Refs #11950, #6650, #15000. Thanks @amoghasgekar. - Doctor/WhatsApp: warn when Linux crontabs still run the legacy `ensure-whatsapp.sh` health check, which can misreport `Gateway inactive` when cron lacks the systemd user-bus environment. Fixes #60204. Thanks @mySebbe. - Slack/setup: print the generated app manifest as plain JSON instead of embedding it inside the framed setup note, so it can be copied into Slack without deleting border characters. Fixes #65751. Thanks @theDanielJLewis. - Channels/WhatsApp: route CLI logout through the live Gateway and stop runtime-backed listeners before channel removal, so removing a WhatsApp account does not leave the old socket replying until restart. Fixes #67746. Thanks @123Mismail. diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index dc876bf38e0..c9c9f428997 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -104,6 +104,22 @@ function resolvePositiveTokenCount(value: number | undefined): number | undefine : undefined; } +// resolvedSkills is stripped from the persisted snapshot (see store-load.ts). +// On cold session resume, the snapshot loaded from disk reaches this code path +// without resolvedSkills. Consumers like prepareClaudeCliSkillsPlugin and the +// claude-live-session fingerprint read resolvedSkills directly, so re-fill it +// here from a fresh workspace scan while preserving the persisted prompt / +// skills / version fields for prompt-cache stability. +export function hydrateResolvedSkills( + snapshot: NonNullable, + rebuild: () => NonNullable, +): NonNullable { + if (snapshot.resolvedSkills) { + return snapshot; + } + return { ...snapshot, resolvedSkills: rebuild().resolvedSkills }; +} + export async function ensureSkillSnapshot(params: { sessionEntry?: SessionEntry; sessionStore?: Record; @@ -175,7 +191,9 @@ export async function ensureSkillSnapshot(params: { updatedAt: Date.now(), }; const skillSnapshot = - !current.skillsSnapshot || shouldRefreshSnapshot ? buildSnapshot() : current.skillsSnapshot; + !current.skillsSnapshot || shouldRefreshSnapshot + ? buildSnapshot() + : hydrateResolvedSkills(current.skillsSnapshot, buildSnapshot); nextEntry = { ...current, sessionId: sessionId ?? current.sessionId ?? crypto.randomUUID(), @@ -190,11 +208,12 @@ export async function ensureSkillSnapshot(params: { const hasFreshSnapshotInEntry = Boolean(nextEntry?.skillsSnapshot) && (nextEntry?.skillsSnapshot !== existingSnapshot || !shouldRefreshSnapshot); - const skillsSnapshot = hasFreshSnapshotInEntry - ? nextEntry?.skillsSnapshot - : shouldRefreshSnapshot || !nextEntry?.skillsSnapshot - ? buildSnapshot() - : nextEntry.skillsSnapshot; + const skillsSnapshot = + hasFreshSnapshotInEntry && nextEntry?.skillsSnapshot + ? hydrateResolvedSkills(nextEntry.skillsSnapshot, buildSnapshot) + : shouldRefreshSnapshot || !nextEntry?.skillsSnapshot + ? buildSnapshot() + : hydrateResolvedSkills(nextEntry.skillsSnapshot, buildSnapshot); if ( skillsSnapshot && sessionStore && diff --git a/src/config/sessions/store.skills-stripping.test.ts b/src/config/sessions/store.skills-stripping.test.ts index 70e386d3fc2..21b16cc0599 100644 --- a/src/config/sessions/store.skills-stripping.test.ts +++ b/src/config/sessions/store.skills-stripping.test.ts @@ -4,6 +4,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { resolveEmbeddedRunSkillEntries } from "../../agents/pi-embedded-runner/skills-runtime.js"; import { createCanonicalFixtureSkill } from "../../agents/skills.test-helpers.js"; import type { Skill } from "../../agents/skills/skill-contract.js"; +import { hydrateResolvedSkills } from "../../auto-reply/reply/session-updates.js"; import { createSuiteTempRootTracker } from "../../test-helpers/temp-dir.js"; import type { SessionEntry, SessionSkillSnapshot } from "./types.js"; @@ -203,3 +204,69 @@ describe("embedded runner falls back to disk when resolvedSkills is absent", () expect(result.skillEntries).toEqual([]); }); }); + +describe("hydrateResolvedSkills", () => { + it("returns the same snapshot when resolvedSkills is already populated", () => { + const snapshot: SessionSkillSnapshot = { + prompt: "p", + skills: [{ name: "x" }], + resolvedSkills: [makeFixtureSkill("x", 100)], + version: 1, + }; + let buildCalls = 0; + const result = hydrateResolvedSkills(snapshot, () => { + buildCalls += 1; + return { prompt: "rebuilt", skills: [], resolvedSkills: [], version: 99 }; + }); + expect(result).toBe(snapshot); + expect(buildCalls).toBe(0); + }); + + it("rebuilds resolvedSkills only when missing and preserves persisted fields", () => { + // Simulates a cold session resume: the on-disk snapshot has no + // resolvedSkills, but consumers like prepareClaudeCliSkillsPlugin still + // need them. Hydration must not change prompt/skills/version, so the + // model's prompt-cache key stays stable across resume. + const stripped: SessionSkillSnapshot = { + prompt: "original-prompt", + skills: [{ name: "x" }], + skillFilter: ["x"], + version: 7, + }; + const rebuiltSkills = [makeFixtureSkill("x", 200)]; + let buildCalls = 0; + const result = hydrateResolvedSkills(stripped, () => { + buildCalls += 1; + return { + prompt: "DIFFERENT-PROMPT", + skills: [{ name: "y" }], + resolvedSkills: rebuiltSkills, + version: 99, + }; + }); + expect(buildCalls).toBe(1); + expect(result.prompt).toBe("original-prompt"); + expect(result.skills).toEqual([{ name: "x" }]); + expect(result.skillFilter).toEqual(["x"]); + expect(result.version).toBe(7); + expect(result.resolvedSkills).toBe(rebuiltSkills); + }); + + it("hydrates an empty resolvedSkills array as if it were absent is NOT done — empty is treated as populated", () => { + // A resolvedSkills set explicitly to [] means the workspace genuinely had + // no skills, not that the field was stripped. Don't trigger a rebuild. + const snapshot: SessionSkillSnapshot = { + prompt: "", + skills: [], + resolvedSkills: [], + version: 1, + }; + let buildCalls = 0; + const result = hydrateResolvedSkills(snapshot, () => { + buildCalls += 1; + return { prompt: "", skills: [], resolvedSkills: [makeFixtureSkill("x")], version: 1 }; + }); + expect(result).toBe(snapshot); + expect(buildCalls).toBe(0); + }); +});