diff --git a/src/main/native-chat/transcript-watch-contract.ts b/src/main/native-chat/transcript-watch-contract.ts index f2e46f3581b..760e07fc967 100644 --- a/src/main/native-chat/transcript-watch-contract.ts +++ b/src/main/native-chat/transcript-watch-contract.ts @@ -28,6 +28,9 @@ export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { debounceMs?: number /** Test-only override for the production resolve-poll backoff. */ resolvePollIntervalMs?: number + /** Test-only override for how long an unresolved transcript stays silent + * before the first "unavailable" snapshot. */ + unresolvedNoticeMs?: number /** Test-only override for the host-side watcher reconciliation interval. */ reconciliationIntervalMs?: number } diff --git a/src/main/native-chat/transcript-watch-unresolved-notice.test.ts b/src/main/native-chat/transcript-watch-unresolved-notice.test.ts new file mode 100644 index 00000000000..f69b3ccaf7e --- /dev/null +++ b/src/main/native-chat/transcript-watch-unresolved-notice.test.ts @@ -0,0 +1,125 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { getActiveNativeChatWatcherCount, subscribeNativeChatTranscript } from './transcript-watch' + +let tempRoots: string[] = [] + +beforeEach(() => { + tempRoots = [] +}) + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function pendingFilePath(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-unresolved-')) + tempRoots.push(root) + return join(root, 'rollout.jsonl') +} + +function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): string { + return `${JSON.stringify({ + type: role, + uuid, + timestamp: new Date().toISOString(), + message: { role, content: [{ type: 'text', text }] } + })}\n` +} + +async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (predicate()) { + return + } + await new Promise((resolve) => setTimeout(resolve, 10)) + } + throw new Error('waitFor timed out') +} + +type Snapshot = { messages: NativeChatMessage[]; error?: string } + +// #13663: an agent in an SSH worktree writes its transcript on the remote host, +// so this host resolves nothing and the poll spins forever with no frame — the +// client sits on a spinner indefinitely. The poll is still correct for a +// not-yet-flushed local session (#8401), so the notice must be advisory. +describe('subscribeNativeChatTranscript unresolved-transcript notice (#13663)', () => { + it('reports the transcript as unavailable once the deadline passes', async () => { + const snapshots: Snapshot[] = [] + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath: await pendingFilePath(), + onAppend: () => {}, + onInitialSnapshot: (messages, _hasMore, _beforeOffset, error) => + snapshots.push({ messages, error }), + resolvePollIntervalMs: 10, + unresolvedNoticeMs: 30 + }) + + await waitFor(() => snapshots.length > 0) + expect(snapshots[0]?.messages).toEqual([]) + expect(snapshots[0]?.error).toMatch(/unavailable on this host/i) + + sub.unsubscribe() + }) + + it('stays silent before the deadline so a slow first flush is not called an error', async () => { + const snapshots: Snapshot[] = [] + const filePath = await pendingFilePath() + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: () => {}, + onInitialSnapshot: (messages, _hasMore, _beforeOffset, error) => + snapshots.push({ messages, error }), + resolvePollIntervalMs: 10, + unresolvedNoticeMs: 10_000 + }) + + await new Promise((resolve) => setTimeout(resolve, 120)) + expect(snapshots).toHaveLength(0) + + sub.unsubscribe() + }) + + // The notice must not be a terminal state: the poll keeps running, so a + // transcript that shows up late still loads. + it('keeps polling after the notice and still tails the file once it appears', async () => { + const snapshots: Snapshot[] = [] + const appended: NativeChatMessage[] = [] + const filePath = await pendingFilePath() + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => appended.push(...messages), + onInitialSnapshot: (messages, _hasMore, _beforeOffset, error) => + snapshots.push({ messages, error }), + debounceMs: 5, + resolvePollIntervalMs: 10, + unresolvedNoticeMs: 30 + }) + + await waitFor(() => snapshots.some((s) => s.error)) + + await writeFile(filePath, claudeLine('u-1', 'user', 'late but real')) + await waitFor(() => appended.some((m) => m.id === 'u-1') || snapshots.length > 1) + + expect(getActiveNativeChatWatcherCount()).toBe(1) + // Exactly one advisory frame — it must not repeat on every poll tick. + expect(snapshots.filter((s) => s.error)).toHaveLength(1) + + sub.unsubscribe() + expect(getActiveNativeChatWatcherCount()).toBe(0) + }) +}) diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index b2ef5b05ec3..b62b0692b03 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -49,6 +49,16 @@ async function attemptInstall( const INITIAL_RESOLVE_POLL_MS = 500 const MAX_RESOLVE_POLL_MS = 5_000 const FALLBACK_RESOLVE_POLL_MS = 5_000 +// Why: a transcript this host can never open (the agent runs on an SSH remote, +// so the file is on that machine — #13663) is indistinguishable here from one +// that simply has not flushed yet, and both poll forever. Past this deadline +// say so instead of spinning silently. Deliberately longer than the observed +// worst-case first flush (#8401): the notice is non-terminal, so paying a +// generous wait costs a late message, while being too eager costs a false one +// on a healthy session. +const UNRESOLVED_NOTICE_MS = 60_000 +const UNRESOLVED_TRANSCRIPT_MESSAGE = + 'Transcript unavailable on this host. If this agent runs on a remote machine, its transcript lives there.' function exactTranscriptPath(args: SubscribeNativeChatTranscriptArgs): string | null { const path = args.transcriptPath?.trim() @@ -81,8 +91,23 @@ function subscribeViaResolvePoll( // Latches only once a frame was actually emitted, so a subscriber without the // callback can't suppress it for a later one. let gateErrorEmitted = false + const startedAt = Date.now() + const unresolvedNoticeMs = args.unresolvedNoticeMs ?? UNRESOLVED_NOTICE_MS const resolveController = new AbortController() + /** Non-terminal: the poll keeps running and a later real snapshot replaces + * this, so a slow-to-flush session still recovers (#8401). */ + function emitUnresolvedNotice(): void { + if (gateErrorEmitted || !args.onInitialSnapshot) { + return + } + if (Date.now() - startedAt < unresolvedNoticeMs) { + return + } + gateErrorEmitted = true + args.onInitialSnapshot([], false, 0, UNRESOLVED_TRANSCRIPT_MESSAGE) + } + function scheduleAttempt(): void { if (closed) { return @@ -167,6 +192,7 @@ function subscribeViaResolvePoll( installed = result return } + emitUnresolvedNotice() scheduleAttempt() }