diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 62f88672342..3c32989a4f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -547,12 +547,13 @@ describe('restart', () => { * them. Every lease loads unreconciled, so this is the state that decides * whether a persisted session is reachable at all. */ async function reboot( - probeOwner: (record: AgentSessionRecord) => Promise + probeOwner: (record: AgentSessionRecord) => Promise, + adapterOverrides: Partial = {} ) { store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) host = new StructuredAgentSessionHost({ store, - adapter: adapter(), + adapter: { ...adapter(), ...adapterOverrides }, journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-b', @@ -633,14 +634,14 @@ describe('restart', () => { handoffStage: null, handoffOperationId: null }) - await expect(host.handoffStatus(SESSION)).resolves.toMatchObject({ + expect(host.handoffStatus(SESSION)).toMatchObject({ owner: 'native', phase: 'idle', stage: null }) }) - it('answers the owner status of a starting chat once its start settles', async () => { + it('answers native for a chat whose start is still in flight', async () => { await attach() await reboot(async () => ({ outcome: 'pid-absent' })) await host.restoreReadableSessions() @@ -658,11 +659,26 @@ describe('restart', () => { const hold = host.hold(SESSION, 'surface-1') await started.promise + const claimMidStart = store.getRecord(SESSION)?.lease.claimStatus const status = host.handoffStatus(SESSION) release.resolve() await hold - await expect(status).resolves.toMatchObject({ owner: 'native', stage: null }) + // Mid-start the lease is only reserved; ownership does not wait for the agent. + expect(claimMidStart).toBe('reserved') + expect(status).toMatchObject({ owner: 'native' }) + }) + + it('vouches for no owner of a chat in manual recovery or one this host cannot run', async () => { + await attach() + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + lease: { ...record.lease, handoffStage: 'manual-recovery' } + })) + expect(host.handoffStatus(SESSION)).toMatchObject({ owner: 'none', phase: 'failed' }) + + await reboot(async () => ({ outcome: 'pid-absent' }), { supportsCreate: () => false }) + expect(() => host.handoffStatus(SESSION)).toThrow('structured_agent_session_unsupported') }) it("keeps a session whose owner cannot be probed out of a live writer's hands", async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 91351f05fca..47a6d509ea8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -273,17 +273,9 @@ export class StructuredAgentSessionHost { commands: this.deps.adapter.readCommands?.(sessionId) }) - async handoffStatus(sessionId: string): Promise { - this.requireSession(sessionId) - // Queued behind an in-flight attach, so a starting chat answers with its settled owner. - return this.serialize(sessionId, async () => { - const record = this.deps.store.getRecord(sessionId) - if (!record) { - throw new Error('agent_session_identity_required') - } - return structuredAgentSessionOwnerStatus(record) - }) - } + /** From the record store, never the session map: an idle-released chat has no map entry. */ + handoffStatus = (sessionId: string): SessionWire.AgentSessionHandoffStatus => + structuredAgentSessionOwnerStatus(this.deps, sessionId) history: StructuredAgentSessionBackgroundTaskChannel['history'] = (request) => this.backgroundTasks.history(request) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts index 890b8f34fee..d29c2dacc51 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts @@ -1,11 +1,22 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { adapterSupportsRecord } from './structured-agent-session-provider-support' /** The `agentSession.handoffStatus` answer. Released desktop clients gate worktree activation on - * `owner`, so the method outlives the terminal handoff it was named for. */ + * `owner`, so the method outlives the terminal handoff it was named for. It reports ownership, not + * liveness: a chat whose agent is stopped, idle-released or still starting is owned all the same. */ export function structuredAgentSessionOwnerStatus( - record: AgentSessionRecord + deps: Pick, + sessionId: string ): AgentSessionHandoffStatus { + const record = deps.store.getRecord(sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + // Same refusal as reveal: a host that cannot run this chat vouches for no owner. + if (!adapterSupportsRecord(deps.adapter, record)) { + throw new Error('structured_agent_session_unsupported') + } const { handoffStage: stage, handoffOperationId: operationId } = record.lease if (stage === 'manual-recovery') { return { @@ -21,7 +32,7 @@ export function structuredAgentSessionOwnerStatus( } } return { - owner: record.lease.claimStatus === 'live' && record.lease.ownerProcess ? 'native' : 'none', + owner: 'native', direction: stage ? 'to-native' : null, phase: stage ? 'switching' : 'idle', stage, diff --git a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts new file mode 100644 index 00000000000..f2c7cbd39d4 --- /dev/null +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -0,0 +1,153 @@ +// A chat whose agent is not running still owns its conversation. Released desktop clients gate +// worktree activation on the host's owner answer, so a chat at rest that answered anything but +// `native` blocked the gate, which then skips adopting and resuming the worktree's paneless agents. +// This drives the real host's answer through the desktop's activation gate for the two ways a chat +// comes to rest. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { StructuredAgentSessionAdapter } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter' +import { StructuredAgentSessionHost } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + resetHostTestOperationIds +} from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { AgentSessionRecordStore } from '../../src/main/runtime/agent-session-record-store' +import { useAppStore } from '../../src/renderer/src/store' +import { runWorktreeAgentActivationGate } from '../../src/renderer/src/lib/worktree-agent-activation-gate' +import { readWorktreeStructuredActivationInventory } from '../../src/renderer/src/lib/worktree-agent-structured-inventory' +import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' + +const WORKTREE = 'repo-1::/workspace/repo' +const SURFACE = 'desktop-chat:1' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let closeSession: Mock> + +function openHost(): void { + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire: async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + closeSession, + releaseAcquisition: async () => true, + dispatch: async () => ({ state: 'rejected', reason: 'unused' }), + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => undefined, + setOption: async () => undefined + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + releaseGraceMs: 5, + probeOwner: async () => ({ outcome: 'pid-absent' }), + now: () => NOW + }) +} + +/** The desktop's runtime bridge, answering the two reads the gate makes from the real host. */ +function serveDesktopRuntime(): void { + const tabs: RuntimeMobileSessionTabsResult = { + worktree: WORKTREE, + publicationEpoch: 'owner-status-test', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'agent-session', + id: `structured-agent-session-${SESSION}`, + title: 'Codex Chat', + sessionId: SESSION, + agent: 'codex', + isActive: false + } + ] + } + const call = async ({ method, params }: { method: string; params: { sessionId?: string } }) => { + if (method === 'session.tabs.list') { + return { ok: true, result: tabs } + } + if (method === 'agentSession.handoffStatus') { + try { + return { ok: true, result: host.handoffStatus(params.sessionId ?? '') } + } catch (error) { + return { ok: false, error: { code: String(error), message: String(error) } } + } + } + throw new Error(`Unexpected runtime method: ${method}`) + } + vi.stubGlobal('window', { api: { runtime: { call } } }) +} + +function activate(): ReturnType { + return runWorktreeAgentActivationGate(WORKTREE, { + getState: () => useAppStore.getState(), + listSessions: async () => [], + listSurfaceOwners: async () => null, + hasStructuredSession: readWorktreeStructuredActivationInventory, + resume: () => 0 + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-owner-status-')) + resetHostTestOperationIds() + closeSession = vi.fn(async () => true) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + openHost() + expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) + serveDesktopRuntime() +}) + +afterEach(async () => { + vi.unstubAllGlobals() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('a chat at rest keeps its worktree activatable', () => { + it('after idle release forgot the session', async () => { + await host.hold(SESSION, SURFACE) + host.release(SESSION, SURFACE) + await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) + expect(closeSession).toHaveBeenCalledWith(SESSION) + + expect(host.handoffStatus(SESSION)).toMatchObject({ owner: 'native' }) + expect(await activate()).toBe('structured') + }) + + it('after an app restart restored it for reading', async () => { + await host.flushAllStreamedEvents() + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + openHost() + await host.restoreReadableSessions() + expect(host.hasSession(SESSION)).toBe(true) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + ownerProcess: null + }) + + expect(host.handoffStatus(SESSION)).toMatchObject({ owner: 'native' }) + expect(await activate()).toBe('structured') + }) +})