diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 300e71a7193..fc1b90a90cd 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -66,6 +66,7 @@ import { } from "./model-selection.js"; import { classifyEmbeddedPiRunResultForModelFallback } from "./pi-embedded-runner/result-fallback-classifier.js"; import { resolveProviderIdForAuth } from "./provider-auth-aliases.js"; +import { hydrateResolvedSkillsAsync } from "./skills/snapshot-hydration.js"; import { normalizeSpawnedRunMetadata } from "./spawned-context.js"; import { resolveAgentTimeoutMs } from "./timeout.js"; import { ensureAgentWorkspace } from "./workspace.js"; @@ -663,12 +664,7 @@ async function agentCommandInternal( ? await buildSkillsSnapshot() : !currentSkillsSnapshot ? undefined - : currentSkillsSnapshot.resolvedSkills === undefined - ? { - ...currentSkillsSnapshot, - resolvedSkills: (await buildSkillsSnapshot()).resolvedSkills, - } - : currentSkillsSnapshot; + : await hydrateResolvedSkillsAsync(currentSkillsSnapshot, buildSkillsSnapshot); if (skillsSnapshot && sessionStore && sessionKey && needsSkillsSnapshot) { const now = Date.now(); diff --git a/src/agents/skills/snapshot-hydration.ts b/src/agents/skills/snapshot-hydration.ts new file mode 100644 index 00000000000..72d7807d4ed --- /dev/null +++ b/src/agents/skills/snapshot-hydration.ts @@ -0,0 +1,30 @@ +type SnapshotWithRuntimeSkills = { + resolvedSkills?: unknown; +}; + +type SnapshotRebuild = { + resolvedSkills?: T["resolvedSkills"]; +}; + +// resolvedSkills is runtime-only: session persistence keeps the lightweight +// catalog/prompt, while consumers that need concrete SKILL.md paths hydrate it +// from a fresh workspace scan. +export function hydrateResolvedSkills( + snapshot: T, + rebuild: () => SnapshotRebuild, +): T { + if (snapshot.resolvedSkills !== undefined) { + return snapshot; + } + return { ...snapshot, resolvedSkills: rebuild().resolvedSkills }; +} + +export async function hydrateResolvedSkillsAsync( + snapshot: T, + rebuild: () => Promise>, +): Promise { + if (snapshot.resolvedSkills !== undefined) { + return snapshot; + } + return { ...snapshot, resolvedSkills: (await rebuild()).resolvedSkills }; +} diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index c9c9f428997..2e2f09edcbb 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -10,6 +10,7 @@ import { shouldRefreshSnapshotForVersion, } from "../../agents/skills/refresh-state.js"; import { ensureSkillsWatcher } from "../../agents/skills/refresh.js"; +import { hydrateResolvedSkills } from "../../agents/skills/snapshot-hydration.js"; import { resolveSessionFilePath, resolveSessionFilePathOptions, @@ -104,22 +105,6 @@ 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; diff --git a/src/config/sessions/store.skills-stripping.test.ts b/src/config/sessions/store.skills-stripping.test.ts index 21b16cc0599..84f8ce25569 100644 --- a/src/config/sessions/store.skills-stripping.test.ts +++ b/src/config/sessions/store.skills-stripping.test.ts @@ -4,7 +4,10 @@ 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 { + hydrateResolvedSkills, + hydrateResolvedSkillsAsync, +} from "../../agents/skills/snapshot-hydration.js"; import { createSuiteTempRootTracker } from "../../test-helpers/temp-dir.js"; import type { SessionEntry, SessionSkillSnapshot } from "./types.js"; @@ -269,4 +272,23 @@ describe("hydrateResolvedSkills", () => { expect(result).toBe(snapshot); expect(buildCalls).toBe(0); }); + + it("supports async runtime hydration for CLI resume paths", async () => { + const stripped: SessionSkillSnapshot = { + prompt: "cached-prompt", + skills: [{ name: "x" }], + version: 2, + }; + const rebuiltSkills = [makeFixtureSkill("x", 120)]; + const result = await hydrateResolvedSkillsAsync(stripped, async () => ({ + prompt: "fresh-prompt", + skills: [{ name: "y" }], + resolvedSkills: rebuiltSkills, + version: 3, + })); + expect(result.prompt).toBe("cached-prompt"); + expect(result.skills).toEqual([{ name: "x" }]); + expect(result.version).toBe(2); + expect(result.resolvedSkills).toBe(rebuiltSkills); + }); });