Merge origin/main into brennanb2025/nc-turn-rollup

This commit is contained in:
Merge Sim
2026-09-07 16:26:31 -07:00
26 changed files with 878 additions and 62 deletions
-5
View File
@@ -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
-30
View File
@@ -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 }]
}
}
]
}
@@ -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<NativeChatBlock, { type: 'image-ref' }> | 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] : [])]
}
}
@@ -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 }]
})
})
})
@@ -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 }
}
@@ -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<string, AgentJournalItemBody>()
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()
})
})
@@ -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<string, 'item' | 'legacy'>()
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()
}
}
@@ -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) {
@@ -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()
})
})
@@ -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<CodexStructuredSessionEvent, { type: 'provider-frame' }>
): 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<CodexStructuredSessionEvent, { type: 'notification' }>
): CodexJournalTranslationAdmission {
const turnId = readCodexTurnId(event.params)
if (!turnId) {
return CODEX_JOURNAL_ADMITTED
@@ -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(
@@ -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:<type>` into the transcript. The
* notification catalog above is keyed by METHOD and never matches these. */
const CODEX_ITEM_CLASSIFICATIONS: Record<string, ProviderFrameClassification> = {
// 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:<type>` row beside it. The live
// notification path intercepts them before this catalog is reached;
@@ -11,6 +11,13 @@ function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
}
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<string, PendingCompaction>()
@@ -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) {
@@ -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 }
})
})
})
@@ -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<string, unknown>
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'
@@ -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 (
<div ref={rowRef}>
<NativeChatNoticeRow
block={notice}
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
/>
</div>
)
}
if (providerFrame) {
return (
<div ref={rowRef}>
@@ -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(
<MessageRow message={message!} expandSignal={false} onScrollMessageToTop={vi.fn()} />
)
}
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()
})
})
@@ -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 (
<div
role="separator"
aria-label={label}
className="flex items-center gap-3 py-2 text-xs text-muted-foreground"
>
<span className="h-px flex-1 bg-border" />
<span>{label}</span>
<span className="h-px flex-1 bg-border" />
</div>
)
}
if (block.presentation === 'plan-document') {
return (
<Card className="gap-3 py-3 shadow-xs">
<CardHeader className="px-4">
<CardTitle className="text-sm">
{translate('components.native-chat.notices.plan', 'Plan')}
</CardTitle>
</CardHeader>
<CardContent className="px-4 text-sm leading-relaxed text-foreground">
<CommentMarkdown
content={block.text}
variant="document"
className="text-sm"
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
linkifyFilePaths={onLinkClick !== undefined}
/>
</CardContent>
</Card>
)
}
const tone = block.tone
const Icon =
tone === 'warning'
? AlertTriangle
: tone === 'error'
? AlertCircle
: tone === 'notice'
? Info
: null
return (
<div
className={cn(
'space-y-2 text-sm text-foreground',
Icon && 'rounded-md border border-border bg-muted/20 p-3',
tone === 'warning' && 'text-[color:var(--warning,#f59e0b)]',
tone === 'error' && 'text-destructive',
tone === 'notice' && 'text-muted-foreground'
)}
>
<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>
{block.providerFrame ? (
<ProviderFrameRow
block={block}
summary={translate('components.native-chat.notices.details', 'Details')}
/>
) : null}
</div>
)
}
@@ -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
<summary className="flex cursor-pointer list-none items-center gap-2 rounded-md px-2 py-1 font-mono hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span className="transition-transform group-open:rotate-90">›</span>
<span className="font-medium text-foreground">{frame.provider}</span>
<span className="truncate">{nativeChatProviderFrameSummary(block)}</span>
<span className="truncate">{summary ?? nativeChatProviderFrameSummary(block)}</span>
{frame.payload.truncated ? (
<span>
·{' '}
+5
View File
@@ -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",
@@ -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', () => {
@@ -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()
})
@@ -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' }
+3
View File
@@ -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
@@ -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,
@@ -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 } : {})
}
]