diff --git a/mobile/src/session/MobileStructuredAgentSessionView.test.tsx b/mobile/src/session/MobileStructuredAgentSessionView.test.tsx index eb44743de63..937e6af4bb7 100644 --- a/mobile/src/session/MobileStructuredAgentSessionView.test.tsx +++ b/mobile/src/session/MobileStructuredAgentSessionView.test.tsx @@ -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') diff --git a/mobile/src/session/MobileStructuredAgentSessionView.tsx b/mobile/src/session/MobileStructuredAgentSessionView.tsx index eed68f68ce7..deb4ee2eadf 100644 --- a/mobile/src/session/MobileStructuredAgentSessionView.tsx +++ b/mobile/src/session/MobileStructuredAgentSessionView.tsx @@ -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 ) : ( [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')} > diff --git a/mobile/src/session/mobile-structured-agent-session-view-styles.ts b/mobile/src/session/mobile-structured-agent-session-view-styles.ts index 6ef7fddd5b8..131e1040be0 100644 --- a/mobile/src/session/mobile-structured-agent-session-view-styles.ts +++ b/mobile/src/session/mobile-structured-agent-session-view-styles.ts @@ -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, diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index 3da906ba0c1..abdd9b75864 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -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 { diff --git a/src/main/codex/codex-structured-journal-translation.test.ts b/src/main/codex/codex-structured-journal-translation.test.ts index 6ad56f537dc..852e32500b2 100644 --- a/src/main/codex/codex-structured-journal-translation.test.ts +++ b/src/main/codex/codex-structured-journal-translation.test.ts @@ -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() diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index 1431d9ed731..dafa86af4eb 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -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, diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts new file mode 100644 index 00000000000..838b7af9155 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -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' +} diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts index 646d4bf7dc3..70a10947d54 100644 --- a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts @@ -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() }) }) diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts index 8801c767cf8..f79a4f70ac3 100644 --- a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts @@ -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 { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 2a5136b345d..6a26f3d5f3f 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -103,6 +103,7 @@ export function NativeChatStructuredSession(props: { { void controller.requestHandoff(direction, mode, action) }} diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx index 4a185a3d630..645670a6675 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx @@ -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', () => { + />, + { 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( + , + { 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() diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx index 840039564d8..23c4f1e1d9d 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx @@ -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({ ) : ( - + + + + + + + {!hasPersistedTurn ? ( + + {translate( + 'components.native-chat.handoff.sendFirst', + 'Send a message first to open the agent TUI.' + )} + + ) : null} + ) ) : owner === 'tui' && phase === 'idle' ? (