Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion config/scripts/vitest-sqlite-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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'
]
62 changes: 62 additions & 0 deletions src/main/ipc/runtime-window-origin.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
1 change: 1 addition & 0 deletions src/main/ipc/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -44,6 +45,7 @@ import {
} from '../../observability/agent-session-instrumentation'

export type StructuredAgentSessionAttachOptions = {
signal?: AbortSignal
hostLaunchDirectory?: string
recordPhase?: AgentSessionCreatePhaseRecorder
onAcquisitionFailed?: AttachFlowInput['onAcquisitionFailed']
Expand Down Expand Up @@ -111,14 +113,18 @@ async function runAttach(
options: StructuredAgentSessionAttachOptions
): Promise<AgentSessionMutationResult<AgentSessionAttachResult>> {
// 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?.()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -44,6 +46,7 @@ export function createStructuredAgentSession(
params: AgentSessionAttachParams,
options: StructuredAgentSessionCreateOptions = {}
): Promise<AgentSessionMutationResult<AgentSessionAttachResult>> {
options.signal?.throwIfAborted()
return context.tasks.trackAttach(
context.serialize(params.envelope.sessionId, async () => {
try {
Expand All @@ -64,6 +67,7 @@ async function foundConversation(
params: AgentSessionAttachParams,
options: StructuredAgentSessionCreateOptions
): Promise<AgentSessionMutationResult<AgentSessionAttachResult>> {
options.signal?.throwIfAborted()
const { deps } = context
const { store } = deps
const sessionId = params.envelope.sessionId
Expand Down Expand Up @@ -173,6 +177,7 @@ async function foundConversation(
let committed = replayed
try {
if (!replayed) {
options.signal?.throwIfAborted()
await importAdoptedTranscript(
params,
{ journal: session.journal, unconfirmedClientMessageIds: [] },
Expand All @@ -181,6 +186,7 @@ async function foundConversation(
{ uncommittedCreate: true }
)
const first = options.firstMessage
options.signal?.throwIfAborted()
if (first) {
const sent = await performSend(
{
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
}
5 changes: 3 additions & 2 deletions src/main/runtime/agent-session-record-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 17 additions & 23 deletions src/main/runtime/orca-runtime-close-mobile-session-tab.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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)) {
Expand Down
54 changes: 30 additions & 24 deletions src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -12,41 +12,47 @@ 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'
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<void> {
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<void> {
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<RuntimeMobileSessionTabsResult> {
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.
*
Expand Down
Loading
Loading