From c8942777d9e8bce78190e0ff3daeafcf6c17d8d7 Mon Sep 17 00:00:00 2001 From: Dalton Date: Thu, 13 Aug 2026 18:08:31 +0900 Subject: [PATCH] fix(native-chat): retire glued echoes when the input line kept a separator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An optimistic composer echo retires only on an exact normalized-text match, and the glue path concatenated pending bodies with no separator between them. When the unsubmitted input line kept that separator, the transcript landed "tell me a joke continue" rather than "tell me a jokecontinue", nothing matched, and no other rule retires an echo — so both bubbles stayed cached and, carrying `sentAt`, sorted past the newest real turn for the rest of the pane's life. Allow a single separator between glued bodies. Unrelated-prefix protection is unchanged: the pieces must still consume the row exactly ("hi" ↛ "history"). Co-Authored-By: Claude Opus 5 (1M context) --- .../native-chat-pending-occurrence.ts | 22 ++- .../native-chat/native-chat-pending.test.ts | 35 ++++ ...native-chat-glued-echo-drift-repro.spec.ts | 173 ++++++++++++++++++ 3 files changed, 222 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/native-chat-glued-echo-drift-repro.spec.ts 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 5a4631421bcc..51e1744301ac 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 @@ -128,9 +128,10 @@ 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 exactly to `userText`, allowing a + * single separator between bodies. Covers rapid-send glue both ways the TUI + * lands it ("joke"+"continue" → "jokecontinue" or "joke continue") without + * matching unrelated prefixes ("hi" ↛ "history"). */ export function countLeadingPendingTextsGluedToUserText( pendingTexts: readonly string[], @@ -139,19 +140,24 @@ 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 + // Why: an unsubmitted input line can keep the separator that sat between the + // two bodies, so the glued row reads "joke continue" and never matched. + if (cursor > 0 && userText.startsWith(' ', cursor)) { + cursor += 1 } - if (!userText.startsWith(combined)) { + if (!userText.startsWith(piece, cursor)) { return 0 } + cursor += piece.length + if (cursor === userText.length) { + return index + 1 + } } return 0 } 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 9686aa3eb382..f0bb08f881b7 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 @@ -161,6 +161,28 @@ describe('prunePendingSends', () => { ).toEqual([]) }) + it('prunes glued sends the input line kept a separator between', () => { + const pending = [pendingOf('p1', 'tell me a joke'), pendingOf('p2', 'continue')] + expect( + prunePendingSends(pending, [ + userMessage('u1', 'tell me a joke continue'), + assistantMessage('a1', 'a joke') + ]) + ).toEqual([]) + }) + + // Why: the composer writes the raw draft to the pty, so a trailing space the + // match key trims away still sits on the input line the next body glues onto. + it('prunes glued sends whose first draft carried a trailing space', () => { + const pending = [pendingOf('p1', 'tell me a joke '), pendingOf('p2', 'continue')] + expect( + prunePendingSends(pending, [ + userMessage('u1', 'tell me a joke continue'), + assistantMessage('a1', 'a joke') + ]) + ).toEqual([]) + }) + it('does not treat an unrelated longer user turn as a glued match', () => { const pending = [pendingOf('p1', 'hi')] expect( @@ -227,6 +249,19 @@ describe('pendingSendsAsMessages', () => { ) }) + // Why: an echo that outlives its glued turn keeps `sentAt`, so it sorts past + // the newest real turn and reads as the conversation reordering. + it('hides separator-glued sends instead of pinning them after the newest turn', () => { + const pending = [pendingOf('p1', 'tell me a joke'), pendingOf('p2', 'continue')] + const transcript = [ + userMessage('u1', 'tell me a joke continue'), + assistantMessage('a1', 'a joke'), + assistantMessage('a2', 'newest real turn') + ] + + expect(pendingSendsAsMessages(pending, transcript)).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/tests/e2e/native-chat-glued-echo-drift-repro.spec.ts b/tests/e2e/native-chat-glued-echo-drift-repro.spec.ts new file mode 100644 index 000000000000..17469338d801 --- /dev/null +++ b/tests/e2e/native-chat-glued-echo-drift-repro.spec.ts @@ -0,0 +1,173 @@ +import { randomUUID } from 'node:crypto' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + getTerminalContent, + waitForActivePaneHookDescriptor, + waitForActiveTerminalManager +} from './helpers/terminal' +import type { GlobalSettings } from '../../src/shared/types' + +const FIRST_SEND = 'tell me a joke' +const SECOND_SEND = 'continue' +const SEED_ASSISTANT = 'seed turn so the transcript hydrates' +const NEWEST_ASSISTANT = 'newest real turn from the agent' + +async function enableNativeChatSetting(page: Page): Promise { + await page.evaluate(async () => { + const nextSettings = await window.api.settings.set({ experimentalNativeChat: true }) + window.__store?.setState({ settings: nextSettings as GlobalSettings }) + }) +} + +// Why: mirrors native-chat-first-flush-race.spec.ts — drives the identical +// store → NativeChatView path a real Claude Code hook would, with no CLI. +// `idle` (not `working`) keeps the composer action a Send button. +async function seedClaudeProviderSession( + page: Page, + args: { paneKey: string; worktreeId: string; sessionId: string; transcriptPath: string } +): Promise { + await page.evaluate(({ paneKey, worktreeId, sessionId, transcriptPath }) => { + window.__store + ?.getState() + .setAgentStatus( + paneKey, + { state: 'idle', prompt: 'e2e glued echo drift probe', agentType: 'claude' }, + 'Claude', + undefined, + { worktreeId }, + { providerSession: { key: 'session_id', id: sessionId, transcriptPath } } + ) + }, args) +} + +async function toggleTerminalTabToChatView( + page: Page, + args: { tabId: string; worktreeId: string } +): Promise { + await page.evaluate(({ tabId, worktreeId }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const unifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.contentType === 'terminal' && tab.entityId === tabId + ) + if (!unifiedTab) { + throw new Error('Unified terminal tab not found for chat toggle') + } + state.toggleTabViewMode(unifiedTab.id) + }, args) +} + +function transcriptLines( + sessionId: string, + turns: readonly { role: 'user' | 'assistant'; text: string }[] +): string { + const base = Date.now() + return `${turns + .map((turn, index) => + JSON.stringify({ + sessionId, + uuid: `${sessionId}-${index}`, + timestamp: new Date(base + index * 1_000).toISOString(), + type: turn.role, + message: + turn.role === 'user' + ? { role: 'user', content: [{ type: 'text', text: turn.text }] } + : { model: 'claude-opus-4', content: [{ type: 'text', text: turn.text }] } + }) + ) + .join('\n')}\n` +} + +test.describe('Native chat glued optimistic echoes never retire', () => { + test('two rapid sends glued into one transcript row retire their echoes', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const descriptor = await waitForActivePaneHookDescriptor(orcaPage) + const [tabId] = descriptor.paneKey.split(':') + const sessionId = `e2e-glued-echo-${randomUUID()}` + const scratchDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-glued-echo-')) + const transcriptPath = path.join(scratchDir, `${sessionId}.jsonl`) + + const screenshotDir = path.join( + process.cwd(), + 'validation-screenshots', + `native-chat-glued-echo-drift-${Date.now()}` + ) + mkdirSync(screenshotDir, { recursive: true }) + await testInfo.attach('validation-screenshot-dir', { + body: screenshotDir, + contentType: 'text/plain' + }) + + try { + writeFileSync( + transcriptPath, + transcriptLines(sessionId, [{ role: 'assistant', text: SEED_ASSISTANT }]) + ) + + await enableNativeChatSetting(orcaPage) + await seedClaudeProviderSession(orcaPage, { + paneKey: descriptor.paneKey, + worktreeId: descriptor.worktreeId, + sessionId, + transcriptPath + }) + await toggleTerminalTabToChatView(orcaPage, { tabId, worktreeId: descriptor.worktreeId }) + + await expect(orcaPage.locator('[data-native-chat-root="true"]')).toBeVisible({ + timeout: 15_000 + }) + await expect(orcaPage.getByText(SEED_ASSISTANT)).toBeVisible({ timeout: 30_000 }) + + // Why: Enter is a separate delayed pty write, so a second send issued + // inside that window writes its body onto the same unsubmitted input line. + const composer = orcaPage.getByPlaceholder('Send a message…') + await composer.fill(FIRST_SEND) + await composer.press('Enter') + await composer.fill(SECOND_SEND) + await composer.press('Enter') + + await expect(orcaPage.getByText(FIRST_SEND, { exact: true })).toBeVisible({ timeout: 10_000 }) + await orcaPage.screenshot({ path: path.join(screenshotDir, '01-two-optimistic-echoes.png') }) + + // Evidence: what the two bodies actually look like on the pty input line. + const terminalText = await getTerminalContent(orcaPage) + await testInfo.attach('pty-input-line', { + body: terminalText, + contentType: 'text/plain' + }) + + // The agent accepts both bodies as one prompt, separated as the pty saw it. + writeFileSync( + transcriptPath, + transcriptLines(sessionId, [ + { role: 'assistant', text: SEED_ASSISTANT }, + { role: 'user', text: `${FIRST_SEND} ${SECOND_SEND}` }, + { role: 'assistant', text: NEWEST_ASSISTANT } + ]) + ) + + await expect(orcaPage.getByText(NEWEST_ASSISTANT)).toBeVisible({ timeout: 30_000 }) + await orcaPage.screenshot({ path: path.join(screenshotDir, '02-after-glued-turn.png') }) + + // Both optimistic echoes are represented by the glued row and must go. + await expect(orcaPage.getByText(FIRST_SEND, { exact: true })).toHaveCount(0) + await expect(orcaPage.getByText(SECOND_SEND, { exact: true })).toHaveCount(0) + } finally { + rmSync(scratchDir, { recursive: true, force: true }) + } + }) +})