fix(native-chat): classify persisted Claude prompts and notices

This commit is contained in:
Merge Sim
2026-09-07 18:45:53 -07:00
parent 102402e41e
commit fa32c9b8aa
6 changed files with 346 additions and 8 deletions
@@ -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: '<task-notification />' },
{ 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' }]
})
})
})
@@ -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<string, unknown>): 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<string, unknown>): 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<string, unknown>
): 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' }
}
}
@@ -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.
@@ -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). <script>bad()</script>'
}
]
}
const onLinkClick = vi.fn((event: React.MouseEvent<HTMLElement>) => event.preventDefault())
const { container } = render(
<MessageRow
message={message}
expandSignal={false}
onScrollMessageToTop={vi.fn()}
onLinkClick={onLinkClick}
/>
)
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 })
@@ -73,7 +73,14 @@ export function NativeChatNoticeRow({
>
<div className="flex items-start gap-2">
{Icon ? <Icon aria-hidden="true" className="mt-0.5 size-4 shrink-0" /> : null}
<p className="min-w-0 whitespace-pre-wrap break-words">{block.text}</p>
<div className="min-w-0 break-words">
<CommentMarkdown
content={block.text}
className="text-sm"
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
/>
</div>
</div>
{block.providerFrame ? (
<ProviderFrameRow
@@ -0,0 +1,107 @@
import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Readable } from 'node:stream'
import { afterEach, describe, expect, it } from 'vitest'
import { mergeNativeChatMessages } from '../../src/shared/native-chat-merge'
import {
pendingSendsAsMessages,
prunePendingSends
} from '../../src/renderer/src/components/native-chat/native-chat-pending'
import { orderNativeChatMessages } from '../../src/renderer/src/components/native-chat/native-chat-message-grouping'
import { decodeClaudeTranscriptLine } from '../../src/main/native-chat/transcript-line-decoders-claude'
import { readNativeChatTranscript } from '../../src/main/native-chat/transcript-reader'
import { readNativeChatTranscriptTailFile } from '../../src/main/native-chat/transcript-tail-reader'
import { decodeTranscriptStream } from '../../src/main/native-chat/transcript-stream-lines'
const lines = [
'{"type":"user","uuid":"u1","timestamp":"2026-06-01T10:00:00.000Z","message":{"role":"user","content":[{"type":"text","text":"start the long running task"}]}}',
'{"type":"assistant","uuid":"a1","timestamp":"2026-06-01T10:00:03.000Z","message":{"content":[{"type":"text","text":"working through the first task now"}]}}',
'{"type":"attachment","uuid":"q1","timestamp":"2026-06-01T10:00:02.000Z","attachment":{"type":"queued_command","prompt":"and check the config while you are at it","commandMode":"prompt"}}',
'{"type":"assistant","uuid":"a2","timestamp":"2026-06-01T10:00:06.000Z","message":{"content":[{"type":"text","text":"both done"}]}}'
]
let root: string
afterEach(async () => {
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)
})
})