fix(mobile-native-chat): stop one unmatchable send freezing glue for the session

The match cursor only advanced on a hit, so a head that could never match —
a pair whose glued row arrived with the read, or a send the count pass claimed
against an older row — froze the run behind it and every later rapid pair
became permanently unretirable. Two cases previously disclosed as bounded were
not bounded at all. Slide past a non-matching head, keeping the cursor
monotonic so a later turn can never take a send an earlier one claimed.

The slide widens the search, so a span cap keeps the work linear in the run
length instead of quadratic; the existing budget test now asserts that bound
rather than the old one it silently broke. Re-fuzzed at 250k seeds: the
boundary guarantee still holds.

Also corrects a comment that claimed the preview-pass filter made a photo claim
against a real tail. It does not — an image echo keeps whatever tail it
captured, so a caption-less photo can still bind to an older photo turn, as on
main.
This commit is contained in:
Brennan Benson
2026-08-16 21:54:33 -07:00
parent 947ccc3ef9
commit db003d66e0
3 changed files with 47 additions and 9 deletions
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo'
import {
MAX_GLUE_SPAN,
retireLandedMobileNativeChatPending,
selectGluedPendingIds
} from './mobile-native-chat-pending-retirement'
@@ -187,6 +188,19 @@ describe('selectGluedPendingIds', () => {
expect(retiredIds(messages, pending)).toEqual([])
})
// A head that can never match must not freeze the run behind it: one stuck
// bubble would otherwise disable glue retirement for the rest of the session.
it('glues a later pair even when the head of the run can never match', () => {
const messages = [
userTurn('m0', 'unrelated', 1000),
userTurn('m1', 'fix the bug', 5000),
userTurn('m2', 'ship the fix', 6000)
]
const stuckHead = pendingSend('p0', 'bug', 'm0')
const pair = [pendingSend('p1', 'ship the', 'm0'), pendingSend('p2', 'fix', 'm0')]
expect(retiredIds(messages, [stuckHead, ...pair])).toEqual(['p1', 'p2'])
})
it('skips a caption-less image echo instead of gluing it', () => {
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)]
const pending = [
@@ -214,7 +228,10 @@ describe('selectGluedPendingIds', () => {
expect(retiredIds(messages, [pendingSend('p1', 'one', 'm1')])).toEqual([])
})
it('inspects each pending segment at most once per transcript turn', () => {
// The cursor slides past a head that cannot match, so a turn may try several
// start positions — but each try is capped at MAX_GLUE_SPAN segments, which
// keeps the work linear in the run length rather than quadratic.
it('keeps segment inspection linear in the run length per transcript turn', () => {
const pendingCount = 96
const turnCount = 12
const text = `${'a '.repeat(pendingCount)}x`
@@ -233,7 +250,7 @@ describe('selectGluedPendingIds', () => {
} finally {
startsWith.mockRestore()
}
expect(segmentChecks).toBeLessThanOrEqual(turnCount * pendingCount)
expect(segmentChecks).toBeLessThanOrEqual(turnCount * pendingCount * MAX_GLUE_SPAN)
})
})
@@ -8,6 +8,10 @@ import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pendin
const SPACE = ' '
const NO_PENDING_IDS: ReadonlySet<string> = new Set()
// The host shares one input line, not an unbounded one: real glue is two or
// three sends. Capping the span keeps the cursor slide below linear-per-turn
// instead of quadratic when a long run of identical sends is outstanding.
export const MAX_GLUE_SPAN = 8
type UserTurn = { index: number; text: string }
type GlueSegment = { text: string; tail: number } | null
@@ -61,14 +65,27 @@ export function selectGluedPendingIds(
if (cursor >= runEnd - 1) {
break
}
const matched = matchGluedRun(turn, segments, cursor, runEnd)
// A send that can never match must not freeze the run behind it. One
// permanently unretirable head — a pair whose own glued row arrived with
// the read, or a send the count pass claimed against an older row — would
// otherwise disable glue retirement for every later pair, for the rest of
// the session. Slide past it; the cursor stays monotonic, so a later turn
// can never claim a send an earlier one already took.
let start = cursor
let matched = 0
for (; start <= runEnd - 2; start++) {
matched = matchGluedRun(turn, segments, start, runEnd)
if (matched > 0) {
break
}
}
if (matched === 0) {
continue
}
for (let index = cursor; index < cursor + matched; index++) {
for (let index = start; index < start + matched; index++) {
retired.add(pending[index]!.id)
}
cursor += matched
cursor = start + matched
}
runStart = runEnd + 1
}
@@ -84,7 +101,8 @@ function matchGluedRun(
): number {
let at = 0
let matched = 0
for (let index = start; index < end; index++) {
const limit = Math.min(end, start + MAX_GLUE_SPAN)
for (let index = start; index < limit; index++) {
const segment = segments[index]!
// Every send carries its OWN boundary: a row that already existed when this
// send was issued can never be part of its echo, however well it reads.
@@ -291,9 +291,12 @@ export function useMobileNativeChatDrafts(args: {
if (pending.length === 0) {
return
}
// This pass runs before the rebase below, so a send captured with no
// boundary would claim any image turn in the read — binding the user's fresh
// photo to an old one. It waits a tick and claims against a real tail.
// Only judge a send against a read known to be this session's. Note this
// does NOT give an image echo a boundary — the rebase deliberately leaves
// those on whatever they captured — so a caption-less photo sent before any
// read settled can still claim an older photo turn, exactly as it does on
// main. Fixing that needs a tail that excludes older image turns without
// excluding the send's own echo, which is a separate change.
const landedImagePreviews = findLandedImagePreviewEchoes(
messages,
pending.filter((item) => item.baselineResolved)