diff --git a/src/compaction.ts b/src/compaction.ts index e2a20b2..e93152a 100644 --- a/src/compaction.ts +++ b/src/compaction.ts @@ -4,11 +4,14 @@ import type { CompactionSource, ExtensionState } from "./state.js"; /** Runtime compaction-source detection between input → before_compact → session_compact (SPEC §8). */ export interface CompactionRuntime { pendingCompactionSource: CompactionSource | null; + /** Manual compaction with default settings: clear stale pending on next user prompt (SPEC §16.5, §12.3). */ + clearPendingReinjectOnNextUserInput: boolean; } export function createCompactionRuntime(): CompactionRuntime { return { pendingCompactionSource: null, + clearPendingReinjectOnNextUserInput: false, }; } @@ -51,8 +54,14 @@ export function consumeCompactionOnSessionCompact( sessionOverride: boolean | null, settings: SkillReinjectSettings, ): boolean { + const source = runtime.pendingCompactionSource; const shouldReinject = shouldReinjectAfterCompaction(sessionOverride, settings, runtime); - state.lastCompactionSource = runtime.pendingCompactionSource; + state.lastCompactionSource = source; + if (source === "manual" && !settings.reinjectOnManualCompaction) { + runtime.clearPendingReinjectOnNextUserInput = true; + } else if (source === "auto" && shouldReinject) { + runtime.clearPendingReinjectOnNextUserInput = false; + } runtime.pendingCompactionSource = null; return shouldReinject; } diff --git a/src/index.ts b/src/index.ts index cc05330..0d61010 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { } from "./compaction.js"; import { detectSlashSkill, matchReadPathToSkillWhenEnabled, parseSkillBlocksFromText, userMessageText } from "./detect.js"; import { + clearPendingReinjectOnUserPrompt, enqueueDeferredReinjectFromCompact, planReinject, sendImmediateReinjectAllFollowUp, @@ -147,7 +148,13 @@ export default function skillReinject(pi: ExtensionAPI): void { const settings = readSettings(ctx); const pendingBefore = state.pendingReinject.length; - const deferred = tryConsumeDeferredReinject(state, settings, registeredSkills, ctx); + const deferred = tryConsumeDeferredReinject( + state, + settings, + registeredSkills, + ctx, + compactionRuntime, + ); if (pendingBefore > 0) { persistState(); } @@ -163,6 +170,10 @@ export default function skillReinject(pi: ExtensionAPI): void { markManualCompactionFromInput(event.text, compactionRuntime); + if (clearPendingReinjectOnUserPrompt(state, compactionRuntime)) { + persistState(); + } + const skillName = detectSlashSkill(event.text); if (skillName) { const skills = resolveRegisteredSkills(ctx.cwd, registeredSkills); diff --git a/src/reinject.ts b/src/reinject.ts index b7adb78..41c3bb3 100644 --- a/src/reinject.ts +++ b/src/reinject.ts @@ -13,6 +13,7 @@ import { skillsPresentInKeptWindow, } from "./kept.js"; import type { SkillReinjectSettings } from "./settings.js"; +import type { CompactionRuntime } from "./compaction.js"; import type { ExtensionState } from "./state.js"; export const DEFERRED_REINJECT_CUSTOM_TYPE = "skill-reinject:inject"; @@ -173,16 +174,37 @@ export function reinjectNow( sendImmediateReinjectAllFollowUp(pi, skillNames, state, settings, registeredSkills, ctx); } +/** + * Manual `/compact` with default settings: drop stale pending on the next user prompt (SPEC §16.5, §12.3). + * Returns true when pending was cleared. + */ +export function clearPendingReinjectOnUserPrompt( + state: ExtensionState, + compactionRuntime: CompactionRuntime, +): boolean { + if (!compactionRuntime.clearPendingReinjectOnNextUserInput) { + return false; + } + compactionRuntime.clearPendingReinjectOnNextUserInput = false; + const hadPending = state.pendingReinject.length > 0; + state.pendingReinject = []; + return hadPending; +} + /** * Defer path on before_agent_start: inject one combined message, then clear queue (SPEC §6.5.1). - * Returns undefined when pendingReinject is empty. + * Returns undefined when pendingReinject is empty or manual compaction scheduled a clear (SPEC §16.5). */ export function tryConsumeDeferredReinject( state: ExtensionState, settings: SkillReinjectSettings, registeredSkills: readonly Pick[], ctx?: ExtensionContext, + compactionRuntime?: CompactionRuntime, ): BeforeAgentStartEventResult | undefined { + if (compactionRuntime?.clearPendingReinjectOnNextUserInput) { + return undefined; + } if (state.pendingReinject.length === 0) { return undefined; } diff --git a/test/reinject-manual-defer.test.ts b/test/reinject-manual-defer.test.ts new file mode 100644 index 0000000..52ddfdf --- /dev/null +++ b/test/reinject-manual-defer.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + consumeCompactionOnSessionCompact, + createCompactionRuntime, +} from "../src/compaction"; +import { clearPendingReinjectOnUserPrompt, tryConsumeDeferredReinject } from "../src/reinject"; +import { createDefaultSettings } from "../src/settings"; +import { createInitialState } from "../src/state"; + +describe("manual compaction defer clear", () => { + it("schedules clear on manual compaction when reinjectOnManualCompaction is false", () => { + const runtime = createCompactionRuntime(); + const state = createInitialState(); + state.pendingReinject = ["alpha"]; + runtime.pendingCompactionSource = "manual"; + + const shouldReinject = consumeCompactionOnSessionCompact(runtime, state, null, createDefaultSettings()); + + expect(shouldReinject).toBe(false); + expect(runtime.clearPendingReinjectOnNextUserInput).toBe(true); + expect(state.pendingReinject).toEqual(["alpha"]); + }); + + it("blocks deferred inject until user prompt clears stale pending", () => { + const runtime = createCompactionRuntime(); + const state = createInitialState(); + state.pendingReinject = ["alpha"]; + runtime.clearPendingReinjectOnNextUserInput = true; + + expect( + tryConsumeDeferredReinject(state, createDefaultSettings(), [], undefined, runtime), + ).toBeUndefined(); + expect(state.pendingReinject).toEqual(["alpha"]); + }); + + it("clears pending on user prompt and allows deferred inject again", () => { + const runtime = createCompactionRuntime(); + const state = createInitialState(); + state.pendingReinject = ["alpha"]; + runtime.clearPendingReinjectOnNextUserInput = true; + + expect(clearPendingReinjectOnUserPrompt(state, runtime)).toBe(true); + expect(state.pendingReinject).toEqual([]); + expect(runtime.clearPendingReinjectOnNextUserInput).toBe(false); + }); + + it("does not schedule clear when manual compaction may reinject", () => { + const runtime = createCompactionRuntime(); + const state = createInitialState(); + state.pendingReinject = ["alpha"]; + runtime.pendingCompactionSource = "manual"; + const settings = createDefaultSettings(); + settings.reinjectOnManualCompaction = true; + + const shouldReinject = consumeCompactionOnSessionCompact(runtime, state, true, settings); + + expect(shouldReinject).toBe(true); + expect(runtime.clearPendingReinjectOnNextUserInput).toBe(false); + }); + + it("clears stale manual flag when a later auto compaction enqueues reinject", () => { + const runtime = createCompactionRuntime(); + const state = createInitialState(); + runtime.clearPendingReinjectOnNextUserInput = true; + state.pendingReinject = ["beta"]; + runtime.pendingCompactionSource = "auto"; + + const shouldReinject = consumeCompactionOnSessionCompact(runtime, state, true, createDefaultSettings()); + + expect(shouldReinject).toBe(true); + expect(runtime.clearPendingReinjectOnNextUserInput).toBe(false); + expect(clearPendingReinjectOnUserPrompt(state, runtime)).toBe(false); + expect(state.pendingReinject).toEqual(["beta"]); + }); +});