From e00299d71b2ea6ce344099e713feec31186e27f3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:00:34 -0700 Subject: [PATCH 01/18] fix(native-chat): a host admits structured sessions by client capability, not its own chat setting A host's experimentalStructuredNativeChat decided whether any paired client could reach agentSession.* at all, and whether session.tabs.* showed it structured tabs. That setting is the host user's own launch preference: whether a new agent opens as a chat or a terminal is decided by whoever launches it. Using it as admission control meant a client whose own preference was "structured chat" was refused on a host whose preference was "terminal", and chats opened while the setting was on were withheld from mobile once it was turned off. The gate now asks one thing: did the client advertise agent-session.structured.v1 (in-process callers negotiate nothing and are always admitted). Tab projection and restore follow the same rule. With the setting no longer gating anything, the separate cleanup gate (close, cancel, unsubscribe, release), which existed only so those kept working after the setting was switched off, is identical to the main gate and is folded into it. The settings listener that republished tabs when the setting changed is removed, since projection no longer depends on it. The host setting still picks the default for launches that start on the host itself (agent.launch from mobile, orchestration worker-start). --- src/main/runtime/orca-runtime-state-fields.ts | 5 - ...me-structured-native-chat-settings.test.ts | 21 --- ...ession-tab-agent-status-projection.test.ts | 123 +++++------------- .../session-tab-agent-status-projection.ts | 19 +-- .../rpc/methods/session-tab-close-methods.ts | 7 +- .../methods/session-tab-mutation-methods.ts | 21 +-- ...session-tabs-inventory-census-race.test.ts | 4 + .../rpc/methods/session-tabs-inventory.ts | 21 +-- .../session-tabs-structured-restore.test.ts | 108 +++++---------- src/main/runtime/rpc/methods/session-tabs.ts | 20 +-- ...structured-agent-session-admission.test.ts | 113 ++++++++-------- ...ession-gate-classification.test-fixture.ts | 10 +- .../methods/structured-agent-session-gate.ts | 44 +------ .../structured-agent-session-hold.test.ts | 4 +- .../methods/structured-agent-session-hold.ts | 4 +- .../structured-agent-session-policy.test.ts | 103 +++------------ .../structured-agent-session-policy.ts | 56 ++------ .../methods/structured-agent-session.test.ts | 14 +- .../rpc/methods/structured-agent-session.ts | 12 +- .../methods/structured-session-tab-restore.ts | 19 +-- ...uctured-agent-session-host-refusal.test.ts | 6 +- 21 files changed, 206 insertions(+), 528 deletions(-) delete mode 100644 src/main/runtime/orca-runtime-structured-native-chat-settings.test.ts diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index ef54b73252c7..e80fc0739b1c 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -119,11 +119,6 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { this.store = store this.machineName.start() this.prepareClaudeAuth = deps?.prepareClaudeAuth - store?.onSettingsChanged?.((updates) => { - if ('experimentalStructuredNativeChat' in updates) { - this.notifyMobileSessionTabsChanged() - } - }) const runtime = this as RuntimeCommandSurfaceHost installRuntimeFileCommandSurface(runtime, this.fileCommands) installRuntimeGitCommandSurface(runtime, this.gitCommands) diff --git a/src/main/runtime/orca-runtime-structured-native-chat-settings.test.ts b/src/main/runtime/orca-runtime-structured-native-chat-settings.test.ts deleted file mode 100644 index 90be5c61ba24..000000000000 --- a/src/main/runtime/orca-runtime-structured-native-chat-settings.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { OrcaRuntimeService } from './orca-runtime' - -describe('structured native chat settings', () => { - it('republishes mobile session tabs when the host visibility setting changes', () => { - const settingsListeners: ((updates: Record) => void)[] = [] - const runtime = new OrcaRuntimeService({ - onSettingsChanged: vi.fn((listener) => { - settingsListeners.push(listener as (updates: Record) => void) - return vi.fn() - }) - } as never) - const notify = vi.spyOn(runtime, 'notifyMobileSessionTabsChanged').mockImplementation(() => {}) - - settingsListeners[0]?.({ compactWorktreeCards: true }) - expect(notify).not.toHaveBeenCalled() - - settingsListeners[0]?.({ experimentalStructuredNativeChat: true }) - expect(notify).toHaveBeenCalledTimes(1) - }) -}) 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 61bb30bdbcf8..d06f5819e7d1 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 @@ -87,9 +87,9 @@ describe('projectSessionTabAgentStatus', () => { } ] } - // A paired client that never negotiated the capability, with the setting on: mobile keeps an - // unrenderable row under a fallback title, so only a non-mobile old client still loses them. - const oldClient = projectSessionTabAgentStatus(snapshot, 'runtime', [], true) + // A paired client that never negotiated the capability: mobile keeps an unrenderable row under + // a fallback title, so only a non-mobile old client still loses them. + const oldClient = projectSessionTabAgentStatus(snapshot, 'runtime', []) expect(oldClient.tabs.map((tab) => tab.type)).toEqual(['terminal']) expect(oldClient.activeTabId).toBe('tab-1::leaf-1') expect(oldClient.activeTabType).toBe('terminal') @@ -98,42 +98,18 @@ describe('projectSessionTabAgentStatus', () => { expect(oldClient.tabGroups).toHaveLength(1) expect(oldClient.tabGroupLayout).toEqual({ type: 'leaf', groupId: 'group-a' }) - expect( - projectSessionTabAgentStatus( - snapshot, - 'mobile', - [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], - false - ) - ).toEqual(oldClient) - - const capableMobile = projectSessionTabAgentStatus( - snapshot, - 'mobile', - [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], - true - ) + const capableMobile = projectSessionTabAgentStatus(snapshot, 'mobile', [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]) expect(capableMobile).toBe(snapshot) - const capable = projectSessionTabAgentStatus( - snapshot, - 'runtime', - [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], - true - ) + const capable = projectSessionTabAgentStatus(snapshot, 'runtime', [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]) expect(capable).toBe(snapshot) - // The host setting is policy for every caller, so a capable desktop client with the - // setting off sees the same projection an old client does. - expect( - projectSessionTabAgentStatus( - snapshot, - 'runtime', - [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], - false - ) - ).toEqual(oldClient) - expect(projectSessionTabAgentStatus(snapshot, undefined, undefined, false)).toEqual(oldClient) + // The in-process caller is the host's own build and negotiates nothing. + expect(projectSessionTabAgentStatus(snapshot, undefined, undefined)).toBe(snapshot) }) const claudeSnapshot = { @@ -177,12 +153,9 @@ describe('projectSessionTabAgentStatus', () => { ] it('withholds Claude rows from a paired runtime client that never negotiated them', () => { - const projected = projectSessionTabAgentStatus( - claudeSnapshot, - 'runtime', - [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], - true - ) + const projected = projectSessionTabAgentStatus(claudeSnapshot, 'runtime', [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]) 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. @@ -194,12 +167,9 @@ describe('projectSessionTabAgentStatus', () => { }) 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 - ) + const projected = projectSessionTabAgentStatus(claudeSnapshot, 'mobile', [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]) // The row survives so the chat the desktop shows is not simply absent on the phone. expect(projected.tabs.map((tab) => tab.id)).toEqual([ @@ -217,7 +187,7 @@ describe('projectSessionTabAgentStatus', () => { }) it('projects agent-specific fallback titles for a mobile client with no capabilities', () => { - const projected = projectSessionTabAgentStatus(claudeSnapshot, 'mobile', [], true) + const projected = projectSessionTabAgentStatus(claudeSnapshot, 'mobile', []) expect(projected.tabs.map((tab) => tab.title)).toEqual([ STRUCTURED_CHAT_UPDATE_REQUIRED_TAB_TITLE, @@ -227,12 +197,9 @@ describe('projectSessionTabAgentStatus', () => { }) 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 - ) + const projected = projectSessionTabAgentStatus(claudeSnapshot, 'mobile', [ + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]) expect(projected.tabs.map((tab) => tab.title)).toEqual([ STRUCTURED_CHAT_UPDATE_REQUIRED_TAB_TITLE, @@ -241,26 +208,15 @@ describe('projectSessionTabAgentStatus', () => { }) it('shows both real titles once mobile negotiates Claude', () => { - expect(projectSessionTabAgentStatus(claudeSnapshot, 'mobile', structuredMobile, true)).toBe( + expect(projectSessionTabAgentStatus(claudeSnapshot, 'mobile', structuredMobile)).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) + const projected = projectSessionTabAgentStatus(claudeSnapshot, 'mobile', capabilities) for (const tab of projected.tabs) { expect(tab.title.length).toBeGreaterThan(0) } @@ -280,7 +236,7 @@ describe('projectSessionTabAgentStatus', () => { ])( 'publishes Claude rows to a paired %s client that negotiated them', (_name, clientKind, capabilities) => { - const projected = projectSessionTabAgentStatus(claudeSnapshot, clientKind, capabilities, true) + const projected = projectSessionTabAgentStatus(claudeSnapshot, clientKind, capabilities) expect(projected).toBe(claudeSnapshot) expect(projected.tabGroupLayout).toEqual(claudeSnapshot.tabGroupLayout) @@ -288,10 +244,8 @@ describe('projectSessionTabAgentStatus', () => { ) it('keeps Claude rows on the local renderer, which negotiates nothing', () => { - expect(projectSessionTabAgentStatus(claudeSnapshot, undefined, undefined, true)).toBe( - claudeSnapshot - ) - expect(projectSessionTabAgentStatus(claudeSnapshot, undefined, [], true)).toBe(claudeSnapshot) + expect(projectSessionTabAgentStatus(claudeSnapshot, undefined, undefined)).toBe(claudeSnapshot) + expect(projectSessionTabAgentStatus(claudeSnapshot, undefined, [])).toBe(claudeSnapshot) }) it('leaves Codex rows untouched whether or not the Claude capability is present', () => { @@ -304,16 +258,14 @@ describe('projectSessionTabAgentStatus', () => { for (const capabilities of [[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], structuredMobile]) { for (const clientKind of ['mobile', 'runtime'] as const) { - expect(projectSessionTabAgentStatus(codexOnly, clientKind, capabilities, true)).toBe( - codexOnly - ) + expect(projectSessionTabAgentStatus(codexOnly, clientKind, capabilities)).toBe(codexOnly) } } - expect(projectSessionTabAgentStatus(codexOnly, undefined, undefined, true)).toBe(codexOnly) + expect(projectSessionTabAgentStatus(codexOnly, undefined, undefined)).toBe(codexOnly) }) it('withholds session boundaries from legacy paired clients', () => { - const projected = projectSessionTabAgentStatus(makeSnapshot(true), 'runtime', [], true) + const projected = projectSessionTabAgentStatus(makeSnapshot(true), 'runtime', []) expect(projected.tabs[0]).not.toHaveProperty('agentStatus') }) @@ -322,12 +274,7 @@ describe('projectSessionTabAgentStatus', () => { const snapshot = makeSnapshot(true) expect( - projectSessionTabAgentStatus( - snapshot, - 'runtime', - [AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY], - true - ) + projectSessionTabAgentStatus(snapshot, 'runtime', [AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY]) ).toBe(snapshot) }) @@ -336,12 +283,8 @@ describe('projectSessionTabAgentStatus', () => { const mobileBoundary = makeSnapshot(true) const runtimeCompletion = makeSnapshot(false) - expect(projectSessionTabAgentStatus(localBoundary, undefined, undefined, true)).toBe( - localBoundary - ) - expect(projectSessionTabAgentStatus(mobileBoundary, 'mobile', [], true)).toBe(mobileBoundary) - expect(projectSessionTabAgentStatus(runtimeCompletion, 'runtime', [], true)).toBe( - runtimeCompletion - ) + expect(projectSessionTabAgentStatus(localBoundary, undefined, undefined)).toBe(localBoundary) + expect(projectSessionTabAgentStatus(mobileBoundary, 'mobile', [])).toBe(mobileBoundary) + expect(projectSessionTabAgentStatus(runtimeCompletion, 'runtime', [])).toBe(runtimeCompletion) }) }) 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 e2aa9ae7b009..5686d8135592 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 @@ -10,7 +10,7 @@ import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' import type { TabGroupLayoutNode } from '../../../../shared/tab-types' -import { structuredNativeChatProjectionEnabled } from './structured-agent-session-policy' +import { supportsStructuredAgentSessions } from './structured-agent-session-policy' type SessionTabsPayload = RuntimeMobileSessionTabsResult | RuntimeMobileSessionTabsSnapshot @@ -36,12 +36,10 @@ function resolveMobileStructuredChatFallbackTitle( 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 @@ -54,22 +52,16 @@ function resolveMobileStructuredChatFallbackTitle( export function projectSessionTabAgentStatus( payload: TPayload, clientKind: 'mobile' | 'runtime' | undefined, - clientCapabilities: readonly RuntimeCapability[] | undefined, - structuredNativeChatEnabled: boolean + clientCapabilities: readonly RuntimeCapability[] | undefined ): TPayload { - const structuredVisible = structuredNativeChatProjectionEnabled({ - clientKind, - clientCapabilities, - structuredNativeChatEnabled - }) + const structuredVisible = supportsStructuredAgentSessions({ clientKind, clientCapabilities }) let projected: TPayload - if (clientKind === 'mobile' && structuredNativeChatEnabled === true) { + if (clientKind === 'mobile') { // 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 + clientCapabilities }) } else { projected = structuredVisible ? payload : projectAgentSessionTabsOut(payload, () => true) @@ -109,7 +101,6 @@ function projectUnsupportedAgentSessionTabTitles[2] = params if (clientKind) { const raw = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) - const projected = projectSessionTabsForClient( - raw, - clientKind, - clientCapabilities, - isStructuredNativeChatEnabled(runtime) - ) + const projected = projectSessionTabsForClient(raw, clientKind, clientCapabilities) translated = translateProjectedSessionTabMove(raw, projected, params) } const base = { tabId: translated.tabId, targetGroupId: translated.targetGroupId } @@ -142,8 +130,7 @@ async function assertVisibleMutationTab( const visible = projectSessionTabsForClient( await runtime.listMobileSessionTabs(worktree, pairedDeviceId), clientKind, - clientCapabilities, - isStructuredNativeChatEnabled(runtime) + clientCapabilities ) assertProjectedSessionTabVisible(visible, tabId) } diff --git a/src/main/runtime/rpc/methods/session-tabs-inventory-census-race.test.ts b/src/main/runtime/rpc/methods/session-tabs-inventory-census-race.test.ts index e8166bdd0566..77c751174819 100644 --- a/src/main/runtime/rpc/methods/session-tabs-inventory-census-race.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs-inventory-census-race.test.ts @@ -127,6 +127,9 @@ function createRuntimeHarness(initialSnapshots: RuntimeMobileSessionTabsSnapshot connectionId: 'conn-runtime-census-race', requestId: 'req-runtime-census-race', pairedDeviceId: 'paired-runtime-census-race', + // A client that cannot read structured sessions has no restore ahead of its census, which + // is the timing these microtask-ordered cases are written against. + clientKind: 'runtime', clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY] }, emit @@ -526,6 +529,7 @@ describe.skipIf(runningBaselineOracle)('real runtime session tabs census boundar runtime, connectionId: 'conn-abort', requestId: 'req-abort', + clientKind: 'runtime', signal: controller.signal }, vi.fn() diff --git a/src/main/runtime/rpc/methods/session-tabs-inventory.ts b/src/main/runtime/rpc/methods/session-tabs-inventory.ts index 3ab06a298fc6..0e80deba85d8 100644 --- a/src/main/runtime/rpc/methods/session-tabs-inventory.ts +++ b/src/main/runtime/rpc/methods/session-tabs-inventory.ts @@ -5,7 +5,6 @@ import type { RpcContext } from '../core' import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection' import { projectSessionTabBrowserPlacements } from './session-tab-browser-placement-projection' import { createSessionTabsRetirementProofDelta } from './session-tabs-retirement-proof-delta' -import { isStructuredNativeChatEnabled } from './structured-agent-session-policy' import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore' type SessionTabsInventory = { @@ -29,16 +28,10 @@ function clientUnderstandsAuthoritativeInventory(context: RpcContext): boolean { export function projectSessionTabsForClient( snapshot: RuntimeMobileSessionTabsResult, clientKind: 'mobile' | 'runtime' | undefined, - clientCapabilities: Parameters[2], - structuredNativeChatEnabled: boolean + clientCapabilities: Parameters[2] ): RuntimeMobileSessionTabsResult { return projectSessionTabBrowserPlacements( - projectSessionTabAgentStatus( - snapshot, - clientKind, - clientCapabilities, - structuredNativeChatEnabled - ), + projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities), clientCapabilities ) } @@ -49,12 +42,7 @@ function projectInventory( ): SessionTabsInventory { return { snapshots: inventory.snapshots.map((snapshot) => - projectSessionTabsForClient( - snapshot, - context.clientKind, - context.clientCapabilities, - isStructuredNativeChatEnabled(context.runtime) - ) + projectSessionTabsForClient(snapshot, context.clientKind, context.clientCapabilities) ), ...(inventory.authoritative && clientUnderstandsAuthoritativeInventory(context) ? { authoritative: true as const } @@ -125,8 +113,7 @@ export async function subscribeSessionTabsInventory( projectSessionTabsForClient( snapshot, context.clientKind, - context.clientCapabilities, - isStructuredNativeChatEnabled(context.runtime) + context.clientCapabilities ) as SessionTabsChange const withoutNavigationIntent = (snapshot: SessionTabsChange): SessionTabsChange => { if (snapshot.navigationIntent === undefined) { 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 index a7bcab537428..4fbcfe41f935 100644 --- a/src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, type Mock } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' @@ -10,96 +10,60 @@ function makeRequest(method: string, params?: unknown): RpcRequest { return { id: 'req-1', authToken: 'tok', method, params } } -function makeRuntime(experimentalStructuredNativeChat: boolean): OrcaRuntimeService { +// The host's own structured-chat setting is off throughout: it is a launch preference, so it must +// not decide whether chats a paired client opened come back after a restart. +function makeRuntime(): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', - getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat })), + getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: false })), restoreStructuredAgentSessionTabs: vi.fn(), listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()) } as unknown as OrcaRuntimeService } -describe('structured session tab restoration follows one rule for every caller', () => { - it('does not restore for the desktop renderer while the host setting is off', async () => { - const runtime = makeRuntime(false) - const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) +async function listTabs( + client?: Parameters[1] +): Promise { + const runtime = makeRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const response = await dispatcher.dispatch( + makeRequest('session.tabs.list', { worktree: 'id:wt-1' }), + client + ) + expect(response.ok).toBe(true) + return runtime +} - const response = await dispatcher.dispatch( - makeRequest('session.tabs.list', { worktree: 'id:wt-1' }), - { - clientKind: 'runtime', +describe('structured session tab restoration', () => { + it.each(['runtime', 'mobile'] as const)( + 'restores for a %s client that can read structured sessions', + async (clientKind) => { + const runtime = await listTabs({ + clientKind, clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] - } - ) - - expect(response.ok).toBe(true) - expect(runtime.restoreStructuredAgentSessionTabs).not.toHaveBeenCalled() - }) + }) - it('restores for the desktop renderer once the host setting is on', async () => { - const runtime = makeRuntime(true) - const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1) + } + ) - const response = await dispatcher.dispatch( - makeRequest('session.tabs.list', { worktree: 'id:wt-1' }), - { - clientKind: 'runtime', - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] - } - ) + it('restores for an in-process caller, which negotiates nothing', async () => { + const runtime = await listTabs() - expect(response.ok).toBe(true) expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1) }) - it('restores for an in-process caller on the same setting that admits remote clients', async () => { - const restoreCallsBySetting = new Map() - for (const enabled of [false, true]) { - const runtime = makeRuntime(enabled) - const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) - - await dispatcher.dispatch(makeRequest('session.tabs.list', { worktree: 'id:wt-1' })) - - restoreCallsBySetting.set( - enabled, - (runtime.restoreStructuredAgentSessionTabs as unknown as Mock).mock.calls.length - ) - } + // 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 for a mobile client that advertises no capability', async () => { + const runtime = await listTabs({ clientKind: 'mobile', clientCapabilities: [] }) - expect(restoreCallsBySetting.get(false)).toBe(0) - expect(restoreCallsBySetting.get(true)).toBe(1) + expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1) }) -}) - -describe('session tab structured restore gating', () => { - it('does not restore structured tabs for mobile while the host setting is off', async () => { - const runtime = makeRuntime(false) - 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] - } - ) + it('does not restore for a paired desktop that cannot read structured sessions', async () => { + const runtime = await listTabs({ clientKind: 'runtime', clientCapabilities: [] }) - 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 = makeRuntime(true) - 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) - }) }) diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 8d3d1af33bf2..d549daffbaf8 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -16,7 +16,6 @@ import { SESSION_TAB_MARKDOWN_METHODS } from './session-tab-markdown-methods' import { SESSION_TAB_MUTATION_METHODS } from './session-tab-mutation-methods' import { createSessionTabsRetirementProofDelta } from './session-tabs-retirement-proof-delta' import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore' -import { isStructuredNativeChatEnabled } from './structured-agent-session-policy' import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership' import { SessionTabsUnsubscribeAllParams } from '../../../../shared/rpc-contract/session-tabs-params' import { SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' @@ -30,8 +29,7 @@ export const SESSION_TAB_METHODS = [ return projectSessionTabsForClient( await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId), clientKind, - clientCapabilities, - isStructuredNativeChatEnabled(runtime) + clientCapabilities ) } }), @@ -162,14 +160,7 @@ export const SESSION_TAB_METHODS = [ const withProofDelta = createSessionTabsRetirementProofDelta(clientCapabilities) emit({ type: 'snapshot', - ...withProofDelta( - projectSessionTabsForClient( - initial, - clientKind, - clientCapabilities, - isStructuredNativeChatEnabled(runtime) - ) - ) + ...withProofDelta(projectSessionTabsForClient(initial, clientKind, clientCapabilities)) }) if (released) { return @@ -179,12 +170,7 @@ export const SESSION_TAB_METHODS = [ emit({ type: 'updated', ...withProofDelta( - projectSessionTabsForClient( - snapshot, - clientKind, - clientCapabilities, - isStructuredNativeChatEnabled(runtime) - ) + projectSessionTabsForClient(snapshot, clientKind, clientCapabilities) ) }) } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts index 554c0912ff03..b9527480b73e 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts @@ -1,11 +1,11 @@ -// Admission can be revoked while sessions are still open: the host setting is turned off with a -// chat already on screen. What the caller may still do to that chat is the rule this suite pins. +// The host's own structured-chat setting is its user's launch preference, not admission control: +// a paired client that can read structured sessions reaches every method whatever that setting says. import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { - ADMISSION_METHODS, - CLEANUP_METHODS + CLEANUP_METHODS, + WORK_METHODS } from './structured-agent-session-gate-classification.test-fixture' import { call, @@ -25,31 +25,50 @@ afterEach(() => { clearStructuredHostStub() }) -describe('admission revoked while a session is still open', () => { - // The host setting is admission control. Turning it off must not strand a chat that was opened - // while it was on: the pane is still mounted, so its close has to land. - const SETTING_OFF = { getClientSettings: () => ({ experimentalStructuredNativeChat: false }) } +const SETTING_OFF = { getClientSettings: () => ({ experimentalStructuredNativeChat: false }) } +const UNSUPPORTED = { message: expect.stringContaining('structured_agent_session_unsupported') } - it.each(CLEANUP_METHODS)( - 'still serves $method after the host setting is turned off', - async (entry) => { - const response = await call(entry.method, entry.params, STRUCTURED_CLIENT, SETTING_OFF) - - expect(response).toMatchObject({ ok: true }) - // `unsubscribe` retires runtime-owned subscriptions and `release` is a no-op, so neither - // calls the host: the result payload is the observable effect. - if (entry.hostCall === null) { - expect(response).toMatchObject({ result: entry.result }) - } else { - expect(hostCalls[entry.hostCall]).toHaveBeenCalled() +describe('a host with structured chat turned off', () => { + it.each(WORK_METHODS)('still serves $method to a capable client', async ({ method, params }) => { + const response = await call(method, params, STRUCTURED_CLIENT, SETTING_OFF).catch( + (error: Error) => { + // An admitted stream the stub never feeds answers nothing; a refused one replies at once. + expect(error.message).toBe(`no reply for ${method}`) + return null } + ) + + // Other failures are the stub's business; the one this pins is the gate's own refusal. + expect(response).not.toMatchObject({ ok: false, error: UNSUPPORTED }) + }) + + it.each(CLEANUP_METHODS)('still serves $method to a capable client', async (entry) => { + const response = await call(entry.method, entry.params, STRUCTURED_CLIENT, SETTING_OFF) + + expect(response).toMatchObject({ ok: true }) + // `unsubscribe` retires runtime-owned subscriptions and `release` is a no-op, so neither + // calls the host: the result payload is the observable effect. + if (entry.hostCall === null) { + expect(response).toMatchObject({ result: entry.result }) + } else { + expect(hostCalls[entry.hostCall]).toHaveBeenCalled() } - ) + }) - it('stops the provider child when closing a chat the setting no longer admits', async () => { - const response = await call('agentSession.close', { sessionId: SESSION }, STRUCTURED_CLIENT, { - ...SETTING_OFF - }) + it('creates a session for a paired client', async () => { + const create = WORK_METHODS.find((entry) => entry.method === 'agentSession.create')! + const response = await call(create.method, create.params, STRUCTURED_CLIENT, SETTING_OFF) + + expect(response).toMatchObject({ ok: true }) + }) + + it('stops the provider child and retires the tab when a chat is closed', async () => { + const response = await call( + 'agentSession.close', + { sessionId: SESSION }, + STRUCTURED_CLIENT, + SETTING_OFF + ) expect(response).toMatchObject({ ok: true, result: { ok: true } }) expect(hostCalls.close).toHaveBeenCalledWith(SESSION) @@ -57,7 +76,7 @@ describe('admission revoked while a session is still open', () => { expect(hostCalls.setSessionTabVisibility).toHaveBeenCalledWith(SESSION, false) }) - it('cancels an in-flight turn the setting no longer admits', async () => { + it('cancels an in-flight turn', async () => { const response = await call( 'agentSession.cancel', { envelope: envelope(), turnId: 'turn-1' }, @@ -69,6 +88,22 @@ describe('admission revoked while a session is still open', () => { expect(hostCalls.cancel).toHaveBeenCalledOnce() }) + it.each([...WORK_METHODS, ...CLEANUP_METHODS])( + 'refuses $method to a client that never advertised the capability', + async ({ method, params }) => { + const response = await call( + method, + params, + { clientKind: 'runtime', clientCapabilities: [] }, + { getClientSettings: () => ({ experimentalStructuredNativeChat: true }) } + ) + + // Asserting the gate's own code, not merely `ok: false`: a params-validation failure would + // pass a bare falsy check and hide a gate that had stopped refusing. + expect(response).toMatchObject({ ok: false, error: UNSUPPORTED }) + } + ) + it.each(['runtime', 'mobile'] as const)( 'lets a %s client close a chat it already owns', async (clientKind) => { @@ -83,30 +118,4 @@ describe('admission revoked while a session is still open', () => { expect(hostCalls.close).toHaveBeenCalledWith(SESSION) } ) - - it('lets an in-process caller close, which is how terminal disposal retires a chat', async () => { - const response = await call( - 'agentSession.close', - { sessionId: SESSION }, - undefined, - SETTING_OFF - ) - - expect(response).toMatchObject({ ok: true }) - expect(hostCalls.close).toHaveBeenCalledWith(SESSION) - }) - - it.each(ADMISSION_METHODS)( - 'keeps $method refused once the setting is off', - async ({ method, params }) => { - const response = await call(method, params, STRUCTURED_CLIENT, SETTING_OFF) - - // Asserting the gate's own code, not merely `ok: false`: a params-validation failure would - // pass a bare falsy check and hide a gate that had stopped refusing. - expect(response).toMatchObject({ - ok: false, - error: { message: expect.stringContaining('structured_agent_session_unsupported') } - }) - } - ) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts index a2876f284db8..12813a8f66ea 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts @@ -1,5 +1,5 @@ -// The method-to-gate classification from `structured-agent-session-gate.ts`, as a table the -// suites iterate. Adding an `agentSession.*` method means adding it to exactly one of these. +// Every `agentSession.*` method with valid params, as tables the suites iterate. Adding a method +// means adding it to exactly one of these. import { attachParams, @@ -9,7 +9,7 @@ import { } from './structured-agent-session-rpc.test-fixture' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' -/** Stops or retires work the caller already owns, so admission may already have been revoked. */ +/** Stops or retires work the caller already owns; none of these builds a host. */ export const CLEANUP_METHODS = [ { method: 'agentSession.close', @@ -36,8 +36,8 @@ export const CLEANUP_METHODS = [ } ] as const -/** Starts, extends, retains or reads work, so every one stays refused once the setting is off. */ -export const ADMISSION_METHODS = [ +/** Starts, extends, retains or reads work. */ +export const WORK_METHODS = [ { method: 'agentSession.createSupport', params: { worktree: 'id:workspace-1', agent: 'codex' } }, { method: 'agentSession.create', 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 0bed563af38b..4ee6c990b376 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -4,9 +4,9 @@ // not advertise `agent-session.structured.v1` is told the surface does not exist rather than being // 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. +// This gate does not imply such a client cannot make the host exist: session-tab restore runs for +// old mobile clients 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 { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' @@ -14,10 +14,7 @@ import type { StructuredAgentSessionHost } from '../../../native-chat/agent-sess import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' import type { RpcContext } from '../core' import { structuredAgentSessionHostRefusal } from '../../structured-agent-session-host-refusal' -import { - supportsStructuredAgentSessionCapability, - supportsStructuredAgentSessions -} from './structured-agent-session-policy' +import { supportsStructuredAgentSessions } from './structured-agent-session-policy' /** * In-process callers are the same build as the host, so they carry no negotiated @@ -40,40 +37,9 @@ export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHo return requireHostOrRefusal() } -/** - * WHICH GATE DOES A NEW `agentSession.*` METHOD GET? - * - * The host setting is admission control, and admission can be revoked while sessions are still - * open. So the surface splits by what a method does to work in flight, not by how dangerous it - * sounds: - * - * - Starts, extends, retains or reads work -> `requireStructuredHost`. Revoked admission means - * no new turns, no new holds, no new reads. create, send, ensure, setOption, - * subscribe, hold, reveal, history, options and the status stream all live here. - * - Stops or retires work the caller already owns -> `requireStructuredCleanupHost`. close, - * cancel, unsubscribe and release live here. - * - * Cleanup keeps working after the setting is turned off because the alternative strands the user: - * a session opened while the setting was on stays open, and refusing its close leaves a chat with - * a live provider child that its own owner can no longer shut down. Stopping is never the thing - * the policy exists to prevent. - * - * Cleanup is not an escape hatch. It still demands the negotiated wire capability, so a client - * that never advertised the surface still cannot see it, and it never creates a host — it can - * only retire what already exists. - */ -export function requireStructuredCleanupHost(ctx: RpcContext): StructuredAgentSessionHost { - if (!supportsStructuredAgentSessionCapability(ctx)) { - throw agentSessionRefusalError('structured_agent_session_unsupported', { - reason: 'clientCapabilityMissing' - }) - } - return requireHostOrRefusal() -} - /** * The host, or why there is none. A process whose journal would not open says so under every - * getter — cleanup included: nothing here can stop a child it never started. + * getter, close included: nothing here can stop a child it never started. */ function requireHostOrRefusal(): StructuredAgentSessionHost { const host = getStructuredAgentSessionHost() diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 000192cc183d..2a461613f2c3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -150,12 +150,12 @@ describe('the hold surface, for clients that still call it', () => { expect(host.hasSession(SESSION)).toBe(true) }) - it('refuses a hold once the setting is off, and still answers a release', async () => { + it('answers a hold and a release whatever the host structured-chat setting says', async () => { structuredNativeChatEnabled = false expect( await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: false }) + ).toMatchObject({ ok: true, result: { held: true } }) expect( await call('agentSession.release', { sessionId: SESSION, holderId: 'chat-1' }) ).toMatchObject({ ok: true, result: { released: true } }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts index e1975f1c7255..2ed733036c72 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts @@ -8,7 +8,7 @@ import { defineMethod } from '../core' import { requireInstalledStructuredHost, - requireStructuredCleanupHost + requireStructuredHost } from './structured-agent-session-gate' import { HoldParams } from './structured-agent-session-schemas' @@ -25,7 +25,7 @@ export const STRUCTURED_AGENT_SESSION_HOLD_METHODS = [ name: 'agentSession.release', params: HoldParams, handler: async (_params, ctx) => { - requireStructuredCleanupHost(ctx) + requireStructuredHost(ctx) return { released: true as const } } }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts index 6c7651193751..d82be22f7253 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts @@ -1,101 +1,32 @@ import { describe, expect, it } from 'vitest' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import type { OrcaRuntimeService } from '../../orca-runtime' import { supportsStructuredAgentSessions } from './structured-agent-session-policy' -function runtimeWithSetting( - experimentalStructuredNativeChat: boolean -): Pick { - return { - getClientSettings: () => ({ experimentalStructuredNativeChat }) - } as unknown as Pick -} - const CAPABLE = [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] -/** Every caller shape that reaches the policy: desktop renderer, paired phone, in-process. */ -const CALLERS = [ - { name: 'desktop renderer', clientKind: 'runtime' as const, clientCapabilities: CAPABLE }, - { name: 'paired mobile', clientKind: 'mobile' as const, clientCapabilities: CAPABLE }, - { name: 'in-process', clientKind: undefined, clientCapabilities: undefined } -] - describe('supportsStructuredAgentSessions', () => { - it.each([true, false])('admits every caller alike when the setting is %s', (enabled) => { - const decisions = CALLERS.map((caller) => - supportsStructuredAgentSessions({ - clientKind: caller.clientKind, - clientCapabilities: caller.clientCapabilities, - runtime: runtimeWithSetting(enabled) - }) - ) - - expect(decisions).toEqual([enabled, enabled, enabled]) - }) + it.each(['runtime', 'mobile'] as const)( + 'admits a %s client that advertises the capability', + (clientKind) => { + expect(supportsStructuredAgentSessions({ clientKind, clientCapabilities: CAPABLE })).toBe( + true + ) + } + ) it('admits a capability-less in-process caller, which negotiates nothing', () => { expect( - supportsStructuredAgentSessions({ - clientKind: undefined, - clientCapabilities: undefined, - runtime: runtimeWithSetting(true) - }) + supportsStructuredAgentSessions({ clientKind: undefined, clientCapabilities: undefined }) ).toBe(true) }) - it('still refuses a remote client that did not advertise the capability', () => { - for (const clientKind of ['runtime', 'mobile'] as const) { - expect( - supportsStructuredAgentSessions({ - clientKind, - clientCapabilities: [], - runtime: runtimeWithSetting(true) - }) - ).toBe(false) + it.each(['runtime', 'mobile'] as const)( + 'refuses a %s client that did not advertise the capability', + (clientKind) => { + expect(supportsStructuredAgentSessions({ clientKind, clientCapabilities: [] })).toBe(false) + expect(supportsStructuredAgentSessions({ clientKind, clientCapabilities: undefined })).toBe( + false + ) } - }) - - it('leaves desktop launch admission unchanged, because launches require the setting anyway', () => { - // `agent-launch-routing.ts` refuses to route a structured launch unless - // `experimentalStructuredNativeChat` is on, so the only state a desktop launch can - // reach the host in is setting-on — which admits exactly as it did before. - expect( - supportsStructuredAgentSessions({ - clientKind: 'runtime', - clientCapabilities: CAPABLE, - runtime: runtimeWithSetting(true) - }) - ).toBe(true) - }) - - it('reads the setting from the caller-supplied value when no runtime is available', () => { - expect( - supportsStructuredAgentSessions({ - clientKind: 'runtime', - clientCapabilities: CAPABLE, - structuredNativeChatEnabled: true - }) - ).toBe(true) - expect( - supportsStructuredAgentSessions({ - clientKind: 'runtime', - clientCapabilities: CAPABLE, - structuredNativeChatEnabled: false - }) - ).toBe(false) - }) - - it('treats an unreadable settings store as off rather than admitting', () => { - expect( - supportsStructuredAgentSessions({ - clientKind: 'runtime', - clientCapabilities: CAPABLE, - runtime: { - getClientSettings: () => { - throw new Error('settings unavailable') - } - } as unknown as Pick - }) - ).toBe(false) - }) + ) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts index 46a1ee34c45e..b8fa78760b47 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -1,54 +1,18 @@ -import { - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - type RuntimeCapability -} from '../../../../shared/protocol-version' -import type { OrcaRuntimeService } from '../../orca-runtime' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { RpcContext } from '../core' -type StructuredPolicyContext = Pick & { - runtime?: Pick - structuredNativeChatEnabled?: boolean -} - -export function isStructuredNativeChatEnabled( - runtime: Pick -): boolean { - try { - return runtime.getClientSettings().experimentalStructuredNativeChat === true - } catch { - return false - } -} - -export function supportsStructuredAgentSessionCapability( - context: Pick +/** + * One rule for every caller: can this client read structured sessions? The host's own + * `experimentalStructuredNativeChat` is not consulted. It is the host user's launch preference, + * and whether a new agent is a chat is decided by whoever launches it, so a paired client's + * sessions stay reachable whatever the host's setting says. The negotiated capability is a wire + * term, asked of remote clients only: in-process callers are the host's own build. + */ +export function supportsStructuredAgentSessions( + context: Pick ): boolean { return ( context.clientKind === undefined || context.clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) === true ) } - -/** - * One rule for every caller. The host setting is policy and applies to desktop, mobile and - * in-process callers alike; the negotiated capability is a wire term, so it is asked of remote - * clients only — in-process callers are the same build as the host and never negotiate one. - */ -export function supportsStructuredAgentSessions(context: StructuredPolicyContext): boolean { - if (!supportsStructuredAgentSessionCapability(context)) { - return false - } - return ( - context.structuredNativeChatEnabled === true || - (context.runtime ? isStructuredNativeChatEnabled(context.runtime) : false) - ) -} - -export function structuredNativeChatProjectionEnabled(args: { - clientKind: 'mobile' | 'runtime' | undefined - clientCapabilities: readonly RuntimeCapability[] | undefined - // Required so no call site can silently project as if the host setting were off. - structuredNativeChatEnabled: boolean -}): boolean { - return supportsStructuredAgentSessions(args) -} diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index d9d430f08e39..18d8f3380b5d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -309,21 +309,10 @@ describe('capability gating', () => { }) }) - it('requires the host structured-chat setting for mobile clients', async () => { + it('serves a capable mobile client whatever the host structured-chat setting says', async () => { const response = await call('agentSession.send', sendParams(), STRUCTURED_MOBILE_CLIENT, { getClientSettings: () => ({ experimentalStructuredNativeChat: false }) }) - expect(response).toMatchObject({ - ok: false, - error: { message: expect.stringContaining('structured_agent_session_unsupported') } - }) - expect(hostCalls.send).not.toHaveBeenCalled() - }) - - it('serves mobile clients only after capability and setting negotiation', async () => { - const response = await call('agentSession.send', sendParams(), STRUCTURED_MOBILE_CLIENT, { - getClientSettings: () => ({ experimentalStructuredNativeChat: true }) - }) expect(response).toMatchObject({ ok: true }) expect(hostCalls.send).toHaveBeenCalledTimes(1) }) @@ -353,7 +342,6 @@ describe('capability gating', () => { setStructuredAgentSessionHost(null) const response = await call(method, params, STRUCTURED_CLIENT, { - getClientSettings: () => ({ experimentalStructuredNativeChat: false }), ensureStructuredAgentSessionHost: ensureHost }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 534d49677793..185d26bc82ff 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -21,7 +21,6 @@ import { ensureStructuredHostInstalled as ensureHostInstalled, requireInstalledStructuredHost as requireInstalledHost, requireStructuredCapability, - requireStructuredCleanupHost, requireStructuredHost as requireHost, structuredCallerFor as callerFor, supportsStructuredSessions @@ -202,10 +201,9 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ handler: sendStructuredAgentSessionForClient }), defineMethod({ - // Stopping a turn, so it stays available after admission is revoked: see the gate's rule. name: 'agentSession.cancel', params: CancelParams, - handler: async (params, ctx) => requireStructuredCleanupHost(ctx).cancel(callerFor(ctx), params) + handler: async (params, ctx) => requireHost(ctx).cancel(callerFor(ctx), params) }), ...STRUCTURED_AGENT_SESSION_QUEUED_METHODS, defineMethod({ @@ -214,9 +212,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ name: 'agentSession.close', params: OptionsParams, handler: async (params, ctx) => { - // Cleanup gate: turning the host setting off must not strand an open chat whose owner can - // then never close it. See the rule on `requireStructuredCleanupHost`. - const host = requireStructuredCleanupHost(ctx) + const host = requireHost(ctx) // Terminal-disposal closes use this RPC without the session-tabs retirement RPC. if (typeof host.setSessionTabVisibility === 'function') { await host.setSessionTabVisibility(params.sessionId, false) @@ -301,9 +297,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ name: 'agentSession.unsubscribe', params: UnsubscribeParams, handler: async (params, ctx) => { - // Why: cleanup must stay available after the setting is disabled, so an admitted caller can - // retire resources it already owns; the base still comes from main's shared helper. - requireStructuredCleanupHost(ctx) + requireHost(ctx) const base = subscriptionBaseFor(ctx, params.sessionId) if (params.subscriptionId) { ctx.runtime.cleanupSubscription(`${base}:${params.subscriptionId}`) 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 6ec962414229..81ecde983603 100644 --- a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts +++ b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts @@ -1,23 +1,16 @@ import type { RpcContext } from '../core' -import { - isStructuredNativeChatEnabled, - supportsStructuredAgentSessions -} from './structured-agent-session-policy' +import { 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. */ + * Mobile restores regardless of its 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. Restoring spawns no provider child for a cleanly closed session, and a + * host with no saved sessions returns before building anything. */ export function restoreStructuredTabsIfSupported( context: Pick ): Promise | undefined { - const shouldRestore = - context.clientKind === 'mobile' - ? isStructuredNativeChatEnabled(context.runtime) - : supportsStructuredAgentSessions(context) + const shouldRestore = context.clientKind === 'mobile' || supportsStructuredAgentSessions(context) if (shouldRestore && typeof context.runtime.restoreStructuredAgentSessionTabs === 'function') { return context.runtime.restoreStructuredAgentSessionTabs() } diff --git a/src/main/runtime/structured-agent-session-host-refusal.test.ts b/src/main/runtime/structured-agent-session-host-refusal.test.ts index 689ac3df969c..1be56d1a7e15 100644 --- a/src/main/runtime/structured-agent-session-host-refusal.test.ts +++ b/src/main/runtime/structured-agent-session-host-refusal.test.ts @@ -18,7 +18,7 @@ import Database from '../sqlite/sync-database' import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' import { OrcaRuntimeService } from './orca-runtime' -import { requireStructuredCleanupHost } from './rpc/methods/structured-agent-session-gate' +import { requireStructuredHost } from './rpc/methods/structured-agent-session-gate' import { assertLegacyAiVaultResumeCommandAllowed } from '../ai-vault/structured-session-ownership' import type { RpcContext } from './rpc/core' import { @@ -29,7 +29,7 @@ import { let root: string // An in-process caller: the same build as the host, so the gate asks it for no capability. -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cleanup gate reads only `clientKind` and `clientCapabilities`. +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the gate reads only `clientKind` and `clientCapabilities`. const IN_PROCESS = {} as RpcContext function install(): ReturnType { @@ -46,7 +46,7 @@ function install(): ReturnType { /** The refusal as the gate throws it for every structured request. */ function gateRefusal(): { reason: unknown; message: string } { try { - requireStructuredCleanupHost(IN_PROCESS) + requireStructuredHost(IN_PROCESS) } catch (error) { if (isAgentSessionRefusalError(error)) { return { reason: error.refusal.details?.reason, message: error.refusal.message } From 8782739b86a59ad144c5ecdcbf8531f7b705372f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:03:17 -0700 Subject: [PATCH 02/18] fix(native-chat): the desktop declares structured chat support to paired hosts The desktop renderer advertised agent-session.structured.v1 (and the Claude, turn-item and background-task capabilities that go with it) to its own main process but not to a paired Orca server. The server therefore refused every agentSession.* call from the desktop and stripped structured chat tabs out of the tab list it published to it, so a structured chat running on a paired server never appeared on the desktop, even though the renderer already mirrors a host's agent-session tabs and drives each one against the server that owns its workspace. The same renderer reads structured chats on either host, so the remote Electron list now carries the same structured-session capabilities as the local one, and the capability test pins that nothing is advertised only locally. --- ...ktop-renderer-runtime-capabilities.test.ts | 19 ++----------------- src/shared/protocol-version.ts | 6 ++++++ 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 4a8fbab955a2..94e85c737ffc 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -8,19 +8,14 @@ import { describe, expect, it } from 'vitest' import { AGENT_LAUNCH_RUNTIME_CAPABILITY, - AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, - AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, - AGENT_SESSION_TURN_ITEM_CAPABILITY, AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, - CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, @@ -50,17 +45,6 @@ const REMOTE_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY ] -/** Gates the renderer must pass against its own main process. The Electron remote list omits all - * five; mobile advertises the structured ones, so this is an Electron-remote gap rather than a - * statement that no remote client wants them. Why it is one is not recorded here. */ -const LOCAL_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ - AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, - AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, - AGENT_SESSION_TURN_ITEM_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY -] - function missingFrom( source: readonly RuntimeCapability[], other: readonly RuntimeCapability[] @@ -93,11 +77,12 @@ describe('desktop renderer runtime client capabilities', () => { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES ) ).toEqual([...REMOTE_ONLY_BY_DECISION].sort()) + // The same renderer reads structured chats on either host, so it claims nothing only locally. expect( missingFrom( DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES, ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES ) - ).toEqual([...LOCAL_ONLY_BY_DECISION].sort()) + ).toEqual([]) }) }) diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index d3880313897a..714c4523485d 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -330,6 +330,12 @@ export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ ...NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, + // The renderer that reads a paired host's structured chats is the one that reads its own. + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, // Why: only the renderer runs the retirement-proof ledger; CLI and mobile must keep full lists. From ffab3a877fcc20d00098d9588bc01f2d2f93c3ee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:28:32 -0700 Subject: [PATCH 03/18] fix(native-chat): negotiate client-chosen launch mode so released phones and old servers keep terminals Hosts advertise agent-session.structured.client-launch-mode.v1: they admit structured sessions by client capability alone. A remote client that does not advertise it (phones released before agent.launch) asks createSupport to pick the launch mode, so the host keeps answering that with its own setting, exactly as before. Cleanup methods keep their own named gate so a future admission condition cannot make close or cancel refusable. --- ...structured-agent-session-admission.test.ts | 77 +++++++++++++++++-- .../methods/structured-agent-session-gate.ts | 31 +++++++- .../methods/structured-agent-session-hold.ts | 4 +- .../structured-agent-session-policy.test.ts | 39 +++++++++- .../structured-agent-session-policy.ts | 32 +++++++- .../rpc/methods/structured-agent-session.ts | 17 ++-- src/shared/protocol-version.ts | 8 ++ 7 files changed, 187 insertions(+), 21 deletions(-) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts index b9527480b73e..bae4680cdaf3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts @@ -2,7 +2,12 @@ // a paired client that can read structured sessions reaches every method whatever that setting says. import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + AGENT_LAUNCH_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import { CLEANUP_METHODS, WORK_METHODS @@ -13,6 +18,7 @@ import { envelope, hostCalls, installStructuredHostStub, + runtimeCalls, SESSION, STRUCTURED_CLIENT } from './structured-agent-session-rpc.test-fixture' @@ -26,11 +32,30 @@ afterEach(() => { }) const SETTING_OFF = { getClientSettings: () => ({ experimentalStructuredNativeChat: false }) } +const SETTING_ON = { getClientSettings: () => ({ experimentalStructuredNativeChat: true }) } +// A client that picks each launch's mode itself, as the desktop does. +const MODE_CHOOSING_CLIENT = { + ...STRUCTURED_CLIENT, + clientCapabilities: [ + ...STRUCTURED_CLIENT.clientCapabilities, + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY + ] +} +// Phones released before `agent.launch` picked their mode by asking createSupport. +const RELEASED_PHONE = { + clientKind: 'mobile' as const, + clientCapabilities: [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + AGENT_LAUNCH_RUNTIME_CAPABILITY + ] +} +const CREATE_SUPPORT = WORK_METHODS.find((entry) => entry.method === 'agentSession.createSupport')! const UNSUPPORTED = { message: expect.stringContaining('structured_agent_session_unsupported') } describe('a host with structured chat turned off', () => { it.each(WORK_METHODS)('still serves $method to a capable client', async ({ method, params }) => { - const response = await call(method, params, STRUCTURED_CLIENT, SETTING_OFF).catch( + const response = await call(method, params, MODE_CHOOSING_CLIENT, SETTING_OFF).catch( (error: Error) => { // An admitted stream the stub never feeds answers nothing; a refused one replies at once. expect(error.message).toBe(`no reply for ${method}`) @@ -43,7 +68,7 @@ describe('a host with structured chat turned off', () => { }) it.each(CLEANUP_METHODS)('still serves $method to a capable client', async (entry) => { - const response = await call(entry.method, entry.params, STRUCTURED_CLIENT, SETTING_OFF) + const response = await call(entry.method, entry.params, MODE_CHOOSING_CLIENT, SETTING_OFF) expect(response).toMatchObject({ ok: true }) // `unsubscribe` retires runtime-owned subscriptions and `release` is a no-op, so neither @@ -57,7 +82,7 @@ describe('a host with structured chat turned off', () => { it('creates a session for a paired client', async () => { const create = WORK_METHODS.find((entry) => entry.method === 'agentSession.create')! - const response = await call(create.method, create.params, STRUCTURED_CLIENT, SETTING_OFF) + const response = await call(create.method, create.params, MODE_CHOOSING_CLIENT, SETTING_OFF) expect(response).toMatchObject({ ok: true }) }) @@ -95,7 +120,7 @@ describe('a host with structured chat turned off', () => { method, params, { clientKind: 'runtime', clientCapabilities: [] }, - { getClientSettings: () => ({ experimentalStructuredNativeChat: true }) } + SETTING_ON ) // Asserting the gate's own code, not merely `ok: false`: a params-validation failure would @@ -119,3 +144,45 @@ describe('a host with structured chat turned off', () => { } ) }) + +describe('a client that leaves the launch mode to the host', () => { + it('is told a chat is unsupported while the host setting is off, so it opens a terminal', async () => { + const response = await call( + CREATE_SUPPORT.method, + CREATE_SUPPORT.params, + RELEASED_PHONE, + SETTING_OFF + ) + + expect(response).toMatchObject({ ok: false, error: UNSUPPORTED }) + expect(runtimeCalls.getStructuredAgentSessionCreateSupport).not.toHaveBeenCalled() + }) + + it('is answered by the workspace once the host setting is on', async () => { + const response = await call( + CREATE_SUPPORT.method, + CREATE_SUPPORT.params, + RELEASED_PHONE, + SETTING_ON + ) + + expect(response).toMatchObject({ ok: true }) + }) + + it('reads an unreadable settings store as off', async () => { + const response = await call(CREATE_SUPPORT.method, CREATE_SUPPORT.params, RELEASED_PHONE, { + getClientSettings: () => { + throw new Error('store unavailable') + } + }) + + expect(response).toMatchObject({ ok: false, error: UNSUPPORTED }) + }) + + it('keeps every other method on capability alone', async () => { + const create = WORK_METHODS.find((entry) => entry.method === 'agentSession.create')! + const response = await call(create.method, create.params, RELEASED_PHONE, SETTING_OFF) + + expect(response).toMatchObject({ ok: true }) + }) +}) 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 4ee6c990b376..754412ffea9d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -14,7 +14,11 @@ import type { StructuredAgentSessionHost } from '../../../native-chat/agent-sess import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' import type { RpcContext } from '../core' import { structuredAgentSessionHostRefusal } from '../../structured-agent-session-host-refusal' -import { supportsStructuredAgentSessions } from './structured-agent-session-policy' +import { + createSupportFollowsHostSetting, + isStructuredNativeChatEnabled, + supportsStructuredAgentSessions +} from './structured-agent-session-policy' /** * In-process callers are the same build as the host, so they carry no negotiated @@ -32,11 +36,36 @@ export function requireStructuredCapability(ctx: RpcContext): void { } } +/** + * `agentSession.createSupport` alone also reads the host setting, for a client that leaves the + * launch mode to the host; it gets the refusal it got before, which it reads as "open a terminal". + */ +export function requireStructuredCreateSupportAdmission(ctx: RpcContext): void { + requireStructuredCapability(ctx) + if (createSupportFollowsHostSetting(ctx) && !isStructuredNativeChatEnabled(ctx.runtime)) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'clientCapabilityMissing' + }) + } +} + export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHost { requireStructuredCapability(ctx) return requireHostOrRefusal() } +/** + * The gate for methods that stop or retire work the caller already owns: close, cancel, + * unsubscribe and release. It asks only what no caller can do without (the wire capability and a + * host), never an admission condition: refusing a close strands a live provider child its own + * owner can no longer shut down. It is `requireStructuredHost` today; keep it apart so a condition + * added there for new work never reaches these. + */ +export function requireStructuredCleanupHost(ctx: RpcContext): StructuredAgentSessionHost { + requireStructuredCapability(ctx) + return requireHostOrRefusal() +} + /** * The host, or why there is none. A process whose journal would not open says so under every * getter, close included: nothing here can stop a child it never started. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts index 2ed733036c72..e1975f1c7255 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts @@ -8,7 +8,7 @@ import { defineMethod } from '../core' import { requireInstalledStructuredHost, - requireStructuredHost + requireStructuredCleanupHost } from './structured-agent-session-gate' import { HoldParams } from './structured-agent-session-schemas' @@ -25,7 +25,7 @@ export const STRUCTURED_AGENT_SESSION_HOLD_METHODS = [ name: 'agentSession.release', params: HoldParams, handler: async (_params, ctx) => { - requireStructuredHost(ctx) + requireStructuredCleanupHost(ctx) return { released: true as const } } }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts index d82be22f7253..662cc948b7dc 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import { supportsStructuredAgentSessions } from './structured-agent-session-policy' +import { + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { + createSupportFollowsHostSetting, + supportsStructuredAgentSessions +} from './structured-agent-session-policy' const CAPABLE = [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] @@ -30,3 +36,32 @@ describe('supportsStructuredAgentSessions', () => { } ) }) + +describe('createSupportFollowsHostSetting', () => { + it.each(['runtime', 'mobile'] as const)( + 'leaves a %s client that picks its own launch mode to capability alone', + (clientKind) => { + expect( + createSupportFollowsHostSetting({ + clientKind, + clientCapabilities: [...CAPABLE, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY] + }) + ).toBe(false) + } + ) + + it.each(['runtime', 'mobile'] as const)( + 'answers a %s client that leaves the mode to the host with the host setting', + (clientKind) => { + expect(createSupportFollowsHostSetting({ clientKind, clientCapabilities: CAPABLE })).toBe( + true + ) + } + ) + + it('leaves an in-process caller, the same build as the host, to capability alone', () => { + expect( + createSupportFollowsHostSetting({ clientKind: undefined, clientCapabilities: undefined }) + ).toBe(false) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts index b8fa78760b47..517ddab01d1f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -1,4 +1,8 @@ -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcContext } from '../core' /** @@ -16,3 +20,29 @@ export function supportsStructuredAgentSessions( context.clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) === true ) } + +/** + * COMPAT(released phones): a remote client that does not pick each launch's mode itself reads + * `agentSession.createSupport` as "should this launch be a chat", which the host's setting + * answered. Such a client keeps that answer until no supported build lacks the capability. + */ +export function createSupportFollowsHostSetting( + context: Pick +): boolean { + return ( + context.clientKind !== undefined && + context.clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY) !== + true + ) +} + +/** An unreadable settings store reads as off, the default. */ +export function isStructuredNativeChatEnabled( + runtime: Pick +): boolean { + try { + return runtime.getClientSettings().experimentalStructuredNativeChat === true + } catch { + return false + } +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 185d26bc82ff..960a8ff1e7f1 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -21,9 +21,10 @@ import { ensureStructuredHostInstalled as ensureHostInstalled, requireInstalledStructuredHost as requireInstalledHost, requireStructuredCapability, + requireStructuredCleanupHost, + requireStructuredCreateSupportAdmission, requireStructuredHost as requireHost, - structuredCallerFor as callerFor, - supportsStructuredSessions + structuredCallerFor as callerFor } from './structured-agent-session-gate' import type { AgentSessionAttachParams } from '../../../native-chat/agent-session-wire/structured-agent-session-attach' import { @@ -133,11 +134,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ name: 'agentSession.createSupport', params: CreateSupportParams, handler: async (params, ctx) => { - if (!supportsStructuredSessions(ctx)) { - throw agentSessionRefusalError('structured_agent_session_unsupported', { - reason: 'clientCapabilityMissing' - }) - } + requireStructuredCreateSupportAdmission(ctx) return ctx.runtime.getStructuredAgentSessionCreateSupport(params.worktree, params.agent) } }), @@ -203,7 +200,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ defineMethod({ name: 'agentSession.cancel', params: CancelParams, - handler: async (params, ctx) => requireHost(ctx).cancel(callerFor(ctx), params) + handler: async (params, ctx) => requireStructuredCleanupHost(ctx).cancel(callerFor(ctx), params) }), ...STRUCTURED_AGENT_SESSION_QUEUED_METHODS, defineMethod({ @@ -212,7 +209,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ name: 'agentSession.close', params: OptionsParams, handler: async (params, ctx) => { - const host = requireHost(ctx) + const host = requireStructuredCleanupHost(ctx) // Terminal-disposal closes use this RPC without the session-tabs retirement RPC. if (typeof host.setSessionTabVisibility === 'function') { await host.setSessionTabVisibility(params.sessionId, false) @@ -297,7 +294,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ name: 'agentSession.unsubscribe', params: UnsubscribeParams, handler: async (params, ctx) => { - requireHost(ctx) + requireStructuredCleanupHost(ctx) const base = subscriptionBaseFor(ctx, params.sessionId) if (params.subscriptionId) { ctx.runtime.cleanupSubscription(`${base}:${params.subscriptionId}`) diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index d3880313897a..7bcb88c80860 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -161,6 +161,13 @@ export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY = // 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: hosts before this gated `agentSession.*` on their own chat setting while still advertising +// agent-session.structured.v1, so that capability alone cannot tell a client its chat will be +// admitted. A host advertising this admits by client capability alone; a client advertising it +// picks each launch's mode itself. A client without it asks `agentSession.createSupport` to pick +// for it (released phones), so the host keeps answering that with its own setting. +export const STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY = + 'agent-session.structured.client-launch-mode.v1' as const // Why: older structured clients render durable pending replies as uncertain delivery. Capable // clients skip the host's bounded best-effort settlement observation. export const AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY = @@ -399,6 +406,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, AGENT_SESSION_KEYBOARD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, // The host side: it accepts a send before any agent has it, and a Stop with no writer before a // turn starts, so a client may gate on either. From a846f4a12fbdd3250e4ffe8c79ebc79b5ce615c6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:37:07 -0700 Subject: [PATCH 04/18] refactor(runtime): keep the Electron client capability list in its own module protocol-version.ts is at its line budget; the list is what the desktop advertises to paired hosts, not the host's own contract. --- ...ktop-renderer-runtime-capabilities.test.ts | 2 +- ...environment-federated-read-routing.test.ts | 6 ++-- ...untime-environment-pairing-verification.ts | 2 +- ...runtime-environment-request-connections.ts | 2 +- .../ipc/runtime-environment-status-owner.ts | 6 ++-- .../runtime-environment-support-routing.ts | 2 +- .../runtime-environment-transport-routing.ts | 2 +- .../runtime-environments-call-routing.test.ts | 6 ++-- .../ipc/runtime-environments-pairing.test.ts | 6 ++-- ...me-environments-status-diagnostics.test.ts | 6 ++-- ...nvironments-subscription-lifecycle.test.ts | 2 +- ...-environments-subscription-routing.test.ts | 6 ++-- ...n-tab-browser-placement-projection.test.ts | 2 +- ...d-agent-session-send-compatibility.test.ts | 6 ++-- ...tron-remote-runtime-client-capabilities.ts | 31 +++++++++++++++++++ src/shared/protocol-version.ts | 18 ----------- src/shared/remote-runtime-client.test.ts | 4 +-- 17 files changed, 54 insertions(+), 55 deletions(-) create mode 100644 src/shared/electron-remote-runtime-client-capabilities.ts diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 94e85c737ffc..d553566fb0cd 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -13,7 +13,6 @@ import { AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, @@ -21,6 +20,7 @@ import { WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { supportsAgentLaunch } from '../runtime/rpc/methods/agent-launch' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from './desktop-renderer-runtime-capabilities' diff --git a/src/main/ipc/runtime-environment-federated-read-routing.test.ts b/src/main/ipc/runtime-environment-federated-read-routing.test.ts index 51c77510a01e..9433e4f28054 100644 --- a/src/main/ipc/runtime-environment-federated-read-routing.test.ts +++ b/src/main/ipc/runtime-environment-federated-read-routing.test.ts @@ -5,10 +5,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { encodePairingOffer } from '../../shared/pairing' -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY -} from '../../shared/protocol-version' +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { addEnvironmentFromPairingCode } from '../../shared/runtime-environment-store' const { sendRemoteRuntimeRequestMock, sendRemoteRuntimeSharedControlRequestMock } = vi.hoisted( diff --git a/src/main/ipc/runtime-environment-pairing-verification.ts b/src/main/ipc/runtime-environment-pairing-verification.ts index d86f298a5f04..e339e75c6244 100644 --- a/src/main/ipc/runtime-environment-pairing-verification.ts +++ b/src/main/ipc/runtime-environment-pairing-verification.ts @@ -9,7 +9,7 @@ import { } from '../../shared/remote-pairing-verification' import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { redactRuntimeEnvironment } from '../../shared/runtime-environments' import type { RuntimeStatus } from '../../shared/runtime-types' diff --git a/src/main/ipc/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environment-request-connections.ts index 6ba9f273c1e4..bb666e757f89 100644 --- a/src/main/ipc/runtime-environment-request-connections.ts +++ b/src/main/ipc/runtime-environment-request-connections.ts @@ -4,7 +4,7 @@ import { getPreferredPairingOffer } from '../../shared/runtime-environments' import type { RuntimeHostStatusOwner } from '../../shared/runtime-host-status-owner' import type { RuntimeStatus } from '../../shared/runtime-types' import { createRuntimeEnvironmentStatusOwner } from './runtime-environment-status-owner' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import type { RuntimeOrchestrationEnvelope, RuntimeRpcResponse diff --git a/src/main/ipc/runtime-environment-status-owner.ts b/src/main/ipc/runtime-environment-status-owner.ts index 26b5264ef9fd..f88741ca9a3c 100644 --- a/src/main/ipc/runtime-environment-status-owner.ts +++ b/src/main/ipc/runtime-environment-status-owner.ts @@ -1,9 +1,7 @@ import { BrowserWindow } from 'electron' import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY -} from '../../shared/protocol-version' +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { getPreferredPairingOffer, type KnownRuntimeEnvironment diff --git a/src/main/ipc/runtime-environment-support-routing.ts b/src/main/ipc/runtime-environment-support-routing.ts index f81d045a568d..ae68b3d35f8f 100644 --- a/src/main/ipc/runtime-environment-support-routing.ts +++ b/src/main/ipc/runtime-environment-support-routing.ts @@ -8,7 +8,7 @@ import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments' import { getPreferredPairingOffer } from '../../shared/runtime-environments' import { markEnvironmentUsed, resolveEnvironment } from '../../shared/runtime-environment-store' import { recordRuntimeEnvironmentUsage } from './runtime-environment-usage-record' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { subscribeRemoteRuntimeRequest, type RemoteRuntimeSubscription diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index 327f1428ea78..fd60ef33fcda 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -1,5 +1,5 @@ import { getPreferredPairingOffer } from '../../shared/runtime-environments' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { resolveEnvironment, markEnvironmentUsed } from '../../shared/runtime-environment-store' import { recordRuntimeEnvironmentUsage } from './runtime-environment-usage-record' import { isOrchestrationMutation } from '../../shared/orchestration-rpc-contract' diff --git a/src/main/ipc/runtime-environments-call-routing.test.ts b/src/main/ipc/runtime-environments-call-routing.test.ts index 0e8472625d5a..643a1dd10e9b 100644 --- a/src/main/ipc/runtime-environments-call-routing.test.ts +++ b/src/main/ipc/runtime-environments-call-routing.test.ts @@ -3,10 +3,8 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY -} from '../../shared/protocol-version' +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import * as environmentStore from '../../shared/runtime-environment-store' import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' import { RuntimeRpcCallQueueOverloadError } from '../../shared/runtime-rpc-call-queue' diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index ce492a7a740d..6919c68f198a 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -4,10 +4,8 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - MIN_COMPATIBLE_RUNTIME_SERVER_VERSION -} from '../../shared/protocol-version' +import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import * as environmentStore from '../../shared/runtime-environment-store' import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' diff --git a/src/main/ipc/runtime-environments-status-diagnostics.test.ts b/src/main/ipc/runtime-environments-status-diagnostics.test.ts index 9fe3d6baf0ea..8e51332c8b5c 100644 --- a/src/main/ipc/runtime-environments-status-diagnostics.test.ts +++ b/src/main/ipc/runtime-environments-status-diagnostics.test.ts @@ -3,10 +3,8 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY -} from '../../shared/protocol-version' +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' const { handleMock, diff --git a/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts b/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts index ed4dcd62182d..699a9cab8d59 100644 --- a/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts +++ b/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import * as environmentStore from '../../shared/runtime-environment-store' const { diff --git a/src/main/ipc/runtime-environments-subscription-routing.test.ts b/src/main/ipc/runtime-environments-subscription-routing.test.ts index 0ef70f7ac963..bc9dfc58c294 100644 --- a/src/main/ipc/runtime-environments-subscription-routing.test.ts +++ b/src/main/ipc/runtime-environments-subscription-routing.test.ts @@ -3,10 +3,8 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY -} from '../../shared/protocol-version' +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' const { handleMock, diff --git a/src/main/runtime/rpc/methods/session-tab-browser-placement-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-browser-placement-projection.test.ts index e5f309bcd4e5..79fc3892895f 100644 --- a/src/main/runtime/rpc/methods/session-tab-browser-placement-projection.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-browser-placement-projection.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../../shared/electron-remote-runtime-client-capabilities' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import { assertProjectedSessionTabVisible, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts index 59bc914558e1..e420176c70cc 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts @@ -2,10 +2,8 @@ // message is handed over: a client that cannot show a rejection after `pending` must not see one. import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY -} from '../../../../shared/protocol-version' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../../shared/electron-remote-runtime-client-capabilities' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities' import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from '../../../native-chat/agent-session-wire/structured-agent-session-send-settlement' import { diff --git a/src/shared/electron-remote-runtime-client-capabilities.ts b/src/shared/electron-remote-runtime-client-capabilities.ts new file mode 100644 index 000000000000..b0c6db5c6433 --- /dev/null +++ b/src/shared/electron-remote-runtime-client-capabilities.ts @@ -0,0 +1,31 @@ +import { + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, + BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, + BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from './protocol-version' + +// Electron clients can decode client-hosted page placement; becoming a page +// host still requires the separate authenticated browser-client lease. +export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ + ...NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, + // The renderer that reads a paired host's structured chats is the one that reads its own. + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, + BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, + // Why: only the renderer runs the retirement-proof ledger; CLI and mobile must keep full lists. + SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY +] as const diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 146b5c5e2a12..95869bd1eec9 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -331,24 +331,6 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ AGENT_LAUNCH_RUNTIME_CAPABILITY ] as const -// Electron clients can decode client-hosted page placement; becoming a page -// host still requires the separate authenticated browser-client lease. -export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ - ...NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, - // The renderer that reads a paired host's structured chats is the one that reads its own. - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - AGENT_SESSION_TURN_ITEM_CAPABILITY, - AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, - AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, - BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, - BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, - // Why: only the renderer runs the retirement-proof ledger; CLI and mobile must keep full lists. - SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY -] as const - export const ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY = 'git.antigravity-configured-model.v1' as const diff --git a/src/shared/remote-runtime-client.test.ts b/src/shared/remote-runtime-client.test.ts index d7a76417e841..096aad470680 100644 --- a/src/shared/remote-runtime-client.test.ts +++ b/src/shared/remote-runtime-client.test.ts @@ -17,9 +17,9 @@ import { MAX_TIMER_DELAY_MS } from './timer-delay' import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, - BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES + BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY } from './protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from './electron-remote-runtime-client-capabilities' const servers: WebSocketServer[] = [] From 875a23fdcad3c3bc3aa2bfa420dc608209d3cfa2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:38:11 -0700 Subject: [PATCH 05/18] fix(native-chat): the desktop declares it picks each launch mode itself Paired hosts and the desktop's own main process then answer createSupport by the workspace rather than by their own chat setting. --- .../desktop-renderer-runtime-capabilities.test.ts | 12 ++++++++++++ .../ipc/desktop-renderer-runtime-capabilities.ts | 2 ++ .../electron-remote-runtime-client-capabilities.ts | 3 +++ 3 files changed, 17 insertions(+) diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index d553566fb0cd..6210afcf20ab 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -22,6 +22,7 @@ import { } from '../../shared/protocol-version' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { supportsAgentLaunch } from '../runtime/rpc/methods/agent-launch' +import { createSupportFollowsHostSetting } from '../runtime/rpc/methods/structured-agent-session-policy' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from './desktop-renderer-runtime-capabilities' /** Advertised to a remote host and deliberately NOT to main: each would change local behaviour or @@ -70,6 +71,17 @@ describe('desktop renderer runtime client capabilities', () => { ).toBe(false) }) + // The desktop routes a launch on its own settings; a host that answered createSupport with its + // own setting would turn a chat the user asked for into a failed launch. + it.each([ + ['a paired host', ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES], + ['its own main process', DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] + ] as const)('tells %s that it picks each launch mode itself', (_host, clientCapabilities) => { + expect(createSupportFollowsHostSetting({ clientKind: 'runtime', clientCapabilities })).toBe( + false + ) + }) + it('diverges from the remote Electron list only where a decision was recorded', () => { expect( missingFrom( diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index 0bcfd1bd686d..d57b02309c89 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -6,6 +6,7 @@ import { AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' @@ -31,6 +32,7 @@ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapab AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, // Without this `supportsAgentLaunch` refuses the renderer outright, while the same renderer // targeting a remote host is admitted — the asymmetry this constant exists to close. AGENT_LAUNCH_RUNTIME_CAPABILITY diff --git a/src/shared/electron-remote-runtime-client-capabilities.ts b/src/shared/electron-remote-runtime-client-capabilities.ts index b0c6db5c6433..1e157c4472c9 100644 --- a/src/shared/electron-remote-runtime-client-capabilities.ts +++ b/src/shared/electron-remote-runtime-client-capabilities.ts @@ -9,6 +9,7 @@ import { CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from './protocol-version' @@ -21,6 +22,8 @@ export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ // The renderer that reads a paired host's structured chats is the one that reads its own. STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + // The desktop picks each launch's mode from its own settings before asking the host. + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, From a39d2261bd4aac0c77e3e53b6cd79c7dae08db05 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:32:20 -0700 Subject: [PATCH 06/18] chore(native-chat): justify the two type assertions this change's lines touch --- src/main/runtime/rpc/methods/session-tabs-inventory.ts | 1 + .../runtime/rpc/methods/session-tabs-structured-restore.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/main/runtime/rpc/methods/session-tabs-inventory.ts b/src/main/runtime/rpc/methods/session-tabs-inventory.ts index 0e80deba85d8..be3a1d2de42c 100644 --- a/src/main/runtime/rpc/methods/session-tabs-inventory.ts +++ b/src/main/runtime/rpc/methods/session-tabs-inventory.ts @@ -110,6 +110,7 @@ export async function subscribeSessionTabsInventory( let censusInvalidated = false const withProofDelta = createSessionTabsRetirementProofDelta(context.clientCapabilities) const projectChange = (snapshot: SessionTabsChange): SessionTabsChange => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: projection rewrites only tabs and groups; the change fields it was handed pass through. projectSessionTabsForClient( snapshot, context.clientKind, 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 index 4fbcfe41f935..3b2f5a5ca1e4 100644 --- a/src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts @@ -13,6 +13,7 @@ function makeRequest(method: string, params?: unknown): RpcRequest { // The host's own structured-chat setting is off throughout: it is a launch preference, so it must // not decide whether chats a paired client opened come back after a restart. function makeRuntime(): OrcaRuntimeService { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the runtime members these RPCs read are staged. return { getRuntimeId: () => 'test-runtime', getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: false })), From 91811d6421ecd69a780a38a4d1ec22dc053a6449 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:48:41 -0700 Subject: [PATCH 07/18] fix(native-chat): chats that already exist keep showing whatever the chat setting says The structured chat setting decides only what new agents open as. With it off, this machine's structured chats used to be hidden while the host, which no longer reads the setting, still reported them to the workspace activation gate, so a workspace holding only a chat opened empty. The local chat mirror and its startup restore now run whatever the setting says, the continue-after-restart offer follows the chats that exist, and the setting's copy says it applies to new agents. --- .../app-shell/use-app-startup-hydration.ts | 9 ++-- .../NativeChatResumeOnRestartModal.tsx | 9 ++-- .../native-chat-restart-offer-gate.ts | 15 ++++++ .../NativeChatExperimentalSetting.tsx | 2 +- .../NativeChatResumeStatusSegment.test.tsx | 33 +++++++++++- .../NativeChatResumeStatusSegment.tsx | 10 ++-- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- ...-structured-session-tabs-sync-hook.test.ts | 52 +++++++++++++++++++ .../local-structured-session-tabs-sync.ts | 10 ++-- 13 files changed, 119 insertions(+), 31 deletions(-) create mode 100644 src/renderer/src/components/native-chat-restart-offer-gate.ts create mode 100644 src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 7cee1762111c..744ddc83ff17 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -281,11 +281,10 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta await timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect', () => window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() ) - if (useAppStore.getState().settings?.experimentalStructuredNativeChat === true) { - await timeRendererStartupStep('project-structured-session-tabs', () => - restoreLocalStructuredSessionTabsOnce() - ) - } + // Existing chats come back whatever the chat setting says; it picks only new launches. + await timeRendererStartupStep('project-structured-session-tabs', () => + restoreLocalStructuredSessionTabsOnce() + ) if (cancelled) { return } diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx index 315a19e3170a..4d7e7c8bfb7f 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, useState, useSyncExternalStore } from 'react' +import { useNativeChatRestartOfferEnabled } from './native-chat-restart-offer-gate' import { RotateCcw } from 'lucide-react' import { Button } from './ui/button' import { Checkbox } from './ui/checkbox' @@ -64,10 +65,8 @@ function selectedByDefault(failure: ResumeFailure | undefined): boolean { } export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { - const structuredEnabled = useAppStore( - (store) => store.settings?.experimentalStructuredNativeChat === true - ) - const { candidates, failed, listedAt } = useNativeChatRestartOffer(structuredEnabled) + const offerEnabled = useNativeChatRestartOfferEnabled() + const { candidates, failed, listedAt } = useNativeChatRestartOffer(offerEnabled) const rows = useMemo(() => [...candidates, ...failed], [candidates, failed]) const failureBySession = useMemo( () => new Map(failed.map((failure) => [failure.sessionId, failure])), @@ -172,7 +171,7 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { }) } - if (!structuredEnabled || !open || rows.length === 0) { + if (!offerEnabled || !open || rows.length === 0) { return null } diff --git a/src/renderer/src/components/native-chat-restart-offer-gate.ts b/src/renderer/src/components/native-chat-restart-offer-gate.ts new file mode 100644 index 000000000000..330e46e4f92d --- /dev/null +++ b/src/renderer/src/components/native-chat-restart-offer-gate.ts @@ -0,0 +1,15 @@ +import { useAppStore } from '../store' +import { getStructuredAgentSessionTabs } from './native-chat/structured-agent-session-tabs' + +/** + * Whether this window offers to carry on its chats: wherever structured chats exist, since the + * chat setting picks only what new agents open as. Without either, a machine that never used + * structured chat builds no host to answer an empty offer. + */ +export function useNativeChatRestartOfferEnabled(): boolean { + return useAppStore( + (store) => + store.settings?.experimentalStructuredNativeChat === true || + getStructuredAgentSessionTabs(store.unifiedTabsByWorktree).length > 0 + ) +} diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index d7f1d0e29138..0c593a0fd6a5 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -131,7 +131,7 @@ export function NativeChatExperimentalSetting({

