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
30 changes: 30 additions & 0 deletions devlog/2026-07-25_inactive-block-fixes/REQ.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# REQ: Inactive Block Fixes

## Problem

Two issues with inactive (consumed/GC'd/user-decompressed) compression blocks:

1. **decompress rejects inactive blocks**: `resolveBlockTarget` in `lib/compress/decompress.ts`
rejects any block where `activeBlocks.length === 0` with "not active. It may have already been
decompressed." — even for standalone inactive blocks that are safe to decompress. The model sees
the block's compress call in context but cannot decompress it.

2. **acp_status hides inactive blocks**: `scope:"compressed"` only iterates `activeBlockIds`,
completely hiding consumed/GC'd blocks. The model cannot discover blocks that were consumed by
secondary compression, making it impossible to reason about the full compression history.

## Fix

1. **decompress**: Remove the "not active" rejection in `resolveBlockTarget`. Keep the nested
redirect (consumed blocks inside an active parent still redirect to the parent). Standalone
inactive blocks (user-decompressed, GC'd, orphaned) can now be decompressed.

2. **acp_status**: Show ALL blocks from `blocksById` (not just active ones). Add `[inactive]`
marker to the metadata line (not the topic line). Add "N active, M inactive/consumed" summary.

## Files

- `lib/compress/decompress.ts` — remove "not active" rejection
- `lib/compress/status.ts` — show all blocks, add inactive marker
- `tests/acp-status.test.ts` — 3 new tests (inactive visibility, user-decompressed, no marker on active)
- `tests/decompress-logic.test.ts` — 2 new tests (standalone inactive, consumed with active ancestor)
57 changes: 57 additions & 0 deletions devlog/2026-07-25_inactive-block-fixes/WORKLOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# WORKLOG: Inactive Block Fixes

## Changes

### lib/compress/decompress.ts
- `resolveBlockTarget` (line ~120): Removed the `return { ok: false, error: "not active" }` branch.
Previously, when `activeBlocks.length === 0` and no active ancestor was found, decompress was
rejected. Now, standalone inactive blocks pass through to `{ ok: true, targets: [target] }`.
The nested-redirect (consumed blocks inside an active parent) is kept — decompressing a consumed
child directly would restore 0 messages since the parent still claims them.

### lib/compress/status.ts
- `createAcpStatusTool` scope:"compressed" handler (line ~436): Changed block source from
`activeBlockIds` (active only) to `blocksById.values()` (all blocks). Inactive/consumed blocks
now appear in the compressed drilldown.
- `renderCompressedDrilldown` (line ~340): Added `[inactive]` marker to the metadata line for
blocks where `!b.active`. Added "N active, M inactive/consumed" summary line when inactive blocks
exist. Active blocks get no marker (default state).

### tests/acp-status.test.ts
- 3 new tests:
- scope=compressed shows inactive/consumed blocks with marker and summary
- scope=compressed marks user-decompressed blocks as inactive
- scope=compressed does not add inactive marker to active blocks

### tests/decompress-logic.test.ts
- 2 new tests:
- findActiveAncestorBlockId returns null for standalone inactive block (the condition that now
allows decompress to proceed)
- findActiveAncestorBlockId returns active ancestor for consumed inactive block (the condition
that still triggers the redirect)

## Verification

- typecheck: pass
- tests: 859 pass (846 existing + 13 new across 4 commits), 0 fail
- build: pass

## Round 2 dual-agent review findings (fixed in commit 4)

### Fixed: toFile writes garbage for inactive blocks
`lib/compress/decompress.ts:325` — inactive blocks had empty `activeBlocks`, so fallback was
`activeBlocks[0]?.summary` (undefined) → wrote literal `"(no content available)"`. Changed to
`targets[0]?.blocks[0]?.summary` so the block's actual summary is written.

### Fixed: /acp decompress slash command still rejected inactive blocks
`lib/commands/decompress.ts:153-161` — same "not active" rejection existed in the slash command
path. Applied the same fix: keep nested-redirect, drop "not active" rejection.

### Added: E2E tests for actual decompress tool behavior
`tests/inactive-block-decompress.test.ts` — 7 tests exercising the real decompress tool:
- resolveCompressionTarget returns target for inactive block
- decompress tool succeeds for standalone inactive block (actual behavior change)
- decompress tool redirects for consumed block with active parent
- decompress tool works normally for active block (control)
- toFile on inactive block writes summary (not placeholder)
- decompress succeeds when all ancestor chain is inactive (multi-block scenario)
9 changes: 0 additions & 9 deletions lib/commands/decompress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,15 +149,6 @@ export async function handleDecompressCommand(ctx: DecompressCommandContext): Pr
)
return
}

