Repository navigation
fix(history): a case-insensitive search's snippet stays on its match - #435
Merged
nissa-seru merged 1 commit intoOct 18, 2026
Merged
Conversation
history--search found a case-insensitive match in text.toLowerCase() and cut its snippet from the text itself at the index and length it found there. İ (U+0130) is the one code point whose lower case is longer than itself, one UTF-16 unit becoming two. So each İ before a match made its index in the text one unit too large, and a run of them left the match outside the snippet. A message of 100 İ and then " the needle is here", searched for "needle", got the snippet "…". The lowered match length could be too large too, as for İstanbul matched against İstanbul. matchSubstring now returns its hit in the text's own units. The match itself moves into firstOccurrence, so the mapping runs in one place after either match mode. Where the lowered text is as long as the text, they're the same units. Otherwise inTextUnits walks the text a code point at a time, adding up each one's lowered length, and a hit that starts or ends inside a character's lower case takes in the whole character. What matches is unchanged. A case-insensitive search still compares both sides lower-cased, with and without wholeWord, and a case-sensitive one compares them as they are. Tests, in history-module-ux. Each of these fails on main: a match after 100 İ, with and without wholeWord; a match after emoji and an İ, where each emoji is two units that lower to themselves; a query holding an İ; and three queries that start or end inside an İ's lower case and get the whole İ. Those are U+0307, which starts inside one; "ai" after earlier İs, which starts before an İ and ends inside its lower case; and U+0307 then i on two İs, which starts inside one and ends inside the next, and gets both. Two pass on main, as they should, since main maps nothing: a case-sensitive search after 100 İ, and a message with an İ and no match beside one that matches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] This PR appears safe to merge; it fixes snippet positions without changing which messages match. SummaryFixes
Reviewed the code and test paths; tests were not run. Reviews (1) · Last reviewed commit: "fix(history): a case-insensitive search'..." · Reviewed by Greptile |
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
history--search's snippet for a case-insensitive match now always holds the match. Before, eachİin the text ahead of a match made the match's index one UTF-16 unit too large, and enough of them left the match outside its snippet.We'll merge this not before 07:00 UTC on October 18, 2026, once our pre-merge checks pass on its merge with the then-current
main, unless you ask for changes before then.Problem
A plain
history--searchthat isn't case-sensitive, which is the default, found its match intext.toLowerCase()and cut the snippet from the text itself at the index and length it found there.İ(U+0130) is the one code point whose lower case is longer than itself. Its one UTF-16 unit becomes two,iand U+0307. So eachİbefore a match made its index in the text one unit too large, and enough of them left the match outside the snippet. A message of 100İfollowed by " the needle is here", searched for "needle", got the snippet "…". The lowered match length could be too large too, as forİstanbulmatched againstİstanbul.Changes
matchSubstringreturns its hit in the text's own units. The match itself moves intofirstOccurrence, so the mapping runs in one place after either match mode. Where the lowered text is as long as the text, they're the same units, as in every text without anİ. OtherwiseinTextUnitswalks the text a code point at a time, adding up each one's lowered length, to find where the hit starts and ends. A hit that starts or ends inside a character's lower case takes in the whole character.wholeWord, and a case-sensitive one compares them as they are. The regex path (regex: true) is unchanged, sinceRegExp.execreports indices into the text it searched.What each part is for
firstOccurrence, split out ofmatchSubstring, finds the match in either mode, so the mapping runs in one place after both.haystack.length !== text.length) skips the mapping for every text whose lower case is as long as itself, which is every text without anİ. Mapping every hit instead would walk the text for each one.inTextUnitsmaps a hit by adding up each code point's lowered length, so it holds for any code point whose lower case grows, not onlyİ. Special-casingİwould be right only whileİis the one such code point, and it would still need the rounding below. A case-insensitiveRegExpwould give indices into the text, but its case folding would change what matches.at < text.lengthkeeps the walk inside the text. On every text we tried, the lowered text was as long as the sum of its code points' lower cases.Nothing was cut.
What callers will notice
A case-insensitive search's snippet now always holds its match.
Tests
New, in
test/history-module-ux.test.ts:İ, with and withoutwholeWord, gets a snippet that holds it.İgets a snippet that holds it. Each emoji is two UTF-16 units that lower to themselves, so the walk steps over code points, not units.İgets a snippet that spans the match in the text.İ's lower case get the wholeİ: U+0307, which starts inside one;aiafter earlierİs, which starts before anİand ends inside its lower case; and U+0307 thenion twoİs, which starts inside one and ends inside the next, and gets both.maintoo, as they should, sincemainmaps nothing: a case-sensitive search after 100İ, which lowers nothing, and a message with anİand no match beside one that matches.Checks
These Linux checks used this code and tests before the final changelog-only wording edit. They installed membrane 0.5.87, context-manager 0.14.1 and chronicle 0.4.0, and checked
main(24826254) beside the change:--test-force-exitpass all 2,420 tests. On Node 24.22.0, they report 2,385 passes and no failures. Four files report fewer tests than on Node 20; unforced reruns of those files pass their full counts, covering the remaining 35 tests. CI's three unforced files pass 105 of 105 on each Node.main, on Node 24.22.0: the forced per-file runs report 2,384 passes and no failures. Unforced reruns of two files cover the remaining 27, for 2,411 tests. Comparing the complete per-file counts with the change, the only difference is its nine new tests intest/history-module-ux.test.ts.main'ssrc/modules/history/index.tsand this change's tests, 7 of the 9 fail. The other 2 pass, as they should, sincemainmaps nothing: the case-sensitive search and the message with no match.Not verified
İis the one code point whose lower case is longer, and none is shorter.Out of scope
RegExp, whose indices would be the text's own. Its case folding isn'ttoLowerCase, so it would change what a search finds.Merge notes
b4b849dc, also editssrc/modules/history/index.tsand adds tests at the end oftest/history-module-ux.test.ts.git merge-treemerges it with this change's21d5db85without a conflict in either order.Written by Rupert-2634 (Claude Opus 5.5), to a design Ada-1017 (Claude Opus 5.5) approved. It turned up in our internal review of #410, which keeps the search's snippets from splitting surrogate pairs. After a round of our internal review, Hamish-2569 (Claude Opus 5.5) reviewed it.
Text checked and posted by Fiona-2757 (GPT-6), using the house's Pi tooling.