fix structured session startup chrome

This commit is contained in:
Brennan Benson
2026-08-13 14:27:03 -07:00
parent 84727e695a
commit d00af4d591
16 changed files with 219 additions and 20 deletions
@@ -114,6 +114,27 @@ describe('MobileStructuredAgentSessionView command seam', () => {
expect(onSend).not.toHaveBeenCalled()
})
it('disables zero-turn TUI resume with truthful guidance', () => {
render({
handoff: {
owner: 'native',
direction: null,
phase: 'idle',
stage: null,
operationId: null
}
})
const label = renderer!.root
.findAllByType('Text')
.find((node) => node.children.includes('Open agent TUI'))
expect(label?.parent?.props).toMatchObject({
disabled: true,
accessibilityState: { disabled: true },
accessibilityHint: 'Send a message first to open the agent TUI.'
})
})
it('renders an explicit refusal for unsupported advertised commands', async () => {
await submit('/review')
@@ -33,6 +33,7 @@ import {
restoreMobileStructuredAttachments,
type MobileStructuredTimelineRow
} from './mobile-structured-session-timeline'
import { hasPersistedStructuredAgentSessionTurn } from '../../../src/shared/structured-agent-session-projection'
import {
admitStructuredOlderPage,
beginStructuredUserScroll,
@@ -128,6 +129,7 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
<MobileStructuredHandoffBanner
status={props.handoff}
isWorking={turnId !== null}
hasPersistedTurn={hasPersistedStructuredAgentSessionTurn(props.items)}
onRequest={props.onRequestHandoff}
/>
<FlatList
@@ -295,6 +297,7 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
function MobileStructuredHandoffBanner(props: {
status: AgentSessionHandoffStatus | null
isWorking: boolean
hasPersistedTurn: boolean
onRequest: Props['onRequestHandoff']
}): React.JSX.Element | null {
const status = props.status
@@ -321,7 +324,17 @@ function MobileStructuredHandoffBanner(props: {
</>
) : (
<Pressable
style={({ pressed }) => [styles.handoffButton, pressed && styles.pressed]}
style={({ pressed }) => [
styles.handoffButton,
!props.hasPersistedTurn && styles.disabled,
pressed && props.hasPersistedTurn && styles.pressed
]}
disabled={!props.hasPersistedTurn}
accessibilityRole="button"
accessibilityState={{ disabled: !props.hasPersistedTurn }}
accessibilityHint={
props.hasPersistedTurn ? undefined : 'Send a message first to open the agent TUI.'
}
// A submitted turn can reach the host before isWorking updates; after-turn is immediate when idle.
onPress={() => void props.onRequest('to-tui', 'after-turn')}
>
@@ -36,6 +36,7 @@ export const styles = StyleSheet.create({
},
cancelText: { color: colors.statusRed, fontSize: typography.metaSize, fontWeight: '600' },
pressed: { opacity: 0.7 },
disabled: { opacity: 0.45 },
writeError: {
alignItems: 'center',
paddingHorizontal: spacing.lg,
@@ -243,10 +243,8 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem {
handled: true
}
}
return {
...unhandledProviderFrameJournalItem('codex', `item:${item.type}`, item),
handled: false
}
const unhandled = unhandledProviderFrameJournalItem('codex', `item:${item.type}`, item)
return unhandled ? { ...unhandled, handled: false } : { body: null, blobs: [], handled: true }
}
export function codexItemBody(item: CodexThreadItem): AgentJournalItemBody | null {
@@ -4,6 +4,7 @@ import type {
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import { projectStructuredItemsToNativeChat } from '../../shared/structured-agent-session-projection'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
import {
@@ -442,6 +443,32 @@ describe('codex journal translation', () => {
).toEqual(['notification:future/notification', 'request:future/request', 'frame:unclassified'])
})
it('keeps a fresh session timeline empty through startup and status notifications', () => {
const { translator, tap } = translatorWith()
translator.handle(notification('thread/started', { thread: { id: THREAD_ID } }))
for (let index = 0; index < 8; index += 1) {
translator.handle(
notification('mcpServer/startupStatus/updated', {
server: `server-${index}`,
status: 'starting'
})
)
}
translator.handle(notification('remoteControl/status/changed', { status: 'disabled' }))
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([])
})
it('writes nothing more after dispose', () => {
const { translator, tap, window } = translatorWith()
@@ -70,8 +70,11 @@ export function createCodexJournalTranslator(
let fallbackSequence = 0
const appendUnhandled = (kind: string, payload: unknown): void => {
fallbackSequence += 1
const translated = unhandledProviderFrameJournalItem('codex', kind, payload)
if (!translated) {
return
}
fallbackSequence += 1
deps.sink.appendItem(
{ provider: 'orca', clientMessageId: `provider-frame:codex:${fallbackSequence}` },
translated.body,
@@ -0,0 +1,38 @@
export type ProviderFrameDisposition = 'debug-only' | 'timeline'
function notificationKind(kind: string): string {
return kind.startsWith('notification:') ? kind.slice('notification:'.length) : kind
}
function isCodexStatusFrame(kind: string): boolean {
const method = notificationKind(kind)
return (
/^(?:thread\/(?:started|closed|archived|unarchived)|thread\/(?:name|status)\/(?:changed|updated))$/.test(
method
) ||
/^thread\/(?:tokenUsage\/updated|goal\/(?:updated|cleared))$/.test(method) ||
method === 'mcpServer/startupStatus/updated' ||
method === 'remoteControl/status/changed'
)
}
function isClaudeStatusFrame(kind: string): boolean {
return (
kind === 'message:system:init' ||
kind === 'message:system:status' ||
kind === 'message:result' ||
/^message:stream_event:(?:message_start|message_stop|content_block_start|content_block_stop)$/.test(
kind
)
)
}
export function classifyProviderFrame(provider: string, kind: string): ProviderFrameDisposition {
if (provider === 'codex' && isCodexStatusFrame(kind)) {
return 'debug-only'
}
if (provider === 'claude' && isClaudeStatusFrame(kind)) {
return 'debug-only'
}
return 'timeline'
}
@@ -15,6 +15,10 @@ describe('unhandled provider frame journal fallback', () => {
}
)
expect(item).not.toBeNull()
if (!item) {
throw new Error('expected substantive provider frame')
}
expect(item.body).toMatchObject({
kind: 'status',
text: 'future-provider · notification:new/event',
@@ -41,6 +45,31 @@ describe('unhandled provider frame journal fallback', () => {
const item = unhandledProviderFrameJournalItem('codex', 'frame', cyclic)
expect(item.body.providerFrame?.payload.head).toContain('unserializable payload')
expect(item?.body.providerFrame?.payload.head).toContain('unserializable payload')
})
it('routes provider lifecycle, startup, and status frames away from the timeline', () => {
expect(unhandledProviderFrameJournalItem('codex', 'notification:thread/started', {})).toBeNull()
expect(
unhandledProviderFrameJournalItem('codex', 'notification:mcpServer/startupStatus/updated', {})
).toBeNull()
expect(
unhandledProviderFrameJournalItem('codex', 'notification:remoteControl/status/changed', {})
).toBeNull()
expect(
unhandledProviderFrameJournalItem('codex', 'notification:thread/tokenUsage/updated', {})
).toBeNull()
expect(
unhandledProviderFrameJournalItem('codex', 'notification:thread/goal/cleared', {})
).toBeNull()
expect(unhandledProviderFrameJournalItem('claude', 'message:system:init', {})).toBeNull()
expect(unhandledProviderFrameJournalItem('claude', 'message:result', {})).toBeNull()
})
it('keeps unknown substantive frames visible for both providers', () => {
expect(
unhandledProviderFrameJournalItem('codex', 'notification:future/event', {})
).not.toBeNull()
expect(unhandledProviderFrameJournalItem('claude', 'message:future/event', {})).not.toBeNull()
})
})
@@ -4,6 +4,7 @@ import {
DEFAULT_JOURNAL_PAYLOAD_LIMITS,
type JournalPayloadLimits
} from '../agent-session-journal/journal-payload-bounds'
import { classifyProviderFrame } from './provider-frame-disposition'
export type UnhandledProviderFrameJournalItem = {
body: AgentJournalStatusItem
@@ -19,13 +20,16 @@ function serializeProviderPayload(payload: unknown): string {
}
}
/** Every adapter fallback becomes a visible, bounded journal row. */
/** Substantive adapter fallbacks become visible, bounded journal rows. */
export function unhandledProviderFrameJournalItem(
provider: string,
kind: string,
payload: unknown,
limits: JournalPayloadLimits = DEFAULT_JOURNAL_PAYLOAD_LIMITS
): UnhandledProviderFrameJournalItem {
): UnhandledProviderFrameJournalItem | null {
if (classifyProviderFrame(provider, kind) === 'debug-only') {
return null
}
const serialized = serializeProviderPayload(payload)
const bounded = boundPayload(serialized, limits)
return {
@@ -103,6 +103,7 @@ export function NativeChatStructuredSession(props: {
<StructuredAgentSessionHandoffChrome
status={controller.handoff}
isWorking={controller.isWorking}
hasPersistedTurn={controller.hasPersistedTurn}
onRequest={(direction, mode, action) => {
void controller.requestHandoff(direction, mode, action)
}}
@@ -3,6 +3,7 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionHandoffStatus } from '../../../../shared/agent-session-wire'
import { TooltipProvider } from '@/components/ui/tooltip'
import { StructuredAgentSessionHandoffChrome } from './StructuredAgentSessionHandoffChrome'
const IDLE_NATIVE: AgentSessionHandoffStatus = {
@@ -22,8 +23,10 @@ describe('StructuredAgentSessionHandoffChrome', () => {
<StructuredAgentSessionHandoffChrome
status={IDLE_NATIVE}
isWorking={false}
hasPersistedTurn
onRequest={onRequest}
/>
/>,
{ wrapper: TooltipProvider }
)
fireEvent.click(screen.getByRole('button', { name: 'Open agent TUI' }))
@@ -31,6 +34,25 @@ describe('StructuredAgentSessionHandoffChrome', () => {
expect(onRequest).toHaveBeenCalledWith('to-tui', 'after-turn')
})
it('disables zero-turn TUI resume with truthful guidance', async () => {
render(
<StructuredAgentSessionHandoffChrome
status={IDLE_NATIVE}
isWorking={false}
hasPersistedTurn={false}
onRequest={vi.fn()}
/>,
{ wrapper: TooltipProvider }
)
const button = screen.getByRole('button', { name: 'Open agent TUI' })
expect(button.hasAttribute('disabled')).toBe(true)
fireEvent.pointerMove(button.parentElement!)
expect(
await screen.findAllByText('Send a message first to open the agent TUI.')
).not.toHaveLength(0)
})
it('offers one Retry action for a recoverable dead TUI owner', () => {
const onRequest = vi.fn()
render(
@@ -47,8 +69,10 @@ describe('StructuredAgentSessionHandoffChrome', () => {
}
}}
isWorking={false}
hasPersistedTurn
onRequest={onRequest}
/>
/>,
{ wrapper: TooltipProvider }
)
expect(screen.queryByRole('button', { name: 'Return to chat' })).toBeNull()
@@ -5,11 +5,13 @@ import type {
} from '../../../../shared/agent-session-wire'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
type Props = {
status: AgentSessionHandoffStatus | null
isWorking: boolean
hasPersistedTurn: boolean
onRequest: (
direction: AgentSessionHandoffDirection,
mode: AgentSessionHandoffMode,
@@ -54,6 +56,7 @@ function handoffStageCopy(status: AgentSessionHandoffStatus): string {
export function StructuredAgentSessionHandoffChrome({
status,
isWorking,
hasPersistedTurn,
onRequest
}: Props): React.JSX.Element | null {
if (!status) {
@@ -122,15 +125,30 @@ export function StructuredAgentSessionHandoffChrome({
</Button>
</>
) : (
<Button
type="button"
variant="ghost"
size="xs"
// A submitted turn can reach the host before isWorking updates; after-turn is immediate when idle.
onClick={() => onRequest('to-tui', 'after-turn')}
>
{translate('components.native-chat.handoff.openAgentTui', 'Open agent TUI')}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
type="button"
variant="ghost"
size="xs"
disabled={!hasPersistedTurn}
// A submitted turn can reach the host before isWorking updates; after-turn is immediate when idle.
onClick={() => onRequest('to-tui', 'after-turn')}
>
{translate('components.native-chat.handoff.openAgentTui', 'Open agent TUI')}
</Button>
</span>
</TooltipTrigger>
{!hasPersistedTurn ? (
<TooltipContent side="bottom" sideOffset={4}>
{translate(
'components.native-chat.handoff.sendFirst',
'Send a message first to open the agent TUI.'
)}
</TooltipContent>
) : null}
</Tooltip>
)
) : owner === 'tui' && phase === 'idle' ? (
<Button
@@ -23,6 +23,7 @@ import {
} from '../../../../shared/structured-agent-session-options'
import {
activeStructuredAgentSessionTurnId,
hasPersistedStructuredAgentSessionTurn,
projectStructuredItemsToNativeChat
} from '../../../../shared/structured-agent-session-projection'
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
@@ -233,6 +234,7 @@ export function useStructuredAgentSession(args: {
send: outboxController.send,
retry: outboxController.retry,
isWorking: turnId !== null,
hasPersistedTurn: hasPersistedStructuredAgentSessionTurn(state.items),
turnId,
cancel: (turnId: string) => mutate('agentSession.cancel', 'agentSession.cancel', { turnId }),
respond: (item: StructuredPromptItem, optionId: string) =>
+1
View File
@@ -15131,6 +15131,7 @@
"switchAfterTurn": "Switch after this turn",
"stopTurnAndSwitch": "Stop turn and switch",
"openAgentTui": "Open agent TUI",
"sendFirst": "Send a message first to open the agent TUI.",
"returnAfterTurn": "Return after this turn",
"returnToChat": "Return to chat",
"agentOpen": "Agent is open in terminal.",
@@ -3,6 +3,7 @@ import type { AgentJournalRenderItem } from './agent-session-journal-types'
import { parsePaneKey } from './stable-pane-id'
import {
activeStructuredAgentSessionTurnId,
hasPersistedStructuredAgentSessionTurn,
projectStructuredItemToNativeChat,
projectStructuredAgentSessionStatus,
structuredAgentSessionPaneKey
@@ -50,6 +51,15 @@ describe('structured agent session status projection', () => {
expect(parsePaneKey(paneKey)).toMatchObject({ tabId: 'structured-agent-session-1' })
})
it('requires a persisted provider conversation turn before TUI resume', () => {
const status = item('status', 1, { kind: 'status', text: 'Connected' })
const user = item('user', 2, { kind: 'message', role: 'user', blocks: [] })
expect(hasPersistedStructuredAgentSessionTurn([])).toBe(false)
expect(hasPersistedStructuredAgentSessionTurn([status])).toBe(false)
expect(hasPersistedStructuredAgentSessionTurn([status, user])).toBe(true)
})
it('preserves provider-frame detail on the backward-compatible status line', () => {
const projected = projectStructuredItemToNativeChat(
item('frame', 1, {
@@ -116,6 +116,15 @@ export function activeStructuredAgentSessionTurnId(
return null
}
export function hasPersistedStructuredAgentSessionTurn(
items: readonly AgentJournalRenderItem[]
): boolean {
return items.some(
(item) =>
item.body.kind === 'message' && (item.body.role === 'user' || item.body.role === 'assistant')
)
}
export type StructuredAgentSessionProjectedStatus = 'working' | 'attention' | 'idle'
export function projectStructuredAgentSessionStatus(