diff --git a/docs/superpowers/plans/2026-08-22-dcp-reliability-troubleshooting/phase-02-stats-and-anchor-contracts.md b/docs/superpowers/plans/2026-08-22-dcp-reliability-troubleshooting/phase-02-stats-and-anchor-contracts.md index f1303a4..6aed8ff 100644 --- a/docs/superpowers/plans/2026-08-22-dcp-reliability-troubleshooting/phase-02-stats-and-anchor-contracts.md +++ b/docs/superpowers/plans/2026-08-22-dcp-reliability-troubleshooting/phase-02-stats-and-anchor-contracts.md @@ -23,6 +23,7 @@ **Files:** - Modify: `tests/commands-context.test.ts` - Modify: `tests/commands-stats.test.ts` +- Modify: `tests/index.test.ts` - Modify: `src/commands/context.ts` - Modify: `src/commands/stats.ts` @@ -87,6 +88,10 @@ pnpm vitest run tests/commands-context.test.ts tests/commands-stats.test.ts Expected: PASS. +The extension-level compaction regression must also verify that `dcp:stats` +preserves cumulative values while `dcp:context` reports zero active pruned +tool calls after `session_compact`. + ### Task 2: Prove stale-anchor reconciliation and remove stale commentary **Files:** @@ -146,7 +151,7 @@ Do not alter `addAnchorIfAllowed()` or add cleanup to `session_compact`. Run: ```bash -pnpm vitest run tests/commands-context.test.ts tests/commands-stats.test.ts tests/pipeline.test.ts +pnpm vitest run tests/commands-context.test.ts tests/commands-stats.test.ts tests/index.test.ts tests/pipeline.test.ts pnpm typecheck git diff --check ``` @@ -156,6 +161,6 @@ Expected: all PASS. - [ ] **Step 5: Commit Phase 2** ```bash -git add src/commands/context.ts src/commands/stats.ts src/messages/inject.ts tests/commands-context.test.ts tests/commands-stats.test.ts tests/pipeline.test.ts +git add docs/superpowers/plans/2026-08-22-dcp-reliability-troubleshooting/phase-02-stats-and-anchor-contracts.md src/commands/context.ts src/commands/stats.ts src/messages/inject.ts tests/commands-context.test.ts tests/commands-stats.test.ts tests/index.test.ts tests/pipeline.test.ts git commit -m "fix: clarify dcp statistics and anchor cleanup" ``` diff --git a/src/commands/context.ts b/src/commands/context.ts index 6323052..b517240 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -16,7 +16,7 @@ export function contextCommand( lines.push(" Tokens: unavailable"); } - lines.push(` Pruned tool calls: ${state.prune.tools.size}`); + lines.push(` Currently pruned tool calls: ${state.prune.tools.size}`); lines.push(` Active compression blocks: ${state.prune.messages.activeBlockIds.size}`); lines.push(` Total blocks: ${state.prune.messages.blocksById.size}`); lines.push(` Tool cache entries: ${state.toolParameters.size}`); diff --git a/src/commands/stats.ts b/src/commands/stats.ts index 2fd0cab..12d8f6b 100644 --- a/src/commands/stats.ts +++ b/src/commands/stats.ts @@ -3,9 +3,9 @@ import type { SessionState } from "../state/types.ts"; export function statsCommand(state: SessionState): string { return [ "DCP Session Statistics:", - ` Tools pruned: ${state.stats.toolsPruned}`, - ` Total tokens saved (pruning): ${state.stats.totalPruneTokens}`, - ` Messages compressed: ${state.stats.messagesCompressed}`, + ` Tools pruned this session: ${state.stats.toolsPruned}`, + ` Cumulative tokens saved by pruning: ${state.stats.totalPruneTokens}`, + ` Messages compressed this session: ${state.stats.messagesCompressed}`, ` Prune token counter: ${state.stats.pruneTokenCounter}`, ].join("\n"); } diff --git a/src/messages/inject.ts b/src/messages/inject.ts index f2cba51..02b38b5 100644 --- a/src/messages/inject.ts +++ b/src/messages/inject.ts @@ -246,10 +246,6 @@ function addAnchorIfAllowed( // Anchors not in current messages (stale) are ignored for distance calculation } - // TODO: Stale anchors (keys not present in current messages) are never pruned from the Sets. - // In sessions with heavy compaction, Sets may grow over time. A future task should clean them - // up — e.g., after compaction by intersecting anchor sets with keys of surviving messages. - if (closestDistance >= frequency) { anchorSet.add(targetKey); } diff --git a/tests/commands-context.test.ts b/tests/commands-context.test.ts index 86f607c..adcc2f8 100644 --- a/tests/commands-context.test.ts +++ b/tests/commands-context.test.ts @@ -13,6 +13,8 @@ describe("context command", () => { expect(result).toContain("200000"); expect(result).toContain("2.5"); expect(result).toContain("Current user turn: 0"); + expect(result).toContain("Currently pruned tool calls: 1"); + expect(result).not.toContain("\n Pruned tool calls:"); }); it("handles null token values in context usage (E5)", () => { diff --git a/tests/commands-stats.test.ts b/tests/commands-stats.test.ts index b2969f0..671b08c 100644 --- a/tests/commands-stats.test.ts +++ b/tests/commands-stats.test.ts @@ -10,8 +10,8 @@ describe("stats command", () => { state.stats.messagesCompressed = 3; const result = statsCommand(state); - expect(result).toContain("5"); - expect(result).toContain("1234"); - expect(result).toContain("3"); + expect(result).toContain("Tools pruned this session: 5"); + expect(result).toContain("Cumulative tokens saved by pruning: 1234"); + expect(result).toContain("Messages compressed this session: 3"); }); }); diff --git a/tests/index.test.ts b/tests/index.test.ts index a68f65e..8e6811b 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -845,14 +845,100 @@ describe("sub-agent support", () => { } }); - it("session_compact handler clears subagent cache without error", async () => { - const { api, handlers } = createMockApi(); + it("preserves cumulative stats while compaction clears active pruning", async () => { + const { api, handlers, commands } = createMockApi(); createExtension(api); + const sessionStartHandler = handlers.get("session_start")?.[0]; + await (sessionStartHandler as (...args: unknown[]) => Promise)( + { reason: "new" }, + { + sessionManager: { + getSessionDir: () => "/tmp/test-session-dir", + getSessionId: () => "session", + getBranch: () => [], + }, + getContextUsage: () => undefined, + }, + ); + + const contextHandler = handlers.get("context")?.[0]; + await (contextHandler as (...args: unknown[]) => Promise)( + { + messages: [ + { role: "user", content: [{ type: "text", text: "find the file" }], timestamp: 1001 }, + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call-1", + name: "search_files", + arguments: { query: "foo" }, + }, + ], + stopReason: "toolUse", + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalTokens: 0, + }, + timestamp: 1002, + }, + { + role: "toolResult", + toolCallId: "call-1", + toolName: "search_files", + content: [{ type: "text", text: "result" }], + isError: false, + timestamp: 1003, + }, + ], + }, + { + getContextUsage: () => ({ tokens: 1000, contextWindow: 200000, percent: 0.5 }), + }, + ); + + const sweepHandler = commands.get("dcp:sweep") as { + handler: (...args: unknown[]) => Promise; + }; + await sweepHandler.handler("", { ui: { notify: vi.fn() } }); + + const statsHandler = commands.get("dcp:stats") as { + handler: (...args: unknown[]) => Promise; + }; + const statsBeforeNotify = vi.fn(); + await statsHandler.handler("", { ui: { notify: statsBeforeNotify } }); + const statsBefore = statsBeforeNotify.mock.calls[0]?.[0] as string; + expect(statsBefore).toContain("Tools pruned this session: 1"); + expect(statsBefore).toMatch(/Cumulative tokens saved by pruning: \d+/); + + const contextCommandHandler = commands.get("dcp:context") as { + handler: (...args: unknown[]) => Promise; + }; + const contextBeforeNotify = vi.fn(); + await contextCommandHandler.handler("", { + getContextUsage: () => undefined, + ui: { notify: contextBeforeNotify }, + }); + expect(contextBeforeNotify.mock.calls[0]?.[0]).toContain("Currently pruned tool calls: 1"); + const sessionCompactHandler = handlers.get("session_compact")?.[0]; - await expect( - (sessionCompactHandler as (...args: unknown[]) => Promise)({}, {}), - ).resolves.not.toThrow(); + await (sessionCompactHandler as (...args: unknown[]) => Promise)({}, {}); + + const statsAfterNotify = vi.fn(); + await statsHandler.handler("", { ui: { notify: statsAfterNotify } }); + expect(statsAfterNotify.mock.calls[0]?.[0]).toBe(statsBefore); + + const contextAfterNotify = vi.fn(); + await contextCommandHandler.handler("", { + getContextUsage: () => undefined, + ui: { notify: contextAfterNotify }, + }); + expect(contextAfterNotify.mock.calls[0]?.[0]).toContain("Currently pruned tool calls: 0"); }); it("before_agent_start returns early when PI_SUBAGENT_CHILD=1", async () => { diff --git a/tests/pipeline.test.ts b/tests/pipeline.test.ts index e364b43..34932cd 100644 --- a/tests/pipeline.test.ts +++ b/tests/pipeline.test.ts @@ -19,6 +19,22 @@ describe("runPipeline", () => { expect(result.messages.length).toBe(2); }); + it("removes stale nudge anchors and preserves surviving anchors", () => { + const state = createSessionState(); + const config = makeDefaultConfig(); + state.nudges.turnAnchors.add("user:1:0"); + state.nudges.turnAnchors.add("user:999:0"); + state.nudges.contextLimitAnchors.add("assistant:2:0"); + state.nudges.iterationAnchors.add("assistant:998:0"); + const messages = [makeUserMessage("kept user", 1), makeAssistantMessage("kept assistant", 2)]; + + runPipeline(state, config, messages, undefined); + + expect(state.nudges.turnAnchors).toEqual(new Set(["user:1:0"])); + expect(state.nudges.contextLimitAnchors).toEqual(new Set(["assistant:2:0"])); + expect(state.nudges.iterationAnchors).toEqual(new Set()); + }); + it("strips hallucinated DCP tags from assistant messages", () => { const state = createSessionState(); const config = makeDefaultConfig();