Files
orca/src/main/ipc/runtime.test.ts
T
Brennan BensonandMerge Sim ba4e79c250 fix(runtime): apply the structured-chat setting to every RPC caller (#18700)
* fix(runtime): apply the structured-chat setting to every RPC caller

supportsStructuredAgentSessions only consulted experimentalStructuredNativeChat
when clientKind === 'mobile', so identical host settings admitted desktop and
in-process callers while refusing a phone. The server branched on client surface.

The setting is now one rule for every caller. The negotiated capability stays a
wire term asked of remote clients only, so a capability-less in-process caller is
still admitted on the setting alone.

Making the projection's structuredNativeChatEnabled argument required surfaced
eight call sites that passed `undefined` for non-mobile clients; they now read the
host setting, so tab projection follows the same single rule.

Announced behaviour change: with the flag off, session.tabs.list/listAll no longer
restore structured tabs for desktop. The desktop renderer already discards them in
that state, and startup record/lease reconciliation is unaffected.

* fix(runtime): keep structured session cleanup available

* test(runtime): enable structured chat in desktop projection fixture

* test(agent-session): settle merged fixtures against the all-clients structured policy

The merge with main left three fixtures written for the old mobile-only rule:
a duplicate getClientSettings key, a create fixture with no host settings at
all, and a projection call whose 'old client' is now the mobile fallback-title
case.

* fix(native-chat): let an admitted caller close a chat after the setting is off

Turning `experimentalStructuredNativeChat` off revoked admission for every
`agentSession.*` method, including `close`. A chat opened while the setting was
on stays mounted, so its owner was left with a live provider child and an X
button that answered `structured_agent_session_unsupported`.

Split the surface by what a method does to work in flight rather than by how it
sounds, and write that rule where the gate lives so the next method lands on the
right side: starting, extending, retaining or reading needs admission; stopping
or retiring work the caller already owns does not. Moves `close` and `cancel`
onto the cleanup gate alongside `unsubscribe` and `release`.

The tightening is unchanged - the cleanup gate still demands the negotiated wire
capability and never creates a host, so an incapable client still cannot see the
surface and no method that starts work is reachable with the setting off.

Extracts the dispatcher harness and the method-to-gate table into fixtures so
the new admission suite can share them without a max-lines disable.

* Drop a duplicate lastActivityAt key carried in from main

The main commit this branch merged (fb322046e8) had two lastActivityAt
properties in the same object literal at both journal stubs, which fails
TS1117 and oxlint. Upstream has since kept only the later value; match it.

Not introduced here, but merged in, so it has to be fixed here.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-06 23:32:00 -07:00

283 lines
9.8 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const { handleMock, onMock, removeAllListenersMock, removeHandlerMock, fromWebContentsMock } =
vi.hoisted(() => ({
handleMock: vi.fn(),
onMock: vi.fn(),
removeAllListenersMock: vi.fn(),
removeHandlerMock: vi.fn(),
fromWebContentsMock: vi.fn()
}))
vi.mock('electron', () => ({
BrowserWindow: {
fromWebContents: fromWebContentsMock
},
ipcMain: {
handle: handleMock,
on: onMock,
removeAllListeners: removeAllListenersMock,
removeHandler: removeHandlerMock
}
}))
import { registerRuntimeHandlers } from './runtime'
import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline'
function runtimeCallEvent() {
const mainFrame = {}
return {
sender: {
id: 1,
mainFrame,
on: vi.fn(),
once: vi.fn()
},
senderFrame: mainFrame
}
}
describe('registerRuntimeHandlers', () => {
beforeEach(() => {
handleMock.mockReset()
onMock.mockReset()
removeAllListenersMock.mockReset()
removeHandlerMock.mockReset()
fromWebContentsMock.mockReset()
})
it('routes sync requests through the authoritative browser window id', () => {
const runtime = {
syncWindowGraph: vi.fn().mockReturnValue({ graphStatus: 'ready' }),
getStatus: vi.fn().mockReturnValue({ graphStatus: 'unavailable' }),
getRuntimeId: vi.fn().mockReturnValue('runtime-1')
}
registerRuntimeHandlers(runtime as never)
const syncRegistration = handleMock.mock.calls.find(
([channel]) => channel === 'runtime:syncWindowGraph'
)
expect(syncRegistration).toBeTruthy()
fromWebContentsMock.mockReturnValue({ id: 17 })
const currentMainFrame = {}
const sender = { mainFrame: currentMainFrame }
const handler = syncRegistration![1]
const graph = { tabs: [], leaves: [], rendererGeneration: 'renderer-1' }
const result = handler({ sender, senderFrame: currentMainFrame }, graph)
expect(runtime.syncWindowGraph).toHaveBeenCalledWith(17, graph)
expect(result).toEqual({ graphStatus: 'ready' })
})
it('rejects a graph publication queued by a superseded main frame', () => {
const runtime = {
syncWindowGraph: vi.fn(),
getStatus: vi.fn(),
getRuntimeId: vi.fn()
}
registerRuntimeHandlers(runtime as never)
const handler = handleMock.mock.calls.find(
([channel]) => channel === 'runtime:syncWindowGraph'
)![1]
const sender = { mainFrame: { generation: 2 } }
fromWebContentsMock.mockReturnValue({ id: 17 })
expect(() =>
handler({ sender, senderFrame: { generation: 1 } }, { tabs: [], leaves: [] })
).toThrow('Runtime graph sync must originate from the current main frame')
expect(runtime.syncWindowGraph).not.toHaveBeenCalled()
})
it('rejects graph publications without a renderer generation', () => {
const runtime = { syncWindowGraph: vi.fn() }
registerRuntimeHandlers(runtime as never)
const handler = handleMock.mock.calls.find(
([channel]) => channel === 'runtime:syncWindowGraph'
)![1]
const currentMainFrame = {}
const sender = { mainFrame: currentMainFrame }
fromWebContentsMock.mockReturnValue({ id: 17 })
expect(() =>
handler({ sender, senderFrame: currentMainFrame }, { tabs: [], leaves: [] })
).toThrow('Runtime graph sync requires a renderer generation')
expect(runtime.syncWindowGraph).not.toHaveBeenCalled()
})
it('routes generic local runtime RPC calls through the dispatcher', async () => {
const runtime = {
syncWindowGraph: vi.fn(),
getStatus: vi.fn().mockReturnValue({
runtimeId: 'runtime-1',
rendererGraphEpoch: 0,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0
}),
getRuntimeId: vi.fn().mockReturnValue('runtime-1')
}
registerRuntimeHandlers(runtime as never)
const callRegistration = handleMock.mock.calls.find(([channel]) => channel === 'runtime:call')
expect(callRegistration).toBeTruthy()
const handler = callRegistration![1]
const result = await handler(runtimeCallEvent(), { method: 'status.get' })
expect(result).toMatchObject({
ok: true,
result: { runtimeId: 'runtime-1', graphStatus: 'ready' },
_meta: { runtimeId: 'runtime-1' }
})
})
it('projects Claude structured tabs to the same-version desktop client', async () => {
const claudeTab = {
type: 'agent-session',
id: 'agent-session:claude-1',
title: 'Claude Chat',
sessionId: 'claude-1',
agent: 'claude',
isActive: true
}
const runtime = {
getRuntimeId: vi.fn().mockReturnValue('runtime-1'),
getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })),
restoreStructuredAgentSessionTabs: vi.fn(async () => undefined),
listMobileSessionTabs: vi.fn(async () => ({
worktree: 'workspace-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: claudeTab.id,
activeTabType: 'agent-session',
tabGroups: [{ id: 'group-1', activeTabId: claudeTab.id, tabOrder: [claudeTab.id] }],
tabs: [claudeTab]
}))
}
registerRuntimeHandlers(runtime as never)
const callRegistration = handleMock.mock.calls.find(([channel]) => channel === 'runtime:call')
const result = await callRegistration![1](runtimeCallEvent(), {
method: 'session.tabs.list',
params: { worktree: 'id:workspace-1' }
})
expect(result).toMatchObject({ ok: true, result: { tabs: [claudeTab] } })
})
it('registers project group runtime RPC methods for local desktop callers', async () => {
const runtime = {
syncWindowGraph: vi.fn(),
getStatus: vi.fn(),
getRuntimeId: vi.fn().mockReturnValue('runtime-1'),
listProjectGroups: vi.fn().mockReturnValue([{ id: 'group-1', name: 'Platform' }])
}
registerRuntimeHandlers(runtime as never)
const callRegistration = handleMock.mock.calls.find(([channel]) => channel === 'runtime:call')
expect(callRegistration).toBeTruthy()
const handler = callRegistration![1]
const result = await handler(runtimeCallEvent(), { method: 'projectGroup.list' })
expect(result).toMatchObject({
ok: true,
result: { groups: [{ id: 'group-1', name: 'Platform' }] },
_meta: { runtimeId: 'runtime-1' }
})
})
it('registers local runtime streaming subscription lifecycle handlers', () => {
registerRuntimeHandlers({ syncWindowGraph: vi.fn(), getStatus: vi.fn() } as never)
expect(handleMock.mock.calls.some(([channel]) => channel === 'runtime:subscribe')).toBe(true)
expect(onMock.mock.calls.some(([channel]) => channel === 'runtime:unsubscribe')).toBe(true)
expect(removeAllListenersMock).toHaveBeenCalledWith('runtime:unsubscribe')
})
it('deduplicates retries while a terminal fit restore is still pending', async () => {
const finishRestoreByPtyId = new Map<string, (restored: boolean) => void>()
const reclaimTerminalForDesktop = vi.fn(
(ptyId: string) =>
new Promise<boolean>((resolve) => {
finishRestoreByPtyId.set(ptyId, resolve)
})
)
const runtime = {
syncWindowGraph: vi.fn(),
getStatus: vi.fn(),
reclaimTerminalForDesktop
}
registerRuntimeHandlers(runtime as never)
const restoreRegistration = handleMock.mock.calls.find(
([channel]) => channel === 'runtime:restoreTerminalFit'
)
expect(restoreRegistration).toBeTruthy()
const handler = restoreRegistration![1]
const first = handler({ sender: {} }, { ptyId: 'pty-1' })
const retry = handler({ sender: {} }, { ptyId: 'pty-1' })
const otherTerminal = handler({ sender: {} }, { ptyId: 'pty-2' })
expect(reclaimTerminalForDesktop).toHaveBeenCalledTimes(2)
expect(reclaimTerminalForDesktop).toHaveBeenNthCalledWith(1, 'pty-1')
expect(reclaimTerminalForDesktop).toHaveBeenNthCalledWith(2, 'pty-2')
finishRestoreByPtyId.get('pty-1')?.(true)
finishRestoreByPtyId.get('pty-2')?.(true)
await expect(otherTerminal).resolves.toEqual({ restored: true })
await expect(first).resolves.toEqual({ restored: true })
await expect(retry).resolves.toEqual({ restored: true })
expect(reclaimTerminalForDesktop).toHaveBeenCalledTimes(2)
const afterSettlement = handler({ sender: {} }, { ptyId: 'pty-1' })
expect(reclaimTerminalForDesktop).toHaveBeenCalledTimes(3)
finishRestoreByPtyId.get('pty-1')?.(false)
await expect(afterSettlement).resolves.toEqual({ restored: false })
})
it('bounds retries without accumulating reclaim waiters for one PTY', async () => {
vi.useFakeTimers()
try {
let finishRestore!: (restored: boolean) => void
const reclaimTerminalForDesktop = vi.fn(
() =>
new Promise<boolean>((resolve) => {
finishRestore = resolve
})
)
registerRuntimeHandlers({
syncWindowGraph: vi.fn(),
getStatus: vi.fn(),
reclaimTerminalForDesktop
} as never)
const handler = handleMock.mock.calls.find(
([channel]) => channel === 'runtime:restoreTerminalFit'
)![1]
const first = handler({ sender: {} }, { ptyId: 'pty-wedged' })
await vi.advanceTimersByTimeAsync(TERMINAL_FIT_RESTORE_DEADLINE_MS)
await expect(first).resolves.toEqual({ restored: false })
const retry = handler({ sender: {} }, { ptyId: 'pty-wedged' })
expect(reclaimTerminalForDesktop).toHaveBeenCalledTimes(1)
finishRestore(true)
await expect(retry).resolves.toEqual({ restored: true })
const afterSettlement = handler({ sender: {} }, { ptyId: 'pty-wedged' })
expect(reclaimTerminalForDesktop).toHaveBeenCalledTimes(2)
finishRestore(false)
await expect(afterSettlement).resolves.toEqual({ restored: false })
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
})