diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index 1a1811f808d..13fbddf8e69 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -32,6 +32,13 @@ describe('mobileNativeChatEmptyState', () => { expect(mobileNativeChatEmptyState('ready', 'codex')?.title).toBe('Start a chat with Codex') }) + it('invites a first message while the transcript file is still unwritten', () => { + // The spinner is already gone by then, so a bare list would read as broken. + expect(mobileNativeChatEmptyState('awaiting-transcript', 'claude')?.title).toBe( + 'Start a chat with Claude' + ) + }) + it('falls back to "the agent" when the agent is unknown', () => { expect(mobileNativeChatEmptyState('waiting-session', null)?.title).toBe( 'Start a chat with the agent' diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index 73a45db6962..ce24a1fa507 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -20,10 +20,11 @@ export function mobileNativeChatEmptyState( ): NativeChatEmptyStateCopy | null { const agentLabel = agent ? formatAgentTypeLabel(agent) : 'the agent' switch (status) { - // A live agent with no transcript yet — and a loaded-but-empty transcript — - // are both "start a chat"; invite the first message instead of implying the - // agent is still starting up. + // A live agent with no transcript yet — an unwritten transcript file, or a + // loaded-but-empty one — is "start a chat"; invite the first message instead + // of implying the agent is still starting up. case 'waiting-session': + case 'awaiting-transcript': case 'ready': return formatNativeChatEmptyStateCopy('empty', agentLabel) case 'error': { diff --git a/mobile/src/session/mobile-native-chat-stream-frame.test.ts b/mobile/src/session/mobile-native-chat-stream-frame.test.ts index 1f00bc5ec15..e7733dac1ce 100644 --- a/mobile/src/session/mobile-native-chat-stream-frame.test.ts +++ b/mobile/src/session/mobile-native-chat-stream-frame.test.ts @@ -37,6 +37,36 @@ describe('applyMobileNativeChatStreamFrame', () => { }) }) + it('marks a pending snapshot so the caller can settle the view but not the read', () => { + const merger = createNativeChatMerger() + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', messages: [], hasMore: false, pending: true }, + limit: 40, + replaceSnapshot: true + }) + + expect(result).toEqual({ + kind: 'messages', + messages: [], + hasMore: false, + windowReplaced: true, + pending: true + }) + }) + + it('leaves an ordinary snapshot unmarked', () => { + const merger = createNativeChatMerger() + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', messages: [message('a')], hasMore: false }, + limit: 40, + replaceSnapshot: true + }) + + expect(result).not.toHaveProperty('pending') + }) + it('merges reconnect snapshots and live appends into the bounded window', () => { const merger = createNativeChatMerger() replaceList(merger, [message('a'), message('b')]) diff --git a/mobile/src/session/mobile-native-chat-stream-frame.ts b/mobile/src/session/mobile-native-chat-stream-frame.ts index a3cef3d5464..8984a04a0e3 100644 --- a/mobile/src/session/mobile-native-chat-stream-frame.ts +++ b/mobile/src/session/mobile-native-chat-stream-frame.ts @@ -7,6 +7,10 @@ export type MobileNativeChatStreamFrame = { messages?: NativeChatMessage[] hasMore?: boolean beforeOffset?: number + /** Snapshot only: no transcript file exists behind this window yet (the agent + * has not flushed, or was never prompted). The empty list is real enough to + * render, but it is not a settled read of the session's history. */ + pending?: boolean error?: string message?: string } @@ -24,6 +28,9 @@ export type AppliedMobileNativeChatFrame = * snapshot, or a replay snapshot disjoint from local history) — the * caller must reset its paging window/cursor to the frame's. */ windowReplaced?: boolean + /** The transcript behind this window does not exist yet: show it, but keep + * the read open — the real snapshot follows on the same subscription. */ + pending?: boolean } function replayRetainedTailStart( @@ -80,6 +87,7 @@ export function applyMobileNativeChatStreamFrame(args: { if (!Array.isArray(frame.messages)) { return { kind: 'ignored' } } + const pending = frame.type === 'snapshot' && frame.pending === true const replayStartIndex = frame.type === 'snapshot' && !replaceSnapshot && merger.list.length > 0 ? replayRetainedTailStart(merger, frame.messages, frame.hasMore) @@ -91,6 +99,7 @@ export function applyMobileNativeChatStreamFrame(args: { messages: merger.list, hasMore: frame.hasMore, windowReplaced: true, + ...(pending ? { pending: true } : {}), ...(frame.beforeOffset == null ? {} : { beforeOffset: frame.beforeOffset }) } } @@ -101,6 +110,7 @@ export function applyMobileNativeChatStreamFrame(args: { return { kind: 'messages', messages, + ...(pending ? { pending: true } : {}), // Why: once the bounded live window drops its oldest row, the snapshot's // byte cursor no longer describes the oldest retained message. ...(cursorInvalidated ? { cursorInvalidated: true } : {}), diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts index 2ff97a3370c..ed2f66c42bf 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -633,4 +633,81 @@ describe('useMobileNativeChatSession transcriptLoading', () => { ids: [] }) }) + + it('settles the view but not the read on a pending snapshot', async () => { + // The host answers with an empty pending window while the transcript file + // does not exist yet. Calling that 'ready' would let the launch-draft seed + // adopt a prefill the agent may already have been sent; leaving it + // 'loading' is the bare forever-spinner this frame exists to end. + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + onData({ type: 'snapshot', messages: [], hasMore: false, pending: true }) + return () => {} + }) + await mountAt({ subscribe } as unknown as RpcClient, 'session-a') + + expect(subscribe).toHaveBeenCalledWith( + 'nativeChat.subscribe', + expect.objectContaining({ capabilities: { transcriptPending: 1 } }), + expect.any(Function) + ) + expect(renders.at(-1)).toMatchObject({ + status: 'awaiting-transcript', + transcriptLoading: true, + ids: [] + }) + }) + + it('takes the real snapshot after a pending one as this subscription’s base', async () => { + let emitFrame: (frame: unknown) => void = () => {} + const client = { + subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => { + emitFrame = onData + onData({ type: 'snapshot', messages: [], hasMore: false, pending: true }) + return () => {} + }) + } as unknown as RpcClient + await mountAt(client, 'session-a') + + await act(async () => + emitFrame({ type: 'snapshot', messages: [message('a-1')], hasMore: false }) + ) + + expect(renders.at(-1)).toMatchObject({ + status: 'ready', + transcriptLoading: false, + ids: ['a-1'] + }) + }) + + it('never captures a pending window over the retained transcript', async () => { + const client = { + subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => { + onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false }) + return () => {} + }) + } as unknown as RpcClient + await mountAt(client, 'session-a') + + // Same identity, fresh client: this read finds no transcript file behind the + // session and answers pending. + const reconnected = { + subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => { + onData({ type: 'snapshot', messages: [], hasMore: false, pending: true }) + return () => {} + }) + } as unknown as RpcClient + await act(async () => + renderer?.update(createElement(Harness, { client: reconnected, sessionId: 'session-a' })) + ) + + // One more rebind reads retention back: it still holds the real history, + // which it could not had the empty pending window captured over it. + const rebound = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient + renders.length = 0 + await act(async () => + renderer?.update(createElement(Harness, { client: rebound, sessionId: 'session-a' })) + ) + + expect(renders.at(-1)).toMatchObject({ status: 'loading', ids: ['a-1'] }) + }) }) diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index a79b5648dd7..e509b202c2a 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -12,16 +12,25 @@ import { type MobileNativeChatStreamFrame } from './mobile-native-chat-stream-frame' -export type MobileNativeChatStatus = 'idle' | 'loading' | 'waiting-session' | 'ready' | 'error' +export type MobileNativeChatStatus = + | 'idle' + | 'loading' + | 'waiting-session' + /** The host answered, but the session has no transcript file yet — render the + * empty chat instead of a spinner while the read stays open. */ + | 'awaiting-transcript' + | 'ready' + | 'error' export type MobileNativeChatSession = { messages: NativeChatMessage[] status: MobileNativeChatStatus /** True while `messages` cannot be trusted as this session's real history: - * the read is in flight, OR the subscription effect has not yet caught up to - * a just-changed agent/session, so `messages`/`status` still describe the - * previous tab. Consumers that decide something from an empty transcript - * (the launch-draft seed) must wait for this to clear. */ + * the read is in flight, the transcript has not been written yet, OR the + * subscription effect has not yet caught up to a just-changed agent/session, + * so `messages`/`status` still describe the previous tab. Consumers that + * decide something from an empty transcript (the launch-draft seed) must + * wait for this to clear. */ transcriptLoading: boolean error?: string /** True when an older page may exist (the last read filled the window). */ @@ -145,6 +154,7 @@ export function useMobileNativeChatSession(args: { sessionId, limit: limitRef.current, subscriptionId: buildNativeChatSubscriptionId(agent, sessionId), + capabilities: { transcriptPending: 1 }, ...(transcriptPath ? { transcriptPath } : {}) }, (raw) => { @@ -166,7 +176,9 @@ export function useMobileNativeChatSession(args: { setError(applied.error) return } - if (frame.type === 'snapshot') { + if (frame.type === 'snapshot' && !applied.pending) { + // A pending window has no transcript behind it, so the snapshot that + // follows is still this subscription's base, not a reconnect replay. snapshotSeenRef.current = true } if (applied.windowReplaced || frame.type === 'snapshot') { @@ -198,7 +210,7 @@ export function useMobileNativeChatSession(args: { setLoadingEarlier(false) beforeOffsetRef.current = null } - setRead({ client, identity, status: 'ready' }) + setRead({ client, identity, status: applied.pending ? 'awaiting-transcript' : 'ready' }) } ) @@ -286,7 +298,7 @@ export function useMobileNativeChatSession(args: { // clears the previous tab's list is passive, so `messages` lags a commit. messages: visibleMessages, status, - transcriptLoading: status === 'loading', + transcriptLoading: status === 'loading' || status === 'awaiting-transcript', error, hasMore, loadingEarlier, diff --git a/src/main/ipc/native-chat.test.ts b/src/main/ipc/native-chat.test.ts index 4d0e1765a44..680dd315339 100644 --- a/src/main/ipc/native-chat.test.ts +++ b/src/main/ipc/native-chat.test.ts @@ -252,6 +252,54 @@ describe('nativeChat:readSession handler', () => { } }) + it('settles the view with a pending frame while the transcript is unflushed', async () => { + // The user-visible bug: a session that has not been prompted never writes + // its JSONL, so with no frame at all the chat view spins indefinitely. + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-unflushed-')) + tempRoots.push(root) + await mkdir(join(root, '.claude', 'projects', '-repo'), { recursive: true }) + + registerNativeChatHandlers() + const subscribe = listeners.get('nativeChat:subscribe') + expect(subscribe).toBeDefined() + + const sent: { channel: string; payload: unknown }[] = [] + let destroyedCb: (() => void) | undefined + const sender = { + id: 7, + isDestroyed: () => false, + once: (event: string, cb: () => void) => { + if (event === 'destroyed') { + destroyedCb = cb + } + }, + send: (channel: string, payload: unknown) => sent.push({ channel, payload }) + } + + const previousHome = process.env.HOME + process.env.HOME = root + try { + subscribe!({ sender }, { subscriptionId: 'sub-pending', agent: 'claude', sessionId: 'ghost' }) + + await waitFor(() => sent.some((s) => s.channel === 'nativeChat:appended'), 6_000) + expect(sent[0]).toMatchObject({ + channel: 'nativeChat:appended', + payload: { + subscriptionId: 'sub-pending', + frame: { type: 'snapshot', messages: [], hasMore: false, pending: true } + } + }) + + destroyedCb!() + } finally { + if (previousHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = previousHome + } + } + }) + it('drops cleanup registration when sender is destroyed before subscribe completes', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-destroy-race-')) tempRoots.push(root) diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index 4ef0f48b872..f94424b67f6 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -64,6 +64,9 @@ export type NativeChatAppendedPayload = { hasMore: boolean error?: string lifecycle?: NativeChatTurnLifecycle + /** No transcript exists behind this window yet — render it, but do not + * treat it as a settled read of the session's history. */ + pending?: boolean } | { type: 'replacement' @@ -181,6 +184,18 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg sessionId, transcriptPath, initialLimit: limit, + onTranscriptPending: () => { + if (sender.isDestroyed()) { + return + } + // `pending` marks a window with no transcript behind it yet; clients that + // don't know the flag still stop spinning on the empty snapshot. + const payload: NativeChatAppendedPayload = { + subscriptionId, + frame: { type: 'snapshot', messages: [], hasMore: false, pending: true } + } + sender.send('nativeChat:appended', payload) + }, onInitialSnapshot: (messages, hasMore, _beforeOffset, error, lifecycle) => { if (sender.isDestroyed()) { return diff --git a/src/main/native-chat/transcript-watch-contract.ts b/src/main/native-chat/transcript-watch-contract.ts index f2e46f3581b..13463e524ef 100644 --- a/src/main/native-chat/transcript-watch-contract.ts +++ b/src/main/native-chat/transcript-watch-contract.ts @@ -17,6 +17,11 @@ export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { error?: string, lifecycle?: NativeChatTurnLifecycle ) => void + /** The transcript file does not exist yet (a session whose agent has not + * flushed, or has not been prompted at all). Fires at most once, before any + * snapshot, so a client can settle its view on the empty window it really + * has instead of spinning — while still knowing the read is not settled. */ + onTranscriptPending?: () => void onReplace?: ( messages: NativeChatMessage[], hasMore: boolean, diff --git a/src/main/native-chat/transcript-watch-unflushed-settle.test.ts b/src/main/native-chat/transcript-watch-unflushed-settle.test.ts new file mode 100644 index 00000000000..5eb0becff2c --- /dev/null +++ b/src/main/native-chat/transcript-watch-unflushed-settle.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../shared/native-chat-types' + +const mocks = vi.hoisted(() => ({ + install: vi.fn(), + resolve: vi.fn() +})) + +vi.mock('./session-file-resolver', () => ({ + resolveSessionFilePath: mocks.resolve +})) +vi.mock('./transcript-watch-engine', () => ({ + getActiveNativeChatWatcherCount: vi.fn(() => 0), + installTranscriptWatcher: mocks.install +})) + +import { subscribeNativeChatTranscript } from './transcript-watch' +import { WslTranscriptFsError } from './wsl-transcript-fs-gate' + +type Snapshot = [NativeChatMessage[], boolean, number, string | undefined] + +const realPlatform = process.platform + +function subscribeCollecting( + snapshots: Snapshot[], + onTranscriptPending: () => void = () => {} +): Promise<{ unsubscribe: () => void }> { + return subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session-id', + transcriptPath: '/projects/p/session-id.jsonl', + resolvePollIntervalMs: 10, + onAppend: () => {}, + onTranscriptPending, + onInitialSnapshot: (messages, hasMore, beforeOffset, error) => { + snapshots.push([messages, hasMore, beforeOffset, error]) + } + }) +} + +// A brand-new agent session flushes its first JSONL line seconds to minutes +// after start — and never at all until it is prompted. Emitting nothing in that +// window leaves every client on an unexplained spinner (mobile native chat +// renders `status === 'loading'` as a bare ActivityIndicator). +describe('unflushed transcript settles the view', () => { + beforeEach(() => { + vi.useFakeTimers() + mocks.install.mockReset().mockResolvedValue(null) + mocks.resolve.mockReset().mockResolvedValue(null) + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true }) + vi.useRealTimers() + }) + + it('reports the pending transcript once while it stays unflushed', async () => { + const pending = vi.fn() + const snapshots: Snapshot[] = [] + const subscription = await subscribeCollecting(snapshots, pending) + + await vi.advanceTimersByTimeAsync(1_400) + expect(pending).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(200) + expect(pending).toHaveBeenCalledTimes(1) + // Never a snapshot: an empty window sold as a settled read captures over + // retained history and unblocks consumers that need a real transcript. + expect(snapshots).toEqual([]) + + // Latched: the still-failing poll must not re-report on every tick. + await vi.advanceTimersByTimeAsync(30_000) + expect(pending).toHaveBeenCalledTimes(1) + + subscription.unsubscribe() + }) + + it('leaves the settle to the real snapshot when the file lands inside the grace window', async () => { + const unsubscribe = vi.fn() + // Missing at subscribe time, flushed by the first poll tick. + mocks.install.mockResolvedValueOnce(null).mockResolvedValue({ unsubscribe, watching: true }) + const pending = vi.fn() + const subscription = await subscribeCollecting([], pending) + + await vi.advanceTimersByTimeAsync(5_000) + // The engine owns the initial drain; an empty window announced here would + // blank a real transcript for a frame. + expect(pending).not.toHaveBeenCalled() + + subscription.unsubscribe() + }) + + it('does not report a subscription that was torn down first', async () => { + const pending = vi.fn() + const subscription = await subscribeCollecting([], pending) + + await vi.advanceTimersByTimeAsync(100) + subscription.unsubscribe() + await vi.advanceTimersByTimeAsync(5_000) + + expect(pending).not.toHaveBeenCalled() + }) + + it('keeps the WSL gate message instead of overwriting it with the empty window', async () => { + const stalled = new WslTranscriptFsError('timeout', 'WSL transcript files are unavailable.') + mocks.install.mockRejectedValue(stalled) + const pending = vi.fn() + const snapshots: Snapshot[] = [] + const subscription = await subscribeCollecting(snapshots, pending) + + await vi.advanceTimersByTimeAsync(5_000) + + expect(snapshots).toEqual([[[], false, 0, stalled.message]]) + expect(pending).not.toHaveBeenCalled() + + subscription.unsubscribe() + }) +}) diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index b2ef5b05ec3..a104bd35d3b 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -49,6 +49,11 @@ async function attemptInstall( const INITIAL_RESOLVE_POLL_MS = 500 const MAX_RESOLVE_POLL_MS = 5_000 const FALLBACK_RESOLVE_POLL_MS = 5_000 +// Why: with no frame at all a client shows a bare spinner for the whole flush +// delay — a fresh session that has yet to be prompted never flushes, so the +// spinner is permanent. Long enough that a merely slow resolve still wins the +// race and paints history directly. +const UNFLUSHED_SETTLE_MS = 1_500 function exactTranscriptPath(args: SubscribeNativeChatTranscriptArgs): string | null { const path = args.transcriptPath?.trim() @@ -62,6 +67,8 @@ function exactTranscriptPath(args: SubscribeNativeChatTranscriptArgs): string | * unsubscribe() cancels it. Reports watching:true — the engine's first drain * delivers the initial snapshot once the file appears, so subscribers must not * settle a merely not-yet-flushed transcript into a permanent error (#8401). + * A short grace period in, it reports the transcript as pending once so the + * view can stop spinning while it waits. */ function subscribeViaResolvePoll( args: SubscribeNativeChatTranscriptArgs, @@ -81,8 +88,36 @@ 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 + // Whether the subscriber already has an initial frame to render. + let settled = false + let settleTimer: ReturnType | null = null const resolveController = new AbortController() + function stopSettleTimer(): void { + if (settleTimer) { + clearTimeout(settleTimer) + settleTimer = null + } + } + + /** Report a still-unresolved transcript so the view can leave 'loading' and + * invite a first message instead of spinning. Deliberately not a snapshot: + * an empty window presented as a settled read would capture over retained + * history and unblock consumers that require a trustworthy transcript. */ + function settleUnflushed(): void { + settleTimer = null + if (closed || settled || installed || !args.onTranscriptPending) { + return + } + settled = true + args.onTranscriptPending() + } + + if (args.onTranscriptPending) { + settleTimer = setTimeout(settleUnflushed, UNFLUSHED_SETTLE_MS) + settleTimer.unref?.() + } + function scheduleAttempt(): void { if (closed) { return @@ -154,6 +189,9 @@ function subscribeViaResolvePoll( // runAttempt is invoked as `void runAttempt()`. if (error instanceof WslTranscriptFsError && !gateErrorEmitted && args.onInitialSnapshot) { gateErrorEmitted = true + // Its retryable message outranks the empty settle; don't overwrite it. + settled = true + stopSettleTimer() args.onInitialSnapshot([], false, 0, error.message) } result = null @@ -165,6 +203,7 @@ function subscribeViaResolvePoll( } if (result) { installed = result + stopSettleTimer() return } scheduleAttempt() @@ -180,6 +219,7 @@ function subscribeViaResolvePoll( } closed = true resolveController.abort() + stopSettleTimer() if (pollTimer) { clearTimeout(pollTimer) pollTimer = null diff --git a/src/main/runtime/rpc/methods/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat.test.ts index af885e35de3..65bb525e798 100644 --- a/src/main/runtime/rpc/methods/native-chat.test.ts +++ b/src/main/runtime/rpc/methods/native-chat.test.ts @@ -53,6 +53,7 @@ const watcher = vi.hoisted(() => ({ timestamp: number | null } ) => void + onTranscriptPending?: () => void }, watching: true, setupSignal: undefined as AbortSignal | undefined, @@ -443,6 +444,44 @@ describe('nativeChat.subscribe initial snapshot', () => { } }) + it('settles a not-yet-flushed transcript with a pending window, then the real snapshot', async () => { + // A brand-new session's JSONL can be a minute out, or never written at all + // until the agent is prompted. With no frame the client just spins. + watcher.watching = true + watcher.args = null + const emitted: unknown[] = [] + await subscribeHandler()( + { agent: 'claude', sessionId: 's', capabilities: { transcriptPending: 1 } }, + streamingContext('mobile'), + (value) => emitted.push(value) + ) + + const callbacks = activeWatcherArgs() + callbacks.onTranscriptPending?.() + expect(emitted).toEqual([{ type: 'snapshot', messages: [], hasMore: false, pending: true }]) + + const message = makeTextMessage('first turn') + callbacks.onInitialSnapshot?.([message], false, 123) + expect(emitted).toHaveLength(2) + expect(emitted[1]).toMatchObject({ type: 'snapshot', hasMore: false }) + // The real window is authoritative and carries no pending marker. + expect((emitted[1] as { pending?: boolean }).pending).toBeUndefined() + }) + + it('does not publish pending semantics to a legacy client', async () => { + watcher.watching = true + watcher.args = null + const emitted: unknown[] = [] + await subscribeHandler()( + { agent: 'claude', sessionId: 's' }, + streamingContext('mobile'), + (value) => emitted.push(value) + ) + + expect(activeWatcherArgs().onTranscriptPending).toBeUndefined() + expect(emitted).toEqual([]) + }) + it('emits one windowed snapshot with pagination state before live appends', async () => { watcher.watching = true watcher.args = null diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 3b8d42287de..8fc86bf695a 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -48,6 +48,10 @@ const NativeChatSession = z.object({ // locate the file directly when the session id no longer names it (recent // Claude Code). Optional for back-compat with older clients. transcriptPath: z.string().min(1).optional(), + // A pending snapshot is not authoritative transcript history. Only clients + // that advertise this semantic may receive one; legacy clients treat it as a + // settled empty read and can overwrite retention / unblock launch drafts. + capabilities: z.object({ transcriptPending: z.literal(1).optional() }).optional(), beforeOffset: z.number().int().nonnegative().optional() }) @@ -288,6 +292,15 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ ...(lifecycle ? { lifecycle } : {}) }) }, + ...(params.capabilities?.transcriptPending === 1 + ? { + onTranscriptPending: () => { + if (!closed) { + emit({ type: 'snapshot', messages: [], hasMore: false, pending: true }) + } + } + } + : {}), onReplace: (messages, hasMore, beforeOffset, lifecycle) => { if (closed) { return diff --git a/src/preload/api/native-chat-api.ts b/src/preload/api/native-chat-api.ts index 01eac44bde6..90cb5b0cfba 100644 --- a/src/preload/api/native-chat-api.ts +++ b/src/preload/api/native-chat-api.ts @@ -22,6 +22,9 @@ export type NativeChatSubscriptionFrame = hasMore: boolean error?: string lifecycle?: NativeChatTurnLifecycle + /** No transcript exists behind this window yet — render it, but do not + * treat it as a settled read of the session's history. */ + pending?: boolean } | { type: 'replacement' diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index 4fc6df0f13b..ed798188fcc 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -3,6 +3,7 @@ import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '../../store' import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption' import { useNativeChatRetainedSession } from './use-native-chat-retained-session' +import { isNativeChatTranscriptUnsettled } from './use-native-chat-live-session' import { selectNativeChatViewState } from './native-chat-view-state' import { NativeChatMessageList } from './NativeChatMessageList' import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer' @@ -142,7 +143,9 @@ function NativeChatResolvedView({ terminalTabId, agent, messages: session.messages, - transcriptLoading: session.readPhase === 'loading' + // 'awaiting' counts too: adopting a prefill against a transcript that hasn't + // flushed would re-offer a prompt the user already submitted. + transcriptLoading: isNativeChatTranscriptUnsettled(session.readPhase) }) // The live-session merge reconciles hooks with replayable transcript turn // boundaries; all working consumers must use that one lifecycle decision. diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts index 47832b10897..1627f5e3e35 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts @@ -181,6 +181,13 @@ describe('runtime subscribe', () => { transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, onFrame) await Promise.resolve() + expect(runtimeEnvironmentsSubscribe).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ capabilities: { transcriptPending: 1 } }) + }), + expect.any(Object) + ) + deliver({ type: 'appended', messages: [message('m-1')] }) deliver({ type: 'snapshot', messages: [message('m-snapshot')] }) deliver({ type: 'replacement', messages: [message('m-replacement')], hasMore: true }) @@ -287,6 +294,39 @@ describe('runtime subscribe', () => { }) }) + it('forwards the pending flag and still treats the real snapshot as the initial frame', async () => { + markRuntimeEnvironmentCompatible(ENV) + const { deliver } = stubSubscribe() + const onFrame = vi.fn() + const transport = getNativeChatSessionTransport(ENV) + + transport.subscribe( + { subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1', limit: 1 }, + onFrame + ) + await Promise.resolve() + + // The host's unflushed-transcript frame, then the flush that follows it. + deliver({ type: 'snapshot', messages: [], hasMore: false, pending: true }) + deliver({ type: 'snapshot', messages: [message('m-1')] }) + + expect(onFrame).toHaveBeenNthCalledWith(1, { + type: 'snapshot', + messages: [], + hasMore: false, + pending: true + }) + // Dropping `pending` would settle an empty read; carrying it onto the real + // snapshot would keep the view unsettled over real history. hasMore proves the + // pending frame did not consume the initial slot — only the initial branch + // infers a filled window from the limit. + expect(onFrame).toHaveBeenNthCalledWith(2, { + type: 'snapshot', + messages: [message('m-1')], + hasMore: true + }) + }) + it('carries an error on a post-initial reconnect snapshot', async () => { markRuntimeEnvironmentCompatible(ENV) const { deliver } = stubSubscribe() diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.ts index 0fe22211db0..112e7cdc3e0 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-transport.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.ts @@ -105,7 +105,14 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess { selector: environmentId, method: 'nativeChat.subscribe', - params: { subscriptionId, agent, sessionId, transcriptPath, limit }, + params: { + subscriptionId, + agent, + sessionId, + transcriptPath, + limit, + capabilities: { transcriptPending: 1 } + }, timeoutMs: 15_000 }, { @@ -134,8 +141,12 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess hasMore?: boolean error?: string lifecycle?: unknown + pending?: boolean } const lifecycle = parseRuntimeNativeChatTurnLifecycle(frame?.lifecycle) + // No transcript behind this window yet — forwarded so the view can + // stop spinning, but it is not the settled initial read. + const pending = frame?.pending === true if ( (frame?.type === 'appended' || frame?.type === 'snapshot' || @@ -143,13 +154,16 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess Array.isArray(frame.messages) ) { if (!receivedInitial) { - receivedInitial = true + if (!pending) { + receivedInitial = true + } onFrame({ type: 'snapshot', messages: frame.messages, hasMore: frame.hasMore ?? frame.messages.length >= (limit ?? 300), ...(frame.error ? { error: frame.error } : {}), - ...(lifecycle ? { lifecycle } : {}) + ...(lifecycle ? { lifecycle } : {}), + ...(pending ? { pending: true } : {}) }) } else if (frame.type === 'snapshot') { onFrame({ @@ -157,7 +171,8 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess messages: frame.messages, hasMore: frame.hasMore ?? false, ...(frame.error ? { error: frame.error } : {}), - ...(lifecycle ? { lifecycle } : {}) + ...(lifecycle ? { lifecycle } : {}), + ...(pending ? { pending: true } : {}) }) } else { onFrame( diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session-pending.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session-pending.test.ts new file mode 100644 index 00000000000..c8ef643b1fa --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session-pending.test.ts @@ -0,0 +1,244 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { useAppStore } from '@/store' + +const { resetTransport, subscriptions, transport } = vi.hoisted(() => { + const subscriptions: { onFrame: (frame: unknown) => void }[] = [] + const transport = { readSession: vi.fn(), subscribe: vi.fn() } + const resetTransport = (): void => { + subscriptions.splice(0) + // Default to an in-flight read so only the frames a test emits move the view. + transport.readSession.mockReset().mockImplementation(() => new Promise(() => {})) + transport.subscribe.mockReset().mockImplementation((_args, onFrame) => { + subscriptions.push({ onFrame }) + return vi.fn() + }) + } + return { resetTransport, subscriptions, transport } +}) + +vi.mock('./native-chat-session-transport', () => ({ + getNativeChatSessionTransport: () => transport +})) + +import { + isNativeChatTranscriptUnsettled, + NOTFOUND_RETRY_WINDOW_MS, + useNativeChatLiveSession, + type NativeChatLiveSession, + type UseNativeChatLiveSessionArgs +} from './use-native-chat-live-session' +import { useNativeChatRetainedSession } from './use-native-chat-retained-session' + +const BASE_ARGS: UseNativeChatLiveSessionArgs = { + paneKey: 'tab-1:leaf-1', + agent: 'claude', + sessionId: 'session-1', + transcriptPath: '/home/agent/session-1.jsonl', + enabled: true +} + +/** The frame the host emits once it has waited out the flush grace period. */ +const PENDING_FRAME = { type: 'snapshot', messages: [], hasMore: false, pending: true } + +function assistant(id: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 1, + source: 'transcript' + } +} + +describe('useNativeChatLiveSession — unflushed transcript (pending frame)', () => { + let root: Root + let latest: NativeChatLiveSession | null = null + + function Probe(props: UseNativeChatLiveSessionArgs): null { + latest = useNativeChatLiveSession(props) + return null + } + + async function render(props: UseNativeChatLiveSessionArgs = BASE_ARGS): Promise { + await act(async () => { + root.render(createElement(Probe, props)) + await Promise.resolve() + await Promise.resolve() + }) + } + + async function emit(frame: unknown): Promise { + await act(async () => { + subscriptions[0]?.onFrame(frame) + await Promise.resolve() + }) + } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + root = createRoot(document.createElement('div')) + latest = null + resetTransport() + useAppStore.setState({ agentStatusByPaneKey: {} }) + }) + + afterEach(() => { + act(() => root.unmount()) + vi.useRealTimers() + }) + + it('spins while neither the read nor the stream has produced anything', async () => { + await render() + + expect(latest?.readPhase).toBe('loading') + expect(latest?.status).toBe('loading') + }) + + it('leaves the loading surface on a pending frame without claiming a settled read', async () => { + await render() + + await emit(PENDING_FRAME) + + expect(latest?.readPhase).toBe('awaiting') + expect(latest?.status).not.toBe('loading') + expect(latest?.status).not.toBe('error') + expect(latest?.messages).toEqual([]) + }) + + it('keeps the readSession seed live, so the real transcript still backfills', async () => { + // The seed is the only thing that repairs the pane when the flush lands + // between the stream's initial drain and its next event — a pending frame + // must not consume it the way a real snapshot does. + let settleRead = (_result: { messages: NativeChatMessage[] }): void => {} + transport.readSession.mockImplementationOnce( + () => new Promise((resolve) => (settleRead = resolve)) + ) + await render() + + await emit(PENDING_FRAME) + await act(async () => { + settleRead({ messages: [assistant('flushed-later')] }) + await Promise.resolve() + }) + + expect(latest?.readPhase).toBe('ready') + expect(latest?.messages.map((message) => message.id)).toEqual(['flushed-later']) + }) + + it('settles to ready when the real snapshot arrives on the same subscription', async () => { + await render() + + await emit(PENDING_FRAME) + await emit({ type: 'snapshot', messages: [assistant('m-1')], hasMore: false }) + + expect(latest?.readPhase).toBe('ready') + expect(latest?.messages.map((message) => message.id)).toEqual(['m-1']) + }) + + it('errors once the not-found window expires with no word from the host', async () => { + // Control for the next case: proves the clock really drives the retry loop, + // so a passing suppression test can't be the timers never firing. + vi.useFakeTimers() + transport.readSession.mockResolvedValue({ error: 'no transcript', notFound: true }) + await render() + + await act(async () => { + await vi.advanceTimersByTimeAsync(NOTFOUND_RETRY_WINDOW_MS + 30_000) + }) + + expect(latest?.readPhase).toBe('error') + }) + + it('stops duplicate seed polling once the live stream owns the pending transcript', async () => { + // The reported bug: a tab that is never prompted never writes a transcript, so + // every read is notFound forever. The host has told us that is expected. + vi.useFakeTimers() + let settleRead = (_result: { error: string; notFound: true }): void => {} + transport.readSession.mockImplementationOnce( + () => new Promise((resolve) => (settleRead = resolve)) + ) + await render() + + await emit(PENDING_FRAME) + await act(async () => { + settleRead({ error: 'no transcript', notFound: true }) + await Promise.resolve() + await vi.advanceTimersByTimeAsync(NOTFOUND_RETRY_WINDOW_MS + 30_000) + }) + + expect(latest?.readPhase).toBe('awaiting') + expect(latest?.status).not.toBe('error') + // The resolve-poll subscription owns the eventual initial drain; continuing + // the seed too would issue one redundant filesystem read every 10 seconds. + expect(transport.readSession).toHaveBeenCalledOnce() + }) + + it('does not drop live appends already merged into the window', async () => { + await render() + + await emit({ type: 'appended', messages: [assistant('appended-1')] }) + await emit(PENDING_FRAME) + + expect(latest?.readPhase).toBe('awaiting') + expect(latest?.messages.map((message) => message.id)).toEqual(['appended-1']) + }) +}) + +describe('useNativeChatRetainedSession — unflushed transcript', () => { + let root: Root + let latest: NativeChatLiveSession | null = null + + function Probe(props: UseNativeChatLiveSessionArgs): null { + latest = useNativeChatRetainedSession(props) + return null + } + + async function emit(frame: unknown): Promise { + await act(async () => { + subscriptions[0]?.onFrame(frame) + await Promise.resolve() + }) + } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + root = createRoot(document.createElement('div')) + latest = null + resetTransport() + useAppStore.setState({ agentStatusByPaneKey: {} }) + }) + + afterEach(() => { + act(() => root.unmount()) + }) + + it('retains committed history instead of capturing the empty pending window', async () => { + await act(async () => { + root.render(createElement(Probe, BASE_ARGS)) + await Promise.resolve() + await Promise.resolve() + }) + await emit({ type: 'snapshot', messages: [assistant('committed')], hasMore: false }) + + await emit(PENDING_FRAME) + + expect(latest?.readPhase).toBe('awaiting') + expect(latest?.messages.map((message) => message.id)).toEqual(['committed']) + }) +}) + +describe('isNativeChatTranscriptUnsettled', () => { + it('covers both unsettled phases and neither settled one', () => { + // The launch-draft gate in NativeChatView reads this: treating 'awaiting' as + // settled would re-offer a prefill the user already submitted. + expect(isNativeChatTranscriptUnsettled('loading')).toBe(true) + expect(isNativeChatTranscriptUnsettled('awaiting')).toBe(true) + expect(isNativeChatTranscriptUnsettled('ready')).toBe(false) + expect(isNativeChatTranscriptUnsettled('error')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index 6044da30673..d56fb1cbe50 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -62,13 +62,25 @@ function nextSubscriptionId(): string { } // Why: a new session's transcript can take minutes to appear on disk (#8401). -const NOTFOUND_RETRY_WINDOW_MS = 60_000 +// Only a guess at the flush delay — a host that reports the transcript pending +// overrides it outright. Exported for tests. +export const NOTFOUND_RETRY_WINDOW_MS = 60_000 export type ReadState = | { phase: 'loading' } + /** The host reported no transcript behind this window yet: rendered, but not a + * settled read, so nothing may treat the empty list as real history. */ + | { phase: 'awaiting' } | { phase: 'ready'; messages: NativeChatMessage[] } | { phase: 'error'; error: string } +/** True while no transcript read has settled — 'loading' and 'awaiting' alike. + * Consumers that must not act on `messages` as real history use this, not a + * bare `!== 'ready'`, which would also swallow the error surface. */ +export function isNativeChatTranscriptUnsettled(phase: ReadState['phase']): boolean { + return phase === 'loading' || phase === 'awaiting' +} + /** * Renderer hook that streams a NativeChatSession for a pane: windowed * `readSession` + live `subscribe` tail, merged with live hook turn-state. @@ -156,6 +168,9 @@ export function useNativeChatLiveSession( let cancelled = false // Set by the first authoritative frame so the readSession seed below can't clobber a live snapshot. let frameArrived = false + // Set once the host reports no transcript on disk yet, which makes a notFound + // read known-good news rather than a failure worth surfacing. + let transcriptPending = false const retryTimer = createNativeChatReadRetryTimer() const retryStartedAt = Date.now() // Re-bound as a const: TS drops the `!sessionId` narrowing inside the hoisted nested function. @@ -171,7 +186,7 @@ export function useNativeChatLiveSession( // Independent initial seed in case subscribe never delivers a snapshot; applied only until an authoritative frame lands so a live snapshot wins. function loadSession(attempt: number): void { - if (!latestEnabled.current || frameArrived) { + if (!latestEnabled.current || frameArrived || transcriptPending) { return } void transport @@ -181,10 +196,16 @@ export function useNativeChatLiveSession( return } if (result && 'error' in result) { - // A not-yet-flushed transcript: stay in 'loading' and retry with backoff instead of a permanent error (#8401). - if (result.notFound && Date.now() - retryStartedAt < NOTFOUND_RETRY_WINDOW_MS) { - retryTimer.schedule(attempt, () => loadSession(attempt + 1)) - return + if (result.notFound) { + // The live stream owns recovery once it confirms the missing file; + // an older host gets the bounded seed retry as its fallback. + if (transcriptPending) { + return + } + if (Date.now() - retryStartedAt < NOTFOUND_RETRY_WINDOW_MS) { + retryTimer.schedule(attempt, () => loadSession(attempt + 1)) + return + } } setRead({ phase: 'error', error: result.error }) return @@ -226,6 +247,17 @@ export function useNativeChatLiveSession( setRead({ phase: 'error', error: frame.error }) return } + if (frame.type === 'snapshot' && frame.pending === true) { + // No transcript exists yet (an agent that hasn't flushed, or was never + // prompted). Move off 'loading' so the view stops spinning, but keep + // an in-flight seed and appended tail eligible — this is not a read. + transcriptPending = true + // The live resolve-poll stream now owns the eventual initial drain; + // stop duplicating its filesystem probes forever from the renderer. + retryTimer.cancel() + setRead({ phase: 'awaiting' }) + return + } frameArrived = true transcriptLifecycleControl.replace(frame.lifecycle) replaceList(appendMergerRef.current, frame.messages) diff --git a/src/renderer/src/web/preload-api/web-native-chat-api.ts b/src/renderer/src/web/preload-api/web-native-chat-api.ts index 31eec6d92fa..746236330f9 100644 --- a/src/renderer/src/web/preload-api/web-native-chat-api.ts +++ b/src/renderer/src/web/preload-api/web-native-chat-api.ts @@ -45,7 +45,8 @@ export function createWebNativeChatApi(): NativeChatApi { sessionId: args.sessionId, subscriptionId: args.subscriptionId, transcriptPath: args.transcriptPath, - limit: args.limit + limit: args.limit, + capabilities: { transcriptPending: 1 } }, { onResponse: (response) => { @@ -70,8 +71,11 @@ export function createWebNativeChatApi(): NativeChatApi { hasMore?: boolean error?: string lifecycle?: unknown + pending?: boolean } const lifecycle = parseRuntimeNativeChatTurnLifecycle(result?.lifecycle) + // No transcript behind this window yet — forwarded so the view can stop spinning, but it is not the settled initial read. + const pending = result?.pending === true if ( (result?.type === 'appended' || result?.type === 'snapshot' || @@ -79,13 +83,16 @@ export function createWebNativeChatApi(): NativeChatApi { Array.isArray(result.messages) ) { if (!receivedInitial) { - receivedInitial = true + if (!pending) { + receivedInitial = true + } onFrame({ type: 'snapshot', messages: result.messages, hasMore: result.hasMore ?? result.messages.length >= (args.limit ?? 300), ...(result.error ? { error: result.error } : {}), - ...(lifecycle ? { lifecycle } : {}) + ...(lifecycle ? { lifecycle } : {}), + ...(pending ? { pending: true } : {}) }) } else if (result.type === 'snapshot') { onFrame({ @@ -93,7 +100,8 @@ export function createWebNativeChatApi(): NativeChatApi { messages: result.messages, hasMore: result.hasMore ?? false, ...(result.error ? { error: result.error } : {}), - ...(lifecycle ? { lifecycle } : {}) + ...(lifecycle ? { lifecycle } : {}), + ...(pending ? { pending: true } : {}) }) } else { onFrame( diff --git a/src/renderer/src/web/web-preload-api-agent-providers.test.ts b/src/renderer/src/web/web-preload-api-agent-providers.test.ts index 1dc55f984ff..35324baaa13 100644 --- a/src/renderer/src/web/web-preload-api-agent-providers.test.ts +++ b/src/renderer/src/web/web-preload-api-agent-providers.test.ts @@ -86,6 +86,64 @@ describe('web native chat preload API', () => { } ]) }) + + it('forwards the pending flag and still treats the real snapshot as the initial frame', async () => { + const message = { + id: 'a-1', + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: 'flushed' }], + timestamp: 7, + source: 'transcript' as const + } + let deliver: (result: unknown) => void = () => {} + let subscribeParams: unknown + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + subscribe( + _method: string, + params: unknown, + callbacks: { onResponse: (response: RuntimeRpcResponse) => void } + ): Promise<{ unsubscribe: () => void }> { + subscribeParams = params + deliver = (result) => + callbacks.onResponse({ + id: 'stream-1', + ok: true, + result, + _meta: { runtimeId: 'runtime-1' } + }) + return Promise.resolve({ unsubscribe: vi.fn() }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + const frames: unknown[] = [] + globals.window.api.nativeChat.subscribe( + { subscriptionId: 'sub-1', agent: 'claude', sessionId: 'session-1', limit: 1 }, + (frame) => frames.push(frame) + ) + await Promise.resolve() + + expect(subscribeParams).toMatchObject({ capabilities: { transcriptPending: 1 } }) + + // The host's unflushed-transcript frame, then the flush that follows it. + deliver({ type: 'snapshot', messages: [], hasMore: false, pending: true }) + deliver({ type: 'snapshot', messages: [message] }) + + // Dropping `pending` would settle an empty read as the session's history. + // hasMore proves the pending frame did not consume the initial slot — only + // the initial branch infers a filled window from the limit. + expect(frames).toEqual([ + { type: 'snapshot', messages: [], hasMore: false, pending: true }, + { type: 'snapshot', messages: [message], hasMore: true } + ]) + }) }) describe('web MiniMax preload API', () => {