diff --git a/src/main/native-chat/claude-transcript-record-admission.test.ts b/src/main/native-chat/claude-transcript-record-admission.test.ts
new file mode 100644
index 00000000000..f2dd67c27e8
--- /dev/null
+++ b/src/main/native-chat/claude-transcript-record-admission.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it } from 'vitest'
+import { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude'
+
+const decode = (record: unknown) => decodeClaudeTranscriptLine(JSON.stringify(record), 'fallback')
+const queued = (attachment: unknown) => ({ type: 'attachment', uuid: 'q1', attachment })
+
+describe('Claude semantic record admission', () => {
+ it('admits the persisted queued prompt with its provider identity and enqueue timestamp', () => {
+ expect(
+ decode({
+ ...queued({ type: 'queued_command', commandMode: 'prompt', prompt: 'check the config' }),
+ timestamp: '2026-06-01T10:00:02.000Z'
+ })
+ ).toEqual({
+ id: 'q1',
+ role: 'user',
+ blocks: [{ type: 'text', text: 'check the config' }],
+ timestamp: Date.parse('2026-06-01T10:00:02.000Z'),
+ source: 'transcript'
+ })
+ })
+
+ it.each([
+ null,
+ {},
+ { type: 'queued_command', prompt: 'missing mode' },
+ { type: 'queued_command', commandMode: 'task-notification', prompt: '' },
+ { type: 'queued_command', commandMode: 'prompt', prompt: ' ' },
+ { type: 'queued_command', commandMode: 'prompt', prompt: { text: 'not a prompt' } },
+ { type: 'other', commandMode: 'prompt', prompt: 'other attachment' }
+ ])('does not impersonate a human for a non-prompt attachment: %j', (attachment) => {
+ expect(decode(queued(attachment))).toBeNull()
+ })
+
+ it('admits informational login copy as a notice, never assistant speech', () => {
+ const text =
+ 'Remote Control disconnected — Please run `/login` in Claude Code to enroll this device.'
+ expect(
+ decode({ type: 'system', subtype: 'informational', uuid: 'notice', content: text })
+ ).toEqual({
+ id: 'notice',
+ role: 'system',
+ blocks: [{ type: 'text', text, tone: 'notice' }],
+ timestamp: null,
+ source: 'transcript'
+ })
+ })
+
+ it.each(['warning', 'error'])('preserves provider %s severity', (level) => {
+ expect(decode({ type: 'system', level, message: 'Check your account' })?.blocks).toEqual([
+ { type: 'text', text: 'Check your account', tone: level }
+ ])
+ })
+
+ it.each([
+ { content: [{ type: 'text', text: 'Future notice' }] },
+ { message: { content: [{ type: 'text', text: 'Future notice' }] } },
+ { message: { text: 'Future notice' } },
+ { text: 'Future notice' }
+ ])('keeps extractable copy on an unknown subtype: %j', (copy) => {
+ expect(decode({ type: 'system', subtype: 'future_notice', ...copy })?.blocks).toEqual([
+ { type: 'text', text: 'Future notice', tone: 'notice' }
+ ])
+ })
+
+ it.each([
+ 'stop_hook_summary',
+ 'turn_duration',
+ 'away_summary',
+ 'local_command',
+ 'hook_callback',
+ 'init',
+ 'compact_boundary'
+ ])('keeps %s bookkeeping out of the conversation', (subtype) => {
+ expect(decode({ type: 'system', subtype, content: 'Bookkeeping copy' })).toBeNull()
+ })
+
+ it('never dumps unknown payloads or turns tool payloads into system prose', () => {
+ expect(
+ decode({ type: 'system', subtype: 'future', payload: { opaque: 'not display copy' } })
+ ).toBeNull()
+ expect(
+ decode({ type: 'system', content: [{ type: 'tool_result', content: 'not a notice' }] })
+ ).toBeNull()
+ expect(decode({ type: 'telemetry', message: 'not display copy' })).toBeNull()
+ })
+
+ it('preserves synthetic tool results and interruption ownership', () => {
+ expect(
+ decode({
+ type: 'user',
+ isSynthetic: true,
+ message: {
+ content: [
+ { type: 'text', text: 'Injected context' },
+ { type: 'tool_result', content: 'Actual output' }
+ ]
+ }
+ })
+ ).toMatchObject({ role: 'tool', blocks: [{ type: 'tool-result', output: 'Actual output' }] })
+ expect(
+ decode({ type: 'user', interruptedMessageId: 'a1', message: { content: 'boilerplate' } })
+ ).toMatchObject({
+ role: 'system',
+ blocks: [{ type: 'text', text: 'Conversation interrupted' }]
+ })
+ })
+})
diff --git a/src/main/native-chat/claude-transcript-record-admission.ts b/src/main/native-chat/claude-transcript-record-admission.ts
new file mode 100644
index 00000000000..0fccf19da9f
--- /dev/null
+++ b/src/main/native-chat/claude-transcript-record-admission.ts
@@ -0,0 +1,63 @@
+import type { NativeChatTextBlock } from '../../shared/native-chat-types'
+import { asRecord, extractString } from '../ai-vault/session-scanner-values'
+import { readableProviderFrameText } from './agent-session-wire/unhandled-provider-frame'
+import { claudeContentBlocks } from './transcript-record-blocks'
+
+type ClaudeTranscriptRecordAdmission =
+ | { kind: 'message'; role: 'user' | 'assistant' }
+ | { kind: 'queued-prompt'; blocks: NativeChatTextBlock[] }
+ | { kind: 'notice'; blocks: NativeChatTextBlock[] }
+ | { kind: 'ignored' }
+
+// Persisted bookkeeping has different ownership from structured live frames.
+const BOOKKEEPING_SUBTYPES = new Set([
+ 'stop_hook_summary',
+ 'turn_duration',
+ 'away_summary',
+ 'local_command',
+ 'hook_callback',
+ 'init',
+ 'compact_boundary'
+])
+
+function queuedPrompt(record: Record): ClaudeTranscriptRecordAdmission {
+ const attachment = asRecord(record.attachment)
+ if (attachment?.type !== 'queued_command' || attachment.commandMode !== 'prompt') {
+ return { kind: 'ignored' }
+ }
+ const text = extractString(attachment.prompt)
+ return text ? { kind: 'queued-prompt', blocks: [{ type: 'text', text }] } : { kind: 'ignored' }
+}
+
+function systemNotice(record: Record): ClaudeTranscriptRecordAdmission {
+ if (BOOKKEEPING_SUBTYPES.has(String(record.subtype))) {
+ return { kind: 'ignored' }
+ }
+ const content = record.content ?? asRecord(record.message)?.content
+ const text =
+ claudeContentBlocks(content)
+ .flatMap((block) => (block.type === 'text' ? [block.text] : []))
+ .join('\n') || readableProviderFrameText(record)
+ if (!text?.trim()) {
+ return { kind: 'ignored' }
+ }
+ const tone = record.level === 'error' || record.level === 'warning' ? record.level : 'notice'
+ return { kind: 'notice', blocks: [{ type: 'text', text, tone }] }
+}
+
+/** Admit provider records by meaning before mapping them into chat roles. */
+export function classifyClaudeTranscriptRecord(
+ record: Record
+): ClaudeTranscriptRecordAdmission {
+ switch (record.type) {
+ case 'user':
+ case 'assistant':
+ return { kind: 'message', role: record.type }
+ case 'attachment':
+ return queuedPrompt(record)
+ case 'system':
+ return systemNotice(record)
+ default:
+ return { kind: 'ignored' }
+ }
+}
diff --git a/src/main/native-chat/transcript-line-decoders-claude.ts b/src/main/native-chat/transcript-line-decoders-claude.ts
index 5202035bbc6..f540a23ceb5 100644
--- a/src/main/native-chat/transcript-line-decoders-claude.ts
+++ b/src/main/native-chat/transcript-line-decoders-claude.ts
@@ -14,6 +14,7 @@ import {
timestampMs
} from '../ai-vault/session-scanner-values'
import { imageSourcePathFromText } from '../../shared/native-chat-image-transcript-markers'
+import { classifyClaudeTranscriptRecord } from './claude-transcript-record-admission'
import { claudeContentBlocks } from './transcript-record-blocks'
import { claudeInterruptedMessageId } from './transcript-turn-markers'
@@ -78,12 +79,22 @@ export function decodeClaudeTranscriptLine(
if (!record) {
return null
}
- const role = record.type
- if (role !== 'user' && role !== 'assistant') {
+ const admission = classifyClaudeTranscriptRecord(record)
+ if (admission.kind === 'ignored') {
return null
}
const timestamp = parseTimestamp(record.timestamp)
const recordMessageId = extractString(record.uuid) ?? fallbackId
+ if (admission.kind !== 'message') {
+ return {
+ id: recordMessageId,
+ role: admission.kind === 'queued-prompt' ? 'user' : 'system',
+ blocks: admission.blocks,
+ timestamp,
+ source: 'transcript'
+ }
+ }
+ const role = admission.role
if (claudeInterruptedMessageId(record)) {
// Why: keep Claude's injected boilerplate out of the user-bubble path while
// preserving the interruption as a quiet, replayable conversation status.
diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx
index ef4364b9cb1..35fbd84e77b 100644
--- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx
@@ -1,10 +1,11 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
-import { cleanup, render, screen } from '@testing-library/react'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AgentJournalItemBodySchema } from '../../../../shared/agent-session-journal-schemas'
import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection'
import type { AgentJournalStatusItem } from '../../../../shared/agent-session-journal-types'
+import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { MessageRow } from './NativeChatMessageRow'
afterEach(cleanup)
@@ -19,8 +20,45 @@ function renderStatus(body: AgentJournalStatusItem) {
}
describe('notice rows', () => {
+ it('renders a legacy notice with safe links and no assistant bubble', () => {
+ const message: NativeChatMessage = {
+ id: 'notice',
+ role: 'system',
+ source: 'transcript',
+ timestamp: null,
+ blocks: [
+ {
+ type: 'text',
+ tone: 'notice',
+ text: 'Please run `/login`. Read [enrollment](https://example.test/enroll). '
+ }
+ ]
+ }
+ const onLinkClick = vi.fn((event: React.MouseEvent) => event.preventDefault())
+ const { container } = render(
+
+ )
+ expect(screen.getByText('/login').tagName).toBe('CODE')
+ expect(screen.getByText('/login').closest('a')).toBeNull()
+ expect(container.querySelector('script')).toBeNull()
+ expect(container.querySelector('.bg-muted\\/20')).not.toBeNull()
+ const link = screen.getByRole('link', { name: 'enrollment' })
+ fireEvent.click(link)
+ expect(onLinkClick).toHaveBeenCalledWith(expect.anything(), 'https://example.test/enroll')
+ expect(container.querySelector('.rounded-tr-sm')).toBeNull()
+ })
+
it('renders compaction as a centered separator', () => {
- renderStatus({ kind: 'status', text: 'Context compacted', presentation: 'compaction' })
+ renderStatus({
+ kind: 'status',
+ text: 'Context compacted',
+ presentation: 'compaction'
+ })
expect(screen.getByRole('separator', { name: 'Context compacted' })).toHaveClass(
'text-muted-foreground'
)
@@ -34,7 +72,7 @@ describe('notice rows', () => {
['notice', 'text-muted-foreground']
])('renders %s using its existing color treatment', (tone, className) => {
renderStatus({ kind: 'status', text: 'Readable notice', tone })
- expect(screen.getByText('Readable notice').parentElement?.parentElement).toHaveClass(className)
+ expect(screen.getByText('Readable notice').closest('.space-y-2')).toHaveClass(className)
})
it('renders a plan as readable markdown in the card primitive', () => {
renderStatus({
@@ -78,7 +116,7 @@ describe('notice rows', () => {
tone: 'future-tone',
presentation: 'future-presentation'
})
- expect(screen.getByText('Future readable text').parentElement?.parentElement).toHaveClass(
+ expect(screen.getByText('Future readable text').closest('.space-y-2')).toHaveClass(
'text-foreground'
)
expect(screen.getByText('Future readable text').parentElement?.querySelector('svg')).toBeNull()
@@ -100,7 +138,11 @@ describe('old-reader compatibility', () => {
{ tone: 'notice' },
{ tone: 'future-tone', presentation: 'future-presentation' }
])('accepts new metadata and still renders text with an old reader: %j', (metadata) => {
- const body = { kind: 'status', text: 'Text survives version skew', ...metadata }
+ const body = {
+ kind: 'status',
+ text: 'Text survives version skew',
+ ...metadata
+ }
expect(AgentJournalItemBodySchema.safeParse(body).success).toBe(true)
const oldBody = oldStatusSchema.parse(body) as AgentJournalStatusItem
expect(oldBody).toEqual({ kind: 'status', text: body.text })
diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx
index 8b05f0d2d61..dc52bc8d211 100644
--- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx
@@ -73,7 +73,14 @@ export function NativeChatNoticeRow({
>
{Icon ?
: null}
-
{block.text}
+
+
+
{block.providerFrame ? (
{
+ if (root) {
+ await rm(root, { recursive: true, force: true })
+ }
+})
+
+describe('Claude admitted record replay', () => {
+ it.each(['\n', '\r\n'])(
+ 'agrees across full, bounded, append and reconnect reads with %j lines',
+ async (newline) => {
+ root = await mkdtemp(join(tmpdir(), 'orca-claude-admission-'))
+ const filePath = join(root, 'session.jsonl')
+ const initial = lines.slice(0, 2).join(newline) + newline
+ await writeFile(filePath, initial)
+ const base = await readNativeChatTranscriptTailFile(filePath, 10, decodeClaudeTranscriptLine)
+ const appended = lines.slice(2).join(newline) + newline
+ await appendFile(filePath, appended)
+ const incremental = await decodeTranscriptStream(
+ Readable.from([Buffer.from(appended)]),
+ filePath,
+ Buffer.byteLength(initial),
+ decodeClaudeTranscriptLine,
+ false
+ )
+ const merged = mergeNativeChatMessages(base.messages, incremental.messages)
+ const full = await readNativeChatTranscript('claude', 'synthetic', {
+ filePath
+ })
+ expect(full).toEqual({ messages: merged })
+ expect(merged.map((message) => message.id)).toEqual(['u1', 'a1', 'q1', 'a2'])
+ expect(merged[2]?.timestamp).toBe(Date.parse('2026-06-01T10:00:02.000Z'))
+ const tail = await readNativeChatTranscriptTailFile(filePath, 2, decodeClaudeTranscriptLine)
+ expect(tail.messages.map((message) => message.id)).toEqual(['q1', 'a2'])
+ const earlier = await readNativeChatTranscriptTailFile(
+ filePath,
+ 2,
+ decodeClaudeTranscriptLine,
+ false,
+ tail.beforeOffset
+ )
+ expect(mergeNativeChatMessages(earlier.messages, tail.messages)).toEqual(merged)
+ expect(mergeNativeChatMessages(merged, tail.messages)).toEqual(merged)
+ const pending = [
+ {
+ id: 'pending-q1',
+ text: 'and check the config while you are at it',
+ sentAt: Date.parse('2026-06-01T10:00:02.000Z'),
+ afterMessageId: 'u1'
+ }
+ ]
+ expect(pendingSendsAsMessages(pending, base.messages)).toHaveLength(1)
+ expect(pendingSendsAsMessages(pending, merged)).toEqual([])
+ expect(prunePendingSends(pending, merged)).toEqual([])
+ // Timestamp ordering is independently owned downstream and remains out of scope.
+ expect(orderNativeChatMessages(merged).map((message) => message.id)).toEqual([
+ 'u1',
+ 'q1',
+ 'a1',
+ 'a2'
+ ])
+ }
+ )
+
+ it('keeps fallback notice IDs stable between tail and full readers', async () => {
+ root = await mkdtemp(join(tmpdir(), 'orca-claude-notice-'))
+ const filePath = join(root, 'session.jsonl')
+ await writeFile(
+ filePath,
+ `${JSON.stringify({
+ type: 'system',
+ subtype: 'future_notice',
+ content: 'Please run /login'
+ })}\n`
+ )
+ const full = await readNativeChatTranscript('claude', 'synthetic', {
+ filePath
+ })
+ const tail = await readNativeChatTranscriptTailFile(filePath, 1, decodeClaudeTranscriptLine)
+ expect(tail.messages).toHaveLength(1)
+ expect(full).toEqual({ messages: tail.messages })
+ expect(mergeNativeChatMessages(tail.messages, tail.messages)).toHaveLength(1)
+ })
+})