diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index e25444616883..757bd326c2ce 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -241,6 +241,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts', + 'src/main/runtime/rpc/methods/structured-chat-startup-entry.test.ts', 'src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts', 'src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts', 'src/main/runtime/runtime-orchestration-sender-names.test.ts', @@ -282,5 +283,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'tests/e2e/folder-upgrade-identity-persistence.unit.test.ts', 'tests/e2e/host-agent-launch-settings-hook-bookkeeping.unit.test.ts', 'tests/e2e/structured-agent-session-read-owner.unit.test.ts', - 'tests/e2e/structured-chat-owner-status-activation.unit.test.ts' + 'tests/e2e/structured-chat-owner-status-activation.unit.test.ts', + 'tests/e2e/structured-session-reopened-tab-close.unit.test.ts', + 'tests/e2e/structured-session-tab-close-stale-frame.unit.test.ts' ] diff --git a/src/main/ipc/runtime-window-origin.test.ts b/src/main/ipc/runtime-window-origin.test.ts new file mode 100644 index 000000000000..1696681da457 --- /dev/null +++ b/src/main/ipc/runtime-window-origin.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../runtime/orca-runtime' + +const { handle, fromWebContents } = vi.hoisted(() => ({ + handle: vi.fn(), + fromWebContents: vi.fn() +})) +vi.mock('electron', () => ({ + BrowserWindow: { fromWebContents }, + ipcMain: { handle, on: vi.fn(), removeAllListeners: vi.fn(), removeHandler: vi.fn() } +})) +vi.mock('../runtime/rpc/methods', async () => { + const { defineMethod } = await import('../runtime/rpc/core') + const { z } = await import('zod') + return { + ALL_RPC_METHODS: [ + defineMethod({ + name: 'test.windowOrigin', + permission: 'workspace', + params: z.object({ originatingWindowId: z.number().optional() }), + handler: (_params, context) => ({ + originatingWindowId: context.originatingWindowId, + caller: context.caller + }) + }) + ] + } +}) +import { registerRuntimeHandlers } from './runtime' + +beforeEach(() => { + handle.mockReset() + fromWebContents.mockReset() +}) + +function register() { + registerRuntimeHandlers(new OrcaRuntimeService()) + const handler = handle.mock.calls.find(([channel]) => channel === 'runtime:call')?.[1] + if (!handler) { + throw new Error('runtime:call was not registered') + } + const mainFrame = {} + const sender = { id: 4, mainFrame, on: vi.fn(), once: vi.fn() } + return { handler, event: { sender, senderFrame: mainFrame } } +} + +it('stamps the actual BrowserWindow id after main-frame validation, ignoring forged params', async () => { + const { handler, event } = register() + fromWebContents.mockReturnValue({ id: 17 }) + expect( + await handler(event, { method: 'test.windowOrigin', params: { originatingWindowId: 999 } }) + ).toMatchObject({ ok: true, result: { originatingWindowId: 17, caller: { kind: 'desktop' } } }) + expect(fromWebContents).toHaveBeenCalledWith(event.sender) +}) + +it('rejects a subframe before resolving a window or dispatching a method', async () => { + const { handler, event } = register() + await expect( + handler({ ...event, senderFrame: {} }, { method: 'test.windowOrigin', params: {} }) + ).rejects.toThrow('current main frame') + expect(fromWebContents).not.toHaveBeenCalled() +}) diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index 1736cbc6f60f..7bacb3f7980d 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -81,6 +81,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { clientId: 'desktop-renderer', caller: DESKTOP_RPC_CALLER, clientKind: 'runtime', + originatingWindowId: BrowserWindow.fromWebContents(event.sender)?.id, connectionId: desktopSenders.connectionIdFor(event.sender), clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 2fdc842b335f..d5262718eb0e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -22,6 +22,7 @@ import { pinnedAgentSessionLaunchEnv } from './structured-agent-session-launch-env' import { refuseAgentSessionMutation } from './structured-agent-session-mutation-admission' +import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-refusals' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' @@ -44,6 +45,7 @@ import { } from '../../observability/agent-session-instrumentation' export type StructuredAgentSessionAttachOptions = { + signal?: AbortSignal hostLaunchDirectory?: string recordPhase?: AgentSessionCreatePhaseRecorder onAcquisitionFailed?: AttachFlowInput['onAcquisitionFailed'] @@ -111,14 +113,18 @@ async function runAttach( options: StructuredAgentSessionAttachOptions ): Promise> { // Begun first, so a close or Stop during any phase below still stops the start. + if (options.signal?.aborted) { + return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) + } const acquire = context.runtimeState.acquireAborts.begin(params.envelope.sessionId) + const signal = options.signal ? AbortSignal.any([options.signal, acquire.signal]) : acquire.signal try { - return await runAttachUnderAbort(context, callerKey, params, options, acquire.signal) + return await runAttachUnderAbort(context, callerKey, params, options, signal) } finally { acquire.end() - if (isStructuredAgentSessionStartupExpired(acquire.signal.reason)) { + if (isStructuredAgentSessionStartupExpired(signal.reason)) { options.onStartupExpired?.() - } else if (acquire.signal.aborted) { + } else if (signal.aborted) { options.onAborted?.() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts index 54bb3e6b4b8f..2a6b43679bef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts @@ -31,6 +31,8 @@ import { readAgentSessionHydrationPage } from './agent-session-history-page' import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' export type StructuredAgentSessionCreateOptions = { + /** The opening request's lifetime, including preparation before this host was reached. */ + signal?: AbortSignal firstMessage?: { clientMessageId: string; body: AgentJournalMessageItem } hostLaunchDirectory?: string /** Called after the durable create, before delivery can start the agent. */ @@ -44,6 +46,7 @@ export function createStructuredAgentSession( params: AgentSessionAttachParams, options: StructuredAgentSessionCreateOptions = {} ): Promise> { + options.signal?.throwIfAborted() return context.tasks.trackAttach( context.serialize(params.envelope.sessionId, async () => { try { @@ -64,6 +67,7 @@ async function foundConversation( params: AgentSessionAttachParams, options: StructuredAgentSessionCreateOptions ): Promise> { + options.signal?.throwIfAborted() const { deps } = context const { store } = deps const sessionId = params.envelope.sessionId @@ -173,6 +177,7 @@ async function foundConversation( let committed = replayed try { if (!replayed) { + options.signal?.throwIfAborted() await importAdoptedTranscript( params, { journal: session.journal, unconfirmedClientMessageIds: [] }, @@ -181,6 +186,7 @@ async function foundConversation( { uncommittedCreate: true } ) const first = options.firstMessage + options.signal?.throwIfAborted() if (first) { const sent = await performSend( { @@ -259,7 +265,9 @@ async function foundConversation( ...(tabId ? { tabId } : {}) } } - context.wakeDelivery?.(sessionId) + if (!options.signal?.aborted) { + context.wakeDelivery?.(sessionId) + } return result } finally { if (!committed && !existing) { 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 3b675c4054a2..539a148566ce 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 @@ -11,6 +11,7 @@ let host: StructuredAgentSessionHost | null = null let held = false let stopWatchingHost: (() => void) | null = null const heldListeners = new Set<(held: boolean) => void>() +const installedListeners = new Set<(host: StructuredAgentSessionHost) => void>() // Why typeof: tests install partial hosts. function hostHoldsSessions(candidate: StructuredAgentSessionHost | null): boolean { @@ -41,6 +42,15 @@ export function setStructuredAgentSessionHost(next: StructuredAgentSessionHost | stopWatchingHost?.() stopWatchingHost = null host = next + if (next) { + for (const listener of installedListeners) { + try { + listener(next) + } catch (error) { + console.warn('[structured-agent-session] a host-installed listener threw', error) + } + } + } try { stopWatchingHost = typeof next?.onSessionsHeld === 'function' ? next.onSessionsHeld(publishHeld) : null @@ -66,3 +76,16 @@ export function onStructuredAgentSessionsHeldChanged( export function getStructuredAgentSessionHost(): StructuredAgentSessionHost | null { return host } + +/** Reads installed metadata before an awaiting close can mutate the new host. */ +export function onStructuredAgentSessionHostInstalled( + listener: (host: StructuredAgentSessionHost) => void +): () => void { + if (host) { + listener(host) + } + installedListeners.add(listener) + return () => { + installedListeners.delete(listener) + } +} diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 02a1ab2f63a4..5c7f931f2227 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -139,8 +139,9 @@ export class AgentSessionRecordStore { agentSessionVisibleTabIndex(this.state) /** The id of the chat tab showing this conversation, if one does. */ - getSessionTabId = (sessionId: string): string | null => - this.state.sessionTabs?.tabIdFor(sessionId) ?? null + getSessionTabId = (sessionId: string) => this.state.sessionTabs?.tabIdFor(sessionId) ?? null + + getSessionIdForTab = (tabId: string) => this.state.sessionTabs?.sessionIdFor(tabId) ?? null /** * Persist the user-visible tab reference separately from the rollback-sensitive profile tabs. diff --git a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts index 2bfeaec2adde..35262886e33d 100644 --- a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts @@ -1,9 +1,6 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithRefuseUnattributedMobileSessionTabClose } from './orca-runtime-refuse-unattributed-mobile-session-tab-close' -import type { - RuntimeMobileSessionTerminalTab, - RuntimeSessionTabCloseReason -} from '../../shared/runtime-types' +import type { RuntimeMobileSessionTerminalTab } from '../../shared/runtime-types' import type { RuntimePtyTabCloseAuthority } from './runtime-terminal-state-records' import { adjudicateAbsentMobileSessionTabClose, @@ -18,17 +15,20 @@ import { } from './mobile-session-tab-close-outcome' import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' import type { RuntimeCommandSurfaceHost } from './orca-runtime-core' -import { SESSION_TAB_NOT_FOUND_ERROR } from '../../shared/session-tab-close' import { rendererPublicationThrottle } from '../window/renderer-publication-throttle' -import { structuredAgentSessionTabCloseCause } from './structured-agent-session-tab-close-cause' import { retireHeadlessMobileSessionEditorTab } from './mobile-session-editor-projection' +import { + closeListedStructuredAgentSessionTab, + closeStructuredAgentSessionTabForUser, + isUserStructuredAgentSessionTabClose, + type StructuredAgentSessionTabCloseOptions +} from './structured-agent-session-tab-close' export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseUnattributedMobileSessionTabClose { async closeMobileSessionTab( worktreeSelector: string, tabId: string, - options: { - reason?: RuntimeSessionTabCloseReason + options: StructuredAgentSessionTabCloseOptions & { expectedPublicationEpoch?: string expectedTerminalHandle?: string clientNavigationId?: string @@ -41,6 +41,14 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id + // Why before the hydrate: a chat close needs no terminal process scan, which can wait on SSH. + if (isUserStructuredAgentSessionTabClose(tabId, options)) { + if (graphEpoch !== null) { + this.assertStableReadyGraph(graphEpoch) + } + await closeStructuredAgentSessionTabForUser(this, worktreeId, tabId, options) + return committedMobileSessionTabClose(this.clientSessionTabSelections, worktreeId, [tabId]) + } this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) const observedPtyIds = await this.refreshMobileSessionPtyRecords() if (graphEpoch !== null) { @@ -291,21 +299,7 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU await this.notifier.closeSessionTab(tab.id, worktreeId) } } else if (tab.type === 'agent-session') { - if (this.notifier?.closeSessionTab) { - try { - // Why: a reopened chat's window tab id is not derivable from its session; the window maps ours. - await this.notifier.closeSessionTab(tab.id, worktreeId) - } catch (error) { - // The renderer already having removed the tab is an idempotent close, not a veto. - if (!(error instanceof Error && error.message === SESSION_TAB_NOT_FOUND_ERROR)) { - throw error - } - } - } - await this.closeStructuredAgentSessionTab( - tab, - structuredAgentSessionTabCloseCause(options.reason) - ) + await closeListedStructuredAgentSessionTab(this, worktreeId, tab, options) } else if (!this.notifier?.closeSessionTab) { // Why: a headless host listed this editor from its own session, so it retires it there. if (!retireHeadlessMobileSessionEditorTab(this, worktreeId, tab, options.force)) { diff --git a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts index 88d2837f6646..39479b0cdd43 100644 --- a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts @@ -1,9 +1,9 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithCloseMobileSessionTab } from './orca-runtime-close-mobile-session-tab' import type { - RuntimeMobileSessionAgentTab, RuntimeMobileSessionBrowserTab, RuntimeMobileSessionRetiredTerminalSurface, + RuntimeMobileSessionTabsResult, RuntimeMobileSessionTabsSnapshot, RuntimeMobileSessionTerminalTab } from '../../shared/runtime-types' @@ -12,9 +12,10 @@ import { getMobileSessionSnapshotTabIdentityKeys } from './mobile-session-tab-me import type { BrowserSessionTabSelectionOptions } from './browser-tab-create-publication' import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' import { applyBrowserSessionTabSelection } from './browser-session-tab-selection-snapshot' -import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import type { StructuredAgentSessionCloseCause } from '../native-chat/agent-session-wire/structured-agent-session-host-lifetime' import { retireStructuredAgentSessionTabFrom } from './structured-agent-session-tab-retirement' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { AGENT_SESSION_CLOSE_PENDING_CODE } from '../../shared/session-tab-close' +import { structuredAgentSessionIdForTabClose } from './structured-agent-session-tab-close' import { chatTabId, withChatTabChanges, type ChatTabChange } from './agent-session-tab-layout' import { savedSessionId } from './saved-structured-agent-session-restoration' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -22,31 +23,36 @@ import { structuredSessionTabRetired } from './structured-worker-custody' import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWithCloseMobileSessionTab { - protected async closeStructuredAgentSessionTab( - tab: RuntimeMobileSessionAgentTab, - cause: StructuredAgentSessionCloseCause - ): Promise { - const host = getStructuredAgentSessionHost() - if (host) { - if (typeof host.setSessionTabVisibility === 'function') { - // The restore index is bookkeeping: one that cannot be written (a newer Orca's records, a - // failing disk) is reported, and the tab still closes. - await host.setSessionTabVisibility(tab.sessionId, false).catch((error: unknown) => { - host.deps.logger.warn('recording a closed chat tab failed', { - scope: 'tab-visibility-close', - sessionId: tab.sessionId, - error - }) - }) - } + resolveStructuredAgentSessionTabCloseSessionId(tabId: string): string | null { + return structuredAgentSessionIdForTabClose(this, tabId) + } + + /** Establish chat ownership without startup's terminal census; an unreadable store is not empty. */ + async ensureStructuredAgentSessionHostForClose(): Promise { + if (getStructuredAgentSessionHost() || !this.hasPersistedStructuredAgentSessionStore()) { + return + } + try { + await this.ensureStructuredAgentSessionHost() + } catch (error) { + throw new Error(AGENT_SESSION_CLOSE_PENDING_CODE, { cause: error }) } - // Retire durable visibility and the runtime snapshot before stopping the provider. - this.retireStructuredAgentSessionTabFromSnapshot(tab.sessionId) - if (typeof host?.close === 'function') { - await host.close(tab.sessionId, cause) + if (!getStructuredAgentSessionHost()) { + throw new Error(AGENT_SESSION_CLOSE_PENDING_CODE) } } + /** The worktree's stored list without the terminal process scan, which no chat close needs. */ + async listMobileSessionTabsForChatClose( + worktreeSelector: string, + clientNavigationId?: string + ): Promise { + const worktreeId = + this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) ?? + (await this.resolveWorktreeSelector(worktreeSelector)).id + return this.getMobileSessionTabsForWorktree(worktreeId, clientNavigationId) + } + /** * Prunes a structured session's chat tab from whichever worktree snapshot still carries it. * 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 42593b34fc85..8ab114f96f64 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 @@ -282,6 +282,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR }, (error) => { this.structuredAgentSessionTabRestorePromise = null + console.warn('[runtime] restoring saved chat tabs failed', error) throw error } ) @@ -289,6 +290,18 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR } prepareStructuredAgentSessionStartupRestoration(): Promise { + if (!this.hasPersistedStructuredAgentSessionStore()) { + return this.prepareStructuredAgentSessionStartupRestorationStage() + } + const finished = this.restoreStructuredAgentSessionTabs().catch(() => undefined) + return this.prepareStructuredAgentSessionStartupRestorationStage().catch(async (error) => { + // A retry must start a fresh full operation after the failed participant has ended. + await finished + throw error + }) + } + + protected prepareStructuredAgentSessionStartupRestorationStage(): Promise { this.structuredAgentSessionStartupRestorePromise ??= this.prepareStructuredAgentSessionStartupRestorationOnce().catch((error) => { this.structuredAgentSessionStartupRestorePromise = null diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 46e7c3350f7e..9900f7a71586 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -3,10 +3,8 @@ import { defaultAgentChatLabel } from '../../shared/agent-session-chat-label' import { OrcaRuntimeWithGetStructuredAgentSessionCreateSupport } from './orca-runtime-get-structured-agent-session-create-support' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' -import { - chatTabChangesMatchingIndex, - seedStructuredAgentSessionTabIndex -} from './structured-agent-session-tab-index-seed' +import { restoreStructuredAgentSessionTabPublications } from './structured-agent-session-tab-restoration' +import { chatTabChangesMatchingIndex } from './structured-agent-session-tab-index-seed' import { chatTabId } from './agent-session-tab-layout' import { structuredSessionTabRetired } from './structured-worker-custody' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -42,59 +40,48 @@ export type AgentLaunchScope = Parameters< export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRuntimeWithGetStructuredAgentSessionCreateSupport { protected async restoreStructuredAgentSessionTabsOnce(): Promise { - await this.prepareStructuredAgentSessionStartupRestoration() - const host = getStructuredAgentSessionHost() - const persistedVisibleIndex = - typeof host?.getPersistedVisibleSessionTabIndex === 'function' - ? host.getPersistedVisibleSessionTabIndex() - : { present: false, sessionIds: [] } const profileIds = collectSavedStructuredAgentSessionIds( this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null ) - const targets = persistedVisibleIndex.present ? persistedVisibleIndex.sessionIds : profileIds - await host?.restoreReadableSessions(targets) - for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { - allowAttachedWindow: true, - onlyRuntimeOwnedTerminals: true - }) - } - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() - // Every session here is of an agent this host registered: its store holds no other agent's records. - const restored = (host?.listSessionTabs() ?? []).flatMap((session) => { - let sessionId = session.sessionId - while (sessionId.startsWith('agent-session:')) { - sessionId = sessionId.slice('agent-session:'.length) - } - return [{ ...session, agent: session.agent, sessionId }] - }) - await seedStructuredAgentSessionTabIndex( - host, - targets, - restored.map((session) => session.sessionId) - ) - const changes = chatTabChangesMatchingIndex( - this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID)?.unifiedTabs ?? {}, - restored.map((session) => ({ - worktreeId: session.workspaceId, - open: { - sessionId: session.sessionId, - agent: session.agent, - tabId: chatTabId(session.sessionId) + await restoreStructuredAgentSessionTabPublications({ + runtime: this, + getHost: getStructuredAgentSessionHost, + profileIds, + prepare: () => this.prepareStructuredAgentSessionStartupRestorationStage(), + hydrate: () => { + for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true + }) } - })), - // A chat a newer Orca wrote is only unreadable here, so it keeps its tab. - (sessionId) => - !!host && - structuredSessionTabRetired(host, sessionId) && - !host.deps?.store?.isSessionUnreadable?.(sessionId) - ) - for (const [worktreeId, worktreeChanges] of changes) { - this.changeWindowlessChatTabs(worktreeId, worktreeChanges) - } - for (const session of restored) { - this.projectStructuredAgentSessionTab({ ...session, activate: false, notify: false }) - } + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() + }, + reconcile: (restored) => { + const host = getStructuredAgentSessionHost() + const changes = chatTabChangesMatchingIndex( + this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID)?.unifiedTabs ?? {}, + restored.map((session) => ({ + worktreeId: session.workspaceId, + open: { + sessionId: session.sessionId, + agent: session.agent, + tabId: chatTabId(session.sessionId) + } + })), + (sessionId) => + !!host && + structuredSessionTabRetired(host, sessionId) && + !host.deps?.store?.isSessionUnreadable?.(sessionId) + ) + for (const [worktreeId, worktreeChanges] of changes) { + this.changeWindowlessChatTabs(worktreeId, worktreeChanges) + } + }, + project: (session) => + this.projectStructuredAgentSessionTab({ ...session, activate: false, notify: false }) + }) + const host = getStructuredAgentSessionHost() const wasUnverifiable = this.structuredAgentSessionInventoryUnverifiable // No host means no one can say which chats exist; with none on disk, empty is the answer. this.structuredAgentSessionInventoryUnverifiable = @@ -113,7 +100,11 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu notify?: boolean /** The host tab id a create reserved; a session that already has a tab keeps its own. */ tabId?: string + signal?: AbortSignal }): Promise { + if (input.signal?.aborted) { + return + } const host = getStructuredAgentSessionHost() if (typeof host?.setSessionTabVisibility === 'function') { // The restore index is bookkeeping: one that cannot be written (a newer Orca's records, a @@ -128,8 +119,10 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu }) }) } - this.saveWindowlessAgentSessionTab(input.workspaceId, input) - this.projectStructuredAgentSessionTab(input) + if (!input.signal?.aborted) { + this.saveWindowlessAgentSessionTab(input.workspaceId, input) + this.projectStructuredAgentSessionTab(input) + } } /** The runtime's own snapshot of a chat tab. Records nothing: the caller owns the store write. */ diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 059487711750..1172723a0cea 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -67,7 +67,13 @@ describe('structured session cold restoration', () => { const refresh = vi.fn(async () => new Set()) const ensureHost = vi.fn(async () => undefined) const reconcileRestartLeases = vi.fn(async () => undefined) - const restoreReadableSessions = vi.fn(async () => undefined) + let finishHistory!: () => void + const restoreReadableSessions = vi.fn( + () => + new Promise((resolve) => { + finishHistory = resolve + }) + ) const internal = runtime as unknown as { hasPersistedStructuredAgentSessionStore(): boolean refreshMobileSessionPtyRecords(): Promise | null> @@ -76,14 +82,21 @@ describe('structured session cold restoration', () => { internal.hasPersistedStructuredAgentSessionStore = () => true internal.refreshMobileSessionPtyRecords = refresh internal.ensureStructuredAgentSessionHost = ensureHost - setStructuredAgentSessionHost({ reconcileRestartLeases, restoreReadableSessions } as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the preparation and completion paths read only these host methods; index reads are optional. + setStructuredAgentSessionHost({ + reconcileRestartLeases, + restoreReadableSessions, + listSessionTabs: () => [] + } as never) await runtime.prepareStructuredAgentSessionStartupRestoration() expect(ensureHost).toHaveBeenCalledOnce() expect(refresh).toHaveBeenCalledOnce() expect(reconcileRestartLeases).toHaveBeenCalledOnce() - expect(restoreReadableSessions).not.toHaveBeenCalled() + await vi.waitFor(() => expect(restoreReadableSessions).toHaveBeenCalledOnce()) + finishHistory() + await runtime.restoreStructuredAgentSessionTabs() }) it('loads records, inventories PTYs, restores ownership, then projects tabs exactly once', async () => { @@ -258,11 +271,16 @@ describe('structured session cold restoration', () => { internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() internal.refreshMobileSessionPtyRecords = async () => new Set() internal.ensureStructuredAgentSessionHost = async () => undefined + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restoration and listed close read only these host methods; no provider is invoked. setStructuredAgentSessionHost({ reconcileRestartLeases: async () => undefined, restoreReadableSessions: async () => undefined, close: closeStructuredSession, setSessionTabVisibility, + getPersistedVisibleSessionTabIndex: () => ({ + present: true, + sessionIds: ['restored-session'] + }), listSessionTabs: () => [ { sessionId: 'agent-session:agent-session:restored-session', @@ -373,7 +391,12 @@ describe('structured session cold restoration', () => { internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() internal.refreshMobileSessionPtyRecords = async () => new Set() internal.ensureStructuredAgentSessionHost = async () => undefined + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this restoration stub implements every host method the title projection reads. setStructuredAgentSessionHost({ + getPersistedVisibleSessionTabIndex: () => ({ + present: true, + sessionIds: ['restored-claude'] + }), reconcileRestartLeases: async () => undefined, restoreReadableSessions: async () => undefined, listSessionTabs: () => [ diff --git a/src/main/runtime/remote-runtime-close-intent.integration.test.ts b/src/main/runtime/remote-runtime-close-intent.integration.test.ts index 043555621781..4fa9c6a6d3b3 100644 --- a/src/main/runtime/remote-runtime-close-intent.integration.test.ts +++ b/src/main/runtime/remote-runtime-close-intent.integration.test.ts @@ -23,6 +23,7 @@ it( snapshotRepublished: true }) const closeMobileSessionTab = vi.fn() + const resolveStructuredAgentSessionTabCloseSessionId = vi.fn(() => null) const listMobileSessionTabs = vi.fn().mockResolvedValue({ worktree: 'wt-1', publicationEpoch: 'epoch-1', @@ -44,6 +45,7 @@ it( } ] }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the server invokes only these runtime members for this terminal close request. const runtime = { configureNotificationDismissalStore: () => {}, getRuntimeId: () => 'close-intent-runtime-test', @@ -51,6 +53,7 @@ it( cleanupSubscriptionsForConnection: () => {}, cancelMobileDictationForConnection: () => {}, onClientDisconnected: () => {}, + resolveStructuredAgentSessionTabCloseSessionId, listMobileSessionTabs, refuseUnattributedMobileSessionTabClose, closeMobileSessionTab @@ -91,6 +94,7 @@ it( } }) expect(listMobileSessionTabs).toHaveBeenCalledWith('id:wt-1', pairing.pairedDeviceId) + expect(resolveStructuredAgentSessionTabCloseSessionId).toHaveBeenCalledWith('tab-1') expect(refuseUnattributedMobileSessionTabClose).toHaveBeenCalledWith('id:wt-1', 'tab-1') expect(closeMobileSessionTab).not.toHaveBeenCalled() } finally { diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index ba11c2cf4fcc..cc0cd49ac25d 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -91,6 +91,8 @@ export type RpcContext = { signal?: AbortSignal // Why: per-WebSocket key so the server reaps a closing socket's subscriptions without touching sibling sockets sharing the deviceToken. connectionId?: string + /** Trusted local IPC sender; never supplied by request params or remote peers. */ + originatingWindowId?: number // An unsubscribe cannot retire a registration created after its dispatch began. subscriptionRegistrationVersion?: number // Why: shared-control multiplexes many logical streams over one socket; the frame id lets handlers register cleanup per logical stream. diff --git a/src/main/runtime/rpc/dispatcher-stream-options.ts b/src/main/runtime/rpc/dispatcher-stream-options.ts index 7de6511411e5..843021d1c1c6 100644 --- a/src/main/runtime/rpc/dispatcher-stream-options.ts +++ b/src/main/runtime/rpc/dispatcher-stream-options.ts @@ -8,6 +8,7 @@ export type RpcDispatchStreamingOptions = Pick< RpcContext, | 'authenticatedCallerFingerprint' | 'connectionId' + | 'originatingWindowId' | 'signal' | 'clientId' | 'pairedDeviceId' @@ -38,6 +39,7 @@ export function rpcContextFromTransport( signal: options?.signal, requestId: request.id, connectionId: options?.connectionId, + originatingWindowId: options?.originatingWindowId, // Session tabs always need this fence. COMPAT(terminal request-addressed unsubscribe): terminal only for phones without `requestId`. // Capture before middleware yields to a replacement subscribe on the same connection. subscriptionRegistrationVersion: isRegistrationFencedUnsubscribe(request.method) diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index 1030b009579b..249e25b6f6f5 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -81,6 +81,16 @@ describe('mapRuntimeError', () => { } ) + it.each(['agent_session_worktree_mismatch', 'agent_session_close_pending'])( + 'preserves the chat close failure %s, which a client must not read as no such tab', + (code) => { + expect(mapRuntimeError('req_1', { runtimeId: 'runtime-1' }, new Error(code))).toMatchObject({ + ok: false, + error: { code, message: code } + }) + } + ) + it.each(['agent_prompt_blocked', 'agent_prompt_stalled', 'request_aborted'])( 'preserves the agent prompt failure %s', (code) => { diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index a78f186bbc24..1f08d3db6665 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -36,6 +36,10 @@ import { AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE } from '../../../shared/agent-launc import { AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE } from '../../../shared/agent-launch-session-already-exists' import { AGENT_LAUNCH_TAB_CLOSED_CODE } from '../../../shared/agent-launch-tab-closed' import { AGENT_LAUNCH_TARGET_FORBIDDEN_CODE } from '../../../shared/agent-launch-target-forbidden' +import { + AGENT_SESSION_CLOSE_PENDING_CODE, + AGENT_SESSION_WORKTREE_MISMATCH_CODE +} from '../../../shared/session-tab-close' import { parseTerminalSpawnDeferral, TERMINAL_SPAWN_DEFERRED_CODE @@ -86,6 +90,9 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ 'terminal_tab_close_timeout', 'terminal_tab_not_found', 'terminal_tab_pinned', + // Why: a client settles a chat close answered `tab_not_found` as closed; these two are not. + AGENT_SESSION_WORKTREE_MISMATCH_CODE, + AGENT_SESSION_CLOSE_PENDING_CODE, 'agent_prompt_blocked', 'agent_prompt_stalled', 'no_active_terminal', diff --git a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts index 1996789733de..d8e79842d35f 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts @@ -131,6 +131,7 @@ describe('session tab structured capability mutations', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Dispatcher uses only the methods supplied by this test double. const runtime = { getRuntimeId: () => 'test-runtime', + ...chatCloseMethods(snapshot), getClientSettings: vi.fn(() => ({ experimentalNativeChat: true })), listMobileSessionTabs: vi.fn().mockResolvedValue(snapshot), closeMobileSessionTab @@ -181,6 +182,7 @@ function createFixture( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Dispatcher uses only the methods supplied by this test double. const runtime = { getRuntimeId: () => 'test-runtime', + ...chatCloseMethods(snapshot), listMobileSessionTabs: vi.fn().mockResolvedValue(snapshot), getClientSettings: () => ({ // Why: defaults on, so a fixture that says nothing about the setting exercises capability @@ -243,3 +245,14 @@ function agentSnapshot() { tabs: [codexTab, claudeTab] } as unknown as RuntimeMobileSessionTabsResult } + +function chatCloseMethods(snapshot: RuntimeMobileSessionTabsResult) { + return { + resolveStructuredAgentSessionTabCloseSessionId: vi.fn((tabId: string) => { + const tab = snapshot.tabs.find((entry) => entry.id === tabId) + return tab?.type === 'agent-session' ? tab.sessionId : null + }), + ensureStructuredAgentSessionHostForClose: vi.fn().mockResolvedValue(undefined), + listMobileSessionTabsForChatClose: vi.fn().mockResolvedValue(snapshot) + } +} diff --git a/src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts b/src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts index 7a4d474806cf..edae62063506 100644 --- a/src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts @@ -66,8 +66,10 @@ describe('session tab browser placement mutations', () => { }) it('refuses an old-client close for a projected-out browser page', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this browser-close fixture supplies every runtime member the handler invokes. const runtime = { getRuntimeId: () => 'test-runtime', + resolveStructuredAgentSessionTabCloseSessionId: vi.fn(() => null), listMobileSessionTabs: vi.fn().mockResolvedValue(mixedPlacementSnapshot()), closeMobileSessionTab: vi.fn() } as unknown as OrcaRuntimeService @@ -84,8 +86,10 @@ describe('session tab browser placement mutations', () => { }) it('keeps capable mutation callers on the unprojected path', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this browser-close fixture supplies every runtime member the handler invokes. const runtime = { getRuntimeId: () => 'test-runtime', + resolveStructuredAgentSessionTabCloseSessionId: vi.fn(() => null), listMobileSessionTabs: vi.fn().mockResolvedValue(mixedPlacementSnapshot()), closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }) } as unknown as OrcaRuntimeService diff --git a/src/main/runtime/rpc/methods/session-tab-close-admission.ts b/src/main/runtime/rpc/methods/session-tab-close-admission.ts new file mode 100644 index 000000000000..6bb3cbe5140a --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tab-close-admission.ts @@ -0,0 +1,59 @@ +import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { structuredAgentSessionIdFromHostTabId } from '../../structured-agent-session-tab-close' +import type { RpcContext } from '../core' +import { assertProjectedSessionTabVisible } from './session-tab-browser-placement-projection' +import { assertAgentSessionTabDestructiveMutationSupported } from './session-tab-agent-status-projection' +import { projectSessionTabsForClient } from './session-tabs-inventory' +import { + clientRendersStructuredAgent, + supportsStructuredAgentSessions +} from './structured-agent-session-policy' + +/** A chat the host does not list may be closed by id only by a client that would render it. */ +function clientMayCloseUnlistedChat(context: RpcContext, sessionId: string): boolean { + const agent = getStructuredAgentSessionHost()?.sessionAgent(sessionId) + return agent + ? clientRendersStructuredAgent(context.clientCapabilities, agent) + : supportsStructuredAgentSessions(context) +} + +/** + * The remote-client gate of `session.tabs.close`; the local window passes it too. Throws + * `tab_not_found` for a tab this client cannot see. A chat's check reads the stored list without + * the terminal process scan, and a chat the host does not list passes, for the close by session id. + */ +export async function admitRemoteSessionTabClose( + params: { worktree: string; tabId: string }, + context: RpcContext, + receiptSessionId?: string +): Promise { + if (!context.clientKind) { + return + } + const sessionId = receiptSessionId ?? structuredAgentSessionIdFromHostTabId(params.tabId) + if (sessionId !== null && supportsStructuredAgentSessions(context)) { + await context.runtime.ensureStructuredAgentSessionHostForClose() + } + const raw = + sessionId === null + ? await context.runtime.listMobileSessionTabs(params.worktree, context.pairedDeviceId) + : await context.runtime.listMobileSessionTabsForChatClose( + params.worktree, + context.pairedDeviceId + ) + if ( + sessionId !== null && + !raw.tabs.some((tab) => tab.id === params.tabId) && + clientMayCloseUnlistedChat(context, sessionId) + ) { + return + } + const visible = projectSessionTabsForClient(raw, context.clientKind, context.clientCapabilities) + assertProjectedSessionTabVisible(visible, params.tabId) + assertAgentSessionTabDestructiveMutationSupported( + raw, + params.tabId, + context.clientKind, + context.clientCapabilities + ) +} diff --git a/src/main/runtime/rpc/methods/session-tab-close-methods.ts b/src/main/runtime/rpc/methods/session-tab-close-methods.ts index b32b8fbdf489..e93b7f8a2ccb 100644 --- a/src/main/runtime/rpc/methods/session-tab-close-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-close-methods.ts @@ -1,6 +1,7 @@ +import type { z } from 'zod' import { withSpan } from '../../../observability/tracer' import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import { defineMethod } from '../core' +import { defineMethod, type RpcContext } from '../core' import { CloseLifecycleTab, CloseTab @@ -8,83 +9,26 @@ import { import { assertProjectedSessionTabVisible } from './session-tab-browser-placement-projection' import { assertAgentSessionTabDestructiveMutationSupported } from './session-tab-agent-status-projection' import { projectSessionTabsForClient } from './session-tabs-inventory' +import { admitRemoteSessionTabClose } from './session-tab-close-admission' +import type { RuntimeMobileSessionTabCloseResult } from '../../../../shared/runtime-types' +import { + getStructuredAgentSessionClosesInFlight, + type StructuredAgentSessionCloseRequest +} from '../../structured-agent-session-closes-in-flight' export const SESSION_TAB_CLOSE_METHODS = [ defineMethod({ name: 'session.tabs.close', permission: 'workspace', params: CloseTab, - handler: async (params, context) => { - if (context.clientKind) { - const raw = await context.runtime.listMobileSessionTabs( - params.worktree, - context.pairedDeviceId - ) - const visible = projectSessionTabsForClient( - raw, - context.clientKind, - context.clientCapabilities - ) - assertProjectedSessionTabVisible(visible, params.tabId) - assertAgentSessionTabDestructiveMutationSupported( - raw, - params.tabId, - context.clientKind, - context.clientCapabilities - ) - } - const requiresIntent = - context.clientKind === undefined || - (context.clientKind === 'runtime' && - context.clientCapabilities?.includes(SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY) === - true) - return withSpan( - 'runtime.session-tabs.close', - async (span) => { - // Why: old runtime clicks and cleanup are wire-identical, so changing their behavior would regress mixed-version pairings. - if (!params.reason && requiresIntent) { - const result = await context.runtime.refuseUnattributedMobileSessionTabClose( - params.worktree, - params.tabId - ) - span.setAttribute('decision', `refused-${result.refusalReason ?? 'missing-intent'}`) - return result - } - const result = await context.runtime.closeMobileSessionTab( - params.worktree, - params.tabId, - { - reason: 'user', - ...(context.pairedDeviceId ? { clientNavigationId: context.pairedDeviceId } : {}) - } + handler: (params, context) => { + const sessionId = context.runtime.resolveStructuredAgentSessionTabCloseSessionId(params.tabId) + // Why on arrival: a reveal of this chat that lands during any await below must wait for it. + return sessionId === null + ? closeSessionTab(params, context) + : getStructuredAgentSessionClosesInFlight(context.runtime).track(sessionId, (request) => + closeSessionTab(params, context, request) ) - span.setAttribute( - 'decision', - result.refused ? `refused-${result.refusalReason ?? 'unknown'}` : 'allowed' - ) - return result - }, - { - kind: 'client', - attributes: { - attribution: 'session-tab-close', - runtimeId: context.runtime.getRuntimeId(), - origin: context.clientKind ?? 'in-process', - deviceId: context.pairedDeviceId ?? 'in-process', - worktree: params.worktree, - tabId: params.tabId, - closeReason: - params.reason ?? - (requiresIntent - ? 'missing' - : context.clientKind === 'mobile' - ? 'legacy-mobile-user' - : 'legacy-runtime-user'), - connectionGeneration: context.connectionId ?? 'in-process', - requestId: context.requestId ?? 'in-process' - } - } - ) } }), defineMethod({ @@ -149,3 +93,65 @@ export const SESSION_TAB_CLOSE_METHODS = [ } }) ] + +async function closeSessionTab( + params: z.infer, + context: RpcContext, + structuredCloseRequest?: StructuredAgentSessionCloseRequest +): Promise { + await admitRemoteSessionTabClose(params, context, structuredCloseRequest?.sessionId) + const requiresIntent = + context.clientKind === undefined || + (context.clientKind === 'runtime' && + context.clientCapabilities?.includes(SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY) === true) + return withSpan( + 'runtime.session-tabs.close', + async (span) => { + // Why: old runtime clicks and cleanup are wire-identical, so changing their behavior would regress mixed-version pairings. + if (!params.reason && requiresIntent) { + const result = await context.runtime.refuseUnattributedMobileSessionTabClose( + params.worktree, + params.tabId + ) + span.setAttribute('decision', `refused-${result.refusalReason ?? 'missing-intent'}`) + return result + } + const result = await context.runtime.closeMobileSessionTab(params.worktree, params.tabId, { + reason: 'user', + ...(structuredCloseRequest ? { structuredCloseRequest } : {}), + ...(params.reason === 'user' && + context.caller?.kind === 'desktop' && + context.clientKind === 'runtime' && + context.originatingWindowId !== undefined + ? { originatingWindowId: context.originatingWindowId } + : {}), + ...(context.pairedDeviceId ? { clientNavigationId: context.pairedDeviceId } : {}) + }) + span.setAttribute( + 'decision', + result.refused ? `refused-${result.refusalReason ?? 'unknown'}` : 'allowed' + ) + return result + }, + { + kind: 'client', + attributes: { + attribution: 'session-tab-close', + runtimeId: context.runtime.getRuntimeId(), + origin: context.clientKind ?? 'in-process', + deviceId: context.pairedDeviceId ?? 'in-process', + worktree: params.worktree, + tabId: params.tabId, + closeReason: + params.reason ?? + (requiresIntent + ? 'missing' + : context.clientKind === 'mobile' + ? 'legacy-mobile-user' + : 'legacy-runtime-user'), + connectionGeneration: context.connectionId ?? 'in-process', + requestId: context.requestId ?? 'in-process' + } + } + ) +} diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index 0fbab1bb9b0e..39fc130c6787 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -127,8 +127,10 @@ describe('session tab RPC methods', () => { }) it('refuses a reasonless close without invoking destructive runtime logic', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this terminal-close fixture supplies every runtime member the handler invokes. const runtime = { getRuntimeId: () => 'test-runtime', + resolveStructuredAgentSessionTabCloseSessionId: vi.fn(() => null), refuseUnattributedMobileSessionTabClose: vi.fn().mockResolvedValue({ closed: true, refused: true, @@ -151,8 +153,10 @@ describe('session tab RPC methods', () => { }) it('passes explicit user intent to host adjudication', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this terminal-close fixture supplies every runtime member the handler invokes. const runtime = { getRuntimeId: () => 'test-runtime', + resolveStructuredAgentSessionTabCloseSessionId: vi.fn(() => null), refuseUnattributedMobileSessionTabClose: vi.fn(), closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }) } as unknown as OrcaRuntimeService @@ -174,8 +178,10 @@ describe('session tab RPC methods', () => { }) it('preserves explicit user closes from current runtime clients', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this terminal-close fixture supplies every runtime member the handler invokes. const runtime = { getRuntimeId: () => 'test-runtime', + resolveStructuredAgentSessionTabCloseSessionId: vi.fn(() => null), listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()), refuseUnattributedMobileSessionTabClose: vi.fn(), closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }) @@ -202,8 +208,10 @@ describe('session tab RPC methods', () => { }) it('preserves reasonless explicit closes from authenticated legacy mobile clients', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this terminal-close fixture supplies every runtime member the handler invokes. const runtime = { getRuntimeId: () => 'test-runtime', + resolveStructuredAgentSessionTabCloseSessionId: vi.fn(() => null), listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()), refuseUnattributedMobileSessionTabClose: vi.fn(), closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }) @@ -225,8 +233,10 @@ describe('session tab RPC methods', () => { }) it('preserves reasonless closes from authenticated legacy runtime clients', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this terminal-close fixture supplies every runtime member the handler invokes. const runtime = { getRuntimeId: () => 'test-runtime', + resolveStructuredAgentSessionTabCloseSessionId: vi.fn(() => null), listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()), refuseUnattributedMobileSessionTabClose: vi.fn(), closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }) @@ -249,8 +259,10 @@ describe('session tab RPC methods', () => { }) it('refuses reasonless closes from runtime clients that negotiated explicit intent', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this terminal-close fixture supplies every runtime member the handler invokes. const runtime = { getRuntimeId: () => 'test-runtime', + resolveStructuredAgentSessionTabCloseSessionId: vi.fn(() => null), listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()), refuseUnattributedMobileSessionTabClose: vi.fn().mockResolvedValue({ closed: true, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts index 335648ecd87d..396df0643850 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts @@ -18,6 +18,7 @@ import { structuredAgentSessionCreateIntentFingerprint } from './structured-agent-session-create' import { resolveUncommittedStructuredCreate } from './structured-agent-session-precommit-refusal' +import { getStructuredAgentSessionClosesInFlight } from '../../structured-agent-session-closes-in-flight' /** * The attach-shaped entries take the location from the client instead of resolving it from a @@ -64,47 +65,53 @@ export async function handleStructuredAgentSessionCreate( reason: 'requestMalformed' }) } - // Everything up to `attach` is pre-commit, and answers with a refusal rather than a throw so - // a client can tell "nothing was created" from "the outcome is unknown". - const prepared = await resolveUncommittedStructuredCreate(async () => { - if ('worktree' in params) { - const conflict = agentSessionFingerprintConflict( - params.envelope, - structuredAgentSessionCreateIntentFingerprint(params) - ) - if (conflict) { - return { refusal: conflict } + return getStructuredAgentSessionClosesInFlight(ctx.runtime).runPublication( + params.envelope.sessionId, + async (signal) => { + // Everything up to `attach` is pre-commit, and answers with a refusal rather than a throw so + // a client can tell "nothing was created" from "the outcome is unknown". + const prepared = await resolveUncommittedStructuredCreate(async () => { + if ('worktree' in params) { + const conflict = agentSessionFingerprintConflict( + params.envelope, + structuredAgentSessionCreateIntentFingerprint(params) + ) + if (conflict) { + return { refusal: conflict } + } + return prepareStructuredAgentSessionCreateForWorktree({ + runtime: ctx.runtime, + ensureHost: async () => { + await ensureHostInstalled(ctx) + return requireHost(ctx) + }, + envelope: params.envelope, + worktree: params.worktree, + agent: params.agent, + caller: callerFor(ctx), + ...(params.resumeFrom ? { resumeFrom: params.resumeFrom } : {}), + ...(params.tabId ? { tabId: params.tabId } : {}), + ...(params.options ? { options: params.options } : {}), + ...(params.firstMessage ? { firstMessage: params.firstMessage } : {}), + atRest: Boolean( + params.firstMessage || + ctx.clientCapabilities?.includes(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) + ) + }) + } + const { host, attachParams } = await resolveClientSuppliedAttach(params, ctx) + return { host, attachParams, tab: null } + }) + if ('refusal' in prepared) { + return { ok: false, refusal: prepared.refusal } } - return prepareStructuredAgentSessionCreateForWorktree({ + return commitStructuredAgentSessionCreate({ runtime: ctx.runtime, - ensureHost: async () => { - await ensureHostInstalled(ctx) - return requireHost(ctx) - }, - envelope: params.envelope, - worktree: params.worktree, - agent: params.agent, caller: callerFor(ctx), - ...(params.resumeFrom ? { resumeFrom: params.resumeFrom } : {}), - ...(params.tabId ? { tabId: params.tabId } : {}), - ...(params.options ? { options: params.options } : {}), - ...(params.firstMessage ? { firstMessage: params.firstMessage } : {}), - atRest: Boolean( - params.firstMessage || - ctx.clientCapabilities?.includes(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) - ) + prepared, + activate: true, + signal }) } - const { host, attachParams } = await resolveClientSuppliedAttach(params, ctx) - return { host, attachParams, tab: null } - }) - if ('refusal' in prepared) { - return { ok: false, refusal: prepared.refusal } - } - return commitStructuredAgentSessionCreate({ - runtime: ctx.runtime, - caller: callerFor(ctx), - prepared, - activate: true - }) + ) } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.ts index cf708f374916..2538cc2c1e2a 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.ts @@ -27,8 +27,8 @@ it('logs a created chat whose tab could not be published, and answers it unconfi caller: { callerKey: 'client-1' }, prepared: { host, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stubbed attach reads none of it. - attachParams: {} as AgentSessionAttachParams, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the lifecycle coordinator reads this session identity; attach is stubbed. + attachParams: { envelope: { sessionId: 'session-1' } } as AgentSessionAttachParams, tab: { workspaceId: 'workspace-1', agent: 'codex' } }, activate: true diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index 8fd8c2245192..232335a8f47e 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -25,6 +25,14 @@ import { resolveUncommittedStructuredCreate, type StructuredCreateRefused } from './structured-agent-session-precommit-refusal' +import { + getStructuredAgentSessionClosesInFlight, + STRUCTURED_AGENT_SESSION_CLOSE_WAIT_MS +} from '../../structured-agent-session-closes-in-flight' +import { + AGENT_SESSION_NOT_ATTACHED, + refuseAgentSessionMutation +} from '../../../native-chat/agent-session-wire/structured-agent-session-mutation-refusals' export type PreparedStructuredAgentSessionCreate = { host: StructuredAgentSessionHost @@ -157,19 +165,46 @@ export async function commitStructuredAgentSessionCreate(args: { caller: StructuredAgentSessionCaller prepared: PreparedStructuredAgentSessionCreate activate: boolean + signal?: AbortSignal }): Promise> { const { prepared } = args + const closes = getStructuredAgentSessionClosesInFlight(args.runtime) + if (!args.signal) { + return closes.runPublication(prepared.attachParams.envelope.sessionId, (signal) => + commitStructuredAgentSessionCreate({ ...args, signal }) + ) + } + const signal = args.signal + if ( + signal.aborted || + !(await closes.settled( + prepared.attachParams.envelope.sessionId, + STRUCTURED_AGENT_SESSION_CLOSE_WAIT_MS + )) || + signal.aborted + ) { + return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) + } const tab = prepared.tab + const publishTab = async ( + input: Parameters[0] + ) => { + if (signal.aborted) { + return + } + await args.runtime.publishStructuredAgentSessionTab({ ...input, signal }) + } if (prepared.atRest) { return prepared.host.create(args.caller, prepared.attachParams, { ...prepared.atRest, + signal, ...(prepared.hostLaunchDirectory ? { hostLaunchDirectory: prepared.hostLaunchDirectory } : {}), ...(tab ? { publishTab: () => - args.runtime.publishStructuredAgentSessionTab({ + publishTab({ workspaceId: tab.workspaceId, sessionId: prepared.attachParams.envelope.sessionId, agent: tab.agent, @@ -182,17 +217,16 @@ export async function commitStructuredAgentSessionCreate(args: { : {}) }) } - const result = prepared.hostLaunchDirectory - ? await prepared.host.attach(args.caller, prepared.attachParams, { - hostLaunchDirectory: prepared.hostLaunchDirectory - }) - : await prepared.host.attach(args.caller, prepared.attachParams) + const result = await prepared.host.attach(args.caller, prepared.attachParams, { + signal, + ...(prepared.hostLaunchDirectory ? { hostLaunchDirectory: prepared.hostLaunchDirectory } : {}) + }) if (!result.ok || !prepared.tab) { return result } const surfaceTabId = prepared.attachParams.surfaceTabId try { - await args.runtime.publishStructuredAgentSessionTab({ + await publishTab({ workspaceId: prepared.tab.workspaceId, sessionId: result.value.sessionId, agent: prepared.tab.agent, @@ -230,19 +264,25 @@ export async function createStructuredAgentSessionForWorktree(args: { options?: Readonly> tabId?: string }): Promise> { - const prepared: PreparedStructuredAgentSessionCreate | StructuredCreateRefused = - await resolveUncommittedStructuredCreate(() => - prepareStructuredAgentSessionCreateForWorktree(args) - ) - if ('refusal' in prepared) { - return { ok: false, refusal: prepared.refusal } - } - return commitStructuredAgentSessionCreate({ - runtime: args.runtime, - caller: args.caller, - prepared, - activate: args.activate - }) + return getStructuredAgentSessionClosesInFlight(args.runtime).runPublication( + args.envelope.sessionId, + async (signal) => { + const prepared: PreparedStructuredAgentSessionCreate | StructuredCreateRefused = + await resolveUncommittedStructuredCreate(() => + prepareStructuredAgentSessionCreateForWorktree(args) + ) + if ('refusal' in prepared) { + return { ok: false, refusal: prepared.refusal } + } + return commitStructuredAgentSessionCreate({ + runtime: args.runtime, + caller: args.caller, + prepared, + activate: args.activate, + signal + }) + } + ) } function seededPermissionMode( diff --git a/src/main/runtime/rpc/methods/structured-agent-session-permission-create.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-permission-create.test.ts index 46a9b75fcd75..460ff3c0a040 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-permission-create.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-permission-create.test.ts @@ -35,7 +35,8 @@ describe('legacy attach-shaped permission creation', () => { expect.objectContaining({ options: { permissionMode: 'full-access' }, envelope: params.envelope - }) + }), + ...(method === 'agentSession.create' ? [{ signal: expect.any(AbortSignal) }] : []) ) } ) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts index b82f0e3c3a01..5a9b6fdf5f84 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts @@ -24,6 +24,15 @@ import { requireStructuredHost } from './structured-agent-session-gate' import { OptionsParams } from './structured-agent-session-schemas' +import { + getStructuredAgentSessionClosesInFlight, + STRUCTURED_AGENT_SESSION_CLOSE_WAIT_MS +} from '../../structured-agent-session-closes-in-flight' +import { AGENT_SESSION_CLOSE_PENDING_CODE } from '../../../../shared/session-tab-close' + +/** Under the window's own 5 s reveal timeout, so the host never publishes after the window gave up; + * a close held past it (a pinned-tab confirm can take minutes) fails the reveal rather than racing. */ +export const STRUCTURED_AGENT_SESSION_REVEAL_CLOSE_WAIT_MS = STRUCTURED_AGENT_SESSION_CLOSE_WAIT_MS export const STRUCTURED_AGENT_SESSION_REVEAL_METHODS = [ defineMethod({ @@ -32,37 +41,58 @@ export const STRUCTURED_AGENT_SESSION_REVEAL_METHODS = [ params: OptionsParams, handler: async (params, ctx) => { requireStructuredCapability(ctx) - await ensureStructuredHostInstalled(ctx) - let revealed: StructuredAgentSessionReveal - try { - const host = requireStructuredHost(ctx) - requireStructuredAgentAudience(ctx, host.sessionAgent(params.sessionId) ?? '') - revealed = await host.revealSession(params.sessionId) - } catch (error) { - // The host raises its refusal as the code itself; anything else is a genuine fault and - // must not be laundered into a tidy "no such chat". - const code = error instanceof Error ? error.message : '' - if (!isAgentSessionWireRefusalCode(code)) { - throw error + // Why first: a reveal published before an earlier close of the same chat ends is retired by it. + // Waits for every close of the chat in flight while it waits, including ones that begin later. + const closes = getStructuredAgentSessionClosesInFlight(ctx.runtime) + return closes.runPublication(params.sessionId, async (signal) => { + if ( + !(await closes.settled(params.sessionId, STRUCTURED_AGENT_SESSION_REVEAL_CLOSE_WAIT_MS)) + ) { + // Thrown, not refused: a client reads a refusal as the chat being gone or the host too old. + throw new Error(AGENT_SESSION_CLOSE_PENDING_CODE) + } + await ensureStructuredHostInstalled(ctx) + if (signal.aborted) { + throw new Error(AGENT_SESSION_CLOSE_PENDING_CODE) } - return refuseAgentSessionMutation( - agentSessionRefusalFromReference( - isAgentSessionRefusalError(error) ? error.refusal : { code }, - code === 'structured_agent_session_unsupported' - ? 'This host cannot open that chat.' - : 'This chat is no longer on this host.' + let revealed: StructuredAgentSessionReveal + try { + const host = requireStructuredHost(ctx) + requireStructuredAgentAudience(ctx, host.sessionAgent(params.sessionId) ?? '') + revealed = await host.revealSession(params.sessionId) + } catch (error) { + // The host raises its refusal as the code itself; anything else is a genuine fault and + // must not be laundered into a tidy "no such chat". + const code = error instanceof Error ? error.message : '' + if (!isAgentSessionWireRefusalCode(code)) { + throw error + } + return refuseAgentSessionMutation( + agentSessionRefusalFromReference( + isAgentSessionRefusalError(error) ? error.refusal : { code }, + code === 'structured_agent_session_unsupported' + ? 'This host cannot open that chat.' + : 'This chat is no longer on this host.' + ) ) - ) - } - await ctx.runtime.publishStructuredAgentSessionTab({ - workspaceId: revealed.workspaceId, - sessionId: revealed.sessionId, - agent: revealed.agent, - activate: true + } + if (signal.aborted) { + throw new Error(AGENT_SESSION_CLOSE_PENDING_CODE) + } + await ctx.runtime.publishStructuredAgentSessionTab({ + workspaceId: revealed.workspaceId, + sessionId: revealed.sessionId, + agent: revealed.agent, + activate: true, + signal + }) + if (signal.aborted) { + throw new Error(AGENT_SESSION_CLOSE_PENDING_CODE) + } + // Named fields only: the host's reasons a journal did not open stay on the host. + const { sessionId, workspaceId, agent, readable } = revealed + return { ok: true as const, sessionId, workspaceId, agent, readable } }) - // Named fields only: the host's reasons a journal did not open stay on the host. - const { sessionId, workspaceId, agent, readable } = revealed - return { ok: true as const, sessionId, workspaceId, agent, readable } } }) ] 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 dfda59b17b3f..7488f00aa6c4 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 @@ -70,9 +70,7 @@ export const hostCalls: Record> = {} export const runtimeCalls: Record> = {} function reset(record: Record>): void { - for (const key of Object.keys(record)) { - delete record[key] - } + Object.keys(record).forEach((key) => delete record[key]) } export const STATUS_SESSION = 'session-status' @@ -253,6 +251,7 @@ export function dispatcher( registerSubscriptionCleanup: vi.fn(), cleanupSubscription: vi.fn(), cleanupSubscriptionsByPrefix: vi.fn(), + retireStructuredAgentSessionTabFromSnapshot: vi.fn(() => false), ...runtimeCalls, ...runtimeOverrides } 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 f67dd8ed74fb..24635d8a3810 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -77,7 +77,8 @@ describe('agentSession.reveal', () => { workspaceId: 'workspace-from-record', sessionId: SESSION, agent: 'claude', - activate: true + activate: true, + signal: expect.any(AbortSignal) }) }) @@ -141,13 +142,13 @@ describe('capability gating', () => { ) }) - it('does not stop the provider when durable tab retirement fails', async () => { + it('still stops the provider when durable tab retirement fails', async () => { hostCalls.setSessionTabVisibility.mockRejectedValueOnce(new Error('visibility write failed')) const response = await call('agentSession.close', { sessionId: SESSION }, STRUCTURED_CLIENT) - expect(response).toMatchObject({ ok: false }) - expect(hostCalls.close).not.toHaveBeenCalled() + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + expect(hostCalls.close).toHaveBeenCalledWith(SESSION, 'user-close') }) it('advertises the capability without bumping the protocol version', () => { @@ -336,7 +337,7 @@ describe('capability gating', () => { } ) - it.each(CLEANUP_METHODS)( + it.each(CLEANUP_METHODS.filter(({ method }) => method !== 'agentSession.close'))( 'does not install a host for cleanup-only method $method', async ({ method, params }) => { const ensureHost = vi.fn() @@ -392,7 +393,8 @@ describe('method routing', () => { expect.objectContaining({ accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' }, options: { model: 'gpt-5.6-sol', effort: 'medium' } - }) + }), + expect.objectContaining({ signal: expect.any(AbortSignal) }) ) expect(hostCalls.attach.mock.calls[0]?.[1]).not.toHaveProperty('providerHandle') expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledWith( @@ -422,7 +424,8 @@ describe('method routing', () => { // conversation this attaches to. expect(hostCalls.attach).toHaveBeenCalledWith( expect.anything(), - expect.objectContaining({ surfaceTabId: 'chat-tab-1' }) + expect.objectContaining({ surfaceTabId: 'chat-tab-1' }), + expect.objectContaining({ signal: expect.any(AbortSignal) }) ) }) @@ -462,7 +465,7 @@ describe('method routing', () => { expect(hostCalls.attach).toHaveBeenCalledWith( expect.anything(), expect.not.objectContaining({ hostLaunchDirectory: expect.anything() }), - { hostLaunchDirectory: '/host/floating-folder' } + expect.objectContaining({ hostLaunchDirectory: '/host/floating-folder' }) ) }) @@ -578,7 +581,8 @@ describe('method routing', () => { expect.anything(), expect.objectContaining({ accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/host/.claude' } - }) + }), + expect.objectContaining({ signal: expect.any(AbortSignal) }) ) expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledWith( expect.objectContaining({ @@ -646,7 +650,10 @@ describe('method routing', () => { expect(await call(method, params, STRUCTURED_CLIENT)).toMatchObject({ ok: true }) expect(hostCalls.attach).toHaveBeenCalledWith( expect.anything(), - expect.objectContaining({ location: params.location }) + expect.objectContaining({ location: params.location }), + ...(method === 'agentSession.create' + ? [expect.objectContaining({ signal: expect.any(AbortSignal) })] + : []) ) expect(hostCalls.attach.mock.calls[0]?.[1]).not.toHaveProperty('launchDirectory') } diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index a25d40b52b28..c58c2797f7c6 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -58,6 +58,7 @@ import { UnsubscribeParams } from './structured-agent-session-schemas' import { sendStructuredAgentSessionForClient } from './structured-agent-session-send-compatibility' +import { closeStructuredAgentSessionOnHost } from '../../structured-agent-session-tab-close' export const STRUCTURED_AGENT_SESSION_METHODS = [ defineMethod({ @@ -131,13 +132,10 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ permission: 'workspace', params: OptionsParams, handler: async (params, ctx) => { - const host = requireStructuredCleanupHost(ctx) - // Terminal-disposal closes use this RPC without the session-tabs retirement RPC. - if (typeof host.setSessionTabVisibility === 'function') { - await host.setSessionTabVisibility(params.sessionId, false) - } - // Clients call this only when the user closes this chat's tab or cancels its launch. - await host.close(params.sessionId, 'user-close') + requireStructuredCapability(ctx) + // Clients call this only when the user closes this chat's tab or cancels its launch; the same + // host close as `session.tabs.close`, for older clients and launch cancellations. + await closeStructuredAgentSessionOnHost(ctx.runtime, params.sessionId, 'user-close') return { ok: true as const } } }), diff --git a/src/main/runtime/rpc/methods/structured-chat-close-by-session-id.test.ts b/src/main/runtime/rpc/methods/structured-chat-close-by-session-id.test.ts new file mode 100644 index 000000000000..497f5a815938 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-chat-close-by-session-id.test.ts @@ -0,0 +1,228 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { + RUNTIME_CAPABILITIES, + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { SESSION_TABS_CLOSE_AGENT_SESSION_BY_ID_RUNTIME_CAPABILITY } from '../../../../shared/session-tabs-close-capabilities' +import { SESSION_TAB_CLOSE_CANCELED_ERROR } from '../../../../shared/session-tab-close' +import { HOST_TEST_SESSION } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { STRUCTURED_AGENT_SESSION_REVEAL_CLOSE_WAIT_MS } from './structured-agent-session-reveal' +import { + WORKTREE, + call, + closeSession, + send, + createChat, + host, + runtime, + snapshot, + store +} from './structured-chat-tab-table.test-fixture' + +const CHAT_TAB = `agent-session:${HOST_TEST_SESSION}` +// The window and a paired desktop both close as a runtime client that reads chats. +const WINDOW = { + clientKind: 'runtime' as const, + clientCapabilities: [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY + ] +} + +function closeTab(worktree = WORKTREE, tabId = CHAT_TAB, client: typeof WINDOW = WINDOW) { + return call('session.tabs.close', { worktree, tabId, reason: 'user' }, client) +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +function relayWindowClose(relay: () => Promise = async () => {}) { + const closeSessionTab = vi.fn(relay) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the chat close path uses only this relay. + runtime.setNotifier({ closeSessionTab } as never) + return closeSessionTab +} + +async function settleTicks(count = 20): Promise { + for (let tick = 0; tick < count; tick++) { + await new Promise((resolve) => setImmediate(resolve)) + } +} + +async function listedChatIds(): Promise { + return (await snapshot()).tabs.flatMap((tab) => (tab.type === 'agent-session' ? [tab.id] : [])) +} + +afterEach(() => { + vi.useRealTimers() +}) + +it('stops the chat and drops its tab when the restore index cannot be written', async () => { + await createChat(HOST_TEST_SESSION) + expect(await send(HOST_TEST_SESSION, 'keep working')).toMatchObject({ ok: true }) + vi.spyOn(store, 'setSessionTabVisibility').mockRejectedValue(new Error('disk I/O error')) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const close = vi.spyOn(host, 'close') + + expect(await closeTab()).toMatchObject({ ok: true, result: { closed: true } }) + + expect(close).toHaveBeenCalledWith(HOST_TEST_SESSION, 'user-close') + expect(closeSession).toHaveBeenCalledOnce() + expect(await listedChatIds()).toEqual([]) +}) + +it('closes it by session id: hidden in the restore index and stopped, with no window relay', async () => { + await createChat(HOST_TEST_SESSION) + // As before restore publishes it: the record exists, the list does not carry the tab. + runtime.retireStructuredAgentSessionTabFromSnapshot(HOST_TEST_SESSION) + setStructuredAgentSessionHost(null) + Object.assign(runtime, { hasPersistedStructuredAgentSessionStore: () => true }) + const install = vi + .spyOn(runtime, 'ensureStructuredAgentSessionHost') + .mockImplementation(async () => { + setStructuredAgentSessionHost(host) + }) + const relay = relayWindowClose() + const close = vi.spyOn(host, 'close') + + expect(await closeTab()).toMatchObject({ ok: true, result: { closed: true } }) + + expect(close).toHaveBeenCalledWith(HOST_TEST_SESSION, 'user-close') + expect(store.listVisibleSessionIds()).toEqual([]) + expect(relay).not.toHaveBeenCalled() + expect(install).toHaveBeenCalledOnce() +}) + +it('answers a worktree mismatch, not tab_not_found, for a chat of another worktree', async () => { + await createChat(HOST_TEST_SESSION) + runtime.retireStructuredAgentSessionTabFromSnapshot(HOST_TEST_SESSION) + const close = vi.spyOn(host, 'close') + + expect(await closeTab('id:another-worktree')).toMatchObject({ + ok: false, + error: { + code: 'agent_session_worktree_mismatch', + message: 'agent_session_worktree_mismatch' + } + }) + + expect(close).not.toHaveBeenCalled() + expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION]) +}) + +it.each([ + ['a client without chats', [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY], 'codex'], + ['a client that cannot render the agent', WINDOW.clientCapabilities, 'claude'] +] as const)('answers tab_not_found to %s, stopping nothing', async (_client, caps, agent) => { + await createChat(HOST_TEST_SESSION) + runtime.retireStructuredAgentSessionTabFromSnapshot(HOST_TEST_SESSION) + vi.spyOn(host, 'sessionAgent').mockReturnValue(agent) + const close = vi.spyOn(host, 'close') + + expect( + await closeTab(WORKTREE, CHAT_TAB, { clientKind: 'runtime', clientCapabilities: [...caps] }) + ).toMatchObject({ ok: false, error: { message: 'tab_not_found' } }) + + expect(close).not.toHaveBeenCalled() + expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION]) +}) + +it('neither hides nor stops it when the window keeps the tab open', async () => { + await createChat(HOST_TEST_SESSION) + // As a pinned tab's declined confirm answers. + relayWindowClose(async () => { + throw new Error(SESSION_TAB_CLOSE_CANCELED_ERROR) + }) + const close = vi.spyOn(host, 'close') + + expect(await closeTab()).toMatchObject({ + ok: false, + error: { message: SESSION_TAB_CLOSE_CANCELED_ERROR } + }) + + expect(close).not.toHaveBeenCalled() + expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION]) + expect(await listedChatIds()).toEqual([CHAT_TAB]) +}) + +it('stays idempotent: both answer, the chat is stopped, hidden and unlisted', async () => { + await createChat(HOST_TEST_SESSION) + const close = vi.spyOn(host, 'close') + + const [byId, byTab] = await Promise.all([ + call('agentSession.close', { sessionId: HOST_TEST_SESSION }, WINDOW), + closeTab() + ]) + + expect(byId).toMatchObject({ ok: true }) + expect(byTab).toMatchObject({ ok: true, result: { closed: true } }) + expect(close).toHaveBeenCalledWith(HOST_TEST_SESSION, 'user-close') + expect(store.listVisibleSessionIds()).toEqual([]) + expect(await listedChatIds()).toEqual([]) +}) + +it('waits for the close, then shows the chat', async () => { + await createChat(HOST_TEST_SESSION) + const windowAnswers = deferred() + relayWindowClose(() => windowAnswers.promise) + + // Same tick: the close is marked in flight on arrival, before any of its awaits. + const closed = closeTab() + let revealAnswered = false + const revealed = call('agentSession.reveal', { sessionId: HOST_TEST_SESSION }).then( + (response) => { + revealAnswered = true + return response + } + ) + await settleTicks() + expect(revealAnswered).toBe(false) + windowAnswers.resolve() + + expect(await closed).toMatchObject({ ok: true, result: { closed: true } }) + expect(await revealed).toMatchObject({ ok: true, result: { ok: true } }) + expect(await listedChatIds()).toEqual([CHAT_TAB]) + expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION]) +}) + +it('returns retry within three seconds when the provider close never settles', async () => { + expect(STRUCTURED_AGENT_SESSION_REVEAL_CLOSE_WAIT_MS).toBe(3_000) + await createChat(HOST_TEST_SESSION) + const stopping = deferred() + const release = deferred() + closeSession.mockImplementationOnce(async () => { + stopping.resolve() + await release.promise + return true + }) + const closed = closeTab() + await stopping.promise + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const revealed = call('agentSession.reveal', { sessionId: HOST_TEST_SESSION }) + await vi.advanceTimersByTimeAsync(STRUCTURED_AGENT_SESSION_REVEAL_CLOSE_WAIT_MS) + expect(await revealed).toMatchObject({ + ok: false, + error: { code: 'agent_session_close_pending' } + }) + expect(store.listVisibleSessionIds()).toEqual([]) + } finally { + vi.useRealTimers() + release.resolve() + await closed + } +}) + +it('advertises closing a chat by session id', () => { + expect(RUNTIME_CAPABILITIES).toContain(SESSION_TABS_CLOSE_AGENT_SESSION_BY_ID_RUNTIME_CAPABILITY) + expect(SESSION_TABS_CLOSE_AGENT_SESSION_BY_ID_RUNTIME_CAPABILITY).toBe( + 'session-tabs.close-agent-session-by-id.v1' + ) +}) diff --git a/src/main/runtime/rpc/methods/structured-chat-close-custom-identity.test.ts b/src/main/runtime/rpc/methods/structured-chat-close-custom-identity.test.ts new file mode 100644 index 000000000000..6d5d86ae0ca7 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-chat-close-custom-identity.test.ts @@ -0,0 +1,73 @@ +import { expect, it, vi } from 'vitest' +import { HOST_TEST_SESSION } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { + WORKTREE, + call, + createChat, + runtime, + snapshot, + store +} from './structured-chat-tab-table.test-fixture' + +function deferred() { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} +async function graphTab(tabId: string, sessionId = HOST_TEST_SESSION) { + const saved = await snapshot() + const source = saved.tabs.find( + (tab) => tab.type === 'agent-session' && tab.sessionId === sessionId + ) + if (!source) { + throw new Error('chat missing') + } + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [{ ...saved, tabs: [{ ...source, id: tabId }] }] + }) +} +function close(tabId: string) { + return call('session.tabs.close', { worktree: WORKTREE, tabId, reason: 'user' }) +} +function holdCensus() { + const reached = deferred() + const release = deferred() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the existing census await is controlled to establish close ordering. + const internal = runtime as unknown as { + refreshMobileSessionPtyRecords(): Promise | null> + } + vi.spyOn(internal, 'refreshMobileSessionPtyRecords') + .mockImplementationOnce(async () => { + reached.resolve() + await release.promise + return new Set() + }) + .mockResolvedValue(new Set()) + return { reached, release } +} +it.each([false, true])( + 'an earlier custom-listed close cannot erase a later explicit reveal, graph-only=%s', + async (graphOnly) => { + await createChat(HOST_TEST_SESSION, graphOnly ? undefined : 'custom-chat-tab') + await graphTab('custom-chat-tab') + const { reached, release } = holdCensus() + const closing = close('custom-chat-tab') + await reached.promise + const revealing = call('agentSession.reveal', { sessionId: HOST_TEST_SESSION }) + try { + await Promise.race([revealing, new Promise((resolve) => setTimeout(resolve, 100))]) + } finally { + release.resolve() + } + expect(await closing).toMatchObject({ ok: true, result: { closed: true } }) + expect(await revealing).toMatchObject({ ok: true, result: { ok: true } }) + expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION]) + expect((await snapshot()).tabs.map((tab) => tab.id)).toEqual([ + `agent-session:${HOST_TEST_SESSION}` + ]) + } +) diff --git a/src/main/runtime/rpc/methods/structured-chat-close-ordering.test.ts b/src/main/runtime/rpc/methods/structured-chat-close-ordering.test.ts new file mode 100644 index 000000000000..0de22b00325f --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-chat-close-ordering.test.ts @@ -0,0 +1,135 @@ +import { expect, it, vi } from 'vitest' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + HOST_TEST_SESSION, + hostTestAttachParams, + hostTestOperationId +} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { + createStructuredAgentSessionForWorktree, + structuredAgentSessionCreateIntentFingerprint +} from './structured-agent-session-create' +import { + WORKTREE, + acquisitions, + call, + caller, + createChat, + host, + runtime, + snapshot, + store +} from './structured-chat-tab-table.test-fixture' + +function deferred() { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +function createParams() { + const params = { + worktree: WORKTREE, + agent: 'codex' as const, + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: '' + } + } + params.envelope.payloadFingerprint = structuredAgentSessionCreateIntentFingerprint(params) + return params +} + +async function closeChat() { + return call('session.tabs.close', { + worktree: WORKTREE, + tabId: `agent-session:${HOST_TEST_SESSION}`, + reason: 'user' + }) +} + +it.each(['acquired', 'at-rest', 'in-process'] as const)( + 'cancels an earlier %s create still preparing, without poisoning the next create', + async (mode) => { + const reached = deferred() + const release = deferred() + const intent = hostTestAttachParams(null) + vi.spyOn(runtime, 'resolveStructuredAgentSessionCreateIntent').mockImplementation(async () => { + reached.resolve() + await release.promise + return intent + }) + const params = createParams() + const creating = + mode === 'in-process' + ? createStructuredAgentSessionForWorktree({ + ...params, + runtime, + ensureHost: async () => host, + caller, + activate: true + }).then((result) => result.ok) + : call('agentSession.create', params, { + clientKind: 'runtime', + clientCapabilities: [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + ...(mode === 'at-rest' ? [AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY] : []) + ] + }).then((result) => result.result?.ok) + await reached.promise + try { + expect(await closeChat()).toMatchObject({ ok: true, result: { closed: true } }) + expect(acquisitions).toBe(0) + } finally { + release.resolve() + } + expect(await creating).toBe(false) + expect(acquisitions).toBe(0) + expect(store.getRecord(HOST_TEST_SESSION)).toBeNull() + expect(store.listVisibleSessionIds()).toEqual([]) + + expect(await call('agentSession.create', createParams())).toMatchObject({ + ok: true, + result: { ok: true } + }) + expect(acquisitions).toBe(1) + expect((await snapshot()).tabs.map((tab) => tab.id)).toEqual([ + `agent-session:${HOST_TEST_SESSION}` + ]) + } +) + +it('does not publish an earlier reveal after a later close has finished', async () => { + await createChat(HOST_TEST_SESSION) + const reached = deferred() + const release = deferred() + const reveal = host.revealSession.bind(host) + vi.spyOn(host, 'revealSession').mockImplementation(async (sessionId) => { + const result = await reveal(sessionId) + reached.resolve() + await release.promise + return result + }) + const revealing = call('agentSession.reveal', { sessionId: HOST_TEST_SESSION }) + await reached.promise + try { + expect(await closeChat()).toMatchObject({ ok: true, result: { closed: true } }) + } finally { + release.resolve() + } + expect(await revealing).toMatchObject({ + ok: false, + error: { code: 'agent_session_close_pending' } + }) + expect(store.listVisibleSessionIds()).toEqual([]) + expect((await snapshot()).tabs).toEqual([]) + expect(await call('agentSession.reveal', { sessionId: HOST_TEST_SESSION })).toMatchObject({ + ok: true, + result: { ok: true } + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-chat-startup-entry.test.ts b/src/main/runtime/rpc/methods/structured-chat-startup-entry.test.ts new file mode 100644 index 000000000000..70bc21633ff9 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-chat-startup-entry.test.ts @@ -0,0 +1,187 @@ +import { beforeAll, expect, it, vi } from 'vitest' +import { registerMainProcessIpcHandlers } from '../../../startup/main-process-ipc-bootstrap' +import { + HOST_TEST_LOCATION, + HOST_TEST_SESSION +} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { editPersistedTestAgentSessionStore } from '../../agent-session-record-store-test-harness' +import { + WORKTREE, + call, + createChat, + directory, + host, + openHost, + runtime, + snapshot, + store +} from './structured-chat-tab-table.test-fixture' + +const { handlers } = vi.hoisted(() => ({ handlers: new Map Promise>() })) +vi.mock('electron', () => ({ + ipcMain: { + handle: (name: string, handler: () => Promise) => handlers.set(name, handler) + } +})) +vi.mock('../../../startup/main-process-state', () => ({ + mainProcessState: { + get runtime() { + return runtime + }, + firstWindowStartupServicesReady: Promise.resolve(), + managedWslCliStartupBarrierReady: Promise.resolve() + } +})) +vi.mock('../../../startup/legacy-worker-renderer-recovery', () => ({ + recoverLegacyWorkerTerminalsForRendererStartup: vi.fn() +})) +vi.mock('../../../startup/startup-diagnostics', () => ({ logStartupMilestone: vi.fn() })) +vi.mock('../../../startup/os-opened-documents', () => ({ resolveOsOpenedDocuments: vi.fn() })) +vi.mock('../../saved-structured-agent-session-restoration', () => ({ + collectSavedStructuredAgentSessionIds: () => ['session-alpha', 'second-startup-entry-chat'] +})) +beforeAll(registerMainProcessIpcHandlers) + +function deferred() { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} +function prepareViaIpc() { + const handler = handlers.get('app:prepareTerminalStartupRestoration') + if (!handler) { + throw new Error('startup handler missing') + } + return handler() +} +function controlPreparation() { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: controls only the existing persistence predicate and terminal census. + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + refreshMobileSessionPtyRecords(): Promise | null> + } + vi.spyOn(internal, 'hasPersistedStructuredAgentSessionStore').mockReturnValue(true) + vi.spyOn(runtime, 'ensureStructuredAgentSessionHost').mockResolvedValue() + return vi.spyOn(internal, 'refreshMobileSessionPtyRecords').mockResolvedValue(null) +} +async function saveChats(legacy = false) { + for (const sessionId of legacy + ? [HOST_TEST_SESSION, 'second-startup-entry-chat'] + : [HOST_TEST_SESSION]) { + expect((await createChat(sessionId)).ok).toBe(true) + await host.close(sessionId, 'user-close') + runtime.retireStructuredAgentSessionTabFromSnapshot(sessionId) + } + if (legacy) { + await editPersistedTestAgentSessionStore(directory, (persisted) => { + delete persisted.sessionTabs + }) + await openHost() + } +} + +it.each([ + { legacy: false, entry: 'IPC' }, + { legacy: true, entry: 'IPC' }, + { legacy: false, entry: 'public' }, + { legacy: true, entry: 'public' } +])( + 'the actual startup $entry owns restoration before close, legacy=$legacy', + async ({ legacy, entry }) => { + await saveChats(legacy) + const reached = deferred() + const release = deferred() + controlPreparation().mockImplementationOnce(async () => { + reached.resolve() + await release.promise + return null + }) + const preparing = + entry === 'IPC' ? prepareViaIpc() : runtime.prepareStructuredAgentSessionStartupRestoration() + await reached.promise + if (!legacy) { + vi.spyOn(store, 'setSessionTabVisibility').mockRejectedValue(new Error('disk failure')) + } + try { + expect( + await call('session.tabs.close', { + worktree: WORKTREE, + tabId: `agent-session:${HOST_TEST_SESSION}`, + reason: 'user' + }) + ).toMatchObject({ ok: true, result: { closed: true } }) + } finally { + release.resolve() + await preparing + } + const first = runtime.restoreStructuredAgentSessionTabs() + expect(runtime.restoreStructuredAgentSessionTabs()).toBe(first) + await first + expect((await snapshot()).tabs.map((tab) => tab.id)).toEqual( + legacy ? ['agent-session:second-startup-entry-chat'] : [] + ) + if (legacy) { + expect(store.listVisibleSessionIds()).toEqual(['second-startup-entry-chat']) + } + } +) + +it('a close cancels the older windowless restore without a saved or live ghost, then reopens once', async () => { + await saveChats(true) + let layout: WorkspaceSessionState = { + activeRepoId: null, + activeWorktreeId: HOST_TEST_LOCATION.workspaceId, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {} + } + Object.assign(runtime, { + getWorkspaceSessionForWorktree: () => layout, + getWorkspaceSessionHostIdForWorktree: () => 'local', + setWorkspaceSessionForWorktree: (_id: string, next: WorkspaceSessionState) => { + layout = next + } + }) + const savedIds = () => + (layout.unifiedTabs?.[HOST_TEST_LOCATION.workspaceId] ?? []).map((tab) => tab.entityId) + controlPreparation() + const reached = deferred() + const release = deferred() + const history = host.restoreReadableSessions.bind(host) + vi.spyOn(host, 'restoreReadableSessions').mockImplementationOnce(async (ids) => { + reached.resolve() + await release.promise + await history(ids) + }) + const restoring = runtime.restoreStructuredAgentSessionTabs() + await reached.promise + vi.spyOn(store, 'setSessionTabVisibility').mockRejectedValueOnce(new Error('disk failure')) + try { + expect( + await call('session.tabs.close', { + worktree: WORKTREE, + tabId: `agent-session:${HOST_TEST_SESSION}`, + reason: 'user' + }) + ).toMatchObject({ ok: true, result: { closed: true } }) + } finally { + release.resolve() + } + await restoring + expect(savedIds()).toEqual(['second-startup-entry-chat']) + expect((await snapshot()).tabs.map((tab) => tab.id)).toEqual([ + 'agent-session:second-startup-entry-chat' + ]) + expect(await call('agentSession.reveal', { sessionId: HOST_TEST_SESSION })).toMatchObject({ + ok: true, + result: { ok: true } + }) + expect(savedIds()).toEqual(['second-startup-entry-chat', HOST_TEST_SESSION]) + expect((await snapshot()).tabs.map((tab) => tab.id)).toEqual([ + 'agent-session:second-startup-entry-chat', + `agent-session:${HOST_TEST_SESSION}` + ]) +}) diff --git a/src/main/runtime/rpc/runtime-close-attribution-topology.test.ts b/src/main/runtime/rpc/runtime-close-attribution-topology.test.ts index 13009905aad5..0642f55902b3 100644 --- a/src/main/runtime/rpc/runtime-close-attribution-topology.test.ts +++ b/src/main/runtime/rpc/runtime-close-attribution-topology.test.ts @@ -63,8 +63,10 @@ describe('runtime close attribution topology', () => { .fn() .mockResolvedValueOnce({ closed: true, refused: true, refusalReason: 'stale-terminal' }) .mockResolvedValue({ closed: true }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this browser-close fixture supplies every runtime member the handler invokes. const runtime = { getRuntimeId: () => 'runtime-owner-1', + resolveStructuredAgentSessionTabCloseSessionId: vi.fn(() => null), closeMobileSessionTab, listMobileSessionTabs: vi.fn(async (worktree: string) => visibleSessionTab(worktree, worktree.endsWith('a') ? 'tab-a' : 'tab-b') @@ -137,8 +139,10 @@ describe('runtime close attribution topology', () => { it('keeps concurrent cross-worktree request and target identities distinct', async () => { const closeMobileSessionTab = vi.fn().mockResolvedValue({ closed: true }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this browser-close fixture supplies every runtime member the handler invokes. const runtime = { getRuntimeId: () => 'runtime-owner-2', + resolveStructuredAgentSessionTabCloseSessionId: vi.fn(() => null), listMobileSessionTabs: vi.fn(async (worktree: string) => visibleSessionTab(worktree, worktree.endsWith('a') ? 'tab-a' : 'tab-b') ), diff --git a/src/main/runtime/rpc/runtime-close-attribution.test.ts b/src/main/runtime/rpc/runtime-close-attribution.test.ts index 12290423a750..3f34a38c1734 100644 --- a/src/main/runtime/rpc/runtime-close-attribution.test.ts +++ b/src/main/runtime/rpc/runtime-close-attribution.test.ts @@ -130,8 +130,10 @@ describe('runtime close attribution', () => { ] as const)( 'records the device identity for %s without the bearer credential', async (method, params, spanName) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this browser-close fixture supplies every runtime member the handler invokes. const runtime = { getRuntimeId: () => 'test-runtime', + resolveStructuredAgentSessionTabCloseSessionId: vi.fn(() => null), listMobileSessionTabs: vi.fn(async () => visibleSessionTab('wt-1', 'tab-1')), closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }) } as unknown as OrcaRuntimeService diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index 5f12d134e086..35fd2c904478 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -116,7 +116,11 @@ export type RuntimeNotifier = { renameTerminal(tabId: string, title: string | null, options?: { recordInteraction: false }): void focusTerminal(tabId: string, worktreeId: string, leafId?: string | null): void focusEditorTab?(tabId: string, worktreeId: string): void - closeSessionTab?(tabId: string, worktreeId: string): void | Promise + closeSessionTab?( + tabId: string, + worktreeId: string, + options?: { originatingWindowId?: number } + ): void | Promise moveSessionTab?(worktreeId: string, move: RuntimeMobileSessionTabMove): void /** * Acts only on the host's own window: 'host'/'all' move it, 'caller'/'clients' open without moving diff --git a/src/main/runtime/structured-agent-session-closes-in-flight.ts b/src/main/runtime/structured-agent-session-closes-in-flight.ts new file mode 100644 index 000000000000..0d298b9147fe --- /dev/null +++ b/src/main/runtime/structured-agent-session-closes-in-flight.ts @@ -0,0 +1,154 @@ +/** + * The chat closes a host is still running, per session id, in memory only. + * + * A reopen waits for earlier closes; a committed close cancels the opening attempts it overtakes. + * Entries end with their work, and cancellation belongs only to those concrete attempts. + */ +import { AgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { AGENT_SESSION_NOT_ATTACHED } from '../native-chat/agent-session-wire/structured-agent-session-mutation-refusals' + +export const STRUCTURED_AGENT_SESSION_CLOSE_WAIT_MS = 3_000 + +export type StructuredAgentSessionCloseRequest = { + sessionId: string + cancelEarlierPublications: () => void +} + +export type StructuredAgentSessionPublication = { + signalFor: (sessionId: string) => AbortSignal +} + +type PublicationAttempt = StructuredAgentSessionPublication & { + cancel: (sessionId: string) => void +} + +export class StructuredAgentSessionClosesInFlight { + private readonly bySession = new Map>>() + private readonly publications = new Set() + + /** Registered before preparation; a completed close cannot make this attempt current again. */ + async runPublication( + sessionId: string, + publish: (signal: AbortSignal) => Promise + ): Promise { + return this.runPublicationInScope(new Set([sessionId]), (attempt) => + publish(attempt.signalFor(sessionId)) + ) + } + + /** Registered before preparation even when the host has not exposed its targets yet. */ + runRestoration( + restore: (attempt: StructuredAgentSessionPublication) => Promise + ): Promise { + return this.runPublicationInScope(null, restore) + } + + private async runPublicationInScope( + scope: ReadonlySet | null, + publish: (attempt: StructuredAgentSessionPublication) => Promise + ): Promise { + const controllers = new Map() + const controllerFor = (sessionId: string): AbortController => { + let controller = controllers.get(sessionId) + if (!controller) { + controller = new AbortController() + controllers.set(sessionId, controller) + } + return controller + } + const attempt: PublicationAttempt = { + signalFor: (sessionId) => controllerFor(sessionId).signal, + cancel: (sessionId) => { + if (scope === null || scope.has(sessionId)) { + controllerFor(sessionId).abort(new AgentSessionRefusalError(AGENT_SESSION_NOT_ATTACHED)) + } + } + } + this.publications.add(attempt) + try { + return await publish(attempt) + } finally { + this.publications.delete(attempt) + } + } + + /** `track` for the close itself, past every check that could still refuse it. */ + commit( + sessionId: string, + close: () => Promise, + request = this.closeRequest(sessionId) + ): Promise { + request.cancelEarlierPublications() + return this.track(sessionId, close) + } + + private closeRequest(sessionId: string): StructuredAgentSessionCloseRequest { + const earlier = [...this.publications] + return { + sessionId, + cancelEarlierPublications: () => { + for (const attempt of earlier) { + attempt.cancel(sessionId) + } + } + } + } + + /** Runs one close, marked in flight from this call until it settles. */ + track( + sessionId: string, + close: (request: StructuredAgentSessionCloseRequest) => Promise + ): Promise { + const request = this.closeRequest(sessionId) + const closes = this.bySession.get(sessionId) ?? new Set>() + this.bySession.set(sessionId, closes) + // Marked before the close starts, so a reveal arriving during its first await already waits. + const run = Promise.resolve().then(() => close(request)) + const settled = run.then( + () => undefined, + () => undefined + ) + closes.add(settled) + void settled.finally(() => { + closes.delete(settled) + if (closes.size === 0 && this.bySession.get(sessionId) === closes) { + this.bySession.delete(sessionId) + } + }) + return run + } + + /** True once no close of the session is running; false if one still is after `timeoutMs`. */ + async settled(sessionId: string, timeoutMs: number): Promise { + let timer: ReturnType | undefined + const expired = new Promise<'expired'>((resolve) => { + timer = setTimeout(() => resolve('expired'), timeoutMs) + }) + try { + // A close that begins while an earlier one settles is waited for too, within the same bound. + for (let closes = this.bySession.get(sessionId); closes;) { + if ((await Promise.race([Promise.all(closes), expired])) === 'expired') { + return false + } + closes = this.bySession.get(sessionId) + } + return true + } finally { + clearTimeout(timer) + } + } +} + +/** Keyed by runtime identity alone, so each runtime (and each test's) has its own. */ +const registries = new WeakMap() + +export function getStructuredAgentSessionClosesInFlight( + runtime: WeakKey +): StructuredAgentSessionClosesInFlight { + let registry = registries.get(runtime) + if (!registry) { + registry = new StructuredAgentSessionClosesInFlight() + registries.set(runtime, registry) + } + return registry +} 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 a33bbb33fd1d..f6608d296800 100644 --- a/src/main/runtime/structured-agent-session-host-refusal.test.ts +++ b/src/main/runtime/structured-agent-session-host-refusal.test.ts @@ -334,6 +334,10 @@ describe('startup and other non-chat work without a structured host', () => { vi.spyOn(host, 'listSessionTabs').mockReturnValue([ { workspaceId: 'workspace-1', sessionId: 'claude-1', agent: 'claude' } ]) + vi.spyOn(host, 'getPersistedVisibleSessionTabIndex').mockReturnValue({ + present: true, + sessionIds: ['claude-1'] + }) vi.spyOn(host, 'setSessionTabVisibility').mockResolvedValue(undefined) const frames = await listInventory(runtime) unsubscribe() diff --git a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts index 6f4c08e815b7..b6bf7f4c0c0f 100644 --- a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts +++ b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts @@ -17,6 +17,7 @@ import { } from './agent-session-record-store-test-harness' import { closeTestJournalHostDatabases } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { OrcaRuntimeService } from './orca-runtime' +import { closeStructuredAgentSessionOnHost } from './structured-agent-session-tab-close' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime @@ -133,13 +134,9 @@ it('closes a chat tab over records a newer Orca wrote, reporting the index it ca await runtime.prepareStructuredAgentSessionStartupRestoration() const host = getStructuredAgentSessionHost() const close = vi.spyOn(host!, 'close') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime's own protected close path, reached with the one field it reads. - const internal = runtime as unknown as { - closeStructuredAgentSessionTab(tab: { sessionId: string }, cause: 'user-close'): Promise - } await expect( - internal.closeStructuredAgentSessionTab({ sessionId }, 'user-close') + closeStructuredAgentSessionOnHost(runtime, sessionId, 'user-close') ).resolves.toBeUndefined() expect(log.entries).toContainEqual({ diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index 039b80631dda..83a863a3435b 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -174,7 +174,7 @@ function startupRuntime(options: { afterInstall?: () => void; profileChats?: str ensureStructuredAgentSessionHost(): Promise refreshMobileSessionPtyRecords(): Promise | null> mobileSessionTabsByWorktree: Map - store: { getWorkspaceSession: () => unknown } + store: { getWorkspaceSession: () => unknown; getRepo: () => undefined } } internal.hasPersistedStructuredAgentSessionStore = () => true internal.ensureStructuredAgentSessionHost = async () => { @@ -194,6 +194,7 @@ function startupRuntime(options: { afterInstall?: () => void; profileChats?: str internal.refreshMobileSessionPtyRecords = async () => new Set() // A legacy profile's saved chat tabs, which a store with no tab index restores from. internal.store = { + getRepo: () => undefined, getWorkspaceSession: () => ({ activeRepoId: null, activeWorktreeId: 'workspace-1', @@ -218,6 +219,7 @@ function startupRuntime(options: { afterInstall?: () => void; profileChats?: str } return { runtime, + internal, log, published: () => internal.mobileSessionTabsByWorktree.get('workspace-1')?.tabs ?? [] } @@ -333,6 +335,39 @@ describe('restoring the chat tabs open at quit', () => { chatRecord(CHAT_B, { codex: true }) ] + it('captures legacy membership during the real cold install before a close changes the index', async () => { + await seedProfile(legacyChats()) + expect(getStructuredAgentSessionHost()).toBeNull() + const { runtime, internal, published } = startupRuntime({ profileChats: [CHAT_A, CHAT_B] }) + let enter!: () => void + let release!: () => void + const reached = new Promise((resolve) => { + enter = resolve + }) + const held = new Promise((resolve) => { + release = resolve + }) + vi.spyOn(internal, 'refreshMobileSessionPtyRecords').mockImplementationOnce(async () => { + enter() + await held + return new Set() + }) + const restoring = runtime.restoreStructuredAgentSessionTabs() + await reached + try { + expect( + await runtime.closeMobileSessionTab('id:workspace-1', `agent-session:${CHAT_A}`, { + reason: 'user' + }) + ).toMatchObject({ closed: true }) + } finally { + release() + } + await restoring + expect(await tabIndexOnDisk()).toEqual([CHAT_B]) + expect(published().map((tab) => tab.id)).toEqual([`agent-session:${CHAT_B}`]) + }) + it('records every restored chat in one write', async () => { const records = legacyChats() await seedProfile(records) diff --git a/src/main/runtime/structured-agent-session-tab-close.ts b/src/main/runtime/structured-agent-session-tab-close.ts new file mode 100644 index 000000000000..75505b769bb0 --- /dev/null +++ b/src/main/runtime/structured-agent-session-tab-close.ts @@ -0,0 +1,254 @@ +/** + * A user's close of a chat tab on the host, by the tab id `session.tabs.close` carries. + * + * Every chat close ends in one host function, `closeStructuredAgentSessionOnHost`: hide in the + * restore index, drop the tab from the host's list, stop the provider. A chat the host does not + * list (its inventory is not restored yet, or it is still starting) is closed by session id. + */ + +import type { + RuntimeMobileSessionAgentTab, + RuntimeMobileSessionTabsSnapshot, + RuntimeSessionTabCloseReason +} from '../../shared/runtime-types' +import { + AGENT_SESSION_WORKTREE_MISMATCH_CODE, + SESSION_TAB_CLOSE_CANCELED_ERROR, + SESSION_TAB_NOT_FOUND_ERROR +} from '../../shared/session-tab-close' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type { StructuredAgentSessionCloseCause } from '../native-chat/agent-session-wire/structured-agent-session-host-lifetime' +import type { RuntimeNotifier } from './runtime-notifier-contract' +import { + getStructuredAgentSessionClosesInFlight, + type StructuredAgentSessionCloseRequest +} from './structured-agent-session-closes-in-flight' +import { findStructuredAgentSessionTab } from './structured-agent-session-tab-retirement' +import { structuredAgentSessionTabCloseCause } from './structured-agent-session-tab-close-cause' + +const HOST_CHAT_TAB_ID_PREFIX = 'agent-session:' + +/** The session a host chat tab id names, or null for every other tab id. */ +export function structuredAgentSessionIdFromHostTabId(tabId: string): string | null { + return tabId.startsWith(HOST_CHAT_TAB_ID_PREFIX) && tabId.length > HOST_CHAT_TAB_ID_PREFIX.length + ? tabId.slice(HOST_CHAT_TAB_ID_PREFIX.length) + : null +} + +/** A user's close of a chat tab, which takes the chat path rather than the terminal-aware one. */ +export function isUserStructuredAgentSessionTabClose( + tabId: string, + options: { + reason?: RuntimeSessionTabCloseReason + expectedPublicationEpoch?: string + expectedTerminalHandle?: string + expectedPtyCloseAuthority?: unknown + } +): boolean { + return ( + structuredAgentSessionIdFromHostTabId(tabId) !== null && + (options.reason === undefined || options.reason === 'user') && + options.expectedPublicationEpoch === undefined && + options.expectedTerminalHandle === undefined && + options.expectedPtyCloseAuthority === undefined + ) +} + +/** The runtime the host close drops a chat's tab from. */ +export type StructuredAgentSessionCloseRuntime = WeakKey & { + retireStructuredAgentSessionTabFromSnapshot(sessionId: string): boolean + ensureStructuredAgentSessionHostForClose(): Promise +} + +export type StructuredAgentSessionTabCloseHost = StructuredAgentSessionCloseRuntime & { + mobileSessionTabsByWorktree: ReadonlyMap + notifier: Pick | null +} + +export type StructuredAgentSessionTabCloseOptions = { + reason?: RuntimeSessionTabCloseReason + structuredCloseRequest?: StructuredAgentSessionCloseRequest + originatingWindowId?: number +} + +/** Owned aliases take precedence over the spelling of an unlisted canonical id. */ +export function structuredAgentSessionIdForTabClose( + host: Pick, + tabId: string +): string | null { + return ( + ownedStructuredAgentSessionIdForTab(host, tabId) ?? structuredAgentSessionIdFromHostTabId(tabId) + ) +} + +function ownedStructuredAgentSessionIdForTab( + host: Pick, + tabId: string +): string | null { + const store = getStructuredAgentSessionHost()?.deps.store + const owned = store?.getSessionIdForTab?.(tabId) + if (owned) { + return owned + } + for (const snapshot of host.mobileSessionTabsByWorktree.values()) { + const tab = snapshot.tabs.find((candidate) => candidate.id === tabId) + if (tab?.type === 'agent-session') { + return tab.sessionId + } + } + return null +} + +function assertOriginalCloseTarget( + host: StructuredAgentSessionTabCloseHost, + tabId: string, + request?: StructuredAgentSessionCloseRequest, + listedSessionId?: string +): void { + if (!request) { + return + } + const current = ownedStructuredAgentSessionIdForTab(host, tabId) + const retargeted = [...host.mobileSessionTabsByWorktree.values()].some((snapshot) => + snapshot.tabs.some( + (tab) => + tab.id === tabId && tab.type === 'agent-session' && tab.sessionId !== request.sessionId + ) + ) + if ( + retargeted || + (listedSessionId !== undefined && listedSessionId !== request.sessionId) || + (current !== null && current !== request.sessionId) + ) { + throw new Error(SESSION_TAB_CLOSE_CANCELED_ERROR) + } +} + +/** + * The host's one close of a chat, by session id, whether or not its tab is listed: hide it in the + * restore index, drop its tab from the list, stop the provider (which also aborts a start). + * In flight until it settles, so a reveal of the same chat waits for it. + */ +export function closeStructuredAgentSessionOnHost( + runtime: StructuredAgentSessionCloseRuntime, + sessionId: string, + cause: StructuredAgentSessionCloseCause, + request?: StructuredAgentSessionCloseRequest +): Promise { + return getStructuredAgentSessionClosesInFlight(runtime).commit( + sessionId, + async () => { + if (!getStructuredAgentSessionHost()) { + await runtime.ensureStructuredAgentSessionHostForClose() + } + const host = getStructuredAgentSessionHost() + if (typeof host?.setSessionTabVisibility === 'function') { + // The restore index is bookkeeping: one that cannot be written (a newer Orca's records, a + // failing disk) is reported, and the chat still closes. + await host.setSessionTabVisibility(sessionId, false).catch((error: unknown) => { + host.deps.logger.warn('recording a closed chat tab failed', { + scope: 'tab-visibility-close', + sessionId, + error + }) + }) + } + try { + runtime.retireStructuredAgentSessionTabFromSnapshot(sessionId) + } catch (error) { + // A throwing snapshot listener must not skip the stop. + console.warn('[runtime] retiring a closed chat tab failed', sessionId, error) + } + if (typeof host?.close === 'function') { + await host.close(sessionId, cause) + } + }, + request + ) +} + +/** The worktree a chat belongs to: its record's, else the list that carries it; null if neither. */ +function structuredAgentSessionWorktreeId( + host: Pick, + sessionId: string +): string | null { + const record = getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId) + if (record) { + return record.location.workspaceId + } + for (const [worktreeId, snapshot] of host.mobileSessionTabsByWorktree) { + if (findStructuredAgentSessionTab(snapshot, sessionId)) { + return worktreeId + } + } + return null +} + +/** A listed chat: the window closes its own tab first, so its pinned-tab confirm can still veto. */ +export async function closeListedStructuredAgentSessionTab( + host: StructuredAgentSessionTabCloseHost, + worktreeId: string, + tab: RuntimeMobileSessionAgentTab, + options: StructuredAgentSessionTabCloseOptions +): Promise { + assertOriginalCloseTarget(host, tab.id, options.structuredCloseRequest, tab.sessionId) + if (host.notifier?.closeSessionTab) { + try { + // Why: a reopened chat's window tab id is not derivable from its session; the window maps ours. + await (options.reason === 'user' && options.originatingWindowId !== undefined + ? host.notifier.closeSessionTab(tab.id, worktreeId, { + originatingWindowId: options.originatingWindowId + }) + : host.notifier.closeSessionTab(tab.id, worktreeId)) + } catch (error) { + // The renderer already having removed the tab is an idempotent close, not a veto. + if (!(error instanceof Error && error.message === SESSION_TAB_NOT_FOUND_ERROR)) { + throw error + } + } + } + assertOriginalCloseTarget(host, tab.id, options.structuredCloseRequest, tab.sessionId) + await closeStructuredAgentSessionOnHost( + host, + tab.sessionId, + structuredAgentSessionTabCloseCause(options.reason), + options.structuredCloseRequest + ) +} + +/** + * Closes the chat `tabId` names in `worktreeId`. An unlisted chat has no window tab to relay to; it + * still has to stop (and a still-starting one, which may have no record yet, has its start aborted). + * Throws `agent_session_worktree_mismatch` when the chat belongs to another worktree. + */ +export async function closeStructuredAgentSessionTabForUser( + host: StructuredAgentSessionTabCloseHost, + worktreeId: string, + tabId: string, + options: StructuredAgentSessionTabCloseOptions +): Promise { + const cause = structuredAgentSessionTabCloseCause(options.reason) + const request = options.structuredCloseRequest + await host.ensureStructuredAgentSessionHostForClose() + assertOriginalCloseTarget(host, tabId, request) + const tab = host.mobileSessionTabsByWorktree + .get(worktreeId) + ?.tabs.find( + (candidate): candidate is RuntimeMobileSessionAgentTab => + candidate.type === 'agent-session' && candidate.id === tabId + ) + if (tab) { + await closeListedStructuredAgentSessionTab(host, worktreeId, tab, options) + return + } + const sessionId = request?.sessionId ?? structuredAgentSessionIdFromHostTabId(tabId) + if (sessionId === null) { + throw new Error('tab_not_found') + } + const owner = structuredAgentSessionWorktreeId(host, sessionId) + if (owner !== null && owner !== worktreeId) { + // Not `tab_not_found`: a client settles that as closed, and this chat is still open elsewhere. + throw new Error(AGENT_SESSION_WORKTREE_MISMATCH_CODE) + } + await closeStructuredAgentSessionOnHost(host, sessionId, cause, request) +} diff --git a/src/main/runtime/structured-agent-session-tab-restoration.ts b/src/main/runtime/structured-agent-session-tab-restoration.ts new file mode 100644 index 000000000000..abf1babb417e --- /dev/null +++ b/src/main/runtime/structured-agent-session-tab-restoration.ts @@ -0,0 +1,81 @@ +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { getStructuredAgentSessionClosesInFlight } from './structured-agent-session-closes-in-flight' +import { seedStructuredAgentSessionTabIndex } from './structured-agent-session-tab-index-seed' +import { onStructuredAgentSessionHostInstalled } from '../native-chat/agent-session-wire/structured-agent-session-registry' + +type RestoredSession = ReturnType[number] + +/** Restore reads history; only current, uncanceled targets may publish a tab. */ +export async function restoreStructuredAgentSessionTabPublications(input: { + runtime: WeakKey + getHost: () => StructuredAgentSessionHost | null + profileIds: readonly string[] + prepare?: () => Promise + hydrate: () => void + reconcile?: (sessions: readonly RestoredSession[]) => void + project: (session: RestoredSession) => void +}): Promise { + await getStructuredAgentSessionClosesInFlight(input.runtime).runRestoration(async (attempt) => { + let original: { present: boolean; targets: readonly string[] } | undefined + const discover = (host: StructuredAgentSessionHost): void => { + if (original) { + return + } + const index = host.getPersistedVisibleSessionTabIndex?.() ?? { + present: false, + sessionIds: [] + } + original = { + present: index.present, + targets: [...(index.present ? index.sessionIds : input.profileIds)] + } + } + const stopDiscovering = onStructuredAgentSessionHostInstalled(discover) + try { + await input.prepare?.() + const host = input.getHost() + if (host) { + discover(host) + } + const membership = original ?? { present: false, targets: input.profileIds } + const { targets } = membership + await host?.restoreReadableSessions(targets) + input.hydrate() + const restored = (host?.listSessionTabs() ?? []).map((session) => { + let sessionId = session.sessionId + while (sessionId.startsWith('agent-session:')) { + sessionId = sessionId.slice('agent-session:'.length) + } + return { ...session, sessionId } + }) + const eligible = targets.filter((sessionId) => !attempt.signalFor(sessionId).aborted) + if (!membership.present) { + await seedStructuredAgentSessionTabIndex( + host, + eligible, + restored + .filter((session) => eligible.includes(session.sessionId)) + .map((session) => session.sessionId) + ) + } + const current = host?.getPersistedVisibleSessionTabIndex?.() ?? { + present: false, + sessionIds: [] + } + const visible = new Set(current.present ? current.sessionIds : eligible) + input.reconcile?.( + restored.filter( + (session) => + !attempt.signalFor(session.sessionId).aborted && visible.has(session.sessionId) + ) + ) + for (const session of restored) { + if (!attempt.signalFor(session.sessionId).aborted && visible.has(session.sessionId)) { + input.project(session) + } + } + } finally { + stopDiscovering() + } + }) +} diff --git a/src/main/runtime/structured-agent-session-tab-retirement.ts b/src/main/runtime/structured-agent-session-tab-retirement.ts index aee5ef6d719d..369ddb39c8fe 100644 --- a/src/main/runtime/structured-agent-session-tab-retirement.ts +++ b/src/main/runtime/structured-agent-session-tab-retirement.ts @@ -1,7 +1,7 @@ /** * Removing a structured agent session's chat tab from a live workspace tab snapshot. * - * Extracted from `closeStructuredAgentSessionTab` so that user-initiated tab closes and + * Extracted from `closeStructuredAgentSessionOnHost` so that user-initiated tab closes and * orchestration settlements (stop / release / discard) retire the same tab the same way, rather * than orchestration leaving a dead chat tab behind that re-attaches the session when opened. */ diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 95acdabeff7a..17bfc42d9fec 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -173,8 +173,10 @@ export function registerRuntimeWindowLifecycle( focusTerminal: (tabId, worktreeId, leafId) => send('ui:focusTerminal', { tabId, worktreeId, leafId }), focusEditorTab: (tabId, worktreeId) => send('ui:focusEditorTab', { tabId, worktreeId }), - closeSessionTab: (tabId, worktreeId) => - requestSessionTabCloseFromRenderer(mainWindow, tabId, worktreeId), + closeSessionTab: (tabId, worktreeId, options) => + options?.originatingWindowId === mainWindow.id + ? Promise.resolve() + : requestSessionTabCloseFromRenderer(mainWindow, tabId, worktreeId), moveSessionTab: (worktreeId: string, move: RuntimeMobileSessionTabMove) => send('ui:moveSessionTab', { worktreeId, ...move }), openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId?, navigation?, position?) => diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 04dbc7e4f92a..52da79705763 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -57,6 +57,7 @@ import { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' import { MOBILE_RELAY_RUNTIME_CAPABILITIES } from './mobile-desktop-relay-contract' import { SKILL_RUNTIME_CAPABILITIES } from './skill-install-capability' import { ACCOUNT_RUNTIME_CAPABILITIES } from './account-runtime-capabilities' +import { SESSION_TABS_CLOSE_AGENT_SESSION_BY_ID_RUNTIME_CAPABILITY } from './session-tabs-close-capabilities' import { AGENT_LAUNCH_RUNTIME_CAPABILITIES, AGENT_LAUNCH_RUNTIME_CAPABILITY @@ -399,6 +400,7 @@ export const RUNTIME_CAPABILITIES = [ SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, ...SESSION_TABS_RUNTIME_CAPABILITIES, + SESSION_TABS_CLOSE_AGENT_SESSION_BY_ID_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, REMOTE_SERVER_UPDATE_CAPABILITY, AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, diff --git a/src/shared/session-tab-close.ts b/src/shared/session-tab-close.ts index 2817e729a92a..ed46f1ba0957 100644 --- a/src/shared/session-tab-close.ts +++ b/src/shared/session-tab-close.ts @@ -2,6 +2,10 @@ export const SESSION_TAB_CLOSE_CANCELED_ERROR = 'session_tab_close_canceled' export const SESSION_TAB_CLOSE_FAILED_ERROR = 'session_tab_close_failed' export const SESSION_TAB_NOT_FOUND_ERROR = 'session_tab_not_found' export const SESSION_TAB_CLOSE_TIMEOUT_ERROR = 'session_tab_close_timeout' +/** A host chat close named a worktree other than the chat's own; nothing was stopped. */ +export const AGENT_SESSION_WORKTREE_MISMATCH_CODE = 'agent_session_worktree_mismatch' +/** A reveal gave up waiting for an in-flight close of the same chat; nothing was published. */ +export const AGENT_SESSION_CLOSE_PENDING_CODE = 'agent_session_close_pending' export type SessionTabCloseRequest = { requestId: string diff --git a/src/shared/session-tabs-close-capabilities.ts b/src/shared/session-tabs-close-capabilities.ts new file mode 100644 index 000000000000..bf27033301d6 --- /dev/null +++ b/src/shared/session-tabs-close-capabilities.ts @@ -0,0 +1,7 @@ +// The capabilities that gate how a client closes a tab on its host. + +// Why: an older host answers `tab_not_found` to `session.tabs.close` for a chat it does not list, +// so it never stops a still-starting chat; a client sends the parallel `agentSession.close` there. +// A host with it also holds `agentSession.reveal` until an in-flight close of that session finishes. +export const SESSION_TABS_CLOSE_AGENT_SESSION_BY_ID_RUNTIME_CAPABILITY = + 'session-tabs.close-agent-session-by-id.v1' as const diff --git a/tests/e2e/structured-session-reopened-tab-close.unit.test.ts b/tests/e2e/structured-session-reopened-tab-close.unit.test.ts index 972369558aea..dd78a5b48460 100644 --- a/tests/e2e/structured-session-reopened-tab-close.unit.test.ts +++ b/tests/e2e/structured-session-reopened-tab-close.unit.test.ts @@ -8,10 +8,21 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' import type * as RuntimeRpcClient from '../../src/renderer/src/runtime/runtime-rpc-client' import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' import { setStructuredAgentSessionHost } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' +import { + openTestAgentSessionRecordStore, + seedTestAgentSessionRecordStore +} from '../../src/main/runtime/agent-session-record-store-test-harness' +import { foundAgentSessionRecord } from '../../src/main/runtime/agent-session-record-founding' +import { closeTestJournalHostDatabase } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' +import { structuredAgentSessionTabId } from '../../src/shared/structured-agent-session-projection' import { useAppStore } from '../../src/renderer/src/store' import { applyStructuredSessionTabSnapshots } from '../../src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply' import { resetLocalStructuredSessionVersionForTests } from '../../src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-generation-fence' @@ -21,10 +32,11 @@ import { registerTerminalUiRoutingIpcBridge } from '../../src/renderer/src/hooks import { resetWebSessionCloseIntentForTests } from '../../src/renderer/src/runtime/web-session-close-intent' const WORKTREE = 'repo-1::/tmp/wt-reopen' -const CURRENT_SESSION = 'clear-x' +const CURRENT_SESSION = 'clear-current' const REOPENED_SESSION = 'session-s' const CURRENT_TAB = `structured-agent-session-${REOPENED_SESSION}` const REOPENED_TAB = `${CURRENT_TAB}:history-1` +let directory: string type CloseRequest = { requestId: string; tabId: string; worktreeId: string; expiresAt?: number } type Listener = (payload: T) => void @@ -70,6 +82,31 @@ vi.mock('../../src/renderer/src/runtime/structured-agent-session-close', () => ( })) async function setup() { + const sessionIds = [CURRENT_SESSION, REOPENED_SESSION] + await seedTestAgentSessionRecordStore(directory, { + records: sessionIds.map((sessionId) => + foundAgentSessionRecord( + { + sessionId, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKTREE, + workspaceKind: 'git-worktree' + }, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: directory } + }, + { claimKeyId: 'key-1', now: 1_800_000_000_000 } + ) + ), + sessionTabs: sessionIds.map((sessionId) => ({ + tabId: structuredAgentSessionTabId(sessionId), + sessionId + })) + }) + const store = await openTestAgentSessionRecordStore(directory) + expect(store.listVisibleSessionIds()).toEqual(sessionIds) const listeners: { closeSessionTab?: Listener<{ tabId: string; worktreeId: string }> closeRequest?: Listener @@ -121,7 +158,7 @@ async function setup() { listeners.focusEditorTab!({ tabId, worktreeId }) } as never ) - // No durable store, PTYs or workspace session on disk: the host tab list below is the whole state. + // The real saved tab index owns visibility; PTYs and the workspace profile are absent. Object.assign(runtime, { hasPersistedStructuredAgentSessionStore: () => true, getKnownWorkspaceSessionWorktreeIds: () => new Set(), @@ -130,18 +167,23 @@ async function setup() { ensureStructuredAgentSessionHost: async () => undefined }) setStructuredAgentSessionHost( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restore and tab close call only these host members. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restore and tab close use these host members and the real record store. { + deps: { store, logger: createStructuredAgentSessionLogger() }, + getPersistedVisibleSessionTabIndex: () => store.getVisibleSessionTabIndex(), reconcileRestartLeases: async () => undefined, restoreReadableSessions: async () => undefined, close: async (sessionId: string) => { hostSessionCloses.push(sessionId) }, - setSessionTabVisibility: async () => undefined, - listSessionTabs: () => [ - { sessionId: CURRENT_SESSION, workspaceId: WORKTREE, agent: 'claude' }, - { sessionId: REOPENED_SESSION, workspaceId: WORKTREE, agent: 'claude' } - ] + setSessionTabVisibility: (sessionId: string, visible: boolean) => + store.setSessionTabVisibility(sessionId, visible), + listSessionTabs: () => + store.listVisibleSessionIds().map((sessionId) => ({ + sessionId, + workspaceId: WORKTREE, + agent: 'claude' as const + })) } as never ) await runtime.restoreStructuredAgentSessionTabs() @@ -162,16 +204,17 @@ async function setup() { (useAppStore.getState().unifiedTabsByWorktree[WORKTREE] ?? []) .filter((tab) => tab.contentType === 'agent-session') .map((tab) => ({ id: tab.id, entityId: tab.entityId })) - return { runtime, agentTabs, hostSessionCloses, closeResponses } + return { runtime, agentTabs, hostSessionCloses, closeResponses, store } } const initialStoreState = useAppStore.getState() -beforeEach(() => { +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-reopened-tab-close-')) bridge.rendererSessionCloses = [] }) -afterEach(() => { +afterEach(async () => { setStructuredAgentSessionHost(null) bridge.runtime = null useAppStore.setState(initialStoreState, true) @@ -180,6 +223,8 @@ afterEach(() => { // A close's follow-up re-read can outlive its test, which tears the host down under it. resetWebSessionCloseIntentForTests() vi.unstubAllGlobals() + closeTestJournalHostDatabase(directory) + await rm(directory, { recursive: true, force: true }) }) describe('closing a chat reopened from history after /clear', () => { @@ -195,7 +240,7 @@ describe('closing a chat reopened from history after /clear', () => { }) it('closes only the reopened tab when the user closes it on the desktop', async () => { - const { agentTabs, hostSessionCloses, closeResponses } = await setup() + const { agentTabs, hostSessionCloses, closeResponses, store } = await setup() useAppStore.getState().closeUnifiedTab(REOPENED_TAB) @@ -207,10 +252,12 @@ describe('closing a chat reopened from history after /clear', () => { expect(hostSessionCloses).toEqual([REOPENED_SESSION]) // The window already removed the tab, so main's relay is acknowledged, not reported missing. expect(closeResponses).toEqual([undefined]) + expect(store.listVisibleSessionIds()).toEqual([CURRENT_SESSION]) + expect(store.getRecord(REOPENED_SESSION)).not.toBeNull() }) it('closes only the reopened tab when a phone closes it through the runtime', async () => { - const { runtime, agentTabs, hostSessionCloses } = await setup() + const { runtime, agentTabs, hostSessionCloses, store } = await setup() await runtime.closeMobileSessionTab(`id:${WORKTREE}`, `agent-session:${REOPENED_SESSION}`, { reason: 'user' @@ -221,6 +268,9 @@ describe('closing a chat reopened from history after /clear', () => { ) expect(bridge.rendererSessionCloses).not.toContain(CURRENT_SESSION) expect(hostSessionCloses).not.toContain(CURRENT_SESSION) + expect([...new Set(hostSessionCloses)]).toEqual([REOPENED_SESSION]) + expect(store.listVisibleSessionIds()).toEqual([CURRENT_SESSION]) + expect(store.getRecord(REOPENED_SESSION)).not.toBeNull() }) it('focuses the reopened tab when a phone activates it through the runtime', async () => { diff --git a/tests/e2e/structured-session-tab-close-stale-frame.unit.test.ts b/tests/e2e/structured-session-tab-close-stale-frame.unit.test.ts index 0ecadc703a5e..41c741df835b 100644 --- a/tests/e2e/structured-session-tab-close-stale-frame.unit.test.ts +++ b/tests/e2e/structured-session-tab-close-stale-frame.unit.test.ts @@ -7,12 +7,23 @@ * against the real window store and session-tabs mirror; only the process boundary is stubbed. */ -import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type * as RuntimeRpcClient from '../../src/renderer/src/runtime/runtime-rpc-client' import type { RuntimeClientTarget } from '../../src/shared/runtime-client-target' import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' import { setStructuredAgentSessionHost } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' +import { + openTestAgentSessionRecordStore, + seedTestAgentSessionRecordStore +} from '../../src/main/runtime/agent-session-record-store-test-harness' +import { foundAgentSessionRecord } from '../../src/main/runtime/agent-session-record-founding' +import { closeTestJournalHostDatabase } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' +import { structuredAgentSessionTabId } from '../../src/shared/structured-agent-session-projection' import { useAppStore } from '../../src/renderer/src/store' import { applyStructuredSessionTabSnapshots } from '../../src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply' import { resetLocalStructuredSessionVersionForTests } from '../../src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-generation-fence' @@ -27,7 +38,8 @@ import { } from '../../src/renderer/src/runtime/web-session-close-intent' const WORKTREE = 'repo-1::/tmp/wt-stale-frame' -const SESSIONS = ['chat-a', 'chat-b', 'chat-c'] as const +const SESSIONS = ['chat-alpha', 'chat-bravo', 'chat-charlie'] as const +let directory: string const tabIdOf = (sessionId: string): string => `structured-agent-session-${sessionId}` type CloseRequest = { requestId: string; tabId: string; worktreeId: string; expiresAt?: number } @@ -78,6 +90,30 @@ vi.mock('../../src/renderer/src/runtime/structured-agent-session-close', () => ( })) async function setup(options: { hostFailsClose?: boolean } = {}) { + await seedTestAgentSessionRecordStore(directory, { + records: SESSIONS.map((sessionId) => + foundAgentSessionRecord( + { + sessionId, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKTREE, + workspaceKind: 'git-worktree' + }, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: directory } + }, + { claimKeyId: 'key-1', now: 1_800_000_000_000 } + ) + ), + sessionTabs: SESSIONS.map((sessionId) => ({ + tabId: structuredAgentSessionTabId(sessionId), + sessionId + })) + }) + const store = await openTestAgentSessionRecordStore(directory) + expect(store.listVisibleSessionIds()).toEqual(SESSIONS) let closeRequest: ((request: CloseRequest) => void) | undefined const pendingResponses = new Map void>() const runtime = new OrcaRuntimeService() @@ -112,7 +148,7 @@ async function setup(options: { hostFailsClose?: boolean } = {}) { }) } as never ) - // No durable store, PTYs or workspace session on disk: the host tab list below is the whole state. + // The saved tab index owns visibility; PTYs and the workspace profile are absent. Object.assign(runtime, { hasPersistedStructuredAgentSessionStore: () => true, getKnownWorkspaceSessionWorktreeIds: () => new Set(), @@ -121,22 +157,24 @@ async function setup(options: { hostFailsClose?: boolean } = {}) { ensureStructuredAgentSessionHost: async () => undefined }) const hostSessionCloses: string[] = [] - const hostTabs = new Set(SESSIONS) setStructuredAgentSessionHost( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restore and tab close call only these host members. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restore and close use these host members and the real record store. { + deps: { store, logger: createStructuredAgentSessionLogger() }, + getPersistedVisibleSessionTabIndex: () => store.getVisibleSessionTabIndex(), reconcileRestartLeases: async () => undefined, restoreReadableSessions: async () => undefined, close: async (sessionId: string) => { hostSessionCloses.push(sessionId) }, - setSessionTabVisibility: async (sessionId: string, visible: boolean) => { - if (!visible) { - hostTabs.delete(sessionId) - } - }, + setSessionTabVisibility: (sessionId: string, visible: boolean) => + store.setSessionTabVisibility(sessionId, visible), listSessionTabs: () => - [...hostTabs].map((sessionId) => ({ sessionId, workspaceId: WORKTREE, agent: 'claude' })) + store.listVisibleSessionIds().map((sessionId) => ({ + sessionId, + workspaceId: WORKTREE, + agent: 'claude' as const + })) } as never ) await runtime.restoreStructuredAgentSessionTabs() @@ -148,12 +186,16 @@ async function setup(options: { hostFailsClose?: boolean } = {}) { const strip = (): string[] => (useAppStore.getState().groupsByWorktree[WORKTREE] ?? []).flatMap((group) => group.tabOrder) - return { runtime, strip, hostSessionCloses } + return { runtime, strip, hostSessionCloses, store } } const initialStoreState = useAppStore.getState() -afterEach(() => { +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-stale-frame-close-')) +}) + +afterEach(async () => { setStructuredAgentSessionHost(null) bridge.runtime = null bridge.closeHeld = null @@ -163,11 +205,13 @@ afterEach(() => { resetWebSessionTabsSnapshotFreshnessForTests() resetWebSessionCloseIntentForTests() vi.unstubAllGlobals() + closeTestJournalHostDatabase(directory) + await rm(directory, { recursive: true, force: true }) }) describe('a host frame published while a chat tab close is in flight', () => { it('does not bring the closed chat back at the end of the strip', async () => { - const { runtime, strip, hostSessionCloses } = await setup() + const { runtime, strip, hostSessionCloses, store } = await setup() expect(strip()).toEqual(SESSIONS.map(tabIdOf)) let handleClose!: () => void bridge.closeHeld = new Promise((resolve) => { @@ -176,37 +220,40 @@ describe('a host frame published while a chat tab close is in flight', () => { // A close left in flight would be joined by the next test's close of the same chat. onTestFinished(() => handleClose()) - useAppStore.getState().closeUnifiedTab(tabIdOf('chat-b')) - expect(strip()).toEqual([tabIdOf('chat-a'), tabIdOf('chat-c')]) + useAppStore.getState().closeUnifiedTab(tabIdOf('chat-bravo')) + expect(strip()).toEqual([tabIdOf('chat-alpha'), tabIdOf('chat-charlie')]) - // The host has not handled the close yet, so its next frame still lists chat-b. + // The host has not handled the close yet, so its next frame still lists chat-bravo. runtime.touchMobileSessionTabsForWorktree(WORKTREE, { immediate: true }) - expect(strip()).toEqual([tabIdOf('chat-a'), tabIdOf('chat-c')]) + expect(strip()).toEqual([tabIdOf('chat-alpha'), tabIdOf('chat-charlie')]) handleClose() - await vi.waitFor(() => expect(hostSessionCloses).toEqual(['chat-b'])) + await vi.waitFor(() => expect(hostSessionCloses).toEqual(['chat-bravo'])) + expect(store.listVisibleSessionIds()).toEqual(['chat-alpha', 'chat-charlie']) + expect(store.getRecord('chat-bravo')).not.toBeNull() // The host published its removal before it stopped the chat, and that frame lifted the hide. expect( isWebSessionCloseIntentPending( { environmentId: LOCAL_STRUCTURED_SESSION_OWNER }, WORKTREE, - 'agent-session:chat-b', + 'agent-session:chat-bravo', Date.now() ) ).toBe(false) runtime.touchMobileSessionTabsForWorktree(WORKTREE, { immediate: true }) - expect(strip()).toEqual([tabIdOf('chat-a'), tabIdOf('chat-c')]) + expect(strip()).toEqual([tabIdOf('chat-alpha'), tabIdOf('chat-charlie')]) }) it('shows the chat again when the host could not close it', async () => { - const { strip } = await setup({ hostFailsClose: true }) + const { strip, store } = await setup({ hostFailsClose: true }) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - useAppStore.getState().closeUnifiedTab(tabIdOf('chat-b')) - expect(strip()).toEqual([tabIdOf('chat-a'), tabIdOf('chat-c')]) + useAppStore.getState().closeUnifiedTab(tabIdOf('chat-bravo')) + expect(strip()).toEqual([tabIdOf('chat-alpha'), tabIdOf('chat-charlie')]) // The host still has the chat, so once the close fails the window re-reads and shows it. - await vi.waitFor(() => expect(strip()).toContain(tabIdOf('chat-b'))) + await vi.waitFor(() => expect(strip()).toContain(tabIdOf('chat-bravo'))) expect(strip()).toHaveLength(3) + expect(store.listVisibleSessionIds()).toEqual(SESSIONS) warn.mockRestore() }) @@ -249,7 +296,7 @@ describe('a host frame published while a chat tab close is in flight', () => { applyServerFrame(frame(1)) const chatB = useAppStore .getState() - .unifiedTabsByWorktree[WORKTREE]?.find((tab) => tab.entityId === 'chat-b') + .unifiedTabsByWorktree[WORKTREE]?.find((tab) => tab.entityId === 'chat-bravo') expect(chatB?.executionHostId).toBe(`runtime:${SERVER}`) const before = strip() expect(before).toHaveLength(3) @@ -263,7 +310,7 @@ describe('a host frame published while a chat tab close is in flight', () => { const after = before.filter((id) => id !== chatB!.id) expect(strip()).toEqual(after) - // The server has not handled the close yet, so its next frame still lists chat-b. + // The server has not handled the close yet, so its next frame still lists chat-bravo. applyServerFrame(frame(2)) expect(strip()).toEqual(after) })