Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions lib/commands/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand All @@ -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
Expand Down
10 changes: 8 additions & 2 deletions lib/compress/message-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, [singular: string, plural: string]> {
const message = format === "compact" ? "@4@" : "mNNNN"
const block = format === "compact" ? "@b1@" : "bN"
Expand All @@ -97,8 +103,8 @@ function issueTemplates(format: IdFormat): Record<string, [singular: string, plu
`are invalid here. Block IDs like ${block} are not allowed; use ${message} message IDs instead.`,
],
"not-in-context": [
`is not available in the current conversation context. Choose an injected ${message} ID visible in context.`,
`are not available in the current conversation context. Choose injected ${message} IDs visible in context.`,
`is not available in the current conversation context. Choose an injected ${message} ID visible in context.${staleIdHint}`,
`are not available in the current conversation context. Choose injected ${message} IDs visible in context.${staleIdHint}`,
],
protected: [
"refers to a protected message and cannot be compressed.",
Expand Down
11 changes: 9 additions & 2 deletions lib/compress/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ export function buildSearchContext(state: SessionState, rawMessages: WithParts[]
}
}

// An ID that parses but is missing from the lookup is almost never a wrong
// choice by the caller: opencode compaction wipes the alias map, and DCP then
// renumbers the surviving messages from m0001. Without this the model is told it
// picked a bad ID, retries with the same stale value and burns a cycle (#551).
const STALE_BOUNDARY_HINT =
" It was probably issued before opencode compacted this conversation, which renumbers IDs. Re-read the IDs currently shown in context; if the range was already compressed, start from the enclosing compressed block ID instead."

export function resolveBoundaryIds(
context: SearchContext,
state: SessionState,
Expand Down Expand Up @@ -83,13 +90,13 @@ export function resolveBoundaryIds(

if (!startReference) {
issues.push(
`startId ${parsedStartId.ref} is not available in the current conversation context. Choose an injected ID visible in context.`,
`startId ${parsedStartId.ref} is not available in the current conversation context. Choose an injected ID visible in context.${STALE_BOUNDARY_HINT}`,
)
}

if (!endReference) {
issues.push(
`endId ${parsedEndId.ref} is not available in the current conversation context. Choose an injected ID visible in context.`,
`endId ${parsedEndId.ref} is not available in the current conversation context. Choose an injected ID visible in context.${STALE_BOUNDARY_HINT}`,
)
}

Expand Down
12 changes: 12 additions & 0 deletions lib/state/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,4 +342,16 @@ export function resetOnCompaction(state: SessionState): void {
turnNudgeAnchors: new Set<string>(),
iterationNudgeAnchors: new Set<string>(),
}
// 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<string, number>(),
pendingByCallId: new Map(),
}
}
144 changes: 144 additions & 0 deletions tests/compaction-stats.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
124 changes: 124 additions & 0 deletions tests/stale-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})