diff --git a/mobile/src/session/mobile-native-chat-pending-retirement.test.ts b/mobile/src/session/mobile-native-chat-pending-retirement.test.ts index 66eb6b127db..1c865e2853c 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.test.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.test.ts @@ -2,7 +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, + GLUE_SLIDE_BUDGET, retireLandedMobileNativeChatPending, selectGluedPendingIds } from './mobile-native-chat-pending-retirement' @@ -201,6 +201,15 @@ describe('selectGluedPendingIds', () => { expect(retiredIds(messages, [stuckHead, ...pair])).toEqual(['p1', 'p2']) }) + // Nothing bounds how many sends pile onto the agent's input line, so the + // slide's budget must never truncate a genuine glue. + it('retires a glued run far longer than the slide budget', () => { + const words = Array.from({ length: 12 }, (_, index) => `w${index}`) + const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', words.join(' '), 5000)] + const pending = words.map((word, index) => pendingSend(`p${index}`, word, 'm1')) + expect(retiredIds(messages, pending)).toHaveLength(words.length) + }) + it('skips a caption-less image echo instead of gluing it', () => { const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)] const pending = [ @@ -229,8 +238,10 @@ describe('selectGluedPendingIds', () => { }) // 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. + // start positions — but one inspection budget covers the whole slide, keeping + // the work linear in the run length rather than quadratic. The attempt already + // in flight is allowed to overshoot the remaining budget, deliberately: that is + // what guarantees a genuine long glue is never truncated. it('keeps segment inspection linear in the run length per transcript turn', () => { const pendingCount = 96 const turnCount = 12 @@ -250,7 +261,7 @@ describe('selectGluedPendingIds', () => { } finally { startsWith.mockRestore() } - expect(segmentChecks).toBeLessThanOrEqual(turnCount * pendingCount * MAX_GLUE_SPAN) + expect(segmentChecks).toBeLessThanOrEqual(turnCount * (2 * pendingCount + GLUE_SLIDE_BUDGET)) }) }) diff --git a/mobile/src/session/mobile-native-chat-pending-retirement.ts b/mobile/src/session/mobile-native-chat-pending-retirement.ts index c990e348cbd..ffdde70ddd4 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.ts @@ -8,10 +8,13 @@ import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pendin const SPACE = ' ' const NO_PENDING_IDS: ReadonlySet = 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 +// Slack the cursor slide may spend re-trying later start positions, on top of +// one free pass over the run. Nothing bounds how many sends accumulate on the +// agent's input line — that ends when the agent accepts input again — so the +// budget must never truncate a genuine glue: the first attempt covers the whole +// run and always fits. It only stops a run of identical prefix-matching sends +// from making the scan quadratic. +export const GLUE_SLIDE_BUDGET = 8 type UserTurn = { index: number; text: string } type GlueSegment = { text: string; tail: number } | null @@ -71,10 +74,13 @@ export function selectGluedPendingIds( // 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 budget = runEnd - runStart + GLUE_SLIDE_BUDGET let start = cursor let matched = 0 - for (; start <= runEnd - 2; start++) { - matched = matchGluedRun(turn, segments, start, runEnd) + for (; start <= runEnd - 2 && budget > 0; start++) { + const attempt = matchGluedRun(turn, segments, start, runEnd) + budget -= attempt.inspected + matched = attempt.matched if (matched > 0) { break } @@ -92,37 +98,38 @@ export function selectGluedPendingIds( return retired } -/** Length of the exact glued run at `start`, or zero. */ +/** Length of the exact glued run at `start`, plus the segments it had to read. */ function matchGluedRun( turn: UserTurn, segments: readonly GlueSegment[], start: number, end: number -): number { +): { matched: number; inspected: number } { let at = 0 let matched = 0 - const limit = Math.min(end, start + MAX_GLUE_SPAN) - for (let index = start; index < limit; index++) { + let inspected = 0 + for (let index = start; index < end; index++) { const segment = segments[index]! + inspected += 1 // 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. if (turn.index <= segment.tail) { - return 0 + return { matched: 0, inspected } } if (at > 0 && turn.text[at] === SPACE) { at += 1 } if (!turn.text.startsWith(segment.text, at)) { - return 0 + return { matched: 0, inspected } } at += segment.text.length matched += 1 if (at === turn.text.length) { // A lone exact match is an ordinary landing, which the count pass owns. - return matched > 1 ? matched : 0 + return { matched: matched > 1 ? matched : 0, inspected } } } - return 0 + return { matched: 0, inspected } } /** Retires exact and glued transcript landings while preserving pending order. */