Skip to content
Merged
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
36 changes: 36 additions & 0 deletions devlog/2026-07-30_tool-pair-integrity/REQ.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# REQ: Fix Issue #247 — Tool Pair Integrity in Compression Ranges

## Objective

Prevent compression ranges from splitting tool_use/tool_result pairs, which creates orphaned references that providers reject.

## Problem

In OpenCode's message format, a tool call spans two messages:
- **Assistant message** (tool_use): contains the tool call initiation
- **User message** (tool_result): contains the tool call response
Both share the same `callID`.

When a compression range boundary falls between these two messages:
- One is pruned (replaced with summary)
- The other survives, referencing a `callID` whose pair no longer exists
- Provider API rejects: "tool_result block does not have a corresponding tool_use"

## Fix

In `compress/search.ts`, `resolveBoundaryIds` now calls `adjustBoundariesForToolPairs` after boundary resolution. This function:
1. Collects all `callID`s from tool parts within the range
2. Scans forward (up to 20 messages) for tool_results with matching `callID`s → extends `endIdx`
3. Scans backward (up to 20 messages) for tool_uses with matching `callID`s → extends `startIdx`

The scan stops at the first non-matching message after finding at least one match, ensuring efficient O(k) scanning where k is the gap size.

## Scope

- `lib/compress/search.ts` — new `adjustBoundariesForToolPairs` function + integration in `resolveBoundaryIds`
- `tests/tool-pair-integrity.test.ts` — 9 tests covering forward/backward extension, parallel calls, gap tolerance, no-op cases

## Verification

- typecheck: clean
- tests: 947 pass (938 existing + 9 new), 0 fail
41 changes: 41 additions & 0 deletions devlog/2026-07-30_tool-pair-integrity/WORKLOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# WORKLOG: Fix Issue #247 — Tool Pair Integrity

## 2026-07-30

### Investigation
- Read `compress/search.ts` (507 lines): `resolveBoundaryIds` resolves boundaries purely by message ID, no tool pair awareness
- Read `messages/prune.ts` (90 lines): `filterCompressedRanges` removes compressed messages, no tool pair check
- Read `messages/query.ts` (96 lines): confirmed `callID` is the linking field for tool calls
- Confirmed: `grep -r 'tool_use|tool_result|toolUse|toolResult' lib/` = 0 matches
- Existing `resolveSelection` already collects `callID`s from tool parts (line 222-231), confirming same callID appears in both assistant (tool_use) and user (tool_result) messages

### Implementation
- Added `adjustBoundariesForToolPairs` to `search.ts` (~60 lines)
- Collects callIDs in range, scans forward/backward for paired messages
- Scan limit: 20 messages in each direction
- Stop-on-gap: breaks at first non-matching message after finding at least one match
- Integrated in `resolveBoundaryIds` after auto-swap (Bug 34 fix), before return
- When indices change, creates new `BoundaryReference` with updated `rawIndex` + `messageId`

### Tests
- Created `tests/tool-pair-integrity.test.ts` with 9 tests:
1. Forward extension (tool_use at endIdx, tool_result at endIdx+1)
2. Backward extension (tool_result at startIdx, tool_use at startIdx-1)
3. No extension when pair is inside range
4. No extension when range has no tools
5. Multiple tool results for same callID
6. Parallel tool calls (multiple callIDs in one assistant message)
7. Gap tolerance (non-tool message between tool_use and result)
8. Flows through to resolveSelection (messageIds and toolIds correct)
9. Kind change from block to message on backward extension

### Verification
- typecheck: clean
- Full suite: 947 tests pass, 0 fail

