fix structured provider error visibility

This commit is contained in:
Brennan Benson
2026-08-13 14:43:10 -07:00
parent d00af4d591
commit 52a62b417e
4 changed files with 161 additions and 3 deletions
@@ -469,6 +469,39 @@ describe('codex journal translation', () => {
expect(timeline).toEqual([])
})
it('renders a system error carried by a suppressed status kind', () => {
const { translator, tap } = translatorWith()
translator.handle(
notification('thread/status/changed', {
threadId: THREAD_ID,
status: { type: 'systemError' }
})
)
const timeline = projectStructuredItemsToNativeChat(
tap.rows.map((row, index) => ({
itemId: row.key,
revision: 1,
sequence: index + 1,
observedAt: index + 1,
body: row.body
}))
)
expect(timeline).toEqual([
expect.objectContaining({
role: 'system',
blocks: [
expect.objectContaining({
providerFrame: expect.objectContaining({
kind: 'notification:thread/status/changed'
})
})
]
})
])
})
it('writes nothing more after dispose', () => {
const { translator, tap, window } = translatorWith()
@@ -1,5 +1,50 @@
export type ProviderFrameDisposition = 'debug-only' | 'timeline'
const ERROR_VARIANT_KEYS = new Set(['type', 'status', 'state', 'subtype', 'outcome'])
const ERROR_VALUE_KEYS = new Set(['error', 'failureReason', 'failure_reason'])
function isErrorVariant(value: unknown): boolean {
if (typeof value !== 'string') {
return false
}
const normalized = value.replace(/[_\s-]/g, '').toLowerCase()
return (
normalized.startsWith('error') || normalized.startsWith('fail') || normalized === 'systemerror'
)
}
function hasProviderError(payload: unknown): boolean {
const pending = [payload]
const seen = new WeakSet<object>()
while (pending.length > 0) {
const value = pending.pop()
if (typeof value !== 'object' || value === null || seen.has(value)) {
continue
}
seen.add(value)
if (Array.isArray(value)) {
pending.push(...value)
continue
}
for (const [key, nested] of Object.entries(value)) {
if ((key === 'isError' || key === 'is_error') && nested === true) {
return true
}
if (key === 'success' && nested === false) {
return true
}
if (ERROR_VARIANT_KEYS.has(key) && isErrorVariant(nested)) {
return true
}
if (ERROR_VALUE_KEYS.has(key) && nested !== null && nested !== false && nested !== '') {
return true
}
pending.push(nested)
}
}
return false
}
function notificationKind(kind: string): string {
return kind.startsWith('notification:') ? kind.slice('notification:'.length) : kind
}
@@ -27,11 +72,25 @@ function isClaudeStatusFrame(kind: string): boolean {
)
}
export function classifyProviderFrame(provider: string, kind: string): ProviderFrameDisposition {
export function classifyProviderFrame(
provider: string,
kind: string,
payload: unknown
): ProviderFrameDisposition {
if (hasProviderError(payload)) {
return 'timeline'
}
if (provider === 'codex' && isCodexStatusFrame(kind)) {
return 'debug-only'
}
if (provider === 'claude' && isClaudeStatusFrame(kind)) {
if (kind === 'message:result') {
const record =
typeof payload === 'object' && payload !== null
? (payload as Record<string, unknown>)
: null
return record?.subtype === 'success' ? 'debug-only' : 'timeline'
}
return 'debug-only'
}
return 'timeline'
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { projectStructuredItemToNativeChat } from '../../../shared/structured-agent-session-projection'
import { unhandledProviderFrameJournalItem } from './unhandled-provider-frame'
describe('unhandled provider frame journal fallback', () => {
@@ -63,7 +64,72 @@ describe('unhandled provider frame journal fallback', () => {
unhandledProviderFrameJournalItem('codex', 'notification:thread/goal/cleared', {})
).toBeNull()
expect(unhandledProviderFrameJournalItem('claude', 'message:system:init', {})).toBeNull()
expect(unhandledProviderFrameJournalItem('claude', 'message:result', {})).toBeNull()
expect(
unhandledProviderFrameJournalItem('claude', 'message:result', {
subtype: 'success',
is_error: false
})
).toBeNull()
})
it('renders codex systemError and Claude error result variants', () => {
const codex = unhandledProviderFrameJournalItem('codex', 'notification:thread/status/changed', {
threadId: 'thread-1',
status: { type: 'systemError' }
})
const claude = unhandledProviderFrameJournalItem('claude', 'message:result', {
subtype: 'error_during_execution',
is_error: true,
result: 'Provider request failed'
})
expect(codex?.body.providerFrame).toMatchObject({
provider: 'codex',
kind: 'notification:thread/status/changed'
})
expect(claude?.body.providerFrame).toMatchObject({
provider: 'claude',
kind: 'message:result'
})
expect(
claude
? projectStructuredItemToNativeChat({
itemId: 'claude-error',
revision: 1,
sequence: 1,
observedAt: 1,
body: claude.body
})
: null
).toMatchObject({
role: 'system',
blocks: [
expect.objectContaining({
providerFrame: expect.objectContaining({ kind: 'message:result' })
})
]
})
})
it('keeps failed startup variants visible while suppressing startup progress', () => {
const kind = 'notification:mcpServer/startupStatus/updated'
expect(
unhandledProviderFrameJournalItem('codex', kind, {
name: 'filesystem',
status: 'starting',
error: null,
failureReason: null
})
).toBeNull()
expect(
unhandledProviderFrameJournalItem('codex', kind, {
name: 'filesystem',
status: 'failed',
error: 'server exited',
failureReason: null
})
).not.toBeNull()
})
it('keeps unknown substantive frames visible for both providers', () => {
@@ -27,7 +27,7 @@ export function unhandledProviderFrameJournalItem(
payload: unknown,
limits: JournalPayloadLimits = DEFAULT_JOURNAL_PAYLOAD_LIMITS
): UnhandledProviderFrameJournalItem | null {
if (classifyProviderFrame(provider, kind) === 'debug-only') {
if (classifyProviderFrame(provider, kind, payload) === 'debug-only') {
return null
}
const serialized = serializeProviderPayload(payload)