fix: prevent orphaned tool_use blocks from compression and harden autocomplete

Bug 1 (400 API error): applyCompressionBlocks could remove toolResult
messages while leaving their paired assistant(tool_use) message intact.
This produced invalid API sequences that Claude rejected with:
  messages.N: tool_use ids found without tool_result blocks immediately after

The fix adds two boundary expansions before the splice:
- Expand lo backward: if messages[lo-1] is an assistant whose toolCall
  ids appear as toolResult.toolCallId values inside [lo..hi], pull the
  assistant into the range
- Expand hi forward: for assistants inside [lo..hi], extend hi to include
  any consecutive toolResult messages that immediately follow hi

This ensures tool_use/tool_result pairs are always removed together.

Bug 2 (autocomplete crash): harden getArgumentCompletions to return an
explicitly typed AutocompleteItem[] | null, filter on value (not label,
matching pi-tui's internal contract), and add a typeof guard ensuring no
item with a non-string value can reach getBestAutocompleteMatchIndex.
This commit is contained in:
Greg Harvell
2026-03-27 18:50:56 -04:00
parent ebfde87e57
commit e4b4d13ac7
2 changed files with 64 additions and 12 deletions
+51 -2
View File
@@ -54,8 +54,57 @@ function applyCompressionBlocks(messages: any[], state: DcpState): any[] {
if (startIdx === -1 || endIdx === -1) continue;
const lo = Math.min(startIdx, endIdx);
const hi = Math.max(startIdx, endIdx);
let lo = Math.min(startIdx, endIdx);
let hi = Math.max(startIdx, endIdx);
// Expand lo backward: if the message immediately before lo is an assistant
// whose tool_use blocks have matching tool_results inside [lo..hi], pull
// it into the range so the pair is always removed together.
while (lo > 0) {
const prev = messages[lo - 1] as any;
if (prev.role !== "assistant") break;
const toolCallIdsInRange = new Set<string>();
for (let i = lo; i <= hi; i++) {
const m = messages[i] as any;
if (m.role === "toolResult" && typeof m.toolCallId === "string") {
toolCallIdsInRange.add(m.toolCallId);
}
}
const prevContent: any[] = Array.isArray(prev.content) ? prev.content : [];
const hasMatchingToolCalls = prevContent.some(
(block: any) => block.type === "toolCall" && toolCallIdsInRange.has(block.id)
);
if (!hasMatchingToolCalls) break;
lo--;
}
// Expand hi forward: for every assistant message in [lo..hi] that has
// tool_use blocks, include any immediately-following tool_result messages
// that correspond to those blocks. Loop to fixed point because expanding
// hi could expose more assistants in theory.
let prevHi: number;
do {
prevHi = hi;
const assistantToolCallIds = new Set<string>();
for (let i = lo; i <= hi; i++) {
const m = messages[i] as any;
if (m.role !== "assistant") continue;
const content: any[] = Array.isArray(m.content) ? m.content : [];
for (const block of content) {
if (block.type === "toolCall" && typeof block.id === "string") {
assistantToolCallIds.add(block.id);
}
}
}
while (hi + 1 < messages.length) {
const next = messages[hi + 1] as any;
if (next.role === "toolResult" && assistantToolCallIds.has(next.toolCallId)) {
hi++;
} else {
break;
}
}
} while (hi !== prevHi);
// Estimate tokens removed
let removedTokens = 0;