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' ? (