From 32e0fe37c2836e11cd59d800cd53b85de1b9f46b Mon Sep 17 00:00:00 2001 From: Anton Date: Mon, 28 Sep 2026 15:13:08 +0300 Subject: [PATCH 1/2] fix(state): report post-compaction state and stop blaming the caller Closes #563 Closes #551 Three things DCP got wrong once opencode compacted a session. 1. `/dcp context` seeded its numbers from compacted messages. The user/tool traversal in analyzeContextTokens correctly skips compacted messages, but the firstAssistant and lastAssistant searches that pick the system-prompt baseline and the totals did not. After a compaction the panel therefore described a turn that is no longer in context: a 900k pre-compaction message became the denominator and inflated the system share, and since `assistant` is a residual (`total - system - user - tools`) every other percentage was wrong as a consequence. 2. `resetOnCompaction` left the counters the compaction invalidated. It cleared toolParameters, prune.tools, prune.messages, messageIds and nudges, but not state.stats, state.systemPromptTokens or state.compressionTiming. The result was a panel claiming "Pruned: (~15,000 tokens)" directly above "0 tools, 0 messages pruned" - the counter counted history that no longer existed. resetSessionState already resets these fields; the two resets are now consistent. 3. A boundary ID orphaned by compaction was reported as a wrong choice. When opencode compacts, DCP resets the alias map and then renumbers the surviving messages from m0001. Every ID the model was holding becomes meaningless, and the resulting error said only "is not available in the current conversation context. Choose an injected ID visible in context" - which reads as "you picked wrong", invites a retry with the same stale value and burns a turn. The existing sentence is kept verbatim (it is part of the contract other code and tests match on) and the actual cause is appended, in both the range boundary resolver and the message-mode issue templates. Tests: tests/compaction-stats.test.ts and tests/stale-boundary.test.ts, six cases. They fail on the current implementation - the stats test reports `system prompt was inflated to 900000 by a compacted message` and `15000 !== 0`, the boundary test reports `the error still blames the caller for a stale ID`. Full suite: 139 passing (was 133). tsc, tsup and prettier clean. --- lib/commands/context.ts | 6 +- lib/compress/message-utils.ts | 10 ++- lib/compress/search.ts | 11 ++- lib/state/utils.ts | 12 +++ tests/compaction-stats.test.ts | 147 +++++++++++++++++++++++++++++++++ tests/stale-boundary.test.ts | 124 +++++++++++++++++++++++++++ 6 files changed, 304 insertions(+), 6 deletions(-) create mode 100644 tests/compaction-stats.test.ts create mode 100644 tests/stale-boundary.test.ts diff --git a/lib/commands/context.ts b/lib/commands/context.ts index c191a809..ca512904 100644 --- a/lib/commands/context.ts +++ b/lib/commands/context.ts @@ -84,9 +84,11 @@ export function analyzeContextTokens(state: SessionState, messages: WithParts[]) total: 0, } + // Messages that opencode already compacted are no longer part of what the + // model sees, so they must not seed the system prompt estimate or the total. let firstAssistant: AssistantMessage | undefined for (const msg of messages) { - if (msg.info.role === "assistant") { + if (msg.info.role === "assistant" && !isMessageCompacted(state, msg)) { const assistantInfo = msg.info as AssistantMessage if ( assistantInfo.tokens?.input > 0 || @@ -102,7 +104,7 @@ export function analyzeContextTokens(state: SessionState, messages: WithParts[]) let lastAssistant: AssistantMessage | undefined for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i] - if (msg.info.role === "assistant") { + if (msg.info.role === "assistant" && !isMessageCompacted(state, msg)) { const assistantInfo = msg.info as AssistantMessage if (assistantInfo.tokens?.output > 0) { lastAssistant = assistantInfo diff --git a/lib/compress/message-utils.ts b/lib/compress/message-utils.ts index 03330c16..031c1c59 100644 --- a/lib/compress/message-utils.ts +++ b/lib/compress/message-utils.ts @@ -80,6 +80,12 @@ export function formatIssues(skippedIssues: string[], skippedCount: number): str return `Unable to compress any messages. Found ${skippedCount} ${issueNoun}:\n${issueLines}` } +// See STALE_BOUNDARY_HINT in compress/search.ts: a missing alias usually means +// opencode compacted and the IDs were renumbered, not that the caller guessed +// wrong (#551). +const staleIdHint = + " It was probably issued before opencode compacted this conversation, which renumbers IDs. Re-read the IDs currently shown in context." + function issueTemplates(format: IdFormat): Record { const message = format === "compact" ? "@4@" : "mNNNN" const block = format === "compact" ? "@b1@" : "bN" @@ -97,8 +103,8 @@ function issueTemplates(format: IdFormat): Record(), iterationNudgeAnchors: new Set(), } + // Everything the old history contributed is gone with it. Leaving the + // counters behind made the /dcp context panel claim pruned tokens while + // simultaneously reporting zero pruned tools and messages. + state.stats = { + pruneTokenCounter: 0, + totalPruneTokens: 0, + } + state.systemPromptTokens = undefined + state.compressionTiming = { + startsByCallId: new Map(), + pendingByCallId: new Map(), + } } diff --git a/tests/compaction-stats.test.ts b/tests/compaction-stats.test.ts new file mode 100644 index 00000000..6c1c01f3 --- /dev/null +++ b/tests/compaction-stats.test.ts @@ -0,0 +1,147 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { mkdtempSync, rmSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" +import type { WithParts } from "../lib/state" + +const root = mkdtempSync(join(tmpdir(), "dcp-compaction-stats-")) +process.env.XDG_DATA_HOME = join(root, "data") +process.env.XDG_CONFIG_HOME = join(root, "config") +process.env.OPENCODE_CONFIG_DIR = join(root, "config", "opencode") +test.after(() => rmSync(root, { recursive: true, force: true })) + +const { createSessionState } = await import("../lib/state") +const { resetOnCompaction } = await import("../lib/state/utils") +const { analyzeContextTokens } = await import("../lib/commands/context") + +function assistant(sessionID: string, id: string, created: number, tokens: any): WithParts { + return { + info: { + id, + role: "assistant", + sessionID, + agent: "build", + model: { providerID: "lab", modelID: "test" }, + time: { created }, + tokens, + }, + parts: [{ id: `prt_${id}`, messageID: id, sessionID, type: "text", text: "ok" }], + } as WithParts +} + +function user(sessionID: string, id: string, created: number, text: string): WithParts { + return { + info: { + id, + role: "user", + sessionID, + agent: "build", + model: { providerID: "lab", modelID: "test" }, + time: { created }, + }, + parts: [{ id: `prt_${id}`, messageID: id, sessionID, type: "text", text }], + } as WithParts +} + +// After opencode compacts, the surviving messages are what the model still +// sees. Every number in the /dcp context panel has to describe that set. +test("context breakdown ignores messages that opencode already compacted", () => { + const sessionID = `ses_stats_${Date.now()}` + const state = createSessionState() + + const messages = [ + // Pre-compaction history, still in the array but no longer in context. + assistant(sessionID, "msg_old_assistant", 1, { + input: 900_000, + output: 5_000, + reasoning: 0, + cache: { read: 0, write: 0 }, + }), + user(sessionID, "msg_old_user", 2, "a very old question that was compacted away"), + // opencode compaction summary, which is the new first input-bearing turn. + assistant(sessionID, "msg_summary", 3, { + input: 12_000, + output: 400, + reasoning: 0, + cache: { read: 0, write: 0 }, + }), + user(sessionID, "msg_new_user", 4, "carry on"), + assistant(sessionID, "msg_new_assistant", 5, { + input: 3_000, + output: 600, + reasoning: 100, + cache: { read: 40_000, write: 0 }, + }), + ] + + state.lastCompaction = 3 + + const breakdown = analyzeContextTokens(state, messages) + + // The stale 905k turn must not become the denominator or the system prompt. + assert.equal( + breakdown.total, + 3_000 + 600 + 100 + 40_000, + "total came from a compacted assistant message", + ) + assert.ok( + breakdown.system <= 12_000, + `system prompt was inflated to ${breakdown.system} by a compacted message`, + ) + assert.doesNotMatch( + String(breakdown.user), + /NaN/, + ) + assert.ok(breakdown.assistant >= 0, "residual went negative") +}) + +test("compaction clears the prune token counters it invalidates", () => { + const state = createSessionState() + state.stats.pruneTokenCounter = 4_321 + state.stats.totalPruneTokens = 15_000 + state.systemPromptTokens = 8_000 + state.compressionTiming.startsByCallId.set("msg_1:call_1", 1234) + state.compressionTiming.pendingByCallId.set("msg_1:call_1", { + messageId: "msg_1", + callId: "call_1", + durationMs: 4_200, + }) + state.toolParameters.set("call_1", { name: "bash" } as any) + + resetOnCompaction(state) + + assert.equal(state.stats.totalPruneTokens, 0) + assert.equal(state.stats.pruneTokenCounter, 0) + assert.equal(state.systemPromptTokens, undefined) + assert.equal(state.compressionTiming.startsByCallId.size, 0) + assert.equal(state.compressionTiming.pendingByCallId.size, 0) + assert.equal(state.toolParameters.size, 0) +}) + +test("the panel no longer reports pruned tokens it also reports as absent", () => { + const sessionID = `ses_stats_panel_${Date.now()}` + const state = createSessionState() + state.stats.totalPruneTokens = 15_000 + state.lastCompaction = 2 + + const messages = [ + assistant(sessionID, "msg_summary", 2, { + input: 10_000, + output: 300, + reasoning: 0, + cache: { read: 5_000, write: 0 }, + }), + user(sessionID, "msg_new", 3, "next"), + ] + + const before = analyzeContextTokens(state, messages) + assert.ok(before.prunedTokens > 0) + + resetOnCompaction(state) + + const after = analyzeContextTokens(state, messages) + assert.equal(after.prunedTokens, 0) + assert.equal(after.prunedToolCount, 0) + assert.equal(after.prunedMessageCount, 0) +}) diff --git a/tests/stale-boundary.test.ts b/tests/stale-boundary.test.ts new file mode 100644 index 00000000..5f2fd2e3 --- /dev/null +++ b/tests/stale-boundary.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { mkdtempSync, rmSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" +import type { WithParts } from "../lib/state" + +const root = mkdtempSync(join(tmpdir(), "dcp-stale-boundary-")) +process.env.XDG_DATA_HOME = join(root, "data") +process.env.XDG_CONFIG_HOME = join(root, "config") +process.env.OPENCODE_CONFIG_DIR = join(root, "config", "opencode") +test.after(() => rmSync(root, { recursive: true, force: true })) + +const { Logger } = await import("../lib/logger") +const { createSessionState } = await import("../lib/state") +const { assignMessageRefs } = await import("../lib/message-ids") +const { resetOnCompaction } = await import("../lib/state/utils") +const { buildSearchContext, resolveBoundaryIds } = await import("../lib/compress/search") +const { createCompressRangeTool } = await import("../lib/compress/range") +const { PromptStore } = await import("../lib/prompts/store") + +const logger = new Logger(false) + +function config(): any { + return { + compress: { + mode: "range", + permission: "allow", + protectedTools: [], + protectUserMessages: false, + protectTags: false, + }, + manualMode: { enabled: false, automaticStrategies: true }, + experimental: { allowSubAgents: false }, + turnProtection: { enabled: false, turns: 4 }, + strategies: { deduplication: { enabled: false }, purgeErrors: { enabled: false } }, + pruneNotification: "off", + protectedFilePatterns: [], + } +} + +function messages(sessionID: string, count: number): WithParts[] { + return Array.from({ length: count }, (_, index) => ({ + info: { + id: `msg_${index + 1}`, + role: index % 2 === 0 ? "user" : "assistant", + sessionID, + agent: "build", + model: { providerID: "lab", modelID: "test" }, + time: { created: index + 1 }, + }, + parts: [{ type: "text", text: `body ${index}` }], + })) as WithParts[] +} + +test("a boundary ID orphaned by compaction explains why it is gone", async () => { + const sessionID = `ses_stale_${Date.now()}` + const state = createSessionState() + const settings = config() + const prompts = new PromptStore(logger, root, false, "xml") + + const beforeCompaction = messages(sessionID, 20) + assignMessageRefs(state, beforeCompaction) + const staleRef = state.messageIds.byRawId.get("msg_18")! + assert.equal(staleRef, "m0018") + + // opencode compacts: the old turns are gone and DCP renumbers the survivors. + const afterCompaction = messages(sessionID, 4) + resetOnCompaction(state) + state.lastCompaction = 2 + assignMessageRefs(state, afterCompaction) + + const searchContext = buildSearchContext(state, afterCompaction) + const client = { + session: { + messages: async () => ({ data: afterCompaction }), + get: async () => ({ data: { parentID: null } }), + }, + } + const tool = createCompressRangeTool({ + client, + state, + config: settings, + logger, + prompts, + } as any) + + let message = "" + try { + await tool.execute( + { + topic: "Stale", + content: [{ startId: staleRef, endId: staleRef, summary: "x" }], + }, + { + ask: async () => {}, + metadata() {}, + sessionID, + messageID: "msg_compress", + } as any, + ) + assert.fail("expected the stale boundary to be rejected") + } catch (error) { + message = String((error as Error).message) + } + + assert.match(message, /not available in the current conversation context/) + assert.match( + message, + /before opencode compacted this conversation/, + "the error still blames the caller for a stale ID", + ) + assert.match(message, /renumbers IDs/) + + // A ref that is genuinely in context must keep working. + const live = state.messageIds.byRawId.get("msg_3")! + const resolved = resolveBoundaryIds( + buildSearchContext(state, afterCompaction), + state, + live, + live, + ) + assert.equal(resolved.startReference.messageId, "msg_3") +}) From 5155ecb2ef70232cf085d9f3f802ce8e7346f528 Mon Sep 17 00:00:00 2001 From: Anton Date: Fri, 2 Oct 2026 01:57:42 +0300 Subject: [PATCH 2/2] style: format compaction-stats test to satisfy format:check --- tests/compaction-stats.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/compaction-stats.test.ts b/tests/compaction-stats.test.ts index 6c1c01f3..38315706 100644 --- a/tests/compaction-stats.test.ts +++ b/tests/compaction-stats.test.ts @@ -89,10 +89,7 @@ test("context breakdown ignores messages that opencode already compacted", () => breakdown.system <= 12_000, `system prompt was inflated to ${breakdown.system} by a compacted message`, ) - assert.doesNotMatch( - String(breakdown.user), - /NaN/, - ) + assert.doesNotMatch(String(breakdown.user), /NaN/) assert.ok(breakdown.assistant >= 0, "residual went negative") })