diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.test.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.test.ts index 06d7111095d..23e5c37db85 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { countLeadingPendingTextsGluedToUserText } from './native-chat-pending-occurrence' import { appendPendingSendCache, clearPendingSendCacheForTests, @@ -64,3 +65,32 @@ describe('pending send occurrence reconciliation', () => { expect(prunePendingSends(afterFirstPrune, secondCompletedTurn)).toEqual([]) }) }) + +describe('countLeadingPendingTextsGluedToUserText', () => { + it('consumes a leading run with or without one separator per boundary', () => { + expect(countLeadingPendingTextsGluedToUserText(['joke', 'continue'], 'jokecontinue')).toBe(2) + expect(countLeadingPendingTextsGluedToUserText(['joke', 'continue'], 'joke continue')).toBe(2) + expect(countLeadingPendingTextsGluedToUserText(['a', 'b', 'c'], 'ab c')).toBe(3) + }) + + it('stops at the first prompt that does not continue the row', () => { + expect(countLeadingPendingTextsGluedToUserText(['hi'], 'history')).toBe(0) + expect(countLeadingPendingTextsGluedToUserText(['hi', 'story'], 'hi story continued')).toBe(0) + expect(countLeadingPendingTextsGluedToUserText(['hi', 'there friend'], 'hi there')).toBe(0) + expect(countLeadingPendingTextsGluedToUserText(['hi', 'x'], 'hi x')).toBe(0) + }) + + it('requires the first prompt to start the row', () => { + expect(countLeadingPendingTextsGluedToUserText(['hi', 'there'], ' hi there')).toBe(0) + }) + + it('returns the run length, leaving later prompts to the next row', () => { + expect(countLeadingPendingTextsGluedToUserText(['a', 'b', 'c'], 'a b')).toBe(2) + }) + + it('rejects empty inputs and empty prompts', () => { + expect(countLeadingPendingTextsGluedToUserText([], 'anything')).toBe(0) + expect(countLeadingPendingTextsGluedToUserText(['a'], '')).toBe(0) + expect(countLeadingPendingTextsGluedToUserText(['a', '', 'b'], 'ab')).toBe(0) + }) +}) 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 a994ecf8da7..1184affb369 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 @@ -114,9 +114,13 @@ export function matchingNativeChatUserTexts( } /** - * How many leading pending texts concatenate exactly to `userText`. - * Covers rapid-send glue ("joke"+"continue" → "jokecontinue") without matching - * unrelated prefixes ("hi" ↛ "history"). + * How many leading pending texts concatenate to exactly `userText`, allowing at + * most one collapsed space at each send boundary. Covers rapid-send glue + * ("joke"+"continue" → "joke continue") while still requiring the whole row to + * be consumed, so unrelated prefixes never match ("hi" ↛ "history"). + * + * Greedy is exact here: both sides are whitespace-normalized, so a piece never + * starts with a space and at most one of the two boundary forms can apply. */ export function countLeadingPendingTextsGluedToUserText( pendingTexts: readonly string[], @@ -125,19 +129,22 @@ export function countLeadingPendingTextsGluedToUserText( if (pendingTexts.length === 0 || userText.length === 0) { return 0 } - let combined = '' + let cursor = 0 for (let index = 0; index < pendingTexts.length; index += 1) { const piece = pendingTexts[index] if (!piece) { return 0 } - combined += piece - if (combined === userText) { - return index + 1 - } - if (!userText.startsWith(combined)) { + if (userText.startsWith(piece, cursor)) { + cursor += piece.length + } else if (index > 0 && userText.startsWith(` ${piece}`, cursor)) { + cursor += piece.length + 1 + } else { return 0 } + if (cursor === userText.length) { + return index + 1 + } } return 0 } @@ -147,6 +154,9 @@ export function countLeadingPendingTextsGluedToUserText( * optimistic texts concatenated into one transcript user row). Exact single * matches stay in the content-key/occurrence path so repeated prompts and * send boundaries keep their existing semantics. + * + * `userTexts` must already be filtered to rows after the oldest entry's send + * boundary — this matcher has no clock of its own. */ export function selectPendingIndicesRepresentedByUserTexts( pending: readonly NativeChatPendingOccurrence[], 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 75b2c3d34c7..29771fd9507 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 @@ -22,22 +22,22 @@ import { } from './native-chat-pending' import { stripNoiseMessages } from './native-chat-noise' -function userMessage(id: string, text: string): NativeChatMessage { +function userMessage(id: string, text: string, timestamp = 1): NativeChatMessage { return { id, role: 'user', blocks: [{ type: 'text', text }], - timestamp: 1, + timestamp, source: 'transcript' } } -function assistantMessage(id: string, text: string): NativeChatMessage { +function assistantMessage(id: string, text: string, timestamp = 2): NativeChatMessage { return { id, role: 'assistant', blocks: [{ type: 'text', text }], - timestamp: 2, + timestamp, source: 'transcript' } } @@ -179,16 +179,6 @@ describe('prunePendingSends', () => { ).toEqual([pendingOf('p2', 'repeat')]) }) - it('prunes consecutive optimistic sends that were glued into one transcript user turn', () => { - const pending = [pendingOf('p1', 'tell me a joke'), pendingOf('p2', 'continue')] - expect( - prunePendingSends(pending, [ - userMessage('u1', 'tell me a jokecontinue'), - assistantMessage('a1', 'a joke') - ]) - ).toEqual([]) - }) - it('does not treat an unrelated longer user turn as a glued match', () => { const pending = [pendingOf('p1', 'hi')] expect( @@ -200,6 +190,148 @@ describe('prunePendingSends', () => { }) }) +// A glued row is written by the agent AFTER the sends that produced it, so every +// fixture here puts the transcript row past `sentAt`. Matching an earlier row is +// the failure mode these tests exist to pin down. +const GLUE_BOUNDARY = assistantMessage('glue-boundary', 'ready', 1000) +const GLUE_SENT_AT = 5000 + +function gluePending(id: string, text: string): NativeChatPendingSend { + return { + id, + text, + sentAt: GLUE_SENT_AT, + afterMessageId: GLUE_BOUNDARY.id, + afterMessageTimestamp: GLUE_BOUNDARY.timestamp + } +} + +/** Visible history, the send boundary, then the row the agent glued the queue into. */ +function glueTranscript(row: string): NativeChatMessage[] { + return [ + userMessage('glue-history', 'hello', 900), + GLUE_BOUNDARY, + userMessage('glue-row', row, 6000) + ] +} + +function advancedGlueTranscript(row: string): NativeChatMessage[] { + return [...glueTranscript(row), assistantMessage('glue-answer', 'done', 6100)] +} + +describe('glued rapid sends', () => { + it('retires both echoes when a lost Enter glued the pair into one row', () => { + const pending = [gluePending('p1', 'tell me a joke'), gluePending('p2', 'continue')] + + expect(prunePendingSends(pending, advancedGlueTranscript('tell me a jokecontinue'))).toEqual([]) + }) + + it('hides both echoes as soon as the glued row lands, before the reply', () => { + const pending = [gluePending('p1', 'tell me a joke'), gluePending('p2', 'continue')] + + expect(pendingSendsAsMessages(pending, glueTranscript('tell me a jokecontinue'))).toEqual([]) + }) + + it('retires a pair the agent glued with a separator of its own', () => { + const pending = [gluePending('p1', 'tell me a joke'), gluePending('p2', 'continue')] + + expect(prunePendingSends(pending, advancedGlueTranscript('tell me a joke continue'))).toEqual( + [] + ) + }) + + it('retires a pair whose glued row separates with tabs and newlines', () => { + const pending = [gluePending('p1', 'tell me a joke'), gluePending('p2', 'continue')] + + expect( + prunePendingSends(pending, advancedGlueTranscript('tell me a joke \t\n continue')) + ).toEqual([]) + }) + + it('retires three prompts collapsed into one row with mixed boundaries', () => { + const pending = [ + gluePending('p1', 'first'), + gluePending('p2', 'second'), + gluePending('p3', 'third') + ] + + expect(prunePendingSends(pending, advancedGlueTranscript('firstsecond third'))).toEqual([]) + }) + + it('retires prompts that themselves contain the separator', () => { + const pending = [gluePending('p1', 'fix the bug'), gluePending('p2', 'then run the tests')] + + expect( + prunePendingSends(pending, advancedGlueTranscript('fix the bug then run the tests')) + ).toEqual([]) + }) + + it('retires an identical prompt sent twice', () => { + const pending = [gluePending('p1', 'repeat'), gluePending('p2', 'repeat')] + + expect(prunePendingSends(pending, advancedGlueTranscript('repeat repeat'))).toEqual([]) + }) + + it('retires an identical prompt sent three times', () => { + const pending = [gluePending('p1', 'ha'), gluePending('p2', 'ha'), gluePending('p3', 'ha')] + + expect(prunePendingSends(pending, advancedGlueTranscript('haha ha'))).toEqual([]) + }) + + it('retires a pending send that is a prefix of the next one', () => { + const pending = [gluePending('p1', 'hi'), gluePending('p2', 'hi there')] + + expect(prunePendingSends(pending, advancedGlueTranscript('hi hi there'))).toEqual([]) + }) + + it('keeps sends when the glue only reaches a prefix of the row', () => { + const pending = [gluePending('p1', 'hi'), gluePending('p2', 'story')] + + expect(prunePendingSends(pending, advancedGlueTranscript('hi story continued'))).toEqual( + pending + ) + }) + + it('keeps sends when the row runs out mid-prompt', () => { + const pending = [gluePending('p1', 'hi'), gluePending('p2', 'there friend')] + + expect(prunePendingSends(pending, advancedGlueTranscript('hi there'))).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')] + + expect( + prunePendingSends(pending, [ + userMessage('u1', 'run the tests again'), + assistantMessage('a1', 'sure') + ]) + ).toEqual(pending) + }) + + it('keeps a re-sent pair that an older identical turn would match (#14406 regression)', () => { + // Rapid-sending "fix the"+"bug" a second time must not bind to the first + // "fix the bug" turn — that drops a real queued prompt with no bubble. + const history = [userMessage('u1', 'fix the bug', 1000), assistantMessage('a1', 'fixed', 1100)] + const pending = [gluePending('p3', 'fix the'), gluePending('p4', 'bug')] + + expect(prunePendingSends(pending, history)).toEqual(pending) + expect(pendingSendsAsMessages(pending, history)).toHaveLength(2) + }) + + it('keeps a re-sent pair even with no recorded message boundary', () => { + const history = [userMessage('u1', 'fix the bug', 1000), assistantMessage('a1', 'fixed', 1100)] + const pending = [ + { id: 'p3', text: 'fix the', sentAt: GLUE_SENT_AT }, + { id: 'p4', text: 'bug', sentAt: GLUE_SENT_AT } + ] + + expect(prunePendingSends(pending, history)).toEqual(pending) + expect(pendingSendsAsMessages(pending, history)).toHaveLength(2) + }) +}) + describe('pendingSendsAsMessages', () => { it('returns the empty input without reading existing history', () => { const pending: NativeChatPendingSend[] = [] @@ -248,13 +380,6 @@ describe('pendingSendsAsMessages', () => { expect(pendingSendsAsMessages(pending, [])).toHaveLength(1) }) - it('hides consecutive optimistic sends once a glued transcript user turn lands', () => { - const pending = [pendingOf('p1', 'tell me a joke'), pendingOf('p2', 'continue')] - expect(pendingSendsAsMessages(pending, [userMessage('u1', 'tell me a jokecontinue')])).toEqual( - [] - ) - }) - it('keeps a repeated prompt visible when its only match predates the send boundary', () => { const history = [userMessage('old-user', 'run tests'), assistantMessage('old-answer', 'passed')] const pending = [{ ...pendingOf('new-send', 'run tests'), afterMessageId: 'old-answer' }] 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 a33b4e27838..c322656f152 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -125,6 +125,26 @@ function messageIsAfterPendingTimestamp( : message.timestamp > boundary } +/** + * Rows a glue match may consume. Glue always starts at the oldest still-open + * echo, so that echo's send boundary is the floor: unbounded, an older turn + * whose text happens to split across the queue ("fix the bug" vs "fix the" + + * "bug") would retire sends issued long after it. A missing message boundary + * falls back to send time — a fuzzy match must never reach further back than + * an exact one. + */ +function gluedCandidateMessages( + messages: readonly NativeChatMessage[], + open: readonly NativeChatPendingSend[] +): readonly NativeChatMessage[] { + const oldest = open[0] + if (!oldest) { + return [] + } + const anchor = oldest.afterMessageId === undefined ? { ...oldest, afterMessageId: null } : oldest + return messagesAfterPendingBoundary(messages, anchor) +} + /** * Drop any pending send only after the transcript has advanced beyond its real * user turn. Keeping the echo through the user-only transcript phase prevents a @@ -151,13 +171,13 @@ export function prunePendingSends( consumed.set(key, Math.max(used, occurrence)) return occurrence > available }) - // Why: when rapid body writes glued two optimistic sends into one transcript - // user row ("joke"+"continue"→"jokecontinue"), exact keys never match. Drop - // those echoes once an assistant turn advances past the glued user text. + // Why: when a lost Enter glued two optimistic sends onto one input line, the + // transcript carries one row ("joke"+"continue"→"jokecontinue") that no exact + // key matches. Drop those echoes once an assistant turn advances past it. const stillOpen = pending.filter((_, index) => exactKeep[index]) const gluedRepresented = selectPendingIndicesRepresentedByUserTexts( stillOpen, - advancedNativeChatUserTexts(messages) + advancedNativeChatUserTexts(gluedCandidateMessages(messages, stillOpen)) ) const next = pending.filter((entry, index) => { if (!exactKeep[index]) { @@ -200,7 +220,7 @@ export function pendingSendsAsMessages( const stillVisible = pending.filter((_, index) => exactVisible[index]) const gluedRepresented = selectPendingIndicesRepresentedByUserTexts( stillVisible, - matchingNativeChatUserTexts(existingMessages) + matchingNativeChatUserTexts(gluedCandidateMessages(existingMessages, stillVisible)) ) return pending .filter((entry, index) => {