diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 4bc75a5a132..6d4e17066c8 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -9,8 +9,3 @@ inline src/main/ssh/ssh-relay-deploy.ts inline src/main/ssh/ssh-relay-session.ts inline src/relay/pty-handler.ts inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts -mobile-config app/h/*/files/*.tsx -mobile-config app/h/*/source-control/*.tsx -mobile-config app/index.tsx -mobile-config scripts/mock-server.ts -mobile-config src/transport/rpc-client.ts diff --git a/mobile/.oxlintrc.json b/mobile/.oxlintrc.json index d58682609a3..aa73bfe8583 100644 --- a/mobile/.oxlintrc.json +++ b/mobile/.oxlintrc.json @@ -20,36 +20,6 @@ "rules": { "max-lines": ["error", { "max": 379, "skipBlankLines": true, "skipComments": true }] } - }, - { - "files": ["app/h/*/source-control/*.tsx"], - "rules": { - "max-lines": ["error", { "max": 2152, "skipBlankLines": true, "skipComments": true }] - } - }, - { - "files": ["app/index.tsx"], - "rules": { - "max-lines": ["error", { "max": 1422, "skipBlankLines": true, "skipComments": true }] - } - }, - { - "files": ["src/transport/rpc-client.ts"], - "rules": { - "max-lines": ["error", { "max": 1074, "skipBlankLines": true, "skipComments": true }] - } - }, - { - "files": ["scripts/mock-server.ts"], - "rules": { - "max-lines": ["error", { "max": 407, "skipBlankLines": true, "skipComments": true }] - } - }, - { - "files": ["app/h/*/files/*.tsx"], - "rules": { - "max-lines": ["error", { "max": 402, "skipBlankLines": true, "skipComments": true }] - } } ] } diff --git a/src/main/codex/codex-image-item-translation.ts b/src/main/codex/codex-image-item-translation.ts new file mode 100644 index 00000000000..7d5e70a0c51 --- /dev/null +++ b/src/main/codex/codex-image-item-translation.ts @@ -0,0 +1,61 @@ +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { NativeChatBlock } from '../../shared/native-chat-types' +import { buildImageDataUri } from '../../shared/image-data-uri' +import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds' +import { readString } from './codex-item-field-readers' +import type { CodexThreadItem } from './codex-thread-item-identity' + +// Leave room for operation text and the journal envelope beside inline image bytes. +const MAX_IMAGE_REFERENCE_BYTES = DEFAULT_JOURNAL_PAYLOAD_LIMITS.inlineHeadBytes / 2 + +function imagePath(item: CodexThreadItem, key: string): string | null { + const value = readString(item, key) + return value?.trim() && Buffer.byteLength(value, 'utf8') <= MAX_IMAGE_REFERENCE_BYTES + ? value + : null +} + +function generatedImageUrl(item: CodexThreadItem): string | null { + const result = readString(item, 'result') + if (!result || result.length > MAX_IMAGE_REFERENCE_BYTES) { + return null + } + const match = /^data:(image\/(?:png|jpeg|webp));base64,(.*)$/s.exec(result) + const base64 = (match?.[2] ?? result).replace(/\s/g, '') + if (!base64 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(base64)) { + return null + } + const url = buildImageDataUri(match?.[1] ?? 'image/png', base64) + return url && url.length <= MAX_IMAGE_REFERENCE_BYTES ? url : null +} + +export function codexImageItemBody(item: CodexThreadItem): AgentJournalMessageItem { + let image: Extract | null = null + let text: string + if (item.type === 'imageView') { + const path = imagePath(item, 'path') + text = path ? 'Viewed image' : 'Image view: preview unavailable' + image = path ? { type: 'image-ref', path } : null + } else { + const status = readString(item, 'status') + if (item.failure || status === 'failed') { + text = 'Image generation failed' + } else if (status !== 'completed') { + text = status === 'inProgress' ? 'Generating image…' : 'Image generation: preview unavailable' + } else { + const path = imagePath(item, 'savedPath') + const url = path ? null : generatedImageUrl(item) + image = path + ? { type: 'image-ref', path } + : url + ? { type: 'image-ref', url, alt: 'Generated image' } + : null + text = image ? 'Generated image' : 'Image generated: preview unavailable' + } + } + return { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text }, ...(image ? [image] : [])] + } +} diff --git a/src/main/codex/codex-notice-item-translation.test.ts b/src/main/codex/codex-notice-item-translation.test.ts new file mode 100644 index 00000000000..15f638c4015 --- /dev/null +++ b/src/main/codex/codex-notice-item-translation.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { codexItemBody, codexStreamingJournalItem } from './codex-structured-item-translation' +import { AgentJournalItemBodySchema } from '../../shared/agent-session-journal-schemas' +import { projectStructuredItemsToNativeChat } from '../../shared/structured-agent-session-projection' + +describe('plan document translation', () => { + it('marks both complete documents and streaming snapshots', () => { + const item = { id: 'plan-1', type: 'plan', text: '# Plan\n\nReadable prose.' } + expect(codexItemBody(item)).toEqual({ + kind: 'status', + text: item.text, + presentation: 'plan-document' + }) + expect(codexStreamingJournalItem(item, '# Plan\n\nPartial').body).toEqual({ + kind: 'status', + text: '# Plan\n\nPartial', + presentation: 'plan-document' + }) + expect(codexItemBody({ id: 'plan-1', type: 'plan' })).toBeNull() + }) + it('preserves the full existing reasoning body byte for byte', () => { + expect( + codexItemBody({ id: 'r', type: 'reasoning', summary: ['Thinking through the problem.'] }) + ).toEqual({ + kind: 'status', + text: 'Thinking through the problem.' + }) + expect(codexStreamingJournalItem({ id: 'r', type: 'reasoning' }, 'Thinking…')).toEqual({ + body: { kind: 'status', text: 'Thinking…' }, + handled: true + }) + }) +}) + +describe('image item translation', () => { + it.each(['/remote/work/image.png', 'C:\\work\\image.png'])( + 'preserves the execution-host path %s and old-reader operation text', + (path) => { + expect(codexItemBody({ id: 'view', type: 'imageView', path })).toEqual({ + kind: 'message', + role: 'assistant', + blocks: [ + { type: 'text', text: 'Viewed image' }, + { type: 'image-ref', path } + ] + }) + } + ) + it('prefers saved paths over unbounded inline image data', () => { + const body = codexItemBody({ + id: 'gen', + type: 'imageGeneration', + status: 'completed', + savedPath: '/remote/image.png', + result: 'A'.repeat(100_000) + }) + expect(body).toEqual({ + kind: 'message', + role: 'assistant', + blocks: [ + { type: 'text', text: 'Generated image' }, + { type: 'image-ref', path: '/remote/image.png' } + ] + }) + expect(AgentJournalItemBodySchema.safeParse(body).success).toBe(true) + const [message] = projectStructuredItemsToNativeChat([ + { itemId: 'gen', revision: 1, sequence: 1, observedAt: 1, body: body! } + ]) + expect(message?.blocks).toEqual(body?.kind === 'message' ? body.blocks : []) + }) + it.each(['AAAA', 'data:image/png;base64,AAAA'])( + 'maps bounded image data onto a meaningful existing image-ref: %s', + (result) => { + expect( + codexItemBody({ id: 'gen', type: 'imageGeneration', status: 'completed', result }) + ).toMatchObject({ + kind: 'message', + role: 'assistant', + blocks: [ + { type: 'text', text: 'Generated image' }, + { type: 'image-ref', url: 'data:image/png;base64,AAAA', alt: 'Generated image' } + ] + }) + } + ) + it.each(['A'.repeat(20_000), 'not valid image bytes', 'data:text/html;base64,AAAA', ''])( + 'keeps unavailable results bounded and readable', + (result) => { + const body = codexItemBody({ + id: 'gen', + type: 'imageGeneration', + status: 'completed', + result + }) + expect(body).toEqual({ + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'Image generated: preview unavailable' }] + }) + } + ) + it.each([ + [{ status: 'inProgress' }, 'Generating image…'], + [{ status: 'failed' }, 'Image generation failed'], + [{ status: 'completed', failure: { type: 'usageLimitExceeded' } }, 'Image generation failed'], + [{ status: 'future-state' }, 'Image generation: preview unavailable'] + ])('does not invent completed output for %j', (fields, text) => { + expect(codexItemBody({ id: 'gen', type: 'imageGeneration', ...fields })).toEqual({ + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text }] + }) + }) +}) diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index fca66257636..576f3fb19ec 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -7,6 +7,7 @@ import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds' import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' +import { codexImageItemBody } from './codex-image-item-translation' import { commandActionFacts } from './codex-command-action-class' import { readFirstString, @@ -250,6 +251,23 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem { if (item.type === 'webSearch') { return webSearchItem(item) } + if (item.type === 'imageView' || item.type === 'imageGeneration') { + return { body: codexImageItemBody(item), handled: true } + } + if (item.type === 'plan') { + const text = readTextContent(item, 'text') + return { + body: + text === null + ? null + : { + kind: 'status', + text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text, + presentation: 'plan-document' + }, + handled: true + } + } if (item.type === 'reasoning' || item.type === 'plan') { const text = readTextContent(item, 'text') ?? @@ -298,6 +316,16 @@ export function codexStreamingJournalItem(item: CodexThreadItem, text: string): handled: true } } + if (item.type === 'plan') { + return { + body: { + kind: 'status', + text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text, + presentation: 'plan-document' + }, + handled: true + } + } const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS) return { body: { kind: 'status', text: bounded.text }, handled: true } } diff --git a/src/main/codex/codex-structured-journal-compactions.test.ts b/src/main/codex/codex-structured-journal-compactions.test.ts new file mode 100644 index 00000000000..b86cea2725d --- /dev/null +++ b/src/main/codex/codex-structured-journal-compactions.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { projectStructuredItemsToNativeChat } from '../../shared/structured-agent-session-projection' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + createCodexJournalTranslator, + MAX_CODEX_GENERIC_TURN_BUCKETS +} from './codex-structured-journal-translation' + +function setup() { + const rows = new Map() + let writes = 0 + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => { + writes += 1 + rows.set(agentJournalItemKey(identity), body) + }, + appendTombstone: (identity) => rows.delete(agentJournalItemKey(identity)), + publish: () => {} + } + const translator = createCodexJournalTranslator({ sink }) + const send = (method: string, turnId = 'turn', threadId = 'thread') => + translator.handle({ + type: 'notification', + sessionId: 'session', + threadId, + method, + params: { turnId, item: { id: `compact-${turnId}`, type: 'contextCompaction' } } + }) + return { rows, sink, translator, send, writes: () => writes } +} + +describe('compaction provider generation compatibility', () => { + it.each([ + ['item/completed'], + ['thread/compacted'], + ['item/completed', 'thread/compacted'], + ['thread/compacted', 'item/completed'] + ])('projects one readable divider for %j', (...methods) => { + const { rows, translator, send } = setup() + expect(send('item/started')).toEqual({ accepted: true }) + expect(rows.size).toBe(0) + for (const method of methods) { + expect(send(method)).toEqual({ accepted: true }) + } + const messages = projectStructuredItemsToNativeChat( + [...rows].map(([itemId, body], index) => ({ + itemId, + body, + sequence: index + 1, + revision: 1, + observedAt: 1 + })) + ) + expect(messages).toHaveLength(1) + expect(messages[0]?.blocks).toEqual([ + { type: 'text', text: 'Context compacted', presentation: 'compaction' } + ]) + translator.dispose() + }) + + it('keeps the canonical item authoritative and scopes deduplication by thread and turn', () => { + const { rows, translator, send, writes } = setup() + send('thread/compacted') + send('item/completed') + expect(writes()).toBe(2) + send('thread/compacted') + send('item/completed') + expect(writes()).toBe(2) + send('item/completed', 'turn-2') + send('item/completed', 'turn', 'other-thread') + expect(rows.size).toBe(3) + translator.dispose() + }) + + it.each(['append', 'publish'])('does not suppress the retry after rejected %s', (stage) => { + const { rows, sink, translator, send } = setup() + let reject = true + let publishes = 0 + sink.tryAppendItem = (identity, body) => { + if (stage === 'append' && reject) { + return { accepted: false, reason: 'backpressure' } + } + sink.appendItem(identity, body) + return { accepted: true } + } + sink.tryPublish = () => { + publishes += 1 + return stage === 'publish' && reject + ? { accepted: false, reason: 'backpressure' } + : { accepted: true } + } + expect(send('item/completed')).toEqual({ accepted: false, reason: 'backpressure' }) + reject = false + expect(send('item/completed')).toEqual({ accepted: true }) + expect(rows.size).toBe(1) + expect(publishes).toBe(stage === 'publish' ? 2 : 1) + translator.dispose() + }) + + it('bounds retained turns and keeps the same journal identity after eviction', () => { + const { rows, translator, send, writes } = setup() + send('item/completed', 'oldest') + for (let index = 0; index < MAX_CODEX_GENERIC_TURN_BUCKETS; index += 1) { + send('item/completed', `turn-${index}`) + } + const before = writes() + send('thread/compacted', 'oldest') + expect(writes()).toBe(before + 1) + expect(rows.size).toBe(MAX_CODEX_GENERIC_TURN_BUCKETS + 1) + translator.dispose() + }) + + it('restores canonical compaction history over a previously journaled legacy fallback', () => { + const { rows, sink, translator, send } = setup() + send('thread/compacted') + translator.dispose() + const restored = createCodexJournalTranslator({ sink }) + expect( + restored.restoreThread('thread', { + turns: [{ id: 'turn', items: [{ id: 'renumbered', type: 'contextCompaction' }] }] + }) + ).toEqual({ accepted: true }) + expect(rows.size).toBe(1) + expect([...rows.values()][0]).toEqual({ + kind: 'status', + text: 'Context compacted', + presentation: 'compaction' + }) + restored.dispose() + }) +}) diff --git a/src/main/codex/codex-structured-journal-compactions.ts b/src/main/codex/codex-structured-journal-compactions.ts new file mode 100644 index 00000000000..d2d6aeca464 --- /dev/null +++ b/src/main/codex/codex-structured-journal-compactions.ts @@ -0,0 +1,66 @@ +import { createHash } from 'node:crypto' +import { isCodexCompactionComplete } from '../native-chat/agent-session-wire/structured-session-compaction' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + CODEX_JOURNAL_ADMITTED, + type CodexJournalTranslationAdmission +} from './codex-structured-journal-contracts' +import { MAX_CODEX_GENERIC_TURN_BUCKETS } from './codex-structured-journal-limits' +import { appendCodexLifecycleItem, publishCodexLifecycle } from './codex-structured-journal-sink' +import { readCodexTurnId } from './codex-structured-thread-facts' + +export class CodexJournalCompactions { + private readonly turns = new Map() + + constructor( + private readonly sink: StructuredAgentSessionEventSink, + private readonly activeTurn: (threadId: string) => string | null + ) {} + + handle(event: { + threadId: string + method: string + params: unknown + }): CodexJournalTranslationAdmission | null { + if (!isCodexCompactionComplete(event.method, event.params)) { + return null + } + const turnId = readCodexTurnId(event.params) ?? this.activeTurn(event.threadId) + if (!turnId) { + return null + } + // Collapse compactions within a thread/turn; the canonical item replaces its legacy fallback. + const key = createHash('sha256') + .update(JSON.stringify([event.threadId, turnId])) + .digest('hex') + const source = event.method === 'item/completed' ? 'item' : 'legacy' + const previous = this.turns.get(key) + if (previous === 'item' || previous === source) { + return CODEX_JOURNAL_ADMITTED + } + const admission = appendCodexLifecycleItem( + this.sink, + { provider: 'orca', clientMessageId: `codex-compaction:${key}` }, + { kind: 'status', text: 'Context compacted', presentation: 'compaction' } + ) + if (!admission.accepted) { + return admission + } + const published = publishCodexLifecycle(this.sink) + if (!published.accepted) { + return published + } + this.turns.set(key, source) + while (this.turns.size > MAX_CODEX_GENERIC_TURN_BUCKETS) { + const oldest = this.turns.keys().next().value + if (oldest !== undefined) { + this.turns.delete(oldest) + } + } + return CODEX_JOURNAL_ADMITTED + } + + clear(): void { + this.turns.clear() + } +} diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts index fd8fbf558a8..f984bc1d9bf 100644 --- a/src/main/codex/codex-structured-journal-items.ts +++ b/src/main/codex/codex-structured-journal-items.ts @@ -78,6 +78,9 @@ export class CodexJournalItems { if (source === 'live' && item.type === 'userMessage') { return { handled: true, admission: CODEX_JOURNAL_ADMITTED } } + if (item.type === 'contextCompaction' && event.method === 'item/started') { + return { handled: true, admission: CODEX_JOURNAL_ADMITTED } + } const translated = codexJournalItem(item) const command = readCodexJournalString(item, 'command') if (command) { diff --git a/src/main/codex/codex-structured-journal-translation-streams.test.ts b/src/main/codex/codex-structured-journal-translation-streams.test.ts index 86eb94fef07..ea01f88e4c9 100644 --- a/src/main/codex/codex-structured-journal-translation-streams.test.ts +++ b/src/main/codex/codex-structured-journal-translation-streams.test.ts @@ -603,3 +603,68 @@ describe('codex journal translation', () => { expect(tap.rows).toEqual([]) }) }) + +describe('notice journal pipeline', () => { + it('replaces a legacy compaction divider with its canonical item at the same journal key', () => { + const { translator, tap } = translatorWith() + translator.handle(notification('thread/compacted', { threadId: THREAD_ID, turnId: TURN_ID })) + translator.handle( + notification('item/completed', { + turnId: TURN_ID, + item: { id: 'compact', type: 'contextCompaction' } + }) + ) + expect(tap.rows).toHaveLength(2) + expect([...new Map(tap.rows.map((row) => [row.key, row.body])).values()]).toEqual([ + expect.objectContaining({ + kind: 'status', + text: 'Context compacted', + presentation: 'compaction' + }) + ]) + translator.dispose() + }) + it('preserves every notice after generic traffic reaches its cap', () => { + const { translator, tap, window } = translatorWith() + translator.handle(TURN_STARTED) + for (let index = 0; index < MAX_CODEX_GENERIC_ROWS_PER_TURN; index += 1) { + translator.handle(notification('future/notification', { value: index })) + } + for (const method of ['warning', 'guardianWarning', 'configWarning', 'deprecationNotice']) { + translator.handle(notification(method, { message: method, summary: method })) + } + window.fire() + expect(tap.rows.slice(-4).map((row) => row.body)).toEqual([ + expect.objectContaining({ text: 'warning', tone: 'warning' }), + expect.objectContaining({ text: 'guardianWarning', tone: 'warning' }), + expect.objectContaining({ text: 'configWarning', tone: 'warning' }), + expect.objectContaining({ text: 'deprecationNotice', tone: 'notice' }) + ]) + translator.dispose() + }) + it('keeps the plan document marker during streamed updates and completion', () => { + const { translator, tap, window } = translatorWith() + translator.handle( + notification('item/started', { + turnId: TURN_ID, + item: { id: 'plan', type: 'plan', text: '' } + }) + ) + translator.handle( + notification('item/plan/delta', { turnId: TURN_ID, itemId: 'plan', delta: '# Plan' }) + ) + window.fire() + expect(tap.rows.at(-1)?.body).toMatchObject({ text: '# Plan', presentation: 'plan-document' }) + translator.handle( + notification('item/completed', { + turnId: TURN_ID, + item: { id: 'plan', type: 'plan', text: '# Plan\n\nComplete' } + }) + ) + expect(tap.rows.at(-1)?.body).toMatchObject({ + text: '# Plan\n\nComplete', + presentation: 'plan-document' + }) + translator.dispose() + }) +}) diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index c8a6fe9f158..00b90d7ffc8 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -6,6 +6,7 @@ import { import { CodexSubagentRoster } from './codex-subagent-roster' import { readCodexThreadItem } from './codex-structured-item-translation' import { CodexJournalGenericFrames } from './codex-structured-journal-generic-frames' +import { CodexJournalCompactions } from './codex-structured-journal-compactions' import { CodexJournalItems } from './codex-structured-journal-items' import { CodexJournalPrompts } from './codex-structured-journal-prompts' import { @@ -46,6 +47,9 @@ export function createCodexJournalTranslator( deps: CodexJournalTranslatorDeps ): CodexJournalTranslator { const activeTurns = new CodexJournalActiveTurns() + const compactions = new CodexJournalCompactions(deps.sink, (threadId) => + activeTurns.current(threadId) + ) const genericFrames = new CodexJournalGenericFrames(deps, (threadId) => activeTurns.current(threadId) ) @@ -94,6 +98,10 @@ export function createCodexJournalTranslator( currentTurnIds: activeTurns.byThread, ordinals: items.ordinals, handleItem: (event) => { + const compaction = compactions.handle(event) + if (compaction) { + return compaction + } const translated = items.handle(event, 'history') return translated.handled ? translated.admission @@ -135,6 +143,7 @@ export function createCodexJournalTranslator( items.activeItems.clear() prompts.pending.clear() activeTurns.clear() + compactions.clear() return CODEX_JOURNAL_ADMITTED } if (event.type === 'notification') { @@ -168,6 +177,10 @@ export function createCodexJournalTranslator( if (event.method === 'turn/started') { return startTurn(event) } + const compaction = compactions.handle(event) + if (compaction) { + return publishActivity(event, compaction) + } if (event.method === 'turn/completed') { return completeTurn(event) } @@ -223,17 +236,15 @@ export function createCodexJournalTranslator( genericFrames.dispose() subagents.dispose() activeTurns.clear() + compactions.clear() } } /** Settles the item a notification the transport refused to carry left * mid-flight; null when the frame is not one. */ - function settleOversizedNotification(event: { - sessionId: string - threadId: string - kind: string - payload: unknown - }): CodexJournalTranslationAdmission | null { + function settleOversizedNotification( + event: Extract + ): CodexJournalTranslationAdmission | null { return settleCodexOversizedNotificationFrame({ ...event, sink: deps.sink, @@ -242,11 +253,9 @@ export function createCodexJournalTranslator( }) } - function startTurn(event: { - sessionId: string - threadId: string - params: unknown - }): CodexJournalTranslationAdmission { + function startTurn( + event: Extract + ): CodexJournalTranslationAdmission { const turnId = readCodexTurnId(event.params) if (!turnId) { return CODEX_JOURNAL_ADMITTED diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts index d4726a9b602..a57aa5d9ed1 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts @@ -104,11 +104,12 @@ describe('provider frame classification catalog', () => { }) it('dispositions codex item-form frames, which the method catalog never matches', () => { - // `thread/compacted` is already chrome; its item form is the same event and - // must not leak `codex · item:contextCompaction` into the transcript. - expect(classifyProviderFrame('codex', 'item:contextCompaction', {})).toBe('status-chrome') + // Both provider generations reach the journal's compaction deduplication. + expect(classifyProviderFrame('codex', 'item:contextCompaction', {})).toBe( + 'timeline-substantive' + ) expect(classifyProviderFrame('codex', 'notification:thread/compacted', {})).toBe( - 'status-chrome' + 'timeline-substantive' ) // An item type nobody has dispositioned still falls through visibly. expect(classifyProviderFrame('codex', 'item:futureThing', {})).toBe('timeline-substantive') @@ -165,6 +166,21 @@ describe('provider frame classification catalog', () => { }) }) +describe('notice disposition boundaries', () => { + it.each(['warning', 'guardianWarning', 'deprecationNotice', 'configWarning'])( + 'retains the error-surface cap exemption for %s', + (method) => { + expect(classifyProviderFrame('codex', `notification:${method}`, {})).toBe('error-surface') + } + ) + it('does not change usage or rate-limit classifications', () => { + expect(classifyProviderFrame('codex', 'thread/tokenUsage/updated', {})).toBe('status-chrome') + expect(classifyProviderFrame('codex', 'account/rateLimits/updated', {})).toBe( + 'suppressed-benign' + ) + }) +}) + describe('codex subagent item disposition', () => { it('keeps subagent lifecycle out of the transcript now that it renders as a roster row', () => { expect( diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts index 35223a1971f..d1bc7d0e7d3 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -66,7 +66,7 @@ export const PROVIDER_FRAME_CLASSIFICATIONS = { 'item/reasoning/summaryTextDelta': 'stream-into-item', 'item/reasoning/summaryPartAdded': 'stream-into-item', 'item/reasoning/textDelta': 'stream-into-item', - 'thread/compacted': 'status-chrome', + 'thread/compacted': 'timeline-substantive', 'model/rerouted': 'status-chrome', 'model/verification': 'status-chrome', 'turn/moderationMetadata': 'suppressed-benign', @@ -196,9 +196,8 @@ function hasProviderError(payload: unknown): boolean { * new item type cannot leak `codex · item:` into the transcript. The * notification catalog above is keyed by METHOD and never matches these. */ const CODEX_ITEM_CLASSIFICATIONS: Record = { - // The `thread/compacted` notification is already chrome; its item form is the - // same event and must not read as a mysterious opcode row. - contextCompaction: 'status-chrome', + // The journal coalesces this canonical completion with the legacy notification. + contextCompaction: 'timeline-substantive', // Subagent lifecycle renders as the spawn-group roster row, so its raw items // must not print a gray `codex · item:` row beside it. The live // notification path intercepts them before this catalog is reached; diff --git a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts b/src/main/native-chat/agent-session-wire/structured-session-compaction.ts index 69d5bfffc14..328c283dc11 100644 --- a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts +++ b/src/main/native-chat/agent-session-wire/structured-session-compaction.ts @@ -11,6 +11,13 @@ function record(value: unknown): Record { return value && typeof value === 'object' ? (value as Record) : {} } +export function isCodexCompactionComplete(method: string, params: unknown): boolean { + return ( + method === 'thread/compacted' || + (method === 'item/completed' && record(record(params).item).type === 'contextCompaction') + ) +} + /** A receipt is not completion; keep listening through the provider's terminal frame. */ export class StructuredSessionCompaction { private readonly pending = new Map() @@ -95,10 +102,7 @@ export class StructuredSessionCompaction { if (method === 'turn/started' && typeof turn.id === 'string') { pending.turnId = turn.id } - if ( - method === 'thread/compacted' || - (method === 'item/completed' && record(params.item).type === 'contextCompaction') - ) { + if (isCodexCompactionComplete(method, params)) { pending.compacted = true } if (method === 'turn/completed' && turn.id === pending.turnId) { diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts index e389b30aba2..61544fd56ab 100644 --- a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts @@ -235,3 +235,41 @@ describe('a failed provider dependency', () => { ).toBeNull() }) }) + +describe('typed notice metadata', () => { + it('publishes readable compaction statuses for both provider forms', () => { + expect( + unhandledProviderFrameJournalItem('codex', 'notification:thread/compacted', {}) + ).toMatchObject({ + classification: 'timeline-substantive', + body: { kind: 'status', text: 'Context compacted', presentation: 'compaction' } + }) + expect(unhandledProviderFrameJournalItem('codex', 'item:contextCompaction', {})).toMatchObject({ + body: { kind: 'status', text: 'Context compacted', presentation: 'compaction' } + }) + }) + it.each([ + ['warning', { message: 'Check this' }, 'warning', 'Check this'], + ['guardianWarning', { message: 'Review required' }, 'warning', 'Review required'], + [ + 'configWarning', + { summary: 'Invalid option', details: 'Remove the option' }, + 'warning', + 'Invalid option\n\nRemove the option' + ], + [ + 'deprecationNotice', + { summary: 'Old option', details: 'Use its replacement' }, + 'notice', + 'Old option\n\nUse its replacement' + ], + ['error', { error: { message: 'Connection failed' } }, 'error', 'Connection failed'] + ])('assigns the tone and readable text for %s', (method, payload, tone, text) => { + expect( + unhandledProviderFrameJournalItem('codex', `notification:${method}`, payload) + ).toMatchObject({ + classification: 'error-surface', + body: { kind: 'status', text, tone } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts index 60f34707ac9..272e960b838 100644 --- a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts @@ -90,12 +90,38 @@ export function unhandledProviderFrameJournalItem( // Why: the opcode alone ("codex · notification:warning") tells the user nothing // and reads as protocol noise. Lead with the provider's own sentence when it has // one; the raw frame stays behind the row's disclosure either way. - const message = readableProviderFrameText(payload) + const method = kind.startsWith('notification:') ? kind.slice('notification:'.length) : kind + const compaction = + provider === 'codex' && (method === 'thread/compacted' || method === 'item:contextCompaction') + const noticeTone = + provider === 'codex' + ? method === 'deprecationNotice' + ? 'notice' + : ['warning', 'guardianWarning', 'configWarning'].includes(method) + ? 'warning' + : undefined + : undefined + const tone = noticeTone ?? (classification === 'error-surface' ? 'error' : undefined) + let message = readableProviderFrameText(payload) + if ( + provider === 'codex' && + (method === 'configWarning' || method === 'deprecationNotice') && + typeof payload === 'object' && + payload !== null + ) { + const record = payload as Record + message = + [record.summary, record.details] + .filter((part): part is string => typeof part === 'string' && part.trim().length > 0) + .join('\n\n') || message + } const display = message ? boundInlineText(message, limits) : null return { body: { kind: 'status', - text: display?.text ?? `${provider} · ${kind}`, + text: compaction ? 'Context compacted' : (display?.text ?? `${provider} · ${kind}`), + ...(compaction ? { presentation: 'compaction' } : {}), + ...(tone ? { tone } : {}), providerFrame: { provider, kind, payload: bounded } }, classification: classification === 'error-surface' ? 'error-surface' : 'timeline-substantive' diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index 245bee46c9f..39cbf2753bf 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -11,6 +11,7 @@ import { import { isSubagentGroupBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' import { splitNativeChatBlocks } from './native-chat-tool-fold' import { NativeChatToolRun } from './NativeChatToolRun' +import { NativeChatNoticeRow } from './NativeChatNoticeRow' import { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp' import { nativeChatProseToMarkdown } from './native-chat-prose' import { @@ -97,6 +98,24 @@ export const MessageRow = memo(function MessageRow({ return null } + const notice = isSystem + ? message.blocks.find( + (block) => + block.type === 'text' && (block.presentation !== undefined || block.tone !== undefined) + ) + : undefined + if (notice?.type === 'text') { + return ( +
+ +
+ ) + } + if (providerFrame) { return (
diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx new file mode 100644 index 00000000000..ef4364b9cb1 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx @@ -0,0 +1,110 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { cleanup, 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 { MessageRow } from './NativeChatMessageRow' + +afterEach(cleanup) + +function renderStatus(body: AgentJournalStatusItem) { + const [message] = projectStructuredItemsToNativeChat([ + { itemId: 'notice', sequence: 1, revision: 1, observedAt: 1, body } + ]) + return render( + + ) +} + +describe('notice rows', () => { + it('renders compaction as a centered separator', () => { + renderStatus({ kind: 'status', text: 'Context compacted', presentation: 'compaction' }) + expect(screen.getByRole('separator', { name: 'Context compacted' })).toHaveClass( + 'text-muted-foreground' + ) + expect( + screen.getByText('Context compacted').parentElement?.querySelectorAll('.bg-border') + ).toHaveLength(2) + }) + it.each([ + ['warning', 'text-[color:var(--warning,#f59e0b)]'], + ['error', 'text-destructive'], + ['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) + }) + it('renders a plan as readable markdown in the card primitive', () => { + renderStatus({ + kind: 'status', + text: '# Steps\n\nA **readable** document.', + presentation: 'plan-document' + }) + expect(screen.getByText('Plan').closest('[data-slot="card"]')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Steps' })).toBeInTheDocument() + expect(screen.getByText('readable').tagName).toBe('STRONG') + expect(screen.getByText('readable').closest('[data-slot="card-content"]')).toHaveClass( + 'text-sm', + 'text-foreground' + ) + }) + it('shows provider notice text once while retaining its diagnostic disclosure', () => { + renderStatus({ + kind: 'status', + text: 'Check the configuration', + tone: 'warning', + providerFrame: { + provider: 'codex', + kind: 'notification:warning', + payload: { + head: '{"message":"Check the configuration"}', + byteLength: 37, + digest: 'digest', + truncated: false + } + } + }) + expect(screen.getAllByText('Check the configuration')).toHaveLength(1) + const disclosure = screen.getByText('Details').closest('details') + expect(disclosure?.querySelector('summary')).not.toHaveTextContent('Check the configuration') + expect(disclosure?.querySelector('pre')).toHaveTextContent('Check the configuration') + }) + it('renders future presentation and tone values as untinted text', () => { + renderStatus({ + kind: 'status', + text: 'Future readable text', + tone: 'future-tone', + presentation: 'future-presentation' + }) + expect(screen.getByText('Future readable text').parentElement?.parentElement).toHaveClass( + 'text-foreground' + ) + expect(screen.getByText('Future readable text').parentElement?.querySelector('svg')).toBeNull() + }) +}) + +describe('old-reader compatibility', () => { + // Derive the prior status shape without its new optional hints. + const statusSchema = AgentJournalItemBodySchema.options.find( + (schema): schema is (typeof AgentJournalItemBodySchema.options)[5] => + schema.shape.kind.value === 'status' + )! + const oldStatusSchema = statusSchema.omit({ tone: true, presentation: true }) + it.each([ + { presentation: 'compaction' }, + { presentation: 'plan-document' }, + { tone: 'warning' }, + { tone: 'error' }, + { 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 } + expect(AgentJournalItemBodySchema.safeParse(body).success).toBe(true) + const oldBody = oldStatusSchema.parse(body) as AgentJournalStatusItem + expect(oldBody).toEqual({ kind: 'status', text: body.text }) + renderStatus(oldBody) + expect(screen.getByText(body.text)).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx new file mode 100644 index 00000000000..8b05f0d2d61 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -0,0 +1,86 @@ +import { AlertCircle, AlertTriangle, Info } from 'lucide-react' +import CommentMarkdown, { + type CommentMarkdownLinkClickHandler +} from '@/components/sidebar/CommentMarkdown' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import type { NativeChatTextBlock } from '../../../../shared/native-chat-types' +import { ProviderFrameRow } from './NativeChatTranscriptChrome' + +export function NativeChatNoticeRow({ + block, + onLinkClick, + allowFileUriLinks = false +}: { + block: NativeChatTextBlock + onLinkClick?: CommentMarkdownLinkClickHandler + allowFileUriLinks?: boolean +}): React.JSX.Element { + if (block.presentation === 'compaction') { + const label = translate('components.native-chat.notices.compaction', 'Context compacted') + return ( +
+ + {label} + +
+ ) + } + if (block.presentation === 'plan-document') { + return ( + + + + {translate('components.native-chat.notices.plan', 'Plan')} + + + + + + + ) + } + const tone = block.tone + const Icon = + tone === 'warning' + ? AlertTriangle + : tone === 'error' + ? AlertCircle + : tone === 'notice' + ? Info + : null + return ( +
+
+ {Icon ?
+ {block.providerFrame ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx index 31566c5ebbb..4dea3e2d617 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx @@ -274,7 +274,13 @@ export function NativeChatAgentControls({ ) } -export function ProviderFrameRow({ block }: { block: NativeChatBlock }): React.JSX.Element | null { +export function ProviderFrameRow({ + block, + summary +}: { + block: NativeChatBlock + summary?: string +}): React.JSX.Element | null { if (block.type !== 'text' || !block.providerFrame) { return null } @@ -284,7 +290,7 @@ export function ProviderFrameRow({ block }: { block: NativeChatBlock }): React.J › {frame.provider} - {nativeChatProviderFrameSummary(block)} + {summary ?? nativeChatProviderFrameSummary(block)} {frame.payload.truncated ? ( ·{' '} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ccddf4f5414..0aa613c6d73 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16981,6 +16981,11 @@ "resolver": "Answered on {{device}}", "cancelledBy": "Cancelled on {{device}}" }, + "notices": { + "compaction": "Context compacted", + "details": "Details", + "plan": "Plan" + }, "composer": { "imageUnsupported": "Image paste is not supported for this agent.", "send": "Send", diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts index 2348c4f6706..ac7dafacfd2 100644 --- a/src/shared/agent-session-journal-schemas.test.ts +++ b/src/shared/agent-session-journal-schemas.test.ts @@ -190,6 +190,34 @@ describe('forward tolerance', () => { }) }) +describe('optional notice metadata', () => { + it.each([ + {}, + { presentation: 'compaction' }, + { presentation: 'plan-document' }, + { tone: 'warning' }, + { tone: 'error' }, + { tone: 'notice' }, + { presentation: 'future-presentation', tone: 'future-tone' } + ])('admits existing status and text kinds with %j', (metadata) => { + expect( + isAdmissibleAgentJournalItemBody({ kind: 'status', text: 'Readable fallback', ...metadata }) + ).toBe(true) + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'message', + role: 'system', + blocks: [{ type: 'text', text: 'Readable fallback', ...metadata }] + }) + ).toBe(true) + }) + it.each([{ tone: false }, { presentation: {} }])('rejects malformed metadata: %j', (metadata) => { + expect(isAdmissibleAgentJournalItemBody({ kind: 'status', text: 'Text', ...metadata })).toBe( + false + ) + }) +}) + describe('optional tool annotations', () => { const body = { kind: 'tool-call', name: 'shell', input: null, state: 'completed' } it('admits old rows and rows with optional annotations without a new kind', () => { diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 7906377c057..89c2d9f7293 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -68,6 +68,8 @@ const Block = z.union([ z.object({ type: z.literal('text'), text: z.string(), + presentation: z.string().optional(), + tone: z.string().optional(), providerFrame: ProviderFrame.optional() }), // `input: undefined` loses its key under JSON.stringify, so a persisted @@ -160,6 +162,8 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('status'), text: z.string(), + presentation: z.string().optional(), + tone: z.string().optional(), turnLifecycle: z.object({ turnId: z.string(), state: z.string().min(1) }).optional(), providerFrame: ProviderFrame.optional() }) diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 03950b9d242..c0074e015f5 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -148,6 +148,9 @@ export type AgentJournalQuestionItem = { export type AgentJournalStatusItem = { kind: 'status' text: string + /** Optional display hints; unknown values retain the ordinary text fallback. */ + presentation?: string + tone?: string /** Durable root-turn lifecycle used by clients to expose cancellation only * while the provider can still accept it. */ turnLifecycle?: { turnId: string; state: 'running' | 'completed' } diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index b07c182d750..3a018d75e6b 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -32,6 +32,9 @@ export type NativeChatRole = (typeof NATIVE_CHAT_ROLES)[number] export type NativeChatTextBlock = { type: 'text' text: string + /** Optional journal display hints; readers narrow only the values they know. */ + presentation?: string + tone?: string /** Optional structured detail for an otherwise ordinary fallback line. */ providerFrame?: { provider: string diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts index 0024afc7895..77980fa86cc 100644 --- a/src/shared/structured-agent-session-projection.test.ts +++ b/src/shared/structured-agent-session-projection.test.ts @@ -343,6 +343,29 @@ describe('structured agent session status projection', () => { }) }) +describe('notice projection for desktop and mobile consumers', () => { + it.each([ + { presentation: 'compaction' }, + { presentation: 'plan-document' }, + { tone: 'warning' }, + { tone: 'error' }, + { tone: 'notice' }, + { presentation: 'future-presentation', tone: 'future-tone' } + ])('preserves readable text alongside optional metadata: %j', (metadata) => { + const projected = projectStructuredItemToNativeChat( + item('notice', 1, { + kind: 'status', + text: 'A readable document or notice', + ...metadata + }) + ) + expect(projected).toMatchObject({ + role: 'system', + blocks: [{ type: 'text', text: 'A readable document or notice', ...metadata }] + }) + }) +}) + it('preserves optional tool annotations for desktop and mobile projection', () => { const metadata = { exitCode: 127, diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index e2ff26a2505..02ad872c211 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -113,6 +113,8 @@ function itemBlocks(item: AgentJournalRenderItem): { { type: 'text', text: body.text, + ...(body.presentation !== undefined ? { presentation: body.presentation } : {}), + ...(body.tone !== undefined ? { tone: body.tone } : {}), ...(body.providerFrame ? { providerFrame: body.providerFrame } : {}) } ]