await sendIgnoredMessage(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里为什么要删除

client,
sessionId,
`Compression ${target.displayId} is not active.`,
params,
logger,
)
return
}

const activeMessagesBefore = snapshotActiveMessages(messagesState)
Expand Down
8 changes: 3 additions & 5 deletions lib/compress/decompress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ function resolveSingleBlockTarget(
}
}

// Keep nested-redirect (consumed blocks must go through parent) but drop the
// "not active" rejection — inactive standalone blocks can be decompressed.
const activeBlocks = target.blocks.filter((block) => block.active)
if (activeBlocks.length === 0) {
const activeAncestorBlockId = findActiveAncestorBlockId(messagesState, target)
Expand All @@ -126,10 +128,6 @@ function resolveSingleBlockTarget(
error: `Error: Block ${target.displayId} is nested inside active block ${activeAncestorBlockId}. Decompress block ${activeAncestorBlockId} first.`,
}
}
return {
ok: false,
error: `Error: Block ${target.displayId} is not active. It may have already been decompressed.`,
}
}

return { ok: true, targets: [target] }
Expand Down Expand Up @@ -324,7 +322,7 @@ export function createDecompressTool(ctx: ToolContext): ReturnType<typeof tool>
const fileContent =
lines.length > 0
? lines.join("\n\n---\n\n")
: (activeBlocks[0]?.summary ?? "(no content available)")
: (targets[0]?.blocks[0]?.summary ?? "(no content available)")
await writeFile(targetPath, fileContent, "utf-8")

