diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts index 0030760b9573..f59f77352454 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts @@ -130,7 +130,7 @@ export async function handoffStructuredSessionToNative( throw error } context.releaseOwner(sessionId) - deps.transport?.revealNativeSession?.({ + await deps.transport?.revealNativeSession?.({ workspaceId: record.location.workspaceId, sessionId, agent: record.provider, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts index 27769c8d5b4a..7155826702da 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts @@ -49,7 +49,7 @@ export type StructuredAgentSessionHandoffTransport = { sessionId: string agent?: 'claude' | 'codex' adoptedTerminal?: true - }): void + }): Promise | void waitForTuiExit(owner: StructuredTuiOwner): Promise<{ transcriptPath?: string }> waitForTuiIdleOrExit( owner: StructuredTuiOwner, 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 47276ea1ba07..c2eecc4d7839 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 @@ -184,6 +184,14 @@ export class StructuredAgentSessionHost { return listStructuredAgentSessionTabs(this.sessions) } + getPersistedVisibleSessionTabIndex(): { present: boolean; sessionIds: string[] } { + return this.deps.store.getVisibleSessionTabIndex() + } + + setSessionTabVisibility(sessionId: string, visible: boolean): Promise { + return this.deps.store.setSessionTabVisibility(sessionId, visible) + } + reconcileRestartLeases = async (): Promise => { const refusal = await this.reconcileLeases('startup') if (refusal) { diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts index 8b8ef6cc4e41..7eb3cacdc7c0 100644 --- a/src/main/runtime/agent-session-record-store-file.ts +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -26,6 +26,8 @@ import { renameDurable, writeTempFileDurable } from '../durable-file-write' +import { parseVisibleSessionIds } from './agent-session-visible-tab-index' +import { serializeAgentSessionStoreState } from './agent-session-store-serialization' export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const @@ -41,6 +43,10 @@ export type AgentSessionStoreState = { retiredClaimKeys: RetiredAgentSessionClaimKey[] /** Rows this build cannot validate, kept with a durable refusal reason. */ unreadableRecords: Map + /** Structured sessions that currently have a visible chat tab. */ + visibleSessionIds: Set + /** True once this store has committed the visibility index field. */ + visibleSessionIdsIndexPresent: boolean } export type LoadedAgentSessionStore = { @@ -58,9 +64,7 @@ export function agentSessionStorePath(directory: string): string { return join(directory, AGENT_SESSION_STORE_FILE_NAME) } -function backupPath(filePath: string): string { - return `${filePath}.bak` -} +const backupPath = (filePath: string): string => `${filePath}.bak` function emptyState(hostId: string): AgentSessionStoreState { return { @@ -69,7 +73,9 @@ function emptyState(hostId: string): AgentSessionStoreState { records: new Map(), operations: new Map(), retiredClaimKeys: [], - unreadableRecords: new Map() + unreadableRecords: new Map(), + visibleSessionIds: new Set(), + visibleSessionIdsIndexPresent: false } } @@ -77,7 +83,7 @@ export function agentSessionStoreRevision(state: AgentSessionStoreState): string return createHash('sha256') .update(String(state.schemaVersion)) .update('\0') - .update(serializeState(state)) + .update(serializeAgentSessionStoreState(state)) .digest('hex') } @@ -101,6 +107,7 @@ function parseState( operations?: unknown retiredClaimKeys?: unknown unusableRecords?: unknown + visibleSessionIds?: unknown } if ( !Number.isSafeInteger(file.schemaVersion) || @@ -208,6 +215,16 @@ function parseState( state.retiredClaimKeys.push({ keyId: key.keyId, retiredAt: key.retiredAt as number }) } } + const visibleSessionIds = parseVisibleSessionIds( + file.visibleSessionIds, + schemaVersion, + AGENT_SESSION_STORE_SCHEMA_VERSION + ) + if (!visibleSessionIds.valid) { + return null + } + state.visibleSessionIdsIndexPresent = visibleSessionIds.present + visibleSessionIds.ids.forEach((sessionId) => state.visibleSessionIds.add(sessionId)) return { state, needsRewrite } } @@ -295,21 +312,6 @@ export async function loadAgentSessionStore( } } -function serializeState(state: AgentSessionStoreState): string { - const records: Record = Object.create(null) - for (const [sessionId, record] of state.records) { - records[sessionId] = record - } - return JSON.stringify({ - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, - hostId: state.hostId, - records, - operations: Object.fromEntries(state.operations), - retiredClaimKeys: state.retiredClaimKeys, - unusableRecords: Object.fromEntries(state.unreadableRecords) - }) -} - /** * Commit the whole state. The live path is never absent: the new content is made durable in a temp * file first, a validated primary is COPIED to the backup, and only then does the rename publish it. @@ -329,7 +331,7 @@ export async function saveAgentSessionStore( await chmod(directory, 0o700) const tmpPath = durableWriteTempPath(filePath) try { - await writeTempFileDurable(tmpPath, serializeState(state), 0o600) + await writeTempFileDurable(tmpPath, serializeAgentSessionStoreState(state), 0o600) // Only a primary parsed under the transaction lock may replace the backup. During recovery the // primary is corrupt or absent, so the known-good backup must survive until publication. if (options.primaryStatus === 'validated') { diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 85f775960102..6fc0e59f212c 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -115,6 +115,29 @@ export class AgentSessionRecordStore { listRecords = (): AgentSessionRecord[] => [...this.state.records.values()] + listVisibleSessionIds = (): string[] => + [...this.state.visibleSessionIds].filter((sessionId) => this.state.records.has(sessionId)) + + getVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } => ({ + present: this.state.visibleSessionIdsIndexPresent, + sessionIds: this.listVisibleSessionIds() + }) + + /** Persist the user-visible tab reference separately from the rollback-sensitive profile tabs. */ + setSessionTabVisibility(sessionId: string, visible: boolean): Promise { + return this.transact(() => { + if (visible) { + if (!this.state.records.has(sessionId)) { + throw new Error('agent_session_identity_required') + } + this.state.visibleSessionIds.add(sessionId) + } else { + this.state.visibleSessionIds.delete(sessionId) + } + this.state.visibleSessionIdsIndexPresent = true + }) + } + listByScope(location: AgentSessionExecutionLocation): AgentSessionRecord[] { const scope = agentSessionScopeKey(location) return this.listRecords().filter((record) => agentSessionScopeKey(record.location) === scope) diff --git a/src/main/runtime/agent-session-recovery-publish-fault.test.ts b/src/main/runtime/agent-session-recovery-publish-fault.test.ts index 37ad172bdfac..f3f2ba91d2aa 100644 --- a/src/main/runtime/agent-session-recovery-publish-fault.test.ts +++ b/src/main/runtime/agent-session-recovery-publish-fault.test.ts @@ -47,7 +47,9 @@ function state(generation: number): AgentSessionStoreState { records: new Map(), operations: new Map(), retiredClaimKeys: [{ keyId: `generation-${generation}`, retiredAt: generation }], - unreadableRecords: new Map() + unreadableRecords: new Map(), + visibleSessionIds: new Set(), + visibleSessionIdsIndexPresent: false } } diff --git a/src/main/runtime/agent-session-store-serialization.ts b/src/main/runtime/agent-session-store-serialization.ts new file mode 100644 index 000000000000..cda3e44d7ff2 --- /dev/null +++ b/src/main/runtime/agent-session-store-serialization.ts @@ -0,0 +1,20 @@ +import type { AgentSessionStoreState } from './agent-session-record-store-file' + +export function serializeAgentSessionStoreState(state: AgentSessionStoreState): string { + const records: Record = Object.create(null) + for (const [sessionId, record] of state.records) { + records[sessionId] = record + } + const serialized: Record = { + schemaVersion: state.schemaVersion, + hostId: state.hostId, + records, + operations: Object.fromEntries(state.operations), + retiredClaimKeys: state.retiredClaimKeys, + unusableRecords: Object.fromEntries(state.unreadableRecords) + } + if (state.visibleSessionIdsIndexPresent) { + serialized.visibleSessionIds = [...state.visibleSessionIds] + } + return JSON.stringify(serialized) +} diff --git a/src/main/runtime/agent-session-store-transaction-queue.ts b/src/main/runtime/agent-session-store-transaction-queue.ts index 43266c0f1135..060ec3bd6334 100644 --- a/src/main/runtime/agent-session-store-transaction-queue.ts +++ b/src/main/runtime/agent-session-store-transaction-queue.ts @@ -37,12 +37,17 @@ function agentSessionStoreStateChanged( records: ReadonlyMap, operations: ReadonlyMap, retiredClaimKeys: AgentSessionStoreState['retiredClaimKeys'], - unreadableRecords: AgentSessionStoreState['unreadableRecords'] + unreadableRecords: AgentSessionStoreState['unreadableRecords'], + visibleSessionIds: AgentSessionStoreState['visibleSessionIds'], + visibleSessionIdsIndexPresent: AgentSessionStoreState['visibleSessionIdsIndexPresent'] ): boolean { return ( !mapEntriesMatch(state.records, records) || !mapEntriesMatch(state.operations, operations) || !mapEntriesMatch(state.unreadableRecords, unreadableRecords) || + state.visibleSessionIdsIndexPresent !== visibleSessionIdsIndexPresent || + state.visibleSessionIds.size !== visibleSessionIds.size || + [...state.visibleSessionIds].some((id) => !visibleSessionIds.has(id)) || state.retiredClaimKeys.length !== retiredClaimKeys.length || state.retiredClaimKeys.some((entry, index) => entry !== retiredClaimKeys[index]) ) @@ -94,6 +99,8 @@ export class AgentSessionStoreTransactionQueue { const operations = new Map(this.state.operations) const retiredClaimKeys = [...this.state.retiredClaimKeys] const unreadableRecords = new Map(this.state.unreadableRecords) + const visibleSessionIds = new Set(this.state.visibleSessionIds) + const visibleSessionIdsIndexPresent = this.state.visibleSessionIdsIndexPresent try { // The lost commit may have granted a higher fence than the backup records show. Rather // than refuse forever, raise every recovered fence clear of anything that commit could @@ -111,7 +118,9 @@ export class AgentSessionStoreTransactionQueue { records, operations, retiredClaimKeys, - unreadableRecords + unreadableRecords, + visibleSessionIds, + visibleSessionIdsIndexPresent ) ) { return result @@ -130,6 +139,8 @@ export class AgentSessionStoreTransactionQueue { this.state.operations = operations this.state.retiredClaimKeys = retiredClaimKeys this.state.unreadableRecords = unreadableRecords + this.state.visibleSessionIds = visibleSessionIds + this.state.visibleSessionIdsIndexPresent = visibleSessionIdsIndexPresent throw error } }) diff --git a/src/main/runtime/agent-session-visible-tab-index.ts b/src/main/runtime/agent-session-visible-tab-index.ts new file mode 100644 index 000000000000..e55ccce4ae62 --- /dev/null +++ b/src/main/runtime/agent-session-visible-tab-index.ts @@ -0,0 +1,21 @@ +export function parseVisibleSessionIds( + raw: unknown, + schemaVersion: number, + currentSchemaVersion: number +): { ids: string[]; present: boolean; valid: boolean } { + if (raw === undefined) { + return { ids: [], present: false, valid: true } + } + if (!Array.isArray(raw)) { + return { ids: [], present: false, valid: schemaVersion !== currentSchemaVersion } + } + const ids: string[] = [] + for (const value of raw) { + if (typeof value === 'string' && value.length > 0) { + ids.push(value) + } else if (schemaVersion === currentSchemaVersion) { + return { ids: [], present: true, valid: false } + } + } + return { ids, present: true, valid: true } +} 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 39275ebedc15..52db96737058 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -107,6 +107,101 @@ describe('structured session cold restoration', () => { ) }) + it('prefers the durable visible-session index after a legacy profile drops agent tabs', async () => { + const runtime = new OrcaRuntimeService() + const restoreReadableSessions = vi.fn(async () => undefined) + const internal = runtime as unknown as { + store: { getWorkspaceSession: () => unknown } + hasPersistedStructuredAgentSessionStore(): boolean + getKnownWorkspaceSessionWorktreeIds(): Set + hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set + refreshMobileSessionPtyRecords(): Promise | null> + ensureStructuredAgentSessionHost(): Promise + } + internal.store = { + getWorkspaceSession: () => ({ + activeRepoId: null, + activeWorktreeId: 'workspace-1', + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { 'workspace-1': [] } + }) + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.getKnownWorkspaceSessionWorktreeIds = () => new Set() + internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() + internal.refreshMobileSessionPtyRecords = async () => new Set() + internal.ensureStructuredAgentSessionHost = async () => undefined + setStructuredAgentSessionHost({ + reconcileRestartLeases: async () => undefined, + getPersistedVisibleSessionTabIndex: () => ({ + present: true, + sessionIds: ['session-survives-rollback'] + }), + restoreReadableSessions, + listSessionTabs: () => [] + } as never) + + await runtime.restoreStructuredAgentSessionTabs() + + expect(restoreReadableSessions).toHaveBeenCalledWith(['session-survives-rollback']) + }) + + it('treats an empty durable visible-session index as authoritative', async () => { + const runtime = new OrcaRuntimeService() + const restoreReadableSessions = vi.fn(async () => undefined) + const internal = runtime as unknown as { + store: { getWorkspaceSession: () => unknown } + hasPersistedStructuredAgentSessionStore(): boolean + getKnownWorkspaceSessionWorktreeIds(): Set + hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set + refreshMobileSessionPtyRecords(): Promise | null> + ensureStructuredAgentSessionHost(): Promise + } + internal.store = { + getWorkspaceSession: () => ({ + activeRepoId: null, + activeWorktreeId: 'workspace-1', + activeTabId: 'agent-session:closed-session', + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + activeTabIdByWorktree: { 'workspace-1': 'agent-session:closed-session' }, + unifiedTabs: { + 'workspace-1': [ + { + id: 'agent-session:closed-session', + entityId: 'closed-session', + groupId: 'group-1', + worktreeId: 'workspace-1', + contentType: 'agent-session', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }) + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.getKnownWorkspaceSessionWorktreeIds = () => new Set() + internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() + internal.refreshMobileSessionPtyRecords = async () => new Set() + internal.ensureStructuredAgentSessionHost = async () => undefined + setStructuredAgentSessionHost({ + reconcileRestartLeases: async () => undefined, + getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }), + restoreReadableSessions, + listSessionTabs: () => [] + } as never) + + await runtime.restoreStructuredAgentSessionTabs() + + expect(restoreReadableSessions).toHaveBeenCalledWith([]) + }) + it('normalizes a restored tab id and removes it when closed', async () => { const runtime = new OrcaRuntimeService() const closeSessionTab = vi.fn(async () => undefined) @@ -225,7 +320,7 @@ describe('structured session cold restoration', () => { throw new Error('session_tab_not_found') }) } as never) - runtime.publishStructuredAgentSessionTab({ + await runtime.publishStructuredAgentSessionTab({ workspaceId: 'workspace-1', sessionId: 'session-1', agent: 'codex', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ea852917b944..f59d4a267373 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -10132,7 +10132,7 @@ export class OrcaRuntimeService { } } } - this.closeStructuredAgentSessionTab(worktreeId, snapshot, tab) + await this.closeStructuredAgentSessionTab(worktreeId, snapshot, tab) } else { if (!this.notifier?.closeSessionTab) { throw new Error('runtime_unavailable') @@ -10238,11 +10238,15 @@ export class OrcaRuntimeService { return true } - private closeStructuredAgentSessionTab( + private async closeStructuredAgentSessionTab( worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionAgentTab - ): void { + ): Promise { + const host = getStructuredAgentSessionHost() + if (typeof host?.setSessionTabVisibility === 'function') { + await host.setSessionTabVisibility(tab.sessionId, false) + } const nextTabs = snapshot.tabs.filter((candidate) => candidate.id !== tab.id) const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { @@ -11754,11 +11758,11 @@ export class OrcaRuntimeService { stopRecoveredOwner: (record) => this.stopStructuredSessionProcess(record), tuiStatus: (owner) => this.structuredTuiStatus(owner), closeTuiOwner: (owner) => this.closeStructuredTuiOwner(owner), - revealNativeSession: ({ workspaceId, sessionId, agent = 'codex', adoptedTerminal }) => { + revealNativeSession: async ({ workspaceId, sessionId, agent = 'codex', adoptedTerminal }) => { if (adoptedTerminal || agent !== 'codex') { return } - this.publishStructuredAgentSessionTab({ + await this.publishStructuredAgentSessionTab({ workspaceId, sessionId, agent, @@ -12317,10 +12321,15 @@ export class OrcaRuntimeService { private 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 + ) await host?.restoreReadableSessions( - collectSavedStructuredAgentSessionIds( - this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null - ) + persistedVisibleIndex.present ? persistedVisibleIndex.sessionIds : profileIds ) for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { @@ -12337,7 +12346,7 @@ export class OrcaRuntimeService { while (sessionId.startsWith('agent-session:')) { sessionId = sessionId.slice('agent-session:'.length) } - this.publishStructuredAgentSessionTab({ + await this.publishStructuredAgentSessionTab({ ...session, agent: 'codex', sessionId, @@ -12347,13 +12356,17 @@ export class OrcaRuntimeService { } } - publishStructuredAgentSessionTab(input: { + async publishStructuredAgentSessionTab(input: { workspaceId: string sessionId: string agent: 'codex' activate: boolean notify?: boolean - }): void { + }): Promise { + const host = getStructuredAgentSessionHost() + if (typeof host?.setSessionTabVisibility === 'function') { + await host.setSessionTabVisibility(input.sessionId, true) + } const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId) const id = `agent-session:${input.sessionId}` if (existing?.tabs.some((tab) => tab.id === id)) { 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 4da598ff8762..c698cc7229c6 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -95,6 +95,8 @@ function hostStub(): StructuredAgentSessionHost { })), send: vi.fn(async () => ({ ok: true, replayed: false })), cancel: vi.fn(async () => ({ ok: true, replayed: false })), + close: vi.fn(async () => undefined), + setSessionTabVisibility: vi.fn(async () => undefined), respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), setOption: vi.fn(async () => ({ ok: true, replayed: false })), handoffStatus: vi.fn(async () => ({ owner: 'native' })), @@ -178,6 +180,14 @@ afterEach(() => { }) describe('capability gating', () => { + it('clears durable tab visibility when closing through the agent-session RPC', async () => { + const response = await call('agentSession.close', { sessionId: SESSION }, STRUCTURED_CLIENT) + + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + expect(hostCalls.close).toHaveBeenCalledWith(SESSION) + expect(hostCalls.setSessionTabVisibility).toHaveBeenCalledWith(SESSION, false) + }) + it('advertises the capability without bumping the protocol version', () => { expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 0f007006109d..8ea85aa0e879 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -91,7 +91,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ envelope: { ...params.envelope, payloadFingerprint: hostFingerprint } }) if (result.ok && resolved.agent === 'codex') { - ctx.runtime.publishStructuredAgentSessionTab({ + await ctx.runtime.publishStructuredAgentSessionTab({ workspaceId: resolved.location.workspaceId, sessionId: result.value.sessionId, agent: 'codex', @@ -128,7 +128,12 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ name: 'agentSession.close', params: OptionsParams, handler: async (params, ctx) => { - await requireHost(ctx).close(params.sessionId) + const host = requireHost(ctx) + await host.close(params.sessionId) + // Terminal-disposal closes use this RPC without the session-tabs retirement RPC. + if (typeof host.setSessionTabVisibility === 'function') { + await host.setSessionTabVisibility(params.sessionId, false) + } return { ok: true as const } } }), diff --git a/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts b/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts new file mode 100644 index 000000000000..293ec194aae7 --- /dev/null +++ b/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts @@ -0,0 +1,145 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { safeParseWorkspaceSession } from '../../shared/workspace-session-schema' +import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { AGENT_SESSION_STORE_FILE_NAME } from './agent-session-record-store-file' +import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' + +const WORKSPACE = 'workspace-1' +const SESSION = 'session-alpha-1' + +function structuredTab() { + return { + id: `agent-session:${SESSION}`, + entityId: SESSION, + groupId: 'group-1', + worktreeId: WORKSPACE, + contentType: 'agent-session' as const, + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function profileWithStructuredTab(): Record { + return { + activeRepoId: null, + activeWorktreeId: WORKSPACE, + activeTabId: structuredTab().id, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + activeTabIdByWorktree: { [WORKSPACE]: structuredTab().id }, + unifiedTabs: { [WORKSPACE]: [structuredTab()] } + } +} + +/** The pinned base rejects the new discriminator and salvages the remaining profile. */ +function pinnedBaseRoundTrip(raw: Record): Record { + const unifiedTabs = raw.unifiedTabs as Record | undefined + return { + ...raw, + ...(unifiedTabs + ? { + unifiedTabs: Object.fromEntries( + Object.entries(unifiedTabs).map(([worktreeId, tabs]) => [ + worktreeId, + tabs.filter((tab) => { + const contentType = (tab as { contentType?: unknown }).contentType + return ( + contentType === 'terminal' || + contentType === 'editor' || + contentType === 'diff' || + contentType === 'conflict-review' || + contentType === 'check-details' || + contentType === 'browser' || + contentType === 'simulator' + ) + }) + ]) + ) + } + : {}) + } +} + +let root: string + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('structured session rollback compatibility', () => { + it('keeps the visible session reference through target → base → target', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-structured-rollback-')) + const storeDir = join(root, 'agent-store') + await mkdir(storeDir, { recursive: true }) + const record = agentSessionRecordFixture({ + ...agentSessionRecordFixture().lease, + sessionId: SESSION, + runtimeKind: 'native' + }) + await writeFile( + join(storeDir, AGENT_SESSION_STORE_FILE_NAME), + JSON.stringify({ + schemaVersion: 2, + hostId: 'local', + records: { [SESSION]: record }, + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + }) + ) + const journalDir = journalDirectoryFor(root, { workspaceId: WORKSPACE, sessionId: SESSION }) + await mkdir(journalDir, { recursive: true }) + await writeFile(join(journalDir, 'journal.log'), 'durable-journal-fixture\n') + + const target = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + expect(target.getVisibleSessionTabIndex()).toEqual({ present: false, sessionIds: [] }) + await target.setSessionTabVisibility(SESSION, true) + expect(target.getVisibleSessionTabIndex()).toEqual({ present: true, sessionIds: [SESSION] }) + + const targetProfile = profileWithStructuredTab() + const targetParsed = safeParseWorkspaceSession(targetProfile) + expect(targetParsed?.success).toBe(true) + expect( + collectSavedStructuredAgentSessionIds(targetParsed?.success ? targetParsed.data : null) + ).toEqual([SESSION]) + + const baseProfile = pinnedBaseRoundTrip(targetProfile) + const baseParsed = safeParseWorkspaceSession(baseProfile) + expect(baseParsed?.success).toBe(true) + expect( + collectSavedStructuredAgentSessionIds(baseParsed?.success ? baseParsed.data : null) + ).toEqual([]) + + await writeFile(join(root, 'profile.json'), JSON.stringify(baseProfile)) + const targetReloadedProfile = safeParseWorkspaceSession(JSON.parse(JSON.stringify(baseProfile))) + expect(targetReloadedProfile?.success).toBe(true) + expect( + collectSavedStructuredAgentSessionIds( + targetReloadedProfile?.success ? targetReloadedProfile.data : null + ) + ).toEqual([]) + const reloaded = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + expect(reloaded.listVisibleSessionIds()).toEqual([SESSION]) + expect(reloaded.getRecord(SESSION)?.providerHandleChain).toHaveLength(1) + await expect(readFile(join(journalDir, 'journal.log'), 'utf8')).resolves.toBe( + 'durable-journal-fixture\n' + ) + expect( + JSON.parse(await readFile(join(storeDir, AGENT_SESSION_STORE_FILE_NAME), 'utf8')) + ).toMatchObject({ visibleSessionIds: [SESSION] }) + + await reloaded.setSessionTabVisibility(SESSION, false) + const afterClose = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + expect(afterClose.getVisibleSessionTabIndex()).toEqual({ present: true, sessionIds: [] }) + expect(afterClose.listVisibleSessionIds()).toEqual([]) + expect(afterClose.getRecord(SESSION)?.providerHandleChain).toHaveLength(1) + }) +})