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 00231c7f114..17a75ec3a22 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.test.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.test.ts @@ -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 = [ diff --git a/mobile/src/session/mobile-native-chat-pending-retirement.ts b/mobile/src/session/mobile-native-chat-pending-retirement.ts index 7f720302ecc..81320e46465 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.ts @@ -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 = 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 = NO_PENDING_IDS ): ReadonlySet { const retired = new Set() - if (pending.length < 2) { + if (pending.length < 2 && !pending.some((item) => nativeChatContinuationSendText(item.text))) { return retired } const messageIndexById = new Map() @@ -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. */ diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts index 3a998b9f37f..f77447a2f62 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts @@ -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 { const represented = new Set() - 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 diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index 0491b9e1373..18b76306578 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -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')] diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index 7bdec0caedb..a828ac38d6e 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -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( diff --git a/src/shared/native-chat-continuation-send.ts b/src/shared/native-chat-continuation-send.ts new file mode 100644 index 00000000000..72cd0ac7a35 --- /dev/null +++ b/src/shared/native-chat-continuation-send.ts @@ -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 +}