{translate( 'auto.components.settings.ExperimentalPane.nativeChat.structuredCopy', - 'Opt in to the host-owned structured chat runtime for Codex and Claude. Off keeps the existing terminal-backed chat path.' + 'Opt in to the host-owned structured chat runtime for Codex and Claude. It decides what new agents open as; off opens them in the existing terminal-backed chat. Chats you already have stay as they are.' )}

diff --git a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx index c5a260ed201c..efeffdf4af1d 100644 --- a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx +++ b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx @@ -168,14 +168,14 @@ describe('NativeChatResumeStatusSegment', () => { ]) }) - it('hides when the feature is disabled or the host offers nothing', async () => { + it('hides when no chat exists and the setting is off, or the host offers nothing', async () => { rpc.mockResolvedValue({ sessions: candidates }) useAppStore.setState({ settings: { ...getDefaultSettings(''), experimentalStructuredNativeChat: false } }) await mount() expect(screen.queryByRole('button')).toBeNull() - // Nothing is even asked of the host while the feature is off. + // A machine with no structured chat and the setting off asks the host nothing. expect(rpc).not.toHaveBeenCalled() cleanup() @@ -187,6 +187,35 @@ describe('NativeChatResumeStatusSegment', () => { expect(screen.queryByRole('button')).toBeNull() }) + // The setting picks what new agents open as; chats that already exist keep their offer. + it('offers to continue existing chats while the setting is off', async () => { + rpc.mockResolvedValue({ sessions: candidates }) + useAppStore.setState({ + settings: { ...getDefaultSettings(''), experimentalStructuredNativeChat: false }, + unifiedTabsByWorktree: { + 'wt-1': [ + { + id: 'agent-session:claude_1', + entityId: 'claude_1', + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'agent-session', + agentSessionAgent: 'claude', + label: 'Claude Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }) + await mount() + + expect(rpc).toHaveBeenCalledWith(expect.anything(), 'agentSession.restartResumable') + expect(screen.getByRole('button')).toBeTruthy() + }) + it('renders a compact count in icon-only mode', async () => { rpc.mockResolvedValue({ sessions: candidates }) await mount(true) diff --git a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx index 751a804b2dd1..24e9b225824e 100644 --- a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx @@ -1,7 +1,7 @@ import { AlertCircle, Loader2, RotateCcw } from 'lucide-react' +import { useNativeChatRestartOfferEnabled } from '../native-chat-restart-offer-gate' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' -import { useAppStore } from '@/store' import { requestNativeChatResumeOnRestartDialog } from '../native-chat-resume-on-restart-dialog' import { getNativeChatRestartResuming, @@ -166,12 +166,10 @@ export function NativeChatResumeStatusSegment({ }: { iconOnly: boolean }): React.JSX.Element | null { - const structuredEnabled = useAppStore( - (store) => store.settings?.experimentalStructuredNativeChat === true - ) - const { candidates, failed } = useNativeChatRestartOffer(structuredEnabled) + const offerEnabled = useNativeChatRestartOfferEnabled() + const { candidates, failed } = useNativeChatRestartOffer(offerEnabled) const resumingIds = useNativeChatRestartResuming() - if (!structuredEnabled) { + if (!offerEnabled) { return null } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 25a3f2aaabf5..62e2fe813954 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7247,7 +7247,7 @@ "defaultViewTerminal": "Terminal chat", "defaultViewNative": "Chat UI", "structuredTitle": "Use updated structured native chat", - "structuredCopy": "Opt in to the host-owned structured chat runtime for Codex and Claude. Off keeps the existing terminal-backed chat path.", + "structuredCopy": "Opt in to the host-owned structured chat runtime for Codex and Claude. It decides what new agents open as; off opens them in the existing terminal-backed chat. Chats you already have stay as they are.", "structuredScope": "Local sessions only for now. WSL and remote execution hosts (including SSH) continue to use terminal chat, and Windows falls back to it unless Orca can read process start times.", "structuredToggleLabel": "Toggle updated structured native chat", "resumeTitle": "Resume working chats automatically after a restart", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 65acfdf49e92..a54130188474 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -7189,7 +7189,7 @@ "shellEnvCopy": "Les chats Codex et Claude démarrent avec toutes les variables exportées par votre shell de connexion, comme un terminal. Désactivez pour choisir celles qu'ils reçoivent.", "shellEnvToggleLabel": "Activer l'utilisation de l'environnement de votre shell", "structuredTitle": "Utiliser le chat natif structuré mis à jour", - "structuredCopy": "Inscrivez-vous au runtime de chat structuré appartenant à l'hôte pour Codex et Claude. Désactivé conserve le chemin de discussion existant sauvegardé sur le terminal.", + "structuredCopy": "Inscrivez-vous au runtime de chat structuré appartenant à l'hôte pour Codex et Claude. Ce réglage détermine comment s'ouvrent les nouveaux agents ; désactivé, ils s'ouvrent dans le chat existant basé sur le terminal. Les discussions existantes restent telles quelles.", "structuredScope": "Séances locales uniquement pour l'instant. WSL et les hôtes d'exécution à distance (y compris SSH) continuent d'utiliser le chat du terminal, et Windows y revient à moins qu'Orca ne puisse lire les heures de début des processus.", "structuredToggleLabel": "Basculer le chat natif structuré mis à jour", "resumeTitle": "Reprendre automatiquement les discussions de travail après un redémarrage", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 85491478e07d..bafb9adf8564 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -7071,7 +7071,7 @@ "shellEnvCopy": "Codex と Claude のチャットは、ターミナルと同じく、ログインシェルがエクスポートするすべての変数を引き継いで開始します。オフにすると、渡す変数を選択できます。", "shellEnvToggleLabel": "シェル環境の使用の切り替え", "structuredTitle": "更新された構造化ネイティブチャットを使用する", - "structuredCopy": "Codex と Claude 用のホスト所有の構造化チャットランタイムにオプトインします。 Off では、既存のターミナルを使用したチャットパスが維持されます。", + "structuredCopy": "Codex と Claude 用のホスト所有の構造化チャットランタイムにオプトインします。この設定は新規 Agent の開き方を決めます。オフにすると既存のターミナルを使用したチャットで開きます。既存のチャットはそのまま残ります。", "structuredScope": "今のところローカルセッションのみ。 WSL およびリモート実行ホスト (SSH を含む) は引き続きターミナルチャットを使用し、Orca がプロセスの開始時間を読み取ることができない限り、Windows はターミナルチャットにフォールバックします。", "structuredToggleLabel": "更新された構造化ネイティブチャットの切り替え", "resumeTitle": "再起動後に作業中のチャットを自動的に再開する", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 180ed46c24fa..e97c1c5794a3 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -7071,7 +7071,7 @@ "shellEnvCopy": "Codex 및 Claude 채팅은 터미널과 마찬가지로 로그인 셸이 내보내는 모든 변수를 가지고 시작합니다. 끄면 전달할 변수를 선택할 수 있습니다.", "shellEnvToggleLabel": "셸 환경 사용 전환", "structuredTitle": "업데이트된 구조화된 기본 채팅 사용", - "structuredCopy": "Codex와 Claude를 위한 호스트 소유의 구조화된 채팅 런타임을 선택하세요. 끄기는 기존 터미널 지원 채팅 경로를 유지합니다.", + "structuredCopy": "Codex와 Claude를 위한 호스트 소유의 구조화된 채팅 런타임을 선택하세요. 이 설정은 새 에이전트를 여는 방식을 정합니다. 끄면 기존 터미널 지원 채팅으로 엽니다. 이미 있는 채팅은 그대로 유지됩니다.", "structuredScope": "현재는 로컬 세션만 가능합니다. WSL 및 원격 실행 호스트(SSH 포함)는 계속해서 터미널 채팅을 사용하며 Orca가 프로세스 시작 시간을 읽을 수 없으면 Windows는 이 채팅을 사용합니다.", "structuredToggleLabel": "업데이트된 구조화된 기본 채팅 전환", "resumeTitle": "다시 시작한 후 작업 중인 채팅을 자동으로 재개합니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 42d4d19309b3..486111424299 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -7071,7 +7071,7 @@ "shellEnvCopy": "Codex 和 Claude 聊天启动时会带上登录 shell 导出的所有变量,与终端一致。关闭后可选择传递哪些变量。", "shellEnvToggleLabel": "切换使用 shell 环境", "structuredTitle": "使用更新的结构化本机聊天", - "structuredCopy": "选择加入 Codex 和 Claude 的主机拥有的结构化聊天运行时。关闭会保留现有的终端支持的聊天路径。", + "structuredCopy": "选择加入 Codex 和 Claude 的主机拥有的结构化聊天运行时。此设置决定新代理的打开方式;关闭时新代理会在现有的终端支持的聊天中打开。已有的聊天保持不变。", "structuredScope": "目前仅限本地会话。 WSL 和远程执行主机(包括 SSH)继续使用终端聊天,Windows 会回退到它,除非 Orca 可以读取进程启动时间。", "structuredToggleLabel": "切换更新的结构化本机聊天", "resumeTitle": "重启后自动恢复工作聊天", diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts new file mode 100644 index 000000000000..4fc4ca12c3aa --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ start: vi.fn(async () => undefined) })) + +vi.mock('./local-structured-session-tabs-sync/subscription', () => ({ + startLocalStructuredSessionTabsSync: mocks.start +})) + +import { useAppStore } from '../store' +import { useLocalStructuredSessionTabsSync } from './local-structured-session-tabs-sync' + +function setStructuredChat(enabled: boolean): void { + useAppStore.setState({ + settings: { ...useAppStore.getState().settings!, experimentalStructuredNativeChat: enabled } + }) +} + +beforeEach(() => { + mocks.start.mockClear() + useAppStore.setState({ + workspaceSessionReady: true, + terminalStartupRestorationReady: true + }) +}) + +afterEach(() => { + cleanup() + useAppStore.setState(useAppStore.getInitialState(), true) +}) + +// The setting picks what new agents open as; chats that already exist keep showing. +describe("this machine's structured chats", () => { + it('are mirrored with the structured chat setting off', () => { + setStructuredChat(false) + + renderHook(() => useLocalStructuredSessionTabsSync()) + + expect(mocks.start).toHaveBeenCalledOnce() + }) + + it('stay mirrored when the setting is turned off', () => { + setStructuredChat(true) + renderHook(() => useLocalStructuredSessionTabsSync()) + + act(() => setStructuredChat(false)) + + expect(mocks.start).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts index 3e0d25e940fb..df8674323f0f 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts @@ -1,6 +1,5 @@ import { useEffect } from 'react' import { useAppStore } from '../store' -import { clearLocalStructuredSessionTabs } from './local-structured-session-tabs-sync/snapshot-apply' import { startLocalStructuredSessionTabsSync } from './local-structured-session-tabs-sync/subscription' export { @@ -22,19 +21,16 @@ export { LOCAL_STRUCTURED_SESSION_OWNER } from './local-structured-session-owner export { projectLocalStructuredSessionTabs } from './local-structured-session-tabs-sync/snapshot-projection' export { startLocalStructuredSessionTabsSync } from './local-structured-session-tabs-sync/subscription' +/** Mirrors this machine's structured chats whatever the chat setting says: the setting picks what + * new agents open as, and chats that already exist keep showing. */ export function useLocalStructuredSessionTabsSync(): void { const ready = useAppStore( (state) => state.workspaceSessionReady && state.terminalStartupRestorationReady ) - const enabled = useAppStore((state) => state.settings?.experimentalStructuredNativeChat === true) useEffect(() => { if (!ready) { return } - if (!enabled) { - clearLocalStructuredSessionTabs() - return - } let disposed = false let unsubscribe = (): void => {} void startLocalStructuredSessionTabsSync({ @@ -47,5 +43,5 @@ export function useLocalStructuredSessionTabsSync(): void { disposed = true unsubscribe() } - }, [enabled, ready]) + }, [ready]) } From f99d968e6f78558d539ec9333c3a2f1e5b9116b0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:06:52 -0700 Subject: [PATCH 08/18] test(native-chat): pin that a host advertises the client-chosen launch mode --- .../orca-runtime-tests/runtime-availability.spec.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/runtime/orca-runtime-tests/runtime-availability.spec.ts b/src/main/runtime/orca-runtime-tests/runtime-availability.spec.ts index 8a818e3ca6a7..aedaa567b3dc 100644 --- a/src/main/runtime/orca-runtime-tests/runtime-availability.spec.ts +++ b/src/main/runtime/orca-runtime-tests/runtime-availability.spec.ts @@ -265,6 +265,14 @@ describe('OrcaRuntimeService', () => { expect(runtime.getStatus().capabilities).toContain('browser.screencast.v1') }) + // Paired desktops open a chat on this host only when it says it admits them by the client's + // chosen launch mode; without it, every paired launch quietly becomes a terminal. + it('advertises that it admits structured sessions by the client-chosen launch mode', () => { + expect(createRuntime().getStatus().capabilities).toContain( + 'agent-session.structured.client-launch-mode.v1' + ) + }) + it('advertises safe Codex reset-credit RPC support as a static capability', () => { const runtime = createRuntime() From c1df7ee8a80ca406712f97fb8725218dd16983c9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:07:06 -0700 Subject: [PATCH 09/18] fix(native-chat): mirror this machine's chats only where it holds them Round 1 ran the local chat mirror for everyone so existing chats show whatever the setting says. That gave every desktop a permanent session-tabs listener, which turns on the runtime's phone replication paths, plus two full session-tab censuses at startup, and made the browser client mirror its remote host a second time. The runtime now says whether it holds structured chats: its structured host is built only when saved chats were restored at startup or a client created one here, and it announces the moment one is built. The mirror, the startup restore and the continue-after-restart offer run only when the setting launches chats or the host holds some, and never in the browser client. A chat a paired client creates here with the setting off still appears at once. The chat behaviour settings show wherever chats exist, and the setting's copy says it picks what new agents open as. The toggle-off teardown this made dead is removed. --- .../structured-agent-session-registry.test.ts | 39 +++++++ .../structured-agent-session-registry.ts | 13 +++ .../startup/main-process-ipc-bootstrap.ts | 13 +++ src/preload/api/app-api.ts | 4 + src/preload/api/app-bridge.ts | 7 ++ .../app-shell/use-app-startup-hydration.ts | 7 +- .../native-chat-restart-offer-gate.ts | 15 +-- .../settings/ExperimentalPane.test.tsx | 47 +++++++- .../NativeChatExperimentalSetting.tsx | 10 +- .../NativeChatResumeStatusSegment.test.tsx | 37 +++++- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- .../runtime/local-structured-chats.test.ts | 69 +++++++++++ .../src/runtime/local-structured-chats.ts | 107 ++++++++++++++++++ ...local-structured-session-tab-retirement.ts | 36 ------ ...-structured-session-tabs-sync-hook.test.ts | 79 +++++++++++-- ...local-structured-session-tabs-sync.test.ts | 90 --------------- .../local-structured-session-tabs-sync.ts | 14 +-- .../inventory-generation-fence.ts | 30 ++--- .../snapshot-apply.ts | 33 +----- .../src/web/preload-api/web-app-api.ts | 3 + 24 files changed, 437 insertions(+), 226 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-registry.test.ts create mode 100644 src/renderer/src/runtime/local-structured-chats.test.ts create mode 100644 src/renderer/src/runtime/local-structured-chats.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registry.test.ts new file mode 100644 index 000000000000..4161cc2229ab --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-registry.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + onStructuredAgentSessionHostInstalled, + setStructuredAgentSessionHost +} from './structured-agent-session-registry' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry only holds the reference. +const HOST = {} as StructuredAgentSessionHost + +afterEach(() => { + setStructuredAgentSessionHost(null) +}) + +// The renderer mirrors this machine's chats once the host exists, e.g. when a paired client +// creates the first chat here while the chat setting is off. +describe('the structured host install signal', () => { + it('fires once when a host is installed where there was none', () => { + const listener = vi.fn() + const stop = onStructuredAgentSessionHostInstalled(listener) + + setStructuredAgentSessionHost(HOST) + setStructuredAgentSessionHost(HOST) + + expect(listener).toHaveBeenCalledOnce() + stop() + }) + + it('does not fire when the host is torn down', () => { + setStructuredAgentSessionHost(HOST) + const listener = vi.fn() + const stop = onStructuredAgentSessionHostInstalled(listener) + + setStructuredAgentSessionHost(null) + + expect(listener).not.toHaveBeenCalled() + stop() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts index b7a26bb8e3ee..ab2f4a7f38d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts @@ -8,9 +8,22 @@ import type { StructuredAgentSessionHost } from './structured-agent-session-host' let host: StructuredAgentSessionHost | null = null +const installedListeners = new Set<() => void>() export function setStructuredAgentSessionHost(next: StructuredAgentSessionHost | null): void { + const installed = next !== null && host === null host = next + if (installed) { + for (const listener of installedListeners) { + listener() + } + } +} + +/** Called each time a host is installed where there was none: this runtime now holds chats. */ +export function onStructuredAgentSessionHostInstalled(listener: () => void): () => void { + installedListeners.add(listener) + return () => installedListeners.delete(listener) } export function getStructuredAgentSessionHost(): StructuredAgentSessionHost | null { diff --git a/src/main/startup/main-process-ipc-bootstrap.ts b/src/main/startup/main-process-ipc-bootstrap.ts index 918fd8443640..a6e3612836fb 100644 --- a/src/main/startup/main-process-ipc-bootstrap.ts +++ b/src/main/startup/main-process-ipc-bootstrap.ts @@ -3,6 +3,10 @@ import { recoverLegacyWorkerTerminalsForRendererStartup } from './legacy-worker- import { logStartupMilestone } from './startup-diagnostics' import { mainProcessState as state } from './main-process-state' import { resolveOpenedMarkdownDocuments } from './os-opened-markdown-files' +import { + getStructuredAgentSessionHost, + onStructuredAgentSessionHostInstalled +} from '../native-chat/agent-session-wire/structured-agent-session-registry' export function registerMainProcessIpcHandlers(): void { ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { @@ -25,6 +29,15 @@ export function registerMainProcessIpcHandlers(): void { ]) await state.runtime?.prepareStructuredAgentSessionStartupRestoration() }) + // The host is built only when this runtime holds structured chats (saved ones restored at startup, + // or one a client created), which is when the renderer has chats of this machine's to mirror. + ipcMain.handle( + 'app:hasStructuredAgentSessionHost', + () => getStructuredAgentSessionHost() !== null + ) + onStructuredAgentSessionHostInstalled(() => { + state.mainWindow?.webContents.send('app:structuredAgentSessionHostInstalled') + }) ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup', () => recoverLegacyWorkerTerminalsForRendererStartup({ firstWindowStartupServicesReady: state.firstWindowStartupServicesReady, diff --git a/src/preload/api/app-api.ts b/src/preload/api/app-api.ts index b2d6eed966c4..3590356c2458 100644 --- a/src/preload/api/app-api.ts +++ b/src/preload/api/app-api.ts @@ -43,6 +43,10 @@ export type AppApi = { awaitGitEnvironmentStartupBarrier: () => Promise /** Inventories retained PTYs and restores durable structured ownership before renderer adoption. */ prepareTerminalStartupRestoration: () => Promise + /** Whether this machine's runtime holds structured chats: its structured host is built. */ + hasStructuredAgentSessionHost: () => Promise + /** Fires when this machine's structured host is built after startup, e.g. by a paired client. */ + onStructuredAgentSessionHostInstalled: (callback: () => void) => () => void /** Reconciles legacy worker authority around persisted terminal reconnect. */ recoverLegacyWorkerTerminalsForRendererStartup: () => Promise /** Emits a startup benchmark marker when ORCA_STARTUP_DIAGNOSTICS is enabled. */ diff --git a/src/preload/api/app-bridge.ts b/src/preload/api/app-bridge.ts index 2a0d50e9de41..47be8caae781 100644 --- a/src/preload/api/app-bridge.ts +++ b/src/preload/api/app-bridge.ts @@ -47,6 +47,13 @@ export const appApi = { ipcRenderer.invoke('app:awaitGitEnvironmentStartupBarrier'), prepareTerminalStartupRestoration: (): Promise => ipcRenderer.invoke('app:prepareTerminalStartupRestoration'), + hasStructuredAgentSessionHost: (): Promise => + ipcRenderer.invoke('app:hasStructuredAgentSessionHost'), + onStructuredAgentSessionHostInstalled: (callback: () => void): (() => void) => { + const listener = (): void => callback() + ipcRenderer.on('app:structuredAgentSessionHostInstalled', listener) + return () => ipcRenderer.removeListener('app:structuredAgentSessionHostInstalled', listener) + }, recoverLegacyWorkerTerminalsForRendererStartup: (): Promise => ipcRenderer.invoke('app:recoverLegacyWorkerTerminalsForRendererStartup'), startupDiagnostic: (event: string, details?: Record): Promise => diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 744ddc83ff17..4186481e7fa9 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react' +import { restoreLocalStructuredChatsAtStartup } from '@/runtime/local-structured-chats' import { syncZoomCSSVar } from '@/lib/ui-zoom' import { installCodexDetachedPaneRestartExecutor } from '@/components/terminal-pane/codex-detached-pane-restart-scheduler' import { useAppStore } from '../store' @@ -35,7 +36,6 @@ import { } from '../../../shared/execution-host' import { mapWithConcurrency } from '../../../shared/map-with-concurrency' import type { OnboardingState } from '../../../shared/onboarding-state-types' -import { restoreLocalStructuredSessionTabsOnce } from '../runtime/local-structured-session-tabs-sync' import { ensureLocalRuntimeCapabilities } from '../runtime/local-runtime-capabilities' async function listRuntimeSessionHostIdsForStartup(): Promise { @@ -281,9 +281,8 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta await timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect', () => window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() ) - // Existing chats come back whatever the chat setting says; it picks only new launches. - await timeRendererStartupStep('project-structured-session-tabs', () => - restoreLocalStructuredSessionTabsOnce() + await restoreLocalStructuredChatsAtStartup(useAppStore.getState().settings, (restore) => + timeRendererStartupStep('project-structured-session-tabs', restore) ) if (cancelled) { return diff --git a/src/renderer/src/components/native-chat-restart-offer-gate.ts b/src/renderer/src/components/native-chat-restart-offer-gate.ts index 330e46e4f92d..5b3bb66f8668 100644 --- a/src/renderer/src/components/native-chat-restart-offer-gate.ts +++ b/src/renderer/src/components/native-chat-restart-offer-gate.ts @@ -1,15 +1,10 @@ -import { useAppStore } from '../store' -import { getStructuredAgentSessionTabs } from './native-chat/structured-agent-session-tabs' +import { useLocalStructuredChatsInUse } from '@/runtime/local-structured-chats' /** - * Whether this window offers to carry on its chats: wherever structured chats exist, since the - * chat setting picks only what new agents open as. Without either, a machine that never used - * structured chat builds no host to answer an empty offer. + * Whether this window offers to carry on its chats. The offer is this machine's runtime's, so it is + * asked only where that runtime can hold chats: the setting launches them, or it holds some. A + * machine that never used structured chat builds no host to answer an empty offer. */ export function useNativeChatRestartOfferEnabled(): boolean { - return useAppStore( - (store) => - store.settings?.experimentalStructuredNativeChat === true || - getStructuredAgentSessionTabs(store.unifiedTabsByWorktree).length > 0 - ) + return useLocalStructuredChatsInUse() } diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index ba8e15189216..bbb0c898cae1 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -1,5 +1,6 @@ // @vitest-environment happy-dom +import { resetLocalStructuredChatsForTests } from '@/runtime/local-structured-chats' import { act } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { createRoot, type Root } from 'react-dom/client' @@ -258,10 +259,8 @@ describe('ExperimentalPane', () => { }) expect(container.textContent).toContain('Use updated structured native chat') - // The one opt-in gates both providers, so its copy must not name only Codex. - expect(container.textContent).toContain( - 'Opt in to the host-owned structured chat runtime for Codex and Claude.' - ) + // The one setting governs both providers, so its copy must not name only Codex. + expect(container.textContent).toContain('Open new Codex and Claude agents as structured chats.') expect(container.textContent).toContain( 'Local sessions only for now. WSL and remote execution hosts (including SSH) continue to use terminal chat, and Windows falls back to it unless Orca can read process start times.' ) @@ -269,6 +268,46 @@ describe('ExperimentalPane', () => { root.unmount() }) + // Those settings govern the chats this machine holds, which keep running with the setting off. + it('shows the structured chat settings while this machine holds chats, whatever the setting', async () => { + Object.defineProperty(window, 'api', { + configurable: true, + value: { + app: { + hasStructuredAgentSessionHost: async () => true, + onStructuredAgentSessionHostInstalled: () => () => undefined + } + } + }) + try { + const { root, container } = await renderExperimentalPane({ + updateSettings: vi.fn(), + settings: { ...getDefaultSettings('/tmp'), experimentalNativeChat: true } + }) + await act(async () => { + await Promise.resolve() + }) + + expect(container.textContent).toContain('Resume working chats automatically after a restart') + root.unmount() + } finally { + resetLocalStructuredChatsForTests() + Reflect.deleteProperty(window, 'api') + } + }) + + it('hides the structured chat settings on a machine that holds none with the setting off', async () => { + const { root, container } = await renderExperimentalPane({ + updateSettings: vi.fn(), + settings: { ...getDefaultSettings('/tmp'), experimentalNativeChat: true } + }) + + expect(container.textContent).not.toContain( + 'Resume working chats automatically after a restart' + ) + root.unmount() + }) + it('hides a stale structured opt-in under Terminal chat without clearing it', async () => { const updateSettings = vi.fn() const settings = { diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index 0c593a0fd6a5..3ebf89bc7568 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -1,4 +1,5 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { useLocalStructuredAgentSessionHostInstalled } from '@/runtime/local-structured-chats' import { translate } from '@/i18n/i18n' import { Label } from '../ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' @@ -25,8 +26,11 @@ export function NativeChatExperimentalSetting({ const resumeOnRestartEnabled = settings.nativeChatResumeWorkOnRestart === true const defaultView: NativeChatDefaultView = settings.openAgentTabsInChatByDefault === true ? 'native-chat' : 'terminal-chat' - // Structured-only settings; terminal-backed chat never reads them. - const structuredChatActive = defaultView === 'native-chat' && structuredNativeChatEnabled + // Structured-only settings; terminal-backed chat never reads them. They govern the chats this + // machine holds too, which keep running whatever the setting says. + const holdsStructuredChats = useLocalStructuredAgentSessionHostInstalled() + const structuredChatActive = + (defaultView === 'native-chat' && structuredNativeChatEnabled) || holdsStructuredChats return ( {translate( 'auto.components.settings.ExperimentalPane.nativeChat.structuredCopy', - 'Opt in to the host-owned structured chat runtime for Codex and Claude. It decides what new agents open as; off opens them in the existing terminal-backed chat. Chats you already have stay as they are.' + 'Open new Codex and Claude agents as structured chats. Off opens them in the terminal-backed chat. Chats that already exist stay as they are.' )}

diff --git a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx index efeffdf4af1d..775a97f628f6 100644 --- a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx +++ b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx @@ -1,5 +1,6 @@ // @vitest-environment happy-dom +import { resetLocalStructuredChatsForTests } from '@/runtime/local-structured-chats' import { act, cleanup, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' @@ -40,6 +41,19 @@ const candidates: ResumeCandidate[] = [ } ] +/** Whether this machine's runtime reports a structured host, as the desktop bridge answers it. */ +function stageLocalHost(installed: boolean): void { + Object.defineProperty(window, 'api', { + configurable: true, + value: { + app: { + hasStructuredAgentSessionHost: async () => installed, + onStructuredAgentSessionHostInstalled: () => () => undefined + } + } + }) +} + /** Mounts the segment and snoozes the launch dialog the offer raises, as the modal's close does. */ async function mount(iconOnly = false): Promise { await act(async () => { @@ -65,6 +79,8 @@ describe('NativeChatResumeStatusSegment', () => { afterEach(() => { cleanup() + resetLocalStructuredChatsForTests() + Reflect.deleteProperty(window, 'api') _resetNativeChatRestartOffer() consumeNativeChatResumeOnRestartDialogRequest() useAppStore.setState(useAppStore.getInitialState(), true) @@ -188,7 +204,20 @@ describe('NativeChatResumeStatusSegment', () => { }) // The setting picks what new agents open as; chats that already exist keep their offer. - it('offers to continue existing chats while the setting is off', async () => { + it('offers to continue the chats this machine holds while the setting is off', async () => { + rpc.mockResolvedValue({ sessions: candidates }) + useAppStore.setState({ + settings: { ...getDefaultSettings(''), experimentalStructuredNativeChat: false } + }) + stageLocalHost(true) + await mount() + + expect(rpc).toHaveBeenCalledWith(expect.anything(), 'agentSession.restartResumable') + expect(screen.getByRole('button')).toBeTruthy() + }) + + // The offer is this machine's runtime's; a paired server's chats are no reason to build it. + it("does not ask this machine for an offer over a paired server's chats", async () => { rpc.mockResolvedValue({ sessions: candidates }) useAppStore.setState({ settings: { ...getDefaultSettings(''), experimentalStructuredNativeChat: false }, @@ -199,6 +228,7 @@ describe('NativeChatResumeStatusSegment', () => { entityId: 'claude_1', groupId: 'group-1', worktreeId: 'wt-1', + executionHostId: 'runtime:server-1', contentType: 'agent-session', agentSessionAgent: 'claude', label: 'Claude Chat', @@ -210,10 +240,11 @@ describe('NativeChatResumeStatusSegment', () => { ] } }) + stageLocalHost(false) await mount() - expect(rpc).toHaveBeenCalledWith(expect.anything(), 'agentSession.restartResumable') - expect(screen.getByRole('button')).toBeTruthy() + expect(rpc).not.toHaveBeenCalled() + expect(screen.queryByRole('button')).toBeNull() }) it('renders a compact count in icon-only mode', async () => { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2ea65fe4c16f..c697ba4f08be 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7247,7 +7247,7 @@ "defaultViewTerminal": "Terminal chat", "defaultViewNative": "Chat UI", "structuredTitle": "Use updated structured native chat", - "structuredCopy": "Opt in to the host-owned structured chat runtime for Codex and Claude. It decides what new agents open as; off opens them in the existing terminal-backed chat. Chats you already have stay as they are.", + "structuredCopy": "Open new Codex and Claude agents as structured chats. Off opens them in the terminal-backed chat. Chats that already exist stay as they are.", "structuredScope": "Local sessions only for now. WSL and remote execution hosts (including SSH) continue to use terminal chat, and Windows falls back to it unless Orca can read process start times.", "structuredToggleLabel": "Toggle updated structured native chat", "resumeTitle": "Resume working chats automatically after a restart", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 3f798004fe63..eac1d9d61c04 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -7189,7 +7189,7 @@ "shellEnvCopy": "Les chats Codex et Claude démarrent avec toutes les variables exportées par votre shell de connexion, comme un terminal. Désactivez pour choisir celles qu'ils reçoivent.", "shellEnvToggleLabel": "Activer l'utilisation de l'environnement de votre shell", "structuredTitle": "Utiliser le chat natif structuré mis à jour", - "structuredCopy": "Inscrivez-vous au runtime de chat structuré appartenant à l'hôte pour Codex et Claude. Ce réglage détermine comment s'ouvrent les nouveaux agents ; désactivé, ils s'ouvrent dans le chat existant basé sur le terminal. Les discussions existantes restent telles quelles.", + "structuredCopy": "Ouvre les nouveaux agents Codex et Claude sous forme de discussions structurées. Désactivé, ils s'ouvrent dans le chat basé sur le terminal. Les discussions existantes restent telles quelles.", "structuredScope": "Séances locales uniquement pour l'instant. WSL et les hôtes d'exécution à distance (y compris SSH) continuent d'utiliser le chat du terminal, et Windows y revient à moins qu'Orca ne puisse lire les heures de début des processus.", "structuredToggleLabel": "Basculer le chat natif structuré mis à jour", "resumeTitle": "Reprendre automatiquement les discussions de travail après un redémarrage", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 28809ba08ded..fec703a33130 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -7071,7 +7071,7 @@ "shellEnvCopy": "Codex と Claude のチャットは、ターミナルと同じく、ログインシェルがエクスポートするすべての変数を引き継いで開始します。オフにすると、渡す変数を選択できます。", "shellEnvToggleLabel": "シェル環境の使用の切り替え", "structuredTitle": "更新された構造化ネイティブチャットを使用する", - "structuredCopy": "Codex と Claude 用のホスト所有の構造化チャットランタイムにオプトインします。この設定は新規 Agent の開き方を決めます。オフにすると既存のターミナルを使用したチャットで開きます。既存のチャットはそのまま残ります。", + "structuredCopy": "新規の Codex および Claude の Agent を構造化チャットとして開きます。オフにすると、ターミナルを使用したチャットで開きます。既存のチャットはそのまま残ります。", "structuredScope": "今のところローカルセッションのみ。 WSL およびリモート実行ホスト (SSH を含む) は引き続きターミナルチャットを使用し、Orca がプロセスの開始時間を読み取ることができない限り、Windows はターミナルチャットにフォールバックします。", "structuredToggleLabel": "更新された構造化ネイティブチャットの切り替え", "resumeTitle": "再起動後に作業中のチャットを自動的に再開する", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index f44be6a3c295..f2d82aac8129 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -7071,7 +7071,7 @@ "shellEnvCopy": "Codex 및 Claude 채팅은 터미널과 마찬가지로 로그인 셸이 내보내는 모든 변수를 가지고 시작합니다. 끄면 전달할 변수를 선택할 수 있습니다.", "shellEnvToggleLabel": "셸 환경 사용 전환", "structuredTitle": "업데이트된 구조화된 기본 채팅 사용", - "structuredCopy": "Codex와 Claude를 위한 호스트 소유의 구조화된 채팅 런타임을 선택하세요. 이 설정은 새 에이전트를 여는 방식을 정합니다. 끄면 기존 터미널 지원 채팅으로 엽니다. 이미 있는 채팅은 그대로 유지됩니다.", + "structuredCopy": "새 Codex 및 Claude 에이전트를 구조화된 채팅으로 엽니다. 끄면 터미널 지원 채팅으로 엽니다. 이미 있는 채팅은 그대로 유지됩니다.", "structuredScope": "현재는 로컬 세션만 가능합니다. WSL 및 원격 실행 호스트(SSH 포함)는 계속해서 터미널 채팅을 사용하며 Orca가 프로세스 시작 시간을 읽을 수 없으면 Windows는 이 채팅을 사용합니다.", "structuredToggleLabel": "업데이트된 구조화된 기본 채팅 전환", "resumeTitle": "다시 시작한 후 작업 중인 채팅을 자동으로 재개합니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a2dc903bb15c..8a54eac4b85a 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -7071,7 +7071,7 @@ "shellEnvCopy": "Codex 和 Claude 聊天启动时会带上登录 shell 导出的所有变量,与终端一致。关闭后可选择传递哪些变量。", "shellEnvToggleLabel": "切换使用 shell 环境", "structuredTitle": "使用更新的结构化本机聊天", - "structuredCopy": "选择加入 Codex 和 Claude 的主机拥有的结构化聊天运行时。此设置决定新代理的打开方式;关闭时新代理会在现有的终端支持的聊天中打开。已有的聊天保持不变。", + "structuredCopy": "将新的 Codex 和 Claude 代理作为结构化聊天打开。关闭时会在终端支持的聊天中打开。已有的聊天保持不变。", "structuredScope": "目前仅限本地会话。 WSL 和远程执行主机(包括 SSH)继续使用终端聊天,Windows 会回退到它,除非 Orca 可以读取进程启动时间。", "structuredToggleLabel": "切换更新的结构化本机聊天", "resumeTitle": "重启后自动恢复工作聊天", diff --git a/src/renderer/src/runtime/local-structured-chats.test.ts b/src/renderer/src/runtime/local-structured-chats.test.ts new file mode 100644 index 000000000000..868fbaea0a90 --- /dev/null +++ b/src/renderer/src/runtime/local-structured-chats.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ hostInstalled: false, isWebClient: false })) + +vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: () => mocks.isWebClient })) +vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) +vi.mock('./local-structured-session-tabs-sync/inventory-refresh', () => ({ + restoreLocalStructuredSessionTabsOnce: vi.fn(async () => undefined) +})) + +import { + resetLocalStructuredChatsForTests, + restoreLocalStructuredChatsAtStartup +} from './local-structured-chats' + +const SETTING_OFF = { experimentalStructuredNativeChat: false } +const SETTING_ON = { experimentalStructuredNativeChat: true } + +beforeEach(() => { + mocks.hostInstalled = false + mocks.isWebClient = false + resetLocalStructuredChatsForTests() + vi.stubGlobal('window', { + api: { + app: { + hasStructuredAgentSessionHost: async () => mocks.hostInstalled, + onStructuredAgentSessionHostInstalled: () => () => undefined + } + } + }) +}) + +describe("startup's restore of this machine's structured chats", () => { + it('runs no session-tab census for a default user who never held a chat', async () => { + const step = vi.fn(async () => undefined) + + await restoreLocalStructuredChatsAtStartup(SETTING_OFF, step) + + expect(step).not.toHaveBeenCalled() + }) + + // Existing chats come back whatever the setting says. + it('restores the chats this machine holds with the setting off', async () => { + mocks.hostInstalled = true + const step = vi.fn(async () => undefined) + + await restoreLocalStructuredChatsAtStartup(SETTING_OFF, step) + + expect(step).toHaveBeenCalledOnce() + }) + + it('restores when the setting launches structured chats', async () => { + const step = vi.fn(async () => undefined) + + await restoreLocalStructuredChatsAtStartup(SETTING_ON, step) + + expect(step).toHaveBeenCalledOnce() + }) + + it('never restores in the browser client', async () => { + mocks.isWebClient = true + mocks.hostInstalled = true + const step = vi.fn(async () => undefined) + + await restoreLocalStructuredChatsAtStartup(SETTING_ON, step) + + expect(step).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/local-structured-chats.ts b/src/renderer/src/runtime/local-structured-chats.ts new file mode 100644 index 000000000000..dbfe485b9c2d --- /dev/null +++ b/src/renderer/src/runtime/local-structured-chats.ts @@ -0,0 +1,107 @@ +import { useSyncExternalStore } from 'react' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import { isWebClientLocation } from '@/lib/web-client-location' +import { useAppStore } from '@/store' +import { restoreLocalStructuredSessionTabsOnce } from './local-structured-session-tabs-sync/inventory-refresh' + +// Whether THIS machine's runtime holds structured chats, as its host says: the host is built only +// when saved chats were restored at startup or a client created one here. The chat setting picks +// what new launches open as, so the chats that exist show whatever it says; a machine that never +// held one pays for no chat mirror. The browser client has no runtime of its own. + +let hostInstalled = false +const listeners = new Set<() => void>() +let stopListening: (() => void) | null = null + +function markHostInstalled(): void { + if (hostInstalled) { + return + } + hostInstalled = true + for (const listener of listeners) { + listener() + } +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener) + // Why optional: a window without the desktop bridge has no runtime to hold chats. + const app = typeof window === 'undefined' ? undefined : window.api?.app + if (!stopListening && app && !isWebClientLocation()) { + stopListening = app.onStructuredAgentSessionHostInstalled(markHostInstalled) + void readLocalStructuredAgentSessionHostInstalled() + } + return () => { + listeners.delete(listener) + } +} + +/** Asks this machine's runtime whether its structured host is built. */ +export async function readLocalStructuredAgentSessionHostInstalled(): Promise { + if (isWebClientLocation()) { + return false + } + try { + if (await window.api?.app?.hasStructuredAgentSessionHost()) { + markHostInstalled() + } + } catch { + // An unanswered query is not an answer; the install event still arrives. + } + return hostInstalled +} + +/** This machine's runtime holds structured chats. */ +export function useLocalStructuredAgentSessionHostInstalled(): boolean { + return useSyncExternalStore( + subscribe, + () => hostInstalled, + () => false + ) +} + +type StructuredChatSettings = Pick | null + +function chatsInUse(settings: StructuredChatSettings | undefined, installed: boolean): boolean { + return ( + !isWebClientLocation() && (settings?.experimentalStructuredNativeChat === true || installed) + ) +} + +/** Structured chats can exist on this machine: the setting launches them, or the runtime holds some. */ +export function useLocalStructuredChatsInUse(): boolean { + const installed = useLocalStructuredAgentSessionHostInstalled() + const setting = useAppStore((state) => state.settings?.experimentalStructuredNativeChat === true) + return chatsInUse({ experimentalStructuredNativeChat: setting }, installed) +} + +/** The same answer for one-shot startup work, asked of the host rather than read from the renderer. */ +export async function localStructuredChatsInUse( + settings: StructuredChatSettings | undefined +): Promise { + if (isWebClientLocation()) { + return false + } + return chatsInUse(settings, await readLocalStructuredAgentSessionHostInstalled()) +} + +/** + * Startup's restore of this machine's chats. Existing chats come back whatever the chat setting + * says; a machine that holds none, or the browser client, runs no session-tab census for them. + */ +export async function restoreLocalStructuredChatsAtStartup( + settings: StructuredChatSettings | undefined, + runStep: (restore: () => Promise) => Promise +): Promise { + if (await localStructuredChatsInUse(settings)) { + await runStep(() => restoreLocalStructuredSessionTabsOnce()) + } +} + +/** @internal - tests need a clean module between cases. */ +export function resetLocalStructuredChatsForTests(): void { + stopListening?.() + stopListening = null + hostInstalled = false + listeners.clear() +} diff --git a/src/renderer/src/runtime/local-structured-session-tab-retirement.ts b/src/renderer/src/runtime/local-structured-session-tab-retirement.ts index 81a8d0d5ef5c..db5608490440 100644 --- a/src/renderer/src/runtime/local-structured-session-tab-retirement.ts +++ b/src/renderer/src/runtime/local-structured-session-tab-retirement.ts @@ -1,6 +1,5 @@ import type { WorktreeRuntimeOwnerState } from '../lib/worktree-runtime-owner' import { folderWorkspaceKey } from '../../../shared/workspace-scope' -import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync' import type { WebSessionTabsSyncState } from './web-session-tabs-sync' export type StructuredSessionTabPublicationVersion = { @@ -27,38 +26,3 @@ export function knownStructuredSessionWorktreeIds( } return ids } - -export function removeStructuredSessionTabsForVersions< - State extends WebSessionTabsSyncState & WorktreeRuntimeOwnerState ->( - state: State, - versions: Iterable, - owner: string, - now: number -): State { - let next = state - for (const [worktree, version] of versions) { - const patch = applyWebSessionTabsSnapshot( - next, - { - worktree, - publicationEpoch: version.publicationEpoch, - snapshotVersion: version.snapshotVersion + 1, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabGroups: [], - tabs: [] - }, - owner, - now, - { - contentScope: 'agent-session', - preserveLocalLayout: true, - terminalPtyMode: 'local' - } - ) - next = patch === next ? next : ({ ...next, ...patch } as State) - } - return next -} diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts index 4fc4ca12c3aa..1c0c1c750a7b 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts @@ -3,14 +3,21 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ start: vi.fn(async () => undefined) })) +const mocks = vi.hoisted(() => ({ + start: vi.fn(async () => undefined), + hostInstalled: false, + isWebClient: false, + installedListener: null as null | (() => void) +})) vi.mock('./local-structured-session-tabs-sync/subscription', () => ({ startLocalStructuredSessionTabsSync: mocks.start })) +vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: () => mocks.isWebClient })) import { useAppStore } from '../store' import { useLocalStructuredSessionTabsSync } from './local-structured-session-tabs-sync' +import { resetLocalStructuredChatsForTests } from './local-structured-chats' function setStructuredChat(enabled: boolean): void { useAppStore.setState({ @@ -18,35 +25,87 @@ function setStructuredChat(enabled: boolean): void { }) } +async function mountSync(): Promise { + renderHook(() => useLocalStructuredSessionTabsSync()) + // The host is asked whether it holds chats; its answer lands on the next tick. + await act(async () => { + await Promise.resolve() + }) +} + beforeEach(() => { mocks.start.mockClear() - useAppStore.setState({ - workspaceSessionReady: true, - terminalStartupRestorationReady: true + mocks.hostInstalled = false + mocks.isWebClient = false + mocks.installedListener = null + resetLocalStructuredChatsForTests() + Object.defineProperty(window, 'api', { + configurable: true, + value: { + app: { + hasStructuredAgentSessionHost: vi.fn(async () => mocks.hostInstalled), + onStructuredAgentSessionHostInstalled: vi.fn((listener: () => void) => { + mocks.installedListener = listener + return () => undefined + }) + } + } }) + useAppStore.setState({ workspaceSessionReady: true, terminalStartupRestorationReady: true }) }) afterEach(() => { cleanup() + resetLocalStructuredChatsForTests() useAppStore.setState(useAppStore.getInitialState(), true) }) -// The setting picks what new agents open as; chats that already exist keep showing. -describe("this machine's structured chats", () => { - it('are mirrored with the structured chat setting off', () => { +describe("this machine's structured chat mirror", () => { + it('holds no session-tabs subscription for a machine that never held a chat', async () => { setStructuredChat(false) - renderHook(() => useLocalStructuredSessionTabsSync()) + await mountSync() + + expect(mocks.start).not.toHaveBeenCalled() + }) + + // The setting picks what new agents open as; chats that already exist keep showing. + it('mirrors the chats this machine holds with the setting off', async () => { + setStructuredChat(false) + mocks.hostInstalled = true + + await mountSync() expect(mocks.start).toHaveBeenCalledOnce() }) - it('stay mirrored when the setting is turned off', () => { + it('starts when a paired client creates the first chat here, without a restart', async () => { + setStructuredChat(false) + await mountSync() + expect(mocks.start).not.toHaveBeenCalled() + + act(() => mocks.installedListener?.()) + + expect(mocks.start).toHaveBeenCalledOnce() + }) + + it('stays mirrored when the setting is turned off over chats this machine holds', async () => { setStructuredChat(true) - renderHook(() => useLocalStructuredSessionTabsSync()) + mocks.hostInstalled = true + await mountSync() act(() => setStructuredChat(false)) expect(mocks.start).toHaveBeenCalledOnce() }) + + it('never runs in the browser client, which has no runtime of its own', async () => { + mocks.isWebClient = true + setStructuredChat(true) + mocks.hostInstalled = true + + await mountSync() + + expect(mocks.start).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts index 2af30958ae17..45a539cdc6e0 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts @@ -10,10 +10,7 @@ import { buildPersistedUnifiedTabSessionData } from '../lib/workspace-session-un import { buildHydratedTabState } from '../store/slices/tabs-hydration' import { applyLocalStructuredSessionTabSnapshots, - clearLocalStructuredSessionTabs, projectLocalStructuredSessionTabs, - removeLocalStructuredSessionTabs, - refreshLocalStructuredSessionTabs, resetLocalStructuredSessionVersionForTests, startLocalStructuredSessionTabsSync } from './local-structured-session-tabs-sync' @@ -163,19 +160,6 @@ function expectExactSplit(state: { } describe('local structured session tab projection', () => { - it('removes only locally mirrored structured tabs when the feature is disabled', () => { - const mirrored = applyLocalStructuredSessionTabSnapshots(createSnapshot(), [ - structuredInventory('epoch-1', 1, 'codex-1') - ]) - - const disabled = removeLocalStructuredSessionTabs(mirrored) - - expect(disabled.unifiedTabsByWorktree[WORKTREE_ID]).toEqual([ - expect.objectContaining({ id: TERMINAL_ID, contentType: 'terminal' }) - ]) - expect(disabled.activeTabTypeByWorktree[WORKTREE_ID]).toBe('terminal') - }) - it('reports publication from every accepted host snapshot', () => { const accepted: string[] = [] applyLocalStructuredSessionTabSnapshots( @@ -260,80 +244,6 @@ describe('local structured session tab projection', () => { } }) - it('ignores an in-flight inventory response after toggle-off clears the mirror', async () => { - let resolveInventory: ((response: unknown) => void) | undefined - const pendingInventory = new Promise((resolve) => { - resolveInventory = resolve - }) - const priorApi = window.api - Object.defineProperty(window, 'api', { - configurable: true, - value: { - runtime: { - call: vi.fn().mockReturnValue(pendingInventory) - } - } - }) - try { - const refresh = refreshLocalStructuredSessionTabs() - clearLocalStructuredSessionTabs() - resolveInventory?.({ - ok: true, - result: { snapshots: [structuredInventory('epoch-1', 8, 'stale-session')] } - }) - await refresh - - const fresh = applyLocalStructuredSessionTabSnapshots(createSnapshot(), [ - structuredInventory('epoch-1', 1, 'fresh-session') - ]) - expect(fresh.unifiedTabsByWorktree[WORKTREE_ID]).toEqual( - expect.arrayContaining([expect.objectContaining({ entityId: 'fresh-session' })]) - ) - } finally { - Object.defineProperty(window, 'api', { configurable: true, value: priorApi }) - } - }) - - it('ignores a subscription frame after toggle-off clears the mirror', async () => { - const callbacks: ((response: unknown) => void)[] = [] - const priorApi = window.api - Object.defineProperty(window, 'api', { - configurable: true, - value: { - runtime: { - getStatus: vi.fn().mockResolvedValue({ - capabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] - }), - call: vi.fn().mockResolvedValue({ ok: true, result: { snapshots: [] } }), - subscribe: vi.fn(async (_args: unknown, callback: (response: unknown) => void) => { - callbacks.push(callback) - return { unsubscribe: vi.fn() } - }) - } - } - }) - let unsubscribe = (): void => {} - try { - await startLocalStructuredSessionTabsSync({ - isDisposed: () => false, - setUnsubscribe: (next) => { - unsubscribe = next - } - }) - clearLocalStructuredSessionTabs() - callbacks[0]?.({ ok: true, result: structuredInventory('epoch-1', 8, 'stale-session') }) - const fresh = applyLocalStructuredSessionTabSnapshots(createSnapshot(), [ - structuredInventory('epoch-1', 1, 'fresh-session') - ]) - expect(fresh.unifiedTabsByWorktree[WORKTREE_ID]).toEqual( - expect.arrayContaining([expect.objectContaining({ entityId: 'fresh-session' })]) - ) - } finally { - unsubscribe() - Object.defineProperty(window, 'api', { configurable: true, value: priorApi }) - } - }) - it('starts the session-tabs inventory without waiting for the capability refresh', async () => { const priorApi = window.api let releaseStatus = (): void => undefined diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts index df8674323f0f..011422abb4c8 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts @@ -1,6 +1,7 @@ import { useEffect } from 'react' import { useAppStore } from '../store' import { startLocalStructuredSessionTabsSync } from './local-structured-session-tabs-sync/subscription' +import { useLocalStructuredChatsInUse } from './local-structured-chats' export { isCurrentLocalStructuredSessionGeneration, @@ -13,22 +14,21 @@ export { } from './local-structured-session-tabs-sync/inventory-refresh' export { applyLocalStructuredSessionTabSnapshots, - applyStructuredSessionTabSnapshots, - clearLocalStructuredSessionTabs, - removeLocalStructuredSessionTabs + applyStructuredSessionTabSnapshots } from './local-structured-session-tabs-sync/snapshot-apply' export { LOCAL_STRUCTURED_SESSION_OWNER } from './local-structured-session-owner' export { projectLocalStructuredSessionTabs } from './local-structured-session-tabs-sync/snapshot-projection' export { startLocalStructuredSessionTabsSync } from './local-structured-session-tabs-sync/subscription' -/** Mirrors this machine's structured chats whatever the chat setting says: the setting picks what - * new agents open as, and chats that already exist keep showing. */ +/** Mirrors this machine's structured chats whenever they can exist, whatever the chat setting says: + * the setting picks what new agents open as, and chats that already exist keep showing. */ export function useLocalStructuredSessionTabsSync(): void { const ready = useAppStore( (state) => state.workspaceSessionReady && state.terminalStartupRestorationReady ) + const inUse = useLocalStructuredChatsInUse() useEffect(() => { - if (!ready) { + if (!ready || !inUse) { return } let disposed = false @@ -43,5 +43,5 @@ export function useLocalStructuredSessionTabsSync(): void { disposed = true unsubscribe() } - }, [ready]) + }, [inUse, ready]) } diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-generation-fence.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-generation-fence.ts index 8ab68385f9e1..647f22ef77a4 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-generation-fence.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-generation-fence.ts @@ -1,11 +1,10 @@ import type { SessionTabsPublicationEpochHistory } from '../web-session-tabs-sync/state' import type { StructuredSessionTabPublicationVersion } from '../local-structured-session-tab-retirement' -// Everything a toggle-off must invalidate: which publisher instance the renderer -// is listening to, which publication it already accepted per worktree, and the -// one-shot startup restore. A response in flight for a superseded instance must -// never reach the mirror, so every async entry point carries the generation it -// was started under and re-checks it before applying. +// Which publisher instance the renderer is listening to, which publication it +// already accepted per worktree, and the one-shot startup restore. Every async +// entry point carries the generation it was started under and re-checks it +// before applying, so a test reset fences responses still in flight. let syncGeneration = 0 let restorePromise: Promise | null = null @@ -26,22 +25,6 @@ export function isCurrentLocalStructuredSessionGeneration(generation: number): b return generation === syncGeneration } -/** Retire the current publisher instance: responses already in flight stop applying. */ -export function supersedeLocalStructuredSessionGeneration(): void { - syncGeneration += 1 -} - -// Separate from superseding because a teardown still has to publish the retiring -// cursors as retracted tabs before it may forget them. -export function forgetLocalStructuredSessionPublicationCursors(): void { - localStructuredSessionVersionByWorktree.clear() - localStructuredSessionEpochHistoryByWorktree.clear() -} - -export function dropLocalStructuredSessionRestoreLatch(): void { - restorePromise = null -} - /** Latch the startup restore, releasing it on failure so a retry can re-run it. */ export function latchLocalStructuredSessionRestore(start: () => Promise): Promise { restorePromise ??= start().catch((error: unknown) => { @@ -52,6 +35,7 @@ export function latchLocalStructuredSessionRestore(start: () => Promise): } export function resetLocalStructuredSessionVersionForTests(): void { - supersedeLocalStructuredSessionGeneration() - forgetLocalStructuredSessionPublicationCursors() + syncGeneration += 1 + localStructuredSessionVersionByWorktree.clear() + localStructuredSessionEpochHistoryByWorktree.clear() } diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts index f9e35cb056b5..a597296b3359 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts @@ -15,16 +15,10 @@ import { reviveRetiredValue, sameSessionTabsPublicationLineage } from '../web-session-tabs-sync/publisher-identity-fences' +import { knownStructuredSessionWorktreeIds } from '../local-structured-session-tab-retirement' import { - knownStructuredSessionWorktreeIds, - removeStructuredSessionTabsForVersions -} from '../local-structured-session-tab-retirement' -import { - dropLocalStructuredSessionRestoreLatch, - forgetLocalStructuredSessionPublicationCursors, localStructuredSessionEpochHistoryByWorktree, - localStructuredSessionVersionByWorktree, - supersedeLocalStructuredSessionGeneration + localStructuredSessionVersionByWorktree } from './inventory-generation-fence' import { forgetRetiredEpochRepairsOutside } from './retired-epoch-repair' import { projectLocalStructuredSessionTabs } from './snapshot-projection' @@ -109,29 +103,6 @@ export function applyStructuredSessionTabSnapshots( } } -export function removeLocalStructuredSessionTabs< - State extends WebSessionTabsSyncState & WorktreeRuntimeOwnerState ->(state: State, owner = LOCAL_STRUCTURED_SESSION_OWNER, now = Date.now()): State { - return removeStructuredSessionTabsForVersions( - state, - localStructuredSessionVersionByWorktree, - owner, - now - ) -} - -export function clearLocalStructuredSessionTabs(): void { - // Fence responses from the previous enabled instance before clearing its mirror. - supersedeLocalStructuredSessionGeneration() - const settleStructuredSessionClear = applyWebSessionTabsStorePatch( - (state) => removeLocalStructuredSessionTabs(state), - { frames: [] } - ) - settleStructuredSessionClear() - dropLocalStructuredSessionRestoreLatch() - forgetLocalStructuredSessionPublicationCursors() -} - export function applyLocalStructuredSessionTabSnapshots< State extends WebSessionTabsSyncState & WorktreeRuntimeOwnerState >( diff --git a/src/renderer/src/web/preload-api/web-app-api.ts b/src/renderer/src/web/preload-api/web-app-api.ts index 28fd9f99176d..fd0141431bc5 100644 --- a/src/renderer/src/web/preload-api/web-app-api.ts +++ b/src/renderer/src/web/preload-api/web-app-api.ts @@ -35,6 +35,9 @@ export function createWebAppApi(): Partial { awaitFirstWindowStartupServices: () => Promise.resolve(), awaitGitEnvironmentStartupBarrier: () => Promise.resolve(), prepareTerminalStartupRestoration: () => Promise.resolve(), + // The browser client has no runtime of its own. + hasStructuredAgentSessionHost: () => Promise.resolve(false), + onStructuredAgentSessionHostInstalled: () => () => undefined, recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(), startupDiagnostic: () => Promise.resolve(), getKeyboardInputSourceId: () => Promise.resolve(null), From 1b2f8953501589439e8bc4b049c3eae74458a396 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:12:50 -0700 Subject: [PATCH 10/18] test(native-chat): record install listeners without a cast --- .../local-structured-session-tabs-sync-hook.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts index 1c0c1c750a7b..b3216996b9f6 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts @@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({ start: vi.fn(async () => undefined), hostInstalled: false, isWebClient: false, - installedListener: null as null | (() => void) + installedListeners: new Array<() => void>() })) vi.mock('./local-structured-session-tabs-sync/subscription', () => ({ @@ -37,7 +37,7 @@ beforeEach(() => { mocks.start.mockClear() mocks.hostInstalled = false mocks.isWebClient = false - mocks.installedListener = null + mocks.installedListeners.splice(0) resetLocalStructuredChatsForTests() Object.defineProperty(window, 'api', { configurable: true, @@ -45,7 +45,7 @@ beforeEach(() => { app: { hasStructuredAgentSessionHost: vi.fn(async () => mocks.hostInstalled), onStructuredAgentSessionHostInstalled: vi.fn((listener: () => void) => { - mocks.installedListener = listener + mocks.installedListeners.push(listener) return () => undefined }) } @@ -84,7 +84,7 @@ describe("this machine's structured chat mirror", () => { await mountSync() expect(mocks.start).not.toHaveBeenCalled() - act(() => mocks.installedListener?.()) + act(() => mocks.installedListeners.forEach((listener) => listener())) expect(mocks.start).toHaveBeenCalledOnce() }) From 4bcad8943b5e30e2162b3bf226405eb2522f8fbc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:11:26 -0700 Subject: [PATCH 11/18] fix(native-chat): mirror this machine's chats only once it holds one, not once its host is built Session history, resume preparation, terminal resume commands and replay-safe phone launches all build the structured host for users who never had a chat, which turned on the chat mirror and the structured-only settings rows until the next restart. The signal is now derived from the host's records (or a records file still owed its import) and pushed when the first chat is restored or created. A throwing listener no longer fails the install that fired it. --- .../structured-agent-session-host.ts | 3 + .../structured-agent-session-registry.test.ts | 86 +++++++++-- .../structured-agent-session-registry.ts | 57 +++++-- .../runtime/agent-session-record-store.ts | 15 ++ ...ructured-agent-session-held-signal.test.ts | 145 ++++++++++++++++++ .../startup/main-process-ipc-bootstrap.ts | 20 +-- src/preload/api/app-api.ts | 8 +- src/preload/api/app-bridge.ts | 13 +- .../settings/ExperimentalPane.test.tsx | 4 +- .../NativeChatExperimentalSetting.tsx | 4 +- .../NativeChatResumeStatusSegment.test.tsx | 4 +- .../runtime/local-structured-chats.test.ts | 12 +- .../src/runtime/local-structured-chats.ts | 63 ++++---- ...-structured-session-tabs-sync-hook.test.ts | 54 +++++-- .../src/web/preload-api/web-app-api.ts | 4 +- 15 files changed, 392 insertions(+), 100 deletions(-) create mode 100644 src/main/runtime/structured-agent-session-held-signal.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 62603707ded9..28e5a991404c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -220,6 +220,9 @@ export class StructuredAgentSessionHost { showSessionTabs = (sessionIds: readonly string[]) => this.deps.store.showSessionTabs(sessionIds) /** The records file could not be read this launch, so chats it holds are not listed yet. */ legacyRecordImportOwed = (): boolean => this.deps.journalDatabase.legacyRecordImportOwed === true + /** This runtime holds a chat: a record, or the records file's chats still owed their copy. */ + holdsSessions = (): boolean => this.deps.store.holdsRecords() || this.legacyRecordImportOwed() + onSessionsHeld = (listener: () => void): (() => void) => this.deps.store.onFirstRecord(listener) setSessionTabVisibility = async ( sessionId: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registry.test.ts index 4161cc2229ab..3bdc49d55241 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-registry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-registry.test.ts @@ -1,39 +1,91 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { StructuredAgentSessionHost } from './structured-agent-session-host' import { - onStructuredAgentSessionHostInstalled, - setStructuredAgentSessionHost + getStructuredAgentSessionHost, + onStructuredAgentSessionsHeldChanged, + setStructuredAgentSessionHost, + structuredAgentSessionsHeld } from './structured-agent-session-registry' -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry only holds the reference. -const HOST = {} as StructuredAgentSessionHost +function fakeHost(initiallyHeld: boolean): { + host: StructuredAgentSessionHost + recordFirstChat: () => void +} { + let holds = initiallyHeld + const watchers = new Set<() => void>() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only these two members. + const host = { + holdsSessions: () => holds, + onSessionsHeld: (listener: () => void) => { + watchers.add(listener) + return () => watchers.delete(listener) + } + } as unknown as StructuredAgentSessionHost + return { + host, + recordFirstChat: () => { + holds = true + watchers.forEach((watcher) => watcher()) + } + } +} afterEach(() => { setStructuredAgentSessionHost(null) + vi.restoreAllMocks() }) -// The renderer mirrors this machine's chats once the host exists, e.g. when a paired client -// creates the first chat here while the chat setting is off. -describe('the structured host install signal', () => { - it('fires once when a host is installed where there was none', () => { +// The renderer mirrors this machine's chats once one exists, e.g. when a paired client creates the +// first chat here while the chat setting is off. Building the host is not holding a chat. +describe("the structured host's held-chats signal", () => { + it('stays false when a host is built over a profile with no chat', () => { const listener = vi.fn() - const stop = onStructuredAgentSessionHostInstalled(listener) + const stop = onStructuredAgentSessionsHeldChanged(listener) - setStructuredAgentSessionHost(HOST) - setStructuredAgentSessionHost(HOST) + setStructuredAgentSessionHost(fakeHost(false).host) - expect(listener).toHaveBeenCalledOnce() + expect(structuredAgentSessionsHeld()).toBe(false) + expect(listener).not.toHaveBeenCalled() stop() }) - it('does not fire when the host is torn down', () => { - setStructuredAgentSessionHost(HOST) + it('turns true once, when the first chat is recorded', () => { const listener = vi.fn() - const stop = onStructuredAgentSessionHostInstalled(listener) + const stop = onStructuredAgentSessionsHeldChanged(listener) + const { host, recordFirstChat } = fakeHost(false) + setStructuredAgentSessionHost(host) - setStructuredAgentSessionHost(null) + recordFirstChat() + recordFirstChat() - expect(listener).not.toHaveBeenCalled() + expect(structuredAgentSessionsHeld()).toBe(true) + expect(listener.mock.calls).toEqual([[true]]) stop() }) + + it('is true as soon as a host restores saved chats', () => { + const listener = vi.fn() + const stop = onStructuredAgentSessionsHeldChanged(listener) + + setStructuredAgentSessionHost(fakeHost(true).host) + + expect(listener.mock.calls).toEqual([[true]]) + stop() + }) + + it('never fails the install when a listener throws', () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const stopThrowing = onStructuredAgentSessionsHeldChanged(() => { + throw new Error('webContents destroyed') + }) + const later = vi.fn() + const stopLater = onStructuredAgentSessionsHeldChanged(later) + const { host } = fakeHost(true) + + expect(() => setStructuredAgentSessionHost(host)).not.toThrow() + expect(getStructuredAgentSessionHost()).toBe(host) + expect(later).toHaveBeenCalledWith(true) + stopThrowing() + stopLater() + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts index ab2f4a7f38d4..3b675c4054a2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts @@ -8,22 +8,59 @@ import type { StructuredAgentSessionHost } from './structured-agent-session-host' let host: StructuredAgentSessionHost | null = null -const installedListeners = new Set<() => void>() +let held = false +let stopWatchingHost: (() => void) | null = null +const heldListeners = new Set<(held: boolean) => void>() + +// Why typeof: tests install partial hosts. +function hostHoldsSessions(candidate: StructuredAgentSessionHost | null): boolean { + return typeof candidate?.holdsSessions === 'function' && candidate.holdsSessions() +} + +// A notification must never fail the install that triggered it: a retry would build a second host. +function publishHeld(): void { + try { + const next = hostHoldsSessions(host) + if (next === held) { + return + } + held = next + for (const listener of heldListeners) { + try { + listener(next) + } catch (error) { + console.warn('[structured-agent-session] a held-chats listener threw', error) + } + } + } catch (error) { + console.warn('[structured-agent-session] reading whether chats are held failed', error) + } +} export function setStructuredAgentSessionHost(next: StructuredAgentSessionHost | null): void { - const installed = next !== null && host === null + stopWatchingHost?.() + stopWatchingHost = null host = next - if (installed) { - for (const listener of installedListeners) { - listener() - } + try { + stopWatchingHost = + typeof next?.onSessionsHeld === 'function' ? next.onSessionsHeld(publishHeld) : null + } catch (error) { + console.warn('[structured-agent-session] watching for held chats failed', error) } + publishHeld() +} + +/** Whether this runtime holds a structured chat, saved or live; building the host alone is not one. */ +export function structuredAgentSessionsHeld(): boolean { + return hostHoldsSessions(host) } -/** Called each time a host is installed where there was none: this runtime now holds chats. */ -export function onStructuredAgentSessionHostInstalled(listener: () => void): () => void { - installedListeners.add(listener) - return () => installedListeners.delete(listener) +/** Called each time that answer changes, e.g. when the first chat here is restored or created. */ +export function onStructuredAgentSessionsHeldChanged( + listener: (held: boolean) => void +): () => void { + heldListeners.add(listener) + return () => heldListeners.delete(listener) } export function getStructuredAgentSessionHost(): StructuredAgentSessionHost | null { diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 300e48d370d9..4547e5632eb3 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -72,6 +72,7 @@ export const AGENT_SESSION_LEASE_TTL_MS = 30_000, export class AgentSessionRecordStore { private readonly deathEvidenceListeners = new Set<(sessionId: string) => void>() + private readonly firstRecordListeners = new Set<() => void>() private constructor( private readonly transactions: AgentSessionStoreTransactions, @@ -104,6 +105,9 @@ export class AgentSessionRecordStore { listRecords = (): AgentSessionRecord[] => [...this.state.records.values()] + /** Whether this host has recorded a chat, readable or not. Nothing removes a record row. */ + holdsRecords = (): boolean => this.state.records.size > 0 || this.state.unreadableRecords.size > 0 + listVisibleSessionIds = (): string[] => (this.state.sessionTabs?.sessionIds() ?? []).filter((sessionId) => this.state.records.has(sessionId) @@ -323,6 +327,12 @@ export class AgentSessionRecordStore { return () => this.deathEvidenceListeners.delete(listener) } + /** Told, once committed, when the store records its first chat. Must not throw. */ + onFirstRecord(listener: () => void): () => void { + this.firstRecordListeners.add(listener) + return () => this.firstRecordListeners.delete(listener) + } + /** Serializes every mutation. `apply` changes only the draft it is given; readers see the change * once its rows have committed. */ private transact = async ( @@ -330,7 +340,9 @@ export class AgentSessionRecordStore { options?: { inMemoryWhenReadOnly?: boolean } ): Promise => { let proven: string[] = [] + let heldBefore = true const result = await this.transactions.transact((draft) => { + heldBefore = this.holdsRecords() if (this.deathEvidenceListeners.size === 0) { return apply(draft) } @@ -346,6 +358,9 @@ export class AgentSessionRecordStore { for (const sessionId of proven) { this.deathEvidenceListeners.forEach((listener) => listener(sessionId)) } + if (!heldBefore && this.holdsRecords()) { + this.firstRecordListeners.forEach((listener) => listener()) + } return result } } diff --git a/src/main/runtime/structured-agent-session-held-signal.test.ts b/src/main/runtime/structured-agent-session-held-signal.test.ts new file mode 100644 index 000000000000..2ce91a287f5a --- /dev/null +++ b/src/main/runtime/structured-agent-session-held-signal.test.ts @@ -0,0 +1,145 @@ +/** + * "This machine holds a structured chat" against a real host and record store. Session history, + * resume preparation and replay-safe phone launches all build the host for a user who never had a + * chat; only a chat record may turn the signal on, and the first one must turn it on at once. + */ + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + onStructuredAgentSessionsHeldChanged, + structuredAgentSessionsHeld +} from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import type { RpcContext, RpcRequest } from './rpc/core' +import { RpcDispatcher } from './rpc/dispatcher' +import type { OrcaRuntimeService } from './orca-runtime' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +vi.mock('../ai-vault/session-scanner-worker-spawn', () => ({ + scanAiVaultSessionsInWorker: vi.fn(), + resolveAiVaultSessionTitlesInWorker: vi.fn(), + resetAiVaultScannerWorkerForTests: vi.fn() +})) + +const { AI_VAULT_METHODS } = await import('./rpc/methods/ai-vault') +const { admitAgentLaunchOperation } = await import('./rpc/methods/agent-launch-replay') + +let stateDirectory: string +let heldChanges: boolean[] +let stopListening: () => void + +function installHost(): Promise { + return ensureStructuredAgentSessionHost({ + stateDirectory, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => stateDirectory, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveEnvironment: async () => ({}) + }) +} + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these paths read only the members stubbed here. +const runtime = { + getRuntimeId: () => 'test-runtime', + ensureStructuredAgentSessionHost: async () => { + await installHost() + }, + listAiVaultSessions: async () => ({ + sessions: [], + issues: [], + scannedAt: new Date().toISOString() + }) +} as unknown as OrcaRuntimeService + +function operationId(suffix: string): string { + return `${Date.now()}-${suffix.padStart(32, '0')}` +} + +async function recordChat(host: StructuredAgentSessionHost): Promise { + await host.deps.store.reserveOwner({ + sessionId: 'session-1', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(stateDirectory, 'claude') }, + expectedFence: null, + spawnToken: 'spawn-1', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'indeterminate', reason: 'no answer' }, + operation: { callerKey: 'desktop', operationId: operationId('c1'), fingerprint: 'fp-1' }, + now: Date.now() + }) +} + +beforeEach(async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-held-signal-')) + heldChanges = [] + stopListening = onStructuredAgentSessionsHeldChanged((held) => heldChanges.push(held)) +}) + +afterEach(async () => { + stopListening() + await stopStructuredAgentSessionRuntime() + await rm(stateDirectory, { recursive: true, force: true }) +}) + +describe('whether this machine holds a structured chat', () => { + it('stays false when Session history builds the host', async () => { + const dispatcher = new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) + const request: RpcRequest = { id: 'r1', authToken: 't', method: 'aiVault.listSessions' } + + await expect(dispatcher.dispatch(request)).resolves.toMatchObject({ ok: true }) + + expect(structuredAgentSessionsHeld()).toBe(false) + expect(heldChanges).toEqual([]) + }) + + it('stays false when a replay-safe phone launch records its operation', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: admission reads only these fields. + const context = { runtime, clientKind: 'mobile', pairedDeviceId: 'phone-1' } as RpcContext + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: admission reads only the operation id. + const params = { operationId: operationId('a1') } as Parameters< + typeof admitAgentLaunchOperation + >[1] + + await expect(admitAgentLaunchOperation(context, params, 'fp-launch')).resolves.toMatchObject({ + decision: 'execute' + }) + + expect(structuredAgentSessionsHeld()).toBe(false) + expect(heldChanges).toEqual([]) + }) + + it('turns true when the first chat is created here, without a restart', async () => { + const host = await installHost() + expect(structuredAgentSessionsHeld()).toBe(false) + + await recordChat(host) + + expect(structuredAgentSessionsHeld()).toBe(true) + expect(heldChanges).toEqual([true]) + }) + + it('is true as soon as the host restores a saved chat', async () => { + await recordChat(await installHost()) + await stopStructuredAgentSessionRuntime() + heldChanges = [] + + await installHost() + + expect(structuredAgentSessionsHeld()).toBe(true) + expect(heldChanges).toEqual([true]) + }) +}) diff --git a/src/main/startup/main-process-ipc-bootstrap.ts b/src/main/startup/main-process-ipc-bootstrap.ts index a6e3612836fb..6bf6533c57c0 100644 --- a/src/main/startup/main-process-ipc-bootstrap.ts +++ b/src/main/startup/main-process-ipc-bootstrap.ts @@ -4,8 +4,8 @@ import { logStartupMilestone } from './startup-diagnostics' import { mainProcessState as state } from './main-process-state' import { resolveOpenedMarkdownDocuments } from './os-opened-markdown-files' import { - getStructuredAgentSessionHost, - onStructuredAgentSessionHostInstalled + onStructuredAgentSessionsHeldChanged, + structuredAgentSessionsHeld } from '../native-chat/agent-session-wire/structured-agent-session-registry' export function registerMainProcessIpcHandlers(): void { @@ -29,14 +29,14 @@ export function registerMainProcessIpcHandlers(): void { ]) await state.runtime?.prepareStructuredAgentSessionStartupRestoration() }) - // The host is built only when this runtime holds structured chats (saved ones restored at startup, - // or one a client created), which is when the renderer has chats of this machine's to mirror. - ipcMain.handle( - 'app:hasStructuredAgentSessionHost', - () => getStructuredAgentSessionHost() !== null - ) - onStructuredAgentSessionHostInstalled(() => { - state.mainWindow?.webContents.send('app:structuredAgentSessionHostInstalled') + // Whether this runtime holds a structured chat (a saved record or one a client created here), which + // is when the renderer has chats of this machine's to mirror. Many non-chat paths build the host. + ipcMain.handle('app:holdsStructuredAgentSessions', () => structuredAgentSessionsHeld()) + onStructuredAgentSessionsHeldChanged((held) => { + const window = state.mainWindow + if (window && !window.isDestroyed() && !window.webContents.isDestroyed()) { + window.webContents.send('app:structuredAgentSessionsHeldChanged', held) + } }) ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup', () => recoverLegacyWorkerTerminalsForRendererStartup({ diff --git a/src/preload/api/app-api.ts b/src/preload/api/app-api.ts index 3590356c2458..55f82e23568a 100644 --- a/src/preload/api/app-api.ts +++ b/src/preload/api/app-api.ts @@ -43,10 +43,10 @@ export type AppApi = { awaitGitEnvironmentStartupBarrier: () => Promise /** Inventories retained PTYs and restores durable structured ownership before renderer adoption. */ prepareTerminalStartupRestoration: () => Promise - /** Whether this machine's runtime holds structured chats: its structured host is built. */ - hasStructuredAgentSessionHost: () => Promise - /** Fires when this machine's structured host is built after startup, e.g. by a paired client. */ - onStructuredAgentSessionHostInstalled: (callback: () => void) => () => void + /** Whether this machine's runtime holds a structured chat, saved or live. */ + holdsStructuredAgentSessions: () => Promise + /** Fires when that answer changes, e.g. when a paired client creates the first chat here. */ + onStructuredAgentSessionsHeldChanged: (callback: (held: boolean) => void) => () => void /** Reconciles legacy worker authority around persisted terminal reconnect. */ recoverLegacyWorkerTerminalsForRendererStartup: () => Promise /** Emits a startup benchmark marker when ORCA_STARTUP_DIAGNOSTICS is enabled. */ diff --git a/src/preload/api/app-bridge.ts b/src/preload/api/app-bridge.ts index 47be8caae781..76c97645bff1 100644 --- a/src/preload/api/app-bridge.ts +++ b/src/preload/api/app-bridge.ts @@ -47,12 +47,13 @@ export const appApi = { ipcRenderer.invoke('app:awaitGitEnvironmentStartupBarrier'), prepareTerminalStartupRestoration: (): Promise => ipcRenderer.invoke('app:prepareTerminalStartupRestoration'), - hasStructuredAgentSessionHost: (): Promise => - ipcRenderer.invoke('app:hasStructuredAgentSessionHost'), - onStructuredAgentSessionHostInstalled: (callback: () => void): (() => void) => { - const listener = (): void => callback() - ipcRenderer.on('app:structuredAgentSessionHostInstalled', listener) - return () => ipcRenderer.removeListener('app:structuredAgentSessionHostInstalled', listener) + holdsStructuredAgentSessions: (): Promise => + ipcRenderer.invoke('app:holdsStructuredAgentSessions'), + onStructuredAgentSessionsHeldChanged: (callback: (held: boolean) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, held: boolean): void => + callback(held === true) + ipcRenderer.on('app:structuredAgentSessionsHeldChanged', listener) + return () => ipcRenderer.removeListener('app:structuredAgentSessionsHeldChanged', listener) }, recoverLegacyWorkerTerminalsForRendererStartup: (): Promise => ipcRenderer.invoke('app:recoverLegacyWorkerTerminalsForRendererStartup'), diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index bbb0c898cae1..808166fe7d2f 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -274,8 +274,8 @@ describe('ExperimentalPane', () => { configurable: true, value: { app: { - hasStructuredAgentSessionHost: async () => true, - onStructuredAgentSessionHostInstalled: () => () => undefined + holdsStructuredAgentSessions: async () => true, + onStructuredAgentSessionsHeldChanged: () => () => undefined } } }) diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index 3ebf89bc7568..4b0320e8b4d0 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -1,5 +1,5 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { useLocalStructuredAgentSessionHostInstalled } from '@/runtime/local-structured-chats' +import { useLocalStructuredAgentSessionsHeld } from '@/runtime/local-structured-chats' import { translate } from '@/i18n/i18n' import { Label } from '../ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' @@ -28,7 +28,7 @@ export function NativeChatExperimentalSetting({ settings.openAgentTabsInChatByDefault === true ? 'native-chat' : 'terminal-chat' // Structured-only settings; terminal-backed chat never reads them. They govern the chats this // machine holds too, which keep running whatever the setting says. - const holdsStructuredChats = useLocalStructuredAgentSessionHostInstalled() + const holdsStructuredChats = useLocalStructuredAgentSessionsHeld() const structuredChatActive = (defaultView === 'native-chat' && structuredNativeChatEnabled) || holdsStructuredChats diff --git a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx index 775a97f628f6..024623935026 100644 --- a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx +++ b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx @@ -47,8 +47,8 @@ function stageLocalHost(installed: boolean): void { configurable: true, value: { app: { - hasStructuredAgentSessionHost: async () => installed, - onStructuredAgentSessionHostInstalled: () => () => undefined + holdsStructuredAgentSessions: async () => installed, + onStructuredAgentSessionsHeldChanged: () => () => undefined } } }) diff --git a/src/renderer/src/runtime/local-structured-chats.test.ts b/src/renderer/src/runtime/local-structured-chats.test.ts index 868fbaea0a90..24a2a481c457 100644 --- a/src/renderer/src/runtime/local-structured-chats.test.ts +++ b/src/renderer/src/runtime/local-structured-chats.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ hostInstalled: false, isWebClient: false })) +const mocks = vi.hoisted(() => ({ held: false, isWebClient: false })) vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: () => mocks.isWebClient })) vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) @@ -17,14 +17,14 @@ const SETTING_OFF = { experimentalStructuredNativeChat: false } const SETTING_ON = { experimentalStructuredNativeChat: true } beforeEach(() => { - mocks.hostInstalled = false + mocks.held = false mocks.isWebClient = false resetLocalStructuredChatsForTests() vi.stubGlobal('window', { api: { app: { - hasStructuredAgentSessionHost: async () => mocks.hostInstalled, - onStructuredAgentSessionHostInstalled: () => () => undefined + holdsStructuredAgentSessions: async () => mocks.held, + onStructuredAgentSessionsHeldChanged: () => () => undefined } } }) @@ -41,7 +41,7 @@ describe("startup's restore of this machine's structured chats", () => { // Existing chats come back whatever the setting says. it('restores the chats this machine holds with the setting off', async () => { - mocks.hostInstalled = true + mocks.held = true const step = vi.fn(async () => undefined) await restoreLocalStructuredChatsAtStartup(SETTING_OFF, step) @@ -59,7 +59,7 @@ describe("startup's restore of this machine's structured chats", () => { it('never restores in the browser client', async () => { mocks.isWebClient = true - mocks.hostInstalled = true + mocks.held = true const step = vi.fn(async () => undefined) await restoreLocalStructuredChatsAtStartup(SETTING_ON, step) diff --git a/src/renderer/src/runtime/local-structured-chats.ts b/src/renderer/src/runtime/local-structured-chats.ts index dbfe485b9c2d..dad01c24361b 100644 --- a/src/renderer/src/runtime/local-structured-chats.ts +++ b/src/renderer/src/runtime/local-structured-chats.ts @@ -4,75 +4,83 @@ import { isWebClientLocation } from '@/lib/web-client-location' import { useAppStore } from '@/store' import { restoreLocalStructuredSessionTabsOnce } from './local-structured-session-tabs-sync/inventory-refresh' -// Whether THIS machine's runtime holds structured chats, as its host says: the host is built only -// when saved chats were restored at startup or a client created one here. The chat setting picks -// what new launches open as, so the chats that exist show whatever it says; a machine that never -// held one pays for no chat mirror. The browser client has no runtime of its own. +// Whether THIS machine's runtime holds a structured chat (a saved record, or one a client created +// here), as main derives it from the host's records. Building the host alone is not holding one: +// session history and phone launches build it too. The chat setting picks what new launches open +// as, so the chats that exist show whatever it says; a machine that holds none pays for no chat +// mirror. The browser client has no runtime of its own. -let hostInstalled = false +let held = false +let pushes = 0 const listeners = new Set<() => void>() let stopListening: (() => void) | null = null -function markHostInstalled(): void { - if (hostInstalled) { +function setHeld(next: boolean): void { + if (held === next) { return } - hostInstalled = true + held = next for (const listener of listeners) { listener() } } +function onHeldChanged(next: boolean): void { + pushes += 1 + setHeld(next) +} + function subscribe(listener: () => void): () => void { listeners.add(listener) // Why optional: a window without the desktop bridge has no runtime to hold chats. const app = typeof window === 'undefined' ? undefined : window.api?.app if (!stopListening && app && !isWebClientLocation()) { - stopListening = app.onStructuredAgentSessionHostInstalled(markHostInstalled) - void readLocalStructuredAgentSessionHostInstalled() + stopListening = app.onStructuredAgentSessionsHeldChanged(onHeldChanged) + void readLocalStructuredAgentSessionsHeld() } return () => { listeners.delete(listener) } } -/** Asks this machine's runtime whether its structured host is built. */ -export async function readLocalStructuredAgentSessionHostInstalled(): Promise { +/** Asks this machine's runtime whether it holds a structured chat. */ +export async function readLocalStructuredAgentSessionsHeld(): Promise { if (isWebClientLocation()) { return false } + const pushesBefore = pushes try { - if (await window.api?.app?.hasStructuredAgentSessionHost()) { - markHostInstalled() + const answer = (await window.api?.app?.holdsStructuredAgentSessions()) === true + // A change pushed while the query was in flight is newer than its answer. + if (pushes === pushesBefore) { + setHeld(answer) } } catch { - // An unanswered query is not an answer; the install event still arrives. + // An unanswered query is not an answer; the change event still arrives. } - return hostInstalled + return held } -/** This machine's runtime holds structured chats. */ -export function useLocalStructuredAgentSessionHostInstalled(): boolean { +/** This machine's runtime holds a structured chat. */ +export function useLocalStructuredAgentSessionsHeld(): boolean { return useSyncExternalStore( subscribe, - () => hostInstalled, + () => held, () => false ) } type StructuredChatSettings = Pick | null -function chatsInUse(settings: StructuredChatSettings | undefined, installed: boolean): boolean { - return ( - !isWebClientLocation() && (settings?.experimentalStructuredNativeChat === true || installed) - ) +function chatsInUse(settings: StructuredChatSettings | undefined, holds: boolean): boolean { + return !isWebClientLocation() && (settings?.experimentalStructuredNativeChat === true || holds) } /** Structured chats can exist on this machine: the setting launches them, or the runtime holds some. */ export function useLocalStructuredChatsInUse(): boolean { - const installed = useLocalStructuredAgentSessionHostInstalled() + const holds = useLocalStructuredAgentSessionsHeld() const setting = useAppStore((state) => state.settings?.experimentalStructuredNativeChat === true) - return chatsInUse({ experimentalStructuredNativeChat: setting }, installed) + return chatsInUse({ experimentalStructuredNativeChat: setting }, holds) } /** The same answer for one-shot startup work, asked of the host rather than read from the renderer. */ @@ -82,7 +90,7 @@ export async function localStructuredChatsInUse( if (isWebClientLocation()) { return false } - return chatsInUse(settings, await readLocalStructuredAgentSessionHostInstalled()) + return chatsInUse(settings, await readLocalStructuredAgentSessionsHeld()) } /** @@ -102,6 +110,7 @@ export async function restoreLocalStructuredChatsAtStartup( export function resetLocalStructuredChatsForTests(): void { stopListening?.() stopListening = null - hostInstalled = false + held = false + pushes = 0 listeners.clear() } diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts index b3216996b9f6..454012c23d6d 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts @@ -4,10 +4,10 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - start: vi.fn(async () => undefined), - hostInstalled: false, + start: vi.fn(async (_sync: { isDisposed: () => boolean }) => undefined), + held: false, isWebClient: false, - installedListeners: new Array<() => void>() + heldListeners: new Array<(held: boolean) => void>() })) vi.mock('./local-structured-session-tabs-sync/subscription', () => ({ @@ -35,17 +35,17 @@ async function mountSync(): Promise { beforeEach(() => { mocks.start.mockClear() - mocks.hostInstalled = false + mocks.held = false mocks.isWebClient = false - mocks.installedListeners.splice(0) + mocks.heldListeners.splice(0) resetLocalStructuredChatsForTests() Object.defineProperty(window, 'api', { configurable: true, value: { app: { - hasStructuredAgentSessionHost: vi.fn(async () => mocks.hostInstalled), - onStructuredAgentSessionHostInstalled: vi.fn((listener: () => void) => { - mocks.installedListeners.push(listener) + holdsStructuredAgentSessions: vi.fn(async () => mocks.held), + onStructuredAgentSessionsHeldChanged: vi.fn((listener: (held: boolean) => void) => { + mocks.heldListeners.push(listener) return () => undefined }) } @@ -72,7 +72,7 @@ describe("this machine's structured chat mirror", () => { // The setting picks what new agents open as; chats that already exist keep showing. it('mirrors the chats this machine holds with the setting off', async () => { setStructuredChat(false) - mocks.hostInstalled = true + mocks.held = true await mountSync() @@ -84,14 +84,44 @@ describe("this machine's structured chat mirror", () => { await mountSync() expect(mocks.start).not.toHaveBeenCalled() - act(() => mocks.installedListeners.forEach((listener) => listener())) + act(() => mocks.heldListeners.forEach((listener) => listener(true))) expect(mocks.start).toHaveBeenCalledOnce() }) + // Session history and phone launches build the host; only a chat answers true. + it('stays off when the host is built for a machine that holds no chat', async () => { + setStructuredChat(false) + await mountSync() + + act(() => mocks.heldListeners.forEach((listener) => listener(false))) + + expect(mocks.start).not.toHaveBeenCalled() + }) + + it('keeps a change pushed while its first query was still in flight', async () => { + setStructuredChat(false) + let answer: (held: boolean) => void = () => undefined + window.api.app.holdsStructuredAgentSessions = () => + new Promise((resolve) => { + answer = resolve + }) + renderHook(() => useLocalStructuredSessionTabsSync()) + + act(() => mocks.heldListeners.forEach((listener) => listener(true))) + await act(async () => { + answer(false) + await Promise.resolve() + }) + + expect(mocks.start).toHaveBeenCalledOnce() + const [{ isDisposed }] = mocks.start.mock.calls[0] + expect(isDisposed()).toBe(false) + }) + it('stays mirrored when the setting is turned off over chats this machine holds', async () => { setStructuredChat(true) - mocks.hostInstalled = true + mocks.held = true await mountSync() act(() => setStructuredChat(false)) @@ -102,7 +132,7 @@ describe("this machine's structured chat mirror", () => { it('never runs in the browser client, which has no runtime of its own', async () => { mocks.isWebClient = true setStructuredChat(true) - mocks.hostInstalled = true + mocks.held = true await mountSync() diff --git a/src/renderer/src/web/preload-api/web-app-api.ts b/src/renderer/src/web/preload-api/web-app-api.ts index fd0141431bc5..1e1793e321f3 100644 --- a/src/renderer/src/web/preload-api/web-app-api.ts +++ b/src/renderer/src/web/preload-api/web-app-api.ts @@ -36,8 +36,8 @@ export function createWebAppApi(): Partial { awaitGitEnvironmentStartupBarrier: () => Promise.resolve(), prepareTerminalStartupRestoration: () => Promise.resolve(), // The browser client has no runtime of its own. - hasStructuredAgentSessionHost: () => Promise.resolve(false), - onStructuredAgentSessionHostInstalled: () => () => undefined, + holdsStructuredAgentSessions: () => Promise.resolve(false), + onStructuredAgentSessionsHeldChanged: () => () => undefined, recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(), startupDiagnostic: () => Promise.resolve(), getKeyboardInputSourceId: () => Promise.resolve(null), From c64f5d9356931b349781c23ff195a7de44dea683 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:55:30 -0700 Subject: [PATCH 12/18] feat(native-chat): createSupport reports the saved selection a new chat on this host starts with A chat on a paired server starts with the server's saved model and options, which the desktop could not read, so its picker showed a guess. createSupport's answer, which the desktop already waits for before a paired launch, now also carries that seed as a new optional field (older clients ignore it). Create and createSupport read it through one resolver so they cannot drift. --- ...structured-agent-session-create-support.ts | 16 ++++++-- ...ctured-agent-session-create-intent.test.ts | 2 + ...structured-agent-session-admission.test.ts | 41 ++++++++++++++++++- ...ructured-agent-session-rpc.test-fixture.ts | 1 + .../rpc/methods/structured-agent-session.ts | 10 ++++- 5 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index c0d4c596ffae..2bf67f0b4f88 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -42,6 +42,17 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR }) } + /** The saved selection a new chat here starts with. createSupport reports it too, so a client's + * picker shows what create will run; one resolver keeps the two from drifting. */ + structuredAgentSessionLaunchSeedOptions( + agent: 'claude' | 'codex' + ): Record | undefined { + return resolveStructuredLaunchSeedOptions( + this.requireStore().getSettings().nativeChatSessionOptions, + agent + ) + } + protected async resolveStructuredAgentSessionLocation(worktreeSelector: string) { const target = await this.resolveRuntimeFileTarget(worktreeSelector) const repo = this.store?.getRepo(target.worktree.repoId) @@ -167,10 +178,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR } const settings = this.requireStore().getSettings() const launchEnv = resolveTuiAgentLaunchEnv(input.agent, settings.agentDefaultEnv) - const options = resolveStructuredLaunchSeedOptions( - settings.nativeChatSessionOptions, - input.agent - ) + const options = this.structuredAgentSessionLaunchSeedOptions(input.agent) const location = await this.resolveStructuredAgentSessionLocation(input.worktree) const host = getStructuredAgentSessionHost() const committedReplay = resolveCommittedStructuredAgentSessionAdoptionIntent({ diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts index c5cdf188cd4a..edff20ee99cb 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -209,6 +209,8 @@ describe('structured agent-session create intent', () => { path: '/configured/claude-home' }) expect(intent.options).toEqual({ model: 'opus', effort: 'high', fastMode: 'true' }) + // createSupport reports this same seed, so a paired client's picker shows what create runs. + expect(runtime.structuredAgentSessionLaunchSeedOptions('claude')).toEqual(intent.options) }) it('uses the managed Claude launch home before falling back to ~/.claude', async () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts index 7fcac39526d2..a1a5171f2387 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts @@ -1,7 +1,7 @@ // The host's own structured-chat setting is its user's launch preference, not admission control: // a paired client that can read structured sessions reaches every method whatever that setting says. -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AGENT_LAUNCH_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, @@ -186,3 +186,42 @@ describe('a client that leaves the launch mode to the host', () => { expect(response).toMatchObject({ ok: true }) }) }) + +// A paired client's picker shows the saved selection create will start the chat with. +describe("createSupport's launch seed", () => { + const SEED = { model: 'opus', effort: 'high' } + + it('carries the seed create will use when the chat is supported', async () => { + const seedOptions = vi.fn(() => SEED) + const response = await call( + CREATE_SUPPORT.method, + CREATE_SUPPORT.params, + MODE_CHOOSING_CLIENT, + { ...SETTING_ON, structuredAgentSessionLaunchSeedOptions: seedOptions } + ) + + expect(response).toMatchObject({ ok: true, result: { supported: true, seedOptions: SEED } }) + expect(seedOptions).toHaveBeenCalledWith('codex') + }) + + it('carries none when the chat is not supported', async () => { + const seedOptions = vi.fn(() => SEED) + const response = await call( + CREATE_SUPPORT.method, + CREATE_SUPPORT.params, + MODE_CHOOSING_CLIENT, + { + ...SETTING_ON, + getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ + supported: false, + reason: 'wsl' + })), + structuredAgentSessionLaunchSeedOptions: seedOptions + } + ) + + expect(response).toMatchObject({ ok: true, result: { supported: false, reason: 'wsl' } }) + expect(response).not.toMatchObject({ result: { seedOptions: expect.anything() } }) + expect(seedOptions).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 85d3e2a5b4bf..af736e22cfc6 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -213,6 +213,7 @@ export function dispatcher(runtimeOverrides: Record = {}): RpcD reset(runtimeCalls) Object.assign(runtimeCalls, { getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })), + structuredAgentSessionLaunchSeedOptions: vi.fn(() => undefined), resolveStructuredAgentSessionCreateIntent: vi.fn(async (params) => ({ envelope: params.envelope, location: { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 7b7587a906f5..e0d24119086c 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -135,7 +135,15 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ params: CreateSupportParams, handler: async (params, ctx) => { requireStructuredCreateSupportAdmission(ctx) - return ctx.runtime.getStructuredAgentSessionCreateSupport(params.worktree, params.agent) + const support = await ctx.runtime.getStructuredAgentSessionCreateSupport( + params.worktree, + params.agent + ) + // Optional: older clients ignore it, and a client seeds its picker with what create will use. + const seedOptions = support.supported + ? ctx.runtime.structuredAgentSessionLaunchSeedOptions(params.agent) + : undefined + return seedOptions ? { ...support, seedOptions } : support } }), defineMethod({ From 884cfa8070ac80affec02f53bc14174817861376 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:13:52 -0700 Subject: [PATCH 13/18] refactor(protocol): move the Electron remote client capability list into its own module Merging main left protocol-version.ts one line over the max-lines limit on this branch. The list of capabilities the desktop advertises to a paired host moves, unchanged, into electron-remote-runtime-client-capabilities.ts, the module the next PR in the stack already uses for it; importers point there. --- ...ktop-renderer-runtime-capabilities.test.ts | 2 +- ...environment-federated-read-routing.test.ts | 6 ++--- ...untime-environment-pairing-verification.ts | 2 +- ...runtime-environment-request-connections.ts | 2 +- .../ipc/runtime-environment-status-owner.ts | 6 ++--- .../runtime-environment-support-routing.ts | 2 +- .../runtime-environment-transport-routing.ts | 2 +- .../runtime-environments-call-routing.test.ts | 6 ++--- .../ipc/runtime-environments-pairing.test.ts | 6 ++--- ...me-environments-status-diagnostics.test.ts | 6 ++--- ...nvironments-subscription-lifecycle.test.ts | 2 +- ...-environments-subscription-routing.test.ts | 6 ++--- ...n-tab-browser-placement-projection.test.ts | 2 +- ...d-agent-session-send-compatibility.test.ts | 6 ++--- .../worktree-rm-background-reply.test.ts | 2 +- ...worktree-removal-marker-projection.test.ts | 2 +- ...tron-remote-runtime-client-capabilities.ts | 23 +++++++++++++++++++ src/shared/protocol-version.ts | 14 ----------- src/shared/remote-runtime-client.test.ts | 4 ++-- 19 files changed, 48 insertions(+), 53 deletions(-) create mode 100644 src/shared/electron-remote-runtime-client-capabilities.ts diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 135b0af68f60..2ed4459cbde1 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -17,7 +17,6 @@ import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, @@ -27,6 +26,7 @@ import { WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { supportsAgentLaunch } from '../runtime/rpc/methods/agent-launch' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from './desktop-renderer-runtime-capabilities' diff --git a/src/main/ipc/runtime-environment-federated-read-routing.test.ts b/src/main/ipc/runtime-environment-federated-read-routing.test.ts index 51c77510a01e..9433e4f28054 100644 --- a/src/main/ipc/runtime-environment-federated-read-routing.test.ts +++ b/src/main/ipc/runtime-environment-federated-read-routing.test.ts @@ -5,10 +5,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { encodePairingOffer } from '../../shared/pairing' -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY -} from '../../shared/protocol-version' +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { addEnvironmentFromPairingCode } from '../../shared/runtime-environment-store' const { sendRemoteRuntimeRequestMock, sendRemoteRuntimeSharedControlRequestMock } = vi.hoisted( diff --git a/src/main/ipc/runtime-environment-pairing-verification.ts b/src/main/ipc/runtime-environment-pairing-verification.ts index d86f298a5f04..e339e75c6244 100644 --- a/src/main/ipc/runtime-environment-pairing-verification.ts +++ b/src/main/ipc/runtime-environment-pairing-verification.ts @@ -9,7 +9,7 @@ import { } from '../../shared/remote-pairing-verification' import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { redactRuntimeEnvironment } from '../../shared/runtime-environments' import type { RuntimeStatus } from '../../shared/runtime-types' diff --git a/src/main/ipc/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environment-request-connections.ts index 6ba9f273c1e4..bb666e757f89 100644 --- a/src/main/ipc/runtime-environment-request-connections.ts +++ b/src/main/ipc/runtime-environment-request-connections.ts @@ -4,7 +4,7 @@ import { getPreferredPairingOffer } from '../../shared/runtime-environments' import type { RuntimeHostStatusOwner } from '../../shared/runtime-host-status-owner' import type { RuntimeStatus } from '../../shared/runtime-types' import { createRuntimeEnvironmentStatusOwner } from './runtime-environment-status-owner' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import type { RuntimeOrchestrationEnvelope, RuntimeRpcResponse diff --git a/src/main/ipc/runtime-environment-status-owner.ts b/src/main/ipc/runtime-environment-status-owner.ts index 26b5264ef9fd..f88741ca9a3c 100644 --- a/src/main/ipc/runtime-environment-status-owner.ts +++ b/src/main/ipc/runtime-environment-status-owner.ts @@ -1,9 +1,7 @@ import { BrowserWindow } from 'electron' import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY -} from '../../shared/protocol-version' +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { getPreferredPairingOffer, type KnownRuntimeEnvironment diff --git a/src/main/ipc/runtime-environment-support-routing.ts b/src/main/ipc/runtime-environment-support-routing.ts index f81d045a568d..ae68b3d35f8f 100644 --- a/src/main/ipc/runtime-environment-support-routing.ts +++ b/src/main/ipc/runtime-environment-support-routing.ts @@ -8,7 +8,7 @@ import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments' import { getPreferredPairingOffer } from '../../shared/runtime-environments' import { markEnvironmentUsed, resolveEnvironment } from '../../shared/runtime-environment-store' import { recordRuntimeEnvironmentUsage } from './runtime-environment-usage-record' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { subscribeRemoteRuntimeRequest, type RemoteRuntimeSubscription diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index 327f1428ea78..fd60ef33fcda 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -1,5 +1,5 @@ import { getPreferredPairingOffer } from '../../shared/runtime-environments' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { resolveEnvironment, markEnvironmentUsed } from '../../shared/runtime-environment-store' import { recordRuntimeEnvironmentUsage } from './runtime-environment-usage-record' import { isOrchestrationMutation } from '../../shared/orchestration-rpc-contract' diff --git a/src/main/ipc/runtime-environments-call-routing.test.ts b/src/main/ipc/runtime-environments-call-routing.test.ts index 0e8472625d5a..643a1dd10e9b 100644 --- a/src/main/ipc/runtime-environments-call-routing.test.ts +++ b/src/main/ipc/runtime-environments-call-routing.test.ts @@ -3,10 +3,8 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY -} from '../../shared/protocol-version' +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import * as environmentStore from '../../shared/runtime-environment-store' import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' import { RuntimeRpcCallQueueOverloadError } from '../../shared/runtime-rpc-call-queue' diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index ce492a7a740d..6919c68f198a 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -4,10 +4,8 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - MIN_COMPATIBLE_RUNTIME_SERVER_VERSION -} from '../../shared/protocol-version' +import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import * as environmentStore from '../../shared/runtime-environment-store' import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' diff --git a/src/main/ipc/runtime-environments-status-diagnostics.test.ts b/src/main/ipc/runtime-environments-status-diagnostics.test.ts index 9fe3d6baf0ea..8e51332c8b5c 100644 --- a/src/main/ipc/runtime-environments-status-diagnostics.test.ts +++ b/src/main/ipc/runtime-environments-status-diagnostics.test.ts @@ -3,10 +3,8 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY -} from '../../shared/protocol-version' +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' const { handleMock, diff --git a/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts b/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts index ed4dcd62182d..699a9cab8d59 100644 --- a/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts +++ b/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import * as environmentStore from '../../shared/runtime-environment-store' const { diff --git a/src/main/ipc/runtime-environments-subscription-routing.test.ts b/src/main/ipc/runtime-environments-subscription-routing.test.ts index 0ef70f7ac963..bc9dfc58c294 100644 --- a/src/main/ipc/runtime-environments-subscription-routing.test.ts +++ b/src/main/ipc/runtime-environments-subscription-routing.test.ts @@ -3,10 +3,8 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY -} from '../../shared/protocol-version' +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' const { handleMock, diff --git a/src/main/runtime/rpc/methods/session-tab-browser-placement-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-browser-placement-projection.test.ts index e5f309bcd4e5..79fc3892895f 100644 --- a/src/main/runtime/rpc/methods/session-tab-browser-placement-projection.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-browser-placement-projection.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../../shared/electron-remote-runtime-client-capabilities' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import { assertProjectedSessionTabVisible, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts index 59bc914558e1..e420176c70cc 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts @@ -2,10 +2,8 @@ // message is handed over: a client that cannot show a rejection after `pending` must not see one. import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY -} from '../../../../shared/protocol-version' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../../shared/electron-remote-runtime-client-capabilities' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities' import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from '../../../native-chat/agent-session-wire/structured-agent-session-send-settlement' import { diff --git a/src/main/runtime/rpc/methods/worktree-rm-background-reply.test.ts b/src/main/runtime/rpc/methods/worktree-rm-background-reply.test.ts index 1905d95b2951..e456e4cbf506 100644 --- a/src/main/runtime/rpc/methods/worktree-rm-background-reply.test.ts +++ b/src/main/runtime/rpc/methods/worktree-rm-background-reply.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../../shared/electron-remote-runtime-client-capabilities' import { RpcDispatcher } from '../dispatcher' import type { OrcaRuntimeService } from '../../orca-runtime' import { WORKTREE_METHODS } from './worktree' diff --git a/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts b/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts index 4ce22b0a3600..f2abee689e76 100644 --- a/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts +++ b/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../shared/electron-remote-runtime-client-capabilities' import { remoteRuntimeClientCapabilities } from '../../../shared/remote-runtime-client-capabilities' import type { RuntimeWorktreeListResult, diff --git a/src/shared/electron-remote-runtime-client-capabilities.ts b/src/shared/electron-remote-runtime-client-capabilities.ts new file mode 100644 index 000000000000..a8a38dc4b350 --- /dev/null +++ b/src/shared/electron-remote-runtime-client-capabilities.ts @@ -0,0 +1,23 @@ +import { + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, + BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, + NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY +} from './protocol-version' + +// Electron clients can decode client-hosted page placement; becoming a page +// host still requires the separate authenticated browser-client lease. +export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ + ...NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, + BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, + BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, + // Why: only the renderer runs the retirement-proof ledger; CLI and mobile must keep full lists. + SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, + // Only the renderer shows Deleting for a `removing` row; CLI and mobile get those rows omitted. + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY +] as const diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index aa75500e5b25..69b2edfc52a2 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -336,20 +336,6 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ AGENT_LAUNCH_RUNTIME_CAPABILITY ] as const -// Electron clients can decode client-hosted page placement; becoming a page -// host still requires the separate authenticated browser-client lease. -export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ - ...NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, - BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, - BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, - // Why: only the renderer runs the retirement-proof ledger; CLI and mobile must keep full lists. - SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, - // Only the renderer shows Deleting for a `removing` row; CLI and mobile get those rows omitted. - WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY -] as const - export const ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY = 'git.antigravity-configured-model.v1' as const diff --git a/src/shared/remote-runtime-client.test.ts b/src/shared/remote-runtime-client.test.ts index d7a76417e841..096aad470680 100644 --- a/src/shared/remote-runtime-client.test.ts +++ b/src/shared/remote-runtime-client.test.ts @@ -17,9 +17,9 @@ import { MAX_TIMER_DELAY_MS } from './timer-delay' import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, - BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES + BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY } from './protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from './electron-remote-runtime-client-capabilities' const servers: WebSocketServer[] = [] From 6cfb8701c26fa31aa55e47313d625f5fe486af5d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:47:38 -0700 Subject: [PATCH 14/18] test(cross-version): stub the launch seed resolver createSupport now reads --- .../cross-version-agent-session-wire.unit.test.ts | 1 + 1 file changed, 1 insertion(+) 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 4aaa02037ec3..6ebd065a9851 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 @@ -85,6 +85,7 @@ function runtimeStub(): unknown { getClientSettings: () => ({ experimentalStructuredNativeChat: true }), ensureStructuredAgentSessionHost: async () => undefined, getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + structuredAgentSessionLaunchSeedOptions: () => undefined, resolveStructuredAgentSessionCreateIntent: async () => { const { envelope: _envelope, From 401da3027b609e56350d7ff2c191e537184bb697 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:02:36 -0700 Subject: [PATCH 15/18] test(protocol): pin the desktop capability divergence against what a paired server receives Every paired transport sends the shared remote base plus the Electron list, so the divergence test now compares that union with the renderer's local list instead of the declared Electron list. A capability added only to the shared base can no longer slip past it. The two base-only capabilities it surfaced are recorded: skills.install-result.v2 has no local caller; the authoritative-inventory label is read by the local tabs sync but dropped by main, and is marked unsettled. The turn-item and both background-task-stop capabilities were already sent through the shared base, so the Electron list no longer repeats them. The wire set is unchanged; this PR's real change on the wire is structured.v1, the Claude structured capability and the client launch-mode capability. --- ...ktop-renderer-runtime-capabilities.test.ts | 42 +++++++++++-------- ...tron-remote-runtime-client-capabilities.ts | 6 --- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 13bb1baaa281..3d7f236917d5 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -14,7 +14,9 @@ import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, + SKILL_INSTALL_RESULT_V2_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, @@ -22,10 +24,16 @@ import { type RuntimeCapability } from '../../shared/protocol-version' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' +import { remoteRuntimeClientCapabilities } from '../../shared/remote-runtime-client-capabilities' import { supportsAgentLaunch } from '../runtime/rpc/methods/agent-launch' import { createSupportFollowsHostSetting } from '../runtime/rpc/methods/structured-agent-session-policy' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from './desktop-renderer-runtime-capabilities' +// Every paired transport sends the shared base plus the Electron list, so compare the union. +const PAIRED_HOST_RECEIVES = remoteRuntimeClientCapabilities( + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES +) + /** Advertised to a remote host and deliberately NOT to main: each would change local behaviour or * has no local meaning. Adding to this set is a decision; leaving it out of both lists is not. */ const REMOTE_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ @@ -47,7 +55,12 @@ const REMOTE_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, // Main marks `removing` on its own worktree IPC listings unconditionally; the renderer never // lists its own host's worktrees over runtime RPC, where this capability decides mark vs omit. - WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, + // `skills.install` reaches a host only from main's remote install service, never over runtime:call. + SKILL_INSTALL_RESULT_V2_CAPABILITY, + // Unsettled, not a decision: the local tabs sync reads the census's `authoritative` label + // (local-structured-session-tabs-sync/inventory-refresh.ts), which main drops without this. + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY ] function missingFrom( @@ -75,10 +88,11 @@ describe('desktop renderer runtime client capabilities', () => { ).toBe(false) }) - // The desktop routes a launch on its own settings; a host that answered createSupport with its - // own setting would turn a chat the user asked for into a failed launch. + // The desktop routes a launch on its own settings. A paired host answering createSupport from its + // own setting would fail a chat the user asked for; main shares the desktop's setting, so locally + // this only lets Retry on an existing chat relaunch after the setting is turned off. it.each([ - ['a paired host', ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES], + ['a paired host', PAIRED_HOST_RECEIVES], ['its own main process', DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] ] as const)('tells %s that it picks each launch mode itself', (_host, clientCapabilities) => { expect(createSupportFollowsHostSetting({ clientKind: 'runtime', clientCapabilities })).toBe( @@ -86,19 +100,13 @@ describe('desktop renderer runtime client capabilities', () => { ) }) - it('diverges from the remote Electron list only where a decision was recorded', () => { - expect( - missingFrom( - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES - ) - ).toEqual([...REMOTE_ONLY_BY_DECISION].sort()) + it('diverges from what a paired host receives only where a decision was recorded', () => { + expect(missingFrom(PAIRED_HOST_RECEIVES, DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES)).toEqual( + [...REMOTE_ONLY_BY_DECISION].sort() + ) // The same renderer reads structured chats on either host, so it claims nothing only locally. - expect( - missingFrom( - DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES - ) - ).toEqual([]) + expect(missingFrom(DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES, PAIRED_HOST_RECEIVES)).toEqual( + [] + ) }) }) diff --git a/src/shared/electron-remote-runtime-client-capabilities.ts b/src/shared/electron-remote-runtime-client-capabilities.ts index fd60deabfa83..47ab6f75bc43 100644 --- a/src/shared/electron-remote-runtime-client-capabilities.ts +++ b/src/shared/electron-remote-runtime-client-capabilities.ts @@ -1,9 +1,6 @@ import { AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, - AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, - AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - AGENT_SESSION_TURN_ITEM_CAPABILITY, BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, @@ -25,9 +22,6 @@ export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, // The desktop picks each launch's mode from its own settings before asking the host. STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, - AGENT_SESSION_TURN_ITEM_CAPABILITY, - AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, - AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, // Why: only the renderer runs the retirement-proof ledger; CLI and mobile must keep full lists. From b28b740b24378bd250be381ef0c4081943d3a2cb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:45:56 -0700 Subject: [PATCH 16/18] fix(native-chat): the desktop tells its own host it picks each launch mode, so retrying an existing chat works with the setting off --- ...ktop-renderer-runtime-capabilities.test.ts | 19 +++++++++++++++++-- .../desktop-renderer-runtime-capabilities.ts | 2 ++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index fbfae3c51fa9..31a27eb416e1 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -19,6 +19,7 @@ import { CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, @@ -29,6 +30,7 @@ import { import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../shared/agent-session-background-task-child-views-capability' import { supportsAgentLaunch } from '../runtime/rpc/methods/agent-launch' +import { createSupportFollowsHostSetting } from '../runtime/rpc/methods/structured-agent-session-policy' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from './desktop-renderer-runtime-capabilities' /** Advertised to a remote host and deliberately NOT to main: each would change local behaviour or @@ -56,7 +58,7 @@ const REMOTE_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ ] /** Gates the renderer must pass against its own main process. The Electron remote list omits all - * six; mobile advertises the structured ones, so this is an Electron-remote gap rather than a + * seven; mobile advertises the structured ones, so this is an Electron-remote gap rather than a * statement that no remote client wants them. Why it is one is not recorded here. */ const LOCAL_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, @@ -64,7 +66,9 @@ const LOCAL_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + // The desktop picks each launch mode itself; only its own host is told so far. + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY ] function missingFrom( @@ -92,6 +96,17 @@ describe('desktop renderer runtime client capabilities', () => { ).toBe(false) }) + // The desktop routes a launch on its own settings; a host that answered createSupport with its + // own setting would turn a chat the user asked for into a failed launch. + it.each([['its own main process', DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES]] as const)( + 'tells %s that it picks each launch mode itself', + (_host, clientCapabilities) => { + expect(createSupportFollowsHostSetting({ clientKind: 'runtime', clientCapabilities })).toBe( + false + ) + } + ) + it('diverges from the remote Electron list only where a decision was recorded', () => { expect( missingFrom( diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index 8fe881e00614..ba4b6b254728 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -6,6 +6,7 @@ import { AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' @@ -33,6 +34,7 @@ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapab AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, // Without this `supportsAgentLaunch` refuses the renderer outright, while the same renderer // targeting a remote host is admitted — the asymmetry this constant exists to close. AGENT_LAUNCH_RUNTIME_CAPABILITY From 7fdebc8aab38725efa856ee841fcce80469d8d73 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:45:58 -0700 Subject: [PATCH 17/18] docs(native-chat): name the real exit for the released-phone createSupport rule --- src/main/runtime/rpc/methods/structured-agent-session-policy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts index 517ddab01d1f..cc03bfd21a5c 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -24,7 +24,7 @@ export function supportsStructuredAgentSessions( /** * COMPAT(released phones): a remote client that does not pick each launch's mode itself reads * `agentSession.createSupport` as "should this launch be a chat", which the host's setting - * answered. Such a client keeps that answer until no supported build lacks the capability. + * answered. Remove once the oldest supported phone build launches agents through `agent.launch`. */ export function createSupportFollowsHostSetting( context: Pick From ccf37e3d04629378268530e96811bc53d0b5f16b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:07:29 -0700 Subject: [PATCH 18/18] test(cross-version): a released client still gets the host-setting createSupport answer; a launch-mode client gets supported plus the seed --- ...ss-version-agent-session-wire.unit.test.ts | 73 ++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) 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 6ebd065a9851..bab2ce58de58 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 @@ -30,6 +30,7 @@ import { AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY, AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import { resolveBaselineReleaseRef } from './release-checkout' @@ -78,7 +79,7 @@ beforeAll(async () => { baseline = await loadAgentSessionWireBuild(baselineRef) }, SUITE_TIMEOUT_MS) -function runtimeStub(): unknown { +function runtimeStub(overrides: Record = {}): unknown { const subscriptions = new RuntimeSubscriptionRegistry() return { getRuntimeId: () => 'runtime-1', @@ -98,7 +99,8 @@ function runtimeStub(): unknown { registerSubscriptionCleanup: subscriptions.register.bind(subscriptions), registerOwnedSubscriptionCleanup: subscriptions.registerOwned.bind(subscriptions), cleanupSubscription: subscriptions.cleanup.bind(subscriptions), - cleanupSubscriptionsByPrefix: subscriptions.cleanupByPrefix.bind(subscriptions) + cleanupSubscriptionsByPrefix: subscriptions.cleanupByPrefix.bind(subscriptions), + ...overrides } } @@ -243,6 +245,71 @@ describe('cross-version structured agent sessions', () => { }) }) + // Released phones ask createSupport whether a launch should be a chat at all, and the host's + // setting answered; a client that picks the mode itself advertises that it does. + describe('a client that leaves the launch mode to the host', () => { + const SEED = { model: 'seeded-model' } + const settingOff = (): unknown => + runtimeStub({ + getClientSettings: () => ({ experimentalStructuredNativeChat: false }), + structuredAgentSessionLaunchSeedOptions: () => SEED + }) + // The release's own list, so the day a release ships the launch-mode capability this still + // describes a client without it. + const released = (...extra: string[]): RpcClientIdentity => ({ + clientKind: 'mobile', + clientCapabilities: [ + ...baseline.capabilities.filter( + (capability) => capability !== STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY + ), + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + ...extra + ] + }) + const createSupport = (build: AgentSessionWireBuild, client: RpcClientIdentity) => + callBuild( + build, + 'agentSession.createSupport', + paramsFor('agentSession.createSupport'), + client, + settingOff() + ) + + beforeEach(async () => { + for (const build of [current, baseline]) { + await build.installStructuredHost(installableHost(structuredHostStub(SESSION, WORKSPACE))) + } + }) + + afterEach(async () => { + for (const build of [current, baseline]) { + await build.installStructuredHost(null) + } + }) + + it('is refused by a host whose setting is off, exactly as the release refused it', async () => { + const replies = await createSupport(current, released()) + expect(replies).toHaveLength(1) + expect(replies[0]).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + if (baseline.methodNames.includes('agentSession.createSupport')) { + expect(replies[0]?.error).toEqual((await createSupport(baseline, released()))[0]?.error) + } + }) + + it('is supported once it picks the mode itself, with the host seed as an extra field', async () => { + const replies = await createSupport( + current, + released(STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY) + ) + expect(replies).toHaveLength(1) + // `supported` is all an older desktop or phone reads; the seed rides beside it. + expect(replies[0]).toMatchObject({ ok: true, result: { supported: true, seedOptions: SEED } }) + }) + }) + describe('a client that predates the turn item', () => { beforeEach(() => turnItemSkew.install(SESSION, WORKSPACE)) afterEach(() => setStructuredAgentSessionHost(null)) @@ -261,6 +328,8 @@ describe('cross-version structured agent sessions', () => { it('registers the whole surface on the new build', () => { expect(current.capabilities).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) expect(current.capabilities).toContain(AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY) + // The host admits by client capability, so a client may pick each launch mode itself. + expect(current.capabilities).toContain(STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY) expect(current.methodNames.filter((name) => name.startsWith('agentSession.'))).toHaveLength( STRUCTURED_CALLS.length )