From 757736628f0dc0dfa972350b22524df0ca9d6f62 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:55:32 -0700 Subject: [PATCH] fix(native-chat): a paired server admits structured chat by client capability, not its own chat setting (#24203) * 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). * 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. * chore(native-chat): justify the two type assertions this change's lines touch * 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. * test(native-chat): pin that a host advertises the client-chosen launch mode * 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. * test(native-chat): record install listeners without a cast * 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. * 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. * 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. * test(cross-version): stub the launch seed resolver createSupport now reads * fix(native-chat): the desktop tells its own host it picks each launch mode, so retrying an existing chat works with the setting off * docs(native-chat): name the real exit for the released-phone createSupport rule * test(cross-version): a released client still gets the host-setting createSupport answer; a launch-mode client gets supported plus the seed --- ...ktop-renderer-runtime-capabilities.test.ts | 21 +- .../desktop-renderer-runtime-capabilities.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 +- .../structured-agent-session-host.ts | 3 + .../structured-agent-session-registry.test.ts | 91 ++++++++ .../structured-agent-session-registry.ts | 50 +++++ .../runtime/agent-session-record-store.ts | 15 ++ ...structured-agent-session-create-support.ts | 16 +- src/main/runtime/orca-runtime-state-fields.ts | 5 - ...ctured-agent-session-create-intent.test.ts | 2 + ...me-structured-native-chat-settings.test.ts | 21 -- .../runtime-availability.spec.ts | 8 + ...ession-tab-agent-status-projection.test.ts | 123 +++-------- .../session-tab-agent-status-projection.ts | 19 +- ...n-tab-browser-placement-projection.test.ts | 2 +- .../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 | 22 +- .../session-tabs-structured-restore.test.ts | 111 ++++------ src/main/runtime/rpc/methods/session-tabs.ts | 20 +- ...structured-agent-session-admission.test.ts | 209 ++++++++++++++---- ...ession-gate-classification.test-fixture.ts | 10 +- .../methods/structured-agent-session-gate.ts | 55 +++-- .../structured-agent-session-hold.test.ts | 4 +- .../structured-agent-session-policy.test.ts | 124 ++++------- .../structured-agent-session-policy.ts | 70 +++--- ...ructured-agent-session-rpc.test-fixture.ts | 1 + ...d-agent-session-send-compatibility.test.ts | 6 +- .../methods/structured-agent-session.test.ts | 14 +- .../rpc/methods/structured-agent-session.ts | 25 +-- .../methods/structured-session-tab-restore.ts | 19 +- .../worktree-rm-background-reply.test.ts | 2 +- ...worktree-removal-marker-projection.test.ts | 2 +- ...ructured-agent-session-held-signal.test.ts | 145 ++++++++++++ ...uctured-agent-session-host-refusal.test.ts | 6 +- .../startup/main-process-ipc-bootstrap.ts | 13 ++ src/preload/api/app-api.ts | 4 + src/preload/api/app-bridge.ts | 8 + .../app-shell/use-app-startup-hydration.ts | 10 +- .../NativeChatResumeOnRestartModal.tsx | 9 +- .../native-chat-restart-offer-gate.ts | 10 + .../settings/ExperimentalPane.test.tsx | 47 +++- .../NativeChatExperimentalSetting.tsx | 10 +- .../NativeChatResumeStatusSegment.test.tsx | 64 +++++- .../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 +- .../runtime/local-structured-chats.test.ts | 69 ++++++ .../src/runtime/local-structured-chats.ts | 116 ++++++++++ ...local-structured-session-tab-retirement.ts | 36 --- ...-structured-session-tabs-sync-hook.test.ts | 141 ++++++++++++ ...local-structured-session-tabs-sync.test.ts | 90 -------- .../local-structured-session-tabs-sync.ts | 18 +- .../inventory-generation-fence.ts | 30 +-- .../snapshot-apply.ts | 33 +-- .../src/web/preload-api/web-app-api.ts | 3 + ...tron-remote-runtime-client-capabilities.ts | 23 ++ src/shared/protocol-version.ts | 22 +- src/shared/remote-runtime-client.test.ts | 4 +- ...ss-version-agent-session-wire.unit.test.ts | 74 ++++++- 74 files changed, 1370 insertions(+), 785 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-registry.test.ts delete mode 100644 src/main/runtime/orca-runtime-structured-native-chat-settings.test.ts create mode 100644 src/main/runtime/structured-agent-session-held-signal.test.ts create mode 100644 src/renderer/src/components/native-chat-restart-offer-gate.ts create mode 100644 src/renderer/src/runtime/local-structured-chats.test.ts create mode 100644 src/renderer/src/runtime/local-structured-chats.ts create mode 100644 src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts 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 a8adaacc0a3..31a27eb416e 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -17,9 +17,9 @@ 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_CLIENT_LAUNCH_MODE_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, @@ -27,8 +27,10 @@ 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 { 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 8fe881e0061..ba4b6b25472 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 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 51c77510a01..9433e4f2805 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 d86f298a5f0..e339e75c624 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 6ba9f273c1e..bb666e757f8 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 7483ab15c23..88586577905 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 7a7927cd8be..f1a846d988e 100644 --- a/src/main/ipc/runtime-environment-support-routing.ts +++ b/src/main/ipc/runtime-environment-support-routing.ts @@ -9,7 +9,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 f8048004720..f67ca33b467 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -1,6 +1,6 @@ import { getRuntimeEnvironmentStatus } from './runtime-environment-status-probe' 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 { resolveManagedRuntimeEnvironment } from './runtime-environment-managed-tunnel' import { recordRuntimeEnvironmentUsage } from './runtime-environment-usage-record' diff --git a/src/main/ipc/runtime-environments-call-routing.test.ts b/src/main/ipc/runtime-environments-call-routing.test.ts index 0e8472625d5..643a1dd10e9 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 d798e12586c..b1dc936acf8 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 9fe3d6baf0e..8e51332c8b5 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 ed4dcd62182..699a9cab8d5 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 0ef70f7ac96..bc9dfc58c29 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/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index eb0b3564527..4c29fd5e3d0 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 @@ -222,6 +222,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 new file mode 100644 index 00000000000..3bdc49d5524 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-registry.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + getStructuredAgentSessionHost, + onStructuredAgentSessionsHeldChanged, + setStructuredAgentSessionHost, + structuredAgentSessionsHeld +} from './structured-agent-session-registry' + +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 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 = onStructuredAgentSessionsHeldChanged(listener) + + setStructuredAgentSessionHost(fakeHost(false).host) + + expect(structuredAgentSessionsHeld()).toBe(false) + expect(listener).not.toHaveBeenCalled() + stop() + }) + + it('turns true once, when the first chat is recorded', () => { + const listener = vi.fn() + const stop = onStructuredAgentSessionsHeldChanged(listener) + const { host, recordFirstChat } = fakeHost(false) + setStructuredAgentSessionHost(host) + + recordFirstChat() + recordFirstChat() + + 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 b7a26bb8e3e..3b675c4054a 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,59 @@ import type { StructuredAgentSessionHost } from './structured-agent-session-host' let host: StructuredAgentSessionHost | null = null +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 { + stopWatchingHost?.() + stopWatchingHost = null host = next + 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 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 9f1c5bf6e7f..cd87b024c0f 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -76,6 +76,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, @@ -108,6 +109,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) @@ -331,6 +335,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 ( @@ -338,7 +348,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) } @@ -354,6 +366,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/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index c0d4c596ffa..2bf67f0b4f8 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-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index ca95bf9dc27..ede9c6edce5 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -122,11 +122,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-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts index c5cdf188cd4..edff20ee99c 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/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 90be5c61ba2..00000000000 --- 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/orca-runtime-tests/runtime-availability.spec.ts b/src/main/runtime/orca-runtime-tests/runtime-availability.spec.ts index 8a818e3ca6a..aedaa567b3d 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() 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 61bb30bdbcf..d06f5819e7d 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 e2aa9ae7b00..5686d813559 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 e8166bdd056..77c75117481 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 3ab06a298fc..be3a1d2de42 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 } @@ -122,11 +110,11 @@ 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, - 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 a7bcab53742..3b2f5a5ca1e 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,61 @@ 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 { + // 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 })), + 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 }) - - const response = await dispatcher.dispatch( - makeRequest('session.tabs.list', { worktree: 'id:wt-1' }), - { - clientKind: 'runtime', - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] - } - ) - - 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 - ) + expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1) } + ) - expect(restoreCallsBySetting.get(false)).toBe(0) - expect(restoreCallsBySetting.get(true)).toBe(1) - }) -}) + it('restores for an in-process caller, which negotiates nothing', async () => { + const runtime = await listTabs() -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] - } - ) - - expect(response.ok).toBe(true) - expect(runtime.restoreStructuredAgentSessionTabs).not.toHaveBeenCalled() + expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1) }) // 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 }) + it('restores for a mobile client that advertises no capability', async () => { + const runtime = await listTabs({ clientKind: 'mobile', clientCapabilities: [] }) - const response = await dispatcher.dispatch( - makeRequest('session.tabs.list', { worktree: 'id:wt-1' }), - { clientKind: 'mobile', clientCapabilities: [] } - ) - - expect(response.ok).toBe(true) expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1) }) + + it('does not restore for a paired desktop that cannot read structured sessions', async () => { + const runtime = await listTabs({ clientKind: 'runtime', clientCapabilities: [] }) + + expect(runtime.restoreStructuredAgentSessionTabs).not.toHaveBeenCalled() + }) }) diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 8d3d1af33bf..d549daffbaf 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 a1f05657e32..a1a5171f238 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,16 @@ -// 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { - ADMISSION_METHODS, - CLEANUP_METHODS + 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 } from './structured-agent-session-gate-classification.test-fixture' import { call, @@ -13,6 +18,7 @@ import { envelope, hostCalls, installStructuredHostStub, + runtimeCalls, SESSION, STRUCTURED_CLIENT } from './structured-agent-session-rpc.test-fixture' @@ -25,31 +31,69 @@ 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 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') } - 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, 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}`) + return null } - } - ) + ) - 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 - }) + // 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, MODE_CHOOSING_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('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, MODE_CHOOSING_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, 'user-close') @@ -57,7 +101,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 +113,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: [] }, + SETTING_ON + ) + + // 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 +143,85 @@ describe('admission revoked while a session is still open', () => { expect(hostCalls.close).toHaveBeenCalledWith(SESSION, 'user-close') } ) +}) - it('lets an in-process caller close, which is how terminal disposal retires a chat', async () => { +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( - 'agentSession.close', - { sessionId: SESSION }, - undefined, + CREATE_SUPPORT.method, + CREATE_SUPPORT.params, + RELEASED_PHONE, SETTING_OFF ) - expect(response).toMatchObject({ ok: true }) - expect(hostCalls.close).toHaveBeenCalledWith(SESSION, 'user-close') + expect(response).toMatchObject({ ok: false, error: UNSUPPORTED }) + expect(runtimeCalls.getStructuredAgentSessionCreateSupport).not.toHaveBeenCalled() }) - 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) + 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 + ) - // 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') } - }) - } - ) + 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 }) + }) +}) + +// 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-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts index a2876f284db..12813a8f66e 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 0bed563af38..754412ffea9 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' @@ -15,7 +15,8 @@ import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-se import type { RpcContext } from '../core' import { structuredAgentSessionHostRefusal } from '../../structured-agent-session-host-refusal' import { - supportsStructuredAgentSessionCapability, + createSupportFollowsHostSetting, + isStructuredNativeChatEnabled, supportsStructuredAgentSessions } from './structured-agent-session-policy' @@ -35,45 +36,39 @@ 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() } /** - * 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. + * 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 { - if (!supportsStructuredAgentSessionCapability(ctx)) { - throw agentSessionRefusalError('structured_agent_session_unsupported', { - reason: 'clientCapabilityMissing' - }) - } + requireStructuredCapability(ctx) 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 446fb107411..769bd32f17b 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 @@ -152,12 +152,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-policy.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts index 6c765119375..662cc948b7d 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,67 @@ 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 -} +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] -/** 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) { + 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 + ) + } + ) +}) + +describe('createSupportFollowsHostSetting', () => { + it.each(['runtime', 'mobile'] as const)( + 'leaves a %s client that picks its own launch mode to capability alone', + (clientKind) => { expect( - supportsStructuredAgentSessions({ + createSupportFollowsHostSetting({ clientKind, - clientCapabilities: [], - runtime: runtimeWithSetting(true) + clientCapabilities: [...CAPABLE, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY] }) ).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.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('reads the setting from the caller-supplied value when no runtime is available', () => { + it('leaves an in-process caller, the same build as the host, to capability alone', () => { 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 - }) + 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 46a1ee34c45..cc03bfd21a5 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -1,15 +1,42 @@ import { - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - type RuntimeCapability + 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' -type StructuredPolicyContext = Pick & { - runtime?: Pick - structuredNativeChatEnabled?: boolean +/** + * 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 + ) } +/** + * 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. Remove once the oldest supported phone build launches agents through `agent.launch`. + */ +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 { @@ -19,36 +46,3 @@ export function isStructuredNativeChatEnabled( return false } } - -export function supportsStructuredAgentSessionCapability( - 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-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 57606c8c456..a09e82464ba 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 @@ -218,6 +218,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-send-compatibility.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts index 59bc914558e..e420176c70c 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/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index cc0677b134a..82437bb6d81 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 b97da008a40..e0d24119086 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -22,9 +22,9 @@ import { 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 { @@ -134,12 +134,16 @@ 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' - }) - } - return ctx.runtime.getStructuredAgentSessionCreateSupport(params.worktree, params.agent) + requireStructuredCreateSupportAdmission(ctx) + 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({ @@ -202,7 +206,6 @@ 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) @@ -214,8 +217,6 @@ 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) // Terminal-disposal closes use this RPC without the session-tabs retirement RPC. if (typeof host.setSessionTabVisibility === 'function') { @@ -302,8 +303,6 @@ 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) const base = subscriptionBaseFor(ctx, params.sessionId) if (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 6ec96241422..81ecde98360 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/rpc/methods/worktree-rm-background-reply.test.ts b/src/main/runtime/rpc/methods/worktree-rm-background-reply.test.ts index 1905d95b295..e456e4cbf50 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 4ce22b0a360..f2abee689e7 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/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 00000000000..2ce91a287f5 --- /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/runtime/structured-agent-session-host-refusal.test.ts b/src/main/runtime/structured-agent-session-host-refusal.test.ts index 3ef5965b4e8..e8296141b3b 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 { @@ -33,7 +33,7 @@ const openFailureLogs = () => log.entries.filter((entry) => entry.fields.scope === 'journal-database-open') // 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 { @@ -51,7 +51,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 } diff --git a/src/main/startup/main-process-ipc-bootstrap.ts b/src/main/startup/main-process-ipc-bootstrap.ts index 918fd844364..6bf6533c57c 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 { + onStructuredAgentSessionsHeldChanged, + structuredAgentSessionsHeld +} 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() }) + // 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({ firstWindowStartupServicesReady: state.firstWindowStartupServicesReady, diff --git a/src/preload/api/app-api.ts b/src/preload/api/app-api.ts index b2d6eed966c..55f82e23568 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 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 2a0d50e9de4..76c97645bff 100644 --- a/src/preload/api/app-bridge.ts +++ b/src/preload/api/app-bridge.ts @@ -47,6 +47,14 @@ export const appApi = { ipcRenderer.invoke('app:awaitGitEnvironmentStartupBarrier'), prepareTerminalStartupRestoration: (): Promise => ipcRenderer.invoke('app:prepareTerminalStartupRestoration'), + 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'), 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 7cee1762111..4186481e7fa 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,11 +281,9 @@ 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() - ) - } + await restoreLocalStructuredChatsAtStartup(useAppStore.getState().settings, (restore) => + timeRendererStartupStep('project-structured-session-tabs', restore) + ) if (cancelled) { return } diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx index 315a19e3170..4d7e7c8bfb7 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 00000000000..5b3bb66f866 --- /dev/null +++ b/src/renderer/src/components/native-chat-restart-offer-gate.ts @@ -0,0 +1,10 @@ +import { useLocalStructuredChatsInUse } from '@/runtime/local-structured-chats' + +/** + * 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 useLocalStructuredChatsInUse() +} diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index ba8e1518921..808166fe7d2 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: { + holdsStructuredAgentSessions: async () => true, + onStructuredAgentSessionsHeldChanged: () => () => 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 d7f1d0e2913..4b0320e8b4d 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 { 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' @@ -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 = useLocalStructuredAgentSessionsHeld() + 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. Off keeps the existing terminal-backed chat path.' + '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 c5a260ed201..02462393502 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: { + holdsStructuredAgentSessions: async () => installed, + onStructuredAgentSessionsHeldChanged: () => () => 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) @@ -168,14 +184,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 +203,50 @@ 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 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 }, + unifiedTabsByWorktree: { + 'wt-1': [ + { + id: 'agent-session:claude_1', + entityId: 'claude_1', + groupId: 'group-1', + worktreeId: 'wt-1', + executionHostId: 'runtime:server-1', + contentType: 'agent-session', + agentSessionAgent: 'claude', + label: 'Claude Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }) + stageLocalHost(false) + await mount() + + expect(rpc).not.toHaveBeenCalled() + expect(screen.queryByRole('button')).toBeNull() + }) + 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 751a804b2dd..24e9b225824 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 d1b4b13ba32..48f6eec3cb1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7266,7 +7266,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": "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 68837d40c1f..d8086183f21 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -7204,7 +7204,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": "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 825b968adbd..0997b8b616b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -7086,7 +7086,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 830bc354b1c..29149cd5a65 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -7086,7 +7086,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 656d7071ef6..d907237dea8 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -7086,7 +7086,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 00000000000..24a2a481c45 --- /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(() => ({ held: 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.held = false + mocks.isWebClient = false + resetLocalStructuredChatsForTests() + vi.stubGlobal('window', { + api: { + app: { + holdsStructuredAgentSessions: async () => mocks.held, + onStructuredAgentSessionsHeldChanged: () => () => 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.held = 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.held = 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 00000000000..dad01c24361 --- /dev/null +++ b/src/renderer/src/runtime/local-structured-chats.ts @@ -0,0 +1,116 @@ +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 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 held = false +let pushes = 0 +const listeners = new Set<() => void>() +let stopListening: (() => void) | null = null + +function setHeld(next: boolean): void { + if (held === next) { + return + } + 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.onStructuredAgentSessionsHeldChanged(onHeldChanged) + void readLocalStructuredAgentSessionsHeld() + } + return () => { + listeners.delete(listener) + } +} + +/** Asks this machine's runtime whether it holds a structured chat. */ +export async function readLocalStructuredAgentSessionsHeld(): Promise { + if (isWebClientLocation()) { + return false + } + const pushesBefore = pushes + try { + 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 change event still arrives. + } + return held +} + +/** This machine's runtime holds a structured chat. */ +export function useLocalStructuredAgentSessionsHeld(): boolean { + return useSyncExternalStore( + subscribe, + () => held, + () => false + ) +} + +type StructuredChatSettings = Pick | null + +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 holds = useLocalStructuredAgentSessionsHeld() + const setting = useAppStore((state) => state.settings?.experimentalStructuredNativeChat === true) + return chatsInUse({ experimentalStructuredNativeChat: setting }, holds) +} + +/** 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 readLocalStructuredAgentSessionsHeld()) +} + +/** + * 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 + held = false + pushes = 0 + 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 81a8d0d5ef5..db560849044 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 new file mode 100644 index 00000000000..454012c23d6 --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts @@ -0,0 +1,141 @@ +// @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 (_sync: { isDisposed: () => boolean }) => undefined), + held: false, + isWebClient: false, + heldListeners: new Array<(held: boolean) => 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({ + settings: { ...useAppStore.getState().settings!, experimentalStructuredNativeChat: enabled } + }) +} + +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() + mocks.held = false + mocks.isWebClient = false + mocks.heldListeners.splice(0) + resetLocalStructuredChatsForTests() + Object.defineProperty(window, 'api', { + configurable: true, + value: { + app: { + holdsStructuredAgentSessions: vi.fn(async () => mocks.held), + onStructuredAgentSessionsHeldChanged: vi.fn((listener: (held: boolean) => void) => { + mocks.heldListeners.push(listener) + return () => undefined + }) + } + } + }) + useAppStore.setState({ workspaceSessionReady: true, terminalStartupRestorationReady: true }) +}) + +afterEach(() => { + cleanup() + resetLocalStructuredChatsForTests() + useAppStore.setState(useAppStore.getInitialState(), true) +}) + +describe("this machine's structured chat mirror", () => { + it('holds no session-tabs subscription for a machine that never held a chat', async () => { + setStructuredChat(false) + + 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.held = true + + await mountSync() + + expect(mocks.start).toHaveBeenCalledOnce() + }) + + 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.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.held = 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.held = 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 2af30958ae1..45a539cdc6e 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 3e0d25e940f..011422abb4c 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts @@ -1,7 +1,7 @@ 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' +import { useLocalStructuredChatsInUse } from './local-structured-chats' export { isCurrentLocalStructuredSessionGeneration, @@ -14,25 +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 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 enabled = useAppStore((state) => state.settings?.experimentalStructuredNativeChat === true) + const inUse = useLocalStructuredChatsInUse() useEffect(() => { - if (!ready) { - return - } - if (!enabled) { - clearLocalStructuredSessionTabs() + if (!ready || !inUse) { return } let disposed = false @@ -47,5 +43,5 @@ export function useLocalStructuredSessionTabsSync(): void { disposed = true unsubscribe() } - }, [enabled, 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 8ab68385f9e..647f22ef77a 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 f9e35cb056b..a597296b335 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 28fd9f99176..1e1793e321f 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. + holdsStructuredAgentSessions: () => Promise.resolve(false), + onStructuredAgentSessionsHeldChanged: () => () => undefined, recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(), startupDiagnostic: () => Promise.resolve(), getKeyboardInputSourceId: () => Promise.resolve(null), 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 00000000000..a8a38dc4b35 --- /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 0e32913a337..b44e78b82fd 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -164,6 +164,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 = @@ -323,20 +330,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 @@ -406,6 +399,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. diff --git a/src/shared/remote-runtime-client.test.ts b/src/shared/remote-runtime-client.test.ts index d7a76417e84..096aad47068 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[] = [] 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 4969357ba87..110fc8e489a 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' @@ -79,13 +80,14 @@ 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', getClientSettings: () => ({ experimentalStructuredNativeChat: true }), ensureStructuredAgentSessionHost: async () => undefined, getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + structuredAgentSessionLaunchSeedOptions: () => undefined, resolveStructuredAgentSessionCreateIntent: async () => { const { envelope: _envelope, @@ -98,7 +100,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 +246,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 +329,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 )