Skip to content

Commit df83bc8

Browse files
fix: filter getActiveSummaryTokenUsage by visible message IDs (#202)
* fix: filter getActiveSummaryTokenUsage by visible message IDs getActiveSummaryTokenUsage counted ALL active blocks' summary tokens regardless of whether their compress calls scrolled out of context via opencode compaction. In 448-block sessions this inflated summary usage from ~6K (real) to ~151K, causing false nudge triggers every turn and false tier 2 triggers above the 50K threshold. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * test: add visibility filtering tests for getActiveSummaryTokenUsage 7 new tests covering: no filter (backward compat), inactive skip, filter by visibleMessageIds, empty set, all visible, missing compressMessageId, 448-block simulation matching real session ses_102504697. Fixed 2 existing tests in token-usage.test.ts where block 7's compressMessageId was not in the message list — after the fix, the block must be visible for summaryBuffer to count it. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * docs: add devlog for summaryBuffer visibility fix Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix(status): filter acp_status summary tokens by visible message IDs Same over-counting bug as getActiveSummaryTokenUsage — collectVisibleMessages summed ALL active blocks regardless of visibility. The model sees this number via acp_status breakdown and uses it to decide compression strategy. Now filters by compressMessageId visibility like getActiveSummaryTokenUsage. Found by dual-agent review (Oracle + General, M1 consensus). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> --------- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 7e467bc commit df83bc8

8 files changed

Lines changed: 232 additions & 5 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# REQ: summaryBuffer over-counting fix
2+
3+
## Problem
4+
5+
`getActiveSummaryTokenUsage()` counts ALL active compression blocks' summary tokens,
6+
regardless of whether those blocks' compress tool calls are still visible in the
7+
current context window.
8+
9+
In long-running sessions (e.g., 448 blocks, 3683 messages), opencode's own
10+
compaction removes old messages — including old compress tool calls. ACP's state
11+
still marks those blocks as active. The function returns 151K tokens instead of
12+
the actual ~6K visible summaries.
13+
14+
## Impact
15+
16+
This inflation feeds into `summaryBuffer` (context limit extension) and nudge
17+
trigger growth computation:
18+
19+
- **False nudge triggers**: Growth appears as 151K → nudge fires every turn
20+
- **False tier 2 triggers**: T1 summary tokens appear >> 50K threshold
21+
- **Misleading stats**: `/acp stats` shows "summary 146%" — impossible
22+
- **Misleading context %**: summaryBuffer extends max limit by 151K instead of 6K
23+
24+
## Solution
25+
26+
Add optional `visibleMessageIds: Set<string>` parameter to
27+
`getActiveSummaryTokenUsage`. When provided (by the inject hook which has the
28+
full message list), only blocks whose `compressMessageId` is in the set are
29+
counted.
30+
31+
Call sites updated:
32+
- `lib/messages/inject/utils.ts``isContextOverLimits` passes message IDs
33+
- `lib/commands/stats.ts``handleStatsCommand` passes message IDs
34+
35+
## Non-Goals
36+
37+
- `getTierTokenUsage()` in PR #200 has the same bug pattern — will be fixed when
38+
PR #200 rebases on this fix.
39+
- No behavioral change when `visibleMessageIds` is omitted (backward compat).
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# WORKLOG: summaryBuffer over-counting fix
2+
3+
## Changes
4+
5+
### `lib/state/utils.ts`
6+
- `getActiveSummaryTokenUsage(state, visibleMessageIds?)`: Added optional filter.
7+
When provided, only counts blocks whose `compressMessageId` is in the set.
8+
Blocks without `compressMessageId` are counted regardless (backward compat).
9+
10+
### `lib/messages/inject/utils.ts`
11+
- `isContextOverLimits`: Builds `new Set(messages.map(m => m.info.id))` and passes
12+
to `getActiveSummaryTokenUsage`. This represents the messages opencode is about
13+
to send to the API (after its own compaction).
14+
15+
### `lib/commands/stats.ts`
16+
- `handleStatsCommand`: Builds visibleMessageIds from ctx.messages. Filters
17+
`sessionSummaryTokens` to only count visible summaries.
18+
19+
### `tests/summary-buffer-visibility.test.ts` (NEW)
20+
7 tests covering: no filter (backward compat), inactive skip, filter, empty set,
21+
all visible, missing compressMessageId, 448-block simulation.
22+
23+
### `tests/token-usage.test.ts`
24+
Fixed 2 tests: block 7 `compressMessageId` set to `"msg-assistant-post-compaction"`
25+
(must be in visible messages for summaryBuffer to count after the fix).
26+
27+
## Verification
28+
- typecheck: PASS
29+
- tests: 880 pass (873 original + 7 new)
30+
- build: PASS

lib/commands/stats.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,12 @@ export async function handleStatsCommand(ctx: StatsCommandContext): Promise<void
9494

9595
// Session stats from in-memory state
9696
const sessionTokens = state.stats.totalPruneTokens
97+
const visibleMessageIds = new Set(messages.map((m) => m.info.id))
9798
const sessionSummaryTokens = Array.from(state.prune.messages.blocksById.values()).reduce(
98-
(total, block) => (block.active ? total + block.summaryTokens : total),
99+
(total, block) =>
100+
block.active && visibleMessageIds.has(block.compressMessageId ?? "")
101+
? total + block.summaryTokens
102+
: total,
99103
0,
100104
)
101105
const sessionDurationMs = getActiveCompressionTargets(state.prune.messages).reduce(

lib/compress/status.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,16 @@ function collectVisibleMessages(
7575
const result: VisibleMessageInfo[] = []
7676
let summaryTokens = 0
7777

78+
const visibleMessageIds = new Set(rawMessages.map((m) => m.info.id))
79+
7880
const activeBlocks = Array.from(ctx.state.prune.messages.activeBlockIds)
7981
.map((id) => ctx.state.prune.messages.blocksById.get(id))
8082
.filter((b): b is NonNullable<typeof b> => b !== undefined && b.active)
8183

8284
for (const block of activeBlocks) {
85+
if (block.compressMessageId && !visibleMessageIds.has(block.compressMessageId)) {
86+
continue
87+
}
8388
summaryTokens += block.summaryTokens || 0
8489
}
8590

lib/messages/inject/utils.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,10 @@ export function isContextOverLimits(
156156
messages: WithParts[],
157157
) {
158158
const summaryTokenExtension = config.compress.summaryBuffer
159-
? getActiveSummaryTokenUsage(state)
159+
? getActiveSummaryTokenUsage(
160+
state,
161+
new Set(messages.map((m) => m.info.id)),
162+
)
160163
: 0
161164
const resolvedMaxContextLimit = resolveContextTokenLimit(
162165
config,

lib/state/utils.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,13 +335,27 @@ export function collectTurnNudgeAnchors(messages: WithParts[]): Set<string> {
335335
return anchors
336336
}
337337

338-
export function getActiveSummaryTokenUsage(state: SessionState): number {
338+
/**
339+
* Sum summary tokens of active blocks.
340+
* When visibleMessageIds is provided, only counts blocks whose compressMessageId
341+
* is still in the context (prevents over-counting from blocks whose compress
342+
* calls scrolled out via opencode compaction).
343+
*/
344+
export function getActiveSummaryTokenUsage(
345+
state: SessionState,
346+
visibleMessageIds?: Set<string>,
347+
): number {
339348
let total = 0
340349
for (const blockId of state.prune.messages.activeBlockIds) {
341350
const block = state.prune.messages.blocksById.get(blockId)
342351
if (!block || !block.active) {
343352
continue
344353
}
354+
if (visibleMessageIds && block.compressMessageId) {
355+
if (!visibleMessageIds.has(block.compressMessageId)) {
356+
continue
357+
}
358+
}
345359
total += block.summaryTokens
346360
}
347361
return total
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import assert from "node:assert/strict"
2+
import test from "node:test"
3+
import { getActiveSummaryTokenUsage, createPruneMessagesState } from "../lib/state/utils"
4+
import type { CompressionBlock, SessionState } from "../lib/state/types"
5+
6+
function makeBlock(overrides: Partial<CompressionBlock> & { blockId: number }): CompressionBlock {
7+
return {
8+
runId: 1,
9+
active: true,
10+
deactivatedByUser: false,
11+
compressedTokens: 1000,
12+
summaryTokens: 100,
13+
durationMs: 0,
14+
mode: "range",
15+
topic: "test",
16+
batchTopic: "",
17+
startId: "s1",
18+
endId: "e1",
19+
anchorMessageId: "anchor-1",
20+
compressMessageId: "compress-1",
21+
compressCallId: undefined,
22+
includedBlockIds: [],
23+
consumedBlockIds: [],
24+
parentBlockIds: [],
25+
directMessageIds: [],
26+
directToolIds: [],
27+
effectiveMessageIds: [],
28+
effectiveToolIds: [],
29+
createdAt: 0,
30+
summary: "summary",
31+
survivedCount: 0,
32+
generation: "young",
33+
...overrides,
34+
}
35+
}
36+
37+
function buildSessionState(blocks: CompressionBlock[]): Pick<SessionState, "prune"> {
38+
const pruneMessages = createPruneMessagesState()
39+
for (const block of blocks) {
40+
pruneMessages.blocksById.set(block.blockId, block)
41+
if (block.active) {
42+
pruneMessages.activeBlockIds.add(block.blockId)
43+
}
44+
}
45+
return { prune: { tools: new Map(), messages: pruneMessages } }
46+
}
47+
48+
test("getActiveSummaryTokenUsage: sums all active blocks without visibility filter", () => {
49+
const state = buildSessionState([
50+
makeBlock({ blockId: 1, summaryTokens: 100 }),
51+
makeBlock({ blockId: 2, summaryTokens: 200 }),
52+
makeBlock({ blockId: 3, summaryTokens: 300 }),
53+
])
54+
assert.equal(getActiveSummaryTokenUsage(state as SessionState), 600)
55+
})
56+
57+
test("getActiveSummaryTokenUsage: skips inactive blocks", () => {
58+
const state = buildSessionState([
59+
makeBlock({ blockId: 1, summaryTokens: 100, active: true }),
60+
makeBlock({ blockId: 2, summaryTokens: 200, active: false }),
61+
])
62+
assert.equal(getActiveSummaryTokenUsage(state as SessionState), 100)
63+
})
64+
65+
test("getActiveSummaryTokenUsage: filters by visibleMessageIds", () => {
66+
const state = buildSessionState([
67+
makeBlock({ blockId: 1, summaryTokens: 100, compressMessageId: "msg-A" }),
68+
makeBlock({ blockId: 2, summaryTokens: 200, compressMessageId: "msg-B" }),
69+
makeBlock({ blockId: 3, summaryTokens: 300, compressMessageId: "msg-C" }),
70+
])
71+
const visible = new Set(["msg-A", "msg-C"])
72+
assert.equal(getActiveSummaryTokenUsage(state as SessionState, visible), 400)
73+
})
74+
75+
test("getActiveSummaryTokenUsage: empty visibleMessageIds returns 0", () => {
76+
const state = buildSessionState([
77+
makeBlock({ blockId: 1, summaryTokens: 100, compressMessageId: "msg-A" }),
78+
makeBlock({ blockId: 2, summaryTokens: 200, compressMessageId: "msg-B" }),
79+
])
80+
const visible = new Set<string>()
81+
assert.equal(getActiveSummaryTokenUsage(state as SessionState, visible), 0)
82+
})
83+
84+
test("getActiveSummaryTokenUsage: all blocks visible returns same as unfiltered", () => {
85+
const state = buildSessionState([
86+
makeBlock({ blockId: 1, summaryTokens: 100, compressMessageId: "msg-A" }),
87+
makeBlock({ blockId: 2, summaryTokens: 200, compressMessageId: "msg-B" }),
88+
])
89+
const visible = new Set(["msg-A", "msg-B"])
90+
assert.equal(getActiveSummaryTokenUsage(state as SessionState, visible), 300)
91+
})
92+
93+
test("getActiveSummaryTokenUsage: blocks without compressMessageId counted when filter provided", () => {
94+
const state = buildSessionState([
95+
makeBlock({ blockId: 1, summaryTokens: 100, compressMessageId: undefined }),
96+
makeBlock({ blockId: 2, summaryTokens: 200, compressMessageId: "msg-B" }),
97+
])
98+
const visible = new Set(["msg-B"])
99+
assert.equal(getActiveSummaryTokenUsage(state as SessionState, visible), 300)
100+
})
101+
102+
test("getActiveSummaryTokenUsage: simulates 448-block session with only 26 visible", () => {
103+
const blocks: CompressionBlock[] = []
104+
for (let i = 1; i <= 448; i++) {
105+
blocks.push(
106+
makeBlock({
107+
blockId: i,
108+
summaryTokens: 340,
109+
compressMessageId: `msg-${i}`,
110+
}),
111+
)
112+
}
113+
const state = buildSessionState(blocks)
114+
115+
const allVisible = new Set<string>()
116+
for (let i = 1; i <= 448; i++) allVisible.add(`msg-${i}`)
117+
const recentVisible = new Set<string>()
118+
for (let i = 423; i <= 448; i++) recentVisible.add(`msg-${i}`)
119+
120+
const withoutFilter = getActiveSummaryTokenUsage(state as SessionState)
121+
const withAllVisible = getActiveSummaryTokenUsage(state as SessionState, allVisible)
122+
const withRecentOnly = getActiveSummaryTokenUsage(state as SessionState, recentVisible)
123+
124+
assert.equal(withoutFilter, 448 * 340)
125+
assert.equal(withAllVisible, 448 * 340)
126+
assert.equal(withRecentOnly, 26 * 340)
127+
assert.ok(withRecentOnly < withoutFilter * 0.1)
128+
})

tests/token-usage.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,9 @@ test("isContextOverLimits extends the max threshold by active summary tokens", (
253253
state.lastCompaction = 2
254254

255255
const storedSummary = wrapCompressedSummary(7, repeatedWord("summary", 120))
256-
state.prune.messages.blocksById.set(7, createActiveBlock(7, storedSummary, 1000))
256+
const block7 = createActiveBlock(7, storedSummary, 1000)
257+
block7.compressMessageId = "msg-assistant-post-compaction"
258+
state.prune.messages.blocksById.set(7, block7)
257259
state.prune.messages.activeBlockIds.add(7)
258260

259261
const freshReportedTotal = 2400 + 600 + 150 + 300
@@ -287,7 +289,9 @@ test("isContextOverLimits does not extend the max threshold when summaryBuffer i
287289
state.lastCompaction = 2
288290

289291
const storedSummary = wrapCompressedSummary(7, repeatedWord("summary", 120))
290-
state.prune.messages.blocksById.set(7, createActiveBlock(7, storedSummary, 1000))
292+
const block7b = createActiveBlock(7, storedSummary, 1000)
293+
block7b.compressMessageId = "msg-assistant-post-compaction"
294+
state.prune.messages.blocksById.set(7, block7b)
291295
state.prune.messages.activeBlockIds.add(7)
292296

293297
const freshReportedTotal = 2400 + 600 + 150 + 300

0 commit comments

Comments
 (0)