fix(native-chat): fingerprint the consumed transcript boundary (#25965)

This commit is contained in:
Neil
2026-10-06 14:11:17 -07:00
committed by GitHub
parent d6b155daf5
commit 40f03199f8
12 changed files with 446 additions and 116 deletions
+78
View File
@@ -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",
@@ -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)
})
@@ -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
@@ -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 {
+12 -1
View File
@@ -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) {
+19 -23
View File
@@ -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<void> {
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(
@@ -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)
}
}
)
@@ -0,0 +1,19 @@
import { subscribeNativeChatTranscript } from './transcript-watch'
import type { NativeChatTranscriptSubscription } from './transcript-watch-contract'
const subscriptions = new Set<NativeChatTranscriptSubscription>()
export async function subscribeNativeChatTranscriptForTest(
...args: Parameters<typeof subscribeNativeChatTranscript>
): Promise<NativeChatTranscriptSubscription> {
const subscription = await subscribeNativeChatTranscript(...args)
subscriptions.add(subscription)
return subscription
}
export function closeNativeChatTestSubscriptions(): void {
for (const subscription of subscriptions) {
subscription.unsubscribe()
}
subscriptions.clear()
}
@@ -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 = []
})
@@ -0,0 +1,72 @@
import type { Page } from '@stablyai/playwright-test'
export async function enableNativeChatSetting(page: Page): Promise<void> {
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<void> {
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<void> {
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`
}
+7 -88
View File
@@ -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<void> {
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<void> {
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<void> {
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,
@@ -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<void> {
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)
}
})