mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 00:02:39 +00:00
fix(native-chat): fingerprint the consumed transcript boundary (#25965)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user