Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions mobile/src/session/use-mobile-native-chat-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -161,7 +172,8 @@ describe('useMobileNativeChatSession', () => {
agent: 'claude',
sessionId: 'session',
limit: 60,
beforeOffset: 500
beforeOffset: 500,
preservesTranscriptOrder: true
})
}
)
Expand Down Expand Up @@ -239,7 +251,8 @@ describe('useMobileNativeChatSession', () => {
expect(sendRequest).toHaveBeenCalledWith('nativeChat.readSession', {
agent: 'claude',
sessionId: 'session',
limit: 100
limit: 100,
preservesTranscriptOrder: true
})
})

Expand Down Expand Up @@ -299,7 +312,8 @@ describe('useMobileNativeChatSession', () => {
expect(sendRequest).toHaveBeenCalledWith('nativeChat.readSession', {
agent: 'claude',
sessionId: 'session',
limit: 100
limit: 100,
preservesTranscriptOrder: true
})
})

Expand Down Expand Up @@ -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'])
})
Expand Down
2 changes: 2 additions & 0 deletions mobile/src/session/use-mobile-native-chat-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export function useMobileNativeChatSession(args: {
limit: limitRef.current,
subscriptionId: buildNativeChatSubscriptionId(agent, sessionId),
capabilities: { transcriptPending: 1 },
preservesTranscriptOrder: true,
...(transcriptPath ? { transcriptPath } : {})
},
(raw) => {
Expand Down Expand Up @@ -243,6 +244,7 @@ export function useMobileNativeChatSession(args: {
agent,
sessionId,
limit: beforeOffset === null ? nextLimit : pageLimit,
preservesTranscriptOrder: true,
...(beforeOffset === null ? {} : { beforeOffset }),
...(transcriptPath ? { transcriptPath } : {})
})
Expand Down
25 changes: 25 additions & 0 deletions src/main/native-chat/transcript-line-decoders-claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -65,6 +68,28 @@ export function decodeClaudeTranscriptLine(
}
}

function decodeQueuedPrompt(
record: Record<string, unknown>,
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(
Expand Down
39 changes: 39 additions & 0 deletions src/main/native-chat/transcript-line-decoders.claude.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude'

function attachment(overrides: Record<string, unknown> = {}): 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()
})
})
143 changes: 143 additions & 0 deletions src/main/native-chat/transcript-reader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,6 +31,68 @@ async function writeFixture(prefix: string, records: unknown[]): Promise<string>
}

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-', [
{
Expand All @@ -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.
Expand Down
16 changes: 13 additions & 3 deletions src/main/native-chat/transcript-tail-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -219,6 +228,7 @@ export async function readNativeChatTranscriptTail(
filePath?: string
limit: number
beforeOffset?: number
includeQueuedPrompts?: boolean
},
signal?: AbortSignal
): Promise<
Expand All @@ -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' }
Expand Down
1 change: 1 addition & 0 deletions src/main/native-chat/transcript-watch-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
2 changes: 1 addition & 1 deletion src/main/native-chat/transcript-watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ export async function subscribeNativeChatTranscript(
setupSignal?: AbortSignal
): Promise<NativeChatTranscriptSubscription> {
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.
Expand Down
Loading
Loading