Skip to content

Commit 78df510

Browse files
authored
Merge pull request #209 from ranxianglei/2026-09-07_range-array-adjacency
fix: segment compressible ranges by array adjacency, not ref arithmetic (#207)
2 parents ead99b1 + 07ecd94 commit 78df510

2 files changed

Lines changed: 116 additions & 25 deletions

File tree

src/recommend.ts

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,6 @@ import {
2828

2929
// ─── Helpers ──────────────────────────────────────────────────────────────────
3030

31-
function refNum(ref: string): number {
32-
const n = parseInt(ref.slice(1), 10);
33-
return Number.isNaN(n) ? -1 : n;
34-
}
35-
3631
/** Default token estimate (chars/4) used when the caller doesn't inject a
3732
* countTokens — preserves the historical behavior for backwards compat. */
3833
function estimateTextTokens(text: string): number {
@@ -153,15 +148,15 @@ export function buildCompressibleRanges(
153148
): ContextRanges {
154149
const compressibleMsgs: {
155150
ref: string;
156-
refNum: number;
151+
gapBefore: boolean;
157152
tokens: number;
158153
chars: number;
159154
isTool: boolean;
160155
isUser: boolean;
161156
}[] = [];
162157
const protectedMsgs: {
163158
ref: string;
164-
refNum: number;
159+
gapBefore: boolean;
165160
tokens: number;
166161
tools: string[];
167162
}[] = [];
@@ -170,54 +165,68 @@ export function buildCompressibleRanges(
170165
// callIds of protected tool-calls first, then protect matching results too.
171166
const protectedCallIds = collectProtectedToolCallIds(messages, config);
172167

168+
// Segmentation is array adjacency, never ref arithmetic: surface-replacing
169+
// hosts leave holes in the ref map (compressed messages leave the array, refs
170+
// stay assigned) and insert mid-array summary nodes with fresh HIGH refs —
171+
// ref arithmetic fragments every range there and emits startRef > endRef
172+
// pairs. Only a numbered-ref message physically skipped between two entries
173+
// interrupts; unrefed/BLOCKED consume no slot. On dense append-only hosts the
174+
// two rules coincide, so ranges are byte-identical to the old behavior.
175+
let skipSinceCompressible = false;
176+
let skipSinceProtected = false;
177+
173178
for (const msg of messages) {
174-
if (isSyntheticOrPruned(msg, state)) continue;
175179
const ref = state.messageRefs.byRaw[msg.id];
176180
if (!ref || ref === "BLOCKED") continue;
177-
178-
const rn = refNum(ref);
181+
if (isSyntheticOrPruned(msg, state)) {
182+
skipSinceCompressible = true;
183+
skipSinceProtected = true;
184+
continue;
185+
}
179186

180187
if (isMessageProtectedWithPairing(msg, config, protectedCallIds)) {
181188
protectedMsgs.push({
182189
ref,
183-
refNum: rn,
190+
gapBefore: skipSinceProtected,
184191
tokens: countTokens(msg.text ?? ""),
185192
tools: msg.toolName ? [msg.toolName] : [],
186193
});
194+
skipSinceProtected = false;
195+
skipSinceCompressible = true;
187196
continue;
188197
}
189198

190199
if (protectedZoneRefs?.has(ref)) {
200+
skipSinceCompressible = true;
201+
skipSinceProtected = true;
191202
continue;
192203
}
193204

194205
compressibleMsgs.push({
195206
ref,
196-
refNum: rn,
207+
gapBefore: skipSinceCompressible,
197208
tokens: countTokens(msg.text ?? ""),
198209
chars: (msg.text ?? "").length,
199210
isTool: isToolMessage(msg),
200211
isUser: msg.role === "user",
201212
});
213+
skipSinceCompressible = false;
214+
skipSinceProtected = true;
202215
}
203216

204-
// Build compressible groups (contiguous, split at ref gaps and at user
205-
// messages once a group has >= 3 messages). Splitting at user boundaries
206-
// keeps each compressible range aligned to roughly one user turn, instead
207-
// of producing one giant range spanning many turns (or, conversely, a
208-
// fragment per message when ref gaps appear). Mirrors opencode-acp's
217+
// Build compressible groups (split at real array gaps and at user messages
218+
// once a group has >= 3 messages). Splitting at user boundaries keeps each
219+
// compressible range aligned to roughly one user turn, instead of producing
220+
// one giant range spanning many turns. Mirrors opencode-acp's
209221
// buildCompressibleRanges condition.
210222
const compressible: CompressibleRange[] = [];
211223
let cur: CompressibleRange | null = null;
212-
let prevRefNum = -2;
213224

214225
for (const info of compressibleMsgs) {
215-
const hasGap = info.refNum > prevRefNum + 1;
216-
if (cur && ((info.isUser && cur.count >= 3) || hasGap)) {
226+
if (cur && ((info.isUser && cur.count >= 3) || info.gapBefore)) {
217227
compressible.push(cur);
218228
cur = null;
219229
}
220-
prevRefNum = info.refNum;
221230
if (!cur) {
222231
cur = {
223232
startRef: info.ref,
@@ -246,15 +255,12 @@ export function buildCompressibleRanges(
246255
// Build protected groups (contiguous)
247256
const protectedRanges: ProtectedRange[] = [];
248257
let pcur: ProtectedRange | null = null;
249-
let pPrevRefNum = -2;
250258

251259
for (const info of protectedMsgs) {
252-
const hasGap = info.refNum > pPrevRefNum + 1;
253-
if (pcur && hasGap) {
260+
if (pcur && info.gapBefore) {
254261
protectedRanges.push(pcur);
255262
pcur = null;
256263
}
257-
pPrevRefNum = info.refNum;
258264
if (!pcur) {
259265
pcur = {
260266
startRef: info.ref,

tests/recommend.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,91 @@ test("buildCompressibleRanges: does NOT split before a group reaches 3 messages"
245245
assert.equal(ranges.compressible[0]!.count, 3);
246246
});
247247

248+
// ─── Surface-replacing hosts (#207): array-adjacency segmentation ─────────────
249+
250+
test("buildCompressibleRanges: ref-map holes do NOT fragment ranges (surface-replace host)", () => {
251+
// Turn 1 assigns m00001..m00005. A compression then durably replaces b..d on
252+
// the surface: they leave the next turn's message array while their refs stay
253+
// assigned (append-only map, never pruned). Ref-arithmetic segmentation saw
254+
// 5 > 1+1 and emitted two singletons; array adjacency sees a and e as array
255+
// neighbors and emits one range.
256+
const a = msg("a", "x".repeat(2000));
257+
const e = msg("e", "v".repeat(2000));
258+
const state = assignAll([a, msg("b", "y"), msg("c", "z"), msg("d", "w"), e]);
259+
const ranges = buildCompressibleRanges([a, e], state, config());
260+
assert.equal(ranges.compressible.length, 1, "holes in the ref map must not split the range");
261+
assert.equal(ranges.compressible[0]!.startRef, "m00001");
262+
assert.equal(ranges.compressible[0]!.endRef, "m00005");
263+
assert.equal(ranges.compressible[0]!.count, 2);
264+
});
265+
266+
test("buildCompressibleRanges: mid-array summary node extends the range, never a descending pair", () => {
267+
// Surface-replace host inserts the model's summary node at the replaced span's
268+
// position. assignRefs gives it a fresh HIGH ref (m00006) while the trailing
269+
// message keeps m00005 — ref arithmetic flushed the head and emitted the
270+
// nonsense pair m00006..m00005. Array adjacency treats the node as a regular
271+
// entry: one ascending range over the whole span.
272+
const a = msg("a", "x".repeat(2000), "assistant");
273+
const d = msg("d", "w".repeat(2000), "assistant");
274+
const e = msg("e", "v".repeat(2000), "assistant");
275+
const summary = msg("s", "Summary of the compressed span: did the work.", "assistant");
276+
const s1 = assignAll([a, msg("b", "y"), msg("c", "z"), d, e]);
277+
const state = assignAll([a, summary, d, e], s1);
278+
assert.equal(state.messageRefs.byRaw["s"], "m00006", "summary node gets a fresh high ref");
279+
const ranges = buildCompressibleRanges([a, summary, d, e], state, config());
280+
assert.equal(ranges.compressible.length, 1, "mid-array summary node must not flush the range");
281+
assert.equal(ranges.compressible[0]!.startRef, "m00001");
282+
assert.equal(ranges.compressible[0]!.endRef, "m00005");
283+
assert.equal(ranges.compressible[0]!.count, 4);
284+
});
285+
286+
test("buildCompressibleRanges: synthetic (covered) summary node still splits ranges", () => {
287+
// A summary node detected via the "[Compressed conversation section]" prefix
288+
// (or active-block coverage) is intentionally skipped — a skipped numbered
289+
// message between two entries is a real interruption, unlike a numeric hole
290+
// where nothing is physically present between them.
291+
const a = msg("a", "x".repeat(2000));
292+
const e = msg("e", "v".repeat(2000));
293+
const synthetic = msg("s", "[Compressed conversation section] earlier work summarized.", "assistant");
294+
const s1 = assignAll([a, msg("b", "y"), msg("c", "z"), msg("d", "w"), e]);
295+
const state = assignAll([a, synthetic, e], s1);
296+
const ranges = buildCompressibleRanges([a, synthetic, e], state, config());
297+
assert.equal(ranges.compressible.length, 2, "covered span must not be folded into the new range");
298+
assert.equal(ranges.compressible[0]!.startRef, "m00001");
299+
assert.equal(ranges.compressible[0]!.endRef, "m00001");
300+
assert.equal(ranges.compressible[1]!.startRef, "m00005");
301+
assert.equal(ranges.compressible[1]!.endRef, "m00005");
302+
});
303+
304+
test("buildCompressibleRanges: protected groups segment by array adjacency too", () => {
305+
// A compressible message physically between two protected tool-calls splits
306+
// them (dense-host behavior preserved); a bare ref-map hole does not.
307+
const p1 = toolMsg("p1", "skill");
308+
const p2 = toolMsg("p2", "skill");
309+
const dense = createInitialState();
310+
dense.messageRefs = {
311+
byRaw: { p1: "m00001", x: "m00002", p2: "m00003" },
312+
byRef: { m00001: "p1", m00002: "x", m00003: "p2" },
313+
};
314+
const denseRanges = buildCompressibleRanges(
315+
[p1, msg("x", "y"), p2],
316+
dense,
317+
config({ protectedTools: ["skill"] }),
318+
);
319+
assert.equal(denseRanges.protected.length, 2, "interleaved non-protected message splits protected groups");
320+
321+
const holed = createInitialState();
322+
holed.messageRefs = {
323+
byRaw: { p1: "m00001", p2: "m00005" },
324+
byRef: { m00001: "p1", m00005: "p2" },
325+
};
326+
const holedRanges = buildCompressibleRanges([p1, p2], holed, config({ protectedTools: ["skill"] }));
327+
assert.equal(holedRanges.protected.length, 1, "hole in the ref map must not split the protected group");
328+
assert.equal(holedRanges.protected[0]!.count, 2);
329+
assert.equal(holedRanges.protected[0]!.startRef, "m00001");
330+
assert.equal(holedRanges.protected[0]!.endRef, "m00005");
331+
});
332+
248333
// ─── Integration: 19-token bug fix ─────────────────────────────────────────────
249334

250335
test("integration: tiny ranges are suppressed — fixes the 19-token compression bug", () => {

0 commit comments

Comments
 (0)