feat(native-chat): advertise structured reader support on paired connections

A paired desktop advertised no structured capability, so a host withheld every
structured session-tab row from it and refused the whole agentSession.* surface. A user
with two machines could see a chat on one of them only.

This advertises the reader: structured, claude-structured, the turn item and the status
feed. Deliberately not hold, reveal or resume-history — an adjunct says the renderer can
answer for it, and those three are the surfaces that write. Mobile already drives
desktop-hosted structured sessions over this same surface, so the host's admission,
projection and multi-subscriber paths are production-exercised; what is new is this
client's list and the desktop's consumption of the rows it now receives.

Advertising is not dormant. Against a capable host it restores and publishes sessions
that already existed, and a pane over one of those would take a hold — which opens the
durable record on the other machine and hands its session a provider child back. The
host does not stop it: the hold is gated on the reader string, not on the hold string
this client withholds. So the pane refuses itself. An environment-owned session renders
through the owner-bound pane with every mutation off and no hold call made at all, under
its own notice rather than one of the hold states, because nothing here was refused and
nothing is missing on the host.

The retreat is a client setting, structuredChatRemoteRead, distinct from the host's own
structured-chat admission switch. Off, this desktop stops advertising the reader and a
host withholds the rows again, from each pairing's next connection. Every call site that
opens a paired connection now reads the list through one place, and a test fails if a new
one reads the constant instead.