### Oracle Review Fixes
- **Tier misclassification risk**: Block anchors are compress tool_use messages (have callIDs). Forward scan would flip `endReference.kind` from `"compressed-block"` to `"message"`, corrupting tier detection (T2 → T1).
- **Fix 1**: Exclude `compress` tool callIDs from scan (`part.tool === "compress" → continue`)
- **Fix 2**: Only extend MESSAGE boundaries (`startReference.kind === "message"` guard)
- **New tests**: block boundary kind preserved (b1→b1 T2 distillation), compress excluded from scan
- **Final**: typecheck clean, 949 tests pass, CI all green (5/5)
103 changes: 103 additions & 0 deletions lib/compress/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,112 @@ export function resolveBoundaryIds(
[startReference, endReference] = [endReference, startReference]
}

// [FIX Issue #247] Auto-extend range boundaries to prevent splitting
// tool_use/tool_result pairs. In OpenCode, a tool call spans two messages:
// assistant (tool_use) and user (tool_result), linked by callID. If a
// compression range includes one but not the other, the pruned output
// has an orphaned reference → provider API rejection.
//
// Only adjust MESSAGE boundaries — block boundaries (bN) are anchors of
// compress tool calls, which are force-protected (always survive intact).
// Adjusting them would flip kind from "compressed-block" to "message",
// corrupting tier detection in applyCompressionState (T2 → T1).
const adjusted = adjustBoundariesForToolPairs(
context,
startReference.rawIndex,
endReference.rawIndex,
)

if (
adjusted.startIndex < startReference.rawIndex &&
startReference.kind === "message"
) {
const msg = context.rawMessages[adjusted.startIndex]
if (msg) {
startReference = {
kind: "message",
rawIndex: adjusted.startIndex,
messageId: msg.info.id,
}
}
}

if (
adjusted.endIndex > endReference.rawIndex &&
endReference.kind === "message"
) {
const msg = context.rawMessages[adjusted.endIndex]
if (msg) {
endReference = {
kind: "message",
rawIndex: adjusted.endIndex,
messageId: msg.info.id,
}
}
}

return { startReference, endReference }
}

function adjustBoundariesForToolPairs(
context: SearchContext,
startIdx: number,
endIdx: number,
): { startIndex: number; endIndex: number } {
const messages = context.rawMessages

const callIdsInRange = new Set<string>()
for (let i = startIdx; i <= endIdx; i++) {
const msg = messages[i]
if (!msg) continue
const parts = Array.isArray(msg.parts) ? msg.parts : []
for (const part of parts) {
if (part.type !== "tool" || !part.callID) continue
if (part.tool === "compress") continue
callIdsInRange.add(part.callID)
}
}

if (callIdsInRange.size === 0) {
return { startIndex: startIdx, endIndex: endIdx }
}

// Extend forward: tool_results follow their tool_use. Stop at the first
// gap after finding at least one matching message.
let newEndIdx = endIdx
for (let i = endIdx + 1; i < messages.length && i <= endIdx + 20; i++) {
const msg = messages[i]
if (!msg) break
const parts = Array.isArray(msg.parts) ? msg.parts : []
const hasMatch = parts.some(
(part) => part.type === "tool" && part.callID && callIdsInRange.has(part.callID),
)
if (hasMatch) {
newEndIdx = i
} else if (newEndIdx > endIdx) {
break
}
}

// Extend backward: tool_uses precede their tool_result.
let newStartIdx = startIdx
for (let i = startIdx - 1; i >= 0 && i >= startIdx - 20; i--) {
const msg = messages[i]
if (!msg) break
const parts = Array.isArray(msg.parts) ? msg.parts : []
const hasMatch = parts.some(
(part) => part.type === "tool" && part.callID && callIdsInRange.has(part.callID),
)
if (hasMatch) {
newStartIdx = i
} else if (newStartIdx < startIdx) {
break
}
}

return { startIndex: newStartIdx, endIndex: newEndIdx }
}

function buildBoundaryRecoveryHint(context: SearchContext, state: SessionState): string {
const visibleRefs: string[] = []
for (const [messageRef, messageId] of state.messageIds.byRef) {
Expand Down
Loading
Loading