Files
orca/src/shared/agent-session-provider-handle-replacement.ts
T
Brennan Benson eaaae0196f 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
2026-10-05 23:22:24 -07:00

69 lines
2.4 KiB
TypeScript

/**
* 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)
)
}