Skip to content

Feat: Show the served session title in abctl when harvesting names nothing - #1186

Open
esnible wants to merge 9 commits into
rossoctl:mainfrom
esnible:feat/tui-served-title
Open

esnible wants to merge 9 commits into
rossoctl:mainfrom
esnible:feat/tui-served-title

Conversation

@esnible

@esnible esnible commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

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
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 — 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 should
name a session rather than one being steadier. The two rankings do not agree on every session, and
an explicit /rename losing 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 an
unrelated 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 this
PR falsifies, so core/session/store.go carries a comment-only change to the field this
feature consumes. No code outside cmd/abctl changes.

The load-bearing part: the fallback is display-only

sessionTitle feeds sessionHasTitle, and through it every harvest-backoff predicate
(untitledSettled, untitledFresh, countUntitled, harvestNamedSomething);
countUntitled in turn feeds both the harvest gate and its scoring. Folding the served
title into sessionTitle would make the row read as named, zero untitledMisses and
stop the re-harvest permanently — settling for whichever title the proxy derived first
and never looking for the harvested one.

So sessionTitle and sessionHasTitle are 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 transcript
scan is the intended trade, not a leak.

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. The producer already
    trims 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. 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 went 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 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 copy
of 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 11
runes rather than the "rune or two" the function's own doc promised. Since sessionHasTitle
reads sessionTitle, an emptied title flips a named row to unnamed and restarts the permanent
~3-minute re-harvest — the same failure f5a0a615 fixed for whitespace, reached by a second
route. Binding is position-dependent, not rune-intrinsic, so a per-rune predicate cannot
answer it: riBindsAtCut counts the run that precedes the cut, so the scan is bounded by that
run and the loop still takes at most one step. 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.

sanitizeLabel ran twice over the untruncated served string. titleIsBlank(served) built
and 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 is titleIsBlank's trim half split out; two session_metadata.go
call sites were in the same position and now say so. The sanitise-before-trim order that
titleIsBlank documents is preserved rather than dropped, and blankSanitized's doc records why
handing it a raw title reintroduces the hazard that ordering prevents.

