diff --git a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts index f759674d6929..3fdb616bb2f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts @@ -1,5 +1,6 @@ import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' +import { agentSessionProviderClaims } from '../../../shared/agent-session-provider-claims' export type StructuredProviderSessionOwnership = { sessionId: string @@ -13,14 +14,12 @@ export type StructuredProviderSessionOwnership = { export function listStructuredProviderSessionOwnership( records: readonly AgentSessionRecord[] ): StructuredProviderSessionOwnership[] { - return records.flatMap((record) => - record.providerHandleChain.map((link) => ({ - sessionId: record.sessionId, - workspaceId: record.location.workspaceId, - provider: record.provider, - providerSessionId: link.handle.nativeId, - conversationName: record.conversationName, - lease: record.lease - })) - ) + return agentSessionProviderClaims(records).map(({ record, handle }) => ({ + sessionId: record.sessionId, + workspaceId: record.location.workspaceId, + provider: record.provider, + providerSessionId: handle.nativeId, + conversationName: record.conversationName, + lease: record.lease + })) } diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index ff840c755b35..61db94555783 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -48,6 +48,7 @@ import { import type { AgentSessionStoreState } from './agent-session-store-state' import { agentSessionRecordIdentityFields } from './agent-session-record-founding' import { agentSessionAccountHomesEqual } from '../../shared/agent-session-account-home' +import { agentSessionProviderClaims } from '../../shared/agent-session-provider-claims' export type AgentSessionReserveRequest = { /** Host-resolved floating directory committed with the first owner reservation. */ @@ -237,13 +238,11 @@ function assertAdoptedConversationUnowned( return } const root = agentSessionProviderHandleRoot(adopted.handle) - for (const record of state.records.values()) { + for (const { record, handle } of agentSessionProviderClaims(state.records.values())) { if (record.sessionId === request.sessionId) { continue } - const holdsSameConversation = record.providerHandleChain.some( - (link) => agentSessionProviderHandleRoot(link.handle) === root - ) + const holdsSameConversation = agentSessionProviderHandleRoot(handle) === root if (holdsSameConversation) { throw agentSessionRefusalError('agent_session_conflict', { reason: 'conversationHeldElsewhere' diff --git a/src/main/runtime/orchestration/structured-session-mail-address.test.ts b/src/main/runtime/orchestration/structured-session-mail-address.test.ts new file mode 100644 index 000000000000..6930f05f6aea --- /dev/null +++ b/src/main/runtime/orchestration/structured-session-mail-address.test.ts @@ -0,0 +1,26 @@ +import { expect, it } from 'vitest' +import { providerContextRecordFixture } from '../../../shared/agent-session-provider-context.test-fixture' +import { lookupOrcaAgentSession } from './structured-session-mail-address' + +it('corrects a provider id to its visible owner after an older host adopted an archived context', () => { + const original = providerContextRecordFixture('codex') + const adopted = { ...providerContextRecordFixture('codex', [1]), sessionId: 'adopted-chat' } + for (const records of [ + [original, adopted], + [adopted, original] + ]) { + const store = { + getRecord: (id: string) => records.find((record) => record.sessionId === id) ?? null, + listRecords: () => records + } + expect(lookupOrcaAgentSession(store, 'context-0')).toEqual({ + kind: 'provider-id', + orcaSessionId: adopted.sessionId + }) + expect(lookupOrcaAgentSession(store, original.sessionId)).toEqual({ + kind: 'found', + record: original + }) + expect(lookupOrcaAgentSession(store, 'missing')).toEqual({ kind: 'unknown' }) + } +}) diff --git a/src/main/runtime/orchestration/structured-session-mail-address.ts b/src/main/runtime/orchestration/structured-session-mail-address.ts index 93f6a57b2bf5..06c442b773ab 100644 --- a/src/main/runtime/orchestration/structured-session-mail-address.ts +++ b/src/main/runtime/orchestration/structured-session-mail-address.ts @@ -11,6 +11,7 @@ */ import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { agentSessionProviderClaims } from '../../../shared/agent-session-provider-claims' import { isOrcaSessionId, type OrcaSessionId } from '../../../shared/orca-session-address' import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' import type { OrchestrationDb } from './db' @@ -35,9 +36,9 @@ export function lookupOrcaAgentSession( if (record) { return { kind: 'found', record } } - const owner = store - .listRecords() - .find((candidate) => candidate.providerHandleChain.some(({ handle }) => handle.nativeId === id)) + const owner = agentSessionProviderClaims(store.listRecords()).find( + ({ handle }) => handle.nativeId === id + )?.record return owner ? { kind: 'provider-id', orcaSessionId: owner.sessionId } : { kind: 'unknown' } } diff --git a/src/shared/agent-session-legacy-handoff-lease.ts b/src/shared/agent-session-legacy-handoff-lease.ts index ce06ddce0eb7..57e819776337 100644 --- a/src/shared/agent-session-legacy-handoff-lease.ts +++ b/src/shared/agent-session-legacy-handoff-lease.ts @@ -14,6 +14,7 @@ import type { AgentSessionRecord } from './agent-session-record' import type { PersistedAgentSessionProviderHandleLink } from './agent-session-provider-handle' +import type { PersistedAgentSessionProviderContextHistory } from './agent-session-provider-context-history' type LegacyHandoffRuntimeKind = 'tui' type LegacyHandoffStage = 'preparing' | 'old-owner-stopped' | 'manual-recovery' @@ -36,6 +37,7 @@ export type PersistedAgentSessionRecord = Omit< > & { lease: PersistedAgentSessionLease providerHandleChain: PersistedAgentSessionProviderHandleLink[] + providerContextHistory?: PersistedAgentSessionProviderContextHistory } export function isPersistedAgentSessionRuntimeKind( diff --git a/src/shared/agent-session-provider-claims.test.ts b/src/shared/agent-session-provider-claims.test.ts new file mode 100644 index 000000000000..8fb5601b4a83 --- /dev/null +++ b/src/shared/agent-session-provider-claims.test.ts @@ -0,0 +1,35 @@ +import { expect, it } from 'vitest' +import { agentSessionProviderClaims } from './agent-session-provider-claims' +import { providerContextRecordFixture } from './agent-session-provider-context.test-fixture' + +it('keeps archives as reservations and prefers the visible owner regardless of record order', () => { + const original = providerContextRecordFixture('codex') + const adopted = { ...providerContextRecordFixture('codex', [1]), sessionId: 'adopted-chat' } + for (const records of [ + [original, adopted], + [adopted, original] + ]) { + expect( + agentSessionProviderClaims(records).filter((claim) => claim.handle.nativeId === 'context-0') + ).toEqual([{ record: adopted, handle: adopted.providerHandleChain[0].handle, archived: false }]) + } + expect( + agentSessionProviderClaims([original]).find((claim) => claim.handle.nativeId === 'context-0') + ?.archived + ).toBe(true) +}) + +it('does not hide conflicting visible claims, and picks a stable archive-only display owner', () => { + const first = { ...providerContextRecordFixture('codex'), sessionId: 'a-chat' } + const second = { ...providerContextRecordFixture('codex'), sessionId: 'z-chat' } + for (const records of [ + [first, second], + [second, first] + ]) { + const claims = agentSessionProviderClaims(records) + expect(claims.filter((claim) => claim.handle.nativeId === 'context-1')).toHaveLength(2) + expect(claims.find((claim) => claim.handle.nativeId === 'context-0')?.record.sessionId).toBe( + 'a-chat' + ) + } +}) diff --git a/src/shared/agent-session-provider-claims.ts b/src/shared/agent-session-provider-claims.ts new file mode 100644 index 000000000000..de2a9bb60a66 --- /dev/null +++ b/src/shared/agent-session-provider-claims.ts @@ -0,0 +1,44 @@ +import { + agentSessionProviderContextStart, + agentSessionProviderHandleRoot, + type AgentSessionProviderHandle +} from './agent-session-provider-handle' +import type { AgentSessionRecord } from './agent-session-record' + +export type AgentSessionProviderClaim = { + record: AgentSessionRecord + handle: AgentSessionProviderHandle + archived: boolean +} + +/** A downgraded host can adopt an invisible archive; its visible claim wins on upgrade. */ +export function agentSessionProviderClaims( + records: Iterable +): AgentSessionProviderClaim[] { + const byRoot = new Map>() + for (const record of records) { + const start = agentSessionProviderContextStart(record.providerHandleChain) + for (const [index, link] of record.providerHandleChain.entries()) { + const root = agentSessionProviderHandleRoot(link.handle) + const owners = byRoot.get(root) ?? new Map() + const claim = { record, handle: link.handle, archived: index < start } + const prior = owners.get(record.sessionId) + if (!prior || !claim.archived) { + owners.set(record.sessionId, claim) + } + byRoot.set(root, owners) + } + } + return [...byRoot.values()].flatMap((owners) => { + const claims = [...owners.values()] + const visible = claims.filter((claim) => !claim.archived) + if (visible.length > 0) { + return visible + } + // Archives reserve identity; their display owner must not depend on database insertion order. + const first = claims.reduce((left, right) => + left.record.sessionId < right.record.sessionId ? left : right + ) + return [first] + }) +} diff --git a/src/shared/agent-session-provider-context-history.test.ts b/src/shared/agent-session-provider-context-history.test.ts new file mode 100644 index 000000000000..cccfc0fd55a2 --- /dev/null +++ b/src/shared/agent-session-provider-context-history.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { + appendAgentSessionProviderHandleLink, + isAgentSessionProviderHandleChain +} from './agent-session-provider-handle' +import { + codexProviderHandle, + agentSessionProviderHandleKey +} from './agent-session-provider-handle-encoding' +import { decodeProviderContextHistory } from './agent-session-provider-context-history' +import { providerContextRecordFixture } from './agent-session-provider-context.test-fixture' +import { + decodePersistedAgentSessionRecord, + encodeAgentSessionRecord +} from './agent-session-record-stored-form' +import { isPersistedAgentSessionRecord } from './agent-session-record' + +describe.each(['claude', 'codex'] as const)('%s retained provider contexts', (provider) => { + it('stores disjoint segments and restores truthful creation reasons', () => { + const record = providerContextRecordFixture(provider, [2, 2, 3]) + const stored = encodeAgentSessionRecord(record) + expect(stored.providerHandleChain).toHaveLength(3) + expect(stored.providerHandleChain[0].replaces).toBeUndefined() + expect(stored.providerContextHistory?.links).toHaveLength(4) + expect(isPersistedAgentSessionRecord(stored)).toBe(true) + const decoded = decodePersistedAgentSessionRecord(stored).record + expect(decoded).toEqual(record) + expect(decoded).not.toHaveProperty('providerContextHistory') + expect(encodeAgentSessionRecord(decoded)).toEqual(stored) + }) + + it('accepts independently full archived and visible contexts', () => { + const record = providerContextRecordFixture(provider, [256, 256]) + const stored = encodeAgentSessionRecord(record) + expect(stored.providerHandleChain).toHaveLength(256) + expect(stored.providerContextHistory?.links).toHaveLength(256) + expect(isPersistedAgentSessionRecord(stored)).toBe(true) + expect(decodePersistedAgentSessionRecord(stored).record).toEqual(record) + expect( + isAgentSessionProviderHandleChain( + providerContextRecordFixture(provider, [257, 1]).providerHandleChain + ) + ).toBe(false) + expect(() => + appendAgentSessionProviderHandleLink(record.providerHandleChain, { + ...record.providerHandleChain[511], + linkId: 'overflow', + mintedAtFence: 513 + }) + ).toThrow('agent_session_provider_handle_chain_overflow') + }) + + it('rejects malformed archives and disagreement across the seam', () => { + const stored = encodeAgentSessionRecord(providerContextRecordFixture(provider)) + const history = stored.providerContextHistory + if (!history) { + throw new Error('fixture must archive its earlier context') + } + for (const corrupt of [ + null, + { ...history, version: 2 }, + { ...history, links: [] }, + { ...history, links: [{ version: 1, links: history.links }] }, + { ...history, replacement: { ...history.replacement, key: 'codex:"elsewhere"' } }, + { ...history, replacement: { ...history.replacement, reason: '' } }, + { ...history, links: [...history.links, history.links[0]] } + ]) { + const malformed = { ...stored, providerContextHistory: corrupt } + expect(isPersistedAgentSessionRecord(malformed)).toBe(false) + expect(decodeProviderContextHistory(stored.providerHandleChain, corrupt)).toBeNull() + } + expect(isPersistedAgentSessionRecord({ ...stored, providerHandleChain: [] })).toBe(false) + const first = stored.providerHandleChain[0] + expect( + isPersistedAgentSessionRecord({ + ...stored, + providerHandleChain: [{ ...first, linkId: history.links[0].linkId }] + }) + ).toBe(false) + expect( + isPersistedAgentSessionRecord({ + ...stored, + providerHandleChain: [{ ...first, mintedAtFence: 0 }] + }) + ).toBe(false) + }) +}) + +it('retains the replaced conversation when the fresh unsaved creation is superseded', () => { + const record = providerContextRecordFixture('codex') + const head = record.providerHandleChain.at(-1) + if (!head) { + throw new Error('fixture must have a head') + } + const chain = appendAgentSessionProviderHandleLink(record.providerHandleChain, { + linkId: 'superseding', + handle: codexProviderHandle('third-context'), + origin: 'created', + mintedAtFence: 4, + observedAt: 4000, + supersedesKey: agentSessionProviderHandleKey(head.handle) + }) + expect(chain.at(-1)?.replaces).toEqual(head.replaces) + expect(isAgentSessionProviderHandleChain(chain)).toBe(true) + const next = { + ...record, + providerHandleChain: chain, + lease: { ...record.lease, runtimeFence: 4, provenHandleLinkId: 'superseding' } + } + expect(decodePersistedAgentSessionRecord(encodeAgentSessionRecord(next)).record).toEqual(next) +}) diff --git a/src/shared/agent-session-provider-context-history.ts b/src/shared/agent-session-provider-context-history.ts new file mode 100644 index 000000000000..eb334934c217 --- /dev/null +++ b/src/shared/agent-session-provider-context-history.ts @@ -0,0 +1,78 @@ +import { + agentSessionProviderContextStart, + decodePersistedAgentSessionProviderHandleChain, + encodePersistedAgentSessionProviderHandleChain, + isAgentSessionProviderHandleChain, + MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS, + type AgentSessionProviderHandleChain, + type AgentSessionProviderHandleLink, + type PersistedAgentSessionProviderHandleLink +} from './agent-session-provider-handle' +import type { AgentSessionProviderHandleReplacement } from './agent-session-provider-handle-replacement' + +export type PersistedAgentSessionProviderContextHistory = { + version: 1 + links: PersistedAgentSessionProviderHandleLink[] + replacement: AgentSessionProviderHandleReplacement +} + +/** Older writers see only the current context and preserve the record's disjoint archive. */ +export function encodeProviderContextHistory(chain: AgentSessionProviderHandleChain): { + providerHandleChain: PersistedAgentSessionProviderHandleLink[] + providerContextHistory?: PersistedAgentSessionProviderContextHistory +} { + const start = agentSessionProviderContextStart(chain) + const replacement = chain[start]?.replaces + if (!replacement) { + return { providerHandleChain: encodePersistedAgentSessionProviderHandleChain(chain) } + } + const providerHandleChain = encodePersistedAgentSessionProviderHandleChain(chain.slice(start)) + const { replaces: _replacement, ...first } = providerHandleChain[0] + providerHandleChain[0] = first + return { + providerHandleChain, + providerContextHistory: { + version: 1, + links: encodePersistedAgentSessionProviderHandleChain(chain.slice(0, start)), + replacement + } + } +} + +/** Join once, then validate the complete provenance, including the archived/visible seam. */ +export function decodeProviderContextHistory( + visible: unknown, + history: unknown +): AgentSessionProviderHandleLink[] | null { + const current = decodePersistedAgentSessionProviderHandleChain(visible) + if (!current || history === undefined) { + return current + } + if ( + typeof history !== 'object' || + history === null || + !('version' in history) || + history.version !== 1 || + !('links' in history) || + !('replacement' in history) + ) { + return null + } + const earlier = decodePersistedAgentSessionProviderHandleChain(history.links) + const first = current[0] + if ( + !earlier?.length || + !first || + first.origin !== 'created' || + current.length > MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS || + current.some((link) => link.replaces !== undefined) + ) { + return null + } + const joined: unknown[] = [ + ...earlier, + { ...first, replaces: history.replacement }, + ...current.slice(1) + ] + return isAgentSessionProviderHandleChain(joined) ? joined : null +} diff --git a/src/shared/agent-session-provider-context.test-fixture.ts b/src/shared/agent-session-provider-context.test-fixture.ts new file mode 100644 index 000000000000..0aa843075b42 --- /dev/null +++ b/src/shared/agent-session-provider-context.test-fixture.ts @@ -0,0 +1,62 @@ +import type { AgentSessionProviderHandleLink } from './agent-session-provider-handle' +import { + agentSessionProviderHandleKey, + claudeProviderHandle, + codexProviderHandle +} from './agent-session-provider-handle-encoding' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from './agent-session-record.test-fixture' + +export function providerContextRecordFixture( + provider: 'claude' | 'codex', + contextLengths: readonly number[] = [2, 1] +) { + const chain: AgentSessionProviderHandleLink[] = [] + for (const [context, length] of contextLengths.entries()) { + for (let offset = 0; offset < length; offset += 1) { + const fence = chain.length + 1 + const handle = + provider === 'claude' + ? claudeProviderHandle(`context-${context}`, `leaf-${fence}`) + : codexProviderHandle(`context-${context}`) + const previous = chain.at(-1) + chain.push({ + linkId: `link-${fence}`, + handle, + origin: offset === 0 ? 'created' : 'resumed', + mintedAtFence: fence, + observedAt: fence * 1000, + ...(offset === 0 && previous + ? { + replaces: { + key: agentSessionProviderHandleKey(previous.handle), + reason: context % 2 === 0 ? 'context-cleared' : 'restore-failed', + replacedAt: fence * 1000 + } + } + : {}) + }) + } + } + const head = chain.at(-1) + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + runtimeFence: head?.mintedAtFence ?? 0, + provenHandleLinkId: head?.linkId ?? null + }) + ) + return { + ...record, + provider, + accountHome: + provider === 'claude' + ? record.accountHome + : { + variable: 'CODEX_HOME', + path: '/home/user/.codex' + }, + providerHandleChain: chain + } +} diff --git a/src/shared/agent-session-provider-handle-replacement.ts b/src/shared/agent-session-provider-handle-replacement.ts index f872e2acbc66..cc235dc6d287 100644 --- a/src/shared/agent-session-provider-handle-replacement.ts +++ b/src/shared/agent-session-provider-handle-replacement.ts @@ -4,10 +4,7 @@ */ import type { AgentSessionProviderHandle } from './agent-session-provider-handle' -import { - isAgentSessionProviderHandleKeyFor, - isAgentSessionProviderHandleReadByOlderBuilds -} from './agent-session-provider-handle-encoding' +import { isAgentSessionProviderHandleKeyFor } from './agent-session-provider-handle-encoding' const MAX_REPLACEMENT_REASON_LENGTH = 64 @@ -39,10 +36,6 @@ export function isAgentSessionProviderHandleReplacement( } 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 && diff --git a/src/shared/agent-session-provider-handle.test.ts b/src/shared/agent-session-provider-handle.test.ts index dd6d8f93fa7c..ec922faeda30 100644 --- a/src/shared/agent-session-provider-handle.test.ts +++ b/src/shared/agent-session-provider-handle.test.ts @@ -654,24 +654,22 @@ describe('replacing a conversation the provider could not restore', () => { ).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')] + it('retains truthful replacements in Claude and Codex chains', () => { + for (const [first, next] of [ + [CLAUDE, claudeProviderHandle('sess-2', null)], + [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') + const replacement = fresh({ + handle: next, + replaces: { + key: agentSessionProviderHandleKey(first), + reason: 'restore-failed', + replacedAt: 3_000 + } + }) + const chain = appendAgentSessionProviderHandleLink([link({ handle: first })], replacement) + expect(chain).toEqual([link({ handle: first }), replacement]) + expect(isAgentSessionProviderHandleChain(chain)).toBe(true) } }) diff --git a/src/shared/agent-session-provider-handle.ts b/src/shared/agent-session-provider-handle.ts index b2594d626850..bce686ed8834 100644 --- a/src/shared/agent-session-provider-handle.ts +++ b/src/shared/agent-session-provider-handle.ts @@ -15,9 +15,17 @@ */ import type { AgentType } from './agent-status-types' +import { + agentSessionProviderHandlesEqual, + isAgentSessionProviderHandleLink, + providerHandleLinkFollows +} from './agent-session-provider-link-validation' +export { + agentSessionProviderHandlesEqual, + isAgentSessionProviderHandleLink +} from './agent-session-provider-link-validation' import { agentSessionProviderHandleReplacementsEqual, - isAgentSessionProviderHandleReplacement, type AgentSessionProviderHandleReplacement } from './agent-session-provider-handle-replacement' import { @@ -25,9 +33,7 @@ import { agentSessionProviderHandleRoot, decodePersistedAgentSessionProviderHandle, encodePersistedAgentSessionProviderHandle, - isAgentSessionProviderHandle, isAgentSessionProviderHandleInNamespace, - isAgentSessionProviderHandleKeyFor, type PersistedAgentSessionProviderHandle } from './agent-session-provider-handle-encoding' @@ -103,24 +109,9 @@ export type AgentSessionProviderHandleLink = { export type AgentSessionProviderHandleChain = readonly AgentSessionProviderHandleLink[] -/** Bounded so one session cannot grow an unbounded persisted record. */ +/** Each context stays within the limit older builds apply to the visible stored chain. */ export const MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS = 256 -const LINK_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ - -/** Every field, resume cursor included: a resume that only moved the adapter's state is still news. */ -export function agentSessionProviderHandlesEqual( - left: AgentSessionProviderHandle, - right: AgentSessionProviderHandle -): boolean { - return ( - left.transport === right.transport && - left.agent === right.agent && - left.nativeId === right.nativeId && - left.resumeCursor === right.resumeCursor - ) -} - export function agentSessionProviderHandleChainHead( chain: AgentSessionProviderHandleChain ): AgentSessionProviderHandleLink | null { @@ -134,63 +125,44 @@ export function findAgentSessionProviderHandleLink( return chain.find((link) => link.linkId === linkId) ?? null } -export function isAgentSessionProviderHandleLink( - value: unknown -): value is AgentSessionProviderHandleLink { - if (typeof value !== 'object' || value === null) { - return false - } - const link = value as Partial - if (!isAgentSessionProviderHandle(link.handle)) { - return false - } - const handle = link.handle - const originValid = - link.origin === 'created' || - link.origin === 'adopted' || - link.origin === 'resumed' || - link.origin === 'forked' - return ( - typeof link.linkId === 'string' && - LINK_ID_PATTERN.test(link.linkId) && - originValid && - Number.isSafeInteger(link.mintedAtFence) && - (link.mintedAtFence as number) >= 0 && - Number.isSafeInteger(link.observedAt) && - (link.origin === 'forked' - ? isAgentSessionProviderHandleKeyFor(handle, link.forkedFromKey) - : link.forkedFromKey === undefined) && - (link.supersedesKey === undefined || - (link.origin === 'created' && - isAgentSessionProviderHandleKeyFor(handle, link.supersedesKey))) && - (link.replaces === undefined || - (link.origin === 'created' && isAgentSessionProviderHandleReplacement(handle, link.replaces))) - ) -} - export function isAgentSessionProviderHandleChain( value: unknown ): value is AgentSessionProviderHandleLink[] { - if (!Array.isArray(value) || value.length > MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) { + if (!Array.isArray(value)) { return false } - let validated: AgentSessionProviderHandleLink[] = [] - try { - for (const link of value) { - if (!isAgentSessionProviderHandleLink(link)) { - return false - } - 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) { + let head: AgentSessionProviderHandleLink | null = null + let contextLength = 0 + const linkIds = new Set() + for (const link of value) { + if (!isAgentSessionProviderHandleLink(link) || linkIds.has(link.linkId)) { + return false + } + if (!head) { + if ((link.origin !== 'created' && link.origin !== 'adopted') || link.replaces) { return false } - validated = next + } else if (!providerHandleLinkFollows(head, link)) { + return false } - return true - } catch { - return false + contextLength = link.replaces ? 1 : contextLength + 1 + if (contextLength > MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) { + return false + } + linkIds.add(link.linkId) + head = link } + return true +} + +/** The latest fresh context's first link; earlier links are retained provenance. */ +export function agentSessionProviderContextStart(chain: AgentSessionProviderHandleChain): number { + for (let index = chain.length - 1; index >= 0; index -= 1) { + if (chain[index].replaces) { + return index + } + } + return 0 } /** @@ -277,9 +249,11 @@ function appendNewLink( // Why: the lease names its exact proof by link id; reuse would make that reference ambiguous. throw new Error('agent_session_provider_handle_invalid') } - if (chain.length >= MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) { - // Why: dropping older links would erase fork provenance, so refuse and let the caller roll - // the journal epoch instead of silently losing where this conversation came from. + if ( + !link.replaces && + chain.length - agentSessionProviderContextStart(chain) >= + MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS + ) { throw new Error('agent_session_provider_handle_chain_overflow') } return [...chain, link] @@ -336,7 +310,7 @@ export function encodePersistedAgentSessionProviderHandleChain( export function decodePersistedAgentSessionProviderHandleChain( value: unknown ): AgentSessionProviderHandleLink[] | null { - if (!Array.isArray(value) || value.length > MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) { + if (!Array.isArray(value)) { return null } const decoded: unknown[] = [] diff --git a/src/shared/agent-session-provider-link-validation.ts b/src/shared/agent-session-provider-link-validation.ts new file mode 100644 index 000000000000..a2167d7e1db7 --- /dev/null +++ b/src/shared/agent-session-provider-link-validation.ts @@ -0,0 +1,91 @@ +import type { + AgentSessionProviderHandle, + AgentSessionProviderHandleLink +} from './agent-session-provider-handle' +import { + agentSessionProviderHandleKey, + agentSessionProviderHandleRoot, + isAgentSessionProviderHandle, + isAgentSessionProviderHandleInNamespace, + isAgentSessionProviderHandleKeyFor +} from './agent-session-provider-handle-encoding' +import { isAgentSessionProviderHandleReplacement } from './agent-session-provider-handle-replacement' + +const LINK_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ + +/** Every field, resume cursor included: a resume that only moved the adapter's state is still news. */ +export function agentSessionProviderHandlesEqual( + left: AgentSessionProviderHandle, + right: AgentSessionProviderHandle +): boolean { + return ( + left.transport === right.transport && + left.agent === right.agent && + left.nativeId === right.nativeId && + left.resumeCursor === right.resumeCursor + ) +} + +export function isAgentSessionProviderHandleLink( + value: unknown +): value is AgentSessionProviderHandleLink { + if (typeof value !== 'object' || value === null) { + return false + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every field is validated below before this unknown value is accepted as a link. + const link = value as Partial + if (!isAgentSessionProviderHandle(link.handle)) { + return false + } + const handle = link.handle + const originValid = + link.origin === 'created' || + link.origin === 'adopted' || + link.origin === 'resumed' || + link.origin === 'forked' + return ( + typeof link.linkId === 'string' && + LINK_ID_PATTERN.test(link.linkId) && + originValid && + typeof link.mintedAtFence === 'number' && + Number.isSafeInteger(link.mintedAtFence) && + link.mintedAtFence >= 0 && + Number.isSafeInteger(link.observedAt) && + (link.origin === 'forked' + ? isAgentSessionProviderHandleKeyFor(handle, link.forkedFromKey) + : link.forkedFromKey === undefined) && + (link.supersedesKey === undefined || + (link.origin === 'created' && + isAgentSessionProviderHandleKeyFor(handle, link.supersedesKey))) && + (link.replaces === undefined || + (link.origin === 'created' && isAgentSessionProviderHandleReplacement(handle, link.replaces))) + ) +} + +export function providerHandleLinkFollows( + head: AgentSessionProviderHandleLink, + link: AgentSessionProviderHandleLink +): boolean { + if ( + !isAgentSessionProviderHandleInNamespace(link.handle, head.handle) || + link.mintedAtFence < head.mintedAtFence + ) { + return false + } + const sameRoot = + agentSessionProviderHandleRoot(link.handle) === agentSessionProviderHandleRoot(head.handle) + if (link.origin === 'created') { + return link.replaces?.key === agentSessionProviderHandleKey(head.handle) && !sameRoot + } + if (link.origin === 'forked') { + return !sameRoot && link.forkedFromKey === agentSessionProviderHandleKey(head.handle) + } + return ( + link.origin === 'resumed' && + sameRoot && + !( + agentSessionProviderHandlesEqual(link.handle, head.handle) && + link.mintedAtFence === head.mintedAtFence + ) + ) +} diff --git a/src/shared/agent-session-record-stored-form.ts b/src/shared/agent-session-record-stored-form.ts index ca6d242d24a6..44a26ebca4bd 100644 --- a/src/shared/agent-session-record-stored-form.ts +++ b/src/shared/agent-session-record-stored-form.ts @@ -9,9 +9,9 @@ import { type PersistedAgentSessionRecord } from './agent-session-legacy-handoff-lease' import { - decodePersistedAgentSessionProviderHandleChain, - encodePersistedAgentSessionProviderHandleChain -} from './agent-session-provider-handle' + decodeProviderContextHistory, + encodeProviderContextHistory +} from './agent-session-provider-context-history' import type { AgentSessionRecord } from './agent-session-record' /** The in-memory record, plus whether decode changed anything the store must write back. A @@ -20,14 +20,16 @@ export function decodePersistedAgentSessionRecord(record: PersistedAgentSessionR record: AgentSessionRecord normalized: boolean } { - const providerHandleChain = decodePersistedAgentSessionProviderHandleChain( - record.providerHandleChain + const { providerContextHistory, ...fields } = record + const providerHandleChain = decodeProviderContextHistory( + record.providerHandleChain, + providerContextHistory ) if (!providerHandleChain) { throw new Error('agent_session_provider_handle_invalid') } return { - record: { ...record, providerHandleChain, lease: normalizeLegacyHandoffLease(record.lease) }, + record: { ...fields, providerHandleChain, lease: normalizeLegacyHandoffLease(record.lease) }, normalized: leaseCarriesLegacyHandoffValues(record.lease) } } @@ -36,6 +38,6 @@ export function decodePersistedAgentSessionRecord(record: PersistedAgentSessionR export function encodeAgentSessionRecord(record: AgentSessionRecord): PersistedAgentSessionRecord { return { ...record, - providerHandleChain: encodePersistedAgentSessionProviderHandleChain(record.providerHandleChain) + ...encodeProviderContextHistory(record.providerHandleChain) } } diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index c228335a6264..cbf59fbeaaab 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -20,10 +20,8 @@ import { isAgentSessionConversationCommandRecord, type AgentSessionConversationCommandRecord } from './agent-session-conversation-command' -import { - decodePersistedAgentSessionProviderHandleChain, - type AgentSessionProviderHandleLink -} from './agent-session-provider-handle' +import type { AgentSessionProviderHandleLink } from './agent-session-provider-handle' +import { decodeProviderContextHistory } from './agent-session-provider-context-history' import { isAgentSessionProviderHandleInNamespace, isStructuredAgentId @@ -374,7 +372,10 @@ export function isPersistedAgentSessionRecord( } const validated = record as AgentSessionRecord // The row holds stored handles; validate the chain they decode to. - const chain = decodePersistedAgentSessionProviderHandleChain(validated.providerHandleChain) + const chain = decodeProviderContextHistory( + validated.providerHandleChain, + 'providerContextHistory' in value ? value.providerContextHistory : undefined + ) const head = chain?.at(-1) // One namespace, owned by the record's own agent; which transport is the chain's own fact. const transport = chain?.[0]?.handle.transport diff --git a/tests/e2e/cross-version-wire/agent-session-context-history-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-context-history-downgrade.unit.test.ts new file mode 100644 index 000000000000..2eaad4631eff --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-context-history-downgrade.unit.test.ts @@ -0,0 +1,311 @@ +import { expect, test } from 'vitest' +import { + materializeReleaseCheckout, + importReleaseCheckoutModule, + resolveBaselineReleaseRef +} from './release-checkout' +import { + historicalStore, + historicalRecord, + historicalHandle +} from './agent-session-historical-store.test-fixture' +import { providerContextRecordFixture } from '../../../src/shared/agent-session-provider-context.test-fixture' +import { + encodeAgentSessionRecord, + decodePersistedAgentSessionRecord +} from '../../../src/shared/agent-session-record-stored-form' +import { isPersistedAgentSessionRecord } from '../../../src/shared/agent-session-record' +import { agentSessionProviderClaims } from '../../../src/shared/agent-session-provider-claims' + +const BASE_REF = '8ae5264c638' +const LEGACY_REFS = ['v1.4.222', BASE_REF] +// HEAD exercises the archive-aware path before the rolling stable release contains it. +const REFS = [...new Set([...LEGACY_REFS, resolveBaselineReleaseRef(), 'HEAD'])] +const CASES = REFS.flatMap((ref) => + (['claude', 'codex'] as const).map((provider) => ({ ref, provider })) +) + +test.each(REFS)( + '%s updates the visible Claude resume point without erasing the archive', + async (ref) => { + const checkout = await materializeReleaseCheckout(ref) + const store = await historicalStore(checkout) + try { + const record = providerContextRecordFixture('claude') + store.insert(record.sessionId, encodeAgentSessionRecord(record)) + const loaded = store.load() + const draft = store.draft(loaded) + const old = historicalRecord(draft.records.get(record.sessionId)) + const transition = await importReleaseCheckoutModule( + checkout, + 'src/main/runtime/agent-session-provider-handle-transition.ts' + ) + const revise = + transition.reviseAgentSessionProviderResumePoint ?? + transition.reviseAgentSessionClaudeResumePoint + if (typeof revise !== 'function') { + throw new Error('historical build must expose its actual resume-point transition') + } + const handle = historicalHandle( + 'claude', + 'context-1', + 'transport' in old.providerHandleChain[0].handle + ) + const changed = revise({ + record: { ...old, lease: { ...old.lease, unreconciled: false } }, + fence: 3, + handle: { ...handle, resumeCursor: 'updated-leaf' }, + providerSessionId: 'context-1', + leafUuid: 'updated-leaf', + now: 4000 + }) + draft.records.set(record.sessionId, changed) + store.write(loaded, draft) + const written = store.read(record.sessionId) + if (!isPersistedAgentSessionRecord(written)) { + throw new Error('historical resume point must remain readable on upgrade') + } + const upgraded = decodePersistedAgentSessionRecord(written).record + expect(upgraded.providerHandleChain.slice(0, 2)).toEqual( + record.providerHandleChain.slice(0, 2) + ) + expect(upgraded.providerHandleChain[2].replaces).toEqual( + record.providerHandleChain[2].replaces + ) + expect(upgraded.providerHandleChain[2].handle.resumeCursor).toBe('updated-leaf') + } finally { + store.db.close() + } + }, + 300_000 +) + +test.each(CASES)( + '$ref reads and writes $provider replacements without losing provenance', + async ({ ref, provider }) => { + const checkout = await materializeReleaseCheckout(ref) + const store = await historicalStore(checkout) + try { + const record = providerContextRecordFixture(provider, [2, 2, 1]) + const stored = encodeAgentSessionRecord(record) + store.insert(record.sessionId, stored) + const loaded = store.load() + expect(loaded.unreadableRecords.size).toBe(0) + const old = historicalRecord(loaded.records.get(record.sessionId)) + if (LEGACY_REFS.includes(ref)) { + expect(store.readsContextHistory).toBe(false) + } + if (ref === 'HEAD') { + expect(store.readsContextHistory).toBe(true) + } + expect(old.providerHandleChain).toHaveLength( + store.readsContextHistory ? record.providerHandleChain.length : 1 + ) + const draft = store.draft(loaded) + draft.records.set(record.sessionId, { ...old, lease: { ...old.lease, unreconciled: false } }) + store.write(loaded, draft) + const rewritten = store.read(record.sessionId) + expect(rewritten).toEqual(stored) + if (!isPersistedAgentSessionRecord(rewritten)) { + throw new Error('upgraded record must remain readable') + } + expect(decodePersistedAgentSessionRecord(rewritten).record).toEqual(record) + } finally { + store.db.close() + } + }, + 300_000 +) + +test.each(CASES)( + '$ref grows the visible $provider chain to 256 beside 256 archived links', + async ({ ref, provider }) => { + const store = await historicalStore(await materializeReleaseCheckout(ref)) + try { + const record = providerContextRecordFixture(provider, [256, 1]) + store.insert(record.sessionId, encodeAgentSessionRecord(record)) + const loaded = store.load() + const draft = store.draft(loaded) + const old = historicalRecord(draft.records.get(record.sessionId)) + let chain = old.providerHandleChain + const head = chain.at(-1) + for (let fence = 258; fence <= 512; fence += 1) { + chain = store.appendLink(chain, { + linkId: `old-${fence}`, + handle: head.handle, + origin: 'resumed', + mintedAtFence: fence, + observedAt: fence * 1000 + }) + } + draft.records.set(record.sessionId, { + ...old, + providerHandleChain: chain, + lease: { + ...old.lease, + runtimeFence: 512, + provenHandleLinkId: 'old-512', + unreconciled: false + } + }) + store.write(loaded, draft) + const written = store.read(record.sessionId) + if (!isPersistedAgentSessionRecord(written)) { + throw new Error('independent 256-link contexts must read back') + } + const upgraded = decodePersistedAgentSessionRecord(written).record + expect(upgraded.providerHandleChain).toHaveLength(512) + expect(upgraded.providerHandleChain[256].replaces).toEqual( + record.providerHandleChain[256].replaces + ) + expect(upgraded.providerHandleChain.at(-1)?.linkId).toBe('old-512') + } finally { + store.db.close() + } + }, + 300_000 +) + +test.each(CASES)( + '$ref $provider unsaved supersession and subsequent fork preserve the archive', + async ({ ref, provider }) => { + const store = await historicalStore(await materializeReleaseCheckout(ref)) + try { + const record = providerContextRecordFixture(provider) + store.insert(record.sessionId, encodeAgentSessionRecord(record)) + const loaded = store.load() + const draft = store.draft(loaded) + const old = historicalRecord(draft.records.get(record.sessionId)) + const head = old.providerHandleChain.at(-1) + const neutral = 'transport' in head.handle + const superseding = { + linkId: 'old-superseding', + origin: 'created', + mintedAtFence: 4, + observedAt: 4000, + handle: historicalHandle(provider, 'third-context', neutral), + supersedesKey: store.key(head.handle) + } + const superseded = store.appendLink(old.providerHandleChain, superseding) + const forked = store.appendLink(superseded, { + linkId: 'old-fork', + origin: 'forked', + mintedAtFence: 5, + observedAt: 5000, + handle: historicalHandle(provider, 'fork-context', neutral), + forkedFromKey: store.key(superseding.handle) + }) + draft.records.set(record.sessionId, { + ...old, + providerHandleChain: forked, + lease: { + ...old.lease, + runtimeFence: 5, + provenHandleLinkId: 'old-fork', + unreconciled: false + } + }) + store.write(loaded, draft) + const written = store.read(record.sessionId) + if (!isPersistedAgentSessionRecord(written)) { + throw new Error('supersession and fork must read back') + } + const upgraded = decodePersistedAgentSessionRecord(written).record + expect(upgraded.providerHandleChain.map((link) => link.linkId)).toEqual([ + 'link-1', + 'link-2', + 'old-superseding', + 'old-fork' + ]) + expect(upgraded.providerHandleChain[2].replaces).toEqual( + record.providerHandleChain[2].replaces + ) + } finally { + store.db.close() + } + }, + 300_000 +) + +test.each(CASES)( + '$ref applies its $provider archive ownership policy before re-upgrade', + async ({ ref, provider }) => { + const checkout = await materializeReleaseCheckout(ref) + const store = await historicalStore(checkout) + try { + const original = providerContextRecordFixture(provider) + const stored = encodeAgentSessionRecord(original) + store.insert(original.sessionId, stored) + const loaded = store.load() + const draft = store.draft(loaded) + const oldRecord = historicalRecord(draft.records.get(original.sessionId)) + const neutral = 'transport' in oldRecord.providerHandleChain[0].handle + const admission = await importReleaseCheckoutModule( + checkout, + 'src/main/runtime/agent-session-reservation-admission.ts' + ) + const commit = admission.commitAgentSessionReservation + if (typeof commit !== 'function') { + throw new Error('old adoption must use its actual reservation admission') + } + const adopt = () => + commit( + draft, + { + sessionId: 'old-adopted', + location: original.location, + provider, + accountHome: original.accountHome, + adoptedHandleLink: { + linkId: 'adopted-1', + handle: historicalHandle(provider, 'context-0', neutral), + origin: 'adopted', + mintedAtFence: 1, + observedAt: 1000 + }, + expectedFence: null, + spawnToken: 'old-token', + claimKeyId: 'old-key', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'caller', + operationId: '1800000000000-0123456789abcdef0123456789abcdef', + fingerprint: 'old-adoption' + }, + now: 1800000000000 + }, + 30_000 + ) + if (store.readsContextHistory) { + expect(adopt).toThrowError('agent_session_conflict') + expect(draft.records.has('old-adopted')).toBe(false) + expect(store.read(original.sessionId)).toEqual(stored) + return + } + adopt() + store.write(loaded, draft) + const upgraded = [original.sessionId, 'old-adopted'].map((id) => { + const row = store.read(id) + if (!isPersistedAgentSessionRecord(row)) { + throw new Error('both upgraded chats must read') + } + return decodePersistedAgentSessionRecord(row).record + }) + for (const order of [upgraded, upgraded.toReversed()]) { + const claims = agentSessionProviderClaims(order) + expect( + claims + .filter((claim) => claim.handle.nativeId === 'context-0') + .map((claim) => claim.record.sessionId) + ).toEqual(['old-adopted']) + expect( + claims.find((claim) => claim.handle.nativeId === 'context-1')?.record.sessionId + ).toBe(original.sessionId) + } + } finally { + store.db.close() + } + }, + 300_000 +) 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 index ee103350cdf0..14ded5880cb5 100644 --- 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 @@ -19,9 +19,7 @@ import { encodeAgentSessionRecord } from '../../../src/shared/agent-session-reco 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 RELEASE_REF = 'v1.4.222' const acp = (nativeId: string): AgentSessionProviderHandle => ({ transport: 'acp', @@ -113,9 +111,8 @@ function legacyRow(provider: 'claude' | '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])( +// Older builds that do not run this agent preserve its unreadable row verbatim. +test.each([RELEASE_REF])( '%s preserves legacy rows and sets aside a replaced neutral row verbatim until re-upgrade', async (ref) => { const checkout = await materializeReleaseCheckout(ref) diff --git a/tests/e2e/cross-version-wire/agent-session-historical-store.test-fixture.ts b/tests/e2e/cross-version-wire/agent-session-historical-store.test-fixture.ts new file mode 100644 index 000000000000..ef4decfcd03b --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-historical-store.test-fixture.ts @@ -0,0 +1,111 @@ +import Database from '../../../src/main/sqlite/sync-database' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { importReleaseCheckoutModule, type ReleaseCheckout } from './release-checkout' + +export function historicalStoreMaps(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('historical store must expose readable and unreadable records') + } + return value +} + +export function historicalRecord(value: unknown) { + if ( + typeof value !== 'object' || + value === null || + !('lease' in value) || + typeof value.lease !== 'object' || + value.lease === null || + !('providerHandleChain' in value) || + !Array.isArray(value.providerHandleChain) + ) { + throw new Error('historical record must hold a chain and lease') + } + return { ...value, lease: value.lease, providerHandleChain: value.providerHandleChain } +} + +export async function historicalStore(checkout: ReleaseCheckout) { + const [rows, drafts, handles] = await Promise.all([ + importReleaseCheckoutModule(checkout, 'src/main/runtime/agent-session-record-rows.ts'), + importReleaseCheckoutModule(checkout, 'src/main/runtime/agent-session-store-draft.ts'), + importReleaseCheckoutModule(checkout, 'src/shared/agent-session-provider-handle.ts') + ]) + const loadRows = rows.loadAgentSessionStoreRows + const writeRows = rows.writeAgentSessionStoreRows + const draftState = drafts.draftAgentSessionStoreState + const rowWrites = drafts.agentSessionStoreDraftRowWrites + const appendLink = handles.appendAgentSessionProviderHandleLink + const key = handles.agentSessionProviderHandleKey + const contextHistory = existsSync( + join(checkout.root, 'src/shared/agent-session-provider-context-history.ts') + ) + ? await importReleaseCheckoutModule( + checkout, + 'src/shared/agent-session-provider-context-history.ts' + ) + : null + if (contextHistory && typeof contextHistory.decodeProviderContextHistory !== 'function') { + throw new Error('historical context-history module must expose its decoder') + } + if ( + typeof loadRows !== 'function' || + typeof writeRows !== 'function' || + typeof draftState !== 'function' || + typeof rowWrites !== 'function' || + typeof appendLink !== 'function' || + typeof key !== 'function' + ) { + throw new Error('historical exports are not callable') + } + const db = new Database(':memory:') + 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); + `) + return { + db, + readsContextHistory: contextHistory !== null, + appendLink, + key, + insert: (sessionId: string, value: unknown) => + db + .prepare('INSERT INTO agent_session_records VALUES (?, ?)') + .run(sessionId, JSON.stringify(value)), + load: () => historicalStoreMaps(loadRows(db, 'local')), + draft: (loaded: unknown) => historicalStoreMaps(draftState(loaded)), + write: (loaded: unknown, draft: unknown) => writeRows(db, rowWrites(loaded, draft)), + read: (sessionId: string): unknown => { + const raw = db + .prepare('SELECT record_json FROM agent_session_records WHERE session_id = ?') + .get(sessionId)?.record_json + if (typeof raw !== 'string') { + throw new Error('historical writer lost the stored row') + } + return JSON.parse(raw) + } + } +} + +export function historicalHandle(provider: 'claude' | 'codex', nativeId: string, neutral: boolean) { + if (neutral) { + return { + transport: provider === 'claude' ? 'claude-sdk' : 'codex-app-server', + agent: provider, + nativeId + } + } + return provider === 'claude' + ? { provider, sessionId: nativeId, leafUuid: null } + : { provider, threadId: nativeId } +}