mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
Merge origin/main into brennanb2025/nc-turn-rollup
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
·{' '}
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user