From fa32c9b8aae4545dfbd0bb8a16bec26ef51ce3cd Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Mon, 7 Sep 2026 18:45:53 -0700 Subject: [PATCH] fix(native-chat): classify persisted Claude prompts and notices --- ...claude-transcript-record-admission.test.ts | 108 ++++++++++++++++++ .../claude-transcript-record-admission.ts | 63 ++++++++++ .../transcript-line-decoders-claude.ts | 15 ++- .../native-chat/NativeChatNoticeRow.test.tsx | 52 ++++++++- .../native-chat/NativeChatNoticeRow.tsx | 9 +- ...aude-transcript-record-replay.unit.test.ts | 107 +++++++++++++++++ 6 files changed, 346 insertions(+), 8 deletions(-) create mode 100644 src/main/native-chat/claude-transcript-record-admission.test.ts create mode 100644 src/main/native-chat/claude-transcript-record-admission.ts create mode 100644 tests/e2e/claude-transcript-record-replay.unit.test.ts 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 ?
{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) + }) +})