mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 00:02:48 +00:00
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user