From eaaae0196f556032c64c665f853d46af97461cec Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:22:24 -0700 Subject: [PATCH] feat(native-chat): record fresh sessions after failed restoration (#25747) * feat(native-chat): record a fresh provider conversation that replaced one the agent could not restore A chat whose saved conversation the agent cannot reopen can now continue in a fresh one: the handle chain records the new conversation as a creation that replaces the lost one (which, why, and when), keeping every earlier link. Rows keep a shape older builds read: the stored chain starts at the latest replacement and carries the earlier links inside it. * refactor(native-chat): store a replaced conversation flat; refuse it where older builds read the row Older builds only read Claude and Codex records, so the nested stored form protected rows no replacement can reach while adding a cap mismatch after a downgrade. Store the chain as held, refuse a replacement in a Claude or Codex chain until one has a stored shape older builds read, and refuse a supersession key on a replacement that names no creation in the chain. * test(native-chat): prove replacement rows survive downgrade and re-upgrade --- config/scripts/pr-code-change-scope.mjs | 1 + config/scripts/pr-code-change-scope.test.mjs | 1 + .../agent-session-provider-handle-encoding.ts | 7 + ...ent-session-provider-handle-replacement.ts | 68 ++++++ .../agent-session-provider-handle.test.ts | 175 ++++++++++++++ src/shared/agent-session-provider-handle.ts | 76 ++++-- .../agent-session-record-stored-form.test.ts | 55 ++++- ...-handle-replacement-downgrade.unit.test.ts | 220 ++++++++++++++++++ 8 files changed, 587 insertions(+), 16 deletions(-) create mode 100644 src/shared/agent-session-provider-handle-replacement.ts create mode 100644 tests/e2e/cross-version-wire/agent-session-handle-replacement-downgrade.unit.test.ts diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 2ae50ded85e..d640244e1ef 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -173,6 +173,7 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/shared/structured-agent-session-send-mutation.ts', 'src/shared/structured-agent-session-outbox.ts', 'src/shared/agent-session-record', + 'src/shared/agent-session-provider-handle', 'src/shared/agent-session-journal-', 'src/main/ai-vault/structured-session-ownership.ts', 'src/main/native-chat/agent-session-journal/', diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index ffd19687528..9420a44b11c 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -365,6 +365,7 @@ describe('per-job path classification', () => { 'src/shared/protocol-version.ts', 'src/shared/terminal-stream-protocol.ts', 'src/shared/agent-session-wire.ts', + 'src/shared/agent-session-provider-handle.ts', 'src/shared/agent-session-mutation-envelope.ts', 'src/shared/agent-session-journal-item-key.ts', 'src/shared/agent-session-journal-types.ts', diff --git a/src/shared/agent-session-provider-handle-encoding.ts b/src/shared/agent-session-provider-handle-encoding.ts index c0c2f6c6f57..b18e43720f1 100644 --- a/src/shared/agent-session-provider-handle-encoding.ts +++ b/src/shared/agent-session-provider-handle-encoding.ts @@ -107,6 +107,13 @@ function isLegacyNamespace(handle: AgentSessionProviderHandleNamespace): boolean ) } +/** Whether builds before the neutral handle read a record of this namespace at all. */ +export function isAgentSessionProviderHandleReadByOlderBuilds( + handle: AgentSessionProviderHandleNamespace +): boolean { + return isLegacyNamespace(handle) +} + /** * An in-memory handle. A Claude or Codex handle must also fit its typed stored shape: Claude's * resume cursor is a leaf id, and Codex has none. diff --git a/src/shared/agent-session-provider-handle-replacement.ts b/src/shared/agent-session-provider-handle-replacement.ts new file mode 100644 index 00000000000..f872e2acbc6 --- /dev/null +++ b/src/shared/agent-session-provider-handle-replacement.ts @@ -0,0 +1,68 @@ +/** + * What a chain link records when the provider could not restore a chat's saved conversation and a + * fresh one took over. + */ + +import type { AgentSessionProviderHandle } from './agent-session-provider-handle' +import { + isAgentSessionProviderHandleKeyFor, + isAgentSessionProviderHandleReadByOlderBuilds +} from './agent-session-provider-handle-encoding' + +const MAX_REPLACEMENT_REASON_LENGTH = 64 + +/** + * Why a chat's agent no longer remembers what came before this link: the provider could not + * restore the conversation the chat had, so a fresh one continues it without that history. + */ +export type AgentSessionProviderHandleReplacement = { + /** Key of the conversation that was lost: the head this creation followed. */ + key: string + /** Open: a later build may record a reason this one does not name, and it must stay readable. */ + reason: 'restore-failed' | (string & {}) + /** When the replacement was made; a link's `observedAt` moves with its resume point. */ + replacedAt: number +} + +export function isAgentSessionProviderHandleReplacement( + handle: AgentSessionProviderHandle, + value: unknown +): value is AgentSessionProviderHandleReplacement { + if ( + typeof value !== 'object' || + value === null || + !('key' in value) || + !('reason' in value) || + !('replacedAt' in value) + ) { + return false + } + const { key, reason, replacedAt } = value + return ( + // Why: older builds read Claude and Codex rows and refuse a creation anywhere but first in a + // chain, so a replacement there would set the whole chat aside after a downgrade. Every other + // agent's row is one they never read. A Claude or Codex fallback must first pick a stored shape. + !isAgentSessionProviderHandleReadByOlderBuilds(handle) && + isAgentSessionProviderHandleKeyFor(handle, key) && + typeof reason === 'string' && + reason.length > 0 && + reason.length <= MAX_REPLACEMENT_REASON_LENGTH && + typeof replacedAt === 'number' && + Number.isSafeInteger(replacedAt) && + replacedAt >= 0 + ) +} + +export function agentSessionProviderHandleReplacementsEqual( + left: AgentSessionProviderHandleReplacement | undefined, + right: AgentSessionProviderHandleReplacement | undefined +): boolean { + return ( + left === right || + (left !== undefined && + right !== undefined && + left.key === right.key && + left.reason === right.reason && + left.replacedAt === right.replacedAt) + ) +} diff --git a/src/shared/agent-session-provider-handle.test.ts b/src/shared/agent-session-provider-handle.test.ts index d66c0356815..dd6d8f93fa7 100644 --- a/src/shared/agent-session-provider-handle.test.ts +++ b/src/shared/agent-session-provider-handle.test.ts @@ -519,3 +519,178 @@ describe('a transport shared code has never heard of', () => { } }) }) + +describe('replacing a conversation the provider could not restore', () => { + const acp = (nativeId: string): AgentSessionProviderHandle => ({ + transport: 'acp', + agent: 'grok', + nativeId + }) + const created = link({ handle: acp('s-1') }) + const resumed = link({ + linkId: 'link-2', + origin: 'resumed', + handle: acp('s-1'), + mintedAtFence: 2 + }) + const lost = { + key: agentSessionProviderHandleKey(acp('s-1')), + reason: 'restore-failed', + replacedAt: 3_000 + } + function fresh(overrides: Partial = {}) { + return link({ + linkId: 'link-3', + handle: acp('s-2'), + mintedAtFence: 3, + observedAt: 3_000, + replaces: lost, + ...overrides + }) + } + + it('follows a reopened conversation and keeps every link before it', () => { + const chain = appendAgentSessionProviderHandleLink([created, resumed], fresh()) + expect(chain).toEqual([created, resumed, fresh()]) + expect(agentSessionProviderHandleChainHead(chain)?.handle.nativeId).toBe('s-2') + expect(isAgentSessionProviderHandleChain(chain)).toBe(true) + // The fresh conversation then resumes like any other. + const reopened = appendAgentSessionProviderHandleLink( + chain, + link({ linkId: 'link-4', origin: 'resumed', handle: acp('s-2'), mintedAtFence: 4 }) + ) + expect(reopened).toHaveLength(4) + expect(isAgentSessionProviderHandleChain(reopened)).toBe(true) + }) + + it('names exactly the head it took over from, on a new root', () => { + for (const bad of [ + fresh({ replaces: { ...lost, key: agentSessionProviderHandleKey(acp('s-9')) } }), + fresh({ handle: acp('s-1') }), + fresh({ linkId: 'link-2' }), + fresh({ replaces: undefined }) + ]) { + expect(() => appendAgentSessionProviderHandleLink([created, resumed], bad)).toThrow( + 'agent_session_provider_handle_invalid' + ) + } + expect(() => + appendAgentSessionProviderHandleLink([created, resumed], fresh({ mintedAtFence: 1 })) + ).toThrow('agent_session_provider_handle_stale_fence') + }) + + it('is only ever a creation, and never opens a chain', () => { + expect(() => appendAgentSessionProviderHandleLink([], fresh())).toThrow( + 'agent_session_provider_handle_invalid' + ) + for (const origin of ['resumed', 'adopted', 'forked'] as const) { + expect(() => + appendAgentSessionProviderHandleLink( + [created, resumed], + fresh({ origin, forkedFromKey: lost.key }) + ) + ).toThrow('agent_session_provider_handle_invalid') + } + }) + + it('reads a reason a later build records, but no malformed one', () => { + const later = fresh({ replaces: { ...lost, reason: 'transport-retired' } }) + expect(appendAgentSessionProviderHandleLink([created, resumed], later)).toHaveLength(3) + for (const replaces of [ + { ...lost, reason: '' }, + { ...lost, reason: 'x'.repeat(65) }, + { ...lost, replacedAt: -1 }, + { key: lost.key, reason: lost.reason } + ]) { + expect(() => + appendAgentSessionProviderHandleLink( + [created, resumed], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: deliberately malformed stored data the guard must refuse. + fresh({ replaces: replaces as AgentSessionProviderHandleLink['replaces'] }) + ) + ).toThrow('agent_session_provider_handle_invalid') + } + }) + + it('stays lost when a fresh conversation the agent never saved is superseded in turn', () => { + const chain = appendAgentSessionProviderHandleLink([created, resumed], fresh()) + const superseding = link({ + linkId: 'link-5', + handle: acp('s-3'), + mintedAtFence: 5, + supersedesKey: agentSessionProviderHandleKey(acp('s-2')) + }) + const superseded = appendAgentSessionProviderHandleLink(chain, superseding) + expect(superseded).toEqual([created, resumed, { ...superseding, replaces: lost }]) + expect(isAgentSessionProviderHandleChain(superseded)).toBe(true) + // Unused across many restarts, it still does not grow. + const again = appendAgentSessionProviderHandleLink(superseded, { + ...superseding, + linkId: 'link-6', + handle: acp('s-4'), + mintedAtFence: 6, + supersedesKey: agentSessionProviderHandleKey(acp('s-3')) + }) + expect(again).toHaveLength(3) + expect(again.at(-1)?.replaces).toEqual(lost) + // A supersession cannot rewrite what was lost, nor land back on it. + expect(() => + appendAgentSessionProviderHandleLink(chain, { + ...superseding, + replaces: { ...lost, replacedAt: 9 } + }) + ).toThrow('agent_session_provider_handle_invalid') + expect(() => + appendAgentSessionProviderHandleLink(chain, { ...superseding, handle: acp('s-1') }) + ).toThrow('agent_session_provider_handle_invalid') + }) + + it('refuses a supersession key that names no creation in the chain', () => { + expect(() => + appendAgentSessionProviderHandleLink( + [created, resumed], + fresh({ supersedesKey: agentSessionProviderHandleKey(acp('s-9')) }) + ) + ).toThrow('agent_session_provider_handle_invalid') + }) + + it('is refused in a Claude or Codex chain, whose rows older builds read', () => { + for (const [first, lost, next] of [ + [CLAUDE, CLAUDE, claudeProviderHandle('sess-2', null)], + [codexProviderHandle('t-1'), codexProviderHandle('t-1'), codexProviderHandle('t-2')] + ] as const) { + expect(() => + appendAgentSessionProviderHandleLink( + [link({ handle: first })], + fresh({ + handle: next, + replaces: { + key: agentSessionProviderHandleKey(lost), + reason: 'restore-failed', + replacedAt: 3_000 + } + }) + ) + ).toThrow('agent_session_provider_handle_invalid') + } + }) + + it('still supersedes only a creation the provider never saved', () => { + const chain = appendAgentSessionProviderHandleLink([created, resumed], fresh()) + const proven = appendAgentSessionProviderHandleLink( + chain, + link({ linkId: 'link-4', origin: 'resumed', handle: acp('s-2'), mintedAtFence: 4 }) + ) + expect(() => + appendAgentSessionProviderHandleLink( + proven, + link({ + linkId: 'link-5', + handle: acp('s-3'), + mintedAtFence: 5, + supersedesKey: agentSessionProviderHandleKey(acp('s-2')) + }) + ) + ).toThrow('agent_session_provider_handle_invalid') + }) +}) diff --git a/src/shared/agent-session-provider-handle.ts b/src/shared/agent-session-provider-handle.ts index 3bf8fb69b06..321884430da 100644 --- a/src/shared/agent-session-provider-handle.ts +++ b/src/shared/agent-session-provider-handle.ts @@ -9,10 +9,17 @@ * Resumes extend the chain, forks start a new identity root, and the chain records which is which * so a fork is never presented as a resume. A creation the provider never saved can be superseded * by a new creation, which takes its place instead of standing beside it: the unsaved handle was - * never a conversation to continue. + * never a conversation to continue. A saved conversation the provider could not restore is instead + * replaced: a new creation follows it and names it, so the chain still says what the agent forgot + * and when. */ import type { AgentType } from './agent-status-types' +import { + agentSessionProviderHandleReplacementsEqual, + isAgentSessionProviderHandleReplacement, + type AgentSessionProviderHandleReplacement +} from './agent-session-provider-handle-replacement' import { agentSessionProviderHandleKey, agentSessionProviderHandleRoot, @@ -24,6 +31,7 @@ import { type PersistedAgentSessionProviderHandle } from './agent-session-provider-handle-encoding' +export type { AgentSessionProviderHandleReplacement } from './agent-session-provider-handle-replacement' export { agentSessionProviderHandleKey, agentSessionProviderHandleRoot, @@ -80,6 +88,8 @@ export type AgentSessionProviderHandleLink = { forkedFromKey?: string /** Key of the unsaved creation this creation replaced. Only set when `origin` is `created`. */ supersedesKey?: string + /** The saved conversation this creation took over from. Only set when `origin` is `created`. */ + replaces?: AgentSessionProviderHandleReplacement } export type AgentSessionProviderHandleChain = readonly AgentSessionProviderHandleLink[] @@ -142,7 +152,10 @@ export function isAgentSessionProviderHandleLink( ? isAgentSessionProviderHandleKeyFor(handle, link.forkedFromKey) : link.forkedFromKey === undefined) && (link.supersedesKey === undefined || - (link.origin === 'created' && isAgentSessionProviderHandleKeyFor(handle, link.supersedesKey))) + (link.origin === 'created' && + isAgentSessionProviderHandleKeyFor(handle, link.supersedesKey))) && + (link.replaces === undefined || + (link.origin === 'created' && isAgentSessionProviderHandleReplacement(handle, link.replaces))) ) } @@ -158,7 +171,7 @@ export function isAgentSessionProviderHandleChain( if (!isAgentSessionProviderHandleLink(link)) { return false } - const next = appendAgentSessionProviderHandleLink(validated, link) + const next = appendLink(validated, link, true) // A persisted chain must name every link exactly once; retry elision belongs at append time. if (next.length !== validated.length + 1) { return false @@ -178,13 +191,23 @@ export function isAgentSessionProviderHandleChain( export function appendAgentSessionProviderHandleLink( chain: AgentSessionProviderHandleChain, link: AgentSessionProviderHandleLink +): AgentSessionProviderHandleLink[] { + return appendLink(chain, link, false) +} + +/** `supersededHead`: the replacement already took the place of a creation no longer in `chain`. */ +function appendLink( + chain: AgentSessionProviderHandleChain, + link: AgentSessionProviderHandleLink, + supersededHead: boolean ): AgentSessionProviderHandleLink[] { if (!isAgentSessionProviderHandleLink(link)) { throw new Error('agent_session_provider_handle_invalid') } const head = agentSessionProviderHandleChainHead(chain) if (!head) { - if (link.origin !== 'created' && link.origin !== 'adopted') { + // A replacement names the conversation before it, so it can never open a chain. + if ((link.origin !== 'created' && link.origin !== 'adopted') || link.replaces !== undefined) { throw new Error('agent_session_provider_handle_invalid') } return [link] @@ -195,14 +218,24 @@ export function appendAgentSessionProviderHandleLink( if (link.mintedAtFence < head.mintedAtFence) { throw new Error('agent_session_provider_handle_stale_fence') } - if (link.origin === 'created' && link.supersedesKey !== undefined) { - return supersedeUnsavedCreation(head, link) - } - if (link.origin === 'created' || link.origin === 'adopted') { - throw new Error('agent_session_provider_handle_invalid') - } const sameRoot = agentSessionProviderHandleRoot(link.handle) === agentSessionProviderHandleRoot(head.handle) + if (link.origin === 'created') { + if (link.supersedesKey !== undefined && !supersededHead) { + return supersedeUnsavedCreation(chain, head, link) + } + if ( + link.replaces === undefined || + link.replaces.key !== agentSessionProviderHandleKey(head.handle) || + sameRoot + ) { + throw new Error('agent_session_provider_handle_invalid') + } + return appendNewLink(chain, link) + } + if (link.origin === 'adopted') { + throw new Error('agent_session_provider_handle_invalid') + } if (link.origin === 'resumed' && !sameRoot) { // Why: a resume that lands on another identity root forked; recording it as a resume would // make Orca claim continuity the provider never gave. @@ -224,6 +257,13 @@ export function appendAgentSessionProviderHandleLink( // Why: re-proving the same handle at the same fence is a retry, not a new identity. return [...chain] } + return appendNewLink(chain, link) +} + +function appendNewLink( + chain: AgentSessionProviderHandleChain, + link: AgentSessionProviderHandleLink +): AgentSessionProviderHandleLink[] { if (findAgentSessionProviderHandleLink(chain, link.linkId)) { // Why: the lease names its exact proof by link id; reuse would make that reference ambiguous. throw new Error('agent_session_provider_handle_invalid') @@ -237,25 +277,31 @@ export function appendAgentSessionProviderHandleLink( } /** - * Replace the chain's only link, a creation the provider proved it never saved, with the creation - * that took its place. Every other head names a conversation the provider held (a resume or fork - * proved it, an adoption imported it), so only a `created` head can be superseded, and only by a - * new identity root that names it. + * Replace the head, a creation the provider proved it never saved, with the creation that took its + * place. Every other head names a conversation the provider held (a resume or fork proved it, an + * adoption imported it), so only a `created` head can be superseded, and only by a new identity + * root that names it. A superseded replacement passes on what it replaced: the conversation that + * was lost is still lost. */ function supersedeUnsavedCreation( + chain: AgentSessionProviderHandleChain, head: AgentSessionProviderHandleLink, link: AgentSessionProviderHandleLink ): AgentSessionProviderHandleLink[] { + const earlier = chain.slice(0, -1) if ( head.origin !== 'created' || link.supersedesKey !== agentSessionProviderHandleKey(head.handle) || agentSessionProviderHandleRoot(link.handle) === agentSessionProviderHandleRoot(head.handle) || + (link.replaces !== undefined && + !agentSessionProviderHandleReplacementsEqual(link.replaces, head.replaces)) || link.linkId === head.linkId ) { throw new Error('agent_session_provider_handle_invalid') } + const next = head.replaces ? { ...link, replaces: head.replaces } : link // Why: in place, so a chat reopened unused across many restarts never grows toward the cap. - return [link] + return earlier.length === 0 ? [next] : appendLink(earlier, next, true) } // ─── Stored form ──────────────────────────────────────────────────────────── diff --git a/src/shared/agent-session-record-stored-form.test.ts b/src/shared/agent-session-record-stored-form.test.ts index 22ef2b54a9a..af43b89cd16 100644 --- a/src/shared/agent-session-record-stored-form.test.ts +++ b/src/shared/agent-session-record-stored-form.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest' -import { isAgentSessionHandleProvider } from './agent-session-provider-handle' +import { + decodePersistedAgentSessionProviderHandleChain, + encodePersistedAgentSessionProviderHandleChain, + isAgentSessionHandleProvider, + type AgentSessionProviderHandle, + type AgentSessionProviderHandleLink +} from './agent-session-provider-handle' import { agentSessionJournalProviderHandle, agentSessionProviderHandleFromWire, @@ -316,3 +322,50 @@ describe('the wire and journal forms', () => { ).toEqual({ kind: 'opaque', agent: 'grok', value: 's' }) }) }) + +describe('a chat whose saved conversation could not be restored', () => { + const acp = (nativeId: string): AgentSessionProviderHandle => ({ + transport: 'acp', + agent: 'grok', + nativeId + }) + const link = ( + linkId: string, + nativeId: string, + fence: number, + extra: Partial = {} + ): AgentSessionProviderHandleLink => ({ + linkId, + handle: acp(nativeId), + origin: 'created', + mintedAtFence: fence, + observedAt: fence * 1_000, + ...extra + }) + const lost = (nativeId: string, replacedAt: number) => ({ + key: agentSessionProviderHandleKey(acp(nativeId)), + reason: 'restore-failed', + replacedAt + }) + // Opened, reopened, lost and replaced twice, and the second replacement reopened. + const chain = [ + link('l1', 's-1', 1), + link('l2', 's-1', 2, { origin: 'resumed' }), + link('l3', 's-2', 3, { replaces: lost('s-1', 3_000) }), + link('l4', 's-3', 4, { replaces: lost('s-2', 4_000) }), + link('l5', 's-3', 7, { origin: 'resumed' }) + ] + + it('stores the chain as it is held, and reads every link back', () => { + const stored = JSON.parse(JSON.stringify(encodePersistedAgentSessionProviderHandleChain(chain))) + expect(stored).toEqual(chain) + expect(decodePersistedAgentSessionProviderHandleChain(stored)).toEqual(chain) + }) + + it('refuses a stored replacement that opens the chain or names another conversation', () => { + const stored = JSON.parse(JSON.stringify(encodePersistedAgentSessionProviderHandleChain(chain))) + expect(decodePersistedAgentSessionProviderHandleChain(stored.slice(2))).toBeNull() + stored[3].replaces.key = agentSessionProviderHandleKey(acp('s-1')) + expect(decodePersistedAgentSessionProviderHandleChain(stored)).toBeNull() + }) +}) diff --git a/tests/e2e/cross-version-wire/agent-session-handle-replacement-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-handle-replacement-downgrade.unit.test.ts new file mode 100644 index 00000000000..ee103350cdf --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-handle-replacement-downgrade.unit.test.ts @@ -0,0 +1,220 @@ +import { expect, test } from 'vitest' +import Database from '../../../src/main/sqlite/sync-database' +import { + agentSessionProviderHandleKey, + claudeProviderHandle, + codexProviderHandle +} from '../../../src/shared/agent-session-provider-handle-encoding' +import { + decodePersistedAgentSessionProviderHandleChain, + encodePersistedAgentSessionProviderHandleChain, + type AgentSessionProviderHandle, + type AgentSessionProviderHandleLink +} from '../../../src/shared/agent-session-provider-handle' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../src/shared/agent-session-record.test-fixture' +import { encodeAgentSessionRecord } from '../../../src/shared/agent-session-record-stored-form' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +// A release before neutral handles: handles stored and held in their typed form. +const RELEASE_REF = 'v1.4.220' +// The main build that made handles neutral; no release has it yet. Move to the first that does. +const NEUTRAL_HANDLE_REF = 'e817b0e23747ffd6f16f2ddefea861950d82a3c0' + +const acp = (nativeId: string): AgentSessionProviderHandle => ({ + transport: 'acp', + agent: 'grok', + nativeId +}) + +function link( + linkId: string, + nativeId: string, + fence: number, + extra: Partial = {} +): AgentSessionProviderHandleLink { + return { + linkId, + handle: acp(nativeId), + origin: 'created', + mintedAtFence: fence, + observedAt: fence * 1_000, + ...extra + } +} + +/** The row a build that runs another agent's chats writes: its provider and handles are neutral. */ +function neutralRow(chain: AgentSessionProviderHandleLink[]): unknown { + const head = chain.at(-1) + const fixture = agentSessionRecordFixture( + agentSessionLeaseFixture({ + provenHandleLinkId: head?.linkId, + runtimeFence: head?.mintedAtFence + }) + ) + return JSON.parse( + JSON.stringify({ + ...fixture, + provider: 'grok', + accountHome: { variable: 'GROK_HOME', path: '/home/user/.grok' }, + providerHandleChain: encodePersistedAgentSessionProviderHandleChain(chain) + }) + ) +} + +const REOPENED = [link('l1', 's-1', 1), link('l2', 's-1', 2, { origin: 'resumed' })] +const REPLACED = [ + ...REOPENED, + link('l3', 's-2', 3, { + replaces: { + key: agentSessionProviderHandleKey(acp('s-1')), + reason: 'restore-failed', + replacedAt: 3_000 + } + }) +] + +function storeMaps(value: unknown) { + if ( + typeof value !== 'object' || + value === null || + !('records' in value) || + !(value.records instanceof Map) || + !('unreadableRecords' in value) || + !(value.unreadableRecords instanceof Map) + ) { + throw new Error('old store must expose its readable and unreadable rows') + } + return value +} + +function legacyRow(provider: 'claude' | 'codex') { + const fixture = agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId: `chat-${provider}` }) + ) + return encodeAgentSessionRecord({ + ...fixture, + provider, + accountHome: + provider === 'claude' + ? fixture.accountHome + : { variable: 'CODEX_HOME', path: '/home/user/.codex' }, + providerHandleChain: [ + { + ...fixture.providerHandleChain[0], + handle: + provider === 'claude' + ? claudeProviderHandle('saved-claude', 'leaf-1') + : codexProviderHandle('saved-codex') + } + ] + }) +} + +// A replacement is stored as it is held, which older builds would refuse; it is safe only because +// the rows that can hold one are rows they already set aside. Claude and Codex chains refuse it. +test.each([RELEASE_REF, NEUTRAL_HANDLE_REF])( + '%s preserves legacy rows and sets aside a replaced neutral row verbatim until re-upgrade', + async (ref) => { + const checkout = await materializeReleaseCheckout(ref) + const records = await importReleaseCheckoutModule( + checkout, + 'src/shared/agent-session-record.ts' + ) + const isRecord = records.isPersistedAgentSessionRecord + if (typeof isRecord !== 'function') { + throw new Error(`${ref} exports no isPersistedAgentSessionRecord`) + } + expect(isRecord(neutralRow(REOPENED))).toBe(false) + expect(isRecord(neutralRow(REPLACED))).toBe(false) + + const [rowModule, draftModule] = await Promise.all([ + importReleaseCheckoutModule(checkout, 'src/main/runtime/agent-session-record-rows.ts'), + importReleaseCheckoutModule(checkout, 'src/main/runtime/agent-session-store-draft.ts') + ]) + const loadRows = rowModule.loadAgentSessionStoreRows + const writeRows = rowModule.writeAgentSessionStoreRows + const draftState = draftModule.draftAgentSessionStoreState + const rowWrites = draftModule.agentSessionStoreDraftRowWrites + if ( + typeof loadRows !== 'function' || + typeof writeRows !== 'function' || + typeof draftState !== 'function' || + typeof rowWrites !== 'function' + ) { + throw new Error(`${ref} must expose the row load and write path`) + } + const db = new Database(':memory:') + try { + db.exec(` + CREATE TABLE agent_session_records (session_id TEXT PRIMARY KEY, record_json TEXT); + CREATE TABLE agent_session_operations (operation_key TEXT PRIMARY KEY, row_json TEXT); + CREATE TABLE agent_session_retired_claim_keys (key_id TEXT PRIMARY KEY, retired_at INTEGER); + CREATE TABLE agent_session_tabs (tab_id TEXT PRIMARY KEY, session_id TEXT, position INTEGER); + CREATE TABLE agent_session_store_meta (key TEXT PRIMARY KEY, value TEXT); + `) + const insert = db.prepare('INSERT INTO agent_session_records VALUES (?, ?)') + const legacyRows = [legacyRow('claude'), legacyRow('codex')] + for (const row of legacyRows) { + expect(isRecord(row)).toBe(true) + insert.run(row.sessionId, JSON.stringify(row)) + } + // Whitespace proves preservation of the stored bytes, not just the parsed value. + const neutralJson = JSON.stringify(neutralRow(REPLACED), null, 2) + insert.run('session-alpha-1', neutralJson) + const loaded = storeMaps(loadRows(db, 'local')) + expect([...loaded.records.keys()]).toEqual(['chat-claude', 'chat-codex']) + expect(loaded.unreadableRecords.get('session-alpha-1')).toEqual({ + reason: 'current_shape_invalid', + raw: JSON.parse(neutralJson) + }) + const draft = storeMaps(draftState(loaded)) + for (const row of legacyRows) { + const record: unknown = loaded.records.get(row.sessionId) + if (typeof record !== 'object' || record === null || !('lease' in record)) { + throw new Error(`${ref} must read the legacy row`) + } + const lease = record.lease + if (typeof lease !== 'object' || lease === null) { + throw new Error(`${ref} must preserve the legacy lease`) + } + // Re-derive the owner after load, forcing the old build's actual serialization path. + draft.records.set(row.sessionId, { ...record, lease: { ...lease, unreconciled: false } }) + } + const writes: unknown = rowWrites(loaded, draft) + expect(writes).not.toBeNull() + writeRows(db, writes) + for (const row of legacyRows) { + expect( + db + .prepare('SELECT record_json FROM agent_session_records WHERE session_id = ?') + .get(row.sessionId)?.record_json + ).toBe(JSON.stringify(row)) + } + const preserved = db + .prepare('SELECT record_json FROM agent_session_records WHERE session_id = ?') + .get('session-alpha-1')?.record_json + expect(preserved).toBe(neutralJson) + if (typeof preserved !== 'string') { + throw new Error('the neutral row must survive the old build') + } + const upgraded: unknown = JSON.parse(preserved) + if ( + typeof upgraded !== 'object' || + upgraded === null || + !('providerHandleChain' in upgraded) + ) { + throw new Error('the preserved row must still contain its chain') + } + // The provider-neutral record reader lands in the consumer PR; this PR owns chain decoding. + expect(decodePersistedAgentSessionProviderHandleChain(upgraded.providerHandleChain)).toEqual( + REPLACED + ) + } finally { + db.close() + } + }, + 300_000 +)