diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index e11f4cfa19e..346fe9ee7d1 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -10,6 +10,84 @@ } }, "gates": [ + { + "id": "native-chat.transcript-consumed-boundary", + "title": "Transcript replacement remains visible when it races message publication", + "maturity": "experimental", + "protection": "partial", + "owner": "native-chat", + "layer": "host-file-reader-and-renderer", + "surfaces": ["native chat live transcript"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local"], + "coverageNotes": "Real local JSONL files exercise the shared host reader with Claude records; hidden Electron proof traverses actual host IPC and chat rendering. No physical remote or Windows/Linux deployment claim.", + "motivatingLinks": ["https://github.com/stablyai/orca/actions/runs/37524761640"], + "invariant": "A retained transcript cursor fingerprint contains only bytes actually consumed at that cursor. A larger in-place rewrite during publication must be delivered, and subsequent ordinary appends arrive once. Closing or failing a test leaves no active watcher.", + "oracle": "Synchronous larger rewrites during append, initial snapshot and replacement snapshot callbacks deliver new exactly once, followed by followup exactly once. A 4 MiB read retains an independent 64-byte backing allocation. Hidden Electron reproduces the same ordering at snapshot IPC publication and asserts the new rendered text replaces the old conversation.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/transcript-watch-inplace-drain-race.test.ts src/main/native-chat/transcript-file-version.test.ts src/main/native-chat/transcript-watch.test.ts --maxWorkers=2", + "ORCA_BACKGROUND_LAUNCH=1 pnpm run test:e2e tests/e2e/native-chat-inplace-drain-race.spec.ts --workers=1" + ], + "testFiles": [ + "src/main/native-chat/transcript-watch-inplace-drain-race.test.ts", + "src/main/native-chat/transcript-file-version.test.ts", + "src/main/native-chat/transcript-watch.test.ts", + "tests/e2e/native-chat-inplace-drain-race.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/main/native-chat/transcript-watch-inplace-drain-race.test.ts", + "assertions": ["delivers a larger rewrite while publishing a completed %s"] + }, + { + "file": "src/main/native-chat/transcript-file-version.test.ts", + "assertions": ["owns only the final 64 bytes of a large read"] + }, + { + "file": "tests/e2e/native-chat-inplace-drain-race.spec.ts", + "assertions": [ + "renders a larger transcript rewrite during snapshot publication, then ordinary appends" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-10-06", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/transcript-watch-inplace-drain-race.test.ts src/main/native-chat/transcript-file-version.test.ts src/main/native-chat/transcript-watch.test.ts --maxWorkers=2", + "result": "passed", + "durationSeconds": 2.85, + "summary": "32 tests in three files pass, including all three publication modes, replacement sizes, partial records and watcher teardown." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "Focused local unit command; CI/platform soak not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Deterministic publication ordering replaces a timing-dependent filesystem race; no long-term soak claim." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Identical append callback regression fails on parent main with only old observed and passes with consumed-byte fingerprinting." + }, + "performanceBudget": { + "required": true, + "evidence": "At most 64 owned, unpooled bytes per cursor. Fingerprint capture reuses already-read chunks and removes the post-publication filesystem read; no new timers, polls or subprocesses." + }, + "knownGaps": [ + "Physical SSH/WSL/runtime hosts and native Linux/Windows remain unverified.", + "This gate targets rewrites during publication, not atomic snapshots of arbitrary concurrent multi-chunk file overwrites." + ], + "promotionCriteria": [ + "Collect policy soak and native/remote host evidence without weakening delivery or resource assertions." + ], + "demotionRule": "Remain experimental until soak and platform coverage; investigate missing replacements without retries or longer timeouts." + }, { "id": "terminal-input.prompt-reply-occurrence-acknowledgment", "title": "Terminal chat replies hide only the acknowledged prompt occurrence", diff --git a/src/main/native-chat/transcript-file-version.test.ts b/src/main/native-chat/transcript-file-version.test.ts new file mode 100644 index 00000000000..01851b9e36a --- /dev/null +++ b/src/main/native-chat/transcript-file-version.test.ts @@ -0,0 +1,20 @@ +import { expect, it } from 'vitest' +import { extendTranscriptBoundary } from './transcript-file-version' + +it('owns only the final 64 bytes of a large read', () => { + const chunk = Buffer.alloc(4 * 1024 * 1024, 'x') + chunk.write('end', chunk.length - 3) + const boundary = extendTranscriptBoundary(Buffer.from('previous'), chunk) + expect(boundary.toString()).toBe(`${'x'.repeat(61)}end`) + expect(boundary.buffer.byteLength).toBe(64) + chunk.fill('y') + expect(boundary.toString()).toBe(`${'x'.repeat(61)}end`) +}) + +it('retains the consumed suffix across short reads and reset-sized inputs', () => { + const first = extendTranscriptBoundary(Buffer.alloc(0), Buffer.from('first')) + const next = extendTranscriptBoundary(first, Buffer.from(' next')) + expect(next.toString()).toBe('first next') + expect(next.buffer.byteLength).toBe(next.length) + expect(extendTranscriptBoundary(Buffer.alloc(0), Buffer.alloc(0)).length).toBe(0) +}) diff --git a/src/main/native-chat/transcript-file-version.ts b/src/main/native-chat/transcript-file-version.ts index 2de3c76821f..d0dfed29b5f 100644 --- a/src/main/native-chat/transcript-file-version.ts +++ b/src/main/native-chat/transcript-file-version.ts @@ -2,6 +2,15 @@ import { readTranscriptSlice, wslGatedStat } from './wsl-transcript-fs-access' const BOUNDARY_FINGERPRINT_BYTES = 64 +export function extendTranscriptBoundary(previous: Buffer, chunk: Buffer): Buffer { + const chunkBytes = Math.min(chunk.length, BOUNDARY_FINGERPRINT_BYTES) + const previousBytes = Math.min(previous.length, BOUNDARY_FINGERPRINT_BYTES - chunkBytes) + const boundary = Buffer.allocUnsafeSlow(previousBytes + chunkBytes) + previous.copy(boundary, 0, previous.length - previousBytes) + chunk.copy(boundary, previousBytes, chunk.length - chunkBytes) + return boundary +} + export type TranscriptFileVersion = { identity: string size: number diff --git a/src/main/native-chat/transcript-incremental-reader.ts b/src/main/native-chat/transcript-incremental-reader.ts index 82fbfe57b0d..558c552139d 100644 --- a/src/main/native-chat/transcript-incremental-reader.ts +++ b/src/main/native-chat/transcript-incremental-reader.ts @@ -1,5 +1,6 @@ import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types' import { transcriptFallbackId } from './transcript-fallback-id' +import { extendTranscriptBoundary } from './transcript-file-version' import { MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES, type NativeChatLineDecoder @@ -10,6 +11,7 @@ const APPEND_BATCH_MESSAGE_LIMIT = 40 export type IncrementalTranscriptState = { offset: number + boundary: Buffer pendingChunks: Buffer[] pendingStart: number pendingBytes: number @@ -19,6 +21,7 @@ export type IncrementalTranscriptState = { export function createIncrementalTranscriptState(): IncrementalTranscriptState { return { offset: 0, + boundary: Buffer.alloc(0), pendingChunks: [], pendingStart: 0, pendingBytes: 0, @@ -28,6 +31,7 @@ export function createIncrementalTranscriptState(): IncrementalTranscriptState { export function resetIncrementalTranscriptState(state: IncrementalTranscriptState): void { state.offset = 0 + state.boundary = Buffer.alloc(0) state.pendingChunks.length = 0 state.pendingStart = 0 state.pendingBytes = 0 @@ -74,6 +78,7 @@ export async function readIncrementalTranscriptMessages( } absoluteOffset += chunk.length state.offset = absoluteOffset + state.boundary = extendTranscriptBoundary(state.boundary, chunk) } return messages } finally { diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index 7afef93571f..8f486ecbe26 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -13,6 +13,7 @@ import { decodeOmpTranscriptLine } from './transcript-line-decoders' import { transcriptFallbackId } from './transcript-fallback-id' +import { extendTranscriptBoundary } from './transcript-file-version' import { nativeChatTurnLifecycleDecoderForAgent, type NativeChatTurnLifecycleDecoder @@ -58,7 +59,8 @@ export async function readNativeChatTranscriptTailFile( includeTrailingLine = false, endOffset?: number, decodeLifecycle?: NativeChatTurnLifecycleDecoder | null, - signal?: AbortSignal + signal?: AbortSignal, + onConsumedBoundary?: (boundary: Buffer) => void ): Promise<{ messages: NativeChatMessage[] lifecycle?: NativeChatTurnLifecycle @@ -101,6 +103,11 @@ export async function readNativeChatTranscriptTailFile( } ignoreNextMalformedRecord = finalByte !== 0x0a let cursor = consumedTo - (finalByte === 0x0a ? 1 : 0) + const finalBoundaryByte = finalByte === 0x0a ? Buffer.from([finalByte]) : Buffer.alloc(0) + let boundaryCaptured = false + if (cursor === 0) { + onConsumedBoundary?.(finalBoundaryByte) + } while (cursor > 0 && newestFirst.length <= limit) { signal?.throwIfAborted() const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) @@ -121,6 +128,10 @@ export async function readNativeChatTranscriptTailFile( if (bytesRead < buffer.length) { break } + if (!boundaryCaptured && onConsumedBoundary) { + onConsumedBoundary(extendTranscriptBoundary(buffer, finalBoundaryByte)) + boundaryCaptured = true + } let segmentEnd = bytesRead for (let index = bytesRead - 1; index >= 0 && newestFirst.length <= limit; index--) { if (buffer[index] !== 0x0a) { diff --git a/src/main/native-chat/transcript-watch-engine.ts b/src/main/native-chat/transcript-watch-engine.ts index b0ebbeaebfc..894863bc970 100644 --- a/src/main/native-chat/transcript-watch-engine.ts +++ b/src/main/native-chat/transcript-watch-engine.ts @@ -44,7 +44,6 @@ export async function installTranscriptWatcher( const state = createIncrementalTranscriptState() let watchedVersion: TranscriptFileVersion | null = null - let watchedBoundary = '' let initialDrain = true, initialErrorEmitted = false let closed = false @@ -89,8 +88,22 @@ export async function installTranscriptWatcher( } } + async function readSnapshot(limit: number) { + return readNativeChatTranscriptTailFile( + filePath, + limit, + decode, + false, + undefined, + decodeLifecycle, + gateAbort.signal, + (boundary) => { + state.boundary = boundary + } + ) + } + async function finishSuccessfulDrain(startVersion: TranscriptFileVersion): Promise { - watchedBoundary = await boundaryFingerprint(filePath, state.offset, gateAbort.signal) const completedVersion = await readTranscriptFileVersion(filePath, gateAbort.signal) if (transcriptFileVersionChanged(completedVersion, startVersion)) { // Why: a write racing this drain needs another pass even when the reader @@ -100,6 +113,7 @@ export async function installTranscriptWatcher( } else { watchedVersion = completedVersion } + pendingReadRequested ||= completedVersion.size !== state.offset if (closed) { return } @@ -128,7 +142,7 @@ export async function installTranscriptWatcher( identityChanged || sameSizeVersionChanged || current.size < state.offset || - (state.offset > 0 && watchedBoundary !== currentBoundary) + (state.offset > 0 && state.boundary.toString('base64') !== currentBoundary) if (identityChanged) { nativeWatcher.invalidate() } @@ -142,15 +156,7 @@ export async function installTranscriptWatcher( // Why: 0 is a valid window — an explicit undefined check keeps an empty // snapshot empty instead of falling back to an unbounded incremental read. contentReplaced && !initialDrain && onReplace && initialLimit !== undefined - ? await readNativeChatTranscriptTailFile( - filePath, - initialLimit, - decode, - false, - undefined, - decodeLifecycle, - gateAbort.signal - ) + ? await readSnapshot(initialLimit) : null if (closed) { return @@ -164,22 +170,13 @@ export async function installTranscriptWatcher( replacementSnapshot.beforeOffset, replacementSnapshot.lifecycle ) - await readAndEmitAppends() await finishSuccessfulDrain(current) return } const initialSnapshot = initialDrain && onInitialSnapshot && initialLimit !== undefined - ? await readNativeChatTranscriptTailFile( - filePath, - initialLimit, - decode, - false, - undefined, - decodeLifecycle, - gateAbort.signal - ) + ? await readSnapshot(initialLimit) : null if (closed) { return @@ -196,7 +193,6 @@ export async function installTranscriptWatcher( undefined, initialSnapshot.lifecycle ) - await readAndEmitAppends() } else { let lifecycle: NativeChatTurnLifecycle | undefined const messages = await readIncrementalTranscriptMessages( diff --git a/src/main/native-chat/transcript-watch-inplace-drain-race.test.ts b/src/main/native-chat/transcript-watch-inplace-drain-race.test.ts new file mode 100644 index 00000000000..c52de9a9896 --- /dev/null +++ b/src/main/native-chat/transcript-watch-inplace-drain-race.test.ts @@ -0,0 +1,66 @@ +import { writeFileSync } from 'node:fs' +import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { getActiveNativeChatWatcherCount, subscribeNativeChatTranscript } from './transcript-watch' + +function record(id: string, text: string): string { + return `${JSON.stringify({ + type: 'user', + uuid: id, + timestamp: '2026-06-01T10:00:00.000Z', + message: { role: 'user', content: text } + })}\n` +} + +it.each(['append', 'initial snapshot', 'replacement snapshot'] as const)( + 'delivers a larger rewrite while publishing a completed %s', + async (mode) => { + const before = getActiveNativeChatWatcherCount() + const root = await mkdtemp(join(tmpdir(), 'orca-transcript-drain-race-')) + const filePath = join(root, 'rollout.jsonl') + const seen: string[] = [] + let stop = (): void => {} + try { + await writeFile(filePath, record('old', 'old')) + const publish = (messages: NativeChatMessage[]): void => { + seen.push(...messages.map((message) => message.id)) + if (messages.some((message) => message.id === 'old')) { + writeFileSync( + filePath, + record( + mode === 'replacement snapshot' ? 'middle' : 'new', + 'a larger replacement transcript' + ) + ) + } + if (messages.some((message) => message.id === 'middle')) { + writeFileSync(filePath, record('new', 'a second still larger replacement transcript')) + } + } + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + debounceMs: 0, + reconciliationIntervalMs: 20, + initialLimit: mode === 'append' ? undefined : 50, + onAppend: publish, + ...(mode === 'initial snapshot' ? { onInitialSnapshot: publish } : {}), + ...(mode === 'replacement snapshot' ? { onReplace: publish } : {}) + }) + stop = sub.unsubscribe + await expect.poll(() => seen, { timeout: 1_000 }).toContain('new') + await appendFile(filePath, record('followup', 'normal append')) + await expect.poll(() => seen, { timeout: 1_000 }).toContain('followup') + expect(seen.filter((id) => id === 'new')).toHaveLength(1) + expect(seen.filter((id) => id === 'followup')).toHaveLength(1) + } finally { + stop() + await rm(root, { recursive: true, force: true }) + expect(getActiveNativeChatWatcherCount()).toBe(before) + } + } +) diff --git a/src/main/native-chat/transcript-watch-test-subscriptions.ts b/src/main/native-chat/transcript-watch-test-subscriptions.ts new file mode 100644 index 00000000000..313c5f32b42 --- /dev/null +++ b/src/main/native-chat/transcript-watch-test-subscriptions.ts @@ -0,0 +1,19 @@ +import { subscribeNativeChatTranscript } from './transcript-watch' +import type { NativeChatTranscriptSubscription } from './transcript-watch-contract' + +const subscriptions = new Set() + +export async function subscribeNativeChatTranscriptForTest( + ...args: Parameters +): Promise { + const subscription = await subscribeNativeChatTranscript(...args) + subscriptions.add(subscription) + return subscription +} + +export function closeNativeChatTestSubscriptions(): void { + for (const subscription of subscriptions) { + subscription.unsubscribe() + } + subscriptions.clear() +} diff --git a/src/main/native-chat/transcript-watch.test.ts b/src/main/native-chat/transcript-watch.test.ts index 4830f658527..cff7de1be71 100644 --- a/src/main/native-chat/transcript-watch.test.ts +++ b/src/main/native-chat/transcript-watch.test.ts @@ -3,11 +3,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types' +import { getActiveNativeChatWatcherCount, readNativeChatTranscriptTail } from './transcript-watch' import { - getActiveNativeChatWatcherCount, - readNativeChatTranscriptTail, - subscribeNativeChatTranscript -} from './transcript-watch' + closeNativeChatTestSubscriptions, + subscribeNativeChatTranscriptForTest as subscribeNativeChatTranscript +} from './transcript-watch-test-subscriptions' let tempRoots: string[] = [] @@ -16,6 +16,7 @@ beforeEach(() => { }) afterEach(async () => { + closeNativeChatTestSubscriptions() await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) tempRoots = [] }) diff --git a/tests/e2e/helpers/native-chat-transcript-fixture.ts b/tests/e2e/helpers/native-chat-transcript-fixture.ts new file mode 100644 index 00000000000..fecd1d3bcc3 --- /dev/null +++ b/tests/e2e/helpers/native-chat-transcript-fixture.ts @@ -0,0 +1,72 @@ +import type { Page } from '@stablyai/playwright-test' + +export async function enableNativeChatSetting(page: Page): Promise { + await page.evaluate(async () => { + await window.__store?.getState().updateSettings({ experimentalNativeChat: true }) + }) +} + +export 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: 'working', prompt: 'e2e first-flush race probe', agentType: 'claude' }, + 'Claude', + undefined, + { worktreeId }, + { providerSession: { key: 'session_id', id: sessionId, transcriptPath } } + ) + }, args) +} + +export 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) +} + +export function claudeTranscriptLines(args: { + sessionId: string + messageIdPrefix?: string + userText: string + assistantText: string +}): string { + const userTime = new Date() + const assistantTime = new Date(userTime.getTime() + 2_000) + const lines = [ + { + sessionId: args.sessionId, + uuid: `${args.messageIdPrefix ?? args.sessionId}-user`, + timestamp: userTime.toISOString(), + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: args.userText }] } + }, + { + sessionId: args.sessionId, + uuid: `${args.messageIdPrefix ?? args.sessionId}-assistant`, + timestamp: assistantTime.toISOString(), + type: 'assistant', + message: { model: 'claude-opus-4', content: [{ type: 'text', text: args.assistantText }] } + } + ] + return `${lines.map((line) => JSON.stringify(line)).join('\n')}\n` +} diff --git a/tests/e2e/native-chat-first-flush-race.spec.ts b/tests/e2e/native-chat-first-flush-race.spec.ts index 979cb33117c..889eb3dd716 100644 --- a/tests/e2e/native-chat-first-flush-race.spec.ts +++ b/tests/e2e/native-chat-first-flush-race.spec.ts @@ -2,96 +2,19 @@ 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 { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal' -import type { GlobalSettings } from '../../src/shared/global-settings-types' +import { + enableNativeChatSetting, + seedClaudeProviderSession, + toggleTerminalTabToChatView, + claudeTranscriptLines +} from './helpers/native-chat-transcript-fixture' const LOADING_TITLE = 'Loading conversation…' const ERROR_TITLE = 'Could not load conversation' -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: seeding agentStatusByPaneKey directly (rather than posting a real -// `/hook/claude` event) mirrors the technique agent-session-quit-resume.spec.ts -// uses to stay hermetic — it exercises the identical store → NativeChatView -// path a real Claude Code hook would drive, without an installed CLI. -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: 'working', prompt: 'e2e first-flush race probe', agentType: 'claude' }, - 'Claude', - undefined, - { worktreeId }, - { providerSession: { key: 'session_id', id: sessionId, transcriptPath } } - ) - }, args) -} - -// Why: toggleTabViewMode keys off the *unified* tab id, which can differ from -// the terminal tab id embedded in paneKey — resolve it the same way -// TerminalPane.tsx does before calling the store action a real toggle/shortcut -// would use. -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 claudeTranscriptLines(args: { - sessionId: string - userText: string - assistantText: string -}): string { - // Why: distinct timestamps keep the rendered order deterministic (a tie is - // broken by uuid, which would put the assistant turn first). - const userTime = new Date() - const assistantTime = new Date(userTime.getTime() + 2_000) - const lines = [ - { - sessionId: args.sessionId, - uuid: `${args.sessionId}-user`, - timestamp: userTime.toISOString(), - type: 'user', - message: { role: 'user', content: [{ type: 'text', text: args.userText }] } - }, - { - sessionId: args.sessionId, - uuid: `${args.sessionId}-assistant`, - timestamp: assistantTime.toISOString(), - type: 'assistant', - message: { model: 'claude-opus-4', content: [{ type: 'text', text: args.assistantText }] } - } - ] - return `${lines.map((line) => JSON.stringify(line)).join('\n')}\n` -} - test.describe('Native chat first-flush transcript race (#8401)', () => { test('stays in loading (never errors) until a not-yet-flushed transcript appears, then hydrates live', async ({ orcaPage @@ -111,11 +34,7 @@ test.describe('Native chat first-flush transcript race (#8401)', () => { const scratchDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-native-chat-')) const transcriptPath = path.join(scratchDir, `${sessionId}.jsonl`) - const screenshotDir = path.join( - process.cwd(), - 'validation-screenshots', - `native-chat-first-flush-race-${Date.now()}` - ) + const screenshotDir = testInfo.outputPath('screenshots') mkdirSync(screenshotDir, { recursive: true }) await testInfo.attach('validation-screenshot-dir', { body: screenshotDir, diff --git a/tests/e2e/native-chat-inplace-drain-race.spec.ts b/tests/e2e/native-chat-inplace-drain-race.spec.ts new file mode 100644 index 00000000000..af090727f11 --- /dev/null +++ b/tests/e2e/native-chat-inplace-drain-race.spec.ts @@ -0,0 +1,134 @@ +import { randomUUID } from 'node:crypto' +import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal' +import { + claudeTranscriptLines, + enableNativeChatSetting, + seedClaudeProviderSession, + toggleTerminalTabToChatView +} from './helpers/native-chat-transcript-fixture' + +test('renders a larger transcript rewrite during snapshot publication, then ordinary appends', async ({ + electronApp, + orcaPage, + registerPostElectronShutdownCleanup +}, 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 = `drain-race-${randomUUID()}` + const root = mkdtempSync(join(tmpdir(), 'orca-native-chat-drain-proof-')) + registerPostElectronShutdownCleanup(async () => rmSync(root, { recursive: true, force: true })) + const transcriptPath = join(root, `${sessionId}.jsonl`) + const cleanupChannel = `e2e:drain-race-cleanup:${sessionId}` + const oldText = 'Original transcript before replacement' + const newText = 'Replacement transcript written while the original snapshot was being published' + writeFileSync( + transcriptPath, + claudeTranscriptLines({ + sessionId, + messageIdPrefix: 'old', + userText: 'Original question', + assistantText: oldText + }) + ) + const replacement = claudeTranscriptLines({ + sessionId, + messageIdPrefix: 'new', + userText: 'The transcript was replaced during publication. Can you show the new conversation?', + assistantText: newText + }) + // Force the observed ordering on the real host; leave IPC delivery and renderer handling intact. + await electronApp.evaluate( + ({ BrowserWindow, ipcMain }, args) => { + const fs = process.getBuiltinModule('fs') + const contents = BrowserWindow.getAllWindows()[0]?.webContents + if (!contents) { + throw new Error('Renderer unavailable') + } + const send = contents.send + ipcMain.once(args.cleanupChannel, () => { + contents.send = send + }) + contents.send = function (channel: string, ...values: unknown[]): void { + send.call(this, channel, ...values) + const payload = values[0] + if ( + channel !== 'nativeChat:appended' || + !payload || + typeof payload !== 'object' || + !('frame' in payload) + ) { + return + } + const frame = payload.frame + if ( + !frame || + typeof frame !== 'object' || + !('messages' in frame) || + !Array.isArray(frame.messages) + ) { + return + } + if ( + frame.messages.some( + (message: unknown) => + message && typeof message === 'object' && 'id' in message && message.id === 'old-user' + ) + ) { + contents.send = send + fs.writeFileSync(args.transcriptPath, args.replacement) + } + } + }, + { transcriptPath, replacement, cleanupChannel } + ) + + const transcript = orcaPage.locator('[data-native-chat-root="true"]') + async function capture(name: string): Promise { + const bounds = await transcript.boundingBox() + if (!bounds) { + throw new Error('Chat transcript has no rendered bounds') + } + await orcaPage.screenshot({ + path: testInfo.outputPath(name), + clip: { ...bounds, height: Math.min(bounds.height, 320) } + }) + } + try { + await enableNativeChatSetting(orcaPage) + await seedClaudeProviderSession(orcaPage, { ...descriptor, sessionId, transcriptPath }) + await toggleTerminalTabToChatView(orcaPage, { tabId, worktreeId: descriptor.worktreeId }) + await expect(transcript).toBeVisible() + try { + await expect(transcript.getByText(newText)).toBeVisible({ timeout: 8_000 }) + await expect(transcript.getByText(oldText)).toHaveCount(0) + } finally { + await capture('after-drain.png') + } + const followup = 'Ordinary appended messages still arrive exactly once.' + appendFileSync( + transcriptPath, + claudeTranscriptLines({ + sessionId, + messageIdPrefix: 'followup', + userText: 'Continue', + assistantText: followup + }) + ) + await expect(transcript.getByText(followup)).toBeVisible() + await expect(transcript.getByText(followup)).toHaveCount(1) + await capture('after-followup.png') + } finally { + await electronApp.evaluate(({ ipcMain }, channel) => { + ipcMain.emit(channel) + }, cleanupChannel) + } +})