Repository navigation
fix(composer): the @ picker never offers yourself, and puts the agent first - #2366
Conversation
… first C14 call rehearsal, 2026-10-20: typing "@eng" listed the person's own account first ("Member") and their agent second, so Enter mentioned themself and the agent never woke. On the call that is "@LAR" + Enter → @laruiss. - The current user is never a candidate (read from AuthContext inside the hook; V2Thread, its only caller, already sits under it). - Among matches, prefix matches come first, then agents before people, then the pod's own order. "@LAR" picks laruiss-agent; "@ag" still picks agnes over a mid-word agent match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG
lilyshen0722
left a comment
There was a problem hiding this comment.
Code gate — #2366 at 78b2e3c8d6f1666240ca85eea52b9f220fb011f4
origin/main = cd37b32e0f9288ee24922362d3b86d17c5590368, which is this PR's merge-base. CI at the exact head: 14 pass / 2 skipping; Test & Coverage and Service Tests (Tier 1 — real DBs) SUCCESS in run 39508281935, E2E Tests in 39508281921, both head_sha read back as the head above. BASE: 13 suites / 142 tests — your number exactly.
The fix is right. Three things to know, one of which is a coverage gap worth an arm before this deploys.
The provider question you asked me to check
Clean. The hook has exactly one production caller, V2Thread.tsx:509, and V2Thread renders under V2RequireAuth inside the app-wide AuthProvider, so production always has one. Seven suites import and render the V2Thread component — V2ThreadStarts, V2ThreadDecisionCard, V2ThreadStarter, V2ThreadReveal, V2ThreadDeliveryHint, V2Composer, V2ComposerMentionState — every one of them references AuthContext/AuthProvider, and all seven are inside the 142 that pass. So no harness renders the hook bare; that is measured rather than inferred.
Your fifth arm is the right control, too: flipping the signed-in user to larissa and asserting laruiss reappears proves the skip keys on the viewer and not on a name in the code.
1. No arm reaches the cap, so ranking-before-capping is unpinned
Moving .slice(0, 8) above the .sort(...) leaves 142 / 142 green. It cannot be caught: the fixture has 4 candidates and the cap is 8.
It is not a cosmetic ordering question. Probed with 12 candidates — ten people matching lar, then the agent last in pod order:
first offered = laruiss-agent agent present = true list length = 8
So the shipped code is correct, and that is the property nothing holds. Capped first, those same inputs rank only the first eight in pod order and the agent is not among them — it disappears from the picker entirely, which is the C14 failure again in any pod with more than eight matching members. One arm with a >8 fixture covers it.
2. The self-guard fails open when currentUser is unavailable — but the ranking covers for it
selfUsername is (currentUser?.username || '').toLowerCase(), and the skip is if (selfUsername && …), so an absent user disables it. Measured on the small fixture, no cap in play:
| auth state | picker |
|---|---|
signed in as laruiss |
[laruiss-agent, larissa] |
currentUser: null |
[laruiss-agent, **laruiss**, larissa] |
currentUser with no username |
[laruiss-agent, **laruiss**, larissa] |
Reachable without a 401: V2RequireAuth gates on loading and isAuthenticated, which is !!token, and never on currentUser — so a non-401 failure of /api/auth/user (a 500, a network blip) leaves loading: false, the token in place, and the composer rendering with currentUser null.
The mitigation is already in the PR and is worth seeing: in that degraded window the agent is still at index 0, so Enter still picks the agent and the C14 harm does not recur — the picker merely offers self as a second option. The two halves of this fix cover for each other, which is a good argument for both.
3. The case-insensitivity of the skip is unpinned and load-bearing
Dropping .toLowerCase() from selfUsername is 142 / 142 green, because every username in the fixture is already lowercase. It does real work: with the auth username LaRuiss against the member row laruiss, the shipped code still skips self ([laruiss-agent, larissa]), and the case-sensitive mutant would offer it.
Verified as claimed
Needle asserted at one occurrence, site printed, source restored and re-verified after every run:
| mutation | site | result |
|---|---|---|
| offer self | useV2ThreadMentions.ts:108 |
1 failed — never offers your own handle |
| drop agents-first | :169 |
2 failed — the agent comes first, so Enter picks it, other people are still offered, after the agent |
| drop prefix rank | :168 |
1 failed — a prefix match outranks a match in the middle |
startsWith → includes in prefix() (mine) |
:163 |
1 failed — same arm, so the prefix semantics are pinned, not just the term |
| cap before ranking (mine) | :165 |
142 / 142 |
drop the a.order - b.order tiebreak (mine) |
:170 |
142 / 142 |
| case-sensitive self-skip (mine) | :58 |
142 / 142 |
The tiebreak row is the one green I would not chase: V8's sort is already stable, so the explicit index only makes the guarantee independent of the engine. Its green is correctness, not a gap.
One measured consequence, for the record
A bare @ now lists agents before people, where it used to follow pod order — because ''.startsWith('') is true, every item is a prefix match and the tiebreak falls through to agents-first. Probed: members alice, scout, bob with scout the agent give [scout, alice, bob]. Consistent with "among matches the likeliest agent is first", and worth knowing it reaches the empty query too.
eslint on both changed files: 0 errors, 1 warning (react/jsx-filename-extension), and that warning is the house state — src/v2/__tests__ carries 78 of them and 0 errors. Your "0 lint errors" is accurate.
Written by sprint-review, a Commonly agent — https://commonly.me/v2/pods/6a692a1be833c668acdb84cf
GATE: PASS
… @ picker sprint-review on #2366 (PASS at 78b2e3c) found two properties the shipped code holds and no arm pinned: - Ranking before the 8-item cap. With ten people matching and the agent last in pod order, capping first drops the agent from the picker. New arm with 11 candidates; moving .slice(0, 8) above the sort now fails it. - The self-skip ignores case. A signed-in "LaRuiss" against the member row "laruiss" must still be skipped; dropping .toLowerCase() now fails it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG
lilyshen0722
left a comment
There was a problem hiding this comment.
Re-gate — #2366 at d2c558f7c3ea16aa5d7de128c9eb02f3e05a2fc7
Baseline: my PASS at 78b2e3c8. One commit since, d2c558f7, authored Lily Shen, and tests-only confirmed by path: frontend/src/v2/__tests__/useV2ThreadMentions.test.tsx is the whole diff. origin/main = cd37b32e0f9288ee24922362d3b86d17c5590368, still the merge-base. CI at the exact head: 14 pass / 2 skipping, Test & Coverage and Service Tests (Tier 1 — real DBs) SUCCESS in run 39513402517, head_sha read back as the head above. BASE: 144 tests, up two arms.
Both findings closed. One new finding, which is not yours.
The two arms discriminate, each alone
● @ picker › ranking happens before the 8-item cap: the agent last in pod order still leads
Tests: 1 failed, 143 passed, 144 total (.slice(0, 8) moved above the sort)
● @ picker › the self-skip ignores case
Tests: 2 failed, 142 passed, 144 total (.toLowerCase() dropped from selfUsername)
The second failure in that last row is the new finding below, not a second discrimination.
New: a flaky arm in the suites this gate cites
V2Thread decision cards › chunks loaded source ids so decisions at both ends of a long transcript remain discoverable fails on a clean tree. Measured, three consecutive runs of the unmutated scope:
run1: 1 failed, 143 passed ● chunks loaded source ids so decisions at both ends…
run2: 144 passed
run3: 144 passed
One in three on a small sample, so I will not claim a rate — but it is real, it is pre-existing (nothing in this PR touches V2Thread), and it sits inside the composer/thread/hook scope this gate and your own summary both quote. It cost me two misreadings before I isolated it: it appeared in both mutation rows above, and in the cap row it arrived alongside four more V2Thread failures that do not reproduce on an idle machine — those were my own contention from three batteries at once. Worth a ticket of its own; a flaky arm in a gate's scope makes every future mutation row here ambiguous.
Written by sprint-review, a Commonly agent — https://commonly.me/v2/pods/6a692a1be833c668acdb84cf
GATE: PASS
Why
In the C14 call rehearsal on live, 10-20, typing
@engin the composer listed the person's own account first ("Member") and their agent second. Enter took the first item, so they mentioned themself and the agent never woke. On the call that would be@lar+ Enter, giving@laruissinstead of@laruiss-agent.What
frontend/src/v2/hooks/useV2ThreadMentions.ts:You are never a candidate. The current user comes from
AuthContextinside the hook.V2Thread, its only caller, already callsuseAuth(), so no prop changes and no edit toV2Thread, which feat(first-session): Run an agent here starts a hosted agent from Scout's welcome, in one click (TASK-290) #2297 also touches.Ranking among matches:
So
@larpickslaruiss-agent, and@agstill picksagnesover an agent that only matches mid-word.Cofounder's brief said "agents first when the pod's agents were just active". The hook has no message history, so this ranks agents first among equal matches always. Removing yourself is what fixes the Enter trap.
Tests
useV2ThreadMentions.test.tsx:LaRuissagainstlaruiss). Capping before ranking, and dropping.toLowerCase(), now each fail an arm.Mutations. Each fails a test:
The composer, thread and hook suites pass, 142 tests.
tscreports no frontend errors and lint reports 0 errors.git merge-treeis clean against #2297, #2196, #2307 and #2360.Merge
Draft, aimed at Wednesday's second deploy. The cut-off is an exact-head PASS by Wed 13:30 PT.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG