Description
capCodePoints in packages/core/src/thread-search.ts:599 promises to "Cap a string to at most maxCodePoints code points" (:594-598), but for maxCodePoints <= 0 it returns nearly the entire input. The truncation at :602 computes codePoints.slice(0, maxCodePoints - 1): with maxCodePoints = 0 the end index is -1, and a negative end counts back from the array end, so only the last code point is dropped and 'hell…' (5 code points) comes back for a cap of 0. Negative caps behave the same way (-2 keeps all but 3 code points). The cap is therefore completely bypassed exactly at the boundary where bounding matters most. The two sibling truncation helpers handle this boundary correctly (sanitizeUnicodeText in packages/core/src/text-sanitize.ts:101 returns just the suffix for a 0 cap; truncateUtf16Safe at :131 returns '' for maxUnits <= 0), so this is also inconsistent within the repo. Current callers pass SNIPPET_MAX_CODE_POINTS = 240 (thread-search.ts:71,268,326), so the production path is unaffected today, but any caller relying on the documented bound with a zero/negative cap (e.g. a disabled-snippet or zero-budget path) gets an unbounded string back, defeating the TOTAL_PAYLOAD_CAP_BYTES accounting the snippet feeds.
Reproduce
- From the repository root, run the following (it reads
capCodePoints verbatim out of packages/core/src/thread-search.ts and evaluates it, so no build is needed):
rtk node -e 'const fs=require("node:fs");const src=fs.readFileSync("packages/core/src/thread-search.ts","utf8");const s=src.indexOf("export function capCodePoints");let b=src.indexOf("{",s);let d=0;let e=b;for(;e<src.length;e++){const c=src[e];if(c==="{")d++;if(c==="}"){d--;if(d===0)break;}}const fn=src.slice(s,e+1).replace("export ","").replace("(value: string, maxCodePoints: number): string","(value, maxCodePoints)");const f=new Function(fn+";return capCodePoints;")();const rows=[["hello",0],["0123456789".repeat(4),0],["hello",-2],["hello",1]].map(function(a){const o=f(a[0],a[1]);return JSON.stringify(a[0])+" @"+a[1]+" => "+JSON.stringify(o)+" ("+Array.from(o).length+"pts)"});console.log(rows.join("\n"))'
- Observed output:
"hello" @0 => "hell…" (5pts)
"0123456789012345678901234567890123456789" @0 => "012345678901234567890123456789012345678…" (40pts)
"hello" @-2 => "he…" (3pts)
"hello" @1 => "…" (1pts)
The @1 control case is correct (1 code point); the @0 and @-2 cases return far more code points than the cap allows.
Expected
capCodePoints(value, 0) on a non-empty string should return "…" (and "" for an empty input, which already short-circuits at :601); negative caps should behave the same as 0. Smallest fix: clamp the slice end so it can never go negative, e.g. codePoints.slice(0, Math.max(0, maxCodePoints - 1)).join('') + '…' at thread-search.ts:602, matching the 0-cap behavior of sanitizeUnicodeText.
Checklist
Distinct from fix(desktop): stop history search after enough matches #5524, which limits how many history-search results are collected, not how a snippet string is capped to a code-point budget -- it does not touch capCodePoints or any truncation boundary.
Description
capCodePointsinpackages/core/src/thread-search.ts:599promises to "Cap a string to at mostmaxCodePointscode points" (:594-598), but formaxCodePoints <= 0it returns nearly the entire input. The truncation at:602computescodePoints.slice(0, maxCodePoints - 1): withmaxCodePoints = 0the end index is-1, and a negative end counts back from the array end, so only the last code point is dropped and'hell…'(5 code points) comes back for a cap of 0. Negative caps behave the same way (-2keeps all but 3 code points). The cap is therefore completely bypassed exactly at the boundary where bounding matters most. The two sibling truncation helpers handle this boundary correctly (sanitizeUnicodeTextinpackages/core/src/text-sanitize.ts:101returns just the suffix for a 0 cap;truncateUtf16Safeat:131returns''formaxUnits <= 0), so this is also inconsistent within the repo. Current callers passSNIPPET_MAX_CODE_POINTS = 240(thread-search.ts:71,268,326), so the production path is unaffected today, but any caller relying on the documented bound with a zero/negative cap (e.g. a disabled-snippet or zero-budget path) gets an unbounded string back, defeating theTOTAL_PAYLOAD_CAP_BYTESaccounting the snippet feeds.Reproduce
capCodePointsverbatim out ofpackages/core/src/thread-search.tsand evaluates it, so no build is needed):rtk node -e 'const fs=require("node:fs");const src=fs.readFileSync("packages/core/src/thread-search.ts","utf8");const s=src.indexOf("export function capCodePoints");let b=src.indexOf("{",s);let d=0;let e=b;for(;e<src.length;e++){const c=src[e];if(c==="{")d++;if(c==="}"){d--;if(d===0)break;}}const fn=src.slice(s,e+1).replace("export ","").replace("(value: string, maxCodePoints: number): string","(value, maxCodePoints)");const f=new Function(fn+";return capCodePoints;")();const rows=[["hello",0],["0123456789".repeat(4),0],["hello",-2],["hello",1]].map(function(a){const o=f(a[0],a[1]);return JSON.stringify(a[0])+" @"+a[1]+" => "+JSON.stringify(o)+" ("+Array.from(o).length+"pts)"});console.log(rows.join("\n"))'The
@1control case is correct (1 code point); the@0and@-2cases return far more code points than the cap allows.Expected
capCodePoints(value, 0)on a non-empty string should return"…"(and""for an empty input, which already short-circuits at:601); negative caps should behave the same as 0. Smallest fix: clamp the slice end so it can never go negative, e.g.codePoints.slice(0, Math.max(0, maxCodePoints - 1)).join('') + '…'atthread-search.ts:602, matching the 0-cap behavior ofsanitizeUnicodeText.Checklist
Distinct from fix(desktop): stop history search after enough matches #5524, which limits how many history-search results are collected, not how a snippet string is capped to a code-point budget -- it does not touch
capCodePointsor any truncation boundary.