const displayIds = targets.map((t) => `b${t.displayId}`).join(", ")
Expand Down
19 changes: 13 additions & 6 deletions lib/compress/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,13 @@ function renderCompressedDrilldown(
lines.push("")

const shown = sorted.slice(0, limit)
const activeCount = sorted.filter((b) => b.active).length
const inactiveCount = sorted.length - activeCount
if (inactiveCount > 0) {
lines.push(`${activeCount} active, ${inactiveCount} inactive/consumed`)
lines.push("")
}

for (const b of shown) {
const survived = b.survivedCount ?? 0
const gen = b.generation ?? "young"
Expand All @@ -346,9 +353,10 @@ function renderCompressedDrilldown(
b.consumedBlockIds && b.consumedBlockIds.length > 0
? ` nested=[${b.consumedBlockIds.map((n) => `b${n}`).join(",")}]`
: ""
const status = b.active ? "" : " [inactive]"
const topic = b.topic || "(no topic)"
lines.push(
` b${b.blockId} ${formatTokens(b.compressedTokens)}→${formatTokens(b.summaryTokens)} ${formatAge(b.createdAt)} ${formatIdRange(b)} age=${survived} ${gen} eff=${effCount}${consumed}`,
` b${b.blockId} ${formatTokens(b.compressedTokens)}→${formatTokens(b.summaryTokens)} ${formatAge(b.createdAt)} ${formatIdRange(b)} age=${survived} ${gen} eff=${effCount}${consumed}${status}`,
)
lines.push(` "${topic}"`)
}
Expand Down Expand Up @@ -433,10 +441,9 @@ export function createAcpStatusTool(ctx: ToolContext): ReturnType<typeof tool> {
Number.isFinite(args.limit) && args.limit! > 0 ? Math.min(args.limit!, 200) : 30

const msgState = ctx.state.prune.messages
const activeIds = Array.from(msgState.activeBlockIds).sort((a, b) => a - b)
const allBlocks = activeIds
.map((id) => msgState.blocksById.get(id))
.filter((b): b is NonNullable<typeof b> => b !== undefined && b.active)
const allBlocks = Array.from(msgState.blocksById.values()).sort(
(a, b) => a.blockId - b.blockId,
)

const lines: string[] = []

Expand Down Expand Up @@ -471,7 +478,7 @@ export function createAcpStatusTool(ctx: ToolContext): ReturnType<typeof tool> {
...renderOverview(
visibleMsgs,
summaryTokens,
allBlocks,
allBlocks.filter((b) => b.active),
fetchFailed,
rawMessages,
ctx,
Expand Down
68 changes: 68 additions & 0 deletions tests/acp-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,3 +344,71 @@ test("acp_status: invalid sort falls back to size", async () => {

assert.match(result, /Sorted by size/)
})

test("acp_status: scope=compressed shows inactive/consumed blocks", async () => {
const blocks = blocksMap(
makeBlock({ blockId: 1, active: true, compressedTokens: 5000, topic: "live" }),
makeBlock({
blockId: 2,
active: false,
compressedTokens: 3000,
topic: "consumed",
parentBlockIds: [1],
}),
)
const result = await runStatus([1], blocks, { scope: "compressed" })

assert.match(result, /b2/)
assert.match(result, /"consumed"/)
assert.match(result, /\[inactive\]/)
assert.match(result, /1 active, 1 inactive\/consumed/)
})

test("acp_status: scope=compressed marks user-decompressed blocks as inactive", async () => {
const blocks = blocksMap(
makeBlock({
blockId: 5,
active: false,
deactivatedByUser: true,
topic: "user-decompressed",
}),
)
const result = await runStatus([], blocks, { scope: "compressed" })

assert.match(result, /b5/)
assert.match(result, /\[inactive\]/)
assert.match(result, /0 active, 1 inactive\/consumed/)
})

test("acp_status: scope=compressed does not add inactive marker to active blocks", async () => {
const blocks = blocksMap(makeBlock({ blockId: 1, active: true, topic: "active block" }))
const result = await runStatus([1], blocks, { scope: "compressed" })

assert.match(result, /b1/)
assert.doesNotMatch(result, /\[inactive\]/)
})

test("acp_status: overview (no scope) excludes inactive blocks from count and totals", async () => {
const blocks = blocksMap(
makeBlock({
blockId: 1,
active: true,
summaryTokens: 1000,
compressedTokens: 5000,
topic: "live",
}),
makeBlock({
blockId: 2,
active: false,
summaryTokens: 500,
compressedTokens: 3000,
topic: "dead",
deactivatedByUser: true,
}),
)
const result = await runStatus([1], blocks)

assert.match(result, /1 active/)
assert.doesNotMatch(result, /2 active/)
assert.doesNotMatch(result, /"dead"/)
})
22 changes: 22 additions & 0 deletions tests/decompress-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,3 +580,25 @@ test("resolveDecompressMode: empty string startId/endId → error (treated as mi
assert.equal(result.ok, false)
if (!result.ok) assert.match(result.error, /Must specify either/)
})

// --- Inactive block decompress ---

test("findActiveAncestorBlockId returns null for standalone inactive block", () => {
const inactiveBlock = makeBlock({ blockId: 5, active: false, parentBlockIds: [] })
const ms = makeMessagesState({ blocksById: new Map([[5, inactiveBlock]]) })
const target = makeTarget({ blocks: [inactiveBlock] })
assert.equal(findActiveAncestorBlockId(ms, target), null)
})

test("findActiveAncestorBlockId returns active ancestor for consumed inactive block", () => {
const activeParent = makeBlock({ blockId: 10, active: true })
const consumedBlock = makeBlock({ blockId: 5, active: false, parentBlockIds: [10] })
const ms = makeMessagesState({
blocksById: new Map([
[5, consumedBlock],
[10, activeParent],
]),
})
const target = makeTarget({ blocks: [consumedBlock] })
assert.equal(findActiveAncestorBlockId(ms, target), 10)
})
Loading
Loading