diff --git a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts index a2a93533b6f..183f981ccee 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts @@ -50,6 +50,8 @@ const METHODS = [ } ] as const +const DESTRUCTIVE_METHOD_NAMES = new Set(['session.tabs.close', 'session.tabs.closeLifecycle']) + describe('session tab structured capability mutations', () => { for (const method of METHODS) { it(`rejects ${method.name} when the structured row is hidden`, async () => { @@ -88,6 +90,38 @@ describe('session tab structured capability mutations', () => { }) } + for (const method of METHODS) { + const expectedToAllowPromptedRow = !DESTRUCTIVE_METHOD_NAMES.has(method.name) + + it(`${expectedToAllowPromptedRow ? 'allows' : 'rejects'} ${method.name} on a row an old mobile client was prompted to update`, async () => { + const { calls, dispatch } = createFixture([], { + clientKind: 'mobile', + structuredNativeChatEnabled: true + }) + + const response = await dispatch(method.name, method.params('codex-session')) + + expect(response.ok).toBe(expectedToAllowPromptedRow) + expect(calls[method.runtimeMethod as keyof typeof calls]).toHaveBeenCalledTimes( + expectedToAllowPromptedRow ? 1 : 0 + ) + }) + + it(`${expectedToAllowPromptedRow ? 'allows' : 'rejects'} ${method.name} on a prompted Claude row for a mobile client without the Claude capability`, async () => { + const { calls, dispatch } = createFixture([STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], { + clientKind: 'mobile', + structuredNativeChatEnabled: true + }) + + const response = await dispatch(method.name, method.params('claude-session')) + + expect(response.ok).toBe(expectedToAllowPromptedRow) + expect(calls[method.runtimeMethod as keyof typeof calls]).toHaveBeenCalledTimes( + expectedToAllowPromptedRow ? 1 : 0 + ) + }) + } + it.each(['session.tabs.close', 'session.tabs.closeLifecycle'] as const)( 'allows capable mobile clients to close structured tabs when the experiment is enabled (%s)', async (method) => { @@ -131,7 +165,10 @@ describe('session tab structured capability mutations', () => { ) }) -function createFixture(capabilities: RuntimeCapability[]) { +function createFixture( + capabilities: RuntimeCapability[], + options: { clientKind?: 'mobile' | 'runtime'; structuredNativeChatEnabled?: boolean } = {} +) { const snapshot = agentSnapshot() const calls = { closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }), @@ -142,11 +179,14 @@ function createFixture(capabilities: RuntimeCapability[]) { const runtime = { getRuntimeId: () => 'test-runtime', listMobileSessionTabs: vi.fn().mockResolvedValue(snapshot), + getClientSettings: () => ({ + experimentalStructuredNativeChat: options.structuredNativeChatEnabled === true + }), ...calls } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) const context: RpcDispatchStreamingOptions = { - clientKind: 'runtime', + clientKind: options.clientKind ?? 'runtime', pairedDeviceId: 'paired-client', clientCapabilities: capabilities } diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts index 7128f756d6e..cf68f7739c0 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts @@ -5,7 +5,11 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' -import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection' +import { + CLAUDE_STRUCTURED_CHAT_DESKTOP_ONLY_TAB_TITLE, + STRUCTURED_CHAT_UPDATE_REQUIRED_TAB_TITLE, + projectSessionTabAgentStatus +} from './session-tab-agent-status-projection' function makeSnapshot(sessionBoundary: boolean): RuntimeMobileSessionTabsSnapshot { return { @@ -160,23 +164,96 @@ describe('projectSessionTabAgentStatus', () => { CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY ] - it.each([ - ['mobile', 'mobile' as const, [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]], - ['runtime', 'runtime' as const, [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]] - ])( - 'withholds Claude rows from a paired %s client that never negotiated them', - (_name, clientKind, capabilities) => { - const projected = projectSessionTabAgentStatus(claudeSnapshot, clientKind, capabilities, true) + it('withholds Claude rows from a paired runtime client that never negotiated them', () => { + const projected = projectSessionTabAgentStatus( + claudeSnapshot, + 'runtime', + [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + true + ) - expect(projected.tabs.map((tab) => tab.id)).toEqual(['agent-session:codex']) - // A row pruned from `tabs` but left in the layout is its own dead tab. - expect(projected.tabGroups?.map((group) => group.id)).toEqual(['group-a']) - expect(projected.tabGroupLayout).toEqual({ type: 'leaf', groupId: 'group-a' }) - expect(projected.activeGroupId).toBe('group-a') - expect(projected.activeTabId).toBe('agent-session:codex') - expect(projected.activeTabType).toBe('agent-session') + expect(projected.tabs.map((tab) => tab.id)).toEqual(['agent-session:codex']) + // A row pruned from `tabs` but left in the layout is its own dead tab. + expect(projected.tabGroups?.map((group) => group.id)).toEqual(['group-a']) + expect(projected.tabGroupLayout).toEqual({ type: 'leaf', groupId: 'group-a' }) + expect(projected.activeGroupId).toBe('group-a') + expect(projected.activeTabId).toBe('agent-session:codex') + expect(projected.activeTabType).toBe('agent-session') + }) + + it('uses a desktop fallback for an unsupported Claude row instead of withholding it', () => { + const projected = projectSessionTabAgentStatus( + claudeSnapshot, + 'mobile', + [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + true + ) + + // The row survives so the chat the desktop shows is not simply absent on the phone. + expect(projected.tabs.map((tab) => tab.id)).toEqual([ + 'agent-session:codex', + 'agent-session:claude' + ]) + expect(projected.tabs.map((tab) => tab.title)).toEqual([ + 'Codex Chat', + CLAUDE_STRUCTURED_CHAT_DESKTOP_ONLY_TAB_TITLE + ]) + // Nothing is removed, so the layout it belonged to is untouched. + expect(projected.tabGroups?.map((group) => group.id)).toEqual(['group-a', 'group-b']) + expect(projected.tabGroupLayout).toEqual(claudeSnapshot.tabGroupLayout) + expect(projected.activeTabId).toBe('agent-session:codex') + }) + + it('projects agent-specific fallback titles for a mobile client with no capabilities', () => { + const projected = projectSessionTabAgentStatus(claudeSnapshot, 'mobile', [], true) + + expect(projected.tabs.map((tab) => tab.title)).toEqual([ + STRUCTURED_CHAT_UPDATE_REQUIRED_TAB_TITLE, + CLAUDE_STRUCTURED_CHAT_DESKTOP_ONLY_TAB_TITLE + ]) + expect(projected.tabGroupLayout).toEqual(claudeSnapshot.tabGroupLayout) + }) + + it('does not treat the Claude capability as a substitute for the base structured capability', () => { + const projected = projectSessionTabAgentStatus( + claudeSnapshot, + 'mobile', + [CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + true + ) + + expect(projected.tabs.map((tab) => tab.title)).toEqual([ + STRUCTURED_CHAT_UPDATE_REQUIRED_TAB_TITLE, + CLAUDE_STRUCTURED_CHAT_DESKTOP_ONLY_TAB_TITLE + ]) + }) + + it('shows both real titles once mobile negotiates Claude', () => { + expect(projectSessionTabAgentStatus(claudeSnapshot, 'mobile', structuredMobile, true)).toBe( + claudeSnapshot + ) + }) + + // Why: updating cannot reveal a chat the desktop is not serving, so the prompt would lie. + it('withholds rather than prompts when the desktop experiment is off', () => { + for (const capabilities of [ + [], + [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + structuredMobile + ]) { + const projected = projectSessionTabAgentStatus(claudeSnapshot, 'mobile', capabilities, false) + expect(projected.tabs).toEqual([]) } - ) + }) + + it('never emits an empty structured tab title', () => { + for (const capabilities of [[], [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]]) { + const projected = projectSessionTabAgentStatus(claudeSnapshot, 'mobile', capabilities, true) + for (const tab of projected.tabs) { + expect(tab.title.length).toBeGreaterThan(0) + } + } + }) it.each([ ['mobile', 'mobile' as const, structuredMobile], diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts index 0e0d9c716a5..4496fdc5435 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts @@ -1,6 +1,7 @@ import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../../shared/protocol-version' import type { @@ -13,6 +14,43 @@ import { structuredNativeChatProjectionEnabled } from './structured-agent-sessio type SessionTabsPayload = RuntimeMobileSessionTabsResult | RuntimeMobileSessionTabsSnapshot +/** Capped at 128px / one line in every shipped mobile build, so ~15-18 characters render. */ +export const STRUCTURED_CHAT_UPDATE_REQUIRED_TAB_TITLE = 'Update to view' +export const CLAUDE_STRUCTURED_CHAT_DESKTOP_ONLY_TAB_TITLE = 'Open on desktop' + +function clientCanRenderStructuredAgentSessionTab( + tab: RuntimeMobileSessionAgentTab, + clientCapabilities: readonly RuntimeCapability[] | undefined +): boolean { + if (!clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) { + return false + } + return ( + tab.agent === 'codex' || + clientCapabilities.includes(CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + ) +} + +function resolveMobileStructuredChatFallbackTitle( + tab: RuntimeMobileSessionAgentTab, + args: { + clientKind: 'mobile' | 'runtime' | undefined + clientCapabilities: readonly RuntimeCapability[] | undefined + structuredNativeChatEnabled?: boolean + } +): string | null { + if ( + args.clientKind !== 'mobile' || + args.structuredNativeChatEnabled !== true || + clientCanRenderStructuredAgentSessionTab(tab, args.clientCapabilities) + ) { + return null + } + return tab.agent === 'claude' + ? CLAUDE_STRUCTURED_CHAT_DESKTOP_ONLY_TAB_TITLE + : STRUCTURED_CHAT_UPDATE_REQUIRED_TAB_TITLE +} + export function projectSessionTabAgentStatus( payload: TPayload, clientKind: 'mobile' | 'runtime' | undefined, @@ -24,16 +62,27 @@ export function projectSessionTabAgentStatus true) - // Why: a paired client renders only codex structured tabs unless it says otherwise - // (mobile's resolveMobileNativeChat returns null for every other agent), so an - // ungated row would list and select into a pane that shows neither chat nor terminal. - if ( - structuredVisible && - clientKind !== undefined && - !clientCapabilities?.includes(CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) - ) { - projected = projectAgentSessionTabsOut(projected, (tab) => tab.agent !== 'codex') + let projected: TPayload + if (clientKind === 'mobile' && structuredNativeChatEnabled === true) { + // Why: deleting the row left the user hunting for a chat the desktop says exists; the row + // survives with a title naming the fix. Nothing is removed, so no group/layout repair applies. + projected = projectUnsupportedAgentSessionTabTitles(payload, { + clientKind, + clientCapabilities, + structuredNativeChatEnabled + }) + } else { + projected = structuredVisible ? payload : projectAgentSessionTabsOut(payload, () => true) + // Why: a paired client renders only codex structured tabs unless it says otherwise + // (mobile's resolveMobileNativeChat returns null for every other agent), so an + // ungated row would list and select into a pane that shows neither chat nor terminal. + if ( + structuredVisible && + clientKind !== undefined && + !clientCapabilities?.includes(CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + ) { + projected = projectAgentSessionTabsOut(projected, (tab) => tab.agent !== 'codex') + } } // Why: only paired runtimes have legacy `done` completion side effects; mobile must keep its row without changing the exact v2 auth shape. if ( @@ -55,6 +104,47 @@ export function projectSessionTabAgentStatus( + payload: TPayload, + args: { + clientKind: 'mobile' + clientCapabilities: readonly RuntimeCapability[] | undefined + structuredNativeChatEnabled: true + } +): TPayload { + let changed = false + const tabs = payload.tabs.map((tab) => { + if (tab.type !== 'agent-session') { + return tab + } + const title = resolveMobileStructuredChatFallbackTitle(tab, args) + if (title === null) { + return tab + } + changed = true + return { ...tab, title } + }) + return changed ? ({ ...payload, tabs } as TPayload) : payload +} + +export function assertAgentSessionTabDestructiveMutationSupported( + payload: SessionTabsPayload, + tabId: string, + clientKind: 'mobile' | 'runtime' | undefined, + clientCapabilities: readonly RuntimeCapability[] | undefined +): void { + if (clientKind === undefined) { + return + } + const tab = payload.tabs.find((candidate) => candidate.id === tabId) + if ( + tab?.type === 'agent-session' && + !clientCanRenderStructuredAgentSessionTab(tab, clientCapabilities) + ) { + throw new Error('structured_agent_session_unsupported') + } +} + function projectAgentSessionTabsOut( payload: TPayload, shouldHide: (tab: RuntimeMobileSessionAgentTab) => boolean diff --git a/src/main/runtime/rpc/methods/session-tab-close-methods.ts b/src/main/runtime/rpc/methods/session-tab-close-methods.ts index bd60ecd6ddf..361ba8e4c51 100644 --- a/src/main/runtime/rpc/methods/session-tab-close-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-close-methods.ts @@ -3,6 +3,7 @@ import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/ import { defineMethod, type RpcAnyMethod } from '../core' import { CloseLifecycleTab, CloseTab } from './session-tabs-schemas' import { assertProjectedSessionTabVisible } from './session-tab-browser-placement-projection' +import { assertAgentSessionTabDestructiveMutationSupported } from './session-tab-agent-status-projection' import { projectSessionTabsForClient } from './session-tabs-inventory' import { isStructuredNativeChatEnabled } from './structured-agent-session-policy' @@ -12,8 +13,12 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ params: CloseTab, handler: async (params, context) => { if (context.clientKind) { + const raw = await context.runtime.listMobileSessionTabs( + params.worktree, + context.pairedDeviceId + ) const visible = projectSessionTabsForClient( - await context.runtime.listMobileSessionTabs(params.worktree, context.pairedDeviceId), + raw, context.clientKind, context.clientCapabilities, context.clientKind === 'mobile' @@ -21,6 +26,12 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ : undefined ) assertProjectedSessionTabVisible(visible, params.tabId) + assertAgentSessionTabDestructiveMutationSupported( + raw, + params.tabId, + context.clientKind, + context.clientCapabilities + ) } const requiresIntent = context.clientKind === undefined || @@ -81,8 +92,12 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ params: CloseLifecycleTab, handler: async (params, context) => { if (context.clientKind) { + const raw = await context.runtime.listMobileSessionTabs( + params.worktree, + context.pairedDeviceId + ) const visible = projectSessionTabsForClient( - await context.runtime.listMobileSessionTabs(params.worktree, context.pairedDeviceId), + raw, context.clientKind, context.clientCapabilities, context.clientKind === 'mobile' @@ -90,6 +105,12 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ : undefined ) assertProjectedSessionTabVisible(visible, params.tabId) + assertAgentSessionTabDestructiveMutationSupported( + raw, + params.tabId, + context.clientKind, + context.clientCapabilities + ) } return withSpan( 'runtime.session-tabs.close-lifecycle', diff --git a/src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts b/src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts new file mode 100644 index 00000000000..083f334285e --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { SESSION_TAB_METHODS } from './session-tabs' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('session tab structured restore gating', () => { + it('does not restore structured tabs for mobile while the host setting is off', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: false })), + restoreStructuredAgentSessionTabs: vi.fn(), + listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.list', { worktree: 'id:wt-1' }), + { + clientKind: 'mobile', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + } + ) + + expect(response.ok).toBe(true) + expect(runtime.restoreStructuredAgentSessionTabs).not.toHaveBeenCalled() + }) + + // Why: an old build has no capability to advertise, and skipping the restore left it with + // nothing to project after a desktop restart — neither the chat nor its fallback row. + it('restores structured tabs for a mobile client that advertises no capability', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })), + restoreStructuredAgentSessionTabs: vi.fn(), + listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.list', { worktree: 'id:wt-1' }), + { clientKind: 'mobile', clientCapabilities: [] } + ) + + expect(response.ok).toBe(true) + expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1) + }) + + it('restores structured tabs for mobile once the setting is present', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })), + restoreStructuredAgentSessionTabs: vi.fn(), + listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.list', { worktree: 'id:wt-1' }), + { + clientKind: 'mobile', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + } + ) + + expect(response.ok).toBe(true) + expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1) + }) +}) + +function visibleSnapshot() { + return { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'tab-1::leaf-1', + activeTabType: 'terminal' as const, + tabGroups: [{ id: 'group-1', activeTabId: 'tab-1', tabOrder: ['tab-1'] }], + tabs: [ + { + type: 'terminal' as const, + id: 'tab-1::leaf-1', + parentTabId: 'tab-1', + leafId: 'leaf-1', + title: 'Terminal', + status: 'ready' as const, + terminal: 'pty-1', + isActive: true + } + ] + } +} diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index 29131869fa0..be61fc55edf 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -2,10 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' -import { - SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY -} from '../../../../shared/protocol-version' +import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { SESSION_TAB_METHODS } from './session-tabs' function makeRequest(method: string, params?: unknown): RpcRequest { @@ -13,48 +10,6 @@ function makeRequest(method: string, params?: unknown): RpcRequest { } describe('session tab RPC methods', () => { - it('does not restore structured tabs for mobile while the host setting is off', async () => { - const runtime = { - getRuntimeId: () => 'test-runtime', - getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: false })), - restoreStructuredAgentSessionTabs: vi.fn(), - listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()) - } as unknown as OrcaRuntimeService - const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) - - const response = await dispatcher.dispatch( - makeRequest('session.tabs.list', { worktree: 'id:wt-1' }), - { - clientKind: 'mobile', - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] - } - ) - - expect(response.ok).toBe(true) - expect(runtime.restoreStructuredAgentSessionTabs).not.toHaveBeenCalled() - }) - - it('restores structured tabs for mobile only after capability and setting are present', async () => { - const runtime = { - getRuntimeId: () => 'test-runtime', - getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })), - restoreStructuredAgentSessionTabs: vi.fn(), - listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()) - } as unknown as OrcaRuntimeService - const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) - - const response = await dispatcher.dispatch( - makeRequest('session.tabs.list', { worktree: 'id:wt-1' }), - { - clientKind: 'mobile', - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] - } - ) - - expect(response.ok).toBe(true) - expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1) - }) - it('routes mobile-only activation without notifying desktop clients', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts index 33018c21f4d..e15544b2d24 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -2,8 +2,11 @@ // // Shared by every structured method file so one gate governs the whole surface: a client that does // not advertise `agent-session.structured.v1` is told the surface does not exist rather than being -// handed a session it cannot render or drive — and, just as importantly, cannot make the host EXIST -// by calling into it, which is an observable side effect. +// handed the session journal or mutation surface. +// +// This gate no longer implies such a client cannot make the host exist: session-tab restore runs +// for old mobile clients while structured chat is enabled so they receive a fallback row, and that +// path constructs the host. `agentSession.*` stays refused either way, which is what this gate is for. import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 3b18f6b0ef1..abe196bd636 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -2,9 +2,8 @@ // // Every method here is gated on the client advertising // `agent-session.structured.v1`. A client that does not is told the surface does -// not exist rather than being handed a session it cannot render or drive; that -// is the whole visibility rule, because nothing else on the runtime publishes a -// structured session. +// not exist rather than receiving the journal or mutation surface. Session-tab +// inventory may expose only a metadata placeholder for an incapable mobile client. import { agentSessionFingerprintConflict, diff --git a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts index 4333713a445..f9efa46d16d 100644 --- a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts +++ b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts @@ -1,13 +1,24 @@ import type { RpcContext } from '../core' -import { supportsStructuredAgentSessions } from './structured-agent-session-policy' +import { + isStructuredNativeChatEnabled, + supportsStructuredAgentSessions +} from './structured-agent-session-policy' +/** Republishes structured tabs into the host's own snapshot map. + * + * Mobile is gated on the host setting alone, NOT on the client's capability: an old build is + * shown a fallback prompt in place of each chat, and gating on capability left it with nothing to + * project after a desktop restart — no chat and no prompt. The setting still gates it, because + * with structured chat off there is nothing for any mobile client to reach. Restoring spawns no + * provider child for a cleanly closed session. */ export async function restoreStructuredTabsIfSupported( context: Pick ): Promise { - if ( - supportsStructuredAgentSessions(context) && - typeof context.runtime.restoreStructuredAgentSessionTabs === 'function' - ) { + const shouldRestore = + context.clientKind === 'mobile' + ? isStructuredNativeChatEnabled(context.runtime) + : supportsStructuredAgentSessions(context) + if (shouldRestore && typeof context.runtime.restoreStructuredAgentSessionTabs === 'function') { await context.runtime.restoreStructuredAgentSessionTabs() } } diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index d78c6223c99..76e1252640a 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -121,13 +121,12 @@ export const AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY = 'agent-session.host-authority.v1' as const export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY = 'agent-session.omp-resume-path.v1' as const -// Why: structured sessions are journal-backed, not PTY-backed, so a client that -// cannot read them must not see them at all — it would render an agent tab it -// can neither display nor drive. The host also refuses every agentSession.* -// method from a connection that does not advertise this. +// Why: structured sessions are journal-backed, not PTY-backed, so an incapable client must not +// receive their journal or drive their lifecycle. Mobile may receive a metadata-only placeholder; +// the host still refuses agentSession.* methods and destructive tab mutations without capability. export const STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.structured.v1' as const -// Why: mobile clients advertise Claude-structured session support during E2EE -// pairing so the desktop can keep structured-specific affordances enabled. +// Why: paired clients advertise Claude-structured support so the host can gate its agent-specific +// journal and lifecycle surfaces independently from Codex support. export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.structured.claude.v1' as const // Why: paired structured clients explicitly hold every visible session surface, allowing the host diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 2ad8bd0647f..a2a64897fcb 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -2,9 +2,14 @@ // way the terminal wire harness is: current code against a real published release. // // Three skews matter here, and none can be checked from one build alone — an old -// client must not be shown a session it cannot render, a new client must find an -// old host's missing surface cleanly, and a client's cursor must survive the host -// process that minted it. +// client must not receive a journal-backed RPC surface it cannot read, a new client +// must find an old host's missing surface cleanly, and a client's cursor must survive +// the host process that minted it. +// +// The session-tabs projection may keep a metadata-only row for an incapable mobile client so the +// chat is not simply absent on the phone. Every `agentSession.*` method and destructive close stays +// refused, which is what the tests below pin; the row-level behaviour is pinned in +// src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os'