Conversation
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (6)
🚧 Files skipped from review as they are similar to previous changes (2)
Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe TUI now uses proxy-served session titles when harvested titles are unavailable. Harvested titles remain preferred. Served titles do not count as harvested titles for title checks or re-harvest backoff. ChangesSession title fallback
Priority: ⬇️ Low Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to The served-title fallback remains display-only, preserves harvested-title precedence, and applies the intended sanitization. No merge-blocking issue was established; the change is ready to merge after normal checks. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The change is limited to session naming for display. Titles are sanitized and capped, matched to the correct session, and kept separate from transcript-harvesting decisions. No introduced security concern was established, but incomplete comparison coverage warrants a cautious low-risk assessment. Retained concerns Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
Resilience and Maintainability Implications
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
3189468 to
ed78188
Compare
huang195
left a comment
There was a problem hiding this comment.
The fallback is correct and cleanly layered. Keeping it out of sessionTitle is the right call, and the headline mutation (folding the fallback into sessionTitle) is killed by exactly the test the body says, and only that test in the whole package.
Two new behaviors have no test that can fail, and both fixes are under 10 lines in this PR's own test file (guards inline, each verified green at HEAD and red under its mutant):
servedTitle's id match: every fixture lists one session.- The gate side of the backoff (
untitledSettled/untitledFresh), which the regression test's header claims to cover.
The rest are comments the second commit set out to correct and missed. One more is outside this diff: core/session/store.go:737 still says "No consumer reads this yet". The commit declines it for scope, which is fair, but it becomes false on merge. Worth a follow-up issue.
Mutation gate: 15 mutants, 11 killed, 4 survived (3 live gaps, 1 unreachable by construction: the cached-only lookup, as the test's own comment says).
| // it means nothing served a title, which is indistinguishable here from serving an empty one. | ||
| func (m *model) servedTitle(id string) string { | ||
| for _, s := range m.sessions { | ||
| if s.ID == id { |
There was a problem hiding this comment.
must-fix: this lookup has no test that can fail. Mutating if s.ID == id to if s.ID != "" (so every id gets the first listed session's title) leaves the whole tui suite green. Every served-title fixture lists exactly one session, so the lookup is indistinguishable from a constant. It is reachable on any pod with two or more sessions, where session B's header would show A's title.
This guard is green at HEAD and red under that mutant. It could also go into TestSessionsPane_ServedTitleFillsAnUnharvestedCell by adding an s2:
m := newServedTitleModel(t, map[string]SessionMetadata{},
map[string]string{"s1": "first", "s2": "second"}, "s1", "s2")
for id, want := range map[string]string{"s1": "first (s1)", "s2": "second (s2)", "gone": "gone"} {
if got := m.sessionLabel(id); got != want {
t.Errorf("sessionLabel(%q) = %q, want %q", id, got, want)
}
}| // | ||
| // This is the only test that fails on that mutation: every display test above still passes, because | ||
| // the cell is correct either way. That is exactly why it is here. | ||
| func TestSessionMetadata_ServedTitleDoesNotSatisfyTheHarvestBackoff(t *testing.T) { |
There was a problem hiding this comment.
must-fix: the gate half of the backoff is unasserted. The header says a served title must not satisfy "the harvest-backoff predicates", and the PR body lists four. This test asserts countUntitled and harvestNamedSomething, which are the scoring side. It does not assert the two predicates the gate actually reads (app.go:1457–1462). Repointing untitledSettled's skip (session_metadata.go:246) or untitledFresh's (:307) at !titleIsBlank(m.sessionTitleFor(id, s.Title)) survives the whole suite. The untitledSettled one is exactly the regression this test is named for: a served-only row never opens the gate, so the re-harvest stops for good.
This guard is green at HEAD and red under both mutants:
// The GATE's two predicates too, not only the scoring's: untitledSettled decides whether a
// harvest starts at all.
now := time.Now()
m.sessions[0].UpdatedAt = now.Add(-untitledSettleDelay)
if !m.untitledSettled(now) {
t.Error("untitledSettled is false for a settled served-only row: no harvest will start")
}
if !m.untitledFresh() {
t.Error("untitledFresh is false for an uncounted served-only row")
}|
|
||
| // Harvest wins when both sources name the session. | ||
| // | ||
| // It is the richer of the two — tiered from cwd and prompt text — and the stable one: the served |
There was a problem hiding this comment.
suggestion: c2f2088 retracted "richer — cwd plus prompt text, tiered" as an overstatement, "corrected in all three places it appeared". It is still here, and still in the PR body ("the richer of the two", "the richer harvested one"). The "stable one" reason is also backwards. The harvest is LAST-wins (core/observe/claude/harvest.go:510; every prompt tier is last-wins), while the served title is FIRST-wins. So the harvest lets whichever turn landed last decide, which is exactly what this comment faults the proxy for doing with the first turn. Suggest reusing sessionTitleFor's "fixed precedence, not a judgement" wording.
| // | ||
| // THE FALLBACK IS ONLY HERE, not in sessionTitle. Every backoff predicate in session_metadata.go | ||
| // judges "unnamed" through sessionTitle, so this deliberately leaves a server-titled row reading as | ||
| // unnamed to them: the harvest keeps hunting for the better title, at the cost of a periodic |
There was a problem hiding this comment.
nit: "hunting for the better title" sits ten lines below "Deliberately a fixed precedence and not a judgement about which string is better". "the harvested title" or "the title it would prefer" (sessionHasTitle's wording) keeps the two consistent. CLAUDE.md's /v1/sessions row has the same pair ("not a judgement" … "looking for the better one").
| |---|---|---| | ||
| | `GET /` | text | One-line-per-endpoint index. Answers "is this the session API, and on the right port?" — the reason a 404 here was worth replacing. | | ||
| | `GET /v1/sessions` | `application/json` | List active sessions: `{sessions: [{id, createdAt, updatedAt, eventCount, title, totalTokens, costMicros, avoidedMicros, saturated, active, promptContext}]}`. `id`, `createdAt`, `updatedAt`, `eventCount` and `active` are always present; every other field is `omitempty` — absent rather than zero, on the standing rule that an unknown value must not render as a real one. (Do not read that off the position of `active`: it sits second-to-last, between two `omitempty` fields.) **`title` is a suggestion, not an identifier:** the proxy derives it from the session's own events (a `/rename`, else a `<user_query>`, else ordinary user prose, with `<system-reminder>` blocks excised), so it is a display convenience and nothing addresses a session by it. Absent when nothing in the events named it. **Folded at append time and FIRST-WINS, except that a `/rename` always overrides** — so ordinary conversation does not re-title a session on every turn, and a `/rename` survives eviction of the event that carried it. abctl does not read this field yet — its TITLE column still comes from harvested Claude Code transcripts, and reconciling the two is outstanding. | | ||
| | `GET /v1/sessions` | `application/json` | List active sessions: `{sessions: [{id, createdAt, updatedAt, eventCount, title, totalTokens, costMicros, avoidedMicros, saturated, active, promptContext}]}`. `id`, `createdAt`, `updatedAt`, `eventCount` and `active` are always present; every other field is `omitempty` — absent rather than zero, on the standing rule that an unknown value must not render as a real one. (Do not read that off the position of `active`: it sits second-to-last, between two `omitempty` fields.) **`title` is a suggestion, not an identifier:** the proxy derives it from the session's own events (a `/rename`, else a `<user_query>`, else ordinary user prose, with `<system-reminder>` blocks excised), so it is a display convenience and nothing addresses a session by it. Absent when nothing in the events named it. **Folded at append time and FIRST-WINS, except that a `/rename` always overrides** — so ordinary conversation does not re-title a session on every turn, and a `/rename` survives eviction of the event that carried it. abctl reads this field as a FALLBACK: its TITLE column prefers a harvested Claude Code transcript title and uses the served title only for a session the harvest cannot name. That precedence is fixed rather than a judgement about which string is better — both sides rank candidates their own way and do not agree on every session. That is the case worth having: an agent with no transcript tree on the operator's disk still routes through the proxy, so a row that used to render blank now has a name. abctl deliberately still treats such a row as unnamed for its own re-harvest backoff, so a served title does not stop it looking for the better one. | |
There was a problem hiding this comment.
nit: this cell says the precedence is "fixed rather than a judgement about which string is better" and then ends "so a served title does not stop it looking for the better one". "looking for a harvested one" would say the same without the judgement.
| // sessionHasTitle's comment exists to prevent. Deliberately not enumerated here: the list went | ||
| // stale the first time a caller was added, and the callers are one grep away. | ||
| // | ||
| // THE CELL DOES NOT CALL THIS, and the claim that it does was overstated. sessionTitleCell tests |
There was a problem hiding this comment.
suggestion: this paragraph went stale in this PR. sessionTitleCell → sessionTitleFor → titleIsBlank, so the cell does call this now. And "a " " title … is returned as " "" no longer holds: a probe at HEAD gets "" for a harvested " " with no served title, and the served title when there is one. Line 377's "it renders sessionTitle" should now say sessionTitleFor.
| @@ -136,12 +136,34 @@ func (m *model) sessionLabel(id string) string { | |||
| // THROUGH titleIsBlank, like the other two consumers of "is this named". A raw != "" accepted | |||
There was a problem hiding this comment.
nit: "like the other two consumers" is now three: sessionHasTitle, harvestNamedSomething and sessionTitleFor. This PR removed titleIsBlank's caller list because it "went stale the first time a caller was added", and this count did the same. Dropping the number avoids the next one.
| // words, which is the side this test is named for. | ||
| titleW := sessionsColumnWidth(sessionsColumnsFor(90), "TITLE") | ||
| cut := m.sessionTitleCell("s1", titleW) | ||
| cut := m.sessionTitleCell("s1", "", titleW) |
There was a problem hiding this comment.
nit: noServedTitle exists so that "the one legitimate empty argument says so by name". This call and the five other rewritten ones (647, 787, 856, 943, 1000) pass a bare "" where they mean exactly that.
|
Both must-fix items are addressed in 93b5ebe, and both of your suggested guards were right about
The gate half of the backoff — folded into the regression guard, with your Worth flagging one process note: my first attempt at re-running the headline mutant was a no-op All the comment items are fixed too, including the one you caught as a factual error rather than On Also done: the stale "other two consumers" count (dropped rather than incremented, same reasoning as Still open by design: Not done, unchanged: no end-to-end run against a live proxy. Assisted-By: Claude (Anthropic AI) noreply@anthropic.com |
d27e6ee to
4df25f9
Compare
abctl's TITLE column came only from harvested Claude Code transcripts, so a session with no transcript tree on the operator's disk rendered blank. PR rossoctl#1167 added a server-derived title to /v1/sessions and nothing read it. Now the display falls back to it. On the laptop this was found on, every blank harvested entry belonged to an agent that has no Claude Code transcript tree — but does route through the proxy, so a served title existed for exactly those rows. The two sources are complementary rather than redundant: harvesting names what the proxy never saw, the proxy names what the filesystem cannot. Harvest wins when both exist. It is the richer of the two (cwd plus prompt text, tiered) and the stable one, since the served title is first-wins per session. The fallback is display-only, which is the load-bearing part. sessionTitle feeds sessionHasTitle and through it every harvest-backoff predicate, so folding the served title in there would make the row read as named, zero untitledMisses and stop the re-harvest permanently — settling for whichever title the proxy derived first. So sessionTitle is unchanged and a new sessionTitleFor applies the precedence for the cell and the three headers. A server-titled row therefore still reads as unnamed to the backoff and keeps being re-harvested; that periodic scan is the intended trade. Two details worth keeping: - the fallback triggers on titleIsBlank, not == "", so a harvested " " (which paints nothing) falls back while a harvested "\t" (which sanitizeLabel turns into a visible glyph) does not — overriding a title already on screen would be the worse bug. - the served string is sanitizeLabel'd like the harvested one. /v1/sessions is unauthenticated and the title is folded from caller-supplied event content, so sessionTitle's CWE-150 reasoning applies to it at least as much. Cached-only rows pass no served title by construction: they exist because the server stopped listing the session, so there is no summary to carry one. Their test says so plainly now — it cannot fail on the "simplification" of looking the title up there, because cachedOnlySessionIDs excludes every listed id, so the lookup can only return "". What it earns is the check that such a row does not disturb a live row's title; it is kept for that and not as a gate. The README described the old behavior in the two places a reader looks first — the Sessions pane column list and --skip-claude-metadata — and both now say what fills the column and which source wins. One transient shape is documented rather than changed: a session that arrives on the event stream before a list refresh gets a stub summary with a zero Title, so its row shows no served title for up to two seconds. It is the one display path where an empty served string does not mean the proxy derived none, and it self-corrects on the next poll. Eight tests, and the one that matters is the backoff guard — it is the only one that fails when the fallback is moved into sessionTitle, because the rendered cell is correct either way. Five mutations checked by hand, each dying against the test written for it: fallback moved into sessionTitle, precedence swapped, trigger weakened to == "", sanitizeLabel dropped, and sessionLabel reverted to harvest-only. Verified: cmd/abctl/tui green with -race, gofmt and go vet clean, go mod tidy -diff clean. The one cmd/abctl failure (TestRunExec_BeforeFirstStartRunsAndSaysWhatIsLost) predates this branch — it wants a local CA bundle this machine has not got — and was confirmed failing on the untouched base. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Ed Snible <snible@us.ibm.com>
…ved title Review found eight places where the fallback's arrival left a claim behind that is now false, plus one footgun. All comment and doc changes except the constant; no behavior changes, so the suite passing unmodified is the check that the descriptions now match the code rather than the reverse. The two that actually misled: - sessionHasTitle's doc said it judges titles "as the TITLE cell would judge it" and that the two "can never disagree". The entire design is that they now deliberately do: a served-only row displays a title while this predicate calls it unnamed, which is what keeps the harvest looking. It is now documented as asking whether the HARVEST has named the session, with the distinction and the warning not to widen it. - --skip-claude-metadata's help text still promised bare ids for unharvested rows. The README was fixed for that in the previous commit and the flag string was not, which is the copy a user reads without opening a doc. Overstatement, corrected in all three places it appeared (sessionTitleFor, README, CLAUDE.md): the harvest was justified as "richer — cwd plus prompt text, tiered". It is either/or, not both — the harvester returns from one switch arm, and on the tree measured most sessions fell through to a bare cwd. Since both sides pick titles through their own ranking and both may change, these now say as little as possible about either mechanism: harvest-wins is recorded as a fixed precedence, not a claim about which string is better, with the note that the two rankings do not agree on every session and an explicit /rename is the clearest case where the loser looks better. Accepted for now pending what operators report; it is one line to invert. titleIsBlank's doc enumerated its three callers and described what each asks about. The list was wrong the moment sessionTitleFor was added, so it is gone rather than extended — the callers are one grep away. untitledSettled's clock-skew comment said the harm is a permanently blank TITLE cell. With a fallback it is stuck on whatever the proxy served, or blank if it served nothing. app.go's harvest repaint said the sessions table is the only cell a title lands in and implied the harvest map is the only thing naming sessions. Two inputs do now; only the harvest needs its repaint triggered there, because the list refresh rebuilds on its own path. Verified by checking every write to m.sessions. The footgun, and the one real code change: sessionTitleFor takes the served title as a parameter, so sessionTitleFor(id, "") compiles and silently disables the fallback. The parameter stays — the live row loop already holds the summary, and resolving it internally would put a scan of m.sessions in the per-row render path — so the single legitimate empty argument is now a named noServedTitle constant. Also noted where the value reaches the two display paths differently (the row loop has the summary, the header has only an id), so neither gets "unified" into the other. Two findings from the same review are not addressed here, deliberately. SessionSummary.Title's doc still says "No consumer reads this yet", which is now false, but it lives in core/session and this PR is scoped to abctl. And the suggestion that the served title removes an escape hatch for keeping prompt text off screen is declined: --skip-claude-metadata suppresses the scan, never the display, and the served title is folded from events the same operator is already reading on the same unauthenticated port. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Ed Snible <snible@us.ibm.com>
…lently Review found two new behaviors with no test that could fail. Both are real reachable bugs, not theoretical ones, and each mutant was confirmed surviving the whole tui suite before the guard was written and killing it after. servedTitle's id match had no fixture that could distinguish it from a constant. Every served-title fixture listed exactly one session, so mutating `if s.ID == id` to `if s.ID != ""` — return the first listed session's title for every id — left the suite green. On any pod listing two or more sessions that shows session B's header with A's title. TestSessionMetadata_ServedTitleIsPerSession lists two and also asks about an unlisted id, so it pins the miss path that returns "" as well; the mutant fails both of those assertions. The GATE half of the backoff was unasserted. The regression guard's own header claims a served title must not satisfy "the harvest-backoff predicates", but it only checked the SCORING side — countUntitled and harvestNamedSomething, which record what a harvest achieved. The two the gate actually reads, untitledSettled and untitledFresh, decide whether a harvest starts at all, and repointing either at sessionTitleFor survived the suite. untitledSettled is precisely the regression the test is named for: a served-only row that never opens the gate is never re-harvested, whatever the scoring would have said. Both are asserted now, each killing its own mutant. The headline mutant the PR body claims — sessionTitle learning the fallback — was re-checked against the full suite and is still killed by that guard alone. The rest are comments the previous commit set out to fix and missed: - The harvest-wins test comment still carried the "richer … tiered" overstatement that commit retracted in three other places, and its "stable one" reasoning was backwards. The harvest is LAST-wins (core/observe/claude, every tier) and the served title is FIRST-wins, so the harvest lets the latest turn decide — which is what that comment faulted the proxy for doing with the first. Neither side is the steady one; they disagree about which turn should name a session. Reworded to sessionTitleFor's "fixed precedence, not a judgement". - titleIsBlank's "THE CELL DOES NOT CALL THIS" paragraph went stale in this PR: sessionTitleCell now reaches it through sessionTitleFor. Its worked example was also wrong — a harvested " " is no longer returned as " ", it answers blank and the cell shows the served title or "". Probed both to confirm before rewriting. - "like the other two consumers" had become three, the same stale-count problem this PR fixed by deleting titleIsBlank's caller list. Dropped the number. - Two "the better title" phrasings sat beside "not a judgement about which string is better", in sessions_pane.go and CLAUDE.md. Both now say "harvested". - The six rewritten test call sites passed a bare "" where noServedTitle says exactly that; the constant exists for them too. One review item is still open by design: core/session/store.go's "No consumer reads this yet" becomes false on merge. It is outside this PR's scope and has a follow-up issue. Verified: cmd/abctl/tui green plain and with -race, gofmt and go vet clean, go mod tidy -diff clean, and git diff against the base touches no core/ file. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Ed Snible <snible@us.ibm.com>
…lsified Three review findings, all about the served title reaching code whose comments predate it. THE CAP IS THE ONE BEHAVIOUR CHANGE. sessionTitleFor sanitised the served title but never bounded its length. The harvested title arrives capped at claude.MaxTitleLen and the cross-module cap test asserts that against a harvested fixture -- a path the served title never takes. The proxy's own cap is unexported in another module on purpose, and /v1/sessions is unauthenticated and operator-pointed, so "the producer caps it" was not an assertion this side could make. What that cost was not a malformed cell -- truncLeft/truncRight bound their output either way -- but the quadratic search inside them. Their fast path is disabled by any zero-width rune, and a served title keeps its combining marks, so, on ONE call: 2503 runes 72ms, 5003 287ms, 10003 1.12s, 20003 4.50s, 40003 17.55s -- and 200003 runes ran past a 10-MINUTE test timeout without finishing. A profile of that run names the cost centre: lipgloss.Width -> ansi.stringWidth -> displaywidth.lookup, re-measuring the whole remaining tail on every iteration. On the UI goroutine, per row per rebuild. Capped at the harvester's own constant so both sources share one budget. TWO COMMENTS STATED A PREMISE THAT IS NOW FALSE. truncLeft justified its skip-ahead guard with "a title reaching this file never contains a zero-width rune -- core/observe/claude drops every Mn/Me/Cf/Cc/Sk", and truncRight referred to it. True of a harvested title only: core/session.sanitizeTitle deliberately KEEPS combining marks, and pipeline.IsControlRune covers C0/C1/DEL/BIDI/Cf but not Mn/Me/Sk. So an accent or any ordinary emoji (U+FE0F is Mn, not Cf as first reported) runs the path the comment called unreachable. The guard is structural so behaviour was always correct -- but the false premise is what someone would delete the guard on. AND THE PRODUCER'S OWN DOC CONTRADICTED THIS PR. SessionSummary.Title still said "No consumer reads this yet". This adds that consumer, so it now records that abctl renders the field as a fallback, and which sessions it therefore decides the display for. Verified: the cap guard was confirmed to FAIL with the cap removed (both the accessor and the header assertion) and pass with it restored -- this package's mutation gate. gofmt, go vet and go mod tidy -diff clean on both modules; cmd/abctl/tui green plain and under -race. Still no end-to-end run against a live proxy. One pre-existing failure is unrelated and untouched by this branch: TestRunExec_BeforeFirstStartRunsAndSaysWhatIsLost fails identically on the base ref, from a CA bundle path in the local environment. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Ed Snible <snible@us.ibm.com>
…s not asserted Six review items. One is a user-visible wording error, one is a dead test assertion, and the rest name limits the comments overstated. THE README WAS WRONG ABOUT WHEN A CELL IS EMPTY. It said "empty only when neither source names it", which the cached-only row contradicts: it passes noServedTitle unconditionally, so a session named ONLY by the proxy renders its title while listed and goes blank once the server stops listing it -- the rossoctl#870 scenario whose whole point is that the events are preserved. An operator watches the name disappear from a row that still has data. The row's own comment stated this correctly; only the README generalized from it. A TEST ASSERTED A LITERAL, NOT ITS FIXTURE. TestSessionsPane_ServedTitleOnlyFillsWhatRendersBlank built the model with newServedTitleModel and then passed "served name" directly, so it passed whether or not the helper had wired Title onto the summary -- leaving one other test as the only gate on that wiring. It now resolves through m.servedTitle, and a mutant that strips the helper's assignment fails three of its subtests. THE BIDI GAP IS NOW RECORDED. sanitizeLabel covers the BIDI overrides and isolates but NOT the plain marks (U+200E/200F/061C) or ZWJ; measured, all four survive it. core/session.sanitizeTitle folds them, so a served title is safe -- which makes this the second cross-package invariant this fallback leans on, after the length cap. Pinned as characterization that names the reliance, plus the contrasting override case, rather than by exporting sanitizeTitle from another module to test one line of rendering. AND TWO COMMENTS UNDERSOLD THEIR COSTS. "Until it finds one or the backoff caps out" reads transient, but for an agent with no transcript tree the harvest can NEVER succeed: the steady state is a full ~/.claude walk every 3 minutes for the process lifetime, on exactly the rows this feature serves. And noServedTitle's "cannot have one" now says it is a fact about today's structure -- nothing retains a last-seen served title -- rather than reading as settled. Two further items needed no change. The stale "core/observe/claude normalises every one" in truncLeft's ANSI argument was already fixed in 4df25f9, which names both normalisers. The positional-vs-by-id divergence between the row loop and sessionLabel needs duplicate ids, which an id-keyed store cannot produce. One suggestion declined: a sessionTitleForID(id) wrapper would put a scan of m.sessions behind the SHORTER name, and the live row loop calls it per row per rebuild -- making the O(n)-per-row call the default is the regression noServedTitle's doc exists to prevent. Verified: the rossoctl#8 fix confirmed to FAIL with the fixture wiring removed and pass with it restored. gofmt, go vet, go mod tidy -diff clean on both modules; cmd/abctl/tui green plain and under -race. Still no end-to-end run against a live proxy. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Ed Snible <snible@us.ibm.com>
…sified Five stale docs plus one live gap the served-title cap had made asymmetric. THE HARVESTED TITLE WAS CAPPED ONLY BY ITS WRITER. core/observe/claude emits at most MaxTitleLen runes, but LoadSessionMetadata re-reads that file and applies no cap, so a rewritten or hand-edited ~/.cortex/session-metadata.json reached the same quadratic truncation the served cap was just added to prevent. Measured: a 10003-rune path-shaped title with combining marks made ONE rebuildSessionsTable take 1.11s on the UI goroutine. Pre-existing, and out of this PR's path -- but capping one source and not the other left them asymmetric for no reason, and it is the same constant. Capped in sessionTitle, the accessor every consumer reads, so one line covers the cell, the headers and sessionHasTitle. Safe for the backoff by construction: truncation cannot turn a non-blank title blank, so no named/unnamed verdict moves, and the guard asserts that alongside the length. FOUR DOCS CLAIMED THE HARVEST WAS THE ONLY WAY TO NAME A SESSION. - app.go's sessionsData field: "what an agent knows about its own sessions that the proxy does not" and "renders as an empty TITLE column" -- both false now. The same file states it correctly at the harvest-repaint site, which this PR added, so the field doc was the outlier in a file already edited here. - LoadSessionMetadata: a total load failure no longer blanks the column for a proxy-named session. Worth saying, because it makes the deliberate silence on a corrupt file easier to justify rather than harder. - README's --skip-claude-metadata opening: presented the harvest as the only route, contradicting the same section's own fallback description further down. Now names both and scopes the section to the harvest, including that the flag suppresses this route only. Verified: the new cap confirmed to FAIL with it removed and pass with it restored. The full cmd/abctl/tui suite is green after capping a path every title flows through -- the real risk here, since existing title tests read the same accessor. gofmt, go vet, go mod tidy -diff clean on both modules; green plain and under -race. Still no end-to-end run against a live proxy. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Ed Snible <snible@us.ibm.com>
Three defects, and the first was mine: the cap I added last commit came with a false claim and a guard that asserted it with a fixture that could not test it. THE CAP COULD FLIP A ROW FROM NAMED TO UNNAMED. "Truncation cannot turn a non-blank title blank" is false. A harvested title whose first MaxTitleLen runes are whitespace, with real text after them, clips to pure spaces -- which titleIsBlank calls blank, so sessionHasTitle returns false and the row re-harvests ~/.claude every 3 minutes for the life of the process. That is the permanent-rescan cost this package documents as the price of an UNNAMABLE session, charged instead to a session with a perfectly good name. Reproduced before fixing: sessionHasTitle was false for exactly that input. Fixed by trimming after the cut, mirroring clipTitle in core/observe/claude, which does the same thing for the same reason. Trimming cannot introduce the failure it prevents -- it only removes whitespace, so a clip still holding text is untouched and one holding nothing else collapses to "", which is the honest answer and one sessionHasTitle already handles. THE GUARD ASSERTED THAT INVARIANT WITH A LEADING-"/" PATH, where no prefix is whitespace, so it passed for the wrong reason. Now three cases: the over-long performance one, whitespace filling the whole clip window (must read unnamed), and a whitespace prefix with text inside the window (must still read named). It also asserts the result is trimmed, so the verdict is deliberate rather than incidental. A mutant dropping TrimSpace fails two of the three. AND A WHITESPACE-ONLY SERVED TITLE PAINTED SPACES INTO THE CELL. titleIsBlank guarded the harvested title and not the served one, so sessionTitleFor returned " " verbatim while sessionLabel -- which blank-checks what sessionTitleFor returns -- rendered the bare id. One accessor, two callers, two different names for one session. Guarded before the cap. One note on the new test's fixtures: it covers spaces and the exotic spaces (U+00A0, U+3000), NOT tab or newline. Written first with "\t" it failed, and the test was wrong rather than the code -- titleIsBlank sanitises before it trims, so a tab becomes a visible U+FFFD glyph and is a real title. That order is already pinned from the harvested side, and the two tests must not contradict each other. Verified: both fixes independently mutation-checked -- dropping TrimSpace fails the harvested test, removing the blank-served guard fails all four served cases. gofmt, go vet, go mod tidy -diff clean on both modules; cmd/abctl/tui green plain and under -race. Still no end-to-end run against a live proxy. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Ed Snible <snible@us.ibm.com>
…t two overstated claims Ten review items. Four changed behaviour; six were claims the code did not support. Each verified against the source or by probe before being accepted. THE SANITISER WAS RELYING ON THE PRODUCER IT SAID IT DID NOT. sessionTitleFor's doc claimed "this does not rely on [the producer]" while sanitizeLabel replaced only the BIDI overrides and isolates (U+202A-202E, U+2066-2069). The plain marks U+200E/200F/061C and the zero-widths U+200B/200C/200D/2060/FEFF passed through untouched, so the only thing keeping a mark out of a cell was core/session's sanitizeTitle — unexported, in another module, reached over an unauthenticated API. That is the rune class whose whole purpose is to make the rendered order differ from the byte order, and the one this side must not delegate. sanitizeLabel now delegates to pipeline.IsControlRune, the repo's single copy of the rule. Both sources are covered: the metadata file on disk is not a trusted input either. THE CAP COULD SEVER A GRAPHEME CLUSTER. The cap mirrored clipTitle's TrimSpace while silently dropping its precondition — clipTitle's own doc says a plain rune cut is safe for it ONLY BECAUSE normalizeTitle removed every binding character first, and neither string this cap sees has been through that (core/session's sanitizeTitle keeps combining marks "so café survives"). A cut at MaxTitleLen could leave a dangling accent, half a ZWJ emoji, or one regional indicator of a flag. capTitleRunes now walks back off Mn/Me/Mc, ZWJ and regional indicators. Deliberately not Lm: Grapheme_Extend excludes modifier letters, and Lm also holds runes that legitimately start a cluster (U+02BB okina). A fixture built on U+02B0 is what surfaced that — the fixture was wrong, not the code. ALLOCATION-FREE ON THE COMMON PATH. len([]rune(x)) allocated a full rune slice just to compare a length, ~3-4x per row per 2s tick: 160 B/op and 232ns for a 40-rune title against 0 B/op and 155ns with utf8.RuneCountInString. Measured trade, noted in the comment: counting first walks the string twice, so the over-long path is ~45% slower (780us vs 1.13ms at 200k runes). The short path is the one that runs constantly. THE CAP TESTS PASSED FOR A BYTE-BASED CAP. Both asserted <= MaxTitleLen, which a byte cap satisfies while halving the budget of a two-byte-per-rune title. Now ==, and a genuine byte cap fails it. The fixtures also had no guard against NFC normalisation: a precomposed U+00E9 is one Mn-free rune, takes the truncation FAST path, and would leave both tests passing while exercising nothing. Shared combiningMarkRune + assertFixtureIsSlowPath, which the served test guarded inline and the harvested test did not guard at all. Docs corrected, each falsified by this PR or by the one before it: - "Capping the input is what makes the render cost flat" overstated it. The cap removes the quadratic term; sanitizeLabel's b.Grow(len(s)) and the rune count stay linear in the untruncated input, ~1.6MB transient per row per rebuild at 200k runes. Linear is the difference between laggy and unusable, but "flat" was wrong, and the honest bound is what a reader needs when deciding whether to cap earlier at the decode. - sessionTitleFor's two arguments have no coupling, so a future caller can pair one session's id with another's title and render a confident wrong name — the worst failure this column has, since a title is what an operator reads before acting on a row. Both live callers resolve served from the same id; that is now stated as the contract a third must keep. - --skip-claude-metadata's comment said it leaves only "whatever titles the metadata file already held". Served titles still render; the flag declines a filesystem scan, not naming. - Its flag help said a bare id "means neither source named it" — not exact for a cached-only row, whose served title is not retained and goes away with the listing. - README said the worst a missing metadata file costs is the TITLE column, which is the claim LoadSessionMetadata's own doc retracts four lines above. One characterization test deleted rather than inverted, as its doc instructed: it recorded the BIDI-mark gap as a deliberate reliance on core/session, and that reliance is what this commit removes. Sixteen new test functions against the base ref. Four mutations, each confirmed to fail with the fix reverted: sanitizeLabel narrowed to its old set (fails on both sources), a genuine byte cap, the cluster walk removed (all six binder cases plus the degenerate all-marks case), and the fixture constant normalised to U+00E9 (fails both cap tests). Still not run end-to-end against a live proxy: restarting the shared local Cortex cuts every attached session. These commits touch sessionTitle, the accessor every title in the UI flows through, so that gap is worth weighing. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Ed Snible <snible@us.ibm.com>
Two behavioural fixes and five gaps review found in the round-5 commit. capTitleRunes walked back over EVERY regional indicator, with no pairing logic. They pair left-to-right into flags, so in a run only every second one binds, and treating all of them as binders walked to index 0 across the run: 41 consecutive flags capped to "", and "a"*70 + 10 flags lost 11 runes rather than the "rune or two" the function's own doc promised. That is not cosmetic, because sessionHasTitle reads sessionTitle: an emptied title flips a named row to unnamed and restarts the permanent ~3-minute re-harvest -- the same failure f5a0a61 fixed for whitespace, reached by a second route. riBindsAtCut resolves the pairing by counting the run that PRECEDES the cut, so the scan is bounded by that run and the loop still takes at most one step. sessionTitleFor sanitised the untruncated served string twice -- titleIsBlank(served) built and discarded a full copy, then the next line sanitised again for the cap. sanitizeLabel is idempotent, so the second pass was pure waste: ~3.2MB of transient allocation per row per rebuild at 200k runes where the documented bound said ~1.6MB. It now sanitises once into a local and blank-checks that through a new blankSanitized, which is titleIsBlank's trim half split out. Two call sites in session_metadata.go were in the same position and now say so. The sanitise-before-trim ORDER titleIsBlank documents is preserved, not dropped, and blankSanitized's doc says why handing it a raw title reintroduces the hazard that ordering prevents. Tests and docs: - The existing cap test asserted the walk-to-zero behaviour on a lone regional indicator. One RI preceded by ordinary text STARTS a flag rather than completing one, so the cut before it is already on a boundary -- the test encoded the defect. Removed, with a comment on why the assertion was wrong. - Three new tests: the RI parity cases, a flag-only title staying named through sessionHasTitle and countUntitled, and modifier letters (Lm, including U+02BB okina) not binding -- the "DELIBERATELY NOT Lm" exclusion had no gate, so adding Lm and Sk kept the suite green. - A fourth pins the zeroWidthFree (Mn/Me/Cf/Cc/Sk) versus bindsToPrevious (Mn/Me/Mc) divergence, which was unpinned: the two predicates answer different questions and unifying their category sets is wrong in both directions. - The served-title sanitise-before-cap ordering is now gated at a ZWJ cut; it survived the whole suite before. - Two tests built a nil served map and called sessionTitleFor directly, so they contributed nothing to servedTitle's coverage; both now route through m.servedTitle. One header assertion used the at-most form the same test argues against five lines earlier, and one blankness assertion trimmed before comparing -- the exact defect titleIsBlank exists to prevent. - untitledSettled and untitledFresh still claimed the steady state is "every row titled" and costs nothing. sessionHasTitle answers false for served-only rows, and on an agent with no transcript tree the harvest can never succeed, so that state is permanent rather than transient. Both docs now say so. - bindsToPrevious records that its ZWJ arm is unreachable from both production callers today, and that the unreachability is incidental -- it depends on a sanitiser in another file continuing to treat ZWJ as a control rune, which is a rule about terminal safety, not clusters. - Rewrapped three over-wide lines (one Go doc line, two README). cmd/abctl/tui green plain and with -race; gofmt and GOWORK=off go vet clean on every changed file; go mod tidy -diff clean. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Ed Snible <snible@us.ibm.com>
…session pairing Three review findings, all confirmed by probe before changing anything. capTitleRunes could still empty a non-blank title. The regional-indicator arm was parity-guarded, but the mark arm walked back over every consecutive Mn/Me/Mc: "a" + 100 U+0301 capped to "". That flips sessionHasTitle to unnamed for a named session and restarts the permanent ~3-minute re-harvest — the same failure f5a0a61 fixed for whitespace, reached by a third route. The loop's documented bound was the root cause, stated as a justification: "a mark cannot follow a mark" is false. Marks stack, so the walk had no bound at all and "a"*60 + 40 marks lost 21 runes against a promised one step. Replaced with clusterStart, which finds where the straddling cluster begins and stops at the first non-binder, so both the work and the loss are proportional to one cluster. Growing that cluster from 5 marks to 2000 no longer moves the cut. Where there is genuinely no boundary — a title that opens with marks, so the cluster starts at index 0 — the cap takes its blunt prefix rather than returning "". That case is decided structurally, from clusterStart == 0, and not by noticing the result came out blank: a blanket "if it emptied a non-blank title, cut bluntly" guard was written first and rejected, because it rescues the output of any walk including the unbounded one, leaving every mutation of the walk passing. TestSessionsPane_CapOfOnlyCombiningMarksIsEmpty asserted the old "" and is inverted, not kept — like the lone-regional-indicator test an earlier round, it characterised the walk instead of what the cap owes its callers, and its own doc named the cost ("what sessionHasTitle already handles") as the reason it was fine. The row loop's per-session served-title pairing had no test that could fail. Mutating s.Title to m.sessions[0].Title rendered every row with the first session's title and the whole package stayed green, because every fixture that put a served title in the table had one row. The sibling test covers the header, which reaches the value by a lookup; a lookup can be wrong about which id it matched, a loop about which iteration it read, and neither test sees the other. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Ed Snible <snible@us.ibm.com>
622d6af to
89c9ad1
Compare
capTitleRunes trimmed AFTER its cut, so a title whose first MaxTitleLen runes were whitespace lost its real text to the cut and was then emptied by the trim. Measured at MaxTitleLen = 80: 40 leading spaces kept 50 runes, 79 kept just "m", and 80 or more returned "" — for U+0020, U+00A0 and U+3000 alike. sessionHasTitle reads sessionTitle, so a named session read as UNNAMED and re-harvested ~/.claude every ~3 minutes for the process lifetime. That is the exact failure the function's own comment claimed trimming could not introduce. Trim before measuring instead. Leading whitespace is not part of a name, so it should never have spent the rune budget; once it does not, no amount of it can push real text past the cut. The post-cut trim stays, for whitespace the cut newly exposes. Three follow-on fixes: - sessionTitleFor decided precedence on the CAPPED harvested title, so any cap-blanking route also silently inverted the documented harvest-wins contract: 85 spaces + "real" against a served title returned the served one. It now asks about the raw harvested title. No input is known to blank after the fix above, so this is structural defence rather than an observable change — the test says so rather than implying a gate it cannot be. - clusterStart panicked on cut == len(r), safe only via capTitleRunes' early return guaranteeing a longer slice. That coupling was two functions apart, documented nowhere, pinned by nothing, and bindsToPrevious' doc invites other callers. A cut past the last rune orphans nothing, so it returns cut. - clusterStart's "BOUNDED BY ONE CLUSTER" doc was read as promising one step. The bound is real but is min(cluster, cut): 80 steps for "a" + 100 marks, still 80 for "a" + 2000. Corrected in three places. An existing test had encoded the main defect: TestSessionsPane_Harvested TitleIsCappedAtLoad asserted wantNamed: false for 80 spaces + "real name", labelled "the correctness case", five lines above an assertion calling an unnamed verdict the thing that restarts the re-harvest forever. Inverted. The guarding property test could not catch any of it — all six fixtures were binder-class, so none reached the trim. It now carries whitespace shapes in three Unicode classes, and a fixture exists for every path through the function rather than for every bug found in one of them. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Ed Snible <snible@us.ibm.com>
|
Closing in favor of #1192 |
|
closing this since we merged the other parallel PR |
What
abctl's TITLE column came only from harvested Claude Code transcripts, so a session with
no transcript tree on the operator's disk rendered blank. #1167 added a server-derived
titleto/v1/sessionsand nothing read it. Now the display falls back to it.On the laptop this was found on, every blank harvested entry belonged to an agent that has
no Claude Code transcript tree — but does route through the proxy, so a served title
existed for exactly those rows. The two sources are complementary rather than redundant:
harvesting names what the proxy never saw, the proxy names what the filesystem cannot.
Harvest wins when both exist — a fixed precedence, not a judgement about which string is
better. Neither side is the "stable" one: the harvest is LAST-wins (every tier in
core/observe/claude) and the served title is FIRST-wins, so they disagree about which turn shouldname a session rather than one being steadier. The two rankings do not agree on every session, and
an explicit
/renamelosing to a bare cwd is the clearest case where the loser looks better;accepted for now pending what operators report, and one line to invert. No provenance indicator —
the cell shows a title, not where it came from.
Scope: abctl, plus two comments in
core/session. An earlier revision carried anunrelated comment fix there and it was dropped. What is in the diff now is not that: review
found
SessionSummary.Title's own doc still said "No consumer reads this yet", which thisPR falsifies, so
core/session/store.gocarries a comment-only change to the field thisfeature consumes. No code outside
cmd/abctlchanges.The load-bearing part: the fallback is display-only
sessionTitlefeedssessionHasTitle, and through it every harvest-backoff predicate(
untitledSettled,untitledFresh,countUntitled,harvestNamedSomething);countUntitledin turn feeds both the harvest gate and its scoring. Folding the servedtitle into
sessionTitlewould make the row read as named, zerountitledMissesandstop the re-harvest permanently — settling for whichever title the proxy derived first
and never looking for the harvested one.
So
sessionTitleandsessionHasTitleare unchanged, and a newsessionTitleForappliesthe precedence for the cell and the three headers. A server-titled row therefore still
reads as unnamed to the backoff and keeps being re-harvested; that periodic transcript
scan is the intended trade, not a leak.
Two details worth keeping:
titleIsBlank, not== "", so a harvested" "(which paintsnothing) falls back while a harvested
"\t"(whichsanitizeLabelturns into a visibleglyph) does not — overriding a title already on screen would be the worse bug.
sanitizeLabel'd like the harvested one./v1/sessionsisunauthenticated and the title is folded from caller-supplied event content, so
sessionTitle's CWE-150 reasoning applies to it at least as much. The producer alreadytrims and caps; this does not rely on that — and review caught that the claim was not
true when first written. See below.
Two things review found that the earlier commits got wrong
The sanitiser was relying on the producer it said it did not.
sanitizeLabelreplacedonly the BIDI overrides and isolates (U+202A-202E, U+2066-2069). The plain marks
U+200E/200F/061C and the zero-widths U+200B/200C/200D/2060/FEFF went through untouched, so
the only thing keeping a mark out of a cell was
core/session'ssanitizeTitle—unexported, in another module, reached over an unauthenticated API. That is precisely the
rune class whose purpose is to make rendered order differ from byte order, and the one this
side must not delegate. It now delegates to
pipeline.IsControlRune, the repo's single copyof the rule, whose own doc records that three byte-identical duplicates once drifted under a
comment asserting they moved together. Both sources are covered: the metadata file on disk is
not a trusted input either.
The cap walked back over every regional indicator. They pair left-to-right into flags, so
in a run only every second one binds — and treating all of them as binders walked to index 0
across the run. Measured: 41 consecutive flags capped to
"", and"a"*70+ 10 flags lost 11runes rather than the "rune or two" the function's own doc promised. Since
sessionHasTitlereads
sessionTitle, an emptied title flips a named row to unnamed and restarts the permanent~3-minute re-harvest — the same failure
f5a0a615fixed for whitespace, reached by a secondroute. Binding is position-dependent, not rune-intrinsic, so a per-rune predicate cannot
answer it:
riBindsAtCutcounts the run that precedes the cut. (The fix as first writtenclaimed the loop therefore "takes at most one step". It does not — see the next section; the
latest round replaced both the claim and the loop.) The existing cap test had asserted the
walk-to-zero behaviour on a lone RI — one RI after ordinary text starts a flag rather than
completing one, so that cut was already on a boundary and the test encoded the defect.
sanitizeLabelran twice over the untruncated served string.titleIsBlank(served)builtand discarded a full copy, then the next line sanitised again for the cap — ~3.2MB of transient
allocation per row per rebuild at 200k runes where the documented bound said ~1.6MB. Idempotent
made it harmless, not free. It now sanitises once into a local and blank-checks that through a
new
blankSanitized, which istitleIsBlank's trim half split out; twosession_metadata.gocall sites were in the same position and now say so. The sanitise-before-trim order that
titleIsBlankdocuments is preserved rather than dropped, andblankSanitized's doc records whyhanding it a raw title reintroduces the hazard that ordering prevents.
The cap could sever a grapheme cluster. The cap mirrored
clipTitle'sTrimSpacewhiledropping its precondition —
clipTitle's doc says a plain rune cut is safe for it onlybecause
normalizeTitlestripped every binding character first, and neither string this capsees has been through that (
core/session'ssanitizeTitledeliberately keeps combiningmarks "so café survives"). A cut at
MaxTitleLencould leave a dangling accent, half a ZWJemoji, or one regional indicator of a flag.
capTitleRunesnow walks back off Mn/Me/Mc, ZWJand regional indicators — deliberately not
Lm, which Grapheme_Extend excludes and whichholds runes that legitimately start a cluster (U+02BB ʻokina). A fixture built on U+02B0 is
what surfaced that; the fixture was the wrong part, not the code.
Both cap sites are now one helper, which is also where the
len([]rune(x))allocation went:counting with
utf8.RuneCountInStringtakes the common path from 160 B/op and 232ns to 0 B/opand 155ns for a 40-rune title, several times per row per 2s tick. The measured counter-trade,
recorded in the comment: counting first walks the string twice, so the over-long path is ~45%
slower (780µs vs 1.13ms at 200k runes). The short path is the one that runs constantly.
Review also found four claims in the code that no test would have caught being falsified —
the
Lmexclusion, the sanitise-before-cap ordering, the served-title path throughservedTitle, and thezeroWidthFree/bindsToPreviousdivergence. Each now has a gate. Twoassertions were themselves too weak to fail: one header check used the at-most form the same
test argues against five lines earlier, and one blankness check trimmed before comparing, which
is the exact defect
titleIsBlankexists to prevent.untitledSettledanduntitledFreshstill described the steady state as "every row titled",costing nothing.
sessionHasTitleanswers false for served-only rows by design, and on an agentwith no transcript tree the harvest can never succeed — so that state is permanent, not
transient, and the per-row-per-tick allocation is paid for as long as the pane is open (bounded
by the ~3m backoff cap rather than by ever being satisfied). That is the accepted price of not
letting a served title stop the search for the harvested one, and both docs now say so. It is
the same class of stale claim this PR set out to fix.
Four comments were corrected because this PR or the one before it falsified them, including
one of my own overstatements — "capping the input is what makes the render cost flat" claimed
more than the code does. The cap removes the quadratic term;
sanitizeLabel'sb.Grow(len(s))and the rune count stay linear in the untruncated input, ~1.6MB transient per row per
rebuild at 200k runes. Linear is the difference between laggy and unusable, but "flat" was
wrong, and the honest bound is what a reader needs in order to decide whether to cap earlier at
the decode instead.
The latest round: three findings, and a rejected fix worth recording
All three were reproduced by probe before anything was changed, and each now has a test that
dies against the mutation that reintroduces it.
The row loop's per-session pairing had no test that could fail. Mutating
m.sessionTitleCell(s.ID, s.Title, titleW)to readm.sessions[0].Titlerenderss2's rowwith
s1's title and left the whole suite green — no fixture put two distinct served titleson screen at once.
TestSessionsPane_ServedTitleCellIsPerRowdoes, and asserts at the cell.This is deliberately separate from the header's own gate: the header reaches the title by
linear lookup (
servedTitle) and the loop by iteration, so a lookup can be wrong about whichid it matched and a loop about which iteration it read. One test cannot fail for both.
The mark arm could still empty a non-blank title — the same failure, third route. The RI
arm was parity-guarded, but
bindsToPreviouswalked back over every consecutive Mn/Me/Mc.Probe:
"a"+ 100×U+0301 →"", which flipssessionHasTitlefalse for a named session andrestarts the ~3-minute re-harvest.
And the bound the loop documented was false. It claimed "at most one step for marks and
ZWJ, because a mark cannot follow a mark" — a mark certainly can. The walk is now
clusterStart, which stops at the first rune that does not bind to what precedes it, so thework is proportional to the one cluster straddling the cut rather than to the title (5 marks
and 2000 marks yield the same result). The defect was the false claim and the walk-to-empty it
justified, not any amount of shortening:
"a"*60+ 40 marks returns 59 runes both beforeand after, and 59 is correct — the cluster's base is at index 59, so it is the only boundary
at or before the cut. An earlier revision of this section cited that as a 21-rune loss, which
was wrong; the next section has the corrected bound.
The rejected fix is the part worth keeping. The first version of this guarded the result:
if the cap emptied a non-blank title, cut bluntly instead. Every display test passed. Then
restoring the old unbounded walk underneath it also passed — the guard rescues the output
of any walk, so broken and fixed became indistinguishable and every mutation test of the walk
was dead. It now decides the blunt cut structurally (
clusterStart == 0, i.e. the titleopens with marks or an odd flag half and there is no earlier boundary), which keeps the walk's
correctness observable. A dangling accent renders as one odd glyph;
""restarts there-harvest. The comment records why the blanket form was rejected.
One existing test had encoded this defect too.
..._CapOfOnlyCombiningMarksIsEmptyasserted""and called it "the honest answer" — while its own doc named the cost, "whatsessionHasTitlealready handles". Inverted to..._CapOfOnlyCombiningMarksKeepsAPrefix, thesame treatment the lone-regional-indicator test got a round earlier.
The round after that: the cap's trim was the third route to the same failure
Five findings, four of them real, and the main one is the same bug this PR has now fixed
three times over — which is the most useful thing in this section.
capTitleRunestrimmed after its cut, and that emptied genuinely-named titles. A titlewhose first
MaxTitleLenrunes are whitespace loses its real text to the CUT and is thenemptied by the TRIM. Reproduced: 40 leading spaces keeps 50 runes, 79 keeps just
"m", 80 ormore returns
"", for U+0020, U+00A0 and U+3000 alike.sessionHasTitlereadssessionTitle,so a session the harvest successfully named reads as unnamed and re-harvests
~/.claudeevery ~3 minutes for the process lifetime.
The comment above it asserted that trimming "cannot introduce the failure it prevents, because
it only ever removes whitespace". That comment was written as the fix for the whitespace
route, and it was wrong about its own direction: removing whitespace cannot blank a string that
holds text, but the cut had already thrown the text away. The fix is to trim before
measuring — leading whitespace is not part of a name and should never have spent the rune
budget — which dissolves the case rather than guarding against it.
clipTitleupstream trimsafter its cut safely, for a reason that does not transfer:
normalizeTitlehas alreadycollapsed every whitespace run before it sees the string.
The guarding property test could not catch it, and that is the lesson. All six fixtures in
..._CapNeverEmptiesANonBlankTitlewere binder-class, so none of them reached the trim at all.The property was exactly right and the table could not express the defect. It now carries
whitespace shapes in three Unicode classes plus the boundary length — and the standing rule is
a fixture per code path through the function, not a fixture per bug already found in one.
An existing test had encoded the defect, again.
..._HarvestedTitleIsCappedAtLoadassertedwantNamed: falsefor 80 spaces +"real name",labelled it "the correctness case", and sat five lines above an assertion whose own message
calls an unnamed verdict the thing that "restarts the ~3-minute re-harvest forever". Inverted,
and a 500-space case added so no off-by-a-few fix passes by accident. That is the third test in
this PR to have pinned a defect while explaining why the defect was bad.
sessionTitleFordecided precedence on the capped title. So every cap-blanking route alsosilently inverted the published harvest-wins contract:
sessionTitleFor(85×" " + "real", "served-name")returned"served-name", showing the proxy's name to an operator told byCLAUDE.md,cmd/abctl/README.mdandcore/session/store.gothat they are looking at theharvest. Worse than the blank cell it replaced — a wrong name gets acted on. It now asks about
the raw harvested title, so a display bound cannot reach into a precedence decision.
That one is not a mutation gate and its test says so. Reverting the guard leaves the suite
green, necessarily: a search over this file's degenerate shapes — marks, ZWJ, BOM, word joiner,
NBSP, ideographic space, lone regional indicators, Thai vowel signs, five lengths around the
cap, with and without a real suffix — found zero inputs that sanitise non-blank and cap to
blank. With no observable difference there is nothing to gate, so the test pins the outcome and
the doc carries the argument, rather than implying a guarantee it cannot make.
clusterStartpanicked oncut == len(r).clusterStart([]rune("abc"), 3)→index out of range [3] with length 3, safe in production only becausecapTitleRunes' early returnguarantees a longer slice — a coupling two functions apart that no comment stated and no test
pinned, while
bindsToPrevious' doc invites other callers in this package. A cut past the lastrune orphans nothing, so it returns
cut. A cut abovelen(r)still panics deliberately:clamping it would invent an answer for a question the caller got wrong.
The fifth finding did not reproduce, and the instrumentation is why. It reported
clusterStart's walk as unbounded — "5 marks → 6 runes back; 20 → 21; 40 → 41" — contradictingits own doc. Counting iterations directly:
r[80]"a"*75+ 5 marks + tailt"a"*40+ 40 marks + tailt"a"*1+ 79 marks + tailt"a"+ 100 marks +"z"*200"a"+ 2000 marks +"z"*200The reported curve measures
clusterStartat a fixed cut against a mark run anchored beforeit; inside
capTitleRunesthe cut is alwaysMaxTitleLen, so growing the run moves the baselater and the walk gets shorter. What the finding did surface is a genuine doc inaccuracy in
the other direction: "bounded by one cluster" is true but is not "one step", because a
degenerate cluster can be as long as the cut. The bound is
min(cluster, cut)— 80 steps for100 marks, still 80 for 2000, which is the property that matters. Corrected in three places,
and
..._CapWalkIsBoundedByOneClusternow asserts the degenerate case throughclusterStartdirectly, since
capTitleRunes' blunt-cut fallback returns the same answer either way and hidit.
Five mutants, four dying (the fifth is the precedence guard above, reported as surviving rather
than dressed up): trim restored to after-the-cut (4 test functions),
TrimSpacenarrowed toTrimLeft(s, " ")(2), the end-of-slice guard removed (panics), andclusterStart's earlyreturn broken (4).
Docs
The README stated the old behavior in the two places a reader looks first — the Sessions
pane column list and the
--skip-claude-metadatasection — and both now describe whatfills the column and which source wins.
CLAUDE.md's/v1/sessionsrow is updated thesame way.
One transient shape, documented rather than changed
A session that arrives on the event stream before a list refresh gets a stub
SessionSummarywith a zeroTitle, so its row shows no served title for up to twoseconds. It is the one display path where an empty served string does not mean "the proxy
derived none", and it self-corrects on the next poll. Noted in
sessionTitleFor's doc.Cached-only rows are the other empty-by-construction case: they exist because the server
stopped listing the session, so there is no summary to carry a title.
Tests
Twenty-seven new test functions against the base ref (82 vs 55), and the one that matters most
is still the backoff guard — it is the only one that fails when the fallback is moved into
sessionTitle, because the rendered cell is correct either way.Sixteen mutations checked by hand, each dying against the test written for it: fallback
moved into
sessionTitle, precedence swapped, trigger weakened to== "",sanitizeLabeldropped,
sessionLabelreverted to harvest-only, the served-title cap removed (failing boththe accessor and the header assertion), the harvested-title cap removed,
newServedTitleModel's fixture wiring stripped — that last one is why a test that had beenasserting a literal instead of its fixture is now a gate at all — plus four from the latest
round:
sanitizeLabelnarrowed back to overrides-and-isolates only (fails ZWJ, WJ, BOM andLRM on both sources), a genuine byte-based cap (fails the
==rune assertion that the old<=let through), the grapheme-cluster walk removed (fails all six binder cases and thedegenerate all-marks case), and the shared fixture constant normalised to a precomposed
U+00E9 (fails both cap tests via
assertFixtureIsSlowPath, which is the point of having it).Three more from the round after that:
sanitizeLabeldropped fromsessionTitleFornow fails 13tests rather than the 2 it did before the fixtures were rewired;
titleIsBlank's orderingreversed to sanitise-after-trim; and
zeroWidthFree's category set unified withbindsToPrevious's — the tempting "these two are near-duplicates" refactor, which nothingcaught until the new pin. The two predicates answer different questions (is a rune count wrong
about this string's width? vs would cutting before this rune orphan it?) and unifying them is
wrong in both directions: it makes a
Mc-terminated title take the slow width path for nothing,and makes the cap walk back off an
Skthat starts nothing.And three from the latest round, one per finding: the row loop's
s.Titlereplaced withm.sessions[0].Title(fails..._ServedTitleCellIsPerRowwithTITLE for s2 = "first session", want "second session"); the unbounded mark walk restored (fails..._CapNeverEmptiesANonBlankTitleand..._CapOfOnlyCombiningMarksKeepsAPrefix); andclusterStart's early return broken so the scan runs past the cluster (fails..._CapWalkIsBoundedByOneClusterand the existing..._CapCutsOnAClusterBoundary). Each wasapplied and reverted individually.
Two fixture guards, because a cap test is easy to write so that it exercises nothing:
assertFixtureIsSlowPathfails loudly if the string it is handed would taketruncLeft'sfast path or is already under the cap. The served test had that check inline; the harvested
test had none.
One test is explicitly not a mutation gate, and now says so in its own comment:
TestSessionsPane_CachedOnlyRowTakesNoServedTitlecannot fail if the cached-only loop ischanged to look the title up itself, because
cachedOnlySessionIDsexcludes every listedid, so the lookup can only return
"". Verified by running that mutation — the suitestays green. It is kept for the cross-talk check and the worked example, not as a gate.
One test was deleted rather than inverted, as its own doc instructed:
TestSessionsPane_ServedTitleBidiMarksRelyOnTheProducerrecorded the BIDI-mark gap as adeliberate reliance on
core/session's sanitiser. Closing that gap is what the latestcommit does, so the characterization no longer describes the code. Its replacement,
TestSessionsPane_ServedTitleStripsEveryControlClass, walks all ten classes (LRM, RLM, ALM,ZWSP, ZWNJ, ZWJ, WJ, BOM, RLO, LRI) and asserts the rune is gone, U+FFFD is present, and the
surrounding text survives — and its harvested twin does the same for the file on disk, which
is not a trusted input either.
cmd/abctl/tuigreen plain and with-race;gofmt -landGOWORK=off go vetclean;go mod tidy -diffclean.Verified end-to-end against a live proxy
The motivating case is confirmed on screen, with a base-ref control. A second
authbridge-proxybuilt from this branch ran on its own loopback ports (47700-47704,bind_loopback_only, throwaway CA dir) alongside the laptop's existing instance, which keptanswering on its own port throughout — no shared config, launchd label, port or CA was
touched, and no
abctl servicecommand was run.Traffic was inference (
POST /v1/chat/completions) bucketed byX-Task-Id, which is thewhole point: such a session has no Claude Code transcript tree, so the harvest can never name
it. Worth recording for the next person, because it cost two attempts — A2A traffic folds no
title at all, since
titleCandidatereturnsrankNonewhene.Inference == nil. Onlyinference events name a session.
Both binaries were then driven in a real pty against the same endpoint. Sessions list, this
branch:
Base ref, same proxy:
The header on Enter is a separate call site — the cell takes
Titleoff the summarythe row loop already holds, the header resolves it by id through
servedTitle— and it movedtoo:
abctl · Why did the flag emoji title cap to an empty string? (e2e-e4f69e1c-…)against thebase ref's bare
abctl · e2e-e4f69e1c-….Three things that confirms beyond "the cell filled in":
title, and it stays blank on this branch. A blanket substitution would have painted something.
message; the title did not change.
proxy, same data, blank.
Not done
cmd/abctlfailure,TestRunExec_BeforeFirstStartRunsAndSaysWhatIsLost,predates this branch — it wants a local CA bundle this machine has not got — and was
confirmed failing on the untouched base (checked out detached, reproduced, discarded).
Rebased
Rebased onto current
main(5625049a, "Merge pull request #1153 … feat/billing-units"),since the head was 20 commits behind and
mainhad since touchedcmd/abctl—cmd_cost*.goandcmd_pricing*.go, none of which overlap the eight files here, so therebase was clean. Everything above was re-verified on the new base:
cmd/abctl/tuigreenplain and with
-race,gofmt -landGOWORK=off go vetclean forcmd/abctlandcore,go mod tidy -diffclean,core/sessionandcore/sessionapigreen.Assisted-By: Claude (Anthropic AI) noreply@anthropic.com
Summary by CodeRabbit