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 ed2f66c42bff..5f3419b799b2 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -49,6 +49,17 @@ describe('useMobileNativeChatSession', () => { }) } + it('advertises transcript-order support on the stream', async () => { + const subscribe = vi.fn(() => () => {}) + await mount({ sendRequest: vi.fn(), subscribe } as unknown as RpcClient) + + expect(subscribe).toHaveBeenCalledWith( + 'nativeChat.subscribe', + expect.objectContaining({ preservesTranscriptOrder: true }), + expect.any(Function) + ) + }) + it('drops an older-page response captured before transcript replacement', async () => { let resolveEarlier: (response: unknown) => void = () => {} const sendRequest = vi.fn( @@ -161,7 +172,8 @@ describe('useMobileNativeChatSession', () => { agent: 'claude', sessionId: 'session', limit: 60, - beforeOffset: 500 + beforeOffset: 500, + preservesTranscriptOrder: true }) } ) @@ -239,7 +251,8 @@ describe('useMobileNativeChatSession', () => { expect(sendRequest).toHaveBeenCalledWith('nativeChat.readSession', { agent: 'claude', sessionId: 'session', - limit: 100 + limit: 100, + preservesTranscriptOrder: true }) }) @@ -299,7 +312,8 @@ describe('useMobileNativeChatSession', () => { expect(sendRequest).toHaveBeenCalledWith('nativeChat.readSession', { agent: 'claude', sessionId: 'session', - limit: 100 + limit: 100, + preservesTranscriptOrder: true }) }) @@ -420,7 +434,8 @@ describe('useMobileNativeChatSession', () => { expect(sendRequest).toHaveBeenLastCalledWith('nativeChat.readSession', { agent: 'claude', sessionId: 'session', - limit: 100 + limit: 100, + preservesTranscriptOrder: true }) expect(state?.messages.map((entry) => entry.id)).toEqual(['fresh-growing-tail']) }) diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index e509b202c2a7..b37ccbc1c4aa 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -155,6 +155,7 @@ export function useMobileNativeChatSession(args: { limit: limitRef.current, subscriptionId: buildNativeChatSubscriptionId(agent, sessionId), capabilities: { transcriptPending: 1 }, + preservesTranscriptOrder: true, ...(transcriptPath ? { transcriptPath } : {}) }, (raw) => { @@ -243,6 +244,7 @@ export function useMobileNativeChatSession(args: { agent, sessionId, limit: beforeOffset === null ? nextLimit : pageLimit, + preservesTranscriptOrder: true, ...(beforeOffset === null ? {} : { beforeOffset }), ...(transcriptPath ? { transcriptPath } : {}) }) diff --git a/src/main/native-chat/transcript-line-decoders-claude.ts b/src/main/native-chat/transcript-line-decoders-claude.ts index 2e936874454b..6a22464526e1 100644 --- a/src/main/native-chat/transcript-line-decoders-claude.ts +++ b/src/main/native-chat/transcript-line-decoders-claude.ts @@ -22,6 +22,9 @@ export function decodeClaudeTranscriptLine( if (!record) { return null } + if (record.type === 'attachment') { + return decodeQueuedPrompt(record, fallbackId) + } const role = record.type if (role !== 'user' && role !== 'assistant') { return null @@ -65,6 +68,28 @@ export function decodeClaudeTranscriptLine( } } +function decodeQueuedPrompt( + record: Record, + fallbackId: string +): NativeChatMessage | null { + const attachment = asRecord(record.attachment) + if (attachment?.type !== 'queued_command' || attachment.commandMode !== 'prompt') { + return null + } + const blocks = claudeContentBlocks(attachment.prompt) + if (blocks.length === 0) { + return null + } + return { + id: extractString(record.uuid) ?? fallbackId, + role: 'user', + blocks, + timestamp: parseTimestamp(record.timestamp ?? attachment.timestamp), + source: 'transcript', + queued: true + } +} + // Claude marks reasoning via `thinking` content blocks; when a message is made // up solely of reasoning, surface it as a reasoning-role message. function claudeMessageRole( diff --git a/src/main/native-chat/transcript-line-decoders.claude.test.ts b/src/main/native-chat/transcript-line-decoders.claude.test.ts new file mode 100644 index 000000000000..3c9e20cc4d8b --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders.claude.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude' + +function attachment(overrides: Record = {}): string { + return JSON.stringify({ + type: 'attachment', + uuid: 'queued-1', + timestamp: '2026-06-01T10:00:05.000Z', + attachment: { + type: 'queued_command', + commandMode: 'prompt', + prompt: 'queued prompt', + ...overrides + } + }) +} + +describe('decodeClaudeTranscriptLine queued prompts', () => { + it('decodes a queued prompt with provider identity and enqueue time', () => { + expect(decodeClaudeTranscriptLine(attachment(), 'fallback')).toEqual({ + id: 'queued-1', + role: 'user', + blocks: [{ type: 'text', text: 'queued prompt' }], + timestamp: Date.parse('2026-06-01T10:00:05.000Z'), + source: 'transcript', + queued: true + }) + }) + + it.each([ + { commandMode: 'task-notification' }, + { commandMode: 'command' }, + { type: 'other' }, + { prompt: '' }, + { prompt: { unexpected: true } } + ])('rejects non-prompt attachment content %#', (overrides) => { + expect(decodeClaudeTranscriptLine(attachment(overrides), 'fallback')).toBeNull() + }) +}) diff --git a/src/main/native-chat/transcript-reader.test.ts b/src/main/native-chat/transcript-reader.test.ts index ff48548804d8..e2b6a3ed3c07 100644 --- a/src/main/native-chat/transcript-reader.test.ts +++ b/src/main/native-chat/transcript-reader.test.ts @@ -2,7 +2,9 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' +import { isTextBlock, type NativeChatMessage } from '../../shared/native-chat-types' import { readNativeChatTranscript } from './transcript-reader' +import { readIncrementalTranscriptMessages } from './transcript-incremental-reader' import { nativeChatLineDecoderForAgent, readNativeChatTranscriptTail, @@ -29,6 +31,68 @@ async function writeFixture(prefix: string, records: unknown[]): Promise } describe('readNativeChatTranscript (claude)', () => { + it('renders a queued prompt once at its transcript position', async () => { + const filePath = await writeFixture('orca-native-chat-queued-', [ + { + type: 'user', + uuid: 'u1', + timestamp: '2026-06-01T10:00:00.000Z', + message: { role: 'user', content: 'first prompt' } + }, + { + type: 'assistant', + uuid: 'z-predecessor', + timestamp: '2026-06-01T10:00:10.000Z', + message: { role: 'assistant', content: [{ type: 'text', text: 'first reply' }] } + }, + { + type: 'attachment', + uuid: 'a-queued', + timestamp: '2026-06-01T10:00:05.000Z', + attachment: { + type: 'queued_command', + commandMode: 'prompt', + prompt: 'queued prompt', + timestamp: '2026-06-01T10:00:05.000Z' + } + }, + { + type: 'assistant', + uuid: 'a2', + timestamp: '2026-06-01T10:00:20.000Z', + message: { role: 'assistant', content: [{ type: 'text', text: 'final reply' }] } + } + ]) + + const result = await readNativeChatTranscript('claude', 'sess', { filePath }) + expect('messages' in result).toBe(true) + if (!('messages' in result)) { + return + } + const assemblerUrl = new URL( + '../../renderer/src/components/native-chat/native-chat-session-assembler.ts', + import.meta.url + ).href + const assembler = (await import(/* @vite-ignore */ assemblerUrl)) as { + assembleNativeChatSession: (input: { + sources: { transcript: NativeChatMessage[] } + sessionId: string + agent: 'claude' + }) => { messages: NativeChatMessage[] } + } + const session = assembler.assembleNativeChatSession({ + sources: { transcript: result.messages }, + sessionId: 'sess', + agent: 'claude' + }) + const visibleText = session.messages.flatMap((message) => + message.blocks.filter(isTextBlock).map((block) => block.text) + ) + + expect(visibleText).toEqual(['first prompt', 'first reply', 'queued prompt', 'final reply']) + expect(visibleText.filter((text) => text === 'queued prompt')).toHaveLength(1) + }) + it('decodes OpenClaude with the Claude transcript format', async () => { const filePath = await writeFixture('orca-native-chat-openclaude-', [ { @@ -43,6 +107,85 @@ describe('readNativeChatTranscript (claude)', () => { ).resolves.toMatchObject({ messages: [{ id: 'openclaude-assistant' }] }) }) + it('does not let a hidden queued row consume a legacy tail-window slot', async () => { + const filePath = await writeFixture('orca-native-chat-legacy-window-', [ + { + type: 'assistant', + uuid: 'a1', + message: { role: 'assistant', content: [{ type: 'text', text: 'one' }] } + }, + { + type: 'assistant', + uuid: 'a2', + message: { role: 'assistant', content: [{ type: 'text', text: 'two' }] } + }, + { + type: 'attachment', + uuid: 'queued', + attachment: { type: 'queued_command', commandMode: 'prompt', prompt: 'queued' } + }, + { + type: 'assistant', + uuid: 'a3', + message: { role: 'assistant', content: [{ type: 'text', text: 'three' }] } + } + ]) + + const result = await readNativeChatTranscriptTail({ + agent: 'claude', + sessionId: 'sess', + filePath, + limit: 2, + includeQueuedPrompts: false + }) + + expect(result).toMatchObject({ messages: [{ id: 'a2' }, { id: 'a3' }], hasMore: true }) + }) + + it('does not let a hidden queued row consume an incremental batch slot', async () => { + const records: unknown[] = Array.from({ length: 39 }, (_unused, index) => ({ + type: 'assistant', + uuid: `a-${index}`, + message: { role: 'assistant', content: [{ type: 'text', text: `${index}` }] } + })) + records.push( + { + type: 'attachment', + uuid: 'queued', + attachment: { type: 'queued_command', commandMode: 'prompt', prompt: 'queued' } + }, + ...[39, 40, 41].map((index) => ({ + type: 'assistant', + uuid: `a-${index}`, + message: { role: 'assistant', content: [{ type: 'text', text: `${index}` }] } + })) + ) + const filePath = await writeFixture('orca-native-chat-legacy-batch-', records) + const decode = nativeChatLineDecoderForAgent('claude', false) + expect(decode).not.toBeNull() + if (!decode) { + return + } + const batches: NativeChatMessage[][] = [] + const remaining = await readIncrementalTranscriptMessages( + filePath, + { + offset: 0, + pendingChunks: [], + pendingStart: 0, + pendingBytes: 0, + droppingOversizedRecord: false + }, + decode, + (messages) => batches.push(messages) + ) + + expect(batches).toHaveLength(1) + expect(batches[0]).toHaveLength(40) + expect(remaining).toHaveLength(1) + expect([...batches[0]!, ...remaining].some((message) => message.queued)).toBe(false) + }) + it('returns ordered user/assistant/tool messages with no 5-message cap', async () => { const records: unknown[] = [] // 4 user/assistant turns = 8 messages, well past the AI-Vault preview cap. diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index b6fa9bafdb0a..396c6366bfd3 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -33,10 +33,19 @@ export const MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES = 2 * 1024 * 1024 export type NativeChatLineDecoder = (line: string, fallbackId: string) => NativeChatMessage | null -export function nativeChatLineDecoderForAgent(agent: AgentType): NativeChatLineDecoder | null { +export function nativeChatLineDecoderForAgent( + agent: AgentType, + includeQueuedPrompts = true +): NativeChatLineDecoder | null { const transcriptAgent = resolveNativeChatTranscriptAgent(agent) if (transcriptAgent === 'claude') { - return decodeClaudeTranscriptLine + if (includeQueuedPrompts) { + return decodeClaudeTranscriptLine + } + return (line, fallbackId) => { + const message = decodeClaudeTranscriptLine(line, fallbackId) + return message?.queued ? null : message + } } if (transcriptAgent === 'codex') { return decodeCodexTranscriptLine @@ -219,6 +228,7 @@ export async function readNativeChatTranscriptTail( filePath?: string limit: number beforeOffset?: number + includeQueuedPrompts?: boolean }, signal?: AbortSignal ): Promise< @@ -230,7 +240,7 @@ export async function readNativeChatTranscriptTail( } | { error: string; notFound?: true } > { - const decode = nativeChatLineDecoderForAgent(args.agent) + const decode = nativeChatLineDecoderForAgent(args.agent, args.includeQueuedPrompts) const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent) if (!decode) { return { error: 'Transcript unavailable' } diff --git a/src/main/native-chat/transcript-watch-contract.ts b/src/main/native-chat/transcript-watch-contract.ts index 13463e524ef3..121494bfaf2a 100644 --- a/src/main/native-chat/transcript-watch-contract.ts +++ b/src/main/native-chat/transcript-watch-contract.ts @@ -29,6 +29,7 @@ export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { lifecycle?: NativeChatTurnLifecycle ) => void initialLimit?: number + includeQueuedPrompts?: boolean filePath?: string debounceMs?: number /** Test-only override for the production resolve-poll backoff. */ diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index a104bd35d3b7..aa007ba4b710 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -248,7 +248,7 @@ export async function subscribeNativeChatTranscript( setupSignal?: AbortSignal ): Promise { setupSignal?.throwIfAborted() - const decode = nativeChatLineDecoderForAgent(args.agent) + const decode = nativeChatLineDecoderForAgent(args.agent, args.includeQueuedPrompts) if (!decode) { // Nothing watchable — return a no-op teardown so callers can unconditionally // unsubscribe without null-checks. diff --git a/src/main/runtime/rpc/methods/native-chat-client-transcript-window.ts b/src/main/runtime/rpc/methods/native-chat-client-transcript-window.ts new file mode 100644 index 000000000000..8848d987bfbc --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat-client-transcript-window.ts @@ -0,0 +1,148 @@ +import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types' +import type { RpcContext } from '../core' +import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block' + +// Why: a long agent session can hold thousands of turns (with full tool I/O). +// Shipping all of them over the paired connection and rendering them at once +// freezes the mobile app, so the runtime RPC windows to the most recent slice — +// the conversation tail is what the chat view shows first. The desktop IPC path +// is unaffected (it reads locally with a virtualized list). +// Small first page for a fast initial paint; the client raises `limit` to load +// older history as the user scrolls back. +export const MOBILE_NATIVE_CHAT_DEFAULT_WINDOW = 40 +export const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000 +// Why: a single tool result (a big file read, a long diff) can be hundreds of KB. +// The mobile view only previews tool block bodies, so truncate them on the wire +// to keep the payload small; the marker tells the user content was clipped. +const MOBILE_BLOCK_CHAR_CAP = 4000 +// Why: text blocks are the message body itself, rendered in full by the chat +// view — a preview-sized cap cut long assistant replies mid-sentence with no way +// to read on (STA-3230). Keep only a generous safety ceiling: a transcript +// record can legally reach 2MB, and shipping that much markdown in one block +// would freeze the phone. +const MOBILE_TEXT_BLOCK_CHAR_CAP = 64_000 +const MOBILE_TOOL_INPUT_ITEMS_CAP = 20 +const MOBILE_TOOL_INPUT_NODE_CAP = 100 +const TRUNCATION_MARKER = '\n… (truncated)' + +function clip(text: string, cap: number): string { + return text.length > cap ? text.slice(0, cap) + TRUNCATION_MARKER : text +} + +function sanitizeBlock( + block: NativeChatBlock, + clientKind: RpcContext['clientKind'] +): NativeChatBlock { + if (block.type === 'image-ref') { + return sanitizeNativeChatRpcImageBlock(block) + } + if (clientKind !== 'mobile') { + return block + } + if (block.type === 'text') { + return block.text.length > MOBILE_TEXT_BLOCK_CHAR_CAP + ? { ...block, text: clip(block.text, MOBILE_TEXT_BLOCK_CHAR_CAP) } + : block + } + if (block.type === 'tool-result') { + return block.output.length > MOBILE_BLOCK_CHAR_CAP + ? { ...block, output: clip(block.output, MOBILE_BLOCK_CHAR_CAP) } + : block + } + if (block.type === 'tool-call') { + const budget = { remaining: MOBILE_BLOCK_CHAR_CAP, nodes: MOBILE_TOOL_INPUT_NODE_CAP } + return { ...block, input: sanitizeToolInput(block.input, budget, 0) } + } + return block +} + +function sanitizeToolInput( + value: unknown, + budget: { remaining: number; nodes: number }, + depth: number +): unknown { + budget.nodes-- + if (budget.nodes < 0 || budget.remaining <= 0) { + return '… (truncated)' + } + if (typeof value === 'string') { + const length = Math.min(value.length, budget.remaining) + budget.remaining -= length + return length < value.length ? `${value.slice(0, length)}… (truncated)` : value + } + if (!value || typeof value !== 'object' || depth >= 5) { + return value && typeof value === 'object' ? '… (truncated)' : value + } + if (Array.isArray(value)) { + const result = value + .slice(0, MOBILE_TOOL_INPUT_ITEMS_CAP) + .map((item) => sanitizeToolInput(item, budget, depth + 1)) + if (value.length > MOBILE_TOOL_INPUT_ITEMS_CAP) { + result.push('… (truncated)') + } + return result + } + const result: Record = {} + let count = 0 + for (const key in value) { + if (!Object.hasOwn(value, key)) { + continue + } + if (count >= MOBILE_TOOL_INPUT_ITEMS_CAP || budget.remaining <= 0) { + result['…'] = 'truncated' + break + } + let boundedKey = key.slice(0, Math.min(key.length, budget.remaining, 128)) + // Why: sibling keys sharing a >=128-char (or budget-truncated) prefix collapse + // to the same bounded key; suffix collisions so neither field is silently lost. + if (Object.hasOwn(result, boundedKey)) { + boundedKey = `${boundedKey}~${count}` + } + budget.remaining -= boundedKey.length + result[boundedKey] = sanitizeToolInput( + (value as Record)[key], + budget, + depth + 1 + ) + count++ + } + return result +} + +function sanitizeMessage( + message: NativeChatMessage, + clientKind: RpcContext['clientKind'] +): NativeChatMessage { + return { ...message, blocks: message.blocks.map((block) => sanitizeBlock(block, clientKind)) } +} + +export function sanitizeAppendForClient( + messages: readonly NativeChatMessage[], + clientKind: RpcContext['clientKind'] +): NativeChatMessage[] { + return messages.map((message) => sanitizeMessage(message, clientKind)) +} + +/** Window a transcript to its most recent `limit` messages so a long session + * can't freeze the client. Windowing by count applies to ALL RPC clients — + * shipping thousands of turns over the paired link is bad for web and mobile + * alike. Char-clipping (the mobile-only payload diet) is applied separately. */ +function windowTranscript( + messages: readonly NativeChatMessage[], + limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW +): NativeChatMessage[] { + const window = Math.min(Math.max(limit, 1), MOBILE_NATIVE_CHAT_MAX_WINDOW) + return messages.length > window ? messages.slice(-window) : messages.slice() +} + +/** Apply the windowed slice and keep inline image bytes off every RPC transport. + * Mobile clients additionally receive bounded text and tool bodies; runtime + * clients keep those bodies intact. */ +export function windowForClient( + messages: readonly NativeChatMessage[], + clientKind: RpcContext['clientKind'], + limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW +): NativeChatMessage[] { + const windowed = windowTranscript(messages, limit) + return windowed.map((message) => sanitizeMessage(message, clientKind)) +} diff --git a/src/main/runtime/rpc/methods/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat.test.ts index 65bb525e798f..9d1ea8ddc28a 100644 --- a/src/main/runtime/rpc/methods/native-chat.test.ts +++ b/src/main/runtime/rpc/methods/native-chat.test.ts @@ -21,7 +21,10 @@ const cachedResult = vi.hoisted(() => ({ } } })) -const tailRead = vi.hoisted(() => ({ signal: undefined as AbortSignal | undefined })) +const tailRead = vi.hoisted(() => ({ + signal: undefined as AbortSignal | undefined, + args: undefined as { includeQueuedPrompts?: boolean } | undefined +})) const watcher = vi.hoisted(() => ({ args: null as null | { onInitialSnapshot?: ( @@ -53,6 +56,7 @@ const watcher = vi.hoisted(() => ({ timestamp: number | null } ) => void + includeQueuedPrompts?: boolean onTranscriptPending?: () => void }, watching: true, @@ -64,12 +68,16 @@ const watcher = vi.hoisted(() => ({ unsubscribe: vi.fn() })) vi.mock('../../../native-chat/transcript-watch', () => ({ - readNativeChatTranscriptTail: ({ limit }: { limit: number }, signal?: AbortSignal) => { + readNativeChatTranscriptTail: ( + args: { limit: number; includeQueuedPrompts?: boolean }, + signal?: AbortSignal + ) => { tailRead.signal = signal + tailRead.args = args const messages = cachedResult.value.messages return Promise.resolve({ - messages: messages.slice(-limit), - hasMore: messages.length > limit, + messages: messages.slice(-args.limit), + hasMore: messages.length > args.limit, beforeOffset: 123, ...(cachedResult.value.lifecycle ? { lifecycle: cachedResult.value.lifecycle } : {}) }) @@ -158,6 +166,17 @@ function activeWatcherArgs(): NonNullable { } describe('nativeChat.readSession clientKind truncation gating', () => { + it('enables queued prompts only for order-preserving clients', async () => { + await readSessionHandler()( + { agent: 'claude', sessionId: 's', preservesTranscriptOrder: true }, + ctxWith('runtime') + ) + expect(tailRead.args?.includeQueuedPrompts).toBe(true) + + await readSessionHandler()({ agent: 'claude', sessionId: 's' }, ctxWith('runtime')) + expect(tailRead.args?.includeQueuedPrompts).toBe(false) + }) + it('passes request cancellation to transcript resolution', async () => { const controller = new AbortController() const context = { ...ctxWith('runtime'), signal: controller.signal } @@ -417,6 +436,19 @@ describe('nativeChat.readSession clientKind truncation gating', () => { }) describe('nativeChat.subscribe initial snapshot', () => { + it('enables queued prompts only for order-preserving streams', async () => { + const context = streamingContext('runtime') + await subscribeHandler()( + { agent: 'claude', sessionId: 's', preservesTranscriptOrder: true }, + context, + vi.fn() + ) + expect(activeWatcherArgs().includeQueuedPrompts).toBe(true) + + await subscribeHandler()({ agent: 'claude', sessionId: 'legacy' }, context, vi.fn()) + expect(activeWatcherArgs().includeQueuedPrompts).toBe(false) + }) + it('preserves long assistant text across mobile stream frame types', 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 8fc86bf695a2..b73b4cf668fe 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -1,17 +1,18 @@ import { z } from 'zod' -import type { - NativeChatBlock, - NativeChatMessage, - AgentType -} from '../../../../shared/native-chat-types' +import type { AgentType } from '../../../../shared/native-chat-types' import { readNativeChatTranscriptTail, subscribeNativeChatTranscript, type NativeChatTranscriptSubscription, type SubscribeNativeChatTranscriptArgs } from '../../../native-chat/transcript-watch' -import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' -import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block' +import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' +import { + MOBILE_NATIVE_CHAT_DEFAULT_WINDOW, + MOBILE_NATIVE_CHAT_MAX_WINDOW, + sanitizeAppendForClient, + windowForClient +} from './native-chat-client-transcript-window' // Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The // desktop reaches the readers via Electron IPC; mobile/web clients reach the @@ -52,158 +53,14 @@ const NativeChatSession = z.object({ // 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() + beforeOffset: z.number().int().nonnegative().optional(), + preservesTranscriptOrder: z.literal(true).optional() }) const NativeChatUnsubscribe = z.object({ subscriptionId: z.string().min(1).optional() }) -// Why: a long agent session can hold thousands of turns (with full tool I/O). -// Shipping all of them over the paired connection and rendering them at once -// freezes the mobile app, so the runtime RPC windows to the most recent slice — -// the conversation tail is what the chat view shows first. The desktop IPC path -// is unaffected (it reads locally with a virtualized list). -// Small first page for a fast initial paint; the client raises `limit` to load -// older history as the user scrolls back. -const MOBILE_NATIVE_CHAT_DEFAULT_WINDOW = 40 -const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000 -// Why: a single tool result (a big file read, a long diff) can be hundreds of KB. -// The mobile view only previews tool block bodies, so truncate them on the wire -// to keep the payload small; the marker tells the user content was clipped. -const MOBILE_BLOCK_CHAR_CAP = 4000 -// Why: text blocks are the message body itself, rendered in full by the chat -// view — a preview-sized cap cut long assistant replies mid-sentence with no way -// to read on (STA-3230). Keep only a generous safety ceiling: a transcript -// record can legally reach 2MB, and shipping that much markdown in one block -// would freeze the phone. -const MOBILE_TEXT_BLOCK_CHAR_CAP = 64_000 -const MOBILE_TOOL_INPUT_ITEMS_CAP = 20 -const MOBILE_TOOL_INPUT_NODE_CAP = 100 -const TRUNCATION_MARKER = '\n… (truncated)' - -function clip(text: string, cap: number): string { - return text.length > cap ? text.slice(0, cap) + TRUNCATION_MARKER : text -} - -function sanitizeBlock( - block: NativeChatBlock, - clientKind: RpcContext['clientKind'] -): NativeChatBlock { - if (block.type === 'image-ref') { - return sanitizeNativeChatRpcImageBlock(block) - } - if (clientKind !== 'mobile') { - return block - } - if (block.type === 'text') { - return block.text.length > MOBILE_TEXT_BLOCK_CHAR_CAP - ? { ...block, text: clip(block.text, MOBILE_TEXT_BLOCK_CHAR_CAP) } - : block - } - if (block.type === 'tool-result') { - return block.output.length > MOBILE_BLOCK_CHAR_CAP - ? { ...block, output: clip(block.output, MOBILE_BLOCK_CHAR_CAP) } - : block - } - if (block.type === 'tool-call') { - const budget = { remaining: MOBILE_BLOCK_CHAR_CAP, nodes: MOBILE_TOOL_INPUT_NODE_CAP } - return { ...block, input: sanitizeToolInput(block.input, budget, 0) } - } - return block -} - -function sanitizeToolInput( - value: unknown, - budget: { remaining: number; nodes: number }, - depth: number -): unknown { - budget.nodes-- - if (budget.nodes < 0 || budget.remaining <= 0) { - return '… (truncated)' - } - if (typeof value === 'string') { - const length = Math.min(value.length, budget.remaining) - budget.remaining -= length - return length < value.length ? `${value.slice(0, length)}… (truncated)` : value - } - if (!value || typeof value !== 'object' || depth >= 5) { - return value && typeof value === 'object' ? '… (truncated)' : value - } - if (Array.isArray(value)) { - const result = value - .slice(0, MOBILE_TOOL_INPUT_ITEMS_CAP) - .map((item) => sanitizeToolInput(item, budget, depth + 1)) - if (value.length > MOBILE_TOOL_INPUT_ITEMS_CAP) { - result.push('… (truncated)') - } - return result - } - const result: Record = {} - let count = 0 - for (const key in value) { - if (!Object.hasOwn(value, key)) { - continue - } - if (count >= MOBILE_TOOL_INPUT_ITEMS_CAP || budget.remaining <= 0) { - result['…'] = 'truncated' - break - } - let boundedKey = key.slice(0, Math.min(key.length, budget.remaining, 128)) - // Why: sibling keys sharing a >=128-char (or budget-truncated) prefix collapse - // to the same bounded key; suffix collisions so neither field is silently lost. - if (Object.hasOwn(result, boundedKey)) { - boundedKey = `${boundedKey}~${count}` - } - budget.remaining -= boundedKey.length - result[boundedKey] = sanitizeToolInput( - (value as Record)[key], - budget, - depth + 1 - ) - count++ - } - return result -} - -function sanitizeMessage( - message: NativeChatMessage, - clientKind: RpcContext['clientKind'] -): NativeChatMessage { - return { ...message, blocks: message.blocks.map((block) => sanitizeBlock(block, clientKind)) } -} - -function sanitizeAppendForClient( - messages: readonly NativeChatMessage[], - clientKind: RpcContext['clientKind'] -): NativeChatMessage[] { - return messages.map((message) => sanitizeMessage(message, clientKind)) -} - -/** Window a transcript to its most recent `limit` messages so a long session - * can't freeze the client. Windowing by count applies to ALL RPC clients — - * shipping thousands of turns over the paired link is bad for web and mobile - * alike. Char-clipping (the mobile-only payload diet) is applied separately. */ -function windowTranscript( - messages: readonly NativeChatMessage[], - limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW -): NativeChatMessage[] { - const window = Math.min(Math.max(limit, 1), MOBILE_NATIVE_CHAT_MAX_WINDOW) - return messages.length > window ? messages.slice(-window) : messages.slice() -} - -/** Apply the windowed slice and keep inline image bytes off every RPC transport. - * Mobile clients additionally receive bounded text and tool bodies; runtime - * clients keep those bodies intact. */ -function windowForClient( - messages: readonly NativeChatMessage[], - clientKind: RpcContext['clientKind'], - limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW -): NativeChatMessage[] { - const windowed = windowTranscript(messages, limit) - return windowed.map((message) => sanitizeMessage(message, clientKind)) -} - export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ defineMethod({ name: 'nativeChat.readSession', @@ -216,7 +73,8 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ sessionId: params.sessionId, transcriptPath: params.transcriptPath, limit, - beforeOffset: params.beforeOffset + beforeOffset: params.beforeOffset, + includeQueuedPrompts: params.preservesTranscriptOrder === true }, signal ) @@ -277,6 +135,7 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ sessionId: params.sessionId, transcriptPath: params.transcriptPath, initialLimit: limit, + includeQueuedPrompts: params.preservesTranscriptOrder === true, onInitialSnapshot: (messages, hasMore, beforeOffset, error, lifecycle) => { if (closed) { return diff --git a/src/renderer/src/components/native-chat/native-chat-incremental-assembler.test.ts b/src/renderer/src/components/native-chat/native-chat-incremental-assembler.test.ts index 79ba3fcfdf42..8b817520ea1b 100644 --- a/src/renderer/src/components/native-chat/native-chat-incremental-assembler.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-incremental-assembler.test.ts @@ -121,4 +121,16 @@ describe('incremental assembler — oracle differential', () => { const out = assembler.messages expect(applyAppends(assembler, [])).toBe(out) }) + + it('fast-appends a queued prompt after the active reply', () => { + const predecessor = msg({ id: 'z-predecessor', timestamp: 10 }) + const queued = msg({ id: 'a-queued', role: 'user', timestamp: 5, queued: true }) + const assembler = createIncrementalAssembler() + reset(assembler, [predecessor]) + + expect(applyAppends(assembler, [queued]).map((message) => message.id)).toEqual([ + 'z-predecessor', + 'a-queued' + ]) + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-incremental-assembler.ts b/src/renderer/src/components/native-chat/native-chat-incremental-assembler.ts index 19cf5c2b4d0a..9598aad07f3a 100644 --- a/src/renderer/src/components/native-chat/native-chat-incremental-assembler.ts +++ b/src/renderer/src/components/native-chat/native-chat-incremental-assembler.ts @@ -13,7 +13,8 @@ // base ++ all-appends for every prefix (locked by the oracle differential test). import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import { compareMessages, mergeOne } from './native-chat-session-assembler' +import { nativeChatMessageSortRank, orderNativeChatMessages } from './native-chat-message-order' +import { mergeOne } from './native-chat-session-assembler' export type IncrementalChatAssembler = { byId: Map @@ -37,14 +38,14 @@ export function reset( for (const message of base) { mergeOne(assembler.byId, assembler.byTurn, message) } - assembler.messages = Array.from(assembler.byId.values()).sort(compareMessages) + assembler.messages = orderNativeChatMessages(Array.from(assembler.byId.values())) return assembler.messages } /** Fold a live append batch through the same merge rule as the full rebuild. * Fast path: when every incoming message is a brand-new id, has a brand-new * turnKey-free identity (no merge/removal), and sorts at/after the current - * tail, splice the batch in (O(k log k)). Any ambiguity → full re-sort of the + * transcript tail, splice the batch in (O(k)). Any ambiguity → full re-sort of the * whole map (still correct, just O(n log n) for that one rare batch). */ export function applyAppends( assembler: IncrementalChatAssembler, @@ -63,38 +64,48 @@ export function applyAppends( // size — some incoming id/turn collided with or superseded an existing entry, // which can change an existing entry's sort position. Fall back to re-sort. const grewByBatch = assembler.byId.size === sizeBefore + incoming.length - if (grewByBatch && isTailAppend(assembler.messages, incoming)) { - // Every incoming message is new and sorts at/after the tail: splice the - // batch in its own sorted order without touching the existing prefix. - const tail = [...incoming].sort(compareMessages) - assembler.messages = [...assembler.messages, ...tail] + if (grewByBatch && isTranscriptTailAppend(assembler.messages, incoming)) { + const deferredAt = assembler.messages.findIndex((message) => nativeChatMessageSortRank(message)) + const insertAt = deferredAt === -1 ? assembler.messages.length : deferredAt + assembler.messages = [ + ...assembler.messages.slice(0, insertAt), + ...incoming, + ...assembler.messages.slice(insertAt) + ] return assembler.messages } - assembler.messages = Array.from(assembler.byId.values()).sort(compareMessages) + assembler.messages = orderNativeChatMessages(Array.from(assembler.byId.values())) return assembler.messages } -/** True when the whole batch sorts strictly at/after the current last message - * AND is internally unambiguous to splice. A null timestamp in the batch sorts - * before any real timestamp, so it can never be a pure tail append — bail to - * the full re-sort. */ -function isTailAppend( +/** True when file-order semantics put the whole batch after current transcript content. */ +function isTranscriptTailAppend( current: readonly NativeChatMessage[], incoming: readonly NativeChatMessage[] ): boolean { - const last = current.at(-1) - if (!last) { - return true + if ( + !current.every( + (message) => + nativeChatMessageSortRank(message) > 0 || + (message.source === 'transcript' && nativeChatMessageSortRank(message) === 0) + ) + ) { + return false } + let latestTime = current.reduce( + (latest, message) => Math.max(latest, message.timestamp ?? Number.NEGATIVE_INFINITY), + Number.NEGATIVE_INFINITY + ) for (const message of incoming) { - // Null timestamp (sorts to the front) can never be a tail append. - if (message.timestamp === null) { + if (message.source !== 'transcript' || nativeChatMessageSortRank(message) !== 0) { return false } - if (compareMessages(message, last) < 0) { + const timestamp = message.timestamp ?? Number.NEGATIVE_INFINITY + if (!message.queued && timestamp < latestTime) { return false } + latestTime = Math.max(latestTime, timestamp) } return true } diff --git a/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts b/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts index 7df292d5ed93..46598092a4bd 100644 --- a/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts @@ -41,6 +41,24 @@ describe('orderNativeChatMessages', () => { ]) expect(ordered.map((m) => m.id)).toEqual(['real-user', 'streaming', 'pending:abc']) }) + + it('keeps a queued prompt after its transcript predecessor despite its older timestamp', () => { + const ordered = orderNativeChatMessages([ + msg({ id: 'z-predecessor', timestamp: 10 }), + msg({ id: 'a-queued', role: 'user', timestamp: 5, queued: true }), + msg({ id: 'final', timestamp: 20 }) + ]) + expect(ordered.map((message) => message.id)).toEqual(['z-predecessor', 'a-queued', 'final']) + }) + + it('keeps the legacy null-first rule outside queued ordering groups', () => { + const ordered = orderNativeChatMessages([ + msg({ id: 'finite', timestamp: 10 }), + msg({ id: 'ordinary-null', timestamp: null }), + msg({ id: 'queued-null', role: 'user', timestamp: null, queued: true }) + ]) + expect(ordered.map((message) => message.id)).toEqual(['ordinary-null', 'finite', 'queued-null']) + }) }) describe('buildNativeChatRenderItems', () => { @@ -53,6 +71,24 @@ describe('buildNativeChatRenderItems', () => { expect(items[0]?.kind).toBe('message') }) + it('renders the queued-prompt fixture in transcript order', () => { + const items = buildNativeChatRenderItems([ + msg({ + id: 'z-predecessor', + timestamp: 10, + blocks: [{ type: 'text', text: 'first reply' }] + }), + msg({ + id: 'a-queued', + role: 'user', + timestamp: 5, + queued: true, + blocks: [{ type: 'text', text: 'queued prompt' }] + }) + ]) + expect(items.map((item) => item.id)).toEqual(['z-predecessor', 'a-queued']) + }) + it('pairs a tool-call with its tool-result into one step', () => { const items = buildNativeChatRenderItems([ msg({ diff --git a/src/renderer/src/components/native-chat/native-chat-message-grouping.ts b/src/renderer/src/components/native-chat/native-chat-message-grouping.ts index 6d9ff9bf71ec..42f9a5cf553f 100644 --- a/src/renderer/src/components/native-chat/native-chat-message-grouping.ts +++ b/src/renderer/src/components/native-chat/native-chat-message-grouping.ts @@ -1,9 +1,7 @@ // Pure grouping logic for the native chat message list. Kept out of the .tsx so // the pairing/ordering rules are unit-testable without rendering. Two jobs: -// 1. Order messages stably (timestamp then id; null timestamps sort first as -// the shared model documents) — the assembler already sorts, but the list -// re-sorts defensively so a caller passing unordered fixtures still reads -// correctly. +// 1. Apply the shared stable order, including queued transcript position — +// the assembler already sorts, but the list re-sorts defensively. // 2. Within an assistant turn, pair each tool-call block with the tool-result // that answers it so the view can render one collapsible step instead of // two disconnected rows. @@ -16,7 +14,7 @@ import { type NativeChatToolCallBlock, type NativeChatToolResultBlock } from '../../../../shared/native-chat-types' -import { compareMessages } from './native-chat-session-assembler' +import { orderNativeChatMessages as orderMessages } from './native-chat-message-order' /** A tool-call block paired with the result that answered it, when one exists. * `result` is null while the call is still in flight (no result yet). */ @@ -45,11 +43,9 @@ export type NativeChatRenderItem = step: NativeChatToolStep } -/** Order messages stably: null timestamps first (model rule), then ascending - * timestamp, ties broken by id. Shares the assembler's comparator so both - * paths order identically. */ +/** Apply the same collection-level order used by the session assemblers. */ export function orderNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] { - return [...messages].sort(compareMessages) + return orderMessages(messages) } /** Collect every tool-result across the whole conversation in document order so diff --git a/src/renderer/src/components/native-chat/native-chat-message-order.ts b/src/renderer/src/components/native-chat/native-chat-message-order.ts new file mode 100644 index 000000000000..e71c3dbfa746 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-message-order.ts @@ -0,0 +1,68 @@ +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' +import { isLaunchPromptMessageId, isPendingMessageId } from './native-chat-pending' + +type MessageOrderKey = { + message: NativeChatMessage + rank: number + effectiveTime: number + inputIndex: number + transcriptIndex: number +} + +export function nativeChatMessageSortRank(message: NativeChatMessage): number { + if (message.id === NATIVE_CHAT_STREAMING_ID) { + return 1 + } + if (isPendingMessageId(message.id) || isLaunchPromptMessageId(message.id)) { + return 2 + } + return 0 +} + +// Claude timestamps queued prompts when they are sent but persists them after the active reply, +// so only those rows inherit the latest transcript time while their provider timestamp stays intact. +export function orderNativeChatMessages( + messages: readonly NativeChatMessage[] +): NativeChatMessage[] { + let latestTranscriptTime = Number.NEGATIVE_INFINITY + const keyed = messages.map((message, index): MessageOrderKey => { + const timestamp = message.timestamp ?? Number.NEGATIVE_INFINITY + const rank = nativeChatMessageSortRank(message) + if (message.source === 'transcript' && rank === 0) { + latestTranscriptTime = Math.max(latestTranscriptTime, timestamp) + } + return { + message, + rank, + effectiveTime: message.queued ? latestTranscriptTime : timestamp, + inputIndex: index, + transcriptIndex: Number.POSITIVE_INFINITY + } + }) + + const queuedTimes = new Set( + keyed.filter(({ message }) => message.queued).map(({ effectiveTime }) => effectiveTime) + ) + for (const key of keyed) { + if (key.message.source === 'transcript' && queuedTimes.has(key.effectiveTime)) { + key.transcriptIndex = key.inputIndex + } + } + + keyed.sort(compareMessageOrderKeys) + return keyed.map(({ message }) => message) +} + +function compareMessageOrderKeys(a: MessageOrderKey, b: MessageOrderKey): number { + if (a.rank !== b.rank) { + return a.rank - b.rank + } + if (a.effectiveTime !== b.effectiveTime) { + return a.effectiveTime - b.effectiveTime + } + if (a.transcriptIndex !== b.transcriptIndex) { + return a.transcriptIndex - b.transcriptIndex + } + return a.message.id.localeCompare(b.message.id) +} diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index 1773fb10e611..e8f2fb13a683 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -222,6 +222,20 @@ function advancedGlueTranscript(row: string): NativeChatMessage[] { } describe('glued rapid sends', () => { + it('hides and retires an echo when its queued transcript row lands', () => { + const pending = [gluePending('p1', 'queued prompt')] + const queued = { + ...userMessage('queued', 'queued prompt', GLUE_SENT_AT), + queued: true as const + } + const transcript = [GLUE_BOUNDARY, queued] + + expect(pendingSendsAsMessages(pending, transcript)).toEqual([]) + expect( + prunePendingSends(pending, [...transcript, assistantMessage('answer', 'done', 6000)]) + ).toEqual([]) + }) + it('retires both echoes when a lost Enter glued the pair into one row', () => { const pending = [gluePending('p1', 'tell me a joke'), gluePending('p2', 'continue')] diff --git a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts index 2b318554fc7a..0a8e58c05428 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts @@ -6,9 +6,8 @@ import { type NativeChatSession, type NativeChatSessionStatus } from '../../../../shared/native-chat-types' -import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' import { normalizeImageTranscriptMessages } from '../../../../shared/native-chat-image-transcript-markers' -import { isLaunchPromptMessageId, isPendingMessageId } from './native-chat-pending' +import { orderNativeChatMessages } from './native-chat-message-order' /** Messages grouped by source. Higher-priority sources (transcript > hook > * scrape) supersede lower ones when they describe the same turn. */ @@ -86,43 +85,6 @@ function supersedes(candidate: NativeChatMessage, existing: NativeChatMessage): return candidateRank > existingRank } -// Why: the tail bubbles form fixed tiers that timestamps alone can't express. -// The streaming preview (null timestamp) must follow real content but sit ahead -// of the optimistic composer echoes, which carry finite `sentAt` timestamps that -// would otherwise sort past it. Rank first, then timestamp within a tier. -function messageSortRank(message: NativeChatMessage): number { - if (message.id === NATIVE_CHAT_STREAMING_ID) { - return 1 - } - if (isPendingMessageId(message.id) || isLaunchPromptMessageId(message.id)) { - return 2 - } - return 0 -} - -// Why: null timestamps (sources that can't supply one, e.g. scrape segments) -// sort before any real timestamp within their tier so they don't jump to the -// end. Ties break on id for a stable, deterministic order. -export function compareMessages(a: NativeChatMessage, b: NativeChatMessage): number { - const ar = messageSortRank(a) - const br = messageSortRank(b) - if (ar !== br) { - return ar - br - } - const at = a.timestamp ?? Number.NEGATIVE_INFINITY - const bt = b.timestamp ?? Number.NEGATIVE_INFINITY - if (at !== bt) { - return at - bt - } - if (a.id < b.id) { - return -1 - } - if (a.id > b.id) { - return 1 - } - return 0 -} - /** * Pure merge of layered conversation sources into a single ordered, deduped * `NativeChatSession`. Precedence: transcript > hook > scrape. Dedup happens on @@ -151,7 +113,7 @@ export function assembleNativeChatSession( mergeOne(byId, byTurn, message) } - const messages = Array.from(byId.values()).sort(compareMessages) + const messages = orderNativeChatMessages(Array.from(byId.values())) const derivedStatus: NativeChatSessionStatus = messages.length === 0 ? 'empty' : 'ready' 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 1627f5e3e35f..9b3bfa832690 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 @@ -76,7 +76,13 @@ describe('getNativeChatSessionTransport — selection', () => { expect(runtimeEnvironmentsCall).toHaveBeenCalledWith({ selector: ENV, method: 'nativeChat.readSession', - params: { agent: 'claude', sessionId: 'sess-1', limit: 40, transcriptPath: '/t/path' }, + params: { + agent: 'claude', + sessionId: 'sess-1', + limit: 40, + transcriptPath: '/t/path', + preservesTranscriptOrder: true + }, timeoutMs: 15_000 }) expect(nativeChatReadSession).not.toHaveBeenCalled() @@ -183,7 +189,10 @@ describe('runtime subscribe', () => { expect(runtimeEnvironmentsSubscribe).toHaveBeenCalledWith( expect.objectContaining({ - params: expect.objectContaining({ capabilities: { transcriptPending: 1 } }) + params: expect.objectContaining({ + capabilities: { transcriptPending: 1 }, + preservesTranscriptOrder: true + }) }), expect.any(Object) ) 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 112e7cdc3e0a..c5fb9a6d5cea 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 @@ -57,7 +57,7 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess const result = await callRuntimeRpc( target, 'nativeChat.readSession', - { agent, sessionId, limit, transcriptPath }, + { agent, sessionId, limit, transcriptPath, preservesTranscriptOrder: true }, { timeoutMs: 15_000 } ) return parseRuntimeNativeChatReadSessionResult(result) @@ -111,7 +111,8 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess sessionId, transcriptPath, limit, - capabilities: { transcriptPending: 1 } + capabilities: { transcriptPending: 1 }, + preservesTranscriptOrder: true }, timeoutMs: 15_000 }, 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 746236330f9c..ea1a9d439abe 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 @@ -16,7 +16,8 @@ export function createWebNativeChatApi(): NativeChatApi { agent, sessionId, limit, - transcriptPath + transcriptPath, + preservesTranscriptOrder: true }) ), subscribe: (args, onFrame) => { @@ -46,7 +47,8 @@ export function createWebNativeChatApi(): NativeChatApi { subscriptionId: args.subscriptionId, transcriptPath: args.transcriptPath, limit: args.limit, - capabilities: { transcriptPending: 1 } + capabilities: { transcriptPending: 1 }, + preservesTranscriptOrder: true }, { onResponse: (response) => { diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 96d01f64181e..0dfe26e6ce7e 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -74,6 +74,8 @@ export type NativeChatMessage = { * supply one (e.g. some scrape segments). Null sorts before any timestamp. */ timestamp: number | null source: NativeChatSource + /** Claude persisted this prompt after the turn that was active when it was queued. */ + queued?: true /** Optional explicit turn key. When present, two messages with the same * `turnId` are treated as the same turn for dedup regardless of `id`. */ turnId?: string