mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(native-chat): retire ghost queued continuation bubbles
Retire queued continuation echoes when transcript rows represent the sends. Preserve literal backslashes, occurrence boundaries, and image barriers on mobile and desktop. Closes #15288. Adapted from #15283. Co-authored-by: grboy6770 <2000grboy@gmail.com>
This commit is contained in:
@@ -60,6 +60,63 @@ describe('selectGluedPendingIds', () => {
|
||||
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2'])
|
||||
})
|
||||
|
||||
// `first\` + Enter is a newline request to the agent TUI, not a submit, so the
|
||||
// next send lands in the SAME turn with the backslash dropped. The echo keeps
|
||||
// a character the transcript row never has.
|
||||
it('still retires literal-backslash sends glued by agents that retain it', () => {
|
||||
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'run\\ again', 5000)]
|
||||
const pending = [pendingSend('p1', 'run\\', 'm1'), pendingSend('p2', 'again', 'm1')]
|
||||
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2'])
|
||||
})
|
||||
|
||||
it('keeps a repeated continuation pending until a second transcript turn lands', () => {
|
||||
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'run again', 5000)]
|
||||
const pending = [pendingSend('p1', 'run\\', 'm1'), pendingSend('p2', 'run\\', 'm1', 2)]
|
||||
expect(retiredIds(messages, pending)).toEqual(['p1'])
|
||||
})
|
||||
|
||||
it('never retires continuation echoes against a row older than their baseline', () => {
|
||||
const messages = [userTurn('m1', 'run again', 1000), assistantTurn('m2', 'ready', 2000)]
|
||||
expect(retiredIds(messages, [pendingSend('p1', 'run\\', 'm2')])).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['one\\ two three', 'one two\\ three'])(
|
||||
'retires mixed literal and continuation backslashes in %s',
|
||||
(text) => {
|
||||
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', text, 5000)]
|
||||
const pending = [
|
||||
pendingSend('p1', 'one\\', 'm1'),
|
||||
pendingSend('p2', 'two\\', 'm1'),
|
||||
pendingSend('p3', 'three', 'm1')
|
||||
]
|
||||
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2', 'p3'])
|
||||
}
|
||||
)
|
||||
|
||||
it('retires a pair whose first send ended in a continuation backslash', () => {
|
||||
const messages = [
|
||||
assistantTurn('m1', 'ready', 1000),
|
||||
userTurn('m2', 'run the tests\nagain', 5000)
|
||||
]
|
||||
const pending = [pendingSend('p1', 'run the tests\\', 'm1'), pendingSend('p2', 'again', 'm1')]
|
||||
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2'])
|
||||
})
|
||||
|
||||
it('retires a lone continuation echo absorbed into a longer turn', () => {
|
||||
const messages = [
|
||||
assistantTurn('m1', 'ready', 1000),
|
||||
userTurn('m2', 'run the tests\nfrom another client', 5000)
|
||||
]
|
||||
const pending = [pendingSend('p1', 'run the tests\\', 'm1')]
|
||||
expect(retiredIds(messages, pending)).toEqual(['p1'])
|
||||
})
|
||||
|
||||
it('keeps a continuation echo when a later row only shares its prefix', () => {
|
||||
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'history repeats', 5000)]
|
||||
const pending = [pendingSend('p1', 'hi\\', 'm1')]
|
||||
expect(retiredIds(messages, pending)).toEqual([])
|
||||
})
|
||||
|
||||
it('retires three collapsed prompts in one row', () => {
|
||||
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two three', 5000)]
|
||||
const pending = [
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { nativeChatContinuationSendText } from '../../../src/shared/native-chat-continuation-send'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import {
|
||||
countImageSourceTurnsAfter,
|
||||
@@ -17,7 +18,7 @@ const NO_PENDING_IDS: ReadonlySet<string> = new Set()
|
||||
export const GLUE_SLIDE_BUDGET = 8
|
||||
|
||||
type UserTurn = { index: number; text: string }
|
||||
type GlueSegment = { text: string; tail: number } | null
|
||||
type GlueSegment = { text: string; literal: string; tail: number; continuation: boolean } | null
|
||||
|
||||
/** Pending ids represented by post-send transcript rows that glued adjacent sends. */
|
||||
export function selectGluedPendingIds(
|
||||
@@ -29,7 +30,7 @@ export function selectGluedPendingIds(
|
||||
reboundImagePendingIds: ReadonlySet<string> = NO_PENDING_IDS
|
||||
): ReadonlySet<string> {
|
||||
const retired = new Set<string>()
|
||||
if (pending.length < 2) {
|
||||
if (pending.length < 2 && !pending.some((item) => nativeChatContinuationSendText(item.text))) {
|
||||
return retired
|
||||
}
|
||||
const messageIndexById = new Map<string, number>()
|
||||
@@ -42,7 +43,8 @@ export function selectGluedPendingIds(
|
||||
}
|
||||
}
|
||||
const segments: GlueSegment[] = pending.map((item) => {
|
||||
const text = normalizeReconcileText(item.text)
|
||||
const continuation = nativeChatContinuationSendText(item.text)
|
||||
const text = continuation ?? normalizeReconcileText(item.text)
|
||||
const tail =
|
||||
item.baselineTailMessageId === null
|
||||
? -1
|
||||
@@ -58,7 +60,12 @@ export function selectGluedPendingIds(
|
||||
text === '' ||
|
||||
tail === null
|
||||
? null
|
||||
: { text, tail }
|
||||
: {
|
||||
text,
|
||||
literal: normalizeReconcileText(item.text),
|
||||
tail,
|
||||
continuation: continuation !== null
|
||||
}
|
||||
})
|
||||
|
||||
// Barriers preserve original adjacency after exact landings retire.
|
||||
@@ -73,7 +80,7 @@ export function selectGluedPendingIds(
|
||||
}
|
||||
let cursor = runStart
|
||||
for (const turn of turns) {
|
||||
if (cursor >= runEnd - 1) {
|
||||
if (cursor >= runEnd) {
|
||||
break
|
||||
}
|
||||
// A send that can never match must not freeze the run behind it. One
|
||||
@@ -85,7 +92,7 @@ export function selectGluedPendingIds(
|
||||
let budget = runEnd - runStart + GLUE_SLIDE_BUDGET
|
||||
let start = cursor
|
||||
let matched = 0
|
||||
for (; start <= runEnd - 2 && budget > 0; start++) {
|
||||
for (; start < runEnd && budget > 0; start++) {
|
||||
const attempt = matchGluedRun(turn, segments, start, runEnd)
|
||||
budget -= attempt.inspected
|
||||
matched = attempt.matched
|
||||
@@ -115,6 +122,7 @@ function matchGluedRun(
|
||||
): { matched: number; inspected: number } {
|
||||
let at = 0
|
||||
let matched = 0
|
||||
let opened = 0
|
||||
let inspected = 0
|
||||
for (let index = start; index < end; index++) {
|
||||
const segment = segments[index]!
|
||||
@@ -122,22 +130,27 @@ function matchGluedRun(
|
||||
// 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 { matched: 0, inspected }
|
||||
return { matched: opened, inspected }
|
||||
}
|
||||
if (at > 0 && turn.text[at] === SPACE) {
|
||||
at += 1
|
||||
}
|
||||
if (!turn.text.startsWith(segment.text, at)) {
|
||||
return { matched: 0, inspected }
|
||||
const literal = segment.continuation && turn.text.startsWith(segment.literal, at)
|
||||
const text = literal ? segment.literal : segment.text
|
||||
if (!turn.text.startsWith(text, at)) {
|
||||
return { matched: opened, inspected }
|
||||
}
|
||||
at += segment.text.length
|
||||
at += text.length
|
||||
matched += 1
|
||||
if (segment.continuation && !literal && (at === turn.text.length || turn.text[at] === SPACE)) {
|
||||
opened = matched
|
||||
}
|
||||
if (at === turn.text.length) {
|
||||
// A lone exact match is an ordinary landing, which the count pass owns.
|
||||
return { matched: matched > 1 ? matched : 0, inspected }
|
||||
return { matched: matched > 1 ? matched : opened, inspected }
|
||||
}
|
||||
}
|
||||
return { matched: 0, inspected }
|
||||
return { matched: opened, inspected }
|
||||
}
|
||||
|
||||
/** Retires exact and glued transcript landings while preserving pending order. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { nativeChatContinuationSendText } from '../../../../shared/native-chat-continuation-send'
|
||||
import {
|
||||
normalizeNativeChatUserText,
|
||||
normalizedNativeChatUserMessageText
|
||||
@@ -170,6 +171,39 @@ export function countLeadingPendingTextsGluedToUserText(
|
||||
return 0
|
||||
}
|
||||
|
||||
// Continuations can finish outside this queue, so only their prefix must be represented.
|
||||
function countLeadingPendingTextsRepresentedByUserText(
|
||||
pieces: readonly { text: string; literal: string; continuation: boolean }[],
|
||||
userText: string
|
||||
): number {
|
||||
let cursor = 0
|
||||
let opened = 0
|
||||
for (let index = 0; index < pieces.length; index += 1) {
|
||||
const piece = pieces[index]
|
||||
if (!piece?.text) {
|
||||
return opened
|
||||
}
|
||||
const at = index > 0 && userText[cursor] === ' ' ? cursor + 1 : cursor
|
||||
const literal = piece.continuation && userText.startsWith(piece.literal, at)
|
||||
const text = literal ? piece.literal : piece.text
|
||||
if (!userText.startsWith(text, at)) {
|
||||
return opened
|
||||
}
|
||||
cursor = at + text.length
|
||||
if (
|
||||
piece.continuation &&
|
||||
!literal &&
|
||||
(cursor === userText.length || userText[cursor] === ' ')
|
||||
) {
|
||||
opened = index + 1
|
||||
}
|
||||
if (cursor === userText.length) {
|
||||
return index > 0 ? index + 1 : opened
|
||||
}
|
||||
}
|
||||
return opened
|
||||
}
|
||||
|
||||
/** A transcript row a glue match may consume, carrying the send boundaries it
|
||||
* satisfies — this matcher has no clock of its own. */
|
||||
export type NativeChatGluedUserRow = {
|
||||
@@ -190,13 +224,19 @@ export function selectPendingIndicesRepresentedByUserRows(
|
||||
rows: readonly NativeChatGluedUserRow[]
|
||||
): Set<number> {
|
||||
const represented = new Set<number>()
|
||||
if (pending.length < 2 || rows.length === 0) {
|
||||
const remaining = pending.map((entry, index) => {
|
||||
const continuation = nativeChatContinuationSendText(entry.text)
|
||||
return {
|
||||
index,
|
||||
literal: normalizeNativeChatPendingText(entry.text),
|
||||
text: continuation ?? normalizeNativeChatPendingText(entry.text),
|
||||
continuation: continuation !== null
|
||||
}
|
||||
})
|
||||
// A lone continuation can be represented without a second queued send.
|
||||
if (rows.length === 0 || (pending.length < 2 && !remaining.some((entry) => entry.continuation))) {
|
||||
return represented
|
||||
}
|
||||
const remaining = pending.map((entry, index) => ({
|
||||
index,
|
||||
text: normalizeNativeChatPendingText(entry.text)
|
||||
}))
|
||||
for (const row of rows) {
|
||||
const open: typeof remaining = []
|
||||
for (const entry of remaining) {
|
||||
@@ -210,15 +250,11 @@ export function selectPendingIndicesRepresentedByUserRows(
|
||||
}
|
||||
open.push(entry)
|
||||
}
|
||||
const gluedCount = countLeadingPendingTextsGluedToUserText(
|
||||
open.map((entry) => entry.text),
|
||||
row.text
|
||||
)
|
||||
// Why: gluedCount === 1 is an exact match — leave it to occurrence counting.
|
||||
if (gluedCount < 2) {
|
||||
const matched = countLeadingPendingTextsRepresentedByUserText(open, row.text)
|
||||
if (matched === 0) {
|
||||
continue
|
||||
}
|
||||
for (let i = 0; i < gluedCount; i += 1) {
|
||||
for (let i = 0; i < matched; i += 1) {
|
||||
const entry = open[i]
|
||||
if (!entry) {
|
||||
continue
|
||||
|
||||
@@ -332,6 +332,112 @@ describe('glued rapid sends', () => {
|
||||
expect(prunePendingSends(pending, advancedGlueTranscript('hi there'))).toEqual(pending)
|
||||
})
|
||||
|
||||
// A draft ending in `\` is a newline request to the agent TUI, not a submit:
|
||||
// measured against Claude Code v2.1.234, `first\` + CR leaves the input open
|
||||
// with the backslash dropped, so the next send lands in the SAME turn
|
||||
// ("first\nsecond"). The echo keeps the backslash the transcript never has.
|
||||
it('still retires literal-backslash sends glued by agents that retain it', () => {
|
||||
const pending = [gluePending('p1', 'run\\'), gluePending('p2', 'again')]
|
||||
expect(prunePendingSends(pending, advancedGlueTranscript('run\\ again'))).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps repeated continuation sends matched to separate transcript occurrences', () => {
|
||||
const pending = [gluePending('p1', 'run\\'), gluePending('p2', 'run\\')]
|
||||
expect(prunePendingSends(pending, advancedGlueTranscript('run again'))).toEqual([pending[1]])
|
||||
})
|
||||
|
||||
it.each(['one\\ two three', 'one two\\ three'])(
|
||||
'retires mixed literal and continuation backslashes in %s',
|
||||
(text) => {
|
||||
const pending = [
|
||||
gluePending('p1', 'one\\'),
|
||||
gluePending('p2', 'two\\'),
|
||||
gluePending('p3', 'three')
|
||||
]
|
||||
expect(prunePendingSends(pending, advancedGlueTranscript(text))).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
it('retires a pair whose first send ended in a continuation backslash', () => {
|
||||
const pending = [gluePending('p1', 'tell me a joke\\'), gluePending('p2', 'continue')]
|
||||
|
||||
expect(prunePendingSends(pending, advancedGlueTranscript('tell me a joke\ncontinue'))).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('hides a continuation-backslash pair as soon as its glued row lands', () => {
|
||||
const pending = [gluePending('p1', 'tell me a joke\\'), gluePending('p2', 'continue')]
|
||||
|
||||
expect(pendingSendsAsMessages(pending, glueTranscript('tell me a joke\ncontinue'))).toEqual([])
|
||||
})
|
||||
|
||||
// The observed report: the rest of the turn was finished somewhere this queue
|
||||
// cannot see (another client, or typed into the pane), so the echo is alone.
|
||||
it('retires a lone continuation echo absorbed into a longer turn', () => {
|
||||
const pending = [gluePending('p1', 'tell me a joke\\')]
|
||||
|
||||
expect(
|
||||
prunePendingSends(pending, advancedGlueTranscript('tell me a joke\nabout cats'))
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a continuation echo when a later row only shares its prefix', () => {
|
||||
const pending = [gluePending('p1', 'hi\\')]
|
||||
|
||||
expect(prunePendingSends(pending, advancedGlueTranscript('history repeats'))).toEqual(pending)
|
||||
})
|
||||
|
||||
it('retires a continuation echo the user then submitted on its own', () => {
|
||||
// `\` + Enter, then Enter again on the empty line it opened.
|
||||
const pending = [gluePending('p1', 'tell me a joke\\')]
|
||||
|
||||
expect(prunePendingSends(pending, advancedGlueTranscript('tell me a joke'))).toEqual([])
|
||||
})
|
||||
|
||||
it('retires a pair when only the last of several backslashes was eaten', () => {
|
||||
const pending = [gluePending('p1', 'tell me a joke\\\\'), gluePending('p2', 'continue')]
|
||||
|
||||
expect(
|
||||
prunePendingSends(pending, advancedGlueTranscript('tell me a joke\\\ncontinue'))
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('retires a run of three sends chained by continuations', () => {
|
||||
const pending = [
|
||||
gluePending('p1', 'first\\'),
|
||||
gluePending('p2', 'second\\'),
|
||||
gluePending('p3', 'third')
|
||||
]
|
||||
|
||||
expect(prunePendingSends(pending, advancedGlueTranscript('first\nsecond\nthird'))).toEqual([])
|
||||
})
|
||||
|
||||
it('retires the reported prompt, whose text is not Latin', () => {
|
||||
const pending = [
|
||||
gluePending('p1', '사양을 올리는게 가능해? 이건 과금의 영역아니야?\\'),
|
||||
gluePending('p2', '일단 퀵하게 되는 버전으로 반영되어야할거같아')
|
||||
]
|
||||
|
||||
expect(
|
||||
prunePendingSends(
|
||||
pending,
|
||||
advancedGlueTranscript(
|
||||
'사양을 올리는게 가능해? 이건 과금의 영역아니야?\n일단 퀵하게 되는 버전으로 반영되어야할거같아'
|
||||
)
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a continuation echo the row breaks mid-word', () => {
|
||||
// The run must stop on the separator the TUI's newline normalizes to.
|
||||
const pending = [gluePending('p1', 'tell me a jo\\')]
|
||||
|
||||
expect(prunePendingSends(pending, advancedGlueTranscript('tell me a joke about cats'))).toEqual(
|
||||
pending
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a queued pair when an earlier turn splits across it (#14406 regression)', () => {
|
||||
// The row predates both sends: "run the tests"+"again" only looks glued.
|
||||
const pending = [pendingOf('p1', 'run the tests'), pendingOf('p2', 'again')]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// user turn lands in the transcript. Kept separate from the view so the prune
|
||||
// rule (match on normalized user-message content) is unit-testable without React.
|
||||
|
||||
import { nativeChatContinuationSendText } from '../../../../shared/native-chat-continuation-send'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { setBoundedScopeCacheEntry } from '../../../../shared/native-chat-scope-cache'
|
||||
import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt'
|
||||
@@ -187,9 +188,8 @@ function gluedCandidateRows(
|
||||
const [oldest, ...newer] = open.map((entry) =>
|
||||
entry.afterMessageId === undefined ? { ...entry, afterMessageId: null } : entry
|
||||
)
|
||||
// Glue needs a run of 2+ sends, so a lone queued echo — by far the common
|
||||
// case while an agent streams — skips the per-send boundary scans entirely.
|
||||
if (!oldest || newer.length === 0) {
|
||||
// Ordinary lone sends need no glue scan; continuations may finish outside this queue.
|
||||
if (!oldest || (newer.length === 0 && nativeChatContinuationSendText(oldest.text) === null)) {
|
||||
return []
|
||||
}
|
||||
const newerRowIds = newer.map(
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { normalizeNativeChatUserText } from './native-chat-image-transcript-markers'
|
||||
|
||||
// Agent TUIs may consume the final backslash and join the next send into this turn.
|
||||
export function nativeChatContinuationSendText(text: string): string | null {
|
||||
return text.endsWith('\\') ? normalizeNativeChatUserText(text.slice(0, -1)) : null
|
||||
}
|
||||
Reference in New Issue
Block a user