Wire: new client capability advertisement and changed published content with no codec
change. Cross-version journeys run both directions against a real release — this
desktop's own advertised list, read from the shipped constant, against an old host and a
new one, and a desktop derived without the advertisement against both, on the same host
and the same socket.
This commit is contained in:
Merge Sim
2026-09-11 09:33:11 -07:00
parent 1272d9f9c6
commit fd0ea14101
20 changed files with 648 additions and 25 deletions
@@ -9,9 +9,9 @@ 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 { redactRuntimeEnvironment } from '../../shared/runtime-environments'
import type { RuntimeStatus } from '../../shared/runtime-types'
import { electronRemoteRuntimeClientCapabilities } from './structured-reader-advertisement'
type VerifyAndAddRuntimeEnvironmentArgs = {
name: string
@@ -44,7 +44,7 @@ export async function verifyAndAddRuntimeEnvironmentFromPairingCode(
15_000,
undefined,
undefined,
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
electronRemoteRuntimeClientCapabilities()
)
if (!response.ok) {
return {
@@ -4,7 +4,6 @@ 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 type {
RuntimeOrchestrationEnvelope,
RuntimeRpcResponse
@@ -22,6 +21,7 @@ import {
advanceRuntimeEnvironmentTransportGeneration,
getRuntimeEnvironmentTransportGeneration
} from './runtime-environment-transport-generation'
import { electronRemoteRuntimeClientCapabilities } from './structured-reader-advertisement'
type CachedRuntimeConnection = {
pairingKey: string
@@ -102,7 +102,7 @@ export function sendRemoteRuntimeConnectionRequest<TResult>(
pairingKey,
connection: new RemoteRuntimeRequestConnection(
pairing,
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
electronRemoteRuntimeClientCapabilities()
)
}
requestConnections.set(environmentId, cached)
@@ -212,7 +212,7 @@ function getSharedControlConnection(
pairingKey,
connection: new RemoteRuntimeSharedControlConnection(pairing, {
environmentId,
clientCapabilities: ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES,
clientCapabilities: electronRemoteRuntimeClientCapabilities(),
isManuallyDisconnected: () => isRuntimeEnvironmentManuallyDisconnected(environmentId),
isCapabilityPaused: () => isRuntimeEnvironmentCapabilityPaused(environmentId),
onDiagnosticsChanged: (diagnostics) => {
@@ -1,9 +1,6 @@
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 {
getPreferredPairingOffer,
type KnownRuntimeEnvironment
@@ -20,6 +17,7 @@ import {
captureRuntimeEnvironmentCapabilityEvidence
} from './runtime-environment-capability-evidence'
import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect'
import { electronRemoteRuntimeClientCapabilities } from './structured-reader-advertisement'
export function createRuntimeEnvironmentStatusOwner(
userDataPath: string,
@@ -49,7 +47,7 @@ export function createRuntimeEnvironmentStatusOwner(
15_000,
undefined,
signal,
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
electronRemoteRuntimeClientCapabilities()
)
},
verified: (response, active) => {
@@ -7,7 +7,6 @@ import type {
import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments'
import { getPreferredPairingOffer } from '../../shared/runtime-environments'
import { markEnvironmentUsed, resolveEnvironment } from '../../shared/runtime-environment-store'
import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version'
import {
subscribeRemoteRuntimeRequest,
type RemoteRuntimeSubscription
@@ -24,6 +23,7 @@ import {
sendRemoteRuntimeSharedControlRequestAbortable
} from './runtime-environment-abortable-requests'
import { subscribeRemoteRuntimeSharedControlRequest } from './runtime-environment-request-connections'
import { electronRemoteRuntimeClientCapabilities } from './structured-reader-advertisement'
type SupportRoute = {
environment: KnownRuntimeEnvironment
@@ -98,7 +98,7 @@ export function executeSupportRoutedCall(args: {
args.timeoutMs,
args.envelope,
args.signal,
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
electronRemoteRuntimeClientCapabilities()
),
markUsed: (environmentId, response) => {
if (response.ok) {
@@ -156,7 +156,7 @@ export async function subscribeSupportRoutedRuntimeEnvironment(args: {
args.params,
args.timeoutMs,
callbacks,
{ clientCapabilities: ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES }
{ clientCapabilities: electronRemoteRuntimeClientCapabilities() }
)
}
})
@@ -1,5 +1,4 @@
import { getPreferredPairingOffer } from '../../shared/runtime-environments'
import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version'
import { resolveEnvironment, markEnvironmentUsed } from '../../shared/runtime-environment-store'
import { isOrchestrationMutation } from '../../shared/orchestration-rpc-contract'
import type {
@@ -30,6 +29,7 @@ import {
shouldRouteSubscriptionBySupport,
subscribeSupportRoutedRuntimeEnvironment
} from './runtime-environment-support-routing'
import { electronRemoteRuntimeClientCapabilities } from './structured-reader-advertisement'
const DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS = 15_000
@@ -114,7 +114,7 @@ export async function callRuntimeEnvironment(
effectiveTimeoutMs,
envelope,
options?.signal,
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
electronRemoteRuntimeClientCapabilities()
)
markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response)
return response
@@ -152,7 +152,7 @@ export async function callRuntimeEnvironment(
effectiveTimeoutMs,
sharedControlEnvelope,
options?.signal,
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
electronRemoteRuntimeClientCapabilities()
)
markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response)
return response
@@ -236,7 +236,7 @@ export async function subscribeRuntimeEnvironment(
params,
effectiveTimeoutMs,
callbacksWithMarkUsed,
{ clientCapabilities: ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES }
{ clientCapabilities: electronRemoteRuntimeClientCapabilities() }
)
} catch (error) {
if (error instanceof Error) {
+4
View File
@@ -36,6 +36,7 @@ import {
computerAwakeSettingsForMode,
normalizeComputerAwakeMode
} from '../../shared/computer-awake-mode'
import { setStructuredChatRemoteReadSource } from './structured-reader-advertisement'
// Why: the whitelist is the source-of-truth for which keys we emit on. Casting
// to a Set once at module load lets the IPC handler's per-key membership
@@ -72,6 +73,9 @@ export function registerSettingsHandlers(
store: Store,
agentAwakeService?: AgentAwakeService
): void {
// Read live rather than mirrored: every paired connection this process opens decides its
// structured-reader advertisement from this, and a copy taken here would outlive the switch.
setStructuredChatRemoteReadSource(() => store.getSettings().structuredChatRemoteRead !== false)
ipcMain.handle(
'agentAwake:getStatus',
() => agentAwakeService?.getStatus() ?? { mode: 'off', active: false }
@@ -0,0 +1,73 @@
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES,
STRUCTURED_AGENT_SESSION_READER_RUNTIME_CAPABILITIES
} from '../../shared/protocol-version'
import {
electronRemoteRuntimeClientCapabilities,
resetStructuredChatRemoteReadSourceForTests,
setStructuredChatRemoteReadSource,
structuredChatRemoteReadEnabled
} from './structured-reader-advertisement'
const OWNER = 'structured-reader-advertisement.ts'
function ipcSourceFiles(root: string): string[] {
return readdirSync(root).flatMap((entry) => {
const full = join(root, entry)
if (statSync(full).isDirectory()) {
return ipcSourceFiles(full)
}
return full.endsWith('.ts') && !full.endsWith('.test.ts') ? [full] : []
})
}
afterEach(() => {
resetStructuredChatRemoteReadSourceForTests()
})
describe('what this desktop advertises to a paired host', () => {
it('carries the structured reader while the setting is on or unset', () => {
expect(structuredChatRemoteReadEnabled()).toBe(true)
setStructuredChatRemoteReadSource(() => true)
for (const capability of STRUCTURED_AGENT_SESSION_READER_RUNTIME_CAPABILITIES) {
expect(electronRemoteRuntimeClientCapabilities()).toContain(capability)
}
})
it('drops only the reader when the setting is off, so the retreat is not a new client', () => {
setStructuredChatRemoteReadSource(() => false)
const advertised = electronRemoteRuntimeClientCapabilities()
for (const capability of STRUCTURED_AGENT_SESSION_READER_RUNTIME_CAPABILITIES) {
expect(advertised, `${capability} is withheld`).not.toContain(capability)
}
// Everything else is untouched: a user retreating from structured reads must not also lose
// page placement or the retirement-proof ledger and start looking like some other client.
const withheld = new Set<string>(STRUCTURED_AGENT_SESSION_READER_RUNTIME_CAPABILITIES)
expect(advertised).toEqual(
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES.filter((capability) => !withheld.has(capability))
)
})
it('keeps advertising when the settings read throws rather than retreating silently', () => {
setStructuredChatRemoteReadSource(() => {
throw new Error('settings store unavailable')
})
expect(electronRemoteRuntimeClientCapabilities()).toEqual(
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
)
})
it('is the only place a paired connection reads the list from', () => {
const offenders = ipcSourceFiles(__dirname)
.filter((file) => !file.endsWith(OWNER))
.filter((file) =>
readFileSync(file, 'utf8').includes('ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES')
)
// A call site holding the constant directly advertises the reader whatever the user set, and
// nothing about it looks wrong: it is the same list this module returns on the default path.
expect(offenders, 'these advertise past the retreat lever').toEqual([])
})
})
@@ -0,0 +1,51 @@
// What this desktop tells a paired host it can read, and the one lever that takes it back.
//
// The structured reader strings are not inert: a host withholds structured session-tab rows and
// refuses `agentSession.*` to a client that omits them, so advertising them is what makes a paired
// host start publishing other machines' chats here. That is a change to published content, which
// the wire contract treats as a wire change, so it needs a retreat that does not require shipping
// a new build. Every connection this process opens reads the list from here.
//
// The lever is read through a source rather than mirrored, because a stale copy would advertise on
// behalf of a user who had already switched it off. It only reaches connections opened after the
// switch: a capability is negotiated once per connection, so an established pairing keeps reading
// until it reconnects.
import {
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES,
STRUCTURED_AGENT_SESSION_READER_RUNTIME_CAPABILITIES,
type RuntimeCapability
} from '../../shared/protocol-version'
const READER_CAPABILITIES = new Set<string>(STRUCTURED_AGENT_SESSION_READER_RUNTIME_CAPABILITIES)
const WITHOUT_STRUCTURED_READER: readonly RuntimeCapability[] =
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES.filter(
(capability) => !READER_CAPABILITIES.has(capability)
)
/** Absent source = advertise, matching every other capability this build ships with. */
let readStructuredChatRemoteRead: (() => boolean) | null = null
export function setStructuredChatRemoteReadSource(source: () => boolean): void {
readStructuredChatRemoteRead = source
}
export function resetStructuredChatRemoteReadSourceForTests(): void {
readStructuredChatRemoteRead = null
}
export function structuredChatRemoteReadEnabled(): boolean {
try {
return readStructuredChatRemoteRead?.() !== false
} catch {
// A settings read that throws must not silently retreat a feature the user left on.
return true
}
}
export function electronRemoteRuntimeClientCapabilities(): readonly RuntimeCapability[] {
return structuredChatRemoteReadEnabled()
? ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
: WITHOUT_STRUCTURED_READER
}
@@ -60,6 +60,23 @@ function RetryButton({ onClick }: { onClick: () => void }): React.JSX.Element {
)
}
/** A live host this build only reads. Deliberately not one of the hold states below: nothing was
* refused and nothing is missing on the other machine, so saying "update that server" would send
* the user after the wrong fix. */
function RemoteReadOnlyNotice(): React.JSX.Element {
return (
<NoticeRow>
<span data-native-chat-remote="read-only">
{translate(
'components.native-chat.structuredSessionRemoteReadOnly',
'This chat runs on a paired host. This version of Orca shows it here but does not send to it.'
)}{' '}
{holdLifetimeNote()}
</span>
</NoticeRow>
)
}
/** The two degraded states stay apart: a host that answers and lacks the method is an update
* prompt, never an error; a host that never answered is the read-only one. */
function HoldNotice({
@@ -151,7 +168,11 @@ export function NativeChatStructuredSessionNotices({
)}
</p>
) : null}
<HoldNotice hold={controller.hold} onRetry={controller.retryHold} />
{controller.remoteReadOnly ? (
<RemoteReadOnlyNotice />
) : (
<HoldNotice hold={controller.hold} onRetry={controller.retryHold} />
)}
{controller.error || composerError ? (
<p className="mx-auto w-full max-w-4xl px-4 py-1 text-xs text-destructive">
{controller.error ?? composerError}
@@ -3,6 +3,10 @@
// Two degraded panes that must never look alike: a paired host that answers and has no hold method
// still reads and still sends, it just needs an update; a host that has not answered leaves the
// pane on the transcript it last read, with writes off and a Retry.
//
// Every case here is about a pane that holds, so remote writes are on for the file: with them off
// a paired pane takes no hold at all, which is its own guard in
// `structured-remote-read-only-pane.test.tsx` rather than a variant of these.
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -23,6 +27,9 @@ vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({
...(await importOriginal<typeof RuntimeRpcClientModule>()),
runtimeEnvironmentSupportsCapability: holdMocks.supportsCapability
}))
vi.mock('./structured-remote-session-writes', () => ({
structuredRemoteSessionWritesEnabled: () => true
}))
vi.mock('./use-native-chat-font-scale', () => moduleFactories.useNativeChatFontScale())
vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext())
vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick())
@@ -0,0 +1,210 @@
// @vitest-environment happy-dom
//
// This release advertises that it can READ a paired host's structured chats, and that is the whole
// of it. The host is willing — it admits the hold to any client that advertises the reader — so the
// only thing standing between a restored chat row and a provider child waking up on somebody else's
// machine is this pane. These are that guard.
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import { EMPTY_STRUCTURED_AGENT_SESSION } from '../../../../shared/structured-agent-session-reducer'
import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
import { structuredTabOwnerBinding } from '@/runtime/structured-tab-owner'
import { buildMirroredAgentTabs } from '@/runtime/web-session-tabs-sync/terminal-surfaces'
import type * as RuntimeRpcClientModule from '@/runtime/runtime-rpc-client'
const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () =>
(await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks()
)
// A host with every structured capability: the pane must refuse itself, not be refused.
const hostMocks = vi.hoisted(() => ({ supportsCapability: vi.fn(async () => true) }))
vi.mock('@/runtime/structured-agent-session-client', () =>
moduleFactories.structuredAgentSessionClient()
)
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({
...(await importOriginal<typeof RuntimeRpcClientModule>()),
runtimeEnvironmentSupportsCapability: hostMocks.supportsCapability
}))
vi.mock('./use-native-chat-font-scale', () => moduleFactories.useNativeChatFontScale())
vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext())
vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick())
vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList())
vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer())
vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState())
vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard())
vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard())
vi.mock('./use-structured-agent-session-read', () => ({
useStructuredAgentSessionRead: () => ({
state: readState,
loadingOlder: false,
loadOlder: vi.fn(),
providerSession: undefined
})
}))
import { NativeChatStructuredSession } from './NativeChatStructuredSession'
const ENVIRONMENT_ID = 'env-paired'
const SESSION_ID = 'session-restored'
const transcriptItem: AgentJournalRenderItem = {
itemId: 'item-1',
revision: 1,
sequence: 1,
observedAt: 1,
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Last answer.' }] }
}
/** The transcript the host published; a read-only pane still shows all of it. */
const readState = {
...EMPTY_STRUCTURED_AGENT_SESSION,
epoch: 'epoch-1',
fence: 1,
status: 'ready' as const,
items: [transcriptItem],
commands: null
}
/** A worktree the paired host was already holding a chat in before this client ever connected. */
function preExistingHostSnapshot(): RuntimeMobileSessionTabsResult {
return {
worktree: 'wt-paired',
publicationEpoch: 'epoch-1',
snapshotVersion: 4,
activeGroupId: 'group-1',
activeTabId: `agent-session:${SESSION_ID}`,
activeTabType: 'agent-session',
tabs: [
{
type: 'agent-session',
id: `agent-session:${SESSION_ID}`,
title: 'Codex Chat',
sessionId: SESSION_ID,
agent: 'codex',
isActive: true
}
]
}
}
/**
* The tab the mirror really builds from that snapshot, then the owner binding the pane really
* reads off it. Going through both is the point: a pane handed a hand-written environment target
* proves nothing about what a restored host row turns into.
*/
function paneFromRestoredHostRow(): { target: { kind: string }; stale: boolean } {
const mirrored = buildMirroredAgentTabs(
preExistingHostSnapshot(),
new Map(),
'group-1',
0,
[],
1,
`runtime:${ENVIRONMENT_ID}`
)
const stamp = mirrored[0]?.unifiedTab
if (!stamp) {
throw new Error('the mirror built no tab for a published structured row')
}
const binding = structuredTabOwnerBinding(stamp, null)
return { target: binding.target, stale: binding.ownerPairingStale }
}
function renderRestoredPane(): void {
const { target, stale } = paneFromRestoredHostRow()
expect(target, 'the restored row must bind to its paired owner').toMatchObject({
kind: 'environment',
environmentId: ENVIRONMENT_ID
})
// Not the re-paired degraded state: this pane is read-only while its owner is perfectly current.
expect(stale).toBe(false)
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="tab-1"
sessionId={SESSION_ID}
target={target as { kind: 'environment'; environmentId: string }}
ownerPairingRevision={7}
agent="codex"
/>
)
}
function callsTo(method: string): unknown[][] {
return mocks.call.mock.calls.filter((call) => call[1] === method)
}
beforeEach(() => {
replaceRuntimeEnvironmentRevisions([{ id: ENVIRONMENT_ID, createdAt: 7, pairingRevision: 7 }])
hostMocks.supportsCapability.mockClear()
mocks.call.mockImplementation(async (_target: unknown, method: string) =>
method === 'agentSession.options'
? { current: { model: 'gpt-live' }, models: [], conversationCommands: [] }
: {}
)
})
afterEach(() => {
cleanup()
resetStructuredSessionMocks()
replaceRuntimeEnvironmentRevisions([])
})
describe('a structured chat restored from a paired host', () => {
it('renders the transcript without waking anything on the host that owns it', async () => {
renderRestoredPane()
expect(await screen.findByTestId('message-list')).toBeTruthy()
// The one call that opens a durable record on the other machine and hands its session a
// provider child back. A read-only pane must never make it, and must not even negotiate it:
// the host here advertises everything, so a pane that asked would have been told yes.
await waitFor(() => expect(mocks.composerProps?.canSend).toBe(false))
expect(callsTo('agentSession.hold')).toHaveLength(0)
expect(callsTo('agentSession.release')).toHaveLength(0)
expect(hostMocks.supportsCapability).not.toHaveBeenCalled()
})
it('says why it is read-only in terms of this client, not of the host', async () => {
renderRestoredPane()
const notice = await screen.findByText(/runs on a paired host/i)
expect(notice.dataset.nativeChatRemote).toBe('read-only')
// The lifetime sentence this stack is required to use. "Work continues while you are away"
// describes a PTY and is false for a structured session.
expect(notice.textContent).toContain(
'The in-flight turn and its approvals survive going offline; idle sessions park after about 15 seconds and resume on demand.'
)
expect(notice.textContent).not.toMatch(/work continues/i)
// Not the update prompt and not the lost-contact pane: neither is true of this host.
expect(document.querySelector('[data-native-chat-hold]')).toBeNull()
})
it('sends no mutation, whatever the pane is asked to do', async () => {
renderRestoredPane()
await waitFor(() => expect(mocks.composerProps?.canSend).toBe(false))
const transport = mocks.composerProps?.structuredTransport as
| { send?: (text: string, attachments: readonly unknown[]) => boolean }
| undefined
expect(transport?.send, 'the composer was never handed a transport').toBeTypeOf('function')
expect(transport?.send?.('hello', [])).toBe(false)
for (const method of [
'agentSession.hold',
'agentSession.send',
'agentSession.cancel',
'agentSession.setOption',
'agentSession.respondToApproval',
'agentSession.respondToQuestion',
'agentSession.conversationCommand'
]) {
expect(callsTo(method), `${method} reached the paired host`).toHaveLength(0)
}
// Anti-vacuous: the pane is talking to the host, just only ever reading it.
expect(callsTo('agentSession.options').length).toBeGreaterThan(0)
})
})
@@ -0,0 +1,10 @@
// Whether a structured chat owned by a paired host may be written to from this client.
//
// The reader advertisement ships ahead of the switch that makes remote creation legal, so for now
// a paired chat is a transcript: no send, no approval, no option change, and above all no hold.
// The hold is the one read-shaped call that is not a read — it opens the durable record on the
// other machine and hands its session a provider child back — so a pane that took one would start
// work on a host nobody is watching.
export function structuredRemoteSessionWritesEnabled(): boolean {
return false
}
@@ -32,6 +32,7 @@ import {
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import { useStructuredAgentSessionHold } from './use-structured-agent-session-hold'
import { structuredRemoteSessionWritesEnabled } from './structured-remote-session-writes'
import { useStructuredAgentSessionRead } from './use-structured-agent-session-read'
import {
pendingStructuredSessionPrompts,
@@ -60,17 +61,22 @@ export function useStructuredAgentSession(args: {
// A re-paired owner leaves the transcript exactly as last read and stops every write: the id now
// names a different machine, so re-reading or mutating would address a stranger's journal.
const live = isVisible && !ownerPairingStale
// A chat this client only reads. Not a degraded state and not a host's answer: the host is
// willing, and this build is the side that has not shipped the other half yet.
const remoteReadOnly = target.kind === 'environment' && !structuredRemoteSessionWritesEnabled()
// Declared first: the hold is what gives a restored session its provider child back, and the
// read below is useless for sending until it lands.
const hold = useStructuredAgentSessionHold({
sessionId,
target,
surface: 'desktop-chat',
enabled: live
// Never merely ignored: the hold is the provider wake, so a read-only pane must not make the
// call at all rather than make it and refuse to use what it reserved.
enabled: live && !remoteReadOnly
})
// A host that never answered cannot have taken the hold a restored session needs, so the pane is
// looking at the last transcript it read; a write would address a host it has no contact with.
const readOnly = ownerPairingStale || hold.state.kind === 'unreachable'
const readOnly = ownerPairingStale || remoteReadOnly || hold.state.kind === 'unreachable'
const { state, loadingOlder, loadOlder } = useStructuredAgentSessionRead({
...args,
isVisible: live
@@ -219,6 +225,8 @@ export function useStructuredAgentSession(args: {
cached: ownerPairingStale,
/** Every reason this pane refuses writes, re-paired owner or unreachable host alike. */
readOnly,
/** Specifically: a live, willing paired host this build reads and does not drive. */
remoteReadOnly,
hold: hold.state,
retryHold: hold.retry,
conversationCommands:
@@ -262,13 +262,45 @@ describe('ExperimentalPane', () => {
expect(container.textContent).toContain(
'Opt in to the host-owned structured chat runtime for Codex and Claude.'
)
// Scoped to STARTING a session: a paired host's own structured chats are readable here, so
// the line must not read as "remote hosts have none of this".
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.'
'Orca starts these sessions locally 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.'
)
expect(container.textContent).toContain('Default view')
root.unmount()
})
it('offers the paired-host read retreat only once structured chat is on, and writes it off', async () => {
const updateSettings = vi.fn()
const structuredOff = {
...getDefaultSettings('/tmp'),
experimentalNativeChat: true,
openAgentTabsInChatByDefault: true
}
const hidden = await renderExperimentalPane({ updateSettings, settings: structuredOff })
// Nothing to retreat from while structured chat itself is off.
expect(hidden.container.textContent).not.toContain('Read structured chats on paired hosts')
hidden.root.unmount()
const { root, container } = await renderExperimentalPane({
updateSettings,
settings: { ...structuredOff, experimentalStructuredNativeChat: true }
})
const remoteReadSwitch = container.querySelector<HTMLButtonElement>(
'#experimental-native-chat button[role="switch"][aria-label="Toggle reading structured chats on paired hosts"]'
)
// On for a profile that never saw the setting: the reader ships advertised.
expect(remoteReadSwitch?.getAttribute('aria-checked')).toBe('true')
await act(async () => {
remoteReadSwitch?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(updateSettings).toHaveBeenCalledWith({ structuredChatRemoteRead: false })
root.unmount()
})
it('hides a stale structured opt-in under Terminal chat without clearing it', async () => {
const updateSettings = vi.fn()
const settings = {
@@ -20,6 +20,7 @@ export function NativeChatExperimentalSetting({
}: NativeChatExperimentalSettingProps): React.JSX.Element {
const nativeChatEnabled = settings.experimentalNativeChat === true
const structuredNativeChatEnabled = settings.experimentalStructuredNativeChat === true
const structuredChatRemoteRead = settings.structuredChatRemoteRead !== false
const defaultView: NativeChatDefaultView =
settings.openAgentTabsInChatByDefault === true ? 'native-chat' : 'terminal-chat'
@@ -132,7 +133,7 @@ export function NativeChatExperimentalSetting({
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.ExperimentalPane.nativeChat.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.'
'Orca starts these sessions locally 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.'
)}
</p>
</div>
@@ -150,6 +151,41 @@ export function NativeChatExperimentalSetting({
/>
</div>
) : null}
{defaultView === 'native-chat' && structuredNativeChatEnabled ? (
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 shrink space-y-0.5">
<Label>
{translate(
'components.settings.nativeChat.remoteReadTitle',
'Read structured chats on paired hosts'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate(
'components.settings.nativeChat.remoteReadCopy',
'Show the structured chats a paired Orca host is running. They are read-only here; sending and starting new ones stay on that machine.'
)}
</p>
<p className="text-xs text-muted-foreground">
{translate(
'components.settings.nativeChat.remoteReadScope',
'Turning this off stops Orca asking each paired host for them from the next time it connects to that host.'
)}
</p>
</div>
<SettingsSwitch
checked={structuredChatRemoteRead}
ariaLabel={translate(
'components.settings.nativeChat.remoteReadToggleLabel',
'Toggle reading structured chats on paired hosts'
)}
onChange={() =>
updateSettings({ structuredChatRemoteRead: !structuredChatRemoteRead })
}
/>
</div>
) : null}
</div>
) : null}
</SearchableSetting>
+10 -1
View File
@@ -7030,7 +7030,7 @@
"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.",
"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.",
"structuredScope": "Orca starts these sessions locally 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"
},
"agentDashboard": {
@@ -17250,6 +17250,7 @@
"structuredSessionHoldConflict": "Another surface already holds this session on its host, so this pane did not reserve it.",
"structuredSessionHoldOwnerUnknown": "This session\u2019s host could not prove who owns it, so this pane did not reserve it.",
"structuredSessionHoldFailed": "This session could not be reserved on its host.",
"structuredSessionRemoteReadOnly": "This chat runs on a paired host. This version of Orca shows it here but does not send to it.",
"handoff": {
"stage": {
"finishingChat": "Finishing chat session…",
@@ -17611,6 +17612,14 @@
"copySessionIdSuccess": "Session ID copied",
"copySessionIdError": "Unable to copy session ID"
}
},
"settings": {
"nativeChat": {
"remoteReadTitle": "Read structured chats on paired hosts",
"remoteReadCopy": "Show the structured chats a paired Orca host is running. They are read-only here; sending and starting new ones stay on that machine.",
"remoteReadScope": "Turning this off stops Orca asking each paired host for them from the next time it connects to that host.",
"remoteReadToggleLabel": "Toggle reading structured chats on paired hosts"
}
}
},
"dashboardPopout": {
+3
View File
@@ -214,6 +214,9 @@ export type GlobalSettings = {
experimentalNativeChat?: boolean
/** Opt-in updated structured runtime; off keeps the existing PTY-backed native chat path. */
experimentalStructuredNativeChat?: boolean
/** Read structured chats a paired host owns. On by default; off retreats the advertisement that
* makes a host publish them, and applies to each pairing from its next connection. */
structuredChatRemoteRead?: boolean
/** Last explicit native-chat model + option selections; live panes need an applied/dispatched record before showing a value. */
nativeChatSessionOptions?: PersistedNativeChatSessionOptions
/** Extra launcher rows for the worktree "Open in" submenu. VS Code is always shown first. */
+16 -1
View File
@@ -227,6 +227,20 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [
AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
] as const
// Reading a structured chat a paired host owns, and nothing that writes to one.
//
// A host withholds structured session-tab rows and refuses the whole `agentSession.*` surface to a
// client that advertises none of these, so adding them changes what this connection is published —
// a wire change with no codec change. Each string is a separate promise about renderer behaviour
// that is live NOW: hold, reveal and resume-history stay out until the surfaces that answer for
// them ship, because advertising one is what makes a host expect this client to drive it.
export const STRUCTURED_AGENT_SESSION_READER_RUNTIME_CAPABILITIES = [
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
AGENT_SESSION_TURN_ITEM_CAPABILITY,
AGENT_SESSION_STATUS_FEED_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 = [
@@ -235,7 +249,8 @@ export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [
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
SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY,
...STRUCTURED_AGENT_SESSION_READER_RUNTIME_CAPABILITIES
] as const
export const RUNTIME_CAPABILITIES = [
@@ -15,6 +15,10 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry'
import {
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_READER_RUNTIME_CAPABILITIES,
STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../../src/shared/protocol-version'
import type {
@@ -26,7 +30,8 @@ import {
materializeReleaseCheckout,
resolveBaselineReleaseRef
} from './release-checkout'
import { turnItemSkew } from './structured-agent-session-host-fixture'
import type { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host'
import { structuredHostStub, turnItemSkew } from './structured-agent-session-host-fixture'
import {
loadAgentSessionWireBuild,
WORKING_TREE,
@@ -49,6 +54,7 @@ const CLAUDE_TAB_TITLE = 'Claude Chat'
const LIST_METHOD = 'session.tabs.list'
const SUBSCRIBE_METHOD = 'session.tabs.subscribe'
const CLOSE_METHOD = 'session.tabs.close'
const HOLD_METHOD = 'agentSession.hold'
const PROJECTION_MODULE = '/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts'
@@ -239,6 +245,23 @@ function c1WithClaudeReader(): string[] {
return [...c1(), CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
/**
* D — what a paired desktop of that build actually advertises, taken from the shipped constant
* rather than assembled here. C0 and C1 above are hypotheses about clients; this one is the
* client, so the day someone edits that list these journeys change with it instead of quietly
* continuing to describe a desktop that no longer exists.
*/
function desktopOf(build: AgentSessionWireBuild): string[] {
return [...build.clientCapabilities]
}
/** A desktop from before the advertisement, derived the way C0 is: once a release ships the reader
* strings, a hand-written list would stop being that client and start being this one. */
function withoutStructuredReader(capabilities: readonly string[]): string[] {
const reader = new Set<string>(STRUCTURED_AGENT_SESSION_READER_RUNTIME_CAPABILITIES)
return capabilities.filter((capability) => !reader.has(capability))
}
async function callBuild(
build: AgentSessionWireBuild,
method: string,
@@ -557,4 +580,109 @@ describe('cross-version session-tab sync', () => {
}
})
})
/**
* The desktop this release ships is the client PR-12 was written against in the abstract. These
* journeys use its real advertisement, so the harness stops describing a hypothesis and starts
* describing the build.
*/
describe('the paired desktop this release ships', () => {
let stub: SessionTabsRuntimeStub
beforeEach(() => {
stub = sessionTabsRuntimeStub()
})
afterEach(() => {
setStructuredAgentSessionHost(null)
})
it('advertises the reader and nothing whose surface it cannot yet drive', () => {
const desktop = desktopOf(current)
// Anti-vacuous: a list read from the wrong export, or an empty one, would satisfy every
// absence below while proving nothing about what this desktop says on the wire.
expect(desktop.length).toBeGreaterThan(0)
for (const capability of STRUCTURED_AGENT_SESSION_READER_RUNTIME_CAPABILITIES) {
expect(desktop, `the desktop advertises ${capability}`).toContain(capability)
}
for (const adjunct of [
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY
]) {
// This build names all three, so leaving them out is a decision about what the renderer
// can answer for rather than a string nobody has written down yet.
expect(current.capabilities, `${adjunct} exists in this build`).toContain(adjunct)
expect(desktop, `the desktop withholds ${adjunct}`).not.toContain(adjunct)
}
})
it('is published the structured rows by either host while an older desktop still is not', async () => {
const olderDesktop = withoutStructuredReader(desktopOf(baseline))
expect(olderDesktop.length).toBeGreaterThan(0)
for (const build of hostBuilds()) {
const projected = await listTabs(build, stub, runtimeClient(desktopOf(current)))
expect(tabIds(projected), `${build.label} publishes both chats to this desktop`).toEqual([
TERMINAL_TAB,
CODEX_TAB,
CLAUDE_TAB
])
expect(projected.tabs.find((tab) => tab.id === CLAUDE_TAB)).toMatchObject({
title: CLAUDE_TAB_TITLE,
sessionId: CLAUDE_SESSION
})
// Same host, same socket, a client that predates the advertisement: the decision is per
// connection, so shipping it here does not change what an older desktop is handed.
const older = await listTabs(build, stub, runtimeClient(olderDesktop))
expect(tabIds(older), `${build.label} still withholds from an older desktop`).toEqual([
TERMINAL_TAB
])
}
expect(stub.published).toEqual(hostSnapshot())
})
it('finds a session the host already had, at the publication it was already at', async () => {
for (const build of hostBuilds()) {
const projected = await listTabs(build, stub, runtimeClient(desktopOf(current)))
expect(tabIds(projected)).toContain(CODEX_TAB)
// The advertisement restores the host's own records; it does not mint a publication, so a
// desktop that turns it on lands on the work that was already there.
expect(projected.publicationEpoch).toBe(hostSnapshot().publicationEpoch)
expect(projected.snapshotVersion).toBe(hostSnapshot().snapshotVersion)
}
expect(stub.restoreCalls).toBeGreaterThan(0)
expect(stub.published).toEqual(hostSnapshot())
})
it('would be let through to a hold, so only this client keeps it from waking a provider', async () => {
const host = structuredHostStub(CODEX_SESSION, WORKTREE)
setStructuredAgentSessionHost(host as unknown as StructuredAgentSessionHost)
const held = await callBuild(
current,
HOLD_METHOD,
{ sessionId: CODEX_SESSION, holderId: 'desktop-chat' },
runtimeClient(desktopOf(current)),
stub.runtime
)
// The reader advertisement admits the whole read surface AND the hold: the host gates the
// hold on the reader string, not on the hold string this desktop withholds. So nothing on
// the host side is what stops a read-only pane opening a record and handing its session a
// provider child back — the renderer is, and its guard is the only one there is.
expect(held[0], 'the host admits a hold from this desktop').toMatchObject({ ok: true })
expect(host.hold).toHaveBeenCalledTimes(1)
host.hold.mockClear()
const refused = await callBuild(
current,
HOLD_METHOD,
{ sessionId: CODEX_SESSION, holderId: 'desktop-chat' },
runtimeClient(withoutStructuredReader(desktopOf(baseline))),
stub.runtime
)
expect(refused[0], 'a desktop without the advertisement is refused').toMatchObject({
ok: false,
error: { message: expect.stringContaining('structured_agent_session_unsupported') }
})
expect(host.hold).not.toHaveBeenCalled()
})
})
})
@@ -50,6 +50,10 @@ export type AgentSessionWireBuild = {
/** Capability strings this build defines. A peer cannot advertise — nor a client
* ask for — a string its own source never names. */
capabilities: readonly string[]
/** What this build's own Electron desktop advertises when it pairs, read from the shipped
* constant. A journey written against a hand-copied list stops describing the desktop the
* moment someone edits that constant, and passes while doing it. */
clientCapabilities: readonly string[]
protocolVersion: number
/** RPC method names the build registers, read from source. */
methodNames: readonly string[]
@@ -94,6 +98,16 @@ function capabilityStrings(module: Record<string, unknown>): readonly string[] {
return declared as readonly string[]
}
function electronClientCapabilityStrings(module: Record<string, unknown>): readonly string[] {
const declared = module.ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
if (!Array.isArray(declared) || declared.length === 0) {
throw new Error(
'Cross-version harness found no ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES to advertise'
)
}
return declared as readonly string[]
}
async function loadWorkingTreeBuild(): Promise<AgentSessionWireBuild> {
const [protocol, dispatcher, methodRegistry] = await Promise.all([
import('../../../src/shared/protocol-version'),
@@ -106,6 +120,9 @@ async function loadWorkingTreeBuild(): Promise<AgentSessionWireBuild> {
label: WORKING_TREE,
revision: WORKING_TREE,
capabilities: capabilityStrings(protocol as unknown as Record<string, unknown>),
clientCapabilities: electronClientCapabilityStrings(
protocol as unknown as Record<string, unknown>
),
protocolVersion: protocol.RUNTIME_PROTOCOL_VERSION,
methodNames: registeredMethodNames(methods),
createDispatcher: (runtime) =>
@@ -133,6 +150,7 @@ async function loadReleaseBuild(checkout: ReleaseCheckout): Promise<AgentSession
label: checkout.ref,
revision: checkout.commit,
capabilities: capabilityStrings(protocol),
clientCapabilities: electronClientCapabilityStrings(protocol),
protocolVersion: protocol.RUNTIME_PROTOCOL_VERSION as number,
methodNames: registeredMethodNames(methods),
createDispatcher: (runtime) =>