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..38315706 --- /dev/null +++ b/tests/compaction-stats.test.ts @@ -0,0 +1,144 @@ +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") +})