refactor(skills): centralize snapshot hydration

This commit is contained in:
Peter Steinberger
2026-05-02 12:47:13 +01:00
parent e607ad4ab0
commit 70ef234753
4 changed files with 56 additions and 23 deletions

View File

@@ -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();

View File

@@ -0,0 +1,30 @@
type SnapshotWithRuntimeSkills = {
resolvedSkills?: unknown;
};
type SnapshotRebuild<T extends SnapshotWithRuntimeSkills> = {
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<T extends SnapshotWithRuntimeSkills>(
snapshot: T,
rebuild: () => SnapshotRebuild<T>,
): T {
if (snapshot.resolvedSkills !== undefined) {
return snapshot;
}
return { ...snapshot, resolvedSkills: rebuild().resolvedSkills };
}
export async function hydrateResolvedSkillsAsync<T extends SnapshotWithRuntimeSkills>(
snapshot: T,
rebuild: () => Promise<SnapshotRebuild<T>>,
): Promise<T> {
if (snapshot.resolvedSkills !== undefined) {
return snapshot;
}
return { ...snapshot, resolvedSkills: (await rebuild()).resolvedSkills };
}

View File

@@ -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<SessionEntry["skillsSnapshot"]>,
rebuild: () => NonNullable<SessionEntry["skillsSnapshot"]>,
): NonNullable<SessionEntry["skillsSnapshot"]> {
if (snapshot.resolvedSkills) {
return snapshot;
}
return { ...snapshot, resolvedSkills: rebuild().resolvedSkills };
}
export async function ensureSkillSnapshot(params: {
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;

View File

@@ -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);
});
});