mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
fix(native-chat): classify persisted Claude prompts and notices
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user