Refuse a chat create whose close came first (dark, build switch off by default) (#29659)

* Refuse a chat create whose close came first, behind the layout-owner switch

With ORCA_LAYOUT_OWNER=main, main remembers a close that reaches it before
the chat's create and refuses that create, or closes the chat as it lands.
The window then tells this machine's host about a cancel instead of
keeping a cancel tombstone; a host it cannot tell still gets the
tombstone. Paired hosts keep the tombstones, whose fence module moves to
lib/launch-cancel-tombstones-compat/.

* Close a chat whose create a cancel beat, on any create outcome

The local cancel now retires the chat on main (close and host-tab close),
so a create that landed first leaves no tab behind; a tab_not_found answer
means the create had not landed. Main closes the chat for any create
answer once a close was noted, including a refusal or a throw past commit,
and publishes no tab for it. The tombstone fallback notifies listeners.

* Keep the switched-off create path byte-identical

* Log, not throw, a failed close of a chat closed before create

* Run the close-before-create test in the Node SQLite project
This commit is contained in:
Jinwoo Hong
2026-10-16 05:41:33 -07:00
committed by GitHub
parent 469ae09c97
commit 18df97a7bc
14 changed files with 625 additions and 27 deletions
@@ -253,6 +253,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts',
'src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts',
'src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts',
'src/main/runtime/rpc/methods/structured-agent-session-close-before-create.test.ts',
'src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts',
'src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts',
'src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts',
@@ -0,0 +1,167 @@
import '../unused-default-rpc-methods.test-fixture'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities'
import { structuredAgentSessionCreateParams } from '../../../../shared/structured-agent-session-create'
import {
createRestTestRig,
type RestTestRig
} from '../../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig'
import {
HOST_TEST_SESSION as SESSION,
HOST_TEST_NOW as NOW
} 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 { call, runtimeCalls } from './structured-agent-session-rpc.test-fixture'
import {
resetStructuredAgentSessionClosesBeforeCreateForTests,
structuredAgentSessionClosedBeforeCreate
} from './structured-agent-session-close-before-create'
const mocks = vi.hoisted(() => ({ layoutOwnerIsMain: true }))
vi.mock('../../../../shared/layout-owner-switch', () => ({
get ORCA_LAYOUT_OWNER_IS_MAIN() {
return mocks.layoutOwnerIsMain
}
}))
const CLIENT = {
clientId: 'device-1',
clientKind: 'runtime' as const,
clientCapabilities: [
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY
]
}
let rig: RestTestRig
const retireTab = vi.fn(() => true)
beforeEach(async () => {
mocks.layoutOwnerIsMain = true
resetStructuredAgentSessionClosesBeforeCreateForTests()
retireTab.mockClear()
rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } })
setStructuredAgentSessionHost(rig.host)
})
afterEach(async () => {
setStructuredAgentSessionHost(null)
await rig.dispose()
vi.restoreAllMocks()
})
function createParams() {
return structuredAgentSessionCreateParams({
sessionId: SESSION,
worktree: 'id:workspace-1',
agent: 'codex',
randomUuid: () => '00000000-0000-0000-0000-0000000000fa',
now: NOW
})
}
const create = (overrides: Record<string, unknown> = {}) =>
call('agentSession.create', createParams(), CLIENT, {
retireStructuredAgentSessionTabFromSnapshot: retireTab,
...overrides
})
const close = () => call('agentSession.close', { sessionId: SESSION }, CLIENT)
const refusedAsClosed = {
ok: true,
result: {
ok: false,
refusal: {
code: 'agent_session_operation_invalid',
message: 'The chat was closed before it was created.'
}
}
}
describe('a close that reaches main before its create', () => {
it('cancel then create: main refuses the create and makes nothing', async () => {
expect(await close()).toMatchObject({ ok: true })
expect(await create()).toMatchObject(refusedAsClosed)
expect(rig.store.getRecord(SESSION)).toBeNull()
expect(runtimeCalls.publishStructuredAgentSessionTab).not.toHaveBeenCalled()
})
it('create then cancel: an ordinary close, and the create still replays', async () => {
expect(await create()).toMatchObject({ ok: true, result: { ok: true } })
expect(await close()).toMatchObject({ ok: true })
expect(await create()).toMatchObject({ ok: true, result: { ok: true, replayed: true } })
})
it('a close while the create resolves its workspace refuses it before anything is written', async () => {
const resolving = Promise.withResolvers<void>()
const entered = Promise.withResolvers<void>()
const creating = create({
resolveStructuredAgentSessionCreateIntent: vi.fn(async (params: { envelope: unknown }) => {
entered.resolve()
await resolving.promise
return {
envelope: params.envelope,
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
provider: 'codex',
agent: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' },
runtimeKind: 'native'
}
})
})
await entered.promise
await close()
resolving.resolve()
expect(await creating).toMatchObject(refusedAsClosed)
expect(rig.store.getRecord(SESSION)).toBeNull()
})
it('a close while the create commits closes the chat as it lands', async () => {
let closing: Promise<unknown> | undefined
const result = await create({
publishStructuredAgentSessionTab: vi.fn(async () => {
closing = close()
})
})
await closing
expect(result).toMatchObject(refusedAsClosed)
expect(retireTab).toHaveBeenCalledWith(SESSION)
expect(rig.store.getSessionTabId(SESSION)).toBeNull()
expect(rig.host.hasSession(SESSION)).toBe(false)
})
it('a close before a create that fails past commit still closes what it made', async () => {
let closing: Promise<unknown> | undefined
const result = await create({
publishStructuredAgentSessionTab: vi.fn(async () => {
// Not awaited: the close's stop queues behind this create.
closing = close()
await vi.waitFor(() => expect(structuredAgentSessionClosedBeforeCreate(SESSION)).toBe(true))
throw new Error('tab write failed')
})
})
await closing
expect(result).toMatchObject(refusedAsClosed)
expect(rig.store.getRecord(SESSION)).not.toBeNull()
expect(retireTab).toHaveBeenCalledWith(SESSION)
expect(rig.host.hasSession(SESSION)).toBe(false)
})
it('switch off: a close before the create is not remembered', async () => {
mocks.layoutOwnerIsMain = false
await close()
mocks.layoutOwnerIsMain = true
expect(await create()).toMatchObject({ ok: true, result: { ok: true } })
})
})
@@ -0,0 +1,97 @@
// A chat's close can reach main before its create: the window cancels while its create is still on
// the way. With ORCA_LAYOUT_OWNER=main the window keeps no cancel tombstone for this machine's host,
// so main remembers the close and refuses that create, or closes the chat as its create lands.
import type { AgentSessionWireRefusal } from '../../../../shared/agent-session-wire-refusals'
import { refuseUnclassified } from '../../../../shared/agent-session-wire-refusals'
import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
import type { RpcContext } from '../core'
// Session ids are minted per launch, so an entry only ever matches its own create; the cap bounds
// closes whose create never came.
const MAX_CLOSED_BEFORE_CREATE = 256
const closedBeforeCreate = new Set<string>()
const createsUnderWay = new Map<string, number>()
/** `hasRecord`: a create already committed, so this is an ordinary close unless one is under way. */
export function noteStructuredAgentSessionClose(sessionId: string, hasRecord: boolean): void {
if (hasRecord && !createsUnderWay.has(sessionId)) {
return
}
closedBeforeCreate.add(sessionId)
for (const oldest of closedBeforeCreate) {
if (closedBeforeCreate.size <= MAX_CLOSED_BEFORE_CREATE) {
break
}
closedBeforeCreate.delete(oldest)
}
}
export function structuredAgentSessionClosedBeforeCreate(sessionId: string): boolean {
return closedBeforeCreate.has(sessionId)
}
export function structuredAgentSessionClosedBeforeCreateRefusal(): AgentSessionWireRefusal {
return refuseUnclassified(
'agent_session_operation_invalid',
'The chat was closed before it was created.'
)
}
/** Wraps `agentSession.create`: a close that arrived while it ran closes the chat it made. */
export function refusingStructuredAgentSessionCreatesClosedFirst<
Params extends { envelope: { sessionId: string } },
Result extends { ok: boolean }
>(
create: (params: Params, ctx: RpcContext) => Promise<Result>
): (
params: Params,
ctx: RpcContext
) => Promise<Result | { ok: false; refusal: AgentSessionWireRefusal }> {
return async (params, ctx) => {
const { sessionId } = params.envelope
createsUnderWay.set(sessionId, (createsUnderWay.get(sessionId) ?? 0) + 1)
let closedFirst = false
try {
const result = await create(params, ctx)
closedFirst = closedBeforeCreate.has(sessionId)
// The record stays on disk, as for any closed chat; the refusal only says the launch is off.
return closedFirst
? { ok: false, refusal: structuredAgentSessionClosedBeforeCreateRefusal() }
: result
} catch (error) {
closedFirst = closedBeforeCreate.has(sessionId)
throw error
} finally {
const remaining = (createsUnderWay.get(sessionId) ?? 1) - 1
if (remaining > 0) {
createsUnderWay.set(sessionId, remaining)
} else {
createsUnderWay.delete(sessionId)
}
// Any answer, a refusal or a throw past commit included, may have left the chat made.
if (closedFirst) {
// Logged, not thrown: the client must still hear that the chat was closed first.
await closeCreatedChat(ctx, sessionId).catch((error: unknown) =>
console.warn(
'[structured-agent-session] closing a chat closed before create failed',
error
)
)
}
}
}
}
/** The close the window asked for, run now that the chat exists: the tab first, then the agent. */
async function closeCreatedChat(ctx: RpcContext, sessionId: string): Promise<void> {
const host = getStructuredAgentSessionHost()
await host?.setSessionTabVisibility?.(sessionId, false)
ctx.runtime.retireStructuredAgentSessionTabFromSnapshot(sessionId)
await host?.close(sessionId, 'user-close')
}
export function resetStructuredAgentSessionClosesBeforeCreateForTests(): void {
closedBeforeCreate.clear()
createsUnderWay.clear()
}
@@ -18,6 +18,11 @@ import {
structuredAgentSessionCreateIntentFingerprint
} from './structured-agent-session-create'
import { resolveUncommittedStructuredCreate } from './structured-agent-session-precommit-refusal'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import {
structuredAgentSessionClosedBeforeCreate,
structuredAgentSessionClosedBeforeCreateRefusal
} from './structured-agent-session-close-before-create'
/**
* The attach-shaped entries take the location from the client instead of resolving it from a
@@ -101,6 +106,13 @@ export async function handleStructuredAgentSessionCreate(
if ('refusal' in prepared) {
return { ok: false, refusal: prepared.refusal }
}
// Nothing is created yet, so a close that came first refuses the create outright.
if (
ORCA_LAYOUT_OWNER_IS_MAIN &&
structuredAgentSessionClosedBeforeCreate(params.envelope.sessionId)
) {
return { ok: false, refusal: structuredAgentSessionClosedBeforeCreateRefusal() }
}
return commitStructuredAgentSessionCreate({
runtime: ctx.runtime,
caller: callerFor(ctx),
@@ -25,6 +25,8 @@ import {
resolveUncommittedStructuredCreate,
type StructuredCreateRefused
} from './structured-agent-session-precommit-refusal'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { structuredAgentSessionClosedBeforeCreate } from './structured-agent-session-close-before-create'
export type PreparedStructuredAgentSessionCreate = {
host: StructuredAgentSessionHost
@@ -168,16 +170,20 @@ export async function commitStructuredAgentSessionCreate(args: {
: {}),
...(tab
? {
// A chat closed while it was being made gets no tab to show before main closes it.
publishTab: () =>
args.runtime.publishStructuredAgentSessionTab({
workspaceId: tab.workspaceId,
sessionId: prepared.attachParams.envelope.sessionId,
agent: tab.agent,
activate: args.activate,
...(prepared.attachParams.surfaceTabId
? { tabId: prepared.attachParams.surfaceTabId }
: {})
})
ORCA_LAYOUT_OWNER_IS_MAIN &&
structuredAgentSessionClosedBeforeCreate(prepared.attachParams.envelope.sessionId)
? Promise.resolve()
: args.runtime.publishStructuredAgentSessionTab({
workspaceId: tab.workspaceId,
sessionId: prepared.attachParams.envelope.sessionId,
agent: tab.agent,
activate: args.activate,
...(prepared.attachParams.surfaceTabId
? { tabId: prepared.attachParams.surfaceTabId }
: {})
})
}
: {})
})
@@ -190,6 +196,13 @@ export async function commitStructuredAgentSessionCreate(args: {
if (!result.ok || !prepared.tab) {
return result
}
// A chat closed while it was being made gets no tab to show before main closes it.
if (
ORCA_LAYOUT_OWNER_IS_MAIN &&
structuredAgentSessionClosedBeforeCreate(result.value.sessionId)
) {
return result
}
const surfaceTabId = prepared.attachParams.surfaceTabId
try {
await args.runtime.publishStructuredAgentSessionTab({
@@ -58,6 +58,11 @@ import {
UnsubscribeParams
} from './structured-agent-session-schemas'
import { sendStructuredAgentSessionForClient } from './structured-agent-session-send-compatibility'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import {
noteStructuredAgentSessionClose,
refusingStructuredAgentSessionCreatesClosedFirst
} from './structured-agent-session-close-before-create'
export const STRUCTURED_AGENT_SESSION_METHODS = [
defineMethod({
@@ -103,7 +108,9 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [
name: 'agentSession.create',
permission: 'workspace',
params: CreateParams,
handler: handleStructuredAgentSessionCreate
handler: ORCA_LAYOUT_OWNER_IS_MAIN
? refusingStructuredAgentSessionCreatesClosedFirst(handleStructuredAgentSessionCreate)
: handleStructuredAgentSessionCreate
}),
defineMethod({
name: 'agentSession.ensure',
@@ -132,6 +139,12 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [
params: OptionsParams,
handler: async (params, ctx) => {
const host = requireStructuredCleanupHost(ctx)
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
noteStructuredAgentSessionClose(
params.sessionId,
host.deps.store.getRecord(params.sessionId) !== null
)
}
// Terminal-disposal closes use this RPC without the session-tabs retirement RPC.
if (typeof host.setSessionTabVisibility === 'function') {
await host.setSessionTabVisibility(params.sessionId, false)
@@ -1,18 +1,18 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import { suppressCancelledStructuredSessionTabs } from '@/runtime/structured-agent-session-tab-retirement'
import type { StructuredLaunchState } from './structured-agent-session-launch-registry'
import type { StructuredLaunchState } from '../structured-agent-session-launch-registry'
import {
hasStructuredAgentSessionLaunchCancellationTombstone,
markStructuredAgentSessionLaunchCancelled,
retireAbsentStructuredAgentSessionLaunchCancellationTombstones,
resetStructuredAgentLaunchRegistryForTests,
setStructuredLaunchState
} from './structured-agent-session-launch-registry'
} from '../structured-agent-session-launch-registry'
import { beginStructuredAgentSessionAuthoritativeInventory } from './structured-agent-session-launch-cancellation'
import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence'
import { resetStructuredAgentLaunchPersistenceForTests } from '../structured-agent-session-launch-persistence'
import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync/inventory-refresh'
const WORKTREE_ID = 'repo-1::worktree-1'
@@ -1,11 +1,15 @@
import type { ExecutionHostId } from '../../../shared/execution-host'
// The window's cancel tombstones: a chat cancelled before its create landed is hidden and closed when
// its host later lists it. Kept for paired hosts, which may not refuse a create whose close came
// first; the remote-server project sets their floor. Local chats need them only in window-owned
// builds (ORCA_LAYOUT_OWNER), until phase F.
import type { ExecutionHostId } from '../../../../shared/execution-host'
import {
hasStructuredAgentLaunchCancellationTombstonePersisted,
markStructuredAgentLaunchCancelledPersisted,
readStructuredAgentLaunchCancellationTombstoneSessionIds,
retireAbsentStructuredAgentLaunchCancellationTombstonesPersisted,
retireStructuredAgentLaunchCancellationTombstonePersisted
} from './structured-agent-session-launch-persistence'
} from '../structured-agent-session-launch-persistence'
type CancellationRetirement = {
retireAfterInventory: number | null
@@ -2,7 +2,7 @@ import { useSyncExternalStore } from 'react'
import type { TuiAgent } from '../../../shared/tui-agent'
import type { StructuredLaunchFailure } from './structured-agent-session-launch-failure'
import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
import type { StructuredLaunchRecoveryState } from './structured-agent-session-launch-recovery'
import type { StructuredLaunchSelection } from './structured-agent-session-launch-options'
import type {
@@ -22,7 +22,9 @@ import {
resetStructuredAgentLaunchCancellationForTests,
retireAbsentStructuredAgentLaunchCancellations,
retireStructuredAgentLaunchCancellation
} from './structured-agent-session-launch-cancellation'
} from './launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { cancelStructuredAgentLaunchOnLocalHost } from './structured-agent-session-local-launch-cancel'
export type StructuredLaunchState = StructuredLaunchRecoveryState & {
identity: string
@@ -227,17 +229,32 @@ function markStructuredAgentSessionLaunchCancelledInternal(
const state = getStructuredLaunchStateBySessionId(sessionId)
if (matchesLaunchWorktree(state, worktreeId) && state) {
// The launch's own owner outranks the caller's: it is the host the create was sent to.
markStructuredAgentLaunchCancellation(
sessionId,
state.intent.executionHostId,
alreadyCancelled,
state.promise
)
if (ORCA_LAYOUT_OWNER_IS_MAIN && state.intent.executionHostId === LOCAL_EXECUTION_HOST_ID) {
cancelStructuredAgentLaunchOnLocalHost(
worktreeId,
sessionId,
notifyStructuredLaunchListeners,
state.promise
)
} else {
markStructuredAgentLaunchCancellation(
sessionId,
state.intent.executionHostId,
alreadyCancelled,
state.promise
)
}
state.cancelled = true
state.callers.outcome = 'cancelled'
// The tombstone is the durable authority; drop the in-memory launch so bulk closes cannot
// retain a dead promise for the lifetime of the renderer.
deleteStructuredLaunchStateIfCurrent(state)
} else if (
ORCA_LAYOUT_OWNER_IS_MAIN &&
!alreadyCancelled &&
executionHostId === LOCAL_EXECUTION_HOST_ID
) {
cancelStructuredAgentLaunchOnLocalHost(worktreeId, sessionId, notifyStructuredLaunchListeners)
} else if (!alreadyCancelled) {
markStructuredAgentLaunchCancellation(sessionId, executionHostId, alreadyCancelled)
}
@@ -0,0 +1,40 @@
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code'
import { retireStructuredAgentSessionTab } from '@/runtime/structured-agent-session-tab-retirement'
import {
deleteStructuredAgentLaunchRecord,
hasStructuredAgentLaunchCancellationTombstonePersisted
} from './structured-agent-session-launch-persistence'
import { markStructuredAgentLaunchCancellation } from './launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation'
/**
* ORCA_LAYOUT_OWNER=main only: this machine's host refuses a create whose close came first, or
* closes the chat as it lands, so a local cancel retires the chat on the host at once instead of
* keeping a tombstone until the host lists it. A host that cannot be told gets the tombstone.
*/
export function cancelStructuredAgentLaunchOnLocalHost(
worktreeId: string,
sessionId: string,
onTombstoned: () => void,
launchPromise?: Promise<unknown>
): void {
deleteStructuredAgentLaunchRecord(sessionId)
void retireStructuredAgentSessionTab({
target: { kind: 'local' },
worktreeId,
sessionId,
onError: (error) => {
// A create that has not landed has no host tab yet; main still heard the close.
if (hasRuntimeRpcErrorCode(error, 'tab_not_found')) {
return
}
markStructuredAgentLaunchCancellation(
sessionId,
LOCAL_EXECUTION_HOST_ID,
hasStructuredAgentLaunchCancellationTombstonePersisted(sessionId),
launchPromise
)
onTombstoned()
}
})
}
@@ -9,7 +9,7 @@ import { applyStructuredSessionTabSnapshots } from './snapshot-apply'
import {
beginStructuredAgentSessionAuthoritativeInventory,
startStructuredAgentLaunchCancellationCleanup
} from '../../lib/structured-agent-session-launch-cancellation'
} from '../../lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation'
import { closeStructuredAgentSession } from '../structured-agent-session-close'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import { callRuntimeRpc } from '../runtime-rpc-client'
@@ -29,7 +29,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import {
beginStructuredAgentSessionAuthoritativeInventory,
startStructuredAgentLaunchCancellationCleanup
} from '../../lib/structured-agent-session-launch-cancellation'
} from '../../lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation'
import { suppressCancelledStructuredSessionTabs } from '../structured-agent-session-tab-retirement'
import { LOCAL_STRUCTURED_SESSION_OWNER } from '../local-structured-session-owner'
import { closeStructuredAgentSession } from '../structured-agent-session-close'
@@ -37,7 +37,7 @@ import {
beginStructuredAgentSessionAuthoritativeInventory,
resetStructuredAgentLaunchCancellationForTests,
startStructuredAgentLaunchCancellationCleanup
} from '@/lib/structured-agent-session-launch-cancellation'
} from '@/lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation'
import {
resetStructuredAgentLaunchPersistenceForTests,
writeStructuredAgentLaunchRecord
@@ -0,0 +1,234 @@
// @vitest-environment happy-dom
// With ORCA_LAYOUT_OWNER=main, this machine's host is told about a cancel instead of the window
// keeping a tombstone; paired hosts keep the tombstone in both orders.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import type { ExecutionHostId } from '../../../shared/execution-host'
const mocks = vi.hoisted(() => ({
layoutOwnerIsMain: true,
closeSession:
vi.fn<(target: RuntimeClientTarget, sessionId: string) => Promise<'closed' | 'unsupported'>>(),
callRuntime:
vi.fn<(target: RuntimeClientTarget, method: string, params?: unknown) => Promise<unknown>>()
}))
vi.mock('../../../shared/layout-owner-switch', () => ({
get ORCA_LAYOUT_OWNER_IS_MAIN() {
return mocks.layoutOwnerIsMain
}
}))
vi.mock('@/lib/structured-agent-session-launch-prompt', () => ({
discardStructuredAgentSessionChatSends: vi.fn()
}))
vi.mock('./structured-agent-session-close', () => ({
closeStructuredAgentSession: mocks.closeSession
}))
vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntime }))
vi.mock('./local-session-tab-close-owner', () => ({
withLocalSessionTabCloseOwner: async (_w: string, _t: string, close: () => Promise<unknown>) =>
close()
}))
vi.mock('./runtime-worktree-selector', () => ({
toRuntimeWorktreeSelector: (worktreeId: string) => `id:${worktreeId}`
}))
import {
getStructuredAgentSessionLaunchLifecycle,
hasStructuredAgentSessionLaunchCancellationTombstone,
markStructuredAgentSessionLaunchCancelled,
resetStructuredAgentLaunchRegistryForTests,
setStructuredLaunchState,
subscribeStructuredAgentLaunchStatus,
type StructuredLaunchState
} from '@/lib/structured-agent-session-launch-registry'
import {
readStructuredAgentLaunchRecord,
resetStructuredAgentLaunchPersistenceForTests,
writeStructuredAgentLaunchRecord
} from '@/lib/structured-agent-session-launch-persistence'
import {
acceptPairedHostStructuredSessions,
beginStructuredAgentSessionTabClose,
suppressCancelledStructuredSessionTabs
} from './structured-agent-session-tab-retirement'
const WORKTREE = 'repo-1::worktree-1'
const SESSION = 'chat-1'
const LOCAL: RuntimeClientTarget = { kind: 'local' }
const SERVER: RuntimeClientTarget = { kind: 'environment', environmentId: 'server-1' }
const SERVER_HOST = 'runtime:server-1'
function hostFrame(): RuntimeMobileSessionTabsResult {
return {
worktree: WORKTREE,
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: [
{
type: 'agent-session',
id: `agent-session:${SESSION}`,
title: 'Codex Chat',
sessionId: SESSION,
agent: 'codex',
isActive: false
}
]
}
}
/** A launch whose create is still on its way to `executionHostId`. */
function pendingLaunch(executionHostId: ExecutionHostId, target: RuntimeClientTarget): void {
setStructuredLaunchState({
identity: `codex:${WORKTREE}`,
intent: {
worktreeId: WORKTREE,
executionHostId,
target,
sessionId: SESSION,
agent: 'codex',
params: {
envelope: {
sessionId: SESSION,
clientOperationId: 'operation-1',
expectedRuntimeFence: null,
payloadFingerprint: 'fingerprint-1'
},
worktree: `id:${WORKTREE}`,
agent: 'codex'
}
},
promptDelivery: 'auto-submit',
callers: {
outcome: 'pending',
attempt: { kind: 'first', requestId: 'plus-pick', stagedPrompt: null },
entries: new Set(),
promptDeliveryResults: new Set(),
onSettled: () => undefined
},
promise: new Promise(() => undefined),
visibilityUnknown: false,
cancelled: false,
selection: { held: {} }
} satisfies StructuredLaunchState)
}
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
mocks.layoutOwnerIsMain = true
mocks.closeSession.mockResolvedValue('closed')
mocks.callRuntime.mockResolvedValue(undefined)
resetStructuredAgentLaunchRegistryForTests()
resetStructuredAgentLaunchPersistenceForTests()
})
describe('this machine, with main owning the layout', () => {
const hostTabClose = [
LOCAL,
'session.tabs.close',
{ worktree: `id:${WORKTREE}`, tabId: `agent-session:${SESSION}`, reason: 'user' }
]
it('cancel before the create: retires the chat on main at once and keeps no tombstone', async () => {
// The create has not landed, so main has no tab for it yet.
mocks.callRuntime.mockRejectedValueOnce(new Error('tab_not_found'))
pendingLaunch('local', LOCAL)
markStructuredAgentSessionLaunchCancelled(WORKTREE, SESSION, 'local')
await vi.waitFor(() => expect(mocks.callRuntime).toHaveBeenCalledWith(...hostTabClose))
expect(mocks.closeSession).toHaveBeenCalledWith(LOCAL, SESSION)
// Let the retirement settle, so a tombstone written on its answer would show.
await new Promise((resolve) => setTimeout(resolve, 0))
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(false)
expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE, SESSION)).toBeNull()
})
it('create before the cancel (an aborted launch): closes the chat and its published tab', async () => {
pendingLaunch('local', LOCAL)
markStructuredAgentSessionLaunchCancelled(WORKTREE, SESSION, 'local')
await vi.waitFor(() => expect(mocks.callRuntime).toHaveBeenCalledWith(...hostTabClose))
expect(mocks.closeSession).toHaveBeenCalledWith(LOCAL, SESSION)
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(false)
})
it('cancel after a reload, when only the saved launch is left: tells main and forgets it', () => {
writeStructuredAgentLaunchRecord({
sessionId: SESSION,
executionHostId: 'local',
agent: 'codex',
lifecycle: 'pending',
clientOperationId: 'operation-1',
payloadFingerprint: 'fingerprint-1',
expectedRuntimeFence: null
})
markStructuredAgentSessionLaunchCancelled(WORKTREE, SESSION, 'local')
expect(mocks.closeSession).toHaveBeenCalledWith(LOCAL, SESSION)
expect(readStructuredAgentLaunchRecord(SESSION)).toBeUndefined()
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(false)
})
it('a main that cannot be told gets the tombstone, so a late listing is still hidden', async () => {
mocks.closeSession.mockRejectedValueOnce(new Error('host unavailable'))
pendingLaunch('local', LOCAL)
const listener = vi.fn()
subscribeStructuredAgentLaunchStatus(listener)
markStructuredAgentSessionLaunchCancelled(WORKTREE, SESSION, 'local')
await vi.waitFor(() =>
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(true)
)
expect(listener).toHaveBeenCalledTimes(2)
expect(suppressCancelledStructuredSessionTabs(hostFrame(), LOCAL).tabs).toEqual([])
})
it('switch off: keeps the tombstone and leaves the close to the tab close, as today', () => {
mocks.layoutOwnerIsMain = false
pendingLaunch('local', LOCAL)
markStructuredAgentSessionLaunchCancelled(WORKTREE, SESSION, 'local')
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(true)
expect(mocks.closeSession).not.toHaveBeenCalled()
})
})
describe('a paired server, with main owning the layout', () => {
it('cancel before the create: keeps the tombstone and hides and closes the late listing', () => {
pendingLaunch(SERVER_HOST, SERVER)
markStructuredAgentSessionLaunchCancelled(WORKTREE, SESSION, SERVER_HOST)
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(true)
expect(mocks.closeSession).not.toHaveBeenCalled()
expect(acceptPairedHostStructuredSessions(hostFrame(), 'server-1').tabs).toEqual([])
expect(mocks.closeSession).toHaveBeenCalledWith(SERVER, SESSION)
})
it('create before the cancel: the published chat closes as any chat does, no tombstone', () => {
pendingLaunch(SERVER_HOST, SERVER)
expect(acceptPairedHostStructuredSessions(hostFrame(), 'server-1').tabs).toHaveLength(1)
// Once the create has answered, the tab is not provisional, so its close cancels nothing.
beginStructuredAgentSessionTabClose({
target: SERVER,
worktreeId: WORKTREE,
sessionId: SESSION,
provisional: false
})
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(false)
expect(mocks.closeSession).toHaveBeenCalledWith(SERVER, SESSION)
})
})