The cap could sever a grapheme cluster. The cap mirrored clipTitle's TrimSpace while
dropping its precondition — clipTitle's doc says a plain rune cut is safe for it only
because
normalizeTitle stripped every binding character first, and neither string this cap
sees has been through that (core/session's sanitizeTitle deliberately 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, which Grapheme_Extend excludes and which
holds 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.RuneCountInString takes the common path from 160 B/op and 232ns to 0 B/op
and 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 Lm exclusion, the sanitise-before-cap ordering, the served-title path through
servedTitle, and the zeroWidthFree/bindsToPrevious divergence. Each now has a gate. Two
assertions 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 titleIsBlank exists to prevent.

untitledSettled and untitledFresh still described the steady state as "every row titled",
costing nothing. sessionHasTitle answers false for served-only rows by design, and on an agent
with 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'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 in order to decide whether to cap earlier at
the decode instead.

Docs

The README stated the old behavior in the two places a reader looks first — the Sessions
pane column list and the --skip-claude-metadata section — and both now describe what
fills the column and which source wins. CLAUDE.md's /v1/sessions row is updated the
same way.

One transient shape, documented rather than changed

A session that arrives on the event stream before a list refresh gets a stub
SessionSummary 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. 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-one new test functions against the base ref (76 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 == "", sanitizeLabel
dropped, sessionLabel reverted to harvest-only, the served-title cap removed (failing both
the accessor and the header assertion), the harvested-title cap removed,
newServedTitleModel's fixture wiring stripped — that last one is why a test that had been
asserting a literal instead of its fixture is now a gate at all — plus four from the latest
round: sanitizeLabel narrowed back to overrides-and-isolates only (fails ZWJ, WJ, BOM and
LRM 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 the
degenerate 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 latest round: sanitizeLabel dropped from sessionTitleFor now fails 13
tests rather than the 2 it did before the fixtures were rewired; titleIsBlank's ordering
reversed to sanitise-after-trim; and zeroWidthFree's category set unified with
bindsToPrevious's — the tempting "these two are near-duplicates" refactor, which nothing
caught 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 Sk that starts nothing.

Two fixture guards, because a cap test is easy to write so that it exercises nothing:
assertFixtureIsSlowPath fails loudly if the string it is handed would take truncLeft's
fast 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_CachedOnlyRowTakesNoServedTitle cannot fail if the cached-only loop is
changed to look the title up itself, because cachedOnlySessionIDs excludes every listed
id, so the lookup can only return "". Verified by running that mutation — the suite
stays 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_ServedTitleBidiMarksRelyOnTheProducer recorded the BIDI-mark gap as a
deliberate reliance on core/session's sanitiser. Closing that gap is what the latest
commit 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/tui green plain and with -race; gofmt -l and GOWORK=off go vet clean;
go mod tidy -diff clean.

Not done

  • No end-to-end run against a live proxy. The motivating case (blank harvest, live
    served title) has not been confirmed on screen.
  • 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

Summary by CodeRabbit

  • New Features
    • Session titles now fall back to the proxy-provided title when no harvested transcript title is available. Harvested titles take precedence when both sources provide a title.
    • Proxy-provided titles appear in the sessions list, are sanitized and limited in length, and may be truncated to fit the column. They are shown only while the proxy lists the session.
    • Skipping Claude metadata scanning does not hide previously saved harvested titles or proxy-provided titles. The title column does not indicate which source supplied a title.

@esnible
esnible requested a review from a team as a code owner September 29, 2026 18:48
@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 0b3d9402-8b1c-4860-9323-018baaef35ed

📥 Commits

Reviewing files that changed from the base of the PR and between e5503af and 622d6af.

📒 Files selected for processing (6)
  • cmd/abctl/README.md
  • cmd/abctl/main.go
  • cmd/abctl/tui/session_metadata.go
  • cmd/abctl/tui/sessions_pane.go
  • cmd/abctl/tui/sessions_title_test.go
  • cmd/abctl/tui/usage_render.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • cmd/abctl/main.go
  • cmd/abctl/README.md

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The 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.

Changes

Session title fallback

Layer / File(s) Summary
Resolve and display session titles
cmd/abctl/tui/session_metadata.go, cmd/abctl/tui/sessions_pane.go, cmd/abctl/tui/sessions_title_test.go, cmd/abctl/tui/app.go, cmd/abctl/main.go, cmd/abctl/README.md, CLAUDE.md, core/session/store.go
Session labels and table rows use a nonblank harvested title first, then a sanitized proxy-served title. Cached-only rows have no served-title fallback. Both title sources are capped at the shared maximum title length. Tests cover precedence, sanitization, truncation, cached-only rows, and harvesting checks. Documentation describes the fallback and clarifies that served titles do not suppress re-harvesting.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 622d6

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 Review

Security architecture risk: 🔵 Low · up to 622d6

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
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The demonstrated new exposure is event-influenced title text reaching the operator’s session table and selected-session headers. The traced fallback does not acquire harvest-state authority or redirect titles between session IDs.

Trust Boundaries and Controls

  • observed — At the text-to-terminal boundary, C0, DEL, C1, specified bidi controls, and specified zero-width characters become visible replacement glyphs. Sanitization precedes blank checking and capping, so headers and cells receive the controlled accessor result.

Resilience and Maintainability Implications

  • observed — The producer bounds stored title output before publication, and the consumer independently caps rendered titles. These controls constrain downstream rendering work; they do not establish an upstream request-size limit.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 42 functions across 7 files. (1 skipped: 1…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: abctl displays the served session title when title harvesting finds no name. It is specific and related to the changeset, although the wording is slightly …
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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>
@esnible
esnible force-pushed the feat/tui-served-title branch from 3189468 to ed78188 Compare September 29, 2026 21:43
…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>

@huang195 huang195 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")
}

Comment thread cmd/abctl/tui/sessions_title_test.go Outdated

// 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread cmd/abctl/tui/sessions_pane.go Outdated
//
// 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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").

Comment thread CLAUDE.md Outdated
|---|---|---|
| `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. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread cmd/abctl/tui/session_metadata.go Outdated
// 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread cmd/abctl/tui/session_metadata.go Outdated
@@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread cmd/abctl/tui/sessions_title_test.go Outdated
// 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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

…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>
@esnible

esnible commented Sep 30, 2026

Copy link
Copy Markdown
Member Author

Both must-fix items are addressed in 93b5ebe, and both of your suggested guards were right about
what they'd catch. I reproduced each mutant surviving the full tui suite before writing the guard,
and confirmed it red afterwards.

servedTitle's id match — TestSessionMetadata_ServedTitleIsPerSession. Took your two-session
fixture including the unlisted "gone" id, which turned out to matter: the mutant fails on both the
wrong-session assertion and the miss path, since if s.ID != "" returns "first" for an id that
is listed by nobody.

The gate half of the backoff — folded into the regression guard, with your untitledSettled /
untitledFresh assertions. Verified each gate mutant separately; each is killed by its own line.
The guard's header now distinguishes the scoring side from the gate side and says why they fail
differently, since the old header claimed coverage it didn't have. I also re-ran the headline mutant
(sessionTitle learning the fallback) against the full suite — still killed by that guard alone.

Worth flagging one process note: my first attempt at re-running the headline mutant was a no-op
(I substituted sanitizeLabel(m.sessionsData[id].Title), which is just sessionTitle's own body),
so it "passed" and told me nothing. Redone as the real hazard.

All the comment items are fixed too, including the one you caught as a factual error rather than
staleness: the harvest is LAST-wins (core/observe/claude/harvest.go:511 — "Both are LAST-wins", and
every prompt tier the same), so my "stable one" reasoning was backwards. Neither side is steadier;
they disagree about which turn should name a session. Reworded to the "fixed precedence, not a
judgement" form, and the same fix applied to the PR body above, which had the stale wording in two
places.

On titleIsBlank's stale paragraph — you were right on both halves, and I probed before rewriting:
a harvested " " with no served title now yields "", and the served title when there is one. So
the old worked example ("returned as \" \"") was doubly wrong. The paragraph now says the cell
reaches the predicate through sessionTitleFor, and scopes "not a blankness test" to what's actually
left of it — sessionTitleCell's title == "" fast path.

Also done: the stale "other two consumers" count (dropped rather than incremented, same reasoning as
deleting the caller list), both "the better title" phrasings, and noServedTitle at the six rewritten
call sites.

Still open by design: core/session/store.go:737's "No consumer reads this yet", which becomes
false on merge. Filed as a follow-up — #1191 covers a different core/session title defect found
while testing this branch (a Claude Pro quota probe capturing a session's served title via
first-wins), and I'll note the stale doc there rather than leave it untracked. This PR stays
abctl-only.

Not done, unchanged: no end-to-end run against a live proxy.

Assisted-By: Claude (Anthropic AI) noreply@anthropic.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>
@esnible
esnible force-pushed the feat/tui-served-title branch from d27e6ee to 4df25f9 Compare September 30, 2026 00:50
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: New/ToDo

Development

Successfully merging this pull request may close these issues.

2 participants